diff --git a/.github/workflows/release-prepare.yaml b/.github/workflows/release-prepare.yaml index 939f561..1dc024f 100644 --- a/.github/workflows/release-prepare.yaml +++ b/.github/workflows/release-prepare.yaml @@ -40,7 +40,7 @@ jobs: bundler-cache: true - name: Create release PR env: - GH_TOKEN: ${{ github.token }} + GITHUB_TOKEN: ${{ github.token }} BUMP: ${{ inputs.bump }} REFRESH: ${{ inputs.refresh }} run: | diff --git a/.github/workflows/release-publish.yaml b/.github/workflows/release-publish.yaml index 0879424..722aac8 100644 --- a/.github/workflows/release-publish.yaml +++ b/.github/workflows/release-publish.yaml @@ -1,34 +1,28 @@ name: Publish release -# Only merged source is executed. Validation of unmerged PRs lives in its own -# read-only pull_request workflow. This trigger also supports reviewed fork PRs. +# Inspect the exact pushed commit and publish only a validated, merged release PR. on: - pull_request_target: - types: [closed] + push: branches: ["main"] permissions: contents: read pull-requests: read -concurrency: - group: release-publish - cancel-in-progress: false - env: BUNDLE_WITH: maintenance - RELEASE_PR: ${{ github.event.pull_request.number }} jobs: inspect: - if: github.event.pull_request.merged == true runs-on: ubuntu-latest outputs: release: ${{ steps.inspect.outputs.release }} commit: ${{ steps.inspect.outputs.commit }} + pull_request: ${{ steps.inspect.outputs.pull_request }} steps: - uses: actions/checkout@v7 with: + ref: ${{ github.sha }} fetch-depth: 0 persist-credentials: false - uses: ruby/setup-ruby@v1 @@ -37,7 +31,8 @@ jobs: bundler-cache: true - id: inspect env: - GH_TOKEN: ${{ github.token }} + GITHUB_TOKEN: ${{ github.token }} + RELEASE_COMMIT: ${{ github.sha }} run: bundle exec bake gem:github:release:resolve publish: @@ -45,6 +40,12 @@ jobs: if: needs.inspect.outputs.release == 'true' runs-on: ubuntu-latest environment: rubygems + concurrency: + group: release-publish + cancel-in-progress: false + queue: max + env: + RELEASE_PR: ${{ needs.inspect.outputs.pull_request }} permissions: contents: write pull-requests: read @@ -55,8 +56,6 @@ jobs: - uses: actions/checkout@v7 with: # Inspection verified this merged commit belongs to the default branch. - # Checkout v7 also blocks merged fork PRs without this explicit opt-in. - allow-unsafe-pr-checkout: true ref: ${{ needs.inspect.outputs.commit }} fetch-depth: 0 persist-credentials: false @@ -68,7 +67,7 @@ jobs: - name: Build or restore artifact id: build env: - GH_TOKEN: ${{ github.token }} + GITHUB_TOKEN: ${{ github.token }} GEM_SIGNING_KEY: ${{ secrets.GEM_SIGNING_KEY }} run: bundle exec bake gem:github:release:build - name: Sign RubyGems attestation @@ -100,5 +99,5 @@ jobs: - uses: rubygems/configure-rubygems-credentials@main - name: Verify, publish, and finalize env: - GH_TOKEN: ${{ github.token }} + GITHUB_TOKEN: ${{ github.token }} run: bundle exec bake gem:github:release:publish diff --git a/bake/gem/github/release.rb b/bake/gem/github/release.rb index e2aca8a..809cb49 100644 --- a/bake/gem/github/release.rb +++ b/bake/gem/github/release.rb @@ -26,20 +26,25 @@ def major(refresh: false) Bake::Gem::GitHub::Project.new(context.root).prepare(context, "major", refresh: refresh) end -# Resolve and validate a merged PR, emitting a commit output for the publishing job. -# @parameter number [String] The merged PR number; defaults to `RELEASE_PR`. +# Resolve and validate a pushed commit, emitting its merged PR and commit for publishing. +# @parameter number [String | Nil] A merged PR number for older workflows; defaults to `RELEASE_PR`. +# @parameter commit [String | Nil] The pushed commit SHA; defaults to `RELEASE_COMMIT` and takes precedence over `number`. # @returns [Hash | Nil] Release metadata, or nil for an ordinary PR. -def resolve(number: ENV.fetch("RELEASE_PR")) - result = Bake::Gem::GitHub::Project.new(context.root).inspect_release(number) +def resolve(number: ENV["RELEASE_PR"], commit: ENV["RELEASE_COMMIT"]) + project = Bake::Gem::GitHub::Project.new(context.root) + result = commit ? project.inspect_commit(commit) : project.inspect_release(number) if path = ENV["GITHUB_OUTPUT"] File.open(path, "a") do |file| file.puts "release=#{!result.nil?}" - file.puts "commit=#{result.fetch(:commit)}" if result + if result + file.puts "commit=#{result.fetch(:commit)}" + file.puts "pull_request=#{result.fetch(:pull_request)}" + end end end - return result + result end # Build or restore the exact artifact for a merged release PR. diff --git a/context/getting-started.md b/context/getting-started.md index fea84ac..92f6862 100644 --- a/context/getting-started.md +++ b/context/getting-started.md @@ -4,7 +4,7 @@ This guide explains how to configure reviewed Ruby gem releases and prepare the ## How releases work -Maintainers prepare a release PR containing the version bump and generated release notes. CI regenerates those changes from the current base to verify the content. Native GitHub rules control approval and merging; after merge, GitHub Actions builds the exact merged commit and publishes its verified artifact to RubyGems. +Maintainers prepare a release PR containing the version bump and generated release notes. CI regenerates those changes from the current base to verify the content. Native GitHub rules control approval and merging. A push to the default branch starts release inspection; GitHub Actions builds the exact pushed commit only when it is a validated, merged release PR, then publishes its verified artifact to RubyGems. `bake-gem` provides version updates, release hooks, and clean builds. `bake-gem-github` adds PR preparation, GitHub policy, and remote publishing. The supported process uses one gemspec, stable three-part versions, merge or squash merging, and RubyGems.org. @@ -111,6 +111,8 @@ git diff This updates managed files in the working tree and returns their changed paths. Review the diff and selectively retain repository customizations before committing. The task does not stage, commit, or change remote settings. Repeated updates produce no further changes unless customizations differ from the templates. Apply changed rulesets after the corresponding workflows are running. +Regenerate existing workflows to adopt publishing on `push` instead of `pull_request_target`. The workflow filename and `rubygems` environment remain the same, so the RubyGems Trusted Publisher configuration does not change. No exception to GitHub's `pull_request_target` execution policy is needed. The resolve task continues to accept PR numbers from older workflows while you migrate. + The release workflows follow `bake modernize` action versions and use moving major tags where available. The RubyGems credentials action uses its [documented `@main` reference](https://github.com/rubygems/configure-rubygems-credentials#trusted-publisher-recommended). Repositories that require fixed revisions can customize these references. ## Current scope diff --git a/context/preparing-releases.md b/context/preparing-releases.md index fface6c..89f885f 100644 --- a/context/preparing-releases.md +++ b/context/preparing-releases.md @@ -21,6 +21,14 @@ Replace `patch` with `minor` or `major`. The wrapper fetches the default branch All release changes belong in the PR. Core preparation commits additions and deletions from release hooks but never pushes, tags or publishes. Validation independently generates the expected tree from the current base. A changed base SHA alone is fine; changed generated notes are not. Ordinary PRs with no version change pass release validation and still build unsigned. +## Publish the merged release + +Merging into the configured default branch triggers `release-publish.yaml` through its `push` event. Inspection uses the exact pushed SHA and resolves its associated PR through GitHub. Publication requires one matching merged PR in this repository, targeting the configured branch, with that exact merge commit. Ordinary changes do not publish; release changes without a matching merged PR fail inspection. Both merge commits and squash merges are supported, including merged fork PRs. + +Each release must land as the tip of its own push, as it does when merging a PR through GitHub. Later pushes do not change a pending release's source: inspection and publishing remain pinned to the original commit, and reruns use the same event. Do not combine a release and later changes into one direct push. If you automate merging, use a GitHub App or personal access token; pushes made using a workflow's `GITHUB_TOKEN` do not trigger another workflow. + +Only validated releases enter the publishing queue and request approval from the `rubygems` environment when required. Ordinary pushes cannot replace them in that queue. The publishing job retains the existing signing, attestation, and artifact recovery checks. + ## Resume interrupted preparation If preparation stops after creating or pushing the release branch, return to the current default branch and repeat the same command. The existing branch is validated and reused, so retries do not create a second version bump or PR. Resolve any uncommitted changes before switching branches. diff --git a/context/recovering-releases.md b/context/recovering-releases.md index bda94ad..d3eba04 100644 --- a/context/recovering-releases.md +++ b/context/recovering-releases.md @@ -24,4 +24,6 @@ A rerun can recover an interrupted individual asset upload once `release.tar` is ## Understand workflow reruns -GitHub concurrency does not guarantee a durable FIFO queue: rerun any publishing run displaced while pending. Resume reruns all jobs, including integrity checks; it does not repeat or second-guess the native review policy or a permitted administrator bypass. Older publishing runs execute their original code; adding this recovery support to the default branch does not change an already-triggered workflow. +Only publishing jobs share a concurrency group. They use `queue: max`, allowing up to 100 pending publishing jobs without replacing earlier ones; ordinary pushes only run inspection and do not enter this queue. GitHub cancels additional jobs if that limit is reached. Rerun a canceled publishing workflow after capacity becomes available. + +Resume reruns all jobs against the original pushed commit, including integrity checks; it does not repeat or second-guess the native review policy or a permitted administrator bypass. Older publishing runs execute their original code and concurrency policy; updating the default branch does not change an already-triggered workflow. diff --git a/guides/getting-started/readme.md b/guides/getting-started/readme.md index fea84ac..92f6862 100644 --- a/guides/getting-started/readme.md +++ b/guides/getting-started/readme.md @@ -4,7 +4,7 @@ This guide explains how to configure reviewed Ruby gem releases and prepare the ## How releases work -Maintainers prepare a release PR containing the version bump and generated release notes. CI regenerates those changes from the current base to verify the content. Native GitHub rules control approval and merging; after merge, GitHub Actions builds the exact merged commit and publishes its verified artifact to RubyGems. +Maintainers prepare a release PR containing the version bump and generated release notes. CI regenerates those changes from the current base to verify the content. Native GitHub rules control approval and merging. A push to the default branch starts release inspection; GitHub Actions builds the exact pushed commit only when it is a validated, merged release PR, then publishes its verified artifact to RubyGems. `bake-gem` provides version updates, release hooks, and clean builds. `bake-gem-github` adds PR preparation, GitHub policy, and remote publishing. The supported process uses one gemspec, stable three-part versions, merge or squash merging, and RubyGems.org. @@ -111,6 +111,8 @@ git diff This updates managed files in the working tree and returns their changed paths. Review the diff and selectively retain repository customizations before committing. The task does not stage, commit, or change remote settings. Repeated updates produce no further changes unless customizations differ from the templates. Apply changed rulesets after the corresponding workflows are running. +Regenerate existing workflows to adopt publishing on `push` instead of `pull_request_target`. The workflow filename and `rubygems` environment remain the same, so the RubyGems Trusted Publisher configuration does not change. No exception to GitHub's `pull_request_target` execution policy is needed. The resolve task continues to accept PR numbers from older workflows while you migrate. + The release workflows follow `bake modernize` action versions and use moving major tags where available. The RubyGems credentials action uses its [documented `@main` reference](https://github.com/rubygems/configure-rubygems-credentials#trusted-publisher-recommended). Repositories that require fixed revisions can customize these references. ## Current scope diff --git a/guides/preparing-releases/readme.md b/guides/preparing-releases/readme.md index fface6c..89f885f 100644 --- a/guides/preparing-releases/readme.md +++ b/guides/preparing-releases/readme.md @@ -21,6 +21,14 @@ Replace `patch` with `minor` or `major`. The wrapper fetches the default branch All release changes belong in the PR. Core preparation commits additions and deletions from release hooks but never pushes, tags or publishes. Validation independently generates the expected tree from the current base. A changed base SHA alone is fine; changed generated notes are not. Ordinary PRs with no version change pass release validation and still build unsigned. +## Publish the merged release + +Merging into the configured default branch triggers `release-publish.yaml` through its `push` event. Inspection uses the exact pushed SHA and resolves its associated PR through GitHub. Publication requires one matching merged PR in this repository, targeting the configured branch, with that exact merge commit. Ordinary changes do not publish; release changes without a matching merged PR fail inspection. Both merge commits and squash merges are supported, including merged fork PRs. + +Each release must land as the tip of its own push, as it does when merging a PR through GitHub. Later pushes do not change a pending release's source: inspection and publishing remain pinned to the original commit, and reruns use the same event. Do not combine a release and later changes into one direct push. If you automate merging, use a GitHub App or personal access token; pushes made using a workflow's `GITHUB_TOKEN` do not trigger another workflow. + +Only validated releases enter the publishing queue and request approval from the `rubygems` environment when required. Ordinary pushes cannot replace them in that queue. The publishing job retains the existing signing, attestation, and artifact recovery checks. + ## Resume interrupted preparation If preparation stops after creating or pushing the release branch, return to the current default branch and repeat the same command. The existing branch is validated and reused, so retries do not create a second version bump or PR. Resolve any uncommitted changes before switching branches. diff --git a/guides/recovering-releases/readme.md b/guides/recovering-releases/readme.md index bda94ad..d3eba04 100644 --- a/guides/recovering-releases/readme.md +++ b/guides/recovering-releases/readme.md @@ -24,4 +24,6 @@ A rerun can recover an interrupted individual asset upload once `release.tar` is ## Understand workflow reruns -GitHub concurrency does not guarantee a durable FIFO queue: rerun any publishing run displaced while pending. Resume reruns all jobs, including integrity checks; it does not repeat or second-guess the native review policy or a permitted administrator bypass. Older publishing runs execute their original code; adding this recovery support to the default branch does not change an already-triggered workflow. +Only publishing jobs share a concurrency group. They use `queue: max`, allowing up to 100 pending publishing jobs without replacing earlier ones; ordinary pushes only run inspection and do not enter this queue. GitHub cancels additional jobs if that limit is reached. Rerun a canceled publishing workflow after capacity becomes available. + +Resume reruns all jobs against the original pushed commit, including integrity checks; it does not repeat or second-guess the native review policy or a permitted administrator bypass. Older publishing runs execute their original code and concurrency policy; updating the default branch does not change an already-triggered workflow. diff --git a/lib/bake/gem/github/project.rb b/lib/bake/gem/github/project.rb index 25393f5..1e683c9 100644 --- a/lib/bake/gem/github/project.rb +++ b/lib/bake/gem/github/project.rb @@ -111,10 +111,12 @@ def merged(number) # Resolve release identity; ordinary merged PRs do not publish. # @parameter number [String | Integer] The merged PR number. + # @parameter commit [String | Nil] The expected merge commit, when resolving a push event. # @returns [Hash | Nil] Release metadata with symbol keys, or nil for an ordinary PR. Includes `name`, `version`, `commit`, `base`, `bump`, `repository`, `pull_request`, `merged_by`, and `pull_request_url`. # @raises [RuntimeError] If the merged source does not match the independently generated release. - def inspect_release(number) + def inspect_release(number, commit: nil) pr = merged(number) + raise "PR merge commit does not match the pushed commit." if commit && pr.fetch("merge_commit_sha") != commit commit = pr.fetch("merge_commit_sha") metadata = @release.validate(base: "#{commit}^1", candidate: commit, optional: true) @@ -128,6 +130,31 @@ def inspect_release(number) end end + # Resolve a pushed release commit to the PR which merged it into the configured branch. + # @parameter commit [String] The full commit SHA from the push event. + # @returns [Hash | Nil] Release metadata from {inspect_release}, or nil for an ordinary change. + # @raises [RuntimeError] If the commit is invalid or a release has no unique matching merged PR. + # @raises [Bake::Gem::CommandExecutionError] If Git or GitHub cannot verify the release. + def inspect_commit(commit) + raise "Expected a full pushed commit SHA." unless commit.match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/) + responses = readlines( + "gh", "api", "repos/#{@repository}/commits/#{commit}/pulls?per_page=100", + "--paginate", "--slurp", chdir: @root, + ) + pulls = JSON.parse(responses.join).flatten(1).select do |pr| + pr["merged_at"] && pr["merge_commit_sha"] == commit && + pr.dig("base", "ref") == @config.fetch("branch") && pr.dig("base", "repo", "full_name") == @repository + end + + if pulls.empty? + return nil unless @release.validate(base: "#{commit}^1", candidate: commit, optional: true) + raise "Release commit has no matching merged PR." + end + raise "Multiple merged PRs match the pushed commit." if pulls.size > 1 + + return inspect_release(pulls.first.fetch("number"), commit: commit) + end + # Return a read-only comparison of managed settings and current repository settings. # @returns [Hash] Desired rules, existing rules, environments, optional environment changes, and expected Trusted Publisher settings. This does not verify RubyGems ownership or publisher configuration. def doctor diff --git a/releases.md b/releases.md index 7e2b927..0d288a6 100644 --- a/releases.md +++ b/releases.md @@ -1,5 +1,10 @@ # Releases +## Unreleased + + - Publish validated release PRs from pushes to the default branch, retaining the exact merged commit and environment approval without requiring `pull_request_target`. + - Queue publishing jobs without replacing pending releases when later changes land. + ## v0.3.0 - Configure publishing environment reviewers through release setup while preserving existing environment protections. diff --git a/templates/release-prepare.yaml.erb b/templates/release-prepare.yaml.erb index 9594c1e..25c5623 100644 --- a/templates/release-prepare.yaml.erb +++ b/templates/release-prepare.yaml.erb @@ -40,7 +40,7 @@ jobs: bundler-cache: true - name: Create release PR env: - GH_TOKEN: ${{ github.token }} + GITHUB_TOKEN: ${{ github.token }} BUMP: ${{ inputs.bump }} REFRESH: ${{ inputs.refresh }} run: | diff --git a/templates/release-publish.yaml.erb b/templates/release-publish.yaml.erb index f694601..52d0b6c 100644 --- a/templates/release-publish.yaml.erb +++ b/templates/release-publish.yaml.erb @@ -1,34 +1,28 @@ name: Publish release -# Only merged source is executed. Validation of unmerged PRs lives in its own -# read-only pull_request workflow. This trigger also supports reviewed fork PRs. +# Inspect the exact pushed commit and publish only a validated, merged release PR. on: - pull_request_target: - types: [closed] + push: branches: [<%= branch.to_json %>] permissions: contents: read pull-requests: read -concurrency: - group: release-publish - cancel-in-progress: false - env: BUNDLE_WITH: maintenance - RELEASE_PR: ${{ github.event.pull_request.number }} jobs: inspect: - if: github.event.pull_request.merged == true runs-on: ubuntu-latest outputs: release: ${{ steps.inspect.outputs.release }} commit: ${{ steps.inspect.outputs.commit }} + pull_request: ${{ steps.inspect.outputs.pull_request }} steps: - uses: actions/checkout@v7 with: + ref: ${{ github.sha }} fetch-depth: 0 persist-credentials: false - uses: ruby/setup-ruby@v1 @@ -37,7 +31,8 @@ jobs: bundler-cache: true - id: inspect env: - GH_TOKEN: ${{ github.token }} + GITHUB_TOKEN: ${{ github.token }} + RELEASE_COMMIT: ${{ github.sha }} run: bundle exec bake gem:github:release:resolve publish: @@ -45,6 +40,12 @@ jobs: if: needs.inspect.outputs.release == 'true' runs-on: ubuntu-latest environment: rubygems + concurrency: + group: release-publish + cancel-in-progress: false + queue: max + env: + RELEASE_PR: ${{ needs.inspect.outputs.pull_request }} permissions: contents: write pull-requests: read @@ -55,8 +56,6 @@ jobs: - uses: actions/checkout@v7 with: # Inspection verified this merged commit belongs to the default branch. - # Checkout v7 also blocks merged fork PRs without this explicit opt-in. - allow-unsafe-pr-checkout: true ref: ${{ needs.inspect.outputs.commit }} fetch-depth: 0 persist-credentials: false @@ -68,7 +67,7 @@ jobs: - name: Build or restore artifact id: build env: - GH_TOKEN: ${{ github.token }} + GITHUB_TOKEN: ${{ github.token }} <% if signing -%> GEM_SIGNING_KEY: ${{ secrets.GEM_SIGNING_KEY }} <% end -%> @@ -102,5 +101,5 @@ jobs: - uses: rubygems/configure-rubygems-credentials@main - name: Verify, publish, and finalize env: - GH_TOKEN: ${{ github.token }} + GITHUB_TOKEN: ${{ github.token }} run: bundle exec bake gem:github:release:publish diff --git a/test/bake/gem/github/project/inspect_commit.rb b/test/bake/gem/github/project/inspect_commit.rb new file mode 100644 index 0000000..390c382 --- /dev/null +++ b/test/bake/gem/github/project/inspect_commit.rb @@ -0,0 +1,147 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "bake/gem/github/repository_context" +require "bake/gem/github/project_client" + +describe Bake::Gem::GitHub::Project do + include Bake::Gem::GitHub::RepositoryContext + + let(:project) {Bake::Gem::GitHub::ProjectClient.new(repository)} + let(:commit) {git("rev-parse", "HEAD")} + let(:pull) do + { + "number" => 42, "merged" => true, "merged_at" => "2026-09-22T00:00:00Z", + "base" => {"ref" => "main", "repo" => {"full_name" => "socketry/example"}}, + "merge_commit_sha" => commit, "html_url" => "https://github.com/socketry/example/pull/42" + } + end + let(:pages) {[[pull]]} + + def configure_pull + project.responses["repos/socketry/example/commits/#{commit}/pulls?per_page=100"] = pages + project.responses["repos/socketry/example/pulls/42"] = pull + end + + with "#inspect_commit" do + ["--help", "main", "a" * 39, "a" * 41, "a" * 65, "a" * 40 + "/pulls"].each do |value| + it "rejects invalid commit identifiers before calling GitHub", unique: value do + expect{project.inspect_commit(value)}.to raise_exception(RuntimeError, message: be =~ /full pushed commit SHA/) + expect(project.requests).to be == [] + end + end + + with "ordinary changes" do + before do + git("commit", "--quiet", "--allow-empty", "-m", "Ordinary change") + git("push", "--quiet", "origin", "main") + configure_pull + end + + it "does not publish an ordinary merged PR" do + expect(project.inspect_commit(commit)).to be_nil + end + + it "does not publish an ordinary direct push" do + pages.clear + + expect(project.inspect_commit(commit)).to be_nil + end + end + + with "release changes" do + before do + isolated_project('Bake::Gem::GitHub::ProjectClient.new(Dir.pwd).prepare(Bake::Context.load(Dir.pwd), "patch")') + git("checkout", "--quiet", "main") + git("merge", "--quiet", "--no-ff", "releases/v1.0.1", "-m", "Merge release") + git("push", "--quiet", "origin", "main") + configure_pull + end + + it "resolves the release after later commits land on the default branch" do + git("commit", "--quiet", "--allow-empty", "-m", "Later development") + git("push", "--quiet", "origin", "main") + result = project.inspect_commit(commit) + + expect(result).to have_keys(version: be == "1.0.1", commit: be == commit, pull_request: be == 42) + expect(result[:commit]).not.to be == git("rev-parse", "HEAD") + end + + it "accepts a merged fork PR" do + pull["head"] = {"repo" => {"full_name" => "contributor/example"}} + + expect(project.inspect_commit(commit)).to have_keys(commit: be == commit, pull_request: be == 42) + end + + it "finds the exact merged PR across all response pages" do + pages.unshift([pull.merge("merge_commit_sha" => "a" * 40)]) + + expect(project.inspect_commit(commit)).to have_keys(pull_request: be == 42) + expect(project.requests.first).to be == ["gh", "api", "repos/socketry/example/commits/#{commit}/pulls?per_page=100", "--paginate", "--slurp"] + end + + it "rejects release changes without a matching merged PR" do + pages.clear + + expect{project.inspect_commit(commit)}.to raise_exception(RuntimeError, message: be =~ /no matching merged PR/) + end + + [ + {"merged_at" => nil}, + {"merge_commit_sha" => "a" * 40}, + {"base" => {"ref" => "other", "repo" => {"full_name" => "socketry/example"}}}, + {"base" => {"ref" => "main", "repo" => {"full_name" => "other/example"}}} + ].each do |change| + it "rejects unrelated PR associations", unique: change.inspect do + pages.replace([[pull.merge(change)]]) + + expect{project.inspect_commit(commit)}.to raise_exception(RuntimeError, message: be =~ /no matching merged PR/) + end + end + + it "rejects ambiguous merged PR associations" do + pages << [pull.merge("number" => 43)] + + expect{project.inspect_commit(commit)}.to raise_exception(RuntimeError, message: be =~ /Multiple merged PRs/) + end + + it "rechecks the PR merge commit against the pushed commit" do + git("commit", "--quiet", "--allow-empty", "-m", "Unrelated merge") + git("push", "--quiet", "origin", "main") + project.responses["repos/socketry/example/pulls/42"] = pull.merge("merge_commit_sha" => git("rev-parse", "HEAD")) + + expect{project.inspect_commit(commit)}.to raise_exception(RuntimeError, message: be =~ /does not match the pushed commit/) + end + + it "rechecks that the PR is merged" do + project.responses["repos/socketry/example/pulls/42"] = pull.merge("merged" => false) + + expect{project.inspect_commit(commit)}.to raise_exception(RuntimeError, message: be =~ /PR must be merged/) + end + + it "propagates GitHub lookup failures" do + error = Bake::Gem::CommandExecutionError.new("GitHub unavailable", nil) + expect(project).to receive(:readlines).and_raise(error) + + expect{project.inspect_commit(commit)}.to raise_exception(Bake::Gem::CommandExecutionError) + end + end + + with "squash merges" do + before do + isolated_project('Bake::Gem::GitHub::ProjectClient.new(Dir.pwd).prepare(Bake::Context.load(Dir.pwd), "patch")') + git("checkout", "--quiet", "main") + git("merge", "--quiet", "--squash", "releases/v1.0.1") + git("commit", "--quiet", "-m", "Squash release") + git("push", "--quiet", "origin", "main") + configure_pull + end + + it "resolves the squash commit to its release PR" do + expect(project.inspect_commit(commit)).to have_keys(version: be == "1.0.1", commit: be == commit, pull_request: be == 42) + end + end + end +end diff --git a/test/bake/gem/github/release.rb b/test/bake/gem/github/release.rb index 95ecf19..7486f1a 100644 --- a/test/bake/gem/github/release.rb +++ b/test/bake/gem/github/release.rb @@ -65,10 +65,10 @@ end with "gem:github:release:resolve" do - [false, true].each do |release| - it "writes workflow outputs for the actual merged PR", unique: release do + ["number", "commit"].product([false, true]).each do |source, release| + it "writes workflow outputs for the exact merged PR", unique: [source, release] do output = File.join(root, "output") - result = isolated_project(<<~'RUBY', env: {"RELEASE" => release.to_s, "RELEASE_PR" => "42", "GITHUB_OUTPUT" => output}) + result = isolated_project(<<~'RUBY', env: {"SOURCE" => source, "RELEASE" => release.to_s, "RELEASE_PR" => nil, "RELEASE_COMMIT" => nil, "GITHUB_OUTPUT" => output}) require "sus/mock" project = Bake::Gem::GitHub::ProjectClient.new(Dir.pwd) context = Bake::Context.load(Dir.pwd) @@ -81,16 +81,24 @@ end project.system("git", "push", "--quiet", "origin", "main") commit = project.readlines("git", "rev-parse", "HEAD").join.strip - project.responses["repos/socketry/example/pulls/42"] = { - "number" => 42, "merged" => true, "base" => {"ref" => "main", "repo" => {"full_name" => "socketry/example"}}, + pull = { + "number" => 42, "merged" => true, "merged_at" => "2026-09-22T00:00:00Z", + "base" => {"ref" => "main", "repo" => {"full_name" => "socketry/example"}}, "merge_commit_sha" => commit, "html_url" => "https://github.com/socketry/example/pull/42" } + project.responses["repos/socketry/example/commits/#{commit}/pulls?per_page=100"] = [[pull]] + project.responses["repos/socketry/example/pulls/42"] = pull Sus::Mock.new(Bake::Gem::GitHub::Project).replace(:new){project} + if ENV.fetch("SOURCE") == "commit" + ENV["RELEASE_COMMIT"] = commit + else + ENV["RELEASE_PR"] = "42" + end context.call("gem:github:release:resolve") RUBY expect(result.nil?).to be == !release - expect(File.readlines(output, chomp: true)).to be == (release ? ["release=true", "commit=#{git('rev-parse', 'HEAD')}"] : ["release=false"]) + expect(File.readlines(output, chomp: true)).to be == (release ? ["release=true", "commit=#{git('rev-parse', 'HEAD')}", "pull_request=42"] : ["release=false"]) end end end diff --git a/test/bake/gem/github/setup.rb b/test/bake/gem/github/setup.rb index 069cf97..8177687 100644 --- a/test/bake/gem/github/setup.rb +++ b/test/bake/gem/github/setup.rb @@ -75,7 +75,7 @@ def generate expect(validation).not.to be(:include?, "pull_request_target") publish = File.read(File.join(root, ".github/workflows/release-publish.yaml")) - expect(publish).to be(:include?, "github.event.pull_request.merged == true") + expect(publish).not.to be(:include?, "pull_request_target") expect(publish.index("actions/upload-artifact@")).to be < publish.index("rubygems/configure-rubygems-credentials@") expect(publish).not.to be(:include?, "GEM_SIGNING_KEY") end @@ -89,22 +89,31 @@ def generate expect(File.read(path)).to be == "Custom workflow\n" end - it "permits a fork checkout only after merged release inspection" do - generate + it "pins the pushed commit and queues only validated releases for environment approval" do + setup.generate(repository: "socketry/example", branch: "stable", checks: ["Tests"], signing: false) workflow = YAML.safe_load_file(File.join(root, ".github/workflows/release-publish.yaml")) + + expect(workflow.fetch(true)).to be == {"push" => {"branches" => ["stable"]}} + expect(workflow).not.to have_keys("concurrency") inspect = workflow.fetch("jobs").fetch("inspect") - expect(inspect.fetch("if")).to be == "github.event.pull_request.merged == true" - expect(inspect.fetch("steps").first.fetch("with")).not.to have_keys("allow-unsafe-pr-checkout", "ref") + expect(inspect).not.to have_keys("if", "environment", "concurrency") + expect(inspect.fetch("steps").first.fetch("with")).to have_keys("ref" => be == "${{ github.sha }}", "persist-credentials" => be == false) + expect(inspect.fetch("steps").first.fetch("with")).not.to have_keys("allow-unsafe-pr-checkout") expect(inspect.fetch("steps").last.fetch("run")).to be == "bundle exec bake gem:github:release:resolve" + expect(inspect.fetch("steps").last.fetch("env")).to have_keys("RELEASE_COMMIT" => be == "${{ github.sha }}") + expect(inspect.fetch("outputs")).to have_keys("pull_request" => be == "${{ steps.inspect.outputs.pull_request }}") publish = workflow.fetch("jobs").fetch("publish") expect(publish.fetch("needs")).to be == "inspect" expect(publish.fetch("if")).to be == "needs.inspect.outputs.release == 'true'" + expect(publish.fetch("environment")).to be == "rubygems" + expect(publish.fetch("env")).to have_keys("RELEASE_PR" => be == "${{ needs.inspect.outputs.pull_request }}") + expect(publish.fetch("concurrency")).to be == {"group" => "release-publish", "cancel-in-progress" => false, "queue" => "max"} checkout = publish.fetch("steps").first.fetch("with") expect(checkout.fetch("ref")).to be == "${{ needs.inspect.outputs.commit }}" - expect(checkout.fetch("allow-unsafe-pr-checkout")).to be == true + expect(checkout).not.to have_keys("allow-unsafe-pr-checkout") %w[prepare validate].each do |name| workflow = File.read(File.join(root, ".github/workflows/release-#{name}.yaml"))