Skip to content

Commit 7add838

Browse files
authored
feat(desktop): version canary builds by run number and build stable only from main (#434)
* feat(desktop): version canary builds by run number and build stable only from main Canary's bundle version is now <package version>.<workflow run number>, so it can ship patch builds without a package bump; its shown version is <package version>+<sha7> in About Ace, Settings, and the GitHub release name. Tags, archives, and feed entries use the bundle version. Stable keeps plain x.y.z. The publisher compares versions with Sparkle's numeric order. The release workflow refuses a stable revision that main does not contain, and `bun desktop ci stable` dispatches on main to match the environment. Why: apps/desktop/scripts/version.ts#function version | bundle versions must stay strictly increasing per channel because Sparkle orders updates by CFBundleVersion alone; the SHA is display-only and the canary build number must exceed every published canary's Why: apps/desktop/scripts/version.ts#function order | must match Sparkle's comparator: a longer numeric version is newer when shared parts match, so 0.0.46.N follows the three-part canaries Why: .github/workflows/desktop-release.yml | stable must build only revisions main contains; the environment's branch rule alone does not cover the revision input * lane: sync release-versions memory
1 parent 411b4fb commit 7add838

12 files changed

Lines changed: 182 additions & 40 deletions

File tree

‎.github/workflows/desktop-release.yml‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,25 @@ jobs:
4040
ACE_NOTARY_ISSUER: ${{ vars.ACE_NOTARY_ISSUER }}
4141
ACE_RELEASE_CHANNEL: ${{ inputs.channel }}
4242
ACE_RELEASE_REVISION: ${{ inputs.revision || github.sha }}
43+
# Sparkle orders canary builds by this run number; it increases across every dispatch.
44+
ACE_BUILD_NUMBER: ${{ github.run_number }}
4345
steps:
4446
- name: Validate source revision
47+
env:
48+
GH_TOKEN: ${{ github.token }}
4549
run: |
4650
if [[ ! "$ACE_RELEASE_REVISION" =~ ^[a-f0-9]{40}$ ]]; then
4751
echo "::error::The release revision must be a full commit SHA."
4852
exit 1
4953
fi
54+
# The environment admits only runs from main, but the revision input could name any commit.
55+
if [[ "$ACE_RELEASE_CHANNEL" == stable ]]; then
56+
status=$(gh api "repos/$GITHUB_REPOSITORY/compare/$ACE_RELEASE_REVISION...main" --jq .status)
57+
if [[ "$status" != identical && "$status" != ahead ]]; then
58+
echo "::error::Stable releases build only commits on main, which does not contain $ACE_RELEASE_REVISION ($status)."
59+
exit 1
60+
fi
61+
fi
5062
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
5163
with:
5264
ref: ${{ env.ACE_RELEASE_REVISION }}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
id: 01M4ZYXYMVCFZRG0W3T4D7CC4V
3+
anchor: '@file'
4+
created: 2026-10-15T14:19:48Z
5+
norm: '1'
6+
sig: 3d5ee7c2049417a6
7+
body_hash: 659704f733e228e1
8+
raw_hash: 044b7ef8bfbfe3b3
9+
lines: 1-97
10+
---
11+
12+
stable must build only revisions main contains; the environment's branch rule alone does not cover the revision input
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
id: 01M4ZYXYM8WJEQZ9KBPWSVMDN5
3+
anchor: function version
4+
created: 2026-10-15T14:19:48Z
5+
norm: '1'
6+
sig: 7a25e8819d294b11
7+
body_hash: c135f782443afeff
8+
raw_hash: 24b485b88e135d86
9+
lines: 12-22
10+
---
11+
12+
bundle versions must stay strictly increasing per channel because Sparkle orders updates by CFBundleVersion alone; the SHA is display-only and the canary build number must exceed every published canary's
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
id: 01M4ZYXYMJKAM1WYG5BP9D8M9H
3+
anchor: function order
4+
created: 2026-10-15T14:19:48Z
5+
norm: '1'
6+
sig: 185ed31e60918e42
7+
body_hash: 73767f964c61329f
8+
raw_hash: 8399bc3689eea54c
9+
lines: 34-42
10+
---
11+
12+
must match Sparkle's comparator: a longer numeric version is newer when shared parts match, so 0.0.46.N follows the three-part canaries

‎apps/desktop/electrobun.config.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import type { ElectrobunConfig } from "electrobun";
22

3-
import { version } from "./package.json";
3+
import { current } from "./scripts/version";
44

55
const channel = process.env.ACE_BUILD_CHANNEL || "dev";
66

77
export default {
88
app: {
99
name: "Ace",
1010
identifier: channel === "stable" ? "dev.ace.desktop" : `dev.ace.desktop.${channel}`,
11-
version,
11+
// Electrobun writes this to CFBundleVersion and version.json; the helper script moves it to
12+
// CFBundleShortVersionString and sets the orderable bundle version.
13+
version: current(channel).display,
1214
},
1315
build: {
1416
// Electrobun's bundled runtime matches its native FFI; Ace Helper carries its own Bun.

‎apps/desktop/scripts/ci.ts‎

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,12 @@ import { parseArgs } from "node:util";
55

66
import { root } from "./build";
77
import { archiveName, type Release } from "./release";
8+
import { bundlePattern } from "./version";
89

910
const workflow = "desktop-release.yml";
1011
const help = `Signed desktop builds from an Ace channel or terminal. Requires gh auth login.
1112
12-
bun desktop ci canary|stable [--publish] build the current, clean, pushed lane
13+
bun desktop ci canary|stable [--publish] build the current, clean, pushed lane (stable: on main)
1314
bun desktop ci status <run-id> show progress and failed steps
1415
bun desktop ci download <run-id> download and verify a successful build
1516
@@ -37,13 +38,16 @@ function start(repo: string, channel: string, publish: boolean): void {
3738
if (output(["git", "status", "--porcelain"])) {
3839
throw new Error("Commit the lane's changes before starting a release build");
3940
}
40-
const branch = output(["git", "symbolic-ref", "--short", "HEAD"]);
4141
const revision = output(["git", "rev-parse", "HEAD"]);
42-
const remote = JSON.parse(api(`repos/${repo}/branches/${encodeURIComponent(branch)}`)) as {
43-
commit: { sha: string };
44-
};
45-
if (remote.commit.sha !== revision) {
46-
throw new Error(`Push this lane so ${repo}'s ${branch} matches ${revision}`);
42+
// The stable environment admits only runs from main; the workflow checks main contains the revision.
43+
const branch = channel === "stable" ? "main" : output(["git", "symbolic-ref", "--short", "HEAD"]);
44+
if (channel === "canary") {
45+
const remote = JSON.parse(api(`repos/${repo}/branches/${encodeURIComponent(branch)}`)) as {
46+
commit: { sha: string };
47+
};
48+
if (remote.commit.sha !== revision) {
49+
throw new Error(`Push this lane so ${repo}'s ${branch} matches ${revision}`);
50+
}
4751
}
4852
const run = JSON.parse(api(
4953
`repos/${repo}/actions/workflows/${workflow}/dispatches`,
@@ -86,7 +90,7 @@ function download(repo: string, id: string, run: Run): void {
8690
output(["gh", "run", "download", id, "--repo", repo, "--name", artifact.name, "--dir", path]);
8791
const release = JSON.parse(readFileSync(join(path, "release.json"), "utf8")) as Release;
8892
if (
89-
!/^\d+\.\d+\.\d+$/.test(release.version)
93+
!bundlePattern.test(release.version)
9094
|| artifact.name !== `ace-${release.channel}-macos-arm64`
9195
|| release.archive !== archiveName(release.channel, release.version)
9296
|| !/^[a-f0-9]{40}$/.test(release.revision)
@@ -98,7 +102,9 @@ function download(repo: string, id: string, run: Run): void {
98102
const dmg = join(path, release.archive);
99103
const sha256 = createHash("sha256").update(readFileSync(dmg)).digest("hex");
100104
if (sha256 !== release.sha256) throw new Error("The downloaded DMG checksum does not match");
101-
console.log(`Verified Ace ${release.channel} ${release.version} from ${release.revision}.`);
105+
console.log(
106+
`Verified Ace ${release.channel} ${release.display} (${release.version}) from ${release.revision}.`,
107+
);
102108
console.log(dmg);
103109
}
104110

‎apps/desktop/scripts/github-release.ts‎

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { join, resolve } from "node:path";
33

44
import { root } from "./build";
55
import { archiveName, prefix, type Release } from "./release";
6+
import { bundlePattern, order } from "./version";
67

78
type GithubRelease = {
89
id: number;
@@ -30,16 +31,12 @@ function gh(args: string[], missing = false): string | undefined {
3031

3132
const version = (tag: string) => tag.slice(tag.lastIndexOf("-v") + 2);
3233

33-
function compare(a: string, b: string): number {
34-
const [x, y] = [a.split(".").map(Number), b.split(".").map(Number)];
35-
return x[0]! - y[0]! || x[1]! - y[1]! || x[2]! - y[2]!;
36-
}
37-
3834
export async function publishGithub(directory: string): Promise<void> {
3935
const release = await Bun.file(join(directory, "release.json")).json() as Release;
4036
if (
4137
!/^(canary|stable)$/.test(release.channel)
42-
|| !/^\d+\.\d+\.\d+$/.test(release.version)
38+
|| !bundlePattern.test(release.version)
39+
|| !/^\d+\.\d+\.\d+(\+[a-f0-9]{7})?$/.test(release.display)
4340
|| !/^[a-f0-9]{40}$/.test(release.revision)
4441
|| release.archive !== archiveName(release.channel, release.version)
4542
) throw new Error("Invalid desktop release manifest");
@@ -51,9 +48,10 @@ export async function publishGithub(directory: string): Promise<void> {
5148
|| gh(["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"])!;
5249
const tags = `desktop-${prefix(release.channel)}v`;
5350
const tag = `${tags}${release.version}`;
51+
// Matches About Ace; a canary's display version repeats when one commit is built twice.
5452
const title = release.channel === "stable"
55-
? `Ace ${release.version}`
56-
: `Ace Canary ${release.version}`;
53+
? `Ace ${release.display}`
54+
: `Ace Canary ${release.display} (${release.version})`;
5755
const endpoint = `repos/${repo}`;
5856
const prerelease = release.channel === "canary";
5957
const ref = gh(["api", `${endpoint}/git/ref/tags/${tag}`], true);
@@ -80,8 +78,8 @@ export async function publishGithub(directory: string): Promise<void> {
8078
) throw new Error(`${tag} does not match the requested release`);
8179
} else {
8280
const previous = releases
83-
.filter((item) => !item.draft && compare(version(item.tag_name), release.version) < 0)
84-
.sort((a, b) => compare(version(b.tag_name), version(a.tag_name)))[0];
81+
.filter((item) => !item.draft && order(version(item.tag_name), release.version) < 0)
82+
.sort((a, b) => order(version(b.tag_name), version(a.tag_name)))[0];
8583
const changes = JSON.parse(
8684
gh([
8785
"api",

‎apps/desktop/scripts/helper.ts‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,16 @@ import { copyFileSync, cpSync, mkdirSync, readdirSync, readFileSync, writeFileSy
33
import { dirname, join } from "node:path";
44
import { fileURLToPath } from "node:url";
55

6-
import { version } from "../package.json";
76
import { notices } from "./notices";
87
import { lane, run, sparkle } from "./downloads";
8+
import { current } from "./version";
99

1010
const build = process.env.ELECTROBUN_BUILD_DIR;
1111
const channel = process.env.ELECTROBUN_BUILD_ENV;
1212
const identifier = process.env.ELECTROBUN_APP_IDENTIFIER;
1313
if (!build || !channel || !identifier) throw new Error("Run this script through Electrobun");
1414
if (process.env.ELECTROBUN_OS !== "macos") throw new Error("Ace Helper currently supports macOS");
15+
const { bundle, display: version } = current(channel);
1516
const compiler = Bun.which("bun");
1617
if (!compiler) throw new Error("Building Ace Helper requires Bun 1.4 or newer on PATH");
1718
const runtime = Bun.spawnSync([compiler, "--version"]);
@@ -57,6 +58,23 @@ run([
5758
"15.0",
5859
join(contents, "Info.plist"),
5960
]);
61+
// Sparkle orders by CFBundleVersion and shows CFBundleShortVersionString.
62+
run([
63+
"/usr/bin/plutil",
64+
"-replace",
65+
"CFBundleVersion",
66+
"-string",
67+
bundle,
68+
join(contents, "Info.plist"),
69+
]);
70+
run([
71+
"/usr/bin/plutil",
72+
"-insert",
73+
"CFBundleShortVersionString",
74+
"-string",
75+
version,
76+
join(contents, "Info.plist"),
77+
]);
6078
// Electrobun names non-stable bundles "Ace-<channel>", which macOS shows in the menu bar.
6179
if (channel === "canary") {
6280
run([

‎apps/desktop/scripts/publish.ts‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,13 @@ import { root } from "./build";
88
import { publishGithub } from "./github-release";
99
import { type Release, required, signedTool } from "./release";
1010
import { sparkle } from "./downloads";
11+
import { bundlePattern, order } from "./version";
1112

1213
const output = join(root, "artifacts", "sparkle");
1314
const release = await Bun.file(join(output, "release.json")).json() as Release;
15+
if (!bundlePattern.test(release.version)) {
16+
throw new Error("The release manifest's version is not orderable");
17+
}
1418
const bucket = required("ACE_UPDATE_BUCKET");
1519
const account = required("CLOUDFLARE_ACCOUNT_ID");
1620
const storage = new S3Client({
@@ -39,7 +43,10 @@ if (remote.ok) {
3943
match[1]
4044
);
4145
if (
42-
!versions.length || versions.some((version) => Bun.semver.order(version, release.version) > 0)
46+
!versions.length
47+
|| versions.some((version) =>
48+
!bundlePattern.test(version) || order(version, release.version) > 0
49+
)
4350
) {
4451
throw new Error("Refusing to replace a newer or unrecognized release feed");
4552
}
@@ -74,7 +81,9 @@ const current = await fetch(`${release.url}${release.archive}?check=${crypto.ran
7481
});
7582
if (current.ok) {
7683
if (digest(await current.arrayBuffer()) !== release.sha256) {
77-
throw new Error("That release version already exists with different bytes. Bump the version.");
84+
throw new Error(
85+
"That release version already exists with different bytes. Release a new build.",
86+
);
7887
}
7988
} else if (current.status === 404) {
8089
await upload(
@@ -103,5 +112,5 @@ if (!published.ok || await published.text() !== await Bun.file(feed).text()) {
103112
throw new Error("The published feed could not be verified. Check the bucket's cache rules.");
104113
}
105114
console.log(
106-
`Published Ace ${release.version} to ${release.channel}: ${release.url}${release.archive}`,
115+
`Published Ace ${release.display} (${release.version}) to ${release.channel}: ${release.url}${release.archive}`,
107116
);

‎apps/desktop/scripts/release.ts‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ import { createHash, createPrivateKey, createPublicKey } from "node:crypto";
22
import { mkdirSync, readdirSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
33
import { join } from "node:path";
44

5-
import { version } from "../package.json";
65
import { build, root } from "./build";
76
import { sign } from "./sign";
87
import { run, sparkle } from "./downloads";
8+
import { current } from "./version";
99

1010
export function required(name: string): string {
1111
const value = process.env[name];
@@ -45,7 +45,9 @@ async function stapler(action: "staple" | "validate", path: string): Promise<voi
4545
}
4646

4747
export type Release = {
48+
/** The bundle version Sparkle orders by; it also names the archive and tag. */
4849
version: string;
50+
display: string;
4951
revision: string;
5052
channel: string;
5153
archive: string;
@@ -62,9 +64,7 @@ export const archiveName = (channel: string, version: string) =>
6264
async function release(): Promise<void> {
6365
const channel = process.argv[2] || "canary";
6466
if (channel !== "canary" && channel !== "stable") throw new Error("Choose canary or stable");
65-
if (!/^\d+\.\d+\.\d+$/.test(version)) {
66-
throw new Error("Use an incrementing x.y.z desktop version");
67-
}
67+
const { bundle: version, display } = current(channel);
6868
const changes = Bun.spawnSync(["git", "status", "--porcelain"], { cwd: root });
6969
if (!changes.success || changes.stdout.length) {
7070
throw new Error("Commit the lane's changes before making a release");
@@ -225,7 +225,7 @@ async function release(): Promise<void> {
225225
writeFileSync(
226226
join(output, "release.json"),
227227
JSON.stringify(
228-
{ version, revision, channel, archive: filename, url, sha256 } satisfies Release,
228+
{ version, display, revision, channel, archive: filename, url, sha256 } satisfies Release,
229229
null,
230230
"\t",
231231
),

0 commit comments

Comments
 (0)