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:
Vincent Koc
2026-08-21 00:24:31 -07:00
committed by GitHub
parent b470422371
commit fa86caf94f
28 changed files with 3119 additions and 248 deletions
+136 -34
View File
@@ -15,7 +15,7 @@ import { execGhRead } from "./lib/plain-gh.mjs";
const WORKFLOW = "full-release-validation.yml";
const TRUSTED_WORKFLOW_PATH = `.github/workflows/${WORKFLOW}`;
const RELEASE_ISOLATION_TOOLING_CONTRACT = "1";
const RELEASE_ISOLATION_TOOLING_CONTRACT = "2";
const RELEASE_ISOLATION_TOOLING_CONTRACT_ENV = "RELEASE_ISOLATION_TOOLING_CONTRACT";
const RELEASE_EVIDENCE_VERIFIER_PATHS = [
"scripts/release-ci-summary.mjs",
@@ -37,6 +37,7 @@ const RELEASE_CONTEXT_BRANCH_PATTERN =
/^(?:release\/[0-9]{4}\.(?:[1-9]|1[0-2])\.[1-9][0-9]*|extended-stable\/[0-9]{4}\.(?:[1-9]|1[0-2])\.33)$/u;
const RELEASE_TAG_PATTERN =
/^v([0-9]{4}\.(?:[1-9]|1[0-2])\.[1-9][0-9]*(?:-(?:alpha|beta)\.[1-9][0-9]*)?)$/u;
const TRUSTED_WORKFLOW_TAG_PATTERN = /^release-publish\/([a-f0-9]{12})-[1-9][0-9]*$/u;
const SHA_PATTERN = /^[a-f0-9]{40}$/u;
const RERUN_GROUPS = new Set([
"all",
@@ -72,6 +73,10 @@ type TemporaryRefParams = {
parentConclusion: string;
evidenceVerified: boolean;
};
type TrustedWorkflowHarness = {
contract: "1" | "2";
verifierPath: string;
};
function stringValue(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : fallback;
@@ -85,7 +90,7 @@ function displayValue(value: unknown): string {
}
function usage() {
console.error(`Usage: node scripts/full-release-validation-at-sha.mjs [--sha <target-sha>] [--target-ref <canonical-release-branch-or-tag>] [--workflow-sha <trusted-main-ref>] [--keep-branch] [--dry-run] [-- -f key=value ...]
console.error(`Usage: node scripts/full-release-validation-at-sha.mjs [--sha <target-sha>] [--target-ref <canonical-release-branch-or-tag>] [--workflow-sha <trusted-tooling-sha>] [--trusted-workflow-ref <main-or-release-publish-tag>] [--keep-branch] [--dry-run] [-- -f key=value ...]
Creates temporary remote branches pinned to the exact Tooling SHA and Validation SHA,
dispatches Full Release Validation with the full Validation SHA as its ref input
@@ -140,6 +145,7 @@ export function parseArgs(argv: string[]) {
const args = {
sha: "",
targetRef: "",
trustedWorkflowRef: "main",
workflowSha: "",
keepBranch: false,
dryRun: false,
@@ -162,6 +168,11 @@ export function parseArgs(argv: string[]) {
i += 1;
continue;
}
if (arg === "--trusted-workflow-ref") {
args.trustedWorkflowRef = readOptionValue(argv, i, arg);
i += 1;
continue;
}
if (arg === "--target-ref") {
args.targetRef = readOptionValue(argv, i, arg);
i += 1;
@@ -243,6 +254,9 @@ export function parseArgs(argv: string[]) {
if (Object.hasOwn(args.inputs, "expected_sha")) {
throw new Error("SHA-pinned release validation reserves expected_sha for the resolved --sha");
}
if (Object.hasOwn(args.inputs, "trusted_workflow_json")) {
throw new Error("SHA-pinned release validation reserves trusted_workflow_json");
}
if (
args.targetRef &&
!RELEASE_CONTEXT_BRANCH_PATTERN.test(args.targetRef) &&
@@ -250,6 +264,19 @@ export function parseArgs(argv: string[]) {
) {
throw new Error("--target-ref must be a canonical OpenClaw release branch or tag");
}
if (
args.trustedWorkflowRef !== "main" &&
!TRUSTED_WORKFLOW_TAG_PATTERN.test(args.trustedWorkflowRef)
) {
throw new Error(
"--trusted-workflow-ref must be main or a protected release-publish/<12hex>-<decimal> tag",
);
}
if (args.trustedWorkflowRef !== "main" && !SHA_PATTERN.test(args.workflowSha.toLowerCase())) {
throw new Error(
"protected release-publish workflow refs require --workflow-sha with an explicit full Tooling SHA",
);
}
if (
RELEASE_CONTEXT_BRANCH_PATTERN.test(args.targetRef) &&
!SHA_PATTERN.test(args.workflowSha.toLowerCase())
@@ -396,22 +423,53 @@ export function releaseProfileForTarget(
return releaseProfileForVersion(targetVersionForTarget(targetSha, readPackageJson));
}
function resolveTrustedWorkflowSha(requestedSha: string) {
run("git", ["fetch", "--no-tags", "origin", "refs/heads/main:refs/remotes/origin/main"], {
stdio: "inherit",
});
const workflowSha = resolveSha(requestedSha || "origin/main");
const ancestry = runStatus("git", [
"merge-base",
"--is-ancestor",
workflowSha,
"refs/remotes/origin/main",
]);
if (ancestry.status !== 0) {
export function verifyTrustedWorkflowRef(
workflowSha: string,
trustedWorkflowRef: string,
resolveRemoteTagSha: (tag: string) => string = (tag) =>
run("git", ["ls-remote", "--tags", "origin", `refs/tags/${tag}`]).split(/\s+/u)[0] ?? "",
isMainAncestor: (sha: string) => boolean = (sha) =>
runStatus("git", ["merge-base", "--is-ancestor", sha, "refs/remotes/origin/main"]).status === 0,
) {
if (trustedWorkflowRef === "main") {
if (!isMainAncestor(workflowSha)) {
throw new Error(
`Workflow SHA ${workflowSha} is not reachable from current origin/main; refusing an untrusted release harness.`,
);
}
return;
}
const tagMatch = trustedWorkflowRef.match(TRUSTED_WORKFLOW_TAG_PATTERN);
if (!tagMatch) {
throw new Error(
`Workflow SHA ${workflowSha} is not reachable from current origin/main; refusing an untrusted release harness.`,
"trusted workflow ref must be main or a protected release-publish/<12hex>-<decimal> tag",
);
}
if (workflowSha.slice(0, 12) !== tagMatch[1]) {
throw new Error(
`Trusted workflow tag ${trustedWorkflowRef} does not match Tooling SHA ${workflowSha}`,
);
}
const remoteTagSha = resolveRemoteTagSha(trustedWorkflowRef);
if (!remoteTagSha) {
throw new Error(`Trusted workflow tag ${trustedWorkflowRef} does not exist on origin`);
}
if (remoteTagSha.toLowerCase() !== workflowSha.toLowerCase()) {
throw new Error(
`Trusted workflow tag ${trustedWorkflowRef} resolves to ${remoteTagSha}, expected ${workflowSha}`,
);
}
}
function resolveTrustedWorkflowSha(requestedSha: string, trustedWorkflowRef: string) {
if (trustedWorkflowRef === "main") {
run("git", ["fetch", "--no-tags", "origin", "refs/heads/main:refs/remotes/origin/main"], {
stdio: "inherit",
});
}
const workflowSha = resolveSha(requestedSha || "origin/main");
verifyTrustedWorkflowRef(workflowSha, trustedWorkflowRef);
return workflowSha;
}
@@ -557,15 +615,29 @@ export function releaseEvidenceVerificationArgs(
parentRunId: unknown,
verifierSourceSha: string,
verifierSourceFile: string,
trustedWorkflowRef = "main",
) {
if (!/^[1-9][0-9]*$/u.test(String(parentRunId))) {
throw new Error("parent run ID must be a positive decimal");
}
const trustedWorkflowFullRef =
trustedWorkflowRef === "main"
? "refs/heads/main"
: TRUSTED_WORKFLOW_TAG_PATTERN.test(trustedWorkflowRef)
? `refs/tags/${trustedWorkflowRef}`
: "";
if (!trustedWorkflowFullRef) {
throw new Error("trusted workflow ref must be main or a protected release-publish tag");
}
return [
"--validate-run",
String(parentRunId),
"--trusted-workflow-ref",
"main",
trustedWorkflowRef,
"--trusted-workflow-full-ref",
trustedWorkflowFullRef,
"--trusted-workflow-sha",
verifierSourceSha,
"--json",
"--verifier-source-sha",
verifierSourceSha,
@@ -589,7 +661,7 @@ export function assertTrustedWorkflowHarness(
}).status === 0,
readPath: (relativePath: string) => string = (relativePath) =>
run("git", ["show", `${workflowSha}:${relativePath}`]),
) {
): TrustedWorkflowHarness {
if (!pathExists(TRUSTED_WORKFLOW_PATH)) {
throw new Error(
`trusted workflow SHA ${workflowSha} does not contain ${TRUSTED_WORKFLOW_PATH}`,
@@ -604,23 +676,33 @@ export function assertTrustedWorkflowHarness(
{ cause: error },
);
}
if (
!isJsonRecord(workflow) ||
!isJsonRecord(workflow.env) ||
workflow.env[RELEASE_ISOLATION_TOOLING_CONTRACT_ENV] !== RELEASE_ISOLATION_TOOLING_CONTRACT
) {
const contract =
isJsonRecord(workflow) && isJsonRecord(workflow.env)
? workflow.env[RELEASE_ISOLATION_TOOLING_CONTRACT_ENV]
: undefined;
if (contract !== "1" && contract !== RELEASE_ISOLATION_TOOLING_CONTRACT) {
throw new Error(
`Tooling SHA ${workflowSha} does not declare ${RELEASE_ISOLATION_TOOLING_CONTRACT_ENV}=${RELEASE_ISOLATION_TOOLING_CONTRACT} in ${TRUSTED_WORKFLOW_PATH}`,
`Tooling SHA ${workflowSha} does not declare a supported ${RELEASE_ISOLATION_TOOLING_CONTRACT_ENV} in ${TRUSTED_WORKFLOW_PATH}`,
);
}
const workflowInputs =
isJsonRecord(workflow) &&
isJsonRecord(workflow.on) &&
isJsonRecord(workflow.on.workflow_dispatch) &&
isJsonRecord(workflow.on.workflow_dispatch.inputs)
? workflow.on.workflow_dispatch.inputs
: undefined;
if (!workflowInputs || !Object.hasOwn(workflowInputs, "expected_sha")) {
throw new Error(
`Tooling SHA ${workflowSha} is missing workflow_dispatch input expected_sha in ${TRUSTED_WORKFLOW_PATH}`,
);
}
if (
!isJsonRecord(workflow.on) ||
!isJsonRecord(workflow.on.workflow_dispatch) ||
!isJsonRecord(workflow.on.workflow_dispatch.inputs) ||
!Object.hasOwn(workflow.on.workflow_dispatch.inputs, "expected_sha")
contract === RELEASE_ISOLATION_TOOLING_CONTRACT &&
!Object.hasOwn(workflowInputs, "trusted_workflow_json")
) {
throw new Error(
`Tooling SHA ${workflowSha} is missing workflow_dispatch input expected_sha in ${TRUSTED_WORKFLOW_PATH}`,
`Tooling SHA ${workflowSha} declares ${RELEASE_ISOLATION_TOOLING_CONTRACT_ENV}=2 but is missing workflow_dispatch input trusted_workflow_json in ${TRUSTED_WORKFLOW_PATH}`,
);
}
const verifierPath = RELEASE_EVIDENCE_VERIFIER_PATHS.find((relativePath) =>
@@ -631,7 +713,7 @@ export function assertTrustedWorkflowHarness(
`trusted workflow SHA ${workflowSha} does not contain a supported release evidence verifier`,
);
}
return verifierPath;
return { contract, verifierPath };
}
export function releaseEvidenceVerifierPath(worktreeRoot: string) {
@@ -645,7 +727,11 @@ export function releaseEvidenceVerifierPath(worktreeRoot: string) {
return verifier;
}
function verifyReleaseEvidence(parentRunId: string, workflowSha: string) {
function verifyReleaseEvidence(
parentRunId: string,
workflowSha: string,
trustedWorkflowRef: string,
) {
const verifierWorktree = mkdtempSync(join(tmpdir(), "openclaw-release-verifier-"));
try {
run("git", ["worktree", "add", "--detach", verifierWorktree, workflowSha], {
@@ -655,7 +741,7 @@ function verifyReleaseEvidence(parentRunId: string, workflowSha: string) {
const evidence: unknown = JSON.parse(
run(process.execPath, [
verifier,
...releaseEvidenceVerificationArgs(parentRunId, workflowSha, verifier),
...releaseEvidenceVerificationArgs(parentRunId, workflowSha, verifier, trustedWorkflowRef),
]),
);
if (
@@ -684,8 +770,11 @@ function main() {
args.inputs.release_profile ??= releaseProfileForVersion(targetVersion);
args.inputs.allow_unreleased_changelog ??= args.targetRef ? "false" : "true";
const targetContextRef = verifyTargetRef(args.targetRef, targetSha, targetVersion);
const workflowSha = resolveTrustedWorkflowSha(args.workflowSha);
assertTrustedWorkflowHarness(workflowSha);
const workflowSha = resolveTrustedWorkflowSha(args.workflowSha, args.trustedWorkflowRef);
const trustedWorkflowHarness = assertTrustedWorkflowHarness(workflowSha);
if (trustedWorkflowHarness.contract === "1") {
args.inputs.reuse_evidence = "false";
}
const shortSha = workflowSha.slice(0, 12);
const branch = `release-ci/${shortSha}-${Date.now()}`;
const remoteBranchRef = `refs/heads/${branch}`;
@@ -694,12 +783,25 @@ function main() {
const dispatchInputs = {
ref: targetSha,
expected_sha: targetSha,
...(trustedWorkflowHarness.contract === RELEASE_ISOLATION_TOOLING_CONTRACT
? {
trusted_workflow_json: JSON.stringify({
ref: args.trustedWorkflowRef,
fullRef:
args.trustedWorkflowRef === "main"
? "refs/heads/main"
: `refs/tags/${args.trustedWorkflowRef}`,
sha: workflowSha,
}),
}
: {}),
...(targetContextRef !== targetSha ? { target_context_ref: targetContextRef } : {}),
...args.inputs,
};
console.log(`Validation SHA: ${targetSha}`);
console.log(`Tooling SHA: ${workflowSha}`);
console.log(`Trusted workflow ref: ${args.trustedWorkflowRef}`);
console.log(
`Frozen validation tuple: candidate=${targetSha} tooling=${workflowSha} rerun_group=${args.inputs.rerun_group}`,
);
@@ -753,7 +855,7 @@ function main() {
`Full Release Validation concluded ${parentConclusion.toLowerCase() || "without a conclusion"}: https://github.com/openclaw/openclaw/actions/runs/${parentRunId}`,
);
}
verifyReleaseEvidence(parentRunId, workflowSha);
verifyReleaseEvidence(parentRunId, workflowSha, args.trustedWorkflowRef);
evidenceVerified = true;
} finally {
if (
@@ -12,6 +12,9 @@ WORKFLOW_FILE="full-release-validation.yml"
TARGET_SHA=""
VERIFIER_WORKFLOW_SHA=""
WORKFLOW_REF=""
TRUSTED_WORKFLOW_REF=""
TRUSTED_WORKFLOW_FULL_REF=""
TRUSTED_WORKFLOW_SHA=""
RELEASE_PROFILE=""
RUN_RELEASE_SOAK="false"
INPUTS_JSON=""
@@ -27,6 +30,9 @@ usage() {
cat >&2 <<'EOF'
Usage: find-reusable-release-validation.sh --target-sha <sha> --workflow-sha <sha> \
--workflow-ref <main|release-ci/sha12-timestamp> \
[--trusted-workflow-ref <main|release-publish/sha12-run>] \
[--trusted-workflow-full-ref <refs/heads/main|refs/tags/release-publish/sha12-run>] \
[--trusted-workflow-sha <sha>] \
--release-profile <beta|stable|full> --inputs-json <json> \
[--run-release-soak <true|false>] [--repo <owner/repo>] [--repo-dir <path>] \
[--workflow <file>] [--max-candidates <n>] [--github-output <file>]
@@ -55,6 +61,18 @@ while [[ $# -gt 0 ]]; do
WORKFLOW_REF="${2:-}"
shift 2
;;
--trusted-workflow-ref)
TRUSTED_WORKFLOW_REF="${2:-}"
shift 2
;;
--trusted-workflow-full-ref)
TRUSTED_WORKFLOW_FULL_REF="${2:-}"
shift 2
;;
--trusted-workflow-sha)
TRUSTED_WORKFLOW_SHA="${2:-}"
shift 2
;;
--release-profile)
RELEASE_PROFILE="${2:-}"
shift 2
@@ -124,6 +142,16 @@ if [[ ! "$VERIFIER_WORKFLOW_SHA" =~ ^[0-9a-f]{40}$ ]]; then
echo "Expected --workflow-sha to be a full lowercase commit SHA; got: ${VERIFIER_WORKFLOW_SHA}" >&2
exit 2
fi
TRUSTED_WORKFLOW_REF="${TRUSTED_WORKFLOW_REF:-main}"
TRUSTED_WORKFLOW_FULL_REF="${TRUSTED_WORKFLOW_FULL_REF:-refs/heads/main}"
TRUSTED_WORKFLOW_SHA="${TRUSTED_WORKFLOW_SHA:-${VERIFIER_WORKFLOW_SHA}}"
if [[ ! "$TRUSTED_WORKFLOW_SHA" =~ ^[0-9a-f]{40}$ ]]; then
echo "Expected --trusted-workflow-sha to be a full lowercase commit SHA; got: ${TRUSTED_WORKFLOW_SHA}" >&2
exit 2
fi
if [[ "$TRUSTED_WORKFLOW_SHA" != "$VERIFIER_WORKFLOW_SHA" ]]; then
no_reuse "trusted workflow SHA does not match verifier source SHA"
fi
if [[ "$WORKFLOW_REF" != "main" ]]; then
expected_release_ref="release-ci/${VERIFIER_WORKFLOW_SHA:0:12}-"
if [[ ! "$WORKFLOW_REF" =~ ^release-ci/[0-9a-f]{12}-[1-9][0-9]*$ ]] ||
@@ -149,18 +177,44 @@ if ! expected_inputs="$(jq -Sc 'if type == "object" then . else error("expected
exit 2
fi
workflow_lineage=""
if ! workflow_lineage="$(
gh api "repos/${REPO}/compare/${VERIFIER_WORKFLOW_SHA}...main"
)"; then
no_reuse "could not verify workflow SHA against trusted main"
fi
if ! jq -e \
--arg workflow_sha "$VERIFIER_WORKFLOW_SHA" '
(.status == "ahead" or .status == "identical")
and .merge_base_commit.sha == $workflow_sha
' <<< "$workflow_lineage" >/dev/null; then
no_reuse "workflow SHA is not on trusted main lineage"
trusted_workflow_route=""
if [[ "$TRUSTED_WORKFLOW_REF" == "main" ]]; then
if [[ "$TRUSTED_WORKFLOW_FULL_REF" != "refs/heads/main" ]]; then
no_reuse "trusted main workflow full ref is invalid"
fi
workflow_lineage=""
if ! workflow_lineage="$(
gh api "repos/${REPO}/compare/${TRUSTED_WORKFLOW_SHA}...main"
)"; then
no_reuse "could not verify workflow SHA against trusted main"
fi
if ! jq -e \
--arg workflow_sha "$TRUSTED_WORKFLOW_SHA" '
(.status == "ahead" or .status == "identical")
and .merge_base_commit.sha == $workflow_sha
' <<< "$workflow_lineage" >/dev/null; then
no_reuse "workflow SHA is not on trusted main lineage"
fi
trusted_workflow_route="main"
elif [[ "$TRUSTED_WORKFLOW_REF" =~ ^release-publish/([0-9a-f]{12})-[1-9][0-9]*$ ]] &&
[[ "$TRUSTED_WORKFLOW_FULL_REF" == "refs/tags/${TRUSTED_WORKFLOW_REF}" ]] &&
[[ "$TRUSTED_WORKFLOW_REF" == "release-publish/${TRUSTED_WORKFLOW_SHA:0:12}-"* ]]; then
trusted_tag_json=""
if ! trusted_tag_json="$(
gh api "repos/${REPO}/git/ref/tags/${TRUSTED_WORKFLOW_REF}"
)"; then
no_reuse "could not verify protected trusted workflow tag"
fi
if ! jq -e \
--arg workflow_sha "$TRUSTED_WORKFLOW_SHA" '
.object.type == "commit"
and .object.sha == $workflow_sha
' <<< "$trusted_tag_json" >/dev/null; then
no_reuse "protected trusted workflow tag moved or is not lightweight"
fi
trusted_workflow_route="protected-tag"
else
no_reuse "trusted workflow identity is not main or an exact protected tag"
fi
# Exact-target reuse still requires internally consistent version stamps
@@ -190,7 +244,9 @@ for ((index = 0; index < run_count; index += 1)); do
node "$VALIDATOR" \
--validate-run "$run_id" \
--repo "$REPO" \
--trusted-workflow-ref main \
--trusted-workflow-ref "$TRUSTED_WORKFLOW_REF" \
--trusted-workflow-full-ref "$TRUSTED_WORKFLOW_FULL_REF" \
--trusted-workflow-sha "$TRUSTED_WORKFLOW_SHA" \
--verifier-source-sha "$VERIFIER_WORKFLOW_SHA" \
--verifier-source-file "$VALIDATOR" \
--json
@@ -217,14 +273,17 @@ for ((index = 0; index < run_count; index += 1)); do
if ! jq -e \
--arg repo "$REPO" \
--arg run_id "$run_id" \
--arg trusted_workflow_full_ref "$TRUSTED_WORKFLOW_FULL_REF" \
--arg trusted_workflow_ref "$TRUSTED_WORKFLOW_REF" \
--arg trusted_workflow_route "$trusted_workflow_route" \
--arg verifier_sha "$VERIFIER_WORKFLOW_SHA" '
. as $record
| .schema == "openclaw.release-validation-evidence/v3"
and .valid == true
and .repository == $repo
and .producerOnTrustedMainLineage == true
and .trustedWorkflowRef == "main"
and .trustedWorkflowFullRef == "refs/heads/main"
and .producerOnTrustedMainLineage == ($trusted_workflow_route == "main")
and .trustedWorkflowRef == $trusted_workflow_ref
and .trustedWorkflowFullRef == $trusted_workflow_full_ref
and .directRoot == true
and .evidenceReuse == null
and .rerunGroup == "all"
@@ -239,7 +298,7 @@ for ((index = 0; index < run_count; index += 1)); do
and (.root.artifact.digest | type == "string" and test("^sha256:[0-9a-f]{64}$"))
and all($record.current, $record.root;
. as $parent
| .producerOnTrustedMainLineage == true
| .producerOnTrustedMainLineage == ($trusted_workflow_route == "main")
and .workflowRefType == "branch"
and .workflowPath == ".github/workflows/full-release-validation.yml"
and .workflowFullRef == ("refs/heads/" + .workflowRef)
@@ -249,24 +308,31 @@ for ((index = 0; index < run_count; index += 1)); do
.workflowRunPath == ".github/workflows/full-release-validation.yml"
or .workflowRunPath == .workflowQualifiedPath
)
and (
and if $trusted_workflow_route == "main" then
(
.workflowRef == "main"
and (
(.manifestVersion == 3 and .workflowRefProof == "manifest-v3-branch")
or (
.manifestVersion == 2
and .workflowRefProof == "legacy-v2-main-ancestry"
(
.workflowRef == "main"
and (
(.manifestVersion == 3 and .workflowRefProof == "manifest-v3-branch")
or (
.manifestVersion == 2
and .workflowRefProof == "legacy-v2-main-ancestry"
)
)
)
or (
.manifestVersion == 3
and .workflowRefProof == "manifest-v3-sha-pinned-main-ancestry"
and (.workflowRef | test("^release-ci/[0-9a-f]{12}-[1-9][0-9]*$"))
and (.workflowRef | startswith("release-ci/\($parent.workflowSha[0:12])-"))
)
)
or (
.manifestVersion == 3
and .workflowRefProof == "manifest-v3-sha-pinned-main-ancestry"
and (.workflowRef | test("^release-ci/[0-9a-f]{12}-[1-9][0-9]*$"))
and (.workflowRef | startswith("release-ci/\($parent.workflowSha[0:12])-"))
)
)
else
.manifestVersion == 3
and .workflowRefProof == "manifest-v3-protected-tag-exact-sha"
and (.workflowRef | test("^release-ci/[0-9a-f]{12}-[1-9][0-9]*$"))
and (.workflowRef | startswith("release-ci/\($parent.workflowSha[0:12])-"))
end
)
and (.verifier.schemaVersion == 3)
and (.verifier.sourceSha == $verifier_sha)
+64 -34
View File
@@ -22,6 +22,8 @@ export interface OpenClawNpmResumeValidationInput {
run: ResumeRunRecord;
tag: ResumeTagRecord;
tagRef: ResumeTagRecord;
trustedWorkflowFullRef: unknown;
trustedWorkflowRef: unknown;
}
const SHA_PATTERN = /^[a-f0-9]{40}$/u;
@@ -79,14 +81,6 @@ function requiredSha(value: unknown, label: string): string {
return sha;
}
function trustedWorkflowPath(path: string, branch: string): boolean {
return new Set([
WORKFLOW_PATH,
`${WORKFLOW_PATH}@${branch}`,
`${WORKFLOW_PATH}@refs/tags/${branch}`,
]).has(path);
}
export function validateOpenClawNpmResumeRun({
canonicalWorkflowId,
compareStatus,
@@ -94,41 +88,50 @@ export function validateOpenClawNpmResumeRun({
run,
tag,
tagRef,
trustedWorkflowFullRef,
trustedWorkflowRef,
}: OpenClawNpmResumeValidationInput) {
const url = requiredString(run?.html_url, "html_url");
const branch = requiredString(run?.head_branch, "head_branch");
const branchMatch = RELEASE_PUBLISH_REF_PATTERN.exec(branch);
if (!branchMatch) {
const workflowRef = requiredString(trustedWorkflowRef, "trusted workflow ref");
const workflowFullRef = requiredString(trustedWorkflowFullRef, "trusted workflow full ref");
const workflowRefMatch = RELEASE_PUBLISH_REF_PATTERN.exec(workflowRef);
if (!workflowRefMatch || workflowFullRef !== `refs/tags/${workflowRef}`) {
fail(`OpenClaw npm resume run has an untrusted workflow ref: ${url}`);
}
const branch = requiredString(run?.head_branch, "head_branch");
const sha = requiredSha(run?.head_sha, "head_sha");
const path = requiredString(run?.path, "path");
if (
run?.conclusion !== "success" ||
run?.event !== "workflow_dispatch" ||
!trustedWorkflowPath(path, branch) ||
path !== WORKFLOW_PATH ||
run?.workflow_id !== canonicalWorkflowId ||
sha.slice(0, 12) !== branchMatch[1]
branch !== workflowRef ||
sha.slice(0, 12) !== workflowRefMatch[1]
) {
fail(`OpenClaw npm resume run has an untrusted workflow identity: ${url}`);
}
const tagObjectSha = requiredSha(tagRef?.object?.sha, "tooling tag object SHA");
if (tagRef?.object?.type !== "tag") {
fail(`OpenClaw npm resume run tooling ref is not a signed annotated tag: ${url}`);
}
const tagCommitSha = requiredSha(tag?.object?.sha, "tooling tag commit SHA");
if (
tag?.object?.type !== "commit" ||
tagCommitSha !== sha ||
tag?.verification?.verified !== true ||
(compareStatus !== "ahead" && compareStatus !== "identical")
) {
fail(
`OpenClaw npm resume run is not bound to a real, main-reachable protected tooling tag: ${url}`,
);
if (tagRef?.object?.type === "commit") {
if (tagObjectSha !== sha) {
fail(`OpenClaw npm resume run protected tooling tag moved after dispatch: ${url}`);
}
} else if (tagRef?.object?.type === "tag") {
const tagCommitSha = requiredSha(tag?.object?.sha, "tooling tag commit SHA");
if (
tag?.object?.type !== "commit" ||
tagCommitSha !== sha ||
tag?.verification?.verified !== true ||
(compareStatus !== "ahead" && compareStatus !== "identical")
) {
fail(
`OpenClaw npm resume run is not bound to a real, main-reachable protected tooling tag: ${url}`,
);
}
} else {
fail(`OpenClaw npm resume run tooling ref is not a protected tag: ${url}`);
}
if (
@@ -140,7 +143,7 @@ export function validateOpenClawNpmResumeRun({
return {
url,
workflowRef: `refs/tags/${branch}`,
workflowRef: workflowFullRef,
workflowSha: sha,
tagObjectSha,
};
@@ -177,10 +180,14 @@ function runGhCommand(
export function resolveOpenClawNpmResumeRun({
repo,
runId,
trustedWorkflowFullRef,
trustedWorkflowRef,
runGh = runOpenClawNpmResumeGh,
}: {
repo: string;
runId: string;
trustedWorkflowFullRef: string;
trustedWorkflowRef: string;
runGh?: (args: string[]) => string;
}) {
if (!/^[1-9][0-9]*$/u.test(runId)) {
@@ -192,14 +199,21 @@ export function resolveOpenClawNpmResumeRun({
const api = (endpoint: string): unknown =>
parseJson(runGh(["api", `repos/${repo}/${endpoint}`, "--method", "GET"]), endpoint);
const trustedRefMatch = RELEASE_PUBLISH_REF_PATTERN.exec(trustedWorkflowRef);
if (!trustedRefMatch || trustedWorkflowFullRef !== `refs/tags/${trustedWorkflowRef}`) {
fail(
"OpenClaw npm resume trusted workflow identity must be an exact protected release-publish tag.",
);
}
const run = resumeRunRecord(api(`actions/runs/${runId}`));
const canonicalWorkflow = api(`actions/workflows/${WORKFLOW_PATH.split("/").at(-1)}`);
const branch = requiredString(run?.head_branch, "head_branch");
const tagRef = resumeTagRecord(api(`git/ref/tags/${branch}`));
const tagRef = resumeTagRecord(api(`git/ref/tags/${trustedWorkflowRef}`));
const tagObjectSha = requiredSha(tagRef?.object?.sha, "tooling tag object SHA");
const tag = resumeTagRecord(api(`git/tags/${tagObjectSha}`));
const sha = requiredSha(run?.head_sha, "head_sha");
const comparison = api(`compare/${sha}...main`);
const annotatedTag = tagRef?.object?.type === "tag";
const tag = annotatedTag ? resumeTagRecord(api(`git/tags/${tagObjectSha}`)) : {};
const comparison = annotatedTag ? api(`compare/${sha}...main`) : {};
const jobs = resumeJobRecords(
parseJson(
runGh(["run", "view", runId, "--repo", repo, "--json", "jobs", "--jq", ".jobs"]),
@@ -214,17 +228,33 @@ export function resolveOpenClawNpmResumeRun({
run,
tag,
tagRef,
trustedWorkflowFullRef,
trustedWorkflowRef,
});
}
function parseArgs(argv: string[]): { repo: string; runId: string } {
const options = { repo: "", runId: "" };
function parseArgs(argv: string[]): {
repo: string;
runId: string;
trustedWorkflowFullRef: string;
trustedWorkflowRef: string;
} {
const options = {
repo: "",
runId: "",
trustedWorkflowFullRef: "",
trustedWorkflowRef: "",
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--repo") {
options.repo = argv[(index += 1)] ?? "";
} else if (arg === "--run-id") {
options.runId = argv[(index += 1)] ?? "";
} else if (arg === "--trusted-workflow-ref") {
options.trustedWorkflowRef = argv[(index += 1)] ?? "";
} else if (arg === "--trusted-workflow-full-ref") {
options.trustedWorkflowFullRef = argv[(index += 1)] ?? "";
} else {
fail(`Unknown argument: ${arg}`);
}
+24
View File
@@ -172,6 +172,26 @@ if [[ "${mirror_auth_requirement}" == "required" && -z "${mirror_auth_token}" ]]
exit 1
fi
verify_release_tooling_identity() {
if [[ "${OPENCLAW_RELEASE_TOOLING_IDENTITY_REQUIRED:-}" != "true" ]]; then
return 0
fi
identity_args=(
verify
--repository "${OPENCLAW_RELEASE_TOOLING_REPOSITORY:-}"
--workflow-ref "${OPENCLAW_RELEASE_TOOLING_REF:-}"
--workflow-full-ref "${OPENCLAW_RELEASE_TOOLING_FULL_REF:-}"
--workflow-sha "${OPENCLAW_RELEASE_TOOLING_SHA:-}"
--release-publish-run-id "${OPENCLAW_RELEASE_PUBLISH_RUN_ID:-}"
--release-publish-run-attempt "${OPENCLAW_RELEASE_PUBLISH_RUN_ATTEMPT:-}"
--release-publish-parent-state-policy "${OPENCLAW_RELEASE_PUBLISH_PARENT_STATE_POLICY:-}"
)
if [[ "${OPENCLAW_RELEASE_TOOLING_ALLOW_PREVALIDATED_REF:-}" == "true" ]]; then
identity_args+=(--allow-prevalidated-ref)
fi
node "${tooling_root}/scripts/release-tooling-identity.mjs" "${identity_args[@]}"
}
if [[ "${mode}" == "--pack" || "${mode}" == "--pack-dry-run" ]]; then
{
printf 'Publish command:'
@@ -228,6 +248,9 @@ fi
cleanup_files+=("${publish_userconfig}")
chmod 0600 "${publish_userconfig}"
printf '%s\n' "//registry.npmjs.org/:_authToken=${publish_auth_token}" > "${publish_userconfig}"
fi
verify_release_tooling_identity
if [[ -n "${publish_auth_token}" ]]; then
NPM_CONFIG_USERCONFIG="${publish_userconfig}" run_with_manifest_overlay "${publish_cmd[@]}"
else
run_with_manifest_overlay "${publish_cmd[@]}"
@@ -243,6 +266,7 @@ fi
for dist_tag in "${mirror_dist_tags[@]}"; do
[[ -n "${dist_tag}" ]] || continue
echo "Mirroring ${package_name}@${package_version} onto dist-tag ${dist_tag}"
verify_release_tooling_identity
if ! NPM_CONFIG_USERCONFIG="${mirror_userconfig}" \
npm dist-tag add "${package_name}@${package_version}" "${dist_tag}"; then
if [[ "${mirror_auth_requirement}" == "required" ]]; then
+51
View File
@@ -18,6 +18,7 @@ import { basename, dirname, join, resolve as resolvePath } from "node:path";
import { fileURLToPath } from "node:url";
import { isDeepStrictEqual } from "node:util";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { parse as parseYaml } from "yaml";
import {
booleanFlag,
parseFlagArgs,
@@ -1205,6 +1206,47 @@ export function requireRunIdFromDispatchOutput(output: string, workflowFile: str
return runId;
}
export function fullReleaseTrustedWorkflowFields({
workflowRef,
workflowSha,
workflowSource,
}: {
workflowRef: string;
workflowSha: string;
workflowSource: string;
}) {
const workflow: unknown = parseYaml(workflowSource);
const env = isRecord(workflow) && isRecord(workflow.env) ? workflow.env : undefined;
const contract = String(env?.RELEASE_ISOLATION_TOOLING_CONTRACT ?? "");
if (contract === "1") {
return {};
}
if (contract !== "2") {
throw new Error(
"Full Release Validation does not declare a supported release tooling contract",
);
}
const workflowDispatch =
isRecord(workflow) && isRecord(workflow.on) && isRecord(workflow.on.workflow_dispatch)
? workflow.on.workflow_dispatch
: undefined;
const inputs =
workflowDispatch && isRecord(workflowDispatch.inputs) ? workflowDispatch.inputs : undefined;
if (!inputs || !Object.hasOwn(inputs, "trusted_workflow_json")) {
throw new Error(`Full Release Validation contract ${contract} requires trusted_workflow_json`);
}
if (!/^[a-f0-9]{40}$/u.test(workflowSha)) {
throw new Error("Full Release Validation trusted workflow SHA must be a full lowercase SHA");
}
return {
trusted_workflow_json: JSON.stringify({
ref: workflowRef,
fullRef: `refs/heads/${workflowRef}`,
sha: workflowSha,
}),
};
}
async function wait(ms: number) {
await new Promise<void>((resolve) => {
setTimeout(resolve, ms);
@@ -1819,9 +1861,18 @@ async function main() {
if (!options.fullReleaseRunId && !options.skipDispatch) {
const workflowFile = "full-release-validation.yml";
const targetContextRef = releaseBranchForTag(options.tag);
const trustedWorkflowFields = fullReleaseTrustedWorkflowFields({
workflowRef: options.workflowRef,
workflowSha: toolingSha,
workflowSource: readFileSync(
join(TOOLING_ROOT, ".github", "workflows", workflowFile),
"utf8",
),
});
options.fullReleaseRunId = dispatchWorkflow(options.repo, workflowFile, options.workflowRef, {
ref: targetSha,
...(targetContextRef ? { target_context_ref: targetContextRef } : {}),
...trustedWorkflowFields,
provider: options.provider,
mode: options.mode,
release_profile: options.releaseProfile,
+83 -16
View File
@@ -15,6 +15,8 @@ import { execGhRead, plainGhEnv, resolvePlainGhBin } from "./lib/plain-gh.mjs";
const DEFAULT_REPO = process.env.OPENCLAW_RELEASE_REPO || "openclaw/openclaw";
const RELEASE_EVIDENCE_SCHEMA = "openclaw.release-validation-evidence/v3";
const SHA_PINNED_BRANCH_PATTERN = /^release-ci\/[a-f0-9]{12}-[1-9][0-9]*$/u;
const TRUSTED_RELEASE_PUBLISH_TAG_PATTERN =
/^refs\/tags\/release-publish\/([a-f0-9]{12})-[1-9][0-9]*$/u;
const RELEASE_EVIDENCE_SCRIPT = "scripts/release-ci-summary.mjs";
const RELEASE_EVIDENCE_FILE = fileURLToPath(import.meta.url);
const RELEASE_EVIDENCE_REPO_ROOT = resolve(dirname(RELEASE_EVIDENCE_FILE), "..");
@@ -1149,8 +1151,26 @@ function loadValidatedParentEvidence({ client, manifestPath, repository, runId }
};
}
function trustedWorkflowFullRef(workflowRef) {
return `refs/heads/${workflowRef}`;
function resolveTrustedWorkflowIdentity(workflowRef, workflowFullRef, workflowSha) {
const fullRef = workflowFullRef ?? `refs/heads/${workflowRef}`;
const protectedTag = TRUSTED_RELEASE_PUBLISH_TAG_PATTERN.exec(fullRef);
if (protectedTag) {
if (workflowRef !== fullRef.slice("refs/tags/".length)) {
throw new Error("trusted workflow tag name does not match its full ref");
}
const sha = normalizeSha(workflowSha, "trusted workflow SHA");
if (sha.slice(0, 12) !== protectedTag[1]) {
throw new Error("trusted workflow tag does not match its workflow SHA");
}
return { fullRef, ref: workflowRef, sha, type: "tag" };
}
if (fullRef !== `refs/heads/${workflowRef}`) {
throw new Error("trusted workflow full ref does not match its ref");
}
if (workflowRef.startsWith("release-publish/")) {
throw new Error("trusted release-publish workflow ref must be a protected tag");
}
return { fullRef, ref: workflowRef, sha: undefined, type: "branch" };
}
function normalizeWorkflowPathRef(ref) {
@@ -1160,11 +1180,31 @@ function normalizeWorkflowPathRef(ref) {
return `refs/heads/${ref}`;
}
export function validateTrustedProducerIdentity(evidence, client, verifier, trustedWorkflowRef) {
export function validateTrustedProducerIdentity(
evidence,
client,
verifier,
trustedWorkflowRef,
trustedWorkflowFullRef,
trustedWorkflowSha,
) {
const { manifest, parentRun } = evidence;
const trustedIdentity = resolveTrustedWorkflowIdentity(
trustedWorkflowRef,
trustedWorkflowFullRef,
trustedWorkflowSha,
);
// Keep this predicate local: verifier source identity covers this file only.
const shaPinned = SHA_PINNED_BRANCH_PATTERN.test(manifest.workflowRef ?? "");
if (manifest.workflowRef !== trustedWorkflowRef && !shaPinned) {
const protectedTagRoute = trustedIdentity.type === "tag";
if (protectedTagRoute) {
if (!shaPinned) {
throw new Error("protected-tag release evidence must use a canonical release-ci branch");
}
if (manifest.workflowSha !== trustedIdentity.sha) {
throw new Error("protected-tag release evidence workflow SHA does not match trusted tooling");
}
} else if (manifest.workflowRef !== trustedWorkflowRef && !shaPinned) {
throw new Error(
`release evidence producer must run from trusted workflow ref: ${trustedWorkflowRef}`,
);
@@ -1180,7 +1220,7 @@ export function validateTrustedProducerIdentity(evidence, client, verifier, trus
throw new Error("SHA-pinned release evidence target ref must equal its target SHA");
}
}
const expectedFullRef = trustedWorkflowFullRef(manifest.workflowRef);
const expectedFullRef = `refs/heads/${manifest.workflowRef}`;
const runPath = String(parentRun.path ?? "");
const [runWorkflowPath, runWorkflowFullRef] = runPath.split("@", 2);
if (runWorkflowPath !== ".github/workflows/full-release-validation.yml") {
@@ -1195,19 +1235,25 @@ export function validateTrustedProducerIdentity(evidence, client, verifier, trus
if (manifest.workflowRefType !== "branch" || manifest.workflowFullRef !== expectedFullRef) {
throw new Error("release evidence producer workflow full ref is not trusted");
}
workflowRefProof = shaPinned ? "manifest-v3-sha-pinned-main-ancestry" : "manifest-v3-branch";
workflowRefProof = protectedTagRoute
? "manifest-v3-protected-tag-exact-sha"
: shaPinned
? "manifest-v3-sha-pinned-main-ancestry"
: "manifest-v3-branch";
}
const comparison = client.compareCommitLineage(manifest.workflowSha, verifier.sourceSha);
if (
!["ahead", "identical"].includes(String(comparison.status)) ||
comparison.merge_base_commit?.sha !== manifest.workflowSha
) {
throw new Error("release evidence producer is not on the trusted main verifier lineage");
if (!protectedTagRoute) {
const comparison = client.compareCommitLineage(manifest.workflowSha, verifier.sourceSha);
if (
!["ahead", "identical"].includes(String(comparison.status)) ||
comparison.merge_base_commit?.sha !== manifest.workflowSha
) {
throw new Error("release evidence producer is not on the trusted main verifier lineage");
}
}
return {
producerOnTrustedMainLineage: true,
producerOnTrustedMainLineage: !protectedTagRoute,
workflowFullRef: expectedFullRef,
workflowQualifiedPath: `${runWorkflowPath}@${expectedFullRef}`,
workflowRefProof,
@@ -1352,7 +1398,9 @@ function validateStrictChildRun({ child, client, parentEvidence, parentJobs, rep
* manifestPath?: string,
* repository?: string,
* runId: string,
* trustedWorkflowFullRef?: string,
* trustedWorkflowRef?: string,
* trustedWorkflowSha?: string,
* verifierSourceContent?: string | Uint8Array,
* verifierSourceSha: string,
* }} options
@@ -1362,7 +1410,9 @@ export function validateReleaseRunEvidence(
manifestPath,
repository = DEFAULT_REPO,
runId,
trustedWorkflowFullRef,
trustedWorkflowRef = "main",
trustedWorkflowSha,
verifierSourceContent,
verifierSourceSha,
},
@@ -1374,6 +1424,11 @@ export function validateReleaseRunEvidence(
trustedWorkflowRef,
"trusted workflow ref",
);
const trustedIdentity = resolveTrustedWorkflowIdentity(
normalizedTrustedWorkflowRef,
trustedWorkflowFullRef,
trustedWorkflowSha,
);
const evidenceClient = client ?? createReleaseEvidenceClient(normalizedRepository);
const verifier = resolveVerifierIdentity(verifierSourceSha, verifierSourceContent);
const currentEvidence = loadValidatedParentEvidence({
@@ -1390,6 +1445,8 @@ export function validateReleaseRunEvidence(
evidenceClient,
verifier,
normalizedTrustedWorkflowRef,
trustedIdentity.fullRef,
trustedIdentity.sha,
),
],
]);
@@ -1428,6 +1485,8 @@ export function validateReleaseRunEvidence(
evidenceClient,
verifier,
normalizedTrustedWorkflowRef,
trustedIdentity.fullRef,
trustedIdentity.sha,
),
);
}
@@ -1490,8 +1549,8 @@ export function validateReleaseRunEvidence(
root,
runReleaseSoak: rootEvidence.manifest.runReleaseSoak === "true",
schema: RELEASE_EVIDENCE_SCHEMA,
producerOnTrustedMainLineage: true,
trustedWorkflowFullRef: trustedWorkflowFullRef(normalizedTrustedWorkflowRef),
producerOnTrustedMainLineage: trustedIdentity.type === "branch",
trustedWorkflowFullRef: trustedIdentity.fullRef,
trustedWorkflowRef: normalizedTrustedWorkflowRef,
valid: true,
validationInputs: rootEvidence.manifest.validationInputs ?? null,
@@ -1506,7 +1565,9 @@ function parseReleaseCiSummaryArgs(argv) {
manifestPath: undefined,
repository: DEFAULT_REPO,
runId: undefined,
trustedWorkflowFullRef: undefined,
trustedWorkflowRef: "main",
trustedWorkflowSha: undefined,
validate: false,
verifierSourceFile: undefined,
verifierSourceSha: undefined,
@@ -1523,6 +1584,10 @@ function parseReleaseCiSummaryArgs(argv) {
options.manifestPath = argv[++index];
} else if (argument === "--trusted-workflow-ref") {
options.trustedWorkflowRef = argv[++index];
} else if (argument === "--trusted-workflow-full-ref") {
options.trustedWorkflowFullRef = argv[++index];
} else if (argument === "--trusted-workflow-sha") {
options.trustedWorkflowSha = argv[++index];
} else if (argument === "--verifier-source-sha") {
options.verifierSourceSha = argv[++index];
} else if (argument === "--verifier-source-file") {
@@ -1563,7 +1628,7 @@ function printUsage() {
[
"usage: release-ci-summary.mjs <full-release-run-id>",
" release-ci-summary.mjs <full-release-run-id> --watch [--interval seconds]",
" release-ci-summary.mjs --validate-run <id> [--repo owner/name] [--trusted-workflow-ref main] [--manifest path] [--verifier-source-sha sha --verifier-source-file path] --json",
" release-ci-summary.mjs --validate-run <id> [--repo owner/name] [--trusted-workflow-ref main --trusted-workflow-full-ref refs/heads/main] [--trusted-workflow-sha sha] [--manifest path] [--verifier-source-sha sha --verifier-source-file path] --json",
].join("\n"),
);
}
@@ -1662,7 +1727,9 @@ async function main() {
manifestPath: options.manifestPath,
repository,
runId,
trustedWorkflowFullRef: options.trustedWorkflowFullRef,
trustedWorkflowRef: options.trustedWorkflowRef,
trustedWorkflowSha: options.trustedWorkflowSha,
verifierSourceContent: options.verifierSourceFile
? readFileSync(options.verifierSourceFile)
: undefined,
+47
View File
@@ -0,0 +1,47 @@
export type ReleaseToolingIdentity = {
fullRef: string;
ref: string;
route: "main" | "prevalidated-branch" | "protected-tag";
sha: string;
};
export type ReleaseToolingIdentityInput = {
allowPrevalidatedRef?: boolean;
workflowFullRef: string;
workflowRef: string;
workflowSha: string;
};
export function resolveReleaseToolingIdentity(
input: {
requestedIdentityJson?: string;
workflowContract: string;
} & Pick<ReleaseToolingIdentityInput, "workflowFullRef" | "workflowRef" | "workflowSha">,
): Pick<ReleaseToolingIdentity, "fullRef" | "ref" | "sha">;
export function validateReleaseToolingIdentity(
input: ReleaseToolingIdentityInput & {
mainComparisonStatus?: unknown;
branchRef?: unknown;
tagRef?: unknown;
},
): ReleaseToolingIdentity;
export function verifyReleaseToolingIdentity(
input: ReleaseToolingIdentityInput & {
repository: string;
releasePublishParentStatePolicy?: "active" | "active-or-success" | "manual-recovery";
releasePublishRunAttempt?: string;
releasePublishRunId?: string;
runGh?: (args: string[]) => string;
},
): ReleaseToolingIdentity;
export function validateReleasePublishParentRun(input: {
identity: Pick<ReleaseToolingIdentity, "fullRef" | "ref" | "sha">;
releasePublishParentStatePolicy: "active" | "active-or-success" | "manual-recovery";
releasePublishRunAttempt: string;
releasePublishRunId: string;
repository: string;
run: unknown;
}): void;
+520
View File
@@ -0,0 +1,520 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { isRecord } from "./lib/record-shared.mjs";
const SHA_PATTERN = /^[a-f0-9]{40}$/u;
const RELEASE_PUBLISH_REF_PATTERN = /^release-publish\/([a-f0-9]{12})-([1-9][0-9]*)$/u;
const RELEASE_CI_REF_PATTERN = /^release-ci\/([a-f0-9]{12})-([1-9][0-9]*)$/u;
const DIRECT_WORKFLOW_REF_PATTERN =
/^(?:main|release\/[0-9]{4}\.(?:[1-9]|1[0-2])\.[1-9][0-9]*|extended-stable\/[0-9]{4}\.(?:[1-9]|1[0-2])\.33|tideclaw\/alpha\/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z)$/u;
const RELEASE_PUBLISH_PARENT_STATE_POLICIES = new Set([
"active",
"active-or-success",
"manual-recovery",
]);
const GH_COMMAND_TIMEOUT_MS = 60_000;
function fail(message) {
throw new Error(message);
}
function requiredString(value, label) {
if (typeof value !== "string" || value.trim().length === 0) {
fail(`${label} is required.`);
}
return value.trim();
}
function requiredSha(value, label) {
const sha = requiredString(value, label);
if (!SHA_PATTERN.test(sha)) {
fail(`${label} must be a lowercase 40-character commit SHA.`);
}
return sha;
}
function requireRepository(value) {
const repository = requiredString(value, "release tooling repository");
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(repository)) {
fail("release tooling repository must be owner/name.");
}
return repository;
}
function parseIdentityJson(value) {
const raw = requiredString(value, "requested release tooling identity");
let identity;
try {
identity = JSON.parse(raw);
} catch (error) {
throw new Error("requested release tooling identity must be valid JSON.", { cause: error });
}
if (!isRecord(identity)) {
fail("requested release tooling identity must be a JSON object.");
}
return {
fullRef: requiredString(identity.fullRef, "requested release tooling full ref"),
ref: requiredString(identity.ref, "requested release tooling ref"),
sha: requiredSha(identity.sha, "requested release tooling SHA"),
};
}
export function resolveReleaseToolingIdentity({
requestedIdentityJson = "",
workflowContract,
workflowFullRef,
workflowRef,
workflowSha,
}) {
const contract = requiredString(workflowContract, "release tooling contract");
if (contract !== "1" && contract !== "2") {
fail(`release tooling contract ${contract} is not supported.`);
}
const ref = requiredString(workflowRef, "workflow ref");
const fullRef = requiredString(workflowFullRef, "workflow full ref");
const sha = requiredSha(workflowSha, "workflow SHA");
const directRoute = fullRef === `refs/heads/${ref}` && DIRECT_WORKFLOW_REF_PATTERN.test(ref);
const releaseCiMatch = fullRef === `refs/heads/${ref}` ? RELEASE_CI_REF_PATTERN.exec(ref) : null;
const protectedTagMatch =
fullRef === `refs/tags/${ref}` ? RELEASE_PUBLISH_REF_PATTERN.exec(ref) : null;
if (releaseCiMatch && releaseCiMatch[1] !== sha.slice(0, 12)) {
fail("release-ci workflow ref does not match the workflow SHA.");
}
if (protectedTagMatch && protectedTagMatch[1] !== sha.slice(0, 12)) {
fail("protected workflow ref does not match the workflow SHA.");
}
if (!directRoute && !releaseCiMatch && !protectedTagMatch) {
fail("workflow ref is not a trusted direct, release-ci, or protected-tag route.");
}
const requested = requestedIdentityJson.trim()
? parseIdentityJson(requestedIdentityJson)
: undefined;
if (!requested) {
if (contract !== "1" && contract !== "2") {
fail(`release tooling contract ${contract} requires explicit trusted workflow identity.`);
}
if (!directRoute) {
fail("release-ci and protected-tag workflows require explicit trusted workflow identity.");
}
return { fullRef, ref, sha };
}
if (directRoute || protectedTagMatch) {
if (requested.ref !== ref || requested.fullRef !== fullRef || requested.sha !== sha) {
fail("direct workflow identity must match the executing workflow ref and SHA.");
}
return requested;
}
const requestedProtectedTag = RELEASE_PUBLISH_REF_PATTERN.test(requested.ref);
const requestedMain = requested.ref === "main" && requested.fullRef === "refs/heads/main";
if (
requested.sha !== sha ||
(!requestedMain &&
(!requestedProtectedTag || requested.fullRef !== `refs/tags/${requested.ref}`))
) {
fail("release-ci workflow identity must be trusted main or an exact protected tag.");
}
return requested;
}
function classifyIdentity({ allowPrevalidatedRef, workflowFullRef, workflowRef, workflowSha }) {
const ref = requiredString(workflowRef, "release tooling ref");
const fullRef = requiredString(workflowFullRef, "release tooling full ref");
const sha = requiredSha(workflowSha, "release tooling SHA");
const protectedMatch = RELEASE_PUBLISH_REF_PATTERN.exec(ref);
if (protectedMatch) {
if (fullRef !== `refs/tags/${ref}`) {
fail("protected release tooling identity must use the exact tag full ref.");
}
if (sha.slice(0, 12) !== protectedMatch[1]) {
fail("protected release tooling tag SHA prefix does not match the workflow SHA.");
}
return { fullRef, ref, route: "protected-tag", sha };
}
if (
ref.startsWith("release-publish/") ||
fullRef.startsWith("refs/tags/release-publish/") ||
fullRef.startsWith("refs/heads/release-publish/")
) {
fail("release-publish tooling identity must be an exact protected tag.");
}
if (ref === "main" || fullRef === "refs/heads/main") {
if (ref !== "main" || fullRef !== "refs/heads/main") {
fail("main release tooling identity must use ref main and full ref refs/heads/main.");
}
return { fullRef, ref, route: "main", sha };
}
if (allowPrevalidatedRef !== true || fullRef !== `refs/heads/${ref}`) {
fail(
"release tooling identity is not trusted main, a protected tag, or a prevalidated branch.",
);
}
return { fullRef, ref, route: "prevalidated-branch", sha };
}
export function validateReleaseToolingIdentity({
allowPrevalidatedRef = false,
branchRef,
mainComparisonStatus,
tagRef,
workflowFullRef,
workflowRef,
workflowSha,
}) {
const identity = classifyIdentity({
allowPrevalidatedRef,
workflowFullRef,
workflowRef,
workflowSha,
});
if (identity.route === "protected-tag") {
if (
!isRecord(tagRef) ||
tagRef.ref !== identity.fullRef ||
!isRecord(tagRef.object) ||
tagRef.object.type !== "commit" ||
tagRef.object.sha !== identity.sha
) {
fail(
"protected release tooling tag is missing, moved, annotated, or bound to the wrong SHA.",
);
}
} else if (identity.route === "main") {
if (mainComparisonStatus !== "ahead" && mainComparisonStatus !== "identical") {
fail("main release tooling SHA is not reachable from current main.");
}
} else if (
!isRecord(branchRef) ||
branchRef.ref !== identity.fullRef ||
!isRecord(branchRef.object) ||
branchRef.object.type !== "commit" ||
branchRef.object.sha !== identity.sha
) {
fail("prevalidated release tooling branch is missing or moved from the workflow SHA.");
}
return identity;
}
export function validateReleasePublishParentRun({
identity,
releasePublishParentStatePolicy,
releasePublishRunAttempt,
releasePublishRunId,
repository,
run,
}) {
const runId = requiredString(releasePublishRunId, "release publish run id");
const runAttempt = requiredString(releasePublishRunAttempt, "release publish run attempt");
if (!/^[1-9][0-9]*$/u.test(runId) || !/^[1-9][0-9]*$/u.test(runAttempt)) {
fail("release publish run id and attempt must be positive integers.");
}
const parentStatePolicy = requiredString(
releasePublishParentStatePolicy,
"release publish parent state policy",
);
if (!RELEASE_PUBLISH_PARENT_STATE_POLICIES.has(parentStatePolicy)) {
fail(`release publish parent state policy ${parentStatePolicy} is not supported.`);
}
const normalizedRepository = requireRepository(repository);
const [workflowPath, workflowFullRef] = String(run?.path ?? "").split("@", 2);
const expected = {
event: "workflow_dispatch",
headBranch: identity.ref,
headSha: identity.sha,
repository: normalizedRepository,
runAttempt: Number(runAttempt),
runId: Number(runId),
workflowPath: ".github/workflows/openclaw-release-publish.yml",
};
const actual = {
event: run?.event,
headBranch: run?.head_branch,
headSha: run?.head_sha,
repository: run?.repository?.full_name,
runAttempt: run?.run_attempt,
runId: run?.id,
workflowPath,
};
for (const key of Object.keys(expected)) {
if (actual[key] !== expected[key]) {
fail(`release publish parent run ${key} does not match the trusted tooling identity.`);
}
}
if (workflowFullRef && workflowFullRef !== identity.fullRef) {
fail("release publish parent run workflow full ref does not match trusted tooling.");
}
const active = run?.status === "in_progress" && !run?.conclusion;
const completedSuccess = run?.status === "completed" && run?.conclusion === "success";
const completedFailure = run?.status === "completed" && run?.conclusion === "failure";
if (
!active &&
!(parentStatePolicy === "active-or-success" && completedSuccess) &&
!(parentStatePolicy === "manual-recovery" && (completedSuccess || completedFailure))
) {
fail(
`release publish parent run state is not allowed by ${parentStatePolicy}: status=${run?.status ?? "<missing>"} conclusion=${run?.conclusion ?? "<missing>"}.`,
);
}
}
function parseJson(raw, label) {
try {
return JSON.parse(raw);
} catch (error) {
throw new Error(`${label} returned invalid JSON.`, { cause: error });
}
}
function runReleaseToolingGh(args) {
return execFileSync("gh", args, {
encoding: "utf8",
killSignal: "SIGKILL",
maxBuffer: 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
timeout: GH_COMMAND_TIMEOUT_MS,
});
}
export function verifyReleaseToolingIdentity({
allowPrevalidatedRef = false,
releasePublishParentStatePolicy,
releasePublishRunAttempt,
releasePublishRunId,
repository,
runGh = runReleaseToolingGh,
workflowFullRef,
workflowRef,
workflowSha,
}) {
const normalizedRepository = requireRepository(repository);
const identity = classifyIdentity({
allowPrevalidatedRef,
workflowFullRef,
workflowRef,
workflowSha,
});
if (identity.route === "protected-tag") {
let tagRef;
try {
tagRef = parseJson(
runGh([
"api",
`repos/${normalizedRepository}/git/ref/tags/${identity.ref}`,
"--method",
"GET",
]),
"protected release tooling tag",
);
} catch (error) {
throw new Error("protected release tooling tag is missing or unreadable.", { cause: error });
}
const validated = validateReleaseToolingIdentity({
allowPrevalidatedRef,
tagRef,
workflowFullRef,
workflowRef,
workflowSha,
});
validateParentRunIfRequested({
identity: validated,
releasePublishParentStatePolicy,
releasePublishRunAttempt,
releasePublishRunId,
repository: normalizedRepository,
runGh,
});
return validated;
}
if (identity.route === "main") {
let comparison;
try {
comparison = parseJson(
runGh([
"api",
`repos/${normalizedRepository}/compare/${identity.sha}...main`,
"--method",
"GET",
]),
"main release tooling comparison",
);
} catch (error) {
throw new Error("main release tooling ancestry could not be verified.", { cause: error });
}
const validated = validateReleaseToolingIdentity({
allowPrevalidatedRef,
mainComparisonStatus: isRecord(comparison) ? comparison.status : undefined,
workflowFullRef,
workflowRef,
workflowSha,
});
validateParentRunIfRequested({
identity: validated,
releasePublishParentStatePolicy,
releasePublishRunAttempt,
releasePublishRunId,
repository: normalizedRepository,
runGh,
});
return validated;
}
let branchRef;
try {
branchRef = parseJson(
runGh([
"api",
`repos/${normalizedRepository}/git/ref/heads/${identity.ref}`,
"--method",
"GET",
]),
"prevalidated release tooling branch",
);
} catch (error) {
throw new Error("prevalidated release tooling branch is missing or unreadable.", {
cause: error,
});
}
const validated = validateReleaseToolingIdentity({
allowPrevalidatedRef,
branchRef,
workflowFullRef,
workflowRef,
workflowSha,
});
validateParentRunIfRequested({
identity: validated,
releasePublishParentStatePolicy,
releasePublishRunAttempt,
releasePublishRunId,
repository: normalizedRepository,
runGh,
});
return validated;
}
function validateParentRunIfRequested({
identity,
releasePublishParentStatePolicy,
releasePublishRunAttempt,
releasePublishRunId,
repository,
runGh,
}) {
if (!releasePublishRunId && !releasePublishRunAttempt && !releasePublishParentStatePolicy) {
return;
}
if (!releasePublishRunId || !releasePublishRunAttempt || !releasePublishParentStatePolicy) {
fail("release publish run id, attempt, and parent state policy must be provided together.");
}
let run;
try {
run = parseJson(
runGh(["api", `repos/${repository}/actions/runs/${releasePublishRunId}`, "--method", "GET"]),
"release publish parent run",
);
} catch (error) {
throw new Error("release publish parent run is missing or unreadable.", { cause: error });
}
validateReleasePublishParentRun({
identity,
releasePublishParentStatePolicy,
releasePublishRunAttempt,
releasePublishRunId,
repository,
run,
});
}
function parseArgs(argv) {
const options = {
allowPrevalidatedRef: false,
command: "",
releasePublishRunAttempt: "",
releasePublishRunId: "",
releasePublishParentStatePolicy: "",
repository: "",
requestedIdentityJson: "",
workflowContract: "",
workflowFullRef: "",
workflowRef: "",
workflowSha: "",
};
options.command = argv.shift() ?? "";
if (options.command !== "verify" && options.command !== "resolve") {
fail("usage: release-tooling-identity.mjs <verify|resolve> [options]");
}
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--allow-prevalidated-ref") {
options.allowPrevalidatedRef = true;
continue;
}
const value = argv[(index += 1)] ?? "";
if (arg === "--release-publish-run-id") {
options.releasePublishRunId = value;
} else if (arg === "--release-publish-run-attempt") {
options.releasePublishRunAttempt = value;
} else if (arg === "--release-publish-parent-state-policy") {
options.releasePublishParentStatePolicy = value;
} else if (arg === "--repository") {
options.repository = value;
} else if (arg === "--requested-identity-json") {
options.requestedIdentityJson = value;
} else if (arg === "--workflow-contract") {
options.workflowContract = value;
} else if (arg === "--workflow-full-ref") {
options.workflowFullRef = value;
} else if (arg === "--workflow-ref") {
options.workflowRef = value;
} else if (arg === "--workflow-sha") {
options.workflowSha = value;
} else {
fail(`unknown release tooling identity argument: ${arg}`);
}
}
return options;
}
function main(argv = process.argv.slice(2)) {
const options = parseArgs([...argv]);
let identity;
if (options.command === "resolve") {
identity = resolveReleaseToolingIdentity(options);
const protectedMatch = RELEASE_PUBLISH_REF_PATTERN.exec(identity.ref);
verifyReleaseToolingIdentity({
allowPrevalidatedRef: identity.ref !== "main" && !protectedMatch,
releasePublishParentStatePolicy: options.releasePublishParentStatePolicy,
releasePublishRunAttempt: options.releasePublishRunAttempt,
releasePublishRunId: options.releasePublishRunId,
repository: options.repository,
workflowFullRef: identity.fullRef,
workflowRef: identity.ref,
workflowSha: identity.sha,
});
} else {
identity = verifyReleaseToolingIdentity(options);
}
process.stdout.write(`${JSON.stringify(identity)}\n`);
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
try {
main();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
}
@@ -8,6 +8,8 @@ const FULL_RELEASE_WORKFLOW = "Full Release Validation";
const FULL_RELEASE_WORKFLOW_PATH = ".github/workflows/full-release-validation.yml";
const SHA_PATTERN = /^[a-f0-9]{40}$/u;
const PINNED_BRANCH_PATTERN = /^release-ci\/([a-f0-9]{12})-([1-9][0-9]*)$/u;
const TRUSTED_RELEASE_PUBLISH_TAG_PATTERN =
/^refs\/tags\/release-publish\/([a-f0-9]{12})-[1-9][0-9]*$/u;
const EXACT_TARGET_EVIDENCE_REUSE_POLICY = "exact-target-full-validation-v1";
const CHANGELOG_ONLY_EVIDENCE_REUSE_POLICY = "changelog-only-release-v1";
@@ -70,6 +72,8 @@ function displayValue(value) {
* @property {string} expectedRepository
* @property {string | number} expectedRunId
* @property {string} expectedTargetSha
* @property {string} [expectedTrustedWorkflowFullRef]
* @property {string} [expectedTrustedWorkflowSha]
* @property {string} [expectedWorkflowBranch]
* @property {(sha: string) => boolean} [isTrustedMainAncestor]
* @property {(params: { repository: string, runId: string, targetSha: string }) => StrictReleaseEvidence} [validateEvidenceReuseStrictly]
@@ -114,11 +118,28 @@ export function validateFullReleaseValidationEvidence({
expectedRepository,
expectedRunId,
expectedTargetSha,
expectedTrustedWorkflowFullRef,
expectedTrustedWorkflowSha,
expectedWorkflowBranch,
isTrustedMainAncestor,
validateEvidenceReuseStrictly,
}) {
const run = normalizeFullReleaseValidationRun(rawRun);
const trustedWorkflowFullRef = expectedTrustedWorkflowFullRef ?? "refs/heads/main";
const protectedTag = TRUSTED_RELEASE_PUBLISH_TAG_PATTERN.exec(trustedWorkflowFullRef);
if (protectedTag) {
if (!SHA_PATTERN.test(expectedTrustedWorkflowSha ?? "")) {
throw new Error("Protected release-publish evidence requires an exact trusted workflow SHA.");
}
if (expectedTrustedWorkflowSha.slice(0, 12) !== protectedTag[1]) {
throw new Error("Protected release-publish tag does not match its trusted workflow SHA.");
}
} else if (
!trustedWorkflowFullRef.startsWith("refs/heads/") ||
trustedWorkflowFullRef.startsWith("refs/heads/release-publish/")
) {
throw new Error("Trusted release-publish workflow ref must be an exact protected tag.");
}
const checks = [
["databaseId", String(expectedRunId)],
["workflowName", FULL_RELEASE_WORKFLOW],
@@ -176,6 +197,11 @@ export function validateFullReleaseValidationEvidence({
const pinnedMatch = PINNED_BRANCH_PATTERN.exec(run.headBranch ?? "");
if (!pinnedMatch) {
if (protectedTag) {
throw new Error(
"Protected-tag release evidence must use a canonical release-ci producer branch.",
);
}
if (run.headBranch?.startsWith("release-ci/")) {
throw new Error(
`Referenced full release validation run ${expectedRunId} has untrusted head branch ${run.headBranch}.`,
@@ -204,6 +230,14 @@ export function validateFullReleaseValidationEvidence({
`SHA-pinned validation target ref mismatch: expected ${expectedTargetSha}, got ${displayValue(manifest.targetRef)}.`,
);
}
if (protectedTag) {
if (run.headSha !== expectedTrustedWorkflowSha) {
throw new Error(
`Protected-tag release evidence workflow SHA ${run.headSha} does not match trusted tooling ${expectedTrustedWorkflowSha}.`,
);
}
return { run, source: "sha-pinned-protected-tag" };
}
if (!isTrustedMainAncestor?.(run.headSha)) {
throw new Error(
`SHA-pinned validation workflow ${run.headSha} is not reachable from current main.`,
@@ -267,6 +301,9 @@ export function validateFullReleaseValidationEvidence({
* runId: string | number;
* validatorFile?: string;
* verifierSourceSha?: string;
* trustedWorkflowFullRef?: string;
* trustedWorkflowRef?: string;
* trustedWorkflowSha?: string;
* }} params
*/
export function runStrictReleaseEvidenceValidation({
@@ -274,6 +311,9 @@ export function runStrictReleaseEvidenceValidation({
runId,
validatorFile = fileURLToPath(new URL("./release-ci-summary.mjs", import.meta.url)),
verifierSourceSha,
trustedWorkflowFullRef = "refs/heads/main",
trustedWorkflowRef = "main",
trustedWorkflowSha,
}) {
const verifierSourceArgs = verifierSourceSha
? ["--verifier-source-sha", verifierSourceSha, "--verifier-source-file", validatorFile]
@@ -287,8 +327,11 @@ export function runStrictReleaseEvidenceValidation({
"--repo",
repository,
"--trusted-workflow-ref",
"main",
trustedWorkflowRef,
"--trusted-workflow-full-ref",
trustedWorkflowFullRef,
"--json",
...(trustedWorkflowSha ? ["--trusted-workflow-sha", trustedWorkflowSha] : []),
...verifierSourceArgs,
],
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
@@ -336,12 +379,17 @@ function main() {
expectedRepository: process.env.GITHUB_REPOSITORY,
expectedRunId: process.env.FULL_RELEASE_VALIDATION_RUN_ID,
expectedTargetSha: process.env.EXPECTED_SHA,
expectedTrustedWorkflowFullRef: process.env.TRUSTED_WORKFLOW_FULL_REF,
expectedTrustedWorkflowSha: process.env.TRUSTED_WORKFLOW_SHA,
expectedWorkflowBranch: process.env.EXPECTED_WORKFLOW_BRANCH,
isTrustedMainAncestor: (sha) => gitIsAncestor(sha, trustedMainRef),
validateEvidenceReuseStrictly: ({ repository, runId }) =>
runStrictReleaseEvidenceValidation({
repository,
runId,
trustedWorkflowFullRef: process.env.TRUSTED_WORKFLOW_FULL_REF,
trustedWorkflowRef: process.env.TRUSTED_WORKFLOW_REF,
trustedWorkflowSha: process.env.TRUSTED_WORKFLOW_SHA,
validatorFile:
process.env.STRICT_VALIDATOR_FILE ??
fileURLToPath(new URL("./release-ci-summary.mjs", import.meta.url)),
@@ -11,6 +11,8 @@ const allowCompletedSuccessfulParent = process.env.ALLOW_COMPLETED_SUCCESSFUL_PA
const approvalPath = process.env.APPROVAL_PATH ?? "";
const approvalKind = process.env.RELEASE_APPROVAL_KIND ?? "android";
const expectedRunAttempt = process.env.EXPECTED_RUN_ATTEMPT ?? "";
const expectedWorkflowFullRef = process.env.EXPECTED_WORKFLOW_FULL_REF ?? "";
const expectedWorkflowSha = process.env.EXPECTED_WORKFLOW_SHA ?? "";
const childWorkflowSha = process.env.CHILD_WORKFLOW_SHA ?? "";
function fail(message) {
@@ -92,6 +94,9 @@ const checks = [
["headBranch", expectedBranch],
["event", "workflow_dispatch"],
];
if (process.env.GITHUB_REPOSITORY) {
checks.push(["repository", process.env.GITHUB_REPOSITORY]);
}
for (const [key, expected] of checks) {
if (run[key] !== expected) {
@@ -101,6 +106,21 @@ for (const [key, expected] of checks) {
}
}
if (expectedWorkflowSha && run.headSha !== expectedWorkflowSha) {
fail(
`Referenced release publish run ${releasePublishRunId} must use tooling SHA ${expectedWorkflowSha}, got ${run.headSha ?? "<missing>"}.`,
);
}
if (expectedWorkflowFullRef) {
const [workflowPath, workflowFullRef] = String(run.path ?? "").split("@", 2);
if (workflowPath !== ".github/workflows/openclaw-release-publish.yml") {
fail(`Referenced release publish run ${releasePublishRunId} has untrusted workflow path.`);
}
if (workflowFullRef && workflowFullRef !== expectedWorkflowFullRef) {
fail(`Referenced release publish run ${releasePublishRunId} has untrusted workflow full ref.`);
}
}
if (expectedRunAttempt && run.runAttempt !== positiveRunAttempt(expectedRunAttempt)) {
fail(
`Referenced release publish run ${releasePublishRunId} must use attempt ${expectedRunAttempt}, got ${run.runAttempt ?? "<missing>"}.`,