Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ on:
description: Public openclaw/openclaw Full Release Validation run id
required: true
type: string
full_validation_run_attempt:
description: Optional exact Full Release Validation attempt; blank uses the current attempt
required: false
default: ""
type: string
release_id:
description: Release evidence directory name, for example 2026.4.27-beta.1
required: true
Expand Down Expand Up @@ -36,6 +41,7 @@ env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
NODE_VERSION: "24.x"
FULL_VALIDATION_RUN_ID: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.full_validation_run_id || inputs.full_validation_run_id }}
FULL_VALIDATION_RUN_ATTEMPT: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.full_validation_run_attempt || inputs.full_validation_run_attempt }}
RELEASE_ID: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.release_id || inputs.release_id }}
RELEASE_REF: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.release_ref || inputs.release_ref }}
PACKAGE_SPEC: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.package_spec || inputs.package_spec }}
Expand Down Expand Up @@ -73,8 +79,17 @@ jobs:
GH_TOKEN: ${{ secrets.OPENCLAW_RELEASES_PRIVATE_PUSH_TOKEN }}
run: |
set -euo pipefail
if [[ "$GITHUB_EVENT_NAME" == "repository_dispatch" && ! "$FULL_VALIDATION_RUN_ATTEMPT" =~ ^[1-9][0-9]*$ ]]; then
echo "::error::repository_dispatch requires a positive full_validation_run_attempt."
exit 1
fi
attempt_args=()
if [[ -n "$FULL_VALIDATION_RUN_ATTEMPT" ]]; then
attempt_args=(--full-validation-run-attempt "$FULL_VALIDATION_RUN_ATTEMPT")
fi
node scripts/openclaw-release-evidence-from-full-validation.mjs \
--full-validation-run-id "$FULL_VALIDATION_RUN_ID" \
"${attempt_args[@]}" \
--release-id "$RELEASE_ID" \
--release-ref "$RELEASE_REF" \
--package-spec "$PACKAGE_SPEC" \
Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,11 @@ fail the release by itself.
`full-release-validation-<run-id>-<attempt>` manifest artifact. The ingest
rejects missing, duplicate, expired, malformed, or identity-mismatched
artifacts; child run ids and evidence-reuse provenance come only from the
validated manifest.
validated manifest. Repository dispatches must provide the exact parent run
attempt; manual dispatches may omit it to resolve the current attempt. Schema
v2 records `runAttempt` on every persisted run while schema v1 remains readable.
Exact duplicate parent attempts are no-ops, and an older attempt cannot replace
a newer attempt for the same run id.

