From 7f65aa6753def2ea301441167a02d0bc87d5a328 Mon Sep 17 00:00:00 2001 From: Evan Yu <50347938+Badbird5907@users.noreply.github.com> Date: Fri, 3 Apr 2026 14:33:23 -0400 Subject: [PATCH 1/7] bump bun --- bun.lock | 6 +++--- package.json | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/bun.lock b/bun.lock index af243cf4eb..fa3127d831 100644 --- a/bun.lock +++ b/bun.lock @@ -181,7 +181,7 @@ "@effect/platform-node": "4.0.0-beta.43", "@effect/sql-sqlite-bun": "4.0.0-beta.43", "@effect/vitest": "4.0.0-beta.43", - "@types/bun": "^1.3.9", + "@types/bun": "^1.3.11", "@types/node": "^24.10.13", "effect": "4.0.0-beta.43", "tsdown": "^0.20.3", @@ -743,7 +743,7 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], - "@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="], + "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], "@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=="], @@ -883,7 +883,7 @@ "builder-util-runtime": ["builder-util-runtime@9.5.1", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ=="], - "bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], + "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], diff --git a/package.json b/package.json index a26a359c03..fce3db3f85 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "@effect/sql-sqlite-bun": "4.0.0-beta.43", "@effect/vitest": "4.0.0-beta.43", "@effect/language-service": "0.84.2", - "@types/bun": "^1.3.9", + "@types/bun": "^1.3.11", "@types/node": "^24.10.13", "tsdown": "^0.20.3", "typescript": "^5.7.3", @@ -64,10 +64,10 @@ "vite": "^8.0.0" }, "engines": { - "bun": "^1.3.9", + "bun": "^1.3.11", "node": "^24.13.1" }, - "packageManager": "bun@1.3.9", + "packageManager": "bun@1.3.11", "msw": { "workerDirectory": [ "apps/web/public" From 8d391f33dc8345db43f7ce3ee99b737daca9e556 Mon Sep 17 00:00:00 2001 From: Evan Yu <50347938+Badbird5907@users.noreply.github.com> Date: Fri, 3 Apr 2026 16:43:30 -0400 Subject: [PATCH 2/7] add windows arm support --- .github/workflows/release.yml | 11 ++++++ package.json | 4 ++- scripts/build-desktop-artifact.ts | 10 ++---- scripts/lib/build-target-arch.test.ts | 42 +++++++++++++++++++++++ scripts/lib/build-target-arch.ts | 48 +++++++++++++++++++++++++++ 5 files changed, 106 insertions(+), 9 deletions(-) create mode 100644 scripts/lib/build-target-arch.test.ts create mode 100644 scripts/lib/build-target-arch.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 504952e3aa..305893b6c0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -105,6 +105,11 @@ jobs: platform: win target: nsis arch: x64 + - label: Windows arm64 + runner: windows-11-arm + platform: win + target: nsis + arch: arm64 steps: - name: Checkout uses: actions/checkout@v6 @@ -216,6 +221,12 @@ jobs: fi fi + if [[ "${{ matrix.platform }}" == "win" && "${{ matrix.arch }}" == "arm64" ]]; then + if [[ -f release-publish/latest.yml ]]; then + mv release-publish/latest.yml "release-publish/latest-win-arm64.yml" + fi + fi + - name: Upload build artifacts uses: actions/upload-artifact@v7 with: diff --git a/package.json b/package.json index fce3db3f85..0a4a4aed64 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,9 @@ "dist:desktop:dmg:arm64": "node scripts/build-desktop-artifact.ts --platform mac --target dmg --arch arm64", "dist:desktop:dmg:x64": "node scripts/build-desktop-artifact.ts --platform mac --target dmg --arch x64", "dist:desktop:linux": "node scripts/build-desktop-artifact.ts --platform linux --target AppImage --arch x64", - "dist:desktop:win": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch x64", + "dist:desktop:win": "node scripts/build-desktop-artifact.ts --platform win --target nsis", + "dist:desktop:win:arm64": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch arm64", + "dist:desktop:win:x64": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch x64", "release:smoke": "node scripts/release-smoke.ts", "clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .turbo apps/*/.turbo packages/*/.turbo", "sync:vscode-icons": "node scripts/sync-vscode-icons.mjs" diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index c0327ab4ca..f872ec9d05 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -9,6 +9,7 @@ import desktopPackageJson from "../apps/desktop/package.json" with { type: "json import serverPackageJson from "../apps/server/package.json" with { type: "json" }; import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts"; +import { getDefaultBuildArch } from "./lib/build-target-arch.ts"; import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; @@ -91,14 +92,7 @@ function getDefaultArch(platform: typeof BuildPlatform.Type): typeof BuildArch.T return "x64"; } - if (process.arch === "arm64" && config.archChoices.includes("arm64")) { - return "arm64"; - } - if (process.arch === "x64" && config.archChoices.includes("x64")) { - return "x64"; - } - - return config.archChoices[0] ?? "x64"; + return getDefaultBuildArch(platform, process.arch, process.env, config); } class BuildScriptError extends Data.TaggedError("BuildScriptError")<{ diff --git a/scripts/lib/build-target-arch.test.ts b/scripts/lib/build-target-arch.test.ts new file mode 100644 index 0000000000..5ab23eb184 --- /dev/null +++ b/scripts/lib/build-target-arch.test.ts @@ -0,0 +1,42 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { getDefaultBuildArch, resolveHostProcessArch } from "./build-target-arch.ts"; + +describe("build-target-arch", () => { + it("prefers arm64 for Windows-on-Arm hosts running x64 emulation", () => { + const hostArch = resolveHostProcessArch("win32", "x64", { + PROCESSOR_ARCHITECTURE: "AMD64", + PROCESSOR_ARCHITEW6432: "ARM64", + }); + + assert.equal(hostArch, "arm64"); + }); + + it("falls back to x64 for native x64 Windows hosts", () => { + const hostArch = resolveHostProcessArch("win32", "x64", { + PROCESSOR_ARCHITECTURE: "AMD64", + }); + + assert.equal(hostArch, "x64"); + }); + + it("keeps arm64 when the current process is already native arm64", () => { + const hostArch = resolveHostProcessArch("win32", "arm64", {}); + + assert.equal(hostArch, "arm64"); + }); + + it("uses the resolved host arch when selecting the default Windows build arch", () => { + const arch = getDefaultBuildArch( + "win", + "x64", + { + PROCESSOR_ARCHITECTURE: "AMD64", + PROCESSOR_ARCHITEW6432: "ARM64", + }, + { archChoices: ["x64", "arm64"] }, + ); + + assert.equal(arch, "arm64"); + }); +}); diff --git a/scripts/lib/build-target-arch.ts b/scripts/lib/build-target-arch.ts new file mode 100644 index 0000000000..9414ee7cf0 --- /dev/null +++ b/scripts/lib/build-target-arch.ts @@ -0,0 +1,48 @@ +export type BuildArch = "arm64" | "x64" | "universal"; +export type BuildPlatform = "mac" | "linux" | "win"; + +interface PlatformConfig { + readonly archChoices: ReadonlyArray; +} + +function normalizeWindowsArch(value: string | undefined): BuildArch | undefined { + const normalized = value?.trim().toLowerCase(); + if (!normalized) return undefined; + if (normalized.includes("arm64") || normalized === "aarch64") return "arm64"; + if (normalized.includes("amd64") || normalized.includes("x64")) return "x64"; + return undefined; +} + +export function resolveHostProcessArch( + platform: NodeJS.Platform, + processArch: NodeJS.Architecture, + env: NodeJS.ProcessEnv, +): BuildArch | undefined { + if (processArch === "arm64") return "arm64"; + if (processArch === "x64") { + if (platform !== "win32") return "x64"; + + // On Windows-on-Arm, x64 Node/Bun can run under emulation while the host + // still reports ARM64 via the processor environment variables. + return ( + normalizeWindowsArch(env.PROCESSOR_ARCHITEW6432) ?? + normalizeWindowsArch(env.PROCESSOR_ARCHITECTURE) ?? + "x64" + ); + } + return undefined; +} + +export function getDefaultBuildArch( + platform: BuildPlatform, + processArch: NodeJS.Architecture, + env: NodeJS.ProcessEnv, + platformConfig: PlatformConfig, +): BuildArch { + const hostArch = resolveHostProcessArch(process.platform, processArch, env); + if (hostArch && platformConfig.archChoices.includes(hostArch)) { + return hostArch; + } + + return platformConfig.archChoices[0] ?? "x64"; +} From d4f3cded2f3e299551d83e89cea01f7a87f85201 Mon Sep 17 00:00:00 2001 From: Evan Yu <50347938+Badbird5907@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:48:46 -0400 Subject: [PATCH 3/7] fix(build): respect target platform for arch detection --- CLAUDE.md | 2 +- scripts/lib/build-target-arch.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 47dc3e3d86..c317064255 120000 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1 @@ -AGENTS.md \ No newline at end of file +AGENTS.md diff --git a/scripts/lib/build-target-arch.ts b/scripts/lib/build-target-arch.ts index 9414ee7cf0..65be20f41b 100644 --- a/scripts/lib/build-target-arch.ts +++ b/scripts/lib/build-target-arch.ts @@ -39,7 +39,9 @@ export function getDefaultBuildArch( env: NodeJS.ProcessEnv, platformConfig: PlatformConfig, ): BuildArch { - const hostArch = resolveHostProcessArch(process.platform, processArch, env); + const nodePlatform: NodeJS.Platform = + platform === "win" ? "win32" : platform === "mac" ? "darwin" : "linux"; + const hostArch = resolveHostProcessArch(nodePlatform, processArch, env); if (hostArch && platformConfig.archChoices.includes(hostArch)) { return hostArch; } From 8d28fe810e0e19a8464613b9950159fb6b2c5e5f Mon Sep 17 00:00:00 2001 From: Evan Yu <50347938+Badbird5907@users.noreply.github.com> Date: Fri, 3 Apr 2026 17:00:59 -0400 Subject: [PATCH 4/7] docs(build): clarify windows arm64 arch handling --- .github/workflows/release.yml | 2 ++ scripts/lib/build-target-arch.test.ts | 15 ++++++++++----- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 305893b6c0..6dd1bdb368 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -221,6 +221,8 @@ jobs: fi fi + # Both Windows NSIS builds emit "latest.yml". Rename the arm64 copy so + # the merged release-assets directory keeps both updater manifests. if [[ "${{ matrix.platform }}" == "win" && "${{ matrix.arch }}" == "arm64" ]]; then if [[ -f release-publish/latest.yml ]]; then mv release-publish/latest.yml "release-publish/latest-win-arm64.yml" diff --git a/scripts/lib/build-target-arch.test.ts b/scripts/lib/build-target-arch.test.ts index 5ab23eb184..ec1cb7a5da 100644 --- a/scripts/lib/build-target-arch.test.ts +++ b/scripts/lib/build-target-arch.test.ts @@ -4,9 +4,11 @@ import { getDefaultBuildArch, resolveHostProcessArch } from "./build-target-arch describe("build-target-arch", () => { it("prefers arm64 for Windows-on-Arm hosts running x64 emulation", () => { + // Windows-on-Arm can run an x64 Node process under emulation while still + // exposing the real host CPU via PROCESSOR_ARCHITEW6432. const hostArch = resolveHostProcessArch("win32", "x64", { - PROCESSOR_ARCHITECTURE: "AMD64", - PROCESSOR_ARCHITEW6432: "ARM64", + PROCESSOR_ARCHITECTURE: "AMD64", // The currently running Node process is x64. + PROCESSOR_ARCHITEW6432: "ARM64", // Windows exposes the real host CPU here when x64 runs under ARM emulation. }); assert.equal(hostArch, "arm64"); @@ -14,7 +16,7 @@ describe("build-target-arch", () => { it("falls back to x64 for native x64 Windows hosts", () => { const hostArch = resolveHostProcessArch("win32", "x64", { - PROCESSOR_ARCHITECTURE: "AMD64", + PROCESSOR_ARCHITECTURE: "AMD64", // Both the process and the Windows host are native x64. }); assert.equal(hostArch, "x64"); @@ -27,12 +29,15 @@ describe("build-target-arch", () => { }); it("uses the resolved host arch when selecting the default Windows build arch", () => { + // This mirrors the packaging script's default-path behavior: the current + // process is x64, but the machine itself is ARM64, so the default build + // target should be win-arm64 rather than win-x64. const arch = getDefaultBuildArch( "win", "x64", { - PROCESSOR_ARCHITECTURE: "AMD64", - PROCESSOR_ARCHITEW6432: "ARM64", + PROCESSOR_ARCHITECTURE: "AMD64", // The currently running Node process is x64. + PROCESSOR_ARCHITEW6432: "ARM64", // The process is x64, but the actual Windows host is ARM64. }, { archChoices: ["x64", "arm64"] }, ); From 34a8a2268165b21a45f5429b27a92bcbddb143d2 Mon Sep 17 00:00:00 2001 From: Evan Yu <50347938+Badbird5907@users.noreply.github.com> Date: Fri, 3 Apr 2026 17:24:03 -0400 Subject: [PATCH 5/7] fix(release): merge windows updater manifests --- .github/workflows/release.yml | 17 +- scripts/merge-win-update-manifests.test.ts | 127 +++++++++ scripts/merge-win-update-manifests.ts | 290 +++++++++++++++++++++ scripts/release-smoke.ts | 64 +++++ 4 files changed, 494 insertions(+), 4 deletions(-) create mode 100644 scripts/merge-win-update-manifests.test.ts create mode 100644 scripts/merge-win-update-manifests.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6dd1bdb368..aa25785fa6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -221,11 +221,12 @@ jobs: fi fi - # Both Windows NSIS builds emit "latest.yml". Rename the arm64 copy so - # the merged release-assets directory keeps both updater manifests. - if [[ "${{ matrix.platform }}" == "win" && "${{ matrix.arch }}" == "arm64" ]]; then + # Both Windows NSIS builds emit "latest.yml". Rename both per-job + # copies so the release job can merge them back into one canonical + # Windows latest.yml for electron-updater discovery. + if [[ "${{ matrix.platform }}" == "win" ]]; then if [[ -f release-publish/latest.yml ]]; then - mv release-publish/latest.yml "release-publish/latest-win-arm64.yml" + mv release-publish/latest.yml "release-publish/latest-win-${{ matrix.arch }}.yml" fi fi @@ -298,6 +299,14 @@ jobs: release-assets/latest-mac-x64.yml rm -f release-assets/latest-mac-x64.yml + - name: Merge Windows updater manifests + run: | + node scripts/merge-win-update-manifests.ts \ + release-assets/latest-win-arm64.yml \ + release-assets/latest-win-x64.yml \ + release-assets/latest.yml + rm -f release-assets/latest-win-arm64.yml release-assets/latest-win-x64.yml + - name: Publish release uses: softprops/action-gh-release@v2 with: diff --git a/scripts/merge-win-update-manifests.test.ts b/scripts/merge-win-update-manifests.test.ts new file mode 100644 index 0000000000..86f0f13bdd --- /dev/null +++ b/scripts/merge-win-update-manifests.test.ts @@ -0,0 +1,127 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { + mergeWinUpdateManifests, + parseWinUpdateManifest, + serializeWinUpdateManifest, +} from "./merge-win-update-manifests.ts"; + +describe("merge-win-update-manifests", () => { + it("merges arm64 and x64 Windows update manifests into one multi-arch manifest", () => { + const arm64 = parseWinUpdateManifest( + `version: 0.0.4 +files: + - url: T3-Code-0.0.4-arm64.exe + sha512: arm64exe + size: 125621344 + - url: T3-Code-0.0.4-arm64.exe.blockmap + sha512: arm64blockmap + size: 131754 +path: T3-Code-0.0.4-arm64.exe +sha512: arm64exe +releaseDate: '2026-03-07T10:32:14.587Z' +`, + "latest-win-arm64.yml", + ); + + const x64 = parseWinUpdateManifest( + `version: 0.0.4 +files: + - url: T3-Code-0.0.4-x64.exe + sha512: x64exe + size: 132000112 + - url: T3-Code-0.0.4-x64.exe.blockmap + sha512: x64blockmap + size: 138148 +path: T3-Code-0.0.4-x64.exe +sha512: x64exe +releaseDate: '2026-03-07T10:36:07.540Z' +`, + "latest-win-x64.yml", + ); + + const merged = mergeWinUpdateManifests(arm64, x64); + + assert.equal(merged.version, "0.0.4"); + assert.equal(merged.releaseDate, "2026-03-07T10:36:07.540Z"); + assert.deepStrictEqual( + merged.files.map((file) => file.url), + [ + "T3-Code-0.0.4-arm64.exe", + "T3-Code-0.0.4-arm64.exe.blockmap", + "T3-Code-0.0.4-x64.exe", + "T3-Code-0.0.4-x64.exe.blockmap", + ], + ); + + const serialized = serializeWinUpdateManifest(merged); + assert.ok(!serialized.includes("path:")); + assert.equal((serialized.match(/- url:/g) ?? []).length, 4); + }); + + it("rejects mismatched manifest versions", () => { + const arm64 = parseWinUpdateManifest( + `version: 0.0.4 +files: + - url: T3-Code-0.0.4-arm64.exe + sha512: arm64exe + size: 1 +releaseDate: '2026-03-07T10:32:14.587Z' +`, + "latest-win-arm64.yml", + ); + + const x64 = parseWinUpdateManifest( + `version: 0.0.5 +files: + - url: T3-Code-0.0.5-x64.exe + sha512: x64exe + size: 1 +releaseDate: '2026-03-07T10:36:07.540Z' +`, + "latest-win-x64.yml", + ); + + assert.throws(() => mergeWinUpdateManifests(arm64, x64), /different versions/); + }); + + it("preserves quoted scalars as strings", () => { + const manifest = parseWinUpdateManifest( + `version: '1.0' +files: + - url: T3-Code-1.0-x64.exe + sha512: exesha + size: 1 +releaseName: 'true' +minimumSystemVersion: '10.0' +stagingPercentage: 50 +releaseDate: '2026-03-07T10:36:07.540Z' +`, + "latest-win-x64.yml", + ); + + assert.equal(manifest.version, "1.0"); + assert.equal(manifest.extras.releaseName, "true"); + assert.equal(manifest.extras.minimumSystemVersion, "10.0"); + assert.equal(manifest.extras.stagingPercentage, 50); + }); + + it("round-trips numeric-looking versions as strings", () => { + const original = parseWinUpdateManifest( + `version: '1.0' +files: + - url: T3-Code-1.0-x64.exe + sha512: exesha + size: 1 +releaseDate: '2026-03-07T10:36:07.540Z' +`, + "latest-win-x64.yml", + ); + + const serialized = serializeWinUpdateManifest(original); + assert.ok(serialized.includes("version: '1.0'")); + + const reparsed = parseWinUpdateManifest(serialized, "latest-win-x64.yml"); + assert.equal(reparsed.version, "1.0"); + }); +}); diff --git a/scripts/merge-win-update-manifests.ts b/scripts/merge-win-update-manifests.ts new file mode 100644 index 0000000000..bdda708ece --- /dev/null +++ b/scripts/merge-win-update-manifests.ts @@ -0,0 +1,290 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +interface WindowsUpdateFile { + readonly url: string; + readonly sha512: string; + readonly size: number; +} + +type WindowsUpdateScalar = string | number | boolean; + +interface WindowsUpdateManifest { + readonly version: string; + readonly releaseDate: string; + readonly files: ReadonlyArray; + readonly extras: Readonly>; +} + +interface MutableWindowsUpdateFile { + url?: string; + sha512?: string; + size?: number; +} + +function stripSingleQuotes(value: string): string { + if (value.startsWith("'") && value.endsWith("'")) { + return value.slice(1, -1).replace(/''/g, "'"); + } + return value; +} + +function parseFileRecord( + currentFile: MutableWindowsUpdateFile | null, + sourcePath: string, + lineNumber: number, +): WindowsUpdateFile | null { + if (currentFile === null) { + return null; + } + if ( + typeof currentFile.url !== "string" || + typeof currentFile.sha512 !== "string" || + typeof currentFile.size !== "number" + ) { + throw new Error( + `Invalid Windows update manifest at ${sourcePath}:${lineNumber}: incomplete file entry.`, + ); + } + return { + url: currentFile.url, + sha512: currentFile.sha512, + size: currentFile.size, + }; +} + +function parseScalarValue(rawValue: string): WindowsUpdateScalar { + const trimmed = rawValue.trim(); + const isQuoted = trimmed.startsWith("'") && trimmed.endsWith("'") && trimmed.length >= 2; + const value = isQuoted ? trimmed.slice(1, -1).replace(/''/g, "'") : trimmed; + if (isQuoted) return value; + if (value === "true") return true; + if (value === "false") return false; + if (/^-?\d+(?:\.\d+)?$/.test(value)) { + return Number(value); + } + return value; +} + +export function parseWinUpdateManifest(raw: string, sourcePath: string): WindowsUpdateManifest { + const lines = raw.split(/\r?\n/); + const files: WindowsUpdateFile[] = []; + const extras: Record = {}; + let version: string | null = null; + let releaseDate: string | null = null; + let inFiles = false; + let currentFile: MutableWindowsUpdateFile | null = null; + + for (const [index, rawLine] of lines.entries()) { + const lineNumber = index + 1; + const line = rawLine.trimEnd(); + if (line.length === 0) continue; + + const fileUrlMatch = line.match(/^ - url:\s*(.+)$/); + if (fileUrlMatch?.[1]) { + const finalized = parseFileRecord(currentFile, sourcePath, lineNumber); + if (finalized) files.push(finalized); + currentFile = { url: stripSingleQuotes(fileUrlMatch[1].trim()) }; + inFiles = true; + continue; + } + + const fileShaMatch = line.match(/^ sha512:\s*(.+)$/); + if (fileShaMatch?.[1]) { + if (currentFile === null) { + throw new Error( + `Invalid Windows update manifest at ${sourcePath}:${lineNumber}: sha512 without a file entry.`, + ); + } + currentFile.sha512 = stripSingleQuotes(fileShaMatch[1].trim()); + continue; + } + + const fileSizeMatch = line.match(/^ size:\s*(\d+)$/); + if (fileSizeMatch?.[1]) { + if (currentFile === null) { + throw new Error( + `Invalid Windows update manifest at ${sourcePath}:${lineNumber}: size without a file entry.`, + ); + } + currentFile.size = Number(fileSizeMatch[1]); + continue; + } + + if (line === "files:") { + inFiles = true; + continue; + } + + if (inFiles && currentFile !== null) { + const finalized = parseFileRecord(currentFile, sourcePath, lineNumber); + if (finalized) files.push(finalized); + currentFile = null; + } + inFiles = false; + + const topLevelMatch = line.match(/^([A-Za-z][A-Za-z0-9]*):\s*(.+)$/); + if (!topLevelMatch?.[1] || topLevelMatch[2] === undefined) { + throw new Error( + `Invalid Windows update manifest at ${sourcePath}:${lineNumber}: unsupported line '${line}'.`, + ); + } + + const [, key, rawValue] = topLevelMatch; + const value = parseScalarValue(rawValue); + + if (key === "version") { + if (typeof value !== "string") { + throw new Error( + `Invalid Windows update manifest at ${sourcePath}:${lineNumber}: version must be a string.`, + ); + } + version = value; + continue; + } + + if (key === "releaseDate") { + if (typeof value !== "string") { + throw new Error( + `Invalid Windows update manifest at ${sourcePath}:${lineNumber}: releaseDate must be a string.`, + ); + } + releaseDate = value; + continue; + } + + if (key === "path" || key === "sha512") { + continue; + } + + extras[key] = value; + } + + const finalized = parseFileRecord(currentFile, sourcePath, lines.length); + if (finalized) files.push(finalized); + + if (!version) { + throw new Error(`Invalid Windows update manifest at ${sourcePath}: missing version.`); + } + if (!releaseDate) { + throw new Error(`Invalid Windows update manifest at ${sourcePath}: missing releaseDate.`); + } + if (files.length === 0) { + throw new Error(`Invalid Windows update manifest at ${sourcePath}: missing files.`); + } + + return { + version, + releaseDate, + files, + extras, + }; +} + +function mergeExtras( + primary: Readonly>, + secondary: Readonly>, +): Record { + const merged: Record = { ...primary }; + + for (const [key, value] of Object.entries(secondary)) { + const existing = merged[key]; + if (existing !== undefined && existing !== value) { + throw new Error( + `Cannot merge Windows update manifests: conflicting '${key}' values ('${existing}' vs '${value}').`, + ); + } + merged[key] = value; + } + + return merged; +} + +export function mergeWinUpdateManifests( + primary: WindowsUpdateManifest, + secondary: WindowsUpdateManifest, +): WindowsUpdateManifest { + if (primary.version !== secondary.version) { + throw new Error( + `Cannot merge Windows update manifests with different versions (${primary.version} vs ${secondary.version}).`, + ); + } + + const filesByUrl = new Map(); + for (const file of [...primary.files, ...secondary.files]) { + const existing = filesByUrl.get(file.url); + if (existing && (existing.sha512 !== file.sha512 || existing.size !== file.size)) { + throw new Error( + `Cannot merge Windows update manifests: conflicting file entry for ${file.url}.`, + ); + } + filesByUrl.set(file.url, file); + } + + return { + version: primary.version, + releaseDate: + primary.releaseDate >= secondary.releaseDate ? primary.releaseDate : secondary.releaseDate, + files: [...filesByUrl.values()], + extras: mergeExtras(primary.extras, secondary.extras), + }; +} + +function quoteYamlString(value: string): string { + return `'${value.replace(/'/g, "''")}'`; +} + +function serializeScalarValue(value: WindowsUpdateScalar): string { + if (typeof value === "string") { + return quoteYamlString(value); + } + return String(value); +} + +export function serializeWinUpdateManifest(manifest: WindowsUpdateManifest): string { + const lines = [`version: ${quoteYamlString(manifest.version)}`, "files:"]; + + for (const file of manifest.files) { + lines.push(` - url: ${file.url}`); + lines.push(` sha512: ${file.sha512}`); + lines.push(` size: ${file.size}`); + } + + for (const key of Object.keys(manifest.extras).toSorted()) { + const value = manifest.extras[key]; + if (value === undefined) { + throw new Error(`Cannot serialize Windows update manifest: missing value for '${key}'.`); + } + lines.push(`${key}: ${serializeScalarValue(value)}`); + } + + lines.push(`releaseDate: ${quoteYamlString(manifest.releaseDate)}`); + lines.push(""); + return lines.join("\n"); +} + +function main(args: ReadonlyArray): void { + const [primaryPathArg, secondaryPathArg, outputPathArg] = args; + if (!primaryPathArg || !secondaryPathArg) { + throw new Error( + "Usage: node scripts/merge-win-update-manifests.ts [output-path]", + ); + } + + const primaryPath = resolve(primaryPathArg); + const secondaryPath = resolve(secondaryPathArg); + const outputPath = resolve(outputPathArg ?? primaryPathArg); + + const primaryManifest = parseWinUpdateManifest(readFileSync(primaryPath, "utf8"), primaryPath); + const secondaryManifest = parseWinUpdateManifest( + readFileSync(secondaryPath, "utf8"), + secondaryPath, + ); + const merged = mergeWinUpdateManifests(primaryManifest, secondaryManifest); + writeFileSync(outputPath, serializeWinUpdateManifest(merged)); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main(process.argv.slice(2)); +} diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index bf9d9f5c6a..1e9c303ee7 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -69,6 +69,48 @@ releaseDate: '2026-03-08T10:36:07.540Z' return { arm64Path, x64Path }; } +function writeWindowsManifestFixtures(targetRoot: string): { arm64Path: string; x64Path: string } { + const assetDirectory = resolve(targetRoot, "release-assets"); + mkdirSync(assetDirectory, { recursive: true }); + + const arm64Path = resolve(assetDirectory, "latest-win-arm64.yml"); + const x64Path = resolve(assetDirectory, "latest-win-x64.yml"); + + writeFileSync( + arm64Path, + `version: 9.9.9-smoke.0 +files: + - url: T3-Code-9.9.9-smoke.0-arm64.exe + sha512: arm64exe + size: 126621344 + - url: T3-Code-9.9.9-smoke.0-arm64.exe.blockmap + sha512: arm64blockmap + size: 152344 +path: T3-Code-9.9.9-smoke.0-arm64.exe +sha512: arm64exe +releaseDate: '2026-03-08T10:32:14.587Z' +`, + ); + + writeFileSync( + x64Path, + `version: 9.9.9-smoke.0 +files: + - url: T3-Code-9.9.9-smoke.0-x64.exe + sha512: x64exe + size: 132000112 + - url: T3-Code-9.9.9-smoke.0-x64.exe.blockmap + sha512: x64blockmap + size: 160112 +path: T3-Code-9.9.9-smoke.0-x64.exe +sha512: x64exe +releaseDate: '2026-03-08T10:36:07.540Z' +`, + ); + + return { arm64Path, x64Path }; +} + function assertContains(haystack: string, needle: string, message: string): void { if (!haystack.includes(needle)) { throw new Error(message); @@ -128,6 +170,28 @@ try { "Merged manifest is missing the x64 asset.", ); + const { arm64Path: winArm64Path, x64Path: winX64Path } = writeWindowsManifestFixtures(tempRoot); + execFileSync( + process.execPath, + [resolve(repoRoot, "scripts/merge-win-update-manifests.ts"), winArm64Path, winX64Path], + { + cwd: repoRoot, + stdio: "inherit", + }, + ); + + const mergedWindowsManifest = readFileSync(winArm64Path, "utf8"); + assertContains( + mergedWindowsManifest, + "T3-Code-9.9.9-smoke.0-arm64.exe", + "Merged Windows manifest is missing the arm64 asset.", + ); + assertContains( + mergedWindowsManifest, + "T3-Code-9.9.9-smoke.0-x64.exe", + "Merged Windows manifest is missing the x64 asset.", + ); + console.log("Release smoke checks passed."); } finally { rmSync(tempRoot, { recursive: true, force: true }); From b85cee3e204f71f11e9501845015f7733644e2ef Mon Sep 17 00:00:00 2001 From: Evan Yu <50347938+Badbird5907@users.noreply.github.com> Date: Fri, 3 Apr 2026 17:32:10 -0400 Subject: [PATCH 6/7] Remove CLAUDE.md newline-only change --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index c317064255..47dc3e3d86 120000 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1 @@ -AGENTS.md +AGENTS.md \ No newline at end of file From 93bdf8c348adf2865b4885306c58be83ef660dae Mon Sep 17 00:00:00 2001 From: Evan Yu <50347938+Badbird5907@users.noreply.github.com> Date: Fri, 3 Apr 2026 18:04:30 -0400 Subject: [PATCH 7/7] refactor(scripts): share update manifest logic and fix build arch detection --- scripts/lib/build-target-arch.test.ts | 14 ++ scripts/lib/build-target-arch.ts | 4 +- scripts/lib/update-manifest.ts | 279 +++++++++++++++++++++ scripts/merge-mac-update-manifests.test.ts | 16 ++ scripts/merge-mac-update-manifests.ts | 261 +------------------ scripts/merge-win-update-manifests.ts | 261 +------------------ 6 files changed, 337 insertions(+), 498 deletions(-) create mode 100644 scripts/lib/update-manifest.ts diff --git a/scripts/lib/build-target-arch.test.ts b/scripts/lib/build-target-arch.test.ts index ec1cb7a5da..56251d3ffd 100644 --- a/scripts/lib/build-target-arch.test.ts +++ b/scripts/lib/build-target-arch.test.ts @@ -44,4 +44,18 @@ describe("build-target-arch", () => { assert.equal(arch, "arm64"); }); + + it("does not apply Windows host env heuristics for non-Windows targets", () => { + const arch = getDefaultBuildArch( + "linux", + "x64", + { + PROCESSOR_ARCHITECTURE: "AMD64", + PROCESSOR_ARCHITEW6432: "ARM64", + }, + { archChoices: ["x64", "arm64"] }, + ); + + assert.equal(arch, "x64"); + }); }); diff --git a/scripts/lib/build-target-arch.ts b/scripts/lib/build-target-arch.ts index 65be20f41b..8c39648414 100644 --- a/scripts/lib/build-target-arch.ts +++ b/scripts/lib/build-target-arch.ts @@ -39,9 +39,9 @@ export function getDefaultBuildArch( env: NodeJS.ProcessEnv, platformConfig: PlatformConfig, ): BuildArch { - const nodePlatform: NodeJS.Platform = + const hostPlatform: NodeJS.Platform = platform === "win" ? "win32" : platform === "mac" ? "darwin" : "linux"; - const hostArch = resolveHostProcessArch(nodePlatform, processArch, env); + const hostArch = resolveHostProcessArch(hostPlatform, processArch, env); if (hostArch && platformConfig.archChoices.includes(hostArch)) { return hostArch; } diff --git a/scripts/lib/update-manifest.ts b/scripts/lib/update-manifest.ts new file mode 100644 index 0000000000..ca6b66d0c0 --- /dev/null +++ b/scripts/lib/update-manifest.ts @@ -0,0 +1,279 @@ +export interface UpdateManifestFile { + readonly url: string; + readonly sha512: string; + readonly size: number; +} + +export type UpdateManifestScalar = string | number | boolean; + +export interface UpdateManifest { + readonly version: string; + readonly releaseDate: string; + readonly files: ReadonlyArray; + readonly extras: Readonly>; +} + +interface MutableUpdateManifestFile { + url?: string; + sha512?: string; + size?: number; +} + +function stripSingleQuotes(value: string): string { + if (value.startsWith("'") && value.endsWith("'")) { + return value.slice(1, -1).replace(/''/g, "'"); + } + return value; +} + +function parseFileRecord( + currentFile: MutableUpdateManifestFile | null, + sourcePath: string, + lineNumber: number, + platformLabel: string, +): UpdateManifestFile | null { + if (currentFile === null) { + return null; + } + if ( + typeof currentFile.url !== "string" || + typeof currentFile.sha512 !== "string" || + typeof currentFile.size !== "number" + ) { + throw new Error( + `Invalid ${platformLabel} update manifest at ${sourcePath}:${lineNumber}: incomplete file entry.`, + ); + } + return { + url: currentFile.url, + sha512: currentFile.sha512, + size: currentFile.size, + }; +} + +function parseScalarValue(rawValue: string): UpdateManifestScalar { + const trimmed = rawValue.trim(); + const isQuoted = trimmed.startsWith("'") && trimmed.endsWith("'") && trimmed.length >= 2; + const value = isQuoted ? trimmed.slice(1, -1).replace(/''/g, "'") : trimmed; + if (isQuoted) return value; + if (value === "true") return true; + if (value === "false") return false; + if (/^-?\d+(?:\.\d+)?$/.test(value)) { + return Number(value); + } + return value; +} + +export function parseUpdateManifest( + raw: string, + sourcePath: string, + platformLabel: string, +): UpdateManifest { + const lines = raw.split(/\r?\n/); + const files: UpdateManifestFile[] = []; + const extras: Record = {}; + let version: string | null = null; + let releaseDate: string | null = null; + let inFiles = false; + let currentFile: MutableUpdateManifestFile | null = null; + + for (const [index, rawLine] of lines.entries()) { + const lineNumber = index + 1; + const line = rawLine.trimEnd(); + if (line.length === 0) continue; + + const fileUrlMatch = line.match(/^ - url:\s*(.+)$/); + if (fileUrlMatch?.[1]) { + const finalized = parseFileRecord(currentFile, sourcePath, lineNumber, platformLabel); + if (finalized) files.push(finalized); + currentFile = { url: stripSingleQuotes(fileUrlMatch[1].trim()) }; + inFiles = true; + continue; + } + + const fileShaMatch = line.match(/^ sha512:\s*(.+)$/); + if (fileShaMatch?.[1]) { + if (currentFile === null) { + throw new Error( + `Invalid ${platformLabel} update manifest at ${sourcePath}:${lineNumber}: sha512 without a file entry.`, + ); + } + currentFile.sha512 = stripSingleQuotes(fileShaMatch[1].trim()); + continue; + } + + const fileSizeMatch = line.match(/^ size:\s*(\d+)$/); + if (fileSizeMatch?.[1]) { + if (currentFile === null) { + throw new Error( + `Invalid ${platformLabel} update manifest at ${sourcePath}:${lineNumber}: size without a file entry.`, + ); + } + currentFile.size = Number(fileSizeMatch[1]); + continue; + } + + if (line === "files:") { + inFiles = true; + continue; + } + + if (inFiles && currentFile !== null) { + const finalized = parseFileRecord(currentFile, sourcePath, lineNumber, platformLabel); + if (finalized) files.push(finalized); + currentFile = null; + } + inFiles = false; + + const topLevelMatch = line.match(/^([A-Za-z][A-Za-z0-9]*):\s*(.+)$/); + if (!topLevelMatch?.[1] || topLevelMatch[2] === undefined) { + throw new Error( + `Invalid ${platformLabel} update manifest at ${sourcePath}:${lineNumber}: unsupported line '${line}'.`, + ); + } + + const [, key, rawValue] = topLevelMatch; + const value = parseScalarValue(rawValue); + + if (key === "version") { + if (typeof value !== "string") { + throw new Error( + `Invalid ${platformLabel} update manifest at ${sourcePath}:${lineNumber}: version must be a string.`, + ); + } + version = value; + continue; + } + + if (key === "releaseDate") { + if (typeof value !== "string") { + throw new Error( + `Invalid ${platformLabel} update manifest at ${sourcePath}:${lineNumber}: releaseDate must be a string.`, + ); + } + releaseDate = value; + continue; + } + + if (key === "path" || key === "sha512") { + continue; + } + + extras[key] = value; + } + + const finalized = parseFileRecord(currentFile, sourcePath, lines.length, platformLabel); + if (finalized) files.push(finalized); + + if (!version) { + throw new Error(`Invalid ${platformLabel} update manifest at ${sourcePath}: missing version.`); + } + if (!releaseDate) { + throw new Error( + `Invalid ${platformLabel} update manifest at ${sourcePath}: missing releaseDate.`, + ); + } + if (files.length === 0) { + throw new Error(`Invalid ${platformLabel} update manifest at ${sourcePath}: missing files.`); + } + + return { + version, + releaseDate, + files, + extras, + }; +} + +function mergeExtras( + primary: Readonly>, + secondary: Readonly>, + platformLabel: string, +): Record { + const merged: Record = { ...primary }; + + for (const [key, value] of Object.entries(secondary)) { + const existing = merged[key]; + if (existing !== undefined && existing !== value) { + throw new Error( + `Cannot merge ${platformLabel} update manifests: conflicting '${key}' values ('${existing}' vs '${value}').`, + ); + } + merged[key] = value; + } + + return merged; +} + +export function mergeUpdateManifests( + primary: UpdateManifest, + secondary: UpdateManifest, + platformLabel: string, +): UpdateManifest { + if (primary.version !== secondary.version) { + throw new Error( + `Cannot merge ${platformLabel} update manifests with different versions (${primary.version} vs ${secondary.version}).`, + ); + } + + const filesByUrl = new Map(); + for (const file of [...primary.files, ...secondary.files]) { + const existing = filesByUrl.get(file.url); + if (existing && (existing.sha512 !== file.sha512 || existing.size !== file.size)) { + throw new Error( + `Cannot merge ${platformLabel} update manifests: conflicting file entry for ${file.url}.`, + ); + } + filesByUrl.set(file.url, file); + } + + return { + version: primary.version, + releaseDate: + primary.releaseDate >= secondary.releaseDate ? primary.releaseDate : secondary.releaseDate, + files: [...filesByUrl.values()], + extras: mergeExtras(primary.extras, secondary.extras, platformLabel), + }; +} + +function quoteYamlString(value: string): string { + return `'${value.replace(/'/g, "''")}'`; +} + +function serializeScalarValue(value: UpdateManifestScalar): string { + if (typeof value === "string") { + return quoteYamlString(value); + } + return String(value); +} + +export function serializeUpdateManifest( + manifest: UpdateManifest, + options: { + readonly quoteVersion: boolean; + readonly platformLabel: string; + }, +): string { + const version = options.quoteVersion ? quoteYamlString(manifest.version) : manifest.version; + const lines = [`version: ${version}`, "files:"]; + + for (const file of manifest.files) { + lines.push(` - url: ${file.url}`); + lines.push(` sha512: ${file.sha512}`); + lines.push(` size: ${file.size}`); + } + + for (const key of Object.keys(manifest.extras).toSorted()) { + const value = manifest.extras[key]; + if (value === undefined) { + throw new Error( + `Cannot serialize ${options.platformLabel} update manifest: missing value for '${key}'.`, + ); + } + lines.push(`${key}: ${serializeScalarValue(value)}`); + } + + lines.push(`releaseDate: ${quoteYamlString(manifest.releaseDate)}`); + lines.push(""); + return lines.join("\n"); +} diff --git a/scripts/merge-mac-update-manifests.test.ts b/scripts/merge-mac-update-manifests.test.ts index 22d2e7627e..0463ad1902 100644 --- a/scripts/merge-mac-update-manifests.test.ts +++ b/scripts/merge-mac-update-manifests.test.ts @@ -105,4 +105,20 @@ releaseDate: '2026-03-07T10:36:07.540Z' assert.equal(manifest.extras.minimumSystemVersion, "13.0"); assert.equal(manifest.extras.stagingPercentage, 50); }); + + it("serializes versions without quotes for compatibility", () => { + const original = parseMacUpdateManifest( + `version: '1.0' +files: + - url: T3-Code-1.0-x64.zip + sha512: zipsha + size: 1 +releaseDate: '2026-03-07T10:36:07.540Z' +`, + "latest-mac.yml", + ); + + const serialized = serializeMacUpdateManifest(original); + assert.ok(serialized.includes("version: 1.0")); + }); }); diff --git a/scripts/merge-mac-update-manifests.ts b/scripts/merge-mac-update-manifests.ts index c59bc76b9b..99f296d5da 100644 --- a/scripts/merge-mac-update-manifests.ts +++ b/scripts/merge-mac-update-manifests.ts @@ -2,266 +2,31 @@ import { readFileSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; -interface MacUpdateFile { - readonly url: string; - readonly sha512: string; - readonly size: number; -} - -type MacUpdateScalar = string | number | boolean; - -interface MacUpdateManifest { - readonly version: string; - readonly releaseDate: string; - readonly files: ReadonlyArray; - readonly extras: Readonly>; -} +import { + mergeUpdateManifests, + parseUpdateManifest, + serializeUpdateManifest, + type UpdateManifest, +} from "./lib/update-manifest.ts"; -interface MutableMacUpdateFile { - url?: string; - sha512?: string; - size?: number; -} - -function stripSingleQuotes(value: string): string { - if (value.startsWith("'") && value.endsWith("'")) { - return value.slice(1, -1).replace(/''/g, "'"); - } - return value; -} - -function parseFileRecord( - currentFile: MutableMacUpdateFile | null, - sourcePath: string, - lineNumber: number, -): MacUpdateFile | null { - if (currentFile === null) { - return null; - } - if ( - typeof currentFile.url !== "string" || - typeof currentFile.sha512 !== "string" || - typeof currentFile.size !== "number" - ) { - throw new Error( - `Invalid macOS update manifest at ${sourcePath}:${lineNumber}: incomplete file entry.`, - ); - } - return { - url: currentFile.url, - sha512: currentFile.sha512, - size: currentFile.size, - }; -} - -function parseScalarValue(rawValue: string): MacUpdateScalar { - const trimmed = rawValue.trim(); - const isQuoted = trimmed.startsWith("'") && trimmed.endsWith("'") && trimmed.length >= 2; - const value = isQuoted ? trimmed.slice(1, -1).replace(/''/g, "'") : trimmed; - if (isQuoted) return value; - if (value === "true") return true; - if (value === "false") return false; - if (/^-?\d+(?:\.\d+)?$/.test(value)) { - return Number(value); - } - return value; -} +type MacUpdateManifest = UpdateManifest; export function parseMacUpdateManifest(raw: string, sourcePath: string): MacUpdateManifest { - const lines = raw.split(/\r?\n/); - const files: MacUpdateFile[] = []; - const extras: Record = {}; - let version: string | null = null; - let releaseDate: string | null = null; - let inFiles = false; - let currentFile: MutableMacUpdateFile | null = null; - - for (const [index, rawLine] of lines.entries()) { - const lineNumber = index + 1; - const line = rawLine.trimEnd(); - if (line.length === 0) continue; - - const fileUrlMatch = line.match(/^ - url:\s*(.+)$/); - if (fileUrlMatch?.[1]) { - const finalized = parseFileRecord(currentFile, sourcePath, lineNumber); - if (finalized) files.push(finalized); - currentFile = { url: stripSingleQuotes(fileUrlMatch[1].trim()) }; - inFiles = true; - continue; - } - - const fileShaMatch = line.match(/^ sha512:\s*(.+)$/); - if (fileShaMatch?.[1]) { - if (currentFile === null) { - throw new Error( - `Invalid macOS update manifest at ${sourcePath}:${lineNumber}: sha512 without a file entry.`, - ); - } - currentFile.sha512 = stripSingleQuotes(fileShaMatch[1].trim()); - continue; - } - - const fileSizeMatch = line.match(/^ size:\s*(\d+)$/); - if (fileSizeMatch?.[1]) { - if (currentFile === null) { - throw new Error( - `Invalid macOS update manifest at ${sourcePath}:${lineNumber}: size without a file entry.`, - ); - } - currentFile.size = Number(fileSizeMatch[1]); - continue; - } - - if (line === "files:") { - inFiles = true; - continue; - } - - if (inFiles && currentFile !== null) { - const finalized = parseFileRecord(currentFile, sourcePath, lineNumber); - if (finalized) files.push(finalized); - currentFile = null; - } - inFiles = false; - - const topLevelMatch = line.match(/^([A-Za-z][A-Za-z0-9]*):\s*(.+)$/); - if (!topLevelMatch?.[1] || topLevelMatch[2] === undefined) { - throw new Error( - `Invalid macOS update manifest at ${sourcePath}:${lineNumber}: unsupported line '${line}'.`, - ); - } - - const [, key, rawValue] = topLevelMatch; - const value = parseScalarValue(rawValue); - - if (key === "version") { - if (typeof value !== "string") { - throw new Error( - `Invalid macOS update manifest at ${sourcePath}:${lineNumber}: version must be a string.`, - ); - } - version = value; - continue; - } - - if (key === "releaseDate") { - if (typeof value !== "string") { - throw new Error( - `Invalid macOS update manifest at ${sourcePath}:${lineNumber}: releaseDate must be a string.`, - ); - } - releaseDate = value; - continue; - } - - if (key === "path" || key === "sha512") { - continue; - } - - extras[key] = value; - } - - const finalized = parseFileRecord(currentFile, sourcePath, lines.length); - if (finalized) files.push(finalized); - - if (!version) { - throw new Error(`Invalid macOS update manifest at ${sourcePath}: missing version.`); - } - if (!releaseDate) { - throw new Error(`Invalid macOS update manifest at ${sourcePath}: missing releaseDate.`); - } - if (files.length === 0) { - throw new Error(`Invalid macOS update manifest at ${sourcePath}: missing files.`); - } - - return { - version, - releaseDate, - files, - extras, - }; -} - -function mergeExtras( - primary: Readonly>, - secondary: Readonly>, -): Record { - const merged: Record = { ...primary }; - - for (const [key, value] of Object.entries(secondary)) { - const existing = merged[key]; - if (existing !== undefined && existing !== value) { - throw new Error( - `Cannot merge macOS update manifests: conflicting '${key}' values ('${existing}' vs '${value}').`, - ); - } - merged[key] = value; - } - - return merged; + return parseUpdateManifest(raw, sourcePath, "macOS"); } export function mergeMacUpdateManifests( primary: MacUpdateManifest, secondary: MacUpdateManifest, ): MacUpdateManifest { - if (primary.version !== secondary.version) { - throw new Error( - `Cannot merge macOS update manifests with different versions (${primary.version} vs ${secondary.version}).`, - ); - } - - const filesByUrl = new Map(); - for (const file of [...primary.files, ...secondary.files]) { - const existing = filesByUrl.get(file.url); - if (existing && (existing.sha512 !== file.sha512 || existing.size !== file.size)) { - throw new Error( - `Cannot merge macOS update manifests: conflicting file entry for ${file.url}.`, - ); - } - filesByUrl.set(file.url, file); - } - - return { - version: primary.version, - releaseDate: - primary.releaseDate >= secondary.releaseDate ? primary.releaseDate : secondary.releaseDate, - files: [...filesByUrl.values()], - extras: mergeExtras(primary.extras, secondary.extras), - }; -} - -function quoteYamlString(value: string): string { - return `'${value.replace(/'/g, "''")}'`; -} - -function serializeScalarValue(value: MacUpdateScalar): string { - if (typeof value === "string") { - return quoteYamlString(value); - } - return String(value); + return mergeUpdateManifests(primary, secondary, "macOS"); } export function serializeMacUpdateManifest(manifest: MacUpdateManifest): string { - const lines = [`version: ${manifest.version}`, "files:"]; - - for (const file of manifest.files) { - lines.push(` - url: ${file.url}`); - lines.push(` sha512: ${file.sha512}`); - lines.push(` size: ${file.size}`); - } - - for (const key of Object.keys(manifest.extras).toSorted()) { - const value = manifest.extras[key]; - if (value === undefined) { - throw new Error(`Cannot serialize macOS update manifest: missing value for '${key}'.`); - } - lines.push(`${key}: ${serializeScalarValue(value)}`); - } - - lines.push(`releaseDate: ${quoteYamlString(manifest.releaseDate)}`); - lines.push(""); - return lines.join("\n"); + return serializeUpdateManifest(manifest, { + quoteVersion: false, + platformLabel: "macOS", + }); } function main(args: ReadonlyArray): void { diff --git a/scripts/merge-win-update-manifests.ts b/scripts/merge-win-update-manifests.ts index bdda708ece..10adb276cf 100644 --- a/scripts/merge-win-update-manifests.ts +++ b/scripts/merge-win-update-manifests.ts @@ -2,266 +2,31 @@ import { readFileSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; -interface WindowsUpdateFile { - readonly url: string; - readonly sha512: string; - readonly size: number; -} - -type WindowsUpdateScalar = string | number | boolean; - -interface WindowsUpdateManifest { - readonly version: string; - readonly releaseDate: string; - readonly files: ReadonlyArray; - readonly extras: Readonly>; -} +import { + mergeUpdateManifests, + parseUpdateManifest, + serializeUpdateManifest, + type UpdateManifest, +} from "./lib/update-manifest.ts"; -interface MutableWindowsUpdateFile { - url?: string; - sha512?: string; - size?: number; -} - -function stripSingleQuotes(value: string): string { - if (value.startsWith("'") && value.endsWith("'")) { - return value.slice(1, -1).replace(/''/g, "'"); - } - return value; -} - -function parseFileRecord( - currentFile: MutableWindowsUpdateFile | null, - sourcePath: string, - lineNumber: number, -): WindowsUpdateFile | null { - if (currentFile === null) { - return null; - } - if ( - typeof currentFile.url !== "string" || - typeof currentFile.sha512 !== "string" || - typeof currentFile.size !== "number" - ) { - throw new Error( - `Invalid Windows update manifest at ${sourcePath}:${lineNumber}: incomplete file entry.`, - ); - } - return { - url: currentFile.url, - sha512: currentFile.sha512, - size: currentFile.size, - }; -} - -function parseScalarValue(rawValue: string): WindowsUpdateScalar { - const trimmed = rawValue.trim(); - const isQuoted = trimmed.startsWith("'") && trimmed.endsWith("'") && trimmed.length >= 2; - const value = isQuoted ? trimmed.slice(1, -1).replace(/''/g, "'") : trimmed; - if (isQuoted) return value; - if (value === "true") return true; - if (value === "false") return false; - if (/^-?\d+(?:\.\d+)?$/.test(value)) { - return Number(value); - } - return value; -} +type WindowsUpdateManifest = UpdateManifest; export function parseWinUpdateManifest(raw: string, sourcePath: string): WindowsUpdateManifest { - const lines = raw.split(/\r?\n/); - const files: WindowsUpdateFile[] = []; - const extras: Record = {}; - let version: string | null = null; - let releaseDate: string | null = null; - let inFiles = false; - let currentFile: MutableWindowsUpdateFile | null = null; - - for (const [index, rawLine] of lines.entries()) { - const lineNumber = index + 1; - const line = rawLine.trimEnd(); - if (line.length === 0) continue; - - const fileUrlMatch = line.match(/^ - url:\s*(.+)$/); - if (fileUrlMatch?.[1]) { - const finalized = parseFileRecord(currentFile, sourcePath, lineNumber); - if (finalized) files.push(finalized); - currentFile = { url: stripSingleQuotes(fileUrlMatch[1].trim()) }; - inFiles = true; - continue; - } - - const fileShaMatch = line.match(/^ sha512:\s*(.+)$/); - if (fileShaMatch?.[1]) { - if (currentFile === null) { - throw new Error( - `Invalid Windows update manifest at ${sourcePath}:${lineNumber}: sha512 without a file entry.`, - ); - } - currentFile.sha512 = stripSingleQuotes(fileShaMatch[1].trim()); - continue; - } - - const fileSizeMatch = line.match(/^ size:\s*(\d+)$/); - if (fileSizeMatch?.[1]) { - if (currentFile === null) { - throw new Error( - `Invalid Windows update manifest at ${sourcePath}:${lineNumber}: size without a file entry.`, - ); - } - currentFile.size = Number(fileSizeMatch[1]); - continue; - } - - if (line === "files:") { - inFiles = true; - continue; - } - - if (inFiles && currentFile !== null) { - const finalized = parseFileRecord(currentFile, sourcePath, lineNumber); - if (finalized) files.push(finalized); - currentFile = null; - } - inFiles = false; - - const topLevelMatch = line.match(/^([A-Za-z][A-Za-z0-9]*):\s*(.+)$/); - if (!topLevelMatch?.[1] || topLevelMatch[2] === undefined) { - throw new Error( - `Invalid Windows update manifest at ${sourcePath}:${lineNumber}: unsupported line '${line}'.`, - ); - } - - const [, key, rawValue] = topLevelMatch; - const value = parseScalarValue(rawValue); - - if (key === "version") { - if (typeof value !== "string") { - throw new Error( - `Invalid Windows update manifest at ${sourcePath}:${lineNumber}: version must be a string.`, - ); - } - version = value; - continue; - } - - if (key === "releaseDate") { - if (typeof value !== "string") { - throw new Error( - `Invalid Windows update manifest at ${sourcePath}:${lineNumber}: releaseDate must be a string.`, - ); - } - releaseDate = value; - continue; - } - - if (key === "path" || key === "sha512") { - continue; - } - - extras[key] = value; - } - - const finalized = parseFileRecord(currentFile, sourcePath, lines.length); - if (finalized) files.push(finalized); - - if (!version) { - throw new Error(`Invalid Windows update manifest at ${sourcePath}: missing version.`); - } - if (!releaseDate) { - throw new Error(`Invalid Windows update manifest at ${sourcePath}: missing releaseDate.`); - } - if (files.length === 0) { - throw new Error(`Invalid Windows update manifest at ${sourcePath}: missing files.`); - } - - return { - version, - releaseDate, - files, - extras, - }; -} - -function mergeExtras( - primary: Readonly>, - secondary: Readonly>, -): Record { - const merged: Record = { ...primary }; - - for (const [key, value] of Object.entries(secondary)) { - const existing = merged[key]; - if (existing !== undefined && existing !== value) { - throw new Error( - `Cannot merge Windows update manifests: conflicting '${key}' values ('${existing}' vs '${value}').`, - ); - } - merged[key] = value; - } - - return merged; + return parseUpdateManifest(raw, sourcePath, "Windows"); } export function mergeWinUpdateManifests( primary: WindowsUpdateManifest, secondary: WindowsUpdateManifest, ): WindowsUpdateManifest { - if (primary.version !== secondary.version) { - throw new Error( - `Cannot merge Windows update manifests with different versions (${primary.version} vs ${secondary.version}).`, - ); - } - - const filesByUrl = new Map(); - for (const file of [...primary.files, ...secondary.files]) { - const existing = filesByUrl.get(file.url); - if (existing && (existing.sha512 !== file.sha512 || existing.size !== file.size)) { - throw new Error( - `Cannot merge Windows update manifests: conflicting file entry for ${file.url}.`, - ); - } - filesByUrl.set(file.url, file); - } - - return { - version: primary.version, - releaseDate: - primary.releaseDate >= secondary.releaseDate ? primary.releaseDate : secondary.releaseDate, - files: [...filesByUrl.values()], - extras: mergeExtras(primary.extras, secondary.extras), - }; -} - -function quoteYamlString(value: string): string { - return `'${value.replace(/'/g, "''")}'`; -} - -function serializeScalarValue(value: WindowsUpdateScalar): string { - if (typeof value === "string") { - return quoteYamlString(value); - } - return String(value); + return mergeUpdateManifests(primary, secondary, "Windows"); } export function serializeWinUpdateManifest(manifest: WindowsUpdateManifest): string { - const lines = [`version: ${quoteYamlString(manifest.version)}`, "files:"]; - - for (const file of manifest.files) { - lines.push(` - url: ${file.url}`); - lines.push(` sha512: ${file.sha512}`); - lines.push(` size: ${file.size}`); - } - - for (const key of Object.keys(manifest.extras).toSorted()) { - const value = manifest.extras[key]; - if (value === undefined) { - throw new Error(`Cannot serialize Windows update manifest: missing value for '${key}'.`); - } - lines.push(`${key}: ${serializeScalarValue(value)}`); - } - - lines.push(`releaseDate: ${quoteYamlString(manifest.releaseDate)}`); - lines.push(""); - return lines.join("\n"); + return serializeUpdateManifest(manifest, { + quoteVersion: true, + platformLabel: "Windows", + }); } function main(args: ReadonlyArray): void {