Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 164 additions & 0 deletions .github/workflows/release-gems.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
name: Release gems

# Builds, publishes, and announces a release. Dispatch it against the `vX.Y.Z` tag
# of the release: the tag is created first so that everything published afterwards
# traces back to an immutable ref, and so the reversible step comes before the
# irreversible one.
#
# Dispatching against a branch builds and verifies the gems and stops there, which
# is how the build is exercised without releasing anything.
#
# | Gem | Platform | Parser |
# | -------------------- | ------------- | -------------------------------- |
# | `rbs-X.Y.Z.gem` | `ruby` (MRI) | C extension, compiled on install |
# | `rbs-X.Y.Z-java.gem` | `java` (JRuby)| `rbs_parser.wasm`, prebuilt here |
#
# See docs/release.md. The `java` gem is built on CRuby: the platform comes from
# `RBS_PLATFORM`, not from the engine running `gem build`. JRuby is only needed to
# run the result, which the workflow does before it would publish anything.
#
# One job on purpose. Tagging, pushing the gems, and opening the GitHub release
# all belong to a single release, and keeping them in one place keeps their order
# readable -- the tag is created before anything is published, so the reversible
# step comes before the irreversible one.

on:
workflow_dispatch:

permissions:
contents: read

env:
# Keep in sync with .github/workflows/wasm.yml and .github/workflows/jruby.yml.
WASI_SDK_VERSION: "33"
WASI_SDK_RELEASE: "33.0"

jobs:
release:
name: release
runs-on: ubuntu-latest
permissions:
contents: write # publish the GitHub release
id-token: write # trusted publishing to RubyGems
steps:
# The gemspec takes its file list from `git ls-files`, so both gems are built
# from the committed state.
- uses: actions/checkout@v7
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: ruby
bundler: none
- name: Update rubygems & bundler
run: gem update --system
- name: Install gems
run: |
bundle config set --local without libs:profilers
bundle install --jobs 4 --retry 3

- name: Read the version
id: version
run: echo "version=$(ruby -e 'load "lib/rbs/version.rb"; print RBS::VERSION')" >> "$GITHUB_OUTPUT"

# Fail before spending a minute on the build, and before anything is pushed:
# the tag is what the release is named after, so it has to be the version the
# tagged commit actually declares.
- name: Check the tag against RBS::VERSION
if: github.ref_type == 'tag'
run: |
if [ "${{ github.ref_name }}" != "v${{ steps.version.outputs.version }}" ]; then
echo "::error::tag ${{ github.ref_name }} does not match RBS::VERSION ${{ steps.version.outputs.version }}"
exit 1
fi

- name: Build the ruby gem
run: |
mkdir -p pkg
gem build rbs.gemspec -o "pkg/rbs-${{ steps.version.outputs.version }}.gem"

# `rake wasm:jruby_setup` compiles src/**/*.c to WebAssembly and copies the
# result to lib/rbs/wasm/, where the gemspec picks it up. clang runs as a
# subprocess, so this works on CRuby.
- name: Install the WASI SDK
run: |
url="https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-${WASI_SDK_VERSION}/wasi-sdk-${WASI_SDK_RELEASE}-x86_64-linux.tar.gz"
mkdir -p "$HOME/wasi-sdk"
curl -sSL "$url" | tar xz --strip-components=1 -C "$HOME/wasi-sdk"
echo "WASI_SDK_PATH=$HOME/wasi-sdk" >> "$GITHUB_ENV"
- name: Build rbs_parser.wasm
run: bundle exec rake wasm:jruby_setup

- name: Build the java gem
env:
RBS_PLATFORM: java
run: gem build rbs.gemspec -o "pkg/rbs-${{ steps.version.outputs.version }}-java.gem"

# `git ls-files` vouches for everything else, but rbs_parser.wasm is a build
# artifact, so the java gem is the one that can come out quietly wrong.
- name: Check the built gems
run: |
ruby -rrubygems/package -e '
ruby_gem, java_gem = ARGV.map { Gem::Package.new(_1).spec }

raise "unexpected platform: #{ruby_gem.platform}" unless ruby_gem.platform.to_s == "ruby"
raise "the C extension is not declared" if ruby_gem.extensions.empty?

raise "unexpected platform: #{java_gem.platform}" unless java_gem.platform.to_s == "java"
raise "rbs_parser.wasm is missing" unless java_gem.files.include?("lib/rbs/wasm/rbs_parser.wasm")
raise "the java gem must not declare an extension" unless java_gem.extensions.empty?

[ruby_gem, java_gem].each { puts "#{_1.full_name}: #{_1.files.size} files" }
' "pkg/rbs-${{ steps.version.outputs.version }}.gem" \
"pkg/rbs-${{ steps.version.outputs.version }}-java.gem"

# The checks above cannot tell whether rbs_parser.wasm actually runs. Install
# the gem the way a user would -- jar-dependencies fetches Chicory and ASM
# from Maven during the install -- and parse something with it, so the
# WebAssembly runtime is exercised end to end before anything is published.
- name: Set up JRuby
uses: ruby/setup-ruby@v1
with:
ruby-version: jruby
bundler: none
- name: Check the java gem on JRuby
run: |
gem install "pkg/rbs-${{ steps.version.outputs.version }}-java.gem"
ruby -e '
require "rbs"
_, _, decls = RBS::Parser.parse_signature("class Foo end")
names = decls.map { _1.name.to_s }
raise "parsed #{names.inspect}, expected [\"Foo\"]" unless names == ["Foo"]
puts "#{RUBY_ENGINE} #{RUBY_VERSION}: rbs #{RBS::VERSION} parses through the WebAssembly runtime"
'

- name: Switch back to CRuby
uses: ruby/setup-ruby@v1
with:
ruby-version: ruby
bundler: none

