diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d8d1410825..8e8cf0fdfc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -262,6 +262,13 @@ jobs: merge-multiple: true path: release-assets + - name: Merge macOS updater manifests + run: | + node scripts/merge-mac-update-manifests.ts \ + release-assets/latest-mac.yml \ + release-assets/latest-mac-x64.yml + rm -f release-assets/latest-mac-x64.yml + - name: Publish release uses: softprops/action-gh-release@v2 with: diff --git a/docs/release.md b/docs/release.md index 6c338ee641..4aec150f33 100644 --- a/docs/release.md +++ b/docs/release.md @@ -36,6 +36,9 @@ This document covers how to run desktop releases from one tag, first without sig - platform installers (`.exe`, `.dmg`, `.AppImage`, plus macOS `.zip` for Squirrel.Mac update payloads) - `latest*.yml` metadata - `*.blockmap` files (used for differential downloads) +- macOS metadata note: + - `electron-updater` reads `latest-mac.yml` for both Intel and Apple Silicon. + - The workflow merges the per-arch mac manifests into one `latest-mac.yml` before publishing the GitHub Release. ## 0) npm OIDC trusted publishing setup (CLI) diff --git a/scripts/merge-mac-update-manifests.test.ts b/scripts/merge-mac-update-manifests.test.ts new file mode 100644 index 0000000000..22d2e7627e --- /dev/null +++ b/scripts/merge-mac-update-manifests.test.ts @@ -0,0 +1,108 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { + mergeMacUpdateManifests, + parseMacUpdateManifest, + serializeMacUpdateManifest, +} from "./merge-mac-update-manifests.ts"; + +describe("merge-mac-update-manifests", () => { + it("merges arm64 and x64 macOS update manifests into one multi-arch manifest", () => { + const arm64 = parseMacUpdateManifest( + `version: 0.0.4 +files: + - url: T3-Code-0.0.4-arm64.zip + sha512: arm64zip + size: 125621344 + - url: T3-Code-0.0.4-arm64.dmg + sha512: arm64dmg + size: 131754935 +path: T3-Code-0.0.4-arm64.zip +sha512: arm64zip +releaseDate: '2026-03-07T10:32:14.587Z' +`, + "latest-mac.yml", + ); + + const x64 = parseMacUpdateManifest( + `version: 0.0.4 +files: + - url: T3-Code-0.0.4-x64.zip + sha512: x64zip + size: 132000112 + - url: T3-Code-0.0.4-x64.dmg + sha512: x64dmg + size: 138148807 +path: T3-Code-0.0.4-x64.zip +sha512: x64zip +releaseDate: '2026-03-07T10:36:07.540Z' +`, + "latest-mac-x64.yml", + ); + + const merged = mergeMacUpdateManifests(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.zip", + "T3-Code-0.0.4-arm64.dmg", + "T3-Code-0.0.4-x64.zip", + "T3-Code-0.0.4-x64.dmg", + ], + ); + + const serialized = serializeMacUpdateManifest(merged); + assert.ok(!serialized.includes("path:")); + assert.equal((serialized.match(/- url:/g) ?? []).length, 4); + }); + + it("rejects mismatched manifest versions", () => { + const arm64 = parseMacUpdateManifest( + `version: 0.0.4 +files: + - url: T3-Code-0.0.4-arm64.zip + sha512: arm64zip + size: 1 +releaseDate: '2026-03-07T10:32:14.587Z' +`, + "latest-mac.yml", + ); + + const x64 = parseMacUpdateManifest( + `version: 0.0.5 +files: + - url: T3-Code-0.0.5-x64.zip + sha512: x64zip + size: 1 +releaseDate: '2026-03-07T10:36:07.540Z' +`, + "latest-mac-x64.yml", + ); + + assert.throws(() => mergeMacUpdateManifests(arm64, x64), /different versions/); + }); + + it("preserves quoted scalars as strings", () => { + const manifest = parseMacUpdateManifest( + `version: '1.0' +files: + - url: T3-Code-1.0-x64.zip + sha512: zipsha + size: 1 +releaseName: 'true' +minimumSystemVersion: '13.0' +stagingPercentage: 50 +releaseDate: '2026-03-07T10:36:07.540Z' +`, + "latest-mac.yml", + ); + + assert.equal(manifest.version, "1.0"); + assert.equal(manifest.extras.releaseName, "true"); + assert.equal(manifest.extras.minimumSystemVersion, "13.0"); + assert.equal(manifest.extras.stagingPercentage, 50); + }); +}); diff --git a/scripts/merge-mac-update-manifests.ts b/scripts/merge-mac-update-manifests.ts new file mode 100644 index 0000000000..c59bc76b9b --- /dev/null +++ b/scripts/merge-mac-update-manifests.ts @@ -0,0 +1,287 @@ +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>; +} + +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; +} + +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; +} + +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); +} + +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"); +} + +function main(args: ReadonlyArray): void { + const [arm64PathArg, x64PathArg, outputPathArg] = args; + if (!arm64PathArg || !x64PathArg) { + throw new Error( + "Usage: node scripts/merge-mac-update-manifests.ts [output-path]", + ); + } + + const arm64Path = resolve(arm64PathArg); + const x64Path = resolve(x64PathArg); + const outputPath = resolve(outputPathArg ?? arm64PathArg); + + const arm64Manifest = parseMacUpdateManifest(readFileSync(arm64Path, "utf8"), arm64Path); + const x64Manifest = parseMacUpdateManifest(readFileSync(x64Path, "utf8"), x64Path); + const merged = mergeMacUpdateManifests(arm64Manifest, x64Manifest); + writeFileSync(outputPath, serializeMacUpdateManifest(merged)); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main(process.argv.slice(2)); +}