diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6ff0beb..a370b1c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -197,21 +197,24 @@ jobs: if: github.event_name == 'push' runs-on: ubuntu-24.04 timeout-minutes: 10 + # GitHub hides drafts from tokens without push access. The commands below + # remain GET-only; expose this token only to the two API-reading steps. permissions: - contents: read - env: - GH_TOKEN: ${{ github.token }} + contents: write steps: # The tagged revision — verify exactly what was packaged, not a moving default. - uses: actions/checkout@v7 with: ref: ${{ github.sha }} + persist-credentials: false - uses: actions/setup-node@v7 with: node-version: "26" cache: npm - run: npm ci - name: List every release, drafts included + env: + GH_TOKEN: ${{ github.token }} # --slurp wraps the paginated pages into one JSON document # ([[release,...],[release,...]]); the validator flattens it. run: gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/releases?per_page=100" > "$RUNNER_TEMP/releases.json" @@ -219,6 +222,8 @@ jobs: # Machine JSON on stdout (release id + asset ids); warnings on stderr. run: npx tsx scripts/verify-release.ts select "$RUNNER_TEMP/releases.json" --tag "$GITHUB_REF_NAME" > "$RUNNER_TEMP/draft.json" - name: Download the three update feeds + env: + GH_TOKEN: ${{ github.token }} run: | set -euo pipefail mkdir -p "$RUNNER_TEMP/metadata" diff --git a/docs/desktop-release.md b/docs/desktop-release.md index b758acb..b061bf2 100644 --- a/docs/desktop-release.md +++ b/docs/desktop-release.md @@ -53,7 +53,7 @@ The three jobs upload distinct updater feeds: `latest-mac.yml`, `latest.yml`, `l ### The verification job -`verify` runs only on tag pushes (`needs: package`, so after **all three** packaging legs; manual rehearsals upload nothing and skip it). With a read-only `contents: read` token it checks out the tagged revision and runs `scripts/verify-release.ts`, which: +`verify` runs only on tag pushes (`needs: package`, so after **all three** packaging legs; manual rehearsals upload nothing and skip it). It checks out the tagged revision without persisting credentials and runs `scripts/verify-release.ts`. GitHub requires push access to list **draft** releases, so this job needs `contents: write` even though every API operation is GET-only. The token is exposed only to the release-list and feed-download steps, not dependency installation or the offline validator. The validator: - finds the draft using the same tag matching as the draft job and electron-builder's publisher — `tag_name` equals `v` **or** the bare ``; zero matches, two matches, or an already-published match fail the run, and a draft matched only via the unprefixed form is flagged with a warning (a later run could create a second draft under the `v`-tag and split the assets — rename before publishing); - fails unless the release holds **exactly** these 11 assets — nothing missing, nothing extra (a stale name from an older run is an error), nothing zero-size, nothing still in a non-`uploaded` state: diff --git a/tests/verify-release.test.ts b/tests/verify-release.test.ts index 899ca56..ad60656 100644 --- a/tests/verify-release.test.ts +++ b/tests/verify-release.test.ts @@ -312,17 +312,38 @@ describe("release gate — workflow wiring", () => { const workflow = yaml.load(fs.readFileSync(path.join(repoRoot, ".github", "workflows", "release.yml"), "utf8")); const verify = workflow.jobs.verify; - it("runs read-only after ALL packaging jobs, only on real tag pushes", () => { + it("can see drafts after ALL packaging jobs, only on real tag pushes", () => { assert.ok(verify); assert.equal(verify.needs, "package"); assert.equal(verify.if, "github.event_name == 'push'"); - assert.deepEqual(verify.permissions, { contents: "read" }); - assert.equal(verify.env.GH_TOKEN, "${{ github.token }}"); + // GitHub's release-list API hides drafts from read-only tokens. + assert.deepEqual(verify.permissions, { contents: "write" }); + assert.equal(verify.env?.GH_TOKEN, undefined); + }); + + it("exposes the elevated token only to the two API-reading steps", () => { + const readers = ["List every release, drafts included", "Download the three update feeds"]; + for (const step of verify.steps) { + if (readers.includes(step.name)) assert.equal(step.env?.GH_TOKEN, "${{ github.token }}"); + else assert.equal(step.env?.GH_TOKEN, undefined, String(step.name ?? step.uses)); + } + assert.equal(verify.steps.filter((step: any) => step.env?.GH_TOKEN).length, 2); + }); + + it("uses only implicit GET requests, never release-mutating commands", () => { + const calls: string[] = verify.steps.flatMap((step: any) => + (step.run ?? "").split("\n").filter((line: string) => /^\s*gh\s/.test(line)), + ); + assert.equal(calls.length, 2); + for (const call of calls) { + assert.match(call, /^\s*gh api /); + assert.doesNotMatch(call, /(?:\s|^)(?:--(?:method|input|field|raw-field)(?:[=\s]|$)|-[XfF])/); + } }); it("checks out the tagged revision and runs select + verify", () => { const checkout = verify.steps.find((step: any) => step.uses?.startsWith("actions/checkout")); - assert.deepEqual(checkout.with, { ref: "${{ github.sha }}" }); + assert.deepEqual(checkout.with, { ref: "${{ github.sha }}", "persist-credentials": false }); const select = verify.steps.find((step: any) => step.run?.includes("verify-release.ts select")); const final = verify.steps.find((step: any) => step.run?.includes("verify-release.ts verify")); assert.ok(select && final);