# Uploaded before publishing, so a failed push still leaves the gems behind.
- uses: actions/upload-artifact@v7
with:
name: gems
path: pkg/*.gem
if-no-files-found: error

# Everything below runs only for a release tag.
- name: Configure RubyGems credentials
if: github.ref_type == 'tag'
# No floating major tag on this action, so the exact release is pinned.
uses: rubygems/configure-rubygems-credentials@v2.1.0

- name: Push the gems
if: github.ref_type == 'tag'
run: |
gem push "pkg/rbs-${{ steps.version.outputs.version }}.gem"
gem push "pkg/rbs-${{ steps.version.outputs.version }}-java.gem"

# Last, so that a failed push never announces a release that has no gems.
- name: Publish the GitHub release
if: github.ref_type == 'tag'
env:
GH_TOKEN: ${{ github.token }}
run: bundle exec rake gem:gh_release
51 changes: 51 additions & 0 deletions Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -759,6 +759,57 @@ namespace :gem do
print_changelog_json(from, paths: GEM_CHANGELOG_PATHS)
end
end

desc "Publish the GitHub release for RBS::VERSION, unless it is a `.dev.` version"
task :gh_release do
require "open3"

version = Gem::Version.new(RBS::VERSION)
major, minor, *_ = RBS::VERSION.split(".")
tag = "v#{RBS::VERSION}"

# There are three kinds of release: `X.Y.Z`, `X.Y.Z.pre.N`, and `X.Y.Z.dev.N`.
# The `.dev.N` ones are cut from the development line for people who need a
# specific change early; they are not written up in the changelog, so there are
# no notes to publish and nothing worth announcing.
if version.segments.include?("dev")
puts "⏭️ #{RBS::VERSION} is a dev release, so there is no GitHub release to publish."
next
end

# The release is created against an existing tag, so that the artifacts and the
# notes describe a commit that is already immutable.
_, status = Open3.capture2("git", "rev-parse", "--verify", "--quiet", "#{tag}^{commit}")
raise "🚨 No such tag: `#{tag}`. Tag the release before creating the GitHub release." unless status.success?

# The topmost section of the changelog is this release, minus its own heading.
# The encoding is explicit because the default external encoding follows the
# locale, and the changelog is not ASCII.
content = File.read(File.join(__dir__, "CHANGELOG.md"), encoding: Encoding::UTF_8)
section = content.scan(/^## \d.*?(?=^## \d)/m)[0] or raise "🚨 Cannot find a release section in CHANGELOG.md"
heading, _, body = section.partition("\n")
heading.include?(RBS::VERSION) or raise "🚨 CHANGELOG.md starts with `#{heading.strip}`, which is not #{RBS::VERSION}"

notes = <<~NOTES
[Release note](https://github.com/ruby/rbs/wiki/Release-Note-#{major}.#{minor})

#{body.strip}
NOTES

# Published rather than drafted: the notes are the changelog section that was
# already reviewed in the release pull request, so there is nothing left to edit.
command = [
"gh", "release", "create", tag,
"--title=#{RBS::VERSION}",
"--notes=#{notes}"
]
command << "--prerelease" if version.prerelease?

output, status = Open3.capture2(*command)
raise "🚨 `gh release create` failed: #{status.inspect}" unless status.success?

puts "📝 Released #{tag}: #{output.chomp}"
end
end

desc "Compile extension without C23 extensions"
Expand Down
114 changes: 64 additions & 50 deletions docs/release.md
Original file line number Diff line number Diff line change
@@ -1,23 +1,38 @@
# Releasing RBS

A release is a pull request, a tag, and one workflow run. Everything that leaves
the repository — both gems and the GitHub release — is produced by the `Release
gems` workflow, so nothing has to be built or pushed from a laptop.

Each release ships **two gems**:

| Gem | Platform | Parser | How it is built |
| --- | --- | --- | --- |
| `rbs-X.Y.Z.gem` | `ruby` (MRI) | C extension | `rake release` (re-builds it) |
| `rbs-X.Y.Z-java.gem` | `java` (JRuby) | WebAssembly (`lib/rbs/wasm`) | Docker image, pushed manually |
| Gem | Platform | Parser |
| --- | --- | --- |
| `rbs-X.Y.Z.gem` | `ruby` (MRI) | C extension, compiled on install |
| `rbs-X.Y.Z-java.gem` | `java` (JRuby) | `rbs_parser.wasm`, built by the workflow |

The `-java` gem contains no native code — just `rbs_parser.wasm`. The Chicory/ASM
jars it needs are not shipped in the gem; they are declared as `jar-dependencies`
requirements and fetched from Maven when the gem is installed. So the gem can be
built once in any environment and runs on every JRuby.

There are three kinds of release, and they differ in what gets written up:

| Version | CHANGELOG section | GitHub release |
| --- | --- | --- |
| `X.Y.Z` | The whole cycle since the previous release proper, prereleases included | Published |
| `X.Y.Z.pre.N` | What changed since `X.Y.Z.pre.N-1` | Published, marked as a prerelease |
| `X.Y.Z.dev.N` | None | None |

`.dev.N` releases are cut from the development line for people who need a change
early, so they are gems and tags and nothing else.

## Prerequisites

- Push rights to the `rbs` gem on RubyGems (`gem signin`). If your account has
MFA enabled, `gem push` / `rake release` will prompt for an OTP.
- Docker, for the `-java` gem. The WASI SDK is baked into the image, so there is
nothing to install on the host.
Push rights to the `rbs` gem on RubyGems are **not** needed: the workflow
authenticates through a trusted publisher registered for this repository and
`release-gems.yml`. What is needed is write access to the repository, since that
is what lets you dispatch the workflow.

## Steps

Expand All @@ -26,8 +41,7 @@ built once in any environment and runs on every JRuby.
Open a pull request that carries everything the release needs:

- `lib/rbs/version.rb` — set `RBS::VERSION` to the version being released.
- `Gemfile.lock` — run `bundle install` after the bump; the lockfile records the version too, and
`rake release` refuses to run with a dirty working tree.
- `Gemfile.lock` — run `bundle install` after the bump; the lockfile records the version too.
- `CHANGELOG.md` — add a section for the new version, directly under the `# CHANGELOG` heading.
Sections are newest first.

Expand All @@ -41,10 +55,16 @@ entry.
$ bundle exec rake gem:changelog | pbcopy
```

It starts from the latest `v*` tag; pass a version to start somewhere else
(`rake 'gem:changelog[4.1.0]'`). Only the list goes to STDOUT, so it pipes cleanly. Pull requests
labeled `skip-changelog` are left out and reported on STDERR, and pull requests that only touch
`rust/` are left out because the crates have their own release cycle.
Where it starts follows `RBS::VERSION`, so bump the version first: a prerelease starts from the
latest tag, and a release proper skips the prerelease tags and starts from the previous release
proper. Pass a version to override it (`rake 'gem:changelog[4.1.0]'`). Only the list goes to
STDOUT, so it pipes cleanly. Pull requests labeled `skip-changelog` are left out and reported on
STDERR, and pull requests that only touch `rust/` are left out because the crates have their own
release cycle.

On a release proper, the `X.Y.Z.pre.N` sections above the previous release are replaced by the one
section being written — their pull requests are in it, and the notes they were published with stay
on their own GitHub releases.

Sort the list into the sections below. `rake gem:changelog:json` prints the same pull requests with
the changed files, labels, and body of each, which is what the sorting is based on.
Expand Down Expand Up @@ -77,61 +97,55 @@ on a small release. Two things scale with the size of the release:
The date is the day the gem is released, matching the `vX.Y.Z` tag — not the day this pull request
is opened. Fix it up before step 2 if the pull request sat for a few days.

> While `.github/workflows/milestone.yml` is a required check, the release pull request needs a
> milestone matching the new version (`RBS X.Y` for a `.0` release, `RBS X.Y.x` otherwise). Create
> the milestone on GitHub first if the minor version changes.

### 2. Release the `ruby` gem
### 2. Tag the release

Once the release pull request is merged and `master` is checked out, run on CRuby:
Once the pull request is merged, tag the merge commit and push the tag:

```console
$ bundle exec rake release
$ git switch master && git pull
$ git tag "v$(ruby -e 'load "lib/rbs/version.rb"; print RBS::VERSION')"
$ git push origin --tags
```

This re-builds the `ruby`-platform gem and then:
The tag comes before anything is published, so that the gems and the release notes describe a
commit that is already immutable — and because a tag can be deleted, while a version pushed to
RubyGems can only be yanked.

- creates the tag `vX.Y.Z`,
- pushes the current branch and the tag to `origin`,
- pushes the gem to RubyGems,
- runs `release:note`, which opens a GitHub **draft** release (with
`--prerelease` for `*.pre.*` versions) and prints the remaining manual steps.
### 3. Run the `Release gems` workflow against the tag

### 3. Build and push the `java` gem
Dispatch [`release-gems.yml`](../.github/workflows/release-gems.yml) from the Actions tab, picking
the `vX.Y.Z` tag — **not** a branch — in the ref selector. The trusted publisher has no branch
condition, so the ref you pick is what decides what gets published; the workflow refuses to run
unless the tag matches `RBS::VERSION`.

The `java` gem is not built by `rake release`, so build and push it manually:
It then:

```console
# Build from the committed state (the gemspec's file list comes from `git ls-files`).
$ docker build -f Dockerfile.jruby -t rbs-jruby .
- builds `rbs-X.Y.Z.gem`,
- compiles `rbs_parser.wasm` and builds `rbs-X.Y.Z-java.gem`,
- checks both: platforms, the C extension on one and its absence on the other, and that the wasm
module made it into the `java` gem,
- installs the `java` gem on JRuby and parses with it, so the WebAssembly runtime is exercised
before anything is published,
- uploads both gems as an artifact,
- pushes both to RubyGems through trusted publishing,
- publishes the GitHub release with the notes from CHANGELOG.md, skipping this last step for
`.dev.N` versions.

# Build rbs_parser.wasm and the -java gem into ./pkg on the host. The Chicory/ASM
# jars are not bundled; they are fetched from Maven when the gem is installed.
$ docker run --rm -e RBS_PLATFORM=java -v "$PWD/pkg:/out" rbs-jruby \
gem build rbs.gemspec -o /out/rbs-X.Y.Z-java.gem

$ gem push pkg/rbs-X.Y.Z-java.gem
```

Optionally confirm it installs and runs on JRuby before pushing:

```console
$ docker run --rm -v "$PWD/pkg:/pkg" -w /tmp rbs-jruby bash -c \
'gem install /pkg/rbs-X.Y.Z-java.gem && ruby -e "require %q{rbs}; puts [RUBY_ENGINE, RBS::VERSION].join(%q{ })"'
```
Dispatching against a branch runs everything up to the artifact and stops, which is how the build
is exercised without releasing.

### 4. Start the next development cycle

Open another pull request setting `RBS::VERSION` to the next prerelease (`4.1.1` → `4.1.2.pre`),
with `Gemfile.lock` regenerated, labeled `skip-changelog` like the release pull request itself.
Without it the version on `master` keeps claiming to be the released version for the whole
development periodand, while the milestone check is in place, pull requests are checked against
the released version's milestone.
development period, and `rake gem:changelog` reads that version to decide where the next changelog
starts.

## Notes

- Prereleases (`X.Y.Z.pre.N`) are only installed with `gem install rbs --pre`;
a plain `gem install rbs` is unaffected. On JRuby, `gem install rbs [--pre]`
resolves to the `-java` gem automatically.
- The Dockerfile pins the WASI SDK / Chicory / ASM versions to match the
`wasm` and `jruby` CI workflows. Keep them in sync when bumping.
- `Dockerfile.jruby` pins the WASI SDK / Chicory / ASM versions to match the
`wasm`, `jruby`, and `release-gems` workflows. Keep them in sync when bumping.
Loading