Skip to content

Scope the remaining release app tokens and bind a trailer - #6272

Merged
ChrisJBurns merged 2 commits into
mainfrom
harden-release-tag-token
Aug 10, 2026
Merged

Scope the remaining release app tokens and bind a trailer#6272
ChrisJBurns merged 2 commits into
mainfrom
harden-release-tag-token

Conversation

@ChrisJBurns

@ChrisJBurns ChrisJBurns commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Continues the release-workflow work in #6253, after #6263, #6266 and #6270. Covers both remaining github-app findings — the last two unscoped app tokens in the repository.

Summary

  • Two app tokens carried every permission their installation holds. actions/create-github-app-token with no permission-* inputs mints an installation-wide token. Both now name what they use.
  • The release-creation step interpolated a git commit trailer into the shell. TRIGGERED_BY="${{ steps.actor.outputs.triggered_by }}", where the value comes from git log --format='%(trailers:key=Release-Triggered-By,valueonly)'. Unlike the version in the same step — checked against ^[0-9]+\.[0-9]+\.[0-9]+$ before use — nothing constrains a trailer. Bound through the step's existing env: block.
Token Used for Now requests
create-release-tag.yml — release app git push the tag, gh release create contents: write
releaser.yml — Homebrew tap app GoReleaser committing the formula to stacklok/homebrew-tap contents: write

Repo-wide High goes 4 → 2. Both survivors are in helm-publish.yml and are fixed by #6270, so these two PRs together take High to zero.

Confidence

The Homebrew tap token is the better-evidenced of the two. It was already limited to a single repository by owner: + repositories:, so this narrows it from "everything on one auxiliary repo" to "one permission on one auxiliary repo". Three things confirm contents: write is both sufficient and available:

  • The GoReleaser brews: block configures no pull_request:, so the formula is committed straight to the tap's default branch.
  • The tap's history bears that out — every release lands as a direct Brew formula update for toolhive version vX.Y.Z commit, never a pull request.
  • HOMEBREW_TAP_GITHUB_TOKEN is wired from this token's output, so the token is the push credential. Those pushes have succeeded every release for months, which means the installation already holds the permission. No org-settings check is needed.

Its failure mode is also the mildest in the release path: the formula step runs at the end of goreleaser release --clean, after the binaries are uploaded, and this workflow already has a "Remove existing release assets (allows re-runs)" step because it is designed to be re-run.

The release tag token will mint — the same RELEASE_APP was shown to hold contents: write by the #6266 dispatch, and permission-* can only narrow. Two smaller things remain inferred rather than observed:

  • GitHub documents that Create a release additionally needs Workflows: write when the resolved target commit modifies workflow files. Release commits touch VERSION, chart manifests and generated chart READMEs, so this should not apply — but it is a conditional a blanket token satisfies silently, and requesting workflows: write as insurance is not an option: if the installation lacks it, the token step fails outright.
  • --generate-notes has no documented fine-grained permission requirement.

Its failure mode: it fires when a release pull request merges, and a token failure stops the run after the merge is on main but before the tag exists — so releaser.yml never triggers and nothing is half-published. Recovery is re-running the workflow or tagging by hand. It stalls a release rather than corrupting one.

Neither workflow can be exercised by a pull request. create-release-tag.yml runs on push to main with paths: [VERSION]; releaser.yml on release: published. Neither has a workflow_dispatch, so unlike #6266 there is no dry run.

Part of #6253

Type of change

  • Bug fix
  • New feature
  • Refactoring (no behavior change)
  • Dependency update
  • Documentation
  • Other (describe): CI configuration

Test plan

  • Unit tests (task test)

  • E2E tests (task test-e2e)

  • Linting (task lint-fix)

  • Manual testing (describe below)

  • Traced every use of both tokens. The release app token is the GH_TOKEN for the tag push, the release-existence check and the release creation, and nothing else. The tap token is passed only as HOMEBREW_TAP_GITHUB_TOKEN to GoReleaser.

  • Established the tap's update mechanism from its own commit history and from the GoReleaser config, rather than assuming it.

  • Confirmed the TRIGGERED_BY binding preserves behaviour: the value is in the step's env:, the script still reads $TRIGGERED_BY, and no interpolation of steps.actor.outputs remains in the block.

  • Confirmed the version in that same step is validated against a semver pattern earlier in the job, which is why it is left interpolated rather than converted for symmetry.

  • Both workflows parse as YAML; actionlint reports nothing new — releaser.yml's one finding is the pre-existing extract-release-actor reference tracked in Release failure Slack notice never shows the release triggerer #6265.

  • zizmor: github-app 2 → 0.

Does this introduce a user-facing change?

No.

Special notes for reviewers

  • If you would rather not take the create-release-tag.yml change, it can be dropped and replaced with a justified # zizmor: ignore[github-app], the way Suppress four zizmor findings that cannot be fixed #6262 handled findings that cannot be fixed — a --min-severity=high gate stays reachable either way. The Homebrew change is worth keeping regardless, since its permission is confirmed by months of successful pushes.
  • Best merged just after a release rather than just before, to maximise the window for noticing anything wrong.
  • The remaining template-injection findings in these files are github.repository and the semver-validated version, neither of which can carry a shell metacharacter. Left alone deliberately; recorded in zizmor findings backlog: pins, expressions and checkout credentials #6253.

Generated with Claude Code

actions/create-github-app-token with no permission-* inputs mints a
token carrying every permission the app installation holds. This one
pushes the tag and creates the GitHub Release, both of which are
contents, so that is all it now asks for.

The release-creation step also interpolated the triggering actor into
the shell. That value is read out of a git commit trailer, so unlike the
version — which is checked against a semver pattern before use — nothing
constrains it. It is bound through the step's existing env: block.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ChrisJBurns
ChrisJBurns requested a review from JAORMX as a code owner August 10, 2026 19:54
@github-actions github-actions Bot added the size/XS Extra small PR: < 100 lines changed label Aug 10, 2026
@codecov

codecov Bot commented Aug 10, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 72.77%. Comparing base (094a621) to head (6814493).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6272      +/-   ##
==========================================
+ Coverage   72.70%   72.77%   +0.06%     
==========================================
  Files         742      742              
  Lines       77299    77299              
==========================================
+ Hits        56201    56255      +54     
+ Misses      17136    17064      -72     
- Partials     3962     3980      +18     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

The token was already limited to a single repository by owner and
repositories, but not to a single permission. GoReleaser commits the
formula straight to the tap's default branch — the brews block
configures no pull request — so contents is all it uses.

Confirmed against the tap's history: every release lands as a direct
"Brew formula update" commit, and the app token is the push credential,
so the installation demonstrably holds this permission already.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added size/XS Extra small PR: < 100 lines changed and removed size/XS Extra small PR: < 100 lines changed labels Aug 10, 2026
@ChrisJBurns ChrisJBurns changed the title Scope the release tag token and bind the commit trailer Scope the remaining release app tokens and bind a trailer Aug 10, 2026
@github-actions github-actions Bot added size/XS Extra small PR: < 100 lines changed and removed size/XS Extra small PR: < 100 lines changed labels Aug 10, 2026
@ChrisJBurns
ChrisJBurns merged commit 06483ba into main Aug 10, 2026
46 checks passed
@ChrisJBurns
ChrisJBurns deleted the harden-release-tag-token branch August 10, 2026 20:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/XS Extra small PR: < 100 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants