From f19448f68b4a70a0603054bce7150acf883e6bd1 Mon Sep 17 00:00:00 2001 From: Szymon Kurcab Date: Fri, 18 Sep 2026 20:40:47 +0200 Subject: [PATCH] build: pin the actions that can reach the publishing key The release job carries id-token: write, so every step in it can ask GitHub for the OIDC token RubyGems trades for a short-lived publishing key. All three actions in that job were referenced by tag. A tag is a mutable pointer: whoever can move it decides what runs inside the job that can publish this gem, and trusted publishing removes the stored secret without removing that. ruby/setup-ruby@v1 was not even a tag. The repository has no v1 tag; v1 is a branch, so the reference resolved to whatever its head was that morning -- currently a0102e0 (v1.324.0). All three are pinned to commit SHAs with the version in a trailing comment for whoever bumps them. Checkout in the release job also gets persist-credentials: false; the job never pushes, and a token left in .git/config is one more thing a later step could pick up. CI declares permissions: contents: read rather than inheriting the repository default. Last, hardening rather than a fix: the publish check appended everything rake release:status wrote to stdout into GITHUB_OUTPUT. Today that is exactly one line -- verified, and bundler's warnings go to stderr -- so there is no bug here. But that line decides whether credentials are fetched and a gem is pushed, and GITHUB_OUTPUT is parsed as key=value pairs, so the step now takes the one anchored publish= line and fails loudly if there is not exactly one. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 9 +++++++-- .github/workflows/release.yml | 27 +++++++++++++++++++++++---- README.md | 6 ++++++ 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b6a65d3..6d7e11f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,11 @@ on: branches: [main] pull_request: +# Nothing here writes anything. Say so, rather than inheriting whatever the +# repository default happens to be. +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest @@ -18,9 +23,9 @@ jobs: name: Ruby ${{ matrix.ruby }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.3.1 - - uses: ruby/setup-ruby@v1 + - uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0 with: ruby-version: ${{ matrix.ruby }} bundler-cache: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ada3be1..396d7c0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,9 +39,18 @@ jobs: id-token: write steps: - - uses: actions/checkout@v4 + # Actions are pinned to commit SHAs, not tags. This job holds + # id-token: write, so every step in it can ask GitHub for the OIDC + # token that RubyGems trades for a publishing key. A tag is a mutable + # pointer; whoever can move it can change what runs here. The trailing + # comment is the version the SHA was, for whoever bumps it next. + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.3.1 + with: + # The job never pushes, and a token left in .git/config is one more + # thing a later step could pick up. + persist-credentials: false - - uses: ruby/setup-ruby@v1 + - uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0 with: ruby-version: .ruby-version bundler-cache: true @@ -79,14 +88,24 @@ jobs: - name: Is this version already on RubyGems? id: check run: | - status=$(bundle exec rake release:status) + # Take the one line that is the answer, not whatever else reached + # stdout. Today there is nothing else -- rake prints the one line + # and bundler's warnings go to stderr -- but this decides whether + # credentials are fetched and a gem is pushed, and GITHUB_OUTPUT is + # parsed as key=value lines. Anchoring it costs nothing and turns a + # surprise into a failed step. + status=$(bundle exec rake release:status | grep -Ex 'publish=(true|false)' | tail -n 1) + if [ -z "$status" ]; then + echo "::error::rake release:status printed no publish=true or publish=false line" + exit 1 + fi echo "$status" echo "$status" >> "$GITHUB_OUTPUT" # Exchanges the OIDC token for a short-lived RubyGems API key and writes # it where `gem push` looks. The gem must list this repo, this workflow # file, and the release environment as a trusted publisher on RubyGems. - - uses: rubygems/configure-rubygems-credentials@v2.1.0 + - uses: rubygems/configure-rubygems-credentials@dc5a8d8553e6ee01fc26761a49e99e733d17954a # v2.1.0 if: steps.check.outputs.publish == 'true' - name: Push to RubyGems diff --git a/README.md b/README.md index 79638d6..2b67bff 100644 --- a/README.md +++ b/README.md @@ -201,3 +201,9 @@ anywhere. To ship a version: The same workflow can be started by hand from the Actions tab or with `gh workflow run release.yml`. + +Actions in both workflows are pinned to commit SHAs with the version they were +in a trailing comment. The release job can ask GitHub for the OIDC token that +RubyGems trades for a publishing key, so what runs in it should not be able to +change under a tag that someone else controls. Bump a pin by replacing both the +SHA and the comment.