mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 03:45:46 -06:00
perf(release): prebuild immutable candidate artifacts
This commit is contained in:
@@ -0,0 +1,354 @@
|
||||
name: Release Candidate Artifacts
|
||||
run-name: Release Candidate Artifacts ${{ inputs.dispatch_id }}
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dispatch_id:
|
||||
description: Unique caller nonce used to locate this exact producer run
|
||||
required: true
|
||||
type: string
|
||||
release_plan_lock_base64:
|
||||
description: Base64-encoded canonical ReleasePlanLock bytes
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
packages: read
|
||||
pull-requests: read
|
||||
|
||||
concurrency:
|
||||
group: release-candidate-artifacts-${{ inputs.dispatch_id }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
jobs:
|
||||
validate_release_plan:
|
||||
name: Validate immutable release plan
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
allow_frozen_target_scenario_omissions: ${{ steps.plan.outputs.allow_frozen_target_scenario_omissions }}
|
||||
allow_unreleased_changelog: ${{ steps.plan.outputs.allow_unreleased_changelog }}
|
||||
candidate_sha: ${{ steps.plan.outputs.candidate_sha }}
|
||||
release_plan_digest: ${{ steps.plan.outputs.release_plan_digest }}
|
||||
release_profile: ${{ steps.plan.outputs.release_profile }}
|
||||
steps:
|
||||
- name: Checkout trusted receipt tooling
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
|
||||
- name: Validate ReleasePlanLock
|
||||
id: plan
|
||||
env:
|
||||
DISPATCH_ID: ${{ inputs.dispatch_id }}
|
||||
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'
|
||||
import fs from "node:fs";
|
||||
import { parseReleasePlanLockJson } from "./scripts/release-plan-contract.mjs";
|
||||
|
||||
const dispatchId = process.env.DISPATCH_ID ?? "";
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/u.test(dispatchId)) {
|
||||
throw new Error("dispatch_id must be one safe unique caller nonce");
|
||||
}
|
||||
const encoded = process.env.RELEASE_PLAN_LOCK_BASE64 ?? "";
|
||||
if (!/^[A-Za-z0-9+/]+={0,2}$/u.test(encoded)) {
|
||||
throw new Error("release_plan_lock_base64 must contain one canonical base64 payload");
|
||||
}
|
||||
const bytes = Buffer.from(encoded, "base64");
|
||||
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");
|
||||
}
|
||||
if (lock.plan.tooling.ref !== process.env.WORKFLOW_FULL_REF) {
|
||||
throw new Error("ReleasePlan tooling ref must equal the candidate producer workflow ref");
|
||||
}
|
||||
const outputPath = process.env.GITHUB_OUTPUT;
|
||||
if (!outputPath) {
|
||||
throw new Error("GITHUB_OUTPUT is required");
|
||||
}
|
||||
const frozenTarget = lock.plan.target_context_ref !== lock.plan.candidate_sha;
|
||||
fs.appendFileSync(
|
||||
outputPath,
|
||||
[
|
||||
`allow_frozen_target_scenario_omissions=${frozenTarget}`,
|
||||
`allow_unreleased_changelog=${lock.plan.purpose === "main-qualification"}`,
|
||||
`candidate_sha=${lock.plan.candidate_sha}`,
|
||||
`release_plan_digest=${lock.digest}`,
|
||||
`release_profile=${lock.plan.validation.profile}`,
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
NODE
|
||||
|
||||
candidate_artifacts:
|
||||
name: Produce package, plugin registry, and Docker image
|
||||
needs: validate_release_plan
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
packages: read
|
||||
pull-requests: read
|
||||
uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml
|
||||
with:
|
||||
ref: ${{ needs.validate_release_plan.outputs.candidate_sha }}
|
||||
prepare_only: true
|
||||
include_repo_e2e: false
|
||||
include_release_path_suites: false
|
||||
include_openwebui: false
|
||||
include_live_suites: false
|
||||
enable_prepublish_plugin_registry: true
|
||||
allow_frozen_target_scenario_omissions: ${{ needs.validate_release_plan.outputs.allow_frozen_target_scenario_omissions == 'true' }}
|
||||
allow_unreleased_changelog: ${{ needs.validate_release_plan.outputs.allow_unreleased_changelog == 'true' }}
|
||||
release_test_profile: ${{ needs.validate_release_plan.outputs.release_profile }}
|
||||
shared_image_artifact_namespace: release-candidate
|
||||
shared_image_policy: no-push-artifact
|
||||
|
||||
root_image:
|
||||
name: Produce root Dockerfile image
|
||||
needs: validate_release_plan
|
||||
runs-on: blacksmith-32vcpu-ubuntu-2404
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
outputs:
|
||||
archive_sha256: ${{ steps.image_artifact.outputs.archive_sha256 }}
|
||||
artifact_digest: ${{ steps.upload.outputs.artifact-digest }}
|
||||
artifact_id: ${{ steps.upload.outputs.artifact-id }}
|
||||
artifact_name: ${{ steps.image_artifact.outputs.artifact_name }}
|
||||
env:
|
||||
DOCKER_BUILD_RECORD_UPLOAD: "false"
|
||||
DOCKER_BUILD_SUMMARY: "false"
|
||||
steps:
|
||||
- name: Checkout candidate
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: ${{ needs.validate_release_plan.outputs.candidate_sha }}
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
|
||||
- name: Checkout trusted image artifact helper
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
repository: ${{ github.repository }}
|
||||
ref: ${{ github.sha }}
|
||||
path: .release-harness
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
|
||||
- name: Set up Blacksmith Docker Builder
|
||||
uses: useblacksmith/setup-docker-builder@6ff44f8e5255f9d8aa31ef22f7e57a2d926b7da0 # v1
|
||||
with:
|
||||
max-cache-size-mb: 800000
|
||||
|
||||
- name: Build root Dockerfile image
|
||||
env:
|
||||
IMAGE_REF: openclaw-release-candidate-root:${{ 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 OPENCLAW_EXTENSIONS=matrix \
|
||||
--tag "$IMAGE_REF" \
|
||||
--file ./Dockerfile \
|
||||
.
|
||||
|
||||
- name: Pack root Dockerfile image artifact
|
||||
id: image_artifact
|
||||
env:
|
||||
IMAGE_REF: openclaw-release-candidate-root:${{ needs.validate_release_plan.outputs.candidate_sha }}
|
||||
TARGET_SHA: ${{ needs.validate_release_plan.outputs.candidate_sha }}
|
||||
WORKFLOW_SHA: ${{ github.sha }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
artifact_dir="${RUNNER_TEMP}/release-candidate-root-image"
|
||||
artifact_name="release-candidate-root-image-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
bash .release-harness/scripts/docker/shared-image-artifact.sh \
|
||||
pack "$artifact_dir" release-candidate-root "$TARGET_SHA" "$WORKFLOW_SHA" "$IMAGE_REF"
|
||||
archive_sha256="$(
|
||||
jq -er '.archive.sha256 | select(type == "string" and test("^[a-f0-9]{64}$"))' \
|
||||
"$artifact_dir/shared-image-artifact.json"
|
||||
)"
|
||||
{
|
||||
echo "archive_sha256=$archive_sha256"
|
||||
echo "artifact_name=$artifact_name"
|
||||
echo "artifact_path=$artifact_dir"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload root Dockerfile image artifact
|
||||
id: upload
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: ${{ steps.image_artifact.outputs.artifact_name }}
|
||||
path: ${{ steps.image_artifact.outputs.artifact_path }}
|
||||
if-no-files-found: error
|
||||
compression-level: 0
|
||||
retention-days: 7
|
||||
|
||||
candidate_receipt:
|
||||
name: Emit immutable candidate receipt
|
||||
needs: [validate_release_plan, candidate_artifacts, root_image]
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout trusted receipt tooling
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
|
||||
- name: Verify exact producer workflow attempt
|
||||
id: producer
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
EXPECTED_RUN_TITLE: Release Candidate Artifacts ${{ inputs.dispatch_id }}
|
||||
EXPECTED_WORKFLOW_PATH: .github/workflows/release-candidate-artifacts.yml
|
||||
EXPECTED_WORKFLOW_SHA: ${{ github.sha }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
run_json="$(
|
||||
gh api --method GET \
|
||||
"repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/attempts/${GITHUB_RUN_ATTEMPT}"
|
||||
)"
|
||||
workflow_id="$(
|
||||
jq -er \
|
||||
--arg attempt "$GITHUB_RUN_ATTEMPT" \
|
||||
--arg path "$EXPECTED_WORKFLOW_PATH" \
|
||||
--arg run_id "$GITHUB_RUN_ID" \
|
||||
--arg sha "$EXPECTED_WORKFLOW_SHA" \
|
||||
--arg title "$EXPECTED_RUN_TITLE" \
|
||||
'
|
||||
select(
|
||||
(.id | tostring) == $run_id and
|
||||
(.run_attempt | tostring) == $attempt and
|
||||
.event == "workflow_dispatch" and
|
||||
.display_title == $title and
|
||||
.path == $path and
|
||||
.head_sha == $sha
|
||||
)
|
||||
| .workflow_id
|
||||
' <<< "$run_json"
|
||||
)"
|
||||
workflow_json="$(
|
||||
gh api --method GET \
|
||||
"repos/${GITHUB_REPOSITORY}/actions/workflows/${workflow_id}"
|
||||
)"
|
||||
jq -e \
|
||||
--arg id "$workflow_id" \
|
||||
--arg path "$EXPECTED_WORKFLOW_PATH" \
|
||||
'(.id | tostring) == $id and .path == $path and .state == "active"' \
|
||||
<<< "$workflow_json" >/dev/null
|
||||
echo "workflow_id=$workflow_id" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create canonical CandidateReceiptLock
|
||||
env:
|
||||
DOCKER_IMAGE_ARCHIVE_SHA256: ${{ needs.candidate_artifacts.outputs.shared_image_archive_sha256 }}
|
||||
DOCKER_IMAGE_ARTIFACT_DIGEST: ${{ needs.candidate_artifacts.outputs.shared_image_artifact_digest }}
|
||||
DOCKER_IMAGE_ARTIFACT_ID: ${{ needs.candidate_artifacts.outputs.shared_image_artifact_id }}
|
||||
DOCKER_IMAGE_ARTIFACT_NAME: ${{ needs.candidate_artifacts.outputs.shared_image_artifact_name }}
|
||||
PACKAGE_ARTIFACT_DIGEST: ${{ needs.candidate_artifacts.outputs.package_artifact_digest }}
|
||||
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 }}
|
||||
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 }}
|
||||
ROOT_IMAGE_ARTIFACT_ID: ${{ needs.root_image.outputs.artifact_id }}
|
||||
ROOT_IMAGE_ARTIFACT_NAME: ${{ needs.root_image.outputs.artifact_name }}
|
||||
WORKFLOW_ID: ${{ steps.producer.outputs.workflow_id }}
|
||||
WORKFLOW_SHA: ${{ github.sha }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p .artifacts/candidate-receipt
|
||||
node --input-type=module <<'NODE'
|
||||
import fs from "node:fs";
|
||||
import {
|
||||
canonicalCandidateReceiptLockJson,
|
||||
createCandidateReceiptLock,
|
||||
} from "./scripts/release-candidate-receipt-contract.mjs";
|
||||
|
||||
const required = (name) => {
|
||||
const value = process.env[name] ?? "";
|
||||
if (!value) {
|
||||
throw new Error(`${name} is required`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
const prefixedDigest = (name) => {
|
||||
const value = required(name);
|
||||
return value.startsWith("sha256:") ? value : `sha256:${value}`;
|
||||
};
|
||||
const artifact = (prefix, contentDigestName) => ({
|
||||
artifact_digest: prefixedDigest(`${prefix}_ARTIFACT_DIGEST`),
|
||||
artifact_id: required(`${prefix}_ARTIFACT_ID`),
|
||||
artifact_name: required(`${prefix}_ARTIFACT_NAME`),
|
||||
content_digest: prefixedDigest(contentDigestName),
|
||||
});
|
||||
const lock = createCandidateReceiptLock({
|
||||
schema: "openclaw.candidate-receipt.v1",
|
||||
release_plan_digest: required("RELEASE_PLAN_DIGEST"),
|
||||
producer: {
|
||||
repository: required("GITHUB_REPOSITORY"),
|
||||
workflow_path: ".github/workflows/release-candidate-artifacts.yml",
|
||||
workflow_id: required("WORKFLOW_ID"),
|
||||
workflow_sha: required("WORKFLOW_SHA"),
|
||||
run_id: required("GITHUB_RUN_ID"),
|
||||
run_attempt: required("GITHUB_RUN_ATTEMPT"),
|
||||
},
|
||||
artifacts: {
|
||||
docker_image: artifact("DOCKER_IMAGE", "DOCKER_IMAGE_ARCHIVE_SHA256"),
|
||||
package: artifact("PACKAGE", "PACKAGE_SHA256"),
|
||||
plugin_registry: artifact(
|
||||
"PLUGIN_REGISTRY",
|
||||
"PLUGIN_REGISTRY_MANIFEST_SHA256",
|
||||
),
|
||||
root_image: artifact("ROOT_IMAGE", "ROOT_IMAGE_ARCHIVE_SHA256"),
|
||||
},
|
||||
});
|
||||
fs.writeFileSync(
|
||||
".artifacts/candidate-receipt/candidate-receipt-lock.json",
|
||||
canonicalCandidateReceiptLockJson(lock),
|
||||
"ascii",
|
||||
);
|
||||
NODE
|
||||
|
||||
- name: Upload CandidateReceiptLock
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: release-candidate-receipt-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: .artifacts/candidate-receipt/candidate-receipt-lock.json
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
@@ -0,0 +1,44 @@
|
||||
export type CandidateReceiptArtifact = {
|
||||
artifact_digest: string;
|
||||
artifact_id: string;
|
||||
artifact_name: string;
|
||||
content_digest: string;
|
||||
};
|
||||
|
||||
export type CandidateReceipt = {
|
||||
schema: "openclaw.candidate-receipt.v1";
|
||||
release_plan_digest: string;
|
||||
producer: {
|
||||
repository: "openclaw/openclaw";
|
||||
workflow_path: ".github/workflows/release-candidate-artifacts.yml";
|
||||
workflow_id: string;
|
||||
workflow_sha: string;
|
||||
run_id: string;
|
||||
run_attempt: string;
|
||||
};
|
||||
artifacts: {
|
||||
docker_image: CandidateReceiptArtifact;
|
||||
package: CandidateReceiptArtifact;
|
||||
plugin_registry: CandidateReceiptArtifact;
|
||||
root_image: CandidateReceiptArtifact;
|
||||
};
|
||||
};
|
||||
|
||||
export type CandidateReceiptLock = {
|
||||
schema: "openclaw.candidate-receipt-lock.v1";
|
||||
digest: string;
|
||||
receipt: CandidateReceipt;
|
||||
};
|
||||
|
||||
export const CANDIDATE_RECEIPT_SCHEMA: "openclaw.candidate-receipt.v1";
|
||||
export const CANDIDATE_RECEIPT_LOCK_SCHEMA: "openclaw.candidate-receipt-lock.v1";
|
||||
export const CANDIDATE_RECEIPT_CANONICALIZATION: "ascii-sorted-compact-json-trailing-newline-v1";
|
||||
export const CANDIDATE_RECEIPT_MAX_BYTES: number;
|
||||
export const CANDIDATE_RECEIPT_WORKFLOW_PATH: ".github/workflows/release-candidate-artifacts.yml";
|
||||
export function validateCandidateReceipt(value: unknown): CandidateReceipt;
|
||||
export function canonicalCandidateReceiptJson(value: unknown): string;
|
||||
export function candidateReceiptDigest(value: unknown): string;
|
||||
export function createCandidateReceiptLock(value: unknown): CandidateReceiptLock;
|
||||
export function validateCandidateReceiptLock(value: unknown): CandidateReceiptLock;
|
||||
export function canonicalCandidateReceiptLockJson(value: unknown): string;
|
||||
export function parseCandidateReceiptLockJson(text: string): CandidateReceiptLock;
|
||||
@@ -0,0 +1,257 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { parseDocument } from "yaml";
|
||||
import { isRecord } from "./lib/record-shared.mjs";
|
||||
|
||||
export const CANDIDATE_RECEIPT_SCHEMA = "openclaw.candidate-receipt.v1";
|
||||
export const CANDIDATE_RECEIPT_LOCK_SCHEMA = "openclaw.candidate-receipt-lock.v1";
|
||||
export const CANDIDATE_RECEIPT_CANONICALIZATION = "ascii-sorted-compact-json-trailing-newline-v1";
|
||||
export const CANDIDATE_RECEIPT_MAX_BYTES = 16 * 1024;
|
||||
export const CANDIDATE_RECEIPT_WORKFLOW_PATH = ".github/workflows/release-candidate-artifacts.yml";
|
||||
|
||||
const REPOSITORY = "openclaw/openclaw";
|
||||
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 compareAscii = (left, right) => (left < right ? -1 : left > right ? 1 : 0);
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
function exactKeys(value, keys, label) {
|
||||
const actual = Object.keys(value).toSorted(compareAscii);
|
||||
const expected = [...keys].toSorted(compareAscii);
|
||||
if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) {
|
||||
fail(`${label} keys must be exactly: ${expected.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
function asciiString(value, label) {
|
||||
if (typeof value !== "string" || !ASCII_PATTERN.test(value)) {
|
||||
fail(`${label} must be a non-empty printable ASCII string`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function positiveDecimal(value, label) {
|
||||
const normalized = asciiString(value, label);
|
||||
if (!POSITIVE_DECIMAL_PATTERN.test(normalized)) {
|
||||
fail(`${label} must be a positive decimal integer string`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function sha(value, label) {
|
||||
const normalized = asciiString(value, label);
|
||||
if (!SHA_PATTERN.test(normalized)) {
|
||||
fail(`${label} must be a lowercase 40-character commit SHA`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function digest(value, label) {
|
||||
if (typeof value !== "string" || !DIGEST_PATTERN.test(value)) {
|
||||
fail(`${label} must be sha256:<64 lowercase hex characters>`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function canonicalize(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(canonicalize);
|
||||
}
|
||||
if (isRecord(value)) {
|
||||
return Object.fromEntries(
|
||||
Object.keys(value)
|
||||
.toSorted(compareAscii)
|
||||
.map((key) => [key, canonicalize(value[key])]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function canonicalAsciiJson(value) {
|
||||
const json = `${JSON.stringify(canonicalize(value))}\n`;
|
||||
if (!/^[\x20-\x7e]+\n$/u.test(json)) {
|
||||
fail("canonical JSON must be printable ASCII with exactly one trailing newline");
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
function validateArtifact(value, label) {
|
||||
if (!isRecord(value)) {
|
||||
fail(`${label} must be an object`);
|
||||
}
|
||||
exactKeys(value, ["artifact_digest", "artifact_id", "artifact_name", "content_digest"], label);
|
||||
return {
|
||||
artifact_digest: digest(value.artifact_digest, `${label} artifact_digest`),
|
||||
artifact_id: positiveDecimal(value.artifact_id, `${label} artifact_id`),
|
||||
artifact_name: asciiString(value.artifact_name, `${label} artifact_name`),
|
||||
content_digest: digest(value.content_digest, `${label} content_digest`),
|
||||
};
|
||||
}
|
||||
|
||||
export function validateCandidateReceipt(value) {
|
||||
if (!isRecord(value)) {
|
||||
fail("candidate receipt must be an object");
|
||||
}
|
||||
exactKeys(value, ["artifacts", "producer", "release_plan_digest", "schema"], "candidate receipt");
|
||||
if (value.schema !== CANDIDATE_RECEIPT_SCHEMA) {
|
||||
fail(`candidate receipt schema must be ${CANDIDATE_RECEIPT_SCHEMA}`);
|
||||
}
|
||||
if (!isRecord(value.producer)) {
|
||||
fail("candidate receipt producer must be an object");
|
||||
}
|
||||
exactKeys(
|
||||
value.producer,
|
||||
["repository", "run_attempt", "run_id", "workflow_id", "workflow_path", "workflow_sha"],
|
||||
"candidate receipt producer",
|
||||
);
|
||||
if (!isRecord(value.artifacts)) {
|
||||
fail("candidate receipt artifacts must be an object");
|
||||
}
|
||||
exactKeys(value.artifacts, ARTIFACT_KEYS, "candidate receipt artifacts");
|
||||
|
||||
const receipt = {
|
||||
schema: CANDIDATE_RECEIPT_SCHEMA,
|
||||
release_plan_digest: digest(value.release_plan_digest, "candidate receipt release_plan_digest"),
|
||||
producer: {
|
||||
repository: asciiString(value.producer.repository, "candidate receipt producer repository"),
|
||||
workflow_path: asciiString(
|
||||
value.producer.workflow_path,
|
||||
"candidate receipt producer workflow_path",
|
||||
),
|
||||
workflow_id: positiveDecimal(
|
||||
value.producer.workflow_id,
|
||||
"candidate receipt producer workflow_id",
|
||||
),
|
||||
workflow_sha: sha(value.producer.workflow_sha, "candidate receipt producer workflow_sha"),
|
||||
run_id: positiveDecimal(value.producer.run_id, "candidate receipt producer run_id"),
|
||||
run_attempt: positiveDecimal(
|
||||
value.producer.run_attempt,
|
||||
"candidate receipt producer run_attempt",
|
||||
),
|
||||
},
|
||||
artifacts: Object.fromEntries(
|
||||
ARTIFACT_KEYS.map((key) => [
|
||||
key,
|
||||
validateArtifact(value.artifacts[key], `candidate receipt artifacts.${key}`),
|
||||
]),
|
||||
),
|
||||
};
|
||||
if (receipt.producer.repository !== REPOSITORY) {
|
||||
fail(`candidate receipt producer repository must be ${REPOSITORY}`);
|
||||
}
|
||||
if (receipt.producer.workflow_path !== CANDIDATE_RECEIPT_WORKFLOW_PATH) {
|
||||
fail(`candidate receipt producer workflow_path must be ${CANDIDATE_RECEIPT_WORKFLOW_PATH}`);
|
||||
}
|
||||
const artifactIds = ARTIFACT_KEYS.map((key) => receipt.artifacts[key].artifact_id);
|
||||
if (new Set(artifactIds).size !== artifactIds.length) {
|
||||
fail("candidate receipt artifact IDs must be unique");
|
||||
}
|
||||
const expectedNameSuffix = `-${receipt.producer.run_id}-${receipt.producer.run_attempt}`;
|
||||
for (const key of ARTIFACT_KEYS) {
|
||||
if (!receipt.artifacts[key].artifact_name.endsWith(expectedNameSuffix)) {
|
||||
fail(`candidate receipt artifacts.${key} name must bind the producer run attempt`);
|
||||
}
|
||||
}
|
||||
const exactArtifactNames = {
|
||||
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)) {
|
||||
if (receipt.artifacts[key].artifact_name !== expectedName) {
|
||||
fail(`candidate receipt artifacts.${key} name does not match its artifact kind`);
|
||||
}
|
||||
}
|
||||
if (
|
||||
!new RegExp(
|
||||
`^docker-e2e-shared-images-release-candidate-[a-f0-9]{12}${expectedNameSuffix}$`,
|
||||
"u",
|
||||
).test(receipt.artifacts.docker_image.artifact_name)
|
||||
) {
|
||||
fail("candidate receipt artifacts.docker_image name does not match its artifact kind");
|
||||
}
|
||||
if (Buffer.byteLength(canonicalAsciiJson(receipt), "ascii") > CANDIDATE_RECEIPT_MAX_BYTES) {
|
||||
fail(`candidate receipt exceeds ${CANDIDATE_RECEIPT_MAX_BYTES} bytes`);
|
||||
}
|
||||
return receipt;
|
||||
}
|
||||
|
||||
export function canonicalCandidateReceiptJson(value) {
|
||||
return canonicalAsciiJson(validateCandidateReceipt(value));
|
||||
}
|
||||
|
||||
export function candidateReceiptDigest(value) {
|
||||
return `sha256:${createHash("sha256")
|
||||
.update(canonicalCandidateReceiptJson(value), "ascii")
|
||||
.digest("hex")}`;
|
||||
}
|
||||
|
||||
export function createCandidateReceiptLock(value) {
|
||||
const receipt = validateCandidateReceipt(value);
|
||||
return {
|
||||
schema: CANDIDATE_RECEIPT_LOCK_SCHEMA,
|
||||
digest: candidateReceiptDigest(receipt),
|
||||
receipt,
|
||||
};
|
||||
}
|
||||
|
||||
export function validateCandidateReceiptLock(value) {
|
||||
if (!isRecord(value)) {
|
||||
fail("candidate receipt lock must be an object");
|
||||
}
|
||||
exactKeys(value, ["digest", "receipt", "schema"], "candidate receipt lock");
|
||||
if (value.schema !== CANDIDATE_RECEIPT_LOCK_SCHEMA) {
|
||||
fail(`candidate receipt lock schema must be ${CANDIDATE_RECEIPT_LOCK_SCHEMA}`);
|
||||
}
|
||||
const receipt = validateCandidateReceipt(value.receipt);
|
||||
const receiptDigest = digest(value.digest, "candidate receipt lock digest");
|
||||
if (receiptDigest !== candidateReceiptDigest(receipt)) {
|
||||
fail("candidate receipt lock digest does not match its canonical receipt");
|
||||
}
|
||||
return { schema: CANDIDATE_RECEIPT_LOCK_SCHEMA, digest: receiptDigest, receipt };
|
||||
}
|
||||
|
||||
export function canonicalCandidateReceiptLockJson(value) {
|
||||
return canonicalAsciiJson(validateCandidateReceiptLock(value));
|
||||
}
|
||||
|
||||
export function parseCandidateReceiptLockJson(text) {
|
||||
if (
|
||||
typeof text !== "string" ||
|
||||
Buffer.byteLength(text, "utf8") > CANDIDATE_RECEIPT_MAX_BYTES + 4096
|
||||
) {
|
||||
fail("candidate receipt lock JSON is missing or too large");
|
||||
}
|
||||
if (!/^[\x20-\x7e]+\n$/u.test(text)) {
|
||||
fail(
|
||||
"candidate receipt lock JSON must be compact printable ASCII with exactly one trailing LF",
|
||||
);
|
||||
}
|
||||
const document = parseDocument(text, { strict: true, uniqueKeys: true });
|
||||
if (document.errors.length > 0) {
|
||||
const duplicate = document.errors.find((error) =>
|
||||
error.message.includes("keys must be unique"),
|
||||
);
|
||||
fail(
|
||||
duplicate
|
||||
? "candidate receipt JSON contains a duplicate key"
|
||||
: `candidate receipt lock JSON is invalid: ${document.errors[0].message}`,
|
||||
);
|
||||
}
|
||||
let value;
|
||||
try {
|
||||
value = JSON.parse(text);
|
||||
} catch (error) {
|
||||
throw new Error("candidate receipt lock JSON is invalid JSON", { cause: error });
|
||||
}
|
||||
const lock = validateCandidateReceiptLock(value);
|
||||
if (text !== canonicalCandidateReceiptLockJson(lock)) {
|
||||
fail("candidate receipt lock JSON does not use canonical bytes");
|
||||
}
|
||||
return lock;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { CandidateReceiptLock } from "./release-candidate-receipt-contract.mjs";
|
||||
|
||||
type RunGh = (args: string[]) => string;
|
||||
|
||||
export type CandidateReceiptLocatorOptions = {
|
||||
dispatchId: string;
|
||||
releasePlanDigest: string;
|
||||
repo: string;
|
||||
runAttempt?: string;
|
||||
runGh?: RunGh;
|
||||
runId?: string;
|
||||
sleep?: (milliseconds: number) => Promise<void>;
|
||||
timeoutMs?: number;
|
||||
workflowId: string;
|
||||
workflowSha: string;
|
||||
};
|
||||
|
||||
export function validateCandidateReceiptProvenance(params: {
|
||||
artifacts: unknown;
|
||||
expectedDispatchId: string;
|
||||
expectedReleasePlanDigest: string;
|
||||
expectedRunAttempt: string;
|
||||
expectedRunId: string;
|
||||
expectedWorkflowId: string;
|
||||
expectedWorkflowSha: string;
|
||||
lock: CandidateReceiptLock;
|
||||
run: unknown;
|
||||
workflow: unknown;
|
||||
}): CandidateReceiptLock;
|
||||
export function runCandidateReceiptGh(
|
||||
args: string[],
|
||||
params?: {
|
||||
execFileSyncImpl?: (
|
||||
command: string,
|
||||
args: string[],
|
||||
options: {
|
||||
encoding: "utf8";
|
||||
killSignal: "SIGKILL";
|
||||
maxBuffer: number;
|
||||
timeout: number;
|
||||
},
|
||||
) => string;
|
||||
},
|
||||
): string;
|
||||
export function locateCandidateReceipt(
|
||||
options: CandidateReceiptLocatorOptions,
|
||||
): Promise<CandidateReceiptLock>;
|
||||
@@ -0,0 +1,476 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { isRecord } from "./lib/record-shared.mjs";
|
||||
import {
|
||||
canonicalCandidateReceiptLockJson,
|
||||
CANDIDATE_RECEIPT_WORKFLOW_PATH,
|
||||
parseCandidateReceiptLockJson,
|
||||
validateCandidateReceiptLock,
|
||||
type CandidateReceiptLock,
|
||||
} from "./release-candidate-receipt-contract.mjs";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
type RunGh = (args: string[]) => string;
|
||||
|
||||
export type CandidateReceiptLocatorOptions = {
|
||||
dispatchId: string;
|
||||
releasePlanDigest: string;
|
||||
repo: string;
|
||||
runAttempt?: string;
|
||||
runGh?: RunGh;
|
||||
runId?: string;
|
||||
sleep?: (milliseconds: number) => Promise<void>;
|
||||
timeoutMs?: number;
|
||||
workflowId: string;
|
||||
workflowSha: string;
|
||||
};
|
||||
|
||||
const REPOSITORY = "openclaw/openclaw";
|
||||
const RUN_NAME_PREFIX = "Release Candidate Artifacts";
|
||||
const RECEIPT_FILE_NAME = "candidate-receipt-lock.json";
|
||||
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 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;
|
||||
|
||||
function fail(message: string): never {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
function parseJson(raw: string, label: string): unknown {
|
||||
try {
|
||||
return JSON.parse(raw) as unknown;
|
||||
} catch (error) {
|
||||
throw new Error(`${label} returned invalid JSON`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
function record(value: unknown, label: string): JsonRecord {
|
||||
if (!isRecord(value)) {
|
||||
fail(`${label} must be an object`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requiredString(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
fail(`${label} is missing`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function positiveDecimal(value: unknown, label: string): string {
|
||||
const normalized =
|
||||
typeof value === "number" && Number.isSafeInteger(value) ? String(value) : value;
|
||||
if (typeof normalized !== "string" || !POSITIVE_DECIMAL_PATTERN.test(normalized)) {
|
||||
fail(`${label} must be a positive decimal integer`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function sha(value: unknown, label: string): string {
|
||||
const normalized = requiredString(value, label);
|
||||
if (!SHA_PATTERN.test(normalized)) {
|
||||
fail(`${label} must be a lowercase full commit SHA`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function digest(value: unknown, label: string): string {
|
||||
const normalized = requiredString(value, label);
|
||||
if (!DIGEST_PATTERN.test(normalized)) {
|
||||
fail(`${label} must be a prefixed lowercase SHA-256 digest`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function requireOptions(options: CandidateReceiptLocatorOptions) {
|
||||
if (options.repo !== REPOSITORY) {
|
||||
fail(`candidate receipt repository must be ${REPOSITORY}`);
|
||||
}
|
||||
if (!DISPATCH_ID_PATTERN.test(options.dispatchId)) {
|
||||
fail("candidate receipt dispatch id is invalid");
|
||||
}
|
||||
const runPairCount =
|
||||
Number(options.runId !== undefined) + Number(options.runAttempt !== undefined);
|
||||
if (runPairCount === 1) {
|
||||
fail("candidate receipt exact run id and attempt must be supplied together");
|
||||
}
|
||||
return {
|
||||
...options,
|
||||
releasePlanDigest: digest(options.releasePlanDigest, "release plan digest"),
|
||||
runAttempt:
|
||||
options.runAttempt === undefined
|
||||
? undefined
|
||||
: positiveDecimal(options.runAttempt, "candidate receipt run attempt"),
|
||||
runId:
|
||||
options.runId === undefined
|
||||
? undefined
|
||||
: positiveDecimal(options.runId, "candidate receipt run id"),
|
||||
timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
workflowId: positiveDecimal(options.workflowId, "candidate receipt workflow id"),
|
||||
workflowSha: sha(options.workflowSha, "candidate receipt workflow SHA"),
|
||||
};
|
||||
}
|
||||
|
||||
function artifactRecords(value: unknown): JsonRecord[] {
|
||||
const root = record(value, "candidate receipt artifact response");
|
||||
if (!Array.isArray(root.artifacts)) {
|
||||
fail("candidate receipt artifact response must contain artifacts");
|
||||
}
|
||||
return root.artifacts.map((entry, index) =>
|
||||
record(entry, `candidate receipt artifact response artifacts[${index}]`),
|
||||
);
|
||||
}
|
||||
|
||||
function validateArtifactMetadata(
|
||||
artifact: JsonRecord,
|
||||
expected: {
|
||||
digest: string;
|
||||
id: string;
|
||||
name: string;
|
||||
runId: string;
|
||||
},
|
||||
) {
|
||||
const workflowRun = record(artifact.workflow_run, `${expected.name} workflow_run`);
|
||||
if (
|
||||
positiveDecimal(artifact.id, `${expected.name} artifact id`) !== expected.id ||
|
||||
requiredString(artifact.name, `${expected.name} artifact name`) !== expected.name ||
|
||||
digest(artifact.digest, `${expected.name} artifact digest`) !== expected.digest ||
|
||||
artifact.expired !== false ||
|
||||
positiveDecimal(workflowRun.id, `${expected.name} workflow run id`) !== expected.runId
|
||||
) {
|
||||
fail(`${expected.name} metadata does not match the candidate receipt`);
|
||||
}
|
||||
}
|
||||
|
||||
export function validateCandidateReceiptProvenance(params: {
|
||||
artifacts: unknown;
|
||||
expectedDispatchId: string;
|
||||
expectedReleasePlanDigest: string;
|
||||
expectedRunAttempt: string;
|
||||
expectedRunId: string;
|
||||
expectedWorkflowId: string;
|
||||
expectedWorkflowSha: string;
|
||||
lock: CandidateReceiptLock;
|
||||
run: unknown;
|
||||
workflow: unknown;
|
||||
}) {
|
||||
const lock = validateCandidateReceiptLock(params.lock);
|
||||
const run = record(params.run, "candidate receipt run");
|
||||
const workflow = record(params.workflow, "candidate receipt workflow");
|
||||
const expectedTitle = `${RUN_NAME_PREFIX} ${params.expectedDispatchId}`;
|
||||
if (
|
||||
positiveDecimal(run.id, "candidate receipt run id") !== params.expectedRunId ||
|
||||
positiveDecimal(run.run_attempt, "candidate receipt run attempt") !==
|
||||
params.expectedRunAttempt ||
|
||||
positiveDecimal(run.workflow_id, "candidate receipt run workflow id") !==
|
||||
params.expectedWorkflowId ||
|
||||
sha(run.head_sha, "candidate receipt run head SHA") !== params.expectedWorkflowSha ||
|
||||
requiredString(run.path, "candidate receipt run path") !== CANDIDATE_RECEIPT_WORKFLOW_PATH ||
|
||||
requiredString(run.display_title, "candidate receipt run title") !== expectedTitle ||
|
||||
run.event !== "workflow_dispatch" ||
|
||||
run.status !== "completed" ||
|
||||
run.conclusion !== "success"
|
||||
) {
|
||||
fail("candidate receipt run does not match the exact successful producer attempt");
|
||||
}
|
||||
if (
|
||||
positiveDecimal(workflow.id, "candidate receipt workflow id") !== params.expectedWorkflowId ||
|
||||
requiredString(workflow.path, "candidate receipt workflow path") !==
|
||||
CANDIDATE_RECEIPT_WORKFLOW_PATH ||
|
||||
workflow.state !== "active"
|
||||
) {
|
||||
fail("candidate receipt workflow identity does not match the canonical active workflow");
|
||||
}
|
||||
|
||||
const receipt = lock.receipt;
|
||||
if (
|
||||
receipt.release_plan_digest !== params.expectedReleasePlanDigest ||
|
||||
receipt.producer.repository !== REPOSITORY ||
|
||||
receipt.producer.workflow_path !== CANDIDATE_RECEIPT_WORKFLOW_PATH ||
|
||||
receipt.producer.workflow_id !== params.expectedWorkflowId ||
|
||||
receipt.producer.workflow_sha !== params.expectedWorkflowSha ||
|
||||
receipt.producer.run_id !== params.expectedRunId ||
|
||||
receipt.producer.run_attempt !== params.expectedRunAttempt
|
||||
) {
|
||||
fail("candidate receipt payload does not match the requested producer provenance");
|
||||
}
|
||||
|
||||
const artifacts = artifactRecords(params.artifacts);
|
||||
for (const artifact of Object.values(receipt.artifacts)) {
|
||||
const metadata = artifacts.find(
|
||||
(entry) =>
|
||||
positiveDecimal(entry.id, "candidate receipt artifact id") === artifact.artifact_id,
|
||||
);
|
||||
if (!metadata) {
|
||||
fail(`candidate receipt artifact ${artifact.artifact_id} is missing from the producer run`);
|
||||
}
|
||||
validateArtifactMetadata(metadata, {
|
||||
digest: artifact.artifact_digest,
|
||||
id: artifact.artifact_id,
|
||||
name: artifact.artifact_name,
|
||||
runId: params.expectedRunId,
|
||||
});
|
||||
}
|
||||
return lock;
|
||||
}
|
||||
|
||||
export function runCandidateReceiptGh(
|
||||
args: string[],
|
||||
params: { execFileSyncImpl?: typeof runGhCommand } = {},
|
||||
): string {
|
||||
const execFileSyncImpl = params.execFileSyncImpl ?? runGhCommand;
|
||||
return execFileSyncImpl("gh", args, {
|
||||
encoding: "utf8",
|
||||
killSignal: "SIGKILL",
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
timeout: GH_COMMAND_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
function runGhCommand(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: {
|
||||
encoding: "utf8";
|
||||
killSignal: "SIGKILL";
|
||||
maxBuffer: number;
|
||||
timeout: number;
|
||||
},
|
||||
) {
|
||||
return execFileSync(command, args, options);
|
||||
}
|
||||
|
||||
async function pollUntil<T>(
|
||||
deadline: number,
|
||||
poll: () => T | undefined,
|
||||
sleep: (milliseconds: number) => Promise<void>,
|
||||
timeoutMessage: string,
|
||||
): Promise<T> {
|
||||
while (Date.now() <= deadline) {
|
||||
const result = poll();
|
||||
if (result !== undefined) {
|
||||
return result;
|
||||
}
|
||||
await sleep(Math.min(POLL_INTERVAL_MS, Math.max(1, deadline - Date.now())));
|
||||
}
|
||||
fail(timeoutMessage);
|
||||
}
|
||||
|
||||
function discoverRun(
|
||||
api: (endpoint: string) => 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",
|
||||
);
|
||||
if (!Array.isArray(response.workflow_runs)) {
|
||||
fail("candidate receipt workflow runs response must contain workflow_runs");
|
||||
}
|
||||
const expectedTitle = `${RUN_NAME_PREFIX} ${params.dispatchId}`;
|
||||
const matches = response.workflow_runs
|
||||
.map((entry, index) => record(entry, `candidate receipt workflow_runs[${index}]`))
|
||||
.filter(
|
||||
(run) =>
|
||||
run.display_title === expectedTitle &&
|
||||
run.head_sha === params.workflowSha &&
|
||||
run.workflow_id !== undefined &&
|
||||
positiveDecimal(run.workflow_id, "candidate receipt discovered workflow id") ===
|
||||
params.workflowId,
|
||||
);
|
||||
if (matches.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (matches.length !== 1) {
|
||||
fail("candidate receipt dispatch id matched multiple workflow runs");
|
||||
}
|
||||
return {
|
||||
runAttempt: positiveDecimal(matches[0]!.run_attempt, "candidate receipt run attempt"),
|
||||
runId: positiveDecimal(matches[0]!.id, "candidate receipt run id"),
|
||||
};
|
||||
}
|
||||
|
||||
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> {
|
||||
const options = requireOptions(rawOptions);
|
||||
if (!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs <= 0) {
|
||||
fail("candidate receipt timeout must be a positive integer");
|
||||
}
|
||||
const runGh = options.runGh ?? runCandidateReceiptGh;
|
||||
const sleep =
|
||||
options.sleep ??
|
||||
((milliseconds: number) =>
|
||||
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 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"
|
||||
) {
|
||||
fail("candidate receipt workflow identity does not match the canonical active workflow");
|
||||
}
|
||||
|
||||
const exactRun =
|
||||
options.runId && options.runAttempt
|
||||
? { runAttempt: options.runAttempt, runId: options.runId }
|
||||
: await pollUntil(
|
||||
deadline,
|
||||
() =>
|
||||
discoverRun(api, {
|
||||
dispatchId: options.dispatchId,
|
||||
workflowId: options.workflowId,
|
||||
workflowSha: options.workflowSha,
|
||||
}),
|
||||
sleep,
|
||||
"timed out locating the candidate receipt producer run",
|
||||
);
|
||||
|
||||
const run = await pollUntil(
|
||||
deadline,
|
||||
() => {
|
||||
const current = record(
|
||||
api(`actions/runs/${exactRun.runId}/attempts/${exactRun.runAttempt}`),
|
||||
"candidate receipt run attempt",
|
||||
);
|
||||
if (current.status !== "completed") {
|
||||
return undefined;
|
||||
}
|
||||
if (current.conclusion !== "success") {
|
||||
fail(`candidate receipt producer concluded ${String(current.conclusion)}`);
|
||||
}
|
||||
return current;
|
||||
},
|
||||
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`);
|
||||
const receiptArtifactName = `release-candidate-receipt-${exactRun.runId}-${exactRun.runAttempt}`;
|
||||
const receiptArtifact = artifactRecords(artifacts).find(
|
||||
(entry) => entry.name === receiptArtifactName,
|
||||
);
|
||||
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"),
|
||||
name: receiptArtifactName,
|
||||
runId: exactRun.runId,
|
||||
});
|
||||
|
||||
const downloadDir = mkdtempSync(join(tmpdir(), "openclaw-candidate-receipt-"));
|
||||
try {
|
||||
runGh([
|
||||
"run",
|
||||
"download",
|
||||
exactRun.runId,
|
||||
"--repo",
|
||||
options.repo,
|
||||
"--name",
|
||||
receiptArtifactName,
|
||||
"--dir",
|
||||
downloadDir,
|
||||
]);
|
||||
const parsedLock = parseCandidateReceiptLockJson(
|
||||
readFileSync(join(downloadDir, RECEIPT_FILE_NAME), "utf8"),
|
||||
);
|
||||
const validatedLock = validateCandidateReceiptProvenance({
|
||||
artifacts,
|
||||
expectedDispatchId: options.dispatchId,
|
||||
expectedReleasePlanDigest: options.releasePlanDigest,
|
||||
expectedRunAttempt: exactRun.runAttempt,
|
||||
expectedRunId: exactRun.runId,
|
||||
expectedWorkflowId: options.workflowId,
|
||||
expectedWorkflowSha: options.workflowSha,
|
||||
lock: parsedLock,
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CandidateReceiptLocatorOptions {
|
||||
const options: Record<string, string> = {};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (
|
||||
arg === "--dispatch-id" ||
|
||||
arg === "--release-plan-digest" ||
|
||||
arg === "--repo" ||
|
||||
arg === "--run-attempt" ||
|
||||
arg === "--run-id" ||
|
||||
arg === "--timeout-seconds" ||
|
||||
arg === "--workflow-id" ||
|
||||
arg === "--workflow-sha"
|
||||
) {
|
||||
options[arg] = argv[(index += 1)] ?? "";
|
||||
} else {
|
||||
fail(`unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
const timeoutSeconds = options["--timeout-seconds"];
|
||||
return {
|
||||
dispatchId: options["--dispatch-id"] ?? "",
|
||||
releasePlanDigest: options["--release-plan-digest"] ?? "",
|
||||
repo: options["--repo"] ?? "",
|
||||
...(options["--run-attempt"] ? { runAttempt: options["--run-attempt"] } : {}),
|
||||
...(options["--run-id"] ? { runId: options["--run-id"] } : {}),
|
||||
...(timeoutSeconds ? { timeoutMs: Number.parseInt(timeoutSeconds, 10) * 1000 } : {}),
|
||||
workflowId: options["--workflow-id"] ?? "",
|
||||
workflowSha: options["--workflow-sha"] ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
async function main(argv: string[] = process.argv.slice(2)): Promise<void> {
|
||||
const lock = await locateCandidateReceipt(parseArgs(argv));
|
||||
process.stdout.write(canonicalCandidateReceiptLockJson(lock));
|
||||
}
|
||||
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
void main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
console.error("[release-candidate-receipt-locator] FAILED (exit 1)");
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -2273,6 +2273,7 @@ const SEMANTIC_TOOLING_TARGET_PATTERNS: Array<[RegExp, string[]]> = [
|
||||
/^\.github\/workflows\/full-release-validation\.yml$/u,
|
||||
["src/dockerfile.test.ts", packageAcceptance, pluginPrerelease],
|
||||
],
|
||||
[/^\.github\/workflows\/release-candidate-artifacts\.yml$/u, ["release-candidate-receipt"]],
|
||||
[
|
||||
/^\.github\/workflows\/openclaw-release-checks\.yml$/u,
|
||||
[packageAcceptance, crossOsReleaseChecks, pluginPrerelease, installDocker],
|
||||
@@ -2424,6 +2425,10 @@ const SEMANTIC_TOOLING_TARGET_PATTERNS: Array<[RegExp, string[]]> = [
|
||||
[/^apps\/ios\/fastlane\/Fastfile$/u, ["ios-release-fastlane-gates"]],
|
||||
[/^scripts\/ios-release-cut\.(?:sh|ts)$/u, ["ios-release-plan"]],
|
||||
[/^scripts\/ios-release-prepare\.sh$/u, ["ios-release-prepare", "ios-release-wrapper-args"]],
|
||||
[
|
||||
/^scripts\/release-candidate-receipt-(?:contract|locator)\.(?:d\.mts|mjs|mts)$/u,
|
||||
["release-candidate-receipt"],
|
||||
],
|
||||
[
|
||||
/^scripts\/lib\/bundled-runtime-sidecar-paths\.json$/u,
|
||||
[
|
||||
|
||||
@@ -0,0 +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"}
|
||||
@@ -0,0 +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"}
|
||||
@@ -0,0 +1,475 @@
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { parse } from "yaml";
|
||||
import {
|
||||
canonicalCandidateReceiptJson,
|
||||
canonicalCandidateReceiptLockJson,
|
||||
CANDIDATE_RECEIPT_CANONICALIZATION,
|
||||
createCandidateReceiptLock,
|
||||
parseCandidateReceiptLockJson,
|
||||
validateCandidateReceipt,
|
||||
} from "../../scripts/release-candidate-receipt-contract.mjs";
|
||||
import {
|
||||
locateCandidateReceipt,
|
||||
runCandidateReceiptGh,
|
||||
validateCandidateReceiptProvenance,
|
||||
} from "../../scripts/release-candidate-receipt-locator.mts";
|
||||
|
||||
const fixtureDir = resolve("test/fixtures");
|
||||
const sourceText = readFileSync(resolve(fixtureDir, "candidate-receipt-v1.source.json"), "utf8");
|
||||
const lockText = readFileSync(
|
||||
resolve(fixtureDir, "candidate-receipt-lock-v1.compatibility.json"),
|
||||
"utf8",
|
||||
);
|
||||
const sourceFixture = JSON.parse(sourceText) as Record<string, unknown>;
|
||||
const lockFixture = parseCandidateReceiptLockJson(lockText);
|
||||
const runId = lockFixture.receipt.producer.run_id;
|
||||
const runAttempt = lockFixture.receipt.producer.run_attempt;
|
||||
const workflowId = lockFixture.receipt.producer.workflow_id;
|
||||
const workflowSha = lockFixture.receipt.producer.workflow_sha;
|
||||
const dispatchId = "candidate-2026.8.1-beta.3";
|
||||
const runTitle = `Release Candidate Artifacts ${dispatchId}`;
|
||||
|
||||
function runFixture(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
conclusion: "success",
|
||||
display_title: runTitle,
|
||||
event: "workflow_dispatch",
|
||||
head_sha: workflowSha,
|
||||
id: Number(runId),
|
||||
path: ".github/workflows/release-candidate-artifacts.yml",
|
||||
run_attempt: Number(runAttempt),
|
||||
status: "completed",
|
||||
workflow_id: Number(workflowId),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function workflowFixture(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: Number(workflowId),
|
||||
path: ".github/workflows/release-candidate-artifacts.yml",
|
||||
state: "active",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function artifactFixture(
|
||||
artifact: (typeof lockFixture.receipt.artifacts)[keyof typeof lockFixture.receipt.artifacts],
|
||||
) {
|
||||
return {
|
||||
digest: artifact.artifact_digest,
|
||||
expired: false,
|
||||
id: Number(artifact.artifact_id),
|
||||
name: artifact.artifact_name,
|
||||
workflow_run: { id: Number(runId) },
|
||||
};
|
||||
}
|
||||
|
||||
function artifactsFixture() {
|
||||
return {
|
||||
artifacts: Object.values(lockFixture.receipt.artifacts).map(artifactFixture),
|
||||
total_count: 4,
|
||||
};
|
||||
}
|
||||
|
||||
describe("candidate receipt contract", () => {
|
||||
it("pins canonical source and lock bytes as the cross-workflow golden fixture", () => {
|
||||
expect(CANDIDATE_RECEIPT_CANONICALIZATION).toBe(
|
||||
"ascii-sorted-compact-json-trailing-newline-v1",
|
||||
);
|
||||
expect(sourceText).toBe(canonicalCandidateReceiptJson(sourceFixture));
|
||||
expect(lockText).toBe(canonicalCandidateReceiptLockJson(lockFixture));
|
||||
expect(createCandidateReceiptLock(sourceFixture)).toEqual(lockFixture);
|
||||
expect(lockText.endsWith("\n")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects duplicate, reordered, pretty, CRLF, and non-ASCII lock bytes", () => {
|
||||
const duplicate = lockText.replace('{"digest":', `{"digest":"${lockFixture.digest}","digest":`);
|
||||
expect(() => parseCandidateReceiptLockJson(duplicate)).toThrow("duplicate key");
|
||||
expect(() =>
|
||||
parseCandidateReceiptLockJson(
|
||||
`${JSON.stringify({
|
||||
schema: lockFixture.schema,
|
||||
receipt: lockFixture.receipt,
|
||||
digest: lockFixture.digest,
|
||||
})}\n`,
|
||||
),
|
||||
).toThrow("canonical bytes");
|
||||
expect(() =>
|
||||
parseCandidateReceiptLockJson(`${JSON.stringify(lockFixture, null, 2)}\n`),
|
||||
).toThrow("compact printable ASCII");
|
||||
expect(() => parseCandidateReceiptLockJson(lockText.replace(/\n$/u, "\r\n"))).toThrow(
|
||||
"exactly one trailing LF",
|
||||
);
|
||||
expect(() =>
|
||||
parseCandidateReceiptLockJson(lockText.replace("openclaw/openclaw", "opénclaw")),
|
||||
).toThrow("printable ASCII");
|
||||
});
|
||||
|
||||
it("rejects digest drift, duplicate artifact IDs, and names from another attempt", () => {
|
||||
expect(() =>
|
||||
parseCandidateReceiptLockJson(
|
||||
lockText.replace(lockFixture.digest, `sha256:${"9".repeat(64)}`),
|
||||
),
|
||||
).toThrow("does not match");
|
||||
expect(() =>
|
||||
validateCandidateReceipt({
|
||||
...sourceFixture,
|
||||
artifacts: {
|
||||
...(sourceFixture.artifacts as Record<string, unknown>),
|
||||
root_image: {
|
||||
...(sourceFixture.artifacts as Record<string, Record<string, unknown>>).root_image,
|
||||
artifact_id: "103",
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toThrow("artifact IDs must be unique");
|
||||
expect(() =>
|
||||
validateCandidateReceipt({
|
||||
...sourceFixture,
|
||||
artifacts: {
|
||||
...(sourceFixture.artifacts as Record<string, unknown>),
|
||||
package: {
|
||||
...(sourceFixture.artifacts as Record<string, Record<string, unknown>>).package,
|
||||
artifact_name: "docker-e2e-package-12345-1",
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toThrow("name must bind the producer run attempt");
|
||||
expect(() =>
|
||||
validateCandidateReceipt({
|
||||
...sourceFixture,
|
||||
artifacts: {
|
||||
...(sourceFixture.artifacts as Record<string, unknown>),
|
||||
package: {
|
||||
...(sourceFixture.artifacts as Record<string, Record<string, unknown>>).package,
|
||||
artifact_name: "release-candidate-root-image-12345-2",
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toThrow("name does not match its artifact kind");
|
||||
});
|
||||
|
||||
it("references ReleasePlan only by digest", () => {
|
||||
const receipt = validateCandidateReceipt(sourceFixture);
|
||||
expect(receipt).not.toHaveProperty("candidate_sha");
|
||||
expect(receipt).not.toHaveProperty("version");
|
||||
expect(receipt).not.toHaveProperty("validation");
|
||||
expect(() =>
|
||||
validateCandidateReceipt({ ...sourceFixture, candidate_sha: "a".repeat(40) }),
|
||||
).toThrow("candidate receipt keys must be exactly");
|
||||
});
|
||||
});
|
||||
|
||||
describe("candidate receipt locator", () => {
|
||||
it("validates the exact workflow, run attempt, ReleasePlan, and artifact service digests", () => {
|
||||
expect(
|
||||
validateCandidateReceiptProvenance({
|
||||
artifacts: artifactsFixture(),
|
||||
expectedDispatchId: dispatchId,
|
||||
expectedReleasePlanDigest: lockFixture.receipt.release_plan_digest,
|
||||
expectedRunAttempt: runAttempt,
|
||||
expectedRunId: runId,
|
||||
expectedWorkflowId: workflowId,
|
||||
expectedWorkflowSha: workflowSha,
|
||||
lock: lockFixture,
|
||||
run: runFixture(),
|
||||
workflow: workflowFixture(),
|
||||
}),
|
||||
).toEqual(lockFixture);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["workflow id", { run: runFixture({ workflow_id: 999 }) }],
|
||||
["workflow path", { run: runFixture({ path: ".github/workflows/ci.yml" }) }],
|
||||
["workflow SHA", { run: runFixture({ head_sha: "c".repeat(40) }) }],
|
||||
["run attempt", { run: runFixture({ run_attempt: 1 }) }],
|
||||
["dispatch title", { run: runFixture({ display_title: "other" }) }],
|
||||
["event", { run: runFixture({ event: "push" }) }],
|
||||
["conclusion", { run: runFixture({ conclusion: "failure" }) }],
|
||||
])("rejects mismatched %s provenance", (_label, overrides) => {
|
||||
expect(() =>
|
||||
validateCandidateReceiptProvenance({
|
||||
artifacts: artifactsFixture(),
|
||||
expectedDispatchId: dispatchId,
|
||||
expectedReleasePlanDigest: lockFixture.receipt.release_plan_digest,
|
||||
expectedRunAttempt: runAttempt,
|
||||
expectedRunId: runId,
|
||||
expectedWorkflowId: workflowId,
|
||||
expectedWorkflowSha: workflowSha,
|
||||
lock: lockFixture,
|
||||
run: overrides.run,
|
||||
workflow: workflowFixture(),
|
||||
}),
|
||||
).toThrow("exact successful producer attempt");
|
||||
});
|
||||
|
||||
it("rejects missing, expired, moved, or digest-mismatched artifacts", () => {
|
||||
const artifacts = artifactsFixture();
|
||||
const firstArtifact = artifacts.artifacts[0]!;
|
||||
artifacts.artifacts[0] = { ...firstArtifact, digest: `sha256:${"8".repeat(64)}` };
|
||||
expect(() =>
|
||||
validateCandidateReceiptProvenance({
|
||||
artifacts,
|
||||
expectedDispatchId: dispatchId,
|
||||
expectedReleasePlanDigest: lockFixture.receipt.release_plan_digest,
|
||||
expectedRunAttempt: runAttempt,
|
||||
expectedRunId: runId,
|
||||
expectedWorkflowId: workflowId,
|
||||
expectedWorkflowSha: workflowSha,
|
||||
lock: lockFixture,
|
||||
run: runFixture(),
|
||||
workflow: workflowFixture(),
|
||||
}),
|
||||
).toThrow("metadata does not match");
|
||||
});
|
||||
|
||||
it("bounds each gh lookup", () => {
|
||||
const execFileSyncImpl = vi.fn(() => "result");
|
||||
expect(
|
||||
runCandidateReceiptGh(["api", "repos/openclaw/openclaw/actions/runs/12345"], {
|
||||
execFileSyncImpl,
|
||||
}),
|
||||
).toBe("result");
|
||||
expect(execFileSyncImpl).toHaveBeenCalledWith(
|
||||
"gh",
|
||||
["api", "repos/openclaw/openclaw/actions/runs/12345"],
|
||||
{
|
||||
encoding: "utf8",
|
||||
killSignal: "SIGKILL",
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
timeout: 60_000,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("discovers one nonce-bound run, polls its exact attempt, and reads its receipt artifact", 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 responses = new Map<string, unknown>([
|
||||
[
|
||||
`api repos/openclaw/openclaw/actions/workflows/${workflowId} --method GET`,
|
||||
workflowFixture(),
|
||||
],
|
||||
[
|
||||
`api repos/openclaw/openclaw/actions/workflows/${workflowId}/runs?event=workflow_dispatch&per_page=100 --method GET`,
|
||||
{ workflow_runs: [runFixture()] },
|
||||
],
|
||||
[
|
||||
`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,
|
||||
],
|
||||
]);
|
||||
const runGh = vi.fn((args: string[]) => {
|
||||
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 "";
|
||||
}
|
||||
const response = responses.get(args.join(" "));
|
||||
if (!response) {
|
||||
throw new Error(`unexpected gh invocation: ${args.join(" ")}`);
|
||||
}
|
||||
return JSON.stringify(response);
|
||||
});
|
||||
|
||||
await expect(
|
||||
locateCandidateReceipt({
|
||||
dispatchId,
|
||||
releasePlanDigest: lockFixture.receipt.release_plan_digest,
|
||||
repo: "openclaw/openclaw",
|
||||
runGh,
|
||||
sleep: async () => {},
|
||||
timeoutMs: 1000,
|
||||
workflowId,
|
||||
workflowSha,
|
||||
}),
|
||||
).resolves.toEqual(lockFixture);
|
||||
expect(runGh).toHaveBeenCalledWith([
|
||||
"run",
|
||||
"download",
|
||||
runId,
|
||||
"--repo",
|
||||
"openclaw/openclaw",
|
||||
"--name",
|
||||
receiptArtifactName,
|
||||
"--dir",
|
||||
expect.any(String),
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects a superseded exact attempt", async () => {
|
||||
const runGh = vi.fn((args: string[]) => {
|
||||
const key = args.join(" ");
|
||||
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 }));
|
||||
}
|
||||
throw new Error(`unexpected gh invocation: ${key}`);
|
||||
});
|
||||
await expect(
|
||||
locateCandidateReceipt({
|
||||
dispatchId,
|
||||
releasePlanDigest: lockFixture.receipt.release_plan_digest,
|
||||
repo: "openclaw/openclaw",
|
||||
runAttempt,
|
||||
runGh,
|
||||
runId,
|
||||
sleep: async () => {},
|
||||
timeoutMs: 1000,
|
||||
workflowId,
|
||||
workflowSha,
|
||||
}),
|
||||
).rejects.toThrow("superseded by a rerun");
|
||||
});
|
||||
});
|
||||
|
||||
type WorkflowStep = {
|
||||
env?: Record<string, string>;
|
||||
id?: string;
|
||||
name?: string;
|
||||
run?: string;
|
||||
uses?: string;
|
||||
with?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type WorkflowJob = {
|
||||
"runs-on"?: string;
|
||||
needs?: string | string[];
|
||||
outputs?: Record<string, string>;
|
||||
permissions?: Record<string, string>;
|
||||
steps?: WorkflowStep[];
|
||||
uses?: string;
|
||||
with?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type Workflow = {
|
||||
jobs: Record<string, WorkflowJob>;
|
||||
on?: {
|
||||
workflow_dispatch?: { inputs?: Record<string, Record<string, unknown>> };
|
||||
workflow_call?: unknown;
|
||||
};
|
||||
permissions?: Record<string, string>;
|
||||
"run-name"?: string;
|
||||
};
|
||||
|
||||
function workflowJob(workflow: Workflow, name: string): WorkflowJob {
|
||||
const found = workflow.jobs[name];
|
||||
expect(found, name).toBeDefined();
|
||||
return found!;
|
||||
}
|
||||
|
||||
function workflowStep(job: WorkflowJob, name: string): WorkflowStep {
|
||||
const found = job.steps?.find((step) => step.name === name);
|
||||
expect(found, name).toBeDefined();
|
||||
return found!;
|
||||
}
|
||||
|
||||
describe("release candidate artifact producer workflow", () => {
|
||||
const path = ".github/workflows/release-candidate-artifacts.yml";
|
||||
const text = readFileSync(path, "utf8");
|
||||
const workflow = parse(text) as Workflow;
|
||||
|
||||
it("is one read-only standalone producer keyed by a caller nonce", () => {
|
||||
expect(workflow["run-name"]).toBe("Release Candidate Artifacts ${{ inputs.dispatch_id }}");
|
||||
expect(workflow.on?.workflow_call).toBeUndefined();
|
||||
expect(workflow.on?.workflow_dispatch?.inputs).toMatchObject({
|
||||
dispatch_id: { required: true, type: "string" },
|
||||
release_plan_lock_base64: { required: true, type: "string" },
|
||||
});
|
||||
expect(workflow.permissions).toEqual({
|
||||
actions: "read",
|
||||
contents: "read",
|
||||
packages: "read",
|
||||
"pull-requests": "read",
|
||||
});
|
||||
expect(text).not.toContain("contents: write");
|
||||
expect(text).not.toContain("packages: write");
|
||||
expect(text).not.toContain("--push");
|
||||
});
|
||||
|
||||
it("validates canonical ReleasePlan bytes and derives candidate inputs without copying plan fields", () => {
|
||||
const validate = workflowJob(workflow, "validate_release_plan");
|
||||
const step = workflowStep(validate, "Validate ReleasePlanLock");
|
||||
expect(step.run).toContain("parseReleasePlanLockJson");
|
||||
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("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}");
|
||||
});
|
||||
|
||||
it("runs the existing candidate producer and root-image producer in parallel", () => {
|
||||
const candidate = workflowJob(workflow, "candidate_artifacts");
|
||||
const root = workflowJob(workflow, "root_image");
|
||||
expect(candidate.needs).toBe("validate_release_plan");
|
||||
expect(root.needs).toBe("validate_release_plan");
|
||||
expect(candidate.uses).toBe("./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml");
|
||||
expect(candidate.with).toMatchObject({
|
||||
prepare_only: true,
|
||||
include_repo_e2e: false,
|
||||
include_release_path_suites: false,
|
||||
include_openwebui: false,
|
||||
include_live_suites: false,
|
||||
enable_prepublish_plugin_registry: true,
|
||||
shared_image_artifact_namespace: "release-candidate",
|
||||
shared_image_policy: "no-push-artifact",
|
||||
});
|
||||
expect(root["runs-on"]).toBe("blacksmith-32vcpu-ubuntu-2404");
|
||||
expect(workflowStep(root, "Pack root Dockerfile image artifact").run).toContain(
|
||||
"scripts/docker/shared-image-artifact.sh",
|
||||
);
|
||||
expect(workflowStep(root, "Upload root Dockerfile image artifact").with).toMatchObject({
|
||||
"compression-level": 0,
|
||||
"if-no-files-found": "error",
|
||||
"retention-days": 7,
|
||||
});
|
||||
});
|
||||
|
||||
it("emits one receipt only after all four immutable artifacts exist", () => {
|
||||
const receipt = workflowJob(workflow, "candidate_receipt");
|
||||
expect(receipt.needs).toEqual(["validate_release_plan", "candidate_artifacts", "root_image"]);
|
||||
const provenance = workflowStep(receipt, "Verify exact producer workflow attempt");
|
||||
expect(provenance.run).toContain(
|
||||
"actions/runs/${GITHUB_RUN_ID}/attempts/${GITHUB_RUN_ATTEMPT}",
|
||||
);
|
||||
expect(provenance.run).toContain(".display_title == $title");
|
||||
expect(provenance.run).toContain("actions/workflows/${workflow_id}");
|
||||
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('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 }}",
|
||||
path: ".artifacts/candidate-receipt/candidate-receipt-lock.json",
|
||||
"if-no-files-found": "error",
|
||||
"retention-days": 7,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user