From 31e633e5669abb29858b68c436825ea17b18989e Mon Sep 17 00:00:00 2001 From: forkwright Date: Mon, 17 Aug 2026 10:22:44 -0500 Subject: [PATCH] fix(gate): require a Bot author type alongside the release-please branch shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `github.head_ref` is the branch name chosen by whoever opens the PR, so a branch-shaped waiver is a predicate the checked party controls. Anyone able to open a PR could name a branch `release-please--branches--x` and take the waiver on any content. Four sites, and the two files differ in what they were waiving: - hybrid-gate.yml's `Verify no AI attribution` exited 0 on the branch pattern alone, one line above a dependabot waiver that does check the author. The asymmetry was the tell. This workflow is called by 11 repos. - gate-attestation.yml waived the Gate-Passed trailer verification itself, and its negated forms skipped both the checkout and the verify step. The branch pattern stays, because it is the half that is STABLE: release-please's login varies (github-actions[bot] under GITHUB_TOKEN, a PAT owner after kanon#1092) while its branch shape does not. The error was treating stable and unforgeable as the same property. `user.type` is both — GitHub derives it from the resolved account and a pusher cannot set it. Neither condition alone is now sufficient. gate-attestation.yml already carried a WHY calling head_ref attacker-controlled text, as the reason for env-var indirection against shell injection. The same property disqualifies it as a trust predicate; only one of the two conclusions had been drawn. No effect on current releases: release-please authors as github-actions[bot], type Bot, so the waiver still fires. When kanon#1092 moves it to a PAT-owned account the waiver stops firing and that PR goes red rather than silently exempt — the correct direction to fail, and a one-line allowlist addition then. --- .github/workflows/gate-attestation.yml | 23 +++++++++++------ .github/workflows/hybrid-gate.yml | 35 +++++++++++++++++++------- 2 files changed, 41 insertions(+), 17 deletions(-) diff --git a/.github/workflows/gate-attestation.yml b/.github/workflows/gate-attestation.yml index a1bbd80..d8538a1 100644 --- a/.github/workflows/gate-attestation.yml +++ b/.github/workflows/gate-attestation.yml @@ -22,13 +22,20 @@ jobs: steps: # WHY: waiver keys off the PR author login, not github.actor — actor flips # to a maintainer login on "Re-run failed jobs", re-arming the check on bot PRs. - # Release-please is ALSO waived branch-shaped: under GITHUB_TOKEN its PRs - # are authored as github-actions[bot] (and as the PAT owner once a PAT is - # wired) — the author varies, the branch pattern does not. Release-PR - # content is generated version bumps; its verification is the live - # deny/audit/osv checks. + # + # WARNING: the release-please branch pattern is paired with a Bot author + # type and is NEVER sufficient alone. `github.head_ref` is chosen by + # whoever opens the PR, so a branch-shaped waiver is a predicate the + # checked party controls: a branch named `release-please--branches--x` + # would otherwise skip the Gate-Passed trailer verification entirely on + # any content. The env-var indirection below already treats head_ref as + # attacker-controlled for shell-injection purposes — the same property + # disqualifies it as a trust predicate. The pattern stays because + # release-please's LOGIN varies (github-actions[bot] under GITHUB_TOKEN, + # a PAT owner after kanon#1092) while its branch shape does not; + # `user.type` is the half a pusher cannot forge. - name: Pass trusted automation PRs - if: ${{ github.event.pull_request.user.login == 'dependabot[bot]' || github.event.pull_request.user.login == 'release-please[bot]' || startsWith(github.head_ref, 'release-please--branches--') }} + if: ${{ github.event.pull_request.user.login == 'dependabot[bot]' || github.event.pull_request.user.login == 'release-please[bot]' || (startsWith(github.head_ref, 'release-please--branches--') && github.event.pull_request.user.type == 'Bot') }} env: # WHY: head_ref is attacker-controlled text — env-var indirection keeps # it out of shell interpolation (actionlint expression rule). @@ -43,14 +50,14 @@ jobs: # same status GitHub uses for a superseded run, so a slow clone is # indistinguishable from a supersede and presents as a gate failure. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - if: ${{ github.event.pull_request.user.login != 'dependabot[bot]' && github.event.pull_request.user.login != 'release-please[bot]' && !startsWith(github.head_ref, 'release-please--branches--') }} + if: ${{ github.event.pull_request.user.login != 'dependabot[bot]' && github.event.pull_request.user.login != 'release-please[bot]' && !(startsWith(github.head_ref, 'release-please--branches--') && github.event.pull_request.user.type == 'Bot') }} with: fetch-depth: 0 filter: blob:none persist-credentials: false - name: Verify Gate-Passed trailer - if: ${{ github.event.pull_request.user.login != 'dependabot[bot]' && github.event.pull_request.user.login != 'release-please[bot]' && !startsWith(github.head_ref, 'release-please--branches--') }} + if: ${{ github.event.pull_request.user.login != 'dependabot[bot]' && github.event.pull_request.user.login != 'release-please[bot]' && !(startsWith(github.head_ref, 'release-please--branches--') && github.event.pull_request.user.type == 'Bot') }} # WHY (#2399): bind to the PR TIP, not "any PR commit". The old loop # passed if any ancestor carried a Gate-Passed trailer, so an un-stamped # tip could ride an earlier commit's attestation — a false green on the diff --git a/.github/workflows/hybrid-gate.yml b/.github/workflows/hybrid-gate.yml index 405f7ea..35bcd4c 100644 --- a/.github/workflows/hybrid-gate.yml +++ b/.github/workflows/hybrid-gate.yml @@ -446,15 +446,29 @@ jobs: - name: Verify no AI attribution # WHY: lifted verbatim from kanon's own gate-attestation.yml (and - # harmonia's inline copy) — one implementation, centralized. Waivers - # match check-trailer's: PR-author-shaped for dependabot, branch-shaped - # for release-please (its author varies: github-actions[bot] under - # GITHUB_TOKEN today, a future PAT owner post kanon#1092 — the branch - # pattern is what stays stable). Runs regardless of docs_only_exemption - # — a docs-only PR still carries its own title/body/commits and must - # not smuggle an attribution marker through the build exemption. + # harmonia's inline copy) — one implementation, centralized. Runs + # regardless of docs_only_exemption — a docs-only PR still carries its + # own title/body/commits and must not smuggle an attribution marker + # through the build exemption. + # + # WARNING: the release-please waiver requires BOTH the branch shape and + # a Bot author type, and neither alone is sufficient. `github.head_ref` + # is the branch name chosen by whoever opens the PR, so a branch-shaped + # waiver is a predicate the checked party controls — anyone could name a + # branch `release-please--branches--x` and exempt any content. The + # branch pattern still has to be part of it, because release-please's + # LOGIN varies (github-actions[bot] under GITHUB_TOKEN today, a PAT + # owner after kanon#1092) while its branch shape does not. `user.type` + # supplies the half the branch cannot: GitHub derives it from the + # resolved account and a pusher cannot set it. + # + # NOTE: when kanon#1092 moves release-please to a PAT-owned (human) + # account, `user.type` becomes "User" and this waiver stops firing — the + # release PR goes red rather than silently exempt, which is the correct + # direction to fail. Add that login to an explicit allowlist then. env: PR_AUTHOR: ${{ github.event.pull_request.user.login }} + PR_AUTHOR_TYPE: ${{ github.event.pull_request.user.type }} PR_BODY: ${{ github.event.pull_request.body }} PR_HEAD_REF: ${{ github.head_ref }} PR_TITLE: ${{ github.event.pull_request.title }} @@ -463,8 +477,11 @@ jobs: run: | case "$PR_HEAD_REF" in release-please--branches--*) - echo "Release-please branch ($PR_HEAD_REF): AI attribution check exempt." - exit 0 + if [ "$PR_AUTHOR_TYPE" = "Bot" ]; then + echo "Release-please branch from bot author ($PR_AUTHOR): AI attribution check exempt." + exit 0 + fi + echo "Branch $PR_HEAD_REF has the release-please shape but author $PR_AUTHOR is type ${PR_AUTHOR_TYPE:-unknown}, not Bot — not waiving." ;; esac if [ "$PR_AUTHOR" = "dependabot[bot]" ]; then