openclaw/test/scripts/release-workflow-git-lifecycle.test.ts
Peter Steinberger d457b2c611
fix(test): checkout fixture tests fail healthy runs on loaded macOS hosts (#162888)
* fix(test): checkout fixture budgets cut healthy runs on loaded hosts

On a loaded macOS host (load 55-83), ci-platform-checkout.test.ts and the
other checkout fixture consumers failed through three fixture-internal
wall-clock budgets, not through the workflow under test:

- the supervisor's mock-resolution preflight ran bash under its own 2 s
  timeout ("mock command resolution failed: spawnSync bash ETIMEDOUT");
- the POSIX process census shadowed its caller's operation deadline with a
  fresh 1 s budget for all singleton ps queries ("Fixture process census
  failed (ETIMEDOUT)"); the owner-ancestry walk had the same 1 s shape;
- the supervisor cut every run at 45 s ("fixture deadline exceeded"), nested
  under the helper's 50 s close race only because the helper could not see
  the Vitest test timeout.

Measured breakdown: a multi-attempt scenario serially starts 25-36 Node
actors plus the Python owner; at load ~72 Linux git-failure spent 19.4 s,
of which the fixture's own 82 ps calls were 1.6 s. The work scales with
scheduler latency and is the contract under test, so the fix is ownership
of the time bound, not less work.

The owning test's AbortSignal now bounds a supervised run.
withCiCheckoutFixture takes it, rejects its close join on abort, and asks
the live supervisor to cancel over IPC so its own stop() retires the
detached shell group and actors, keeping the SIGKILL fallback for a wedged
supervisor. The supervisor drops its 45 s watchdog; its operation deadline
becomes the existing 60 s actor lifetime ceiling, which only bounds orphans.
Census, preflight, and ancestry queries borrow that operation deadline, as
the Windows census witness already did.

Every runCiGitStep and withCiCheckoutFixture caller passes its test signal;
.each registrations that need the context move to .for, which is the only
form Vitest 5 hands the context to.

Regressions: the shared slow-witness preload now gives POSIX supervisors a
first native query that spends 1.1 s on their clock (fails main's fixture
7/7 with "census failed (unverified)"), and the outer-runner retention
proof gains a cancel fault proving the aborted supervisor retires its shell.

* fix(test): keep checkout signal bindings lint-clean
2026-10-01 20:42:36 +00:00

386 lines
13 KiB
TypeScript

import { expect, it } from "vitest";
import { runCiGitStep } from "./ci-git-owner.test-support.js";
// These admission jobs run on Ubuntu. The shared ci-platform-checkout suite
// owns native Windows Job Object proof for the same pinned owner.
const posixIt = it.skipIf(process.platform === "win32");
const sha = "a".repeat(40);
const otherSha = "f".repeat(40);
const releaseTag = "v2026.8.1";
type ReleaseMode = "linux" | "macos" | "placeholder";
type RunOptions = Partial<Parameters<typeof runCiGitStep>[0]>;
const releases: Record<
ReleaseMode,
{
workflow: { file: string; job: string; step: string };
env: Record<string, string>;
revisions?: Record<string, string>;
}
> = {
linux: {
workflow: {
file: ".github/workflows/linux-app-release.yml",
job: "validate_release",
step: "Ensure tag commit is reachable from its release branch",
},
env: { RELEASE_TAG: releaseTag, WORKFLOW_SHA: otherSha },
revisions: { [`refs/tags/${releaseTag}^{commit}`]: sha },
},
macos: {
workflow: {
file: ".github/workflows/macos-release.yml",
job: "validate_macos_release_request",
step: "Validate release tag and package metadata",
},
env: { PUBLIC_RELEASE_BRANCH: "main", RELEASE_TAG: releaseTag },
},
placeholder: {
workflow: {
file: ".github/workflows/npm-placeholder-bootstrap.yml",
job: "plan",
step: "Validate trusted workflow and target",
},
env: {
EVENT_SHA: sha,
SOURCE_REF: sha,
WORKFLOW_REF: "refs/heads/main",
WORKFLOW_SHA: sha,
},
},
};
function releaseRun(signal: AbortSignal, mode: ReleaseMode, options: RunOptions = {}) {
const release = releases[mode];
return runCiGitStep({
signal,
workflow: release.workflow,
fetchResults: [],
...options,
env: { ...release.env, ...options.env },
revisions: { ...release.revisions, ...options.revisions },
});
}
function gitCommands(report: Awaited<ReturnType<typeof releaseRun>>) {
return report.commands.filter(({ tool }) => tool === "git").map(({ args }) => args);
}
posixIt.for([releaseTag, `${releaseTag}-2`])(
"Linux admits a stable tag from its matching release branch: %s",
{ timeout: 55_000 },
async (tag, { signal }) => {
const report = await releaseRun(signal, "linux", {
env: { RELEASE_TAG: tag },
revisions: { [`refs/tags/${tag}^{commit}`]: sha },
commandResults: {
[`merge-base --is-ancestor ${sha} origin/main`]: { code: 1 },
[`merge-base --is-ancestor ${sha} refs/remotes/origin/release/2026.8.1`]: { code: 0 },
},
});
expect(report.code, report.output).toBe(0);
expect(report.githubOutput).toBe(`tag_sha=${sha}\n`);
expect(gitCommands(report)).toContainEqual([
"fetch",
"--no-tags",
"origin",
"+refs/heads/release/2026.8.1:refs/remotes/origin/release/2026.8.1",
]);
},
);
posixIt.for([
{
mode: "linux" as const,
commands: [
["fetch", "--no-tags", "origin", "+refs/heads/main:refs/remotes/origin/main"],
["merge-base", "--is-ancestor", otherSha, "origin/main"],
["rev-parse", `refs/tags/${releaseTag}^{commit}`],
["merge-base", "--is-ancestor", sha, "origin/main"],
],
output: `tag_sha=${sha}\n`,
},
{
mode: "macos" as const,
commands: [
["rev-parse", "HEAD"],
["fetch", "--no-tags", "origin", "+refs/heads/main:refs/remotes/origin/main"],
],
output: "",
},
{
mode: "placeholder" as const,
commands: [
["rev-parse", "HEAD"],
["fetch", "--no-tags", "origin", "+refs/heads/main:refs/remotes/origin/main"],
["merge-base", "--is-ancestor", sha, "origin/main"],
["merge-base", "--is-ancestor", sha, "origin/main"],
],
output: `sha=${sha}\n`,
},
])(
"$mode admission drains every Git tree before output or consumer",
{ timeout: 55_000 },
async ({ mode, commands, output }, { signal }) => {
const report = await releaseRun(signal, mode);
expect(report.code, report.output).toBe(0);
expect(gitCommands(report)).toEqual(commands);
expect(report.githubOutput).toBe(output);
expect(report.readyAttempts).toHaveLength(commands.length);
if (mode === "macos") {
expect(report.commands.filter(({ tool }) => tool === "pnpm").map(({ args }) => args)).toEqual(
[["release:openclaw:npm:check"]],
);
expect(report.boundaries.some(({ name }) => name === "consumer:pnpm")).toBe(true);
} else {
expect(report.boundaries.some(({ name }) => name === "output")).toBe(true);
}
},
);
posixIt.for(
(["linux", "macos", "placeholder"] as const).flatMap((mode) =>
([23, 125, "hang"] as const).map((failure) => ({ failure, mode })),
),
)(
"$mode fetch failure $failure stops before output or consumer",
{ timeout: 55_000 },
async ({ failure, mode }, { signal }) => {
const report = await releaseRun(signal, mode, { fetchResults: [failure] });
expect(report.code, report.output).toBe(failure === "hang" ? 124 : failure);
expect(gitCommands(report).at(-1)?.[0]).toBe("fetch");
expect(report.githubOutput).toBe("");
expect(report.commands.some(({ tool }) => tool === "pnpm")).toBe(false);
},
);
posixIt.for(
(["linux", "macos"] as const).flatMap((mode) =>
([23, 125] as const).map((code) => ({ code, mode })),
),
)(
"$mode ordinary rev-parse status $code remains terminal",
{ timeout: 55_000 },
async ({ code, mode }, { signal }) => {
const report = await releaseRun(signal, mode, {
gitFault: { match: "^rev-parse ", code },
});
expect(report.code, report.output).toBe(code);
expect(gitCommands(report).at(-1)?.[0]).toBe("rev-parse");
expect(report.githubOutput).toBe("");
expect(report.commands.some(({ tool }) => tool === "pnpm")).toBe(false);
},
);
posixIt(
"macOS rejects an invalid public branch before any Git command",
async ({ signal }) => {
const report = await releaseRun(signal, "macos", {
env: { PUBLIC_RELEASE_BRANCH: "feature/not-a-release" },
});
expect(report.code, report.output).toBe(1);
expect(report.output).toContain(
"public_release_branch must be main or release/YYYY.M.PATCH, got feature/not-a-release.",
);
expect(gitCommands(report)).toEqual([]);
expect(report.commands.some(({ tool }) => tool === "pnpm")).toBe(false);
},
55_000,
);
const terminalOperations = [
{ mode: "linux" as const, match: "^fetch ", operation: "fetch" },
{ mode: "linux" as const, match: "^rev-parse ", operation: "rev-parse" },
{ mode: "linux" as const, match: "^merge-base ", operation: "merge-base" },
{ mode: "macos" as const, match: "^rev-parse ", operation: "rev-parse" },
{ mode: "macos" as const, match: "^fetch ", operation: "fetch" },
{ mode: "placeholder" as const, match: "^rev-parse ", operation: "rev-parse" },
{ mode: "placeholder" as const, match: "^fetch ", operation: "fetch" },
{
mode: "placeholder" as const,
match: "^merge-base ",
occurrence: 1,
operation: "merge-base",
},
{
mode: "placeholder" as const,
match: "^merge-base ",
occurrence: 2,
operation: "merge-base",
},
];
posixIt.for(
terminalOperations.flatMap((entry) =>
(["cleanup-failure", "cancel"] as const).map((failure) =>
Object.assign({}, entry, { failure }),
),
),
)(
"$mode $operation $failure is terminal before every later boundary",
{ timeout: 55_000 },
async ({ failure, match, mode, occurrence, operation }, { signal }) => {
const report = await releaseRun(signal, mode, {
gitFault: { match, occurrence, code: failure },
});
expect(report.code, report.output).toBe(failure === "cancel" ? 143 : 125);
expect(gitCommands(report).at(-1)?.[0]).toBe(operation);
expect(report.githubOutput).toBe("");
expect(report.commands.some(({ tool }) => tool === "pnpm")).toBe(false);
expect(report.output).not.toMatch(/not reachable|requires ref to equal/u);
},
);
posixIt.for([23, 125])(
"Linux ordinary merge-base status %s is terminal without trying another branch",
{ timeout: 55_000 },
async (code, { signal }) => {
const report = await releaseRun(signal, "linux", {
gitFault: { match: "^merge-base ", code },
});
expect(report.code, report.output).toBe(code);
expect(gitCommands(report).at(-1)?.[0]).toBe("merge-base");
expect(report.githubOutput).toBe("");
},
);
posixIt(
"Linux rejects a tag outside main and its matching release branch",
async ({ signal }) => {
const report = await releaseRun(signal, "linux", {
commandResults: {
[`merge-base --is-ancestor ${sha} origin/main`]: { code: 1 },
[`merge-base --is-ancestor ${sha} refs/remotes/origin/release/2026.8.1`]: { code: 1 },
},
});
expect(report.code, report.output).toBe(1);
expect(report.output).toContain(
`Tag ${releaseTag} (${sha}) is not reachable from main or release/2026.8.1.`,
);
expect(report.githubOutput).toBe("");
},
55_000,
);
posixIt(
"Linux rejects tooling outside main before inspecting the candidate",
async ({ signal }) => {
const report = await releaseRun(signal, "linux", {
commandResults: { [`merge-base --is-ancestor ${otherSha} origin/main`]: { code: 1 } },
});
expect(report.code, report.output).toBe(1);
expect(report.output).toContain("Linux release tooling must be reachable from current main.");
expect(gitCommands(report).some(([operation]) => operation === "rev-parse")).toBe(false);
expect(report.githubOutput).toBe("");
},
55_000,
);
posixIt.for([128, "cleanup-failure", "cancel"] as const)(
"Linux matching release branch fetch failure %s cannot admit a stale ref",
{ timeout: 55_000 },
async (failure, { signal }) => {
const report = await releaseRun(signal, "linux", {
commandResults: { [`merge-base --is-ancestor ${sha} origin/main`]: { code: 1 } },
gitFault: { match: "^fetch ", occurrence: 2, code: failure },
});
expect(report.code, report.output).toBe(
failure === "cancel" ? 143 : failure === "cleanup-failure" ? 125 : failure,
);
expect(gitCommands(report).at(-1)?.[0]).toBe("fetch");
expect(report.githubOutput).toBe("");
},
);
posixIt.for([
{ occurrence: 1, message: "workflow revision is not reachable" },
{ occurrence: 2, message: "target must be reachable" },
])(
"placeholder ordinary merge-base failure $occurrence keeps its custom rejection",
{ timeout: 55_000 },
async ({ message, occurrence }, { signal }) => {
const report = await releaseRun(signal, "placeholder", {
gitFault: { match: "^merge-base ", occurrence, code: 23 },
});
expect(report.code, report.output).toBe(1);
expect(report.output).toContain(message);
expect(gitCommands(report).filter(([operation]) => operation === "merge-base")).toHaveLength(
occurrence,
);
expect(report.githubOutput).toBe("");
},
);
posixIt.for([23, 125])(
"placeholder rev-parse status %s retains exact-SHA rejection",
{ timeout: 55_000 },
async (code, { signal }) => {
const report = await releaseRun(signal, "placeholder", {
gitFault: { match: "^rev-parse ", code },
});
expect(report.code, report.output).toBe(1);
expect(report.output).toContain(
"NPM placeholder publication requires ref to equal the exact main workflow SHA.",
);
expect(gitCommands(report)).toHaveLength(1);
expect(report.githubOutput).toBe("");
},
);
const placeholderIdentityMismatches: Array<{
env: Record<string, string>;
message: string;
}> = [
{
env: { WORKFLOW_REF: "refs/heads/release/2026.8.1" },
message: "must run from the trusted main workflow",
},
{
env: { EVENT_SHA: otherSha },
message: "requires ref to equal the exact main workflow SHA",
},
];
posixIt.for(placeholderIdentityMismatches)(
"placeholder rejects non-Git identity mismatch before checkout inspection",
{ timeout: 55_000 },
async ({ env, message }, { signal }) => {
const report = await releaseRun(signal, "placeholder", { env });
expect(report.code, report.output).toBe(1);
expect(report.output).toContain(message);
expect(gitCommands(report)).toEqual([]);
expect(report.githubOutput).toBe("");
},
);
posixIt(
"placeholder rejects a checked-out SHA mismatch before fetch",
async ({ signal }) => {
const report = await releaseRun(signal, "placeholder", {
commandResults: { "rev-parse HEAD": { code: 0, output: `${otherSha}\n` } },
});
expect(report.code, report.output).toBe(1);
expect(report.output).toContain(
"NPM placeholder publication requires ref to equal the exact main workflow SHA.",
);
expect(gitCommands(report)).toEqual([["rev-parse", "HEAD"]]);
expect(report.githubOutput).toBe("");
},
55_000,
);
posixIt.for(
(["linux", "macos", "placeholder"] as const).flatMap((mode) =>
(["owner", "python", "git"] as const).map((setupFailure) => ({ mode, setupFailure })),
),
)(
"$mode setup failure $setupFailure cannot publish or consume admission",
{ timeout: 55_000 },
async ({ mode, setupFailure }, { signal }) => {
const report = await releaseRun(signal, mode, { setupFailure });
expect(report.code, report.output).not.toBe(0);
expect(report.githubOutput).toBe("");
expect(report.commands.some(({ tool }) => tool === "pnpm")).toBe(false);
},
);