mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 20:35:39 -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:
@@ -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)
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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>"}.`,
|
||||
|
||||
Reference in New Issue
Block a user