Files
lefarcen a07dc736fd perf(ci): take prerelease validation off the release pipeline, and report it on one live card (#7842)
* perf(ci): make prerelease mac Intel opt-in

build_mac_intel is the prerelease critical path: ~33 min on the
macos-15-intel fleet against ~21 min for mac arm64 and Windows, which
stretches every release-branch push to ~56 min end to end.

Gate it behind a new `enable_mac_x64` input that defaults to false, the
same shape build_linux already uses, and let publish tolerate a skipped
Intel job. Push-triggered prereleases now finish in ~21 min; a manual
dispatch can still turn Intel back on.

The stable promotion gate is unchanged and still hard-requires
platforms.macIntel in the promoted prerelease metadata, so an Intel-less
prerelease fails promotion loudly rather than shipping unvalidated.

* perf(ci): stop the prerelease pipeline waiting on work it does not need

Run 34102146769 took 56m, 42m of which survived turning macOS Intel off.
Four things in that 42m were wall clock nobody was buying anything with.

Tests no longer sit in front of packaging. functional_e2e, e2e_vitest,
daemon_unit_tests and verify take ~11 min; packaging takes ~21 min, and the
build jobs needed all four before they could start. They now need only
metadata and run alongside the tests. Nothing about the safety of a release
changes: `publish` is what writes metadata.json, and it already required all
four gates to be `success` — a red suite still cannot ship. What it costs is
CI minutes, since a failing gate now stops builds that have already run.

The Feishu card stops cloning the repository to read one `git log`. That
checkout was `fetch-depth: 0` against a 2.2 GiB pack and took 4m09s to
produce a `PREV..CUR` commit list. The list now comes from the compare API,
reproducing `git log --no-merges --pretty=format:'%s (%h)'` line for line —
verified against 35 commits of real history, where the only difference was a
leading space that feishu.ts trims anyway. Every fallback the old script had
(no baseline, unreachable commit) still produces an empty changelog rather
than a failed notification.

Packaged smoke and Apple notarization become opt-in, like Intel already is.
Smoke is advisory — `continue-on-error`, so it has never blocked publication
— and costs ~2.5 min on the Windows critical path. Notarization is a
round-trip to Apple that no prerelease consumer needs. Signing is NOT
optional and stays on: prepare-stable.ts requires platforms.mac.signed ==
true, so an unsigned prerelease could never be promoted. `mac_sign_mode: no`
is offered for parity with beta and now correctly reports signed=false
rather than lying in the metadata.

Windows drops setup-node's pnpm store cache and every job checks out
shallow. Measured across four prerelease runs the store cache missed 3 times
out of 4, costing 100s/66s/43s to save with no install speedup, and on its
one hit traded a 32s restore for a 36s faster install — while competing for
the 10 GB cache budget against tools-pack caches worth ~11 min each.
Nothing in this workflow reads git history.

* perf(ci): let a prerelease ship on the build's schedule, not the tests'

prerelease is the internal channel. Its job is to put a package in a
colleague's hands so a human can test it; automated CI is a second, parallel
opinion about the same commit. Wiring the two in series meant a single flaky
suite cost the team a whole day's package — the expensive failure, and the
one this pipeline kept producing.

The four test jobs (functional_e2e, e2e_vitest, daemon_unit_tests, verify)
now gate nothing. They are absent from `build_*` and from `publish`. They
still run on every prerelease and their results leave the workflow as
outputs — functional_e2e_result and friends, plus a tests_all_green /
failed_test_jobs rollup — so the notification layer can raise the alarm
without holding the package hostage. Stable is untouched: its promotion gate
reads the prerelease metadata and runs its own checks.

The packaged smoke suites move for the same reason. They were already
`continue-on-error`, so a smoke failure never failed the job — but they sat
between the build and the R2 upload, which meant the package waited on them
and a hung smoke could still eat the job. Every smoke now runs AFTER
"Publish <platform> platform" and "Upload <platform> publish manifest", and
every step after that manifest upload is continue-on-error, so nothing
post-delivery can fail a build job and retroactively block publish.

The results travel through a `test_signals` job rather than the
`jobs.<job_id>.result` expression GitHub documents for workflow_call outputs,
because actionlint has rejected that property since 2023
(rhysd/actionlint#343) and ci.yml runs actionlint at a pinned version.

One consequence to hand to the notification work: a failed advisory job now
makes the overall workflow conclusion red even though the package shipped, so
`needs.build.result` is no longer the right thing to colour a card by.
`version_metadata_url` is the authoritative "did a package ship?" signal and
is documented as such.

* perf(ci): move prerelease validation out of the pipeline it validates

release-prerelease.yml holds one repository-wide concurrency group with
cancel-in-progress: false, so any job that outlives `publish` keeps that
group held and stops the NEXT prerelease from starting. Making the test
jobs advisory did not fix that: they still held the group for 45 minutes
after the package had shipped.

The pipeline is now metadata -> build_* -> publish plus two dispatcher
jobs that nothing depends on. The four code suites, the packaged
mac/Windows smoke, and the release card each run in a workflow of their
own, keyed on the origin run.

The packaged smoke no longer installs electron-builder's output
directory on the machine that produced it. It reads the published
version metadata, downloads the DMG / setup.exe out of R2, verifies its
sha256 sidecar, and writes it to the single path
`tools-pack <platform> install` reads - which is why the specs need no
changes. That is both a better test and what lets the smoke live on a
different runner at all.

The release card becomes progressive. A Feishu application bot can PATCH
a message it sent, so one card is created when the first platform lands
in R2 and edited in place as the rest of the platforms, the code tests,
and the smoke report in. A PATCH replaces the whole card, so exactly one
job may write it: the watcher owns the message and polls the three runs.
The card is withheld until a package exists, because a "building..."
card posted at dispatch time becomes a permanent lie when a build dies -
with total build failure as the one exception, where silence would hide
an incident.

macOS Intel goes back to on-by-default: stable ships Intel and stable's
gate is a validated prerelease artifact, so an Intel-less prerelease
cannot be promoted at all. The switch stays, forwarded as
`github.event_name != 'workflow_dispatch' || inputs.enable_mac_x64` -
`inputs.enable_mac_x64 || true` would be unturnoffable, because a
dispatch with the box unchecked also yields true.

Also fixes the signal batch 2 left wrong: a reusable workflow reports
failure if any job inside it failed, including work that runs after
publication, so `needs.build.result` painted the download card red while
its own buttons worked. `version_metadata_url` is non-empty only when
publish wrote metadata, and now drives both the header colour and the
smoke annotations.

Two topology tests were already red on this branch - batch 2 changed the
workflow without updating them. Both now assert the new invariant.

The existing webhook card stays until the new one has carried a couple
of real releases; a gap in release notification is worse than a
duplicate.
2026-09-07 17:38:33 +00:00

221 lines
9.4 KiB
TypeScript

// Stage a PUBLISHED release artifact for the packaged smoke, instead of a
// locally built one.
//
// The packaged smoke used to run on the same runner that produced the package,
// against `<toolsPackDir>/out/...` as electron-builder left it. That coupled the
// smoke to the build job — which is what kept the release workflow's
// repository-wide concurrency group held long after the package had shipped —
// and it tested a directory tree no user ever sees.
//
// `tools-pack <platform> install` reads exactly one file, at a path it derives
// from `--dir` and `--namespace` (tools/pack/src/{mac,win}/paths.ts). So the
// smoke can run anywhere: download that one artifact out of R2, write it to
// that path, and every later `tools-pack` verb behaves as if the build had
// happened here.
//
// Two modes:
// plan — read the published version metadata and report which targets exist
// stage — download one target's artifact into the tools-pack layout
//
// Both read the metadata.json that `tools-release publish-metadata` wrote, so
// the artifact URL is the published one rather than a name reconstructed here.
import { createHash } from "node:crypto";
import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
type ReleaseTarget = "mac_arm64" | "mac_x64" | "win_x64" | "linux_x64";
type AssetEntry = { name?: unknown; sha256Url?: unknown; size?: unknown; url?: unknown };
type TargetEntry = { artifacts?: Record<string, AssetEntry>; status?: unknown };
type VersionMetadata = {
channel?: unknown;
releaseVersion?: unknown;
releaseTargets?: Record<string, TargetEntry>;
};
/**
* The artifact each platform's smoke installs. Mac installs the DMG it mounts;
* Windows installs the NSIS setup. The payload/zip artifacts are updater inputs
* and belong to the `full` Windows profile, which builds its own fixture.
*/
const PRIMARY_ARTIFACT: Record<ReleaseTarget, string> = {
mac_arm64: "dmg",
mac_x64: "dmg",
win_x64: "installer",
linux_x64: "appImage",
};
function required(name: string): string {
const value = process.env[name];
if (value == null || value.length === 0) throw new Error(`${name} is required`);
return value;
}
function optional(name: string, fallback = ""): string {
const value = process.env[name];
return value == null || value.length === 0 ? fallback : value;
}
function setOutput(key: string, value: string): void {
const file = process.env.GITHUB_OUTPUT;
console.log(`${key}=${value}`);
if (file == null || file.length === 0) return;
appendFileSync(file, `${key}=${value}\n`, "utf8");
}
function sanitizeNamespace(value: string): string {
// Mirrors sanitizeNamespace in tools/pack/src/{mac,win}/paths.ts. The token is
// what ends up in the artifact filename tools-pack looks for.
return value.replace(/[^A-Za-z0-9._-]+/g, "-");
}
async function fetchJson(url: string): Promise<VersionMetadata> {
const response = await fetch(url, { redirect: "follow" });
if (!response.ok) throw new Error(`GET ${url} failed: HTTP ${response.status}`);
return (await response.json()) as VersionMetadata;
}
function publishedTargets(metadata: VersionMetadata): ReleaseTarget[] {
const targets = metadata.releaseTargets ?? {};
const published: ReleaseTarget[] = [];
for (const target of ["mac_arm64", "mac_x64", "win_x64", "linux_x64"] as const) {
const entry = targets[target];
// A target that did not build carries `{status: "missing"|"failed"}` with no
// `artifacts` key at all, so `status` is the only safe thing to branch on.
if (entry?.status === "published" && entry.artifacts != null) published.push(target);
}
return published;
}
function assetOf(metadata: VersionMetadata, target: ReleaseTarget): { name: string; sha256Url: string; url: string } {
const entry = metadata.releaseTargets?.[target];
if (entry?.status !== "published" || entry.artifacts == null) {
throw new Error(`release target ${target} is not published in this metadata (status=${String(entry?.status)})`);
}
const key = PRIMARY_ARTIFACT[target];
const asset = entry.artifacts[key];
if (asset == null || typeof asset.url !== "string" || asset.url.length === 0) {
throw new Error(`release target ${target} has no ${key} artifact url`);
}
return {
name: typeof asset.name === "string" ? asset.name : key,
sha256Url: typeof asset.sha256Url === "string" ? asset.sha256Url : "",
url: asset.url,
};
}
/**
* Where `tools-pack <platform> install` looks. Keep in lockstep with
* resolveMacPaths().dmgPath and resolveWinPaths().setupPath.
*/
function artifactDestination(target: ReleaseTarget, toolsPackDir: string, namespace: string): string {
const token = sanitizeNamespace(namespace);
if (target === "win_x64") {
return join(toolsPackDir, "out", "win", "namespaces", namespace, "builder", `Open Design-${token}-setup.exe`);
}
if (target === "linux_x64") {
return join(toolsPackDir, "out", "linux", "namespaces", namespace, "builder", `Open Design-${token}.AppImage`);
}
return join(toolsPackDir, "out", "mac", "namespaces", namespace, "dmg", `Open Design-${token}.dmg`);
}
async function download(url: string, destination: string): Promise<{ bytes: number; sha256: string }> {
const response = await fetch(url, { redirect: "follow" });
if (!response.ok) throw new Error(`GET ${url} failed: HTTP ${response.status}`);
const buffer = Buffer.from(await response.arrayBuffer());
mkdirSync(dirname(destination), { recursive: true });
writeFileSync(destination, buffer);
return { bytes: buffer.byteLength, sha256: createHash("sha256").update(buffer).digest("hex") };
}
async function verifyChecksum(sha256Url: string, actual: string): Promise<void> {
if (sha256Url.length === 0) {
console.warn(`::warning::published artifact carries no sha256 sidecar; downloaded bytes were not verified`);
return;
}
const response = await fetch(sha256Url, { redirect: "follow" });
if (!response.ok) throw new Error(`GET ${sha256Url} failed: HTTP ${response.status}`);
// `shasum -a 256 <name>` / PowerShell both write "<hash> <name>".
const expected = (await response.text()).trim().split(/\s+/)[0]?.toLowerCase() ?? "";
if (expected.length === 0) throw new Error(`sha256 sidecar at ${sha256Url} was empty`);
if (expected !== actual) {
throw new Error(`downloaded artifact sha256 ${actual} does not match published ${expected}`);
}
console.log(`sha256 verified: ${actual}`);
}
/**
* `e2e/scripts/release-smoke.ts` refuses to start without a readable build json,
* and `tools-release write-report` throws on a zero-byte one. Nothing on the
* core smoke path parses its contents, so a stand-in only has to be honest
* about where the artifact came from and be valid JSON.
*/
function writeBuildJson(path: string, body: Record<string, unknown>): void {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, `${JSON.stringify(body, null, 2)}\n`, "utf8");
}
async function plan(): Promise<void> {
const metadataUrl = required("VERSION_METADATA_URL");
const metadata = await fetchJson(metadataUrl);
const expectedVersion = optional("EXPECTED_VERSION");
if (expectedVersion.length > 0 && metadata.releaseVersion !== expectedVersion) {
throw new Error(
`version metadata is for ${String(metadata.releaseVersion)}, not the dispatched ${expectedVersion}`,
);
}
const published = publishedTargets(metadata);
console.log(`published targets: ${published.join(", ") || "<none>"}`);
for (const target of ["mac_arm64", "mac_x64", "win_x64", "linux_x64"] as const) {
setOutput(target, published.includes(target) ? "true" : "false");
}
setOutput("channel", typeof metadata.channel === "string" ? metadata.channel : "");
setOutput("release_version", typeof metadata.releaseVersion === "string" ? metadata.releaseVersion : "");
}
async function stage(): Promise<void> {
const metadataUrl = required("VERSION_METADATA_URL");
const target = required("RELEASE_TARGET") as ReleaseTarget;
if (!(target in PRIMARY_ARTIFACT)) throw new Error(`unsupported RELEASE_TARGET: ${target}`);
const toolsPackDir = required("TOOLS_PACK_DIR");
const namespace = required("RELEASE_NAMESPACE");
const buildJsonPath = required("BUILD_JSON_PATH");
const metadata = await fetchJson(metadataUrl);
const asset = assetOf(metadata, target);
const destination = artifactDestination(target, toolsPackDir, namespace);
console.log(`staging ${asset.url}\n -> ${destination}`);
const { bytes, sha256 } = await download(asset.url, destination);
await verifyChecksum(asset.sha256Url, sha256);
writeBuildJson(buildJsonPath, {
source: "published-artifact",
generatedAt: new Date().toISOString(),
outputRoot: join(toolsPackDir, "out"),
releaseTarget: target,
releaseVersion: metadata.releaseVersion ?? null,
channel: metadata.channel ?? null,
namespace,
publishedArtifact: { bytes, name: asset.name, sha256, url: asset.url },
...(target === "win_x64" ? { installerPath: destination } : {}),
...(target === "linux_x64" ? { appImagePath: destination } : {}),
...(target === "mac_arm64" || target === "mac_x64" ? { dmgPath: destination } : {}),
cacheReport: { entries: [] },
timings: [],
});
setOutput("artifact_path", destination);
setOutput("artifact_url", asset.url);
setOutput("artifact_bytes", String(bytes));
}
const mode = process.argv[2];
if (mode === "plan") {
await plan();
} else if (mode === "stage") {
await stage();
} else {
throw new Error("usage: smoke-artifacts.ts <plan|stage>");
}