From a109a31e8a5081eb57ecfdae4a5a183616a3b65f Mon Sep 17 00:00:00 2001 From: Douglas Eichelberger Date: Mon, 28 Sep 2026 22:11:51 -0700 Subject: [PATCH 1/6] 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. - The release job runs `bundle install` without a cache, since it publishes. - Bumps rubygems/release-gem from v1.1.0 to v1.4.1, which fetches tags before releasing and keeps credentials off disk. - 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 | 106 ++++++++++++++++++++++++++++++--- 1 file changed, 98 insertions(+), 8 deletions(-) diff --git a/.github/workflows/push_gem.yml b/.github/workflows/push_gem.yml index 63b0cc2..145e199 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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + 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,22 +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@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0 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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + 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@2e007403fc1ec238429ecaa57af6f22f019cc135 # v1.234.0 with: - bundler-cache: true ruby-version: ruby + - run: bundle install + + - 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 - # Release - - uses: rubygems/release-gem@9e85cb11501bebc2ae661c1500176316d3987059 # v1 + 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 b5d9ee2e3f7b8084eacd3cbe3ed24abdf56626bf Mon Sep 17 00:00:00 2001 From: Douglas Eichelberger Date: Mon, 28 Sep 2026 22:16:45 -0700 Subject: [PATCH 2/6] 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 145e199..b8c7fba 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 caca31e372fdea51265e19ef8b2d168afc11f38b Mon Sep 17 00:00:00 2001 From: Douglas Eichelberger Date: Mon, 28 Sep 2026 22:20:05 -0700 Subject: [PATCH 3/6] 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 b8c7fba..7732186 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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: ref: main From 6e0947c0a8e31d3666c5c3e1dd747b08b0283290 Mon Sep 17 00:00:00 2001 From: Douglas Eichelberger Date: Mon, 28 Sep 2026 22:23:51 -0700 Subject: [PATCH 4/6] 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 7732186..8880c99 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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - 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 835a7fe3d09de87f65f83078f35cca3978b9b734 Mon Sep 17 00:00:00 2001 From: Douglas Eichelberger Date: Tue, 29 Sep 2026 10:28:23 -0700 Subject: [PATCH 5/6] ci: release with release-please instead of on a version bump Follows Gusto/rubocop-gusto: release-please keeps a release PR open that bumps the version and CHANGELOG.md from Conventional Commits, and merging it tags the release and creates the GitHub release. The gem is still published with trusted publishing through rubygems/release-gem. - Publishing runs in the same workflow run, when release-please reports release_created. rubocop-gusto publishes on `release: published` with a GitHub App token, because a release created with GITHUB_TOKEN doesn't trigger other workflows; rubyatscale has no such app. push_gem.yml keeps its filename and the rubygems.org environment, which the trusted publisher is registered against. - The version moves from a literal in the gemspec to lib/singed/version.rb, since release-please's Ruby strategy updates a VERSION constant there. It updates singed's own version line in Gemfile.lock too, so there's no separate lockfile job. - bump-minor-pre-major, so a breaking change before 1.0 bumps the minor version rather than jumping to 1.0.0. - A PR title check enforces Conventional Commits, since the squash-merged title drives the version, and Dependabot's commits get a chore(deps) prefix so its PRs pass it. - README and AGENTS.md describe the process. The version-bump check job from the previous approach is gone. --- .github/dependabot.yml | 2 + .github/workflows/push_gem.yml | 119 +++++++----------------- .github/workflows/validate-pr-title.yml | 39 ++++++++ .release-please-manifest.json | 3 + AGENTS.md | 4 + CHANGELOG.md | 1 + README.md | 6 ++ lib/singed/version.rb | 5 + release-please-config.json | 13 +++ singed.gemspec | 4 +- 10 files changed, 108 insertions(+), 88 deletions(-) create mode 100644 .github/workflows/validate-pr-title.yml create mode 100644 .release-please-manifest.json create mode 100644 CHANGELOG.md create mode 100644 lib/singed/version.rb create mode 100644 release-please-config.json diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 09f4568..e9f589e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,6 +4,8 @@ updates: directory: "/" schedule: interval: "monthly" + commit-message: + prefix: "chore(deps)" groups: bundler: patterns: diff --git a/.github/workflows/push_gem.yml b/.github/workflows/push_gem.yml index 8880c99..41d8dea 100644 --- a/.github/workflows/push_gem.yml +++ b/.github/workflows/push_gem.yml @@ -1,84 +1,46 @@ -# 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. +# release-please keeps a release PR open that bumps lib/singed/version.rb and +# CHANGELOG.md from the Conventional Commits merged to main. Merging it creates +# the tag and GitHub release, and the release job then publishes the gem to +# RubyGems.org with trusted publishing (OIDC). To retry a failed publish, +# re-run the failed jobs of that workflow run. # -# The filename and the rubygems.org environment are what the trusted publisher -# on RubyGems.org is registered against, so don't rename either. +# Publishing happens in this run rather than on `release: published`, because a +# release created with GITHUB_TOKEN doesn't trigger other workflows. 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] + push: branches: [main] - workflow_dispatch: -permissions: - contents: read +permissions: {} concurrency: group: push-gem cancel-in-progress: false jobs: - 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) + release-please: + name: Update the release PR or create a release + if: github.repository == 'rubyatscale/singed' runs-on: ubuntu-latest + permissions: + contents: write # create the release tag and GitHub release + pull-requests: write # open and update the release PR outputs: - release: ${{ steps.version.outputs.release }} - github_release: ${{ steps.version.outputs.github_release }} - version: ${{ steps.version.outputs.version }} - sha: ${{ steps.version.outputs.sha }} + release_created: ${{ steps.release.outputs.release_created }} steps: - # 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: | - 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." ;; - 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" + - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 + id: release + with: + config-file: release-please-config.json + manifest-file: .release-please-manifest.json release: - name: Release - needs: check - if: needs.check.outputs.release == 'true' + name: Publish to RubyGems + needs: release-please + if: needs.release-please.outputs.release_created == 'true' runs-on: ubuntu-latest environment: @@ -86,7 +48,7 @@ jobs: url: https://rubygems.org/gems/singed permissions: - contents: write # push the version tag + contents: write # `rake release` pushes the branch and the tag release-please created id-token: write # trusted publishing steps: @@ -96,19 +58,19 @@ 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. The push needs no + # branch checkout rather than a detached HEAD at the release commit. The push needs no # persisted credentials: release-gem puts the token in git's credential cache for it. - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: ref: main persist-credentials: false - - name: Confirm main is still the commit CI tested + - name: Confirm main is still the release commit env: - SHA: ${{ needs.check.outputs.sha }} + SHA: ${{ github.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." + echo "::error::main moved from the release commit $SHA to $head before publishing, so this run can't publish it." exit 1 fi # No bundler cache here: a job that publishes the gem shouldn't restore one. @@ -120,26 +82,9 @@ 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" --repo "$GITHUB_REPOSITORY" --verify-tag --generate-notes - notify_on_failure: name: Notify on failure - needs: [check, release, github_release] + needs: [release-please, release] if: failure() runs-on: ubuntu-latest steps: diff --git a/.github/workflows/validate-pr-title.yml b/.github/workflows/validate-pr-title.yml new file mode 100644 index 0000000..1ed5ec5 --- /dev/null +++ b/.github/workflows/validate-pr-title.yml @@ -0,0 +1,39 @@ +# release-please derives the next version and the changelog from squash-merged +# commit titles, which GitHub takes from the PR title. +name: Validate PR Title + +on: + pull_request: + types: [opened, edited, synchronize, reopened] + +permissions: {} + +concurrency: + group: validate-pr-title-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + validate: + name: Validate PR Title + runs-on: ubuntu-latest + steps: + - name: Check Conventional Commits format + env: + PR_TITLE: ${{ github.event.pull_request.title }} + run: | + pattern="^(feat|fix|chore|docs|refactor|perf|test|ci|build|revert)(\(.+\))?(!)?: .+" + if ! grep -qE "$pattern" <<<"$PR_TITLE"; then + echo "::error::PR title does not follow Conventional Commits format." + echo "" + echo "Expected: (): " + echo "" + echo "Examples:" + echo " feat: add a Sidekiq middleware option for sampling" + echo " fix: stop flamegraph from swallowing exceptions" + echo " chore(deps): bump stackprof" + echo "" + echo "Allowed types: feat, fix, chore, docs, refactor, perf, test, ci, build, revert" + echo "feat bumps the minor version, fix the patch version, and a ! after the type marks a breaking change." + exit 1 + fi + echo "PR title is valid: $PR_TITLE" diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..0ee8c01 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.3.0" +} diff --git a/AGENTS.md b/AGENTS.md index 23cb7c0..4494e1a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,3 +25,7 @@ bundle exec rubocop -a # auto-correct - `lib/singed.rb` — main entry point; provides `Singed.flamegraph` block helper - `lib/singed/` — core classes: flamegraph output handling, stackprof/rbspy integrations, speedscope launcher - `spec/` — RSpec tests + +## Pull requests + +PR titles must follow Conventional Commits (`feat: ...`, `fix: ...`, `chore: ...`, with `!` for a breaking change). release-please uses the squash-merged title to pick the next version and write the changelog entry. Don't edit `lib/singed/version.rb` or `CHANGELOG.md` by hand; the release PR does that. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..825c32f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1 @@ +# Changelog diff --git a/README.md b/README.md index a70903e..a605d84 100644 --- a/README.md +++ b/README.md @@ -163,3 +163,9 @@ The `open` command is expected to be available. - using [rbspy](https://rbspy.github.io/) directly - using [stackprof](https://github.com/tmm1/stackprof) (a dependency of singed) directly + +## Releasing + +Releases are automated with [release-please](https://github.com/googleapis/release-please). PR titles must follow [Conventional Commits](https://www.conventionalcommits.org/), because the squash-merged title decides the next version and becomes the changelog entry. For example, `feat: ...` bumps the minor version, `fix: ...` bumps the patch version, and `chore: ...` doesn't release anything. A check on each PR enforces the format. + +release-please keeps a release PR open that bumps `lib/singed/version.rb` and `CHANGELOG.md`. Merging it tags the release, creates the GitHub release, and publishes the gem to RubyGems.org with trusted publishing. diff --git a/lib/singed/version.rb b/lib/singed/version.rb new file mode 100644 index 0000000..5ae6129 --- /dev/null +++ b/lib/singed/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module Singed + VERSION = "0.3.0" +end diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..5d3d83a --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "packages": { + ".": { + "release-type": "ruby", + "package-name": "singed", + "include-component-in-tag": false, + "changelog-path": "CHANGELOG.md", + "version-file": "lib/singed/version.rb", + "bump-minor-pre-major": true + } + } +} diff --git a/singed.gemspec b/singed.gemspec index 050e805..0e60027 100644 --- a/singed.gemspec +++ b/singed.gemspec @@ -1,9 +1,11 @@ # frozen_string_literal: true +require_relative "lib/singed/version" + Gem::Specification.new do |spec| spec.name = "singed" - spec.version = "0.3.0" + spec.version = Singed::VERSION spec.license = "MIT" spec.authors = ["Josh Nichols"] spec.email = ["josh.nichols@gusto.com"] From 43a20c7e528f27e9747a42468f905c2ab5ff9542 Mon Sep 17 00:00:00 2001 From: Douglas Eichelberger Date: Tue, 29 Sep 2026 12:03:41 -0700 Subject: [PATCH 6/6] Fix release PR checks, label permissions and the publish checkout A review turned up several problems with the release-please setup: - Release PRs couldn't be merged. Sorbet is a required check, and release-please pushes its PR with GITHUB_TOKEN, which doesn't trigger workflows. A new job dispatches the CI workflows on the release branch whenever release-please opens or updates the PR, since GITHUB_TOKEN can trigger workflow_dispatch. build.yml, rubocop.yml and sorbet.yml gain that trigger. - The release-please job gets issues: write. The autorelease labels don't exist yet, and GITHUB_TOKEN can't create them with pull-requests: write alone (googleapis/release-please-action#1105). - The publish job checks out the release tag instead of main, and the check that main hadn't moved is gone. release-gem fetches tags before `rake release`, which then skips pushing the branch and tag, so the branch checkout wasn't needed. The check also made "re-run failed jobs" fail for good once anything merged, and it compared against github.sha rather than the tagged commit. With nothing to push, the job only needs contents: read. bundle install runs frozen. - CHANGELOG.md is removed. The Changelog updater found no version header in a file holding only "# Changelog", so it would have left a stray "## Changelog" heading below the first entry. release-please creates the file when it's missing. - harden-runner goes from v2.11.0, which has open advisories, to v2.21.1. checkout and setup-ruby match the pins in the other workflows, and Dependabot now updates GitHub Actions too, with a 7-day cooldown. - The PR title regex rejects nested parentheses in the scope, which release-please can't parse. The error message and README say that perf and revert release a patch, and that a BREAKING CHANGE footer counts as breaking. The README no longer claims the title check enforces anything, since it isn't required. - lib/singed.rb requires singed/version, so Singed::VERSION is defined for apps that install the gem, not only for those loading it by path. --- .github/dependabot.yml | 14 +++++ .github/workflows/build.yml | 3 +- .github/workflows/push_gem.yml | 75 +++++++++++++++++-------- .github/workflows/rubocop.yml | 3 +- .github/workflows/sorbet.yml | 3 +- .github/workflows/validate-pr-title.yml | 6 +- CHANGELOG.md | 1 - README.md | 2 +- lib/singed.rb | 1 + 9 files changed, 77 insertions(+), 31 deletions(-) delete mode 100644 CHANGELOG.md diff --git a/.github/dependabot.yml b/.github/dependabot.yml index e9f589e..a3daa51 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,9 +4,23 @@ updates: directory: "/" schedule: interval: "monthly" + cooldown: + default-days: 7 commit-message: prefix: "chore(deps)" groups: bundler: patterns: - "*" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + cooldown: + default-days: 7 + commit-message: + prefix: "chore(deps)" + groups: + github-actions: + patterns: + - "*" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6a1490a..30a942c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,5 +1,6 @@ name: CI Test -on: [push, pull_request] +# push_gem.yml dispatches this on release PRs, since pushes made with GITHUB_TOKEN don't trigger it. +on: [push, pull_request, workflow_dispatch] permissions: contents: read jobs: diff --git a/.github/workflows/push_gem.yml b/.github/workflows/push_gem.yml index 41d8dea..bc201fc 100644 --- a/.github/workflows/push_gem.yml +++ b/.github/workflows/push_gem.yml @@ -1,8 +1,8 @@ -# release-please keeps a release PR open that bumps lib/singed/version.rb and -# CHANGELOG.md from the Conventional Commits merged to main. Merging it creates -# the tag and GitHub release, and the release job then publishes the gem to -# RubyGems.org with trusted publishing (OIDC). To retry a failed publish, -# re-run the failed jobs of that workflow run. +# release-please keeps a release PR open that bumps lib/singed/version.rb, +# singed's line in Gemfile.lock, and CHANGELOG.md from the Conventional Commits +# merged to main. Merging it creates the tag and GitHub release, and the release +# job then publishes the gem to RubyGems.org with trusted publishing (OIDC). To +# retry a failed publish, re-run the failed jobs of that workflow run. # # Publishing happens in this run rather than on `release: published`, because a # release created with GITHUB_TOKEN doesn't trigger other workflows. The filename @@ -27,9 +27,13 @@ jobs: runs-on: ubuntu-latest permissions: contents: write # create the release tag and GitHub release + issues: write # create the autorelease labels, which pull-requests: write doesn't allow pull-requests: write # open and update the release PR outputs: release_created: ${{ steps.release.outputs.release_created }} + tag_name: ${{ steps.release.outputs.tag_name }} + prs_created: ${{ steps.release.outputs.prs_created }} + pr: ${{ steps.release.outputs.pr }} steps: - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 id: release @@ -37,6 +41,34 @@ jobs: config-file: release-please-config.json manifest-file: .release-please-manifest.json + # release-please pushes the release PR with GITHUB_TOKEN, which doesn't trigger + # workflows, so its required checks would never report. GITHUB_TOKEN can + # trigger workflow_dispatch, and the dispatched runs report on the PR's head + # commit. Each workflow listed here needs a workflow_dispatch trigger. + release-pr-checks: + name: Run CI on the release PR + needs: release-please + if: needs.release-please.outputs.prs_created == 'true' + runs-on: ubuntu-latest + permissions: + actions: write # dispatch the CI workflows + steps: + - name: Dispatch CI on the release branch + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + PR: ${{ needs.release-please.outputs.pr }} + run: | + # release-please reports PR number 0 when it found nothing to change. + if [ "$(jq -r .number <<<"$PR")" = "0" ]; then + echo "No release PR changes to check." + exit 0 + fi + branch=$(jq -r .headBranchName <<<"$PR") + for workflow in build.yml rubocop.yml sorbet.yml; do + gh workflow run "$workflow" --ref "$branch" + done + release: name: Publish to RubyGems needs: release-please @@ -48,43 +80,38 @@ jobs: url: https://rubygems.org/gems/singed permissions: - contents: write # `rake release` pushes the branch and the tag release-please created + contents: read id-token: write # trusted publishing steps: - name: Harden Runner - uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0 + 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 release commit. The push needs no - # persisted credentials: release-gem puts the token in git's credential cache for it. - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + # Build from the release tag, so a re-run publishes the tagged tree even after main has + # moved. `rake release` only pushes the branch and tag when the tag is missing locally, + # and release-gem fetches tags first, so it never pushes and needs no write access. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: main + ref: refs/tags/${{ needs.release-please.outputs.tag_name }} persist-credentials: false - - name: Confirm main is still the release commit - env: - SHA: ${{ github.sha }} - run: | - head=$(git rev-parse HEAD) - if [ "$head" != "$SHA" ]; then - echo "::error::main moved from the release commit $SHA to $head before publishing, so this run can't publish 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@2e007403fc1ec238429ecaa57af6f22f019cc135 # v1.234.0 + uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0 with: - ruby-version: ruby + ruby-version: "3.4" + # Frozen, so a Gemfile.lock that disagrees with the gemspec fails here with a clear error, + # rather than being rewritten and failing `rake release`'s clean-tree check. - run: bundle install + env: + BUNDLE_FROZEN: "true" - uses: rubygems/release-gem@7f9650160c1a4e7989fdc9855807bdbd421d8b6b # v1.4.1 notify_on_failure: name: Notify on failure - needs: [release-please, release] + needs: [release-please, release-pr-checks, release] if: failure() runs-on: ubuntu-latest steps: diff --git a/.github/workflows/rubocop.yml b/.github/workflows/rubocop.yml index 655752d..24191e6 100644 --- a/.github/workflows/rubocop.yml +++ b/.github/workflows/rubocop.yml @@ -1,6 +1,7 @@ name: RuboCop -on: [push, pull_request] +# push_gem.yml dispatches this on release PRs, since pushes made with GITHUB_TOKEN don't trigger it. +on: [push, pull_request, workflow_dispatch] permissions: contents: read diff --git a/.github/workflows/sorbet.yml b/.github/workflows/sorbet.yml index 57d4f55..57448d1 100644 --- a/.github/workflows/sorbet.yml +++ b/.github/workflows/sorbet.yml @@ -1,6 +1,7 @@ name: Sorbet -on: [push, pull_request] +# push_gem.yml dispatches this on release PRs, since pushes made with GITHUB_TOKEN don't trigger it. +on: [push, pull_request, workflow_dispatch] permissions: contents: read diff --git a/.github/workflows/validate-pr-title.yml b/.github/workflows/validate-pr-title.yml index 1ed5ec5..1e2b13b 100644 --- a/.github/workflows/validate-pr-title.yml +++ b/.github/workflows/validate-pr-title.yml @@ -21,7 +21,7 @@ jobs: env: PR_TITLE: ${{ github.event.pull_request.title }} run: | - pattern="^(feat|fix|chore|docs|refactor|perf|test|ci|build|revert)(\(.+\))?(!)?: .+" + pattern="^(feat|fix|chore|docs|refactor|perf|test|ci|build|revert)(\([^()]+\))?(!)?: .+" if ! grep -qE "$pattern" <<<"$PR_TITLE"; then echo "::error::PR title does not follow Conventional Commits format." echo "" @@ -31,9 +31,11 @@ jobs: echo " feat: add a Sidekiq middleware option for sampling" echo " fix: stop flamegraph from swallowing exceptions" echo " chore(deps): bump stackprof" + echo " revert: feat: add a Sidekiq middleware option for sampling" echo "" echo "Allowed types: feat, fix, chore, docs, refactor, perf, test, ci, build, revert" - echo "feat bumps the minor version, fix the patch version, and a ! after the type marks a breaking change." + echo "feat bumps the minor version; fix, perf and revert bump the patch version; the other types don't release." + echo "A ! after the type marks a breaking change, which bumps the minor version until 1.0." exit 1 fi echo "PR title is valid: $PR_TITLE" diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 825c32f..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1 +0,0 @@ -# Changelog diff --git a/README.md b/README.md index e44a556..df796f4 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,6 @@ The `open` command is expected to be available. ## Releasing -Releases are automated with [release-please](https://github.com/googleapis/release-please). PR titles must follow [Conventional Commits](https://www.conventionalcommits.org/), because the squash-merged title decides the next version and becomes the changelog entry. For example, `feat: ...` bumps the minor version, `fix: ...` bumps the patch version, and `chore: ...` doesn't release anything. A check on each PR enforces the format. +Releases are automated with [release-please](https://github.com/googleapis/release-please). PR titles must follow [Conventional Commits](https://www.conventionalcommits.org/), because the squash-merged title decides the next version and becomes the changelog entry. For example, `feat: ...` bumps the minor version, `fix: ...` bumps the patch version (as do `perf: ...` and `revert: ...`), and `chore: ...` doesn't release anything. A `!` after the type, or a `BREAKING CHANGE:` footer in the squash message, marks a breaking change, which bumps the minor version until 1.0. A check on each PR flags titles that don't follow the format. release-please keeps a release PR open that bumps `lib/singed/version.rb` and `CHANGELOG.md`. Merging it tags the release, creates the GitHub release, and publishes the gem to RubyGems.org with trusted publishing. diff --git a/lib/singed.rb b/lib/singed.rb index e3fa6a5..69c07f4 100644 --- a/lib/singed.rb +++ b/lib/singed.rb @@ -3,6 +3,7 @@ require "json" require "stackprof" +require "singed/version" module Singed # Methods defined with plain `def` below are both module methods (Singed.start) and public