From 4aa4c75ce333c3b6c6d967037958b14ed8aa444f Mon Sep 17 00:00:00 2001 From: Tejas Parmar Date: Thu, 22 Oct 2026 15:41:06 +0000 Subject: [PATCH] fix(release): require complete updater manifests --- .github/workflows/release.yml | 32 +--- .../merge-release-update-manifests.test.ts | 83 +++++++++++ scripts/merge-release-update-manifests.ts | 137 ++++++++++++++++++ scripts/release-smoke.ts | 74 ++-------- 4 files changed, 241 insertions(+), 85 deletions(-) create mode 100644 scripts/merge-release-update-manifests.test.ts create mode 100644 scripts/merge-release-update-manifests.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6a80220c28a87..2420ee2173954 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -832,34 +832,12 @@ jobs: fi # electron-updater reads one manifest per platform and channel and picks - # the file entry whose name carries the running arch, so the per-arch - # manifests the build jobs wrote are merged back into that one file. - - name: Merge macOS updater manifests + # the file entry whose name carries the running arch. Require every + # platform's two build jobs to have produced matching metadata before + # merging it, so a successful release can never strand one architecture. + - name: Merge desktop updater manifests if: needs.preflight.outputs.release_channel != 'preview' - run: | - shopt -s nullglob - for x64_manifest in release-assets/*-mac-x64.yml; do - arm64_manifest="${x64_manifest%-x64.yml}.yml" - if [[ -f "$arm64_manifest" ]]; then - node scripts/merge-update-manifests.ts --platform mac "$arm64_manifest" "$x64_manifest" - rm -f "$x64_manifest" - fi - done - - - name: Merge Windows updater manifests - if: needs.preflight.outputs.release_channel != 'preview' - run: | - shopt -s nullglob - for x64_manifest in release-assets/*-win-x64.yml; do - arm64_manifest="${x64_manifest%-x64.yml}-arm64.yml" - merged_manifest="${x64_manifest%-win-x64.yml}.yml" - if [[ -f "$arm64_manifest" ]]; then - node scripts/merge-update-manifests.ts --platform win "$x64_manifest" "$arm64_manifest" "$merged_manifest" - rm -f "$x64_manifest" "$arm64_manifest" - else - mv "$x64_manifest" "$merged_manifest" - fi - done + run: node scripts/merge-release-update-manifests.ts release-assets # Updater manifests and blockmaps are what electron-updater consumes. # They are only listed for channels an updater is meant to follow. diff --git a/scripts/merge-release-update-manifests.test.ts b/scripts/merge-release-update-manifests.test.ts new file mode 100644 index 0000000000000..f930aaf78506a --- /dev/null +++ b/scripts/merge-release-update-manifests.test.ts @@ -0,0 +1,83 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { mergeReleaseUpdateManifestFiles } from "./merge-release-update-manifests.ts"; + +const manifest = (fileName: string) => `version: 1.2.3 +files: + - url: ${fileName} + sha512: ${fileName}-sha + size: 123 +releaseDate: '2026-10-22T00:00:00.000Z' +`; + +it.layer(NodeServices.layer)("merge release update manifests", (it) => { + it.effect("merges complete macOS and Windows architecture pairs", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const releaseAssetsDir = yield* fs.makeTempDirectoryScoped({ + prefix: "merge-release-update-manifests-", + }); + const fixtures = { + "latest-mac.yml": manifest("T3-Code-1.2.3-arm64.zip"), + "latest-mac-x64.yml": manifest("T3-Code-1.2.3-x64.zip"), + "latest-win-arm64.yml": manifest("T3-Code-1.2.3-arm64.exe"), + "latest-win-x64.yml": manifest("T3-Code-1.2.3-x64.exe"), + }; + yield* Effect.forEach(Object.entries(fixtures), ([fileName, contents]) => + fs.writeFileString(path.join(releaseAssetsDir, fileName), contents), + ); + + yield* mergeReleaseUpdateManifestFiles(releaseAssetsDir); + + const mac = yield* fs.readFileString(path.join(releaseAssetsDir, "latest-mac.yml")); + const win = yield* fs.readFileString(path.join(releaseAssetsDir, "latest.yml")); + assert.include(mac, "T3-Code-1.2.3-arm64.zip"); + assert.include(mac, "T3-Code-1.2.3-x64.zip"); + assert.include(win, "T3-Code-1.2.3-arm64.exe"); + assert.include(win, "T3-Code-1.2.3-x64.exe"); + assert.isFalse(yield* fs.exists(path.join(releaseAssetsDir, "latest-mac-x64.yml"))); + assert.isFalse(yield* fs.exists(path.join(releaseAssetsDir, "latest-win-arm64.yml"))); + assert.isFalse(yield* fs.exists(path.join(releaseAssetsDir, "latest-win-x64.yml"))); + }), + ); + + it.effect.each([ + { + platform: "macOS", + fixtures: ["latest-mac.yml", "latest-win-arm64.yml", "latest-win-x64.yml"], + }, + { + platform: "macOS", + fixtures: ["latest-mac-x64.yml", "latest-win-arm64.yml", "latest-win-x64.yml"], + }, + { + platform: "Windows", + fixtures: ["latest-mac.yml", "latest-mac-x64.yml", "latest-win-arm64.yml"], + }, + { + platform: "Windows", + fixtures: ["latest-mac.yml", "latest-mac-x64.yml", "latest-win-x64.yml"], + }, + ] as const)("refuses a one-architecture $platform release", ({ platform, fixtures }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const releaseAssetsDir = yield* fs.makeTempDirectoryScoped({ + prefix: "merge-release-update-manifests-incomplete-", + }); + yield* Effect.forEach(fixtures, (fileName) => + fs.writeFileString(path.join(releaseAssetsDir, fileName), manifest(fileName)), + ); + + const exit = yield* mergeReleaseUpdateManifestFiles(releaseAssetsDir).pipe(Effect.exit); + + assert.equal(exit._tag, "Failure"); + assert.include(String(exit), `Incomplete ${platform} updater manifests`); + }), + ); +}); diff --git a/scripts/merge-release-update-manifests.ts b/scripts/merge-release-update-manifests.ts new file mode 100644 index 0000000000000..647ef6c2a14cf --- /dev/null +++ b/scripts/merge-release-update-manifests.ts @@ -0,0 +1,137 @@ +#!/usr/bin/env node + +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import { Argument, Command } from "effect/cli"; + +import { mergeUpdateManifestFiles, type UpdateManifestPlatform } from "./merge-update-manifests.ts"; + +interface ReleaseUpdateManifestMerge { + readonly platform: UpdateManifestPlatform; + readonly primary: string; + readonly secondary: string; + readonly output: string; +} + +function incomplete(platform: string, detail: string): Error { + return new Error(`Incomplete ${platform} updater manifests: ${detail}.`); +} + +export function planReleaseUpdateManifestMerges( + fileNames: ReadonlyArray, +): ReadonlyArray { + const macArm64 = new Map(); + const macX64 = new Map(); + const winArm64 = new Map(); + const winX64 = new Map(); + + for (const fileName of fileNames) { + if (fileName.startsWith("builder-debug-")) continue; + + const macArm64Match = /^(.+)-mac\.yml$/u.exec(fileName); + if (macArm64Match?.[1] !== undefined) { + macArm64.set(macArm64Match[1], fileName); + continue; + } + const macX64Match = /^(.+)-mac-x64\.yml$/u.exec(fileName); + if (macX64Match?.[1] !== undefined) { + macX64.set(macX64Match[1], fileName); + continue; + } + const winMatch = /^(.+)-win-(arm64|x64)\.yml$/u.exec(fileName); + if (winMatch?.[1] !== undefined) { + (winMatch[2] === "arm64" ? winArm64 : winX64).set(winMatch[1], fileName); + } + } + + const plans: ReleaseUpdateManifestMerge[] = []; + const macChannels = new Set([...macArm64.keys(), ...macX64.keys()]); + if (macChannels.size === 0) throw incomplete("macOS", "no architecture manifests found"); + for (const channel of [...macChannels].toSorted()) { + const arm64 = macArm64.get(channel); + const x64 = macX64.get(channel); + if (arm64 === undefined || x64 === undefined) { + throw incomplete( + "macOS", + `${channel} is missing its ${arm64 === undefined ? "arm64" : "x64"} manifest`, + ); + } + plans.push({ + platform: "mac", + primary: arm64, + secondary: x64, + output: arm64, + }); + } + + const winChannels = new Set([...winArm64.keys(), ...winX64.keys()]); + if (winChannels.size === 0) throw incomplete("Windows", "no architecture manifests found"); + for (const channel of [...winChannels].toSorted()) { + const arm64 = winArm64.get(channel); + const x64 = winX64.get(channel); + if (arm64 === undefined || x64 === undefined) { + throw incomplete( + "Windows", + `${channel} is missing its ${arm64 === undefined ? "arm64" : "x64"} manifest`, + ); + } + plans.push({ + platform: "win", + primary: x64, + secondary: arm64, + output: `${channel}.yml`, + }); + } + + return plans; +} + +export const mergeReleaseUpdateManifestFiles = Effect.fn("mergeReleaseUpdateManifestFiles")( + function* (releaseAssetsDirArg: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const releaseAssetsDir = path.resolve(releaseAssetsDirArg); + const plans = planReleaseUpdateManifestMerges(yield* fs.readDirectory(releaseAssetsDir)); + + yield* Effect.forEach( + plans, + (plan) => { + const primary = path.join(releaseAssetsDir, plan.primary); + const secondary = path.join(releaseAssetsDir, plan.secondary); + const output = path.join(releaseAssetsDir, plan.output); + return mergeUpdateManifestFiles(plan.platform, primary, secondary, output).pipe( + Effect.andThen( + Effect.forEach( + [primary, secondary].filter((source) => source !== output), + (source) => fs.remove(source), + { discard: true }, + ), + ), + ); + }, + { discard: true }, + ); + }, +); + +export const mergeReleaseUpdateManifestsCommand = Command.make( + "merge-release-update-manifests", + { + releaseAssetsDir: Argument.String("release-assets-dir").pipe( + Argument.withDescription("Directory containing every desktop release artifact."), + ), + }, + ({ releaseAssetsDir }) => mergeReleaseUpdateManifestFiles(releaseAssetsDir), +).pipe( + Command.withDescription("Require and merge complete arm64/x64 desktop updater manifest pairs."), +); + +if (import.meta.main) { + Command.run(mergeReleaseUpdateManifestsCommand, { version: "0.0.0" }).pipe( + Effect.provide(NodeServices.layer), + NodeRuntime.runMain, + ); +} diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index cceda33742206..064f7ec28e20f 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -287,33 +287,6 @@ try { ); const { arm64Path, x64Path } = writeMacManifestFixtures(tempRoot); - NodeChildProcess.execFileSync( - process.execPath, - [ - NodePath.resolve(repoRoot, "scripts/merge-update-manifests.ts"), - "--platform", - "mac", - arm64Path, - x64Path, - ], - { - cwd: repoRoot, - stdio: "inherit", - }, - ); - - const mergedManifest = NodeFS.readFileSync(arm64Path, "utf8"); - assertContains( - mergedManifest, - "T3-Code-9.9.9-smoke.0-arm64.zip", - "Merged manifest is missing the arm64 asset.", - ); - assertContains( - mergedManifest, - "T3-Code-9.9.9-smoke.0-x64.zip", - "Merged manifest is missing the x64 asset.", - ); - const { arm64Path: winArm64Path, x64Path: winX64Path } = writeWindowsManifestFixtures( tempRoot, "latest", @@ -328,38 +301,10 @@ try { const { arm64Path: winDebugArm64Path, x64Path: winDebugX64Path } = writeWindowsBuilderDebugFixtures(tempRoot); NodeChildProcess.execFileSync( - "bash", + process.execPath, [ - "-lc", - ` - release_assets_dir=${JSON.stringify(NodePath.resolve(tempRoot, "release-assets"))} - shopt -s nullglob - found_windows_manifest=false - for x64_manifest in "$release_assets_dir"/*-win-x64.yml; do - if [[ "$(basename "$x64_manifest")" == builder-debug-* ]]; then - continue - fi - - arm64_manifest="\${x64_manifest/-x64.yml/-arm64.yml}" - output_manifest="\${x64_manifest/-win-x64.yml/.yml}" - if [[ ! -f "$arm64_manifest" ]]; then - echo "Missing matching arm64 Windows manifest for $x64_manifest" >&2 - exit 1 - fi - - found_windows_manifest=true - ${JSON.stringify(process.execPath)} ${JSON.stringify(NodePath.resolve(repoRoot, "scripts/merge-update-manifests.ts"))} --platform win \ - "$arm64_manifest" \ - "$x64_manifest" \ - "$output_manifest" - rm -f "$arm64_manifest" "$x64_manifest" - done - - if [[ "$found_windows_manifest" != true ]]; then - echo "No Windows updater manifests found to merge." >&2 - exit 1 - fi - `, + NodePath.resolve(repoRoot, "scripts/merge-release-update-manifests.ts"), + NodePath.resolve(tempRoot, "release-assets"), ], { cwd: repoRoot, @@ -367,6 +312,19 @@ try { }, ); + const mergedManifest = NodeFS.readFileSync(arm64Path, "utf8"); + assertContains( + mergedManifest, + "T3-Code-9.9.9-smoke.0-arm64.zip", + "Merged manifest is missing the arm64 asset.", + ); + assertContains( + mergedManifest, + "T3-Code-9.9.9-smoke.0-x64.zip", + "Merged manifest is missing the x64 asset.", + ); + assertMissing(x64Path, "Release smoke unexpectedly kept the x64 macOS updater manifest."); + const mergedWindowsManifest = NodeFS.readFileSync(mergedWindowsManifestPath, "utf8"); assertContains( mergedWindowsManifest,