From f53b0cf66cb5a451073bb5ea215ece43e33afc16 Mon Sep 17 00:00:00 2001 From: Douglas Eichelberger Date: Mon, 28 Sep 2026 22:11:51 -0700 Subject: [PATCH 1/8] Release automatically when the gemspec version changes Fixes #23. push_gem.yml already published through trusted publishing with rubygems/release-gem, but only by hand, and it had never run. It now runs after CI Test passes on main, and publishes only when singed.gemspec's version isn't on RubyGems yet, so bumping the version is the release, as with the rubyatscale gems that use shared-config's cd.yml. It still works by hand for retries, and skips a version that's already published. - The check job only lets a push to this repo through, since a fork's pull request can come from a branch named main. - `rake release` pushes the current branch along with the tag, so the release job checks out main itself, rather than a detached HEAD, and first confirms main is still the commit CI tested. - Creates the GitHub release, and posts to Slack on failure, like cd.yml. The workflow's filename and the rubygems.org environment stay as they are, since the trusted publisher on RubyGems.org is registered against them. --- .github/workflows/push_gem.yml | 104 ++++++++++++++++++++++++++++++--- 1 file changed, 95 insertions(+), 9 deletions(-) diff --git a/.github/workflows/push_gem.yml b/.github/workflows/push_gem.yml index 5886b41..fb247bd 100644 --- a/.github/workflows/push_gem.yml +++ b/.github/workflows/push_gem.yml @@ -1,14 +1,72 @@ +# Publishes a new version to RubyGems.org with trusted publishing (OIDC) once +# CI passes on main, if the gemspec's version isn't on RubyGems yet. Bumping the +# version in singed.gemspec is the release. Dispatch it by hand to retry a +# failed release; it still skips a version that's already published. +# +# The filename and the rubygems.org environment are what the trusted publisher +# on RubyGems.org is registered against, so don't rename either. name: Push Gem on: + # zizmor: ignore[dangerous-triggers] only a push to this repo's main gets past the check job, and the release job checks out main itself + workflow_run: + workflows: [CI Test] + types: [completed] + branches: [main] workflow_dispatch: permissions: contents: read +concurrency: + group: push-gem + cancel-in-progress: false + jobs: - push: - if: github.repository == 'rubyatscale/singed' + check: + name: Check whether a release is needed + # A fork's pull request can come from a branch named main, so require a push to this repo. + if: >- + github.repository == 'rubyatscale/singed' && + (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' || + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_repository.full_name == github.repository) + runs-on: ubuntu-latest + outputs: + release: ${{ steps.version.outputs.release }} + version: ${{ steps.version.outputs.version }} + sha: ${{ steps.version.outputs.sha }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.workflow_run.head_sha || github.sha }} + persist-credentials: false + - uses: ruby/setup-ruby@2e007403fc1ec238429ecaa57af6f22f019cc135 # v1.234.0 + with: + ruby-version: ruby + - name: Check whether this version is already on RubyGems + id: version + env: + SHA: ${{ github.event.workflow_run.head_sha || github.sha }} + run: | + version=$(ruby -e 'puts Gem::Specification.load("singed.gemspec").version') + status=$(curl -sS -o /dev/null -w '%{http_code}' "https://rubygems.org/api/v2/rubygems/singed/versions/$version.json") + case "$status" in + 200) release=false; echo "singed $version is already on RubyGems, so there's nothing to release." ;; + 404) release=true; echo "singed $version isn't on RubyGems yet; releasing it." ;; + *) echo "::error::RubyGems returned HTTP $status for singed $version"; exit 1 ;; + esac + { + echo "release=$release" + echo "version=$version" + echo "sha=$SHA" + } >> "$GITHUB_OUTPUT" + + release: + name: Release + needs: check + if: needs.check.outputs.release == 'true' runs-on: ubuntu-latest environment: @@ -16,26 +74,54 @@ jobs: url: https://rubygems.org/gems/singed permissions: - contents: write - id-token: write + contents: write # push the version tag and create the GitHub release + id-token: write # trusted publishing steps: - # Set up - name: Harden Runner uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 with: egress-policy: audit + # `rake release` pushes the current branch along with the tag, so this has to be a + # branch checkout rather than a detached HEAD at the tested commit. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: main persist-credentials: false + - name: Confirm main is still the commit CI tested + env: + SHA: ${{ needs.check.outputs.sha }} + run: | + head=$(git rev-parse HEAD) + if [ "$head" != "$SHA" ]; then + echo "::error::main moved from $SHA to $head after CI passed. The run for $head will release it." + exit 1 + fi + # No bundler cache here: a job that publishes the gem shouldn't restore one. - name: Set up Ruby uses: ruby/setup-ruby@e8944e80fb94b20106697132f8c20c665fab29e9 # v1.325.0 with: ruby-version: ruby - # Not bundler-cache: a release shouldn't restore a cache other workflows can write. - - name: Install gems - run: bundle install + - run: bundle install - # Release - uses: rubygems/release-gem@7f9650160c1a4e7989fdc9855807bdbd421d8b6b # v1.4.1 + + - name: Create GitHub release + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ needs.check.outputs.version }} + run: gh release create "v$VERSION" --verify-tag --generate-notes + + notify_on_failure: + name: Notify on failure + needs: [check, release] + if: failure() + runs-on: ubuntu-latest + steps: + - uses: slackapi/slack-github-action@dcb1066f776dd043e64d0e8ba94ca15cc7e1875d # v4.0.0 + with: + webhook: ${{ secrets.SLACK_WEBHOOK_URL }} + webhook-type: incoming-webhook + payload: | + text: "${{ github.repository }} gem release FAILED\n${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" From 34e0d9af3acbbbaf95acae1c6765a14275f25379 Mon Sep 17 00:00:00 2001 From: Douglas Eichelberger Date: Mon, 28 Sep 2026 22:16:45 -0700 Subject: [PATCH 2/8] Create the GitHub release in its own job so a retry can finish it If `gh release create` failed after the gem was already on RubyGems, a retry skipped everything, since the version was published, so the GitHub release never got created. The check job now also looks for a GitHub release for the version, and a separate job creates it whenever it's missing, whether the gem was just published or already had been. --- .github/workflows/push_gem.yml | 41 ++++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/.github/workflows/push_gem.yml b/.github/workflows/push_gem.yml index fb247bd..b86c2e4 100644 --- a/.github/workflows/push_gem.yml +++ b/.github/workflows/push_gem.yml @@ -1,7 +1,7 @@ # Publishes a new version to RubyGems.org with trusted publishing (OIDC) once -# CI passes on main, if the gemspec's version isn't on RubyGems yet. Bumping the -# version in singed.gemspec is the release. Dispatch it by hand to retry a -# failed release; it still skips a version that's already published. +# CI passes on main, if the gemspec's version isn't on RubyGems yet, then creates +# its GitHub release. Bumping the version in singed.gemspec is the release. +# Dispatch it by hand to retry a failed run: it skips whatever's already done. # # The filename and the rubygems.org environment are what the trusted publisher # on RubyGems.org is registered against, so don't rename either. @@ -35,6 +35,7 @@ jobs: runs-on: ubuntu-latest outputs: release: ${{ steps.version.outputs.release }} + github_release: ${{ steps.version.outputs.github_release }} version: ${{ steps.version.outputs.version }} sha: ${{ steps.version.outputs.sha }} steps: @@ -45,20 +46,31 @@ jobs: - uses: ruby/setup-ruby@2e007403fc1ec238429ecaa57af6f22f019cc135 # v1.234.0 with: ruby-version: ruby - - name: Check whether this version is already on RubyGems + - name: Check whether this version is on RubyGems and has a GitHub release id: version env: + GH_TOKEN: ${{ github.token }} SHA: ${{ github.event.workflow_run.head_sha || github.sha }} run: | version=$(ruby -e 'puts Gem::Specification.load("singed.gemspec").version') status=$(curl -sS -o /dev/null -w '%{http_code}' "https://rubygems.org/api/v2/rubygems/singed/versions/$version.json") case "$status" in - 200) release=false; echo "singed $version is already on RubyGems, so there's nothing to release." ;; + 200) release=false; echo "singed $version is already on RubyGems." ;; 404) release=true; echo "singed $version isn't on RubyGems yet; releasing it." ;; *) echo "::error::RubyGems returned HTTP $status for singed $version"; exit 1 ;; esac + if lookup=$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/v$version" 2>&1); then + github_release=false + elif grep -q 'HTTP 404' <<<"$lookup"; then + github_release=true + echo "v$version has no GitHub release yet." + else + echo "::error::Couldn't check for a v$version GitHub release: $lookup" + exit 1 + fi { echo "release=$release" + echo "github_release=$github_release" echo "version=$version" echo "sha=$SHA" } >> "$GITHUB_OUTPUT" @@ -74,7 +86,7 @@ jobs: url: https://rubygems.org/gems/singed permissions: - contents: write # push the version tag and create the GitHub release + contents: write # push the version tag id-token: write # trusted publishing steps: @@ -107,15 +119,26 @@ jobs: - uses: rubygems/release-gem@7f9650160c1a4e7989fdc9855807bdbd421d8b6b # v1.4.1 + # Its own job, so a retry can still create the release after the gem has shipped. + github_release: + name: Create GitHub release + needs: [check, release] + if: >- + always() && needs.check.result == 'success' && needs.check.outputs.github_release == 'true' && + (needs.release.result == 'success' || needs.release.result == 'skipped') + runs-on: ubuntu-latest + permissions: + contents: write # create the GitHub release + steps: - name: Create GitHub release env: GH_TOKEN: ${{ github.token }} VERSION: ${{ needs.check.outputs.version }} - run: gh release create "v$VERSION" --verify-tag --generate-notes + run: gh release create "v$VERSION" --repo "$GITHUB_REPOSITORY" --verify-tag --generate-notes notify_on_failure: name: Notify on failure - needs: [check, release] + needs: [check, release, github_release] if: failure() runs-on: ubuntu-latest steps: @@ -124,4 +147,4 @@ jobs: webhook: ${{ secrets.SLACK_WEBHOOK_URL }} webhook-type: incoming-webhook payload: | - text: "${{ github.repository }} gem release FAILED\n${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + text: "${{ github.repository }} Push Gem workflow FAILED\n${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" From 8753e368ddf1bdcb021013fb5bf883a2bc77d94b Mon Sep 17 00:00:00 2001 From: Douglas Eichelberger Date: Mon, 28 Sep 2026 22:20:05 -0700 Subject: [PATCH 3/8] Note why the release checkout doesn't persist credentials rubygems/release-gem v1.4.1 stores the token in git's credential cache for the tag push and clears it afterwards, so the checkout doesn't need to leave one behind. --- .github/workflows/push_gem.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/push_gem.yml b/.github/workflows/push_gem.yml index b86c2e4..42bd29c 100644 --- a/.github/workflows/push_gem.yml +++ b/.github/workflows/push_gem.yml @@ -96,7 +96,8 @@ jobs: egress-policy: audit # `rake release` pushes the current branch along with the tag, so this has to be a - # branch checkout rather than a detached HEAD at the tested commit. + # branch checkout rather than a detached HEAD at the tested commit. The push needs no + # persisted credentials: release-gem puts the token in git's credential cache for it. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: main From 645c4f53eb02a480393fb78789bd8e887bb3b0ed Mon Sep 17 00:00:00 2001 From: Douglas Eichelberger Date: Mon, 28 Sep 2026 22:23:51 -0700 Subject: [PATCH 4/8] Read the gemspec version without checking out the triggering commit CodeQL flagged actions/cache-poisoning/poisonable-step: the check job checked out the workflow_run head SHA and then ran code from it, since Gem::Specification.load evaluates the gemspec. The job's `if` already limits it to pushes to this repo, but the analysis can't see that. The job now fetches singed.gemspec at that SHA through the API and reads spec.version with a strict pattern, so nothing from the commit runs and the job no longer needs Ruby. If the gemspec ever stops using a string literal for the version, the job fails with an error instead of guessing. --- .github/workflows/push_gem.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/push_gem.yml b/.github/workflows/push_gem.yml index 42bd29c..e19eb7c 100644 --- a/.github/workflows/push_gem.yml +++ b/.github/workflows/push_gem.yml @@ -39,20 +39,20 @@ jobs: version: ${{ steps.version.outputs.version }} sha: ${{ steps.version.outputs.sha }} steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.event.workflow_run.head_sha || github.sha }} - persist-credentials: false - - uses: ruby/setup-ruby@2e007403fc1ec238429ecaa57af6f22f019cc135 # v1.234.0 - with: - ruby-version: ruby + # Reads the gemspec through the API instead of checking it out and evaluating it, so no + # code from the triggering commit runs in this privileged context. - name: Check whether this version is on RubyGems and has a GitHub release id: version env: GH_TOKEN: ${{ github.token }} SHA: ${{ github.event.workflow_run.head_sha || github.sha }} run: | - version=$(ruby -e 'puts Gem::Specification.load("singed.gemspec").version') + gemspec=$(gh api "repos/$GITHUB_REPOSITORY/contents/singed.gemspec?ref=$SHA" -H 'Accept: application/vnd.github.raw') + version=$(sed -n 's/^[[:space:]]*spec\.version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' <<<"$gemspec" | head -1) + if ! [[ "$version" =~ ^[0-9]+(\.[0-9A-Za-z]+)*$ ]]; then + echo "::error::Couldn't read a version from the spec.version line in singed.gemspec at $SHA" + exit 1 + fi status=$(curl -sS -o /dev/null -w '%{http_code}' "https://rubygems.org/api/v2/rubygems/singed/versions/$version.json") case "$status" in 200) release=false; echo "singed $version is already on RubyGems." ;; From 687db8c21abc7c9854470dfae03046b8a1d396c1 Mon Sep 17 00:00:00 2001 From: Douglas Eichelberger Date: Tue, 29 Sep 2026 15:22:01 -0700 Subject: [PATCH 5/8] Retry the RubyGems lookup on transient errors The check job runs after every green push to main, whether or not the version changed, so a timeout or a 5xx from rubygems.org failed it and posted a false alarm to Slack. curl --retry retries those, and still reports a 404 straight away, since a missing version isn't an error. --- .github/workflows/push_gem.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/push_gem.yml b/.github/workflows/push_gem.yml index e19eb7c..d7801ee 100644 --- a/.github/workflows/push_gem.yml +++ b/.github/workflows/push_gem.yml @@ -53,7 +53,7 @@ jobs: echo "::error::Couldn't read a version from the spec.version line in singed.gemspec at $SHA" exit 1 fi - status=$(curl -sS -o /dev/null -w '%{http_code}' "https://rubygems.org/api/v2/rubygems/singed/versions/$version.json") + status=$(curl -sS --retry 3 -o /dev/null -w '%{http_code}' "https://rubygems.org/api/v2/rubygems/singed/versions/$version.json") case "$status" in 200) release=false; echo "singed $version is already on RubyGems." ;; 404) release=true; echo "singed $version isn't on RubyGems yet; releasing it." ;; From bf86be55472a37c31e870d595adf64fa572809cf Mon Sep 17 00:00:00 2001 From: Douglas Eichelberger Date: Tue, 29 Sep 2026 15:22:24 -0700 Subject: [PATCH 6/8] Say what to do when main moved and its CI failed The error said the run for main's new commit would release the version, but that run only happens if CI passes on the commit. If it fails, the version waits for the next green push or a dispatch, so the message now says so. --- .github/workflows/push_gem.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/push_gem.yml b/.github/workflows/push_gem.yml index d7801ee..60a101a 100644 --- a/.github/workflows/push_gem.yml +++ b/.github/workflows/push_gem.yml @@ -108,7 +108,7 @@ jobs: run: | head=$(git rev-parse HEAD) if [ "$head" != "$SHA" ]; then - echo "::error::main moved from $SHA to $head after CI passed. The run for $head will release it." + echo "::error::main moved from $SHA to $head after CI passed. If CI passes on $head, its run will release this version; otherwise dispatch this workflow once main is green." exit 1 fi # No bundler cache here: a job that publishes the gem shouldn't restore one. From bae8df37b6b443cb598d37c9b288d9cbd1cf75ad Mon Sep 17 00:00:00 2001 From: Douglas Eichelberger Date: Tue, 29 Sep 2026 15:23:39 -0700 Subject: [PATCH 7/8] Note that a dispatch doesn't wait for CI and only runs from main A dispatch releases main's tip without checking that CI passed on it, which suits a retry, and dispatching from another branch skips every job without saying why. The header comment now says both. --- .github/workflows/push_gem.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/push_gem.yml b/.github/workflows/push_gem.yml index 60a101a..7587b42 100644 --- a/.github/workflows/push_gem.yml +++ b/.github/workflows/push_gem.yml @@ -1,7 +1,9 @@ # Publishes a new version to RubyGems.org with trusted publishing (OIDC) once # CI passes on main, if the gemspec's version isn't on RubyGems yet, then creates # its GitHub release. Bumping the version in singed.gemspec is the release. -# Dispatch it by hand to retry a failed run: it skips whatever's already done. +# Dispatch it from main to retry a failed run: it skips whatever's already done. +# A dispatch doesn't wait for CI, so only run it when main is green; from any +# other branch it does nothing. # # The filename and the rubygems.org environment are what the trusted publisher # on RubyGems.org is registered against, so don't rename either. From ac7d585e2ba805ad5952da80d37222ca8fd2ee3c Mon Sep 17 00:00:00 2001 From: Douglas Eichelberger Date: Tue, 29 Sep 2026 15:24:48 -0700 Subject: [PATCH 8/8] Mark prerelease versions as prereleases on GitHub A version like 1.0.0.rc1 passed the version check but got an ordinary GitHub release, which would have made it Latest. RubyGems treats any version containing a letter as a prerelease, so the release job now passes --prerelease for those. --- .github/workflows/push_gem.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/push_gem.yml b/.github/workflows/push_gem.yml index 7587b42..e1abe25 100644 --- a/.github/workflows/push_gem.yml +++ b/.github/workflows/push_gem.yml @@ -137,7 +137,11 @@ jobs: env: GH_TOKEN: ${{ github.token }} VERSION: ${{ needs.check.outputs.version }} - run: gh release create "v$VERSION" --repo "$GITHUB_REPOSITORY" --verify-tag --generate-notes + # RubyGems treats any version with a letter in it as a prerelease. + run: | + prerelease=() + if [[ "$VERSION" == *[A-Za-z]* ]]; then prerelease=(--prerelease); fi + gh release create "v$VERSION" --repo "$GITHUB_REPOSITORY" --verify-tag --generate-notes "${prerelease[@]}" notify_on_failure: name: Notify on failure