authorgravatar for noahmgregory@gmail.comNoah Gregory <noahmgregory@gmail.com> 2026-03-08 21:03:18-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-08 18:03:18-07:00
log5de8e9d1ae5dfae3e7ea2bb64c9ecd1420d63821
tree44459d2e4dee72d4ea31ff1747a6f7734f7b84d6
parent96e1e3db5eab48d641c80b0af009f2743b585535
signature Signed by PGP key B5690EEEBB952194

fix: properly emit update metadata for intel macs (#446)

* fix: properly emit update metadata for intel macs * refactor: fix broken parsing of quoted values

4 files changed, 405 insertions(+), 0 deletions(-)

.github/workflows/release.yml+7
......@@ -262,6 +262,13 @@ jobs:
262262 merge-multiple: true
263263 path: release-assets
264264
265 - name: Merge macOS updater manifests
266 run: |
267 node scripts/merge-mac-update-manifests.ts \
268 release-assets/latest-mac.yml \
269 release-assets/latest-mac-x64.yml
270 rm -f release-assets/latest-mac-x64.yml
271
265272 - name: Publish release
266273 uses: softprops/action-gh-release@v2
267274 with:
docs/release.md+3
......@@ -36,6 +36,9 @@ This document covers how to run desktop releases from one tag, first without sig
3636 - platform installers (`.exe`, `.dmg`, `.AppImage`, plus macOS `.zip` for Squirrel.Mac update payloads)
3737 - `latest*.yml` metadata
3838 - `*.blockmap` files (used for differential downloads)
39- macOS metadata note:
40 - `electron-updater` reads `latest-mac.yml` for both Intel and Apple Silicon.
41 - The workflow merges the per-arch mac manifests into one `latest-mac.yml` before publishing the GitHub Release.
3942
4043## 0) npm OIDC trusted publishing setup (CLI)
4144
scripts/merge-mac-update-manifests.test.ts created+108
......@@ -0,0 +1,108 @@
1import { assert, describe, it } from "@effect/vitest";
2
3import {
4 mergeMacUpdateManifests,
5 parseMacUpdateManifest,
6 serializeMacUpdateManifest,
7} from "./merge-mac-update-manifests.ts";
8
9describe("merge-mac-update-manifests", () => {
10 it("merges arm64 and x64 macOS update manifests into one multi-arch manifest", () => {
11 const arm64 = parseMacUpdateManifest(
12 `version: 0.0.4
13files:
14 - url: T3-Code-0.0.4-arm64.zip
15 sha512: arm64zip
16 size: 125621344
17 - url: T3-Code-0.0.4-arm64.dmg
18 sha512: arm64dmg
19 size: 131754935
20path: T3-Code-0.0.4-arm64.zip
21sha512: arm64zip
22releaseDate: '2026-03-07T10:32:14.587Z'
23`,
24 "latest-mac.yml",
25 );
26
27 const x64 = parseMacUpdateManifest(
28 `version: 0.0.4
29files:
30 - url: T3-Code-0.0.4-x64.zip
31 sha512: x64zip
32 size: 132000112
33 - url: T3-Code-0.0.4-x64.dmg
34 sha512: x64dmg
35 size: 138148807
36path: T3-Code-0.0.4-x64.zip
37sha512: x64zip
38releaseDate: '2026-03-07T10:36:07.540Z'
39`,
40 "latest-mac-x64.yml",
41 );
42
43 const merged = mergeMacUpdateManifests(arm64, x64);
44
45 assert.equal(merged.version, "0.0.4");
46 assert.equal(merged.releaseDate, "2026-03-07T10:36:07.540Z");
47 assert.deepStrictEqual(
48 merged.files.map((file) => file.url),
49 [
50 "T3-Code-0.0.4-arm64.zip",
51 "T3-Code-0.0.4-arm64.dmg",
52 "T3-Code-0.0.4-x64.zip",
53 "T3-Code-0.0.4-x64.dmg",
54 ],
55 );
56
57 const serialized = serializeMacUpdateManifest(merged);
58 assert.ok(!serialized.includes("path:"));
59 assert.equal((serialized.match(/- url:/g) ?? []).length, 4);
60 });
61
62 it("rejects mismatched manifest versions", () => {
63 const arm64 = parseMacUpdateManifest(
64 `version: 0.0.4
65files:
66 - url: T3-Code-0.0.4-arm64.zip
67 sha512: arm64zip
68 size: 1
69releaseDate: '2026-03-07T10:32:14.587Z'
70`,
71 "latest-mac.yml",
72 );
73
74 const x64 = parseMacUpdateManifest(
75 `version: 0.0.5
76files:
77 - url: T3-Code-0.0.5-x64.zip
78 sha512: x64zip
79 size: 1
80releaseDate: '2026-03-07T10:36:07.540Z'
81`,
82 "latest-mac-x64.yml",
83 );
84
85 assert.throws(() => mergeMacUpdateManifests(arm64, x64), /different versions/);
86 });
87
88 it("preserves quoted scalars as strings", () => {
89 const manifest = parseMacUpdateManifest(
90 `version: '1.0'
91files:
92 - url: T3-Code-1.0-x64.zip
93 sha512: zipsha
94 size: 1
95releaseName: 'true'
96minimumSystemVersion: '13.0'
97stagingPercentage: 50
98releaseDate: '2026-03-07T10:36:07.540Z'
99`,
100 "latest-mac.yml",
101 );
102
103 assert.equal(manifest.version, "1.0");
104 assert.equal(manifest.extras.releaseName, "true");
105 assert.equal(manifest.extras.minimumSystemVersion, "13.0");
106 assert.equal(manifest.extras.stagingPercentage, 50);
107 });
108});
scripts/merge-mac-update-manifests.ts created+287
......@@ -0,0 +1,287 @@
1import { readFileSync, writeFileSync } from "node:fs";
2import { resolve } from "node:path";
3import { fileURLToPath } from "node:url";
4
5interface MacUpdateFile {
6 readonly url: string;
7 readonly sha512: string;
8 readonly size: number;
9}
10
11type MacUpdateScalar = string | number | boolean;
12
13interface MacUpdateManifest {
14 readonly version: string;
15 readonly releaseDate: string;
16 readonly files: ReadonlyArray<MacUpdateFile>;
17 readonly extras: Readonly<Record<string, MacUpdateScalar>>;
18}
19
20interface MutableMacUpdateFile {
21 url?: string;
22 sha512?: string;
23 size?: number;
24}
25
26function stripSingleQuotes(value: string): string {
27 if (value.startsWith("'") && value.endsWith("'")) {
28 return value.slice(1, -1).replace(/''/g, "'");
29 }
30 return value;
31}
32
33function parseFileRecord(
34 currentFile: MutableMacUpdateFile | null,
35 sourcePath: string,
36 lineNumber: number,
37): MacUpdateFile | null {
38 if (currentFile === null) {
39 return null;
40 }
41 if (
42 typeof currentFile.url !== "string" ||
43 typeof currentFile.sha512 !== "string" ||
44 typeof currentFile.size !== "number"
45 ) {
46 throw new Error(
47 `Invalid macOS update manifest at ${sourcePath}:${lineNumber}: incomplete file entry.`,
48 );
49 }
50 return {
51 url: currentFile.url,
52 sha512: currentFile.sha512,
53 size: currentFile.size,
54 };
55}
56
57function parseScalarValue(rawValue: string): MacUpdateScalar {
58 const trimmed = rawValue.trim();
59 const isQuoted = trimmed.startsWith("'") && trimmed.endsWith("'") && trimmed.length >= 2;
60 const value = isQuoted ? trimmed.slice(1, -1).replace(/''/g, "'") : trimmed;
61 if (isQuoted) return value;
62 if (value === "true") return true;
63 if (value === "false") return false;
64 if (/^-?\d+(?:\.\d+)?$/.test(value)) {
65 return Number(value);
66 }
67 return value;
68}
69
70export function parseMacUpdateManifest(raw: string, sourcePath: string): MacUpdateManifest {
71 const lines = raw.split(/\r?\n/);
72 const files: MacUpdateFile[] = [];
73 const extras: Record<string, MacUpdateScalar> = {};
74 let version: string | null = null;
75 let releaseDate: string | null = null;
76 let inFiles = false;
77 let currentFile: MutableMacUpdateFile | null = null;
78
79 for (const [index, rawLine] of lines.entries()) {
80 const lineNumber = index + 1;
81 const line = rawLine.trimEnd();
82 if (line.length === 0) continue;
83
84 const fileUrlMatch = line.match(/^ - url:\s*(.+)$/);
85 if (fileUrlMatch?.[1]) {
86 const finalized = parseFileRecord(currentFile, sourcePath, lineNumber);
87 if (finalized) files.push(finalized);
88 currentFile = { url: stripSingleQuotes(fileUrlMatch[1].trim()) };
89 inFiles = true;
90 continue;
91 }
92
93 const fileShaMatch = line.match(/^ sha512:\s*(.+)$/);
94 if (fileShaMatch?.[1]) {
95 if (currentFile === null) {
96 throw new Error(
97 `Invalid macOS update manifest at ${sourcePath}:${lineNumber}: sha512 without a file entry.`,
98 );
99 }
100 currentFile.sha512 = stripSingleQuotes(fileShaMatch[1].trim());
101 continue;
102 }
103
104 const fileSizeMatch = line.match(/^ size:\s*(\d+)$/);
105 if (fileSizeMatch?.[1]) {
106 if (currentFile === null) {
107 throw new Error(
108 `Invalid macOS update manifest at ${sourcePath}:${lineNumber}: size without a file entry.`,
109 );
110 }
111 currentFile.size = Number(fileSizeMatch[1]);
112 continue;
113 }
114
115 if (line === "files:") {
116 inFiles = true;
117 continue;
118 }
119
120 if (inFiles && currentFile !== null) {
121 const finalized = parseFileRecord(currentFile, sourcePath, lineNumber);
122 if (finalized) files.push(finalized);
123 currentFile = null;
124 }
125 inFiles = false;
126
127 const topLevelMatch = line.match(/^([A-Za-z][A-Za-z0-9]*):\s*(.+)$/);
128 if (!topLevelMatch?.[1] || topLevelMatch[2] === undefined) {
129 throw new Error(
130 `Invalid macOS update manifest at ${sourcePath}:${lineNumber}: unsupported line '${line}'.`,
131 );
132 }
133
134 const [, key, rawValue] = topLevelMatch;
135 const value = parseScalarValue(rawValue);
136
137 if (key === "version") {
138 if (typeof value !== "string") {
139 throw new Error(
140 `Invalid macOS update manifest at ${sourcePath}:${lineNumber}: version must be a string.`,
141 );
142 }
143 version = value;
144 continue;
145 }
146
147 if (key === "releaseDate") {
148 if (typeof value !== "string") {
149 throw new Error(
150 `Invalid macOS update manifest at ${sourcePath}:${lineNumber}: releaseDate must be a string.`,
151 );
152 }
153 releaseDate = value;
154 continue;
155 }
156
157 if (key === "path" || key === "sha512") {
158 continue;
159 }
160
161 extras[key] = value;
162 }
163
164 const finalized = parseFileRecord(currentFile, sourcePath, lines.length);
165 if (finalized) files.push(finalized);
166
167 if (!version) {
168 throw new Error(`Invalid macOS update manifest at ${sourcePath}: missing version.`);
169 }
170 if (!releaseDate) {
171 throw new Error(`Invalid macOS update manifest at ${sourcePath}: missing releaseDate.`);
172 }
173 if (files.length === 0) {
174 throw new Error(`Invalid macOS update manifest at ${sourcePath}: missing files.`);
175 }
176
177 return {
178 version,
179 releaseDate,
180 files,
181 extras,
182 };
183}
184
185function mergeExtras(
186 primary: Readonly<Record<string, MacUpdateScalar>>,
187 secondary: Readonly<Record<string, MacUpdateScalar>>,
188): Record<string, MacUpdateScalar> {
189 const merged: Record<string, MacUpdateScalar> = { ...primary };
190
191 for (const [key, value] of Object.entries(secondary)) {
192 const existing = merged[key];
193 if (existing !== undefined && existing !== value) {
194 throw new Error(
195 `Cannot merge macOS update manifests: conflicting '${key}' values ('${existing}' vs '${value}').`,
196 );
197 }
198 merged[key] = value;
199 }
200
201 return merged;
202}
203
204export function mergeMacUpdateManifests(
205 primary: MacUpdateManifest,
206 secondary: MacUpdateManifest,
207): MacUpdateManifest {
208 if (primary.version !== secondary.version) {
209 throw new Error(
210 `Cannot merge macOS update manifests with different versions (${primary.version} vs ${secondary.version}).`,
211 );
212 }
213
214 const filesByUrl = new Map<string, MacUpdateFile>();
215 for (const file of [...primary.files, ...secondary.files]) {
216 const existing = filesByUrl.get(file.url);
217 if (existing && (existing.sha512 !== file.sha512 || existing.size !== file.size)) {
218 throw new Error(
219 `Cannot merge macOS update manifests: conflicting file entry for ${file.url}.`,
220 );
221 }
222 filesByUrl.set(file.url, file);
223 }
224
225 return {
226 version: primary.version,
227 releaseDate:
228 primary.releaseDate >= secondary.releaseDate ? primary.releaseDate : secondary.releaseDate,
229 files: [...filesByUrl.values()],
230 extras: mergeExtras(primary.extras, secondary.extras),
231 };
232}
233
234function quoteYamlString(value: string): string {
235 return `'${value.replace(/'/g, "''")}'`;
236}
237
238function serializeScalarValue(value: MacUpdateScalar): string {
239 if (typeof value === "string") {
240 return quoteYamlString(value);
241 }
242 return String(value);
243}
244
245export function serializeMacUpdateManifest(manifest: MacUpdateManifest): string {
246 const lines = [`version: ${manifest.version}`, "files:"];
247
248 for (const file of manifest.files) {
249 lines.push(` - url: ${file.url}`);
250 lines.push(` sha512: ${file.sha512}`);
251 lines.push(` size: ${file.size}`);
252 }
253
254 for (const key of Object.keys(manifest.extras).toSorted()) {
255 const value = manifest.extras[key];
256 if (value === undefined) {
257 throw new Error(`Cannot serialize macOS update manifest: missing value for '${key}'.`);
258 }
259 lines.push(`${key}: ${serializeScalarValue(value)}`);
260 }
261
262 lines.push(`releaseDate: ${quoteYamlString(manifest.releaseDate)}`);
263 lines.push("");
264 return lines.join("\n");
265}
266
267function main(args: ReadonlyArray<string>): void {
268 const [arm64PathArg, x64PathArg, outputPathArg] = args;
269 if (!arm64PathArg || !x64PathArg) {
270 throw new Error(
271 "Usage: node scripts/merge-mac-update-manifests.ts <latest-mac.yml> <latest-mac-x64.yml> [output-path]",
272 );
273 }
274
275 const arm64Path = resolve(arm64PathArg);
276 const x64Path = resolve(x64PathArg);
277 const outputPath = resolve(outputPathArg ?? arm64PathArg);
278
279 const arm64Manifest = parseMacUpdateManifest(readFileSync(arm64Path, "utf8"), arm64Path);
280 const x64Manifest = parseMacUpdateManifest(readFileSync(x64Path, "utf8"), x64Path);
281 const merged = mergeMacUpdateManifests(arm64Manifest, x64Manifest);
282 writeFileSync(outputPath, serializeMacUpdateManifest(merged));
283}
284
285if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
286 main(process.argv.slice(2));
287}