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,