diff --git a/.github/workflows/release-gems.yml b/.github/workflows/release-gems.yml index 1056c415f9..f4b813d1c7 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,60 @@ 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 }} 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 which branches contain it. - 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 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. + - 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 +124,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 +150,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 +167,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 +180,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 +196,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..9719479ca3 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,29 @@ 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, 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. -### 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 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), +- `vX.Y.Z` must not exist yet. It then: @@ -127,14 +130,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 +172,11 @@ 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. +- 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.