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.