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
7 changes: 5 additions & 2 deletions .github/workflows/desktop-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -26,6 +26,8 @@ concurrency:

jobs:
release:
permissions:
contents: write
runs-on: macos-15
timeout-minutes: 60
environment: desktop-${{ inputs.channel }}
Expand Down Expand Up @@ -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 }}
Expand Down
134 changes: 134 additions & 0 deletions apps/desktop/scripts/github-release.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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 <artifact-directory>");
}
await publishGithub(resolve(directory));
}
3 changes: 3 additions & 0 deletions apps/desktop/scripts/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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()}`, {
Expand Down
28 changes: 23 additions & 5 deletions docs/updates.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<channel>-v<version>` 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.
Expand All @@ -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 <artifact-directory>`.

## Building from Ace

An agent in an Ace channel can use its shell tool to run `bun desktop ci canary` from its lane.
Expand Down Expand Up @@ -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

Expand Down