Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 5 additions & 27 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
83 changes: 83 additions & 0 deletions scripts/merge-release-update-manifests.test.ts
Original file line number Diff line number Diff line change
@@ -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`);
}),
);
});
137 changes: 137 additions & 0 deletions scripts/merge-release-update-manifests.ts
Original file line number Diff line number Diff line change
@@ -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<string>,
): ReadonlyArray<ReleaseUpdateManifestMerge> {
const macArm64 = new Map<string, string>();
const macX64 = new Map<string, string>();
const winArm64 = new Map<string, string>();
const winX64 = new Map<string, string>();

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,
);
}
74 changes: 16 additions & 58 deletions scripts/release-smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -328,45 +301,30 @@ 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,
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.",
);
assertMissing(x64Path, "Release smoke unexpectedly kept the x64 macOS updater manifest.");

const mergedWindowsManifest = NodeFS.readFileSync(mergedWindowsManifestPath, "utf8");
assertContains(
mergedWindowsManifest,
Expand Down
Loading