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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
28 changes: 27 additions & 1 deletion packages/desktop-electron/scripts/finalize-latest-yml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {}
const tag = `v${version}`
const tmp = process.env.RUNNER_TEMP ?? "/tmp"
Expand Down Expand Up @@ -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)
Expand Down
149 changes: 149 additions & 0 deletions packages/desktop-electron/scripts/publish-when-complete.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, ProvenanceMarker> = Object.fromEntries(
expectedProvenance.map((name) => [name, { commit: BUILD_SHA, sha512: [shaFor(name)] }]),
)
const updaterSha512s = expectedProvenance.map(shaFor)

const decide = (overrides: Partial<Parameters<typeof decidePublishAction>[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<string, ProvenanceMarker> = {
...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<string, ProvenanceMarker> = {
...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")
})
})
Loading
Loading