From 38f699070d674c3a4ed346dabf510e44ccff3a97 Mon Sep 17 00:00:00 2001 From: Soutaro Matsumoto Date: Thu, 30 Jul 2026 16:31:21 +0900 Subject: [PATCH] Release the gems from a workflow `docs/release.md` had the maintainer build both gems locally and push them by hand: `rake release` for the `ruby` one, and a Docker image for the `java` one because it needs the WASI SDK to compile `rbs_parser.wasm`. That is a lot of laptop for something that has to be reproducible, and it puts the RubyGems credentials of whoever runs it in the path. `Release gems` does it instead. Dispatched against a `vX.Y.Z` tag it builds both gems, checks them, pushes them to RubyGems through trusted publishing, and publishes the GitHub release. A release is now a pull request, a tag, and one workflow run. The `java` gem is built on CRuby rather than in the JRuby image: the platform comes from `RBS_PLATFORM`, not from the engine running `gem build`, so all the image was providing is the WASI SDK -- which the `wasm` and `jruby` workflows already install directly. JRuby is still needed to *run* the result, so the workflow installs the built gem on JRuby and parses with it. That is the check metadata cannot make: a stale or broken wasm module only shows at require time. Publishing is ordered so that the reversible step always comes first. The tag is created before the workflow runs; the artifacts are uploaded before the push, so a failed push still leaves the gems behind; and the GitHub release comes last, so a failed push never announces a release that has no gems. The trusted publisher has no environment, so the dispatched ref is the only thing deciding what gets published. The workflow refuses to publish unless the tag matches `RBS::VERSION`, and dispatching against a branch builds and stops. `gem:gh_release` takes the notes from the topmost CHANGELOG.md section and is skipped for `.dev.N` versions, which are cut from the development line and are not written up. It reads the file as UTF-8 explicitly: the default external encoding follows the locale, which is C on the runners. Co-Authored-By: Claude Opus 5 --- .github/workflows/release-gems.yml | 164 +++++++++++++++++++++++++++++ Rakefile | 51 +++++++++ docs/release.md | 114 +++++++++++--------- 3 files changed, 279 insertions(+), 50 deletions(-) create mode 100644 .github/workflows/release-gems.yml diff --git a/.github/workflows/release-gems.yml b/.github/workflows/release-gems.yml new file mode 100644 index 0000000000..1056c415f9 --- /dev/null +++ b/.github/workflows/release-gems.yml @@ -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 diff --git a/Rakefile b/Rakefile index 383183c83b..9971533203 100644 --- a/Rakefile +++ b/Rakefile @@ -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" diff --git a/docs/release.md b/docs/release.md index a3f0b6cbef..027b306e61 100644 --- a/docs/release.md +++ b/docs/release.md @@ -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 @@ -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. @@ -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. @@ -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 period — and, 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.