mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 19:35:28 -06:00
fix(release): keep protected tooling trusted after main moves (#126881)
* fix(release): keep protected tooling trusted after main moves * fix(release): cover protected tooling recovery paths * fix(release): honor live tooling contracts * fix(release): revalidate tooling at npm publish * fix(release): bind npm publishers to live tooling * fix(release): preserve trusted dispatch identity * fix(release): revalidate parent authorization * fix(release): bind ClawHub to release parent * docs(release): define frozen tooling identity * test(release): align ClawHub protected dispatch ref * fix(release): trust protected plugin npm preflight tooling * docs(release): scope protected writer guarantees * fix(release): keep protected tooling foundation npm-only * test(release): cover trusted npm preflight tooling
This commit is contained in:
@@ -207,6 +207,7 @@ function normalizedEvidence(options: {
|
||||
validationInputs?: Record<string, string> | null;
|
||||
verifierSha?: string | null;
|
||||
workflowRef?: string;
|
||||
trustedWorkflowRef?: string;
|
||||
}): NormalizedEvidence {
|
||||
const runId = options.runId ?? "111";
|
||||
const producerSha = options.producerSha ?? PRODUCER_SHA;
|
||||
@@ -215,6 +216,11 @@ function normalizedEvidence(options: {
|
||||
const workflowRef = options.workflowRef ?? "main";
|
||||
const workflowFullRef = `refs/heads/${workflowRef}`;
|
||||
const shaPinned = workflowRef.startsWith("release-ci/");
|
||||
const trustedWorkflowRef = options.trustedWorkflowRef ?? "main";
|
||||
const protectedTagRoute = trustedWorkflowRef.startsWith("release-publish/");
|
||||
const trustedWorkflowFullRef = protectedTagRoute
|
||||
? `refs/tags/${trustedWorkflowRef}`
|
||||
: "refs/heads/main";
|
||||
const validationInputs =
|
||||
options.validationInputs === undefined ? DEFAULT_INPUTS : options.validationInputs;
|
||||
const npmTelegramRequired =
|
||||
@@ -269,14 +275,16 @@ function normalizedEvidence(options: {
|
||||
status: "completed",
|
||||
targetSha: options.targetSha,
|
||||
url: `https://example.test/runs/${runId}`,
|
||||
producerOnTrustedMainLineage: true,
|
||||
producerOnTrustedMainLineage: !protectedTagRoute,
|
||||
workflowFullRef,
|
||||
workflowPath: ".github/workflows/full-release-validation.yml",
|
||||
workflowQualifiedPath: `.github/workflows/full-release-validation.yml@${workflowFullRef}`,
|
||||
workflowRef,
|
||||
workflowRefProof: shaPinned
|
||||
? "manifest-v3-sha-pinned-main-ancestry"
|
||||
: "legacy-v2-main-ancestry",
|
||||
workflowRefProof: protectedTagRoute
|
||||
? "manifest-v3-protected-tag-exact-sha"
|
||||
: shaPinned
|
||||
? "manifest-v3-sha-pinned-main-ancestry"
|
||||
: "legacy-v2-main-ancestry",
|
||||
workflowRefType: "branch",
|
||||
workflowRunPath: shaPinned
|
||||
? `.github/workflows/full-release-validation.yml@${workflowFullRef}`
|
||||
@@ -362,9 +370,9 @@ function normalizedEvidence(options: {
|
||||
root,
|
||||
runReleaseSoak: soak,
|
||||
schema: "openclaw.release-validation-evidence/v3",
|
||||
producerOnTrustedMainLineage: true,
|
||||
trustedWorkflowFullRef: "refs/heads/main",
|
||||
trustedWorkflowRef: "main",
|
||||
producerOnTrustedMainLineage: !protectedTagRoute,
|
||||
trustedWorkflowFullRef,
|
||||
trustedWorkflowRef,
|
||||
valid: true,
|
||||
validationInputs,
|
||||
verifier: {
|
||||
@@ -404,16 +412,22 @@ import { join } from "node:path";
|
||||
const runIndex = process.argv.indexOf("--validate-run");
|
||||
const repoIndex = process.argv.indexOf("--repo");
|
||||
const trustedRefIndex = process.argv.indexOf("--trusted-workflow-ref");
|
||||
const trustedFullRefIndex = process.argv.indexOf("--trusted-workflow-full-ref");
|
||||
const trustedShaIndex = process.argv.indexOf("--trusted-workflow-sha");
|
||||
const verifierShaIndex = process.argv.indexOf("--verifier-source-sha");
|
||||
const verifierFileIndex = process.argv.indexOf("--verifier-source-file");
|
||||
if (
|
||||
runIndex < 0 ||
|
||||
repoIndex < 0 ||
|
||||
trustedRefIndex < 0 ||
|
||||
trustedFullRefIndex < 0 ||
|
||||
trustedShaIndex < 0 ||
|
||||
verifierShaIndex < 0 ||
|
||||
verifierFileIndex < 0 ||
|
||||
process.argv[repoIndex + 1] !== "openclaw/openclaw" ||
|
||||
process.argv[trustedRefIndex + 1] !== "main" ||
|
||||
process.argv[trustedRefIndex + 1] !== process.env.FAKE_TRUSTED_WORKFLOW_REF ||
|
||||
process.argv[trustedFullRefIndex + 1] !== process.env.FAKE_TRUSTED_WORKFLOW_FULL_REF ||
|
||||
process.argv[trustedShaIndex + 1] !== process.env.FAKE_TRUSTED_WORKFLOW_SHA ||
|
||||
process.argv[verifierShaIndex + 1] !== process.env.FAKE_VERIFIER_SHA ||
|
||||
process.argv[verifierFileIndex + 1] !== process.argv[1] ||
|
||||
!process.argv.includes("--json")
|
||||
@@ -481,12 +495,22 @@ function runResolver(args: {
|
||||
repoDir: string;
|
||||
runReleaseSoak?: string;
|
||||
targetSha: string;
|
||||
trustedTagSha?: string;
|
||||
trustedTagType?: string;
|
||||
trustedWorkflowFullRef?: string;
|
||||
trustedWorkflowRef?: string;
|
||||
trustedWorkflowSha?: string;
|
||||
validatorPath: string;
|
||||
verifierOnMain?: boolean;
|
||||
verifierSha?: string;
|
||||
workflowRef?: string;
|
||||
}) {
|
||||
const verifierSha = args.verifierSha ?? VERIFIER_SHA;
|
||||
const trustedWorkflowRef = args.trustedWorkflowRef ?? "main";
|
||||
const trustedWorkflowFullRef =
|
||||
args.trustedWorkflowFullRef ??
|
||||
(trustedWorkflowRef === "main" ? "refs/heads/main" : `refs/tags/${trustedWorkflowRef}`);
|
||||
const trustedWorkflowSha = args.trustedWorkflowSha ?? verifierSha;
|
||||
writeFileSync(
|
||||
fixtureName(args.fixtures, `repos/${REPOSITORY}/compare/${verifierSha}...main`),
|
||||
JSON.stringify({
|
||||
@@ -494,6 +518,17 @@ function runResolver(args: {
|
||||
status: args.verifierOnMain === false ? "diverged" : "ahead",
|
||||
}),
|
||||
);
|
||||
if (trustedWorkflowRef !== "main") {
|
||||
writeFileSync(
|
||||
fixtureName(args.fixtures, `repos/${REPOSITORY}/git/ref/tags/${trustedWorkflowRef}`),
|
||||
JSON.stringify({
|
||||
object: {
|
||||
sha: args.trustedTagSha ?? trustedWorkflowSha,
|
||||
type: args.trustedTagType ?? "commit",
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (args.compareBaseSha) {
|
||||
writeFileSync(
|
||||
fixtureName(
|
||||
@@ -525,6 +560,12 @@ function runResolver(args: {
|
||||
verifierSha,
|
||||
"--workflow-ref",
|
||||
args.workflowRef ?? "main",
|
||||
"--trusted-workflow-ref",
|
||||
trustedWorkflowRef,
|
||||
"--trusted-workflow-full-ref",
|
||||
trustedWorkflowFullRef,
|
||||
"--trusted-workflow-sha",
|
||||
trustedWorkflowSha,
|
||||
"--release-profile",
|
||||
args.releaseProfile ?? "full",
|
||||
"--run-release-soak",
|
||||
@@ -542,6 +583,9 @@ function runResolver(args: {
|
||||
env: {
|
||||
...process.env,
|
||||
FAKE_GH_FIXTURES: args.fixtures,
|
||||
FAKE_TRUSTED_WORKFLOW_FULL_REF: trustedWorkflowFullRef,
|
||||
FAKE_TRUSTED_WORKFLOW_REF: trustedWorkflowRef,
|
||||
FAKE_TRUSTED_WORKFLOW_SHA: trustedWorkflowSha,
|
||||
FAKE_VALIDATOR_FIXTURES: args.fixtures,
|
||||
FAKE_VERIFIER_SHA: verifierSha,
|
||||
GITHUB_OUTPUT: "",
|
||||
@@ -593,6 +637,82 @@ describe("scripts/github/find-reusable-release-validation.sh", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("reuses strict evidence through the exact lightweight protected tooling tag", () => {
|
||||
const { clone, priorSha } = getSharedRepo();
|
||||
const trustedWorkflowRef = `release-publish/${VERIFIER_SHA.slice(0, 12)}-456`;
|
||||
const producerRef = `release-ci/${VERIFIER_SHA.slice(0, 12)}-122`;
|
||||
const record = normalizedEvidence({
|
||||
producerSha: VERIFIER_SHA,
|
||||
targetSha: priorSha,
|
||||
trustedWorkflowRef,
|
||||
workflowRef: producerRef,
|
||||
});
|
||||
const { binDir, fixtures, validatorPath } = setUpFixtures([{ record, runId: "111" }]);
|
||||
|
||||
const result = runResolver({
|
||||
binDir,
|
||||
fixtures,
|
||||
repoDir: clone,
|
||||
targetSha: priorSha,
|
||||
trustedWorkflowRef,
|
||||
validatorPath,
|
||||
verifierOnMain: false,
|
||||
workflowRef: `release-ci/${VERIFIER_SHA.slice(0, 12)}-123`,
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(parseOutput(result.stdout)).toMatchObject({
|
||||
evidence_run_id: "111",
|
||||
reuse: "true",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: "moved protected tag",
|
||||
options: {
|
||||
trustedTagSha: "d".repeat(40),
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "annotated protected tag",
|
||||
options: {
|
||||
trustedTagType: "tag",
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "same-name branch",
|
||||
options: {
|
||||
trustedWorkflowFullRef: `refs/heads/release-publish/${VERIFIER_SHA.slice(0, 12)}-456`,
|
||||
},
|
||||
},
|
||||
])("rejects protected tooling identity drift: $label", ({ options }) => {
|
||||
const { clone, priorSha } = getSharedRepo();
|
||||
const trustedWorkflowRef = `release-publish/${VERIFIER_SHA.slice(0, 12)}-456`;
|
||||
const producerRef = `release-ci/${VERIFIER_SHA.slice(0, 12)}-122`;
|
||||
const record = normalizedEvidence({
|
||||
producerSha: VERIFIER_SHA,
|
||||
targetSha: priorSha,
|
||||
trustedWorkflowRef,
|
||||
workflowRef: producerRef,
|
||||
});
|
||||
const { binDir, fixtures, validatorPath } = setUpFixtures([{ record, runId: "111" }]);
|
||||
|
||||
const result = runResolver({
|
||||
binDir,
|
||||
fixtures,
|
||||
repoDir: clone,
|
||||
targetSha: priorSha,
|
||||
trustedWorkflowRef,
|
||||
validatorPath,
|
||||
workflowRef: `release-ci/${VERIFIER_SHA.slice(0, 12)}-123`,
|
||||
...options,
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(parseOutput(result.stdout)).toMatchObject({ reuse: "false" });
|
||||
});
|
||||
|
||||
it("reuses npm Telegram evidence only when its selectors match exactly", () => {
|
||||
const { clone, priorSha } = getSharedRepo();
|
||||
const validationInputs = {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
import {
|
||||
assertTrustedWorkflowHarness,
|
||||
FULL_RELEASE_WAIT_POLL_INTERVAL_MS,
|
||||
@@ -14,18 +15,25 @@ import {
|
||||
resolveRemoteTargetRefSha,
|
||||
shouldDeleteTemporaryWorkflowRef,
|
||||
verifyTargetRef,
|
||||
verifyTrustedWorkflowRef,
|
||||
} 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 CURRENT_WORKFLOW_SOURCE = readFileSync(
|
||||
".github/workflows/full-release-validation.yml",
|
||||
"utf8",
|
||||
);
|
||||
const CONTRACT_ONE_WORKFLOW_SOURCE = CURRENT_WORKFLOW_SOURCE.replace(
|
||||
'RELEASE_ISOLATION_TOOLING_CONTRACT: "2"',
|
||||
'RELEASE_ISOLATION_TOOLING_CONTRACT: "1"',
|
||||
).replace(
|
||||
` trusted_workflow_json:
|
||||
description: Trusted release tooling identity JSON
|
||||
required: true
|
||||
type: string
|
||||
`,
|
||||
"",
|
||||
);
|
||||
const LEGACY_WORKFLOW_SOURCE = `name: Full Release Validation
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -70,8 +78,10 @@ function createDispatchFixture(options: { workflowSource?: string } = {}) {
|
||||
join(checkout, "scripts", "release-ci-summary.mjs"),
|
||||
`const expected = [
|
||||
"--validate-run", "123",
|
||||
"--trusted-workflow-ref", "main",
|
||||
"--json",
|
||||
"--trusted-workflow-ref", process.env.MOCK_TRUSTED_WORKFLOW_REF,
|
||||
"--trusted-workflow-full-ref", process.env.MOCK_TRUSTED_WORKFLOW_FULL_REF,
|
||||
"--trusted-workflow-sha", process.env.MOCK_WORKFLOW_SHA,
|
||||
"--json",
|
||||
"--verifier-source-sha", process.env.MOCK_WORKFLOW_SHA,
|
||||
"--verifier-source-file", process.argv[1],
|
||||
];
|
||||
@@ -89,12 +99,21 @@ console.log(JSON.stringify({ valid: true, current: { runId: "123" }, root: { run
|
||||
join(checkout, ".github", "workflows", "full-release-validation.yml"),
|
||||
options.workflowSource ?? CURRENT_WORKFLOW_SOURCE,
|
||||
);
|
||||
const workflow = parseYaml(
|
||||
readFileSync(join(checkout, ".github", "workflows", "full-release-validation.yml"), "utf8"),
|
||||
) as {
|
||||
on?: { workflow_dispatch?: { inputs?: Record<string, unknown> } };
|
||||
};
|
||||
const declaredWorkflowInputs = Object.keys(workflow.on?.workflow_dispatch?.inputs ?? {});
|
||||
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"]);
|
||||
const trustedWorkflowTag = `release-publish/${workflowSha.slice(0, 12)}-123`;
|
||||
runGit(checkout, ["remote", "add", "origin", origin]);
|
||||
runGit(checkout, ["push", "-u", "origin", "main"]);
|
||||
runGit(checkout, ["tag", trustedWorkflowTag, workflowSha]);
|
||||
runGit(checkout, ["push", "origin", `refs/tags/${trustedWorkflowTag}`]);
|
||||
runGit(checkout, ["checkout", "-b", releaseRef]);
|
||||
writeFileSync(join(checkout, "target.txt"), "release target\n");
|
||||
runGit(checkout, ["add", "target.txt"]);
|
||||
@@ -128,6 +147,17 @@ const fs = require("node:fs");
|
||||
const args = process.argv.slice(2);
|
||||
fs.appendFileSync(process.env.MOCK_GH_CALLS, JSON.stringify(args) + "\\n");
|
||||
if (args[0] === "workflow" && args[1] === "run") {
|
||||
const declaredInputs = new Set(JSON.parse(process.env.MOCK_WORKFLOW_INPUTS));
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
if (args[index] !== "-f") continue;
|
||||
const assignment = args[index + 1] || "";
|
||||
const key = assignment.slice(0, assignment.indexOf("="));
|
||||
if (!declaredInputs.has(key)) {
|
||||
console.error("workflow input is not declared: " + key);
|
||||
process.exit(2);
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
console.log("https://github.com/openclaw/openclaw/actions/runs/123");
|
||||
} else if (args[0] === "api" && args.at(-1).endsWith("/actions/runs/123")) {
|
||||
console.log(JSON.stringify({ status: "completed", conclusion: "success", head_sha: process.env.MOCK_WORKFLOW_SHA }));
|
||||
@@ -139,8 +169,13 @@ if (args[0] === "workflow" && args[1] === "run") {
|
||||
);
|
||||
chmodSync(ghPath, 0o755);
|
||||
|
||||
const run = (extraArgs: string[] = []) =>
|
||||
spawnSync(
|
||||
const run = (extraArgs: string[] = []) => {
|
||||
const trustedRefIndex = extraArgs.indexOf("--trusted-workflow-ref");
|
||||
const trustedWorkflowRef =
|
||||
trustedRefIndex >= 0 ? (extraArgs[trustedRefIndex + 1] ?? "") : "main";
|
||||
const trustedWorkflowFullRef =
|
||||
trustedWorkflowRef === "main" ? "refs/heads/main" : `refs/tags/${trustedWorkflowRef}`;
|
||||
return spawnSync(
|
||||
process.execPath,
|
||||
[SCRIPT_PATH, "--sha", targetSha, "--target-ref", releaseRef, ...extraArgs],
|
||||
{
|
||||
@@ -151,11 +186,15 @@ if (args[0] === "workflow" && args[1] === "run") {
|
||||
MOCK_GH_CALLS: ghCallsPath,
|
||||
MOCK_GIT_CALLS: gitCallsPath,
|
||||
MOCK_REAL_PATH: process.env.PATH,
|
||||
MOCK_TRUSTED_WORKFLOW_FULL_REF: trustedWorkflowFullRef,
|
||||
MOCK_TRUSTED_WORKFLOW_REF: trustedWorkflowRef,
|
||||
MOCK_WORKFLOW_INPUTS: JSON.stringify(declaredWorkflowInputs),
|
||||
MOCK_WORKFLOW_SHA: workflowSha,
|
||||
PATH: `${binDir}:${process.env.PATH}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
const readCalls = (path: string): string[][] =>
|
||||
readFileSync(path, "utf8")
|
||||
.trim()
|
||||
@@ -174,6 +213,7 @@ if (args[0] === "workflow" && args[1] === "run") {
|
||||
releaseRef,
|
||||
run,
|
||||
targetSha,
|
||||
trustedWorkflowTag,
|
||||
workflowSha,
|
||||
};
|
||||
}
|
||||
@@ -186,6 +226,8 @@ describe("full-release-validation-at-sha", () => {
|
||||
"abc123",
|
||||
"--workflow-sha",
|
||||
"a".repeat(40),
|
||||
"--trusted-workflow-ref",
|
||||
`release-publish/${"a".repeat(12)}-123`,
|
||||
"--target-ref",
|
||||
"release/2026.7.1",
|
||||
"--keep-branch",
|
||||
@@ -206,6 +248,7 @@ describe("full-release-validation-at-sha", () => {
|
||||
},
|
||||
sha: "abc123",
|
||||
targetRef: "release/2026.7.1",
|
||||
trustedWorkflowRef: `release-publish/${"a".repeat(12)}-123`,
|
||||
workflowSha: "a".repeat(40),
|
||||
});
|
||||
});
|
||||
@@ -221,6 +264,16 @@ describe("full-release-validation-at-sha", () => {
|
||||
expect(() => parseArgs(["--", "-f"])).toThrow("-f requires a value");
|
||||
});
|
||||
|
||||
it("requires an exact Tooling SHA for protected workflow tags", () => {
|
||||
const trustedTag = `release-publish/${"a".repeat(12)}-123`;
|
||||
expect(() => parseArgs(["--trusted-workflow-ref", trustedTag])).toThrow(
|
||||
"explicit full Tooling SHA",
|
||||
);
|
||||
expect(() =>
|
||||
parseArgs(["--workflow-sha", "a".repeat(40), "--trusted-workflow-ref", "release/2026.8.1"]),
|
||||
).toThrow("protected release-publish");
|
||||
});
|
||||
|
||||
it("rejects retry groups that are not controller APIs", () => {
|
||||
expect(() => parseArgs(["-f", "rerun_group=release-checks"])).toThrow(
|
||||
"rerun_group must be one of",
|
||||
@@ -403,6 +456,9 @@ describe("full-release-validation-at-sha", () => {
|
||||
expect(() => parseArgs(["--", `expected_sha=${"a".repeat(40)}`])).toThrow(
|
||||
"reserves expected_sha",
|
||||
);
|
||||
expect(() => parseArgs(["-f", "trusted_workflow_json={}"])).toThrow(
|
||||
"reserves trusted_workflow_json",
|
||||
);
|
||||
});
|
||||
|
||||
it("validates direct and reused runs through the strict evidence verifier", () => {
|
||||
@@ -413,6 +469,10 @@ describe("full-release-validation-at-sha", () => {
|
||||
"123",
|
||||
"--trusted-workflow-ref",
|
||||
"main",
|
||||
"--trusted-workflow-full-ref",
|
||||
"refs/heads/main",
|
||||
"--trusted-workflow-sha",
|
||||
workflowSha,
|
||||
"--json",
|
||||
"--verifier-source-sha",
|
||||
workflowSha,
|
||||
@@ -422,6 +482,71 @@ describe("full-release-validation-at-sha", () => {
|
||||
expect(() => releaseEvidenceVerificationArgs("", workflowSha, verifier)).toThrow(
|
||||
"positive decimal",
|
||||
);
|
||||
const trustedTag = `release-publish/${workflowSha.slice(0, 12)}-123`;
|
||||
expect(releaseEvidenceVerificationArgs("123", workflowSha, verifier, trustedTag)).toEqual([
|
||||
"--validate-run",
|
||||
"123",
|
||||
"--trusted-workflow-ref",
|
||||
trustedTag,
|
||||
"--trusted-workflow-full-ref",
|
||||
`refs/tags/${trustedTag}`,
|
||||
"--trusted-workflow-sha",
|
||||
workflowSha,
|
||||
"--json",
|
||||
"--verifier-source-sha",
|
||||
workflowSha,
|
||||
"--verifier-source-file",
|
||||
verifier,
|
||||
]);
|
||||
expect(() =>
|
||||
releaseEvidenceVerificationArgs("123", workflowSha, verifier, "release/2026.8.1"),
|
||||
).toThrow("protected release-publish tag");
|
||||
});
|
||||
|
||||
it("accepts only exact protected workflow tags outside main ancestry", () => {
|
||||
const workflowSha = "a".repeat(40);
|
||||
const trustedTag = `release-publish/${workflowSha.slice(0, 12)}-123`;
|
||||
|
||||
expect(() =>
|
||||
verifyTrustedWorkflowRef(
|
||||
workflowSha,
|
||||
"main",
|
||||
() => "",
|
||||
() => true,
|
||||
),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
verifyTrustedWorkflowRef(
|
||||
workflowSha,
|
||||
"main",
|
||||
() => "",
|
||||
() => false,
|
||||
),
|
||||
).toThrow("not reachable from current origin/main");
|
||||
expect(() =>
|
||||
verifyTrustedWorkflowRef(
|
||||
workflowSha,
|
||||
trustedTag,
|
||||
() => workflowSha,
|
||||
() => false,
|
||||
),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
verifyTrustedWorkflowRef(
|
||||
workflowSha,
|
||||
`release-publish/${"b".repeat(12)}-123`,
|
||||
() => workflowSha,
|
||||
),
|
||||
).toThrow("does not match Tooling SHA");
|
||||
expect(() => verifyTrustedWorkflowRef(workflowSha, trustedTag, () => "")).toThrow(
|
||||
"does not exist on origin",
|
||||
);
|
||||
expect(() => verifyTrustedWorkflowRef(workflowSha, trustedTag, () => "c".repeat(40))).toThrow(
|
||||
`expected ${workflowSha}`,
|
||||
);
|
||||
expect(() =>
|
||||
verifyTrustedWorkflowRef(workflowSha, "release/2026.8.1", () => workflowSha),
|
||||
).toThrow("protected release-publish");
|
||||
});
|
||||
|
||||
it("bounds polling for the exact workflow run", () => {
|
||||
@@ -461,7 +586,7 @@ describe("full-release-validation-at-sha", () => {
|
||||
},
|
||||
() => CURRENT_WORKFLOW_SOURCE,
|
||||
),
|
||||
).toBe(verifierPath);
|
||||
).toEqual({ contract: "2", verifierPath });
|
||||
expect(checked).toEqual([workflowPath, verifierPath]);
|
||||
expect(() => assertTrustedWorkflowHarness("a".repeat(40), () => false)).toThrow(workflowPath);
|
||||
expect(() =>
|
||||
@@ -477,15 +602,30 @@ describe("full-release-validation-at-sha", () => {
|
||||
() => true,
|
||||
() => LEGACY_WORKFLOW_SOURCE,
|
||||
),
|
||||
).toThrow("does not declare RELEASE_ISOLATION_TOOLING_CONTRACT=1");
|
||||
).toThrow("does not declare a supported RELEASE_ISOLATION_TOOLING_CONTRACT");
|
||||
expect(() =>
|
||||
assertTrustedWorkflowHarness(
|
||||
"b".repeat(40),
|
||||
() => true,
|
||||
() =>
|
||||
'env:\n RELEASE_ISOLATION_TOOLING_CONTRACT: "1"\non:\n workflow_dispatch:\n inputs: {}\n',
|
||||
'env:\n RELEASE_ISOLATION_TOOLING_CONTRACT: "2"\non:\n workflow_dispatch:\n inputs: {}\n',
|
||||
),
|
||||
).toThrow(`Tooling SHA ${"b".repeat(40)} is missing workflow_dispatch input expected_sha`);
|
||||
expect(() =>
|
||||
assertTrustedWorkflowHarness(
|
||||
"b".repeat(40),
|
||||
() => true,
|
||||
() =>
|
||||
'env:\n RELEASE_ISOLATION_TOOLING_CONTRACT: "2"\non:\n workflow_dispatch:\n inputs:\n expected_sha: {}\n',
|
||||
),
|
||||
).toThrow("missing workflow_dispatch input trusted_workflow_json");
|
||||
expect(
|
||||
assertTrustedWorkflowHarness(
|
||||
"b".repeat(40),
|
||||
() => true,
|
||||
() => CONTRACT_ONE_WORKFLOW_SOURCE,
|
||||
),
|
||||
).toEqual({ contract: "1", verifierPath });
|
||||
});
|
||||
|
||||
it("retains a failed parent workflow ref for GitHub reruns", () => {
|
||||
@@ -576,6 +716,11 @@ describe("full-release-validation-at-sha", () => {
|
||||
target_context_ref: fixture.releaseRef,
|
||||
allow_unreleased_changelog: "false",
|
||||
});
|
||||
expect(JSON.parse(dispatchInputs.trusted_workflow_json ?? "{}")).toEqual({
|
||||
ref: "main",
|
||||
fullRef: "refs/heads/main",
|
||||
sha: fixture.workflowSha,
|
||||
});
|
||||
expect(ghCalls).toContainEqual(["api", "repos/openclaw/openclaw/actions/runs/123"]);
|
||||
expect(ghCalls.some((args) => args[0] === "graphql")).toBe(false);
|
||||
expect(ghCalls.some((args) => args[0] === "run" && args[1] === "watch")).toBe(false);
|
||||
@@ -604,10 +749,66 @@ describe("full-release-validation-at-sha", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("dispatches non-main tooling only when its exact protected tag is supplied", () => {
|
||||
const fixture = createDispatchFixture();
|
||||
try {
|
||||
const result = fixture.run([
|
||||
"--workflow-sha",
|
||||
fixture.workflowSha,
|
||||
"--trusted-workflow-ref",
|
||||
fixture.trustedWorkflowTag,
|
||||
]);
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
expect(result.stdout).toContain(`Trusted workflow ref: ${fixture.trustedWorkflowTag}`);
|
||||
expect(fixture.readCalls(fixture.gitCallsPath)).toContainEqual([
|
||||
"ls-remote",
|
||||
"--tags",
|
||||
"origin",
|
||||
`refs/tags/${fixture.trustedWorkflowTag}`,
|
||||
]);
|
||||
const dispatch = fixture
|
||||
.readCalls(fixture.ghCallsPath)
|
||||
.find((args) => args[0] === "workflow" && args[1] === "run");
|
||||
const trustedIdentity = dispatch
|
||||
?.find((arg) => arg.startsWith("trusted_workflow_json="))
|
||||
?.slice("trusted_workflow_json=".length);
|
||||
expect(JSON.parse(trustedIdentity ?? "{}")).toEqual({
|
||||
ref: fixture.trustedWorkflowTag,
|
||||
fullRef: `refs/tags/${fixture.trustedWorkflowTag}`,
|
||||
sha: fixture.workflowSha,
|
||||
});
|
||||
} finally {
|
||||
fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("disables evidence reuse and omits the contract 2 input for contract 1 tooling", () => {
|
||||
const fixture = createDispatchFixture({ workflowSource: CONTRACT_ONE_WORKFLOW_SOURCE });
|
||||
try {
|
||||
const result = fixture.run([
|
||||
"--workflow-sha",
|
||||
fixture.workflowSha,
|
||||
"--trusted-workflow-ref",
|
||||
fixture.trustedWorkflowTag,
|
||||
]);
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
const dispatch = fixture
|
||||
.readCalls(fixture.ghCallsPath)
|
||||
.find((args) => args[0] === "workflow" && args[1] === "run");
|
||||
const assignments = (dispatch ?? [])
|
||||
.filter((_value, index, values) => values[index - 1] === "-f")
|
||||
.map((value) => value.split("=", 1)[0]);
|
||||
expect(assignments).not.toContain("trusted_workflow_json");
|
||||
expect(dispatch).toContain("reuse_evidence=false");
|
||||
} finally {
|
||||
fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects pinned old-schema tooling before either remote ref is pushed", () => {
|
||||
const fixture = createDispatchFixture({
|
||||
workflowSource:
|
||||
'name: Full Release Validation\nenv:\n RELEASE_ISOLATION_TOOLING_CONTRACT: "1"\non:\n workflow_dispatch:\n',
|
||||
'name: Full Release Validation\nenv:\n RELEASE_ISOLATION_TOOLING_CONTRACT: "2"\non:\n workflow_dispatch:\n',
|
||||
});
|
||||
try {
|
||||
const result = fixture.run(["--workflow-sha", fixture.workflowSha]);
|
||||
@@ -629,7 +830,9 @@ describe("full-release-validation-at-sha", () => {
|
||||
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(result.stderr).toContain(
|
||||
"does not declare a supported RELEASE_ISOLATION_TOOLING_CONTRACT",
|
||||
);
|
||||
expect(fixture.readCalls(fixture.gitCallsPath).filter((args) => args[0] === "push")).toEqual(
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -24,7 +24,7 @@ function fixture(
|
||||
head_branch: BRANCH,
|
||||
head_sha: SHA,
|
||||
html_url: URL,
|
||||
path: `.github/workflows/openclaw-npm-release.yml@refs/tags/${BRANCH}`,
|
||||
path: ".github/workflows/openclaw-npm-release.yml",
|
||||
workflow_id: 101,
|
||||
},
|
||||
tag: {
|
||||
@@ -32,6 +32,8 @@ function fixture(
|
||||
verification: { verified: true },
|
||||
},
|
||||
tagRef: { object: { sha: TAG_OBJECT_SHA, type: "tag" } },
|
||||
trustedWorkflowFullRef: `refs/tags/${BRANCH}`,
|
||||
trustedWorkflowRef: BRANCH,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -80,8 +82,46 @@ describe("openclaw npm resume run identity", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts a successful run bound to the exact lightweight protected tooling tag", () => {
|
||||
expect(
|
||||
validateOpenClawNpmResumeRun(
|
||||
fixture({
|
||||
compareStatus: undefined,
|
||||
tag: {},
|
||||
tagRef: { object: { sha: SHA, type: "commit" } },
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
tagObjectSha: SHA,
|
||||
url: URL,
|
||||
workflowRef: `refs/tags/${BRANCH}`,
|
||||
workflowSha: SHA,
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts the canonical path shape returned by the Actions workflow run API", () => {
|
||||
expect(
|
||||
validateOpenClawNpmResumeRun(
|
||||
fixture({
|
||||
run: {
|
||||
conclusion: "success",
|
||||
event: "workflow_dispatch",
|
||||
head_branch: BRANCH,
|
||||
head_sha: SHA,
|
||||
html_url: URL,
|
||||
path: ".github/workflows/openclaw-npm-release.yml",
|
||||
workflow_id: 101,
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
workflowRef: `refs/tags/${BRANCH}`,
|
||||
workflowSha: SHA,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["branch", { run: { ...fixture().run, head_branch: "main" } }, "untrusted workflow ref"],
|
||||
["branch", { run: { ...fixture().run, head_branch: "main" } }, "untrusted workflow identity"],
|
||||
["workflow", { run: { ...fixture().run, workflow_id: 999 } }, "untrusted workflow identity"],
|
||||
["event", { run: { ...fixture().run, event: "push" } }, "untrusted workflow identity"],
|
||||
[
|
||||
@@ -94,10 +134,29 @@ describe("openclaw npm resume run identity", () => {
|
||||
{ run: { ...fixture().run, path: ".github/workflows/ci.yml" } },
|
||||
"untrusted workflow identity",
|
||||
],
|
||||
[
|
||||
"same-name branch full ref",
|
||||
{ trustedWorkflowFullRef: `refs/heads/${BRANCH}` },
|
||||
"untrusted workflow ref",
|
||||
],
|
||||
[
|
||||
"mismatched supplied ref",
|
||||
{ trustedWorkflowRef: `release-publish/${SHA.slice(0, 12)}-124` },
|
||||
"untrusted workflow ref",
|
||||
],
|
||||
[
|
||||
"tag kind",
|
||||
{ tagRef: { object: { sha: TAG_OBJECT_SHA, type: "commit" } } },
|
||||
"not a signed annotated tag",
|
||||
{ tagRef: { object: { sha: TAG_OBJECT_SHA, type: "tree" } } },
|
||||
"not a protected tag",
|
||||
],
|
||||
[
|
||||
"moved lightweight tag",
|
||||
{
|
||||
compareStatus: undefined,
|
||||
tag: {},
|
||||
tagRef: { object: { sha: "c".repeat(40), type: "commit" } },
|
||||
},
|
||||
"moved after dispatch",
|
||||
],
|
||||
[
|
||||
"tag target",
|
||||
@@ -140,8 +199,52 @@ describe("openclaw npm resume run identity", () => {
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveOpenClawNpmResumeRun({ repo: "openclaw/openclaw", runGh, runId: "456" }),
|
||||
resolveOpenClawNpmResumeRun({
|
||||
repo: "openclaw/openclaw",
|
||||
runGh,
|
||||
runId: "456",
|
||||
trustedWorkflowFullRef: `refs/tags/${BRANCH}`,
|
||||
trustedWorkflowRef: BRANCH,
|
||||
}),
|
||||
).toMatchObject({ workflowRef: `refs/tags/${BRANCH}`, workflowSha: SHA });
|
||||
expect(runGh).toHaveBeenCalledTimes(6);
|
||||
});
|
||||
|
||||
it("loads a lightweight protected tag without requiring tag metadata or main ancestry", () => {
|
||||
const lightweight = fixture({
|
||||
compareStatus: undefined,
|
||||
tag: {},
|
||||
tagRef: { object: { sha: SHA, type: "commit" } },
|
||||
});
|
||||
const responses = new Map<string, unknown>([
|
||||
[`api repos/openclaw/openclaw/actions/runs/456 --method GET`, lightweight.run],
|
||||
[
|
||||
`api repos/openclaw/openclaw/actions/workflows/openclaw-npm-release.yml --method GET`,
|
||||
{ id: 101 },
|
||||
],
|
||||
[`api repos/openclaw/openclaw/git/ref/tags/${BRANCH} --method GET`, lightweight.tagRef],
|
||||
[`run view 456 --repo openclaw/openclaw --json jobs --jq .jobs`, lightweight.jobs],
|
||||
]);
|
||||
const runGh = vi.fn((args: string[]) => {
|
||||
const response = responses.get(args.join(" "));
|
||||
if (!response) {
|
||||
throw new Error(`Unexpected gh invocation: ${args.join(" ")}`);
|
||||
}
|
||||
return JSON.stringify(response);
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveOpenClawNpmResumeRun({
|
||||
repo: "openclaw/openclaw",
|
||||
runGh,
|
||||
runId: "456",
|
||||
trustedWorkflowFullRef: `refs/tags/${BRANCH}`,
|
||||
trustedWorkflowRef: BRANCH,
|
||||
}),
|
||||
).toMatchObject({ workflowRef: `refs/tags/${BRANCH}`, workflowSha: SHA });
|
||||
expect(runGh).toHaveBeenCalledTimes(4);
|
||||
expect(runGh.mock.calls.flatMap(([args]) => args)).not.toContain(
|
||||
`repos/openclaw/openclaw/compare/${SHA}...main`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -931,8 +931,11 @@ function runOpenClawNpmTrustedRefGuard(overrides: Record<string, string>) {
|
||||
throw new Error("Expected OpenClaw npm trusted ref guard");
|
||||
}
|
||||
const binDir = tempDirs.make("openclaw-npm-trusted-ref-");
|
||||
const ghPath = `${binDir}/gh`;
|
||||
const gitPath = `${binDir}/git`;
|
||||
const timeoutPath = `${binDir}/timeout`;
|
||||
writeFileSync(ghPath, `#!/bin/sh\nprintf '%s\\n' "\${MOCK_REMOTE_TAG_SHA}"\n`);
|
||||
chmodSync(ghPath, 0o755);
|
||||
writeFileSync(
|
||||
gitPath,
|
||||
`#!/bin/sh\nif [ "$1" = "fetch" ]; then exit 0; fi\nif [ "$1" = "merge-base" ]; then [ "\${MOCK_WORKFLOW_ANCESTOR}" = "true" ]; exit $?; fi\nexit 2\n`,
|
||||
@@ -946,6 +949,8 @@ function runOpenClawNpmTrustedRefGuard(overrides: Record<string, string>) {
|
||||
return spawnSync("bash", ["-c", script], {
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
GITHUB_REPOSITORY: "openclaw/openclaw",
|
||||
MOCK_REMOTE_TAG_SHA: "a".repeat(40),
|
||||
MOCK_WORKFLOW_ANCESTOR: "true",
|
||||
PATH: `${binDir}:${process.env.PATH}`,
|
||||
RELEASE_NPM_DIST_TAG: "beta",
|
||||
@@ -957,6 +962,203 @@ function runOpenClawNpmTrustedRefGuard(overrides: Record<string, string>) {
|
||||
});
|
||||
}
|
||||
|
||||
function runPluginNpmPreflightToolingGuard(overrides: Record<string, string>) {
|
||||
const job = workflowJob(PLUGIN_NPM_RELEASE_WORKFLOW, "preview_plugins_npm");
|
||||
const script = workflowStep(job, "Verify trusted preflight tooling identity").run;
|
||||
if (!script) {
|
||||
throw new Error("Expected plugin npm preflight tooling identity guard");
|
||||
}
|
||||
const workdir = tempDirs.make("plugin-npm-preflight-tooling-");
|
||||
const binDir = resolve(workdir, "bin");
|
||||
const toolingDir = resolve(workdir, ".release-tooling/scripts");
|
||||
const toolingLibDir = resolve(toolingDir, "lib");
|
||||
mkdirSync(binDir, { recursive: true });
|
||||
mkdirSync(toolingLibDir, { recursive: true });
|
||||
writeFileSync(
|
||||
resolve(toolingDir, "release-tooling-identity.mjs"),
|
||||
readFileSync(resolve(REPO_ROOT, "scripts/release-tooling-identity.mjs")),
|
||||
);
|
||||
writeFileSync(
|
||||
resolve(toolingLibDir, "record-shared.mjs"),
|
||||
readFileSync(resolve(REPO_ROOT, "scripts/lib/record-shared.mjs")),
|
||||
);
|
||||
writeFileSync(
|
||||
resolve(binDir, "gh"),
|
||||
`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
[[ "$1" == "api" ]] || exit 64
|
||||
case "$2" in
|
||||
*/git/ref/tags/*)
|
||||
[[ "$MOCK_TAG_MISSING" != "true" ]] || exit 1
|
||||
jq -cn \
|
||||
--arg ref "$MOCK_TAG_FULL_REF" \
|
||||
--arg sha "$MOCK_TAG_SHA" \
|
||||
--arg type "$MOCK_TAG_TYPE" \
|
||||
'{ref: $ref, object: {sha: $sha, type: $type}}'
|
||||
;;
|
||||
*/compare/*)
|
||||
jq -cn --arg status "$MOCK_COMPARE_STATUS" '{status: $status}'
|
||||
;;
|
||||
*)
|
||||
exit 64
|
||||
;;
|
||||
esac
|
||||
`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
return spawnSync("bash", ["-c", script], {
|
||||
cwd: workdir,
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
GITHUB_REPOSITORY: "openclaw/openclaw",
|
||||
MOCK_COMPARE_STATUS: "identical",
|
||||
MOCK_TAG_FULL_REF: "",
|
||||
MOCK_TAG_MISSING: "false",
|
||||
MOCK_TAG_SHA: "",
|
||||
MOCK_TAG_TYPE: "commit",
|
||||
PATH: `${binDir}:${process.env.PATH}`,
|
||||
...overrides,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type ProtectedPreflightConsumerParams = {
|
||||
currentRef: string;
|
||||
currentWorkflowSha: string;
|
||||
liveTagSha?: string;
|
||||
preflightHeadBranch: string;
|
||||
preflightHeadSha: string;
|
||||
};
|
||||
|
||||
function runReleasePublishPreflightConsumerGuard(params: ProtectedPreflightConsumerParams) {
|
||||
const job = workflowJob(RELEASE_PUBLISH_WORKFLOW, "resolve_release_target");
|
||||
const script = workflowStep(job, "Download OpenClaw npm preflight manifest").run;
|
||||
if (!script) {
|
||||
throw new Error("Expected release publish preflight consumer guard");
|
||||
}
|
||||
const workdir = tempDirs.make("release-publish-preflight-consumer-");
|
||||
const binDir = resolve(workdir, "bin");
|
||||
const runnerTemp = resolve(workdir, "runner");
|
||||
mkdirSync(binDir);
|
||||
mkdirSync(runnerTemp);
|
||||
writeFileSync(
|
||||
resolve(binDir, "gh"),
|
||||
`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [[ "$1" == "run" && "$2" == "download" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$1" == "api" ]]; then
|
||||
printf '%s\\n' "$MOCK_PREFLIGHT_RUN"
|
||||
exit 0
|
||||
fi
|
||||
exit 64
|
||||
`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
return spawnSync("bash", ["-c", script], {
|
||||
cwd: workdir,
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
GITHUB_OUTPUT: resolve(workdir, "github-output"),
|
||||
GITHUB_REF: params.currentRef,
|
||||
GITHUB_REPOSITORY: "openclaw/openclaw",
|
||||
MOCK_PREFLIGHT_RUN: JSON.stringify({
|
||||
conclusion: "success",
|
||||
event: "workflow_dispatch",
|
||||
head_branch: params.preflightHeadBranch,
|
||||
head_sha: params.preflightHeadSha,
|
||||
path: ".github/workflows/openclaw-npm-release.yml",
|
||||
run_attempt: 1,
|
||||
}),
|
||||
PATH: `${binDir}:${process.env.PATH}`,
|
||||
PREFLIGHT_RUN_ID: "111",
|
||||
RELEASE_NPM_DIST_TAG: "beta",
|
||||
RELEASE_TAG: "v2026.8.1-beta.3",
|
||||
RUNNER_TEMP: runnerTemp,
|
||||
WORKFLOW_SHA: params.currentWorkflowSha,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function runOpenClawNpmPreflightConsumerGuard(params: ProtectedPreflightConsumerParams) {
|
||||
const job = workflowJob(OPENCLAW_NPM_RELEASE_WORKFLOW, "publish_openclaw_npm");
|
||||
const script = workflowStep(job, "Verify preflight run metadata").run;
|
||||
if (!script) {
|
||||
throw new Error("Expected OpenClaw npm preflight consumer guard");
|
||||
}
|
||||
const workdir = tempDirs.make("openclaw-npm-preflight-consumer-");
|
||||
const binDir = resolve(workdir, "bin");
|
||||
mkdirSync(binDir);
|
||||
writeFileSync(
|
||||
resolve(binDir, "gh"),
|
||||
`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [[ "$1" == "run" && "$2" == "view" ]]; then
|
||||
printf '%s\\n' "$MOCK_PREFLIGHT_RUN"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$1" == "api" ]]; then
|
||||
if [[ "$2" == *"/git/ref/tags/"* ]]; then
|
||||
printf '%s\\n' "$MOCK_REMOTE_TAG_SHA"
|
||||
exit 0
|
||||
fi
|
||||
printf '1\\n'
|
||||
exit 0
|
||||
fi
|
||||
exit 64
|
||||
`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
writeFileSync(
|
||||
resolve(binDir, "git"),
|
||||
`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [[ "$*" == "rev-parse HEAD" ]]; then
|
||||
printf '%s\\n' "$MOCK_RELEASE_SHA"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$*" == *"cat-file -e"* || "$*" == *"merge-base --is-ancestor"* || "$*" == *" fetch "* ]]; then
|
||||
exit 0
|
||||
fi
|
||||
exit 64
|
||||
`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
writeFileSync(
|
||||
resolve(binDir, "node"),
|
||||
`#!/usr/bin/env bash
|
||||
cat >/dev/null
|
||||
`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
return spawnSync("bash", ["-c", script], {
|
||||
cwd: workdir,
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
EXPECTED_EXTENDED_STABLE_BRANCH: "",
|
||||
GITHUB_OUTPUT: resolve(workdir, "github-output"),
|
||||
GITHUB_REPOSITORY: "openclaw/openclaw",
|
||||
MOCK_PREFLIGHT_RUN: JSON.stringify({
|
||||
conclusion: "success",
|
||||
event: "workflow_dispatch",
|
||||
headBranch: params.preflightHeadBranch,
|
||||
headSha: params.preflightHeadSha,
|
||||
url: "https://github.com/openclaw/openclaw/actions/runs/111",
|
||||
workflowName: "OpenClaw NPM Release",
|
||||
}),
|
||||
MOCK_RELEASE_SHA: "d".repeat(40),
|
||||
MOCK_REMOTE_TAG_SHA: params.liveTagSha ?? params.currentWorkflowSha,
|
||||
PATH: `${binDir}:${process.env.PATH}`,
|
||||
PREFLIGHT_RUN_ID: "111",
|
||||
RELEASE_NPM_DIST_TAG: "beta",
|
||||
RUN_KIND: "preflight",
|
||||
WORKFLOW_REF: params.currentRef,
|
||||
WORKFLOW_SHA: params.currentWorkflowSha,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type ReleaseCheckArtifact = {
|
||||
expired: boolean;
|
||||
id: number;
|
||||
@@ -1224,6 +1426,8 @@ describe("package acceptance workflow", () => {
|
||||
expect(dispatch.run).toContain(
|
||||
'-f plugin_sdk_api_acknowledgement="${PLUGIN_SDK_API_ACKNOWLEDGEMENT}"',
|
||||
);
|
||||
expect(dispatch.run).toContain('--trusted-workflow-ref "${PARENT_WORKFLOW_BRANCH}"');
|
||||
expect(dispatch.run).toContain('--trusted-workflow-full-ref "${GITHUB_REF}"');
|
||||
});
|
||||
|
||||
it("requires selected plugin names or complete immutable evidence for broad publication", () => {
|
||||
@@ -1318,11 +1522,11 @@ describe("package acceptance workflow", () => {
|
||||
expect(verifyStep.run).not.toContain("npm view openclaw@extended-stable version");
|
||||
});
|
||||
|
||||
it("accepts only main-reachable protected SHA-pinned release publish tags", () => {
|
||||
it("accepts only exact protected SHA-pinned release publish tags", () => {
|
||||
const workflowSha = "a".repeat(40);
|
||||
const binDir = tempDirs.make("release-publish-gh-");
|
||||
const ghPath = `${binDir}/gh`;
|
||||
writeFileSync(ghPath, `#!/bin/sh\nprintf '%s\\n' "\${MOCK_MERGE_BASE_SHA}"\n`);
|
||||
writeFileSync(ghPath, `#!/bin/sh\nprintf '%s\\n' "\${MOCK_REMOTE_TAG_SHA}"\n`);
|
||||
chmodSync(ghPath, 0o755);
|
||||
const pinnedEnv = {
|
||||
GITHUB_REPOSITORY: "openclaw/openclaw",
|
||||
@@ -1333,7 +1537,7 @@ describe("package acceptance workflow", () => {
|
||||
|
||||
const valid = runReleasePublishInputValidation({
|
||||
...pinnedEnv,
|
||||
MOCK_MERGE_BASE_SHA: workflowSha,
|
||||
MOCK_REMOTE_TAG_SHA: workflowSha,
|
||||
});
|
||||
expect(valid.status, valid.stderr).toBe(0);
|
||||
|
||||
@@ -1346,13 +1550,13 @@ describe("package acceptance workflow", () => {
|
||||
"SHA-pinned release publish tag does not match workflow SHA",
|
||||
);
|
||||
|
||||
const unreachable = runReleasePublishInputValidation({
|
||||
const moved = runReleasePublishInputValidation({
|
||||
...pinnedEnv,
|
||||
MOCK_MERGE_BASE_SHA: "c".repeat(40),
|
||||
MOCK_REMOTE_TAG_SHA: "c".repeat(40),
|
||||
});
|
||||
expect(unreachable.status).toBe(1);
|
||||
expect(unreachable.stderr).toContain(
|
||||
"SHA-pinned release publish tag revision is not reachable from current main",
|
||||
expect(moved.status).toBe(1);
|
||||
expect(moved.stderr).toContain(
|
||||
"SHA-pinned release publish tag does not resolve to workflow SHA",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1363,6 +1567,7 @@ describe("package acceptance workflow", () => {
|
||||
const valid = runOpenClawNpmTrustedRefGuard({
|
||||
WORKFLOW_REF: protectedRef,
|
||||
WORKFLOW_SHA: workflowSha,
|
||||
MOCK_REMOTE_TAG_SHA: workflowSha,
|
||||
});
|
||||
expect(valid.status, valid.stderr).toBe(0);
|
||||
|
||||
@@ -1375,26 +1580,327 @@ describe("package acceptance workflow", () => {
|
||||
"SHA-pinned release-publish tag does not match the OpenClaw npm workflow SHA",
|
||||
);
|
||||
|
||||
const unreachable = runOpenClawNpmTrustedRefGuard({
|
||||
MOCK_WORKFLOW_ANCESTOR: "false",
|
||||
const moved = runOpenClawNpmTrustedRefGuard({
|
||||
MOCK_REMOTE_TAG_SHA: "c".repeat(40),
|
||||
WORKFLOW_REF: protectedRef,
|
||||
WORKFLOW_SHA: workflowSha,
|
||||
});
|
||||
expect(unreachable.status).toBe(1);
|
||||
expect(unreachable.stderr).toContain(
|
||||
"SHA-pinned OpenClaw npm workflow revision is not reachable from current main",
|
||||
expect(moved.status).toBe(1);
|
||||
expect(moved.stderr).toContain(
|
||||
"SHA-pinned release-publish tag does not resolve to the OpenClaw npm workflow SHA",
|
||||
);
|
||||
});
|
||||
|
||||
it("allows protected SHA-pinned tooling tags to consume token-bootstrap evidence", () => {
|
||||
it("runs plugin npm preflight trust from the exact workflow tooling checkout", () => {
|
||||
const job = workflowJob(PLUGIN_NPM_RELEASE_WORKFLOW, "preview_plugins_npm");
|
||||
const checkout = workflowStep(job, "Checkout trusted preflight tooling");
|
||||
const identity = workflowStep(job, "Verify trusted preflight tooling identity");
|
||||
const target = workflowStep(job, "Validate ref is on a trusted publish branch");
|
||||
|
||||
expect(checkout.if).toBe("github.event_name == 'workflow_dispatch' && inputs.preflight_only");
|
||||
expect(checkout.with).toMatchObject({
|
||||
"fetch-depth": 1,
|
||||
path: ".release-tooling",
|
||||
"persist-credentials": false,
|
||||
ref: "${{ github.workflow_sha }}",
|
||||
"sparse-checkout": "scripts/lib/record-shared.mjs\nscripts/release-tooling-identity.mjs\n",
|
||||
"sparse-checkout-cone-mode": false,
|
||||
});
|
||||
expect(identity.if).toBe("github.event_name == 'workflow_dispatch' && inputs.preflight_only");
|
||||
expect(identity.env).toMatchObject({
|
||||
GH_TOKEN: "${{ github.token }}",
|
||||
WORKFLOW_FULL_REF: "${{ github.ref }}",
|
||||
WORKFLOW_REF: "${{ github.ref_name }}",
|
||||
WORKFLOW_SHA: "${{ github.workflow_sha }}",
|
||||
});
|
||||
expect(identity.run).toContain(
|
||||
"node .release-tooling/scripts/release-tooling-identity.mjs verify",
|
||||
);
|
||||
expect(target.run).not.toContain('WORKFLOW_REF}" != "refs/heads/main');
|
||||
expect(target.run).not.toContain('git merge-base --is-ancestor "${WORKFLOW_SHA}" origin/main');
|
||||
});
|
||||
|
||||
it("accepts only the live exact lightweight protected tag for plugin npm preflight", () => {
|
||||
const workflowSha = "a".repeat(40);
|
||||
const workflowRef = `release-publish/${workflowSha.slice(0, 12)}-123`;
|
||||
const workflowFullRef = `refs/tags/${workflowRef}`;
|
||||
const baseEnv = {
|
||||
MOCK_TAG_FULL_REF: workflowFullRef,
|
||||
MOCK_TAG_SHA: workflowSha,
|
||||
WORKFLOW_FULL_REF: workflowFullRef,
|
||||
WORKFLOW_REF: workflowRef,
|
||||
WORKFLOW_SHA: workflowSha,
|
||||
};
|
||||
|
||||
const valid = runPluginNpmPreflightToolingGuard(baseEnv);
|
||||
expect(valid.status, valid.stderr).toBe(0);
|
||||
|
||||
for (const rejected of [
|
||||
{
|
||||
name: "moved tag",
|
||||
env: { ...baseEnv, MOCK_TAG_SHA: "b".repeat(40) },
|
||||
error: "missing, moved, annotated, or bound to the wrong SHA",
|
||||
},
|
||||
{
|
||||
name: "annotated tag",
|
||||
env: { ...baseEnv, MOCK_TAG_TYPE: "tag" },
|
||||
error: "missing, moved, annotated, or bound to the wrong SHA",
|
||||
},
|
||||
{
|
||||
name: "wrong SHA prefix",
|
||||
env: {
|
||||
...baseEnv,
|
||||
MOCK_TAG_FULL_REF: `refs/tags/release-publish/${"b".repeat(12)}-123`,
|
||||
WORKFLOW_FULL_REF: `refs/tags/release-publish/${"b".repeat(12)}-123`,
|
||||
WORKFLOW_REF: `release-publish/${"b".repeat(12)}-123`,
|
||||
},
|
||||
error: "SHA prefix does not match",
|
||||
},
|
||||
{
|
||||
name: "same-name branch",
|
||||
env: { ...baseEnv, WORKFLOW_FULL_REF: `refs/heads/${workflowRef}` },
|
||||
error: "exact tag full ref",
|
||||
},
|
||||
]) {
|
||||
const result = runPluginNpmPreflightToolingGuard(rejected.env);
|
||||
expect(result.status, rejected.name).toBe(1);
|
||||
expect(result.stderr, rejected.name).toContain(rejected.error);
|
||||
}
|
||||
});
|
||||
|
||||
it("binds aggregate preflight consumption to the exact protected tooling tag and SHA", () => {
|
||||
const workflowSha = "a".repeat(40);
|
||||
const workflowTag = `release-publish/${workflowSha.slice(0, 12)}-123`;
|
||||
const valid = runReleasePublishPreflightConsumerGuard({
|
||||
currentRef: `refs/tags/${workflowTag}`,
|
||||
currentWorkflowSha: workflowSha,
|
||||
preflightHeadBranch: workflowTag,
|
||||
preflightHeadSha: workflowSha,
|
||||
});
|
||||
expect(valid.status, valid.stderr).toBe(0);
|
||||
|
||||
for (const rejected of [
|
||||
{
|
||||
currentRef: `refs/tags/${workflowTag}`,
|
||||
preflightHeadBranch: `${workflowTag}-wrong`,
|
||||
preflightHeadSha: workflowSha,
|
||||
},
|
||||
{
|
||||
currentRef: `refs/tags/${workflowTag}`,
|
||||
preflightHeadBranch: workflowTag,
|
||||
preflightHeadSha: "b".repeat(40),
|
||||
},
|
||||
{
|
||||
currentRef: `refs/heads/${workflowTag}`,
|
||||
preflightHeadBranch: workflowTag,
|
||||
preflightHeadSha: workflowSha,
|
||||
},
|
||||
]) {
|
||||
const result = runReleasePublishPreflightConsumerGuard({
|
||||
...rejected,
|
||||
currentWorkflowSha: workflowSha,
|
||||
});
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain("exact protected release-publish tag");
|
||||
}
|
||||
});
|
||||
|
||||
it("binds core npm preflight consumption to the exact protected tooling tag and SHA", () => {
|
||||
const workflowSha = "a".repeat(40);
|
||||
const workflowTag = `release-publish/${workflowSha.slice(0, 12)}-123`;
|
||||
const valid = runOpenClawNpmPreflightConsumerGuard({
|
||||
currentRef: `refs/tags/${workflowTag}`,
|
||||
currentWorkflowSha: workflowSha,
|
||||
preflightHeadBranch: workflowTag,
|
||||
preflightHeadSha: workflowSha,
|
||||
});
|
||||
expect(valid.status, valid.stderr).toBe(0);
|
||||
|
||||
for (const rejected of [
|
||||
{
|
||||
currentRef: `refs/tags/${workflowTag}`,
|
||||
preflightHeadBranch: `${workflowTag}-wrong`,
|
||||
preflightHeadSha: workflowSha,
|
||||
},
|
||||
{
|
||||
currentRef: `refs/tags/${workflowTag}`,
|
||||
preflightHeadBranch: workflowTag,
|
||||
preflightHeadSha: "b".repeat(40),
|
||||
},
|
||||
{
|
||||
currentRef: `refs/heads/${workflowTag}`,
|
||||
preflightHeadBranch: workflowTag,
|
||||
preflightHeadSha: workflowSha,
|
||||
},
|
||||
]) {
|
||||
const result = runOpenClawNpmPreflightConsumerGuard({
|
||||
...rejected,
|
||||
currentWorkflowSha: workflowSha,
|
||||
});
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain("exact protected release-publish tag");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a protected tooling tag moved after request validation and environment approval", () => {
|
||||
const workflowSha = "a".repeat(40);
|
||||
const workflowTag = `release-publish/${workflowSha.slice(0, 12)}-123`;
|
||||
const protectedRef = `refs/tags/${workflowTag}`;
|
||||
const predecessor = runOpenClawNpmTrustedRefGuard({
|
||||
MOCK_REMOTE_TAG_SHA: workflowSha,
|
||||
WORKFLOW_REF: protectedRef,
|
||||
WORKFLOW_SHA: workflowSha,
|
||||
});
|
||||
expect(predecessor.status, predecessor.stderr).toBe(0);
|
||||
|
||||
const consumer = runOpenClawNpmPreflightConsumerGuard({
|
||||
currentRef: protectedRef,
|
||||
currentWorkflowSha: workflowSha,
|
||||
liveTagSha: "b".repeat(40),
|
||||
preflightHeadBranch: workflowTag,
|
||||
preflightHeadSha: workflowSha,
|
||||
});
|
||||
expect(consumer.status).toBe(1);
|
||||
expect(consumer.stderr).toContain(
|
||||
"Protected release-publish tag moved after npm-release approval",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the canonical tooling identity verifier for token-bootstrap evidence", () => {
|
||||
const publishJob = workflowJob(PLUGIN_NPM_RELEASE_WORKFLOW, "publish_plugins_npm");
|
||||
const evidenceStep = workflowStep(publishJob, "Consume immutable npm publication evidence");
|
||||
|
||||
expect(evidenceStep.run).toContain("^refs/tags/release-publish/([a-f0-9]{12})-[1-9][0-9]*$");
|
||||
expect(evidenceStep.run).toContain(
|
||||
'[[ "$WORKFLOW_REF" == "refs/heads/main" || "$sha_pinned_release_publish" == "true" ]]',
|
||||
expect(evidenceStep.env?.RELEASE_PUBLISH_RUN_ID).toBe("${{ inputs.release_publish_run_id }}");
|
||||
expect(evidenceStep.env?.RELEASE_PUBLISH_RUN_ATTEMPT).toBe(
|
||||
"${{ inputs.release_publish_run_attempt }}",
|
||||
);
|
||||
expect(evidenceStep.run).toContain('git merge-base --is-ancestor "$WORKFLOW_SHA" origin/main');
|
||||
expect(evidenceStep.env?.RELEASE_PUBLISH_PARENT_STATE_POLICY).toBe(
|
||||
"${{ inputs.release_publish_run_id != '' && (github.actor == 'github-actions[bot]' && 'active' || 'manual-recovery') || '' }}",
|
||||
);
|
||||
expect(evidenceStep.run).toContain("node scripts/release-tooling-identity.mjs verify");
|
||||
expect(evidenceStep.run).toContain('--workflow-ref "$WORKFLOW_HEAD_BRANCH"');
|
||||
expect(evidenceStep.run).toContain('--workflow-full-ref "$WORKFLOW_REF"');
|
||||
expect(evidenceStep.run).toContain('--workflow-sha "$WORKFLOW_SHA"');
|
||||
expect(evidenceStep.run).toContain('--release-publish-run-id "$RELEASE_PUBLISH_RUN_ID"');
|
||||
expect(evidenceStep.run).toContain(
|
||||
'--release-publish-run-attempt "$RELEASE_PUBLISH_RUN_ATTEMPT"',
|
||||
);
|
||||
expect(evidenceStep.run).toContain(
|
||||
'--release-publish-parent-state-policy "$RELEASE_PUBLISH_PARENT_STATE_POLICY"',
|
||||
);
|
||||
expect(evidenceStep.run).not.toContain("--allow-prevalidated-ref");
|
||||
});
|
||||
|
||||
it("revalidates protected tooling immediately before every core and plugin npm publish", () => {
|
||||
const corePublish = workflowStep(
|
||||
workflowJob(OPENCLAW_NPM_RELEASE_WORKFLOW, "publish_openclaw_npm"),
|
||||
"Publish",
|
||||
);
|
||||
expect(corePublish.env).toMatchObject({
|
||||
GH_TOKEN: "${{ github.token }}",
|
||||
RELEASE_PUBLISH_PARENT_STATE_POLICY:
|
||||
"${{ inputs.release_publish_run_id != '' && (github.actor == 'github-actions[bot]' && 'active' || 'manual-recovery') || '' }}",
|
||||
RELEASE_PUBLISH_RUN_ATTEMPT: "${{ inputs.release_publish_run_attempt }}",
|
||||
RELEASE_PUBLISH_RUN_ID: "${{ inputs.release_publish_run_id }}",
|
||||
WORKFLOW_FULL_REF: "${{ github.ref }}",
|
||||
WORKFLOW_REF: "${{ github.ref_name }}",
|
||||
WORKFLOW_SHA: "${{ github.workflow_sha }}",
|
||||
});
|
||||
expect(corePublish.run).toContain(
|
||||
"node trusted-workflow/scripts/release-tooling-identity.mjs verify",
|
||||
);
|
||||
expect(corePublish.run).toContain("--allow-prevalidated-ref");
|
||||
expect(corePublish.run).toContain(
|
||||
'--release-publish-run-attempt "$RELEASE_PUBLISH_RUN_ATTEMPT"',
|
||||
);
|
||||
expect(corePublish.run).toContain(
|
||||
'--release-publish-parent-state-policy "$RELEASE_PUBLISH_PARENT_STATE_POLICY"',
|
||||
);
|
||||
expect(corePublish.run).toMatch(
|
||||
/verify_release_tooling_identity\s+bash scripts\/openclaw-npm-publish\.sh --publish "\.\/\$\{tarball_path\}"/u,
|
||||
);
|
||||
expect(corePublish.run).toMatch(
|
||||
/verify_release_tooling_identity\s+bash scripts\/openclaw-npm-publish\.sh --publish "\$\{publish_target\}"/u,
|
||||
);
|
||||
|
||||
const pluginPublishJob = workflowJob(PLUGIN_NPM_RELEASE_WORKFLOW, "publish_plugins_npm");
|
||||
const oidcPublish = workflowStep(pluginPublishJob, "Publish with trusted publisher");
|
||||
expect(oidcPublish.env).toMatchObject({
|
||||
GH_TOKEN: "${{ github.token }}",
|
||||
OPENCLAW_RELEASE_PUBLISH_RUN_ATTEMPT: "${{ inputs.release_publish_run_attempt }}",
|
||||
OPENCLAW_RELEASE_PUBLISH_RUN_ID: "${{ inputs.release_publish_run_id }}",
|
||||
OPENCLAW_RELEASE_PUBLISH_PARENT_STATE_POLICY:
|
||||
"${{ inputs.release_publish_run_id != '' && (github.actor == 'github-actions[bot]' && 'active' || 'manual-recovery') || '' }}",
|
||||
OPENCLAW_RELEASE_TOOLING_ALLOW_PREVALIDATED_REF: "true",
|
||||
OPENCLAW_RELEASE_TOOLING_FULL_REF: "${{ github.ref }}",
|
||||
OPENCLAW_RELEASE_TOOLING_IDENTITY_REQUIRED: "true",
|
||||
OPENCLAW_RELEASE_TOOLING_REF: "${{ github.ref_name }}",
|
||||
OPENCLAW_RELEASE_TOOLING_REPOSITORY: "${{ github.repository }}",
|
||||
OPENCLAW_RELEASE_TOOLING_SHA: "${{ github.workflow_sha }}",
|
||||
});
|
||||
|
||||
const bootstrapPublish = workflowStep(pluginPublishJob, "Publish approved bootstrap tarball");
|
||||
expect(bootstrapPublish.env).toMatchObject({
|
||||
GH_TOKEN: "${{ github.token }}",
|
||||
RELEASE_PUBLISH_PARENT_STATE_POLICY:
|
||||
"${{ inputs.release_publish_run_id != '' && (github.actor == 'github-actions[bot]' && 'active' || 'manual-recovery') || '' }}",
|
||||
RELEASE_PUBLISH_RUN_ATTEMPT: "${{ inputs.release_publish_run_attempt }}",
|
||||
RELEASE_PUBLISH_RUN_ID: "${{ inputs.release_publish_run_id }}",
|
||||
WORKFLOW_FULL_REF: "${{ github.ref }}",
|
||||
WORKFLOW_REF: "${{ github.ref_name }}",
|
||||
WORKFLOW_SHA: "${{ github.workflow_sha }}",
|
||||
});
|
||||
const identityIndex =
|
||||
bootstrapPublish.run?.indexOf("node scripts/release-tooling-identity.mjs verify") ?? -1;
|
||||
const publishIndex = bootstrapPublish.run?.indexOf('npm publish "$TARBALL_PATH"') ?? -1;
|
||||
expect(identityIndex).toBeGreaterThan(-1);
|
||||
expect(publishIndex).toBeGreaterThan(identityIndex);
|
||||
expect(bootstrapPublish.run?.slice(identityIndex, publishIndex)).not.toContain("npm view");
|
||||
expect(bootstrapPublish.run).toContain(
|
||||
'--release-publish-parent-state-policy "$RELEASE_PUBLISH_PARENT_STATE_POLICY"',
|
||||
);
|
||||
|
||||
const pluginWrapper = readFileSync("scripts/plugin-npm-publish.sh", "utf8");
|
||||
expect(pluginWrapper).toContain(
|
||||
'--release-publish-parent-state-policy "${OPENCLAW_RELEASE_PUBLISH_PARENT_STATE_POLICY:-}"',
|
||||
);
|
||||
const distTagIndex = pluginWrapper.indexOf(
|
||||
'npm dist-tag add "${package_name}@${package_version}"',
|
||||
);
|
||||
const distTagIdentityIndex = pluginWrapper.lastIndexOf(
|
||||
"verify_release_tooling_identity",
|
||||
distTagIndex,
|
||||
);
|
||||
expect(distTagIdentityIndex).toBeGreaterThan(-1);
|
||||
expect(distTagIndex).toBeGreaterThan(distTagIdentityIndex);
|
||||
});
|
||||
|
||||
it("binds release evidence validation to the exact trusted workflow ref", () => {
|
||||
for (const [workflowPath, jobName, stepName] of [
|
||||
[
|
||||
RELEASE_PUBLISH_WORKFLOW,
|
||||
"resolve_release_target",
|
||||
"Validate full release validation manifest",
|
||||
],
|
||||
[
|
||||
OPENCLAW_NPM_RELEASE_WORKFLOW,
|
||||
"publish_openclaw_npm",
|
||||
"Verify full release validation evidence",
|
||||
],
|
||||
] as const) {
|
||||
const step = workflowStep(workflowJob(workflowPath, jobName), stepName);
|
||||
expect(step.env).toMatchObject({
|
||||
TRUSTED_WORKFLOW_FULL_REF: "${{ github.ref }}",
|
||||
TRUSTED_WORKFLOW_REF: "${{ github.ref_name }}",
|
||||
TRUSTED_WORKFLOW_SHA: "${{ github.workflow_sha }}",
|
||||
});
|
||||
expect(step.run).toContain("^refs/tags/release-publish/[a-f0-9]{12}-[1-9][0-9]*$");
|
||||
expect(step.run).toContain('TRUSTED_MAIN_REF="${trusted_workflow_commit_ref}"');
|
||||
expect(step.run).toContain('--trusted-workflow-ref "$TRUSTED_WORKFLOW_REF"');
|
||||
expect(step.run).toContain('--trusted-workflow-full-ref "$TRUSTED_WORKFLOW_FULL_REF"');
|
||||
expect(step.run).toContain('--trusted-workflow-sha "$TRUSTED_WORKFLOW_SHA"');
|
||||
expect(step.run).toContain('--verifier-source-sha "$');
|
||||
}
|
||||
});
|
||||
|
||||
it("retries child environment approval when deployment propagation lags", () => {
|
||||
@@ -5174,6 +5680,7 @@ describe("package artifact reuse", () => {
|
||||
resolveTargetJob,
|
||||
"Checkout target package manifest",
|
||||
);
|
||||
const toolingIdentity = workflowStep(resolveTargetJob, "Resolve trusted workflow identity");
|
||||
const releaseInputValidation = workflowStep(resolveTargetJob, "Validate release inputs");
|
||||
const evidenceReuseStep = workflowStep(evidenceReuseJob, "Find reusable validation evidence");
|
||||
const releaseChecksDispatchStep = workflowStep(
|
||||
@@ -5189,6 +5696,14 @@ describe("package artifact reuse", () => {
|
||||
default: false,
|
||||
type: "boolean",
|
||||
},
|
||||
trusted_workflow_json: {
|
||||
default: "",
|
||||
required: false,
|
||||
type: "string",
|
||||
},
|
||||
});
|
||||
expect(readWorkflow(FULL_RELEASE_VALIDATION_WORKFLOW).env).toMatchObject({
|
||||
RELEASE_ISOLATION_TOOLING_CONTRACT: "2",
|
||||
});
|
||||
expect(workflow).toContain("CHILD_WORKFLOW_REF: ${{ github.ref_name }}");
|
||||
expect(workflow).toContain('gh workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@" 2>&1');
|
||||
@@ -5202,6 +5717,23 @@ describe("package artifact reuse", () => {
|
||||
expect(resolveTargetSteps.indexOf(targetManifestCheckout)).toBeLessThan(
|
||||
resolveTargetSteps.indexOf(releaseInputValidation),
|
||||
);
|
||||
expect(resolveTargetJob.outputs?.trusted_workflow_json).toBe(
|
||||
"${{ steps.tooling_identity.outputs.json }}",
|
||||
);
|
||||
expect(toolingIdentity.env).toMatchObject({
|
||||
GH_TOKEN: "${{ github.token }}",
|
||||
REQUESTED_IDENTITY_JSON: "${{ inputs.trusted_workflow_json }}",
|
||||
WORKFLOW_CONTRACT: "${{ env.RELEASE_ISOLATION_TOOLING_CONTRACT }}",
|
||||
WORKFLOW_FULL_REF: "${{ github.ref }}",
|
||||
WORKFLOW_REF: "${{ github.ref_name }}",
|
||||
WORKFLOW_SHA: "${{ github.sha }}",
|
||||
});
|
||||
expectTextToIncludeAll(toolingIdentity.run, [
|
||||
"node workflow/scripts/release-tooling-identity.mjs resolve",
|
||||
'--workflow-contract "$WORKFLOW_CONTRACT"',
|
||||
'--requested-identity-json "$REQUESTED_IDENTITY_JSON"',
|
||||
'echo "json=${identity}"',
|
||||
]);
|
||||
expectTextToIncludeAll(releaseInputValidation.run, [
|
||||
'target_version="$(jq -er',
|
||||
"does not belong to release branch",
|
||||
@@ -5227,6 +5759,7 @@ describe("package artifact reuse", () => {
|
||||
NPM_TELEGRAM_PROVIDER_MODE: "${{ inputs.npm_telegram_provider_mode }}",
|
||||
NPM_TELEGRAM_SCENARIO: "${{ inputs.npm_telegram_scenario }}",
|
||||
SKIP_PACKAGE_TELEGRAM_E2E: "${{ inputs.skip_package_telegram_e2e }}",
|
||||
TRUSTED_WORKFLOW_JSON: "${{ needs.resolve_target.outputs.trusted_workflow_json }}",
|
||||
});
|
||||
expectTextToIncludeAll(evidenceReuseStep.run, [
|
||||
"npmTelegramPackageSpec: $npmTelegramPackageSpec",
|
||||
@@ -5234,6 +5767,12 @@ describe("package artifact reuse", () => {
|
||||
"npmTelegramScenario: $npmTelegramScenario",
|
||||
"skipPackageTelegramE2e: $skipPackageTelegramE2e",
|
||||
"allowUnreleasedChangelog: $allowUnreleasedChangelog",
|
||||
'trusted_workflow_ref="$(jq -er',
|
||||
'trusted_workflow_full_ref="$(jq -er',
|
||||
'trusted_workflow_sha="$(jq -er',
|
||||
'--trusted-workflow-ref "$trusted_workflow_ref"',
|
||||
'--trusted-workflow-full-ref "$trusted_workflow_full_ref"',
|
||||
'--trusted-workflow-sha "$trusted_workflow_sha"',
|
||||
]);
|
||||
expect(targetSummaryStep.env).toMatchObject({
|
||||
SKIP_PACKAGE_TELEGRAM_E2E: "${{ inputs.skip_package_telegram_e2e }}",
|
||||
@@ -6214,14 +6753,14 @@ describe("package artifact reuse", () => {
|
||||
expect(trustedTooling.env?.WORKFLOW_SHA).toBe("${{ github.sha }}");
|
||||
expect(validateManifest.env).toMatchObject({
|
||||
RUN_JSON_FILE: "${{ runner.temp }}/full-release-validation-run.json",
|
||||
TRUSTED_MAIN_REF: "refs/remotes/origin/main",
|
||||
TRUSTED_WORKFLOW_FULL_REF: "${{ github.ref }}",
|
||||
TRUSTED_WORKFLOW_REF: "${{ github.ref_name }}",
|
||||
VALIDATOR_FILE:
|
||||
"${{ runner.temp }}/release-validation-tooling/validate-full-release-validation-evidence.mjs",
|
||||
STRICT_VALIDATOR_FILE: "${{ runner.temp }}/release-validation-tooling/release-ci-summary.mjs",
|
||||
});
|
||||
expect(validateManifest.run).toContain(
|
||||
'MANIFEST_FILE="$manifest" node "$VALIDATOR_FILE" < "$RUN_JSON_FILE"',
|
||||
);
|
||||
expect(validateManifest.run).toContain('MANIFEST_FILE="$manifest"');
|
||||
expect(validateManifest.run).toContain('node "$VALIDATOR_FILE" < "$RUN_JSON_FILE"');
|
||||
expect(publishDownload.with?.name).toBe(
|
||||
"full-release-validation-${{ inputs.full_release_validation_run_id }}-${{ needs.resolve_release_target.outputs.full_release_validation_run_attempt }}",
|
||||
);
|
||||
@@ -6661,6 +7200,28 @@ describe("package artifact reuse", () => {
|
||||
contents: "read",
|
||||
"id-token": "write",
|
||||
});
|
||||
expect(clawHubPublish.with?.trusted_tooling_identity_json).toBeUndefined();
|
||||
const clawHubPreview = workflowJob(PLUGIN_CLAWHUB_RELEASE_WORKFLOW, "preview_plugins_clawhub");
|
||||
expect(
|
||||
readWorkflow(PLUGIN_CLAWHUB_RELEASE_WORKFLOW).on?.workflow_dispatch?.inputs
|
||||
?.release_publish_run_attempt,
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
readWorkflow(PLUGIN_CLAWHUB_RELEASE_WORKFLOW).on?.workflow_dispatch?.inputs
|
||||
?.release_publish_full_ref,
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
readWorkflow(PLUGIN_CLAWHUB_RELEASE_WORKFLOW).on?.workflow_dispatch?.inputs
|
||||
?.release_publish_workflow_sha,
|
||||
).toBeUndefined();
|
||||
expect(clawHubPreview.outputs?.trusted_tooling_identity_json).toBeUndefined();
|
||||
const publishOrchestration = workflowStep(releasePublishJob, "Dispatch publish workflows");
|
||||
expect(publishOrchestration.env?.PARENT_WORKFLOW_FULL_REF).toBeUndefined();
|
||||
expect(publishOrchestration.run).toContain(
|
||||
'wait_for_run_background plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "${TARGET_SHA}"',
|
||||
);
|
||||
expect(publishOrchestration.run).not.toContain("release_publish_full_ref");
|
||||
expect(publishOrchestration.run).not.toContain("release_publish_workflow_sha");
|
||||
expect(clawHubBootstrapValidation.environment).toBe("clawhub-plugin-bootstrap");
|
||||
expect(clawHubBootstrapPublish.environment).toBe("clawhub-plugin-bootstrap");
|
||||
|
||||
|
||||
@@ -156,18 +156,32 @@ describe("plugin npm extended-stable workflow", () => {
|
||||
const preview = workflow().jobs?.preview_plugins_npm;
|
||||
const previewSteps = preview?.steps ?? [];
|
||||
const trusted = step(preview, "Validate ref is on a trusted publish branch");
|
||||
expect(previewSteps.slice(0, 4).map((candidate) => candidate.name)).toEqual([
|
||||
expect(previewSteps.slice(0, 6).map((candidate) => candidate.name)).toEqual([
|
||||
"Checkout",
|
||||
"Checkout trusted preflight tooling",
|
||||
"Resolve checked-out ref",
|
||||
"Verify trusted preflight tooling identity",
|
||||
"Validate ref is on a trusted publish branch",
|
||||
"Setup Node environment",
|
||||
]);
|
||||
const trustedIndex = previewSteps.indexOf(trusted);
|
||||
expect(trustedIndex).toBe(2);
|
||||
expect(trustedIndex).toBe(4);
|
||||
for (const candidate of previewSteps.slice(0, trustedIndex)) {
|
||||
expect(candidate.uses?.startsWith("./"), candidate.name).not.toBe(true);
|
||||
expect(candidate.run ?? "", candidate.name).not.toMatch(/\b(?:bun|npm|pnpm)\b/u);
|
||||
}
|
||||
const toolingIdentity = step(preview, "Verify trusted preflight tooling identity");
|
||||
expect(toolingIdentity.env).toMatchObject({
|
||||
WORKFLOW_FULL_REF: "${{ github.ref }}",
|
||||
WORKFLOW_REF: "${{ github.ref_name }}",
|
||||
WORKFLOW_SHA: "${{ github.workflow_sha }}",
|
||||
});
|
||||
expect(toolingIdentity.run).toContain(
|
||||
"node .release-tooling/scripts/release-tooling-identity.mjs verify",
|
||||
);
|
||||
expect(toolingIdentity.run).toContain('--workflow-ref "$WORKFLOW_REF"');
|
||||
expect(toolingIdentity.run).toContain('--workflow-full-ref "$WORKFLOW_FULL_REF"');
|
||||
expect(toolingIdentity.run).toContain('--workflow-sha "$WORKFLOW_SHA"');
|
||||
expect(step(preview, "Setup Node environment").uses).toBe("./.github/actions/setup-node-env");
|
||||
expect(trusted.env).toMatchObject({
|
||||
PREFLIGHT_ONLY:
|
||||
@@ -176,6 +190,8 @@ describe("plugin npm extended-stable workflow", () => {
|
||||
"${{ github.event_name == 'workflow_dispatch' && inputs.trusted_publisher_preflight || false }}",
|
||||
RELEASE_PUBLISH_RUN_ID:
|
||||
"${{ github.event_name == 'workflow_dispatch' && inputs.release_publish_run_id || '' }}",
|
||||
RELEASE_PUBLISH_RUN_ATTEMPT:
|
||||
"${{ github.event_name == 'workflow_dispatch' && inputs.release_publish_run_attempt || '' }}",
|
||||
SOURCE_REF: "${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}",
|
||||
WORKFLOW_REF: "${{ github.ref }}",
|
||||
WORKFLOW_SHA: "${{ github.workflow_sha }}",
|
||||
@@ -184,13 +200,13 @@ describe("plugin npm extended-stable workflow", () => {
|
||||
'[[ "${TRUSTED_PUBLISHER_PREFLIGHT}" == "true" && "${PREFLIGHT_ONLY}" != "true" ]]',
|
||||
);
|
||||
expect(trusted.run).toContain("trusted_publisher_preflight requires preflight_only=true");
|
||||
expect(trusted.run).toContain('[[ "${WORKFLOW_REF}" != "refs/heads/main" ]]');
|
||||
expect(trusted.run).toContain('git merge-base --is-ancestor "${WORKFLOW_SHA}" origin/main');
|
||||
expect(trusted.run).toContain('[[ ! "${SOURCE_REF}" =~ ^[0-9a-fA-F]{40}$ ]]');
|
||||
expect(trusted.run).toContain(
|
||||
'[[ "$(git rev-parse HEAD)" != "$(git rev-parse "${SOURCE_REF}^{commit}")" ]]',
|
||||
);
|
||||
expect(trusted.run).toContain("preflight must not include release_publish_run_id");
|
||||
expect(trusted.run).toContain(
|
||||
"Plugin npm preflight must not include a release publish parent run tuple.",
|
||||
);
|
||||
const preflightBranchRejection = trusted.run?.indexOf(
|
||||
"Plugin npm preflight target must be reachable from main or release/*.",
|
||||
);
|
||||
@@ -413,7 +429,7 @@ describe("plugin npm extended-stable workflow", () => {
|
||||
.split("\n")
|
||||
.filter((line) => line.includes('npm publish "$TARBALL_PATH"'));
|
||||
|
||||
expect(gitFetchLines).toHaveLength(6);
|
||||
expect(gitFetchLines).toHaveLength(5);
|
||||
expect(
|
||||
gitFetchLines.every((line) => line.includes("timeout --signal=TERM --kill-after=10s 120s")),
|
||||
).toBe(true);
|
||||
@@ -468,18 +484,11 @@ describe("plugin npm extended-stable workflow", () => {
|
||||
expect(consume.run).toContain("--connect-timeout 10");
|
||||
expect(consume.run).toContain("--max-time 120");
|
||||
expect(consume.run).toContain("actions/artifacts/${artifact_id}/zip");
|
||||
expect(consume.run).toContain("sha_pinned_release_publish=false");
|
||||
expect(consume.run).toContain(
|
||||
'[[ "$WORKFLOW_REF" =~ ^refs/tags/release-publish/([a-f0-9]{12})-[1-9][0-9]*$ ]]',
|
||||
);
|
||||
expect(consume.run).toContain(
|
||||
'[[ "$WORKFLOW_SHA" =~ ^[a-f0-9]{40}$ && "${WORKFLOW_SHA:0:12}" == "$workflow_sha_prefix" ]]',
|
||||
);
|
||||
expect(consume.run).toContain("sha_pinned_release_publish=true");
|
||||
expect(consume.run).toContain(
|
||||
'[[ "$WORKFLOW_REF" == "refs/heads/main" || "$sha_pinned_release_publish" == "true" ]]',
|
||||
);
|
||||
expect(consume.run).toContain('git merge-base --is-ancestor "$WORKFLOW_SHA" origin/main');
|
||||
expect(consume.run).toContain("node scripts/release-tooling-identity.mjs verify");
|
||||
expect(consume.run).toContain('--workflow-ref "$WORKFLOW_HEAD_BRANCH"');
|
||||
expect(consume.run).toContain('--workflow-full-ref "$WORKFLOW_REF"');
|
||||
expect(consume.run).toContain('--workflow-sha "$WORKFLOW_SHA"');
|
||||
expect(consume.run).toContain('--release-publish-run-id "$RELEASE_PUBLISH_RUN_ID"');
|
||||
expect(
|
||||
step(parsed.jobs?.publish_plugins_npm, "Checkout trusted publication tooling").with?.ref,
|
||||
).toBe("${{ github.workflow_sha }}");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Plugin NPM Publish tests cover publish wrapper argument safety.
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { chmodSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { delimiter, join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
@@ -41,6 +41,31 @@ function makePackage(version: string): { packageDir: string; path: string; root:
|
||||
}
|
||||
|
||||
describe("plugin npm publish wrapper", () => {
|
||||
it("revalidates release tooling after preparation and immediately before npm publish", () => {
|
||||
const source = readFileSync(scriptPath, "utf8");
|
||||
const buildIndex = source.indexOf("build_package_runtime");
|
||||
const identityIndex = source.indexOf("\n verify_release_tooling_identity", buildIndex);
|
||||
const publishIndex = source.indexOf(
|
||||
'run_with_manifest_overlay "${publish_cmd[@]}"',
|
||||
identityIndex,
|
||||
);
|
||||
|
||||
expect(buildIndex).toBeGreaterThan(-1);
|
||||
expect(identityIndex).toBeGreaterThan(buildIndex);
|
||||
expect(publishIndex).toBeGreaterThan(identityIndex);
|
||||
expect(source.slice(identityIndex, publishIndex)).not.toContain("npm view");
|
||||
});
|
||||
|
||||
it("revalidates release tooling immediately before every npm dist-tag mutation", () => {
|
||||
const source = readFileSync(scriptPath, "utf8");
|
||||
const distTagIndex = source.indexOf('npm dist-tag add "${package_name}@${package_version}"');
|
||||
const identityIndex = source.lastIndexOf("verify_release_tooling_identity", distTagIndex);
|
||||
|
||||
expect(identityIndex).toBeGreaterThan(-1);
|
||||
expect(distTagIndex).toBeGreaterThan(identityIndex);
|
||||
expect(source.slice(identityIndex, distTagIndex)).not.toContain("npm view");
|
||||
});
|
||||
|
||||
it("prints help before package or npm checks", () => {
|
||||
const result = runPluginPublishWrapper(["--help"]);
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
candidateCumulativeShippedPullRequests,
|
||||
candidateParallelsArgs,
|
||||
candidateParallelsShellCommand,
|
||||
fullReleaseTrustedWorkflowFields,
|
||||
githubApi,
|
||||
isDirectReleaseCandidateExecution,
|
||||
parseArgs,
|
||||
@@ -1372,6 +1373,59 @@ describe("release candidate checklist", () => {
|
||||
).toThrow("refusing to guess from recent workflow_dispatch runs");
|
||||
});
|
||||
|
||||
it("keeps contract 1 callers compatible and sends identity for contract 2", () => {
|
||||
const workflowSha = "a".repeat(40);
|
||||
const source = (contract: string, declareIdentity: boolean) => `env:
|
||||
RELEASE_ISOLATION_TOOLING_CONTRACT: "${contract}"
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
expected_sha: {}
|
||||
${declareIdentity ? " trusted_workflow_json: {}\n" : ""}`;
|
||||
|
||||
expect(
|
||||
fullReleaseTrustedWorkflowFields({
|
||||
workflowRef: "main",
|
||||
workflowSha,
|
||||
workflowSource: source("1", false),
|
||||
}),
|
||||
).toEqual({});
|
||||
const fields = fullReleaseTrustedWorkflowFields({
|
||||
workflowRef: "main",
|
||||
workflowSha,
|
||||
workflowSource: source("2", true),
|
||||
});
|
||||
expect(JSON.parse(fields.trusted_workflow_json ?? "{}")).toEqual({
|
||||
ref: "main",
|
||||
fullRef: "refs/heads/main",
|
||||
sha: workflowSha,
|
||||
});
|
||||
expect(() =>
|
||||
fullReleaseTrustedWorkflowFields({
|
||||
workflowRef: "main",
|
||||
workflowSha,
|
||||
workflowSource: source("2", false),
|
||||
}),
|
||||
).toThrow("contract 2 requires trusted_workflow_json");
|
||||
for (const contract of ["3", "4"]) {
|
||||
expect(() =>
|
||||
fullReleaseTrustedWorkflowFields({
|
||||
workflowRef: "main",
|
||||
workflowSha,
|
||||
workflowSource: source(contract, true),
|
||||
}),
|
||||
).toThrow("supported release tooling contract");
|
||||
}
|
||||
});
|
||||
|
||||
it("threads the selected tooling identity into direct full validation dispatch", () => {
|
||||
const source = readFileSync("scripts/release-candidate-checklist.mts", "utf8");
|
||||
|
||||
expect(source).toContain("const trustedWorkflowFields = fullReleaseTrustedWorkflowFields({");
|
||||
expect(source).toContain("workflowSha: toolingSha");
|
||||
expect(source).toContain("...trustedWorkflowFields");
|
||||
});
|
||||
|
||||
it("falls back to a single compatible artifact from the same run", () => {
|
||||
expect(
|
||||
resolveArtifactName(
|
||||
|
||||
@@ -1196,6 +1196,114 @@ describe("release CI summary child correlation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts canonical SHA-pinned v3 evidence exactly bound to a protected tooling tag", () => {
|
||||
const workflowSha = "7".repeat(40);
|
||||
const workflowRef = `release-ci/${workflowSha.slice(0, 12)}-1783705000000`;
|
||||
const trustedWorkflowRef = `release-publish/${workflowSha.slice(0, 12)}-123`;
|
||||
const fixture = trustedMainPackageFixture({
|
||||
manifestVersion: 3,
|
||||
targetSha: "8".repeat(40),
|
||||
workflowFullRef: `refs/heads/${workflowRef}`,
|
||||
workflowRef,
|
||||
workflowSha,
|
||||
});
|
||||
fixture.manifest.targetRef = fixture.targetSha;
|
||||
|
||||
expect(
|
||||
validateReleaseRunEvidence(
|
||||
{
|
||||
repository: "openclaw/openclaw",
|
||||
runId: fixture.runId,
|
||||
trustedWorkflowFullRef: `refs/tags/${trustedWorkflowRef}`,
|
||||
trustedWorkflowRef,
|
||||
trustedWorkflowSha: workflowSha,
|
||||
verifierSourceContent: readFileSync(SCRIPT),
|
||||
verifierSourceSha: "c".repeat(40),
|
||||
},
|
||||
fixture.client,
|
||||
),
|
||||
).toMatchObject({
|
||||
producerOnTrustedMainLineage: false,
|
||||
trustedWorkflowFullRef: `refs/tags/${trustedWorkflowRef}`,
|
||||
trustedWorkflowRef,
|
||||
root: {
|
||||
workflowRef,
|
||||
workflowRefProof: "manifest-v3-protected-tag-exact-sha",
|
||||
workflowSha,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects protected-tag evidence from a same-name branch or older ancestor", () => {
|
||||
const trustedWorkflowSha = "7".repeat(40);
|
||||
const trustedWorkflowRef = `release-publish/${trustedWorkflowSha.slice(0, 12)}-123`;
|
||||
const validFixture = trustedMainPackageFixture({
|
||||
manifestVersion: 3,
|
||||
workflowSha: trustedWorkflowSha,
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
validateReleaseRunEvidence(
|
||||
{
|
||||
repository: "openclaw/openclaw",
|
||||
runId: validFixture.runId,
|
||||
trustedWorkflowFullRef: `refs/heads/${trustedWorkflowRef}`,
|
||||
trustedWorkflowRef,
|
||||
trustedWorkflowSha,
|
||||
verifierSourceContent: readFileSync(SCRIPT),
|
||||
verifierSourceSha: "c".repeat(40),
|
||||
},
|
||||
validFixture.client,
|
||||
),
|
||||
).toThrow("must be a protected tag");
|
||||
|
||||
const olderWorkflowSha = "6".repeat(40);
|
||||
const olderWorkflowRef = `release-ci/${olderWorkflowSha.slice(0, 12)}-1783705000000`;
|
||||
const olderFixture = trustedMainPackageFixture({
|
||||
manifestVersion: 3,
|
||||
targetSha: "8".repeat(40),
|
||||
workflowFullRef: `refs/heads/${olderWorkflowRef}`,
|
||||
workflowRef: olderWorkflowRef,
|
||||
workflowSha: olderWorkflowSha,
|
||||
});
|
||||
olderFixture.manifest.targetRef = olderFixture.targetSha;
|
||||
expect(() =>
|
||||
validateReleaseRunEvidence(
|
||||
{
|
||||
repository: "openclaw/openclaw",
|
||||
runId: olderFixture.runId,
|
||||
trustedWorkflowFullRef: `refs/tags/${trustedWorkflowRef}`,
|
||||
trustedWorkflowRef,
|
||||
trustedWorkflowSha,
|
||||
verifierSourceContent: readFileSync(SCRIPT),
|
||||
verifierSourceSha: "c".repeat(40),
|
||||
},
|
||||
olderFixture.client,
|
||||
),
|
||||
).toThrow("does not match trusted tooling");
|
||||
|
||||
const sameNameFixture = trustedMainPackageFixture({
|
||||
manifestVersion: 3,
|
||||
workflowFullRef: `refs/heads/${trustedWorkflowRef}`,
|
||||
workflowRef: trustedWorkflowRef,
|
||||
workflowSha: trustedWorkflowSha,
|
||||
});
|
||||
expect(() =>
|
||||
validateReleaseRunEvidence(
|
||||
{
|
||||
repository: "openclaw/openclaw",
|
||||
runId: sameNameFixture.runId,
|
||||
trustedWorkflowFullRef: `refs/tags/${trustedWorkflowRef}`,
|
||||
trustedWorkflowRef,
|
||||
trustedWorkflowSha,
|
||||
verifierSourceContent: readFileSync(SCRIPT),
|
||||
verifierSourceSha: "c".repeat(40),
|
||||
},
|
||||
sameNameFixture.client,
|
||||
),
|
||||
).toThrow("canonical release-ci branch");
|
||||
});
|
||||
|
||||
it.each(["main", "refs/heads/main"])(
|
||||
"accepts a REST workflow path qualified with %s",
|
||||
(qualifiedRef) => {
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
resolveReleaseToolingIdentity,
|
||||
validateReleasePublishParentRun,
|
||||
validateReleaseToolingIdentity,
|
||||
verifyReleaseToolingIdentity,
|
||||
} from "../../scripts/release-tooling-identity.mjs";
|
||||
|
||||
const SHA = "a".repeat(40);
|
||||
const OTHER_SHA = "b".repeat(40);
|
||||
const RUN_ID = "12345";
|
||||
const PARENT_RUN_ID = "67890";
|
||||
const PARENT_RUN_ATTEMPT = "2";
|
||||
const REF = `release-publish/${SHA.slice(0, 12)}-${RUN_ID}`;
|
||||
const FULL_REF = `refs/tags/${REF}`;
|
||||
|
||||
function protectedIdentity(
|
||||
overrides: Partial<Parameters<typeof verifyReleaseToolingIdentity>[0]> = {},
|
||||
) {
|
||||
return {
|
||||
repository: "openclaw/openclaw",
|
||||
workflowFullRef: FULL_REF,
|
||||
workflowRef: REF,
|
||||
workflowSha: SHA,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("release tooling identity", () => {
|
||||
it.each([
|
||||
["1", "main", "refs/heads/main"],
|
||||
["2", "release/2026.8.1", "refs/heads/release/2026.8.1"],
|
||||
["2", "tideclaw/alpha/2026-08-21-1200Z", "refs/heads/tideclaw/alpha/2026-08-21-1200Z"],
|
||||
])("derives contract %s identity for safe direct workflow ref %s", (contract, ref, fullRef) => {
|
||||
expect(
|
||||
resolveReleaseToolingIdentity({
|
||||
workflowContract: contract,
|
||||
workflowFullRef: fullRef,
|
||||
workflowRef: ref,
|
||||
workflowSha: SHA,
|
||||
}),
|
||||
).toEqual({ fullRef, ref, sha: SHA });
|
||||
});
|
||||
|
||||
it("rejects unsupported contract 3 even with explicit identity", () => {
|
||||
expect(() =>
|
||||
resolveReleaseToolingIdentity({
|
||||
requestedIdentityJson: JSON.stringify({
|
||||
ref: "main",
|
||||
fullRef: "refs/heads/main",
|
||||
sha: SHA,
|
||||
}),
|
||||
workflowContract: "3",
|
||||
workflowFullRef: "refs/heads/main",
|
||||
workflowRef: "main",
|
||||
workflowSha: SHA,
|
||||
}),
|
||||
).toThrow("release tooling contract 3 is not supported");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"release-ci ref",
|
||||
{
|
||||
workflowContract: "2",
|
||||
workflowFullRef: `refs/heads/release-ci/${SHA.slice(0, 12)}-123`,
|
||||
workflowRef: `release-ci/${SHA.slice(0, 12)}-123`,
|
||||
},
|
||||
],
|
||||
[
|
||||
"protected tag",
|
||||
{
|
||||
workflowContract: "2",
|
||||
workflowFullRef: FULL_REF,
|
||||
workflowRef: REF,
|
||||
},
|
||||
],
|
||||
])("requires explicit identity for $0", (_label, overrides) => {
|
||||
const { workflowContract, workflowFullRef } = overrides;
|
||||
const workflowRef = "workflowRef" in overrides ? overrides.workflowRef : "main";
|
||||
expect(() =>
|
||||
resolveReleaseToolingIdentity({
|
||||
workflowContract,
|
||||
workflowFullRef,
|
||||
workflowRef,
|
||||
workflowSha: SHA,
|
||||
}),
|
||||
).toThrow(/requires explicit trusted workflow identity|require explicit trusted workflow/u);
|
||||
});
|
||||
|
||||
it("accepts explicit main identity for a matching release-ci workflow", () => {
|
||||
const releaseCiRef = `release-ci/${SHA.slice(0, 12)}-123`;
|
||||
expect(
|
||||
resolveReleaseToolingIdentity({
|
||||
requestedIdentityJson: JSON.stringify({
|
||||
ref: "main",
|
||||
fullRef: "refs/heads/main",
|
||||
sha: SHA,
|
||||
}),
|
||||
workflowContract: "2",
|
||||
workflowFullRef: `refs/heads/${releaseCiRef}`,
|
||||
workflowRef: releaseCiRef,
|
||||
workflowSha: SHA,
|
||||
}),
|
||||
).toEqual({ ref: "main", fullRef: "refs/heads/main", sha: SHA });
|
||||
});
|
||||
|
||||
it("rejects explicit identity that does not match a direct workflow", () => {
|
||||
expect(() =>
|
||||
resolveReleaseToolingIdentity({
|
||||
requestedIdentityJson: JSON.stringify({
|
||||
ref: "main",
|
||||
fullRef: "refs/heads/main",
|
||||
sha: OTHER_SHA,
|
||||
}),
|
||||
workflowContract: "2",
|
||||
workflowFullRef: "refs/heads/main",
|
||||
workflowRef: "main",
|
||||
workflowSha: SHA,
|
||||
}),
|
||||
).toThrow("must match the executing workflow ref and SHA");
|
||||
});
|
||||
|
||||
it("accepts only the live exact lightweight protected tag", () => {
|
||||
const runGh = vi.fn(() =>
|
||||
JSON.stringify({
|
||||
ref: FULL_REF,
|
||||
object: { sha: SHA, type: "commit" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(verifyReleaseToolingIdentity({ ...protectedIdentity(), runGh })).toEqual({
|
||||
fullRef: FULL_REF,
|
||||
ref: REF,
|
||||
route: "protected-tag",
|
||||
sha: SHA,
|
||||
});
|
||||
expect(runGh).toHaveBeenCalledWith([
|
||||
"api",
|
||||
`repos/openclaw/openclaw/git/ref/tags/${REF}`,
|
||||
"--method",
|
||||
"GET",
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"moved tag",
|
||||
{
|
||||
runGh: () =>
|
||||
JSON.stringify({
|
||||
ref: FULL_REF,
|
||||
object: { sha: OTHER_SHA, type: "commit" },
|
||||
}),
|
||||
},
|
||||
"missing, moved, annotated, or bound to the wrong SHA",
|
||||
],
|
||||
[
|
||||
"deleted tag",
|
||||
{
|
||||
runGh: () => {
|
||||
throw new Error("HTTP 404");
|
||||
},
|
||||
},
|
||||
"missing or unreadable",
|
||||
],
|
||||
[
|
||||
"annotated tag",
|
||||
{
|
||||
runGh: () =>
|
||||
JSON.stringify({
|
||||
ref: FULL_REF,
|
||||
object: { sha: OTHER_SHA, type: "tag" },
|
||||
}),
|
||||
},
|
||||
"missing, moved, annotated, or bound to the wrong SHA",
|
||||
],
|
||||
[
|
||||
"wrong SHA prefix",
|
||||
{
|
||||
workflowRef: `release-publish/${OTHER_SHA.slice(0, 12)}-${RUN_ID}`,
|
||||
workflowFullRef: `refs/tags/release-publish/${OTHER_SHA.slice(0, 12)}-${RUN_ID}`,
|
||||
},
|
||||
"SHA prefix does not match",
|
||||
],
|
||||
["same-name branch", { workflowFullRef: `refs/heads/${REF}` }, "exact tag full ref"],
|
||||
])("rejects $0", (_label, overrides, expectedError) => {
|
||||
expect(() =>
|
||||
verifyReleaseToolingIdentity({
|
||||
...protectedIdentity(),
|
||||
...overrides,
|
||||
}),
|
||||
).toThrow(expectedError);
|
||||
});
|
||||
|
||||
it.each(["ahead", "identical"])(
|
||||
"accepts main tooling reachable from current main: %s",
|
||||
(status) => {
|
||||
const runGh = vi.fn(() => JSON.stringify({ status }));
|
||||
expect(
|
||||
verifyReleaseToolingIdentity({
|
||||
repository: "openclaw/openclaw",
|
||||
runGh,
|
||||
workflowFullRef: "refs/heads/main",
|
||||
workflowRef: "main",
|
||||
workflowSha: SHA,
|
||||
}),
|
||||
).toMatchObject({ route: "main", sha: SHA });
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects main tooling outside current main ancestry", () => {
|
||||
expect(() =>
|
||||
validateReleaseToolingIdentity({
|
||||
mainComparisonStatus: "diverged",
|
||||
workflowFullRef: "refs/heads/main",
|
||||
workflowRef: "main",
|
||||
workflowSha: SHA,
|
||||
}),
|
||||
).toThrow("not reachable from current main");
|
||||
});
|
||||
|
||||
it("preserves explicitly prevalidated non-main branch routes", () => {
|
||||
const runGh = vi.fn(() =>
|
||||
JSON.stringify({
|
||||
ref: "refs/heads/release/2026.8.1",
|
||||
object: { sha: SHA, type: "commit" },
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
verifyReleaseToolingIdentity({
|
||||
allowPrevalidatedRef: true,
|
||||
repository: "openclaw/openclaw",
|
||||
runGh,
|
||||
workflowFullRef: "refs/heads/release/2026.8.1",
|
||||
workflowRef: "release/2026.8.1",
|
||||
workflowSha: SHA,
|
||||
}),
|
||||
).toMatchObject({ route: "prevalidated-branch" });
|
||||
expect(runGh).toHaveBeenCalledWith([
|
||||
"api",
|
||||
"repos/openclaw/openclaw/git/ref/heads/release/2026.8.1",
|
||||
"--method",
|
||||
"GET",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects a prevalidated branch moved after approval", () => {
|
||||
expect(() =>
|
||||
verifyReleaseToolingIdentity({
|
||||
allowPrevalidatedRef: true,
|
||||
repository: "openclaw/openclaw",
|
||||
runGh: () =>
|
||||
JSON.stringify({
|
||||
ref: "refs/heads/release/2026.8.1",
|
||||
object: { sha: OTHER_SHA, type: "commit" },
|
||||
}),
|
||||
workflowFullRef: "refs/heads/release/2026.8.1",
|
||||
workflowRef: "release/2026.8.1",
|
||||
workflowSha: SHA,
|
||||
}),
|
||||
).toThrow("branch is missing or moved");
|
||||
});
|
||||
|
||||
it("binds a distinct current parent run independently from tag provenance", () => {
|
||||
const calls: string[][] = [];
|
||||
const runGh = vi.fn((args: string[]) => {
|
||||
calls.push(args);
|
||||
if (args[1]?.includes("/git/ref/tags/")) {
|
||||
return JSON.stringify({
|
||||
ref: FULL_REF,
|
||||
object: { sha: SHA, type: "commit" },
|
||||
});
|
||||
}
|
||||
return JSON.stringify({
|
||||
id: Number(PARENT_RUN_ID),
|
||||
run_attempt: Number(PARENT_RUN_ATTEMPT),
|
||||
repository: { full_name: "openclaw/openclaw" },
|
||||
path: `.github/workflows/openclaw-release-publish.yml@${FULL_REF}`,
|
||||
event: "workflow_dispatch",
|
||||
head_branch: REF,
|
||||
head_sha: SHA,
|
||||
status: "in_progress",
|
||||
conclusion: null,
|
||||
});
|
||||
});
|
||||
|
||||
expect(
|
||||
verifyReleaseToolingIdentity({
|
||||
...protectedIdentity(),
|
||||
releasePublishParentStatePolicy: "active",
|
||||
releasePublishRunAttempt: PARENT_RUN_ATTEMPT,
|
||||
releasePublishRunId: PARENT_RUN_ID,
|
||||
runGh,
|
||||
}),
|
||||
).toMatchObject({ route: "protected-tag", sha: SHA });
|
||||
expect(PARENT_RUN_ID).not.toBe(RUN_ID);
|
||||
expect(calls).toContainEqual([
|
||||
"api",
|
||||
`repos/openclaw/openclaw/actions/runs/${PARENT_RUN_ID}`,
|
||||
"--method",
|
||||
"GET",
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["active", "in_progress", null, true],
|
||||
["active", "completed", "success", false],
|
||||
["active-or-success", "in_progress", null, true],
|
||||
["active-or-success", "completed", "success", true],
|
||||
["active-or-success", "completed", "failure", false],
|
||||
["manual-recovery", "in_progress", null, true],
|
||||
["manual-recovery", "completed", "success", true],
|
||||
["manual-recovery", "completed", "failure", true],
|
||||
["manual-recovery", "completed", "cancelled", false],
|
||||
] as const)(
|
||||
"enforces parent state policy %s for %s/%s",
|
||||
(releasePublishParentStatePolicy, status, conclusion, accepted) => {
|
||||
const validate = () =>
|
||||
validateReleasePublishParentRun({
|
||||
identity: { ref: REF, fullRef: FULL_REF, sha: SHA },
|
||||
releasePublishParentStatePolicy,
|
||||
releasePublishRunAttempt: PARENT_RUN_ATTEMPT,
|
||||
releasePublishRunId: PARENT_RUN_ID,
|
||||
repository: "openclaw/openclaw",
|
||||
run: {
|
||||
id: Number(PARENT_RUN_ID),
|
||||
run_attempt: Number(PARENT_RUN_ATTEMPT),
|
||||
repository: { full_name: "openclaw/openclaw" },
|
||||
path: `.github/workflows/openclaw-release-publish.yml@${FULL_REF}`,
|
||||
event: "workflow_dispatch",
|
||||
head_branch: REF,
|
||||
head_sha: SHA,
|
||||
status,
|
||||
conclusion,
|
||||
},
|
||||
});
|
||||
|
||||
if (accepted) {
|
||||
expect(validate).not.toThrow();
|
||||
} else {
|
||||
expect(validate).toThrow(`state is not allowed by ${releasePublishParentStatePolicy}`);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("requires the parent state policy with the exact parent run tuple", () => {
|
||||
expect(() =>
|
||||
verifyReleaseToolingIdentity({
|
||||
...protectedIdentity(),
|
||||
releasePublishRunAttempt: PARENT_RUN_ATTEMPT,
|
||||
releasePublishRunId: PARENT_RUN_ID,
|
||||
runGh: () =>
|
||||
JSON.stringify({
|
||||
ref: FULL_REF,
|
||||
object: { sha: SHA, type: "commit" },
|
||||
}),
|
||||
}),
|
||||
).toThrow("run id, attempt, and parent state policy must be provided together");
|
||||
});
|
||||
});
|
||||
@@ -125,6 +125,78 @@ describe("full release validation evidence", () => {
|
||||
expect(isShaPinnedReleaseValidationBranch(pinnedBranch)).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts canonical SHA-pinned evidence exactly bound to a protected tooling tag", () => {
|
||||
const isTrustedMainAncestor = vi.fn(() => false);
|
||||
const trustedWorkflowRef = `release-publish/${workflowSha.slice(0, 12)}-123`;
|
||||
const result = validateFullReleaseValidationEvidence({
|
||||
run: releaseRun(),
|
||||
manifest: releaseManifest(),
|
||||
expectedRepository: "openclaw/openclaw",
|
||||
expectedRunId: "123",
|
||||
expectedTargetSha: targetSha,
|
||||
expectedTrustedWorkflowFullRef: `refs/tags/${trustedWorkflowRef}`,
|
||||
expectedTrustedWorkflowSha: workflowSha,
|
||||
isTrustedMainAncestor,
|
||||
});
|
||||
|
||||
expect(result.source).toBe("sha-pinned-protected-tag");
|
||||
expect(isTrustedMainAncestor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects protected-tag evidence from a same-name branch or older ancestor", () => {
|
||||
const trustedWorkflowRef = `release-publish/${workflowSha.slice(0, 12)}-123`;
|
||||
expect(() =>
|
||||
validateFullReleaseValidationEvidence({
|
||||
run: releaseRun(),
|
||||
manifest: releaseManifest(),
|
||||
expectedRepository: "openclaw/openclaw",
|
||||
expectedRunId: "123",
|
||||
expectedTargetSha: targetSha,
|
||||
expectedTrustedWorkflowFullRef: `refs/heads/${trustedWorkflowRef}`,
|
||||
expectedTrustedWorkflowSha: workflowSha,
|
||||
isTrustedMainAncestor: () => true,
|
||||
}),
|
||||
).toThrow("must be an exact protected tag");
|
||||
|
||||
const olderWorkflowSha = "c".repeat(40);
|
||||
const olderBranch = `release-ci/${olderWorkflowSha.slice(0, 12)}-1783705000000`;
|
||||
expect(() =>
|
||||
validateFullReleaseValidationEvidence({
|
||||
run: releaseRun({
|
||||
head_branch: olderBranch,
|
||||
head_sha: olderWorkflowSha,
|
||||
}),
|
||||
manifest: releaseManifest({
|
||||
workflowFullRef: `refs/heads/${olderBranch}`,
|
||||
workflowRef: olderBranch,
|
||||
workflowSha: olderWorkflowSha,
|
||||
}),
|
||||
expectedRepository: "openclaw/openclaw",
|
||||
expectedRunId: "123",
|
||||
expectedTargetSha: targetSha,
|
||||
expectedTrustedWorkflowFullRef: `refs/tags/${trustedWorkflowRef}`,
|
||||
expectedTrustedWorkflowSha: workflowSha,
|
||||
isTrustedMainAncestor: () => true,
|
||||
}),
|
||||
).toThrow("does not match trusted tooling");
|
||||
|
||||
expect(() =>
|
||||
validateFullReleaseValidationEvidence({
|
||||
run: releaseRun({ head_branch: trustedWorkflowRef }),
|
||||
manifest: releaseManifest({
|
||||
workflowFullRef: `refs/heads/${trustedWorkflowRef}`,
|
||||
workflowRef: trustedWorkflowRef,
|
||||
}),
|
||||
expectedRepository: "openclaw/openclaw",
|
||||
expectedRunId: "123",
|
||||
expectedTargetSha: targetSha,
|
||||
expectedTrustedWorkflowFullRef: `refs/tags/${trustedWorkflowRef}`,
|
||||
expectedTrustedWorkflowSha: workflowSha,
|
||||
isTrustedMainAncestor: () => true,
|
||||
}),
|
||||
).toThrow("canonical release-ci producer branch");
|
||||
});
|
||||
|
||||
it.each([pinnedBranch, `refs/heads/${pinnedBranch}`])(
|
||||
"accepts a REST workflow path qualified with %s",
|
||||
(qualifiedRef) => {
|
||||
|
||||
@@ -15,6 +15,8 @@ function runApprovalScript(
|
||||
CHILD_WORKFLOW_SHA?: string;
|
||||
DIRECT_RELEASE_RECOVERY?: string;
|
||||
EXPECTED_WORKFLOW_BRANCH?: string;
|
||||
EXPECTED_WORKFLOW_FULL_REF?: string;
|
||||
EXPECTED_WORKFLOW_SHA?: string;
|
||||
EXPECTED_RUN_ATTEMPT?: string;
|
||||
APPROVAL_PATH?: string;
|
||||
GITHUB_REPOSITORY?: string;
|
||||
@@ -34,6 +36,8 @@ function runApprovalScript(
|
||||
CHILD_WORKFLOW_SHA: env.CHILD_WORKFLOW_SHA ?? "b".repeat(40),
|
||||
DIRECT_RELEASE_RECOVERY: env.DIRECT_RELEASE_RECOVERY ?? "false",
|
||||
EXPECTED_WORKFLOW_BRANCH: env.EXPECTED_WORKFLOW_BRANCH ?? "release/2026.6.21",
|
||||
EXPECTED_WORKFLOW_FULL_REF: env.EXPECTED_WORKFLOW_FULL_REF ?? "",
|
||||
EXPECTED_WORKFLOW_SHA: env.EXPECTED_WORKFLOW_SHA ?? "",
|
||||
EXPECTED_RUN_ATTEMPT: env.EXPECTED_RUN_ATTEMPT ?? "",
|
||||
APPROVAL_PATH: env.APPROVAL_PATH ?? "",
|
||||
GITHUB_REPOSITORY: env.GITHUB_REPOSITORY ?? "openclaw/openclaw",
|
||||
@@ -71,6 +75,7 @@ function approvalRun(overrides: Record<string, unknown> = {}) {
|
||||
conclusion: null,
|
||||
event: "workflow_dispatch",
|
||||
headBranch: "release/2026.6.21",
|
||||
repository: "openclaw/openclaw",
|
||||
status: "in_progress",
|
||||
url: "https://github.com/openclaw/openclaw/actions/runs/123",
|
||||
workflowName: "OpenClaw Release Publish",
|
||||
@@ -123,6 +128,27 @@ describe("scripts/validate-release-publish-approval.mjs", () => {
|
||||
expect(result.stdout).toBe("");
|
||||
});
|
||||
|
||||
it("binds the parent repository, workflow path, full ref, SHA, and attempt", () => {
|
||||
const workflowSha = "d".repeat(40);
|
||||
const fullRef = "refs/tags/release-publish/aaaaaaaaaaaa-111";
|
||||
const result = runApprovalScript(
|
||||
approvalRun({
|
||||
headBranch: "release-publish/aaaaaaaaaaaa-111",
|
||||
headSha: workflowSha,
|
||||
path: `.github/workflows/openclaw-release-publish.yml@${fullRef}`,
|
||||
runAttempt: 7,
|
||||
}),
|
||||
{
|
||||
EXPECTED_RUN_ATTEMPT: "7",
|
||||
EXPECTED_WORKFLOW_BRANCH: "release-publish/aaaaaaaaaaaa-111",
|
||||
EXPECTED_WORKFLOW_FULL_REF: fullRef,
|
||||
EXPECTED_WORKFLOW_SHA: workflowSha,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects completed runs for normal approval handoff", () => {
|
||||
const result = runApprovalScript(approvalRun({ conclusion: "success", status: "completed" }));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user