mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
* ci: share prepared SDK declarations across PR checks * ci: preserve author-independent SDK producer routing * ci: avoid anchors in SDK composite action * ci: export additional checks to downstream harnesses
2166 lines
74 KiB
TypeScript
2166 lines
74 KiB
TypeScript
import { spawnSync } from "node:child_process";
|
|
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import { EOL, tmpdir } from "node:os";
|
|
import { dirname, join } from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { expectDefined } from "@openclaw/normalization-core/expect";
|
|
import { beforeAll, expect, vi } from "vitest";
|
|
import { parse } from "yaml";
|
|
import { resolveTestNodeExecPath } from "../../src/test-utils/node-process.js";
|
|
import { createCommandTest } from "../helpers/command-fixture.js";
|
|
import { readCiCheckoutStep, renderGitTestClock } from "./ci-checkout.test-support.js";
|
|
import { runCiGitStep, type FetchResult } from "./ci-git-owner.test-support.js";
|
|
import { runDependencyFreePreflight } from "./ci-preflight-dependencies.test-support.js";
|
|
|
|
// Each case owns its checkout and process trees. Overlap their real timeout and
|
|
// drain waits, but keep subprocess pressure bounded on the four-core CI runner.
|
|
beforeAll(() => {
|
|
vi.setConfig({ maxConcurrency: 2 });
|
|
return () => vi.resetConfig();
|
|
});
|
|
|
|
const it = createCommandTest();
|
|
const linuxIt = it.skipIf(process.platform !== "linux").concurrent;
|
|
const releasePolicyIt = it.skipIf(process.platform === "win32");
|
|
const base = "c".repeat(40);
|
|
const head = "a".repeat(40);
|
|
const policyImport =
|
|
"from ci_git_owner import run_git, git_output, GitFailure, FetchTimeout\nimport os, subprocess\n";
|
|
const gitOwnerPath = join(process.cwd(), ".github/actions/git-owner/owner.py");
|
|
const releaseAncestryPolicyPath = join(
|
|
process.cwd(),
|
|
".github/actions/git-owner/release-ancestry.py",
|
|
);
|
|
const releaseAncestryPolicy = readFileSync(releaseAncestryPolicyPath, "utf8");
|
|
const fastReleaseAncestryPolicy = releaseAncestryPolicy.replace(
|
|
"max_fetch_seconds = 30",
|
|
"max_fetch_seconds = 2",
|
|
);
|
|
const expiredReleaseAncestryPolicy = releaseAncestryPolicy.replace(
|
|
"max_total_seconds = 120",
|
|
"max_total_seconds = 0",
|
|
);
|
|
|
|
type AncestryFixture = {
|
|
origin: string;
|
|
root: string;
|
|
source: string;
|
|
target: string;
|
|
};
|
|
|
|
function ancestryGitEnv(): NodeJS.ProcessEnv {
|
|
return {
|
|
...process.env,
|
|
// Detached Git maintenance can keep writing after the fixture starts cleanup.
|
|
GIT_CONFIG_PARAMETERS:
|
|
`${process.env.GIT_CONFIG_PARAMETERS ?? ""} 'maintenance.auto=false' 'gc.auto=0'`.trim(),
|
|
};
|
|
}
|
|
|
|
function fixtureGit(cwd: string, args: string[], input?: string) {
|
|
const result = spawnSync("git", args, {
|
|
cwd,
|
|
encoding: "utf8",
|
|
env: {
|
|
...ancestryGitEnv(),
|
|
GIT_AUTHOR_EMAIL: "fixture@example.invalid",
|
|
GIT_AUTHOR_NAME: "fixture",
|
|
GIT_COMMITTER_EMAIL: "fixture@example.invalid",
|
|
GIT_COMMITTER_NAME: "fixture",
|
|
},
|
|
input,
|
|
});
|
|
expect(result.status, result.stderr).toBe(0);
|
|
return result.stdout.trim();
|
|
}
|
|
|
|
function fixtureCommit(origin: string, tree: string, parent: string | undefined, label: string) {
|
|
return fixtureGit(
|
|
process.cwd(),
|
|
[`--git-dir=${origin}`, "commit-tree", tree, ...(parent ? ["-p", parent] : [])],
|
|
`${label}\n`,
|
|
);
|
|
}
|
|
|
|
function createAncestryFixture(options: {
|
|
sourceDistance: number;
|
|
targetDistance: number;
|
|
related: boolean;
|
|
}): AncestryFixture {
|
|
const root = mkdtempSync(join(tmpdir(), "openclaw-release-ancestry-"));
|
|
const origin = join(root, "origin.git");
|
|
fixtureGit(root, ["init", "--quiet", "--bare", origin]);
|
|
const commits: string[] = [];
|
|
const sourceRef = "refs/heads/release-source";
|
|
const targetRef = "refs/heads/main";
|
|
const commit = (ref: string, parent: number | undefined, label: string) => {
|
|
const mark = commits.length + 1;
|
|
const message = `${label}\n`;
|
|
commits.push(`commit ${ref}
|
|
mark :${mark}
|
|
committer fixture <fixture@example.invalid> ${mark} +0000
|
|
data ${Buffer.byteLength(message)}
|
|
${message}${parent ? `from :${parent}\n` : ""}
|
|
`);
|
|
return mark;
|
|
};
|
|
const sourceRoot = commit(sourceRef, undefined, "source root");
|
|
const targetRoot = options.related ? sourceRoot : commit(targetRef, undefined, "target root");
|
|
let source = sourceRoot;
|
|
for (let index = 0; index < options.sourceDistance; index++) {
|
|
source = commit(sourceRef, source, `source ${String(index)}`);
|
|
}
|
|
let target = targetRoot;
|
|
for (let index = 0; index < options.targetDistance; index++) {
|
|
target = commit(targetRef, target, `target ${String(index)}`);
|
|
}
|
|
fixtureGit(
|
|
origin,
|
|
["fast-import", "--quiet"],
|
|
`${commits.join("")}reset ${sourceRef}\nfrom :${source}\n\nreset ${targetRef}\nfrom :${target}\n\n`,
|
|
);
|
|
return {
|
|
origin,
|
|
root,
|
|
source: fixtureGit(origin, ["rev-parse", sourceRef]),
|
|
target: fixtureGit(origin, ["rev-parse", targetRef]),
|
|
};
|
|
}
|
|
|
|
function createProvisionalMergeBaseFixture(): AncestryFixture & { base: string } {
|
|
const root = mkdtempSync(join(tmpdir(), "openclaw-release-ancestry-provisional-"));
|
|
const origin = join(root, "origin.git");
|
|
fixtureGit(root, ["init", "--quiet", "--bare", origin]);
|
|
const commits = Array.from({ length: 341 }, (_, index) => {
|
|
const mark = index + 1;
|
|
const parent = mark > 1 ? `from :${String(mark - 1)}\n` : "";
|
|
return `commit refs/heads/main
|
|
mark :${String(mark)}
|
|
committer fixture <fixture@example.invalid> ${String(mark)} +0000
|
|
data 1
|
|
x
|
|
${parent}
|
|
`;
|
|
});
|
|
commits.push(`commit refs/heads/main
|
|
mark :342
|
|
committer fixture <fixture@example.invalid> 342 +0000
|
|
data 1
|
|
x
|
|
from :341
|
|
merge :1
|
|
|
|
`);
|
|
for (let mark = 343; mark <= 562; mark += 1) {
|
|
const parent = mark === 343 ? 121 : mark - 1;
|
|
commits.push(`commit refs/heads/release-source
|
|
mark :${String(mark)}
|
|
committer fixture <fixture@example.invalid> ${String(mark)} +0000
|
|
data 1
|
|
x
|
|
from :${String(parent)}
|
|
|
|
`);
|
|
}
|
|
commits.push(`commit refs/heads/release-source
|
|
mark :563
|
|
committer fixture <fixture@example.invalid> 563 +0000
|
|
data 1
|
|
x
|
|
from :562
|
|
merge :1
|
|
|
|
`);
|
|
fixtureGit(origin, ["fast-import", "--quiet"], commits.join(""));
|
|
fixtureGit(origin, ["symbolic-ref", "HEAD", "refs/heads/main"]);
|
|
return {
|
|
base: fixtureGit(origin, ["rev-parse", "refs/heads/main~221"]),
|
|
origin,
|
|
root,
|
|
source: fixtureGit(origin, ["rev-parse", "refs/heads/release-source"]),
|
|
target: fixtureGit(origin, ["rev-parse", "refs/heads/main"]),
|
|
};
|
|
}
|
|
|
|
function cloneAncestrySource(fixture: AncestryFixture, name: string) {
|
|
const checkout = join(fixture.root, name);
|
|
fixtureGit(fixture.root, [
|
|
"clone",
|
|
"--quiet",
|
|
"--depth=1",
|
|
"--branch",
|
|
"release-source",
|
|
pathToFileURL(fixture.origin).href,
|
|
checkout,
|
|
]);
|
|
return checkout;
|
|
}
|
|
|
|
function writeGitProxy(
|
|
fixture: AncestryFixture,
|
|
name: string,
|
|
body: string,
|
|
): { binDir: string; realGit: string } {
|
|
const binDir = join(fixture.root, name);
|
|
const realGit = spawnSync("which", ["git"], { encoding: "utf8" }).stdout.trim();
|
|
mkdirSync(binDir);
|
|
writeFileSync(
|
|
join(binDir, "git"),
|
|
`#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
${body}
|
|
`,
|
|
);
|
|
chmodSync(join(binDir, "git"), 0o755);
|
|
return { binDir, realGit };
|
|
}
|
|
|
|
function runReleaseAncestry(
|
|
checkout: string,
|
|
mode: "ancestor" | "merge-base",
|
|
env: Record<string, string> = {},
|
|
) {
|
|
return spawnSync("python3", ["-I", "-S", gitOwnerPath, "--policy", releaseAncestryPolicyPath], {
|
|
cwd: checkout,
|
|
encoding: "utf8",
|
|
env: {
|
|
...ancestryGitEnv(),
|
|
RELEASE_ANCESTRY_MODE: mode,
|
|
RELEASE_ANCESTRY_TARGET_REF: "refs/heads/main",
|
|
...env,
|
|
},
|
|
});
|
|
}
|
|
|
|
function expectPolicySuccess(result: ReturnType<typeof runReleaseAncestry>, mode: string) {
|
|
expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0);
|
|
expect(result.stdout).toContain(`Established release ${mode} relationship`);
|
|
}
|
|
|
|
releasePolicyIt("hydrates a divergent release merge base beyond the legacy 50+50 ceiling", () => {
|
|
const fixture = createAncestryFixture({
|
|
sourceDistance: 8,
|
|
targetDistance: 950,
|
|
related: true,
|
|
});
|
|
try {
|
|
const checkout = cloneAncestrySource(fixture, "progressive");
|
|
expectPolicySuccess(runReleaseAncestry(checkout, "merge-base"), "merge-base");
|
|
expect(fixtureGit(checkout, ["rev-parse", "refs/remotes/origin/main"])).toBe(fixture.target);
|
|
expect(fixtureGit(checkout, ["merge-base", fixture.source, "origin/main"])).not.toBe("");
|
|
} finally {
|
|
rmSync(fixture.root, { force: true, recursive: true });
|
|
}
|
|
});
|
|
|
|
releasePolicyIt("deepens past a provisional shallow merge base", () => {
|
|
const fixture = createProvisionalMergeBaseFixture();
|
|
try {
|
|
const checkout = cloneAncestrySource(fixture, "checkout");
|
|
expectPolicySuccess(runReleaseAncestry(checkout, "merge-base"), "merge-base");
|
|
expect(fixtureGit(checkout, ["merge-base", fixture.source, "refs/remotes/origin/main"])).toBe(
|
|
fixture.base,
|
|
);
|
|
} finally {
|
|
rmSync(fixture.root, { force: true, recursive: true });
|
|
}
|
|
});
|
|
|
|
releasePolicyIt("accepts a newly proven relation when deepening only moves a boundary", () => {
|
|
const fixture = createAncestryFixture({
|
|
sourceDistance: 64,
|
|
targetDistance: 8,
|
|
related: true,
|
|
});
|
|
try {
|
|
const checkout = cloneAncestrySource(fixture, "checkout");
|
|
expectPolicySuccess(runReleaseAncestry(checkout, "merge-base"), "merge-base");
|
|
expect(fixtureGit(checkout, ["merge-base", fixture.source, fixture.target])).not.toBe("");
|
|
} finally {
|
|
rmSync(fixture.root, { force: true, recursive: true });
|
|
}
|
|
});
|
|
|
|
releasePolicyIt("hydrates a Tideclaw target more than 180 commits beyond its source", () => {
|
|
const fixture = createAncestryFixture({
|
|
sourceDistance: 0,
|
|
targetDistance: 181,
|
|
related: true,
|
|
});
|
|
try {
|
|
const checkout = cloneAncestrySource(fixture, "checkout");
|
|
expectPolicySuccess(runReleaseAncestry(checkout, "ancestor"), "ancestor");
|
|
fixtureGit(checkout, ["merge-base", "--is-ancestor", fixture.source, fixture.target]);
|
|
} finally {
|
|
rmSync(fixture.root, { force: true, recursive: true });
|
|
}
|
|
});
|
|
|
|
releasePolicyIt("freezes the target SHA after the initial release ancestry fetch", () => {
|
|
const fixture = createAncestryFixture({
|
|
sourceDistance: 8,
|
|
targetDistance: 220,
|
|
related: true,
|
|
});
|
|
const moved = fixtureCommit(
|
|
fixture.origin,
|
|
fixtureGit(fixture.root, [`--git-dir=${fixture.origin}`, "mktree"], ""),
|
|
fixture.target,
|
|
"moved target",
|
|
);
|
|
const marker = join(fixture.root, "target-moved");
|
|
const proxy = writeGitProxy(
|
|
fixture,
|
|
"moving-git",
|
|
`"$REAL_GIT" "$@"
|
|
status=$?
|
|
if [[ "$status" == 0 && " $* " == *" --depth=64 "* && ! -e "$MOVE_MARKER" ]]; then
|
|
"$REAL_GIT" --git-dir="$ORIGIN" update-ref refs/heads/main "$MOVED_TARGET"
|
|
: > "$MOVE_MARKER"
|
|
fi
|
|
exit "$status"`,
|
|
);
|
|
try {
|
|
const checkout = cloneAncestrySource(fixture, "checkout");
|
|
fixtureGit(checkout, [
|
|
"config",
|
|
"--add",
|
|
"remote.origin.fetch",
|
|
"+refs/heads/*:refs/remotes/origin/*",
|
|
]);
|
|
expectPolicySuccess(
|
|
runReleaseAncestry(checkout, "merge-base", {
|
|
MOVE_MARKER: marker,
|
|
MOVED_TARGET: moved,
|
|
ORIGIN: fixture.origin,
|
|
PATH: `${proxy.binDir}:${process.env.PATH ?? ""}`,
|
|
REAL_GIT: proxy.realGit,
|
|
}),
|
|
"merge-base",
|
|
);
|
|
expect(fixtureGit(fixture.root, [`--git-dir=${fixture.origin}`, "rev-parse", "main"])).toBe(
|
|
moved,
|
|
);
|
|
expect(fixtureGit(checkout, ["rev-parse", "refs/remotes/origin/main"])).toBe(fixture.target);
|
|
} finally {
|
|
rmSync(fixture.root, { force: true, recursive: true });
|
|
}
|
|
});
|
|
|
|
releasePolicyIt(
|
|
"hydrates a frozen target through its branch when detached wants cannot deepen",
|
|
() => {
|
|
const fixture = createAncestryFixture({
|
|
sourceDistance: 8,
|
|
targetDistance: 220,
|
|
related: true,
|
|
});
|
|
const proxy = writeGitProxy(
|
|
fixture,
|
|
"detached-target-no-deepen-git",
|
|
`if [[ " $* " == *" fetch "* && " $* " == *" --deepen=128 "* && " $* " == *" +${fixture.target}:refs/remotes/origin/main "* ]]; then
|
|
exit 0
|
|
fi
|
|
exec "$REAL_GIT" "$@"`,
|
|
);
|
|
try {
|
|
const checkout = cloneAncestrySource(fixture, "checkout");
|
|
expectPolicySuccess(
|
|
runReleaseAncestry(checkout, "merge-base", {
|
|
PATH: `${proxy.binDir}:${process.env.PATH ?? ""}`,
|
|
REAL_GIT: proxy.realGit,
|
|
}),
|
|
"merge-base",
|
|
);
|
|
expect(fixtureGit(checkout, ["rev-parse", "refs/remotes/origin/main"])).toBe(fixture.target);
|
|
} finally {
|
|
rmSync(fixture.root, { force: true, recursive: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
releasePolicyIt("hydrates a divergent release source through its canonical branch", () => {
|
|
const fixture = createAncestryFixture({
|
|
sourceDistance: 220,
|
|
targetDistance: 8,
|
|
related: true,
|
|
});
|
|
const proxy = writeGitProxy(
|
|
fixture,
|
|
"detached-source-no-deepen-git",
|
|
`if [[ " $* " == *" fetch "* && " $* " == *" --deepen=128 "* && " $* " == *" +${fixture.source}:refs/remotes/origin/release-ancestry-source "* ]]; then
|
|
exit 0
|
|
fi
|
|
exec "$REAL_GIT" "$@"`,
|
|
);
|
|
try {
|
|
const checkout = cloneAncestrySource(fixture, "checkout");
|
|
expectPolicySuccess(
|
|
runReleaseAncestry(checkout, "merge-base", {
|
|
PATH: `${proxy.binDir}:${process.env.PATH ?? ""}`,
|
|
REAL_GIT: proxy.realGit,
|
|
RELEASE_ANCESTRY_SOURCE_REF: "refs/heads/release-source",
|
|
}),
|
|
"merge-base",
|
|
);
|
|
} finally {
|
|
rmSync(fixture.root, { force: true, recursive: true });
|
|
}
|
|
});
|
|
|
|
releasePolicyIt("hydrates each release ancestry branch independently", () => {
|
|
const fixture = createAncestryFixture({
|
|
sourceDistance: 220,
|
|
targetDistance: 8,
|
|
related: true,
|
|
});
|
|
const proxy = writeGitProxy(
|
|
fixture,
|
|
"independent-branch-hydration-git",
|
|
`if [[ " $* " == *" fetch "* && " $* " == *" --deepen="* && " $* " == *" +refs/heads/release-source:refs/remotes/origin/release-ancestry-source "* && " $* " == *" +refs/heads/main:refs/remotes/origin/release-ancestry-target-hydration "* ]]; then
|
|
exit 0
|
|
fi
|
|
exec "$REAL_GIT" "$@"`,
|
|
);
|
|
try {
|
|
const checkout = cloneAncestrySource(fixture, "checkout");
|
|
expectPolicySuccess(
|
|
runReleaseAncestry(checkout, "merge-base", {
|
|
PATH: `${proxy.binDir}:${process.env.PATH ?? ""}`,
|
|
REAL_GIT: proxy.realGit,
|
|
RELEASE_ANCESTRY_SOURCE_REF: "refs/heads/release-source",
|
|
}),
|
|
"merge-base",
|
|
);
|
|
} finally {
|
|
rmSync(fixture.root, { force: true, recursive: true });
|
|
}
|
|
});
|
|
|
|
releasePolicyIt("rejects fully hydrated disconnected release histories", () => {
|
|
const fixture = createAncestryFixture({
|
|
sourceDistance: 8,
|
|
targetDistance: 12,
|
|
related: false,
|
|
});
|
|
try {
|
|
const checkout = cloneAncestrySource(fixture, "checkout");
|
|
const result = runReleaseAncestry(checkout, "merge-base");
|
|
expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(1);
|
|
expect(result.stdout).toContain("is invalid after complete history");
|
|
expect(fixtureGit(checkout, ["rev-parse", "--is-shallow-repository"])).toBe("false");
|
|
} finally {
|
|
rmSync(fixture.root, { force: true, recursive: true });
|
|
}
|
|
});
|
|
|
|
releasePolicyIt(
|
|
"continues hydration when changed shallow boundaries reduce visible history",
|
|
() => {
|
|
const fixture = createAncestryFixture({
|
|
sourceDistance: 8,
|
|
targetDistance: 1000,
|
|
related: true,
|
|
});
|
|
const marker = join(fixture.root, "count-observed");
|
|
// Git can replace shallow cuts when it reaches merged history, making fewer
|
|
// commits visible even though the frontier changed. Replay that observed
|
|
// count transition while keeping fetches and the final ancestry proof real.
|
|
const proxy = writeGitProxy(
|
|
fixture,
|
|
"non-monotonic-count-git",
|
|
`if [[ " $* " == *" rev-list --count "* && ! -e "$COUNT_MARKER" ]]; then
|
|
count=$("$REAL_GIT" "$@") || exit $?
|
|
: > "$COUNT_MARKER"
|
|
echo "$((count + 10000))"
|
|
exit 0
|
|
fi
|
|
exec "$REAL_GIT" "$@"`,
|
|
);
|
|
try {
|
|
const checkout = cloneAncestrySource(fixture, "checkout");
|
|
expectPolicySuccess(
|
|
runReleaseAncestry(checkout, "merge-base", {
|
|
COUNT_MARKER: marker,
|
|
PATH: `${proxy.binDir}:${process.env.PATH ?? ""}`,
|
|
REAL_GIT: proxy.realGit,
|
|
}),
|
|
"merge-base",
|
|
);
|
|
expect(fixtureGit(checkout, ["rev-parse", "refs/remotes/origin/main"])).toBe(fixture.target);
|
|
} finally {
|
|
rmSync(fixture.root, { force: true, recursive: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
releasePolicyIt("rejects a successful release history deepen that makes no progress", () => {
|
|
const fixture = createAncestryFixture({
|
|
sourceDistance: 8,
|
|
targetDistance: 220,
|
|
related: true,
|
|
});
|
|
const proxy = writeGitProxy(
|
|
fixture,
|
|
"no-progress-git",
|
|
`if [[ " $* " == *" fetch "* && " $* " == *" --deepen=128 "* ]]; then
|
|
exit 0
|
|
fi
|
|
exec "$REAL_GIT" "$@"`,
|
|
);
|
|
try {
|
|
const checkout = cloneAncestrySource(fixture, "checkout");
|
|
const result = runReleaseAncestry(checkout, "merge-base", {
|
|
PATH: `${proxy.binDir}:${process.env.PATH ?? ""}`,
|
|
REAL_GIT: proxy.realGit,
|
|
});
|
|
expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(125);
|
|
expect(result.stdout).toContain("completed without ancestry progress");
|
|
} finally {
|
|
rmSync(fixture.root, { force: true, recursive: true });
|
|
}
|
|
});
|
|
|
|
releasePolicyIt.for([
|
|
{ label: "timeout", failure: "hang" },
|
|
{ label: "Git failure", failure: 23 },
|
|
] as const)(
|
|
"retries a drained release ancestry fetch after $label",
|
|
{ timeout: 55_000 },
|
|
async ({ failure }, { signal }) => {
|
|
const report = await runCiGitStep({
|
|
signal,
|
|
policy: failure === "hang" ? fastReleaseAncestryPolicy : releaseAncestryPolicy,
|
|
env: {
|
|
RELEASE_ANCESTRY_MODE: "merge-base",
|
|
RELEASE_ANCESTRY_TARGET_REF: "refs/heads/main",
|
|
},
|
|
fetchResults: [failure, 0],
|
|
commandResults: {
|
|
"rev-parse --verify HEAD^{commit}": { code: 0, output: `${head}\n` },
|
|
"rev-parse --verify refs/remotes/origin/main^{commit}": {
|
|
code: 0,
|
|
output: `${base}\n`,
|
|
},
|
|
"rev-parse --is-shallow-repository": { code: 0, output: "false\n" },
|
|
[`merge-base ${head} ${base}`]: { code: 0, output: `${base}\n` },
|
|
},
|
|
});
|
|
expect(report.code, report.output).toBe(0);
|
|
expect(report.fetches).toHaveLength(2);
|
|
expect(report.output).toContain("fetch failed on attempt 1; retrying");
|
|
},
|
|
);
|
|
|
|
releasePolicyIt(
|
|
"preserves the final release ancestry Git failure after bounded retries",
|
|
async ({ signal }) => {
|
|
const report = await runCiGitStep({
|
|
signal,
|
|
policy: releaseAncestryPolicy,
|
|
env: {
|
|
RELEASE_ANCESTRY_MODE: "merge-base",
|
|
RELEASE_ANCESTRY_TARGET_REF: "refs/heads/main",
|
|
},
|
|
fetchResults: [23, 23, 23],
|
|
commandResults: {
|
|
"rev-parse --verify HEAD^{commit}": { code: 0, output: `${head}\n` },
|
|
},
|
|
});
|
|
expect(report.code, report.output).toBe(23);
|
|
expect(report.fetches).toHaveLength(3);
|
|
},
|
|
);
|
|
|
|
releasePolicyIt(
|
|
"returns 124 when the release ancestry total budget is exhausted",
|
|
async ({ signal }) => {
|
|
const report = await runCiGitStep({
|
|
signal,
|
|
policy: expiredReleaseAncestryPolicy,
|
|
env: {
|
|
RELEASE_ANCESTRY_MODE: "merge-base",
|
|
RELEASE_ANCESTRY_TARGET_REF: "refs/heads/main",
|
|
},
|
|
fetchResults: [],
|
|
});
|
|
expect(report.code, report.output).toBe(124);
|
|
expect(report.commands).toEqual([]);
|
|
},
|
|
);
|
|
|
|
it("materializes an executable preflight manifest from the workflow revision", async ({
|
|
command,
|
|
}) => {
|
|
const root = command.createTempDir("ci-preflight-harness-");
|
|
const origin = join(root, "origin");
|
|
const workspace = join(root, "checkout");
|
|
mkdirSync(origin);
|
|
mkdirSync(workspace);
|
|
fixtureGit(origin, ["init", "--quiet"]);
|
|
for (const file of [
|
|
".github/actions/setup-node-env/action.yml",
|
|
".github/actions/git-owner/test-prerequisites.mjs",
|
|
".github/actions/git-owner/test-prerequisites.json",
|
|
"scripts/ci-build-manifest.mjs",
|
|
"scripts/lib/ci-ios-smoke-plan.mjs",
|
|
"scripts/lib/release-context.mjs",
|
|
"scripts/lib/release-version.mjs",
|
|
]) {
|
|
const destination = join(origin, file);
|
|
mkdirSync(dirname(destination), { recursive: true });
|
|
writeFileSync(destination, readFileSync(file));
|
|
}
|
|
fixtureGit(origin, ["add", "."]);
|
|
const tree = fixtureGit(origin, ["write-tree"]);
|
|
const revision = fixtureCommit(join(origin, ".git"), tree, undefined, "workflow fixture");
|
|
fixtureGit(origin, ["update-ref", "HEAD", revision]);
|
|
const gitConfig = join(root, "gitconfig");
|
|
writeFileSync(gitConfig, "");
|
|
const checkout = await command.run(
|
|
process.platform === "win32" ? "python" : "python3",
|
|
["-I", "-S", gitOwnerPath],
|
|
{
|
|
cwd: workspace,
|
|
env: {
|
|
...process.env,
|
|
CHECKOUT_KIND: "preflight",
|
|
CHECKOUT_REPO: "fixture/preflight",
|
|
CHECKOUT_TOKEN: "",
|
|
CHECKOUT_REF: revision,
|
|
CHECKOUT_FALLBACK_REF: revision,
|
|
WORKFLOW_SHA: revision,
|
|
GITHUB_WORKSPACE: workspace,
|
|
GITHUB_EVENT_NAME: "pull_request",
|
|
GIT_CONFIG_NOSYSTEM: "1",
|
|
GIT_CONFIG_GLOBAL: gitConfig,
|
|
GIT_CONFIG_COUNT: "1",
|
|
GIT_CONFIG_KEY_0: `url.${pathToFileURL(origin).href}.insteadOf`,
|
|
GIT_CONFIG_VALUE_0: "https://github.com/fixture/preflight.git",
|
|
},
|
|
},
|
|
);
|
|
expect(checkout.status, `${checkout.stdout}\n${checkout.stderr}`).toBe(0);
|
|
// The workflow's native Node manifest uses registerHooks to forbid runtime dependencies.
|
|
const { result, manifest } = runDependencyFreePreflight(
|
|
pathToFileURL(join(workspace, ".ci-harness/scripts/ci-build-manifest.mjs")),
|
|
root,
|
|
resolveTestNodeExecPath(),
|
|
);
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(manifest).toContain("run_windows=true\n");
|
|
expect(fixtureGit(workspace, ["status", "--porcelain"])).toBe("");
|
|
});
|
|
|
|
linuxIt.for([
|
|
{ shape: "same", event: "pull_request" },
|
|
{ shape: "different", event: "pull_request" },
|
|
{ shape: "same", event: "schedule" },
|
|
{ shape: "different", event: "schedule" },
|
|
])(
|
|
"executes trusted additional checks after a $shape-SHA $event checkout",
|
|
async ({ shape, event }, { command }) => {
|
|
const root = command.createTempDir("ci-additional-harness-");
|
|
const origin = join(root, "origin");
|
|
const workspace = join(root, "checkout");
|
|
mkdirSync(origin);
|
|
mkdirSync(workspace);
|
|
fixtureGit(origin, ["init", "--quiet"]);
|
|
const script = "scripts/ci-additional-checks.sh";
|
|
const trustedScript = readFileSync(script, "utf8");
|
|
for (const file of [".github/actions/setup-node-env/action.yml", script]) {
|
|
mkdirSync(dirname(join(origin, file)), { recursive: true });
|
|
writeFileSync(join(origin, file), readFileSync(file));
|
|
}
|
|
fixtureGit(origin, ["add", "."]);
|
|
const workflow = fixtureCommit(
|
|
join(origin, ".git"),
|
|
fixtureGit(origin, ["write-tree"]),
|
|
undefined,
|
|
"trusted workflow",
|
|
);
|
|
let target = workflow;
|
|
if (shape === "different") {
|
|
writeFileSync(join(origin, script), "echo untrusted-candidate-script >&2\nexit 99\n");
|
|
fixtureGit(origin, ["add", script]);
|
|
target = fixtureCommit(
|
|
join(origin, ".git"),
|
|
fixtureGit(origin, ["write-tree"]),
|
|
workflow,
|
|
"different candidate",
|
|
);
|
|
}
|
|
fixtureGit(origin, ["update-ref", "HEAD", target]);
|
|
const gitConfig = join(root, "gitconfig");
|
|
writeFileSync(gitConfig, "");
|
|
const checkout = await command.run("python3", ["-I", "-S", gitOwnerPath], {
|
|
cwd: workspace,
|
|
env: {
|
|
...process.env,
|
|
CHECKOUT_KIND: "linux-node",
|
|
CHECKOUT_REPO: "fixture/additional",
|
|
CHECKOUT_TOKEN: "",
|
|
CHECKOUT_SHA: target,
|
|
CHECKOUT_BASE_SHA: "",
|
|
CHECKOUT_GIT_COMMITS_JSON: "[]",
|
|
WORKFLOW_SHA: workflow,
|
|
GITHUB_WORKSPACE: workspace,
|
|
GITHUB_EVENT_NAME: event,
|
|
GIT_CONFIG_NOSYSTEM: "1",
|
|
GIT_CONFIG_GLOBAL: gitConfig,
|
|
GIT_CONFIG_COUNT: "1",
|
|
GIT_CONFIG_KEY_0: `url.${pathToFileURL(origin).href}.insteadOf`,
|
|
GIT_CONFIG_VALUE_0: "https://github.com/fixture/additional.git",
|
|
},
|
|
});
|
|
expect(checkout.status, `${checkout.stdout}\n${checkout.stderr}`).toBe(0);
|
|
expect(readFileSync(join(workspace, ".ci-harness", script), "utf8")).toBe(trustedScript);
|
|
if (shape === "different") {
|
|
expect(fixtureGit(join(workspace, ".ci-harness"), ["rev-parse", "HEAD"])).toBe(workflow);
|
|
expect(readFileSync(join(workspace, script), "utf8")).toContain("untrusted-candidate-script");
|
|
}
|
|
const bin = join(root, "bin");
|
|
mkdirSync(bin);
|
|
writeFileSync(join(bin, "pnpm"), '#!/bin/sh\nprintf "%s\\n" "$*" > "$PROBE_COMMAND"\n');
|
|
chmodSync(join(bin, "pnpm"), 0o755);
|
|
const probe = join(root, "invoked-command");
|
|
const run = await command.run("bash", [`.ci-harness/${script}`], {
|
|
cwd: workspace,
|
|
env: {
|
|
...process.env,
|
|
PATH: `${bin}:${process.env.PATH ?? ""}`,
|
|
ADDITIONAL_CHECK_GROUP: "runtime-topology-architecture",
|
|
PROBE_COMMAND: probe,
|
|
},
|
|
});
|
|
expect(run.status, `${run.stdout}\n${run.stderr}`).toBe(0);
|
|
expect(readFileSync(probe, "utf8")).toBe("check:architecture\n");
|
|
expect(fixtureGit(workspace, ["status", "--porcelain"])).toBe("");
|
|
},
|
|
);
|
|
|
|
// Ask Bash to decode the source independently of the generator and fixture codec.
|
|
it("keeps exactly one byte-identical generated CI owner", () => {
|
|
const workflow = readFileSync(".github/workflows/ci.yml", "utf8");
|
|
const source = readFileSync(".github/actions/git-owner/owner.py", "utf8");
|
|
const projections = [
|
|
...workflow.matchAll(/^ {10}run_owner '[\s\S]*?^ {10}# End generated CI Git owner\.$/gmu),
|
|
];
|
|
expect(projections).toHaveLength(1);
|
|
for (const [projection] of projections) {
|
|
const result = spawnSync("bash", ["--noprofile", "--norc", "-e"], {
|
|
encoding: "utf8",
|
|
input: "run_owner() { printf '%s' \"$1\"; }\n" + projection.replace(/^ {10}/gmu, ""),
|
|
});
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(result.stdout).toBe(source);
|
|
}
|
|
});
|
|
|
|
it("launches the Windows checkout owner below the native command-line limit", () => {
|
|
const root = mkdtempSync(join(tmpdir(), "ci-owner-windows-argv "));
|
|
const bin = join(root, "bin");
|
|
const runnerTemp = join(root, "runner temp");
|
|
mkdirSync(bin);
|
|
mkdirSync(runnerTemp);
|
|
const python = join(bin, "python");
|
|
writeFileSync(python, "#!/usr/bin/env bash\nprintf '%s\\0' \"$@\"\n");
|
|
chmodSync(python, 0o755);
|
|
try {
|
|
const checkout = readCiCheckoutStep("checks-windows").run;
|
|
const owner =
|
|
renderGitTestClock(readFileSync(".github/actions/git-owner/owner.py", "utf8")) +
|
|
`\n#${"x".repeat(32_768)}\n`;
|
|
const source = checkout.replace(
|
|
/^run_owner '[\s\S]*?'\n# End generated CI Git owner\.$/mu,
|
|
() => `run_owner '${owner.replaceAll("'", "'\\''")}'\n# End generated CI Git owner.`,
|
|
);
|
|
expect(source).not.toBe(checkout);
|
|
const result = spawnSync("bash", ["--noprofile", "--norc", "-e"], {
|
|
// Git for Windows prepends its tools; restore the Python probe boundary inside Bash.
|
|
input:
|
|
(process.platform === "win32"
|
|
? 'export PATH="$(cygpath -u "$OWNER_PROBE_BIN"):$PATH"\n'
|
|
: "") + source,
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
PATH: `${bin}${process.platform === "win32" ? ";" : ":"}${process.env.PATH ?? ""}`,
|
|
OWNER_PROBE_BIN: bin,
|
|
RUNNER_OS: "Windows",
|
|
RUNNER_TEMP: runnerTemp.replaceAll("\\", "/"),
|
|
},
|
|
});
|
|
expect(result.status, result.stderr).toBe(0);
|
|
const args = result.stdout.split("\0").slice(0, -1);
|
|
expect(args).toEqual(["-I", "-S", `${runnerTemp.replaceAll("\\", "/")}/ci-git-owner.py`]);
|
|
expect(args.join(" ").length).toBeLessThan(1_024);
|
|
const materialized = readFileSync(join(runnerTemp, "ci-git-owner.py"), "utf8");
|
|
expect(materialized).toBe(owner);
|
|
// Fixed padding keeps the oversized-source regression meaningful if the owner shrinks.
|
|
expect(materialized.length + (materialized.match(/"/gu)?.length ?? 0) + 2).toBeGreaterThan(
|
|
32_767,
|
|
);
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("binds read-only checkout authentication only to the workflow repository", () => {
|
|
const workflow = parse(readFileSync(".github/workflows/ci.yml", "utf8")) as {
|
|
permissions: Record<string, string>;
|
|
jobs: Record<
|
|
string,
|
|
{ permissions?: Record<string, string>; steps?: { env?: Record<string, string> }[] }
|
|
>;
|
|
};
|
|
let ownedCheckouts = 0;
|
|
for (const job of Object.values(workflow.jobs)) {
|
|
for (const step of job.steps ?? []) {
|
|
if (!step.env?.CHECKOUT_REPO) {
|
|
continue;
|
|
}
|
|
ownedCheckouts++;
|
|
const sameRepository = step.env.CHECKOUT_REPO === "${{ github.repository }}";
|
|
expect(step.env.CHECKOUT_TOKEN).toBe(sameRepository ? "${{ github.token }}" : undefined);
|
|
if (sameRepository) {
|
|
expect((job.permissions ?? workflow.permissions).contents).toBe("read");
|
|
}
|
|
}
|
|
}
|
|
expect(ownedCheckouts).toBeGreaterThan(0);
|
|
});
|
|
|
|
it.for([false, true])(
|
|
"preserves linked Git metadata (reclaim locks=%s)",
|
|
async (reclaimLocks, { signal }) => {
|
|
const invocation = reclaimLocks
|
|
? 'run_git(os.getcwd(), "fetch", "origin", "fixture", reclaim_locks=True)'
|
|
: 'print(git_output(os.getcwd(), "rev-parse", "HEAD"), end="")';
|
|
const report = await runCiGitStep({
|
|
signal,
|
|
fetchResults: [],
|
|
policy:
|
|
policyImport +
|
|
`from pathlib import Path
|
|
shared = Path.cwd().parent / "shared-git"
|
|
shared.mkdir()
|
|
lock = shared / "shallow.lock"
|
|
lock.write_text("not invocation-owned\\n")
|
|
metadata = Path(".git")
|
|
metadata.write_text("gitdir: ../shared-git\\n")
|
|
try:
|
|
${invocation}
|
|
finally:
|
|
assert metadata.read_text() == "gitdir: ../shared-git\\n"
|
|
assert lock.read_text() == "not invocation-owned\\n"
|
|
`,
|
|
});
|
|
expect(report.code, report.output).toBe(reclaimLocks ? 125 : 0);
|
|
expect(report.commands.map(({ args }) => args)).toEqual(
|
|
reclaimLocks ? [] : [["rev-parse", "HEAD"]],
|
|
);
|
|
if (!reclaimLocks) {
|
|
expect(report.output).toBe(`${head}${EOL}`);
|
|
}
|
|
},
|
|
);
|
|
|
|
it("reclaims failed supplemental-fetch locks before the next attempt", async ({ signal }) => {
|
|
const report = await runCiGitStep({
|
|
signal,
|
|
job: "checks-fast-core",
|
|
step: "Prepare release-gate ratchet merge tree",
|
|
fetchResults: ["hang", 0],
|
|
prepare: true,
|
|
});
|
|
expect(report.code, report.output).toBe(0);
|
|
expect(report.fetches).toHaveLength(2);
|
|
expect(report.readyAttempts).toEqual([1, 2]);
|
|
});
|
|
|
|
linuxIt(
|
|
"bootstraps only action-owned bytes outside the candidate with isolated Python",
|
|
async ({ signal }) => {
|
|
const report = await runCiGitStep({
|
|
signal,
|
|
action: "git-owner",
|
|
fetchResults: [],
|
|
poisonPython: true,
|
|
});
|
|
expect(report.code, report.output).toBe(0);
|
|
expect(report.commands).toEqual([]);
|
|
expect(report.githubEnv).toContain("CI_GIT_OWNER=");
|
|
},
|
|
);
|
|
|
|
it.for([
|
|
{
|
|
label: "distant shallow base",
|
|
depth: 470,
|
|
shallow: true,
|
|
blockDeepen: false,
|
|
unavailable: false,
|
|
},
|
|
{
|
|
label: "final shallow fallback",
|
|
depth: 8,
|
|
shallow: true,
|
|
blockDeepen: true,
|
|
unavailable: false,
|
|
},
|
|
{
|
|
label: "complete checkout fallback",
|
|
depth: 1,
|
|
shallow: false,
|
|
blockDeepen: true,
|
|
unavailable: false,
|
|
},
|
|
{ label: "unavailable base", depth: 8, shallow: true, blockDeepen: true, unavailable: true },
|
|
])(
|
|
"recovers real base history: $label",
|
|
async ({ depth, shallow, blockDeepen, unavailable }, { command }) => {
|
|
const root = command.createTempDir("openclaw-ensure-base-");
|
|
const origin = join(root, "origin.git");
|
|
const checkout = join(root, "checkout");
|
|
const commandLog = join(root, "git-commands.log");
|
|
fixtureGit(root, ["init", "--quiet", "--bare", "--initial-branch=main", origin]);
|
|
fixtureGit(origin, ["config", "uploadpack.allowFilter", "true"]);
|
|
const commits = Array.from({ length: depth + 1 }, (_, index) => {
|
|
const message = `fixture ${index}\n`;
|
|
const parent = index > 0 ? `from :${index}\n` : "";
|
|
return `commit refs/heads/main
|
|
mark :${index + 1}
|
|
committer fixture <fixture@example.invalid> ${1_700_000_000 + index} +0000
|
|
data ${Buffer.byteLength(message)}
|
|
${message}${parent}M 100644 inline fixture.txt
|
|
data ${Buffer.byteLength(message)}
|
|
${message}
|
|
`;
|
|
});
|
|
fixtureGit(origin, ["fast-import", "--quiet"], commits.join(""));
|
|
let baseSha = fixtureGit(origin, ["rev-parse", `main~${depth}`]);
|
|
fixtureGit(root, [
|
|
"clone",
|
|
"--quiet",
|
|
"--filter=blob:none",
|
|
...(shallow ? ["--depth=2"] : []),
|
|
pathToFileURL(origin).href,
|
|
checkout,
|
|
]);
|
|
if (!shallow) {
|
|
const previous = fixtureGit(origin, ["rev-parse", "main"]);
|
|
fixtureGit(
|
|
origin,
|
|
["fast-import", "--quiet"],
|
|
`commit refs/heads/main
|
|
committer fixture <fixture@example.invalid> 1700000002 +0000
|
|
data 14
|
|
remote update
|
|
from ${previous}
|
|
M 100644 inline fixture.txt
|
|
data 14
|
|
remote update
|
|
|
|
`,
|
|
);
|
|
baseSha = fixtureGit(origin, ["rev-parse", "main"]);
|
|
}
|
|
const actionPath = join(process.cwd(), ".github/actions/ensure-base-commit");
|
|
const fixtureActionPath = join(root, "trusted-actions", "ensure-base-commit");
|
|
const fixtureOwnerPath = join(root, "trusted-actions", "git-owner");
|
|
mkdirSync(fixtureActionPath, { recursive: true });
|
|
mkdirSync(fixtureOwnerPath);
|
|
writeFileSync(
|
|
join(fixtureOwnerPath, "owner.py"),
|
|
readFileSync(join(actionPath, "../git-owner/owner.py")),
|
|
);
|
|
// Intercept the public policy boundary without a batch layer altering native Git arguments.
|
|
writeFileSync(
|
|
join(fixtureActionPath, "policy.py"),
|
|
`import json, os, runpy
|
|
import ci_git_owner
|
|
|
|
real_run_git = ci_git_owner.run_git
|
|
def observed_run_git(directory, *arguments, **options):
|
|
environment = {**os.environ, **(options.get("env") or {})}
|
|
with open(os.environ["GIT_COMMAND_LOG"], "a", encoding="utf-8") as output:
|
|
output.write(json.dumps({"args": arguments, "noLazyFetch": environment.get("GIT_NO_LAZY_FETCH") or "unset"}) + "\\n")
|
|
if "fetch" in arguments:
|
|
if arguments[-1] == os.environ["BASE_SHA"] or os.environ["DENY_ALL_FETCHES"] == "1":
|
|
raise ci_git_owner.GitFailure(128)
|
|
if os.environ["BLOCK_DEEPEN"] == "1" and any(arg.startswith("--deepen=") for arg in arguments):
|
|
raise ci_git_owner.GitFailure(128)
|
|
return real_run_git(directory, *arguments, **options)
|
|
|
|
ci_git_owner.run_git = observed_run_git
|
|
runpy.run_path(os.environ["BASE_REAL_POLICY_PATH"], run_name="__main__")
|
|
`,
|
|
);
|
|
const localProbe = () =>
|
|
spawnSync("git", ["-C", checkout, "rev-parse", "--verify", `${baseSha}^{commit}`], {
|
|
encoding: "utf8",
|
|
env: { ...process.env, GIT_NO_LAZY_FETCH: "1" },
|
|
});
|
|
expect(localProbe().status).toBe(128);
|
|
const action = parse(readFileSync(join(actionPath, "action.yml"), "utf8")) as {
|
|
runs: { steps: { run: string }[] };
|
|
};
|
|
const result = await command.run("bash", ["--noprofile", "--norc", "-eo", "pipefail"], {
|
|
cwd: checkout,
|
|
encoding: "utf8",
|
|
input: expectDefined(action.runs.steps[0], "ensure-base-commit action step").run,
|
|
env: {
|
|
...process.env,
|
|
BASE_ACTION_PATH: fixtureActionPath.replaceAll("\\", "/"),
|
|
BASE_REAL_POLICY_PATH: join(actionPath, "policy.py").replaceAll("\\", "/"),
|
|
BASE_SHA: baseSha,
|
|
FETCH_REF: "main",
|
|
RUNNER_OS:
|
|
process.platform === "win32"
|
|
? "Windows"
|
|
: process.platform === "linux"
|
|
? "Linux"
|
|
: "macOS",
|
|
GIT_COMMAND_LOG: commandLog,
|
|
GIT_NO_LAZY_FETCH: "",
|
|
BLOCK_DEEPEN: blockDeepen ? "1" : "0",
|
|
DENY_ALL_FETCHES: unavailable ? "1" : "0",
|
|
},
|
|
});
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(unavailable ? 1 : 0);
|
|
const commands = readFileSync(commandLog, "utf8")
|
|
.trim()
|
|
.split("\n")
|
|
.map((line) => JSON.parse(line) as { args: string[]; noLazyFetch: string });
|
|
const probes = commands.filter(({ args }) => args.includes("rev-parse"));
|
|
expect(probes.length).toBeGreaterThan(1);
|
|
expect(probes.every(({ noLazyFetch }) => noLazyFetch === "1")).toBe(true);
|
|
const commitProbes = probes.filter(({ args }) => args.includes("--verify"));
|
|
expect(commitProbes.length).toBeGreaterThan(1);
|
|
expect(commitProbes.every(({ args }) => args.at(-1) === `${baseSha}^{commit}`)).toBe(true);
|
|
const fetches = commands.filter(({ args }) => args.includes("fetch"));
|
|
expect(fetches.every(({ args }) => args.includes("--filter=blob:none"))).toBe(true);
|
|
expect(fetches.every(({ noLazyFetch }) => noLazyFetch === "unset")).toBe(true);
|
|
expect(fetches.some(({ args }) => args.includes("--unshallow"))).toBe(shallow && blockDeepen);
|
|
if (unavailable) {
|
|
expect(result.stdout).toContain("::error title=ensure-base-commit missing base::");
|
|
expect(localProbe().status).toBe(128);
|
|
return;
|
|
}
|
|
expect(localProbe().status).toBe(0);
|
|
expect(fixtureGit(checkout, ["diff", "--name-only", baseSha, "HEAD"])).toBe("fixture.txt");
|
|
// Normal downstream reads must still hydrate historical blobs after local-only probes.
|
|
expect(fixtureGit(checkout, ["show", `${baseSha}:fixture.txt`])).toBe(
|
|
shallow ? "fixture 0" : "remote update",
|
|
);
|
|
if (blockDeepen) {
|
|
expect(result.stdout).toContain("Resolved base commit after full ref fetch");
|
|
expect(fixtureGit(checkout, ["rev-parse", "--is-shallow-repository"])).toBe("false");
|
|
} else {
|
|
expect(fetches.some(({ args }) => args.includes("--deepen=1000"))).toBe(true);
|
|
}
|
|
},
|
|
);
|
|
|
|
linuxIt(
|
|
"drains a timed-out exact fetch before deepening for the base",
|
|
async ({ signal }) => {
|
|
const report = await runCiGitStep({
|
|
signal,
|
|
action: "ensure-base-commit",
|
|
baseAvailableAfter: 2,
|
|
fetchResults: ["hang", 0],
|
|
});
|
|
expect(report.code, report.output).toBe(0);
|
|
expect(report.fetches.map(({ args }) => args)).toEqual([
|
|
["fetch", "--filter=blob:none", "--no-tags", "--depth=1", "origin", base],
|
|
["fetch", "--filter=blob:none", "--no-tags", "--deepen=25", "origin", "--", "fixture-base"],
|
|
]);
|
|
},
|
|
55_000,
|
|
);
|
|
|
|
linuxIt.for([
|
|
{ label: "empty", sha: "", code: 0, commands: 0 },
|
|
{ label: "all-zero", sha: "00000", code: 0, commands: 0 },
|
|
{ label: "invalid SHA", sha: "--help", code: 2, commands: 0 },
|
|
{ label: "short SHA rejected", sha: "a".repeat(6), code: 2, commands: 0 },
|
|
{ label: "long SHA rejected", sha: "a".repeat(41), code: 2, commands: 0 },
|
|
{ label: "short uppercase SHA accepted", sha: "ABCDEF1", code: 0, commands: 2 },
|
|
{ label: "invalid ref", sha: base, invalidRef: true, code: 2, commands: 1 },
|
|
{ label: "already available", sha: base, code: 0, commands: 2 },
|
|
])(
|
|
"base policy preserves $label validation and skip behavior",
|
|
async ({ sha, code, commands, invalidRef }, { signal }) => {
|
|
const report = await runCiGitStep({
|
|
signal,
|
|
action: "ensure-base-commit",
|
|
env: { BASE_SHA: sha },
|
|
invalidRef,
|
|
baseAvailableAfter: 0,
|
|
fetchResults: [],
|
|
});
|
|
expect(report.code, report.output).toBe(code);
|
|
expect(report.commands).toHaveLength(commands);
|
|
expect(report.fetches).toEqual([]);
|
|
},
|
|
);
|
|
|
|
linuxIt.for([1, 2, 3, 4, 5, 6, undefined])(
|
|
"base policy preserves exact/deepen/plain-ref order (available after %s)",
|
|
{ timeout: 55_000 },
|
|
async (baseAvailableAfter, { signal }) => {
|
|
const report = await runCiGitStep({
|
|
signal,
|
|
action: "ensure-base-commit",
|
|
baseAvailableAfter,
|
|
fetchResults: [0, 23, 0, 23, 0, 0],
|
|
commandResults: {
|
|
"rev-parse --is-shallow-repository": { code: 0, output: "false\n" },
|
|
},
|
|
poisonPython: true,
|
|
});
|
|
expect(report.code, report.output).toBe(baseAvailableAfter ? 0 : 1);
|
|
const expected = [
|
|
["fetch", "--filter=blob:none", "--no-tags", "--depth=1", "origin", base],
|
|
...[25, 100, 300, 1000].map((depth) => [
|
|
"fetch",
|
|
"--filter=blob:none",
|
|
"--no-tags",
|
|
`--deepen=${depth}`,
|
|
"origin",
|
|
"--",
|
|
"fixture-base",
|
|
]),
|
|
["fetch", "--filter=blob:none", "--no-tags", "origin", "--", "fixture-base"],
|
|
].slice(0, baseAvailableAfter ?? 6);
|
|
expect(report.fetches.map(({ args }) => args)).toEqual(expected);
|
|
expect(
|
|
report.fetches.every(
|
|
({ configuration }) => configuration?.join(" ") === "protocol.version=2",
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
report.commands.filter(({ args }) => args[0] === "rev-parse" && args[1] === "--verify"),
|
|
).toHaveLength(expected.length + 1);
|
|
if (!baseAvailableAfter) {
|
|
expect(report.output).toContain("::error title=ensure-base-commit missing base::");
|
|
}
|
|
},
|
|
);
|
|
|
|
linuxIt.for([125, 143, "hang"] as const)(
|
|
"base remains available after safely drained ordinary outcome %s",
|
|
{ timeout: 55_000 },
|
|
async (failure, { signal }) => {
|
|
const report = await runCiGitStep({
|
|
signal,
|
|
action: "ensure-base-commit",
|
|
baseAvailableAfter: 1,
|
|
fetchResults: [failure],
|
|
});
|
|
expect(report.code, report.output).toBe(0);
|
|
expect(report.fetches).toHaveLength(1);
|
|
expect(report.output).toContain("exact fetch failed");
|
|
expect(report.output).toContain("Resolved base commit after exact fetch");
|
|
},
|
|
);
|
|
|
|
linuxIt.for([
|
|
{ label: "inspection failure", result: "cleanup-failure", code: 125 },
|
|
{ label: "cancellation", result: "hang", scenario: "cancel-SIGTERM", code: 143 },
|
|
{
|
|
label: "cancellation during timeout drain",
|
|
result: "hang",
|
|
cancelDuringCleanup: true,
|
|
code: 143,
|
|
},
|
|
] as const)(
|
|
"base policy stops before availability/retry on $label",
|
|
{ timeout: 55_000 },
|
|
async ({ result, code, ...entry }, { signal }) => {
|
|
const report = await runCiGitStep({
|
|
signal,
|
|
action: "ensure-base-commit",
|
|
baseAvailableAfter: 1,
|
|
fetchResults: [result],
|
|
realDrain: true,
|
|
scenario: "scenario" in entry ? entry.scenario : undefined,
|
|
cancelDuringCleanup: "cancelDuringCleanup" in entry,
|
|
});
|
|
expect(report.code, report.output).toBe(code);
|
|
expect(report.fetches).toHaveLength(1);
|
|
expect(report.commands.filter(({ args }) => args[0] === "rev-parse")).toHaveLength(1);
|
|
expect(report.output).not.toContain("Resolved base commit");
|
|
expect(report.cancelledDuringCleanup).toBe("cancelDuringCleanup" in entry);
|
|
},
|
|
);
|
|
|
|
linuxIt(
|
|
"keeps the base action's 30-second fetch deadline and drains before recovery",
|
|
async ({ signal }) => {
|
|
const report = await runCiGitStep({
|
|
signal,
|
|
action: "ensure-base-commit",
|
|
baseAvailableAfter: 1,
|
|
fetchResults: ["hang"],
|
|
realClock: true,
|
|
readyFetchClockAdvanceSeconds: 30,
|
|
});
|
|
expect(report.code, report.output).toBe(0);
|
|
expect(report.output).toContain("exact fetch failed");
|
|
expect(report.readyAttempts).toEqual([1]);
|
|
expect(report.output.match(/fixture fetch timeout: \d+/gu)).toEqual([
|
|
"fixture fetch timeout: 30",
|
|
]);
|
|
expect(report.fetchClockAdvancedSeconds).toBe(30);
|
|
},
|
|
55_000,
|
|
);
|
|
|
|
linuxIt(
|
|
"fences later calls even if a trusted policy accidentally catches an ownership failure",
|
|
async ({ signal }) => {
|
|
const report = await runCiGitStep({
|
|
signal,
|
|
fetchResults: ["cleanup-failure"],
|
|
policy:
|
|
policyImport +
|
|
`try:
|
|
run_git(os.getcwd(), "fetch", "origin", "fixture")
|
|
except Exception:
|
|
try:
|
|
run_git(os.getcwd(), "rev-parse", "HEAD")
|
|
except RuntimeError:
|
|
print("closed owner rejected reuse")
|
|
else:
|
|
raise AssertionError("closed owner spawned Git")
|
|
`,
|
|
});
|
|
expect(report.code, report.output).toBe(125);
|
|
expect(report.commands).toHaveLength(1);
|
|
expect(report.output).toContain("closed owner rejected reuse");
|
|
},
|
|
);
|
|
|
|
linuxIt.for(
|
|
[false, true].flatMap((inlinePolicy) =>
|
|
([125, "cleanup-failure"] as const).map((failure) => ({ inlinePolicy, failure })),
|
|
),
|
|
)(
|
|
"preserves generic output and typed recovery (stdin=$inlinePolicy, outcome=$failure)",
|
|
{ timeout: 55_000 },
|
|
async ({ inlinePolicy, failure }, { signal }) => {
|
|
const output = " \tpath\0another path\r\n\n\n";
|
|
const report = await runCiGitStep({
|
|
signal,
|
|
fetchResults: [failure],
|
|
inlinePolicy,
|
|
revisions: { HEAD: output.slice(0, -1) },
|
|
policy:
|
|
policyImport +
|
|
`import sys
|
|
assert "RUNNER_OS" not in os.environ
|
|
assert "GITHUB_WORKSPACE" not in os.environ
|
|
try:
|
|
run_git(os.getcwd(), "fetch", "origin", "fixture", env={"CI_OWNER_PROBE": "child-only"})
|
|
except GitFailure as error:
|
|
assert error.code == 125
|
|
assert "CI_OWNER_PROBE" not in os.environ
|
|
sys.stdout.write(git_output(os.getcwd(), "rev-parse", "HEAD", env={"CI_OWNER_PROBE": "output-only"}))
|
|
`,
|
|
poisonPython: true,
|
|
});
|
|
if (failure === "cleanup-failure") {
|
|
expect(report.code, report.output).toBe(125);
|
|
expect(report.commands).toHaveLength(1);
|
|
expect(report.output).toContain("Git ownership/setup failed");
|
|
expect(report.output).not.toContain("path");
|
|
} else {
|
|
expect(report.code, report.output).toBe(0);
|
|
expect(report.output).toBe(output);
|
|
expect(report.commands).toHaveLength(2);
|
|
expect(report.commands.map(({ envProbe }) => envProbe)).toEqual([
|
|
"child-only",
|
|
"output-only",
|
|
]);
|
|
}
|
|
},
|
|
);
|
|
|
|
linuxIt.for([0, 23, "cleanup-failure"] as const)(
|
|
"generic Git output drains its writers before consumption (%s)",
|
|
{ timeout: 55_000 },
|
|
async (code, { signal }) => {
|
|
const output = `${head}\trefs/heads/main\n`;
|
|
const report = await runCiGitStep({
|
|
signal,
|
|
policy:
|
|
policyImport +
|
|
'import sys\nsys.stdout.write(git_output(os.getcwd(), "ls-remote", "origin", "refs/heads/main"))\n',
|
|
fetchResults: [],
|
|
lsRemoteResults: [{ code, output }],
|
|
});
|
|
expect(report.code, report.output).toBe(code === "cleanup-failure" ? 125 : code);
|
|
expect(report.commands.map(({ args }) => args)).toEqual([
|
|
["ls-remote", "origin", "refs/heads/main"],
|
|
]);
|
|
expect(report.readyAttempts).toEqual([1]);
|
|
if (code === 0) {
|
|
expect(report.output).toBe(output);
|
|
} else {
|
|
expect(report.output).not.toContain(output);
|
|
}
|
|
},
|
|
);
|
|
|
|
const posixIt = it.skipIf(process.platform === "win32").concurrent;
|
|
const auditFiles = [".pre-commit-config.yaml", ".github/zizmor.yml"];
|
|
const branch = "refs/remotes/origin/main";
|
|
const auditObjects = Object.fromEntries(
|
|
[base, branch].flatMap((ref) =>
|
|
auditFiles.map((file) => [
|
|
`${ref}:${file}`,
|
|
{
|
|
text: `# ${ref}\n${file === auditFiles[0] ? "config: .github/zizmor.yml" : "rules: {}"}\n`,
|
|
},
|
|
]),
|
|
),
|
|
);
|
|
function requireAuditObject(ref: string, file: string) {
|
|
const object = auditObjects[`${ref}:${file}`];
|
|
if (!object) {
|
|
throw new Error(`Missing audit fixture object: ${ref}:${file}`);
|
|
}
|
|
return object;
|
|
}
|
|
const sanity = (
|
|
signal: AbortSignal,
|
|
options: Omit<Parameters<typeof runCiGitStep>[0], "workflow" | "signal">,
|
|
) =>
|
|
runCiGitStep({
|
|
signal,
|
|
...options,
|
|
workflow: "workflow-sanity",
|
|
objects: { ...auditObjects, ...options.objects },
|
|
});
|
|
|
|
type SanityFetchCase = {
|
|
label: string;
|
|
fetchResults: FetchResult[];
|
|
baseAvailableAfter?: number;
|
|
refs: string[];
|
|
warnings: number;
|
|
code: number;
|
|
};
|
|
const sanityFetchCases: SanityFetchCase[] = [
|
|
{
|
|
label: "already present",
|
|
fetchResults: [],
|
|
baseAvailableAfter: 0,
|
|
refs: [],
|
|
warnings: 0,
|
|
code: 0,
|
|
},
|
|
{ label: "exact success", fetchResults: [0], refs: [base], warnings: 0, code: 0 },
|
|
...[125, 143].map((code) => ({
|
|
label: `ordinary ${code}`,
|
|
fetchResults: [code, 0],
|
|
refs: [base, "refs/heads/main"],
|
|
warnings: 0,
|
|
code: 0,
|
|
})),
|
|
...[124, 137].flatMap((code) => [
|
|
{
|
|
label: `ordinary ${code} retry`,
|
|
fetchResults: [code, 0],
|
|
refs: [base, base],
|
|
warnings: 1,
|
|
code: 0,
|
|
},
|
|
{
|
|
label: `ordinary ${code} exhaustion`,
|
|
fetchResults: Array(6).fill(code),
|
|
refs: [...Array(3).fill(base), ...Array(3).fill("refs/heads/main")],
|
|
warnings: 4,
|
|
code,
|
|
},
|
|
]),
|
|
{
|
|
label: "FetchTimeout exhaustion then branch",
|
|
fetchResults: ["hang", "hang", "hang", 0],
|
|
refs: [base, base, base, "refs/heads/main"],
|
|
warnings: 2,
|
|
code: 0,
|
|
},
|
|
{
|
|
label: "FetchTimeout both refs exhausted",
|
|
fetchResults: Array(6).fill("hang"),
|
|
refs: [...Array(3).fill(base), ...Array(3).fill("refs/heads/main")],
|
|
warnings: 4,
|
|
code: 124,
|
|
},
|
|
];
|
|
|
|
posixIt.for(sanityFetchCases)(
|
|
"workflow sanity preserves fetch policy: $label",
|
|
{ timeout: 55_000 },
|
|
async ({ fetchResults, baseAvailableAfter, refs, warnings, code }, { signal }) => {
|
|
const report = await sanity(signal, { fetchResults, baseAvailableAfter });
|
|
expect(report.code, report.output).toBe(code);
|
|
expect(report.fetches.map(({ args }) => args)).toEqual(
|
|
refs.map((ref) => [
|
|
"fetch",
|
|
"--no-tags",
|
|
"--depth=1",
|
|
"origin",
|
|
`+${ref}:${ref === base ? "refs/remotes/origin/security-base" : branch}`,
|
|
]),
|
|
);
|
|
expect(
|
|
report.fetches.every(
|
|
({ configuration, cwd }) => configuration?.length === 0 && cwd === report.workspace,
|
|
),
|
|
).toBe(true);
|
|
expect(report.output.match(/timed out on attempt [12]; retrying/gu) ?? []).toHaveLength(
|
|
warnings,
|
|
);
|
|
expect(
|
|
report.commands.filter(({ args }) => args[0] === "cat-file").map(({ args }) => args),
|
|
).toEqual([
|
|
["cat-file", "-e", `${base}^{commit}`],
|
|
...(code === 0 ? auditFiles.map((file) => ["cat-file", "-e", `${base}:${file}`]) : []),
|
|
]);
|
|
expect(report.githubEnv).toBe(
|
|
code === 0 ? `PRE_COMMIT_CONFIG_PATH=${report.runnerTemp}/pre-commit-base.yaml\n` : "",
|
|
);
|
|
if (code === 0) {
|
|
expect(report.trustedConfig).toBe(
|
|
`# ${base}\nconfig: ${report.runnerTemp}/zizmor-base.yml\n`,
|
|
);
|
|
expect(report.trustedZizmor).toBe(`# ${base}\nrules: {}\n`);
|
|
} else {
|
|
expect(report.trustedConfig).toBe("");
|
|
expect(report.trustedZizmor).toBe("");
|
|
}
|
|
},
|
|
);
|
|
|
|
posixIt.for([
|
|
{ label: "30-second fetch deadline", fetchResults: ["hang", 0], warnings: 1 },
|
|
{ label: "five-second backoff", fetchResults: [137, 0], warnings: 1 },
|
|
] as const)(
|
|
"workflow sanity retains $label",
|
|
{ timeout: 55_000 },
|
|
async ({ fetchResults, warnings }, { signal }) => {
|
|
const readyFetchClockAdvanceSeconds = fetchResults[0] === "hang" ? 30 : undefined;
|
|
const report = await sanity(signal, {
|
|
fetchResults: [...fetchResults],
|
|
realClock: true,
|
|
virtualBackoff: true,
|
|
cooperativeTrees: true,
|
|
readyFetchClockAdvanceSeconds,
|
|
});
|
|
expect(report.code, report.output).toBe(0);
|
|
expect(report.fetches).toHaveLength(2);
|
|
expect(report.output.match(/; retrying/gu) ?? []).toHaveLength(warnings);
|
|
expect(report.fetchClockAdvancedSeconds).toBe(readyFetchClockAdvanceSeconds);
|
|
if (readyFetchClockAdvanceSeconds !== undefined) {
|
|
expect(report.output.match(/fixture fetch timeout: \d+/gu)).toEqual([
|
|
"fixture fetch timeout: 30",
|
|
"fixture fetch timeout: 30",
|
|
]);
|
|
}
|
|
expect(report.output.match(/fixture backoff: \d+/gu)).toEqual(["fixture backoff: 5"]);
|
|
const elapsed =
|
|
(report.backoffClockAdvancedSeconds + (report.fetchClockAdvancedSeconds ?? 0)) * 1000;
|
|
expect(elapsed).toBeGreaterThanOrEqual(fetchResults[0] === "hang" ? 35_000 : 5_000);
|
|
},
|
|
);
|
|
|
|
posixIt.for([
|
|
{ label: "owner inspection failure", fetchResults: ["cleanup-failure"], code: 125 },
|
|
{ label: "fetch cancellation", fetchResults: ["hang"], scenario: "cancel-SIGTERM", code: 143 },
|
|
{
|
|
label: "timeout drain cancellation",
|
|
fetchResults: ["hang"],
|
|
cancelDuringCleanup: true,
|
|
code: 143,
|
|
},
|
|
{
|
|
label: "backoff cancellation",
|
|
fetchResults: [124],
|
|
cancelDuringBackoff: true,
|
|
realClock: true,
|
|
cooperativeTrees: true,
|
|
code: 143,
|
|
},
|
|
{ label: "missing owner", fetchResults: [], setupFailure: "owner", code: 2 },
|
|
{
|
|
label: "missing Python interpreter",
|
|
fetchResults: [],
|
|
setupFailure: "python",
|
|
code: "launcher",
|
|
},
|
|
{ label: "Git spawn failure", fetchResults: [], setupFailure: "git", code: 125 },
|
|
] satisfies (Partial<Parameters<typeof runCiGitStep>[0]> & {
|
|
label: string;
|
|
code: number | "launcher";
|
|
fetchResults: FetchResult[];
|
|
})[])(
|
|
"workflow sanity never recovers or publishes after $label",
|
|
{ timeout: 55_000 },
|
|
async ({ label: _label, code, ...options }, { signal }) => {
|
|
const report = await sanity(signal, options);
|
|
if (code === "launcher") {
|
|
// Bash versions differ for a found executable whose interpreter is missing.
|
|
expect([126, 127], report.output).toContain(report.code);
|
|
} else {
|
|
expect(report.code, report.output).toBe(code);
|
|
}
|
|
expect(report.fetches).toHaveLength(options.fetchResults.length);
|
|
expect(report.commands.filter(({ args }) => args[0] === "show")).toEqual([]);
|
|
expect(report.githubEnv).toBe("");
|
|
expect(report.trustedConfig).toBe("");
|
|
expect(report.trustedZizmor).toBe("");
|
|
expect(report.cancelledDuringCleanup).toBe(Boolean(options.cancelDuringCleanup));
|
|
expect(report.boundaries.some(({ name }) => name === "backoff-cancel")).toBe(
|
|
Boolean(options.cancelDuringBackoff),
|
|
);
|
|
},
|
|
);
|
|
|
|
posixIt.for([[0], [1]].map((missing) => ({ missing })))(
|
|
"workflow sanity selects missing exact configs independently ($missing)",
|
|
{ timeout: 55_000 },
|
|
async ({ missing }, { signal }) => {
|
|
const report = await sanity(signal, {
|
|
fetchResults: [],
|
|
baseAvailableAfter: 0,
|
|
objects: Object.fromEntries(
|
|
missing.map((index) => {
|
|
const file = auditFiles[index];
|
|
if (!file) {
|
|
throw new Error(`Missing audit fixture file at index ${index}`);
|
|
}
|
|
return [
|
|
`${base}:${file}`,
|
|
{ ...requireAuditObject(base, file), probe: index === 0 ? 125 : 143 },
|
|
];
|
|
}),
|
|
),
|
|
});
|
|
expect(report.code, report.output).toBe(0);
|
|
expect(report.fetches).toEqual([]);
|
|
expect(
|
|
report.commands.filter(({ args }) => args[0] === "show").map(({ args }) => args),
|
|
).toEqual(
|
|
auditFiles.map((file, index) => [
|
|
"show",
|
|
`${missing.includes(index) ? branch : base}:${file}`,
|
|
]),
|
|
);
|
|
for (const index of missing) {
|
|
expect(report.output).toContain(
|
|
`Base SHA ${base} does not expose ${auditFiles[index]}; using origin/main instead.`,
|
|
);
|
|
}
|
|
expect(report.githubEnv).toBe(
|
|
`PRE_COMMIT_CONFIG_PATH=${report.runnerTemp}/pre-commit-base.yaml\n`,
|
|
);
|
|
},
|
|
);
|
|
|
|
posixIt.for(
|
|
auditFiles.flatMap((file) => [
|
|
{ file, fallback: false },
|
|
{ file, fallback: true },
|
|
]),
|
|
)(
|
|
"workflow sanity rejects partial $file show (fallback=$fallback)",
|
|
{ timeout: 55_000 },
|
|
async ({ file, fallback }, { signal }) => {
|
|
const report = await sanity(signal, {
|
|
fetchResults: [],
|
|
baseAvailableAfter: 0,
|
|
objects: {
|
|
[`${base}:${file}`]: { text: "partial\n", probe: fallback ? 1 : 0, code: 23 },
|
|
[`${branch}:${file}`]: { text: "partial\n", code: 23 },
|
|
},
|
|
});
|
|
expect(report.code, report.output).toBe(fallback ? 1 : 23);
|
|
expect(report.fetches).toEqual([]);
|
|
expect(report.githubEnv).toBe("");
|
|
expect(file === auditFiles[0] ? report.trustedConfig : report.trustedZizmor).toBe("");
|
|
const shows = report.commands
|
|
.filter(({ args }) => args[0] === "show")
|
|
.map(({ args }) => args.at(-1));
|
|
expect(shows.at(-1)).toBe(`${fallback ? branch : base}:${file}`);
|
|
expect(shows).not.toContain(`${fallback ? base : branch}:${file}`);
|
|
if (fallback) {
|
|
expect(report.output).toContain(`Could not read ${file} from ${base} or origin/main.`);
|
|
}
|
|
},
|
|
);
|
|
|
|
posixIt("workflow sanity rejects a config without the Zizmor reference", async ({ signal }) => {
|
|
const report = await sanity(signal, {
|
|
fetchResults: [],
|
|
baseAvailableAfter: 0,
|
|
objects: { [`${base}:${auditFiles[0]}`]: { text: "repos: []\n" } },
|
|
poisonPython: true,
|
|
});
|
|
expect(report.code, report.output).toBe(1);
|
|
expect(report.output).toContain(
|
|
"trusted pre-commit config does not reference .github/zizmor.yml",
|
|
);
|
|
expect(report.githubEnv).toBe("");
|
|
});
|
|
|
|
const maturityValidation = {
|
|
file: ".github/workflows/maturity-scorecard.yml",
|
|
job: "validate_selected_ref",
|
|
step: "Validate selected ref",
|
|
};
|
|
const maturityEnvironment = {
|
|
EXPECTED_SHA: head,
|
|
INPUT_REF: "main",
|
|
EVIDENCE_RUN_ID: "123",
|
|
PUBLISH_PULL_REQUEST: "true",
|
|
};
|
|
|
|
posixIt(
|
|
"generated publisher drains real Git descendants before every continuation",
|
|
async ({ signal }) => {
|
|
const report = await runCiGitStep({
|
|
signal,
|
|
action: "publish-generated-pr",
|
|
step: "Publish generated pull request",
|
|
fetchResults: [],
|
|
publisher: {},
|
|
});
|
|
expect(report.code, report.output).toBe(0);
|
|
expect(report.pushes).toHaveLength(1);
|
|
expect(report.githubSummary).toContain("Generated pull request:");
|
|
expect(report.commands.at(-1)?.args).toEqual([
|
|
"config",
|
|
"--local",
|
|
"--unset-all",
|
|
"http.https://github.com/.extraheader",
|
|
]);
|
|
},
|
|
55_000,
|
|
);
|
|
|
|
function publisherRun(
|
|
signal: AbortSignal,
|
|
options: Partial<Parameters<typeof runCiGitStep>[0]> = {},
|
|
) {
|
|
return runCiGitStep({
|
|
signal,
|
|
action: "publish-generated-pr",
|
|
step: "Publish generated pull request",
|
|
fetchResults: [],
|
|
publisher: {},
|
|
...options,
|
|
});
|
|
}
|
|
function maturityRun(
|
|
signal: AbortSignal,
|
|
options: Partial<Parameters<typeof runCiGitStep>[0]> = {},
|
|
) {
|
|
return runCiGitStep({
|
|
signal,
|
|
workflow: maturityValidation,
|
|
env: maturityEnvironment,
|
|
fetchResults: [],
|
|
mergeBase: { ancestor: true, revision: head },
|
|
...options,
|
|
});
|
|
}
|
|
|
|
// Actual-body fault injection covers the former conditional-errexit hole and
|
|
// lifecycle/status collisions; the existing real-repository cases own tree semantics.
|
|
posixIt.for(
|
|
["fetch", "ls-remote", "push", "ls-tree"].flatMap((operation) =>
|
|
(["cleanup-failure", "cancel"] as const).map((code) => ({ operation, code })),
|
|
),
|
|
)(
|
|
"generated publisher $code at $operation is terminal before any continuation",
|
|
{ timeout: 55_000 },
|
|
async ({ operation, code }, { signal }) => {
|
|
const report = await publisherRun(signal, { gitFault: { match: `^${operation} `, code } });
|
|
expect(report.code, report.output).toBe(code === "cancel" ? 143 : 125);
|
|
expect(report.commands.at(-1)?.args[0]).toBe(operation);
|
|
expect(report.githubSummary).toBe("");
|
|
expect(report.authHeaderPresent).toBe(true);
|
|
expect(report.pushLog).toBe("");
|
|
expect(report.output).not.toMatch(
|
|
/refusing a doomed retry|moved concurrently|merged|Deferred|Generated pull request:/u,
|
|
);
|
|
},
|
|
);
|
|
|
|
posixIt.for([124, 125, 143])(
|
|
"generated publisher ordinary push %s drains before semantic failure reporting",
|
|
{ timeout: 55_000 },
|
|
async (code, { signal }) => {
|
|
const report = await publisherRun(signal, {
|
|
gitFault: { match: "^push ", code, output: "GH013 repository rule violations\n" },
|
|
});
|
|
expect(report.code, report.output).toBe(code === 124 ? 0 : code);
|
|
expect(report.pushes).toHaveLength(code === 124 ? 2 : 1);
|
|
expect(report.commands.filter(({ args }) => args[0] === "ls-remote")).toHaveLength(
|
|
code === 124 ? 3 : 2,
|
|
);
|
|
if (code === 124) {
|
|
expect(report.output).toContain("retrying once under the same lease");
|
|
expect(report.githubSummary).toContain("Generated pull request:");
|
|
} else {
|
|
expect(report.output).toContain("refusing a doomed retry");
|
|
expect(report.pushLog).toBe("GH013 repository rule violations\n");
|
|
}
|
|
expect(report.authHeaderPresent).toBe(false);
|
|
if (code !== 124) {
|
|
expect(report.githubSummary).toBe("");
|
|
}
|
|
},
|
|
);
|
|
|
|
posixIt.for(["fetch", "ls-remote", "push"])(
|
|
"generated publisher %s timeout has bounded recovery",
|
|
{ timeout: 55_000 },
|
|
async (operation, { signal }) => {
|
|
const report = await publisherRun(signal, {
|
|
gitFault: { match: `^${operation} `, code: "hang" },
|
|
});
|
|
expect(report.code, report.output).toBe(operation === "push" ? 0 : 124);
|
|
expect(report.fetches).toHaveLength(1);
|
|
expect(report.pushes).toHaveLength(operation === "push" ? 2 : 0);
|
|
expect(report.githubSummary).toBe(
|
|
operation === "push"
|
|
? "Generated pull request: https://github.com/openclaw/openclaw/pull/1\n"
|
|
: "",
|
|
);
|
|
expect(report.authHeaderPresent).toBe(false);
|
|
},
|
|
);
|
|
|
|
posixIt.for([
|
|
{ label: "overlap candidate diff", match: "^diff --name-only", occurrence: 2 },
|
|
{ label: "overlap tree read", match: "^ls-tree ", occurrence: 1 },
|
|
{ label: "invalidation diff", match: "^diff --quiet ", occurrence: 1 },
|
|
{ label: "ancestor probe", match: "^merge-base ", occurrence: 1 },
|
|
{ label: "merged-tree read", match: "^ls-tree ", occurrence: 5, merged: true },
|
|
{ label: "neutralization fetch", match: "^fetch ", occurrence: 1, noChange: true },
|
|
{
|
|
label: "neutralization tree read",
|
|
match: "^ls-tree ",
|
|
occurrence: 1,
|
|
noChange: true,
|
|
overlap: true,
|
|
},
|
|
])(
|
|
"generated publisher ordinary failure inside $label never becomes success",
|
|
{ timeout: 55_000 },
|
|
async ({ match, occurrence, merged, noChange, overlap }, { signal }) => {
|
|
const report = await publisherRun(signal, {
|
|
publisher: {
|
|
mergeGeneratedPush: merged,
|
|
noGeneratedChange: noChange,
|
|
baseChangePath: overlap ? "a" : null,
|
|
},
|
|
gitFault: { match, occurrence, code: 23 },
|
|
});
|
|
expect(report.code, report.output).toBe(23);
|
|
expect(report.githubSummary).toBe("");
|
|
expect(report.authHeaderPresent).toBe(false);
|
|
expect(report.output).not.toMatch(
|
|
/Generated output was merged|Deferred stale|Neutralized stale/u,
|
|
);
|
|
},
|
|
);
|
|
|
|
posixIt.for([0, 2, 23, 125, 143, "hang", "cleanup-failure", "cancel"] as const)(
|
|
"maturity branch lookup %s preserves 0/2/ordinary/fatal policy after drain",
|
|
{ timeout: 55_000 },
|
|
async (code, { signal }) => {
|
|
const report = await maturityRun(signal, {
|
|
env: { ...maturityEnvironment, INPUT_REF: "release/2026.8.1" },
|
|
gitFault: { match: "^ls-remote ", code },
|
|
});
|
|
const success = code === 0 || code === 2;
|
|
expect(report.code, report.output).toBe(
|
|
success
|
|
? 0
|
|
: code === "hang"
|
|
? 124
|
|
: code === "cancel"
|
|
? 143
|
|
: code === "cleanup-failure"
|
|
? 125
|
|
: code,
|
|
);
|
|
expect(report.fetches).toHaveLength(success ? 2 : 1);
|
|
if (success) {
|
|
expect(report.githubOutput).toContain(
|
|
`publication_base=${code === 0 ? "release/2026.8.1" : "main"}\n`,
|
|
);
|
|
} else {
|
|
expect(report.githubOutput).toBe("");
|
|
expect(report.githubSummary).toBe("");
|
|
expect(report.commands.at(-1)?.args[0]).toBe("ls-remote");
|
|
if (typeof code === "number" || code === "hang") {
|
|
expect(report.output).toContain(`(status ${code === "hang" ? 124 : code})`);
|
|
} else {
|
|
expect(report.output).not.toContain("Unable to determine");
|
|
}
|
|
}
|
|
},
|
|
);
|
|
|
|
posixIt(
|
|
"generated publisher retries one timed-out push under the unchanged lease",
|
|
async ({ signal }) => {
|
|
const report = await publisherRun(signal, {
|
|
gitFault: { match: "^push ", occurrence: 1, code: "hang" },
|
|
});
|
|
expect(report.code, report.output).toBe(0);
|
|
expect(report.pushes).toHaveLength(2);
|
|
expect(report.pushes[0]?.args).toEqual(report.pushes[1]?.args);
|
|
expect(report.publication?.generatedA).toBe("desired-a");
|
|
expect(report.githubSummary).toContain("Generated pull request:");
|
|
expect(report.output).toContain("retrying once under the same lease");
|
|
},
|
|
55_000,
|
|
);
|
|
|
|
posixIt.for(
|
|
[
|
|
{ match: "^fetch ", occurrence: 1 },
|
|
{ match: "^fetch ", occurrence: 2 },
|
|
{ match: "^rev-parse refs/remotes", occurrence: 1 },
|
|
{ match: "^rev-parse refs/remotes", occurrence: 2 },
|
|
{ match: "^diff ", occurrence: 1 },
|
|
].flatMap((site) =>
|
|
(["cleanup-failure", "cancel"] as const).map((code) => Object.assign({}, site, { code })),
|
|
),
|
|
)(
|
|
"maturity $code at $match/$occurrence stops before fallback/output",
|
|
{ timeout: 55_000 },
|
|
async ({ match, occurrence, code }, { signal }) => {
|
|
const report = await maturityRun(signal, {
|
|
env: { ...maturityEnvironment, EXPECTED_SHA: "" },
|
|
gitFault: { match, occurrence, code },
|
|
});
|
|
expect(report.code, report.output).toBe(code === "cancel" ? 143 : 125);
|
|
expect(report.commands.at(-1)?.args.join(" ")).toMatch(new RegExp(match));
|
|
expect(report.githubOutput).toBe("");
|
|
expect(report.githubSummary).toBe("");
|
|
},
|
|
);
|
|
|
|
posixIt.for([
|
|
{ race: "delete", secondFailure: false, code: 0, pushes: 2, fetches: 2 },
|
|
{ race: "advance", secondFailure: false, code: 1, pushes: 1, fetches: 1 },
|
|
{ race: "recreate", secondFailure: false, code: 1, pushes: 2, fetches: 2 },
|
|
{ race: "delete", secondFailure: true, code: 1, pushes: 2, fetches: 2 },
|
|
] as const)(
|
|
"generated publisher exact deletion-race lease policy ($race, second failure=$secondFailure)",
|
|
{ timeout: 55_000 },
|
|
async ({ race, secondFailure, code, pushes, fetches }, { signal }) => {
|
|
const report = await publisherRun(signal, {
|
|
publisher: { existingPr: true, race, failGeneratedPush: secondFailure },
|
|
});
|
|
expect(report.code, report.output).toBe(code);
|
|
expect(report.initialBranch).toMatch(/^[0-9a-f]{40}$/u);
|
|
expect(report.pushes.map(({ args }) => args)).toEqual([
|
|
[
|
|
"push",
|
|
`--force-with-lease=refs/heads/automation/locale:${report.initialBranch}`,
|
|
"origin",
|
|
"HEAD:refs/heads/automation/locale",
|
|
],
|
|
...(pushes === 2
|
|
? [
|
|
[
|
|
"push",
|
|
"--force-with-lease=refs/heads/automation/locale:",
|
|
"origin",
|
|
"HEAD:refs/heads/automation/locale",
|
|
],
|
|
]
|
|
: []),
|
|
]);
|
|
expect(report.fetches).toHaveLength(fetches);
|
|
expect(report.authHeaderPresent).toBe(false);
|
|
expect(report.output).toContain("stale info");
|
|
if (code === 0) {
|
|
expect(report.publication?.generatedA).toBe("desired-a");
|
|
expect(report.githubSummary).toContain("Generated pull request:");
|
|
} else {
|
|
expect(report.githubSummary).toBe("");
|
|
expect(
|
|
report.commands.filter(
|
|
({ tool, args }) => tool === "gh" && ["create", "edit", "merge"].includes(args[1] ?? ""),
|
|
),
|
|
).toEqual([]);
|
|
}
|
|
},
|
|
);
|
|
|
|
posixIt.for(
|
|
[
|
|
{ match: "^fetch ", occurrence: 2 },
|
|
{ match: "^ls-tree ", occurrence: 5 },
|
|
].flatMap((site) =>
|
|
([23, "cleanup-failure", "cancel"] as const).map((code) => Object.assign({}, site, { code })),
|
|
),
|
|
)(
|
|
"generated publisher verify_publication $code at $match is terminal",
|
|
{ timeout: 55_000 },
|
|
async ({ match, occurrence, code }, { signal }) => {
|
|
const report = await publisherRun(signal, {
|
|
publisher: { reconciliation: "missing" },
|
|
gitFault: { match, occurrence, code },
|
|
});
|
|
expect(report.code, report.output).toBe(
|
|
code === "cancel" ? 143 : code === "cleanup-failure" ? 125 : code,
|
|
);
|
|
expect(report.githubSummary).toBe("");
|
|
expect(report.authHeaderPresent).toBe(code !== 23);
|
|
expect(report.commands.at(code === 23 ? -2 : -1)?.args.join(" ")).toMatch(new RegExp(match));
|
|
expect(report.output).not.toContain("Generated output was merged");
|
|
},
|
|
);
|
|
|
|
posixIt.for([0, 5, 125, "cleanup-failure", "cancel"] as const)(
|
|
"generated publisher auth cleanup keeps ordinary tolerance but fences fatal %s",
|
|
{ timeout: 55_000 },
|
|
async (code, { signal }) => {
|
|
const report = await publisherRun(signal, {
|
|
gitFault: { match: "^config --local --unset-all ", code },
|
|
});
|
|
expect(report.code, report.output).toBe(
|
|
code === "cleanup-failure" ? 125 : code === "cancel" ? 143 : 0,
|
|
);
|
|
expect(report.commands.at(-1)?.args).toEqual([
|
|
"config",
|
|
"--local",
|
|
"--unset-all",
|
|
"http.https://github.com/.extraheader",
|
|
]);
|
|
for (const text of [report.output, report.pushLog, JSON.stringify(report.commands)]) {
|
|
expect(text).not.toContain("contents-token");
|
|
expect(text).not.toContain(Buffer.from("x-access-token:contents-token").toString("base64"));
|
|
expect(text).not.toContain("test-token");
|
|
}
|
|
},
|
|
);
|
|
|
|
posixIt(
|
|
"generated publisher removes Git auth after an unexpected policy exception",
|
|
async ({ signal }) => {
|
|
const report = await publisherRun(signal, {
|
|
publisher: { autoMerge: true, malformedAutoMergeRecord: true },
|
|
});
|
|
expect(report.code, report.output).toBe(125);
|
|
expect(report.authHeaderPresent).toBe(false);
|
|
expect(report.commands.at(-1)?.args).toEqual([
|
|
"config",
|
|
"--local",
|
|
"--unset-all",
|
|
"http.https://github.com/.extraheader",
|
|
]);
|
|
expect(report.output).toContain("Git ownership/setup failed (IndexError)");
|
|
},
|
|
55_000,
|
|
);
|
|
|
|
posixIt.for(["main-ancestor", "release-tag", "release-branch-head", "floating-main"])(
|
|
"maturity preserves exact trust order, output hash bytes and fetches: %s",
|
|
{ timeout: 55_000 },
|
|
async (reason, { signal }) => {
|
|
const release = "release/2026.8.1";
|
|
const floating = reason === "floating-main";
|
|
const tag = reason === "release-tag";
|
|
const releaseBranch = reason === "release-branch-head";
|
|
const revision = floating ? "d".repeat(40) : head;
|
|
const publicationBase = releaseBranch ? release : "main";
|
|
const report = await maturityRun(signal, {
|
|
realClock: true,
|
|
realDrain: false,
|
|
env: {
|
|
...maturityEnvironment,
|
|
EXPECTED_SHA: floating ? "" : head,
|
|
PUBLISH_PULL_REQUEST: tag ? "false" : "true",
|
|
INPUT_REF: tag ? "refs/tags/v2026.8.1" : releaseBranch ? release : "main",
|
|
},
|
|
revisions: { "refs/heads/main": revision, [`refs/heads/${release}`]: head },
|
|
commandResults: {
|
|
...(tag || releaseBranch
|
|
? { [`merge-base --is-ancestor ${head} refs/remotes/origin/main`]: { code: 1 } }
|
|
: {}),
|
|
...(tag ? { [`tag --points-at ${head}`]: { code: 0, output: "v2026.8.1\n" } } : {}),
|
|
},
|
|
});
|
|
expect(report.code, report.output).toBe(0);
|
|
const { createHash } = await import("node:crypto");
|
|
const digest = createHash("sha256")
|
|
.update(`123\n${publicationBase}\n${revision}\n`)
|
|
.digest("hex")
|
|
.slice(0, 16);
|
|
expect(report.githubOutput).toBe(
|
|
`publication_base=${publicationBase}\npublication_head=${tag ? "" : `automation/maturity-scorecard-123-${digest}`}\nselected_revision=${revision}\ntrusted_reason=${floating ? "main-ancestor" : reason}\n`,
|
|
);
|
|
expect(report.fetches.map(({ args }) => args)).toEqual([
|
|
["fetch", "--no-tags", "origin", "+refs/heads/main:refs/remotes/origin/main"],
|
|
...(releaseBranch
|
|
? [
|
|
[
|
|
"fetch",
|
|
"--no-tags",
|
|
"origin",
|
|
`+refs/heads/${release}:refs/remotes/origin/${release}`,
|
|
],
|
|
]
|
|
: []),
|
|
...(!tag
|
|
? [
|
|
[
|
|
"fetch",
|
|
"--no-tags",
|
|
"origin",
|
|
`+refs/heads/${publicationBase}:refs/remotes/origin/${publicationBase}`,
|
|
],
|
|
]
|
|
: []),
|
|
]);
|
|
expect(report.commands.some(({ args }) => args[0] === "tag")).toBe(tag || releaseBranch);
|
|
expect(report.commands.at(-1)?.args).toEqual(
|
|
tag
|
|
? ["tag", "--points-at", head]
|
|
: [
|
|
"diff",
|
|
"--quiet",
|
|
revision,
|
|
`refs/remotes/origin/${publicationBase}`,
|
|
"--",
|
|
".",
|
|
":(exclude)qa/maturity-scores.yaml",
|
|
":(exclude)docs/maturity/scorecard.md",
|
|
":(exclude)docs/maturity/taxonomy.md",
|
|
],
|
|
);
|
|
},
|
|
);
|
|
|
|
posixIt.for(
|
|
["publisher", "maturity"].flatMap((surface) =>
|
|
(["owner", "python", "git"] as const).map((setupFailure) => ({ surface, setupFailure })),
|
|
),
|
|
)(
|
|
"$surface setup failure ($setupFailure) never reaches Git, GH, or outputs",
|
|
{ timeout: 55_000 },
|
|
async ({ surface, setupFailure }, { signal }) => {
|
|
const report = await (surface === "publisher" ? publisherRun : maturityRun)(signal, {
|
|
setupFailure,
|
|
});
|
|
expect(report.code).not.toBe(0);
|
|
expect(report.commands).toEqual([]);
|
|
expect(report.githubOutput).toBe("");
|
|
expect(report.githubSummary).toBe("");
|
|
},
|
|
);
|
|
|
|
posixIt(
|
|
"generated publisher reconciliation accepts a tree merged after PR mutation",
|
|
async ({ signal }) => {
|
|
const report = await publisherRun(signal, { publisher: { reconciliation: "merged" } });
|
|
expect(report.code, report.output).toBe(0);
|
|
expect(report.fetches).toHaveLength(2);
|
|
expect(report.pushes).toHaveLength(1);
|
|
expect(report.githubSummary).toBe(
|
|
"Generated output was merged while publication was being reconciled.\n",
|
|
);
|
|
expect(report.authHeaderPresent).toBe(false);
|
|
},
|
|
55_000,
|
|
);
|
|
|
|
posixIt.for([125, 143])(
|
|
"generated publisher ordinary stale-lease %s permits the exact deletion rebuild",
|
|
{ timeout: 55_000 },
|
|
async (code, { signal }) => {
|
|
const report = await publisherRun(signal, {
|
|
publisher: { existingPr: true, race: "delete" },
|
|
gitFault: { match: "^push ", code, output: "stale info\n" },
|
|
});
|
|
expect(report.code, report.output).toBe(0);
|
|
expect(report.fetches).toHaveLength(2);
|
|
expect(report.pushes.map(({ args }) => args[1])).toEqual([
|
|
`--force-with-lease=refs/heads/automation/locale:${report.initialBranch}`,
|
|
"--force-with-lease=refs/heads/automation/locale:",
|
|
]);
|
|
expect(report.publication?.generatedA).toBe("desired-a");
|
|
expect(report.authHeaderPresent).toBe(false);
|
|
},
|
|
);
|
|
|
|
posixIt.for([
|
|
{
|
|
label: "invalid expected SHA",
|
|
env: { EXPECTED_SHA: "bad" },
|
|
fetches: 0,
|
|
diagnostic: "expected_sha must be a full",
|
|
},
|
|
{
|
|
label: "mismatched expected SHA",
|
|
env: { EXPECTED_SHA: "f".repeat(40) },
|
|
fetches: 0,
|
|
diagnostic: "expected fffff",
|
|
},
|
|
{
|
|
label: "invalid evidence id",
|
|
env: { EVIDENCE_RUN_ID: "1x" },
|
|
fetches: 1,
|
|
diagnostic: "must be a numeric",
|
|
},
|
|
{
|
|
label: "publication ancestry",
|
|
fault: { match: "^merge-base ", occurrence: 2, code: 1 },
|
|
fetches: 2,
|
|
diagnostic: "not an ancestor of pull request base",
|
|
},
|
|
{
|
|
label: "changed publication inputs",
|
|
fault: { match: "^diff ", code: 1 },
|
|
fetches: 2,
|
|
diagnostic: "changed maturity inputs",
|
|
},
|
|
{
|
|
label: "failed publication diff",
|
|
fault: { match: "^diff ", code: 23 },
|
|
fetches: 2,
|
|
diagnostic: "",
|
|
code: 23,
|
|
},
|
|
])(
|
|
"maturity rejects $label without outputs",
|
|
{ timeout: 55_000 },
|
|
async ({ env, fault, fetches, diagnostic, code }, { signal }) => {
|
|
const report = await maturityRun(signal, {
|
|
env: { ...maturityEnvironment, ...env },
|
|
gitFault: fault,
|
|
});
|
|
expect(report.code, report.output).toBe(code ?? 1);
|
|
expect(report.fetches).toHaveLength(fetches);
|
|
expect(report.githubOutput).toBe("");
|
|
expect(report.githubSummary).toBe("");
|
|
if (diagnostic) {
|
|
expect(report.output).toContain(diagnostic);
|
|
}
|
|
},
|
|
);
|