authorgravatar for contact@badbird.devEvan Yu <contact@badbird.dev> 2026-04-16 20:01:27-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-16 17:01:27-07:00
log6891c77d3e88167bd1dde5647b5d781f2d16bd2a
tree3f50cd157cd2b26f9f91e8e8fd5e3c943df205c1
parentd90e15d1d7ecba302d6977315977d5c2a748bf36
signature Signed by PGP key B5690EEEBB952194

Build for Windows ARM (#2080)

Co-authored-by: Julius Marminge <julius0216@outlook.com>

13 files changed, 800 insertions(+), 413 deletions(-)

.github/workflows/release.yml+42-1
...@@ -160,6 +160,11 @@ jobs:...@@ -160,6 +160,11 @@ jobs:
160 platform: win160 platform: win
161 target: nsis161 target: nsis
162 arch: x64162 arch: x64
163 - label: Windows arm64
164 runner: windows-11-arm
165 platform: win
166 target: nsis
167 arch: arm64
163 steps:168 steps:
164 - name: Checkout169 - name: Checkout
165 uses: actions/checkout@v6170 uses: actions/checkout@v6
...@@ -272,6 +277,17 @@ jobs:...@@ -272,6 +277,17 @@ jobs:
272 done277 done
273 fi278 fi
274279
280 # Windows updater metadata is channel-specific (for example
281 # "latest.yml" or "nightly.yml"). Suffix each per-arch copy so the
282 # release job can merge matching arm64/x64 manifests back into one
283 # canonical manifest per channel.
284 if [[ "${{ matrix.platform }}" == "win" ]]; then
285 shopt -s nullglob
286 for manifest in release-publish/*.yml; do
287 mv "$manifest" "${manifest%.yml}-win-${{ matrix.arch }}.yml"
288 done
289 fi
290
275 - name: Upload build artifacts291 - name: Upload build artifacts
276 uses: actions/upload-artifact@v7292 uses: actions/upload-artifact@v7
277 with:293 with:
...@@ -342,11 +358,36 @@ jobs:...@@ -342,11 +358,36 @@ jobs:
342 for x64_manifest in release-assets/*-mac-x64.yml; do358 for x64_manifest in release-assets/*-mac-x64.yml; do
343 arm64_manifest="${x64_manifest%-x64.yml}.yml"359 arm64_manifest="${x64_manifest%-x64.yml}.yml"
344 if [[ -f "$arm64_manifest" ]]; then360 if [[ -f "$arm64_manifest" ]]; then
345 node scripts/merge-mac-update-manifests.ts "$arm64_manifest" "$x64_manifest"361 node scripts/merge-update-manifests.ts --platform mac "$arm64_manifest" "$x64_manifest"
346 rm -f "$x64_manifest"362 rm -f "$x64_manifest"
347 fi363 fi
348 done364 done
349365
366 - name: Merge Windows updater manifests
367 run: |
368 shopt -s nullglob
369 found_windows_manifest=false
370 for x64_manifest in release-assets/*-win-x64.yml; do
371 arm64_manifest="${x64_manifest/-x64.yml/-arm64.yml}"
372 output_manifest="${x64_manifest/-win-x64.yml/.yml}"
373 if [[ ! -f "$arm64_manifest" ]]; then
374 echo "Missing matching arm64 Windows manifest for $x64_manifest" >&2
375 exit 1
376 fi
377
378 found_windows_manifest=true
379 node scripts/merge-update-manifests.ts --platform win \
380 "$arm64_manifest" \
381 "$x64_manifest" \
382 "$output_manifest"
383 rm -f "$arm64_manifest" "$x64_manifest"
384 done
385
386 if [[ "$found_windows_manifest" != true ]]; then
387 echo "No Windows updater manifests found to merge." >&2
388 exit 1
389 fi
390
350 - name: Publish release391 - name: Publish release
351 if: needs.preflight.outputs.previous_tag != ''392 if: needs.preflight.outputs.previous_tag != ''
352 uses: softprops/action-gh-release@v2393 uses: softprops/action-gh-release@v2
CLAUDE.md+1-1
...@@ -1 +1 @@...@@ -1 +1 @@
1AGENTS.md
\ No newline at end of file
1AGENTS.md
bun.lock+3-3
...@@ -202,7 +202,7 @@...@@ -202,7 +202,7 @@
202 "@effect/platform-node-shared": "4.0.0-beta.45",202 "@effect/platform-node-shared": "4.0.0-beta.45",
203 "@effect/sql-sqlite-bun": "4.0.0-beta.45",203 "@effect/sql-sqlite-bun": "4.0.0-beta.45",
204 "@effect/vitest": "4.0.0-beta.45",204 "@effect/vitest": "4.0.0-beta.45",
205 "@types/bun": "^1.3.9",205 "@types/bun": "^1.3.11",
206 "@types/node": "^24.10.13",206 "@types/node": "^24.10.13",
207 "effect": "4.0.0-beta.45",207 "effect": "4.0.0-beta.45",
208 "tsdown": "^0.20.3",208 "tsdown": "^0.20.3",
...@@ -770,7 +770,7 @@...@@ -770,7 +770,7 @@
770770
771 "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],771 "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
772772
773 "@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],773 "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
774774
775 "@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="],775 "@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="],
776776
...@@ -916,7 +916,7 @@...@@ -916,7 +916,7 @@
916916
917 "builder-util-runtime": ["builder-util-runtime@9.5.1", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ=="],917 "builder-util-runtime": ["builder-util-runtime@9.5.1", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ=="],
918918
919 "bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],919 "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
920920
921 "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],921 "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
922922
package.json+6-4
...@@ -16,7 +16,7 @@...@@ -16,7 +16,7 @@
16 "@effect/sql-sqlite-bun": "4.0.0-beta.45",16 "@effect/sql-sqlite-bun": "4.0.0-beta.45",
17 "@effect/vitest": "4.0.0-beta.45",17 "@effect/vitest": "4.0.0-beta.45",
18 "@effect/language-service": "0.84.2",18 "@effect/language-service": "0.84.2",
19 "@types/bun": "^1.3.9",19 "@types/bun": "^1.3.11",
20 "@types/node": "^24.10.13",20 "@types/node": "^24.10.13",
21 "tsdown": "^0.20.3",21 "tsdown": "^0.20.3",
22 "typescript": "^5.7.3",22 "typescript": "^5.7.3",
...@@ -50,7 +50,9 @@...@@ -50,7 +50,9 @@
50 "dist:desktop:dmg:arm64": "node scripts/build-desktop-artifact.ts --platform mac --target dmg --arch arm64",50 "dist:desktop:dmg:arm64": "node scripts/build-desktop-artifact.ts --platform mac --target dmg --arch arm64",
51 "dist:desktop:dmg:x64": "node scripts/build-desktop-artifact.ts --platform mac --target dmg --arch x64",51 "dist:desktop:dmg:x64": "node scripts/build-desktop-artifact.ts --platform mac --target dmg --arch x64",
52 "dist:desktop:linux": "node scripts/build-desktop-artifact.ts --platform linux --target AppImage --arch x64",52 "dist:desktop:linux": "node scripts/build-desktop-artifact.ts --platform linux --target AppImage --arch x64",
53 "dist:desktop:win": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch x64",53 "dist:desktop:win": "node scripts/build-desktop-artifact.ts --platform win --target nsis",
54 "dist:desktop:win:arm64": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch arm64",
55 "dist:desktop:win:x64": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch x64",
54 "release:smoke": "node scripts/release-smoke.ts",56 "release:smoke": "node scripts/release-smoke.ts",
55 "clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .turbo apps/*/.turbo packages/*/.turbo",57 "clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .turbo apps/*/.turbo packages/*/.turbo",
56 "sync:vscode-icons": "node scripts/sync-vscode-icons.mjs"58 "sync:vscode-icons": "node scripts/sync-vscode-icons.mjs"
...@@ -73,10 +75,10 @@...@@ -73,10 +75,10 @@
73 "vite": "^8.0.0"75 "vite": "^8.0.0"
74 },76 },
75 "engines": {77 "engines": {
76 "bun": "^1.3.9",78 "bun": "^1.3.11",
77 "node": "^24.13.1"79 "node": "^24.13.1"
78 },80 },
79 "packageManager": "bun@1.3.9",81 "packageManager": "bun@1.3.11",
80 "msw": {82 "msw": {
81 "workerDirectory": [83 "workerDirectory": [
82 "apps/web/public"84 "apps/web/public"
scripts/build-desktop-artifact.ts+2-8
...@@ -9,6 +9,7 @@ import desktopPackageJson from "../apps/desktop/package.json" with { type: "json...@@ -9,6 +9,7 @@ import desktopPackageJson from "../apps/desktop/package.json" with { type: "json
9import serverPackageJson from "../apps/server/package.json" with { type: "json" };9import serverPackageJson from "../apps/server/package.json" with { type: "json" };
1010
11import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts";11import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts";
12import { getDefaultBuildArch } from "./lib/build-target-arch.ts";
12import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts";13import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts";
1314
14import * as NodeRuntime from "@effect/platform-node/NodeRuntime";15import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
...@@ -82,14 +83,7 @@ function getDefaultArch(platform: typeof BuildPlatform.Type): typeof BuildArch.T...@@ -82,14 +83,7 @@ function getDefaultArch(platform: typeof BuildPlatform.Type): typeof BuildArch.T
82 return "x64";83 return "x64";
83 }84 }
8485
85 if (process.arch === "arm64" && config.archChoices.includes("arm64")) {86 return getDefaultBuildArch(platform, process.arch, process.env, config);
86 return "arm64";
87 }
88 if (process.arch === "x64" && config.archChoices.includes("x64")) {
89 return "x64";
90 }
91
92 return config.archChoices[0] ?? "x64";
93}87}
9488
95class BuildScriptError extends Data.TaggedError("BuildScriptError")<{89class BuildScriptError extends Data.TaggedError("BuildScriptError")<{
scripts/lib/build-target-arch.test.ts created+61
...@@ -0,0 +1,61 @@
1import { assert, describe, it } from "@effect/vitest";
2
3import { getDefaultBuildArch, resolveHostProcessArch } from "./build-target-arch.ts";
4
5describe("build-target-arch", () => {
6 it("prefers arm64 for Windows-on-Arm hosts running x64 emulation", () => {
7 // Windows-on-Arm can run an x64 Node process under emulation while still
8 // exposing the real host CPU via PROCESSOR_ARCHITEW6432.
9 const hostArch = resolveHostProcessArch("win32", "x64", {
10 PROCESSOR_ARCHITECTURE: "AMD64", // The currently running Node process is x64.
11 PROCESSOR_ARCHITEW6432: "ARM64", // Windows exposes the real host CPU here when x64 runs under ARM emulation.
12 });
13
14 assert.equal(hostArch, "arm64");
15 });
16
17 it("falls back to x64 for native x64 Windows hosts", () => {
18 const hostArch = resolveHostProcessArch("win32", "x64", {
19 PROCESSOR_ARCHITECTURE: "AMD64", // Both the process and the Windows host are native x64.
20 });
21
22 assert.equal(hostArch, "x64");
23 });
24
25 it("keeps arm64 when the current process is already native arm64", () => {
26 const hostArch = resolveHostProcessArch("win32", "arm64", {});
27
28 assert.equal(hostArch, "arm64");
29 });
30
31 it("uses the resolved host arch when selecting the default Windows build arch", () => {
32 // This mirrors the packaging script's default-path behavior: the current
33 // process is x64, but the machine itself is ARM64, so the default build
34 // target should be win-arm64 rather than win-x64.
35 const arch = getDefaultBuildArch(
36 "win",
37 "x64",
38 {
39 PROCESSOR_ARCHITECTURE: "AMD64", // The currently running Node process is x64.
40 PROCESSOR_ARCHITEW6432: "ARM64", // The process is x64, but the actual Windows host is ARM64.
41 },
42 { archChoices: ["x64", "arm64"] },
43 );
44
45 assert.equal(arch, "arm64");
46 });
47
48 it("does not apply Windows host env heuristics for non-Windows targets", () => {
49 const arch = getDefaultBuildArch(
50 "linux",
51 "x64",
52 {
53 PROCESSOR_ARCHITECTURE: "AMD64",
54 PROCESSOR_ARCHITEW6432: "ARM64",
55 },
56 { archChoices: ["x64", "arm64"] },
57 );
58
59 assert.equal(arch, "x64");
60 });
61});
scripts/lib/build-target-arch.ts created+50
...@@ -0,0 +1,50 @@
1export type BuildArch = "arm64" | "x64" | "universal";
2export type BuildPlatform = "mac" | "linux" | "win";
3
4interface PlatformConfig {
5 readonly archChoices: ReadonlyArray<BuildArch>;
6}
7
8function normalizeWindowsArch(value: string | undefined): BuildArch | undefined {
9 const normalized = value?.trim().toLowerCase();
10 if (!normalized) return undefined;
11 if (normalized.includes("arm64") || normalized === "aarch64") return "arm64";
12 if (normalized.includes("amd64") || normalized.includes("x64")) return "x64";
13 return undefined;
14}
15
16export function resolveHostProcessArch(
17 platform: NodeJS.Platform,
18 processArch: NodeJS.Architecture,
19 env: NodeJS.ProcessEnv,
20): BuildArch | undefined {
21 if (processArch === "arm64") return "arm64";
22 if (processArch === "x64") {
23 if (platform !== "win32") return "x64";
24
25 // On Windows-on-Arm, x64 Node/Bun can run under emulation while the host
26 // still reports ARM64 via the processor environment variables.
27 return (
28 normalizeWindowsArch(env.PROCESSOR_ARCHITEW6432) ??
29 normalizeWindowsArch(env.PROCESSOR_ARCHITECTURE) ??
30 "x64"
31 );
32 }
33 return undefined;
34}
35
36export function getDefaultBuildArch(
37 platform: BuildPlatform,
38 processArch: NodeJS.Architecture,
39 env: NodeJS.ProcessEnv,
40 platformConfig: PlatformConfig,
41): BuildArch {
42 const hostPlatform: NodeJS.Platform =
43 platform === "win" ? "win32" : platform === "mac" ? "darwin" : "linux";
44 const hostArch = resolveHostProcessArch(hostPlatform, processArch, env);
45 if (hostArch && platformConfig.archChoices.includes(hostArch)) {
46 return hostArch;
47 }
48
49 return platformConfig.archChoices[0] ?? "x64";
50}
scripts/lib/update-manifest.ts created+277
...@@ -0,0 +1,277 @@
1export interface UpdateManifestFile {
2 readonly url: string;
3 readonly sha512: string;
4 readonly size: number;
5}
6
7export type UpdateManifestScalar = string | number | boolean;
8
9export interface UpdateManifest {
10 readonly version: string;
11 readonly releaseDate: string;
12 readonly files: ReadonlyArray<UpdateManifestFile>;
13 readonly extras: Readonly<Record<string, UpdateManifestScalar>>;
14}
15
16interface MutableUpdateManifestFile {
17 url?: string;
18 sha512?: string;
19 size?: number;
20}
21
22function stripSingleQuotes(value: string): string {
23 if (value.startsWith("'") && value.endsWith("'")) {
24 return value.slice(1, -1).replace(/''/g, "'");
25 }
26 return value;
27}
28
29function parseFileRecord(
30 currentFile: MutableUpdateManifestFile | null,
31 sourcePath: string,
32 lineNumber: number,
33 platformLabel: string,
34): UpdateManifestFile | null {
35 if (currentFile === null) {
36 return null;
37 }
38 if (
39 typeof currentFile.url !== "string" ||
40 typeof currentFile.sha512 !== "string" ||
41 typeof currentFile.size !== "number"
42 ) {
43 throw new Error(
44 `Invalid ${platformLabel} update manifest at ${sourcePath}:${lineNumber}: incomplete file entry.`,
45 );
46 }
47 return {
48 url: currentFile.url,
49 sha512: currentFile.sha512,
50 size: currentFile.size,
51 };
52}
53
54function parseScalarValue(rawValue: string): UpdateManifestScalar {
55 const trimmed = rawValue.trim();
56 const isQuoted = trimmed.startsWith("'") && trimmed.endsWith("'") && trimmed.length >= 2;
57 const value = isQuoted ? trimmed.slice(1, -1).replace(/''/g, "'") : trimmed;
58 if (isQuoted) return value;
59 if (value === "true") return true;
60 if (value === "false") return false;
61 if (/^-?\d+(?:\.\d+)?$/.test(value)) {
62 return Number(value);
63 }
64 return value;
65}
66
67export function parseUpdateManifest(
68 raw: string,
69 sourcePath: string,
70 platformLabel: string,
71): UpdateManifest {
72 const lines = raw.split(/\r?\n/);
73 const files: UpdateManifestFile[] = [];
74 const extras: Record<string, UpdateManifestScalar> = {};
75 let version: string | null = null;
76 let releaseDate: string | null = null;
77 let inFiles = false;
78 let currentFile: MutableUpdateManifestFile | null = null;
79
80 for (const [index, rawLine] of lines.entries()) {
81 const lineNumber = index + 1;
82 const line = rawLine.trimEnd();
83 if (line.length === 0) continue;
84
85 const fileUrlMatch = line.match(/^ - url:\s*(.+)$/);
86 if (fileUrlMatch?.[1]) {
87 const finalized = parseFileRecord(currentFile, sourcePath, lineNumber, platformLabel);
88 if (finalized) files.push(finalized);
89 currentFile = { url: stripSingleQuotes(fileUrlMatch[1].trim()) };
90 inFiles = true;
91 continue;
92 }
93
94 const fileShaMatch = line.match(/^ sha512:\s*(.+)$/);
95 if (fileShaMatch?.[1]) {
96 if (currentFile === null) {
97 throw new Error(
98 `Invalid ${platformLabel} update manifest at ${sourcePath}:${lineNumber}: sha512 without a file entry.`,
99 );
100 }
101 currentFile.sha512 = stripSingleQuotes(fileShaMatch[1].trim());
102 continue;
103 }
104
105 const fileSizeMatch = line.match(/^ size:\s*(\d+)$/);
106 if (fileSizeMatch?.[1]) {
107 if (currentFile === null) {
108 throw new Error(
109 `Invalid ${platformLabel} update manifest at ${sourcePath}:${lineNumber}: size without a file entry.`,
110 );
111 }
112 currentFile.size = Number(fileSizeMatch[1]);
113 continue;
114 }
115
116 if (line === "files:") {
117 inFiles = true;
118 continue;
119 }
120
121 if (inFiles && currentFile !== null) {
122 const finalized = parseFileRecord(currentFile, sourcePath, lineNumber, platformLabel);
123 if (finalized) files.push(finalized);
124 currentFile = null;
125 }
126 inFiles = false;
127
128 const topLevelMatch = line.match(/^([A-Za-z][A-Za-z0-9]*):\s*(.+)$/);
129 if (!topLevelMatch?.[1] || topLevelMatch[2] === undefined) {
130 throw new Error(
131 `Invalid ${platformLabel} update manifest at ${sourcePath}:${lineNumber}: unsupported line '${line}'.`,
132 );
133 }
134
135 const [, key, rawValue] = topLevelMatch;
136 const value = parseScalarValue(rawValue);
137
138 if (key === "version") {
139 if (typeof value !== "string") {
140 throw new Error(
141 `Invalid ${platformLabel} update manifest at ${sourcePath}:${lineNumber}: version must be a string.`,
142 );
143 }
144 version = value;
145 continue;
146 }
147
148 if (key === "releaseDate") {
149 if (typeof value !== "string") {
150 throw new Error(
151 `Invalid ${platformLabel} update manifest at ${sourcePath}:${lineNumber}: releaseDate must be a string.`,
152 );
153 }
154 releaseDate = value;
155 continue;
156 }
157
158 if (key === "path" || key === "sha512") {
159 continue;
160 }
161
162 extras[key] = value;
163 }
164
165 const finalized = parseFileRecord(currentFile, sourcePath, lines.length, platformLabel);
166 if (finalized) files.push(finalized);
167
168 if (!version) {
169 throw new Error(`Invalid ${platformLabel} update manifest at ${sourcePath}: missing version.`);
170 }
171 if (!releaseDate) {
172 throw new Error(
173 `Invalid ${platformLabel} update manifest at ${sourcePath}: missing releaseDate.`,
174 );
175 }
176 if (files.length === 0) {
177 throw new Error(`Invalid ${platformLabel} update manifest at ${sourcePath}: missing files.`);
178 }
179
180 return {
181 version,
182 releaseDate,
183 files,
184 extras,
185 };
186}
187
188function mergeExtras(
189 primary: Readonly<Record<string, UpdateManifestScalar>>,
190 secondary: Readonly<Record<string, UpdateManifestScalar>>,
191 platformLabel: string,
192): Record<string, UpdateManifestScalar> {
193 const merged: Record<string, UpdateManifestScalar> = { ...primary };
194
195 for (const [key, value] of Object.entries(secondary)) {
196 const existing = merged[key];
197 if (existing !== undefined && existing !== value) {
198 throw new Error(
199 `Cannot merge ${platformLabel} update manifests: conflicting '${key}' values ('${existing}' vs '${value}').`,
200 );
201 }
202 merged[key] = value;
203 }
204
205 return merged;
206}
207
208export function mergeUpdateManifests(
209 primary: UpdateManifest,
210 secondary: UpdateManifest,
211 platformLabel: string,
212): UpdateManifest {
213 if (primary.version !== secondary.version) {
214 throw new Error(
215 `Cannot merge ${platformLabel} update manifests with different versions (${primary.version} vs ${secondary.version}).`,
216 );
217 }
218
219 const filesByUrl = new Map<string, UpdateManifestFile>();
220 for (const file of [...primary.files, ...secondary.files]) {
221 const existing = filesByUrl.get(file.url);
222 if (existing && (existing.sha512 !== file.sha512 || existing.size !== file.size)) {
223 throw new Error(
224 `Cannot merge ${platformLabel} update manifests: conflicting file entry for ${file.url}.`,
225 );
226 }
227 filesByUrl.set(file.url, file);
228 }
229
230 return {
231 version: primary.version,
232 releaseDate:
233 primary.releaseDate >= secondary.releaseDate ? primary.releaseDate : secondary.releaseDate,
234 files: [...filesByUrl.values()],
235 extras: mergeExtras(primary.extras, secondary.extras, platformLabel),
236 };
237}
238
239function quoteYamlString(value: string): string {
240 return `'${value.replace(/'/g, "''")}'`;
241}
242
243function serializeScalarValue(value: UpdateManifestScalar): string {
244 if (typeof value === "string") {
245 return quoteYamlString(value);
246 }
247 return String(value);
248}
249
250export function serializeUpdateManifest(
251 manifest: UpdateManifest,
252 options: {
253 readonly platformLabel: string;
254 },
255): string {
256 const lines = [`version: ${quoteYamlString(manifest.version)}`, "files:"];
257
258 for (const file of manifest.files) {
259 lines.push(` - url: ${file.url}`);
260 lines.push(` sha512: ${file.sha512}`);
261 lines.push(` size: ${file.size}`);
262 }
263
264 for (const key of Object.keys(manifest.extras).toSorted()) {
265 const value = manifest.extras[key];
266 if (value === undefined) {
267 throw new Error(
268 `Cannot serialize ${options.platformLabel} update manifest: missing value for '${key}'.`,
269 );
270 }
271 lines.push(`${key}: ${serializeScalarValue(value)}`);
272 }
273
274 lines.push(`releaseDate: ${quoteYamlString(manifest.releaseDate)}`);
275 lines.push("");
276 return lines.join("\n");
277}
scripts/merge-mac-update-manifests.test.ts deleted-108
...@@ -1,108 +0,0 @@
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 deleted-287
...@@ -1,287 +0,0 @@
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}
scripts/merge-update-manifests.test.ts created+190
...@@ -0,0 +1,190 @@
1import { assert, describe, it } from "@effect/vitest";
2
3import {
4 mergePlatformUpdateManifests,
5 parsePlatformUpdateManifest,
6 serializePlatformUpdateManifest,
7} from "./merge-update-manifests.ts";
8
9describe("merge-update-manifests", () => {
10 it("merges arm64 and x64 macOS update manifests into one multi-arch manifest", () => {
11 const arm64 = parsePlatformUpdateManifest(
12 "mac",
13 `version: 0.0.4
14files:
15 - url: T3-Code-0.0.4-arm64.zip
16 sha512: arm64zip
17 size: 125621344
18 - url: T3-Code-0.0.4-arm64.dmg
19 sha512: arm64dmg
20 size: 131754935
21path: T3-Code-0.0.4-arm64.zip
22sha512: arm64zip
23releaseDate: '2026-03-07T10:32:14.587Z'
24`,
25 "latest-mac.yml",
26 );
27
28 const x64 = parsePlatformUpdateManifest(
29 "mac",
30 `version: 0.0.4
31files:
32 - url: T3-Code-0.0.4-x64.zip
33 sha512: x64zip
34 size: 132000112
35 - url: T3-Code-0.0.4-x64.dmg
36 sha512: x64dmg
37 size: 138148807
38path: T3-Code-0.0.4-x64.zip
39sha512: x64zip
40releaseDate: '2026-03-07T10:36:07.540Z'
41`,
42 "latest-mac-x64.yml",
43 );
44
45 const merged = mergePlatformUpdateManifests("mac", arm64, x64);
46
47 assert.equal(merged.version, "0.0.4");
48 assert.equal(merged.releaseDate, "2026-03-07T10:36:07.540Z");
49 assert.deepStrictEqual(
50 merged.files.map((file) => file.url),
51 [
52 "T3-Code-0.0.4-arm64.zip",
53 "T3-Code-0.0.4-arm64.dmg",
54 "T3-Code-0.0.4-x64.zip",
55 "T3-Code-0.0.4-x64.dmg",
56 ],
57 );
58
59 const serialized = serializePlatformUpdateManifest("mac", merged);
60 assert.ok(!serialized.includes("path:"));
61 assert.equal((serialized.match(/- url:/g) ?? []).length, 4);
62 });
63
64 it("merges arm64 and x64 Windows update manifests into one multi-arch manifest", () => {
65 const arm64 = parsePlatformUpdateManifest(
66 "win",
67 `version: 0.0.4
68files:
69 - url: T3-Code-0.0.4-arm64.exe
70 sha512: arm64exe
71 size: 125621344
72 - url: T3-Code-0.0.4-arm64.exe.blockmap
73 sha512: arm64blockmap
74 size: 131754
75path: T3-Code-0.0.4-arm64.exe
76sha512: arm64exe
77releaseDate: '2026-03-07T10:32:14.587Z'
78`,
79 "latest-win-arm64.yml",
80 );
81
82 const x64 = parsePlatformUpdateManifest(
83 "win",
84 `version: 0.0.4
85files:
86 - url: T3-Code-0.0.4-x64.exe
87 sha512: x64exe
88 size: 132000112
89 - url: T3-Code-0.0.4-x64.exe.blockmap
90 sha512: x64blockmap
91 size: 138148
92path: T3-Code-0.0.4-x64.exe
93sha512: x64exe
94releaseDate: '2026-03-07T10:36:07.540Z'
95`,
96 "latest-win-x64.yml",
97 );
98
99 const merged = mergePlatformUpdateManifests("win", arm64, x64);
100
101 assert.equal(merged.version, "0.0.4");
102 assert.equal(merged.releaseDate, "2026-03-07T10:36:07.540Z");
103 assert.deepStrictEqual(
104 merged.files.map((file) => file.url),
105 [
106 "T3-Code-0.0.4-arm64.exe",
107 "T3-Code-0.0.4-arm64.exe.blockmap",
108 "T3-Code-0.0.4-x64.exe",
109 "T3-Code-0.0.4-x64.exe.blockmap",
110 ],
111 );
112
113 const serialized = serializePlatformUpdateManifest("win", merged);
114 assert.ok(!serialized.includes("path:"));
115 assert.equal((serialized.match(/- url:/g) ?? []).length, 4);
116 });
117
118 it("rejects mismatched manifest versions", () => {
119 const primary = parsePlatformUpdateManifest(
120 "win",
121 `version: 0.0.4
122files:
123 - url: T3-Code-0.0.4-arm64.exe
124 sha512: arm64exe
125 size: 1
126releaseDate: '2026-03-07T10:32:14.587Z'
127`,
128 "latest-win-arm64.yml",
129 );
130
131 const secondary = parsePlatformUpdateManifest(
132 "win",
133 `version: 0.0.5
134files:
135 - url: T3-Code-0.0.5-x64.exe
136 sha512: x64exe
137 size: 1
138releaseDate: '2026-03-07T10:36:07.540Z'
139`,
140 "latest-win-x64.yml",
141 );
142
143 assert.throws(
144 () => mergePlatformUpdateManifests("win", primary, secondary),
145 /different versions/,
146 );
147 });
148
149 it("preserves quoted scalars as strings", () => {
150 const manifest = parsePlatformUpdateManifest(
151 "mac",
152 `version: '1.0'
153files:
154 - url: T3-Code-1.0-x64.zip
155 sha512: zipsha
156 size: 1
157releaseName: 'true'
158minimumSystemVersion: '13.0'
159stagingPercentage: 50
160releaseDate: '2026-03-07T10:36:07.540Z'
161`,
162 "latest-mac.yml",
163 );
164
165 assert.equal(manifest.version, "1.0");
166 assert.equal(manifest.extras.releaseName, "true");
167 assert.equal(manifest.extras.minimumSystemVersion, "13.0");
168 assert.equal(manifest.extras.stagingPercentage, 50);
169 });
170
171 it("round-trips numeric-looking versions as strings", () => {
172 const original = parsePlatformUpdateManifest(
173 "win",
174 `version: '1.0'
175files:
176 - url: T3-Code-1.0-x64.exe
177 sha512: exesha
178 size: 1
179releaseDate: '2026-03-07T10:36:07.540Z'
180`,
181 "latest-win-x64.yml",
182 );
183
184 const serialized = serializePlatformUpdateManifest("win", original);
185 assert.ok(serialized.includes("version: '1.0'"));
186
187 const reparsed = parsePlatformUpdateManifest("win", serialized, "latest-win-x64.yml");
188 assert.equal(reparsed.version, "1.0");
189 });
190});
scripts/merge-update-manifests.ts created+91
...@@ -0,0 +1,91 @@
1import { readFileSync, writeFileSync } from "node:fs";
2import { resolve } from "node:path";
3import { fileURLToPath } from "node:url";
4
5import {
6 mergeUpdateManifests,
7 parseUpdateManifest,
8 serializeUpdateManifest,
9 type UpdateManifest,
10} from "./lib/update-manifest.ts";
11
12export type UpdateManifestPlatform = "mac" | "win";
13
14function getPlatformLabel(platform: UpdateManifestPlatform): string {
15 return platform === "mac" ? "macOS" : "Windows";
16}
17
18export function parsePlatformUpdateManifest(
19 platform: UpdateManifestPlatform,
20 raw: string,
21 sourcePath: string,
22): UpdateManifest {
23 return parseUpdateManifest(raw, sourcePath, getPlatformLabel(platform));
24}
25
26export function mergePlatformUpdateManifests(
27 platform: UpdateManifestPlatform,
28 primary: UpdateManifest,
29 secondary: UpdateManifest,
30): UpdateManifest {
31 return mergeUpdateManifests(primary, secondary, getPlatformLabel(platform));
32}
33
34export function serializePlatformUpdateManifest(
35 platform: UpdateManifestPlatform,
36 manifest: UpdateManifest,
37): string {
38 return serializeUpdateManifest(manifest, {
39 platformLabel: getPlatformLabel(platform),
40 });
41}
42
43function parseArgs(args: ReadonlyArray<string>): {
44 platform: UpdateManifestPlatform;
45 primaryPath: string;
46 secondaryPath: string;
47 outputPath: string;
48} {
49 const [platformFlag, platformValue, primaryPathArg, secondaryPathArg, outputPathArg] = args;
50 if (platformFlag !== "--platform" || (platformValue !== "mac" && platformValue !== "win")) {
51 throw new Error(
52 "Usage: node scripts/merge-update-manifests.ts --platform <mac|win> <primary-path> <secondary-path> [output-path]",
53 );
54 }
55 if (!primaryPathArg || !secondaryPathArg) {
56 throw new Error(
57 "Usage: node scripts/merge-update-manifests.ts --platform <mac|win> <primary-path> <secondary-path> [output-path]",
58 );
59 }
60
61 const primaryPath = resolve(primaryPathArg);
62 const secondaryPath = resolve(secondaryPathArg);
63 const outputPath = resolve(outputPathArg ?? primaryPathArg);
64
65 return {
66 platform: platformValue,
67 primaryPath,
68 secondaryPath,
69 outputPath,
70 };
71}
72
73function main(args: ReadonlyArray<string>): void {
74 const { platform, primaryPath, secondaryPath, outputPath } = parseArgs(args);
75 const primaryManifest = parsePlatformUpdateManifest(
76 platform,
77 readFileSync(primaryPath, "utf8"),
78 primaryPath,
79 );
80 const secondaryManifest = parsePlatformUpdateManifest(
81 platform,
82 readFileSync(secondaryPath, "utf8"),
83 secondaryPath,
84 );
85 const merged = mergePlatformUpdateManifests(platform, primaryManifest, secondaryManifest);
86 writeFileSync(outputPath, serializePlatformUpdateManifest(platform, merged));
87}
88
89if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
90 main(process.argv.slice(2));
91}
scripts/release-smoke.ts+77-1
...@@ -70,6 +70,48 @@ releaseDate: '2026-03-08T10:36:07.540Z'...@@ -70,6 +70,48 @@ releaseDate: '2026-03-08T10:36:07.540Z'
70 return { arm64Path, x64Path };70 return { arm64Path, x64Path };
71}71}
7272
73function writeWindowsManifestFixtures(targetRoot: string): { arm64Path: string; x64Path: string } {
74 const assetDirectory = resolve(targetRoot, "release-assets");
75 mkdirSync(assetDirectory, { recursive: true });
76
77 const arm64Path = resolve(assetDirectory, "latest-win-arm64.yml");
78 const x64Path = resolve(assetDirectory, "latest-win-x64.yml");
79
80 writeFileSync(
81 arm64Path,
82 `version: 9.9.9-smoke.0
83files:
84 - url: T3-Code-9.9.9-smoke.0-arm64.exe
85 sha512: arm64exe
86 size: 126621344
87 - url: T3-Code-9.9.9-smoke.0-arm64.exe.blockmap
88 sha512: arm64blockmap
89 size: 152344
90path: T3-Code-9.9.9-smoke.0-arm64.exe
91sha512: arm64exe
92releaseDate: '2026-03-08T10:32:14.587Z'
93`,
94 );
95
96 writeFileSync(
97 x64Path,
98 `version: 9.9.9-smoke.0
99files:
100 - url: T3-Code-9.9.9-smoke.0-x64.exe
101 sha512: x64exe
102 size: 132000112
103 - url: T3-Code-9.9.9-smoke.0-x64.exe.blockmap
104 sha512: x64blockmap
105 size: 160112
106path: T3-Code-9.9.9-smoke.0-x64.exe
107sha512: x64exe
108releaseDate: '2026-03-08T10:36:07.540Z'
109`,
110 );
111
112 return { arm64Path, x64Path };
113}
114
73function assertContains(haystack: string, needle: string, message: string): void {115function assertContains(haystack: string, needle: string, message: string): void {
74 if (!haystack.includes(needle)) {116 if (!haystack.includes(needle)) {
75 throw new Error(message);117 throw new Error(message);
...@@ -144,7 +186,13 @@ try {...@@ -144,7 +186,13 @@ try {
144 const { arm64Path, x64Path } = writeMacManifestFixtures(tempRoot);186 const { arm64Path, x64Path } = writeMacManifestFixtures(tempRoot);
145 execFileSync(187 execFileSync(
146 process.execPath,188 process.execPath,
147 [resolve(repoRoot, "scripts/merge-mac-update-manifests.ts"), arm64Path, x64Path],189 [
190 resolve(repoRoot, "scripts/merge-update-manifests.ts"),
191 "--platform",
192 "mac",
193 arm64Path,
194 x64Path,
195 ],
148 {196 {
149 cwd: repoRoot,197 cwd: repoRoot,
150 stdio: "inherit",198 stdio: "inherit",
...@@ -163,6 +211,34 @@ try {...@@ -163,6 +211,34 @@ try {
163 "Merged manifest is missing the x64 asset.",211 "Merged manifest is missing the x64 asset.",
164 );212 );
165213
214 const { arm64Path: winArm64Path, x64Path: winX64Path } = writeWindowsManifestFixtures(tempRoot);
215 execFileSync(
216 process.execPath,
217 [
218 resolve(repoRoot, "scripts/merge-update-manifests.ts"),
219 "--platform",
220 "win",
221 winArm64Path,
222 winX64Path,
223 ],
224 {
225 cwd: repoRoot,
226 stdio: "inherit",
227 },
228 );
229
230 const mergedWindowsManifest = readFileSync(winArm64Path, "utf8");
231 assertContains(
232 mergedWindowsManifest,
233 "T3-Code-9.9.9-smoke.0-arm64.exe",
234 "Merged Windows manifest is missing the arm64 asset.",
235 );
236 assertContains(
237 mergedWindowsManifest,
238 "T3-Code-9.9.9-smoke.0-x64.exe",
239 "Merged Windows manifest is missing the x64 asset.",
240 );
241
166 console.log("Release smoke checks passed.");242 console.log("Release smoke checks passed.");
167} finally {243} finally {
168 rmSync(tempRoot, { recursive: true, force: true });244 rmSync(tempRoot, { recursive: true, force: true });