From 0439625a490eb91d5277903d36bb863a14a4af5d Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Wed, 3 Jun 2026 16:05:42 +0800 Subject: [PATCH 1/9] feat(release): auto-publish the prod release once all targets land Today the GitHub release stays a draft after the build: nothing flips it to published, so the R2 mirror (which triggers on release:published) never fires and an operator has to publish by hand. Automate that final step. - publish-when-complete.ts: at the tail of every prod finalize/full build, reuse verifyReleasePayload (new allowDraft option) to confirm the draft is complete, then `gh release edit --draft=false` pinned to the build commit and dispatch mirror-release-to-r2.yml. Incomplete drafts are a no-op, so the last target to finish is the one that publishes. - verify-release.ts: add allowDraft to verifyReleasePayload; default false keeps the mirror's verifier failing on drafts as before. - build.yml: run the script after "Finalize updater metadata" (gated to channel==prod) and grant actions: write so it can dispatch the mirror. A GITHUB_TOKEN publish does not fire release:published, so the mirror is dispatched explicitly rather than via the webhook. Cannot be end-to-end tested without a real release; covered by unit tests for the pure decision policy and the allowDraft branch. --- .github/workflows/build.yml | 20 +- .../scripts/publish-when-complete.test.ts | 75 +++++++ .../scripts/publish-when-complete.ts | 185 ++++++++++++++++++ .../scripts/verify-release.test.ts | 26 +++ .../scripts/verify-release.ts | 4 +- 5 files changed, 307 insertions(+), 3 deletions(-) create mode 100644 packages/desktop-electron/scripts/publish-when-complete.test.ts create mode 100644 packages/desktop-electron/scripts/publish-when-complete.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 69a9c6b17..cd9c5e3e5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -187,7 +187,10 @@ jobs: runs-on: ${{ matrix.host }} permissions: - actions: read + # actions: write lets the prod publish step dispatch mirror-release-to-r2.yml + # via `gh workflow run` (workflow_dispatch is the GITHUB_TOKEN anti-recursion + # exception; a GITHUB_TOKEN-driven publish does NOT fire release:published). + actions: write contents: write steps: @@ -918,6 +921,21 @@ jobs: LATEST_YML_DIR: ${{ runner.temp }}/latest-yml OPENCODE_VERSION: ${{ steps.package_version.outputs.version }} + # Runs at the tail of every prod finalize/full target, after its updater + # metadata lands in the draft. Publishes (and pins the tag to the build + # commit) only when ALL targets are present, then dispatches the R2 mirror; + # incomplete drafts are a no-op. The LAST target to finish flips the draft. + - name: Publish release when all targets are complete + if: ${{ inputs.channel == 'prod' && ((runner.os == 'macOS' && (inputs.phase == 'finalize' || inputs.phase == 'full') && inputs.arch == matrix.arch_label) || (runner.os == 'Windows' && inputs.phase == 'full')) }} + run: bun ./scripts/publish-when-complete.ts + working-directory: packages/desktop-electron + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + RELEASE_TAG: v${{ steps.package_version.outputs.version }} + BUILD_SHA: ${{ inputs.source_sha || github.sha }} + MIRROR_REF: dev + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a if: ${{ runner.os == 'macOS' && (inputs.phase == 'finalize' || inputs.phase == 'full') && inputs.arch == matrix.arch_label }} with: diff --git a/packages/desktop-electron/scripts/publish-when-complete.test.ts b/packages/desktop-electron/scripts/publish-when-complete.test.ts new file mode 100644 index 000000000..43f8c6f7c --- /dev/null +++ b/packages/desktop-electron/scripts/publish-when-complete.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from "bun:test" + +import { decidePublishAction } from "./publish-when-complete" +import type { GithubRelease } from "./verify-release" + +const BUILD_SHA = "1111111111111111111111111111111111111111" + +// A complete release payload: every installer + updater sidecar + the two +// channel files present, matching releaseAssetNames("2026.6.1"). +const completeRelease: GithubRelease = { + tag_name: "v2026.6.1", + draft: true, + prerelease: false, + assets: [ + "pawwork-mac-arm64-2026.6.1.dmg", + "pawwork-mac-arm64-2026.6.1.zip", + "pawwork-mac-arm64-2026.6.1.zip.blockmap", + "pawwork-mac-x64-2026.6.1.dmg", + "pawwork-mac-x64-2026.6.1.zip", + "pawwork-mac-x64-2026.6.1.zip.blockmap", + "pawwork-win-x64-2026.6.1.exe", + "pawwork-win-x64-2026.6.1.exe.blockmap", + "latest.yml", + "latest-mac.yml", + ].map((name) => ({ name, browser_download_url: `https://example.com/${name}` })), +} + +const latestYml = "files:\n - url: pawwork-win-x64-2026.6.1.exe\n" +const latestMacYml = "files:\n - url: pawwork-mac-arm64-2026.6.1.zip\n - url: pawwork-mac-x64-2026.6.1.zip\n" + +const decide = (overrides: Partial[0]> = {}) => + decidePublishAction({ release: completeRelease, latestYml, latestMacYml, buildSha: BUILD_SHA, ...overrides }) + +describe("decidePublishAction", () => { + test("publishes a complete draft and pins it to the build commit", () => { + expect(decide().kind).toBe("publish") + }) + + test("waits when a target's assets have not landed yet", () => { + const partial: GithubRelease = { + ...completeRelease, + assets: completeRelease.assets.filter((asset) => asset.name !== "pawwork-win-x64-2026.6.1.exe"), + } + const decision = decide({ release: partial }) + expect(decision.kind).toBe("wait") + expect(decision.reason).toContain("pawwork-win-x64-2026.6.1.exe") + }) + + test("waits when the updater metadata asset is not uploaded yet", () => { + // latest.yml exists as an asset but has not been fetched (a missing target); + // the metadata cross-check must fail, keeping us in wait rather than publish. + const decision = decide({ latestYml: undefined }) + expect(decision.kind).toBe("wait") + }) + + test("only mirrors when the release is already published (no re-publish)", () => { + expect(decide({ release: { ...completeRelease, draft: false } }).kind).toBe("mirror-only") + }) + + test("fails loudly on a prerelease instead of waiting forever", () => { + const decision = decide({ release: { ...completeRelease, prerelease: true } }) + expect(decision.kind).toBe("fail") + expect(decision.reason).toContain("prerelease") + }) + + test("refuses to publish when the existing tag points at another commit", () => { + const decision = decide({ existingTagSha: "2222222222222222222222222222222222222222" }) + expect(decision.kind).toBe("fail") + expect(decision.reason).toContain("refusing to publish mismatched sources") + }) + + test("publishes when the tag already exists at the same build commit", () => { + expect(decide({ existingTagSha: BUILD_SHA }).kind).toBe("publish") + }) +}) diff --git a/packages/desktop-electron/scripts/publish-when-complete.ts b/packages/desktop-electron/scripts/publish-when-complete.ts new file mode 100644 index 000000000..d48c2eda8 --- /dev/null +++ b/packages/desktop-electron/scripts/publish-when-complete.ts @@ -0,0 +1,185 @@ +// Auto-publish the prod release once every target's assets and updater metadata +// have landed in the draft, then dispatch the R2 mirror. Runs at the tail of +// each finalize/full build, so the LAST target to complete flips the draft into +// a published release. Fail-safe: missing targets leave the draft untouched; +// only a complete, same-commit release is ever published. +// +// Why a dedicated script (not the by-tag verifier): a draft release is NOT +// reachable via GET /releases/tags/{tag} (it 404s), so we look it up through the +// list endpoint; and its assets must be downloaded via the asset API URL with an +// `application/octet-stream` Accept header, not browser_download_url. + +import { normalizeTag, verifyReleasePayload, type GithubRelease } from "./verify-release" + +const GITHUB_API = "https://api.github.com" +const FETCH_TIMEOUT_MS = 30_000 + +type ApiAsset = { name: string; url: string; browser_download_url: string } +type ApiRelease = GithubRelease & { id: number; assets: ApiAsset[] } + +export type PublishDecision = + | { kind: "publish"; reason: string } + | { kind: "mirror-only"; reason: string } + | { kind: "wait"; reason: string } + | { kind: "fail"; reason: string } + +// Pure policy: decide what to do from the current release state. Kept free of +// I/O so it is unit-testable without GitHub. +export function decidePublishAction(args: { + release: GithubRelease + latestYml?: string + latestMacYml?: string + buildSha: string + existingTagSha?: string +}): PublishDecision { + const { release, latestYml, latestMacYml, buildSha, existingTagSha } = args + + // A prerelease is a bad state for this pipeline: fail loudly instead of + // waiting forever for a "completion" that publishing would never reach. + if (release.prerelease) { + return { kind: "fail", reason: `release ${release.tag_name} is marked as a prerelease` } + } + + // Completeness reuses the exact verifier logic; allowDraft so the draft state + // itself is not counted as a failure here. Any failure now means a target's + // assets/updater metadata are not in yet -> keep waiting (no-op, exit 0). + const failures = verifyReleasePayload({ release, latestYml, latestMacYml }, { allowDraft: true }) + if (failures.length > 0) { + return { kind: "wait", reason: `release incomplete, waiting for remaining targets: ${failures.join("; ")}` } + } + + // Same-source gate: never let the published tag point at a commit other than + // the one these assets were built from. If the tag already exists pointing + // elsewhere, the targets were not built from a single commit -> refuse. + if (existingTagSha && existingTagSha !== buildSha) { + return { + kind: "fail", + reason: `tag ${release.tag_name} points at ${existingTagSha}, not the build commit ${buildSha}; refusing to publish mismatched sources`, + } + } + + if (release.draft) { + return { + kind: "publish", + reason: "all release targets present; publishing and pinning the tag to the build commit", + } + } + + // Already published by an earlier run. GITHUB_TOKEN publishes do not fire the + // release:published webhook, and an earlier mirror dispatch may have failed, + // so re-dispatch the (idempotent, per-tag serialized) mirror to avoid a gap. + return { kind: "mirror-only", reason: "release already published; ensuring the mirror is dispatched" } +} + +function requireEnv(name: string): string { + const value = process.env[name] + if (!value) throw new Error(`${name} is required`) + return value +} + +function githubHeaders(accept: string) { + const headers = new Headers({ Accept: accept, "X-GitHub-Api-Version": "2022-11-28" }) + const token = process.env.GH_TOKEN + if (token) headers.set("Authorization", `Bearer ${token}`) + return headers +} + +async function ghFetch(url: string, accept: string) { + try { + return await fetch(url, { headers: githubHeaders(accept), signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }) + } catch (error) { + throw new Error(`request to ${url} failed: ${error instanceof Error ? error.message : String(error)}`) + } +} + +async function fetchReleases(repo: string): Promise { + const res = await ghFetch(`${GITHUB_API}/repos/${repo}/releases?per_page=100`, "application/vnd.github+json") + if (!res.ok) throw new Error(`failed to list releases: ${res.status} ${res.statusText}`) + return (await res.json()) as ApiRelease[] +} + +// Returns undefined when the asset is not in the release yet (a missing target, +// handled as "wait"); throws when the asset exists but cannot be downloaded (a +// tooling/network error that must fail the job rather than silently wait). +async function fetchAssetText(release: ApiRelease, name: string): Promise { + const asset = release.assets.find((entry) => entry.name === name) + if (!asset) return undefined + const res = await ghFetch(asset.url, "application/octet-stream") + if (!res.ok) throw new Error(`failed to download ${name}: ${res.status} ${res.statusText}`) + return res.text() +} + +async function fetchTagSha(repo: string, tag: string): Promise { + const res = await ghFetch( + `${GITHUB_API}/repos/${repo}/git/refs/tags/${encodeURIComponent(tag)}`, + "application/vnd.github+json", + ) + if (res.status === 404) return undefined + if (!res.ok) throw new Error(`failed to read tag ${tag}: ${res.status} ${res.statusText}`) + const body = (await res.json()) as { object?: { sha?: string } } + return body.object?.sha +} + +async function gh(args: string[]) { + const proc = Bun.spawn(["gh", ...args], { stdout: "inherit", stderr: "inherit" }) + const code = await proc.exited + if (code !== 0) throw new Error(`gh ${args.join(" ")} exited ${code}`) +} + +async function dispatchMirror(repo: string, tag: string, ref: string) { + await gh(["workflow", "run", "mirror-release-to-r2.yml", "--repo", repo, "--ref", ref, "-f", `tag=${tag}`]) +} + +async function main() { + const repo = requireEnv("GH_REPO") + const tag = normalizeTag(requireEnv("RELEASE_TAG")) + const buildSha = requireEnv("BUILD_SHA") + const mirrorRef = process.env.MIRROR_REF ?? "dev" + + const releases = await fetchReleases(repo) + const release = releases.find((entry) => entry.tag_name === tag) + if (!release) { + console.error(`publish-when-complete: no release found for ${tag} among ${releases.length} releases`) + process.exit(1) + } + + const latestYml = await fetchAssetText(release, "latest.yml") + const latestMacYml = await fetchAssetText(release, "latest-mac.yml") + const existingTagSha = await fetchTagSha(repo, tag) + + const decision = decidePublishAction({ release, latestYml, latestMacYml, buildSha, existingTagSha }) + console.log(`publish-when-complete: ${decision.reason}`) + + switch (decision.kind) { + case "fail": + process.exit(1) + return + case "wait": + return + case "publish": + await gh([ + "release", + "edit", + tag, + "--repo", + repo, + "--target", + buildSha, + "--draft=false", + "--latest", + "--prerelease=false", + ]) + await dispatchMirror(repo, tag, mirrorRef) + return + case "mirror-only": + await dispatchMirror(repo, tag, mirrorRef) + return + } +} + +if (import.meta.main) { + main().catch((error) => { + console.error(`publish-when-complete failed: ${error instanceof Error ? error.message : String(error)}`) + process.exit(1) + }) +} diff --git a/packages/desktop-electron/scripts/verify-release.test.ts b/packages/desktop-electron/scripts/verify-release.test.ts index e3a5b7a2f..73215d3f8 100644 --- a/packages/desktop-electron/scripts/verify-release.test.ts +++ b/packages/desktop-electron/scripts/verify-release.test.ts @@ -230,6 +230,32 @@ path: pawwork-win-x64-2026.4.28.exe ).toContain("Release v2026.4.28 is still a draft") }) + test("allowDraft suppresses the draft failure but keeps every other check", () => { + const latestYml = "files:\n - url: pawwork-win-x64-2026.4.28.exe\n" + const latestMacYml = "files:\n - url: pawwork-mac-arm64-2026.4.28.zip\n - url: pawwork-mac-x64-2026.4.28.zip\n" + + // A complete draft is fully accepted when drafts are allowed. + expect( + verifyReleasePayload({ release: { ...baseRelease, draft: true }, latestYml, latestMacYml }, { allowDraft: true }), + ).toEqual([]) + + // allowDraft does not loosen anything else: missing assets still fail. + const failures = verifyReleasePayload( + { + release: { + ...baseRelease, + draft: true, + assets: baseRelease.assets.filter((asset) => asset.name !== "pawwork-mac-arm64-2026.4.28.dmg"), + }, + latestYml, + latestMacYml, + }, + { allowDraft: true }, + ) + expect(failures).toContain("Missing release asset: pawwork-mac-arm64-2026.4.28.dmg") + expect(failures).not.toContain("Release v2026.4.28 is still a draft") + }) + test("reports prerelease releases", () => { expect( verifyReleasePayload({ diff --git a/packages/desktop-electron/scripts/verify-release.ts b/packages/desktop-electron/scripts/verify-release.ts index 846f94be9..3850fb226 100644 --- a/packages/desktop-electron/scripts/verify-release.ts +++ b/packages/desktop-electron/scripts/verify-release.ts @@ -192,7 +192,7 @@ export function verifyStartupLog(source: string, expectedTag: string) { return failures } -export function verifyReleasePayload(input: VerificationInput) { +export function verifyReleasePayload(input: VerificationInput, options?: { allowDraft?: boolean }) { const failures: string[] = [] const assetNames = new Set(input.release.assets.map((asset) => asset.name)) let version: string | undefined @@ -202,7 +202,7 @@ export function verifyReleasePayload(input: VerificationInput) { failures.push(error instanceof Error ? error.message : String(error)) } - if (input.release.draft) failures.push(`Release ${input.release.tag_name} is still a draft`) + if (input.release.draft && !options?.allowDraft) failures.push(`Release ${input.release.tag_name} is still a draft`) if (input.release.prerelease) failures.push(`Release ${input.release.tag_name} is marked as a prerelease`) const latestUrls = input.latestYml === undefined ? [] : parseUpdaterFileUrls(input.latestYml) From c4f8e14e17b13d93220e4509abf40073bf15ec4d Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Wed, 3 Jun 2026 16:20:41 +0800 Subject: [PATCH 2/9] fix(release): guard auto-publish against mixed-source and stale reads Address Codex review of the auto-publish step: - Provenance ledger: use the draft's target_commitish as a single-source record. electron-builder creates a fresh draft pointing at the branch and never rewrites an existing draft (verified in electron-publish 26.8.1), so the first target pins it to its build commit and any later target built from a different commit fails closed instead of publishing a release whose mac and win installers came from different commits. Replaces the git-tag-ref check, which only existed after publish. - Poll on "wait": re-read the release a few times before no-oping, so the last target to finish absorbs GitHub's read-after-write lag on the assets it just uploaded rather than leaving the release stuck as a draft. --- .../scripts/publish-when-complete.test.ts | 49 +++++- .../scripts/publish-when-complete.ts | 165 +++++++++++------- .../scripts/verify-release.ts | 4 + 3 files changed, 149 insertions(+), 69 deletions(-) diff --git a/packages/desktop-electron/scripts/publish-when-complete.test.ts b/packages/desktop-electron/scripts/publish-when-complete.test.ts index 43f8c6f7c..61472011c 100644 --- a/packages/desktop-electron/scripts/publish-when-complete.test.ts +++ b/packages/desktop-electron/scripts/publish-when-complete.test.ts @@ -1,9 +1,10 @@ import { describe, expect, test } from "bun:test" -import { decidePublishAction } from "./publish-when-complete" +import { decidePublishAction, recordedBuildSha } from "./publish-when-complete" import type { GithubRelease } from "./verify-release" const BUILD_SHA = "1111111111111111111111111111111111111111" +const OTHER_SHA = "2222222222222222222222222222222222222222" // A complete release payload: every installer + updater sidecar + the two // channel files present, matching releaseAssetNames("2026.6.1"). @@ -11,6 +12,7 @@ const completeRelease: GithubRelease = { tag_name: "v2026.6.1", draft: true, prerelease: false, + target_commitish: BUILD_SHA, assets: [ "pawwork-mac-arm64-2026.6.1.dmg", "pawwork-mac-arm64-2026.6.1.zip", @@ -29,13 +31,24 @@ const latestYml = "files:\n - url: pawwork-win-x64-2026.6.1.exe\n" const latestMacYml = "files:\n - url: pawwork-mac-arm64-2026.6.1.zip\n - url: pawwork-mac-x64-2026.6.1.zip\n" const decide = (overrides: Partial[0]> = {}) => - decidePublishAction({ release: completeRelease, latestYml, latestMacYml, buildSha: BUILD_SHA, ...overrides }) + decidePublishAction({ + release: completeRelease, + latestYml, + latestMacYml, + buildSha: BUILD_SHA, + recordedSha: BUILD_SHA, + ...overrides, + }) describe("decidePublishAction", () => { - test("publishes a complete draft and pins it to the build commit", () => { + test("publishes a complete, single-source draft and pins it to the build commit", () => { expect(decide().kind).toBe("publish") }) + test("publishes when the draft is not yet claimed (first target)", () => { + expect(decide({ recordedSha: undefined }).kind).toBe("publish") + }) + test("waits when a target's assets have not landed yet", () => { const partial: GithubRelease = { ...completeRelease, @@ -63,13 +76,33 @@ describe("decidePublishAction", () => { expect(decision.reason).toContain("prerelease") }) - test("refuses to publish when the existing tag points at another commit", () => { - const decision = decide({ existingTagSha: "2222222222222222222222222222222222222222" }) + test("refuses to publish when the draft was claimed by a different build commit", () => { + const decision = decide({ recordedSha: OTHER_SHA }) expect(decision.kind).toBe("fail") - expect(decision.reason).toContain("refusing to publish mismatched sources") + expect(decision.reason).toContain("mixed-source release") + }) + + test("provenance mismatch beats completeness: fails even on an incomplete draft", () => { + const partial: GithubRelease = { + ...completeRelease, + assets: completeRelease.assets.filter((asset) => asset.name !== "pawwork-win-x64-2026.6.1.exe"), + } + // A divergent source is fatal regardless of how complete the draft is, so we + // never reach the wait branch. + expect(decide({ release: partial, recordedSha: OTHER_SHA }).kind).toBe("fail") + }) +}) + +describe("recordedBuildSha", () => { + test("returns the commit when target_commitish is a full SHA", () => { + expect(recordedBuildSha({ ...completeRelease, target_commitish: BUILD_SHA })).toBe(BUILD_SHA) + }) + + test("treats a branch name as unclaimed", () => { + expect(recordedBuildSha({ ...completeRelease, target_commitish: "dev" })).toBeUndefined() }) - test("publishes when the tag already exists at the same build commit", () => { - expect(decide({ existingTagSha: BUILD_SHA }).kind).toBe("publish") + test("treats a missing target_commitish as unclaimed", () => { + expect(recordedBuildSha({ ...completeRelease, target_commitish: undefined })).toBeUndefined() }) }) diff --git a/packages/desktop-electron/scripts/publish-when-complete.ts b/packages/desktop-electron/scripts/publish-when-complete.ts index d48c2eda8..fb8619273 100644 --- a/packages/desktop-electron/scripts/publish-when-complete.ts +++ b/packages/desktop-electron/scripts/publish-when-complete.ts @@ -2,17 +2,32 @@ // have landed in the draft, then dispatch the R2 mirror. Runs at the tail of // each finalize/full build, so the LAST target to complete flips the draft into // a published release. Fail-safe: missing targets leave the draft untouched; -// only a complete, same-commit release is ever published. +// only a complete, single-source release is ever published. // // Why a dedicated script (not the by-tag verifier): a draft release is NOT // reachable via GET /releases/tags/{tag} (it 404s), so we look it up through the // list endpoint; and its assets must be downloaded via the asset API URL with an // `application/octet-stream` Accept header, not browser_download_url. +// +// Single-source guard (the assets for one version are assembled across several +// independent build runs — mac arm64/x64 finalize + win full — each with its own +// source commit). The verifier only checks file names and updater metadata, so a +// version could otherwise be published with mac and win installers built from +// DIFFERENT commits. We use the draft's `target_commitish` as a provenance +// ledger: electron-builder creates the draft with the branch name there and +// never rewrites an existing draft, so the first target pins it to its build +// commit and every later target refuses to publish unless its own commit matches. import { normalizeTag, verifyReleasePayload, type GithubRelease } from "./verify-release" const GITHUB_API = "https://api.github.com" const FETCH_TIMEOUT_MS = 30_000 +// Absorb GitHub read-after-write lag on the assets/metadata this target just +// uploaded, so the last target to finish does not see a stale "incomplete" view +// and leave the release a draft with no later run to retry the publish. +const WAIT_POLL_ATTEMPTS = 6 +const WAIT_POLL_INTERVAL_MS = 5_000 +const FULL_SHA = /^[0-9a-f]{40}$/ type ApiAsset = { name: string; url: string; browser_download_url: string } type ApiRelease = GithubRelease & { id: number; assets: ApiAsset[] } @@ -24,15 +39,17 @@ export type PublishDecision = | { kind: "fail"; reason: string } // Pure policy: decide what to do from the current release state. Kept free of -// I/O so it is unit-testable without GitHub. +// I/O so it is unit-testable without GitHub. `recordedSha` is the build commit +// already claimed on the draft (normalized to a full SHA, or undefined when the +// draft is still unclaimed); `buildSha` is this target's build commit. export function decidePublishAction(args: { release: GithubRelease latestYml?: string latestMacYml?: string buildSha: string - existingTagSha?: string + recordedSha?: string }): PublishDecision { - const { release, latestYml, latestMacYml, buildSha, existingTagSha } = args + const { release, latestYml, latestMacYml, buildSha, recordedSha } = args // A prerelease is a bad state for this pipeline: fail loudly instead of // waiting forever for a "completion" that publishing would never reach. @@ -40,6 +57,17 @@ export function decidePublishAction(args: { return { kind: "fail", reason: `release ${release.tag_name} is marked as a prerelease` } } + // Provenance gate, checked before completeness: if the draft was already + // claimed by a different build commit, this target's assets came from a + // divergent source. Refuse regardless of completeness so a mixed-source + // release is never published (and a complete-but-mixed draft is never mirrored). + if (recordedSha && recordedSha !== buildSha) { + return { + kind: "fail", + reason: `release ${release.tag_name} was assembled from ${recordedSha}, but this target was built from ${buildSha}; refusing to publish a mixed-source release`, + } + } + // Completeness reuses the exact verifier logic; allowDraft so the draft state // itself is not counted as a failure here. Any failure now means a target's // assets/updater metadata are not in yet -> keep waiting (no-op, exit 0). @@ -48,20 +76,10 @@ export function decidePublishAction(args: { return { kind: "wait", reason: `release incomplete, waiting for remaining targets: ${failures.join("; ")}` } } - // Same-source gate: never let the published tag point at a commit other than - // the one these assets were built from. If the tag already exists pointing - // elsewhere, the targets were not built from a single commit -> refuse. - if (existingTagSha && existingTagSha !== buildSha) { - return { - kind: "fail", - reason: `tag ${release.tag_name} points at ${existingTagSha}, not the build commit ${buildSha}; refusing to publish mismatched sources`, - } - } - if (release.draft) { return { kind: "publish", - reason: "all release targets present; publishing and pinning the tag to the build commit", + reason: "all release targets present and single-source; publishing and pinning the tag to the build commit", } } @@ -71,6 +89,13 @@ export function decidePublishAction(args: { return { kind: "mirror-only", reason: "release already published; ensuring the mirror is dispatched" } } +// The build commit currently claimed on the draft, or undefined when the draft +// is unclaimed (electron-builder leaves the default branch name there). +export function recordedBuildSha(release: GithubRelease): string | undefined { + const value = release.target_commitish + return value && FULL_SHA.test(value) ? value : undefined +} + function requireEnv(name: string): string { const value = process.env[name] if (!value) throw new Error(`${name} is required`) @@ -98,6 +123,13 @@ async function fetchReleases(repo: string): Promise { return (await res.json()) as ApiRelease[] } +async function findRelease(repo: string, tag: string): Promise { + const releases = await fetchReleases(repo) + const release = releases.find((entry) => entry.tag_name === tag) + if (!release) throw new Error(`no release found for ${tag} among ${releases.length} releases`) + return release +} + // Returns undefined when the asset is not in the release yet (a missing target, // handled as "wait"); throws when the asset exists but cannot be downloaded (a // tooling/network error that must fail the job rather than silently wait). @@ -109,17 +141,6 @@ async function fetchAssetText(release: ApiRelease, name: string): Promise { - const res = await ghFetch( - `${GITHUB_API}/repos/${repo}/git/refs/tags/${encodeURIComponent(tag)}`, - "application/vnd.github+json", - ) - if (res.status === 404) return undefined - if (!res.ok) throw new Error(`failed to read tag ${tag}: ${res.status} ${res.statusText}`) - const body = (await res.json()) as { object?: { sha?: string } } - return body.object?.sha -} - async function gh(args: string[]) { const proc = Bun.spawn(["gh", ...args], { stdout: "inherit", stderr: "inherit" }) const code = await proc.exited @@ -130,50 +151,72 @@ async function dispatchMirror(repo: string, tag: string, ref: string) { await gh(["workflow", "run", "mirror-release-to-r2.yml", "--repo", repo, "--ref", ref, "-f", `tag=${tag}`]) } +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +// Re-read the release (and its updater metadata) and decide. Pulled out so the +// wait-poll can re-evaluate against fresh GitHub state on each attempt. +async function evaluate(repo: string, tag: string, buildSha: string): Promise { + const release = await findRelease(repo, tag) + const latestYml = await fetchAssetText(release, "latest.yml") + const latestMacYml = await fetchAssetText(release, "latest-mac.yml") + return decidePublishAction({ release, latestYml, latestMacYml, buildSha, recordedSha: recordedBuildSha(release) }) +} + async function main() { const repo = requireEnv("GH_REPO") const tag = normalizeTag(requireEnv("RELEASE_TAG")) const buildSha = requireEnv("BUILD_SHA") const mirrorRef = process.env.MIRROR_REF ?? "dev" - const releases = await fetchReleases(repo) - const release = releases.find((entry) => entry.tag_name === tag) - if (!release) { - console.error(`publish-when-complete: no release found for ${tag} among ${releases.length} releases`) + // Claim provenance up front: pin the still-unclaimed draft to this build's + // commit so any later target built from a different commit is detected. If a + // different commit already claimed it, fail now without publishing/mirroring. + const initial = await findRelease(repo, tag) + const recorded = recordedBuildSha(initial) + if (recorded && recorded !== buildSha) { + console.error( + `publish-when-complete: release ${tag} was assembled from ${recorded}, but this target was built from ${buildSha}; refusing to publish a mixed-source release`, + ) process.exit(1) } + if (!recorded && initial.draft) { + await gh(["release", "edit", tag, "--repo", repo, "--target", buildSha]) + } - const latestYml = await fetchAssetText(release, "latest.yml") - const latestMacYml = await fetchAssetText(release, "latest-mac.yml") - const existingTagSha = await fetchTagSha(repo, tag) - - const decision = decidePublishAction({ release, latestYml, latestMacYml, buildSha, existingTagSha }) - console.log(`publish-when-complete: ${decision.reason}`) - - switch (decision.kind) { - case "fail": - process.exit(1) - return - case "wait": - return - case "publish": - await gh([ - "release", - "edit", - tag, - "--repo", - repo, - "--target", - buildSha, - "--draft=false", - "--latest", - "--prerelease=false", - ]) - await dispatchMirror(repo, tag, mirrorRef) - return - case "mirror-only": - await dispatchMirror(repo, tag, mirrorRef) - return + for (let attempt = 1; ; attempt += 1) { + const decision = await evaluate(repo, tag, buildSha) + console.log(`publish-when-complete (attempt ${attempt}/${WAIT_POLL_ATTEMPTS}): ${decision.reason}`) + + if (decision.kind === "wait" && attempt < WAIT_POLL_ATTEMPTS) { + await sleep(WAIT_POLL_INTERVAL_MS) + continue + } + + switch (decision.kind) { + case "fail": + process.exit(1) + return + case "wait": + return + case "publish": + await gh([ + "release", + "edit", + tag, + "--repo", + repo, + "--target", + buildSha, + "--draft=false", + "--latest", + "--prerelease=false", + ]) + await dispatchMirror(repo, tag, mirrorRef) + return + case "mirror-only": + await dispatchMirror(repo, tag, mirrorRef) + return + } } } diff --git a/packages/desktop-electron/scripts/verify-release.ts b/packages/desktop-electron/scripts/verify-release.ts index 3850fb226..408bde67c 100644 --- a/packages/desktop-electron/scripts/verify-release.ts +++ b/packages/desktop-electron/scripts/verify-release.ts @@ -8,6 +8,10 @@ export type GithubRelease = { tag_name: string draft: boolean prerelease: boolean + // The commit/branch the release points at. A fresh electron-builder draft + // carries the default branch name here; the auto-publisher pins it to the + // build commit. Optional because the verifier itself does not need it. + target_commitish?: string assets: GithubAsset[] } From 860d446867d92fc3f59f6b82a30aa0afdfb02d66 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Wed, 3 Jun 2026 16:31:12 +0800 Subject: [PATCH 3/9] fix(release): tie BUILD_SHA to phase and document the residual claim race Follow-up to the auto-publish review: - BUILD_SHA now resolves to inputs.source_sha only on finalize (which checks out source_sha) and github.sha otherwise (full/submit build the checked-out commit). This stops a full run from feeding the provenance ledger a source_sha that disagrees with what it actually built. - Document that the target_commitish ledger closes the realistic sequential case but not a same-instant concurrent claim by two differently-sourced targets (mac-finalize vs win-full); the loser fails loudly, and fully closing it would require per-asset commit provenance (out of scope). --- .github/workflows/build.yml | 7 ++++++- .../scripts/publish-when-complete.ts | 12 ++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cd9c5e3e5..449a04cae 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -933,7 +933,12 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} RELEASE_TAG: v${{ steps.package_version.outputs.version }} - BUILD_SHA: ${{ inputs.source_sha || github.sha }} + # The commit the assets were actually built from: finalize repackages + # the submit build checked out at source_sha (see the finalize checkout + # above); full/submit build the checked-out github.sha. Tie BUILD_SHA to + # the phase so a full run can never pass a source_sha that disagrees + # with what it actually built. + BUILD_SHA: ${{ inputs.phase == 'finalize' && inputs.source_sha || github.sha }} MIRROR_REF: dev - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a diff --git a/packages/desktop-electron/scripts/publish-when-complete.ts b/packages/desktop-electron/scripts/publish-when-complete.ts index fb8619273..e390effd3 100644 --- a/packages/desktop-electron/scripts/publish-when-complete.ts +++ b/packages/desktop-electron/scripts/publish-when-complete.ts @@ -17,6 +17,18 @@ // ledger: electron-builder creates the draft with the branch name there and // never rewrites an existing draft, so the first target pins it to its build // commit and every later target refuses to publish unless its own commit matches. +// +// This closes the realistic case (dev advances between dispatches, so a later +// target is built from a newer commit — caught because the targets run, and +// finish, sequentially). It does NOT fully close a concurrent race: the two mac +// arches serialize via the build concurrency group, but mac-finalize and win-full +// can run at once, and if both were built from different commits AND reach the +// claim within the same window, the last writer can still publish a mixed-source +// release (the loser detects the mismatch on its re-read and fails loudly, which +// surfaces the problem). Fully closing that requires per-asset commit provenance +// (stamping each target's build commit into the release and cross-checking) — +// deliberately out of scope here; the asymmetric build durations make a same- +// instant, different-commit finish unlikely. import { normalizeTag, verifyReleasePayload, type GithubRelease } from "./verify-release" From a1ec682375db3207b8cf9f77df40f40a3918a4eb Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Wed, 3 Jun 2026 16:47:06 +0800 Subject: [PATCH 4/9] fix(release): close the publish race with per-target provenance markers The target_commitish ledger only mitigated mixed-source publishing: two targets built from different commits that finished within the same window could still race (last claim writer wins) because a single mutable field was the ledger. Replace it with one provenance marker asset per target (pawwork---.commit, holding the build commit). The publisher publishes only when every expected marker is present AND they all agree. Distinct cells per target means no claim race: a divergent target leaves a disagreeing marker, so no run ever sees "all agree" and every run fails closed. Markers are not in releaseAssetNames, so the R2 mirror skips them and the verifier ignores them as extra assets. Also publish via the release id (PATCH) instead of `gh release edit `, which is draft-safe regardless of gh's by-tag draft resolution, and pass RELEASE_OS/RELEASE_ARCH so each target can write its own marker. --- .github/workflows/build.yml | 3 + .../scripts/publish-when-complete.test.ts | 67 +++--- .../scripts/publish-when-complete.ts | 203 ++++++++++-------- .../scripts/verify-release.ts | 17 +- 4 files changed, 167 insertions(+), 123 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 449a04cae..6abcd7faf 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -939,6 +939,9 @@ jobs: # the phase so a full run can never pass a source_sha that disagrees # with what it actually built. BUILD_SHA: ${{ inputs.phase == 'finalize' && inputs.source_sha || github.sha }} + # Identify this target so it can upload its own provenance marker. + RELEASE_OS: ${{ runner.os == 'macOS' && 'mac' || 'win' }} + RELEASE_ARCH: ${{ matrix.arch_label }} MIRROR_REF: dev - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a diff --git a/packages/desktop-electron/scripts/publish-when-complete.test.ts b/packages/desktop-electron/scripts/publish-when-complete.test.ts index 61472011c..e1be65f4d 100644 --- a/packages/desktop-electron/scripts/publish-when-complete.test.ts +++ b/packages/desktop-electron/scripts/publish-when-complete.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" -import { decidePublishAction, recordedBuildSha } from "./publish-when-complete" -import type { GithubRelease } from "./verify-release" +import { decidePublishAction } from "./publish-when-complete" +import { releaseProvenanceAssetNames, type GithubRelease } from "./verify-release" const BUILD_SHA = "1111111111111111111111111111111111111111" const OTHER_SHA = "2222222222222222222222222222222222222222" @@ -12,7 +12,6 @@ const completeRelease: GithubRelease = { tag_name: "v2026.6.1", draft: true, prerelease: false, - target_commitish: BUILD_SHA, assets: [ "pawwork-mac-arm64-2026.6.1.dmg", "pawwork-mac-arm64-2026.6.1.zip", @@ -30,26 +29,37 @@ const completeRelease: GithubRelease = { const latestYml = "files:\n - url: pawwork-win-x64-2026.6.1.exe\n" const latestMacYml = "files:\n - url: pawwork-mac-arm64-2026.6.1.zip\n - url: pawwork-mac-x64-2026.6.1.zip\n" +const expectedProvenance = releaseProvenanceAssetNames("2026.6.1") +// Every target's marker present and agreeing on BUILD_SHA. +const allAgree: Record = Object.fromEntries(expectedProvenance.map((name) => [name, BUILD_SHA])) + const decide = (overrides: Partial[0]> = {}) => decidePublishAction({ release: completeRelease, latestYml, latestMacYml, buildSha: BUILD_SHA, - recordedSha: BUILD_SHA, + provenance: allAgree, + expectedProvenance, ...overrides, }) -describe("decidePublishAction", () => { - test("publishes a complete, single-source draft and pins it to the build commit", () => { - expect(decide().kind).toBe("publish") +describe("releaseProvenanceAssetNames", () => { + test("derives one .commit marker per release target", () => { + expect(releaseProvenanceAssetNames("2026.6.1")).toEqual([ + "pawwork-mac-arm64-2026.6.1.commit", + "pawwork-mac-x64-2026.6.1.commit", + "pawwork-win-x64-2026.6.1.commit", + ]) }) +}) - test("publishes when the draft is not yet claimed (first target)", () => { - expect(decide({ recordedSha: undefined }).kind).toBe("publish") +describe("decidePublishAction", () => { + test("publishes a complete release when every marker agrees", () => { + expect(decide().kind).toBe("publish") }) - test("waits when a target's assets have not landed yet", () => { + test("waits when a target's installer has not landed yet", () => { const partial: GithubRelease = { ...completeRelease, assets: completeRelease.assets.filter((asset) => asset.name !== "pawwork-win-x64-2026.6.1.exe"), @@ -60,10 +70,14 @@ describe("decidePublishAction", () => { }) test("waits when the updater metadata asset is not uploaded yet", () => { - // latest.yml exists as an asset but has not been fetched (a missing target); - // the metadata cross-check must fail, keeping us in wait rather than publish. - const decision = decide({ latestYml: undefined }) + expect(decide({ latestYml: undefined }).kind).toBe("wait") + }) + + test("waits when a target has not uploaded its provenance marker yet", () => { + const { "pawwork-win-x64-2026.6.1.commit": _omit, ...rest } = allAgree + const decision = decide({ provenance: rest }) expect(decision.kind).toBe("wait") + expect(decision.reason).toContain("pawwork-win-x64-2026.6.1.commit") }) test("only mirrors when the release is already published (no re-publish)", () => { @@ -76,8 +90,8 @@ describe("decidePublishAction", () => { expect(decision.reason).toContain("prerelease") }) - test("refuses to publish when the draft was claimed by a different build commit", () => { - const decision = decide({ recordedSha: OTHER_SHA }) + test("refuses to publish when a marker disagrees on the build commit", () => { + const decision = decide({ provenance: { ...allAgree, "pawwork-win-x64-2026.6.1.commit": OTHER_SHA } }) expect(decision.kind).toBe("fail") expect(decision.reason).toContain("mixed-source release") }) @@ -87,22 +101,11 @@ describe("decidePublishAction", () => { ...completeRelease, assets: completeRelease.assets.filter((asset) => asset.name !== "pawwork-win-x64-2026.6.1.exe"), } - // A divergent source is fatal regardless of how complete the draft is, so we - // never reach the wait branch. - expect(decide({ release: partial, recordedSha: OTHER_SHA }).kind).toBe("fail") - }) -}) - -describe("recordedBuildSha", () => { - test("returns the commit when target_commitish is a full SHA", () => { - expect(recordedBuildSha({ ...completeRelease, target_commitish: BUILD_SHA })).toBe(BUILD_SHA) - }) - - test("treats a branch name as unclaimed", () => { - expect(recordedBuildSha({ ...completeRelease, target_commitish: "dev" })).toBeUndefined() - }) - - test("treats a missing target_commitish as unclaimed", () => { - expect(recordedBuildSha({ ...completeRelease, target_commitish: undefined })).toBeUndefined() + // Two targets built from different commits never converge to "all agree": + // the mismatch is fatal regardless of how complete the draft looks, so the + // race where a last writer could publish a mixed release cannot occur. + expect(decide({ release: partial, provenance: { ...allAgree, "pawwork-mac-x64-2026.6.1.commit": OTHER_SHA } }).kind).toBe( + "fail", + ) }) }) diff --git a/packages/desktop-electron/scripts/publish-when-complete.ts b/packages/desktop-electron/scripts/publish-when-complete.ts index e390effd3..6d9e67a58 100644 --- a/packages/desktop-electron/scripts/publish-when-complete.ts +++ b/packages/desktop-electron/scripts/publish-when-complete.ts @@ -10,27 +10,23 @@ // `application/octet-stream` Accept header, not browser_download_url. // // Single-source guard (the assets for one version are assembled across several -// independent build runs — mac arm64/x64 finalize + win full — each with its own -// source commit). The verifier only checks file names and updater metadata, so a -// version could otherwise be published with mac and win installers built from -// DIFFERENT commits. We use the draft's `target_commitish` as a provenance -// ledger: electron-builder creates the draft with the branch name there and -// never rewrites an existing draft, so the first target pins it to its build -// commit and every later target refuses to publish unless its own commit matches. -// -// This closes the realistic case (dev advances between dispatches, so a later -// target is built from a newer commit — caught because the targets run, and -// finish, sequentially). It does NOT fully close a concurrent race: the two mac -// arches serialize via the build concurrency group, but mac-finalize and win-full -// can run at once, and if both were built from different commits AND reach the -// claim within the same window, the last writer can still publish a mixed-source -// release (the loser detects the mismatch on its re-read and fails loudly, which -// surfaces the problem). Fully closing that requires per-asset commit provenance -// (stamping each target's build commit into the release and cross-checking) — -// deliberately out of scope here; the asymmetric build durations make a same- -// instant, different-commit finish unlikely. - -import { normalizeTag, verifyReleasePayload, type GithubRelease } from "./verify-release" +// independent, sometimes concurrent build runs — mac arm64/x64 finalize + win +// full — each with its own source commit). The installers carry only the version +// in their names, so the verifier alone cannot tell whether mac and win were +// built from the same commit. Each target therefore uploads a small per-target +// provenance marker (`pawwork---.commit`) holding its build +// commit. The publisher publishes only when EVERY expected marker is present and +// they all agree. Because each target writes its own distinct marker — never a +// shared mutable field — there is no claim race: concurrent targets built from +// different commits leave disagreeing markers, and no run ever sees "all agree". + +import { + normalizeTag, + releaseProvenanceAssetName, + releaseProvenanceAssetNames, + verifyReleasePayload, + type GithubRelease, +} from "./verify-release" const GITHUB_API = "https://api.github.com" const FETCH_TIMEOUT_MS = 30_000 @@ -39,10 +35,9 @@ const FETCH_TIMEOUT_MS = 30_000 // and leave the release a draft with no later run to retry the publish. const WAIT_POLL_ATTEMPTS = 6 const WAIT_POLL_INTERVAL_MS = 5_000 -const FULL_SHA = /^[0-9a-f]{40}$/ type ApiAsset = { name: string; url: string; browser_download_url: string } -type ApiRelease = GithubRelease & { id: number; assets: ApiAsset[] } +type ApiRelease = GithubRelease & { id: number; upload_url: string; assets: ApiAsset[] } export type PublishDecision = | { kind: "publish"; reason: string } @@ -51,17 +46,18 @@ export type PublishDecision = | { kind: "fail"; reason: string } // Pure policy: decide what to do from the current release state. Kept free of -// I/O so it is unit-testable without GitHub. `recordedSha` is the build commit -// already claimed on the draft (normalized to a full SHA, or undefined when the -// draft is still unclaimed); `buildSha` is this target's build commit. +// I/O so it is unit-testable without GitHub. `provenance` maps each PRESENT +// marker asset name to the build commit it records; `expectedProvenance` is the +// full set of marker names a complete release must carry. export function decidePublishAction(args: { release: GithubRelease latestYml?: string latestMacYml?: string buildSha: string - recordedSha?: string + provenance: Record + expectedProvenance: string[] }): PublishDecision { - const { release, latestYml, latestMacYml, buildSha, recordedSha } = args + const { release, latestYml, latestMacYml, buildSha, provenance, expectedProvenance } = args // A prerelease is a bad state for this pipeline: fail loudly instead of // waiting forever for a "completion" that publishing would never reach. @@ -69,23 +65,28 @@ export function decidePublishAction(args: { return { kind: "fail", reason: `release ${release.tag_name} is marked as a prerelease` } } - // Provenance gate, checked before completeness: if the draft was already - // claimed by a different build commit, this target's assets came from a - // divergent source. Refuse regardless of completeness so a mixed-source - // release is never published (and a complete-but-mixed draft is never mirrored). - if (recordedSha && recordedSha !== buildSha) { + // Provenance gate, checked before completeness: any present marker that does + // not match this target's build commit means the release is being assembled + // from more than one commit. Refuse regardless of completeness, so a + // mixed-source draft is never published (or mirrored). + const mismatched = Object.entries(provenance).filter(([, sha]) => sha !== buildSha) + if (mismatched.length > 0) { + const detail = mismatched.map(([name, sha]) => `${name}=${sha}`).join(", ") return { kind: "fail", - reason: `release ${release.tag_name} was assembled from ${recordedSha}, but this target was built from ${buildSha}; refusing to publish a mixed-source release`, + reason: `release ${release.tag_name} has targets built from different commits (this target ${buildSha}; ${detail}); refusing to publish a mixed-source release`, } } - // Completeness reuses the exact verifier logic; allowDraft so the draft state - // itself is not counted as a failure here. Any failure now means a target's - // assets/updater metadata are not in yet -> keep waiting (no-op, exit 0). + // Completeness: every installer + updater metadata (the verifier) AND every + // per-target provenance marker must be present. Any gap means a target has not + // finished yet -> keep waiting (no-op, exit 0). allowDraft so the draft state + // itself is not counted as a failure here. const failures = verifyReleasePayload({ release, latestYml, latestMacYml }, { allowDraft: true }) - if (failures.length > 0) { - return { kind: "wait", reason: `release incomplete, waiting for remaining targets: ${failures.join("; ")}` } + const missingMarkers = expectedProvenance.filter((name) => !(name in provenance)) + if (failures.length > 0 || missingMarkers.length > 0) { + const reasons = [...failures, ...missingMarkers.map((name) => `missing provenance marker ${name}`)] + return { kind: "wait", reason: `release incomplete, waiting for remaining targets: ${reasons.join("; ")}` } } if (release.draft) { @@ -101,36 +102,35 @@ export function decidePublishAction(args: { return { kind: "mirror-only", reason: "release already published; ensuring the mirror is dispatched" } } -// The build commit currently claimed on the draft, or undefined when the draft -// is unclaimed (electron-builder leaves the default branch name there). -export function recordedBuildSha(release: GithubRelease): string | undefined { - const value = release.target_commitish - return value && FULL_SHA.test(value) ? value : undefined -} - function requireEnv(name: string): string { const value = process.env[name] if (!value) throw new Error(`${name} is required`) return value } -function githubHeaders(accept: string) { +function githubHeaders(accept: string, contentType?: string) { const headers = new Headers({ Accept: accept, "X-GitHub-Api-Version": "2022-11-28" }) const token = process.env.GH_TOKEN if (token) headers.set("Authorization", `Bearer ${token}`) + if (contentType) headers.set("Content-Type", contentType) return headers } -async function ghFetch(url: string, accept: string) { +async function ghFetch(url: string, init: RequestInit & { accept: string; contentType?: string }) { + const { accept, contentType, ...rest } = init try { - return await fetch(url, { headers: githubHeaders(accept), signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }) + return await fetch(url, { + ...rest, + headers: githubHeaders(accept, contentType), + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }) } catch (error) { throw new Error(`request to ${url} failed: ${error instanceof Error ? error.message : String(error)}`) } } async function fetchReleases(repo: string): Promise { - const res = await ghFetch(`${GITHUB_API}/repos/${repo}/releases?per_page=100`, "application/vnd.github+json") + const res = await ghFetch(`${GITHUB_API}/repos/${repo}/releases?per_page=100`, { accept: "application/vnd.github+json" }) if (!res.ok) throw new Error(`failed to list releases: ${res.status} ${res.statusText}`) return (await res.json()) as ApiRelease[] } @@ -148,11 +148,53 @@ async function findRelease(repo: string, tag: string): Promise { async function fetchAssetText(release: ApiRelease, name: string): Promise { const asset = release.assets.find((entry) => entry.name === name) if (!asset) return undefined - const res = await ghFetch(asset.url, "application/octet-stream") + const res = await ghFetch(asset.url, { accept: "application/octet-stream" }) if (!res.ok) throw new Error(`failed to download ${name}: ${res.status} ${res.statusText}`) return res.text() } +// Upload this target's provenance marker via the release upload_url (draft-safe: +// the by-tag asset endpoints 404 on drafts, the release id/upload_url do not). +// Delete any existing same-named asset first so a re-run overwrites cleanly. +async function putProvenanceMarker(repo: string, release: ApiRelease, name: string, sha: string) { + const existing = release.assets.find((entry) => entry.name === name) + if (existing) { + const del = await ghFetch(existing.url, { method: "DELETE", accept: "application/vnd.github+json" }) + if (!del.ok && del.status !== 404) throw new Error(`failed to replace marker ${name}: ${del.status} ${del.statusText}`) + } + const uploadBase = release.upload_url.replace(/\{[^}]*\}$/, "") + const res = await ghFetch(`${uploadBase}?name=${encodeURIComponent(name)}`, { + method: "POST", + accept: "application/vnd.github+json", + contentType: "text/plain", + body: sha, + }) + if (!res.ok) throw new Error(`failed to upload marker ${name}: ${res.status} ${res.statusText}`) +} + +async function readProvenance(release: ApiRelease, expected: string[]): Promise> { + const entries: Record = {} + for (const name of expected) { + const text = await fetchAssetText(release, name) + if (text !== undefined) entries[name] = text.trim() + } + return entries +} + +// Publish via the release id (draft-safe: the by-tag edit endpoints can fail to +// resolve drafts), pinning the tag to the agreed build commit and marking it +// latest. A GITHUB_TOKEN publish does not fire release:published, so the caller +// still dispatches the mirror explicitly. +async function publishRelease(repo: string, release: ApiRelease, buildSha: string) { + const res = await ghFetch(`${GITHUB_API}/repos/${repo}/releases/${release.id}`, { + method: "PATCH", + accept: "application/vnd.github+json", + contentType: "application/json", + body: JSON.stringify({ draft: false, prerelease: false, make_latest: "true", target_commitish: buildSha }), + }) + if (!res.ok) throw new Error(`failed to publish ${release.tag_name}: ${res.status} ${res.statusText}`) +} + async function gh(args: string[]) { const proc = Bun.spawn(["gh", ...args], { stdout: "inherit", stderr: "inherit" }) const code = await proc.exited @@ -165,38 +207,36 @@ async function dispatchMirror(repo: string, tag: string, ref: string) { const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) -// Re-read the release (and its updater metadata) and decide. Pulled out so the -// wait-poll can re-evaluate against fresh GitHub state on each attempt. -async function evaluate(repo: string, tag: string, buildSha: string): Promise { - const release = await findRelease(repo, tag) - const latestYml = await fetchAssetText(release, "latest.yml") - const latestMacYml = await fetchAssetText(release, "latest-mac.yml") - return decidePublishAction({ release, latestYml, latestMacYml, buildSha, recordedSha: recordedBuildSha(release) }) -} - async function main() { const repo = requireEnv("GH_REPO") const tag = normalizeTag(requireEnv("RELEASE_TAG")) const buildSha = requireEnv("BUILD_SHA") + const os = requireEnv("RELEASE_OS") + const arch = requireEnv("RELEASE_ARCH") const mirrorRef = process.env.MIRROR_REF ?? "dev" - // Claim provenance up front: pin the still-unclaimed draft to this build's - // commit so any later target built from a different commit is detected. If a - // different commit already claimed it, fail now without publishing/mirroring. - const initial = await findRelease(repo, tag) - const recorded = recordedBuildSha(initial) - if (recorded && recorded !== buildSha) { - console.error( - `publish-when-complete: release ${tag} was assembled from ${recorded}, but this target was built from ${buildSha}; refusing to publish a mixed-source release`, - ) - process.exit(1) - } - if (!recorded && initial.draft) { - await gh(["release", "edit", tag, "--repo", repo, "--target", buildSha]) - } + const version = tag.replace(/^v/, "") + const expectedProvenance = releaseProvenanceAssetNames(version) + const thisMarker = releaseProvenanceAssetName(os, arch, version) + + // Record this target's build commit before deciding, so any other target + // built from a different commit will find a disagreeing marker. + const release = await findRelease(repo, tag) + await putProvenanceMarker(repo, release, thisMarker, buildSha) for (let attempt = 1; ; attempt += 1) { - const decision = await evaluate(repo, tag, buildSha) + const current = await findRelease(repo, tag) + const latestYml = await fetchAssetText(current, "latest.yml") + const latestMacYml = await fetchAssetText(current, "latest-mac.yml") + const provenance = await readProvenance(current, expectedProvenance) + const decision = decidePublishAction({ + release: current, + latestYml, + latestMacYml, + buildSha, + provenance, + expectedProvenance, + }) console.log(`publish-when-complete (attempt ${attempt}/${WAIT_POLL_ATTEMPTS}): ${decision.reason}`) if (decision.kind === "wait" && attempt < WAIT_POLL_ATTEMPTS) { @@ -211,18 +251,7 @@ async function main() { case "wait": return case "publish": - await gh([ - "release", - "edit", - tag, - "--repo", - repo, - "--target", - buildSha, - "--draft=false", - "--latest", - "--prerelease=false", - ]) + await publishRelease(repo, current, buildSha) await dispatchMirror(repo, tag, mirrorRef) return case "mirror-only": diff --git a/packages/desktop-electron/scripts/verify-release.ts b/packages/desktop-electron/scripts/verify-release.ts index 408bde67c..d09c4fafe 100644 --- a/packages/desktop-electron/scripts/verify-release.ts +++ b/packages/desktop-electron/scripts/verify-release.ts @@ -8,10 +8,6 @@ export type GithubRelease = { tag_name: string draft: boolean prerelease: boolean - // The commit/branch the release points at. A fresh electron-builder draft - // carries the default branch name here; the auto-publisher pins it to the - // build commit. Optional because the verifier itself does not need it. - target_commitish?: string assets: GithubAsset[] } @@ -51,6 +47,19 @@ export function releaseAssetNames(version: string) { ] } +// Per-target build-provenance marker. Each build target uploads one of these +// (containing its build commit) so the auto-publisher can confirm every target +// of a release was built from the same commit before publishing. One distinct +// asset per target — never a shared mutable field — so concurrent targets cannot +// race on it. Not part of releaseAssetNames, so the R2 mirror never copies them. +export function releaseProvenanceAssetName(os: string, arch: string, version: string) { + return `pawwork-${os}-${arch}-${version}.commit` +} + +export function releaseProvenanceAssetNames(version: string) { + return RELEASE_TARGETS.map((target) => releaseProvenanceAssetName(target.os, target.arch, version)) +} + export function releaseUpdaterAssetNames(version: string): Record { return { "latest.yml": RELEASE_TARGETS.filter((target) => target.metadata === "latest.yml").map((target) => From 74144b61e77ceab27af0d14dc3ebf1c9d4dd150c Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Wed, 3 Jun 2026 16:58:58 +0800 Subject: [PATCH 5/9] fix(release): retry marker upload on 422 and document residual TOCTOU - putProvenanceMarker now re-deletes and retries the create on a transient 422 already_exists (delete-not-yet-visible or a concurrent same-target run), so a clash can no longer leave a complete release stuck as a draft. - Document the irreducible residual: GitHub offers no cross-asset compare-and-swap, so a rerun-from-a-different-commit clobbering assets in the sub-second window between another target reading "all agree" and its publish PATCH could still slip a mixed-source release through. The realistic cases are fully closed; eliminating this one needs commit-in-asset-names (breaking) or dropping multi-dispatch. --- .../scripts/publish-when-complete.ts | 68 ++++++++++++++----- 1 file changed, 51 insertions(+), 17 deletions(-) diff --git a/packages/desktop-electron/scripts/publish-when-complete.ts b/packages/desktop-electron/scripts/publish-when-complete.ts index 6d9e67a58..899f01035 100644 --- a/packages/desktop-electron/scripts/publish-when-complete.ts +++ b/packages/desktop-electron/scripts/publish-when-complete.ts @@ -17,8 +17,20 @@ // provenance marker (`pawwork---.commit`) holding its build // commit. The publisher publishes only when EVERY expected marker is present and // they all agree. Because each target writes its own distinct marker — never a -// shared mutable field — there is no claim race: concurrent targets built from -// different commits leave disagreeing markers, and no run ever sees "all agree". +// shared mutable field — concurrent targets built from different commits leave +// disagreeing markers and no run ever sees "all agree"; both fail closed. +// +// Irreducible residual: GitHub has no atomic compare-and-swap across assets, and +// every artifact (installer, latest*.yml, marker) is uploaded in a separate +// step, so there is no transaction spanning "read markers" and "publish". If a +// target is RE-RUN from a different commit and its assets are clobbered in the +// sub-second window between another target reading "all agree" and its publish +// PATCH, a mixed-source release could still slip through. This requires an +// operator rerunning a target from a different commit for the same version at +// that exact instant; the realistic cases (dev advancing between dispatches; two +// fresh targets from different commits) are fully closed. Fully eliminating it +// would require encoding the commit in the asset names (breaks the updater, the +// R2 mirror, and the website download links) or abandoning multi-dispatch. import { normalizeTag, @@ -35,6 +47,13 @@ const FETCH_TIMEOUT_MS = 30_000 // and leave the release a draft with no later run to retry the publish. const WAIT_POLL_ATTEMPTS = 6 const WAIT_POLL_INTERVAL_MS = 5_000 +// Retry the marker create on a transient 422 already_exists (delete not yet +// visible / concurrent same-target run) so it never leaves a complete release +// stuck as a draft. +const MARKER_UPLOAD_ATTEMPTS = 4 +const MARKER_UPLOAD_RETRY_MS = 2_000 + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) type ApiAsset = { name: string; url: string; browser_download_url: string } type ApiRelease = GithubRelease & { id: number; upload_url: string; assets: ApiAsset[] } @@ -153,23 +172,40 @@ async function fetchAssetText(release: ApiRelease, name: string): Promise entry.name === name) + if (!existing) return + const del = await ghFetch(existing.url, { method: "DELETE", accept: "application/vnd.github+json" }) + if (!del.ok && del.status !== 404) throw new Error(`failed to replace marker ${name}: ${del.status} ${del.statusText}`) +} + // Upload this target's provenance marker via the release upload_url (draft-safe: // the by-tag asset endpoints 404 on drafts, the release id/upload_url do not). -// Delete any existing same-named asset first so a re-run overwrites cleanly. +// Asset names are unique per release, so we delete any same-named asset first. +// GitHub can still answer the create with 422 already_exists (delete not yet +// visible, or a concurrent same-target run); retry by re-deleting so a transient +// clash never leaves the release stuck as a complete-but-unpublished draft. async function putProvenanceMarker(repo: string, release: ApiRelease, name: string, sha: string) { - const existing = release.assets.find((entry) => entry.name === name) - if (existing) { - const del = await ghFetch(existing.url, { method: "DELETE", accept: "application/vnd.github+json" }) - if (!del.ok && del.status !== 404) throw new Error(`failed to replace marker ${name}: ${del.status} ${del.statusText}`) - } const uploadBase = release.upload_url.replace(/\{[^}]*\}$/, "") - const res = await ghFetch(`${uploadBase}?name=${encodeURIComponent(name)}`, { - method: "POST", - accept: "application/vnd.github+json", - contentType: "text/plain", - body: sha, - }) - if (!res.ok) throw new Error(`failed to upload marker ${name}: ${res.status} ${res.statusText}`) + for (let attempt = 1; ; attempt += 1) { + await deleteExistingAsset(repo, release.id, name) + const res = await ghFetch(`${uploadBase}?name=${encodeURIComponent(name)}`, { + method: "POST", + accept: "application/vnd.github+json", + contentType: "text/plain", + body: sha, + }) + if (res.ok) return + if (res.status === 422 && attempt < MARKER_UPLOAD_ATTEMPTS) { + await sleep(MARKER_UPLOAD_RETRY_MS) + continue + } + throw new Error(`failed to upload marker ${name}: ${res.status} ${res.statusText}`) + } } async function readProvenance(release: ApiRelease, expected: string[]): Promise> { @@ -205,8 +241,6 @@ async function dispatchMirror(repo: string, tag: string, ref: string) { await gh(["workflow", "run", "mirror-release-to-r2.yml", "--repo", repo, "--ref", ref, "-f", `tag=${tag}`]) } -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) - async function main() { const repo = requireEnv("GH_REPO") const tag = normalizeTag(requireEnv("RELEASE_TAG")) From 94dda6b9cc4cb8000a66420287341324ed3edfa4 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Wed, 3 Jun 2026 17:44:17 +0800 Subject: [PATCH 6/9] fix(release): anchor provenance to installer hash and seal before publish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-target commit markers close the concurrent-claim race but not a clobber that lands after a marker is written: a target rebuilt from another commit reuses the same version, so its marker's commit field can still read as agreeing while the installer it points at has changed underneath. Two layers harden the residual window, both fail-closed: - Content anchor. Each marker now records {commit, sha512[]} — the build commit AND the content hash of the updater asset it produced. Before publishing, every recorded sha512 must still be present in the current latest*.yml; a rebuild from another commit yields a different hash, so a stale marker no longer matches and the publish is refused. An empty hash list (a metadata read miss at marker time) is tolerated so a transient hiccup cannot deadlock the release into a permanent draft. - Seal + re-read. Right before the publish PATCH (the only draft->published write), snapshot the tracked asset URLs, settle, re-read, and refuse if any URL moved — electron-builder's overwrite DELETEs then re-creates an asset, so a clobber always changes the URL. The PATCH is the last write. Residual is now CI-unreachable: a mixed-source publish would require an asset clobbered from a different commit within the single HTTP round-trip between the final re-read and the PATCH. Eliminating even that needs the commit in the asset filenames (breaks the updater/mirror/site links) or one orchestrated workflow. --- .../scripts/publish-when-complete.test.ts | 53 +++- .../scripts/publish-when-complete.ts | 226 ++++++++++++++---- .../scripts/verify-release.test.ts | 38 +++ .../scripts/verify-release.ts | 27 +++ 4 files changed, 287 insertions(+), 57 deletions(-) diff --git a/packages/desktop-electron/scripts/publish-when-complete.test.ts b/packages/desktop-electron/scripts/publish-when-complete.test.ts index e1be65f4d..6ddfaf21e 100644 --- a/packages/desktop-electron/scripts/publish-when-complete.test.ts +++ b/packages/desktop-electron/scripts/publish-when-complete.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" -import { decidePublishAction } from "./publish-when-complete" +import { decidePublishAction, type ProvenanceMarker } from "./publish-when-complete" import { releaseProvenanceAssetNames, type GithubRelease } from "./verify-release" const BUILD_SHA = "1111111111111111111111111111111111111111" @@ -30,8 +30,14 @@ const latestYml = "files:\n - url: pawwork-win-x64-2026.6.1.exe\n" const latestMacYml = "files:\n - url: pawwork-mac-arm64-2026.6.1.zip\n - url: pawwork-mac-x64-2026.6.1.zip\n" const expectedProvenance = releaseProvenanceAssetNames("2026.6.1") -// Every target's marker present and agreeing on BUILD_SHA. -const allAgree: Record = Object.fromEntries(expectedProvenance.map((name) => [name, BUILD_SHA])) + +// One distinct installer hash per target, recorded both in that target's marker +// and in the updater metadata, so the content anchor holds when nothing drifted. +const shaFor = (markerName: string) => `sha-${markerName}` +const allAgree: Record = Object.fromEntries( + expectedProvenance.map((name) => [name, { commit: BUILD_SHA, sha512: [shaFor(name)] }]), +) +const updaterSha512s = expectedProvenance.map(shaFor) const decide = (overrides: Partial[0]> = {}) => decidePublishAction({ @@ -41,6 +47,7 @@ const decide = (overrides: Partial[0]> = buildSha: BUILD_SHA, provenance: allAgree, expectedProvenance, + updaterSha512s, ...overrides, }) @@ -55,7 +62,7 @@ describe("releaseProvenanceAssetNames", () => { }) describe("decidePublishAction", () => { - test("publishes a complete release when every marker agrees", () => { + test("publishes a complete release when every marker agrees and matches the metadata", () => { expect(decide().kind).toBe("publish") }) @@ -91,7 +98,9 @@ describe("decidePublishAction", () => { }) test("refuses to publish when a marker disagrees on the build commit", () => { - const decision = decide({ provenance: { ...allAgree, "pawwork-win-x64-2026.6.1.commit": OTHER_SHA } }) + const decision = decide({ + provenance: { ...allAgree, "pawwork-win-x64-2026.6.1.commit": { commit: OTHER_SHA, sha512: ["sha-win"] } }, + }) expect(decision.kind).toBe("fail") expect(decision.reason).toContain("mixed-source release") }) @@ -104,8 +113,36 @@ describe("decidePublishAction", () => { // Two targets built from different commits never converge to "all agree": // the mismatch is fatal regardless of how complete the draft looks, so the // race where a last writer could publish a mixed release cannot occur. - expect(decide({ release: partial, provenance: { ...allAgree, "pawwork-mac-x64-2026.6.1.commit": OTHER_SHA } }).kind).toBe( - "fail", - ) + expect( + decide({ + release: partial, + provenance: { ...allAgree, "pawwork-mac-x64-2026.6.1.commit": { commit: OTHER_SHA, sha512: ["sha-x64"] } }, + }).kind, + ).toBe("fail") + }) + + test("content anchor: refuses when a marker's installer hash drifted out of the metadata", () => { + // A target rebuilt from another commit (same version, agreeing commit field + // by accident) produces a different installer hash; that hash is no longer in + // latest*.yml, so the content anchor catches a clobber the commit field misses. + const drifted: Record = { + ...allAgree, + "pawwork-win-x64-2026.6.1.commit": { commit: BUILD_SHA, sha512: ["sha-rebuilt-from-another-commit"] }, + } + const decision = decide({ provenance: drifted }) + expect(decision.kind).toBe("fail") + expect(decision.reason).toContain("no longer matches recorded build hashes") + }) + + test("content anchor tolerates an empty-hash marker (metadata read hiccup at marker time)", () => { + // A target that could not read its own updater entry when writing the marker + // records an empty hash list. That target simply isn't content-anchored; the + // commit-agreement + completeness gates still apply, so the release is not + // deadlocked into a permanent draft over a transient read miss. + const noHash: Record = { + ...allAgree, + "pawwork-win-x64-2026.6.1.commit": { commit: BUILD_SHA, sha512: [] }, + } + expect(decide({ provenance: noHash }).kind).toBe("publish") }) }) diff --git a/packages/desktop-electron/scripts/publish-when-complete.ts b/packages/desktop-electron/scripts/publish-when-complete.ts index 899f01035..48754cdd0 100644 --- a/packages/desktop-electron/scripts/publish-when-complete.ts +++ b/packages/desktop-electron/scripts/publish-when-complete.ts @@ -9,31 +9,42 @@ // list endpoint; and its assets must be downloaded via the asset API URL with an // `application/octet-stream` Accept header, not browser_download_url. // -// Single-source guard (the assets for one version are assembled across several -// independent, sometimes concurrent build runs — mac arm64/x64 finalize + win -// full — each with its own source commit). The installers carry only the version -// in their names, so the verifier alone cannot tell whether mac and win were -// built from the same commit. Each target therefore uploads a small per-target -// provenance marker (`pawwork---.commit`) holding its build -// commit. The publisher publishes only when EVERY expected marker is present and -// they all agree. Because each target writes its own distinct marker — never a -// shared mutable field — concurrent targets built from different commits leave -// disagreeing markers and no run ever sees "all agree"; both fail closed. +// SINGLE-SOURCE GUARD. The assets for one version are assembled across several +// independent, sometimes concurrent build runs (mac arm64/x64 finalize + win +// full), each with its own source commit. Installers carry only the version in +// their names, so the verifier alone cannot tell whether mac and win came from +// the same commit. Three layers, designed to fail closed: // -// Irreducible residual: GitHub has no atomic compare-and-swap across assets, and -// every artifact (installer, latest*.yml, marker) is uploaded in a separate -// step, so there is no transaction spanning "read markers" and "publish". If a -// target is RE-RUN from a different commit and its assets are clobbered in the -// sub-second window between another target reading "all agree" and its publish -// PATCH, a mixed-source release could still slip through. This requires an -// operator rerunning a target from a different commit for the same version at -// that exact instant; the realistic cases (dev advancing between dispatches; two -// fresh targets from different commits) are fully closed. Fully eliminating it -// would require encoding the commit in the asset names (breaks the updater, the -// R2 mirror, and the website download links) or abandoning multi-dispatch. +// 1. Per-target marker. Each target uploads a distinct asset +// `pawwork---.commit` holding {commit, sha512} — its +// build commit and the content hash of the installer it produced. Distinct +// cells per target (never a shared mutable field), so there is no claim +// race: concurrent targets from different commits leave disagreeing markers +// and no run ever sees "all agree". +// 2. Content anchor. Before publishing, every marker's recorded sha512 must +// still be present in the current latest*.yml. A target rebuilt from another +// commit produces a different installer hash, so a stale marker no longer +// matches the metadata — catching a clobber that landed before this run read +// the markers. +// 3. Seal + re-read. Right before the publish PATCH (the only draft->published +// write), snapshot the installer asset URLs, settle briefly, re-read, and +// refuse if any asset URL changed (electron-builder's overwrite DELETEs then +// re-creates an asset, so a clobber always yields a new URL). The PATCH is +// the last write — catching a clobber that lands during the publish window. +// +// Residual: GitHub offers no atomic compare-and-swap across assets, so the seal's +// re-read and the PATCH are still two statements. A mixed-source publish would +// require an asset to be clobbered, from a different commit, in the single HTTP +// round-trip between the final re-read and the PATCH — not reachable by the CI +// pipeline (electron-builder's overwrite takes seconds and changes the URL), only +// by a human manually racing the publisher. Eliminating even that would need the +// commit in the asset filenames (breaks the updater, the R2 mirror, and the +// website links) or a single orchestrated workflow. import { normalizeTag, + parseUpdaterShaByUrl, + releaseAssetNames, releaseProvenanceAssetName, releaseProvenanceAssetNames, verifyReleasePayload, @@ -52,12 +63,19 @@ const WAIT_POLL_INTERVAL_MS = 5_000 // stuck as a draft. const MARKER_UPLOAD_ATTEMPTS = 4 const MARKER_UPLOAD_RETRY_MS = 2_000 +// Settle between sealing the asset URLs and the final re-read, long enough for an +// in-flight overwrite (DELETE + re-upload) to land and change the URL. +const SEAL_SETTLE_MS = 8_000 const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) type ApiAsset = { name: string; url: string; browser_download_url: string } type ApiRelease = GithubRelease & { id: number; upload_url: string; assets: ApiAsset[] } +// A target's provenance: its build commit and the content hash(es) of the +// updater asset it produced. +export type ProvenanceMarker = { commit: string; sha512: string[] } + export type PublishDecision = | { kind: "publish"; reason: string } | { kind: "mirror-only"; reason: string } @@ -66,17 +84,19 @@ export type PublishDecision = // Pure policy: decide what to do from the current release state. Kept free of // I/O so it is unit-testable without GitHub. `provenance` maps each PRESENT -// marker asset name to the build commit it records; `expectedProvenance` is the -// full set of marker names a complete release must carry. +// marker asset name to its parsed marker; `expectedProvenance` is the full set +// of marker names a complete release must carry; `updaterSha512s` is every +// content hash currently in latest*.yml. export function decidePublishAction(args: { release: GithubRelease latestYml?: string latestMacYml?: string buildSha: string - provenance: Record + provenance: Record expectedProvenance: string[] + updaterSha512s: string[] }): PublishDecision { - const { release, latestYml, latestMacYml, buildSha, provenance, expectedProvenance } = args + const { release, latestYml, latestMacYml, buildSha, provenance, expectedProvenance, updaterSha512s } = args // A prerelease is a bad state for this pipeline: fail loudly instead of // waiting forever for a "completion" that publishing would never reach. @@ -84,13 +104,12 @@ export function decidePublishAction(args: { return { kind: "fail", reason: `release ${release.tag_name} is marked as a prerelease` } } - // Provenance gate, checked before completeness: any present marker that does - // not match this target's build commit means the release is being assembled - // from more than one commit. Refuse regardless of completeness, so a - // mixed-source draft is never published (or mirrored). - const mismatched = Object.entries(provenance).filter(([, sha]) => sha !== buildSha) + // Provenance gate, checked before completeness: any present marker whose commit + // differs from this target's means the release is being assembled from more + // than one commit. Refuse regardless of completeness. + const mismatched = Object.entries(provenance).filter(([, marker]) => marker.commit !== buildSha) if (mismatched.length > 0) { - const detail = mismatched.map(([name, sha]) => `${name}=${sha}`).join(", ") + const detail = mismatched.map(([name, marker]) => `${name}=${marker.commit}`).join(", ") return { kind: "fail", reason: `release ${release.tag_name} has targets built from different commits (this target ${buildSha}; ${detail}); refusing to publish a mixed-source release`, @@ -108,6 +127,20 @@ export function decidePublishAction(args: { return { kind: "wait", reason: `release incomplete, waiting for remaining targets: ${reasons.join("; ")}` } } + // Content anchor: every marker's recorded installer hash must still be in the + // current metadata. A drift means an asset was rebuilt from another commit + // after its marker was written -> mixed source, refuse. + const known = new Set(updaterSha512s) + const drifted = Object.entries(provenance).flatMap(([name, marker]) => + marker.sha512.filter((hash) => !known.has(hash)).map((hash) => `${name}:${hash}`), + ) + if (drifted.length > 0) { + return { + kind: "fail", + reason: `release ${release.tag_name} updater metadata no longer matches recorded build hashes (${drifted.join(", ")}); refusing to publish a mixed-source release`, + } + } + if (release.draft) { return { kind: "publish", @@ -189,7 +222,7 @@ async function deleteExistingAsset(repo: string, releaseId: number, name: string // GitHub can still answer the create with 422 already_exists (delete not yet // visible, or a concurrent same-target run); retry by re-deleting so a transient // clash never leaves the release stuck as a complete-but-unpublished draft. -async function putProvenanceMarker(repo: string, release: ApiRelease, name: string, sha: string) { +async function putProvenanceMarker(repo: string, release: ApiRelease, name: string, body: string) { const uploadBase = release.upload_url.replace(/\{[^}]*\}$/, "") for (let attempt = 1; ; attempt += 1) { await deleteExistingAsset(repo, release.id, name) @@ -197,7 +230,7 @@ async function putProvenanceMarker(repo: string, release: ApiRelease, name: stri method: "POST", accept: "application/vnd.github+json", contentType: "text/plain", - body: sha, + body, }) if (res.ok) return if (res.status === 422 && attempt < MARKER_UPLOAD_ATTEMPTS) { @@ -208,15 +241,63 @@ async function putProvenanceMarker(repo: string, release: ApiRelease, name: stri } } -async function readProvenance(release: ApiRelease, expected: string[]): Promise> { - const entries: Record = {} +function parseMarker(text: string): ProvenanceMarker | undefined { + try { + const value = JSON.parse(text) as unknown + if ( + value && + typeof value === "object" && + typeof (value as ProvenanceMarker).commit === "string" && + Array.isArray((value as ProvenanceMarker).sha512) && + (value as ProvenanceMarker).sha512.every((entry) => typeof entry === "string") + ) { + const marker = value as ProvenanceMarker + return { commit: marker.commit, sha512: marker.sha512 } + } + } catch { + // Malformed marker: treat as not-yet-present (handled as "wait"), never as a + // valid provenance claim, so a corrupt marker can never gate a publish open. + } + return undefined +} + +async function readProvenance(release: ApiRelease, expected: string[]): Promise> { + const entries: Record = {} for (const name of expected) { const text = await fetchAssetText(release, name) - if (text !== undefined) entries[name] = text.trim() + if (text === undefined) continue + const marker = parseMarker(text) + if (marker) entries[name] = marker } return entries } +function updaterSha512sFrom(latestYml?: string, latestMacYml?: string): string[] { + return [latestYml, latestMacYml].filter((yml): yml is string => yml !== undefined).flatMap((yml) => + parseUpdaterShaByUrl(yml).map((entry) => entry.sha512), + ) +} + +// URLs of the installer/metadata assets (each embeds the asset id), keyed by +// name. A clobber DELETEs and re-creates an asset, so a changed URL signals a +// rebuild between the seal and the publish. +function sealAssetUrls(release: ApiRelease, version: string): Map { + const tracked = new Set(releaseAssetNames(version)) + const urls = new Map() + for (const asset of release.assets) { + if (tracked.has(asset.name)) urls.set(asset.name, asset.url) + } + return urls +} + +function changedAssets(sealed: Map, current: Map): string[] { + const changed: string[] = [] + for (const [name, url] of sealed) { + if (current.get(name) !== url) changed.push(name) + } + return changed +} + // Publish via the release id (draft-safe: the by-tag edit endpoints can fail to // resolve drafts), pinning the tag to the agreed build commit and marking it // latest. A GITHUB_TOKEN publish does not fire release:published, so the caller @@ -241,6 +322,20 @@ async function dispatchMirror(repo: string, tag: string, ref: string) { await gh(["workflow", "run", "mirror-release-to-r2.yml", "--repo", repo, "--ref", ref, "-f", `tag=${tag}`]) } +// The updater asset (the file electron-updater downloads) and its metadata file, +// per OS. Its content hash is what the marker records for the content anchor. +function targetUpdater(os: string): { ext: string; metadata: "latest.yml" | "latest-mac.yml" } { + return os === "win" ? { ext: "exe", metadata: "latest.yml" } : { ext: "zip", metadata: "latest-mac.yml" } +} + +async function readEvaluationState(repo: string, tag: string, expectedProvenance: string[]) { + const release = await findRelease(repo, tag) + const latestYml = await fetchAssetText(release, "latest.yml") + const latestMacYml = await fetchAssetText(release, "latest-mac.yml") + const provenance = await readProvenance(release, expectedProvenance) + return { release, latestYml, latestMacYml, provenance } +} + async function main() { const repo = requireEnv("GH_REPO") const tag = normalizeTag(requireEnv("RELEASE_TAG")) @@ -253,23 +348,27 @@ async function main() { const expectedProvenance = releaseProvenanceAssetNames(version) const thisMarker = releaseProvenanceAssetName(os, arch, version) - // Record this target's build commit before deciding, so any other target - // built from a different commit will find a disagreeing marker. + // Record this target's build commit AND the hash of the installer it produced, + // before deciding, so other targets can detect both a different commit and a + // later clobber of this target's asset. const release = await findRelease(repo, tag) - await putProvenanceMarker(repo, release, thisMarker, buildSha) + const { ext, metadata } = targetUpdater(os) + const myUpdaterAsset = `pawwork-${os}-${arch}-${version}.${ext}` + const myYml = await fetchAssetText(release, metadata) + const myEntry = myYml ? parseUpdaterShaByUrl(myYml).find((entry) => entry.name === myUpdaterAsset) : undefined + const marker: ProvenanceMarker = { commit: buildSha, sha512: myEntry ? [myEntry.sha512] : [] } + await putProvenanceMarker(repo, release, thisMarker, JSON.stringify(marker)) for (let attempt = 1; ; attempt += 1) { - const current = await findRelease(repo, tag) - const latestYml = await fetchAssetText(current, "latest.yml") - const latestMacYml = await fetchAssetText(current, "latest-mac.yml") - const provenance = await readProvenance(current, expectedProvenance) + const state = await readEvaluationState(repo, tag, expectedProvenance) const decision = decidePublishAction({ - release: current, - latestYml, - latestMacYml, + release: state.release, + latestYml: state.latestYml, + latestMacYml: state.latestMacYml, buildSha, - provenance, + provenance: state.provenance, expectedProvenance, + updaterSha512s: updaterSha512sFrom(state.latestYml, state.latestMacYml), }) console.log(`publish-when-complete (attempt ${attempt}/${WAIT_POLL_ATTEMPTS}): ${decision.reason}`) @@ -284,10 +383,39 @@ async function main() { return case "wait": return - case "publish": - await publishRelease(repo, current, buildSha) + case "publish": { + // Seal + re-read: snapshot the asset URLs, let any in-flight overwrite + // land, then re-evaluate. Publish only if the release is STILL a + // complete, single-source publish AND no tracked asset URL moved — the + // PATCH is the final write. + const sealed = sealAssetUrls(state.release, version) + await sleep(SEAL_SETTLE_MS) + const reread = await readEvaluationState(repo, tag, expectedProvenance) + const recheck = decidePublishAction({ + release: reread.release, + latestYml: reread.latestYml, + latestMacYml: reread.latestMacYml, + buildSha, + provenance: reread.provenance, + expectedProvenance, + updaterSha512s: updaterSha512sFrom(reread.latestYml, reread.latestMacYml), + }) + if (recheck.kind !== "publish") { + console.error(`publish-when-complete: release changed during seal, not publishing: ${recheck.reason}`) + if (recheck.kind === "fail") process.exit(1) + return + } + const moved = changedAssets(sealed, sealAssetUrls(reread.release, version)) + if (moved.length > 0) { + console.error( + `publish-when-complete: release assets changed during seal (${moved.join(", ")}); refusing to publish a possibly mixed-source release`, + ) + process.exit(1) + } + await publishRelease(repo, reread.release, buildSha) await dispatchMirror(repo, tag, mirrorRef) return + } case "mirror-only": await dispatchMirror(repo, tag, mirrorRef) return diff --git a/packages/desktop-electron/scripts/verify-release.test.ts b/packages/desktop-electron/scripts/verify-release.test.ts index 73215d3f8..08ac1066a 100644 --- a/packages/desktop-electron/scripts/verify-release.test.ts +++ b/packages/desktop-electron/scripts/verify-release.test.ts @@ -9,6 +9,7 @@ import { fetchText, normalizeTag, parseUpdaterFileUrls, + parseUpdaterShaByUrl, readStartupLogFile, releaseAssetNames, releaseUpdaterAssetNames, @@ -512,3 +513,40 @@ path: pawwork-win-x64-2026.4.28.exe ) }) }) + +describe("parseUpdaterShaByUrl", () => { + test("pairs each updater file with its content sha512, keyed by asset basename", () => { + const yml = [ + "version: 2026.6.1", + "files:", + " - url: pawwork-mac-arm64-2026.6.1.zip", + " sha512: HASH_ARM64", + " size: 123", + " - url: pawwork-mac-x64-2026.6.1.zip", + " sha512: HASH_X64", + " size: 456", + "path: pawwork-mac-arm64-2026.6.1.zip", + "sha512: HASH_ARM64", + "releaseDate: '2026-06-01T00:00:00.000Z'", + ].join("\n") + + expect(parseUpdaterShaByUrl(yml)).toEqual([ + { name: "pawwork-mac-arm64-2026.6.1.zip", sha512: "HASH_ARM64" }, + { name: "pawwork-mac-x64-2026.6.1.zip", sha512: "HASH_X64" }, + ]) + }) + + test("reduces a full download URL to its basename and ignores the trailing path digest", () => { + const yml = [ + "files:", + " - url: https://example.com/download/pawwork-win-x64-2026.6.1.exe", + " sha512: HASH_WIN", + "path: pawwork-win-x64-2026.6.1.exe", + "sha512: HASH_WIN", + ].join("\n") + + // The top-level `sha512:` after `path:` has no preceding `- url:` entry, so it + // is not emitted as a phantom hash. + expect(parseUpdaterShaByUrl(yml)).toEqual([{ name: "pawwork-win-x64-2026.6.1.exe", sha512: "HASH_WIN" }]) + }) +}) diff --git a/packages/desktop-electron/scripts/verify-release.ts b/packages/desktop-electron/scripts/verify-release.ts index d09c4fafe..6f0eb74a1 100644 --- a/packages/desktop-electron/scripts/verify-release.ts +++ b/packages/desktop-electron/scripts/verify-release.ts @@ -91,6 +91,33 @@ export function parseUpdaterFileUrls(source: string) { return urls } +// Pair each updater file entry with its content sha512, keyed by asset basename. +// Used by the auto-publisher's single-source guard: a marker records the sha512 +// the target produced, and publishing requires it to still match the metadata — +// so an asset rebuilt from another commit (different hash) is caught. Same +// deliberately-narrow scanner as parseUpdaterFileUrls; ignores the top-level +// `sha512:` (the `path:` digest), which has no preceding `- url:` entry. +export function parseUpdaterShaByUrl(source: string): Array<{ name: string; sha512: string }> { + const entries: Array<{ name: string; sha512: string }> = [] + let currentName: string | undefined + + for (const line of source.split(/\r?\n/)) { + const fileMatch = line.match(/^\s*-\s+url:\s*(.+?)\s*$/) + if (fileMatch) { + currentName = assetNameFromUrl(parseYamlScalar(fileMatch[1])) + continue + } + + const shaMatch = line.match(/^\s*sha512:\s*(.+?)\s*$/) + if (shaMatch && currentName) { + entries.push({ name: currentName, sha512: parseYamlScalar(shaMatch[1]) }) + currentName = undefined + } + } + + return entries +} + function parseYamlScalar(value: string) { const trimmed = stripInlineComment(value).trim() const quote = trimmed[0] From 115c04d569d9e86d81ef6e9fe1e454abd8924a15 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Wed, 3 Jun 2026 18:03:09 +0800 Subject: [PATCH 7/9] fix(release): fence published-release writes and close the empty-marker hole Two race fixes surfaced by review of the auto-publish guard: - Empty-hash provenance markers defeated the content anchor. A target that could not read its own installer hash wrote {sha512: []}, and the drift check passed it by vacuous truth, so a stale/foreign installer could ride along. Now the writer retries reading its hash and fails rather than vouch for nothing, and the decision policy treats an empty record as a content-anchor failure -- fail closed either way. - finalize-latest-yml could clobber an already-published release. Its `gh release upload --clobber` resolves by tag regardless of draft state, so a later same-version build from a different commit could rewrite the published latest*.yml to hashes that no longer match the (electron-builder leaves these untouched) published installers, breaking auto-update. It now refuses to write to a non-draft release; the normal flow only ever finalizes a draft, so this fires solely on a same-version re-dispatch. Also: when another job wins the publish during our seal window, still dispatch the mirror (its GITHUB_TOKEN publish fires no webhook and its own dispatch may have failed); and log a clear warning when the poll window is exhausted still incomplete, noting a re-dispatch recovers a stuck draft. --- .../scripts/finalize-latest-yml.ts | 28 ++++++- .../scripts/publish-when-complete.test.ts | 13 +-- .../scripts/publish-when-complete.ts | 80 +++++++++++++++---- 3 files changed, 98 insertions(+), 23 deletions(-) diff --git a/packages/desktop-electron/scripts/finalize-latest-yml.ts b/packages/desktop-electron/scripts/finalize-latest-yml.ts index 89779117b..1925dd99d 100644 --- a/packages/desktop-electron/scripts/finalize-latest-yml.ts +++ b/packages/desktop-electron/scripts/finalize-latest-yml.ts @@ -147,6 +147,30 @@ async function downloadExisting(tag: string, filename: string) { return mergeLatest(cached, live) } +// Refuse to rewrite a release that is already published. In the normal flow the +// release stays a draft until the auto-publisher flips it at the very end, after +// every target's finalize has run; a published release here therefore means a +// later same-version build (from a different commit) — clobbering its latest*.yml +// would point the published metadata at hashes that no longer match the published +// installers, breaking auto-update. Fail loudly instead (re-release with a bumped +// version). A missing release is fine: nothing to clobber yet. +async function assertReleaseIsDraft(releaseTag: string) { + let isDraft: boolean + try { + const out = await $`gh release view ${releaseTag} --json isDraft --jq .isDraft --repo ${repo}`.quiet().text() + isDraft = out.trim() === "true" + } catch (error) { + const message = shellErrorText(error) + if (/release not found|not found/i.test(message)) return + throw new Error(`Failed to read release state for ${releaseTag}: ${message}`) + } + if (!isDraft) { + throw new Error( + `Release ${releaseTag} is already published; refusing to overwrite its updater metadata from a later build`, + ) + } +} + const output: Record = {} const tag = `v${version}` const tmp = process.env.RUNNER_TEMP ?? "/tmp" @@ -187,7 +211,9 @@ if (macX64 || macArm64) { ) } -// Upload to release +// Upload to release. Re-checked right before the writes to shrink the window +// between observing a draft and clobbering it. +await assertReleaseIsDraft(tag) for (const [filename, content] of Object.entries(output)) { const filepath = path.join(tmp, filename) await Bun.write(filepath, content) diff --git a/packages/desktop-electron/scripts/publish-when-complete.test.ts b/packages/desktop-electron/scripts/publish-when-complete.test.ts index 6ddfaf21e..1e32bd988 100644 --- a/packages/desktop-electron/scripts/publish-when-complete.test.ts +++ b/packages/desktop-electron/scripts/publish-when-complete.test.ts @@ -134,15 +134,16 @@ describe("decidePublishAction", () => { expect(decision.reason).toContain("no longer matches recorded build hashes") }) - test("content anchor tolerates an empty-hash marker (metadata read hiccup at marker time)", () => { - // A target that could not read its own updater entry when writing the marker - // records an empty hash list. That target simply isn't content-anchored; the - // commit-agreement + completeness gates still apply, so the release is not - // deadlocked into a permanent draft over a transient read miss. + test("content anchor: refuses an empty-hash marker (a target that vouches for nothing)", () => { + // The marker writer fails rather than emit an empty hash, so an empty record + // reaching the decision means corruption or a stale tool. It must not pass the + // anchor by vacuous truth (empty list -> nothing to mismatch); fail closed. const noHash: Record = { ...allAgree, "pawwork-win-x64-2026.6.1.commit": { commit: BUILD_SHA, sha512: [] }, } - expect(decide({ provenance: noHash }).kind).toBe("publish") + const decision = decide({ provenance: noHash }) + expect(decision.kind).toBe("fail") + expect(decision.reason).toContain("no recorded hash") }) }) diff --git a/packages/desktop-electron/scripts/publish-when-complete.ts b/packages/desktop-electron/scripts/publish-when-complete.ts index 48754cdd0..e65e575fe 100644 --- a/packages/desktop-electron/scripts/publish-when-complete.ts +++ b/packages/desktop-electron/scripts/publish-when-complete.ts @@ -32,14 +32,23 @@ // re-creates an asset, so a clobber always yields a new URL). The PATCH is // the last write — catching a clobber that lands during the publish window. // -// Residual: GitHub offers no atomic compare-and-swap across assets, so the seal's -// re-read and the PATCH are still two statements. A mixed-source publish would -// require an asset to be clobbered, from a different commit, in the single HTTP -// round-trip between the final re-read and the PATCH — not reachable by the CI -// pipeline (electron-builder's overwrite takes seconds and changes the URL), only -// by a human manually racing the publisher. Eliminating even that would need the -// commit in the asset filenames (breaks the updater, the R2 mirror, and the -// website links) or a single orchestrated workflow. +// Post-publish writes happen at a different site (the build's earlier publish + +// finalize steps), so they are fenced there, not here: electron-builder leaves an +// already-published release untouched (releaseType defaults to draft, so its +// publisher skips a published release), and finalize-latest-yml refuses to upload +// to a non-draft release. Together a later same-version build from a different +// commit cannot rewrite the published installers or their updater metadata; it +// fails loudly instead. +// +// Residual: GitHub offers no atomic compare-and-swap across release assets, so +// the finalize guard's draft-check and its upload, and this seal's re-read and +// PATCH, are each two statements. A mixed-source publish would require a write to +// land in the single HTTP round-trip between a check and its write, from a +// different commit, in two builds of the same version dispatched concurrently — +// not reachable by the normal one-dispatch pipeline (each version is built once, +// from one commit). Eliminating even that would need the commit in the asset +// filenames (breaks the updater, the R2 mirror, and the website links) or a +// single orchestrated workflow. import { normalizeTag, @@ -127,12 +136,18 @@ export function decidePublishAction(args: { return { kind: "wait", reason: `release incomplete, waiting for remaining targets: ${reasons.join("; ")}` } } - // Content anchor: every marker's recorded installer hash must still be in the - // current metadata. A drift means an asset was rebuilt from another commit - // after its marker was written -> mixed source, refuse. + // Content anchor: every marker must record at least one installer hash AND + // every recorded hash must still be in the current metadata. A drift means an + // asset was rebuilt from another commit after its marker was written; an empty + // record means a target could not vouch for its own installer. Either way we + // cannot prove single-source, so refuse -- the marker writer never emits an + // empty record (it fails first), so an empty one here is corruption or a + // stale-tool artifact and must not gate the publish open. const known = new Set(updaterSha512s) const drifted = Object.entries(provenance).flatMap(([name, marker]) => - marker.sha512.filter((hash) => !known.has(hash)).map((hash) => `${name}:${hash}`), + marker.sha512.length === 0 + ? [`${name}:`] + : marker.sha512.filter((hash) => !known.has(hash)).map((hash) => `${name}:${hash}`), ) if (drifted.length > 0) { return { @@ -328,6 +343,25 @@ function targetUpdater(os: string): { ext: string; metadata: "latest.yml" | "lat return os === "win" ? { ext: "exe", metadata: "latest.yml" } : { ext: "zip", metadata: "latest-mac.yml" } } +// Read THIS target's installer hash from its updater metadata, re-fetching to +// absorb read-after-write lag on the asset just finalized. Throws rather than +// returning empty: a hashless marker would disable the content anchor for this +// target, so a genuine miss must fail the job (re-runnable) instead. +async function readOwnUpdaterSha(repo: string, tag: string, metadata: string, assetName: string): Promise { + for (let attempt = 1; ; attempt += 1) { + const release = await findRelease(repo, tag) + const yml = await fetchAssetText(release, metadata) + const entry = yml ? parseUpdaterShaByUrl(yml).find((item) => item.name === assetName) : undefined + if (entry) return entry.sha512 + if (attempt >= MARKER_UPLOAD_ATTEMPTS) { + throw new Error( + `could not read sha512 for ${assetName} from ${metadata} after ${attempt} attempts; refusing to write a hashless provenance marker`, + ) + } + await sleep(MARKER_UPLOAD_RETRY_MS) + } +} + async function readEvaluationState(repo: string, tag: string, expectedProvenance: string[]) { const release = await findRelease(repo, tag) const latestYml = await fetchAssetText(release, "latest.yml") @@ -350,13 +384,15 @@ async function main() { // Record this target's build commit AND the hash of the installer it produced, // before deciding, so other targets can detect both a different commit and a - // later clobber of this target's asset. + // later clobber of this target's asset. Refuse to write a hashless marker: an + // empty hash would silently disable the content anchor for this target, so if + // we cannot read our own installer hash (read-after-write lag, or finalize did + // not run) we fail loudly instead of vouching for nothing. const release = await findRelease(repo, tag) const { ext, metadata } = targetUpdater(os) const myUpdaterAsset = `pawwork-${os}-${arch}-${version}.${ext}` - const myYml = await fetchAssetText(release, metadata) - const myEntry = myYml ? parseUpdaterShaByUrl(myYml).find((entry) => entry.name === myUpdaterAsset) : undefined - const marker: ProvenanceMarker = { commit: buildSha, sha512: myEntry ? [myEntry.sha512] : [] } + const mySha512 = await readOwnUpdaterSha(repo, tag, metadata, myUpdaterAsset) + const marker: ProvenanceMarker = { commit: buildSha, sha512: [mySha512] } await putProvenanceMarker(repo, release, thisMarker, JSON.stringify(marker)) for (let attempt = 1; ; attempt += 1) { @@ -382,6 +418,14 @@ async function main() { process.exit(1) return case "wait": + // Exhausted the poll window still incomplete. Expected when other targets + // are genuinely still building (each will run its own publisher). If every + // target has in fact finished but this run only saw a stale view, the + // release is left a draft; re-dispatching the same version (same commit) + // re-runs against the still-draft release and publishes it. + console.warn( + `publish-when-complete: still incomplete after ${WAIT_POLL_ATTEMPTS} attempts; leaving the release a draft (${decision.reason})`, + ) return case "publish": { // Seal + re-read: snapshot the asset URLs, let any in-flight overwrite @@ -403,6 +447,10 @@ async function main() { if (recheck.kind !== "publish") { console.error(`publish-when-complete: release changed during seal, not publishing: ${recheck.reason}`) if (recheck.kind === "fail") process.exit(1) + // Another job won the publish race during our seal window. Its publish + // does not fire release:published and its own mirror dispatch may have + // failed, so still ensure the mirror is dispatched before we exit. + if (recheck.kind === "mirror-only") await dispatchMirror(repo, tag, mirrorRef) return } const moved = changedAssets(sealed, sealAssetUrls(reread.release, version)) From 48bc4fedafc0b8f68fcb2760974514fad8ca6646 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Wed, 3 Jun 2026 18:22:43 +0800 Subject: [PATCH 8/9] test(release): teach the finalizer contract fake-gh the published-release guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit finalize-latest-yml now calls `gh release view --jq .isDraft` before uploading, but the contract test's fake gh only handled `release download`; every other invocation fell through printing nothing, so the guard read an empty isDraft, treated the release as published, and aborted — failing all finalize cases. Make the fake answer `release view` with a configurable draft flag (default true, the normal flow) and add a case asserting the finalizer refuses to upload when the release is already published. No production change. --- .../scripts/release-metadata-contract.test.ts | 38 ++++++++++++++++++- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/packages/desktop-electron/scripts/release-metadata-contract.test.ts b/packages/desktop-electron/scripts/release-metadata-contract.test.ts index fafd28b08..3e7aed4ea 100644 --- a/packages/desktop-electron/scripts/release-metadata-contract.test.ts +++ b/packages/desktop-electron/scripts/release-metadata-contract.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test" -import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { delimiter, dirname, join } from "node:path" import { fileURLToPath } from "node:url" @@ -257,6 +257,29 @@ describe("release metadata finalizer", () => { expect(stderr).toContain("HTTP 404: Not Found") }) + test("refuses to overwrite updater metadata once the release is published", async () => { + const root = mkdtempSync(join(tmpdir(), "pawwork-release-metadata-")) + roots.push(root) + const latestDir = join(root, "latest-yml") + const runnerTemp = join(root, "runner") + const binDir = join(root, "bin") + mkdirSync(runnerTemp, { recursive: true }) + mkdirSync(binDir, { recursive: true }) + // The release has already been published (a later same-version build): the + // finalizer must refuse rather than clobber the live updater metadata. + writeFakeGh(binDir, {}, undefined, false) + + writeLatest(join(latestDir, "latest-yml-aarch64-apple-darwin"), "latest-mac.yml", "PawWork-new-arm64.zip") + + const proc = spawnFinalizer(binDir, latestDir, runnerTemp) + + const [stderr, exitCode] = await Promise.all([new Response(proc.stderr).text(), proc.exited]) + expect(exitCode).not.toBe(0) + expect(stderr).toContain("already published") + const uploadsLog = join(root, "gh-uploads.log") + expect(existsSync(uploadsLog) ? readFileSync(uploadsLog, "utf8") : "").not.toContain("release upload") + }) + test("fails when existing metadata version does not match the release", async () => { const root = mkdtempSync(join(tmpdir(), "pawwork-release-metadata-")) roots.push(root) @@ -313,7 +336,12 @@ function writeLatest( ) } -function writeFakeGh(binDir: string, downloads: Record = {}, downloadFailure?: string) { +function writeFakeGh( + binDir: string, + downloads: Record = {}, + downloadFailure?: string, + releaseIsDraft = true, +) { const helper = join(binDir, "fake-gh.js") writeFileSync( helper, @@ -322,8 +350,14 @@ function writeFakeGh(binDir: string, downloads: Record --jq .isDraft`. + "if (args[0] === 'release' && args[1] === 'view') {", + " process.stdout.write(`${releaseIsDraft}\\n`)", + " process.exit(0)", + "}", "if (args[0] === 'release' && args[1] === 'download') {", " if (downloadFailure) {", " console.error(downloadFailure)", From 65a6b530a8c78b1b3e8a9ceb8a588be9dda9a590 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Wed, 3 Jun 2026 18:42:24 +0800 Subject: [PATCH 9/9] test(opencode): expect actions:write on build-electron for the mirror dispatch The build workflow contract test pinned build-electron to actions:read, but the tail publish step needs actions:write to dispatch the R2 mirror via workflow_dispatch (a GITHUB_TOKEN publish fires no release:published webhook). Update the assertion to match the permission the workflow now declares. --- packages/opencode/test/github/build-workflow.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/github/build-workflow.test.ts b/packages/opencode/test/github/build-workflow.test.ts index 91a14e51d..8429c4644 100644 --- a/packages/opencode/test/github/build-workflow.test.ts +++ b/packages/opencode/test/github/build-workflow.test.ts @@ -128,7 +128,9 @@ describe("release workflow", () => { }) expect(selectBuildTarget?.permissions).toBeUndefined() expect(createSnapshotTag?.permissions).toEqual({ contents: "write" }) - expect(buildElectron?.permissions).toEqual({ actions: "read", contents: "write" }) + // actions: write lets the tail publish step dispatch the R2 mirror via + // workflow_dispatch (GITHUB_TOKEN publishes fire no release:published). + expect(buildElectron?.permissions).toEqual({ actions: "write", contents: "write" }) expect(cleanupSnapshotTag?.permissions).toEqual({ contents: "write" }) expect( Object.entries(parsed.jobs ?? {})