Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/docker-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
8 changes: 8 additions & 0 deletions scripts/__tests__/docker-image-chain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions scripts/__tests__/release-cut-order.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading