mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 03:45:46 -06:00
fix(release): harden candidate artifact provenance
This commit is contained in:
@@ -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)) {
|
||||
|
||||
@@ -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 @@
|
||||
{"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
@@ -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"}
|
||||
|
||||
@@ -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 }}",
|
||||
|
||||
Reference in New Issue
Block a user