From c32f40e4e5e364edb48b0d492c6de208b1eadf2f Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Wed, 30 Sep 2026 14:32:58 -0700 Subject: [PATCH] ci: declare the Releases permission by key wherever the job token touches it The 2026-09-30 desktop cut answered POST /releases with 403 'Resource not accessible by integration' although the job's granted scopes printed Contents: write, while the same token pushed tags minutes before and the cli cut had created its release an hour earlier. That is the signature of GitHub's per-key re-mapping of GITHUB_TOKEN (the fine-grained model, rolled out org by org server-side): release operations stop riding contents: write and need releases: write. The repo declared only contents everywhere, which is right for the classic map and invisible to the new one, and it is exactly what softprops' README cannot warn about because the README still says contents: write. Declared by key at all four token-to-Releases touchpoints: both publish jobs (write), build-desktop's sidecar fetch (read), and docker-image's gh release download (read, which narrows its workflow block only). Redundant under either classic mapping, load-bearing under the other, and the next cut verifies empirically: release-cut-order.test.ts pins both publish gates and build-desktop, docker-image-chain.test.ts pins the rail. --- .github/workflows/docker-image.yml | 4 ++++ .github/workflows/release.yml | 18 ++++++++++++++++++ scripts/__tests__/docker-image-chain.test.ts | 8 ++++++++ scripts/__tests__/release-cut-order.test.ts | 10 ++++++++++ 4 files changed, 40 insertions(+) diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index bc299fa14..576be4c7c 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -36,6 +36,10 @@ on: permissions: contents: read packages: write + # `gh release download` is a Releases-API act, and reading releases is + # its own permission under GitHub's per-key GITHUB_TOKEN mapping (the + # 2026-09-30 publish 403, documented at release.yml's publish-cli). + releases: read concurrency: group: docker-image diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 245416667..e779de8ca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1031,6 +1031,15 @@ jobs: timeout-minutes: 15 permissions: contents: write + # RELEASES IS ITS OWN PERMISSION under GitHub's per-key re-mapping of + # GITHUB_TOKEN (the fine-grained model, rolling out org by org); the + # classic map where release operations rode contents: write is what + # softprops' README still describes. The 2026-09-30 desktop cut proved + # the difference is live here: POST /releases answered 403 "Resource + # not accessible by integration" on a token whose granted scopes + # printed Contents: write, while the same token pushed tags minutes + # earlier. Declaring it names the permission under either mapping. + releases: write strategy: fail-fast: false matrix: @@ -1210,6 +1219,13 @@ jobs: needs.plan.result == 'success' && needs.plan.outputs.desktop_matrix != '[]' && (needs.publish-cli.result == 'success' || needs.publish-cli.result == 'skipped') + # stageSidecar FETCHES the CLI release's assets with the job token, and + # reading releases is the Releases permission under GitHub's per-key + # GITHUB_TOKEN mapping (see publish-cli). Narrowed from the workflow + # default because this job pushes nothing itself. + permissions: + contents: read + releases: read # One sign+notarize round trip per darwin artifact again — the mcp # companion is retired (the server binary serves its own `mcp` subcommand). # A desktop shard additionally compiles ~500 Rust crates (its sidecar is @@ -1653,6 +1669,8 @@ jobs: timeout-minutes: 15 permissions: contents: write + # The cut that 403'd; see publish-cli for why this is its own key. + releases: write strategy: fail-fast: false matrix: diff --git a/scripts/__tests__/docker-image-chain.test.ts b/scripts/__tests__/docker-image-chain.test.ts index 32fb0fa2e..90798f9ff 100644 --- a/scripts/__tests__/docker-image-chain.test.ts +++ b/scripts/__tests__/docker-image-chain.test.ts @@ -16,6 +16,14 @@ import { join } from "node:path"; const WORKFLOW = readFileSync(join(import.meta.dir, "../../.github/workflows/docker-image.yml"), "utf8"); describe("docker-image.yml chain", () => { + test("the release-asset download declares the Releases permission", () => { + // Same per-key GITHUB_TOKEN mapping as release.yml's publish jobs (the + // 2026-09-30 403, documented at publish-cli): gh release download is a + // Releases-API act and must not assume contents covers it. + const m = /^permissions:\n(?:[ \t]+\S.*\n)+/m.exec(WORKFLOW); + if (m === null) throw new Error("docker-image.yml: the permissions block is gone (reshaped?)"); + expect(m[0]).toContain("releases: read"); + }); test("triggered by workflow_run on Release, never by the dead on: release", () => { expect(WORKFLOW).toMatch(/workflow_run:\s*\n\s*workflows: \["Release"\]/); // An `on: release` block would sit at the triggers' indentation level. diff --git a/scripts/__tests__/release-cut-order.test.ts b/scripts/__tests__/release-cut-order.test.ts index 4347c5711..f0a03e892 100644 --- a/scripts/__tests__/release-cut-order.test.ts +++ b/scripts/__tests__/release-cut-order.test.ts @@ -86,6 +86,16 @@ describe("cut phases: CLI first, desktop after", () => { // needs no such clause: every conjunct reads false on a dead plan's // empty outputs, so its gate skips on its own.) expect(header("build-desktop")).toContain("needs.plan.result == 'success'"); + // The token-permission lesson of the 2026-09-30 403: under GitHub's + // per-key GITHUB_TOKEN mapping, release operations are the RELEASES + // permission, not contents. Every job that touches the Releases API + // must say so by key, or a server-side mapping change 403s it with the + // scope headers still printing Contents: write (which is exactly how + // that cut failed). + expect(header("publish-cli")).toContain("releases: write"); + expect(header("publish-desktop")).toContain("releases: write"); + // And the sidecar fetch reads release assets with the job token. + expect(header("build-desktop")).toContain("releases: read"); // And no header may use always(), which would displace the gate the other // way — papering over a FAILED or CANCELLED chain. (The one always() in // the build bodies is a STEP guard, un-rooting the container workspace,