Files
nexu-io__open-design/tools/pack/tests/release-workflows.test.ts
lefarcen faba302437 feat(release): alert through the other Feishu bot when the prerelease card goes silent (#7873)
* feat(release): alert through the other Feishu bot when the prerelease card goes silent

The progressive card is now the only prerelease notification, and every way it
breaks is silent: the pipeline stays green, the packages ship, and the channel
hears nothing. The watcher catches Feishu failures on purpose — one hiccup must
not end a two-hour watch — so expired application-bot credentials look exactly
like a healthy run.

Adds a last-resort notice delivered through the custom-bot webhook, which is a
different bot with a different secret, so a credential failure on the card side
cannot take the alert down with it. It fires only when the card did not reach
the group: the watcher now reports `card_delivered` for a card whose FINAL state
is in the chat, and the gate reads that rather than the job result.

Two callers, mutually exclusive so a release can never draw both:
release-prerelease-card.yml's `fallback_notice` job covers a watcher that died
or never delivered; steps at the end of release-prerelease.yml's
`dispatch_validation` cover a card that was never dispatched. The latter are
steps, not a job, because nothing may appear in a `needs:` on the dispatchers.

* fix(release): keep the fallback notice reachable past the dispatch it reports

The two steps that actually deliver the notice carried no status function, so
GitHub's implicit success() skipped them whenever anything earlier in the job
had failed. `dispatch-validation.sh` exits non-zero when it cannot dispatch the
card on any ref — which marks the job failed — so on the one path the
dispatch-side fallback exists for, it was silent. This PR was written to close
the implicit-success() trap at the job level and walked into its step-level twin.

Both consumers now carry `always() && steps.notice.outcome == 'success'`: the
status function so a failed dispatch still reaches them, and the outcome check
so neither a crashed nor a skipped composer can hand feishu-notice.ts an empty
body. The composer writes `alert` last, after every field a poster reads, so
`alert=true` in an append-only $GITHUB_OUTPUT means the whole notice is on disk.

Adds the step-level reachability rule as a spec beside the existing job-level
one, and puts the dispatched prerelease lanes into that job-level sweep —
release-prerelease-card.yml was outside it, so `fallback_notice`, whose `if`
hand-checks `needs.card.result`, was uncovered by the rule it has to obey.
2026-09-08 08:24:00 +00:00

834 lines
48 KiB
TypeScript

import { readFile } from "node:fs/promises";
import { describe, expect, it } from "vitest";
import linuxPack from "@/linux.ts?raw";
import macBuild from "@/mac/build.ts?raw";
import macFs from "@/mac/fs.ts?raw";
import macLifecycle from "@/mac/lifecycle.ts?raw";
import macWorkspace from "@/mac/workspace.ts?raw";
import workspaceBuild from "@/workspace-build.ts?raw";
import winApp from "@/win/app.ts?raw";
import winLifecycle from "@/win/lifecycle.ts?raw";
function sectionBetween(content: string, start: string, end: string): string {
const startIndex = content.indexOf(start);
expect(startIndex).toBeGreaterThanOrEqual(0);
const endIndex = content.indexOf(end, startIndex + start.length);
expect(endIndex).toBeGreaterThan(startIndex);
return content.slice(startIndex, endIndex);
}
function sectionAfter(content: string, start: string): string {
const startIndex = content.indexOf(start);
expect(startIndex).toBeGreaterThanOrEqual(0);
return content.slice(startIndex);
}
function countOccurrences(content: string, needle: string): number {
return content.split(needle).length - 1;
}
type WorkflowJob = { needs: string[]; if: string };
/**
* Minimal `jobs:` reader: job id -> its `needs` list and its job-level `if`.
*
* Deliberately not a YAML parser. These workflows are hand-written with a
* stable two-space job indentation, and the alternative is adding a YAML
* dependency to tools/pack purely for a topology assertion.
*/
function parseJobGraph(content: string): Map<string, WorkflowJob> {
const lines = content.split("\n");
const jobsIndex = lines.indexOf("jobs:");
expect(jobsIndex).toBeGreaterThanOrEqual(0);
const blocks = new Map<string, string[]>();
let current: string | null = null;
for (const line of lines.slice(jobsIndex + 1)) {
const header = /^ {2}([A-Za-z0-9_-]+):\s*$/.exec(line);
if (header?.[1] != null) {
current = header[1];
blocks.set(current, []);
continue;
}
if (line.trim().length > 0 && /^\S/.test(line)) break;
if (current != null) blocks.get(current)?.push(line);
}
const jobs = new Map<string, WorkflowJob>();
for (const [name, block] of blocks) {
const job: WorkflowJob = { needs: [], if: "" };
for (let index = 0; index < block.length; index += 1) {
const line = block[index] ?? "";
const inlineNeeds = /^ {4}needs:\s*(.+)$/.exec(line);
if (inlineNeeds?.[1] != null) {
const value = inlineNeeds[1].split("#")[0]?.trim() ?? "";
job.needs = value.startsWith("[")
? value.replace(/[[\]]/g, "").split(",").map((entry) => entry.trim()).filter(Boolean)
: [value].filter(Boolean);
continue;
}
if (/^ {4}needs:\s*$/.test(line)) {
for (const candidate of block.slice(index + 1)) {
const item = /^ {6}- ([A-Za-z0-9_-]+)/.exec(candidate);
if (item?.[1] == null) break;
job.needs.push(item[1]);
}
continue;
}
const conditionStart = /^ {4}if:\s*(.*)$/.exec(line);
if (conditionStart != null) {
let value = conditionStart[1] ?? "";
for (const candidate of block.slice(index + 1)) {
if (/^ {4}\S/.test(candidate)) break;
value += ` ${candidate.trim()}`;
}
job.if = value.trim();
}
}
jobs.set(name, job);
}
return jobs;
}
/** Every job `name` depends on, directly or through another job. */
function transitiveNeeds(jobs: Map<string, WorkflowJob>, name: string): string[] {
const seen = new Set<string>();
const stack = [...(jobs.get(name)?.needs ?? [])];
while (stack.length > 0) {
const next = stack.pop();
if (next == null || seen.has(next) || !jobs.has(next)) continue;
seen.add(next);
stack.push(...(jobs.get(next)?.needs ?? []));
}
return [...seen];
}
type WorkflowStep = { job: string; name: string; if: string };
/**
* Minimal `steps:` reader: every step in the file as job id + step name +
* step-level `if` (block scalars folded onto one line).
*
* Same rationale as parseJobGraph — hand-written workflows with stable
* indentation, and this is a topology assertion rather than a YAML feature
* test. Keys of a step sit at exactly eight spaces (or on the `- ` line), so
* `run:`/`env:` bodies, which must be indented deeper than their key, never
* look like one.
*/
function parseWorkflowSteps(content: string): WorkflowStep[] {
const steps: WorkflowStep[] = [];
let job: string | null = null;
let inSteps = false;
let collectingIf = false;
for (const line of content.split("\n")) {
const header = /^ {2}([A-Za-z0-9_-]+):\s*$/.exec(line);
if (header?.[1] != null) {
job = header[1];
inSteps = false;
collectingIf = false;
continue;
}
if (/^ {4}steps:\s*$/.test(line)) {
inSteps = true;
continue;
}
if (!inSteps || job == null) continue;
const isStepKey = /^ {6}- |^ {8}[A-Za-z_-]+:/.test(line);
if (collectingIf && !isStepKey) {
const last = steps[steps.length - 1];
const text = line.trim();
if (last != null && text.length > 0 && !text.startsWith("#")) last.if += ` ${text}`;
continue;
}
collectingIf = false;
if (/^ {6}- /.test(line)) steps.push({ job, name: "", if: "" });
const current = steps[steps.length - 1];
if (current == null) continue;
const name = /^(?: {6}- | {8})name:\s*(.*)$/.exec(line);
if (name?.[1] != null) {
current.name = name[1].trim();
continue;
}
const condition = /^(?: {6}- | {8})if:\s*(.*)$/.exec(line);
if (condition != null) {
current.if = (condition[1] ?? "").trim();
collectingIf = true;
}
}
return steps;
}
/**
* Status-check functions that make a job evaluate its own `if` instead of
* inheriting a skip from somewhere up the chain. `success()` does not count:
* it is what GitHub already applies implicitly.
*
* The same list applies one level down. A step-level `if` without a status
* function also gets an implicit `success()`, evaluated against the job's
* status SO FAR — so a step placed after one that failed is skipped before its
* own condition is read, exactly as a job is.
*/
const SKIP_CHAIN_BREAKERS = ["always(", "cancelled(", "failure("];
describe("release workflows", () => {
it("retains only the newest outer tools-pack cache for each release lane", async () => {
const workflows = await Promise.all([
readFile(new URL("../../../.github/workflows/release-beta.yml", import.meta.url), "utf8"),
readFile(new URL("../../../.github/workflows/release-prerelease.yml", import.meta.url), "utf8"),
readFile(new URL("../../../.github/workflows/release-stable.yml", import.meta.url), "utf8"),
]);
expect(workflows.map((workflow) => countOccurrences(workflow, "keep=1"))).toEqual([2, 2, 0]);
expect(workflows.map((workflow) => countOccurrences(workflow, "$keep = 1"))).toEqual([1, 1, 1]);
for (const workflow of workflows) {
expect(workflow).not.toContain("keep=3");
expect(workflow).not.toContain("$keep = 3");
}
});
it("requires Vela CLI for every beta desktop packaging target", async () => {
const [beta, prerelease, stable, stablePrepare, buildMac, buildWin, prepareMac, prepareWin, publishPlatform, desktopUpdater, installUnsafeDmg] = await Promise.all([
readFile(new URL("../../../.github/workflows/release-beta.yml", import.meta.url), "utf8"),
readFile(new URL("../../../.github/workflows/release-prerelease.yml", import.meta.url), "utf8"),
readFile(new URL("../../../.github/workflows/release-stable.yml", import.meta.url), "utf8"),
readFile(new URL("../../../tools/release/src/metadata/prepare-stable.ts", import.meta.url), "utf8"),
readFile(new URL("../../../tools/release/scripts/build-platform.sh", import.meta.url), "utf8"),
readFile(new URL("../../../tools/release/scripts/build-platform.ps1", import.meta.url), "utf8"),
readFile(new URL("../../../tools/release/scripts/prepare-platform-assets.sh", import.meta.url), "utf8"),
readFile(new URL("../../../tools/release/scripts/prepare-platform-assets.ps1", import.meta.url), "utf8"),
readFile(new URL("../../../tools/release/src/storage/publish-platform.ts", import.meta.url), "utf8"),
readFile(new URL("../../../apps/desktop/src/main/updater/payload.ts", import.meta.url), "utf8"),
readFile(new URL("../../../scripts/install-unsafe-dmg.sh", import.meta.url), "utf8"),
]);
const mac = sectionBetween(beta, " build_mac_arm64:", " build_mac_x64:");
const macX64 = sectionBetween(beta, " build_mac_x64:", " build_win_x64:");
const win = sectionBetween(beta, " build_win_x64:", " build_linux_x64:");
const linux = sectionBetween(beta, " build_linux_x64:", " publish:");
const betaMetadata = sectionBetween(beta, " metadata:", " build_mac_arm64:");
const betaPublish = sectionAfter(beta, " publish:");
const prereleaseMetadata = sectionBetween(prerelease, " metadata:", " dispatch_validation:");
const prereleasePublish = sectionBetween(prerelease, " publish:", " cleanup_partial_release_assets:");
const prereleaseMac = sectionBetween(prerelease, " build_mac:", " build_mac_intel:");
const prereleaseMacX64 = sectionBetween(prerelease, " build_mac_intel:", " build_win:");
const prereleaseWin = sectionBetween(prerelease, " build_win:", " build_linux:");
const stableMetadata = sectionBetween(stable, " metadata:", " verify:");
const stablePublish = sectionBetween(stable, " publish:", " cleanup_partial_release_assets:");
expect(mac).not.toContain("bash tools/release/scripts/build-platform.sh");
expect(macX64).not.toContain("bash tools/release/scripts/build-platform.sh");
expect(countOccurrences(mac, "--require-vela-cli")).toBe(3);
expect(countOccurrences(macX64, "--require-vela-cli")).toBe(2);
expect(countOccurrences(win, "--require-vela-cli")).toBe(3);
expect(mac.match(/RELEASE_ARTIFACT_MODE: dmg-and-payload/g)?.length ?? 0).toBe(2);
expect(macX64.match(/RELEASE_ARTIFACT_MODE: \$\{\{ inputs\.mac_x64_target == 'all' && 'all' \|\| 'dmg-and-payload' \}\}/g)?.length ?? 0).toBe(2);
expect(mac).toContain("uses: actions/cache/restore@v5");
expect(mac).toContain("uses: actions/cache/save@v5");
expect(mac).toContain("tools-pack-mac-v1-beta-${RUNNER_OS}-arm64-");
expect(mac).toContain("pnpm exec tools-pack mac cleanup --dir \"$RUNNER_TEMP/tools-pack\" --namespace release-beta --json");
expect(mac).toContain("exec tools-pack mac build");
expect(mac).toContain("build_args+=(--signed --notarize)");
expect(mac).toContain("Build beta mac_arm64 update fixture");
expect(mac).toContain("OD_PACKAGED_E2E_MAC_UPDATE_BUILD_JSON_PATH: ${{ steps.mac_arm64_update_fixture.outputs.update_build_json_path }}");
expect(mac).toContain("OD_PACKAGED_E2E_MAC_UPDATE_FIXTURE: ${{ inputs.mac_arm64_smoke_mode == 'full' && inputs.mac_arm64_update_metadata_url == '' && inputs.mac_arm64_update_target_version == '' && 'tools-serve' || '' }}");
expect(mac).toContain("pnpm exec tsx scripts/release-smoke.ts mac specs/mac.spec.ts");
expect(mac).toContain("bash .github/scripts/release/cache/mac.sh");
expect(macX64).toContain("uses: actions/cache/restore@v5");
expect(macX64).toContain("uses: actions/cache/save@v5");
expect(macX64).toContain("tools-pack-mac-v1-beta-${RUNNER_OS}-x64-");
expect(macX64).toContain("pnpm exec tools-pack mac cleanup --dir \"$RUNNER_TEMP/tools-pack\" --namespace release-beta-x64 --json");
expect(macX64).toContain("exec tools-pack mac build");
expect(macX64).toContain("pnpm exec tsx scripts/release-smoke.ts mac specs/mac.spec.ts");
expect(buildMac).toContain("build_args+=(--require-vela-cli)");
expect(buildMac).toContain("update_args+=(--require-vela-cli)");
expect(buildMac).toContain('--cache-dir "$TOOLS_PACK_CACHE_DIR"');
expect(buildMac).toContain('tools-pack mac build update fixture');
expect(buildMac).toContain('OD_PACKAGED_E2E_MAC_UPDATE_BUILD_JSON_PATH="$update_build_json_path"');
expect(buildMac).toContain('OD_PACKAGED_E2E_MAC_UPDATE_VERSION="${OD_PACKAGED_E2E_MAC_UPDATE_VERSION:-$update_version}"');
expect(buildMac).not.toContain("::warning::Expected Electron framework symlink");
expect(linux).not.toContain("--require-vela-cli");
expect(beta).not.toContain("REQUIRE_VELA_CLI: \"true\"");
expect(beta).toContain("release-beta publish requires win_x64_target=nsis or all");
expect(beta).toContain("mac_arm64_update_metadata_url:");
expect(beta).toContain("win_x64_update_metadata_url:");
expect(beta).toContain("OD_PACKAGED_E2E_MAC_UPDATE_METADATA_URL: ${{ inputs.mac_arm64_update_metadata_url }}");
expect(beta).toContain("OD_PACKAGED_E2E_WIN_UPDATE_METADATA_URL: ${{ inputs.win_x64_update_metadata_url }}");
expect(beta).toContain("POSTHOG_KEY: ${{ inputs.publish && secrets.POSTHOG_KEY || '' }}");
expect(beta).toContain("POSTHOG_HOST: ${{ inputs.publish && vars.POSTHOG_HOST || '' }}");
expect(beta).toContain("POSTHOG_CLI_API_KEY: ${{ inputs.publish && secrets.POSTHOG_CLI_API_KEY || '' }}");
expect(beta).toContain("POSTHOG_CLI_PROJECT_ID: ${{ inputs.publish && vars.POSTHOG_CLI_PROJECT_ID || '' }}");
expect(beta).not.toContain("publish-beta-metadata.ts");
expect(beta).not.toContain("verify-beta-metadata.ts");
expect(beta).not.toContain("summary-beta.ts");
expect(beta).toContain("tools-release publish-metadata");
expect(beta).toContain("tools-release verify-metadata");
expect(beta).toContain("tools-release summary-metadata");
for (const workflow of [beta, prerelease, stable]) {
expect(workflow).not.toContain(".github/scripts/release/r2/");
}
for (const workflow of [beta, prerelease, stable]) {
expect(workflow).toContain("tools-release check-storage");
}
expect(win).not.toContain("tools\\release\\scripts\\build-platform.ps1");
expect(win).toContain("uses: actions/cache/restore@v5");
expect(win).toContain("uses: actions/cache/save@v5");
expect(win).toContain("tools-pack-win-v1-beta-$env:RUNNER_OS-");
expect(win).toContain('pnpm.cmd exec tools-pack win cleanup --dir "${{ runner.temp }}\\tools-pack" --namespace release-beta-win --json');
expect(win).toContain('"tools-pack", "win", "build"');
expect(buildWin).toContain('$buildArgs += "--require-vela-cli"');
expect(buildWin).toContain('$updateArgs += "--require-vela-cli"');
expect(win).toContain("tools-pack win validate-payload");
expect(win).toContain("pnpm exec tsx scripts/release-smoke.ts win specs/win.spec.ts");
expect(win).toContain(".\\.github\\scripts\\release\\cache\\win.ps1");
for (const metadata of [betaMetadata, prereleaseMetadata, stableMetadata]) {
expect(metadata).toContain("uses: pnpm/action-setup@v5");
expect(metadata).toContain("run: pnpm install --frozen-lockfile");
expect(metadata.indexOf("run: pnpm install --frozen-lockfile")).toBeLessThan(metadata.indexOf("tools-release prepare"));
}
for (const publish of [betaPublish, prereleasePublish, stablePublish]) {
expect(publish).toContain("uses: pnpm/action-setup@v5");
expect(publish).toContain("run: pnpm install --frozen-lockfile");
expect(publish.indexOf("run: pnpm install --frozen-lockfile")).toBeLessThan(
publish.indexOf("tools-release publish-metadata"),
);
}
expect(macBuild).toContain('runPhase("xattr-scrub"');
expect(macBuild).toContain("scrubMacExtendedAttributes(paths.appPath)");
expect(macFs).toContain("com.apple.provenance");
expect(macFs).toContain("com.apple.macl");
expect(desktopUpdater).toContain("MAC_PAYLOAD_XATTRS_TO_SCRUB");
expect(desktopUpdater).toContain('execFileAsync("xattr", ["-dr", attribute, input.destinationRoot])');
expect(desktopUpdater).toContain("com.apple.macl");
expect(installUnsafeDmg).toContain("com.apple.macl");
expect(win).toContain("-IncludeZip $${{ inputs.win_x64_target == 'all' || inputs.win_x64_target == 'zip' }}");
expect(prepareMac).not.toContain("required RELEASE_ASSET_SUFFIX");
expect(prepareMac).toContain('RELEASE_ASSET_SUFFIX="${RELEASE_ASSET_SUFFIX:-}"');
expect(prepareWin).toContain("[AllowEmptyString()]");
expect(prepareWin).toContain("$sourcePayload = [string]$build.payloadPath");
expect(prepareWin).toContain("open-design-$ReleaseVersion$ReleaseAssetSuffix-win-x64-payload.7z");
expect(publishPlatform).toContain("open-design-${releaseVersion}${assetSuffix}-win-x64-payload.7z");
expect(publishPlatform).toContain("payload: assetEntry(payload)");
expect(publishPlatform).toContain("versionLockObjectKey(releaseVersion, countedReleaseChannel)");
expect(publishPlatform).toContain("assertCurrentVersionReservation(storage, releaseVersion, versionLockKey, countedReleaseChannel)");
expect(buildWin).toContain("function Validate-WinLauncherPayloadArchive");
expect(buildWin).toContain('Measure-Step "clean tools-pack win namespace"');
expect(buildWin.indexOf('Measure-Step "clean tools-pack win namespace"')).toBeLessThan(buildWin.indexOf('Measure-Step "tools-pack win build"'));
expect(buildWin).toContain('"tools-pack", "win", "cleanup"');
expect(winLifecycle).toContain("const launcher = resolveToolPackLauncherLayout(config)");
expect(winLifecycle).toContain("await removeTree(launcher.paths.namespaceRoot)");
expect(winLifecycle).toContain("removedLauncherNamespaceRoot");
expect(macLifecycle).toContain("const launcher = resolveToolPackLauncherLayout(config)");
expect(macLifecycle).toContain("await rm(launcher.paths.namespaceRoot, { force: true, recursive: true })");
expect(macLifecycle).toContain("removedLauncherNamespaceRoot");
expect(buildWin).toContain('Measure-Step "validate launcher payload artifact"');
expect(buildWin).toContain('Measure-Step "validate launcher payload update fixture"');
expect(buildWin).toContain('Test-JsonString $manifest.entry.executable "entry.executable" "payload/Open Design.exe"');
expect(winApp).toContain("return ensureWorkspaceBuildArtifacts(");
expect(macWorkspace).toContain("await ensureWorkspaceBuildArtifacts(");
expect(linuxPack).toContain("await runWorkspaceBuild(");
for (const buildSource of [winApp, macWorkspace, linuxPack]) {
expect(buildSource).not.toContain('["--filter", "@open-design/platform", "build"]');
expect(buildSource).not.toContain('["--filter", "@open-design/sidecar", "build"]');
}
const dependencyClosureBuild = '"--filter", "@open-design/packaged^..."';
const webSidecarBuild = '"--filter", "@open-design/web", "run", "build:sidecar"';
const packagedBuild = '"--filter", "@open-design/packaged", "run", "build"';
expect(workspaceBuild).toContain('"--filter", "@open-design/dsh-runtime..."');
expect(workspaceBuild.indexOf(dependencyClosureBuild)).toBeLessThan(workspaceBuild.indexOf(webSidecarBuild));
expect(workspaceBuild.indexOf(webSidecarBuild)).toBeLessThan(workspaceBuild.indexOf(packagedBuild));
expect(prerelease).toContain("name: release-prerelease");
expect(prerelease).toContain("pnpm exec tools-release prepare prerelease");
expect(prerelease).toContain("OPEN_DESIGN_PRERELEASE_METADATA_URL");
expect(prerelease).toContain("RELEASE_CHANNEL: prerelease");
expect(prerelease).toContain("open-design-prerelease-mac-arm64-publish-manifest");
expect(prerelease).toContain("open-design-prerelease-win-x64-publish-manifest");
expect(prerelease).toContain("workflow_call:");
expect(prerelease).toContain("OPEN_DESIGN_STABLE_VERSION: ${{ inputs.release_version }}");
expect(prerelease).toContain("GITHUB_SHA: ${{ needs.metadata.outputs.commit }}");
expect(prerelease).toContain("previous_commit: ${{ steps.prev.outputs.previous_commit }}");
expect(prerelease).toContain("version_metadata_url: ${{ steps.outputs.outputs.version_metadata_url }}");
expect(prerelease).not.toContain("RELEASE_CHANNEL: Prerelease");
expect(prerelease).not.toContain("tools-release prepare preview");
expect(prereleaseMetadata).toContain("GH_TOKEN: ${{ github.token }}");
expect(prereleaseMetadata).toContain("OPEN_DESIGN_RELEASE_CHANNEL: prerelease");
expect(prereleasePublish).toContain('GITHUB_RELEASE_ENABLED: "false"');
expect(prerelease).not.toContain("gh release");
expect(prereleaseMac).toContain("uses: actions/cache/restore@v5");
expect(prereleaseMac).toContain("uses: actions/cache/save@v5");
expect(prereleaseMac).toContain("tools-pack-mac-v1-prerelease-${RUNNER_OS}-arm64-");
expect(prereleaseMac).toContain("pnpm exec tools-pack mac cleanup --dir \"$RUNNER_TEMP/tools-pack\" --namespace release-prerelease --json");
expect(prereleaseMac).toContain("exec tools-pack mac build");
expect(prereleaseMac).toContain("--cache-dir \"$RUNNER_TEMP/tools-pack-cache\"");
expect(countOccurrences(prereleaseMac, "--notarize")).toBe(2);
// Both the primary build and the cache-miss retry must carry Apple notary
// env, or notarization fails closed on the retry path.
expect(
countOccurrences(prereleaseMac, "APPLE_ID: ${{ secrets.APPLE_ID }}"),
).toBe(2);
expect(
countOccurrences(
prereleaseMac,
"APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}",
),
).toBe(2);
expect(
countOccurrences(prereleaseMac, "APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}"),
).toBe(2);
expect(prereleaseMac).toContain("tools-release write-report");
expect(prereleaseMacX64).toContain("uses: actions/cache/restore@v5");
expect(prereleaseMacX64).toContain("uses: actions/cache/save@v5");
expect(prereleaseMacX64).toContain("tools-pack-mac-v1-prerelease-${RUNNER_OS}-x64-");
expect(prereleaseMacX64).toContain("pnpm exec tools-pack mac cleanup --dir \"$RUNNER_TEMP/tools-pack\" --namespace release-prerelease-intel --json");
expect(prereleaseMacX64).toContain("exec tools-pack mac build");
expect(prereleaseMacX64).toContain("--cache-dir \"$RUNNER_TEMP/tools-pack-cache\"");
expect(countOccurrences(prereleaseMacX64, "--notarize")).toBe(2);
expect(
countOccurrences(prereleaseMacX64, "APPLE_ID: ${{ secrets.APPLE_ID }}"),
).toBe(2);
expect(
countOccurrences(
prereleaseMacX64,
"APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}",
),
).toBe(2);
expect(
countOccurrences(prereleaseMacX64, "APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}"),
).toBe(2);
expect(prereleaseMacX64).toContain("tools-release write-report");
for (const [prereleaseMacJob, nextStep] of [
[prereleaseMac, "Write mac_arm64 release report"],
[prereleaseMacX64, "Write mac_x64 release report"],
] as const) {
expect(prereleaseMacJob).toContain("Verify prerelease mac");
expect(prereleaseMacJob).toContain('hdiutil attach "$dmg_path" -nobrowse -readonly -mountpoint "$mount_point"');
expect(prereleaseMacJob).toContain('codesign --verify --deep --strict "$candidate_app"');
expect(prereleaseMacJob).toContain('xcrun stapler validate "$candidate_app"');
expect(prereleaseMacJob).toContain('spctl --assess --type execute --verbose=4 "$candidate_app"');
expect(prereleaseMacJob.indexOf("Verify prerelease mac")).toBeLessThan(
prereleaseMacJob.indexOf(nextStep),
);
}
expect(prereleaseWin).toContain("tools-pack-win-v1-prerelease-$env:RUNNER_OS-");
expect(prereleaseWin).toContain("tools-pack win validate-payload");
expect(prereleaseWin).toContain("release-build\\win_x64\\build.json");
expect(prereleaseWin).toContain("tools-release write-report");
expect(stable).not.toContain(".github/scripts/release/assets/mac.sh");
expect(stable).not.toContain(".github/scripts/release/assets/mac-intel.sh");
expect(stable).not.toContain(".github/scripts/release/assets/win.ps1");
expect(stable).not.toContain(".github/scripts/release/assets/linux.sh");
expect(stable).not.toContain(".github/scripts/release/r2/publish.sh");
expect(stable).not.toContain(".github/scripts/release/r2/verify.sh");
expect(stable).not.toContain(".github/scripts/release/r2/summary.sh");
expect(countOccurrences(stable, "tools/release/scripts/prepare-platform-assets.sh")).toBeGreaterThanOrEqual(3);
expect(stable).toContain("tools\\release\\scripts\\prepare-platform-assets.ps1");
expect(countOccurrences(stable, "tools-release publish-platform")).toBeGreaterThanOrEqual(4);
expect(stable).toContain("tools-release publish-metadata");
// The stable promotion gate validates prerelease metadata.github fields; the
// publish steps must therefore pass the resolved release attribution through.
expect(stable).toContain("RELEASE_COMMIT: ${{ needs.metadata.outputs.commit }}");
expect(stable).toContain("RELEASE_REPOSITORY: ${{ github.repository }}");
expect(stable).toContain("RELEASE_WORKFLOW: ${{ github.workflow }}");
expect(countOccurrences(stable, "RELEASE_COMMIT: ${{ needs.metadata.outputs.commit }}")).toBeGreaterThanOrEqual(5);
expect(stable).toContain("RELEASE_RUN_ID: ${{ github.run_id }}");
expect(countOccurrences(stable, "RELEASE_BRANCH: ${{ needs.metadata.outputs.branch }}")).toBeGreaterThanOrEqual(5);
expect(stable).not.toContain("RELEASE_BRANCH: ${{ github.ref_name }}");
expect(stable).toContain("tools-release verify-metadata");
expect(stable).toContain("tools-release summary-metadata");
expect(stable).toContain("open-design-release-mac-arm64-publish-manifest");
expect(stable).toContain("open-design-release-win-x64-publish-manifest");
expect(stable).toContain("--signed");
expect(stable).toContain("--notarize");
expect(stable).toContain("run: pnpm exec tools-release prepare stable");
expect(stable).toContain("OPEN_DESIGN_RELEASE_CHANNEL: stable");
expect(stable).not.toContain("OPEN_DESIGN_STABLE_VERSION:");
expect(stable).toContain("type: choice");
expect(stable).toContain("- metadata");
expect(stable).toContain("- prepublish");
expect(stable).toContain("- publish");
expect(stable).toContain("default: metadata");
expect(stable).toContain("OPEN_DESIGN_RELEASE_DRY_RUN: ${{ inputs.dry_run == 'publish' && 'false' || inputs.dry_run }}");
expect(stable).toContain("run_prepublish_jobs: ${{ steps.stable.outputs.run_prepublish_jobs }}");
expect(stable).toContain("publish_side_effects_enabled: ${{ steps.stable.outputs.publish_side_effects_enabled }}");
expect(stable).toContain("if: ${{ needs.metadata.outputs.run_prepublish_jobs == 'true' }}");
expect(stable).toContain("RELEASE_DRY_RUN_MODE: ${{ needs.metadata.outputs.dry_run_mode }}");
expect(stable).toContain("RELEASE_PUBLISH_SIDE_EFFECTS: ${{ needs.metadata.outputs.publish_side_effects_enabled }}");
expect(stable).toContain("pnpm exec tools-release prepare-github-assets");
expect(stable).toContain('gh release upload "$VERSION_TAG" "$RUNNER_TEMP/github-release-assets"/*');
expect(stable).toContain("RELEASE_METADATA_PATH:");
expect(stable).not.toContain("inputs.channel");
expect(stable).not.toContain("prepare ${{ inputs.channel }}");
expect(stablePrepare).toContain('expectStringFieldIfPresent(github, "workflow", "release-prerelease"');
expect(stablePrepare).toContain('parseStableDryRunMode');
expect(stablePrepare).toContain('setOutput("run_prepublish_jobs"');
expect(stablePrepare).toContain('setOutput("publish_side_effects_enabled"');
});
it("never hands a shipping lane an empty windows smoke mode", async () => {
const notify = await readFile(
new URL("../../../.github/workflows/notify-release-feishu.yml", import.meta.url),
"utf8",
);
// A `workflow_call` `default:` applies only when an input is OMITTED, so
// forwarding an empty string defeats the declared `core` default. The empty
// value then survives `??` in the spec, `smokeProfile === 'core'` is false,
// and the run takes the `full` path — which demands an updater fixture only
// a genuine `full` request wires up, and dies before the smoke starts.
// That is how release/v0.18.1's first prerelease failed on its branch-cut
// commit; release/v0.18.0 stayed hidden behind a branch-name special case
// that produced `skip`, so its smoke never ran at all.
const modeLine = notify
.split("\n")
.find((line) => line.includes("win_x64_smoke_mode:") && line.includes("inputs.win_x64_smoke_mode"));
expect(modeLine, "notify-release-feishu must forward win_x64_smoke_mode").toBeDefined();
expect(modeLine).not.toMatch(/\|\|\s*''\s*\}\}/);
expect(modeLine).toMatch(/\|\|\s*'core'\s*\}\}/);
});
it("keeps a job that hand-checks an upstream result reachable past a skipped ancestor", async () => {
// GitHub, on `jobs.<job_id>.needs`: "If a job fails or is skipped, all jobs
// that need it are skipped unless the jobs use a conditional expression
// that causes the job to continue. If a run contains a series of jobs that
// need each other, a failure or skip applies to all jobs in the dependency
// chain from the point of failure or skip onwards."
//
// The break is per job and is NOT inherited. `always()` on `publish` lets
// PUBLISH run past a skipped `build_linux`; it does nothing for publish's
// own dependents, which are still downstream of the same skip. A job whose
// `if` hand-checks `needs.<x>.result` is by construction making its own
// decision about an upstream outcome — so it has to break the chain too,
// or GitHub's implicit `success()` skips it before that condition is ever
// evaluated.
//
// release-prerelease.yml's `dispatch_smoke` was exactly that shape, and
// `build_linux` is skipped on every single run because the repository has
// no ENABLE_STABLE_LINUX variable. Run 34149795952: publish succeeded,
// version_metadata_url was published, enable_smoke came through as true —
// and the job was skipped with zero steps, so release-prerelease-smoke.yml
// had never once run.
const files = [
"release-prerelease.yml",
"release-beta.yml",
"release-stable.yml",
"notify-release-feishu.yml",
// The dispatched prerelease lanes were outside this sweep, which left
// release-prerelease-card.yml's `fallback_notice` — a job whose `if`
// hand-checks `needs.card.result` — uncovered by the very rule it has to
// obey.
"release-prerelease-card.yml",
"release-prerelease-tests.yml",
"release-prerelease-smoke.yml",
];
const contents = await Promise.all(
files.map((file) => readFile(new URL(`../../../.github/workflows/${file}`, import.meta.url), "utf8")),
);
const unreachable: string[] = [];
for (const [index, content] of contents.entries()) {
const jobs = parseJobGraph(content);
for (const [name, job] of jobs) {
if (!/needs\.[A-Za-z0-9_-]+\.result/.test(job.if)) continue;
if (SKIP_CHAIN_BREAKERS.some((breaker) => job.if.includes(breaker))) continue;
const skippableAncestors = transitiveNeeds(jobs, name).filter(
(ancestor) => (jobs.get(ancestor)?.if ?? "").length > 0,
);
if (skippableAncestors.length === 0) continue;
unreachable.push(`${files[index]}:${name} (skippable ancestors: ${skippableAncestors.sort().join(", ")})`);
}
}
expect(unreachable, "these jobs are skipped before their own condition is evaluated").toEqual([]);
// And specifically: the smoke dispatcher must stay reachable while
// build_linux stays opt-in.
const prerelease = parseJobGraph(contents[0] ?? "");
const dispatchSmoke = prerelease.get("dispatch_smoke");
expect(dispatchSmoke, "release-prerelease.yml must still dispatch packaged smoke").toBeDefined();
expect(transitiveNeeds(prerelease, "dispatch_smoke")).toContain("build_linux");
expect(prerelease.get("build_linux")?.if).toContain("vars.ENABLE_STABLE_LINUX");
expect(SKIP_CHAIN_BREAKERS.some((breaker) => (dispatchSmoke?.if ?? "").includes(breaker))).toBe(true);
});
it("keeps the prerelease card fallback reachable after the step it alerts on has failed", async () => {
// The job-level trap above has a step-level twin, and it is easier to walk
// into because there is no `needs:` to remind you. GitHub applies an
// implicit `success()` to any step-level `if` that carries no status
// function, and that success() is evaluated against the JOB'S STATUS SO
// FAR. So a step placed after a step that failed is skipped before its own
// condition is ever read.
//
// That is fatal for exactly one kind of step: one whose job is to speak up
// BECAUSE something earlier failed. The prerelease card fallback is that
// kind. `dispatch-validation.sh` exits non-zero when it could not dispatch
// the card on any ref, which marks the job failed — and a card that was
// never dispatched is the headline reason the fallback exists. A notifier
// that inherits success() there is silent in precisely its own emergency.
//
// Not a rule that generalizes to every step: most steps SHOULD stop when
// something before them broke. It binds the fallback notifier group, whose
// whole contract is the opposite.
const [prerelease, card] = await Promise.all([
readFile(new URL("../../../.github/workflows/release-prerelease.yml", import.meta.url), "utf8"),
readFile(new URL("../../../.github/workflows/release-prerelease-card.yml", import.meta.url), "utf8"),
]);
// The notifier group inside `dispatch_validation` sits after two dispatch
// steps that can fail, so every step of it needs a status function.
const dispatchSteps = parseWorkflowSteps(prerelease).filter((step) => step.job === "dispatch_validation");
const notifierStart = dispatchSteps.findIndex((step) => step.name === "Setup Node.js for the fallback notifier");
expect(notifierStart, "release-prerelease.yml must still carry the fallback notifier").toBeGreaterThan(0);
const stranded = dispatchSteps
.slice(notifierStart)
.filter((step) => !SKIP_CHAIN_BREAKERS.some((breaker) => step.if.includes(breaker)));
expect(
stranded.map((step) => step.name),
"these fallback steps inherit success() and are skipped by the very dispatch failure they exist to report",
).toEqual([]);
// Wherever the composed notice is consumed, same rule — and one more: a
// consumer must require the composer to have SUCCEEDED. `alert` is written
// last precisely so a half-written notice cannot claim to be one, and the
// outcome check says the same thing at the workflow layer, so neither a
// crashed nor a skipped composer can hand feishu-notice.ts an empty body.
for (const [file, content] of [
["release-prerelease.yml", prerelease],
["release-prerelease-card.yml", card],
] as const) {
const consumers = parseWorkflowSteps(content).filter((step) => step.if.includes("steps.notice.outputs"));
expect(consumers.length, `${file} must consume the composed notice`).toBeGreaterThan(0);
for (const consumer of consumers) {
expect(
SKIP_CHAIN_BREAKERS.some((breaker) => consumer.if.includes(breaker)),
`${file}:${consumer.job}:${consumer.name} inherits success() and cannot report an upstream failure`,
).toBe(true);
expect(
consumer.if,
`${file}:${consumer.job}:${consumer.name} must not post a notice its composer failed to finish`,
).toContain("steps.notice.outcome == 'success'");
}
}
});
it("keeps every prerelease validation lane out of the release concurrency group", async () => {
// release-prerelease.yml holds ONE repository-wide concurrency group with
// cancel-in-progress: false. Anything that outlives `publish` inside it
// keeps that group held, which stops the next prerelease from STARTING —
// so making the test jobs advisory was not enough, they had to leave the
// workflow. Lock that: the pipeline is metadata → build → publish plus two
// fire-and-forget dispatchers, and the lanes live in workflows whose
// concurrency is scoped to the origin run.
const [prerelease, tests, smoke, card, dispatcher] = await Promise.all([
readFile(new URL("../../../.github/workflows/release-prerelease.yml", import.meta.url), "utf8"),
readFile(new URL("../../../.github/workflows/release-prerelease-tests.yml", import.meta.url), "utf8"),
readFile(new URL("../../../.github/workflows/release-prerelease-smoke.yml", import.meta.url), "utf8"),
readFile(new URL("../../../.github/workflows/release-prerelease-card.yml", import.meta.url), "utf8"),
readFile(new URL("../../../.github/scripts/release/dispatch-validation.sh", import.meta.url), "utf8"),
]);
expect(prerelease).toContain("group: open-design-release-prerelease");
for (const jobId of [" functional_e2e:", " e2e_vitest:", " daemon_unit_tests:", " verify:", " test_signals:"]) {
expect(prerelease, `${jobId} must not be a release-prerelease job any more`).not.toContain(jobId);
}
// The mac/Windows packaged smoke left with them; Linux keeps its in-job
// smoke because that whole lane is opt-in and on nobody's critical path.
expect(prerelease).not.toContain("Smoke prerelease mac packaged runtime");
expect(prerelease).not.toContain("Smoke prerelease mac_x64 packaged runtime");
expect(prerelease).not.toContain("Smoke prerelease windows packaged runtime");
expect(prerelease).toContain("Smoke prerelease linux AppImage runtime");
// Dispatchers, and nothing depending on them.
expect(prerelease).toContain(" dispatch_validation:");
expect(prerelease).toContain(" dispatch_smoke:");
expect(prerelease).toContain("dispatch-validation.sh release-prerelease-tests.yml");
expect(prerelease).toContain("dispatch-validation.sh release-prerelease-card.yml");
expect(prerelease).toContain("dispatch-validation.sh release-prerelease-smoke.yml");
expect(prerelease).not.toContain("- dispatch_validation");
expect(prerelease).not.toContain("- dispatch_smoke");
// Smoke needs a package, so it waits for publish — but only long enough to
// POST the dispatch. Asserted as substance rather than as one literal
// line: the previous form pinned the exact string of a condition that
// never ran, which made the topology test agree with the bug. Reachability
// itself is covered by "keeps a job that hand-checks an upstream result
// reachable past a skipped ancestor" above.
const smokeCondition = parseJobGraph(prerelease).get("dispatch_smoke")?.if ?? "";
expect(smokeCondition).toContain("inputs.enable_smoke");
expect(smokeCondition).toContain("needs.publish.result == 'success'");
expect(smokeCondition).toContain("needs.publish.outputs.version_metadata_url != ''");
// Two refs, tried in order, so a release branch cut before these lanes
// existed still gets validated from the default branch.
expect(dispatcher).toContain('for candidate in "${PRIMARY_REF:-}" "${FALLBACK_REF:-}"');
expect(dispatcher).toContain('gh workflow run "$workflow" --ref "$ref"');
for (const [label, workflow] of [
["tests", tests],
["smoke", smoke],
["card", card],
] as const) {
expect(workflow, label).toContain("workflow_dispatch:");
expect(workflow, label).not.toContain("open-design-release-prerelease");
expect(workflow, label).toContain("cancel-in-progress: false");
// Correlation is the run name: `gh workflow run` returns no run id, so
// the Feishu card finds these runs by matching `origin-run <id>`.
expect(workflow, label).toContain("origin-run ${{ inputs.origin_run_id }}");
expect(workflow, label).toContain("group: release-prerelease-");
}
// The suites moved verbatim and still run against the resolved build commit.
for (const suite of [
"pnpm --filter @open-design/e2e test",
"pnpm --filter @open-design/daemon test --shard=${{ matrix.shard }}/4",
"pnpm -r --workspace-concurrency=4 --if-present run typecheck",
"run: pnpm guard",
"uses: ./.github/workflows/ui-extended-main.yml",
]) {
expect(tests).toContain(suite);
}
expect(tests).toContain("ref: ${{ inputs.commit }}");
expect(tests).not.toContain("needs.metadata.outputs.commit");
// Smoke installs the PUBLISHED artifact, not a local build directory.
expect(smoke).toContain("smoke-artifacts.ts plan");
expect(smoke).toContain("smoke-artifacts.ts stage");
expect(smoke).toContain("pnpm exec tsx scripts/release-smoke.ts mac specs/mac.spec.ts");
expect(smoke).toContain("pnpm exec tsx scripts/release-smoke.ts win specs/win.spec.ts");
expect(smoke).not.toContain("tools-pack mac build");
// mac.spec.ts reads the profile with a bare `??`, so an empty string reads
// as "not core" and selects the updater path — which then dies for want of
// a fixture. It must be a literal, never an expression.
expect(smoke).toContain("OD_PACKAGED_E2E_MAC_SMOKE_PROFILE: core");
expect(smoke).not.toMatch(/OD_PACKAGED_E2E_MAC_SMOKE_PROFILE: \$\{\{/);
// Every platform lane is gated on what actually published, not on the flags
// the build was dispatched with.
expect(smoke).toContain("if: ${{ needs.plan.outputs.mac_arm64 == 'true' }}");
expect(smoke).toContain("if: ${{ needs.plan.outputs.mac_x64 == 'true' }}");
// The card watcher reads job state and writes only to Feishu.
expect(card).toContain("actions: read");
expect(card).not.toContain("actions: write");
expect(card).toContain("tools/release/src/notifications/prerelease-progress-card.ts");
expect(card).toContain("FEISHU_APP_ID: ${{ secrets.FEISHU_APP_ID }}");
expect(card).toContain("FEISHU_RELEASE_CHAT_ID: ${{ secrets.FEISHU_RELEASE_CHAT_ID }}");
});
it("keeps macOS Intel and the validation lanes on by default without breaking the off switch", async () => {
const [prerelease, notify] = await Promise.all([
readFile(new URL("../../../.github/workflows/release-prerelease.yml", import.meta.url), "utf8"),
readFile(new URL("../../../.github/workflows/notify-release-feishu.yml", import.meta.url), "utf8"),
]);
// Intel builds on every prerelease: stable ships Intel, and stable's gate
// is a validated prerelease artifact, so an Intel-less prerelease cannot be
// promoted at all.
for (const flag of ["enable_mac_x64", "enable_smoke", "enable_tests"]) {
const declarations = prerelease
.split("\n")
.map((line, index) => ({ index, line }))
.filter((entry) => entry.line.trim() === `${flag}:`);
// Declared once for workflow_dispatch and once for workflow_call.
expect(declarations, flag).toHaveLength(2);
for (const declaration of declarations) {
const block = prerelease.split("\n").slice(declaration.index, declaration.index + 6).join("\n");
expect(block, flag).toContain("default: true");
}
}
// `${{ inputs.<flag> || true }}` would be unturnoffable: `inputs` is unset
// on push (so the fallback is right there) but on a dispatch with the box
// UNCHECKED, `false || true` is also true. Test the event instead.
for (const flag of ["enable_mac_x64", "enable_smoke", "enable_tests"]) {
expect(notify).toContain(`${flag}: \${{ github.event_name != 'workflow_dispatch' || inputs.${flag} }}`);
expect(notify).not.toContain(`${flag}: \${{ inputs.${flag} || true }}`);
}
});
it("bakes both halves of the workspace-team gate into every shipping lane", async () => {
const [beta, prerelease, stable, canary] = await Promise.all([
readFile(new URL("../../../.github/workflows/release-beta.yml", import.meta.url), "utf8"),
readFile(new URL("../../../.github/workflows/release-prerelease.yml", import.meta.url), "utf8"),
readFile(new URL("../../../.github/workflows/release-stable.yml", import.meta.url), "utf8"),
readFile(new URL("../../../.github/workflows/main-prerelease-win-smoke.yml", import.meta.url), "utf8"),
]);
// workspaceTeamTransportEnv (apps/packaged/src/workspace-team.ts) enables the
// four vela transports only when a known AMR profile AND a non-empty vela web
// origin are both baked in. A lane that bakes neither still builds, still
// installs, and still starts — the gap only surfaces as "Workspace Team does
// nothing" once a package reaches a user. So the presence of both halves is
// asserted per lane rather than left to the packaging step to notice.
for (const workflow of [beta, prerelease, stable]) {
expect(workflow).toContain("OPEN_DESIGN_AMR_PROFILE:");
expect(workflow).toContain("OD_VELA_WEB_URL:");
}
// Every package-capable lane must carry the complete map. Otherwise a
// stable/prod package can switch its AMR API to feature-test while its
// console and Workspace links remain on prod (or disappear).
for (const workflow of [beta, prerelease, stable, canary]) {
expect(workflow).toContain(
"OD_VELA_WEB_URL_FEATURE_TEST: ${{ secrets.VELA_WEB_URL_FEATURE_TEST }}",
);
expect(workflow).toContain(
"OD_VELA_WEB_URL_TEST: ${{ secrets.VELA_WEB_URL_TEST }}",
);
expect(workflow).toContain(
"OD_VELA_WEB_URL_PROD: ${{ secrets.VELA_WEB_URL_PROD }}",
);
}
// beta and prerelease are validation lanes and stay dispatch-driven, so an
// operator can aim a build at feature-test or test.
expect(beta).toContain("OPEN_DESIGN_AMR_PROFILE: ${{ inputs.amr_profile }}");
expect(prerelease).toContain("OPEN_DESIGN_AMR_PROFILE: ${{ inputs.amr_profile }}");
expect(beta).toContain(
"(inputs.amr_profile == 'prod' || inputs.amr_profile == '') && secrets.VELA_WEB_URL_PROD || ''",
);
// stable is a production channel by definition. Pinning the pair
// instead of accepting an input removes the footgun of publishing a stable
// build wired to the test backend — there is no legitimate reason for one.
for (const workflow of [stable]) {
expect(workflow).toContain("OPEN_DESIGN_AMR_PROFILE: prod");
expect(workflow).toContain("OD_VELA_WEB_URL: ${{ secrets.VELA_WEB_URL_PROD }}");
expect(workflow).not.toContain("inputs.amr_profile");
}
});
it("passes launcher version floor repo vars through to metadata publish and verify verbatim", async () => {
const [beta, prerelease, stable] = await Promise.all([
readFile(new URL("../../../.github/workflows/release-beta.yml", import.meta.url), "utf8"),
readFile(new URL("../../../.github/workflows/release-prerelease.yml", import.meta.url), "utf8"),
readFile(new URL("../../../.github/workflows/release-stable.yml", import.meta.url), "utf8"),
]);
const passthrough = (suffix: string): string[] => [
`RELEASE_LAUNCHER_VERSION_MIN_${suffix}: \${{ vars.RELEASE_LAUNCHER_VERSION_MIN_${suffix} }}`,
`RELEASE_LAUNCHER_VERSION_MIN_URL_${suffix}: \${{ vars.RELEASE_LAUNCHER_VERSION_MIN_URL_${suffix} }}`,
];
// Each channel workflow forwards its own repo-vars pair plus the STABLE
// fallback pair verbatim; channel policy (pair-level stable fallback,
// format/https/floor validation) lives only in
// tools/release/src/storage/launcher-version-floor.ts, never in YAML.
const lanes: Array<{ minSteps: number; suffix: string; workflow: string }> = [
{ minSteps: 2, suffix: "BETA", workflow: beta },
{ minSteps: 2, suffix: "PRERELEASE", workflow: prerelease },
];
for (const lane of lanes) {
for (const key of [...passthrough(lane.suffix), ...passthrough("STABLE")]) {
// publish-metadata always carries the pair; lanes with a
// verify-metadata step must carry it there too.
expect(countOccurrences(lane.workflow, key)).toBeGreaterThanOrEqual(lane.minSteps);
}
expect(lane.workflow).not.toContain(`vars.RELEASE_LAUNCHER_VERSION_MIN_${lane.suffix} ||`);
}
for (const key of passthrough("STABLE")) {
expect(countOccurrences(stable, key)).toBeGreaterThanOrEqual(2);
}
expect(stable).not.toContain("vars.RELEASE_LAUNCHER_VERSION_MIN_STABLE ||");
});
});