mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* fix(pack): notarize macOS builds only once * perf(pack): separate type validation and defer bounded Windows caches * test(e2e): bound BuildKit cleanup job away from pack cache save The deferred Windows pack-cache job checkouts the repo, so slicing clean-closed-pr-buildkit to EOF made the no-checkout assertion fail. Generated-By: looper 0.16.1 (runner=fixer, agent=omp) * test(ci): bound cache cleanup assertions to their job * fix(ci): preserve cache provenance when the release branch advances * perf(ci): reduce compression work in Windows cache handoff * fix(ci): align Windows cache restore and persistence paths * perf(ci): retain latest complete Windows artifacts for repeat builds * fix(ci): keep newer pack caches when ancestor saves finish later Prefix pruning in cache-maintenance used cache creation time, so an older ancestor finishing after a newer same-ref release deleted that newer cache. Prune only when the validated source SHA is the current commit. Generated-By: looper 0.16.1 (runner=fixer, agent=omp)
835 lines
48 KiB
TypeScript
835 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, "uses: ./.github/actions/defer-pack-cache"))).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).not.toContain("uses: actions/cache/save@v5");
|
|
expect(win).toContain("uses: ./.github/actions/defer-pack-cache");
|
|
expect(win).toContain("tools-pack-win-v2-slim-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("uses: ./.github/actions/defer-pack-cache");
|
|
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-v2-slim-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 ||");
|
|
});
|
|
});
|