From bcba4fc2d124ad69ea7794cb934fd96c45a7cd0c Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 21 Aug 2026 03:45:12 -0700 Subject: [PATCH] perf(release): prebuild immutable candidate artifacts --- .../workflows/release-candidate-artifacts.yml | 354 +++++++++++++ .../release-candidate-receipt-contract.d.mts | 44 ++ .../release-candidate-receipt-contract.mjs | 257 ++++++++++ .../release-candidate-receipt-locator.d.mts | 47 ++ scripts/release-candidate-receipt-locator.mts | 476 ++++++++++++++++++ scripts/test-projects.test-support.mts | 5 + ...ndidate-receipt-lock-v1.compatibility.json | 1 + .../fixtures/candidate-receipt-v1.source.json | 1 + .../scripts/release-candidate-receipt.test.ts | 475 +++++++++++++++++ 9 files changed, 1660 insertions(+) create mode 100644 .github/workflows/release-candidate-artifacts.yml create mode 100644 scripts/release-candidate-receipt-contract.d.mts create mode 100644 scripts/release-candidate-receipt-contract.mjs create mode 100644 scripts/release-candidate-receipt-locator.d.mts create mode 100644 scripts/release-candidate-receipt-locator.mts create mode 100644 test/fixtures/candidate-receipt-lock-v1.compatibility.json create mode 100644 test/fixtures/candidate-receipt-v1.source.json create mode 100644 test/scripts/release-candidate-receipt.test.ts diff --git a/.github/workflows/release-candidate-artifacts.yml b/.github/workflows/release-candidate-artifacts.yml new file mode 100644 index 000000000000..778378269b0f --- /dev/null +++ b/.github/workflows/release-candidate-artifacts.yml @@ -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 diff --git a/scripts/release-candidate-receipt-contract.d.mts b/scripts/release-candidate-receipt-contract.d.mts new file mode 100644 index 000000000000..6155555b46d1 --- /dev/null +++ b/scripts/release-candidate-receipt-contract.d.mts @@ -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; diff --git a/scripts/release-candidate-receipt-contract.mjs b/scripts/release-candidate-receipt-contract.mjs new file mode 100644 index 000000000000..ae5ddefae326 --- /dev/null +++ b/scripts/release-candidate-receipt-contract.mjs @@ -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; +} diff --git a/scripts/release-candidate-receipt-locator.d.mts b/scripts/release-candidate-receipt-locator.d.mts new file mode 100644 index 000000000000..d952bf2d87da --- /dev/null +++ b/scripts/release-candidate-receipt-locator.d.mts @@ -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; + 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; diff --git a/scripts/release-candidate-receipt-locator.mts b/scripts/release-candidate-receipt-locator.mts new file mode 100644 index 000000000000..a71fd2ab3057 --- /dev/null +++ b/scripts/release-candidate-receipt-locator.mts @@ -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; +type RunGh = (args: string[]) => string; + +export type CandidateReceiptLocatorOptions = { + dispatchId: string; + releasePlanDigest: string; + repo: string; + runAttempt?: string; + runGh?: RunGh; + runId?: string; + sleep?: (milliseconds: number) => Promise; + 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( + deadline: number, + poll: () => T | undefined, + sleep: (milliseconds: number) => Promise, + timeoutMessage: string, +): Promise { + 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 { + 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 = {}; + 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 { + 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; + }); +} diff --git a/scripts/test-projects.test-support.mts b/scripts/test-projects.test-support.mts index 0e119b4d5d80..65853909bcd6 100644 --- a/scripts/test-projects.test-support.mts +++ b/scripts/test-projects.test-support.mts @@ -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, [ diff --git a/test/fixtures/candidate-receipt-lock-v1.compatibility.json b/test/fixtures/candidate-receipt-lock-v1.compatibility.json new file mode 100644 index 000000000000..da6c148d7dcd --- /dev/null +++ b/test/fixtures/candidate-receipt-lock-v1.compatibility.json @@ -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"} diff --git a/test/fixtures/candidate-receipt-v1.source.json b/test/fixtures/candidate-receipt-v1.source.json new file mode 100644 index 000000000000..7169039c65ac --- /dev/null +++ b/test/fixtures/candidate-receipt-v1.source.json @@ -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"} diff --git a/test/scripts/release-candidate-receipt.test.ts b/test/scripts/release-candidate-receipt.test.ts new file mode 100644 index 000000000000..3c3a3c14de2f --- /dev/null +++ b/test/scripts/release-candidate-receipt.test.ts @@ -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; +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 = {}) { + 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 = {}) { + 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), + root_image: { + ...(sourceFixture.artifacts as Record>).root_image, + artifact_id: "103", + }, + }, + }), + ).toThrow("artifact IDs must be unique"); + expect(() => + validateCandidateReceipt({ + ...sourceFixture, + artifacts: { + ...(sourceFixture.artifacts as Record), + package: { + ...(sourceFixture.artifacts as Record>).package, + artifact_name: "docker-e2e-package-12345-1", + }, + }, + }), + ).toThrow("name must bind the producer run attempt"); + expect(() => + validateCandidateReceipt({ + ...sourceFixture, + artifacts: { + ...(sourceFixture.artifacts as Record), + package: { + ...(sourceFixture.artifacts as Record>).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([ + [ + `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; + id?: string; + name?: string; + run?: string; + uses?: string; + with?: Record; +}; + +type WorkflowJob = { + "runs-on"?: string; + needs?: string | string[]; + outputs?: Record; + permissions?: Record; + steps?: WorkflowStep[]; + uses?: string; + with?: Record; +}; + +type Workflow = { + jobs: Record; + on?: { + workflow_dispatch?: { inputs?: Record> }; + workflow_call?: unknown; + }; + permissions?: Record; + "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, + }); + }); +});