mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 12:26:38 -06:00
fix(release): keep frozen validation independent of main (#126622)
* fix(release): freeze validation tooling identity * fix(release): enforce frozen validation contract * fix(release): validate candidate identity in parent * fix(ci): close release isolation gate findings
This commit is contained in:
@@ -8515,7 +8515,7 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}"
|
||||
(step: WorkflowStep) => step.name === "Dispatch and await trusted Telegram QA",
|
||||
);
|
||||
const identityStep = telegramWorkflow.jobs.trusted_identity.steps.find(
|
||||
(step: WorkflowStep) => step.name === "Verify dispatched-main identity",
|
||||
(step: WorkflowStep) => step.name === "Verify dispatched workflow identity",
|
||||
);
|
||||
const provenanceSteps = [
|
||||
telegramWorkflow.jobs.build_candidate.steps.find(
|
||||
|
||||
@@ -13,9 +13,26 @@ import {
|
||||
releaseEvidenceVerifierPath,
|
||||
resolveRemoteTargetRefSha,
|
||||
shouldDeleteTemporaryWorkflowRef,
|
||||
verifyTargetRef,
|
||||
} from "../../scripts/full-release-validation-at-sha.mts";
|
||||
|
||||
const SCRIPT_PATH = resolve("scripts/full-release-validation-at-sha.mjs");
|
||||
const CURRENT_WORKFLOW_SOURCE = `name: Full Release Validation
|
||||
env:
|
||||
RELEASE_ISOLATION_TOOLING_CONTRACT: "1"
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
expected_sha:
|
||||
required: false
|
||||
`;
|
||||
const LEGACY_WORKFLOW_SOURCE = `name: Full Release Validation
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
expected_sha:
|
||||
required: false
|
||||
`;
|
||||
|
||||
function runGit(cwd: string, args: string[]): string {
|
||||
return execFileSync("git", args, {
|
||||
@@ -44,17 +61,10 @@ function createDispatchFixture(options: { workflowSource?: string } = {}) {
|
||||
runGit(checkout, ["config", "user.name", "OpenClaw Release Test"]);
|
||||
mkdirSync(join(checkout, ".github", "workflows"), { recursive: true });
|
||||
mkdirSync(join(checkout, "scripts"), { recursive: true });
|
||||
writeFileSync(join(checkout, "package.json"), '{"version":"2026.8.1"}\n');
|
||||
writeFileSync(join(checkout, "package.json"), '{"version":"2026.7.9"}\n');
|
||||
writeFileSync(
|
||||
join(checkout, ".github", "workflows", "full-release-validation.yml"),
|
||||
options.workflowSource ??
|
||||
`name: Full Release Validation
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
expected_sha:
|
||||
required: false
|
||||
`,
|
||||
LEGACY_WORKFLOW_SOURCE,
|
||||
);
|
||||
writeFileSync(
|
||||
join(checkout, "scripts", "release-ci-summary.mjs"),
|
||||
@@ -73,7 +83,15 @@ console.log(JSON.stringify({ valid: true, current: { runId: "123" }, root: { run
|
||||
`,
|
||||
);
|
||||
runGit(checkout, ["add", "."]);
|
||||
runGit(checkout, ["commit", "-m", "test: trusted workflow"]);
|
||||
runGit(checkout, ["commit", "-m", "test: legacy workflow"]);
|
||||
const oldWorkflowSha = runGit(checkout, ["rev-parse", "HEAD"]);
|
||||
writeFileSync(
|
||||
join(checkout, ".github", "workflows", "full-release-validation.yml"),
|
||||
options.workflowSource ?? CURRENT_WORKFLOW_SOURCE,
|
||||
);
|
||||
writeFileSync(join(checkout, "package.json"), '{"version":"2026.8.1"}\n');
|
||||
runGit(checkout, ["add", ".github/workflows/full-release-validation.yml", "package.json"]);
|
||||
runGit(checkout, ["commit", "-m", "test: trusted workflow contract"]);
|
||||
const workflowSha = runGit(checkout, ["rev-parse", "HEAD"]);
|
||||
runGit(checkout, ["remote", "add", "origin", origin]);
|
||||
runGit(checkout, ["push", "-u", "origin", "main"]);
|
||||
@@ -151,6 +169,7 @@ if (args[0] === "workflow" && args[1] === "run") {
|
||||
ghCallsPath,
|
||||
gitCallsPath,
|
||||
origin,
|
||||
oldWorkflowSha,
|
||||
readCalls,
|
||||
releaseRef,
|
||||
run,
|
||||
@@ -166,7 +185,7 @@ describe("full-release-validation-at-sha", () => {
|
||||
"--sha",
|
||||
"abc123",
|
||||
"--workflow-sha",
|
||||
"origin/main",
|
||||
"a".repeat(40),
|
||||
"--target-ref",
|
||||
"release/2026.7.1",
|
||||
"--keep-branch",
|
||||
@@ -187,7 +206,7 @@ describe("full-release-validation-at-sha", () => {
|
||||
},
|
||||
sha: "abc123",
|
||||
targetRef: "release/2026.7.1",
|
||||
workflowSha: "origin/main",
|
||||
workflowSha: "a".repeat(40),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -224,14 +243,21 @@ describe("full-release-validation-at-sha", () => {
|
||||
});
|
||||
|
||||
it("accepts only canonical release branch or tag context", () => {
|
||||
expect(parseArgs(["--target-ref", "extended-stable/2026.6.33"]).targetRef).toBe(
|
||||
"extended-stable/2026.6.33",
|
||||
);
|
||||
expect(
|
||||
parseArgs(["--target-ref", "extended-stable/2026.6.33", "--workflow-sha", "a".repeat(40)])
|
||||
.targetRef,
|
||||
).toBe("extended-stable/2026.6.33");
|
||||
expect(parseArgs(["--target-ref", "v2026.7.1-beta.5"]).targetRef).toBe("v2026.7.1-beta.5");
|
||||
expect(parseArgs(["--target-ref", "v2026.7.1"]).targetRef).toBe("v2026.7.1");
|
||||
expect(() => parseArgs(["--target-ref", "feature/not-release"])).toThrow(
|
||||
"canonical OpenClaw release branch or tag",
|
||||
);
|
||||
expect(() => parseArgs(["--target-ref", "release/2026.7.1"])).toThrow(
|
||||
"requires --workflow-sha with an explicit full Tooling SHA",
|
||||
);
|
||||
expect(() =>
|
||||
parseArgs(["--target-ref", "release/2026.7.1", "--workflow-sha", "origin/main"]),
|
||||
).toThrow("explicit full Tooling SHA");
|
||||
});
|
||||
|
||||
it("resolves annotated release tags through their peeled commit", () => {
|
||||
@@ -259,6 +285,92 @@ describe("full-release-validation-at-sha", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("binds frozen release candidates to the branch or tag package version", () => {
|
||||
const candidateSha = "a".repeat(40);
|
||||
const branchTipSha = "b".repeat(40);
|
||||
expect(
|
||||
verifyTargetRef(
|
||||
"release/2026.7.1",
|
||||
candidateSha,
|
||||
"2026.7.1-beta.5",
|
||||
() => branchTipSha,
|
||||
(ancestor, descendant) => ancestor === candidateSha && descendant === branchTipSha,
|
||||
),
|
||||
).toBe("release/2026.7.1");
|
||||
expect(() =>
|
||||
verifyTargetRef(
|
||||
"release/2026.7.1",
|
||||
candidateSha,
|
||||
"2026.7.1-alpha.5",
|
||||
() => branchTipSha,
|
||||
() => true,
|
||||
),
|
||||
).toThrow("expected 2026.7.1 or a beta prerelease of it");
|
||||
expect(() =>
|
||||
verifyTargetRef(
|
||||
"release/2026.7.1",
|
||||
candidateSha,
|
||||
"2026.7.1",
|
||||
() => branchTipSha,
|
||||
() => false,
|
||||
),
|
||||
).toThrow("is not reachable from release branch");
|
||||
expect(() =>
|
||||
verifyTargetRef(
|
||||
"release/2026.7.1",
|
||||
candidateSha,
|
||||
"2026.6.9",
|
||||
() => branchTipSha,
|
||||
() => true,
|
||||
),
|
||||
).toThrow("does not belong to release branch");
|
||||
expect(
|
||||
verifyTargetRef(
|
||||
"extended-stable/2026.6.33",
|
||||
candidateSha,
|
||||
"2026.6.33",
|
||||
() => branchTipSha,
|
||||
() => true,
|
||||
),
|
||||
).toBe("extended-stable/2026.6.33");
|
||||
expect(() =>
|
||||
verifyTargetRef(
|
||||
"extended-stable/2026.6.33",
|
||||
candidateSha,
|
||||
"2026.6.33-beta.1",
|
||||
() => branchTipSha,
|
||||
() => true,
|
||||
),
|
||||
).toThrow("does not match extended-stable branch");
|
||||
expect(
|
||||
verifyTargetRef(
|
||||
"v2026.7.1-beta.5",
|
||||
candidateSha,
|
||||
"2026.7.1-beta.5",
|
||||
() => candidateSha,
|
||||
() => false,
|
||||
),
|
||||
).toBe("v2026.7.1-beta.5");
|
||||
expect(() =>
|
||||
verifyTargetRef(
|
||||
"v2026.7.1-beta.5",
|
||||
candidateSha,
|
||||
"2026.7.1-beta.5",
|
||||
() => branchTipSha,
|
||||
() => true,
|
||||
),
|
||||
).toThrow("does not resolve");
|
||||
expect(() =>
|
||||
verifyTargetRef(
|
||||
"v2026.7.1-beta.5",
|
||||
candidateSha,
|
||||
"2026.7.1-beta.4",
|
||||
() => candidateSha,
|
||||
() => true,
|
||||
),
|
||||
).toThrow("does not match release tag");
|
||||
});
|
||||
|
||||
it("allows exact-target reuse to be disabled for a forced fresh run", () => {
|
||||
expect(parseArgs(["-f", "reuse_evidence=false"]).inputs.reuse_evidence).toBe("false");
|
||||
expect(() => parseArgs(["-f", "reuse_evidence=maybe"])).toThrow(
|
||||
@@ -308,11 +420,13 @@ describe("full-release-validation-at-sha", () => {
|
||||
const source = readFileSync("scripts/full-release-validation-at-sha.mts", "utf8");
|
||||
expect(FULL_RELEASE_WAIT_TIMEOUT_MINUTES).toBe(720);
|
||||
expect(FULL_RELEASE_WAIT_POLL_INTERVAL_MS).toBe(45_000);
|
||||
expect(source).toContain("const FULL_RELEASE_PROGRESS_INTERVAL_MS = 5 * 60_000;");
|
||||
expect(source).toContain("workflowRun.head_sha !== workflowSha");
|
||||
expect(source).toContain("return suite;");
|
||||
expect(source).toContain("Date.now() + FULL_RELEASE_WAIT_TIMEOUT_MINUTES * 60_000");
|
||||
expect(source).toContain("startedAt + FULL_RELEASE_WAIT_TIMEOUT_MINUTES * 60_000");
|
||||
expect(source).toContain("const remainingMs = deadline - Date.now();");
|
||||
expect(source).toContain("Math.min(FULL_RELEASE_WAIT_POLL_INTERVAL_MS, remainingMs)");
|
||||
expect(source).toContain("Parent run progress after ${elapsedMinutes}m");
|
||||
expect(source).toContain(
|
||||
"Timed out after ${FULL_RELEASE_WAIT_TIMEOUT_MINUTES} minutes waiting for Full Release Validation",
|
||||
);
|
||||
@@ -322,7 +436,7 @@ describe("full-release-validation-at-sha", () => {
|
||||
it("bounds GitHub reads without applying a timeout to workflow dispatch", () => {
|
||||
const source = readFileSync("scripts/full-release-validation-at-sha.mts", "utf8");
|
||||
expect(source).toContain("timeout: GH_READ_TIMEOUT_MS");
|
||||
expect(source.match(/GH_READ_OPTIONS/gu)).toHaveLength(3);
|
||||
expect(source.match(/GH_READ_OPTIONS/gu)).toHaveLength(4);
|
||||
expect(source).toContain('const dispatchOutput = run("gh", dispatchArgs');
|
||||
});
|
||||
|
||||
@@ -337,7 +451,7 @@ describe("full-release-validation-at-sha", () => {
|
||||
checked.push(relativePath);
|
||||
return relativePath === workflowPath || relativePath === verifierPath;
|
||||
},
|
||||
() => "on:\n workflow_dispatch:\n inputs:\n expected_sha: {}\n",
|
||||
() => CURRENT_WORKFLOW_SOURCE,
|
||||
),
|
||||
).toBe(verifierPath);
|
||||
expect(checked).toEqual([workflowPath, verifierPath]);
|
||||
@@ -346,14 +460,22 @@ describe("full-release-validation-at-sha", () => {
|
||||
assertTrustedWorkflowHarness(
|
||||
"a".repeat(40),
|
||||
(relativePath) => relativePath === workflowPath,
|
||||
() => "on:\n workflow_dispatch:\n inputs:\n expected_sha: {}\n",
|
||||
() => CURRENT_WORKFLOW_SOURCE,
|
||||
),
|
||||
).toThrow("supported release evidence verifier");
|
||||
expect(() =>
|
||||
assertTrustedWorkflowHarness(
|
||||
"b".repeat(40),
|
||||
() => true,
|
||||
() => "on:\n workflow_dispatch:\n inputs: {}\n",
|
||||
() => LEGACY_WORKFLOW_SOURCE,
|
||||
),
|
||||
).toThrow("does not declare RELEASE_ISOLATION_TOOLING_CONTRACT=1");
|
||||
expect(() =>
|
||||
assertTrustedWorkflowHarness(
|
||||
"b".repeat(40),
|
||||
() => true,
|
||||
() =>
|
||||
'env:\n RELEASE_ISOLATION_TOOLING_CONTRACT: "1"\non:\n workflow_dispatch:\n inputs: {}\n',
|
||||
),
|
||||
).toThrow(`Tooling SHA ${"b".repeat(40)} is missing workflow_dispatch input expected_sha`);
|
||||
});
|
||||
@@ -396,7 +518,7 @@ describe("full-release-validation-at-sha", () => {
|
||||
it("pushes an exact target ref, dispatches it, prints the run URL, and cleans both refs", () => {
|
||||
const fixture = createDispatchFixture();
|
||||
try {
|
||||
const result = fixture.run();
|
||||
const result = fixture.run(["--workflow-sha", fixture.workflowSha]);
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
const gitCalls = fixture.readCalls(fixture.gitCallsPath);
|
||||
const ghCalls = fixture.readCalls(fixture.ghCallsPath);
|
||||
@@ -451,6 +573,9 @@ describe("full-release-validation-at-sha", () => {
|
||||
expect(ghCalls.some((args) => args[0] === "run" && args[1] === "watch")).toBe(false);
|
||||
expect(result.stdout).toContain(`Validation SHA: ${fixture.targetSha}`);
|
||||
expect(result.stdout).toContain(`Tooling SHA: ${fixture.workflowSha}`);
|
||||
expect(result.stdout).toContain(
|
||||
`Frozen validation tuple: candidate=${fixture.targetSha} tooling=${fixture.workflowSha} rerun_group=all`,
|
||||
);
|
||||
expect(result.stdout).toContain(
|
||||
"Parent run: https://github.com/openclaw/openclaw/actions/runs/123",
|
||||
);
|
||||
@@ -473,7 +598,8 @@ describe("full-release-validation-at-sha", () => {
|
||||
|
||||
it("rejects pinned old-schema tooling before either remote ref is pushed", () => {
|
||||
const fixture = createDispatchFixture({
|
||||
workflowSource: "name: Full Release Validation\non:\n workflow_dispatch:\n",
|
||||
workflowSource:
|
||||
'name: Full Release Validation\nenv:\n RELEASE_ISOLATION_TOOLING_CONTRACT: "1"\non:\n workflow_dispatch:\n',
|
||||
});
|
||||
try {
|
||||
const result = fixture.run(["--workflow-sha", fixture.workflowSha]);
|
||||
@@ -489,10 +615,48 @@ describe("full-release-validation-at-sha", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects pinned pre-contract tooling before either remote ref is pushed", () => {
|
||||
const fixture = createDispatchFixture();
|
||||
try {
|
||||
const result = fixture.run(["--workflow-sha", fixture.oldWorkflowSha]);
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain(`Tooling SHA ${fixture.oldWorkflowSha}`);
|
||||
expect(result.stderr).toContain("does not declare RELEASE_ISOLATION_TOOLING_CONTRACT=1");
|
||||
expect(fixture.readCalls(fixture.gitCallsPath).filter((args) => args[0] === "push")).toEqual(
|
||||
[],
|
||||
);
|
||||
expect(readFileSync(fixture.ghCallsPath, "utf8")).toBe("");
|
||||
} finally {
|
||||
fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an arbitrary older release-branch ancestor with the wrong package version", () => {
|
||||
const fixture = createDispatchFixture();
|
||||
try {
|
||||
const result = fixture.run([
|
||||
"--sha",
|
||||
fixture.oldWorkflowSha,
|
||||
"--workflow-sha",
|
||||
fixture.workflowSha,
|
||||
]);
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain(
|
||||
"Target package version 2026.7.9 does not belong to release branch release/2026.8.1; expected 2026.8.1 or a beta prerelease of it",
|
||||
);
|
||||
expect(fixture.readCalls(fixture.gitCallsPath).filter((args) => args[0] === "push")).toEqual(
|
||||
[],
|
||||
);
|
||||
expect(readFileSync(fixture.ghCallsPath, "utf8")).toBe("");
|
||||
} finally {
|
||||
fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps both temporary refs with --keep-branch", () => {
|
||||
const fixture = createDispatchFixture();
|
||||
try {
|
||||
const result = fixture.run(["--keep-branch"]);
|
||||
const result = fixture.run(["--workflow-sha", fixture.workflowSha, "--keep-branch"]);
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
const gitCalls = fixture.readCalls(fixture.gitCallsPath);
|
||||
expect(
|
||||
@@ -524,7 +688,15 @@ describe("full-release-validation-at-sha", () => {
|
||||
const missingSha = "f".repeat(40);
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[SCRIPT_PATH, "--sha", missingSha, "--target-ref", fixture.releaseRef],
|
||||
[
|
||||
SCRIPT_PATH,
|
||||
"--sha",
|
||||
missingSha,
|
||||
"--target-ref",
|
||||
fixture.releaseRef,
|
||||
"--workflow-sha",
|
||||
fixture.workflowSha,
|
||||
],
|
||||
{
|
||||
cwd: fixture.checkout,
|
||||
encoding: "utf8",
|
||||
|
||||
@@ -83,17 +83,18 @@ function runIdentityVerification(params: {
|
||||
oidcJobWorkflowSha?: string;
|
||||
oidcWorkflowSha?: string;
|
||||
targetContextRef?: string;
|
||||
workflowBranch?: string;
|
||||
workflowSha?: string;
|
||||
}) {
|
||||
const repository = "openclaw/openclaw";
|
||||
const trustedWorkflowRef = `${repository}/.github/workflows/openclaw-release-telegram-qa.yml@refs/heads/main`;
|
||||
const workflowBranch = params.workflowBranch ?? "main";
|
||||
const workflowRefName = `refs/heads/${workflowBranch}`;
|
||||
const trustedWorkflowRef = `${repository}/.github/workflows/openclaw-release-telegram-qa.yml@${workflowRefName}`;
|
||||
const invocation = params.invocation ?? "dispatch";
|
||||
const workflowRef =
|
||||
invocation === "dispatch"
|
||||
? trustedWorkflowRef
|
||||
: `${repository}/.github/workflows/openclaw-release-checks.yml@refs/heads/release-ci/test`;
|
||||
const workflowRefName =
|
||||
invocation === "dispatch" ? "refs/heads/main" : "refs/heads/release-ci/test";
|
||||
: `${repository}/.github/workflows/openclaw-release-checks.yml@${workflowRefName}`;
|
||||
const workdir = tempDirs.make("openclaw-telegram-identity-");
|
||||
const fakeBin = join(workdir, "bin");
|
||||
const githubOutput = join(workdir, "github-output");
|
||||
@@ -128,7 +129,7 @@ function runIdentityVerification(params: {
|
||||
);
|
||||
return spawnSync(
|
||||
"bash",
|
||||
["-c", requireRun("trusted_identity", "Verify dispatched-main identity")],
|
||||
["-c", requireRun("trusted_identity", "Verify dispatched workflow identity")],
|
||||
{
|
||||
cwd: workdir,
|
||||
encoding: "utf8",
|
||||
@@ -371,7 +372,7 @@ describe("release Telegram QA workflow", () => {
|
||||
"runs-on": "ubuntu-24.04",
|
||||
"timeout-minutes": 5,
|
||||
});
|
||||
expect(step("trusted_identity", "Verify dispatched-main identity").id).toBe("identity");
|
||||
expect(step("trusted_identity", "Verify dispatched workflow identity").id).toBe("identity");
|
||||
|
||||
const candidateBuild = requireRun(
|
||||
"build_candidate",
|
||||
@@ -388,6 +389,14 @@ describe("release Telegram QA workflow", () => {
|
||||
expect(requireRun("run_telegram", "Build trusted QA harness").trim()).toBe(
|
||||
"pnpm build qaRuntime",
|
||||
);
|
||||
const extractCandidate = step("run_telegram", "Verify attestation and bounded extract");
|
||||
expect(extractCandidate.env?.CALLED_WORKFLOW_REF).toBe(
|
||||
"${{ needs.trusted_identity.outputs.workflow_ref }}",
|
||||
);
|
||||
expect(extractCandidate.run).toContain(
|
||||
'--cert-identity "https://github.com/${CALLED_WORKFLOW_REF}"',
|
||||
);
|
||||
expect(extractCandidate.run).not.toContain("openclaw-release-telegram-qa.yml@refs/heads/main");
|
||||
|
||||
const runJob = job("run_telegram");
|
||||
expect(runJob.environment).toBe("qa-live-shared");
|
||||
@@ -410,9 +419,28 @@ describe("release Telegram QA workflow", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts only the resolved trusted workflow identity", () => {
|
||||
it("routes every documented workflow ref through exact direct and reusable identity", () => {
|
||||
const trustedSha = "b".repeat(40);
|
||||
const releaseCiBranch = `release-ci/${trustedSha.slice(0, 12)}-1787215404735`;
|
||||
for (const workflowBranch of [
|
||||
"main",
|
||||
"release/2026.7.1",
|
||||
"extended-stable/2026.7.33",
|
||||
releaseCiBranch,
|
||||
]) {
|
||||
for (const invocation of ["dispatch", "reusable"] as const) {
|
||||
const result = runIdentityVerification({
|
||||
expectedTrustedWorkflowSha: trustedSha,
|
||||
invocation,
|
||||
workflowBranch,
|
||||
});
|
||||
expect(result.status, `${workflowBranch}/${invocation}: ${result.stderr}`).toBe(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts only canonical exact-SHA workflow and target identities", () => {
|
||||
const trustedSha = "b".repeat(40);
|
||||
expect(runIdentityVerification({ expectedTrustedWorkflowSha: trustedSha }).status).toBe(0);
|
||||
for (const targetContextRef of [
|
||||
"release/2026.7.1",
|
||||
"extended-stable/2026.7.33",
|
||||
@@ -438,6 +466,31 @@ describe("release Telegram QA workflow", () => {
|
||||
oidcJobWorkflowSha: "c".repeat(40),
|
||||
}).stderr,
|
||||
).toContain("OIDC job_workflow_sha mismatch");
|
||||
expect(
|
||||
runIdentityVerification({
|
||||
expectedTrustedWorkflowSha: trustedSha,
|
||||
workflowBranch: "release-ci/not-canonical",
|
||||
}).stderr,
|
||||
).toContain("must be exact main, canonical release or extended-stable");
|
||||
expect(
|
||||
runIdentityVerification({
|
||||
expectedTrustedWorkflowSha: trustedSha,
|
||||
workflowBranch: `release-ci/${"c".repeat(12)}-1787215404735`,
|
||||
}).stderr,
|
||||
).toContain("release-ci ref does not match the authorized tooling SHA");
|
||||
for (const workflowBranch of [
|
||||
"release/2026.0.1",
|
||||
"release/2026.07.1",
|
||||
"extended-stable/2026.13.33",
|
||||
"extended-stable/2026.7.32",
|
||||
]) {
|
||||
expect(
|
||||
runIdentityVerification({
|
||||
expectedTrustedWorkflowSha: trustedSha,
|
||||
workflowBranch,
|
||||
}).stderr,
|
||||
).toContain("must be exact main, canonical release or extended-stable");
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts trusted release provenance and rejects same-repository PR heads", () => {
|
||||
|
||||
@@ -176,6 +176,10 @@ type WorkflowJob = {
|
||||
};
|
||||
|
||||
type Workflow = {
|
||||
concurrency?: {
|
||||
group?: string;
|
||||
"cancel-in-progress"?: boolean | string;
|
||||
};
|
||||
env?: Record<string, string>;
|
||||
jobs?: Record<string, WorkflowJob>;
|
||||
on?: {
|
||||
@@ -276,12 +280,88 @@ function runFullReleaseInputValidation(releaseProfile: string, skipTelegram: str
|
||||
workflowJob(FULL_RELEASE_VALIDATION_WORKFLOW, "resolve_target"),
|
||||
"Validate release inputs",
|
||||
);
|
||||
const workdir = tempDirs.make("full-release-input-validation-");
|
||||
mkdirSync(resolve(workdir, "target"));
|
||||
writeFileSync(resolve(workdir, "target", "package.json"), '{"version":"2026.8.1"}\n', "utf8");
|
||||
return spawnSync("bash", ["-c", step.run ?? ""], {
|
||||
cwd: workdir,
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
RELEASE_PROFILE: releaseProfile,
|
||||
SKIP_PACKAGE_TELEGRAM_E2E: skipTelegram,
|
||||
TARGET_CONTEXT_REF: "",
|
||||
TARGET_REF: "main",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function runFullReleaseTargetIdentityValidation(params: {
|
||||
comparisonStatus?: string;
|
||||
remoteSha?: string;
|
||||
targetContextRef?: string;
|
||||
targetRef: string;
|
||||
version: string;
|
||||
}) {
|
||||
const step = workflowStep(
|
||||
workflowJob(FULL_RELEASE_VALIDATION_WORKFLOW, "resolve_target"),
|
||||
"Validate release inputs",
|
||||
);
|
||||
const workdir = tempDirs.make("full-release-target-identity-");
|
||||
const fakeBin = resolve(workdir, "bin");
|
||||
mkdirSync(fakeBin);
|
||||
mkdirSync(resolve(workdir, "target"));
|
||||
writeFileSync(
|
||||
resolve(workdir, "target", "package.json"),
|
||||
`${JSON.stringify({ version: params.version })}\n`,
|
||||
"utf8",
|
||||
);
|
||||
writeFileSync(
|
||||
resolve(fakeBin, "git"),
|
||||
`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [[ "$*" == *"ls-remote"* ]]; then
|
||||
printf '%s\\t%s\\n' "$FAKE_REMOTE_SHA" "$FAKE_REMOTE_REF"
|
||||
exit 0
|
||||
fi
|
||||
exit 64
|
||||
`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
writeFileSync(
|
||||
resolve(fakeBin, "gh"),
|
||||
`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [[ "$*" == *"api repos/"*"/compare/"* ]]; then
|
||||
printf '%s\\n' "$FAKE_COMPARISON_STATUS"
|
||||
exit 0
|
||||
fi
|
||||
exit 64
|
||||
`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
const targetSha = params.targetRef.match(/^[a-f0-9]{40}$/u)?.[0] ?? "a".repeat(40);
|
||||
const normalizedContextRef = (params.targetContextRef ?? params.targetRef)
|
||||
.replace(/^refs\/heads\//u, "")
|
||||
.replace(/^refs\/tags\//u, "");
|
||||
const remoteRef = normalizedContextRef.startsWith("v")
|
||||
? `refs/tags/${normalizedContextRef}`
|
||||
: `refs/heads/${normalizedContextRef}`;
|
||||
return spawnSync("bash", ["-c", step.run ?? ""], {
|
||||
cwd: workdir,
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
FAKE_COMPARISON_STATUS: params.comparisonStatus ?? "ahead",
|
||||
FAKE_REMOTE_REF: remoteRef,
|
||||
FAKE_REMOTE_SHA: params.remoteSha ?? targetSha,
|
||||
GH_TOKEN: "test-token",
|
||||
GITHUB_REPOSITORY: "openclaw/openclaw",
|
||||
PATH: `${fakeBin}:${process.env.PATH}`,
|
||||
RELEASE_PROFILE: "beta",
|
||||
SKIP_PACKAGE_TELEGRAM_E2E: "false",
|
||||
TARGET_CONTEXT_REF: params.targetContextRef ?? "",
|
||||
TARGET_REF: params.targetRef,
|
||||
TARGET_SHA: targetSha,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -2274,6 +2354,12 @@ describe("package acceptance workflow", () => {
|
||||
expect(readFileSync(NPM_TELEGRAM_WORKFLOW, "utf8")).toContain(
|
||||
"format('NPM Telegram Beta E2E {0}', inputs.dispatch_id)",
|
||||
);
|
||||
expect(readWorkflow(PLUGIN_PRERELEASE_WORKFLOW).concurrency?.group).toBe(
|
||||
"plugin-prerelease-${{ inputs.target_ref }}-${{ github.sha }}",
|
||||
);
|
||||
expect(readWorkflow(RELEASE_CHECKS_WORKFLOW).concurrency?.group).toBe(
|
||||
"openclaw-release-checks-${{ inputs.expected_sha || inputs.ref }}-${{ github.sha }}-${{ inputs.rerun_group }}",
|
||||
);
|
||||
});
|
||||
|
||||
it.each(FULL_RELEASE_CHILD_DISPATCHES)(
|
||||
@@ -3763,6 +3849,73 @@ describe("package artifact reuse", () => {
|
||||
expect(umbrella.status, umbrella.stderr).toBe(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["release/2026.8.1", "2026.8.1"],
|
||||
["release/2026.8.1", "2026.8.1-beta.3"],
|
||||
["extended-stable/2026.7.33", "2026.7.33"],
|
||||
["v2026.8.1", "2026.8.1"],
|
||||
["v2026.8.1-alpha.2", "2026.8.1-alpha.2"],
|
||||
["v2026.8.1-beta.3", "2026.8.1-beta.3"],
|
||||
])("accepts direct Full Release Validation identity %s at package %s", (targetRef, version) => {
|
||||
const result = runFullReleaseTargetIdentityValidation({ targetRef, version });
|
||||
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["release/2026.8.1", "2026.8.2", "does not belong to release branch"],
|
||||
["release/2026.8.1", "2026.8.1-alpha.2", "expected 2026.8.1 or a beta prerelease"],
|
||||
["extended-stable/2026.7.33", "2026.7.33-beta.1", "does not match extended-stable branch"],
|
||||
["v2026.8.1", "2026.8.1-beta.1", "does not match release tag"],
|
||||
["v2026.8.1-alpha.2", "2026.8.1-alpha.3", "does not match release tag"],
|
||||
])(
|
||||
"rejects direct Full Release Validation identity %s at package %s",
|
||||
(targetRef, version, error) => {
|
||||
const result = runFullReleaseTargetIdentityValidation({ targetRef, version });
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain(error);
|
||||
},
|
||||
);
|
||||
|
||||
it("validates an exact-SHA helper target against its canonical release context", () => {
|
||||
const accepted = runFullReleaseTargetIdentityValidation({
|
||||
targetContextRef: "release/2026.8.1",
|
||||
targetRef: "a".repeat(40),
|
||||
version: "2026.8.1-beta.3",
|
||||
});
|
||||
const rejected = runFullReleaseTargetIdentityValidation({
|
||||
targetContextRef: "release/2026.8.1",
|
||||
targetRef: "a".repeat(40),
|
||||
version: "2026.8.1-alpha.3",
|
||||
});
|
||||
|
||||
expect(accepted.status, accepted.stderr).toBe(0);
|
||||
expect(rejected.status).toBe(1);
|
||||
expect(rejected.stderr).toContain("expected 2026.8.1 or a beta prerelease");
|
||||
});
|
||||
|
||||
it("rejects exact-SHA release contexts outside the named branch or tag", () => {
|
||||
const divergedBranch = runFullReleaseTargetIdentityValidation({
|
||||
comparisonStatus: "diverged",
|
||||
remoteSha: "b".repeat(40),
|
||||
targetContextRef: "release/2026.8.1",
|
||||
targetRef: "a".repeat(40),
|
||||
version: "2026.8.1-beta.3",
|
||||
});
|
||||
const mismatchedTag = runFullReleaseTargetIdentityValidation({
|
||||
remoteSha: "b".repeat(40),
|
||||
targetContextRef: "v2026.8.1-alpha.2",
|
||||
targetRef: "a".repeat(40),
|
||||
version: "2026.8.1-alpha.2",
|
||||
});
|
||||
|
||||
expect(divergedBranch.status).toBe(1);
|
||||
expect(divergedBranch.stderr).toContain("is not reachable from release context branch");
|
||||
expect(mismatchedTag.status).toBe(1);
|
||||
expect(mismatchedTag.stderr).toContain("does not match release tag");
|
||||
});
|
||||
|
||||
it.each(["stable", "full"])(
|
||||
"preserves normal %s validation when Telegram deferral is false",
|
||||
(releaseProfile) => {
|
||||
@@ -4800,12 +4953,18 @@ describe("package artifact reuse", () => {
|
||||
const workflowInputs = readWorkflow(FULL_RELEASE_VALIDATION_WORKFLOW).on?.workflow_dispatch
|
||||
?.inputs;
|
||||
const resolveTargetJob = workflowJob(FULL_RELEASE_VALIDATION_WORKFLOW, "resolve_target");
|
||||
const resolveTargetSteps = resolveTargetJob.steps ?? [];
|
||||
const evidenceReuseJob = workflowJob(FULL_RELEASE_VALIDATION_WORKFLOW, "evidence_reuse");
|
||||
const releaseChecksJob = workflowJob(FULL_RELEASE_VALIDATION_WORKFLOW, "release_checks");
|
||||
const npmTelegramJob = workflowJob(FULL_RELEASE_VALIDATION_WORKFLOW, "npm_telegram");
|
||||
const performanceJob = workflowJob(FULL_RELEASE_VALIDATION_WORKFLOW, "performance");
|
||||
const summaryJob = workflowJob(FULL_RELEASE_VALIDATION_WORKFLOW, "summary");
|
||||
const targetSummaryStep = workflowStep(resolveTargetJob, "Summarize target");
|
||||
const targetManifestCheckout = workflowStep(
|
||||
resolveTargetJob,
|
||||
"Checkout target package manifest",
|
||||
);
|
||||
const releaseInputValidation = workflowStep(resolveTargetJob, "Validate release inputs");
|
||||
const evidenceReuseStep = workflowStep(evidenceReuseJob, "Find reusable validation evidence");
|
||||
const releaseChecksDispatchStep = workflowStep(
|
||||
releaseChecksJob,
|
||||
@@ -4823,6 +4982,24 @@ describe("package artifact reuse", () => {
|
||||
});
|
||||
expect(workflow).toContain("CHILD_WORKFLOW_REF: ${{ github.ref_name }}");
|
||||
expect(workflow).toContain('gh workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@" 2>&1');
|
||||
expect(targetManifestCheckout.with).toMatchObject({
|
||||
ref: "${{ steps.resolve.outputs.sha }}",
|
||||
path: "target",
|
||||
"sparse-checkout": "package.json",
|
||||
"sparse-checkout-cone-mode": false,
|
||||
"persist-credentials": false,
|
||||
});
|
||||
expect(resolveTargetSteps.indexOf(targetManifestCheckout)).toBeLessThan(
|
||||
resolveTargetSteps.indexOf(releaseInputValidation),
|
||||
);
|
||||
expectTextToIncludeAll(releaseInputValidation.run, [
|
||||
'target_version="$(jq -er',
|
||||
"does not belong to release branch",
|
||||
"does not match ${identity_kind}",
|
||||
"is not reachable from release context branch",
|
||||
"does not match release tag",
|
||||
"target_context_ref must be a canonical OpenClaw release branch or tag.",
|
||||
]);
|
||||
expect(npmTelegramJob.name).toBe("Run package Telegram E2E");
|
||||
expect(npmTelegramJob.needs).toEqual(["resolve_target", "evidence_reuse"]);
|
||||
expect(npmTelegramJob["timeout-minutes"]).toBe(
|
||||
@@ -4853,6 +5030,7 @@ describe("package artifact reuse", () => {
|
||||
});
|
||||
expectTextToIncludeAll(targetSummaryStep.run, [
|
||||
"Validation SHA:",
|
||||
"Frozen tuple:",
|
||||
"Package Acceptance Telegram E2E deferred:",
|
||||
"Package Telegram E2E: deferred by \\`skip_package_telegram_e2e\\`",
|
||||
]);
|
||||
@@ -4901,6 +5079,7 @@ describe("package artifact reuse", () => {
|
||||
".display_title == env.DISPATCH_RUN_NAME and .head_branch == env.CHILD_WORKFLOW_REF",
|
||||
"The dispatch was not retried to avoid creating a duplicate child.",
|
||||
'if [[ "$child_head_sha" != "$PARENT_WORKFLOW_SHA" ]]; then',
|
||||
"Still waiting on ${workflow} after ${elapsed_minutes}m:",
|
||||
'-f harness_ref="$TARGET_SHA"',
|
||||
'args=(-f package_spec="$PACKAGE_SPEC"',
|
||||
'args+=(-f scenario="$SCENARIO")',
|
||||
@@ -5539,13 +5718,17 @@ describe("package artifact reuse", () => {
|
||||
const telegramDispatch = workflowStep(telegramCaller, "Dispatch and await trusted Telegram QA");
|
||||
expect(telegramDispatch.run).toContain('workflow="openclaw-release-telegram-qa.yml"');
|
||||
expect(telegramDispatch.run).toContain('--repo "$GITHUB_REPOSITORY"');
|
||||
expect(telegramDispatch.run).toContain("--ref main");
|
||||
expect(telegramDispatch.env).toMatchObject({
|
||||
PARENT_WORKFLOW_REF: "${{ github.ref_name }}",
|
||||
PARENT_WORKFLOW_SHA: "${{ github.sha }}",
|
||||
});
|
||||
expect(telegramDispatch.run).toContain('--ref "$PARENT_WORKFLOW_REF"');
|
||||
expect(telegramDispatch.run).toContain(
|
||||
'-f expected_trusted_workflow_sha="$expected_trusted_workflow_sha"',
|
||||
);
|
||||
expect(telegramDispatch.run).toContain(
|
||||
'[[ "$child_head_sha" == "$expected_trusted_workflow_sha" ]]',
|
||||
'-f expected_trusted_workflow_sha="$PARENT_WORKFLOW_SHA"',
|
||||
);
|
||||
expect(telegramDispatch.run).toContain('[[ "$child_head_sha" != "$PARENT_WORKFLOW_SHA" ]]');
|
||||
expect(telegramDispatch.run).not.toContain("commits/main");
|
||||
expect(telegramDispatch.run).not.toContain("dispatch_attempt");
|
||||
expect(telegramCaller["continue-on-error"]).toBeUndefined();
|
||||
expect(telegramCaller["timeout-minutes"]).toBe(210);
|
||||
|
||||
@@ -6904,6 +7087,10 @@ wait_for_run plugin-clawhub-new.yml 123 "${expectedSha}" || status=$?
|
||||
'VALIDATION_SHA="<full-commit-sha>"',
|
||||
'-f ref="$VALIDATION_SHA"',
|
||||
'-f expected_sha="$VALIDATION_SHA"',
|
||||
'TOOLING_SHA="<recorded-full-main-ancestor-sha>"',
|
||||
'VALIDATION_SHA="<full-release-candidate-sha>"',
|
||||
"--target-ref release/YYYY.M.PATCH",
|
||||
'--workflow-sha "$TOOLING_SHA"',
|
||||
]);
|
||||
for (const text of [releaseCi, releaseCiNotes, testing, parallels, ciDocs, maintainer]) {
|
||||
expect(text).toContain("Validation SHA + Tooling SHA");
|
||||
|
||||
@@ -846,7 +846,7 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => {
|
||||
|
||||
expect(releaseChecksWorkflow.concurrency).toEqual({
|
||||
group:
|
||||
"openclaw-release-checks-${{ inputs.expected_sha || inputs.ref }}-${{ inputs.rerun_group }}",
|
||||
"openclaw-release-checks-${{ inputs.expected_sha || inputs.ref }}-${{ github.sha }}-${{ inputs.rerun_group }}",
|
||||
"cancel-in-progress": "${{ startsWith(github.ref, 'refs/heads/tideclaw/alpha/') }}",
|
||||
});
|
||||
expect(fullReleaseWorkflow.concurrency).toEqual({
|
||||
|
||||
Reference in New Issue
Block a user