Both evidence workflows require an exact package spec and release ref. They
commit with the workflow's same-repository `github.token`, then verify that the
Expand All @@ -129,6 +133,7 @@ gh workflow run openclaw-release-evidence-from-full-validation.yml \
--repo openclaw/releases \
--ref main \
-f full_validation_run_id=24977011361 \
-f full_validation_run_attempt=1 \
-f release_id=2026.4.24 \
-f release_ref=v2026.4.24 \
-f package_spec=openclaw@2026.4.24
Expand Down
86 changes: 82 additions & 4 deletions scripts/openclaw-release-evidence-contract.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,19 @@ async function githubJsonOrNull(pathname) {
export async function githubBinary(url) {
return request(url, { binary: true });
}
export function workflowRunPath(repository, workflowRunId, runAttempt) {
const normalizedRunId = runId(workflowRunId, "workflow run id");
if (runAttempt === undefined || runAttempt === null || runAttempt === "") {
return `/repos/${repository}/actions/runs/${normalizedRunId}`;
}
return `${workflowRunPath(repository, normalizedRunId)}/attempts/${runId(
runAttempt,
"workflow run attempt",
)}`;
}
export function workflowRunJobsPath(repository, workflowRunId, runAttempt) {
return `${workflowRunPath(repository, workflowRunId, runAttempt)}/jobs`;
}
export async function githubPaged(
pathname,
key,
Expand Down Expand Up @@ -271,16 +284,21 @@ async function readManifest(artifact) {
}
export async function loadFullValidationSource(input) {
validateReleaseIdentity(input);
const parent = await githubJson(`/repos/${PUBLIC_REPO}/actions/runs/${input.fullValidationRunId}`);
const parent = await githubJson(
workflowRunPath(PUBLIC_REPO, input.fullValidationRunId, input.fullValidationRunAttempt),
);
if (
String(parent.id) !== String(input.fullValidationRunId) ||
(input.fullValidationRunAttempt &&
String(parent.run_attempt) !== String(input.fullValidationRunAttempt)) ||
parent.name !== WORKFLOW ||
parent.path?.split("@", 1)[0] !== WORKFLOW_PATH ||
parent.event !== "workflow_dispatch" ||
parent.status !== "completed" ||
parent.conclusion !== "success" ||
!Number.isInteger(parent.run_attempt) ||
parent.run_attempt < 1 ||
Number.isNaN(Date.parse(parent.updated_at ?? "")) ||
!SHA.test(parent.head_sha ?? "")
) {
throw new Error(`Full release validation run ${input.fullValidationRunId} is not successful`);
Expand Down Expand Up @@ -310,7 +328,8 @@ export async function loadFullValidationSource(input) {
repo: PUBLIC_REPO, runId: String(parent.id), runAttempt: parent.run_attempt,
workflowName: parent.name, workflowPath: parent.path,
workflowRef: parent.head_branch, workflowSha: parent.head_sha,
status: parent.status, conclusion: parent.conclusion, htmlUrl: parent.html_url,
status: parent.status, conclusion: parent.conclusion, updatedAt: parent.updated_at,
htmlUrl: parent.html_url,
},
artifact: {
id: String(artifact.id), name: artifact.name, digest: artifact.digest,
Expand All @@ -327,7 +346,10 @@ export function fullValidationRunEntries(source) {
const child = source.manifest.childRuns;
const entries = [
{ label: "full-release-validation", runId: source.parentRun.runId, blocking: false,
workflowPath: WORKFLOW_PATH },
runAttempt: source.parentRun.runAttempt, headSha: source.parentRun.workflowSha,
updatedAt: source.parentRun.updatedAt, workflowPath: WORKFLOW_PATH },
// Manifest v3 only qualifies the parent attempt. Child attempt pinning needs an
// additive upstream protocol field; do not infer it from mutable run state here.
{ label: "normal-ci", runId: child.normalCi, blocking: true, workflowPath: ".github/workflows/ci.yml" },
{ label: "plugin-prerelease", runId: child.pluginPrerelease, blocking: true,
workflowPath: ".github/workflows/plugin-prerelease.yml" },
Expand All @@ -348,6 +370,11 @@ export function fullValidationRunEntries(source) {
}
export function validateEvidenceDocument(value, expected, { requireFullValidation = false } = {}) {
validateReleaseIdentity(expected);
const schemaVersion = value?.schemaVersion ?? 1;
if (schemaVersion !== 1 && schemaVersion !== 2) {
throw new Error("Release evidence schema version is unsupported");
}
const requiresRunAttempt = schemaVersion >= 2;
const runs = value?.runs;
if (
value?.release?.id !== expected.releaseId ||
Expand All @@ -360,7 +387,9 @@ export function validateEvidenceDocument(value, expected, { requireFullValidatio
runs.some((run) =>
!run || typeof run.label !== "string" || !run.label ||
typeof run.repo !== "string" || !run.repo.includes("/") ||
!RUN_ID.test(String(run.runId ?? "")) || typeof run.blocking !== "boolean") ||
!RUN_ID.test(String(run.runId ?? "")) ||
(requiresRunAttempt && !RUN_ID.test(String(run.runAttempt ?? ""))) ||
typeof run.blocking !== "boolean") ||
new Set(runs.map((run) => run.label)).size !== runs.length
) {
throw new Error("Release evidence identity does not match");
Expand Down Expand Up @@ -392,6 +421,11 @@ export function validateEvidenceDocument(value, expected, { requireFullValidatio
expectedRuns.length !== runs.length ||
expectedRuns.some((entry) => !runs.some((run) =>
run.label === entry.label && String(run.runId) === entry.runId &&
(!requiresRunAttempt ||
entry.runAttempt === undefined ||
run.runAttempt === entry.runAttempt) &&
(entry.headSha === undefined || run.headSha === entry.headSha) &&
(entry.updatedAt === undefined || run.updatedAt === entry.updatedAt) &&
run.repo === entry.repo && run.blocking === entry.blocking &&
run.path?.split("@", 1)[0] === entry.workflowPath &&
run.status === "completed" && run.conclusion === "success"))
Expand All @@ -401,6 +435,50 @@ export function validateEvidenceDocument(value, expected, { requireFullValidatio
}
return value;
}
export function classifyFullValidationUpdate(previousEvidence, source, expected) {
const previousSource = previousEvidence?.provenance?.fullValidation;
if (!previousSource) {
return "update";
}
const previousRunId = String(previousSource.parentRun?.runId ?? "");
const currentRunId = String(source.parentRun?.runId ?? "");
if (previousRunId !== currentRunId) {
return "update";
}
const previousAttempt = Number(previousSource.parentRun?.runAttempt);
const currentAttempt = Number(source.parentRun?.runAttempt);
if (!Number.isSafeInteger(previousAttempt) || previousAttempt < 1) {
return "update";
}
if (!Number.isSafeInteger(currentAttempt) || currentAttempt < 1) {
throw new Error("Full validation source attempt is invalid");
}
if (previousAttempt > currentAttempt) {
throw new Error(
`Refusing to replace Full Release Validation ${currentRunId} attempt ${previousAttempt} with stale attempt ${currentAttempt}`,
);
}
if (previousAttempt === currentAttempt) {
if (previousEvidence.schemaVersion !== 2) {
return "update";
}
if (
previousSource.parentRun?.workflowSha !== source.parentRun?.workflowSha ||
previousSource.parentRun?.updatedAt !== source.parentRun?.updatedAt
) {
throw new Error(
`Full Release Validation ${currentRunId} attempt ${currentAttempt} identity changed`,
);
}
try {
validateEvidenceDocument(previousEvidence, expected, { requireFullValidation: true });
return "duplicate";
} catch {
return "update";
}
}
return "update";
}
async function cli() {
if (process.argv[2] !== "verify-evidence") {
throw new Error("Usage: openclaw-release-evidence-contract.mjs verify-evidence <file>");
Expand Down
Loading