fix(release): harden candidate artifact provenance

This commit is contained in:
Vincent Koc
2026-08-21 04:00:44 -07:00
parent bcba4fc2d1
commit dfb018f971
7 changed files with 333 additions and 100 deletions
+104 -16
View File
@@ -46,19 +46,35 @@ jobs:
persist-credentials: false
submodules: false
- name: Validate ReleasePlanLock
- name: Setup trusted verifier runtime
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "24.x"
- name: Setup trusted verifier pnpm
uses: ./.github/actions/setup-pnpm-store-cache
with:
node-version: "24.x"
- name: Install trusted verifier dependencies
run: pnpm install --filter . --frozen-lockfile --prefer-offline --ignore-scripts
- name: Verify repository-derived ReleasePlan authority
id: plan
env:
DISPATCH_ID: ${{ inputs.dispatch_id }}
GH_TOKEN: ${{ github.token }}
RELEASE_PLAN_LOCK_BASE64: ${{ inputs.release_plan_lock_base64 }}
WORKFLOW_FULL_REF: ${{ github.ref }}
WORKFLOW_SHA: ${{ github.sha }}
shell: bash
run: |
set -euo pipefail
node --input-type=module <<'NODE'
node --import tsx --input-type=module <<'NODE'
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import { parseReleasePlanLockJson } from "./scripts/release-plan-contract.mjs";
import { verifyReleasePlanLock } from "./scripts/release-plan-producer.mts";
const dispatchId = process.env.DISPATCH_ID ?? "";
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/u.test(dispatchId)) {
@@ -72,13 +88,60 @@ jobs:
if (bytes.toString("base64") !== encoded) {
throw new Error("release_plan_lock_base64 is not canonical base64");
}
const lock = parseReleasePlanLockJson(bytes.toString("utf8"));
if (lock.plan.tooling.sha !== process.env.WORKFLOW_SHA) {
throw new Error("ReleasePlan tooling SHA must equal the candidate producer workflow SHA");
const lockJson = bytes.toString("utf8");
// This parse extracts claimed source coordinates only. The repository-derived
// verifier below is the authority for every plan field and tooling route.
const claimedLock = parseReleasePlanLockJson(lockJson);
const workflowSha = process.env.WORKFLOW_SHA ?? "";
const workflowFullRef = process.env.WORKFLOW_FULL_REF ?? "";
const candidateSha = claimedLock.plan.candidate_sha;
const gh = (args) =>
execFileSync("gh", args, {
encoding: "utf8",
killSignal: "SIGKILL",
maxBuffer: 16 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
timeout: 60_000,
});
const candidateCommit = JSON.parse(
gh([
"api",
`repos/${process.env.GITHUB_REPOSITORY}/commits/${candidateSha}`,
"--method",
"GET",
]),
);
if (candidateCommit.sha !== candidateSha) {
throw new Error("ReleasePlan candidate SHA is not an exact commit in the repository");
}
if (lock.plan.tooling.ref !== process.env.WORKFLOW_FULL_REF) {
throw new Error("ReleasePlan tooling ref must equal the candidate producer workflow ref");
execFileSync("git", ["fetch", "--force", "--no-tags", "origin", candidateSha], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
if (claimedLock.plan.purpose === "postpublish-confidence") {
const tagRef = claimedLock.plan.target_context_ref;
execFileSync("git", ["fetch", "--force", "origin", `${tagRef}:${tagRef}`], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
}
const intent =
claimedLock.plan.purpose === "main-qualification"
? "main-qualification"
: claimedLock.plan.purpose === "postpublish-confidence"
? "postpublish-confidence"
: "publish";
const source = {
candidateRef: claimedLock.plan.target_context_ref,
candidateSha,
toolingFullRef: workflowFullRef,
toolingSha: workflowSha,
intent,
...(intent === "main-qualification"
? { validationIntent: claimedLock.plan.validation.intent }
: {}),
};
const lock = verifyReleasePlanLock(lockJson, source);
const outputPath = process.env.GITHUB_OUTPUT;
if (!outputPath) {
throw new Error("GITHUB_OUTPUT is required");
@@ -162,18 +225,43 @@ jobs:
- name: Build root Dockerfile image
env:
# Candidate images use deterministic build metadata so retries do not alter provenance.
BUILD_TIMESTAMP: "2000-01-01T00:00:00.000Z"
IMAGE_REF: openclaw-release-candidate-root:${{ needs.validate_release_plan.outputs.candidate_sha }}
TARGET_SHA: ${{ needs.validate_release_plan.outputs.candidate_sha }}
shell: bash
run: |
set -euo pipefail
timeout --kill-after=30s 45m docker buildx build \
--progress=plain \
--load \
--build-arg "GIT_COMMIT=$TARGET_SHA" \
--build-arg "OPENCLAW_BUILD_TIMESTAMP=$BUILD_TIMESTAMP" \
--build-arg OPENCLAW_EXTENSIONS=matrix \
--tag "$IMAGE_REF" \
--file ./Dockerfile \
.
- name: Verify root image build provenance
env:
IMAGE_REF: openclaw-release-candidate-root:${{ needs.validate_release_plan.outputs.candidate_sha }}
TARGET_SHA: ${{ needs.validate_release_plan.outputs.candidate_sha }}
shell: bash
run: |
set -euo pipefail
embedded_commit="$(
docker run --rm --entrypoint node "$IMAGE_REF" -e '
const fs = require("node:fs");
const info = JSON.parse(fs.readFileSync("/app/dist/build-info.json", "utf8"));
if (typeof info.commit !== "string") process.exit(2);
process.stdout.write(info.commit);
'
)"
if [[ "$embedded_commit" != "$TARGET_SHA" ]]; then
echo "Root image build commit ${embedded_commit:-missing} does not match ${TARGET_SHA}." >&2
exit 1
fi
- name: Pack root Dockerfile image artifact
id: image_artifact
env:
@@ -264,7 +352,7 @@ jobs:
jq -e \
--arg id "$workflow_id" \
--arg path "$EXPECTED_WORKFLOW_PATH" \
'(.id | tostring) == $id and .path == $path and .state == "active"' \
'(.id | tostring) == $id and .path == $path' \
<<< "$workflow_json" >/dev/null
echo "workflow_id=$workflow_id" >> "$GITHUB_OUTPUT"
@@ -278,10 +366,10 @@ jobs:
PACKAGE_ARTIFACT_ID: ${{ needs.candidate_artifacts.outputs.package_artifact_id }}
PACKAGE_ARTIFACT_NAME: ${{ needs.candidate_artifacts.outputs.package_artifact_name }}
PACKAGE_SHA256: ${{ needs.candidate_artifacts.outputs.package_sha256 }}
PLUGIN_REGISTRY_ARTIFACT_DIGEST: ${{ needs.candidate_artifacts.outputs.prepublish_plugin_registry_artifact_digest }}
PLUGIN_REGISTRY_ARTIFACT_ID: ${{ needs.candidate_artifacts.outputs.prepublish_plugin_registry_artifact_id }}
PLUGIN_REGISTRY_ARTIFACT_NAME: ${{ needs.candidate_artifacts.outputs.prepublish_plugin_registry_artifact_name }}
PLUGIN_REGISTRY_MANIFEST_SHA256: ${{ needs.candidate_artifacts.outputs.prepublish_plugin_registry_manifest_sha256 }}
E2E_PLUGIN_REGISTRY_ARTIFACT_DIGEST: ${{ needs.candidate_artifacts.outputs.prepublish_plugin_registry_artifact_digest }}
E2E_PLUGIN_REGISTRY_ARTIFACT_ID: ${{ needs.candidate_artifacts.outputs.prepublish_plugin_registry_artifact_id }}
E2E_PLUGIN_REGISTRY_ARTIFACT_NAME: ${{ needs.candidate_artifacts.outputs.prepublish_plugin_registry_artifact_name }}
E2E_PLUGIN_REGISTRY_MANIFEST_SHA256: ${{ needs.candidate_artifacts.outputs.prepublish_plugin_registry_manifest_sha256 }}
RELEASE_PLAN_DIGEST: ${{ needs.validate_release_plan.outputs.release_plan_digest }}
ROOT_IMAGE_ARCHIVE_SHA256: ${{ needs.root_image.outputs.archive_sha256 }}
ROOT_IMAGE_ARTIFACT_DIGEST: ${{ needs.root_image.outputs.artifact_digest }}
@@ -330,11 +418,11 @@ jobs:
},
artifacts: {
docker_image: artifact("DOCKER_IMAGE", "DOCKER_IMAGE_ARCHIVE_SHA256"),
package: artifact("PACKAGE", "PACKAGE_SHA256"),
plugin_registry: artifact(
"PLUGIN_REGISTRY",
"PLUGIN_REGISTRY_MANIFEST_SHA256",
e2e_plugin_registry: artifact(
"E2E_PLUGIN_REGISTRY",
"E2E_PLUGIN_REGISTRY_MANIFEST_SHA256",
),
package: artifact("PACKAGE", "PACKAGE_SHA256"),
root_image: artifact("ROOT_IMAGE", "ROOT_IMAGE_ARCHIVE_SHA256"),
},
});
@@ -18,8 +18,8 @@ export type CandidateReceipt = {
};
artifacts: {
docker_image: CandidateReceiptArtifact;
e2e_plugin_registry: CandidateReceiptArtifact;
package: CandidateReceiptArtifact;
plugin_registry: CandidateReceiptArtifact;
root_image: CandidateReceiptArtifact;
};
};
@@ -13,7 +13,7 @@ const SHA_PATTERN = /^[a-f0-9]{40}$/u;
const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/u;
const POSITIVE_DECIMAL_PATTERN = /^[1-9][0-9]*$/u;
const ASCII_PATTERN = /^[\x20-\x7e]+$/u;
const ARTIFACT_KEYS = ["docker_image", "package", "plugin_registry", "root_image"];
const ARTIFACT_KEYS = ["docker_image", "e2e_plugin_registry", "package", "root_image"];
const compareAscii = (left, right) => (left < right ? -1 : left > right ? 1 : 0);
function fail(message) {
@@ -158,8 +158,8 @@ export function validateCandidateReceipt(value) {
}
}
const exactArtifactNames = {
e2e_plugin_registry: `docker-e2e-prepublish-plugin-registry${expectedNameSuffix}`,
package: `docker-e2e-package${expectedNameSuffix}`,
plugin_registry: `docker-e2e-prepublish-plugin-registry${expectedNameSuffix}`,
root_image: `release-candidate-root-image${expectedNameSuffix}`,
};
for (const [key, expectedName] of Object.entries(exactArtifactNames)) {
+109 -66
View File
@@ -15,6 +15,12 @@ import {
type JsonRecord = Record<string, unknown>;
type RunGh = (args: string[]) => string;
type NormalizedCandidateReceiptLocatorOptions = CandidateReceiptLocatorOptions & {
releasePlanDigest: string;
timeoutMs: number;
workflowId: string;
workflowSha: string;
};
export type CandidateReceiptLocatorOptions = {
dispatchId: string;
@@ -39,6 +45,8 @@ const DISPATCH_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/u;
const GH_COMMAND_TIMEOUT_MS = 60_000;
const DEFAULT_TIMEOUT_MS = 2 * 60 * 60 * 1000;
const POLL_INTERVAL_MS = 15_000;
const API_RETRY_ATTEMPTS = 3;
const API_RETRY_DELAY_MS = 1_000;
function fail(message: string): never {
throw new Error(message);
@@ -47,7 +55,7 @@ function fail(message: string): never {
function parseJson(raw: string, label: string): unknown {
try {
return JSON.parse(raw) as unknown;
} catch (error) {
} catch (error: unknown) {
throw new Error(`${label} returned invalid JSON`, { cause: error });
}
}
@@ -91,7 +99,9 @@ function digest(value: unknown, label: string): string {
return normalized;
}
function requireOptions(options: CandidateReceiptLocatorOptions) {
function requireOptions(
options: CandidateReceiptLocatorOptions,
): NormalizedCandidateReceiptLocatorOptions {
if (options.repo !== REPOSITORY) {
fail(`candidate receipt repository must be ${REPOSITORY}`);
}
@@ -138,7 +148,7 @@ function validateArtifactMetadata(
name: string;
runId: string;
},
) {
): void {
const workflowRun = record(artifact.workflow_run, `${expected.name} workflow_run`);
if (
positiveDecimal(artifact.id, `${expected.name} artifact id`) !== expected.id ||
@@ -162,7 +172,7 @@ export function validateCandidateReceiptProvenance(params: {
lock: CandidateReceiptLock;
run: unknown;
workflow: unknown;
}) {
}): CandidateReceiptLock {
const lock = validateCandidateReceiptLock(params.lock);
const run = record(params.run, "candidate receipt run");
const workflow = record(params.workflow, "candidate receipt workflow");
@@ -185,10 +195,9 @@ export function validateCandidateReceiptProvenance(params: {
if (
positiveDecimal(workflow.id, "candidate receipt workflow id") !== params.expectedWorkflowId ||
requiredString(workflow.path, "candidate receipt workflow path") !==
CANDIDATE_RECEIPT_WORKFLOW_PATH ||
workflow.state !== "active"
CANDIDATE_RECEIPT_WORKFLOW_PATH
) {
fail("candidate receipt workflow identity does not match the canonical active workflow");
fail("candidate receipt workflow identity does not match the canonical workflow");
}
const receipt = lock.receipt;
@@ -245,18 +254,18 @@ function runGhCommand(
maxBuffer: number;
timeout: number;
},
) {
): string {
return execFileSync(command, args, options);
}
async function pollUntil<T>(
deadline: number,
poll: () => T | undefined,
poll: () => Promise<T | undefined> | T | undefined,
sleep: (milliseconds: number) => Promise<void>,
timeoutMessage: string,
): Promise<T> {
while (Date.now() <= deadline) {
const result = poll();
const result = await poll();
if (result !== undefined) {
return result;
}
@@ -265,18 +274,38 @@ async function pollUntil<T>(
fail(timeoutMessage);
}
async function retryOperation<T>(
deadline: number,
sleep: (milliseconds: number) => Promise<void>,
label: string,
operation: () => Promise<T> | T,
): Promise<T> {
let lastError: unknown;
let attempts = 0;
for (let attempt = 1; attempt <= API_RETRY_ATTEMPTS; attempt += 1) {
attempts = attempt;
try {
return await operation();
} catch (error: unknown) {
lastError = error;
}
if (attempt === API_RETRY_ATTEMPTS || Date.now() >= deadline) {
break;
}
await sleep(Math.min(API_RETRY_DELAY_MS * attempt, Math.max(1, deadline - Date.now())));
}
throw new Error(`${label} failed after ${attempts} attempts`, { cause: lastError });
}
function discoverRun(
api: (endpoint: string) => unknown,
responseValue: unknown,
params: {
dispatchId: string;
workflowId: string;
workflowSha: string;
},
): { runAttempt: string; runId: string } | undefined {
const response = record(
api(`actions/workflows/${params.workflowId}/runs?event=workflow_dispatch&per_page=100`),
"candidate receipt workflow runs response",
);
const response = record(responseValue, "candidate receipt workflow runs response");
if (!Array.isArray(response.workflow_runs)) {
fail("candidate receipt workflow runs response must contain workflow_runs");
}
@@ -303,19 +332,6 @@ function discoverRun(
};
}
function requireCurrentAttempt(
api: (endpoint: string) => unknown,
runId: string,
runAttempt: string,
) {
const latestRun = record(api(`actions/runs/${runId}`), "candidate receipt latest run");
if (
positiveDecimal(latestRun.run_attempt, "candidate receipt latest run attempt") !== runAttempt
) {
fail("candidate receipt producer attempt was superseded by a rerun");
}
}
export async function locateCandidateReceipt(
rawOptions: CandidateReceiptLocatorOptions,
): Promise<CandidateReceiptLock> {
@@ -330,17 +346,18 @@ export async function locateCandidateReceipt(
new Promise((resolve) => {
setTimeout(resolve, milliseconds);
}));
const api = (endpoint: string): unknown =>
parseJson(runGh(["api", `repos/${options.repo}/${endpoint}`, "--method", "GET"]), endpoint);
const deadline = Date.now() + options.timeoutMs;
const workflow = api(`actions/workflows/${options.workflowId}`);
const api = (endpoint: string): Promise<unknown> =>
retryOperation(deadline, sleep, endpoint, () =>
parseJson(runGh(["api", `repos/${options.repo}/${endpoint}`, "--method", "GET"]), endpoint),
);
const workflow = await api(`actions/workflows/${options.workflowId}`);
const workflowRecord = record(workflow, "candidate receipt workflow");
if (
positiveDecimal(workflowRecord.id, "candidate receipt workflow id") !== options.workflowId ||
workflowRecord.path !== CANDIDATE_RECEIPT_WORKFLOW_PATH ||
workflowRecord.state !== "active"
workflowRecord.path !== CANDIDATE_RECEIPT_WORKFLOW_PATH
) {
fail("candidate receipt workflow identity does not match the canonical active workflow");
fail("candidate receipt workflow identity does not match the canonical workflow");
}
const exactRun =
@@ -348,21 +365,26 @@ export async function locateCandidateReceipt(
? { runAttempt: options.runAttempt, runId: options.runId }
: await pollUntil(
deadline,
() =>
discoverRun(api, {
dispatchId: options.dispatchId,
workflowId: options.workflowId,
workflowSha: options.workflowSha,
}),
async () =>
discoverRun(
await api(
`actions/workflows/${options.workflowId}/runs?event=workflow_dispatch&per_page=100`,
),
{
dispatchId: options.dispatchId,
workflowId: options.workflowId,
workflowSha: options.workflowSha,
},
),
sleep,
"timed out locating the candidate receipt producer run",
);
const run = await pollUntil(
deadline,
() => {
async () => {
const current = record(
api(`actions/runs/${exactRun.runId}/attempts/${exactRun.runAttempt}`),
await api(`actions/runs/${exactRun.runId}/attempts/${exactRun.runAttempt}`),
"candidate receipt run attempt",
);
if (current.status !== "completed") {
@@ -376,16 +398,19 @@ export async function locateCandidateReceipt(
sleep,
"timed out waiting for the candidate receipt producer",
);
requireCurrentAttempt(api, exactRun.runId, exactRun.runAttempt);
const artifacts = api(`actions/runs/${exactRun.runId}/artifacts?per_page=100`);
// A completed attempt is immutable evidence. Later reruns do not revoke it;
// consumers revalidate this exact run, attempt, receipt, and artifact set at use.
const receiptArtifactName = `release-candidate-receipt-${exactRun.runId}-${exactRun.runAttempt}`;
const receiptArtifact = artifactRecords(artifacts).find(
(entry) => entry.name === receiptArtifactName,
const receiptArtifact = await pollUntil(
deadline,
async () => {
const response = await api(`actions/runs/${exactRun.runId}/artifacts?per_page=100`);
return artifactRecords(response).find((entry) => entry.name === receiptArtifactName);
},
sleep,
"timed out waiting for the candidate receipt lock artifact",
);
if (!receiptArtifact) {
fail("candidate receipt lock artifact is missing from the producer run");
}
validateArtifactMetadata(receiptArtifact, {
digest: digest(receiptArtifact.digest, "candidate receipt lock artifact digest"),
id: positiveDecimal(receiptArtifact.id, "candidate receipt lock artifact id"),
@@ -394,20 +419,41 @@ export async function locateCandidateReceipt(
});
const downloadDir = mkdtempSync(join(tmpdir(), "openclaw-candidate-receipt-"));
const receiptPath = join(downloadDir, RECEIPT_FILE_NAME);
try {
runGh([
"run",
"download",
exactRun.runId,
"--repo",
options.repo,
"--name",
receiptArtifactName,
"--dir",
downloadDir,
]);
const parsedLock = parseCandidateReceiptLockJson(
readFileSync(join(downloadDir, RECEIPT_FILE_NAME), "utf8"),
await retryOperation(deadline, sleep, "candidate receipt lock download", () => {
rmSync(receiptPath, { force: true });
return runGh([
"run",
"download",
exactRun.runId,
"--repo",
options.repo,
"--name",
receiptArtifactName,
"--dir",
downloadDir,
]);
});
const parsedLock = parseCandidateReceiptLockJson(readFileSync(receiptPath, "utf8"));
const expectedArtifactIds = new Set(
Object.values(parsedLock.receipt.artifacts).map((artifact) => artifact.artifact_id),
);
const artifacts = await pollUntil(
deadline,
async () => {
const response = await api(`actions/runs/${exactRun.runId}/artifacts?per_page=100`);
const visibleArtifactIds = new Set(
artifactRecords(response).map((artifact) =>
positiveDecimal(artifact.id, "candidate receipt artifact id"),
),
);
return [...expectedArtifactIds].every((id) => visibleArtifactIds.has(id))
? response
: undefined;
},
sleep,
"timed out waiting for candidate artifact metadata propagation",
);
const validatedLock = validateCandidateReceiptProvenance({
artifacts,
@@ -421,9 +467,6 @@ export async function locateCandidateReceipt(
run,
workflow,
});
// A rerun invalidates the just-read artifact namespace even if it starts
// between the first attempt check and the final receipt read.
requireCurrentAttempt(api, exactRun.runId, exactRun.runAttempt);
return validatedLock;
} finally {
rmSync(downloadDir, { force: true, recursive: true });
@@ -468,7 +511,7 @@ async function main(argv: string[] = process.argv.slice(2)): Promise<void> {
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
void main().catch((error) => {
void main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : String(error));
console.error("[release-candidate-receipt-locator] FAILED (exit 1)");
process.exitCode = 1;
+1 -1
View File
@@ -1 +1 @@
{"digest":"sha256:2bc324c84b0aeee94c65c144f53580bfa4f74178e0f632560b790e7213685915","receipt":{"artifacts":{"docker_image":{"artifact_digest":"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc","artifact_id":"103","artifact_name":"docker-e2e-shared-images-release-candidate-aaaaaaaaaaaa-12345-2","content_digest":"sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"},"package":{"artifact_digest":"sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee","artifact_id":"101","artifact_name":"docker-e2e-package-12345-2","content_digest":"sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"},"plugin_registry":{"artifact_digest":"sha256:1111111111111111111111111111111111111111111111111111111111111111","artifact_id":"102","artifact_name":"docker-e2e-prepublish-plugin-registry-12345-2","content_digest":"sha256:2222222222222222222222222222222222222222222222222222222222222222"},"root_image":{"artifact_digest":"sha256:3333333333333333333333333333333333333333333333333333333333333333","artifact_id":"104","artifact_name":"release-candidate-root-image-12345-2","content_digest":"sha256:4444444444444444444444444444444444444444444444444444444444444444"}},"producer":{"repository":"openclaw/openclaw","run_attempt":"2","run_id":"12345","workflow_id":"987","workflow_path":".github/workflows/release-candidate-artifacts.yml","workflow_sha":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},"release_plan_digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","schema":"openclaw.candidate-receipt.v1"},"schema":"openclaw.candidate-receipt-lock.v1"}
{"digest":"sha256:53d9d8af3975f7bcbbf552e71ec5a946762849ce07100a3f6d94dc5609992405","receipt":{"artifacts":{"docker_image":{"artifact_digest":"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc","artifact_id":"103","artifact_name":"docker-e2e-shared-images-release-candidate-aaaaaaaaaaaa-12345-2","content_digest":"sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"},"e2e_plugin_registry":{"artifact_digest":"sha256:1111111111111111111111111111111111111111111111111111111111111111","artifact_id":"102","artifact_name":"docker-e2e-prepublish-plugin-registry-12345-2","content_digest":"sha256:2222222222222222222222222222222222222222222222222222222222222222"},"package":{"artifact_digest":"sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee","artifact_id":"101","artifact_name":"docker-e2e-package-12345-2","content_digest":"sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"},"root_image":{"artifact_digest":"sha256:3333333333333333333333333333333333333333333333333333333333333333","artifact_id":"104","artifact_name":"release-candidate-root-image-12345-2","content_digest":"sha256:4444444444444444444444444444444444444444444444444444444444444444"}},"producer":{"repository":"openclaw/openclaw","run_attempt":"2","run_id":"12345","workflow_id":"987","workflow_path":".github/workflows/release-candidate-artifacts.yml","workflow_sha":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},"release_plan_digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","schema":"openclaw.candidate-receipt.v1"},"schema":"openclaw.candidate-receipt-lock.v1"}
+1 -1
View File
@@ -1 +1 @@
{"artifacts":{"docker_image":{"artifact_digest":"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc","artifact_id":"103","artifact_name":"docker-e2e-shared-images-release-candidate-aaaaaaaaaaaa-12345-2","content_digest":"sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"},"package":{"artifact_digest":"sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee","artifact_id":"101","artifact_name":"docker-e2e-package-12345-2","content_digest":"sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"},"plugin_registry":{"artifact_digest":"sha256:1111111111111111111111111111111111111111111111111111111111111111","artifact_id":"102","artifact_name":"docker-e2e-prepublish-plugin-registry-12345-2","content_digest":"sha256:2222222222222222222222222222222222222222222222222222222222222222"},"root_image":{"artifact_digest":"sha256:3333333333333333333333333333333333333333333333333333333333333333","artifact_id":"104","artifact_name":"release-candidate-root-image-12345-2","content_digest":"sha256:4444444444444444444444444444444444444444444444444444444444444444"}},"producer":{"repository":"openclaw/openclaw","run_attempt":"2","run_id":"12345","workflow_id":"987","workflow_path":".github/workflows/release-candidate-artifacts.yml","workflow_sha":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},"release_plan_digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","schema":"openclaw.candidate-receipt.v1"}
{"artifacts":{"docker_image":{"artifact_digest":"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc","artifact_id":"103","artifact_name":"docker-e2e-shared-images-release-candidate-aaaaaaaaaaaa-12345-2","content_digest":"sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"},"e2e_plugin_registry":{"artifact_digest":"sha256:1111111111111111111111111111111111111111111111111111111111111111","artifact_id":"102","artifact_name":"docker-e2e-prepublish-plugin-registry-12345-2","content_digest":"sha256:2222222222222222222222222222222222222222222222222222222222222222"},"package":{"artifact_digest":"sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee","artifact_id":"101","artifact_name":"docker-e2e-package-12345-2","content_digest":"sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"},"root_image":{"artifact_digest":"sha256:3333333333333333333333333333333333333333333333333333333333333333","artifact_id":"104","artifact_name":"release-candidate-root-image-12345-2","content_digest":"sha256:4444444444444444444444444444444444444444444444444444444444444444"}},"producer":{"repository":"openclaw/openclaw","run_attempt":"2","run_id":"12345","workflow_id":"987","workflow_path":".github/workflows/release-candidate-artifacts.yml","workflow_sha":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},"release_plan_digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","schema":"openclaw.candidate-receipt.v1"}
+115 -13
View File
@@ -50,7 +50,6 @@ function workflowFixture(overrides: Record<string, unknown> = {}) {
return {
id: Number(workflowId),
path: ".github/workflows/release-candidate-artifacts.yml",
state: "active",
...overrides,
};
}
@@ -157,9 +156,20 @@ describe("candidate receipt contract", () => {
expect(receipt).not.toHaveProperty("candidate_sha");
expect(receipt).not.toHaveProperty("version");
expect(receipt).not.toHaveProperty("validation");
expect(receipt.artifacts).toHaveProperty("e2e_plugin_registry");
expect(receipt.artifacts).not.toHaveProperty("plugin_registry");
expect(() =>
validateCandidateReceipt({ ...sourceFixture, candidate_sha: "a".repeat(40) }),
).toThrow("candidate receipt keys must be exactly");
expect(() =>
validateCandidateReceipt({
...sourceFixture,
artifacts: {
...(sourceFixture.artifacts as Record<string, unknown>),
plugin_registry: (sourceFixture.artifacts as Record<string, unknown>).e2e_plugin_registry,
},
}),
).toThrow("candidate receipt artifacts keys must be exactly");
});
});
@@ -245,7 +255,26 @@ describe("candidate receipt locator", () => {
);
});
it("discovers one nonce-bound run, polls its exact attempt, and reads its receipt artifact", async () => {
it("bounds transient API failures", async () => {
const runGh = vi.fn(() => {
throw new Error("GitHub API unavailable");
});
await expect(
locateCandidateReceipt({
dispatchId,
releasePlanDigest: lockFixture.receipt.release_plan_digest,
repo: "openclaw/openclaw",
runGh,
sleep: async () => {},
timeoutMs: 1000,
workflowId,
workflowSha,
}),
).rejects.toThrow("failed after 3 attempts");
expect(runGh).toHaveBeenCalledTimes(3);
});
it("retries APIs and artifact propagation before reading the exact receipt", async () => {
const receiptArtifactName = `release-candidate-receipt-${runId}-${runAttempt}`;
const artifactResponse = artifactsFixture();
artifactResponse.artifacts.push({
@@ -256,6 +285,12 @@ describe("candidate receipt locator", () => {
workflow_run: { id: Number(runId) },
});
artifactResponse.total_count = 5;
const incompleteArtifactResponse = {
artifacts: artifactResponse.artifacts.filter(
(artifact) => artifact.id !== Number(lockFixture.receipt.artifacts.root_image.artifact_id),
),
total_count: 4,
};
const responses = new Map<string, unknown>([
[
`api repos/openclaw/openclaw/actions/workflows/${workflowId} --method GET`,
@@ -269,12 +304,13 @@ describe("candidate receipt locator", () => {
`api repos/openclaw/openclaw/actions/runs/${runId}/attempts/${runAttempt} --method GET`,
runFixture(),
],
[`api repos/openclaw/openclaw/actions/runs/${runId} --method GET`, runFixture()],
[
`api repos/openclaw/openclaw/actions/runs/${runId}/artifacts?per_page=100 --method GET`,
artifactResponse,
],
]);
let workflowAttempts = 0;
let artifactAttempts = 0;
const runGh = vi.fn((args: string[]) => {
if (args[0] === "run" && args[1] === "download") {
const dir = args[args.indexOf("--dir") + 1];
@@ -284,12 +320,33 @@ describe("candidate receipt locator", () => {
writeFileSync(resolve(dir, "candidate-receipt-lock.json"), lockText);
return "";
}
if (
args.join(" ") ===
`api repos/openclaw/openclaw/actions/workflows/${workflowId} --method GET` &&
workflowAttempts++ === 0
) {
throw new Error("transient GitHub API failure");
}
if (
args.join(" ") ===
`api repos/openclaw/openclaw/actions/runs/${runId}/artifacts?per_page=100 --method GET`
) {
artifactAttempts += 1;
return JSON.stringify(
artifactAttempts === 1
? { artifacts: artifactsFixture().artifacts, total_count: 4 }
: artifactAttempts === 2
? incompleteArtifactResponse
: artifactResponse,
);
}
const response = responses.get(args.join(" "));
if (!response) {
throw new Error(`unexpected gh invocation: ${args.join(" ")}`);
}
return JSON.stringify(response);
});
const sleep = vi.fn(async () => {});
await expect(
locateCandidateReceipt({
@@ -297,12 +354,15 @@ describe("candidate receipt locator", () => {
releasePlanDigest: lockFixture.receipt.release_plan_digest,
repo: "openclaw/openclaw",
runGh,
sleep: async () => {},
sleep,
timeoutMs: 1000,
workflowId,
workflowSha,
}),
).resolves.toEqual(lockFixture);
expect(workflowAttempts).toBe(2);
expect(artifactAttempts).toBe(3);
expect(sleep).toHaveBeenCalled();
expect(runGh).toHaveBeenCalledWith([
"run",
"download",
@@ -316,17 +376,35 @@ describe("candidate receipt locator", () => {
]);
});
it("rejects a superseded exact attempt", async () => {
it("keeps an exact successful attempt valid without consulting later reruns", async () => {
const receiptArtifactName = `release-candidate-receipt-${runId}-${runAttempt}`;
const artifactResponse = artifactsFixture();
artifactResponse.artifacts.push({
digest: `sha256:${"5".repeat(64)}`,
expired: false,
id: 105,
name: receiptArtifactName,
workflow_run: { id: Number(runId) },
});
artifactResponse.total_count = 5;
const runGh = vi.fn((args: string[]) => {
const key = args.join(" ");
if (args[0] === "run" && args[1] === "download") {
const dir = args[args.indexOf("--dir") + 1];
if (!dir) {
throw new Error("missing download dir");
}
writeFileSync(resolve(dir, "candidate-receipt-lock.json"), lockText);
return "";
}
if (key.includes(`actions/workflows/${workflowId} --method GET`)) {
return JSON.stringify(workflowFixture());
}
if (key.includes(`actions/runs/${runId}/attempts/${runAttempt}`)) {
return JSON.stringify(runFixture());
}
if (key.includes(`actions/runs/${runId} --method GET`)) {
return JSON.stringify(runFixture({ run_attempt: 3 }));
if (key.includes(`actions/runs/${runId}/artifacts?per_page=100`)) {
return JSON.stringify(artifactResponse);
}
throw new Error(`unexpected gh invocation: ${key}`);
});
@@ -343,7 +421,13 @@ describe("candidate receipt locator", () => {
workflowId,
workflowSha,
}),
).rejects.toThrow("superseded by a rerun");
).resolves.toEqual(lockFixture);
expect(runGh).not.toHaveBeenCalledWith([
"api",
`repos/openclaw/openclaw/actions/runs/${runId}`,
"--method",
"GET",
]);
});
});
@@ -411,13 +495,16 @@ describe("release candidate artifact producer workflow", () => {
expect(text).not.toContain("--push");
});
it("validates canonical ReleasePlan bytes and derives candidate inputs without copying plan fields", () => {
it("verifies repository-derived ReleasePlan authority before exposing candidate inputs", () => {
const validate = workflowJob(workflow, "validate_release_plan");
const step = workflowStep(validate, "Validate ReleasePlanLock");
const step = workflowStep(validate, "Verify repository-derived ReleasePlan authority");
expect(step.run).toContain("parseReleasePlanLockJson");
expect(step.run).toContain("verifyReleasePlanLock");
expect(step.run).toContain("dispatch_id must be one safe unique caller nonce");
expect(step.run).toContain("lock.plan.tooling.sha !== process.env.WORKFLOW_SHA");
expect(step.run).toContain("lock.plan.tooling.ref !== process.env.WORKFLOW_FULL_REF");
expect(step.run).toContain("repos/${process.env.GITHUB_REPOSITORY}/commits/${candidateSha}");
expect(step.run).toContain('"fetch", "--force", "--no-tags", "origin", candidateSha');
expect(step.run).toContain("toolingFullRef: workflowFullRef");
expect(step.run).toContain("toolingSha: workflowSha");
expect(step.run).toContain("candidate_sha=${lock.plan.candidate_sha}");
expect(step.run).toContain("release_plan_digest=${lock.digest}");
expect(step.run).toContain("release_profile=${lock.plan.validation.profile}");
@@ -440,6 +527,20 @@ describe("release candidate artifact producer workflow", () => {
shared_image_policy: "no-push-artifact",
});
expect(root["runs-on"]).toBe("blacksmith-32vcpu-ubuntu-2404");
const build = workflowStep(root, "Build root Dockerfile image");
expect(build.env).toMatchObject({
BUILD_TIMESTAMP: "2000-01-01T00:00:00.000Z",
TARGET_SHA: "${{ needs.validate_release_plan.outputs.candidate_sha }}",
});
expect(build.run).toContain('--build-arg "GIT_COMMIT=$TARGET_SHA"');
expect(build.run).toContain('--build-arg "OPENCLAW_BUILD_TIMESTAMP=$BUILD_TIMESTAMP"');
const verify = workflowStep(root, "Verify root image build provenance");
expect(verify.run).toContain("/app/dist/build-info.json");
expect(verify.run).toContain('embedded_commit" != "$TARGET_SHA');
const stepNames = root.steps?.map((step) => step.name) ?? [];
expect(stepNames.indexOf("Verify root image build provenance")).toBeLessThan(
stepNames.indexOf("Pack root Dockerfile image artifact"),
);
expect(workflowStep(root, "Pack root Dockerfile image artifact").run).toContain(
"scripts/docker/shared-image-artifact.sh",
);
@@ -459,11 +560,12 @@ describe("release candidate artifact producer workflow", () => {
);
expect(provenance.run).toContain(".display_title == $title");
expect(provenance.run).toContain("actions/workflows/${workflow_id}");
expect(provenance.run).not.toContain('.state == "active"');
const create = workflowStep(receipt, "Create canonical CandidateReceiptLock");
expect(create.run).toContain("createCandidateReceiptLock");
expect(create.run).toContain('docker_image: artifact("DOCKER_IMAGE"');
expect(create.run).toContain("e2e_plugin_registry: artifact(");
expect(create.run).toContain('package: artifact("PACKAGE"');
expect(create.run).toContain("plugin_registry: artifact(");
expect(create.run).toContain('root_image: artifact("ROOT_IMAGE"');
expect(workflowStep(receipt, "Upload CandidateReceiptLock").with).toMatchObject({
name: "release-candidate-receipt-${{ github.run_id }}-${{ github.run_attempt }}",