diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 69a9c6b17..6abcd7faf 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,29 @@ 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 }} + # 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 }} + # 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 if: ${{ runner.os == 'macOS' && (inputs.phase == 'finalize' || inputs.phase == 'full') && inputs.arch == matrix.arch_label }} with: 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 new file mode 100644 index 000000000..1e32bd988 --- /dev/null +++ b/packages/desktop-electron/scripts/publish-when-complete.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, test } from "bun:test" + +import { decidePublishAction, type ProvenanceMarker } from "./publish-when-complete" +import { releaseProvenanceAssetNames, 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"). +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 expectedProvenance = releaseProvenanceAssetNames("2026.6.1") + +// 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({ + release: completeRelease, + latestYml, + latestMacYml, + buildSha: BUILD_SHA, + provenance: allAgree, + expectedProvenance, + updaterSha512s, + ...overrides, + }) + +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", + ]) + }) +}) + +describe("decidePublishAction", () => { + test("publishes a complete release when every marker agrees and matches the metadata", () => { + expect(decide().kind).toBe("publish") + }) + + 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"), + } + 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", () => { + 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)", () => { + 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 a marker disagrees on the build commit", () => { + 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") + }) + + 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"), + } + // 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": { 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: 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: [] }, + } + 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 new file mode 100644 index 000000000..e65e575fe --- /dev/null +++ b/packages/desktop-electron/scripts/publish-when-complete.ts @@ -0,0 +1,479 @@ +// 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, 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, 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: +// +// 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. +// +// 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, + parseUpdaterShaByUrl, + releaseAssetNames, + releaseProvenanceAssetName, + releaseProvenanceAssetNames, + 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 +// 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 +// 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 } + | { 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. `provenance` maps each PRESENT +// 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 + expectedProvenance: string[] + updaterSha512s: string[] +}): PublishDecision { + 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. + if (release.prerelease) { + return { kind: "fail", reason: `release ${release.tag_name} is marked as a prerelease` } + } + + // 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, 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`, + } + } + + // 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 }) + 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("; ")}` } + } + + // 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.length === 0 + ? [`${name}:`] + : 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", + reason: "all release targets present and single-source; 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, 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, init: RequestInit & { accept: string; contentType?: string }) { + const { accept, contentType, ...rest } = init + try { + 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`, { 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[] +} + +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). +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, { accept: "application/octet-stream" }) + if (!res.ok) throw new Error(`failed to download ${name}: ${res.status} ${res.statusText}`) + return res.text() +} + +async function deleteExistingAsset(repo: string, releaseId: number, name: string) { + const res = await ghFetch(`${GITHUB_API}/repos/${repo}/releases/${releaseId}/assets?per_page=100`, { + accept: "application/vnd.github+json", + }) + if (!res.ok) throw new Error(`failed to list assets for release ${releaseId}: ${res.status} ${res.statusText}`) + const existing = ((await res.json()) as ApiAsset[]).find((entry) => 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). +// 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, body: string) { + const uploadBase = release.upload_url.replace(/\{[^}]*\}$/, "") + 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, + }) + 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}`) + } +} + +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) 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 +// 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 + 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}`]) +} + +// 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" } +} + +// 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") + 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")) + const buildSha = requireEnv("BUILD_SHA") + const os = requireEnv("RELEASE_OS") + const arch = requireEnv("RELEASE_ARCH") + const mirrorRef = process.env.MIRROR_REF ?? "dev" + + const version = tag.replace(/^v/, "") + const expectedProvenance = releaseProvenanceAssetNames(version) + const thisMarker = releaseProvenanceAssetName(os, arch, version) + + // 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. 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 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) { + const state = await readEvaluationState(repo, tag, expectedProvenance) + const decision = decidePublishAction({ + release: state.release, + latestYml: state.latestYml, + latestMacYml: state.latestMacYml, + buildSha, + provenance: state.provenance, + expectedProvenance, + updaterSha512s: updaterSha512sFrom(state.latestYml, state.latestMacYml), + }) + 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": + // 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 + // 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) + // 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)) + 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 + } + } +} + +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/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)", diff --git a/packages/desktop-electron/scripts/verify-release.test.ts b/packages/desktop-electron/scripts/verify-release.test.ts index e3a5b7a2f..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, @@ -230,6 +231,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({ @@ -486,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 846f94be9..6f0eb74a1 100644 --- a/packages/desktop-electron/scripts/verify-release.ts +++ b/packages/desktop-electron/scripts/verify-release.ts @@ -47,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) => @@ -78,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] @@ -192,7 +232,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 +242,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) 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 ?? {})