From ed7be1d9e4ef045bffd85cc7af9d9ff3d59c7b38 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 10:30:30 +0000 Subject: [PATCH 1/2] Create the release tag in the workflow, from a commit and a version Pushing a tag needs credentials that a cloud development environment does not have, so the tag is created by `Release gems` rather than by hand before it. The workflow now takes the commit being released and the version that commit declares. Everything is built from the commit, so the run no longer depends on where `master` is when it starts, and the version is the same fact stated a second time: the run stops before anything is built unless it matches `RBS::VERSION` at that commit and the section CHANGELOG.md starts with. Releasing the wrong commit, or the right one under the wrong name, is a failed run rather than a gem to yank. The tag is created once both gems are known to build and run, and before anything is published, so the reversible step still comes before the irreversible one. What it names is decided by the checkout, so nothing rests on it existing first, which is what let it move after the build. `github.ref_type == 'tag'` used to be what separated a release from a build, and a branch dispatch was the dry run. The ref no longer decides anything, so `dry_run` is an explicit input: it builds and checks both gems and stops before the tag. `rake gem:check_release[X.Y.Z]` and `rake gem:tag` are the two steps the workflow runs, so both are also available locally. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BvSmN7ats6HGEyeT7VEbau --- .github/workflows/release-gems.yml | 138 +++++++++++++++++++++-------- Rakefile | 70 ++++++++++++--- docs/release.md | 54 ++++++----- 3 files changed, 191 insertions(+), 71 deletions(-) diff --git a/.github/workflows/release-gems.yml b/.github/workflows/release-gems.yml index 1056c415f9..8269e41d4a 100644 --- a/.github/workflows/release-gems.yml +++ b/.github/workflows/release-gems.yml @@ -1,12 +1,17 @@ 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. +# Builds, publishes, and announces a release. Dispatch it with the commit being +# released and the version that commit declares. # -# Dispatching against a branch builds and verifies the gems and stops there, which -# is how the build is exercised without releasing anything. +# Everything is built from `commit`, not from wherever the default branch happens to +# be when the run starts, so the release describes a state that is already immutable. +# `version` is the same fact stated a second time -- the run stops before anything is +# built unless it matches the `RBS::VERSION` of that commit, so dispatching the wrong +# commit, or the right one under the wrong name, is a failed run rather than a gem +# that has to be yanked. +# +# `dry_run` builds and checks both gems and stops before the tag, which is how the +# build is exercised without releasing anything. # # | Gem | Platform | Parser | # | -------------------- | ------------- | -------------------------------- | @@ -19,11 +24,26 @@ name: Release gems # # 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. +# readable -- the tag is created once both gems are known to build and run, and +# before anything is published, so the reversible step comes before the irreversible +# one. +# +# The file name is what the RubyGems trusted publisher for `rbs` is registered +# against, so it cannot be renamed without registering the new name first. on: workflow_dispatch: + inputs: + commit: + description: "Commit to release, as a full 40-character SHA" + required: true + version: + description: "Version to release, without the leading `v` (e.g. `4.1.2`)" + required: true + dry_run: + description: "Build and check the gems without tagging or publishing anything" + type: boolean + default: false permissions: contents: read @@ -38,12 +58,57 @@ jobs: name: release runs-on: ubuntu-latest permissions: - contents: write # publish the GitHub release + contents: write # push the tag, publish the GitHub release id-token: write # trusted publishing to RubyGems + env: + # The inputs are read through the environment rather than interpolated into + # the scripts below. + COMMIT: ${{ inputs.commit }} + VERSION: ${{ inputs.version }} + TAG: v${{ inputs.version }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} steps: # The gemspec takes its file list from `git ls-files`, so both gems are built - # from the committed state. + # from the committed state -- of the dispatched commit, since that is what is + # checked out. The full history is needed to tell whether it is on the default + # branch. - uses: actions/checkout@v7 + with: + ref: ${{ inputs.commit }} + fetch-depth: 0 + + # Before anything is installed or built: these are the two things the release + # is named after and built from, and a mistake in either is cheapest to catch + # here. + - name: Check the inputs + run: | + if [[ ! "$COMMIT" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::\`$COMMIT\` is not a full 40-character SHA. A release names one exact commit." + exit 1 + fi + if [[ ! "$VERSION" =~ ^[0-9][0-9a-zA-Z.]*$ ]]; then + echo "::error::\`$VERSION\` is not a version number. Pass it without the leading \`v\`." + exit 1 + fi + + # A release is cut from the default branch. Anywhere else means the commit + # is not the one that was reviewed and merged. + git fetch --no-tags origin "$DEFAULT_BRANCH" + if ! git merge-base --is-ancestor "$COMMIT" FETCH_HEAD; then + echo "::error::$COMMIT is not on $DEFAULT_BRANCH." + exit 1 + fi + + # A tag that already exists is a version that has already been released, and + # pushing it would fail after the build rather than before it. + - name: Check that the tag does not exist + if: ${{ !inputs.dry_run }} + run: | + if git ls-remote --exit-code --tags origin "refs/tags/$TAG" > /dev/null; then + echo "::error::$TAG already exists, so $VERSION has been released." + exit 1 + fi + - name: Set up Ruby uses: ruby/setup-ruby@v1 with: @@ -56,25 +121,16 @@ jobs: 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 + # Fails before a minute is spent on the build, and before anything is pushed: + # the version has to be the one the released commit declares, and -- unless + # this is a `.dev.N` release -- the one CHANGELOG.md is written up for. + - name: Check the version and the changelog + run: bundle exec rake "gem:check_release[$VERSION]" - name: Build the ruby gem run: | mkdir -p pkg - gem build rbs.gemspec -o "pkg/rbs-${{ steps.version.outputs.version }}.gem" + gem build rbs.gemspec -o "pkg/rbs-$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 @@ -91,7 +147,7 @@ jobs: - name: Build the java gem env: RBS_PLATFORM: java - run: gem build rbs.gemspec -o "pkg/rbs-${{ steps.version.outputs.version }}-java.gem" + run: gem build rbs.gemspec -o "pkg/rbs-$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. @@ -108,8 +164,7 @@ jobs: 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" + ' "pkg/rbs-$VERSION.gem" "pkg/rbs-$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 @@ -122,7 +177,7 @@ jobs: bundler: none - name: Check the java gem on JRuby run: | - gem install "pkg/rbs-${{ steps.version.outputs.version }}-java.gem" + gem install "pkg/rbs-$VERSION-java.gem" ruby -e ' require "rbs" _, _, decls = RBS::Parser.parse_signature("class Foo end") @@ -138,27 +193,40 @@ jobs: bundler: none # Uploaded before publishing, so a failed push still leaves the gems behind. + # This is also where a dry run ends. - uses: actions/upload-artifact@v7 with: name: gems path: pkg/*.gem if-no-files-found: error - # Everything below runs only for a release tag. + # Everything below runs only for a real release. + + # The tag comes after the gems are known to build and run, and before anything + # is published: a tag can be deleted, while a version pushed to RubyGems can + # only be yanked. What it names was decided by the checkout rather than by the + # tagging, so nothing rests on it being created first. + - name: Tag the release + if: ${{ !inputs.dry_run }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + bundle exec rake gem:tag + - name: Configure RubyGems credentials - if: github.ref_type == 'tag' + if: ${{ !inputs.dry_run }} # 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' + if: ${{ !inputs.dry_run }} run: | - gem push "pkg/rbs-${{ steps.version.outputs.version }}.gem" - gem push "pkg/rbs-${{ steps.version.outputs.version }}-java.gem" + gem push "pkg/rbs-$VERSION.gem" + gem push "pkg/rbs-$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' + if: ${{ !inputs.dry_run }} env: GH_TOKEN: ${{ github.token }} run: bundle exec rake gem:gh_release diff --git a/Rakefile b/Rakefile index 33cef063fa..d8da5994c7 100644 --- a/Rakefile +++ b/Rakefile @@ -839,6 +839,60 @@ namespace :gem do end end + # 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. + def dev_release?(version) + Gem::Version.new(version).segments.include?("dev") + end + + # The body of the topmost section of CHANGELOG.md, which is the release being + # prepared, minus its own heading. + # + # The encoding is explicit because the default external encoding follows the + # locale, and the changelog is not ASCII. + # + def changelog_section(version) + 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?(version) or raise "🚨 CHANGELOG.md starts with `#{heading.strip}`, which is not #{version}" + body.strip + end + + desc "Check that the working tree is ready to be released as the given version" + task :check_release, [:version] do |_task, args| + version = args[:version] or raise "🚨 Pass the version being released: `rake 'gem:check_release[4.1.2]'`" + Gem::Version.correct?(version) or raise "🚨 `#{version}` is not a version number." + + # The version being released and the version the commit declares are stated + # separately -- one by whoever starts the release, one by the commit itself -- + # so that releasing the wrong commit, or releasing the right one under the wrong + # name, fails here rather than on RubyGems. + version == RBS::VERSION or + raise "🚨 Releasing #{version}, but this commit declares `RBS::VERSION = #{RBS::VERSION.inspect}`." + + if dev_release?(version) + puts "✅ #{version} is the version of this commit. It is a dev release, so CHANGELOG.md is not checked." + else + changelog_section(version) + puts "✅ #{version} is the version of this commit, and CHANGELOG.md documents it." + end + end + + desc "Create and push the `vX.Y.Z` tag for RBS::VERSION" + task :tag do + tag = "v#{RBS::VERSION}" + + # Annotated, so that the tag carries its own author and date rather than + # borrowing the tagged commit's. + sh "git", "tag", "--annotate", "--message", "RBS #{RBS::VERSION}", tag + sh "git", "push", "origin", tag + + puts "🏷️ Pushed #{tag}." + end + desc "Publish the GitHub release for RBS::VERSION, unless it is a `.dev.` version" task :gh_release do require "open3" @@ -847,11 +901,7 @@ namespace :gem do 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") + if dev_release?(RBS::VERSION) puts "⏭️ #{RBS::VERSION} is a dev release, so there is no GitHub release to publish." next end @@ -861,18 +911,10 @@ namespace :gem do _, 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} + #{changelog_section(RBS::VERSION)} NOTES # Published rather than drafted: the notes are the changelog section that was diff --git a/docs/release.md b/docs/release.md index ffdffb6512..f35a15c8a6 100644 --- a/docs/release.md +++ b/docs/release.md @@ -1,8 +1,8 @@ # 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. +A release is a pull request and one workflow run. Everything that leaves the +repository — the tag, 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**: @@ -97,26 +97,28 @@ 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. -### 2. Tag the release +### 2. Run the `Release gems` workflow -Once the pull request is merged, tag the merge commit and push the tag: +Once the pull request is merged, dispatch +[`release-gems.yml`](../.github/workflows/release-gems.yml) from the Actions tab with two inputs: -```console -$ git switch master && git pull -$ git tag "v$(ruby -e 'load "lib/rbs/version.rb"; print RBS::VERSION')" -$ git push origin --tags -``` +| Input | Value | +| --- | --- | +| `commit` | The full 40-character SHA of the merge commit, taken from the merged pull request | +| `version` | `X.Y.Z`, without the leading `v` | -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. +The ref selector picks which copy of the workflow file runs, not what gets released — leave it on +`master`. Everything is built from `commit`, so the run is unaffected by whatever lands on `master` +in the meantime. -### 3. Run the `Release gems` workflow against the tag +The two inputs say the same thing twice, once as a commit and once as a name, and the run stops +before anything is built unless they agree with each other and with the repository: -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`. +- `commit` has to be a full SHA that is on `master`, +- `version` has to be the `RBS::VERSION` that commit declares, +- CHANGELOG.md has to start with a section for `version` (skipped for `.dev.N`, which is not + written up), +- `vX.Y.Z` must not exist yet. It then: @@ -127,14 +129,19 @@ It then: - 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, +- tags `commit` as `vX.Y.Z` and pushes the tag, +- pushes both gems to RubyGems through trusted publishing, - publishes the GitHub release with the notes from CHANGELOG.md, skipping this last step for `.dev.N` versions. -Dispatching against a branch runs everything up to the artifact and stops, which is how the build -is exercised without releasing. +The tag is created once both gems are known to build and run, and before anything is published: a +tag can be deleted, while a version pushed to RubyGems can only be yanked. + +Checking the `dry_run` box runs everything up to the artifact and stops — no tag, no gems pushed, +no release — which is how the build is exercised without releasing. `version` still has to match +the commit, so a dry run is also how a release is rehearsed before it is cut. -### 4. Start the next development cycle +### 3. 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. @@ -164,3 +171,6 @@ that is why the 4.0.3 changelog credits its three entries to the same pull reque resolves to the `-java` gem automatically. - `Dockerfile.jruby` pins the WASI SDK / Chicory / ASM versions to match the `wasm`, `jruby`, and `release-gems` workflows. Keep them in sync when bumping. +- `rake 'gem:check_release[X.Y.Z]'` and `rake gem:tag` are what the workflow runs to + check the release and to create the tag. Both work locally, which is the fallback + if the tag ever has to be created by hand. From 45c9bb6cf5af6e09761b677e2f46c9e229bd6218 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 10:37:49 +0000 Subject: [PATCH 2/2] Do not require the released commit to be on the default branch A patch release can be cut from a release branch, which is not an ancestor of the default branch, so requiring one rejected a release that is perfectly normal. What is left is that some branch contains the commit -- a commit no branch leads to is one that cannot be found again -- and the branches that do are printed, so the log says where the release came from. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BvSmN7ats6HGEyeT7VEbau --- .github/workflows/release-gems.yml | 19 +++++++++++-------- docs/release.md | 10 ++++++++-- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release-gems.yml b/.github/workflows/release-gems.yml index 8269e41d4a..f4b813d1c7 100644 --- a/.github/workflows/release-gems.yml +++ b/.github/workflows/release-gems.yml @@ -66,12 +66,10 @@ jobs: COMMIT: ${{ inputs.commit }} VERSION: ${{ inputs.version }} TAG: v${{ inputs.version }} - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} steps: # The gemspec takes its file list from `git ls-files`, so both gems are built # from the committed state -- of the dispatched commit, since that is what is - # checked out. The full history is needed to tell whether it is on the default - # branch. + # checked out. The full history is needed to tell which branches contain it. - uses: actions/checkout@v7 with: ref: ${{ inputs.commit }} @@ -91,13 +89,18 @@ jobs: exit 1 fi - # A release is cut from the default branch. Anywhere else means the commit - # is not the one that was reviewed and merged. - git fetch --no-tags origin "$DEFAULT_BRANCH" - if ! git merge-base --is-ancestor "$COMMIT" FETCH_HEAD; then - echo "::error::$COMMIT is not on $DEFAULT_BRANCH." + # A release proper is cut from the default branch, while a patch release can + # be cut from a release branch, so which branch the commit is on is not this + # workflow's business. That it is on one is: a commit no branch contains is + # one that nothing in the repository leads to any more. + git fetch --no-tags origin "+refs/heads/*:refs/remotes/origin/*" + branches=$(git branch --remotes --contains "$COMMIT" --format "%(refname:lstrip=3)") + if [ -z "$branches" ]; then + echo "::error::$COMMIT is not on any branch." exit 1 fi + echo "Branches containing $COMMIT:" + printf '%s\n' "$branches" # A tag that already exists is a version that has already been released, and # pushing it would fail after the build rather than before it. diff --git a/docs/release.md b/docs/release.md index f35a15c8a6..9719479ca3 100644 --- a/docs/release.md +++ b/docs/release.md @@ -109,12 +109,13 @@ Once the pull request is merged, dispatch The ref selector picks which copy of the workflow file runs, not what gets released — leave it on `master`. Everything is built from `commit`, so the run is unaffected by whatever lands on `master` -in the meantime. +in the meantime, and a patch release cut from a release branch is dispatched the same way as any +other: the workflow does not care which branch the commit is on. The two inputs say the same thing twice, once as a commit and once as a name, and the run stops before anything is built unless they agree with each other and with the repository: -- `commit` has to be a full SHA that is on `master`, +- `commit` has to be a full SHA that some branch contains, - `version` has to be the `RBS::VERSION` that commit declares, - CHANGELOG.md has to start with a section for `version` (skipped for `.dev.N`, which is not written up), @@ -174,3 +175,8 @@ that is why the 4.0.3 changelog credits its three entries to the same pull reque - `rake 'gem:check_release[X.Y.Z]'` and `rake gem:tag` are what the workflow runs to check the release and to create the tag. Both work locally, which is the fallback if the tag ever has to be created by hand. +- Those two tasks and `rake gem:gh_release` come from the Rakefile of the commit + being released, not from the branch the workflow was dispatched from. Releasing + from a release branch (`aaa-X.Y.x`) therefore needs the release tooling on that + branch as well; without it the run fails on the missing task, before publishing + anything.