From 14ec827430254544be235f4133e9d25e25a55a59 Mon Sep 17 00:00:00 2001 From: iamnbutler Date: Wed, 7 Oct 2026 10:45:11 -0400 Subject: [PATCH] fix(desktop): publish versioned GitHub releases with signed DMGs --- .github/workflows/desktop-release.yml | 7 +- apps/desktop/scripts/github-release.ts | 134 +++++++++++++++++++++++++ apps/desktop/scripts/publish.ts | 3 + docs/updates.md | 28 +++++- 4 files changed, 165 insertions(+), 7 deletions(-) create mode 100644 apps/desktop/scripts/github-release.ts diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 301cc0f..cd88589 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -10,7 +10,7 @@ on: options: [canary, stable] default: canary publish: - description: Publish the verified release to the public download bucket + description: Publish the verified release to GitHub and the public download bucket type: boolean default: false revision: @@ -26,6 +26,8 @@ concurrency: jobs: release: + permissions: + contents: write runs-on: macos-15 timeout-minutes: 60 environment: desktop-${{ inputs.channel }} @@ -68,10 +70,11 @@ jobs: name: ace-${{ inputs.channel }}-macos-arm64 path: apps/desktop/artifacts/sparkle/ if-no-files-found: error - - name: Publish downloads, then the feed + - name: Publish downloads and GitHub release, then the feed if: inputs.publish run: bun desktop publish env: + GH_TOKEN: ${{ github.token }} ACE_SPARKLE_PRIVATE_KEY: ${{ secrets.ACE_SPARKLE_PRIVATE_KEY }} ACE_UPDATE_BUCKET: ${{ vars.ACE_UPDATE_BUCKET }} CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} diff --git a/apps/desktop/scripts/github-release.ts b/apps/desktop/scripts/github-release.ts new file mode 100644 index 0000000..df3c8a9 --- /dev/null +++ b/apps/desktop/scripts/github-release.ts @@ -0,0 +1,134 @@ +import { createHash } from "node:crypto"; +import { join, resolve } from "node:path"; + +import { root } from "./build"; +import type { Release } from "./release"; + +type GithubRelease = { + id: number; + draft: boolean; + prerelease: boolean; + target_commitish: string; + html_url: string; + assets: { name: string; digest: string | null; state: string }[]; +}; + +function gh(args: string[], missing = false): string | undefined { + const result = Bun.spawnSync(["gh", ...args], { + cwd: root, + env: { ...process.env, GH_PROMPT_DISABLED: "1" }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + if (result.success) return result.stdout.toString().trim(); + const error = result.stderr.toString().trim(); + if (missing && error.includes("(HTTP 404)")) return undefined; + throw new Error(error || "GitHub release command failed"); +} + +export async function publishGithub(directory: string): Promise { + const release = await Bun.file(join(directory, "release.json")).json() as Release; + if ( + !/^(canary|stable)$/.test(release.channel) + || !/^\d+\.\d+\.\d+$/.test(release.version) + || !/^[a-f0-9]{40}$/.test(release.revision) + || release.archive !== `ace-${release.channel}-${release.version}-macos-arm64.dmg` + ) throw new Error("Invalid desktop release manifest"); + const archive = join(directory, release.archive); + const digest = createHash("sha256").update(await Bun.file(archive).bytes()).digest("hex"); + if (digest !== release.sha256) throw new Error("The DMG does not match its release manifest"); + + const repo = process.env.GITHUB_REPOSITORY + || gh(["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"])!; + const tag = `desktop-${release.channel}-v${release.version}`; + const endpoint = `repos/${repo}`; + const prerelease = release.channel === "canary"; + const ref = gh(["api", `${endpoint}/git/ref/tags/${tag}`], true); + const commit = ref ? gh(["api", `${endpoint}/commits/${tag}`]) : undefined; + if (commit && (JSON.parse(commit) as { sha: string }).sha !== release.revision) { + throw new Error(`${tag} already points to another source revision`); + } + // Listing includes unfinished drafts, so retries can resume after an upload failure. + const existing = gh([ + "api", + `${endpoint}/releases`, + "--paginate", + "--jq", + `.[] | select(.tag_name == "${tag}")`, + ]); + let published: GithubRelease; + if (existing) { + published = JSON.parse(existing) as GithubRelease; + if ( + published.prerelease !== prerelease + || (published.draft && published.target_commitish !== release.revision) + || (!published.draft && !commit) + ) throw new Error(`${tag} does not match the requested release`); + } else { + const notes = `Ace ${release.channel} ${release.version} for Apple silicon (macOS 15+).\n\n` + + `Source: ${release.revision}\n\nSHA-256 (${release.archive}): ${digest}`; + published = JSON.parse( + gh([ + "api", + `${endpoint}/releases`, + "--method", + "POST", + "-f", + `tag_name=${tag}`, + "-f", + `target_commitish=${release.revision}`, + "-f", + `name=Ace ${release.channel} ${release.version}`, + "-f", + `body=${notes}`, + "-F", + "draft=true", + "-F", + `prerelease=${prerelease}`, + "-f", + "make_latest=false", + ])!, + ) as GithubRelease; + } + + const asset = published.assets.find(({ name }) => name === release.archive); + if (asset && (asset.state !== "uploaded" || asset.digest !== `sha256:${digest}`)) { + throw new Error(`${tag} already has a different or incomplete DMG; refusing to overwrite it`); + } + if (!asset) { + gh(["release", "upload", tag, archive, "--repo", repo]); + published = JSON.parse(gh(["api", `${endpoint}/releases/${published.id}`])!) as GithubRelease; + const uploaded = published.assets.find(({ name }) => name === release.archive); + if (uploaded?.state !== "uploaded" || uploaded.digest !== `sha256:${digest}`) { + throw new Error("GitHub did not confirm the uploaded DMG checksum"); + } + } + if (published.draft) { + published = JSON.parse( + gh([ + "api", + `${endpoint}/releases/${published.id}`, + "--method", + "PATCH", + "-F", + "draft=false", + "-f", + `make_latest=${prerelease ? "false" : "true"}`, + ])!, + ) as GithubRelease; + } + const source = JSON.parse(gh(["api", `${endpoint}/commits/${tag}`])!) as { sha: string }; + if (published.draft || source.sha !== release.revision) { + throw new Error("The published GitHub release does not match the source revision"); + } + console.log(`Published GitHub release: ${published.html_url}`); +} + +if (import.meta.main) { + const directory = process.argv[2]; + if (!directory) { + throw new Error("Usage: bun apps/desktop/scripts/github-release.ts "); + } + await publishGithub(resolve(directory)); +} diff --git a/apps/desktop/scripts/publish.ts b/apps/desktop/scripts/publish.ts index d6d5af4..22baf1f 100644 --- a/apps/desktop/scripts/publish.ts +++ b/apps/desktop/scripts/publish.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { S3Client } from "bun"; import { root } from "./build"; +import { publishGithub } from "./github-release"; import { type Release, required, signedTool } from "./release"; import { sparkle } from "./sparkle"; @@ -91,6 +92,8 @@ if (!downloaded.ok || digest(await downloaded.arrayBuffer()) !== release.sha256) "The public download does not match the signed archive. The feed has not been published.", ); } +// A shipped feed must already have its versioned GitHub release and DMG. +await publishGithub(output); // The feed is the commit point: all downloads must already exist and remain immutable. await upload("appcast.xml", "application/rss+xml", "no-store"); const published = await fetch(`${release.url}appcast.xml?check=${crypto.randomUUID()}`, { diff --git a/docs/updates.md b/docs/updates.md index bd5a63c..30da68c 100644 --- a/docs/updates.md +++ b/docs/updates.md @@ -89,9 +89,16 @@ version. The initial pipeline uses complete disk images; delta generation is def ## Hosting and publishing -Keep the source repository private. Only signed release artifacts go to a public Cloudflare R2 -bucket over HTTPS. The app contains no GitHub or Cloudflare credential. Publishing uses R2's -bucket-scoped S3 credentials through Bun; it does not need an account-wide Cloudflare API token. +The source repository is public. Each published desktop version has a GitHub release tagged +`desktop--v` at its exact source revision, with the signed DMG attached. Canary +releases are published prereleases, not drafts or GitHub's latest stable release. Build-only runs +create workflow artifacts but no GitHub release. + +Signed release artifacts also go to a public Cloudflare R2 bucket over HTTPS for Sparkle updates. +The app contains no GitHub or Cloudflare credential. Publishing uses R2's bucket-scoped S3 +credentials through Bun; it does not need an account-wide Cloudflare API token. GitHub publishing +uses the GitHub CLI with repository contents-write permission; CI supplies its workflow token only +to the publishing step. The initial canary uses the existing `luau-updates` bucket under its own `/ace` prefix, at `https://pub-5bdeb2efd19f42f09df9efad8b20c91e.r2.dev/ace`. Luau's feed and signing key are separate. @@ -112,9 +119,18 @@ domain's cache rules to respect that header. The publisher verifies an existing feed's signature and refuses a version regression or changed bytes at an existing archive URL. It uploads and downloads the archive to verify its public bytes, -then uploads and reads back the feed. The feed is the final commit point. Channel releases are +then creates the GitHub release as a draft, uploads and checks the DMG's GitHub-reported SHA-256, +and publishes the release before uploading and reading back the feed. The feed is the final commit +point: a newly offered update already has its GitHub release and attachment. Channel releases are serialized by the workflow. +Retries reuse the channel/version release and matching asset without replacing bytes. A conflicting +source tag, DMG, or incomplete upload fails rather than clobbering a release. If publishing stops +after GitHub succeeds, the GitHub release remains available; rerun `bun desktop publish` with the +same prepared artifacts to finish the feed. Do not rebuild that version: signing changes the bytes. +To backfill only GitHub from a verified existing CI artifact directory, without changing the feed, +run `bun apps/desktop/scripts/github-release.ts `. + ## Building from Ace An agent in an Ace channel can use its shell tool to run `bun desktop ci canary` from its lane. @@ -183,7 +199,9 @@ bun desktop release canary bun desktop publish ``` -`release` builds and verifies without publishing. `publish` uploads the prepared artifact. +`release` builds and verifies without publishing. `publish` uploads the prepared artifact to R2, +publishes its GitHub release, and updates the feed. Local publishing also requires `gh auth login` +with repository contents-write permission. ## Validation