| author | |
| committer | |
| log | 6891c77d3e88167bd1dde5647b5d781f2d16bd2a |
| tree | 3f50cd157cd2b26f9f91e8e8fd5e3c943df205c1 |
| parent | d90e15d1d7ecba302d6977315977d5c2a748bf36 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 160 | platform: win |
| 161 | 161 | target: nsis |
| 162 | 162 | arch: x64 |
| 163 | - label: Windows arm64 | |
| 164 | runner: windows-11-arm | |
| 165 | platform: win | |
| 166 | target: nsis | |
| 167 | arch: arm64 | |
| 163 | 168 | steps: |
| 164 | 169 | - name: Checkout |
| 165 | 170 | uses: actions/checkout@v6 |
| ... | ... | @@ -272,6 +277,17 @@ jobs: |
| 272 | 277 | done |
| 273 | 278 | fi |
| 274 | 279 | |
| 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 | 291 | - name: Upload build artifacts |
| 276 | 292 | uses: actions/upload-artifact@v7 |
| 277 | 293 | with: |
| ... | ... | @@ -342,11 +358,36 @@ jobs: |
| 342 | 358 | for x64_manifest in release-assets/*-mac-x64.yml; do |
| 343 | 359 | arm64_manifest="${x64_manifest%-x64.yml}.yml" |
| 344 | 360 | 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 | 362 | rm -f "$x64_manifest" |
| 347 | 363 | fi |
| 348 | 364 | done |
| 349 | 365 | |
| 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 | 391 | - name: Publish release |
| 351 | 392 | if: needs.preflight.outputs.previous_tag != '' |
| 352 | 393 | uses: softprops/action-gh-release@v2 |
CLAUDE.md+1-1| ... | ... | @@ -1 +1 @@ |
| 1 | AGENTS.md | |
| \ No newline at end of file | ||
| 1 | AGENTS.md |
bun.lock+3-3| ... | ... | @@ -202,7 +202,7 @@ |
| 202 | 202 | "@effect/platform-node-shared": "4.0.0-beta.45", |
| 203 | 203 | "@effect/sql-sqlite-bun": "4.0.0-beta.45", |
| 204 | 204 | "@effect/vitest": "4.0.0-beta.45", |
| 205 | "@types/bun": "^1.3.9", | |
| 205 | "@types/bun": "^1.3.11", | |
| 206 | 206 | "@types/node": "^24.10.13", |
| 207 | 207 | "effect": "4.0.0-beta.45", |
| 208 | 208 | "tsdown": "^0.20.3", |
| ... | ... | @@ -770,7 +770,7 @@ |
| 770 | 770 | |
| 771 | 771 | "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], |
| 772 | 772 | |
| 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=="], | |
| 774 | 774 | |
| 775 | 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=="], |
| 776 | 776 | |
| ... | ... | @@ -916,7 +916,7 @@ |
| 916 | 916 | |
| 917 | 917 | "builder-util-runtime": ["builder-util-runtime@9.5.1", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ=="], |
| 918 | 918 | |
| 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=="], | |
| 920 | 920 | |
| 921 | 921 | "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], |
| 922 | 922 |
package.json+6-4| ... | ... | @@ -16,7 +16,7 @@ |
| 16 | 16 | "@effect/sql-sqlite-bun": "4.0.0-beta.45", |
| 17 | 17 | "@effect/vitest": "4.0.0-beta.45", |
| 18 | 18 | "@effect/language-service": "0.84.2", |
| 19 | "@types/bun": "^1.3.9", | |
| 19 | "@types/bun": "^1.3.11", | |
| 20 | 20 | "@types/node": "^24.10.13", |
| 21 | 21 | "tsdown": "^0.20.3", |
| 22 | 22 | "typescript": "^5.7.3", |
| ... | ... | @@ -50,7 +50,9 @@ |
| 50 | 50 | "dist:desktop:dmg:arm64": "node scripts/build-desktop-artifact.ts --platform mac --target dmg --arch arm64", |
| 51 | 51 | "dist:desktop:dmg:x64": "node scripts/build-desktop-artifact.ts --platform mac --target dmg --arch x64", |
| 52 | 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 | 56 | "release:smoke": "node scripts/release-smoke.ts", |
| 55 | 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 | 58 | "sync:vscode-icons": "node scripts/sync-vscode-icons.mjs" |
| ... | ... | @@ -73,10 +75,10 @@ |
| 73 | 75 | "vite": "^8.0.0" |
| 74 | 76 | }, |
| 75 | 77 | "engines": { |
| 76 | "bun": "^1.3.9", | |
| 78 | "bun": "^1.3.11", | |
| 77 | 79 | "node": "^24.13.1" |
| 78 | 80 | }, |
| 79 | "packageManager": "bun@1.3.9", | |
| 81 | "packageManager": "bun@1.3.11", | |
| 80 | 82 | "msw": { |
| 81 | 83 | "workerDirectory": [ |
| 82 | 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 | 9 | import serverPackageJson from "../apps/server/package.json" with { type: "json" }; |
| 10 | 10 | |
| 11 | 11 | import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts"; |
| 12 | import { getDefaultBuildArch } from "./lib/build-target-arch.ts"; | |
| 12 | 13 | import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts"; |
| 13 | 14 | |
| 14 | 15 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; |
| ... | ... | @@ -82,14 +83,7 @@ function getDefaultArch(platform: typeof BuildPlatform.Type): typeof BuildArch.T |
| 82 | 83 | return "x64"; |
| 83 | 84 | } |
| 84 | 85 | |
| 85 | if (process.arch === "arm64" && config.archChoices.includes("arm64")) { | |
| 86 | return "arm64"; | |
| 87 | } | |
| 88 | if (process.arch === "x64" && config.archChoices.includes("x64")) { | |
| 89 | return "x64"; | |
| 90 | } | |
| 91 | ||
| 92 | return config.archChoices[0] ?? "x64"; | |
| 86 | return getDefaultBuildArch(platform, process.arch, process.env, config); | |
| 93 | 87 | } |
| 94 | 88 | |
| 95 | 89 | class BuildScriptError extends Data.TaggedError("BuildScriptError")<{ |
scripts/lib/build-target-arch.test.ts created+61| ... | ... | @@ -0,0 +1,61 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | |
| 2 | ||
| 3 | import { getDefaultBuildArch, resolveHostProcessArch } from "./build-target-arch.ts"; | |
| 4 | ||
| 5 | describe("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 @@ |
| 1 | export type BuildArch = "arm64" | "x64" | "universal"; | |
| 2 | export type BuildPlatform = "mac" | "linux" | "win"; | |
| 3 | ||
| 4 | interface PlatformConfig { | |
| 5 | readonly archChoices: ReadonlyArray<BuildArch>; | |
| 6 | } | |
| 7 | ||
| 8 | function 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 | ||
| 16 | export 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 | ||
| 36 | export 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 @@ |
| 1 | export interface UpdateManifestFile { | |
| 2 | readonly url: string; | |
| 3 | readonly sha512: string; | |
| 4 | readonly size: number; | |
| 5 | } | |
| 6 | ||
| 7 | export type UpdateManifestScalar = string | number | boolean; | |
| 8 | ||
| 9 | export interface UpdateManifest { | |
| 10 | readonly version: string; | |
| 11 | readonly releaseDate: string; | |
| 12 | readonly files: ReadonlyArray<UpdateManifestFile>; | |
| 13 | readonly extras: Readonly<Record<string, UpdateManifestScalar>>; | |
| 14 | } | |
| 15 | ||
| 16 | interface MutableUpdateManifestFile { | |
| 17 | url?: string; | |
| 18 | sha512?: string; | |
| 19 | size?: number; | |
| 20 | } | |
| 21 | ||
| 22 | function stripSingleQuotes(value: string): string { | |
| 23 | if (value.startsWith("'") && value.endsWith("'")) { | |
| 24 | return value.slice(1, -1).replace(/''/g, "'"); | |
| 25 | } | |
| 26 | return value; | |
| 27 | } | |
| 28 | ||
| 29 | function 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 | ||
| 54 | function 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 | ||
| 67 | export 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 | ||
| 188 | function 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 | ||
| 208 | export 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 | ||
| 239 | function quoteYamlString(value: string): string { | |
| 240 | return `'${value.replace(/'/g, "''")}'`; | |
| 241 | } | |
| 242 | ||
| 243 | function serializeScalarValue(value: UpdateManifestScalar): string { | |
| 244 | if (typeof value === "string") { | |
| 245 | return quoteYamlString(value); | |
| 246 | } | |
| 247 | return String(value); | |
| 248 | } | |
| 249 | ||
| 250 | export 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 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | |
| 2 | ||
| 3 | import { | |
| 4 | mergeMacUpdateManifests, | |
| 5 | parseMacUpdateManifest, | |
| 6 | serializeMacUpdateManifest, | |
| 7 | } from "./merge-mac-update-manifests.ts"; | |
| 8 | ||
| 9 | describe("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 | |
| 13 | files: | |
| 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 | |
| 20 | path: T3-Code-0.0.4-arm64.zip | |
| 21 | sha512: arm64zip | |
| 22 | releaseDate: '2026-03-07T10:32:14.587Z' | |
| 23 | `, | |
| 24 | "latest-mac.yml", | |
| 25 | ); | |
| 26 | ||
| 27 | const x64 = parseMacUpdateManifest( | |
| 28 | `version: 0.0.4 | |
| 29 | files: | |
| 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 | |
| 36 | path: T3-Code-0.0.4-x64.zip | |
| 37 | sha512: x64zip | |
| 38 | releaseDate: '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 | |
| 65 | files: | |
| 66 | - url: T3-Code-0.0.4-arm64.zip | |
| 67 | sha512: arm64zip | |
| 68 | size: 1 | |
| 69 | releaseDate: '2026-03-07T10:32:14.587Z' | |
| 70 | `, | |
| 71 | "latest-mac.yml", | |
| 72 | ); | |
| 73 | ||
| 74 | const x64 = parseMacUpdateManifest( | |
| 75 | `version: 0.0.5 | |
| 76 | files: | |
| 77 | - url: T3-Code-0.0.5-x64.zip | |
| 78 | sha512: x64zip | |
| 79 | size: 1 | |
| 80 | releaseDate: '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' | |
| 91 | files: | |
| 92 | - url: T3-Code-1.0-x64.zip | |
| 93 | sha512: zipsha | |
| 94 | size: 1 | |
| 95 | releaseName: 'true' | |
| 96 | minimumSystemVersion: '13.0' | |
| 97 | stagingPercentage: 50 | |
| 98 | releaseDate: '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 @@ |
| 1 | import { readFileSync, writeFileSync } from "node:fs"; | |
| 2 | import { resolve } from "node:path"; | |
| 3 | import { fileURLToPath } from "node:url"; | |
| 4 | ||
| 5 | interface MacUpdateFile { | |
| 6 | readonly url: string; | |
| 7 | readonly sha512: string; | |
| 8 | readonly size: number; | |
| 9 | } | |
| 10 | ||
| 11 | type MacUpdateScalar = string | number | boolean; | |
| 12 | ||
| 13 | interface MacUpdateManifest { | |
| 14 | readonly version: string; | |
| 15 | readonly releaseDate: string; | |
| 16 | readonly files: ReadonlyArray<MacUpdateFile>; | |
| 17 | readonly extras: Readonly<Record<string, MacUpdateScalar>>; | |
| 18 | } | |
| 19 | ||
| 20 | interface MutableMacUpdateFile { | |
| 21 | url?: string; | |
| 22 | sha512?: string; | |
| 23 | size?: number; | |
| 24 | } | |
| 25 | ||
| 26 | function stripSingleQuotes(value: string): string { | |
| 27 | if (value.startsWith("'") && value.endsWith("'")) { | |
| 28 | return value.slice(1, -1).replace(/''/g, "'"); | |
| 29 | } | |
| 30 | return value; | |
| 31 | } | |
| 32 | ||
| 33 | function 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 | ||
| 57 | function 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 | ||
| 70 | export 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 | ||
| 185 | function 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 | ||
| 204 | export 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 | ||
| 234 | function quoteYamlString(value: string): string { | |
| 235 | return `'${value.replace(/'/g, "''")}'`; | |
| 236 | } | |
| 237 | ||
| 238 | function serializeScalarValue(value: MacUpdateScalar): string { | |
| 239 | if (typeof value === "string") { | |
| 240 | return quoteYamlString(value); | |
| 241 | } | |
| 242 | return String(value); | |
| 243 | } | |
| 244 | ||
| 245 | export 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 | ||
| 267 | function 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 | ||
| 285 | if (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 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | |
| 2 | ||
| 3 | import { | |
| 4 | mergePlatformUpdateManifests, | |
| 5 | parsePlatformUpdateManifest, | |
| 6 | serializePlatformUpdateManifest, | |
| 7 | } from "./merge-update-manifests.ts"; | |
| 8 | ||
| 9 | describe("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 | |
| 14 | files: | |
| 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 | |
| 21 | path: T3-Code-0.0.4-arm64.zip | |
| 22 | sha512: arm64zip | |
| 23 | releaseDate: '2026-03-07T10:32:14.587Z' | |
| 24 | `, | |
| 25 | "latest-mac.yml", | |
| 26 | ); | |
| 27 | ||
| 28 | const x64 = parsePlatformUpdateManifest( | |
| 29 | "mac", | |
| 30 | `version: 0.0.4 | |
| 31 | files: | |
| 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 | |
| 38 | path: T3-Code-0.0.4-x64.zip | |
| 39 | sha512: x64zip | |
| 40 | releaseDate: '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 | |
| 68 | files: | |
| 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 | |
| 75 | path: T3-Code-0.0.4-arm64.exe | |
| 76 | sha512: arm64exe | |
| 77 | releaseDate: '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 | |
| 85 | files: | |
| 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 | |
| 92 | path: T3-Code-0.0.4-x64.exe | |
| 93 | sha512: x64exe | |
| 94 | releaseDate: '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 | |
| 122 | files: | |
| 123 | - url: T3-Code-0.0.4-arm64.exe | |
| 124 | sha512: arm64exe | |
| 125 | size: 1 | |
| 126 | releaseDate: '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 | |
| 134 | files: | |
| 135 | - url: T3-Code-0.0.5-x64.exe | |
| 136 | sha512: x64exe | |
| 137 | size: 1 | |
| 138 | releaseDate: '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' | |
| 153 | files: | |
| 154 | - url: T3-Code-1.0-x64.zip | |
| 155 | sha512: zipsha | |
| 156 | size: 1 | |
| 157 | releaseName: 'true' | |
| 158 | minimumSystemVersion: '13.0' | |
| 159 | stagingPercentage: 50 | |
| 160 | releaseDate: '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' | |
| 175 | files: | |
| 176 | - url: T3-Code-1.0-x64.exe | |
| 177 | sha512: exesha | |
| 178 | size: 1 | |
| 179 | releaseDate: '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 @@ |
| 1 | import { readFileSync, writeFileSync } from "node:fs"; | |
| 2 | import { resolve } from "node:path"; | |
| 3 | import { fileURLToPath } from "node:url"; | |
| 4 | ||
| 5 | import { | |
| 6 | mergeUpdateManifests, | |
| 7 | parseUpdateManifest, | |
| 8 | serializeUpdateManifest, | |
| 9 | type UpdateManifest, | |
| 10 | } from "./lib/update-manifest.ts"; | |
| 11 | ||
| 12 | export type UpdateManifestPlatform = "mac" | "win"; | |
| 13 | ||
| 14 | function getPlatformLabel(platform: UpdateManifestPlatform): string { | |
| 15 | return platform === "mac" ? "macOS" : "Windows"; | |
| 16 | } | |
| 17 | ||
| 18 | export function parsePlatformUpdateManifest( | |
| 19 | platform: UpdateManifestPlatform, | |
| 20 | raw: string, | |
| 21 | sourcePath: string, | |
| 22 | ): UpdateManifest { | |
| 23 | return parseUpdateManifest(raw, sourcePath, getPlatformLabel(platform)); | |
| 24 | } | |
| 25 | ||
| 26 | export function mergePlatformUpdateManifests( | |
| 27 | platform: UpdateManifestPlatform, | |
| 28 | primary: UpdateManifest, | |
| 29 | secondary: UpdateManifest, | |
| 30 | ): UpdateManifest { | |
| 31 | return mergeUpdateManifests(primary, secondary, getPlatformLabel(platform)); | |
| 32 | } | |
| 33 | ||
| 34 | export function serializePlatformUpdateManifest( | |
| 35 | platform: UpdateManifestPlatform, | |
| 36 | manifest: UpdateManifest, | |
| 37 | ): string { | |
| 38 | return serializeUpdateManifest(manifest, { | |
| 39 | platformLabel: getPlatformLabel(platform), | |
| 40 | }); | |
| 41 | } | |
| 42 | ||
| 43 | function 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 | ||
| 73 | function 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 | ||
| 89 | if (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 | 70 | return { arm64Path, x64Path }; |
| 71 | 71 | } |
| 72 | 72 | |
| 73 | function 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 | |
| 83 | files: | |
| 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 | |
| 90 | path: T3-Code-9.9.9-smoke.0-arm64.exe | |
| 91 | sha512: arm64exe | |
| 92 | releaseDate: '2026-03-08T10:32:14.587Z' | |
| 93 | `, | |
| 94 | ); | |
| 95 | ||
| 96 | writeFileSync( | |
| 97 | x64Path, | |
| 98 | `version: 9.9.9-smoke.0 | |
| 99 | files: | |
| 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 | |
| 106 | path: T3-Code-9.9.9-smoke.0-x64.exe | |
| 107 | sha512: x64exe | |
| 108 | releaseDate: '2026-03-08T10:36:07.540Z' | |
| 109 | `, | |
| 110 | ); | |
| 111 | ||
| 112 | return { arm64Path, x64Path }; | |
| 113 | } | |
| 114 | ||
| 73 | 115 | function assertContains(haystack: string, needle: string, message: string): void { |
| 74 | 116 | if (!haystack.includes(needle)) { |
| 75 | 117 | throw new Error(message); |
| ... | ... | @@ -144,7 +186,13 @@ try { |
| 144 | 186 | const { arm64Path, x64Path } = writeMacManifestFixtures(tempRoot); |
| 145 | 187 | execFileSync( |
| 146 | 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 | 197 | cwd: repoRoot, |
| 150 | 198 | stdio: "inherit", |
| ... | ... | @@ -163,6 +211,34 @@ try { |
| 163 | 211 | "Merged manifest is missing the x64 asset.", |
| 164 | 212 | ); |
| 165 | 213 | |
| 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 | 242 | console.log("Release smoke checks passed."); |
| 167 | 243 | } finally { |
| 168 | 244 | rmSync(tempRoot, { recursive: true, force: true }); |