diff --git a/.agents/skills/release-openclaw-ci/scripts/release-ci-summary.mjs b/.agents/skills/release-openclaw-ci/scripts/release-ci-summary.mjs index 79c2caebe1f0..c2895f23b7ea 100755 --- a/.agents/skills/release-openclaw-ci/scripts/release-ci-summary.mjs +++ b/.agents/skills/release-openclaw-ci/scripts/release-ci-summary.mjs @@ -4,21 +4,90 @@ * full release run. */ import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; import process from "node:process"; +import { fileURLToPath } from "node:url"; import { plainGhEnv, resolvePlainGhBin } from "../../../../scripts/lib/plain-gh.mjs"; -const runId = process.argv[2]; -const repo = process.env.OPENCLAW_RELEASE_REPO || "openclaw/openclaw"; +const DEFAULT_REPO = process.env.OPENCLAW_RELEASE_REPO || "openclaw/openclaw"; +const RELEASE_EVIDENCE_SCHEMA = "openclaw.release-validation-evidence/v3"; +const RELEASE_EVIDENCE_SCRIPT = ".agents/skills/release-openclaw-ci/scripts/release-ci-summary.mjs"; +const RELEASE_EVIDENCE_FILE = fileURLToPath(import.meta.url); +const RELEASE_EVIDENCE_REPO_ROOT = resolve(dirname(RELEASE_EVIDENCE_FILE), "../../../.."); +const MANIFEST_ARTIFACT_ENTRY = "full-release-validation-manifest.json"; +const MAX_MANIFEST_ARTIFACT_ZIP_BYTES = 256 * 1024; +const MAX_MANIFEST_JSON_BYTES = 128 * 1024; +const MAX_MANIFEST_ENTRY_LIST_BYTES = 8 * 1024; -if (!runId) { - console.error("usage: release-ci-summary.mjs "); - process.exit(2); -} +const CHILD_DISPATCHES = [ + { + manifestKey: "normalCi", + name: "CI", + parentJobName: "Run normal full CI", + suffix: "-ci", + trustedRef: "parent", + workflow: "ci.yml", + }, + { + manifestKey: "releaseChecks", + name: "OpenClaw Release Checks", + parentJobName: "Run release/live/Docker/QA validation", + suffix: "-release-checks", + trustedRef: "parent", + workflow: "openclaw-release-checks.yml", + }, + { + manifestKey: "pluginPrerelease", + name: "Plugin Prerelease", + parentJobName: "Run plugin prerelease validation", + suffix: "-plugin-prerelease", + trustedRef: "parent", + workflow: "plugin-prerelease.yml", + }, + { + manifestKey: "npmTelegram", + name: "NPM Telegram Beta E2E", + parentJobName: "Run package Telegram E2E", + suffix: "-npm-telegram", + trustedRef: "parent", + workflow: "npm-telegram-beta-e2e.yml", + }, + { + manifestKey: "productPerformance", + name: "OpenClaw Performance", + parentJobName: "Run product performance evidence", + suffix: "", + trustedRef: "parent", + workflow: "openclaw-performance.yml", + }, +]; + +const EVIDENCE_REUSE_POLICY = "exact-target-full-validation-v1"; + +const RERUN_GROUP_CHILD_KEYS = new Map([ + ["all", ["normalCi", "releaseChecks", "pluginPrerelease", "productPerformance"]], + ["ci", ["normalCi"]], + ["plugin-prerelease", ["pluginPrerelease"]], + ["release-checks", ["releaseChecks"]], + ["install-smoke", ["releaseChecks"]], + ["cross-os", ["releaseChecks"]], + ["live-e2e", ["releaseChecks"]], + ["package", ["releaseChecks"]], + ["qa", ["releaseChecks"]], + ["qa-parity", ["releaseChecks"]], + ["qa-live", ["releaseChecks"]], + ["npm-telegram", ["npmTelegram"]], + ["performance", ["productPerformance"]], +]); function gh(args) { return execFileSync(resolvePlainGhBin(), args, { encoding: "utf8", env: plainGhEnv(), + maxBuffer: 64 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"], }); } @@ -27,7 +96,7 @@ function jsonGh(args) { return JSON.parse(gh(args)); } -function githubRestJson(pathSuffix) { +function githubRestJson(pathSuffix, repository = DEFAULT_REPO) { const result = execFileSync( "bash", [ @@ -43,7 +112,7 @@ function githubRestJson(pathSuffix) { env: { ...plainGhEnv(), OPENCLAW_PLAIN_GH_BIN: resolvePlainGhBin(), - OPENCLAW_GITHUB_REST_URL: `https://api.github.com/repos/${repo}/${pathSuffix}`, + OPENCLAW_GITHUB_REST_URL: `https://api.github.com/repos/${repository}/${pathSuffix}`, }, maxBuffer: 16 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"], @@ -52,6 +121,29 @@ function githubRestJson(pathSuffix) { return JSON.parse(result); } +function downloadArtifactZip(artifactId, destination, repository = DEFAULT_REPO) { + execFileSync( + "bash", + [ + "-lc", + [ + "set -euo pipefail", + 'token="$("$OPENCLAW_PLAIN_GH_BIN" auth token)"', + 'curl -fsSL -H "Authorization: Bearer ${token}" -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" --output "$OPENCLAW_GITHUB_ARTIFACT_DESTINATION" "$OPENCLAW_GITHUB_ARTIFACT_URL"', + ].join("\n"), + ], + { + env: { + ...plainGhEnv(), + OPENCLAW_GITHUB_ARTIFACT_DESTINATION: destination, + OPENCLAW_GITHUB_ARTIFACT_URL: `https://api.github.com/repos/${repository}/actions/artifacts/${artifactId}/zip`, + OPENCLAW_PLAIN_GH_BIN: resolvePlainGhBin(), + }, + stdio: ["ignore", "ignore", "pipe"], + }, + ); +} + function rate() { try { return jsonGh(["api", "rate_limit"]).resources.core; @@ -60,69 +152,1503 @@ function rate() { } } -const core = rate(); -if (core) { - const reset = new Date(core.reset * 1000).toISOString(); - console.log(`rate: remaining=${core.remaining}/${core.limit} reset=${reset}`); - if (core.remaining < 20) { - console.error("rate too low for CI summary; wait for reset before polling"); - process.exit(3); +export function validateParentRunBinding(parentView, parentRest, expectedRunId) { + const workflowPath = String(parentRest.path ?? "").split("@", 1)[0]; + if ( + String(parentRest.id) !== String(expectedRunId) || + parentRest.event !== "workflow_dispatch" || + workflowPath !== ".github/workflows/full-release-validation.yml" || + Number(parentRest.run_attempt) !== Number(parentView.attempt) || + parentRest.head_branch !== parentView.headBranch || + parentRest.head_sha !== parentView.headSha + ) { + throw new Error(`full release parent run binding mismatch: ${expectedRunId}`); + } + return parentRest; +} + +export function expectedChildDispatches(parentRunId, parentRunAttempt, parentWorkflowRef) { + if (!/^[1-9][0-9]*$/u.test(String(parentRunId))) { + throw new Error("parent run ID must be a positive decimal"); + } + if (!Number.isSafeInteger(parentRunAttempt) || parentRunAttempt < 1) { + throw new Error("parent run attempt must be a positive integer"); + } + if (typeof parentWorkflowRef !== "string" || parentWorkflowRef.length === 0) { + throw new Error("parent workflow ref is required"); + } + const dispatchPrefix = `full-release-validation-${parentRunId}-${parentRunAttempt}`; + return CHILD_DISPATCHES.map((child) => ({ + ...child, + displayTitle: `${child.name} ${dispatchPrefix}${child.suffix}`, + headBranch: child.trustedRef === "main" ? "main" : parentWorkflowRef, + })); +} + +export function requiredChildKeysForRerunGroup(rerunGroup) { + const childKeys = RERUN_GROUP_CHILD_KEYS.get(rerunGroup); + if (!childKeys) { + throw new Error(`release validation manifest rerun group is invalid: ${rerunGroup}`); + } + return new Set(childKeys); +} + +export function expectedSelectedChildDispatches( + parentRunId, + parentRunAttempt, + parentWorkflowRef, + selectedKeys, +) { + return expectedChildDispatches(parentRunId, parentRunAttempt, parentWorkflowRef).filter((child) => + selectedKeys.has(child.manifestKey), + ); +} + +export function selectExactChildRun(runs, expectedDisplayTitle, expectedHeadBranch) { + const matches = runs.filter( + (run) => + run.event === "workflow_dispatch" && + run.display_title === expectedDisplayTitle && + run.head_branch === expectedHeadBranch, + ); + if (matches.length > 1) { + throw new Error( + `multiple child runs have exact dispatch title and branch: ${expectedDisplayTitle} (${expectedHeadBranch})`, + ); + } + return matches[0]; +} + +export function selectExactChildRunFromPages(runPages, expectedDisplayTitle, expectedHeadBranch) { + let exactMatch; + for (const runs of runPages) { + const match = selectExactChildRun(runs, expectedDisplayTitle, expectedHeadBranch); + if (match) { + if (exactMatch) { + throw new Error( + `multiple child runs have exact dispatch title and branch: ${expectedDisplayTitle} (${expectedHeadBranch})`, + ); + } + exactMatch = match; + } + if (runs.length < 100) { + break; + } + } + return exactMatch; +} + +function findExactChildRun(child, repository = DEFAULT_REPO) { + const runPages = []; + for (let page = 1; page <= 10; page += 1) { + const query = new URLSearchParams({ + event: "workflow_dispatch", + branch: child.headBranch, + page: String(page), + per_page: "100", + }); + const runs = + githubRestJson(`actions/workflows/${child.workflow}/runs?${query.toString()}`, repository) + .workflow_runs ?? []; + runPages.push(runs); + if (runs.length < 100) { + break; + } + } + return selectExactChildRunFromPages(runPages, child.displayTitle, child.headBranch); +} + +function findParentJobsAll(parentRunId, repository = DEFAULT_REPO) { + const jobs = []; + for (let page = 1; page <= 10; page += 1) { + const query = new URLSearchParams({ + filter: "all", + page: String(page), + per_page: "100", + }); + const pageJobs = + githubRestJson(`actions/runs/${parentRunId}/jobs?${query.toString()}`, repository).jobs ?? []; + jobs.push(...pageJobs); + if (pageJobs.length < 100) { + break; + } + } + return jobs; +} + +function parentJobLog(jobId, repository = DEFAULT_REPO) { + return gh(["api", `repos/${repository}/actions/jobs/${jobId}/logs`]); +} + +function normalizeOptionalRunId(value, label) { + if (value === "") { + return ""; + } + if (!/^[1-9][0-9]*$/u.test(String(value))) { + throw new Error(`${label} must be empty or a positive decimal run ID`); + } + return String(value); +} + +function normalizeRequiredRunId(value, label) { + const runId = normalizeOptionalRunId(value, label); + if (!runId) { + throw new Error(`${label} is required`); + } + return runId; +} + +function normalizeRepository(value) { + const repository = String(value ?? ""); + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(repository)) { + throw new Error("repository must use the owner/name form"); + } + return repository; +} + +function normalizeWorkflowRef(value, label) { + const workflowRef = String(value ?? ""); + if ( + workflowRef.length === 0 || + workflowRef.length > 255 || + /[\u0000-\u001f\u007f~^:?*[\\\s]/u.test(workflowRef) + ) { + throw new Error(`${label} is invalid`); + } + return workflowRef; +} + +function normalizeSha(value, label) { + const sha = String(value ?? ""); + if (!/^[a-f0-9]{40}$/u.test(sha)) { + throw new Error(`${label} is invalid`); + } + return sha; +} + +function normalizePositiveInteger(value, label) { + const number = Number(value); + if (!Number.isSafeInteger(number) || number < 1) { + throw new Error(`${label} must be a positive integer`); + } + return number; +} + +function normalizeJsonObject(value, label) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${label} is invalid`); + } + return value; +} + +function canonicalJson(value) { + if (Array.isArray(value)) { + return value.map(canonicalJson); + } + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => [key, canonicalJson(entry)]), + ); + } + return value; +} + +function manifestEvidenceIdentity(manifest) { + return canonicalJson({ + childRunIds: manifest.childRunIds, + controls: manifest.controls, + releaseProfile: manifest.releaseProfile, + rerunGroup: manifest.rerunGroup, + runReleaseSoak: manifest.runReleaseSoak, + validationInputs: manifest.validationInputs, + }); +} + +export function validateParentManifest(value, expected) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("release validation manifest must be an object"); + } + if (![2, 3].includes(value.version) || value.workflowName !== "Full Release Validation") { + throw new Error("release validation manifest schema is unsupported"); + } + if (String(value.runId) !== String(expected.runId)) { + throw new Error("release validation manifest run ID mismatch"); + } + if ( + !/^[1-9][0-9]*$/u.test(String(value.runAttempt)) || + (expected.runAttempt !== undefined && Number(value.runAttempt) !== Number(expected.runAttempt)) + ) { + throw new Error("release validation manifest run attempt mismatch"); + } + const targetSha = normalizeSha(value.targetSha, "release validation manifest target SHA"); + if (typeof value.workflowRef !== "string" || value.workflowRef.length === 0) { + throw new Error("release validation manifest workflow ref is invalid"); + } + if (expected.workflowRef !== undefined && value.workflowRef !== expected.workflowRef) { + throw new Error("release validation manifest workflow ref mismatch"); + } + let workflowSha; + let workflowFullRef; + let workflowRefType; + if (value.version === 3) { + workflowSha = normalizeSha(value.workflowSha, "release validation manifest workflow SHA"); + if (expected.workflowSha !== undefined && workflowSha !== expected.workflowSha) { + throw new Error("release validation manifest workflow SHA mismatch"); + } + workflowFullRef = String(value.workflowFullRef ?? ""); + workflowRefType = String(value.workflowRefType ?? ""); + if ( + !["branch", "tag"].includes(workflowRefType) || + workflowFullRef !== + `refs/${workflowRefType === "branch" ? "heads" : "tags"}/${value.workflowRef}` + ) { + throw new Error("release validation manifest workflow full ref is invalid"); + } + } else if (expected.workflowSha !== undefined) { + workflowSha = normalizeSha(expected.workflowSha, "release validation workflow SHA"); + } + const rerunGroup = String(value.rerunGroup ?? ""); + requiredChildKeysForRerunGroup(rerunGroup); + const releaseProfile = String(value.releaseProfile ?? ""); + if (!["beta", "stable", "full"].includes(releaseProfile)) { + throw new Error("release validation manifest release profile is invalid"); + } + const runReleaseSoak = String(value.runReleaseSoak ?? ""); + if (!["true", "false"].includes(runReleaseSoak)) { + throw new Error("release validation manifest release soak value is invalid"); + } + const controls = normalizeJsonObject(value.controls, "release validation manifest controls"); + if (value.version === 3 && controls.performanceReportPublication !== "artifact-only") { + throw new Error("release validation manifest performance report publication mode is invalid"); + } + const validationInputs = + value.validationInputs === undefined + ? undefined + : normalizeJsonObject( + value.validationInputs, + "release validation manifest validation inputs", + ); + const childRuns = value.childRuns; + if (!childRuns || typeof childRuns !== "object" || Array.isArray(childRuns)) { + throw new Error("release validation manifest childRuns is invalid"); + } + const childRunIds = { + normalCi: normalizeOptionalRunId(childRuns.normalCi, "normal CI run ID"), + npmTelegram: normalizeOptionalRunId(childRuns.npmTelegram, "npm Telegram run ID"), + pluginPrerelease: normalizeOptionalRunId( + childRuns.pluginPrerelease, + "plugin prerelease run ID", + ), + productPerformance: normalizeOptionalRunId( + childRuns.productPerformance?.runId ?? "", + "performance run ID", + ), + releaseChecks: normalizeOptionalRunId(childRuns.releaseChecks, "release checks run ID"), + }; + let evidenceReuse; + if (value.evidenceReuse !== undefined) { + const reuse = normalizeJsonObject( + value.evidenceReuse, + "release validation manifest evidence reuse", + ); + if (reuse.policy !== EVIDENCE_REUSE_POLICY) { + throw new Error("release validation manifest evidence reuse policy is invalid"); + } + if (!/^[a-f0-9]{40}$/u.test(String(reuse.evidenceSha))) { + throw new Error("release validation manifest evidence SHA is invalid"); + } + if ( + !Array.isArray(reuse.changedPaths) || + reuse.changedPaths.some( + (changedPath) => typeof changedPath !== "string" || changedPath.length === 0, + ) || + new Set(reuse.changedPaths).size !== reuse.changedPaths.length + ) { + throw new Error("release validation manifest evidence changed paths are invalid"); + } + evidenceReuse = { + changedPaths: reuse.changedPaths, + evidenceSha: String(reuse.evidenceSha), + policy: reuse.policy, + runId: normalizeRequiredRunId(reuse.runId, "evidence reuse root run ID"), + selectedRunId: normalizeRequiredRunId(reuse.selectedRunId, "evidence reuse selected run ID"), + }; + } + return { + childRunIds, + controls, + evidenceReuse, + releaseProfile, + rerunGroup, + runAttempt: Number(value.runAttempt), + runId: String(value.runId), + runReleaseSoak, + targetRef: String(value.targetRef ?? ""), + targetSha, + validationInputs, + version: value.version, + workflowFullRef, + workflowSha, + workflowRef: value.workflowRef, + workflowRefType, + }; +} + +export function validateEvidenceReuseChain(currentManifest, selectedManifest, rootManifest) { + const reuse = currentManifest.evidenceReuse; + if (!reuse) { + throw new Error("release validation manifest does not authorize evidence reuse"); + } + if (reuse.changedPaths.length !== 0) { + throw new Error("full release evidence reuse requires an exact target with no changed paths"); + } + if (rootManifest.evidenceReuse || selectedManifest.evidenceReuse) { + throw new Error("evidence reuse must select a root execution manifest"); + } + if ( + !currentManifest.validationInputs || + !selectedManifest.validationInputs || + !rootManifest.validationInputs + ) { + throw new Error("evidence reuse manifests must record validation inputs"); + } + if (rootManifest.runId !== reuse.runId) { + throw new Error("evidence reuse root manifest run ID mismatch"); + } + if (selectedManifest.runId !== reuse.selectedRunId) { + throw new Error("evidence reuse selected manifest run ID mismatch"); + } + if (selectedManifest.targetSha !== reuse.evidenceSha) { + throw new Error("evidence reuse selected manifest SHA mismatch"); + } + if ( + currentManifest.targetSha !== reuse.evidenceSha || + rootManifest.targetSha !== reuse.evidenceSha + ) { + throw new Error("full release evidence reuse target SHA mismatch"); + } + if (selectedManifest.runId !== rootManifest.runId) { + throw new Error("evidence reuse selected manifest is not the chain root"); + } + + const rootIdentity = JSON.stringify(manifestEvidenceIdentity(rootManifest)); + for (const [label, manifest] of [ + ["selected", selectedManifest], + ["current", currentManifest], + ]) { + if (JSON.stringify(manifestEvidenceIdentity(manifest)) !== rootIdentity) { + throw new Error(`evidence reuse ${label} manifest policy differs from the chain root`); + } + } + return rootManifest.targetSha; +} + +export function selectedChildKeys(parentJobs) { + return new Set( + CHILD_DISPATCHES.filter((child) => { + const parentJob = parentJobs.find((job) => job.name === child.parentJobName); + return parentJob && parentJob.conclusion !== "skipped"; + }).map((child) => child.manifestKey), + ); +} + +export function manifestChildEntries(manifest, children, selectedKeys) { + return children.flatMap((child) => { + const runId = manifest.childRunIds[child.manifestKey]; + if (!runId) { + if (selectedKeys.has(child.manifestKey)) { + throw new Error(`selected child is missing from manifest: ${child.name}`); + } + return []; + } + return [{ child, runId }]; + }); +} + +function childDispatchAttempt(displayTitle, child, parentRunId, parentRunAttempt) { + const prefix = `${child.name} full-release-validation-${parentRunId}-`; + if (!displayTitle.startsWith(prefix) || !displayTitle.endsWith(child.suffix)) { + return undefined; + } + const attemptEnd = child.suffix ? -child.suffix.length : undefined; + const attemptText = displayTitle.slice(prefix.length, attemptEnd); + if (!/^[1-9][0-9]*$/u.test(attemptText)) { + return undefined; + } + const attempt = Number(attemptText); + if (!Number.isSafeInteger(attempt) || attempt > parentRunAttempt) { + return undefined; + } + return attempt; +} + +function parentJobExecutionFingerprint(job) { + return canonicalJson({ + completedAt: job.completed_at, + conclusion: job.conclusion, + name: job.name, + startedAt: job.started_at, + status: job.status, + steps: (job.steps ?? []).map((step) => ({ + completedAt: step.completed_at, + conclusion: step.conclusion, + name: step.name, + number: step.number, + startedAt: step.started_at, + status: step.status, + })), + }); +} + +function selectedAttemptParentJob(parentJobs, child, parentManifest) { + const slotJobs = parentJobs.filter((job) => job.name === child.parentJobName); + if (slotJobs.length === 0) { + throw new Error(`manifest parent job is missing: ${child.name}`); + } + const latestAttempt = Math.max(...slotJobs.map((job) => Number(job.run_attempt))); + if (latestAttempt !== parentManifest.runAttempt) { + throw new Error(`manifest parent job latest attempt mismatch: ${child.name}`); + } + const currentJobs = slotJobs.filter( + (job) => Number(job.run_attempt) === parentManifest.runAttempt, + ); + if (currentJobs.length !== 1) { + throw new Error(`manifest parent job is not unique at the selected attempt: ${child.name}`); + } + const currentJob = currentJobs[0]; + if (currentJob.status !== "completed" || currentJob.conclusion !== "success") { + throw new Error(`manifest parent job is not completed/success: ${child.name}`); + } + return { currentJob, slotJobs }; +} + +export function resolveManifestChildOriginAttempt(run, child, parentManifest, parentJobs) { + const correlatedAttempt = childDispatchAttempt( + String(run.display_title ?? ""), + child, + parentManifest.runId, + parentManifest.runAttempt, + ); + if (correlatedAttempt !== undefined) { + return correlatedAttempt; + } + if (run.display_title !== child.name) { + return undefined; + } + + const { currentJob, slotJobs } = selectedAttemptParentJob(parentJobs, child, parentManifest); + const currentFingerprint = JSON.stringify(parentJobExecutionFingerprint(currentJob)); + const carriedOriginAttempts = slotJobs + .filter( + (job) => + Number(job.run_attempt) < parentManifest.runAttempt && + job.status === "completed" && + job.conclusion === "success" && + JSON.stringify(parentJobExecutionFingerprint(job)) === currentFingerprint, + ) + .map((job) => Number(job.run_attempt)); + return carriedOriginAttempts.length > 0 + ? Math.min(...carriedOriginAttempts) + : parentManifest.runAttempt; +} + +export function selectManifestParentJob(parentJobs, child, parentManifest, originAttempt) { + const { currentJob, slotJobs } = selectedAttemptParentJob(parentJobs, child, parentManifest); + if (originAttempt === parentManifest.runAttempt) { + return currentJob; + } + + const originJobs = slotJobs.filter((job) => Number(job.run_attempt) === originAttempt); + if (originJobs.length !== 1) { + throw new Error(`manifest parent job origin is not unique: ${child.name}`); + } + const originJob = originJobs[0]; + if (originJob.status !== "completed" || originJob.conclusion !== "success") { + throw new Error(`manifest parent job origin is not completed/success: ${child.name}`); + } + if ( + JSON.stringify(parentJobExecutionFingerprint(currentJob)) !== + JSON.stringify(parentJobExecutionFingerprint(originJob)) + ) { + throw new Error(`manifest parent job carry-forward fingerprint mismatch: ${child.name}`); + } + return currentJob; +} + +function childRunIdsFromParentLog(log, repository = DEFAULT_REPO) { + const escapedRepo = repository.replaceAll(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + const pattern = new RegExp( + `https://github\\.com/${escapedRepo}/actions/runs/([1-9][0-9]*)`, + "gu", + ); + return new Set(Array.from(log.matchAll(pattern), (match) => match[1])); +} + +export function validateManifestChildRun( + run, + child, + runId, + parentManifest, + parentJobs, + selectedParentJobLog, + repository = DEFAULT_REPO, +) { + if (String(run.id) !== String(runId)) { + throw new Error(`manifest child run ID mismatch: ${child.name}`); + } + const originAttempt = resolveManifestChildOriginAttempt(run, child, parentManifest, parentJobs); + if ( + run.event !== "workflow_dispatch" || + run.head_branch !== child.headBranch || + (child.trustedRef === "parent" && run.head_sha !== parentManifest.workflowSha) || + !/^[a-f0-9]{40}$/u.test(String(run.head_sha)) || + run.actor?.login !== "github-actions[bot]" || + run.triggering_actor?.login !== "github-actions[bot]" || + !Number.isSafeInteger(Number(run.run_attempt)) || + Number(run.run_attempt) < 1 || + originAttempt === undefined + ) { + throw new Error(`manifest child dispatch tuple mismatch: ${child.name}`); + } + const workflowPath = String(run.path ?? "").split("@", 1)[0]; + if (workflowPath !== `.github/workflows/${child.workflow}`) { + throw new Error(`manifest child workflow mismatch: ${child.name}`); + } + selectManifestParentJob(parentJobs, child, parentManifest, originAttempt); + const emittedChildRunIds = childRunIdsFromParentLog(selectedParentJobLog, repository); + if (emittedChildRunIds.size !== 1 || !emittedChildRunIds.has(String(runId))) { + throw new Error(`manifest child run is not uniquely emitted by its parent job: ${child.name}`); + } + if ( + child.manifestKey !== "npmTelegram" && + !selectedParentJobLog.includes(`TARGET_SHA: ${parentManifest.targetSha}`) + ) { + throw new Error(`manifest parent job target SHA mismatch: ${child.name}`); + } + if ( + child.manifestKey === "productPerformance" && + !selectedParentJobLog.includes("-f publish_reports=false") + ) { + throw new Error("manifest performance child is not dispatched in artifact-only mode"); + } + return run; +} + +export function validatePerformanceArtifactOnlyJobs(jobs, runAttempt) { + const normalizedRunAttempt = normalizePositiveInteger(runAttempt, "performance run attempt"); + const currentJobs = jobs.filter((job) => Number(job.run_attempt) === normalizedRunAttempt); + const guards = currentJobs.filter((job) => job.name === "Verify artifact-only report mode"); + if ( + guards.length !== 1 || + guards[0].status !== "completed" || + guards[0].conclusion !== "success" + ) { + throw new Error("performance artifact-only guard is missing or unsuccessful"); + } + const unsafePublisher = currentJobs.find( + (job) => + String(job.name ?? "").startsWith("Publish ") && + String(job.name ?? "").endsWith(" report") && + job.conclusion !== "skipped", + ); + if (unsafePublisher) { + throw new Error(`performance report publisher was not skipped: ${unsafePublisher.name}`); + } + return guards[0]; +} + +function manifestArtifactName(runId, runAttempt) { + const normalizedRunId = normalizeRequiredRunId(runId, "full release run ID"); + const normalizedRunAttempt = normalizePositiveInteger(runAttempt, "full release run attempt"); + return `full-release-validation-${normalizedRunId}-${normalizedRunAttempt}`; +} + +function legacyManifestArtifactName(runId) { + return `full-release-validation-${normalizeRequiredRunId(runId, "full release run ID")}`; +} + +export function validateManifestArtifactIdentity( + artifact, + { artifactDigest, artifactId, runAttempt, runId }, +) { + const normalizedArtifactId = normalizeRequiredRunId(artifactId, "manifest artifact ID"); + const normalizedRunId = normalizeRequiredRunId(runId, "full release run ID"); + const normalizedRunAttempt = normalizePositiveInteger(runAttempt, "full release run attempt"); + const normalizedDigest = String(artifactDigest ?? ""); + if (!/^sha256:[a-f0-9]{64}$/u.test(normalizedDigest)) { + throw new Error(`release validation manifest artifact digest is invalid: ${normalizedRunId}`); + } + const canonicalName = manifestArtifactName(normalizedRunId, normalizedRunAttempt); + const legacyName = legacyManifestArtifactName(normalizedRunId); + const validName = + artifact.name === canonicalName || (normalizedRunAttempt === 1 && artifact.name === legacyName); + if ( + String(artifact.id) !== normalizedArtifactId || + !validName || + artifact.digest !== normalizedDigest || + artifact.expired !== false || + String(artifact.workflow_run?.id) !== normalizedRunId || + !Number.isSafeInteger(Number(artifact.size_in_bytes)) || + Number(artifact.size_in_bytes) < 1 + ) { + throw new Error(`release validation manifest artifact identity mismatch: ${normalizedRunId}`); + } + return artifact; +} + +export function selectManifestArtifact(artifacts, runId, runAttempt) { + const expectedName = manifestArtifactName(runId, runAttempt); + const canonicalMatches = artifacts.filter( + (artifact) => + artifact.name === expectedName && + artifact.expired === false && + String(artifact.workflow_run?.id) === String(runId), + ); + if (canonicalMatches.length > 1) { + throw new Error(`multiple release validation manifest artifacts found: ${runId}`); + } + const canonicalArtifact = canonicalMatches[0]; + if (canonicalArtifact) { + return validateManifestArtifactIdentity(canonicalArtifact, { + artifactDigest: canonicalArtifact.digest, + artifactId: canonicalArtifact.id, + runAttempt, + runId, + }); + } + + const legacyName = legacyManifestArtifactName(runId); + const legacyMatches = artifacts.filter( + (artifact) => + artifact.name === legacyName && + artifact.expired === false && + String(artifact.workflow_run?.id) === String(runId), + ); + if (legacyMatches.length > 1) { + throw new Error(`multiple legacy release validation manifest artifacts found: ${runId}`); + } + const legacyArtifact = legacyMatches[0]; + if (!legacyArtifact) { + return undefined; + } + if (Number(runAttempt) !== 1) { + throw new Error(`legacy release validation manifest requires run attempt 1: ${runId}`); + } + return validateManifestArtifactIdentity(legacyArtifact, { + artifactDigest: legacyArtifact.digest, + artifactId: legacyArtifact.id, + runAttempt, + runId, + }); +} + +export function validateManifestArtifactCompatibility(artifact, manifest, runId, runAttempt) { + if (artifact.name === manifestArtifactName(runId, runAttempt)) { + return artifact; + } + if ( + Number(runAttempt) === 1 && + artifact.name === legacyManifestArtifactName(runId) && + manifest?.version === 2 + ) { + return artifact; + } + throw new Error(`legacy release validation manifest artifact is not compatible: ${runId}`); +} + +export function readManifestArtifactArchive(archivePath, expectedDigest) { + const archiveSize = statSync(archivePath).size; + if ( + !Number.isSafeInteger(archiveSize) || + archiveSize < 1 || + archiveSize > MAX_MANIFEST_ARTIFACT_ZIP_BYTES + ) { + throw new Error("release validation manifest artifact compressed size is invalid"); + } + const archiveBytes = readFileSync(archivePath); + if (archiveBytes.byteLength !== archiveSize) { + throw new Error("release validation manifest artifact changed while being verified"); + } + const actualDigest = `sha256:${createHash("sha256").update(archiveBytes).digest("hex")}`; + if (actualDigest !== expectedDigest) { + throw new Error("release validation manifest artifact digest mismatch"); + } + + let entryList; + try { + entryList = execFileSync("unzip", ["-Z", "-1", archivePath], { + encoding: "utf8", + maxBuffer: MAX_MANIFEST_ENTRY_LIST_BYTES, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch { + throw new Error("release validation manifest artifact entry list is invalid"); + } + const entries = entryList.split(/\r?\n/u).filter((entry) => entry.length > 0); + if (entries.length !== 1 || entries[0] !== MANIFEST_ARTIFACT_ENTRY) { + throw new Error( + `release validation manifest artifact must contain only ${MANIFEST_ARTIFACT_ENTRY}`, + ); + } + + let manifestBytes; + try { + manifestBytes = execFileSync("unzip", ["-p", archivePath, MANIFEST_ARTIFACT_ENTRY], { + maxBuffer: MAX_MANIFEST_JSON_BYTES + 1, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch { + throw new Error("release validation manifest artifact entry could not be read safely"); + } + if (manifestBytes.byteLength < 1 || manifestBytes.byteLength > MAX_MANIFEST_JSON_BYTES) { + throw new Error("release validation manifest artifact entry size is invalid"); + } + return JSON.parse(manifestBytes.toString("utf8")); +} + +function downloadParentManifestEvidence( + runId, + runAttempt, + repository = DEFAULT_REPO, + manifestPath, +) { + const artifacts = []; + for (let page = 1; page <= 10; page += 1) { + const pageArtifacts = + githubRestJson(`actions/runs/${runId}/artifacts?per_page=100&page=${page}`, repository) + .artifacts ?? []; + artifacts.push(...pageArtifacts); + if (pageArtifacts.length < 100) { + break; + } + } + const listedArtifact = selectManifestArtifact(artifacts, runId, runAttempt); + if (!listedArtifact) { + return undefined; + } + const artifact = validateManifestArtifactIdentity( + githubRestJson(`actions/artifacts/${listedArtifact.id}`, repository), + { + artifactDigest: listedArtifact.digest, + artifactId: listedArtifact.id, + runAttempt, + runId, + }, + ); + const downloadDir = mkdtempSync(join(tmpdir(), "openclaw-release-ci-summary-")); + try { + const archivePath = join(downloadDir, "manifest.zip"); + downloadArtifactZip(String(artifact.id), archivePath, repository); + const manifest = readManifestArtifactArchive(archivePath, artifact.digest); + validateManifestArtifactCompatibility(artifact, manifest, runId, runAttempt); + if (manifestPath) { + const providedManifest = JSON.parse(readFileSync(resolve(manifestPath), "utf8")); + if ( + JSON.stringify(canonicalJson(providedManifest)) !== JSON.stringify(canonicalJson(manifest)) + ) { + throw new Error("provided release validation manifest differs from the run artifact"); + } + } + return { artifact, manifest }; + } finally { + rmSync(downloadDir, { force: true, recursive: true }); } } -const parent = jsonGh([ - "run", - "view", - runId, - "--repo", - repo, - "--json", - "status,conclusion,createdAt,headSha,url,jobs", -]); - -console.log(`parent: ${runId} ${parent.status}/${parent.conclusion || "none"}`); -console.log(`sha: ${parent.headSha}`); -console.log(`url: ${parent.url}`); - -for (const job of parent.jobs ?? []) { - const marker = job.conclusion || job.status; - console.log(`parent-job: ${marker} ${job.name}`); +function tryDownloadParentManifest(runId, runAttempt, repository = DEFAULT_REPO) { + return downloadParentManifestEvidence(runId, runAttempt, repository)?.manifest; } -const since = parent.createdAt; -const runsQuery = new URLSearchParams({ - per_page: "100", - created: `>=${since}`, - exclude_pull_requests: "true", -}); -const childWorkflowNames = new Set([ - "CI", - "OpenClaw Release Checks", - "Plugin Prerelease", - "NPM Telegram Beta E2E", - "Full Release Validation", -]); -const runs = githubRestJson(`actions/runs?${runsQuery.toString()}`).workflow_runs ?? []; -const runList = runs - .filter( - (run) => - run.created_at >= since && - run.head_sha === parent.headSha && - childWorkflowNames.has(run.name), - ) - .map((run) => - [run.id, run.name, run.status, run.conclusion ?? "", run.head_sha, run.html_url].join("\t"), - ) - .join("\n"); - -if (!runList) { - console.log("children: none found yet"); - process.exit(0); +function workflowPath(run) { + return String(run.path ?? "").split("@", 1)[0]; } -console.log("children:"); -for (const line of runList.split("\n")) { - const [id, name, status, conclusion, sha, url] = line.split("\t"); - console.log(`child: ${id} ${name} ${status}/${conclusion || "none"} sha=${sha}`); - console.log(`child-url: ${url}`); +function normalizedManifestArtifact(artifact, runAttempt) { + return { + digest: artifact.digest, + id: String(artifact.id), + name: artifact.name, + runAttempt, + sizeInBytes: Number(artifact.size_in_bytes), + }; +} + +function validateManifestArtifactBinding(artifact, manifest, parentRun, runId) { + validateManifestArtifactCompatibility(artifact, manifest, runId, parentRun.run_attempt); + if ( + String(artifact.workflow_run?.id) !== String(runId) || + artifact.workflow_run?.head_branch !== parentRun.head_branch || + artifact.workflow_run?.head_sha !== parentRun.head_sha + ) { + throw new Error(`release validation manifest artifact binding mismatch: ${runId}`); + } +} + +function validateCompletedParentRun(parentView, parentRest, repository, runId) { + validateParentRunBinding(parentView, parentRest, runId); + if ( + parentView.status !== "completed" || + parentView.conclusion !== "success" || + parentRest.status !== "completed" || + parentRest.conclusion !== "success" || + parentRest.repository?.full_name !== repository + ) { + throw new Error(`full release parent run is not completed/success: ${runId}`); + } +} + +export function createReleaseEvidenceClient(repository = DEFAULT_REPO) { + const normalizedRepository = normalizeRepository(repository); + return { + compareCommits(base, head) { + return githubRestJson(`compare/${base}...${head}`, normalizedRepository); + }, + getJobLog(jobId) { + return parentJobLog(jobId, normalizedRepository); + }, + getParentJobs(runId) { + return findParentJobsAll(runId, normalizedRepository); + }, + getRun(runId) { + return githubRestJson(`actions/runs/${runId}`, normalizedRepository); + }, + getRunView(runId) { + return jsonGh([ + "run", + "view", + String(runId), + "--repo", + normalizedRepository, + "--json", + "status,conclusion,attempt,headBranch,headSha,url,jobs", + ]); + }, + loadManifest(runId, runAttempt, manifestPath) { + return downloadParentManifestEvidence(runId, runAttempt, normalizedRepository, manifestPath); + }, + }; +} + +function loadValidatedParentEvidence({ client, manifestPath, repository, runId }) { + const parentView = client.getRunView(runId); + const parentRun = client.getRun(runId); + validateCompletedParentRun(parentView, parentRun, repository, runId); + + const manifestEvidence = client.loadManifest(runId, parentRun.run_attempt, manifestPath); + if (!manifestEvidence) { + throw new Error(`successful parent run is missing its release validation manifest: ${runId}`); + } + const manifest = validateParentManifest(manifestEvidence.manifest, { + runAttempt: parentRun.run_attempt, + runId, + workflowRef: parentRun.head_branch, + workflowSha: parentRun.head_sha, + }); + validateManifestArtifactBinding(manifestEvidence.artifact, manifest, parentRun, runId); + + return { + artifact: manifestEvidence.artifact, + manifest, + manifestJson: canonicalJson(manifestEvidence.manifest), + parentRun, + parentView, + }; +} + +function trustedWorkflowFullRef(workflowRef) { + return `refs/heads/${workflowRef}`; +} + +function validateTrustedProducerIdentity(evidence, client, verifier, trustedWorkflowRef) { + const { manifest, parentRun } = evidence; + const expectedFullRef = trustedWorkflowFullRef(trustedWorkflowRef); + if (manifest.workflowRef !== trustedWorkflowRef) { + throw new Error( + `release evidence producer must run from trusted workflow ref: ${trustedWorkflowRef}`, + ); + } + const runPath = String(parentRun.path ?? ""); + const [runWorkflowPath, runWorkflowFullRef] = runPath.split("@", 2); + if (runWorkflowPath !== ".github/workflows/full-release-validation.yml") { + throw new Error("release evidence producer workflow path is not trusted"); + } + if (runWorkflowFullRef && runWorkflowFullRef !== expectedFullRef) { + throw new Error("release evidence producer workflow full ref is not trusted"); + } + + let workflowRefProof = "legacy-v2-main-ancestry"; + if (manifest.version === 3) { + if (manifest.workflowRefType !== "branch" || manifest.workflowFullRef !== expectedFullRef) { + throw new Error("release evidence producer workflow full ref is not trusted"); + } + workflowRefProof = "manifest-v3-branch"; + } + + const comparison = client.compareCommits(manifest.workflowSha, verifier.sourceSha); + if ( + !["ahead", "identical"].includes(String(comparison.status)) || + comparison.merge_base_commit?.sha !== manifest.workflowSha + ) { + throw new Error("release evidence producer is not on the trusted main verifier lineage"); + } + + return { + producerOnTrustedMainLineage: true, + workflowFullRef: expectedFullRef, + workflowQualifiedPath: `${runWorkflowPath}@${expectedFullRef}`, + workflowRefProof, + workflowRefType: "branch", + workflowRunPath: runPath, + }; +} + +function normalizedParentTuple(evidence, identity) { + const { manifest, parentRun } = evidence; + return { + artifact: normalizedManifestArtifact(evidence.artifact, manifest.runAttempt), + conclusion: parentRun.conclusion, + manifest: evidence.manifestJson, + manifestVersion: manifest.version, + runAttempt: manifest.runAttempt, + runId: manifest.runId, + status: parentRun.status, + targetSha: manifest.targetSha, + url: parentRun.html_url ?? evidence.parentView.url, + ...identity, + workflowPath: workflowPath(parentRun), + workflowRef: manifest.workflowRef, + workflowSha: manifest.workflowSha, + }; +} + +export function resolveVerifierIdentity( + sourceSha, + verifierSourceContent, + repositoryRoot = RELEASE_EVIDENCE_REPO_ROOT, +) { + let normalizedSourceSha = sourceSha ?? process.env.GITHUB_SHA; + if (!/^[a-f0-9]{40}$/u.test(String(normalizedSourceSha ?? ""))) { + try { + normalizedSourceSha = execFileSync("git", ["-C", repositoryRoot, "rev-parse", "HEAD"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + normalizedSourceSha = null; + } + } + if (!/^[a-f0-9]{40}$/u.test(String(normalizedSourceSha ?? ""))) { + throw new Error("release evidence verifier source SHA is unavailable"); + } + const script = readFileSync(RELEASE_EVIDENCE_FILE); + const scriptSha256 = createHash("sha256").update(script).digest("hex"); + let sourceScript; + if (verifierSourceContent !== undefined) { + sourceScript = Buffer.from(verifierSourceContent); + } else { + try { + sourceScript = execFileSync( + "git", + ["-C", repositoryRoot, "show", `${normalizedSourceSha}:${RELEASE_EVIDENCE_SCRIPT}`], + { + maxBuffer: 16 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + } catch { + throw new Error("release evidence verifier source blob is unavailable"); + } + } + const sourceScriptSha256 = createHash("sha256").update(sourceScript).digest("hex"); + if (scriptSha256 !== sourceScriptSha256) { + throw new Error("release evidence verifier script differs from its source SHA"); + } + return { + schemaVersion: 3, + script: RELEASE_EVIDENCE_SCRIPT, + scriptSha256, + sourceSha: normalizedSourceSha, + }; +} + +function validateStrictChildRun({ child, client, parentEvidence, parentJobs, repository, runId }) { + const run = client.getRun(runId); + const originAttempt = resolveManifestChildOriginAttempt( + run, + child, + parentEvidence.manifest, + parentJobs, + ); + if (originAttempt === undefined) { + throw new Error(`manifest child dispatch tuple mismatch: ${child.name}`); + } + const parentJob = selectManifestParentJob( + parentJobs, + child, + parentEvidence.manifest, + originAttempt, + ); + validateManifestChildRun( + run, + child, + runId, + parentEvidence.manifest, + parentJobs, + client.getJobLog(parentJob.id), + repository, + ); + if ( + run.repository?.full_name !== repository || + run.status !== "completed" || + run.conclusion !== "success" || + run.head_sha !== parentEvidence.manifest.workflowSha + ) { + throw new Error(`manifest child run is not exact completed/success evidence: ${child.name}`); + } + if (child.manifestKey === "productPerformance") { + validatePerformanceArtifactOnlyJobs(client.getParentJobs(runId), run.run_attempt); + } + + return { + conclusion: run.conclusion, + dispatchNonce: `full-release-validation-${parentEvidence.manifest.runId}-${originAttempt}${child.suffix}`, + displayTitle: run.display_title, + event: run.event, + headBranch: run.head_branch, + parentJobId: String(parentJob.id), + path: workflowPath(run), + role: child.manifestKey, + runAttempt: normalizePositiveInteger(run.run_attempt, `${child.name} run attempt`), + runId: String(run.id), + sourceParentAttempt: originAttempt, + sourceParentRunId: parentEvidence.manifest.runId, + status: run.status, + url: run.html_url, + workflowSha: run.head_sha, + ...(child.manifestKey === "productPerformance" ? { reportPublication: "artifact-only" } : {}), + }; +} + +export function validateReleaseRunEvidence( + { + manifestPath, + repository = DEFAULT_REPO, + runId, + trustedWorkflowRef = "main", + verifierSourceContent, + verifierSourceSha, + }, + client, +) { + const normalizedRepository = normalizeRepository(repository); + const normalizedRunId = normalizeRequiredRunId(runId, "full release run ID"); + const normalizedTrustedWorkflowRef = normalizeWorkflowRef( + trustedWorkflowRef, + "trusted workflow ref", + ); + const evidenceClient = client ?? createReleaseEvidenceClient(normalizedRepository); + const verifier = resolveVerifierIdentity(verifierSourceSha, verifierSourceContent); + const currentEvidence = loadValidatedParentEvidence({ + client: evidenceClient, + manifestPath, + repository: normalizedRepository, + runId: normalizedRunId, + }); + + let rootEvidence = currentEvidence; + let selectedEvidence = currentEvidence; + const reuse = currentEvidence.manifest.evidenceReuse; + if (reuse) { + rootEvidence = loadValidatedParentEvidence({ + client: evidenceClient, + repository: normalizedRepository, + runId: reuse.runId, + }); + selectedEvidence = + reuse.selectedRunId === reuse.runId + ? rootEvidence + : loadValidatedParentEvidence({ + client: evidenceClient, + repository: normalizedRepository, + runId: reuse.selectedRunId, + }); + validateEvidenceReuseChain( + currentEvidence.manifest, + selectedEvidence.manifest, + rootEvidence.manifest, + ); + } + + const producerIdentities = new Map(); + for (const evidence of [currentEvidence, selectedEvidence, rootEvidence]) { + if (!producerIdentities.has(evidence.manifest.runId)) { + producerIdentities.set( + evidence.manifest.runId, + validateTrustedProducerIdentity( + evidence, + evidenceClient, + verifier, + normalizedTrustedWorkflowRef, + ), + ); + } + } + const selectedKeys = requiredChildKeysForRerunGroup(rootEvidence.manifest.rerunGroup); + const expectedChildren = expectedSelectedChildDispatches( + rootEvidence.manifest.runId, + rootEvidence.manifest.runAttempt, + rootEvidence.manifest.workflowRef, + selectedKeys, + ); + const parentJobs = evidenceClient.getParentJobs(rootEvidence.manifest.runId); + const children = manifestChildEntries(rootEvidence.manifest, expectedChildren, selectedKeys).map( + ({ child, runId: childRunId }) => + validateStrictChildRun({ + child, + client: evidenceClient, + parentEvidence: rootEvidence, + parentJobs, + repository: normalizedRepository, + runId: childRunId, + }), + ); + + const current = normalizedParentTuple( + currentEvidence, + producerIdentities.get(currentEvidence.manifest.runId), + ); + const root = normalizedParentTuple( + rootEvidence, + producerIdentities.get(rootEvidence.manifest.runId), + ); + const childConclusions = Object.fromEntries( + children.map((child) => [child.role, child.conclusion]), + ); + return canonicalJson({ + children, + conclusions: { + allRequiredSucceeded: children.every((child) => child.conclusion === "success"), + children: childConclusions, + current: current.conclusion, + root: root.conclusion, + }, + controls: rootEvidence.manifest.controls, + current, + directRoot: !reuse, + evidenceReuse: reuse + ? { + changedPaths: reuse.changedPaths, + evidenceSha: reuse.evidenceSha, + policy: reuse.policy, + rootRunId: reuse.runId, + selectedRunId: reuse.selectedRunId, + } + : null, + manifest: rootEvidence.manifestJson, + releaseProfile: rootEvidence.manifest.releaseProfile, + repository: normalizedRepository, + rerunGroup: rootEvidence.manifest.rerunGroup, + root, + runReleaseSoak: rootEvidence.manifest.runReleaseSoak === "true", + schema: RELEASE_EVIDENCE_SCHEMA, + producerOnTrustedMainLineage: true, + trustedWorkflowFullRef: trustedWorkflowFullRef(normalizedTrustedWorkflowRef), + trustedWorkflowRef: normalizedTrustedWorkflowRef, + valid: true, + validationInputs: rootEvidence.manifest.validationInputs ?? null, + verifier, + }); +} + +export function parseReleaseCiSummaryArgs(argv) { + const options = { + json: false, + manifestPath: undefined, + repository: DEFAULT_REPO, + runId: undefined, + trustedWorkflowRef: "main", + validate: false, + }; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--validate-run") { + options.validate = true; + options.runId = argv[++index]; + } else if (argument === "--repo") { + options.repository = argv[++index]; + } else if (argument === "--manifest") { + options.manifestPath = argv[++index]; + } else if (argument === "--trusted-workflow-ref") { + options.trustedWorkflowRef = argv[++index]; + } else if (argument === "--json") { + options.json = true; + } else if (!argument.startsWith("-") && !options.runId && !options.validate) { + options.runId = argument; + } else { + throw new Error(`unknown or incomplete argument: ${argument}`); + } + } + if (!options.validate && options.manifestPath) { + throw new Error("--manifest requires --validate-run"); + } + if (!options.runId) { + throw new Error("full release run ID is required"); + } + return options; +} + +function printUsage() { + console.error( + [ + "usage: release-ci-summary.mjs ", + " release-ci-summary.mjs --validate-run [--repo owner/name] [--trusted-workflow-ref main] [--manifest path] --json", + ].join("\n"), + ); +} + +function main() { + let options; + try { + options = parseReleaseCiSummaryArgs(process.argv.slice(2)); + } catch (error) { + printUsage(); + console.error(error instanceof Error ? error.message : String(error)); + process.exit(2); + } + const { repository, runId } = options; + + if (options.validate) { + try { + const evidence = validateReleaseRunEvidence({ + manifestPath: options.manifestPath, + repository, + runId, + trustedWorkflowRef: options.trustedWorkflowRef, + }); + console.log(JSON.stringify(evidence, null, options.json ? 2 : 0)); + } catch (error) { + const failure = { + error: error instanceof Error ? error.message : String(error), + schema: RELEASE_EVIDENCE_SCHEMA, + valid: false, + }; + if (options.json) { + console.log(JSON.stringify(failure, null, 2)); + } else { + console.error(failure.error); + } + process.exit(1); + } + return; + } + + const core = rate(); + if (core) { + const reset = new Date(core.reset * 1000).toISOString(); + console.log(`rate: remaining=${core.remaining}/${core.limit} reset=${reset}`); + if (core.remaining < 20) { + console.error("rate too low for CI summary; wait for reset before polling"); + process.exit(3); + } + } + + const parent = jsonGh([ + "run", + "view", + runId, + "--repo", + repository, + "--json", + "status,conclusion,attempt,headBranch,headSha,url,jobs", + ]); + validateParentRunBinding(parent, githubRestJson(`actions/runs/${runId}`, repository), runId); + + console.log(`parent: ${runId} ${parent.status}/${parent.conclusion || "none"}`); + console.log(`workflow-ref: ${parent.headBranch}`); + console.log(`workflow-sha: ${parent.headSha}`); + console.log(`url: ${parent.url}`); + + for (const job of parent.jobs ?? []) { + const marker = job.conclusion || job.status; + console.log(`parent-job: ${marker} ${job.name}`); + } + + const currentManifestRaw = tryDownloadParentManifest(runId, parent.attempt, repository); + let children; + if (currentManifestRaw) { + const currentManifest = validateParentManifest(currentManifestRaw, { + runAttempt: parent.attempt, + runId, + workflowRef: parent.headBranch, + workflowSha: parent.headSha, + }); + console.log(`candidate-sha: ${currentManifest.targetSha}`); + console.log(`manifest-run: ${currentManifest.runId}/${currentManifest.runAttempt}`); + + let sourceManifest = currentManifest; + let sourceParent = parent; + if (currentManifest.evidenceReuse) { + const rootRunId = currentManifest.evidenceReuse.runId; + const rootParent = jsonGh([ + "run", + "view", + rootRunId, + "--repo", + repository, + "--json", + "status,conclusion,attempt,headBranch,headSha,url,jobs", + ]); + validateParentRunBinding( + rootParent, + githubRestJson(`actions/runs/${rootRunId}`, repository), + rootRunId, + ); + if (rootParent.status !== "completed" || rootParent.conclusion !== "success") { + throw new Error(`evidence root run is not completed/success: ${rootRunId}`); + } + const rootManifestRaw = tryDownloadParentManifest(rootRunId, rootParent.attempt, repository); + if (!rootManifestRaw) { + throw new Error(`evidence root manifest is unavailable: ${rootRunId}`); + } + const rootManifest = validateParentManifest(rootManifestRaw, { + runAttempt: rootParent.attempt, + runId: rootRunId, + workflowRef: rootParent.headBranch, + workflowSha: rootParent.headSha, + }); + + const selectedRunId = currentManifest.evidenceReuse.selectedRunId; + let selectedManifest = rootManifest; + if (selectedRunId !== rootRunId) { + const selectedParent = jsonGh([ + "run", + "view", + selectedRunId, + "--repo", + repository, + "--json", + "status,conclusion,attempt,headBranch,headSha,url,jobs", + ]); + validateParentRunBinding( + selectedParent, + githubRestJson(`actions/runs/${selectedRunId}`, repository), + selectedRunId, + ); + if (selectedParent.status !== "completed" || selectedParent.conclusion !== "success") { + throw new Error(`selected evidence run is not completed/success: ${selectedRunId}`); + } + const selectedManifestRaw = tryDownloadParentManifest( + selectedRunId, + selectedParent.attempt, + repository, + ); + if (!selectedManifestRaw) { + throw new Error(`selected evidence manifest is unavailable: ${selectedRunId}`); + } + selectedManifest = validateParentManifest(selectedManifestRaw, { + runAttempt: selectedParent.attempt, + runId: selectedRunId, + workflowRef: selectedParent.headBranch, + workflowSha: selectedParent.headSha, + }); + } + + const evidenceSha = validateEvidenceReuseChain( + currentManifest, + selectedManifest, + rootManifest, + ); + sourceManifest = rootManifest; + sourceParent = rootParent; + console.log(`evidence-selected-run: ${selectedRunId}`); + console.log(`evidence-root-run: ${rootRunId}`); + console.log(`evidence-sha: ${evidenceSha}`); + } + + const expectedChildren = expectedSelectedChildDispatches( + sourceManifest.runId, + sourceManifest.runAttempt, + sourceManifest.workflowRef, + requiredChildKeysForRerunGroup(sourceManifest.rerunGroup), + ); + const sourceParentJobs = findParentJobsAll(sourceManifest.runId, repository); + children = manifestChildEntries( + sourceManifest, + expectedChildren, + requiredChildKeysForRerunGroup(sourceManifest.rerunGroup), + ).map(({ child, runId: childRunId }) => { + const run = githubRestJson(`actions/runs/${childRunId}`, repository); + const originAttempt = resolveManifestChildOriginAttempt( + run, + child, + sourceManifest, + sourceParentJobs, + ); + if (originAttempt === undefined) { + throw new Error(`manifest child dispatch tuple mismatch: ${child.name}`); + } + const parentJob = selectManifestParentJob( + sourceParentJobs, + child, + sourceManifest, + originAttempt, + ); + const validatedRun = validateManifestChildRun( + run, + child, + childRunId, + { ...sourceManifest, workflowSha: sourceParent.headSha }, + sourceParentJobs, + parentJobLog(parentJob.id, repository), + repository, + ); + if (child.manifestKey === "productPerformance") { + validatePerformanceArtifactOnlyJobs( + findParentJobsAll(childRunId, repository), + run.run_attempt, + ); + } + return { child, run: validatedRun }; + }); + } else { + console.log("candidate-sha: unavailable (release validation manifest not uploaded)"); + if (parent.status === "completed" && parent.conclusion === "success") { + throw new Error("successful parent run is missing its release validation manifest"); + } + const selectedKeys = selectedChildKeys(parent.jobs ?? []); + children = expectedSelectedChildDispatches( + runId, + parent.attempt, + parent.headBranch, + selectedKeys, + ) + .map((child) => { + const run = findExactChildRun(child, repository); + if (!run) { + console.log( + `child-missing: ${child.name} title=${child.displayTitle} branch=${child.headBranch}`, + ); + } + return { child, run }; + }) + .filter((entry) => entry.run); + } + if (children.length === 0) { + console.log("children: none found yet"); + return; + } + + console.log("children:"); + for (const { child, run } of children) { + console.log( + `child: ${run.id} ${child.name} ${run.status}/${run.conclusion || "none"} branch=${run.head_branch} workflow_sha=${run.head_sha}`, + ); + console.log(`child-url: ${run.html_url}`); + } +} + +if (process.argv[1]?.endsWith("release-ci-summary.mjs")) { + main(); } diff --git a/.github/workflows/full-release-validation.yml b/.github/workflows/full-release-validation.yml index ad0628c0b53f..3ea643d5a113 100644 --- a/.github/workflows/full-release-validation.yml +++ b/.github/workflows/full-release-validation.yml @@ -60,7 +60,7 @@ on: - npm-telegram - performance reuse_evidence: - description: Reuse the newest prior green full validation when the target differs only by release metadata such as changelog and version stamps + description: Reuse the newest prior green full validation only for the exact same target SHA and inputs required: false default: true type: boolean @@ -89,6 +89,11 @@ on: required: false default: "" type: string + dispatch_release_evidence: + description: Dispatch the validated run to openclaw/releases after child proof succeeds + required: false + default: false + type: boolean package_acceptance_package_spec: description: Optional published package spec for Package Acceptance; blank uses the SHA-built release artifact required: false @@ -139,10 +144,10 @@ jobs: - name: Checkout trusted workflow helper uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: - ref: ${{ github.ref_name }} + ref: ${{ github.sha }} path: workflow fetch-depth: 1 - persist-credentials: true + persist-credentials: false submodules: false - name: Resolve target SHA @@ -236,7 +241,7 @@ jobs: evidence_reuse: name: Check for reusable validation evidence needs: [resolve_target] - if: inputs.rerun_group == 'all' && inputs.reuse_evidence + if: inputs.rerun_group == 'all' && inputs.reuse_evidence && github.ref == 'refs/heads/main' runs-on: ubuntu-24.04 timeout-minutes: 10 outputs: @@ -251,10 +256,10 @@ jobs: - name: Checkout trusted workflow helper uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: - ref: ${{ github.ref_name }} + ref: ${{ github.sha }} path: workflow fetch-depth: 1 - persist-credentials: true + persist-credentials: false submodules: false - name: Checkout target SHA @@ -263,7 +268,7 @@ jobs: ref: ${{ needs.resolve_target.outputs.sha }} path: target fetch-depth: 1 - persist-credentials: true + persist-credentials: false submodules: false - name: Find reusable validation evidence @@ -319,13 +324,14 @@ jobs: EVIDENCE_SHA: ${{ steps.find.outputs.evidence_sha }} CHANGED_PATHS: ${{ steps.find.outputs.changed_paths }} run: | + changed_paths_summary="$(jq -r 'if length == 0 then "none" else join(", ") end' <<< "${CHANGED_PATHS:-[]}")" { echo "## Validation evidence reuse" echo if [[ "$REUSE" == "true" ]]; then echo "- Reusing evidence: ${EVIDENCE_RUN_URL}" echo "- Evidence SHA: \`${EVIDENCE_SHA}\`" - echo "- Release-metadata-only delta: \`${CHANGED_PATHS:-none}\`" + echo "- Exact-target reuse changed paths: \`${changed_paths_summary}\`" else echo "- No reusable evidence: ${REUSE_REASON:-unknown}" fi @@ -345,7 +351,7 @@ jobs: with: ref: ${{ needs.resolve_target.outputs.sha }} fetch-depth: 1 - persist-credentials: true + persist-credentials: false - name: Verify Docker runtime-assets prune path env: @@ -375,6 +381,7 @@ jobs: TARGET_REF: ${{ inputs.ref }} TARGET_SHA: ${{ needs.resolve_target.outputs.sha }} CHILD_WORKFLOW_REF: ${{ github.ref_name }} + PARENT_WORKFLOW_SHA: ${{ github.sha }} run: | set -euo pipefail @@ -383,7 +390,7 @@ jobs: local dispatch_run_name="$2" shift 2 - local dispatch_output dispatch_status matches_json match_count run_id status conclusion url poll_count + local dispatch_output dispatch_status matches_json match_count run_id status conclusion url poll_count child_head_sha encoded_workflow_ref current_workflow_sha gh_with_retry() { local output status attempt for attempt in 1 2 3 4 5 6; do @@ -406,6 +413,14 @@ jobs: printf '%s\n' "$output" >&2 return "$status" } + encoded_workflow_ref="$(jq -rn --arg value "$CHILD_WORKFLOW_REF" '$value | @uri')" + current_workflow_sha="$( + gh_with_retry api "repos/${GITHUB_REPOSITORY}/commits/${encoded_workflow_ref}" --jq .sha + )" + if [[ "$current_workflow_sha" != "$PARENT_WORKFLOW_SHA" ]]; then + echo "::error::Child workflow ref ${CHILD_WORKFLOW_REF} moved to ${current_workflow_sha}, expected ${PARENT_WORKFLOW_SHA}; refusing dispatch." >&2 + return 1 + fi # A failed dispatch POST can still create a run. Never retry it; recover only by exact run name. set +e dispatch_output="$(gh workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@" 2>&1)" @@ -482,6 +497,14 @@ jobs: } trap cancel_child EXIT INT TERM + child_head_sha="$(fetch_child_run_json | jq -r '.head_sha // ""')" + if [[ "$child_head_sha" != "$PARENT_WORKFLOW_SHA" ]]; then + echo "::error::${workflow} child run used workflow SHA ${child_head_sha}, expected parent workflow SHA ${PARENT_WORKFLOW_SHA}." + cancel_child + trap - EXIT INT TERM + exit 1 + fi + poll_count=0 while true; do status="$(fetch_child_run_json | jq -r '.status')" @@ -540,6 +563,7 @@ jobs: TARGET_REF: ${{ inputs.ref }} TARGET_SHA: ${{ needs.resolve_target.outputs.sha }} CHILD_WORKFLOW_REF: ${{ github.ref_name }} + PARENT_WORKFLOW_SHA: ${{ github.sha }} run: | set -euo pipefail @@ -548,7 +572,7 @@ jobs: local dispatch_run_name="$2" shift 2 - local dispatch_output dispatch_status matches_json match_count run_id status conclusion url poll_count + local dispatch_output dispatch_status matches_json match_count run_id status conclusion url poll_count child_head_sha encoded_workflow_ref current_workflow_sha gh_with_retry() { local output status attempt for attempt in 1 2 3 4 5 6; do @@ -571,6 +595,14 @@ jobs: printf '%s\n' "$output" >&2 return "$status" } + encoded_workflow_ref="$(jq -rn --arg value "$CHILD_WORKFLOW_REF" '$value | @uri')" + current_workflow_sha="$( + gh_with_retry api "repos/${GITHUB_REPOSITORY}/commits/${encoded_workflow_ref}" --jq .sha + )" + if [[ "$current_workflow_sha" != "$PARENT_WORKFLOW_SHA" ]]; then + echo "::error::Child workflow ref ${CHILD_WORKFLOW_REF} moved to ${current_workflow_sha}, expected ${PARENT_WORKFLOW_SHA}; refusing dispatch." >&2 + return 1 + fi # A failed dispatch POST can still create a run. Never retry it; recover only by exact run name. set +e dispatch_output="$(gh workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@" 2>&1)" @@ -647,6 +679,14 @@ jobs: } trap cancel_child EXIT INT TERM + child_head_sha="$(fetch_child_run_json | jq -r '.head_sha // ""')" + if [[ "$child_head_sha" != "$PARENT_WORKFLOW_SHA" ]]; then + echo "::error::${workflow} child run used workflow SHA ${child_head_sha}, expected parent workflow SHA ${PARENT_WORKFLOW_SHA}." + cancel_child + trap - EXIT INT TERM + exit 1 + fi + poll_count=0 while true; do status="$(fetch_child_run_json | jq -r '.status')" @@ -705,6 +745,7 @@ jobs: TARGET_REF: ${{ inputs.ref }} TARGET_SHA: ${{ needs.resolve_target.outputs.sha }} CHILD_WORKFLOW_REF: ${{ github.ref_name }} + PARENT_WORKFLOW_SHA: ${{ github.sha }} PROVIDER: ${{ inputs.provider }} MODE: ${{ inputs.mode }} RELEASE_PROFILE: ${{ inputs.release_profile }} @@ -723,7 +764,7 @@ jobs: local dispatch_run_name="$2" shift 2 - local dispatch_output dispatch_status matches_json match_count run_id status conclusion url poll_count run_json + local dispatch_output dispatch_status matches_json match_count run_id status conclusion url poll_count run_json child_head_sha encoded_workflow_ref current_workflow_sha gh_with_retry() { local output status attempt for attempt in 1 2 3 4 5 6; do @@ -746,6 +787,14 @@ jobs: printf '%s\n' "$output" >&2 return "$status" } + encoded_workflow_ref="$(jq -rn --arg value "$CHILD_WORKFLOW_REF" '$value | @uri')" + current_workflow_sha="$( + gh_with_retry api "repos/${GITHUB_REPOSITORY}/commits/${encoded_workflow_ref}" --jq .sha + )" + if [[ "$current_workflow_sha" != "$PARENT_WORKFLOW_SHA" ]]; then + echo "::error::Child workflow ref ${CHILD_WORKFLOW_REF} moved to ${current_workflow_sha}, expected ${PARENT_WORKFLOW_SHA}; refusing dispatch." >&2 + return 1 + fi # A failed dispatch POST can still create a run. Never retry it; recover only by exact run name. set +e dispatch_output="$(gh workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@" 2>&1)" @@ -881,6 +930,14 @@ jobs: } trap cancel_child EXIT INT TERM + child_head_sha="$(fetch_child_run_json | jq -r '.head_sha // ""')" + if [[ "$child_head_sha" != "$PARENT_WORKFLOW_SHA" ]]; then + echo "::error::${workflow} child run used workflow SHA ${child_head_sha}, expected parent workflow SHA ${PARENT_WORKFLOW_SHA}." + cancel_child + trap - EXIT INT TERM + exit 1 + fi + poll_count=0 while true; do status="$(fetch_child_run_json | jq -r '.status')" @@ -1001,6 +1058,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} CHILD_WORKFLOW_REF: ${{ github.ref_name }} + PARENT_WORKFLOW_SHA: ${{ github.sha }} TARGET_SHA: ${{ needs.resolve_target.outputs.sha }} PACKAGE_SPEC: ${{ inputs.npm_telegram_package_spec || inputs.release_package_spec }} PROVIDER_MODE: ${{ inputs.npm_telegram_provider_mode }} @@ -1031,6 +1089,15 @@ jobs: return "$status" } + encoded_workflow_ref="$(jq -rn --arg value "$CHILD_WORKFLOW_REF" '$value | @uri')" + current_workflow_sha="$( + gh_with_retry api "repos/${GITHUB_REPOSITORY}/commits/${encoded_workflow_ref}" --jq .sha + )" + if [[ "$current_workflow_sha" != "$PARENT_WORKFLOW_SHA" ]]; then + echo "::error::Child workflow ref ${CHILD_WORKFLOW_REF} moved to ${current_workflow_sha}, expected ${PARENT_WORKFLOW_SHA}; refusing dispatch." >&2 + exit 1 + fi + args=(-f package_spec="$PACKAGE_SPEC" -f harness_ref="$TARGET_SHA" -f provider_mode="$PROVIDER_MODE") if [[ -n "${SCENARIO// }" ]]; then args+=(-f scenario="$SCENARIO") @@ -1093,6 +1160,14 @@ jobs: } trap cancel_child EXIT INT TERM + child_head_sha="$(gh_with_retry run view "$run_id" --json headSha --jq '.headSha // ""')" + if [[ "$child_head_sha" != "$PARENT_WORKFLOW_SHA" ]]; then + echo "::error::npm-telegram-beta-e2e.yml child run used workflow SHA ${child_head_sha}, expected parent workflow SHA ${PARENT_WORKFLOW_SHA}." + cancel_child + trap - EXIT INT TERM + exit 1 + fi + fail_fast_failed_jobs() { local failed_jobs_json failed_jobs_json="$( @@ -1153,6 +1228,7 @@ jobs: GH_TOKEN: ${{ github.token }} TARGET_SHA: ${{ needs.resolve_target.outputs.sha }} CHILD_WORKFLOW_REF: ${{ github.ref_name }} + PARENT_WORKFLOW_SHA: ${{ github.sha }} run: | set -euo pipefail @@ -1179,6 +1255,15 @@ jobs: return "$status" } + encoded_workflow_ref="$(jq -rn --arg value "$CHILD_WORKFLOW_REF" '$value | @uri')" + current_workflow_sha="$( + gh_with_retry api "repos/${GITHUB_REPOSITORY}/commits/${encoded_workflow_ref}" --jq .sha + )" + if [[ "$current_workflow_sha" != "$PARENT_WORKFLOW_SHA" ]]; then + echo "::error::Child workflow ref ${CHILD_WORKFLOW_REF} moved to ${current_workflow_sha}, expected ${PARENT_WORKFLOW_SHA}; refusing dispatch." >&2 + exit 1 + fi + { echo "### Product performance" echo @@ -1187,6 +1272,7 @@ jobs: echo "- Repeat: \`3\`" echo "- Deep profile: \`false\`" echo "- Live OpenAI candidate: \`false\`" + echo "- Report publication: disabled (artifacts only)" echo "- Release impact: blocking" } >> "$GITHUB_STEP_SUMMARY" @@ -1203,6 +1289,7 @@ jobs: -f deep_profile=false \ -f live_openai_candidate=false \ -f fail_on_regression=true \ + -f publish_reports=false \ -f dispatch_id="$dispatch_id" 2>&1)" dispatch_status=$? set -e @@ -1254,6 +1341,14 @@ jobs: } trap cancel_child EXIT INT TERM + child_head_sha="$(gh_with_retry run view "$run_id" --json headSha --jq '.headSha // ""')" + if [[ "$child_head_sha" != "$PARENT_WORKFLOW_SHA" ]]; then + echo "::error::openclaw-performance.yml child run used workflow SHA ${child_head_sha}, expected parent workflow SHA ${PARENT_WORKFLOW_SHA}." + cancel_child + trap - EXIT INT TERM + exit 1 + fi + poll_count=0 while true; do status="$(gh_with_retry run view "$run_id" --json status --jq '.status')" @@ -1319,6 +1414,7 @@ jobs: RERUN_GROUP: ${{ inputs.rerun_group }} TARGET_SHA: ${{ needs.resolve_target.outputs.sha }} CHILD_WORKFLOW_REF: ${{ github.ref_name }} + PARENT_WORKFLOW_SHA: ${{ github.sha }} run: | set -euo pipefail @@ -1409,8 +1505,8 @@ jobs: head_sha="$(jq -r '.headSha // ""' <<< "$run_json")" echo "${label}: ${status}/${conclusion} attempt ${attempt} head ${head_sha}: ${url}" - if [[ ( "$CHILD_WORKFLOW_REF" == release-ci/* || "$CHILD_WORKFLOW_REF" =~ ^extended-stable/[0-9]{4}\.([1-9]|1[0-2])\.33$ ) && -n "${TARGET_SHA// }" && "$head_sha" != "$TARGET_SHA" ]]; then - echo "::error::${label} child run used ${head_sha}, expected ${TARGET_SHA}. Dispatch Full Release Validation from a release-ci or extended-stable ref pinned to the target SHA, not a moving branch." + if [[ "$head_sha" != "$PARENT_WORKFLOW_SHA" ]]; then + echo "::error::${label} child run used workflow SHA ${head_sha}, expected parent workflow SHA ${PARENT_WORKFLOW_SHA}. Use the SHA-pinned release helper when a moving branch cannot stay fixed." return 1 fi @@ -1598,7 +1694,7 @@ jobs: echo echo "- Evidence run: ${EVIDENCE_RUN_URL}" echo "- Evidence SHA: \`${EVIDENCE_SHA}\`" - echo "- Target SHA: \`${TARGET_SHA}\` (release-metadata-only delta)" + echo "- Target SHA: \`${TARGET_SHA}\` (exact-target evidence reuse)" } >> "$GITHUB_STEP_SUMMARY" fi elif [[ "$RERUN_GROUP" == "all" && "$DOCKER_RUNTIME_ASSETS_PREFLIGHT_RESULT" != "success" ]]; then @@ -1679,6 +1775,7 @@ jobs: exit "$failed" - name: Request release evidence update + if: ${{ inputs.dispatch_release_evidence }} env: RELEASES_DISPATCH_TOKEN: ${{ secrets.OPENCLAW_RELEASES_DISPATCH_TOKEN }} TARGET_REF: ${{ inputs.ref }} @@ -1698,7 +1795,7 @@ jobs: # so durable evidence must reference that run id, not this wrapper. notes="Automatically requested by Full Release Validation ${GITHUB_RUN_ID_VALUE} after child workflows completed; the parent summary re-checks current child run conclusions." if [[ "$EVIDENCE_REUSE" == "true" && -n "${EVIDENCE_ROOT_RUN_ID// }" ]]; then - notes="Automatically requested by Full Release Validation ${GITHUB_RUN_ID_VALUE}, which reused green evidence from chain-root run ${EVIDENCE_ROOT_RUN_ID}; the release target differs from the validated SHA only by release metadata." + notes="Automatically requested by Full Release Validation ${GITHUB_RUN_ID_VALUE}, which reused green evidence from chain-root run ${EVIDENCE_ROOT_RUN_ID} for the exact same target SHA and inputs." GITHUB_RUN_ID_VALUE="$EVIDENCE_ROOT_RUN_ID" fi if [[ -z "${RELEASES_DISPATCH_TOKEN// }" ]]; then @@ -1778,7 +1875,10 @@ jobs: PERFORMANCE_RUN_ID: ${{ needs.performance.outputs.run_id }} PERFORMANCE_CONCLUSION: ${{ needs.performance.outputs.conclusion }} EVIDENCE_REUSE: ${{ needs.evidence_reuse.outputs.reuse }} + EVIDENCE_RUN_ID: ${{ needs.evidence_reuse.outputs.evidence_run_id }} EVIDENCE_ROOT_RUN_ID: ${{ needs.evidence_reuse.outputs.evidence_root_run_id }} + EVIDENCE_SHA: ${{ needs.evidence_reuse.outputs.evidence_sha }} + EVIDENCE_CHANGED_PATHS: ${{ needs.evidence_reuse.outputs.changed_paths }} EVIDENCE_MANIFEST: ${{ needs.evidence_reuse.outputs.evidence_manifest }} PROVIDER: ${{ inputs.provider }} MODE: ${{ inputs.mode }} @@ -1798,16 +1898,35 @@ jobs: --arg runId "$GITHUB_RUN_ID" \ --arg runAttempt "$GITHUB_RUN_ATTEMPT" \ --arg workflowRef "$GITHUB_REF_NAME" \ + --arg workflowSha "$GITHUB_SHA" \ + --arg workflowFullRef "$GITHUB_REF" \ + --arg workflowRefType "$GITHUB_REF_TYPE" \ --arg targetRef "$TARGET_REF" \ --arg targetSha "$TARGET_SHA" \ + --arg evidenceRunId "$EVIDENCE_RUN_ID" \ --arg evidenceRootRunId "$EVIDENCE_ROOT_RUN_ID" \ + --arg evidenceSha "$EVIDENCE_SHA" \ + --argjson evidenceChangedPaths "$EVIDENCE_CHANGED_PATHS" \ '. + { + version: 3, runId: $runId, runAttempt: $runAttempt, workflowRef: $workflowRef, + workflowSha: $workflowSha, + workflowFullRef: $workflowFullRef, + workflowRefType: $workflowRefType, targetRef: $targetRef, targetSha: $targetSha, - evidenceReuse: {runId: $evidenceRootRunId} + evidenceReuse: { + policy: "exact-target-full-validation-v1", + runId: $evidenceRootRunId, + selectedRunId: $evidenceRunId, + evidenceSha: $evidenceSha, + changedPaths: $evidenceChangedPaths + }, + controls: ((.controls // {}) + { + performanceReportPublication: "artifact-only" + }) }' <<< "$EVIDENCE_MANIFEST" > "${manifest_dir}/full-release-validation-manifest.json" exit 0 fi @@ -1816,6 +1935,9 @@ jobs: --arg runId "$GITHUB_RUN_ID" \ --arg runAttempt "$GITHUB_RUN_ATTEMPT" \ --arg workflowRef "$GITHUB_REF_NAME" \ + --arg workflowSha "$GITHUB_SHA" \ + --arg workflowFullRef "$GITHUB_REF" \ + --arg workflowRefType "$GITHUB_REF_TYPE" \ --arg targetRef "$TARGET_REF" \ --arg targetSha "$TARGET_SHA" \ --arg releaseProfile "$RELEASE_PROFILE" \ @@ -1835,11 +1957,14 @@ jobs: --arg packageAcceptancePackageSpec "$PACKAGE_ACCEPTANCE_PACKAGE_SPEC" \ --arg codexPluginSpec "$CODEX_PLUGIN_SPEC" \ '{ - version: 2, + version: 3, workflowName: $workflowName, runId: $runId, runAttempt: $runAttempt, workflowRef: $workflowRef, + workflowSha: $workflowSha, + workflowFullRef: $workflowFullRef, + workflowRefType: $workflowRefType, targetRef: $targetRef, targetSha: $targetSha, releaseProfile: $releaseProfile, @@ -1856,7 +1981,8 @@ jobs: }, controls: { stableSoakRequired: ($releaseProfile == "stable" or $releaseProfile == "full"), - performanceBlocking: true + performanceBlocking: true, + performanceReportPublication: "artifact-only" }, childRuns: { normalCi: $normalCiRunId, @@ -1872,9 +1998,18 @@ jobs: }' > "${manifest_dir}/full-release-validation-manifest.json" - name: Upload release validation manifest + if: ${{ success() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: full-release-validation-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/full-release-validation + if-no-files-found: error + + - name: Upload legacy release validation manifest alias if: ${{ success() }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: full-release-validation-${{ github.run_id }} path: ${{ runner.temp }}/full-release-validation if-no-files-found: error + overwrite: true diff --git a/.github/workflows/install-smoke.yml b/.github/workflows/install-smoke.yml index 5b2cccde0f9d..c180b9ed011a 100644 --- a/.github/workflows/install-smoke.yml +++ b/.github/workflows/install-smoke.yml @@ -15,6 +15,14 @@ on: required: false default: latest type: string + root_image_transport: + description: Root Dockerfile image transport + required: false + default: registry + type: choice + options: + - registry + - no-push-artifact workflow_call: inputs: ref: @@ -31,10 +39,16 @@ on: required: false default: latest type: string + root_image_transport: + description: Root Dockerfile image transport + required: false + default: registry + type: string permissions: + actions: read contents: read - packages: write + packages: read concurrency: group: ${{ (github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call') && format('{0}-{1}-{2}', github.workflow, github.event_name, github.run_id) || format('{0}-{1}', github.workflow, github.ref) }} @@ -54,7 +68,42 @@ jobs: run_bun_global_install_smoke: ${{ steps.manifest.outputs.run_bun_global_install_smoke }} target_sha: ${{ steps.manifest.outputs.target_sha }} dockerfile_image: ${{ steps.manifest.outputs.dockerfile_image }} + root_image_transport: ${{ steps.manifest.outputs.root_image_transport }} + workflow_repository: ${{ steps.workflow.outputs.workflow_repository }} + workflow_sha: ${{ steps.workflow.outputs.workflow_sha }} steps: + # github.workflow_sha identifies the caller during workflow_call. Resolve the called + # workflow SHA from job context so trusted harness checkouts cannot drift to candidate code. + - name: Resolve job workflow identity + id: workflow + env: + JOB_CONTEXT: ${{ toJSON(job) }} + shell: bash + run: | + set -euo pipefail + node --input-type=module <<'NODE' + import fs from "node:fs"; + + const job = JSON.parse(process.env.JOB_CONTEXT ?? "{}"); + if ( + typeof job.workflow_repository !== "string" || + !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(job.workflow_repository) + ) { + throw new Error("job.workflow_repository must be an owner/repository slug"); + } + if (typeof job.workflow_sha !== "string" || !/^[0-9a-f]{40}$/u.test(job.workflow_sha)) { + throw new Error("job.workflow_sha must be a full lowercase commit SHA"); + } + const outputPath = process.env.GITHUB_OUTPUT; + if (!outputPath) { + throw new Error("GITHUB_OUTPUT is required"); + } + fs.appendFileSync( + outputPath, + `workflow_repository=${job.workflow_repository}\nworkflow_sha=${job.workflow_sha}\n`, + ); + NODE + - name: Checkout uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: @@ -68,9 +117,12 @@ jobs: id: manifest env: OPENCLAW_CI_EVENT_NAME: ${{ github.event_name }} + OPENCLAW_CI_ROOT_IMAGE_TRANSPORT: ${{ inputs.root_image_transport || 'registry' }} OPENCLAW_CI_WORKFLOW_BUN_GLOBAL_INSTALL_SMOKE: ${{ inputs.run_bun_global_install_smoke || 'false' }} run: | + set -euo pipefail event_name="${OPENCLAW_CI_EVENT_NAME:-}" + root_image_transport="${OPENCLAW_CI_ROOT_IMAGE_TRANSPORT:-registry}" workflow_bun_global_install_smoke="${OPENCLAW_CI_WORKFLOW_BUN_GLOBAL_INSTALL_SMOKE:-false}" docs_only=false run_fast_install_smoke=true @@ -79,7 +131,18 @@ jobs: run_install_smoke=true target_sha="$(git rev-parse HEAD)" owner="$(printf '%s' "${GITHUB_REPOSITORY_OWNER:-openclaw}" | tr '[:upper:]' '[:lower:]')" - dockerfile_image="ghcr.io/${owner}/openclaw-dockerfile-smoke:${target_sha}" + case "$root_image_transport" in + registry) + dockerfile_image="ghcr.io/${owner}/openclaw-dockerfile-smoke:${target_sha}" + ;; + no-push-artifact) + dockerfile_image="openclaw-dockerfile-smoke-local:${target_sha}" + ;; + *) + echo "root_image_transport must be registry or no-push-artifact." >&2 + exit 1 + ;; + esac if [ "$event_name" = "schedule" ]; then run_bun_global_install_smoke=true elif [ "$event_name" = "workflow_dispatch" ] || [ "$event_name" = "workflow_call" ]; then @@ -95,6 +158,7 @@ jobs: echo "run_bun_global_install_smoke=$run_bun_global_install_smoke" echo "target_sha=$target_sha" echo "dockerfile_image=$dockerfile_image" + echo "root_image_transport=$root_image_transport" } >> "$GITHUB_OUTPUT" install-smoke-fast: @@ -104,11 +168,12 @@ jobs: env: DOCKER_BUILD_SUMMARY: "false" DOCKER_BUILD_RECORD_UPLOAD: "false" + OPENCLAW_DOCKER_E2E_REQUIRE_LOCAL_IMAGE: "1" steps: - name: Checkout CLI uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: - ref: ${{ inputs.ref || github.ref }} + ref: ${{ needs.preflight.outputs.target_sha }} persist-credentials: false - name: Set up Blacksmith Docker Builder @@ -210,7 +275,17 @@ jobs: needs: [preflight] if: needs.preflight.outputs.run_full_install_smoke == 'true' runs-on: ubuntu-24.04 + permissions: + contents: read + packages: read outputs: + archive_sha256: ${{ steps.image_artifact.outputs.archive_sha256 }} + artifact_digest: ${{ steps.image_artifact_upload.outputs.artifact-digest }} + artifact_id: ${{ steps.image_artifact_upload.outputs.artifact-id }} + artifact_name: ${{ steps.image_artifact.outputs.artifact_name }} + artifact_run_attempt: ${{ steps.image_artifact.outputs.run_attempt }} + artifact_run_id: ${{ steps.image_artifact.outputs.run_id }} + image_exists: ${{ steps.existing.outputs.exists }} image_ref: ${{ steps.image.outputs.image_ref }} env: DOCKER_BUILD_SUMMARY: "false" @@ -219,10 +294,20 @@ jobs: - name: Checkout CLI uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: - ref: ${{ inputs.ref || github.ref }} + ref: ${{ needs.preflight.outputs.target_sha }} + persist-credentials: false + + - name: Checkout trusted image artifact helper + if: needs.preflight.outputs.root_image_transport == 'no-push-artifact' + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + repository: ${{ needs.preflight.outputs.workflow_repository }} + ref: ${{ needs.preflight.outputs.workflow_sha }} + path: .release-harness persist-credentials: false - name: Log in to GHCR + if: needs.preflight.outputs.root_image_transport == 'registry' uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 with: registry: ghcr.io @@ -231,6 +316,7 @@ jobs: - name: Check for existing root Dockerfile smoke image id: existing + if: needs.preflight.outputs.root_image_transport == 'registry' env: IMAGE_REF: ${{ needs.preflight.outputs.dockerfile_image }} run: | @@ -244,26 +330,60 @@ jobs: fi - name: Set up Blacksmith Docker Builder - if: steps.existing.outputs.exists != 'true' + if: needs.preflight.outputs.root_image_transport == 'no-push-artifact' uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1 with: max-cache-size-mb: 800000 - # Build once with the matrix extension and publish by target SHA. Use a - # direct buildx command so release jobs emit Docker progress and time out. - - name: Build and push root Dockerfile smoke image - if: steps.existing.outputs.exists != 'true' + - name: Build local root Dockerfile smoke image + if: needs.preflight.outputs.root_image_transport == 'no-push-artifact' env: IMAGE_REF: ${{ needs.preflight.outputs.dockerfile_image }} run: | timeout --kill-after=30s 45m docker buildx build \ --progress=plain \ - --push \ + --load \ --build-arg OPENCLAW_EXTENSIONS=matrix \ -t "$IMAGE_REF" \ -f ./Dockerfile \ . + - name: Pack root Dockerfile image artifact + id: image_artifact + if: needs.preflight.outputs.root_image_transport == 'no-push-artifact' + env: + IMAGE_REF: ${{ needs.preflight.outputs.dockerfile_image }} + TARGET_SHA: ${{ needs.preflight.outputs.target_sha }} + WORKFLOW_SHA: ${{ needs.preflight.outputs.workflow_sha }} + run: | + set -euo pipefail + artifact_dir="${RUNNER_TEMP}/install-smoke-root-image" + artifact_name="install-smoke-root-image-${TARGET_SHA:0:12}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + bash .release-harness/scripts/docker/shared-image-artifact.sh \ + pack "$artifact_dir" install-smoke-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" + echo "run_attempt=$GITHUB_RUN_ATTEMPT" + echo "run_id=$GITHUB_RUN_ID" + } >> "$GITHUB_OUTPUT" + + - name: Upload root Dockerfile image artifact + id: image_artifact_upload + if: needs.preflight.outputs.root_image_transport == 'no-push-artifact' + 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 + - name: Record root image output id: image env: @@ -273,6 +393,7 @@ jobs: - name: Summarize root image env: IMAGE_REF: ${{ needs.preflight.outputs.dockerfile_image }} + ROOT_IMAGE_TRANSPORT: ${{ needs.preflight.outputs.root_image_transport }} TARGET_SHA: ${{ needs.preflight.outputs.target_sha }} run: | { @@ -280,34 +401,29 @@ jobs: echo echo "- Target SHA: \`${TARGET_SHA}\`" echo "- Image: \`${IMAGE_REF}\`" - echo "- Reused existing image: \`${{ steps.existing.outputs.exists }}\`" + echo "- Transport: \`${ROOT_IMAGE_TRANSPORT}\`" + if [[ "$ROOT_IMAGE_TRANSPORT" == "registry" ]]; then + echo "- Reused existing image: \`${{ steps.existing.outputs.exists }}\`" + else + echo "- Artifact: \`${{ steps.image_artifact.outputs.artifact_name }}\`" + fi } >> "$GITHUB_STEP_SUMMARY" - qr_package_install_smoke: - needs: [preflight] - if: needs.preflight.outputs.run_full_install_smoke == 'true' - runs-on: ubuntu-24.04 - steps: - - name: Checkout CLI - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - with: - ref: ${{ inputs.ref || github.ref }} - persist-credentials: false - - - name: Run QR package install smoke - env: - OPENCLAW_QR_SMOKE_FORCE_INSTALL: "1" - run: bash scripts/e2e/qr-import-docker.sh - - root_dockerfile_smokes: + push_root_dockerfile_image: needs: [preflight, root_dockerfile_image] - if: needs.preflight.outputs.run_full_install_smoke == 'true' + if: needs.preflight.outputs.root_image_transport == 'registry' && needs.root_dockerfile_image.outputs.image_exists != 'true' runs-on: ubuntu-24.04 + permissions: + contents: read + packages: write + env: + DOCKER_BUILD_SUMMARY: "false" + DOCKER_BUILD_RECORD_UPLOAD: "false" steps: - name: Checkout CLI uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: - ref: ${{ inputs.ref || github.ref }} + ref: ${{ needs.preflight.outputs.target_sha }} persist-credentials: false - name: Log in to GHCR @@ -317,11 +433,203 @@ jobs: username: ${{ github.actor }} password: ${{ github.token }} + - name: Set up Blacksmith Docker Builder + uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1 + with: + max-cache-size-mb: 800000 + + # The registry path publishes one matrix-extension image by target SHA. + # A direct buildx command keeps progress visible and fails on timeout. + - name: Build and push root Dockerfile smoke image + env: + IMAGE_REF: ${{ needs.root_dockerfile_image.outputs.image_ref }} + run: | + timeout --kill-after=30s 45m docker buildx build \ + --progress=plain \ + --push \ + --build-arg OPENCLAW_EXTENSIONS=matrix \ + -t "$IMAGE_REF" \ + -f ./Dockerfile \ + . + + root_dockerfile_image_ready: + needs: [preflight, root_dockerfile_image, push_root_dockerfile_image] + if: always() && needs.preflight.result == 'success' && needs.preflight.outputs.run_full_install_smoke == 'true' + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - name: Verify root Dockerfile image preparation + env: + IMAGE_EXISTS: ${{ needs.root_dockerfile_image.outputs.image_exists }} + PREPARE_RESULT: ${{ needs.root_dockerfile_image.result }} + PUSH_RESULT: ${{ needs.push_root_dockerfile_image.result }} + ROOT_IMAGE_TRANSPORT: ${{ needs.preflight.outputs.root_image_transport }} + run: | + set -euo pipefail + if [[ "$PREPARE_RESULT" != "success" ]]; then + echo "Root Dockerfile image preparation ended with ${PREPARE_RESULT}." >&2 + exit 1 + fi + if [[ "$ROOT_IMAGE_TRANSPORT" == "registry" && "$IMAGE_EXISTS" != "true" ]]; then + if [[ "$PUSH_RESULT" != "success" ]]; then + echo "Root Dockerfile registry image publication ended with ${PUSH_RESULT}." >&2 + exit 1 + fi + elif [[ "$PUSH_RESULT" != "skipped" ]]; then + echo "Unexpected root Dockerfile registry publication result: ${PUSH_RESULT}." >&2 + exit 1 + fi + + qr_package_install_smoke: + needs: [preflight] + if: needs.preflight.outputs.run_full_install_smoke == 'true' + runs-on: ubuntu-24.04 + steps: + - name: Checkout CLI + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + ref: ${{ needs.preflight.outputs.target_sha }} + persist-credentials: false + + - name: Run QR package install smoke + env: + OPENCLAW_QR_SMOKE_FORCE_INSTALL: "1" + run: bash scripts/e2e/qr-import-docker.sh + + root_dockerfile_smokes: + needs: [preflight, root_dockerfile_image, root_dockerfile_image_ready] + if: needs.preflight.outputs.run_full_install_smoke == 'true' + runs-on: ubuntu-24.04 + env: + OPENCLAW_DOCKER_E2E_REQUIRE_LOCAL_IMAGE: ${{ needs.preflight.outputs.root_image_transport == 'no-push-artifact' && '1' || '0' }} + steps: + - name: Checkout CLI + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + ref: ${{ needs.preflight.outputs.target_sha }} + persist-credentials: false + + - name: Checkout trusted image artifact helper + if: needs.preflight.outputs.root_image_transport == 'no-push-artifact' + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + repository: ${{ needs.preflight.outputs.workflow_repository }} + ref: ${{ needs.preflight.outputs.workflow_sha }} + path: .release-harness + persist-credentials: false + + - name: Log in to GHCR + if: needs.preflight.outputs.root_image_transport == 'registry' + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + - name: Pull root Dockerfile smoke image + if: needs.preflight.outputs.root_image_transport == 'registry' env: IMAGE_REF: ${{ needs.root_dockerfile_image.outputs.image_ref }} run: timeout --kill-after=30s 600s docker pull "$IMAGE_REF" + - name: Validate root Dockerfile image artifact binding + if: needs.preflight.outputs.root_image_transport == 'no-push-artifact' + env: + ARCHIVE_SHA256: ${{ needs.root_dockerfile_image.outputs.archive_sha256 }} + ARTIFACT_DIGEST: ${{ needs.root_dockerfile_image.outputs.artifact_digest }} + ARTIFACT_ID: ${{ needs.root_dockerfile_image.outputs.artifact_id }} + ARTIFACT_NAME: ${{ needs.root_dockerfile_image.outputs.artifact_name }} + ARTIFACT_RUN_ATTEMPT: ${{ needs.root_dockerfile_image.outputs.artifact_run_attempt }} + ARTIFACT_RUN_ID: ${{ needs.root_dockerfile_image.outputs.artifact_run_id }} + GH_TOKEN: ${{ github.token }} + TARGET_SHA: ${{ needs.preflight.outputs.target_sha }} + run: | + set -euo pipefail + [[ "$ARTIFACT_ID" =~ ^[1-9][0-9]*$ ]] || { + echo "Root image artifact ID is missing or invalid." >&2 + exit 1 + } + [[ "$ARTIFACT_DIGEST" =~ ^[a-f0-9]{64}$ ]] || { + echo "Root image artifact digest is missing or invalid." >&2 + exit 1 + } + [[ "$ARCHIVE_SHA256" =~ ^[a-f0-9]{64}$ ]] || { + echo "Root image archive SHA-256 is missing or invalid." >&2 + exit 1 + } + [[ "$ARTIFACT_RUN_ID" =~ ^[1-9][0-9]*$ ]] || { + echo "Root image artifact run ID is missing or invalid." >&2 + exit 1 + } + [[ "$ARTIFACT_RUN_ATTEMPT" =~ ^[1-9][0-9]*$ ]] || { + echo "Root image artifact run attempt is missing or invalid." >&2 + exit 1 + } + expected_artifact_name="install-smoke-root-image-${TARGET_SHA:0:12}-${ARTIFACT_RUN_ID}-${ARTIFACT_RUN_ATTEMPT}" + [[ "$ARTIFACT_NAME" == "$expected_artifact_name" ]] || { + echo "Root image artifact name does not match the target and producer run attempt." >&2 + exit 1 + } + artifact_json="$(gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}")" + jq -e \ + --arg digest "sha256:${ARTIFACT_DIGEST}" \ + --arg id "$ARTIFACT_ID" \ + --arg name "$ARTIFACT_NAME" \ + --arg run_id "$ARTIFACT_RUN_ID" \ + ' + (.id | tostring) == $id and + .name == $name and + .expired == false and + .digest == $digest and + (.workflow_run.id | tostring) == $run_id + ' <<< "$artifact_json" >/dev/null || { + echo "Root image artifact identity does not match the requested immutable tuple." >&2 + exit 1 + } + attempt_json="$( + gh api \ + "repos/${GITHUB_REPOSITORY}/actions/runs/${ARTIFACT_RUN_ID}/attempts/${ARTIFACT_RUN_ATTEMPT}" + )" + jq -e \ + --arg attempt "$ARTIFACT_RUN_ATTEMPT" \ + --arg run_id "$ARTIFACT_RUN_ID" \ + '(.id | tostring) == $run_id and (.run_attempt | tostring) == $attempt' \ + <<< "$attempt_json" >/dev/null || { + echo "Root image artifact producer run attempt does not match the requested tuple." >&2 + exit 1 + } + + - name: Download root Dockerfile image artifact + if: needs.preflight.outputs.root_image_transport == 'no-push-artifact' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + artifact-ids: ${{ needs.root_dockerfile_image.outputs.artifact_id }} + path: ${{ runner.temp }}/install-smoke-root-image + run-id: ${{ needs.root_dockerfile_image.outputs.artifact_run_id }} + github-token: ${{ github.token }} + + - name: Verify and load root Dockerfile image artifact + if: needs.preflight.outputs.root_image_transport == 'no-push-artifact' + env: + IMAGE_REF: ${{ needs.root_dockerfile_image.outputs.image_ref }} + OPENCLAW_SHARED_IMAGE_ARCHIVE_SHA256: ${{ needs.root_dockerfile_image.outputs.archive_sha256 }} + OPENCLAW_SHARED_IMAGE_RUN_ATTEMPT: ${{ needs.root_dockerfile_image.outputs.artifact_run_attempt }} + OPENCLAW_SHARED_IMAGE_RUN_ID: ${{ needs.root_dockerfile_image.outputs.artifact_run_id }} + TARGET_SHA: ${{ needs.preflight.outputs.target_sha }} + WORKFLOW_SHA: ${{ needs.preflight.outputs.workflow_sha }} + run: | + set -euo pipefail + bash .release-harness/scripts/docker/shared-image-artifact.sh \ + load "${RUNNER_TEMP}/install-smoke-root-image" install-smoke-root \ + "$TARGET_SHA" "$WORKFLOW_SHA" "$IMAGE_REF" + + - name: Require local root Dockerfile image + if: needs.preflight.outputs.root_image_transport == 'no-push-artifact' + env: + IMAGE_REF: ${{ needs.root_dockerfile_image.outputs.image_ref }} + run: docker image inspect "$IMAGE_REF" >/dev/null + - name: Run root Dockerfile CLI smoke env: IMAGE_REF: ${{ needs.root_dockerfile_image.outputs.image_ref }} @@ -403,27 +711,39 @@ jobs: ' installer_smoke: - needs: [preflight, root_dockerfile_image] + needs: [preflight, root_dockerfile_image, root_dockerfile_image_ready] if: needs.preflight.outputs.run_full_install_smoke == 'true' runs-on: ubuntu-24.04 env: DOCKER_BUILD_SUMMARY: "false" DOCKER_BUILD_RECORD_UPLOAD: "false" + OPENCLAW_DOCKER_E2E_REQUIRE_LOCAL_IMAGE: ${{ needs.preflight.outputs.root_image_transport == 'no-push-artifact' && '1' || '0' }} steps: - name: Checkout trusted installer harness uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: - ref: ${{ github.workflow_sha }} + repository: ${{ needs.preflight.outputs.workflow_repository }} + ref: ${{ needs.preflight.outputs.workflow_sha }} persist-credentials: false - name: Checkout candidate CLI uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: - ref: ${{ inputs.ref || github.ref }} + ref: ${{ needs.preflight.outputs.target_sha }} path: candidate persist-credentials: false + - name: Checkout trusted image artifact helper + if: needs.preflight.outputs.root_image_transport == 'no-push-artifact' + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + repository: ${{ needs.preflight.outputs.workflow_repository }} + ref: ${{ needs.preflight.outputs.workflow_sha }} + path: .release-harness + persist-credentials: false + - name: Log in to GHCR + if: needs.preflight.outputs.root_image_transport == 'registry' uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 with: registry: ghcr.io @@ -431,10 +751,108 @@ jobs: password: ${{ github.token }} - name: Pull root Dockerfile smoke image + if: needs.preflight.outputs.root_image_transport == 'registry' env: IMAGE_REF: ${{ needs.root_dockerfile_image.outputs.image_ref }} run: timeout --kill-after=30s 600s docker pull "$IMAGE_REF" + - name: Validate root Dockerfile image artifact binding + if: needs.preflight.outputs.root_image_transport == 'no-push-artifact' + env: + ARCHIVE_SHA256: ${{ needs.root_dockerfile_image.outputs.archive_sha256 }} + ARTIFACT_DIGEST: ${{ needs.root_dockerfile_image.outputs.artifact_digest }} + ARTIFACT_ID: ${{ needs.root_dockerfile_image.outputs.artifact_id }} + ARTIFACT_NAME: ${{ needs.root_dockerfile_image.outputs.artifact_name }} + ARTIFACT_RUN_ATTEMPT: ${{ needs.root_dockerfile_image.outputs.artifact_run_attempt }} + ARTIFACT_RUN_ID: ${{ needs.root_dockerfile_image.outputs.artifact_run_id }} + GH_TOKEN: ${{ github.token }} + TARGET_SHA: ${{ needs.preflight.outputs.target_sha }} + run: | + set -euo pipefail + [[ "$ARTIFACT_ID" =~ ^[1-9][0-9]*$ ]] || { + echo "Root image artifact ID is missing or invalid." >&2 + exit 1 + } + [[ "$ARTIFACT_DIGEST" =~ ^[a-f0-9]{64}$ ]] || { + echo "Root image artifact digest is missing or invalid." >&2 + exit 1 + } + [[ "$ARCHIVE_SHA256" =~ ^[a-f0-9]{64}$ ]] || { + echo "Root image archive SHA-256 is missing or invalid." >&2 + exit 1 + } + [[ "$ARTIFACT_RUN_ID" =~ ^[1-9][0-9]*$ ]] || { + echo "Root image artifact run ID is missing or invalid." >&2 + exit 1 + } + [[ "$ARTIFACT_RUN_ATTEMPT" =~ ^[1-9][0-9]*$ ]] || { + echo "Root image artifact run attempt is missing or invalid." >&2 + exit 1 + } + expected_artifact_name="install-smoke-root-image-${TARGET_SHA:0:12}-${ARTIFACT_RUN_ID}-${ARTIFACT_RUN_ATTEMPT}" + [[ "$ARTIFACT_NAME" == "$expected_artifact_name" ]] || { + echo "Root image artifact name does not match the target and producer run attempt." >&2 + exit 1 + } + artifact_json="$(gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}")" + jq -e \ + --arg digest "sha256:${ARTIFACT_DIGEST}" \ + --arg id "$ARTIFACT_ID" \ + --arg name "$ARTIFACT_NAME" \ + --arg run_id "$ARTIFACT_RUN_ID" \ + ' + (.id | tostring) == $id and + .name == $name and + .expired == false and + .digest == $digest and + (.workflow_run.id | tostring) == $run_id + ' <<< "$artifact_json" >/dev/null || { + echo "Root image artifact identity does not match the requested immutable tuple." >&2 + exit 1 + } + attempt_json="$( + gh api \ + "repos/${GITHUB_REPOSITORY}/actions/runs/${ARTIFACT_RUN_ID}/attempts/${ARTIFACT_RUN_ATTEMPT}" + )" + jq -e \ + --arg attempt "$ARTIFACT_RUN_ATTEMPT" \ + --arg run_id "$ARTIFACT_RUN_ID" \ + '(.id | tostring) == $run_id and (.run_attempt | tostring) == $attempt' \ + <<< "$attempt_json" >/dev/null || { + echo "Root image artifact producer run attempt does not match the requested tuple." >&2 + exit 1 + } + + - name: Download root Dockerfile image artifact + if: needs.preflight.outputs.root_image_transport == 'no-push-artifact' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + artifact-ids: ${{ needs.root_dockerfile_image.outputs.artifact_id }} + path: ${{ runner.temp }}/install-smoke-root-image + run-id: ${{ needs.root_dockerfile_image.outputs.artifact_run_id }} + github-token: ${{ github.token }} + + - name: Verify and load root Dockerfile image artifact + if: needs.preflight.outputs.root_image_transport == 'no-push-artifact' + env: + IMAGE_REF: ${{ needs.root_dockerfile_image.outputs.image_ref }} + OPENCLAW_SHARED_IMAGE_ARCHIVE_SHA256: ${{ needs.root_dockerfile_image.outputs.archive_sha256 }} + OPENCLAW_SHARED_IMAGE_RUN_ATTEMPT: ${{ needs.root_dockerfile_image.outputs.artifact_run_attempt }} + OPENCLAW_SHARED_IMAGE_RUN_ID: ${{ needs.root_dockerfile_image.outputs.artifact_run_id }} + TARGET_SHA: ${{ needs.preflight.outputs.target_sha }} + WORKFLOW_SHA: ${{ needs.preflight.outputs.workflow_sha }} + run: | + set -euo pipefail + bash .release-harness/scripts/docker/shared-image-artifact.sh \ + load "${RUNNER_TEMP}/install-smoke-root-image" install-smoke-root \ + "$TARGET_SHA" "$WORKFLOW_SHA" "$IMAGE_REF" + + - name: Require local root Dockerfile image + if: needs.preflight.outputs.root_image_transport == 'no-push-artifact' + env: + IMAGE_REF: ${{ needs.root_dockerfile_image.outputs.image_ref }} + run: docker image inspect "$IMAGE_REF" >/dev/null + - name: Set up Blacksmith Docker Builder uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1 with: @@ -502,17 +920,29 @@ jobs: bash -lc 'dnf install -y -q ca-certificates tar gzip xz findutils which sudo >/dev/null && bash /tmp/install-cli.sh --prefix /tmp/openclaw-cli --version latest --no-onboard && /tmp/openclaw-cli/bin/openclaw --version' bun_global_install_smoke: - needs: [preflight, root_dockerfile_image] + needs: [preflight, root_dockerfile_image, root_dockerfile_image_ready] if: needs.preflight.outputs.run_full_install_smoke == 'true' && needs.preflight.outputs.run_bun_global_install_smoke == 'true' runs-on: ubuntu-24.04 + env: + OPENCLAW_DOCKER_E2E_REQUIRE_LOCAL_IMAGE: ${{ needs.preflight.outputs.root_image_transport == 'no-push-artifact' && '1' || '0' }} steps: - name: Checkout CLI uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: - ref: ${{ inputs.ref || github.ref }} + ref: ${{ needs.preflight.outputs.target_sha }} + persist-credentials: false + + - name: Checkout trusted image artifact helper + if: needs.preflight.outputs.root_image_transport == 'no-push-artifact' + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + repository: ${{ needs.preflight.outputs.workflow_repository }} + ref: ${{ needs.preflight.outputs.workflow_sha }} + path: .release-harness persist-credentials: false - name: Log in to GHCR + if: needs.preflight.outputs.root_image_transport == 'registry' uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 with: registry: ghcr.io @@ -520,10 +950,108 @@ jobs: password: ${{ github.token }} - name: Pull root Dockerfile smoke image + if: needs.preflight.outputs.root_image_transport == 'registry' env: IMAGE_REF: ${{ needs.root_dockerfile_image.outputs.image_ref }} run: timeout --kill-after=30s 600s docker pull "$IMAGE_REF" + - name: Validate root Dockerfile image artifact binding + if: needs.preflight.outputs.root_image_transport == 'no-push-artifact' + env: + ARCHIVE_SHA256: ${{ needs.root_dockerfile_image.outputs.archive_sha256 }} + ARTIFACT_DIGEST: ${{ needs.root_dockerfile_image.outputs.artifact_digest }} + ARTIFACT_ID: ${{ needs.root_dockerfile_image.outputs.artifact_id }} + ARTIFACT_NAME: ${{ needs.root_dockerfile_image.outputs.artifact_name }} + ARTIFACT_RUN_ATTEMPT: ${{ needs.root_dockerfile_image.outputs.artifact_run_attempt }} + ARTIFACT_RUN_ID: ${{ needs.root_dockerfile_image.outputs.artifact_run_id }} + GH_TOKEN: ${{ github.token }} + TARGET_SHA: ${{ needs.preflight.outputs.target_sha }} + run: | + set -euo pipefail + [[ "$ARTIFACT_ID" =~ ^[1-9][0-9]*$ ]] || { + echo "Root image artifact ID is missing or invalid." >&2 + exit 1 + } + [[ "$ARTIFACT_DIGEST" =~ ^[a-f0-9]{64}$ ]] || { + echo "Root image artifact digest is missing or invalid." >&2 + exit 1 + } + [[ "$ARCHIVE_SHA256" =~ ^[a-f0-9]{64}$ ]] || { + echo "Root image archive SHA-256 is missing or invalid." >&2 + exit 1 + } + [[ "$ARTIFACT_RUN_ID" =~ ^[1-9][0-9]*$ ]] || { + echo "Root image artifact run ID is missing or invalid." >&2 + exit 1 + } + [[ "$ARTIFACT_RUN_ATTEMPT" =~ ^[1-9][0-9]*$ ]] || { + echo "Root image artifact run attempt is missing or invalid." >&2 + exit 1 + } + expected_artifact_name="install-smoke-root-image-${TARGET_SHA:0:12}-${ARTIFACT_RUN_ID}-${ARTIFACT_RUN_ATTEMPT}" + [[ "$ARTIFACT_NAME" == "$expected_artifact_name" ]] || { + echo "Root image artifact name does not match the target and producer run attempt." >&2 + exit 1 + } + artifact_json="$(gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}")" + jq -e \ + --arg digest "sha256:${ARTIFACT_DIGEST}" \ + --arg id "$ARTIFACT_ID" \ + --arg name "$ARTIFACT_NAME" \ + --arg run_id "$ARTIFACT_RUN_ID" \ + ' + (.id | tostring) == $id and + .name == $name and + .expired == false and + .digest == $digest and + (.workflow_run.id | tostring) == $run_id + ' <<< "$artifact_json" >/dev/null || { + echo "Root image artifact identity does not match the requested immutable tuple." >&2 + exit 1 + } + attempt_json="$( + gh api \ + "repos/${GITHUB_REPOSITORY}/actions/runs/${ARTIFACT_RUN_ID}/attempts/${ARTIFACT_RUN_ATTEMPT}" + )" + jq -e \ + --arg attempt "$ARTIFACT_RUN_ATTEMPT" \ + --arg run_id "$ARTIFACT_RUN_ID" \ + '(.id | tostring) == $run_id and (.run_attempt | tostring) == $attempt' \ + <<< "$attempt_json" >/dev/null || { + echo "Root image artifact producer run attempt does not match the requested tuple." >&2 + exit 1 + } + + - name: Download root Dockerfile image artifact + if: needs.preflight.outputs.root_image_transport == 'no-push-artifact' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + artifact-ids: ${{ needs.root_dockerfile_image.outputs.artifact_id }} + path: ${{ runner.temp }}/install-smoke-root-image + run-id: ${{ needs.root_dockerfile_image.outputs.artifact_run_id }} + github-token: ${{ github.token }} + + - name: Verify and load root Dockerfile image artifact + if: needs.preflight.outputs.root_image_transport == 'no-push-artifact' + env: + IMAGE_REF: ${{ needs.root_dockerfile_image.outputs.image_ref }} + OPENCLAW_SHARED_IMAGE_ARCHIVE_SHA256: ${{ needs.root_dockerfile_image.outputs.archive_sha256 }} + OPENCLAW_SHARED_IMAGE_RUN_ATTEMPT: ${{ needs.root_dockerfile_image.outputs.artifact_run_attempt }} + OPENCLAW_SHARED_IMAGE_RUN_ID: ${{ needs.root_dockerfile_image.outputs.artifact_run_id }} + TARGET_SHA: ${{ needs.preflight.outputs.target_sha }} + WORKFLOW_SHA: ${{ needs.preflight.outputs.workflow_sha }} + run: | + set -euo pipefail + bash .release-harness/scripts/docker/shared-image-artifact.sh \ + load "${RUNNER_TEMP}/install-smoke-root-image" install-smoke-root \ + "$TARGET_SHA" "$WORKFLOW_SHA" "$IMAGE_REF" + + - name: Require local root Dockerfile image + if: needs.preflight.outputs.root_image_transport == 'no-push-artifact' + env: + IMAGE_REF: ${{ needs.root_dockerfile_image.outputs.image_ref }} + run: docker image inspect "$IMAGE_REF" >/dev/null + - name: Setup Node environment for Bun smoke uses: ./.github/actions/setup-node-env with: @@ -548,7 +1076,7 @@ jobs: - name: Checkout CLI uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: - ref: ${{ inputs.ref || github.ref }} + ref: ${{ needs.preflight.outputs.target_sha }} persist-credentials: false - name: Set up Blacksmith Docker Builder diff --git a/.github/workflows/npm-telegram-beta-e2e.yml b/.github/workflows/npm-telegram-beta-e2e.yml index ad257f6294f0..8c760fd95e7d 100644 --- a/.github/workflows/npm-telegram-beta-e2e.yml +++ b/.github/workflows/npm-telegram-beta-e2e.yml @@ -20,8 +20,43 @@ on: required: false default: "" type: string + package_artifact_id: + description: Immutable GitHub artifact id for package_artifact_name + required: false + default: "" + type: string + package_artifact_digest: + description: GitHub artifact service SHA-256 digest without the sha256 prefix + required: false + default: "" + type: string + package_sha256: + description: Expected SHA-256 for the OpenClaw package tarball + required: false + default: "" + type: string package_artifact_run_id: - description: Advanced run id containing package_artifact_name; blank downloads from this run + description: Producer run id containing package_artifact_name + required: false + default: "" + type: string + package_artifact_run_attempt: + description: Producer run attempt containing package_artifact_name + required: false + default: "" + type: string + package_file_name: + description: Exact OpenClaw tarball filename inside package_artifact_name + required: false + default: "" + type: string + package_source_sha: + description: Exact source commit recorded in the package tarball + required: false + default: "" + type: string + package_version: + description: Exact OpenClaw package version required: false default: "" type: string @@ -68,8 +103,43 @@ on: required: false default: "" type: string + package_artifact_digest: + description: GitHub artifact service SHA-256 digest without the sha256 prefix + required: false + default: "" + type: string package_artifact_run_id: - description: Optional run id containing package_artifact_name + description: Producer run id containing package_artifact_name + required: false + default: "" + type: string + package_artifact_run_attempt: + description: Producer run attempt containing package_artifact_name + required: false + default: "" + type: string + package_artifact_id: + description: Immutable GitHub artifact id for package_artifact_name + required: false + default: "" + type: string + package_sha256: + description: Expected SHA-256 for the OpenClaw package tarball + required: false + default: "" + type: string + package_file_name: + description: Exact OpenClaw tarball filename inside package_artifact_name + required: false + default: "" + type: string + package_source_sha: + description: Exact source commit recorded in the package tarball + required: false + default: "" + type: string + package_version: + description: Exact OpenClaw package version required: false default: "" type: string @@ -136,6 +206,7 @@ jobs: with: ref: ${{ inputs.harness_ref || github.sha }} fetch-depth: 1 + persist-credentials: false - name: Set up Blacksmith Docker Builder uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1 @@ -163,7 +234,15 @@ jobs: - name: Validate inputs and secrets env: PACKAGE_SPEC: ${{ inputs.package_spec }} + PACKAGE_ARTIFACT_DIGEST: ${{ inputs.package_artifact_digest || '' }} + PACKAGE_ARTIFACT_ID: ${{ inputs.package_artifact_id || '' }} PACKAGE_ARTIFACT_NAME: ${{ inputs.package_artifact_name || '' }} + PACKAGE_ARTIFACT_RUN_ATTEMPT: ${{ inputs.package_artifact_run_attempt || '' }} + PACKAGE_ARTIFACT_RUN_ID: ${{ inputs.package_artifact_run_id || '' }} + PACKAGE_FILE_NAME: ${{ inputs.package_file_name || '' }} + PACKAGE_SHA256: ${{ inputs.package_sha256 || '' }} + PACKAGE_SOURCE_SHA: ${{ inputs.package_source_sha || '' }} + PACKAGE_VERSION: ${{ inputs.package_version || '' }} PROVIDER_MODE: ${{ inputs.provider_mode }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }} @@ -172,11 +251,40 @@ jobs: run: | set -euo pipefail + artifact_tuple_present=0 + for value in \ + "$PACKAGE_ARTIFACT_DIGEST" \ + "$PACKAGE_ARTIFACT_ID" \ + "$PACKAGE_ARTIFACT_NAME" \ + "$PACKAGE_ARTIFACT_RUN_ATTEMPT" \ + "$PACKAGE_ARTIFACT_RUN_ID" \ + "$PACKAGE_FILE_NAME" \ + "$PACKAGE_SHA256" \ + "$PACKAGE_SOURCE_SHA" \ + "$PACKAGE_VERSION"; do + if [[ -n "${value// }" ]]; then + artifact_tuple_present=1 + fi + done if [[ -z "${PACKAGE_ARTIFACT_NAME// }" ]]; then + if [[ "$artifact_tuple_present" == "1" ]]; then + echo "Artifact-backed Telegram E2E requires all artifact identity fields or none." >&2 + exit 1 + fi if [[ ! "${PACKAGE_SPEC}" =~ ^openclaw@(alpha|beta|latest|[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-[1-9][0-9]*|-(alpha|beta)\.[1-9][0-9]*)?)$ ]]; then echo "package_spec must be openclaw@alpha, openclaw@beta, openclaw@latest, or an exact OpenClaw release version; got: ${PACKAGE_SPEC}" >&2 exit 1 fi + elif [[ ! "$PACKAGE_ARTIFACT_DIGEST" =~ ^[0-9a-f]{64}$ || + ! "$PACKAGE_ARTIFACT_ID" =~ ^[1-9][0-9]*$ || + ! "$PACKAGE_ARTIFACT_RUN_ATTEMPT" =~ ^[1-9][0-9]*$ || + ! "$PACKAGE_ARTIFACT_RUN_ID" =~ ^[1-9][0-9]*$ || + ! "$PACKAGE_FILE_NAME" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*\.tgz$ || + ! "$PACKAGE_SHA256" =~ ^[0-9a-f]{64}$ || + ! "$PACKAGE_SOURCE_SHA" =~ ^[0-9a-f]{40}$ || + -z "${PACKAGE_VERSION// }" ]]; then + echo "Artifact-backed Telegram E2E requires the complete immutable artifact and package identity tuple." >&2 + exit 1 fi case "${PROVIDER_MODE}" in mock-openai | live-frontier) ;; @@ -200,18 +308,65 @@ jobs: require_var OPENAI_API_KEY fi + - name: Validate package artifact identity + if: inputs.package_artifact_name != '' + env: + ARTIFACT_DIGEST: ${{ inputs.package_artifact_digest }} + ARTIFACT_ID: ${{ inputs.package_artifact_id }} + ARTIFACT_NAME: ${{ inputs.package_artifact_name }} + ARTIFACT_RUN_ATTEMPT: ${{ inputs.package_artifact_run_attempt }} + ARTIFACT_RUN_ID: ${{ inputs.package_artifact_run_id }} + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + [[ "$ARTIFACT_NAME" == *"-${ARTIFACT_RUN_ID}-${ARTIFACT_RUN_ATTEMPT}" ]] || { + echo "Package Telegram artifact name does not bind the declared producer run attempt." >&2 + exit 1 + } + artifact_json="$(gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}")" + jq -e \ + --arg digest "sha256:${ARTIFACT_DIGEST}" \ + --arg id "$ARTIFACT_ID" \ + --arg name "$ARTIFACT_NAME" \ + --arg run_id "$ARTIFACT_RUN_ID" \ + ' + (.id | tostring) == $id and + .name == $name and + .expired == false and + .digest == $digest and + (.workflow_run.id | tostring) == $run_id + ' <<< "$artifact_json" >/dev/null || { + echo "Package Telegram artifact identity does not match the requested immutable tuple." >&2 + exit 1 + } + attempt_json="$( + gh api \ + "repos/${GITHUB_REPOSITORY}/actions/runs/${ARTIFACT_RUN_ID}/attempts/${ARTIFACT_RUN_ATTEMPT}" + )" + jq -e \ + --arg attempt "$ARTIFACT_RUN_ATTEMPT" \ + --arg run_id "$ARTIFACT_RUN_ID" \ + '(.id | tostring) == $run_id and (.run_attempt | tostring) == $attempt' \ + <<< "$attempt_json" >/dev/null || { + echo "Package Telegram artifact producer run attempt does not match the requested tuple." >&2 + exit 1 + } + - name: Download package-under-test artifact - if: inputs.package_artifact_name != '' && inputs.package_artifact_run_id == '' + if: inputs.package_artifact_name != '' && inputs.package_artifact_run_id == github.run_id uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: - name: ${{ inputs.package_artifact_name }} + artifact-ids: ${{ inputs.package_artifact_id }} path: .artifacts/telegram-package-under-test + run-id: ${{ inputs.package_artifact_run_id }} + github-token: ${{ github.token }} - name: Download package-under-test artifact from release run - if: inputs.package_artifact_name != '' && inputs.package_artifact_run_id != '' + if: inputs.package_artifact_name != '' && inputs.package_artifact_run_id != github.run_id uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: - name: ${{ inputs.package_artifact_name }} + artifact-ids: ${{ inputs.package_artifact_id }} path: .artifacts/telegram-package-under-test run-id: ${{ inputs.package_artifact_run_id }} github-token: ${{ github.token }} @@ -234,6 +389,10 @@ jobs: OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1" INPUT_SCENARIO: ${{ inputs.scenario }} PACKAGE_ARTIFACT_NAME: ${{ inputs.package_artifact_name || '' }} + PACKAGE_FILE_NAME: ${{ inputs.package_file_name || '' }} + PACKAGE_SHA256: ${{ inputs.package_sha256 || '' }} + PACKAGE_SOURCE_SHA: ${{ inputs.package_source_sha || '' }} + PACKAGE_VERSION: ${{ inputs.package_version || '' }} run: | set -euo pipefail @@ -260,6 +419,11 @@ jobs: if [[ -n "${PACKAGE_ARTIFACT_NAME// }" ]]; then package_dir=".artifacts/telegram-package-under-test" + declared_package_tgz="${package_dir}/${PACKAGE_FILE_NAME}" + [[ -f "$declared_package_tgz" ]] || { + echo "Package Telegram artifact is missing the declared package tarball." >&2 + exit 1 + } manifest="${package_dir}/preflight-manifest.json" if [[ -f "${manifest}" ]]; then package_tgz="$( @@ -367,6 +531,28 @@ jobs: } NODE fi + [[ "$(basename "$package_tgz")" == "$PACKAGE_FILE_NAME" ]] || { + echo "Package Telegram artifact tarball differs from package_file_name." >&2 + exit 1 + } + actual_package_sha256="$(sha256sum "$package_tgz" | awk '{print $1}')" + if [[ "$actual_package_sha256" != "$PACKAGE_SHA256" ]]; then + echo "Package Telegram artifact SHA-256 differs from package_sha256." >&2 + exit 1 + fi + actual_package_version="$( + tar -xOf "$package_tgz" package/package.json | + jq -er '.version | select(type == "string" and length > 0)' + )" + actual_package_source_sha="$( + tar -xOf "$package_tgz" package/dist/build-info.json | + jq -er '.commit | select(type == "string" and test("^[0-9a-f]{40}$"))' + )" + [[ "$actual_package_source_sha" == "$PACKAGE_SOURCE_SHA" && + "$actual_package_version" == "$PACKAGE_VERSION" ]] || { + echo "Package Telegram artifact source SHA/version differs from the declared identity." >&2 + exit 1 + } export OPENCLAW_NPM_TELEGRAM_PACKAGE_TGZ="${package_tgz}" if [[ -z "${OPENCLAW_NPM_TELEGRAM_PACKAGE_LABEL// }" ]]; then export OPENCLAW_NPM_TELEGRAM_PACKAGE_LABEL="$(basename "${package_tgz}")" diff --git a/.github/workflows/openclaw-cross-os-release-checks-reusable.yml b/.github/workflows/openclaw-cross-os-release-checks-reusable.yml index be6c0954747d..7575d30bcfc2 100644 --- a/.github/workflows/openclaw-cross-os-release-checks-reusable.yml +++ b/.github/workflows/openclaw-cross-os-release-checks-reusable.yml @@ -57,12 +57,27 @@ on: default: "" type: string candidate_artifact_name: - description: Optional current-run artifact name containing the candidate OpenClaw tarball + description: Optional artifact name containing the candidate OpenClaw tarball + required: false + default: "" + type: string + candidate_artifact_id: + description: Immutable GitHub artifact id for candidate_artifact_name + required: false + default: "" + type: string + candidate_artifact_digest: + description: Exact upload-artifact SHA-256 digest for candidate_artifact_id required: false default: "" type: string candidate_artifact_run_id: - description: Optional workflow run id for candidate_artifact_name + description: Exact workflow run id that produced candidate_artifact_id + required: false + default: "" + type: string + candidate_artifact_run_attempt: + description: Exact workflow run attempt that produced candidate_artifact_id required: false default: "" type: string @@ -81,6 +96,11 @@ on: required: false default: "" type: string + candidate_sha256: + description: Exact candidate tarball SHA-256 + required: false + default: "" + type: string openai_model: description: OpenAI model for release cross-OS agent-turn smoke required: false @@ -141,12 +161,27 @@ on: default: "" type: string candidate_artifact_name: - description: Optional current-run artifact name containing the candidate OpenClaw tarball + description: Optional artifact name containing the candidate OpenClaw tarball + required: false + default: "" + type: string + candidate_artifact_id: + description: Immutable GitHub artifact id for candidate_artifact_name + required: false + default: "" + type: string + candidate_artifact_digest: + description: Exact upload-artifact SHA-256 digest for candidate_artifact_id required: false default: "" type: string candidate_artifact_run_id: - description: Optional workflow run id for candidate_artifact_name + description: Exact workflow run id that produced candidate_artifact_id + required: false + default: "" + type: string + candidate_artifact_run_attempt: + description: Exact workflow run attempt that produced candidate_artifact_id required: false default: "" type: string @@ -165,6 +200,11 @@ on: required: false default: "" type: string + candidate_sha256: + description: Exact candidate tarball SHA-256 + required: false + default: "" + type: string openai_model: description: OpenAI model for release cross-OS agent-turn smoke required: false @@ -201,9 +241,19 @@ jobs: runs-on: ubuntu-24.04 continue-on-error: ${{ inputs.advisory }} outputs: + baseline_artifact_digest: ${{ steps.upload_baseline.outputs.artifact-digest }} + baseline_artifact_id: ${{ steps.upload_baseline.outputs.artifact-id }} + baseline_artifact_run_attempt: ${{ github.run_attempt }} + baseline_artifact_run_id: ${{ github.run_id }} baseline_file_name: ${{ steps.baseline_metadata.outputs.file_name }} + baseline_sha256: ${{ steps.baseline_metadata.outputs.sha256 }} baseline_spec: ${{ steps.baseline.outputs.value }} + candidate_artifact_digest: ${{ steps.upload_candidate.outputs.artifact-digest }} + candidate_artifact_id: ${{ steps.upload_candidate.outputs.artifact-id }} + candidate_artifact_run_attempt: ${{ github.run_attempt }} + candidate_artifact_run_id: ${{ github.run_id }} candidate_file_name: ${{ steps.candidate_metadata.outputs.file_name }} + candidate_sha256: ${{ steps.candidate_metadata.outputs.sha256 }} candidate_version: ${{ steps.candidate_metadata.outputs.version }} matrix: ${{ steps.matrix.outputs.value }} source_sha: ${{ steps.candidate_metadata.outputs.source_sha }} @@ -337,7 +387,75 @@ jobs: ref: ${{ steps.workflow_ref.outputs.value }} path: workflow fetch-depth: 1 - persist-credentials: true + persist-credentials: false + + - name: Validate provided candidate artifact binding + if: inputs.candidate_artifact_name != '' || inputs.candidate_artifact_id != '' || inputs.candidate_artifact_digest != '' || inputs.candidate_artifact_run_id != '' || inputs.candidate_artifact_run_attempt != '' || inputs.candidate_file_name != '' || inputs.candidate_sha256 != '' || inputs.candidate_version != '' || inputs.candidate_source_sha != '' + env: + ARTIFACT_DIGEST: ${{ inputs.candidate_artifact_digest }} + ARTIFACT_ID: ${{ inputs.candidate_artifact_id }} + ARTIFACT_NAME: ${{ inputs.candidate_artifact_name }} + ARTIFACT_RUN_ATTEMPT: ${{ inputs.candidate_artifact_run_attempt }} + ARTIFACT_RUN_ID: ${{ inputs.candidate_artifact_run_id }} + CANDIDATE_FILE_NAME: ${{ inputs.candidate_file_name }} + CANDIDATE_SHA256: ${{ inputs.candidate_sha256 }} + CANDIDATE_SOURCE_SHA: ${{ inputs.candidate_source_sha }} + CANDIDATE_VERSION: ${{ inputs.candidate_version }} + GH_TOKEN: ${{ github.token }} + INPUT_REF: ${{ inputs.ref }} + shell: bash + run: | + set -euo pipefail + if [[ -z "${ARTIFACT_NAME// }" || + ! "$ARTIFACT_ID" =~ ^[1-9][0-9]*$ || + ! "$ARTIFACT_DIGEST" =~ ^[a-f0-9]{64}$ || + ! "$ARTIFACT_RUN_ID" =~ ^[1-9][0-9]*$ || + ! "$ARTIFACT_RUN_ATTEMPT" =~ ^[1-9][0-9]*$ || + -z "${CANDIDATE_FILE_NAME// }" || + ! "$CANDIDATE_SHA256" =~ ^[a-f0-9]{64}$ || + ! "$CANDIDATE_SOURCE_SHA" =~ ^[a-f0-9]{40}$ || + -z "${CANDIDATE_VERSION// }" ]]; then + echo "Candidate artifact selection requires the complete immutable artifact and package identity tuple." >&2 + exit 1 + fi + [[ "$ARTIFACT_NAME" == *"-${ARTIFACT_RUN_ID}-${ARTIFACT_RUN_ATTEMPT}" ]] || { + echo "Candidate artifact name does not bind the declared producer run attempt." >&2 + exit 1 + } + if [[ "$INPUT_REF" =~ ^[a-f0-9]{40}$ && "$CANDIDATE_SOURCE_SHA" != "$INPUT_REF" ]]; then + echo "Candidate package source SHA does not match the selected exact ref." >&2 + exit 1 + fi + + artifact_json="$(gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}")" + jq -e \ + --arg digest "sha256:${ARTIFACT_DIGEST}" \ + --arg id "$ARTIFACT_ID" \ + --arg name "$ARTIFACT_NAME" \ + --arg run_id "$ARTIFACT_RUN_ID" \ + ' + (.id | tostring) == $id and + .name == $name and + .expired == false and + .digest == $digest and + (.workflow_run.id | tostring) == $run_id + ' <<< "$artifact_json" >/dev/null || { + echo "Candidate artifact identity does not match the requested immutable tuple." >&2 + exit 1 + } + + attempt_json="$( + gh api \ + "repos/${GITHUB_REPOSITORY}/actions/runs/${ARTIFACT_RUN_ID}/attempts/${ARTIFACT_RUN_ATTEMPT}" + )" + jq -e \ + --arg attempt "$ARTIFACT_RUN_ATTEMPT" \ + --arg run_id "$ARTIFACT_RUN_ID" \ + '(.id | tostring) == $run_id and (.run_attempt | tostring) == $attempt' \ + <<< "$attempt_json" >/dev/null || { + echo "Candidate artifact producer run attempt does not match the requested tuple." >&2 + exit 1 + } - name: Checkout public source ref if: inputs.candidate_artifact_name == '' @@ -347,7 +465,7 @@ jobs: ref: ${{ inputs.ref }} path: source fetch-depth: 0 - persist-credentials: true + persist-credentials: false submodules: recursive - name: Setup Node.js @@ -376,72 +494,70 @@ jobs: --source-dir source \ --output-dir "${OUTPUT_DIR}" - - name: Download current-run candidate artifact - if: inputs.candidate_artifact_name != '' && inputs.candidate_artifact_run_id == '' + - name: Download provided candidate artifact + if: inputs.candidate_artifact_name != '' uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: - name: ${{ inputs.candidate_artifact_name }} - path: ${{ runner.temp }}/openclaw-cross-os-release-checks/prepare/package - - - name: Download previous-run candidate artifact - if: inputs.candidate_artifact_name != '' && inputs.candidate_artifact_run_id != '' - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 - with: - name: ${{ inputs.candidate_artifact_name }} + artifact-ids: ${{ inputs.candidate_artifact_id }} + path: ${{ runner.temp }}/openclaw-cross-os-release-checks/prepare/input run-id: ${{ inputs.candidate_artifact_run_id }} github-token: ${{ github.token }} - path: ${{ runner.temp }}/openclaw-cross-os-release-checks/prepare/package - - name: Capture provided candidate artifact metadata + - name: Resolve provided candidate package if: inputs.candidate_artifact_name != '' env: - PACKAGE_DIR: ${{ runner.temp }}/openclaw-cross-os-release-checks/prepare/package + INPUT_DIR: ${{ runner.temp }}/openclaw-cross-os-release-checks/prepare/input + OUTPUT_DIR: ${{ runner.temp }}/openclaw-cross-os-release-checks/prepare/package INPUT_CANDIDATE_FILE_NAME: ${{ inputs.candidate_file_name }} + INPUT_CANDIDATE_SHA256: ${{ inputs.candidate_sha256 }} INPUT_CANDIDATE_VERSION: ${{ inputs.candidate_version }} INPUT_CANDIDATE_SOURCE_SHA: ${{ inputs.candidate_source_sha }} CANDIDATE_JSON: ${{ runner.temp }}/openclaw-cross-os-release-checks/prepare/candidate.json + shell: bash run: | + set -euo pipefail + node workflow/scripts/resolve-openclaw-package-candidate.mjs \ + --source artifact \ + --artifact-dir "$INPUT_DIR" \ + --package-sha256 "$INPUT_CANDIDATE_SHA256" \ + --output-dir "$OUTPUT_DIR" \ + --output-name "$INPUT_CANDIDATE_FILE_NAME" \ + --metadata "$OUTPUT_DIR/package-candidate.json" + actual_sha256="$( + jq -er '.sha256 | select(type == "string" and test("^[a-f0-9]{64}$"))' \ + "$OUTPUT_DIR/package-candidate.json" + )" + actual_source_sha="$( + jq -er '.packageSourceSha | select(type == "string" and test("^[a-f0-9]{40}$"))' \ + "$OUTPUT_DIR/package-candidate.json" + )" + actual_version="$( + jq -er '.version | select(type == "string" and length > 0)' \ + "$OUTPUT_DIR/package-candidate.json" + )" + [[ "$actual_sha256" == "$INPUT_CANDIDATE_SHA256" && + "$actual_source_sha" == "$INPUT_CANDIDATE_SOURCE_SHA" && + "$actual_version" == "$INPUT_CANDIDATE_VERSION" ]] || { + echo "Resolved candidate package identity differs from the declared exact tuple." >&2 + exit 1 + } + export ACTUAL_SHA256="$actual_sha256" + export ACTUAL_SOURCE_SHA="$actual_source_sha" + export ACTUAL_VERSION="$actual_version" node <<'NODE' const fs = require("node:fs"); - const path = require("node:path"); - - const packageDir = process.env.PACKAGE_DIR; - function resolveTarballFileName(value, label) { - const fileName = typeof value === "string" ? value.trim() : ""; - if ( - !fileName.endsWith(".tgz") || - fileName.includes("\0") || - fileName !== path.basename(fileName) || - fileName !== path.win32.basename(fileName) - ) { - throw new Error(`${label} must be a local .tgz filename.`); - } - return fileName; - } - const requestedFileName = process.env.INPUT_CANDIDATE_FILE_NAME.trim(); - const files = fs.readdirSync(packageDir).filter((file) => file.endsWith(".tgz")); - const selectedCandidateFileName = requestedFileName || (files.length === 1 ? files[0] : ""); - if (!selectedCandidateFileName) { - throw new Error(`Expected exactly one candidate .tgz in ${packageDir}; found ${files.length}.`); - } - const candidateFileName = resolveTarballFileName( - selectedCandidateFileName, - "candidate_file_name", - ); - if (!fs.existsSync(path.join(packageDir, candidateFileName))) { - throw new Error(`Provided candidate artifact does not contain ${candidateFileName}.`); - } - const candidateVersion = process.env.INPUT_CANDIDATE_VERSION.trim(); - if (!candidateVersion) { - throw new Error("candidate_version is required when candidate_artifact_name is provided."); - } - const sourceSha = process.env.INPUT_CANDIDATE_SOURCE_SHA.trim(); - if (!/^[0-9a-f]{40}$/iu.test(sourceSha)) { - throw new Error("candidate_source_sha must be a full commit SHA when candidate_artifact_name is provided."); - } fs.writeFileSync( process.env.CANDIDATE_JSON, - `${JSON.stringify({ candidateFileName, candidateVersion, sourceSha }, null, 2)}\n`, + `${JSON.stringify( + { + candidateFileName: process.env.INPUT_CANDIDATE_FILE_NAME, + candidateSha256: process.env.ACTUAL_SHA256, + candidateVersion: process.env.ACTUAL_VERSION, + sourceSha: process.env.ACTUAL_SOURCE_SHA, + }, + null, + 2, + )}\n`, ); NODE @@ -472,13 +588,43 @@ jobs: id: candidate_metadata env: CANDIDATE_JSON: ${{ runner.temp }}/openclaw-cross-os-release-checks/prepare/candidate.json + PACKAGE_DIR: ${{ runner.temp }}/openclaw-cross-os-release-checks/prepare/package run: | node <<'NODE' >>"$GITHUB_OUTPUT" + const crypto = require("node:crypto"); const fs = require("node:fs"); + const path = require("node:path"); const payload = JSON.parse(fs.readFileSync(process.env.CANDIDATE_JSON, "utf8")); - process.stdout.write(`file_name=${payload.candidateFileName}\n`); - process.stdout.write(`version=${payload.candidateVersion}\n`); - process.stdout.write(`source_sha=${payload.sourceSha}\n`); + const fileName = + typeof payload.candidateFileName === "string" ? payload.candidateFileName.trim() : ""; + const version = + typeof payload.candidateVersion === "string" ? payload.candidateVersion.trim() : ""; + const sourceSha = typeof payload.sourceSha === "string" ? payload.sourceSha.trim() : ""; + if ( + !fileName.endsWith(".tgz") || + fileName.includes("\0") || + fileName !== path.basename(fileName) || + fileName !== path.win32.basename(fileName) + ) { + throw new Error("Candidate manifest file name must be a local .tgz filename."); + } + if (!version) { + throw new Error("Candidate manifest version is missing."); + } + if (!/^[0-9a-f]{40}$/u.test(sourceSha)) { + throw new Error("Candidate manifest source SHA must be a lowercase full commit SHA."); + } + const tarball = path.join(process.env.PACKAGE_DIR, fileName); + const sha256 = crypto.createHash("sha256").update(fs.readFileSync(tarball)).digest("hex"); + const declaredSha256 = + typeof payload.candidateSha256 === "string" ? payload.candidateSha256.trim() : ""; + if (declaredSha256 && declaredSha256 !== sha256) { + throw new Error("Candidate manifest SHA-256 differs from the candidate tarball."); + } + process.stdout.write(`file_name=${fileName}\n`); + process.stdout.write(`sha256=${sha256}\n`); + process.stdout.write(`version=${version}\n`); + process.stdout.write(`source_sha=${sourceSha}\n`); NODE - name: Capture baseline metadata @@ -488,6 +634,7 @@ jobs: BASELINE_PACK_JSON: ${{ runner.temp }}/openclaw-cross-os-release-checks/prepare/baseline/pack.json run: | node <<'NODE' >>"$GITHUB_OUTPUT" + const crypto = require("node:crypto"); const fs = require("node:fs"); const path = require("node:path"); function resolveTarballFileName(value, label) { @@ -505,21 +652,28 @@ jobs: const payload = JSON.parse(fs.readFileSync(process.env.BASELINE_PACK_JSON, "utf8")); const entry = Array.isArray(payload) ? payload.at(-1) : null; const fileName = resolveTarballFileName(entry?.filename, "Baseline npm pack filename"); + const sha256 = crypto + .createHash("sha256") + .update(fs.readFileSync(path.join(path.dirname(process.env.BASELINE_PACK_JSON), fileName))) + .digest("hex"); process.stdout.write(`file_name=${fileName}\n`); + process.stdout.write(`sha256=${sha256}\n`); NODE - name: Upload candidate artifact + id: upload_candidate uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: openclaw-cross-os-release-checks-candidate-${{ github.run_id }} + name: openclaw-cross-os-release-checks-candidate-${{ github.run_id }}-${{ github.run_attempt }} path: ${{ runner.temp }}/openclaw-cross-os-release-checks/prepare/package/${{ steps.candidate_metadata.outputs.file_name }} if-no-files-found: error - name: Upload baseline artifact if: ${{ inputs.mode != 'fresh' }} + id: upload_baseline uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: openclaw-cross-os-release-checks-baseline-${{ github.run_id }} + name: openclaw-cross-os-release-checks-baseline-${{ github.run_id }}-${{ github.run_attempt }} path: ${{ runner.temp }}/openclaw-cross-os-release-checks/prepare/baseline/${{ steps.baseline_metadata.outputs.file_name }} if-no-files-found: error @@ -563,7 +717,7 @@ jobs: ref: ${{ needs.prepare.outputs.workflow_ref }} path: workflow fetch-depth: 1 - persist-credentials: true + persist-credentials: false - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 @@ -578,20 +732,140 @@ jobs: lockfile-path: workflow/pnpm-lock.yaml use-actions-cache: "false" + - name: Validate prepared candidate artifact binding + env: + ARTIFACT_DIGEST: ${{ needs.prepare.outputs.candidate_artifact_digest }} + ARTIFACT_ID: ${{ needs.prepare.outputs.candidate_artifact_id }} + ARTIFACT_NAME: ${{ format('openclaw-cross-os-release-checks-candidate-{0}-{1}', needs.prepare.outputs.candidate_artifact_run_id, needs.prepare.outputs.candidate_artifact_run_attempt) }} + ARTIFACT_RUN_ATTEMPT: ${{ needs.prepare.outputs.candidate_artifact_run_attempt }} + ARTIFACT_RUN_ID: ${{ needs.prepare.outputs.candidate_artifact_run_id }} + BASELINE_ARTIFACT_DIGEST: ${{ needs.prepare.outputs.baseline_artifact_digest }} + BASELINE_ARTIFACT_ID: ${{ needs.prepare.outputs.baseline_artifact_id }} + BASELINE_ARTIFACT_NAME: ${{ format('openclaw-cross-os-release-checks-baseline-{0}-{1}', needs.prepare.outputs.baseline_artifact_run_id, needs.prepare.outputs.baseline_artifact_run_attempt) }} + BASELINE_ARTIFACT_RUN_ATTEMPT: ${{ needs.prepare.outputs.baseline_artifact_run_attempt }} + BASELINE_ARTIFACT_RUN_ID: ${{ needs.prepare.outputs.baseline_artifact_run_id }} + BASELINE_SHA256: ${{ needs.prepare.outputs.baseline_sha256 }} + CANDIDATE_SHA256: ${{ needs.prepare.outputs.candidate_sha256 }} + CANDIDATE_SOURCE_SHA: ${{ needs.prepare.outputs.source_sha }} + CANDIDATE_VERSION: ${{ needs.prepare.outputs.candidate_version }} + GH_TOKEN: ${{ github.token }} + SUITE: ${{ matrix.suite }} + shell: bash + run: | + set -euo pipefail + [[ "$ARTIFACT_ID" =~ ^[1-9][0-9]*$ && + "$ARTIFACT_DIGEST" =~ ^[a-f0-9]{64}$ && + -n "${ARTIFACT_NAME// }" && + "$ARTIFACT_RUN_ID" =~ ^[1-9][0-9]*$ && + "$ARTIFACT_RUN_ATTEMPT" =~ ^[1-9][0-9]*$ ]] || { + echo "Prepared candidate artifact binding is incomplete." >&2 + exit 1 + } + [[ "$CANDIDATE_SHA256" =~ ^[a-f0-9]{64}$ && + "$CANDIDATE_SOURCE_SHA" =~ ^[a-f0-9]{40}$ && + -n "${CANDIDATE_VERSION// }" ]] || { + echo "Prepared candidate package identity is incomplete." >&2 + exit 1 + } + if [[ "$SUITE" == "packaged-upgrade" ]]; then + [[ "$BASELINE_ARTIFACT_ID" =~ ^[1-9][0-9]*$ && + "$BASELINE_ARTIFACT_DIGEST" =~ ^[a-f0-9]{64}$ && + -n "${BASELINE_ARTIFACT_NAME// }" && + "$BASELINE_ARTIFACT_RUN_ID" =~ ^[1-9][0-9]*$ && + "$BASELINE_ARTIFACT_RUN_ATTEMPT" =~ ^[1-9][0-9]*$ && + "$BASELINE_SHA256" =~ ^[a-f0-9]{64}$ ]] || { + echo "Prepared baseline artifact binding is incomplete." >&2 + exit 1 + } + fi + node <<'NODE' + const apiUrl = process.env.GITHUB_API_URL || "https://api.github.com"; + const repository = process.env.GITHUB_REPOSITORY; + const token = process.env.GH_TOKEN; + const suite = process.env.SUITE; + const tuples = [ + { + digest: process.env.ARTIFACT_DIGEST, + id: process.env.ARTIFACT_ID, + label: "candidate", + name: process.env.ARTIFACT_NAME, + runAttempt: process.env.ARTIFACT_RUN_ATTEMPT, + runId: process.env.ARTIFACT_RUN_ID, + }, + ]; + if (suite === "packaged-upgrade") { + tuples.push({ + digest: process.env.BASELINE_ARTIFACT_DIGEST, + id: process.env.BASELINE_ARTIFACT_ID, + label: "baseline", + name: process.env.BASELINE_ARTIFACT_NAME, + runAttempt: process.env.BASELINE_ARTIFACT_RUN_ATTEMPT, + runId: process.env.BASELINE_ARTIFACT_RUN_ID, + }); + } + + const request = async (path) => { + const response = await fetch(`${apiUrl}/repos/${repository}/${path}`, { + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28", + }, + }); + if (!response.ok) { + throw new Error(`GitHub artifact API ${path} returned ${response.status}.`); + } + return response.json(); + }; + + async function main() { + for (const tuple of tuples) { + const artifact = await request(`actions/artifacts/${tuple.id}`); + if ( + String(artifact.id) !== tuple.id || + artifact.name !== tuple.name || + artifact.expired !== false || + artifact.digest !== `sha256:${tuple.digest}` || + String(artifact.workflow_run?.id) !== tuple.runId + ) { + throw new Error(`Prepared ${tuple.label} artifact identity does not match.`); + } + const attempt = await request( + `actions/runs/${tuple.runId}/attempts/${tuple.runAttempt}`, + ); + if ( + String(attempt.id) !== tuple.runId || + String(attempt.run_attempt) !== tuple.runAttempt + ) { + throw new Error(`Prepared ${tuple.label} artifact run attempt does not match.`); + } + } + } + + main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }); + NODE + - name: Download candidate artifact id: download_candidate continue-on-error: true uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: - name: openclaw-cross-os-release-checks-candidate-${{ github.run_id }} + artifact-ids: ${{ needs.prepare.outputs.candidate_artifact_id }} path: ${{ runner.temp }}/openclaw-cross-os-release-checks/candidate + run-id: ${{ needs.prepare.outputs.candidate_artifact_run_id }} + github-token: ${{ github.token }} - name: Retry candidate artifact download if: ${{ steps.download_candidate.outcome == 'failure' }} uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: - name: openclaw-cross-os-release-checks-candidate-${{ github.run_id }} + artifact-ids: ${{ needs.prepare.outputs.candidate_artifact_id }} path: ${{ runner.temp }}/openclaw-cross-os-release-checks/candidate + run-id: ${{ needs.prepare.outputs.candidate_artifact_run_id }} + github-token: ${{ github.token }} - name: Download baseline artifact if: ${{ matrix.suite == 'packaged-upgrade' }} @@ -599,21 +873,27 @@ jobs: continue-on-error: true uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: - name: openclaw-cross-os-release-checks-baseline-${{ github.run_id }} + artifact-ids: ${{ needs.prepare.outputs.baseline_artifact_id }} path: ${{ runner.temp }}/openclaw-cross-os-release-checks/baseline + run-id: ${{ needs.prepare.outputs.baseline_artifact_run_id }} + github-token: ${{ github.token }} - name: Retry baseline artifact download if: ${{ matrix.suite == 'packaged-upgrade' && steps.download_baseline.outcome == 'failure' }} uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: - name: openclaw-cross-os-release-checks-baseline-${{ github.run_id }} + artifact-ids: ${{ needs.prepare.outputs.baseline_artifact_id }} path: ${{ runner.temp }}/openclaw-cross-os-release-checks/baseline + run-id: ${{ needs.prepare.outputs.baseline_artifact_run_id }} + github-token: ${{ github.token }} - name: Verify release-check inputs shell: bash env: CANDIDATE_TGZ: ${{ runner.temp }}/openclaw-cross-os-release-checks/candidate/${{ needs.prepare.outputs.candidate_file_name }} + EXPECTED_CANDIDATE_SHA256: ${{ needs.prepare.outputs.candidate_sha256 }} BASELINE_TGZ: ${{ runner.temp }}/openclaw-cross-os-release-checks/baseline/${{ needs.prepare.outputs.baseline_file_name }} + EXPECTED_BASELINE_SHA256: ${{ needs.prepare.outputs.baseline_sha256 }} OUTPUT_DIR: ${{ runner.temp }}/openclaw-cross-os-release-checks/${{ matrix.artifact_name }}-${{ matrix.suite }} SUITE: ${{ matrix.suite }} run: | @@ -622,10 +902,40 @@ jobs: echo "::error::candidate artifact missing: ${CANDIDATE_TGZ}" exit 1 fi + actual_sha256="$( + node -e ' + const crypto = require("node:crypto"); + const fs = require("node:fs"); + process.stdout.write( + crypto.createHash("sha256").update(fs.readFileSync(process.env.CANDIDATE_TGZ)).digest("hex"), + ); + ' + )" + if [[ ! "$EXPECTED_CANDIDATE_SHA256" =~ ^[a-f0-9]{64}$ || + "$actual_sha256" != "$EXPECTED_CANDIDATE_SHA256" ]]; then + echo "::error::candidate artifact SHA-256 does not match the prepared package identity" + exit 1 + fi if [[ "${SUITE}" == "packaged-upgrade" ]] && [[ ! -f "${BASELINE_TGZ}" ]]; then echo "::error::baseline artifact missing: ${BASELINE_TGZ}" exit 1 fi + if [[ "${SUITE}" == "packaged-upgrade" ]]; then + actual_baseline_sha256="$( + node -e ' + const crypto = require("node:crypto"); + const fs = require("node:fs"); + process.stdout.write( + crypto.createHash("sha256").update(fs.readFileSync(process.env.BASELINE_TGZ)).digest("hex"), + ); + ' + )" + if [[ ! "$EXPECTED_BASELINE_SHA256" =~ ^[a-f0-9]{64}$ || + "$actual_baseline_sha256" != "$EXPECTED_BASELINE_SHA256" ]]; then + echo "::error::baseline artifact SHA-256 does not match the prepared package identity" + exit 1 + fi + fi - name: Run cross-OS release checks shell: bash diff --git a/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml b/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml index bd35cdb0a5fd..c64024ce8c86 100644 --- a/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml +++ b/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml @@ -48,15 +48,19 @@ on: required: false default: "" type: string - package_artifact_name: - description: Existing workflow artifact containing openclaw-current.tgz; blank packs the selected ref + shared_image_policy: + description: Shared Docker image transport + required: true + default: allow-push + type: choice + options: + - allow-push + - existing-only + - no-push-artifact + shared_image_artifact_namespace: + description: Safe unique artifact namespace when shared_image_policy=no-push-artifact required: false - default: "" - type: string - package_artifact_run_id: - description: Prior run id containing package_artifact_name; blank uses this run or packs the selected ref - required: false - default: "" + default: direct type: string docker_e2e_bare_image: description: Existing bare Docker E2E image to reuse; blank derives from package SHA/ref @@ -178,11 +182,56 @@ on: required: false default: "" type: string - package_artifact_run_id: - description: Prior run id containing package_artifact_name; blank uses this run or packs the selected ref + package_artifact_id: + description: Immutable GitHub artifact id for package_artifact_name required: false default: "" type: string + package_artifact_digest: + description: GitHub artifact service SHA-256 digest without the sha256 prefix + required: false + default: "" + type: string + package_artifact_run_id: + description: Producer run id containing package_artifact_name + required: false + default: "" + type: string + package_artifact_run_attempt: + description: Producer run attempt containing package_artifact_name + required: false + default: "" + type: string + package_file_name: + description: Exact package tarball filename inside package_artifact_name + required: false + default: "" + type: string + package_source_sha: + description: Exact source commit recorded in the package tarball + required: false + default: "" + type: string + package_sha256: + description: Exact root OpenClaw package SHA-256 for no-push image artifacts + required: false + default: "" + type: string + package_version: + description: Exact OpenClaw package version for no-push image artifacts + required: false + default: "" + type: string + shared_image_policy: + description: "Shared Docker image transport: allow-push, existing-only, or no-push-artifact" + required: false + default: allow-push + type: string + shared_image_artifact_namespace: + description: Safe unique artifact namespace when shared_image_policy=no-push-artifact + required: false + default: direct + type: string docker_e2e_bare_image: description: Existing bare Docker E2E image to reuse; blank derives from package SHA/ref required: false @@ -323,8 +372,9 @@ on: required: false permissions: + actions: read contents: read - packages: write + packages: read pull-requests: read env: @@ -337,7 +387,41 @@ jobs: outputs: selected_sha: ${{ steps.validate.outputs.selected_sha }} trusted_reason: ${{ steps.validate.outputs.trusted_reason }} + workflow_repository: ${{ steps.workflow.outputs.workflow_repository }} + workflow_sha: ${{ steps.workflow.outputs.workflow_sha }} steps: + # github.workflow_sha identifies the caller during workflow_call. Resolve the called + # workflow SHA from job context so trusted harness checkouts cannot drift to candidate code. + - name: Resolve job workflow identity + id: workflow + env: + JOB_CONTEXT: ${{ toJSON(job) }} + shell: bash + run: | + set -euo pipefail + node --input-type=module <<'NODE' + import fs from "node:fs"; + + const job = JSON.parse(process.env.JOB_CONTEXT ?? "{}"); + if ( + typeof job.workflow_repository !== "string" || + !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(job.workflow_repository) + ) { + throw new Error("job.workflow_repository must be an owner/repository slug"); + } + if (typeof job.workflow_sha !== "string" || !/^[0-9a-f]{40}$/u.test(job.workflow_sha)) { + throw new Error("job.workflow_sha must be a full lowercase commit SHA"); + } + const outputPath = process.env.GITHUB_OUTPUT; + if (!outputPath) { + throw new Error("GITHUB_OUTPUT is required"); + } + fs.appendFileSync( + outputPath, + `workflow_repository=${job.workflow_repository}\nworkflow_sha=${job.workflow_sha}\n`, + ); + NODE + - name: Checkout workflow repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: @@ -347,6 +431,19 @@ jobs: id: validate env: INPUT_REF: ${{ inputs.ref }} + PACKAGE_ARTIFACT_DIGEST: ${{ inputs.package_artifact_digest }} + PACKAGE_ARTIFACT_ID: ${{ inputs.package_artifact_id }} + PACKAGE_ARTIFACT_NAME: ${{ inputs.package_artifact_name }} + PACKAGE_ARTIFACT_RUN_ATTEMPT: ${{ inputs.package_artifact_run_attempt }} + PACKAGE_ARTIFACT_RUN_ID: ${{ inputs.package_artifact_run_id }} + PACKAGE_FILE_NAME: ${{ inputs.package_file_name }} + PACKAGE_SHA256: ${{ inputs.package_sha256 }} + PACKAGE_SOURCE_SHA: ${{ inputs.package_source_sha }} + PACKAGE_VERSION: ${{ inputs.package_version }} + PROVIDED_BARE_IMAGE: ${{ inputs.docker_e2e_bare_image }} + PROVIDED_FUNCTIONAL_IMAGE: ${{ inputs.docker_e2e_functional_image }} + SHARED_IMAGE_ARTIFACT_NAMESPACE: ${{ inputs.shared_image_artifact_namespace }} + SHARED_IMAGE_POLICY: ${{ inputs.shared_image_policy }} shell: bash run: | set -euo pipefail @@ -374,6 +471,69 @@ jobs: exit 1 fi + package_tuple_present=0 + for value in \ + "$PACKAGE_ARTIFACT_DIGEST" \ + "$PACKAGE_ARTIFACT_ID" \ + "$PACKAGE_ARTIFACT_NAME" \ + "$PACKAGE_ARTIFACT_RUN_ATTEMPT" \ + "$PACKAGE_ARTIFACT_RUN_ID" \ + "$PACKAGE_FILE_NAME" \ + "$PACKAGE_SHA256" \ + "$PACKAGE_SOURCE_SHA" \ + "$PACKAGE_VERSION"; do + if [[ -n "${value// }" ]]; then + package_tuple_present=1 + fi + done + if [[ "$package_tuple_present" == "1" ]]; then + [[ "$PACKAGE_ARTIFACT_DIGEST" =~ ^[0-9a-f]{64}$ && + "$PACKAGE_ARTIFACT_ID" =~ ^[1-9][0-9]*$ && + -n "${PACKAGE_ARTIFACT_NAME// }" && + "$PACKAGE_ARTIFACT_RUN_ATTEMPT" =~ ^[1-9][0-9]*$ && + "$PACKAGE_ARTIFACT_RUN_ID" =~ ^[1-9][0-9]*$ && + "$PACKAGE_FILE_NAME" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*\.tgz$ && + "$PACKAGE_SHA256" =~ ^[0-9a-f]{64}$ && + "$PACKAGE_SOURCE_SHA" =~ ^[0-9a-f]{40}$ && + -n "${PACKAGE_VERSION// }" ]] || { + echo "Package artifact selection requires the complete immutable artifact and package identity tuple." >&2 + exit 1 + } + [[ "$PACKAGE_SOURCE_SHA" == "$selected_sha" ]] || { + echo "Package source SHA differs from the selected release SHA." >&2 + exit 1 + } + fi + + case "$SHARED_IMAGE_POLICY" in + allow-push) + ;; + existing-only) + if [[ -z "${PROVIDED_BARE_IMAGE// }" && -z "${PROVIDED_FUNCTIONAL_IMAGE// }" ]]; then + echo "shared_image_policy=existing-only requires explicit shared image refs." >&2 + exit 1 + fi + ;; + no-push-artifact) + [[ "$INPUT_REF" =~ ^[0-9a-f]{40}$ && "$INPUT_REF" == "$selected_sha" ]] || { + echo "shared_image_policy=no-push-artifact requires ref to be the exact lowercase target SHA." >&2 + exit 1 + } + [[ "$SHARED_IMAGE_ARTIFACT_NAMESPACE" =~ ^[a-z0-9][a-z0-9-]{0,47}$ ]] || { + echo "shared_image_artifact_namespace must be a lowercase slug up to 48 characters." >&2 + exit 1 + } + [[ -z "${PROVIDED_BARE_IMAGE// }" && -z "${PROVIDED_FUNCTIONAL_IMAGE// }" ]] || { + echo "shared_image_policy=no-push-artifact builds local image artifacts and rejects provided images." >&2 + exit 1 + } + ;; + *) + echo "shared_image_policy must be allow-push, existing-only, or no-push-artifact." >&2 + exit 1 + ;; + esac + echo "selected_sha=$selected_sha" >> "$GITHUB_OUTPUT" echo "trusted_reason=$trusted_reason" >> "$GITHUB_OUTPUT" { @@ -513,7 +673,8 @@ jobs: uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: persist-credentials: false - ref: ${{ github.sha }} + repository: ${{ needs.validate_selected_ref.outputs.workflow_repository }} + ref: ${{ needs.validate_selected_ref.outputs.workflow_sha }} fetch-depth: 1 - name: Plan release workflow matrices @@ -756,7 +917,13 @@ jobs: fi validate_docker_e2e: - needs: [validate_selected_ref, prepare_docker_e2e_image, plan_release_workflow_matrices] + needs: + [ + validate_selected_ref, + prepare_docker_e2e_image, + docker_e2e_image_ready, + plan_release_workflow_matrices, + ] if: inputs.include_release_path_suites && inputs.docker_lanes == '' && needs.plan_release_workflow_matrices.outputs.docker_e2e_count != '0' name: Docker E2E (${{ matrix.label }}) continue-on-error: ${{ inputs.advisory }} @@ -824,6 +991,7 @@ jobs: OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC: ${{ inputs.published_upgrade_survivor_baseline }} OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS: ${{ inputs.published_upgrade_survivor_baselines }} OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS: ${{ inputs.published_upgrade_survivor_scenarios }} + OPENCLAW_DOCKER_E2E_REQUIRE_LOCAL_IMAGE: ${{ inputs.shared_image_policy == 'no-push-artifact' && '1' || '0' }} OPENCLAW_SKIP_DOCKER_BUILD: "1" INCLUDE_OPENWEBUI: ${{ inputs.include_openwebui }} DOCKER_E2E_CHUNK: ${{ matrix.chunk_id }} @@ -841,12 +1009,13 @@ jobs: uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: persist-credentials: false - ref: ${{ github.sha }} + repository: ${{ needs.validate_selected_ref.outputs.workflow_repository }} + ref: ${{ needs.validate_selected_ref.outputs.workflow_sha }} fetch-depth: 1 path: .release-harness - name: Log in to GHCR for shared Docker E2E image - if: contains(matrix.profiles, inputs.release_test_profile) + if: contains(matrix.profiles, inputs.release_test_profile) && inputs.shared_image_policy != 'no-push-artifact' run: bash .release-harness/scripts/ci-docker-login-ghcr.sh env: GHCR_USERNAME: ${{ github.actor }} @@ -893,18 +1062,74 @@ jobs: if: contains(matrix.profiles, inputs.release_test_profile) && steps.plan.outputs.needs_package == '1' uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: - name: ${{ inputs.package_artifact_name || 'docker-e2e-package' }} + artifact-ids: ${{ needs.prepare_docker_e2e_image.outputs.package_artifact_id }} path: .artifacts/docker-e2e-package + run-id: ${{ needs.prepare_docker_e2e_image.outputs.package_artifact_run_id }} + github-token: ${{ github.token }} + + - name: Validate Docker E2E image artifact binding + if: contains(matrix.profiles, inputs.release_test_profile) && inputs.shared_image_policy == 'no-push-artifact' && needs.prepare_docker_e2e_image.outputs.needs_e2e_image == '1' + env: + ARTIFACT_DIGEST: ${{ needs.prepare_docker_e2e_image.outputs.image_artifact_digest }} + ARTIFACT_ID: ${{ needs.prepare_docker_e2e_image.outputs.image_artifact_id }} + ARTIFACT_NAME: ${{ needs.prepare_docker_e2e_image.outputs.image_artifact_name }} + ARTIFACT_RUN_ATTEMPT: ${{ needs.prepare_docker_e2e_image.outputs.image_artifact_run_attempt }} + ARTIFACT_RUN_ID: ${{ needs.prepare_docker_e2e_image.outputs.image_artifact_run_id }} + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + bash .release-harness/scripts/docker/shared-image-artifact.sh \ + verify-upload "Docker E2E image" \ + "$ARTIFACT_ID" "$ARTIFACT_NAME" "$ARTIFACT_DIGEST" \ + "$ARTIFACT_RUN_ID" "$ARTIFACT_RUN_ATTEMPT" + + - name: Download Docker E2E image artifact + if: contains(matrix.profiles, inputs.release_test_profile) && inputs.shared_image_policy == 'no-push-artifact' && needs.prepare_docker_e2e_image.outputs.needs_e2e_image == '1' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + artifact-ids: ${{ needs.prepare_docker_e2e_image.outputs.image_artifact_id }} + path: .artifacts/docker-e2e-images + run-id: ${{ needs.prepare_docker_e2e_image.outputs.image_artifact_run_id }} + github-token: ${{ github.token }} + + - name: Verify and load Docker E2E image artifact + if: contains(matrix.profiles, inputs.release_test_profile) && inputs.shared_image_policy == 'no-push-artifact' && needs.prepare_docker_e2e_image.outputs.needs_e2e_image == '1' + env: + BARE_IMAGE: ${{ needs.prepare_docker_e2e_image.outputs.bare_image }} + FUNCTIONAL_IMAGE: ${{ needs.prepare_docker_e2e_image.outputs.functional_image }} + NEEDS_BARE_IMAGE: ${{ needs.prepare_docker_e2e_image.outputs.needs_bare_image }} + NEEDS_FUNCTIONAL_IMAGE: ${{ needs.prepare_docker_e2e_image.outputs.needs_functional_image }} + PACKAGE_SHA256: ${{ needs.prepare_docker_e2e_image.outputs.package_sha256 }} + ARCHIVE_SHA256: ${{ needs.prepare_docker_e2e_image.outputs.image_archive_sha256 }} + OPENCLAW_SHARED_IMAGE_RUN_ATTEMPT: ${{ needs.prepare_docker_e2e_image.outputs.image_artifact_run_attempt }} + OPENCLAW_SHARED_IMAGE_RUN_ID: ${{ needs.prepare_docker_e2e_image.outputs.image_artifact_run_id }} + TARGET_SHA: ${{ needs.validate_selected_ref.outputs.selected_sha }} + WORKFLOW_SHA: ${{ needs.validate_selected_ref.outputs.workflow_sha }} + shell: bash + run: | + set -euo pipefail + images=() + if [[ "$NEEDS_BARE_IMAGE" == "1" ]]; then + images+=("$BARE_IMAGE") + fi + if [[ "$NEEDS_FUNCTIONAL_IMAGE" == "1" ]]; then + images+=("$FUNCTIONAL_IMAGE") + fi + OPENCLAW_SHARED_IMAGE_ARCHIVE_SHA256="$ARCHIVE_SHA256" \ + OPENCLAW_SHARED_IMAGE_PACKAGE_SHA256="$PACKAGE_SHA256" \ + bash .release-harness/scripts/docker/shared-image-artifact.sh \ + load .artifacts/docker-e2e-images docker-e2e "$TARGET_SHA" "$WORKFLOW_SHA" "${images[@]}" - name: Pull shared bare Docker E2E image - if: contains(matrix.profiles, inputs.release_test_profile) && steps.plan.outputs.needs_bare_image == '1' + if: contains(matrix.profiles, inputs.release_test_profile) && inputs.shared_image_policy != 'no-push-artifact' && steps.plan.outputs.needs_bare_image == '1' shell: bash run: | set -euo pipefail bash .release-harness/scripts/ci-docker-pull-retry.sh "${OPENCLAW_DOCKER_E2E_BARE_IMAGE}" - name: Pull shared functional Docker E2E image - if: contains(matrix.profiles, inputs.release_test_profile) && steps.plan.outputs.needs_functional_image == '1' + if: contains(matrix.profiles, inputs.release_test_profile) && inputs.shared_image_policy != 'no-push-artifact' && steps.plan.outputs.needs_functional_image == '1' shell: bash run: | set -euo pipefail @@ -1003,7 +1228,8 @@ jobs: uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: persist-credentials: false - ref: ${{ github.sha }} + repository: ${{ needs.validate_selected_ref.outputs.workflow_repository }} + ref: ${{ needs.validate_selected_ref.outputs.workflow_sha }} fetch-depth: 1 - name: Build targeted Docker lane groups @@ -1019,7 +1245,13 @@ jobs: echo "groups_json=${groups_json}" >> "$GITHUB_OUTPUT" validate_docker_lanes: - needs: [validate_selected_ref, prepare_docker_e2e_image, plan_docker_lane_groups] + needs: + [ + validate_selected_ref, + prepare_docker_e2e_image, + docker_e2e_image_ready, + plan_docker_lane_groups, + ] if: inputs.docker_lanes != '' name: Docker E2E targeted lanes (${{ matrix.group.label }}) continue-on-error: ${{ inputs.advisory }} @@ -1087,6 +1319,7 @@ jobs: OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC: ${{ inputs.published_upgrade_survivor_baseline }} OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS: ${{ matrix.group.published_upgrade_survivor_baselines || inputs.published_upgrade_survivor_baselines }} OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS: ${{ inputs.published_upgrade_survivor_scenarios }} + OPENCLAW_DOCKER_E2E_REQUIRE_LOCAL_IMAGE: ${{ inputs.shared_image_policy == 'no-push-artifact' && '1' || '0' }} OPENCLAW_SKIP_DOCKER_BUILD: "1" INCLUDE_OPENWEBUI: ${{ inputs.include_openwebui }} DOCKER_E2E_LANES: ${{ matrix.group.docker_lanes }} @@ -1102,11 +1335,13 @@ jobs: uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: persist-credentials: false - ref: ${{ github.sha }} + repository: ${{ needs.validate_selected_ref.outputs.workflow_repository }} + ref: ${{ needs.validate_selected_ref.outputs.workflow_sha }} fetch-depth: 1 path: .release-harness - name: Log in to GHCR for shared Docker E2E image + if: inputs.shared_image_policy != 'no-push-artifact' run: bash .release-harness/scripts/ci-docker-login-ghcr.sh env: GHCR_USERNAME: ${{ github.actor }} @@ -1154,18 +1389,74 @@ jobs: if: steps.plan.outputs.needs_package == '1' uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: - name: ${{ inputs.package_artifact_name || 'docker-e2e-package' }} + artifact-ids: ${{ needs.prepare_docker_e2e_image.outputs.package_artifact_id }} path: .artifacts/docker-e2e-package + run-id: ${{ needs.prepare_docker_e2e_image.outputs.package_artifact_run_id }} + github-token: ${{ github.token }} + + - name: Validate Docker E2E image artifact binding + if: inputs.shared_image_policy == 'no-push-artifact' && needs.prepare_docker_e2e_image.outputs.needs_e2e_image == '1' + env: + ARTIFACT_DIGEST: ${{ needs.prepare_docker_e2e_image.outputs.image_artifact_digest }} + ARTIFACT_ID: ${{ needs.prepare_docker_e2e_image.outputs.image_artifact_id }} + ARTIFACT_NAME: ${{ needs.prepare_docker_e2e_image.outputs.image_artifact_name }} + ARTIFACT_RUN_ATTEMPT: ${{ needs.prepare_docker_e2e_image.outputs.image_artifact_run_attempt }} + ARTIFACT_RUN_ID: ${{ needs.prepare_docker_e2e_image.outputs.image_artifact_run_id }} + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + bash .release-harness/scripts/docker/shared-image-artifact.sh \ + verify-upload "Docker E2E image" \ + "$ARTIFACT_ID" "$ARTIFACT_NAME" "$ARTIFACT_DIGEST" \ + "$ARTIFACT_RUN_ID" "$ARTIFACT_RUN_ATTEMPT" + + - name: Download Docker E2E image artifact + if: inputs.shared_image_policy == 'no-push-artifact' && needs.prepare_docker_e2e_image.outputs.needs_e2e_image == '1' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + artifact-ids: ${{ needs.prepare_docker_e2e_image.outputs.image_artifact_id }} + path: .artifacts/docker-e2e-images + run-id: ${{ needs.prepare_docker_e2e_image.outputs.image_artifact_run_id }} + github-token: ${{ github.token }} + + - name: Verify and load Docker E2E image artifact + if: inputs.shared_image_policy == 'no-push-artifact' && needs.prepare_docker_e2e_image.outputs.needs_e2e_image == '1' + env: + BARE_IMAGE: ${{ needs.prepare_docker_e2e_image.outputs.bare_image }} + FUNCTIONAL_IMAGE: ${{ needs.prepare_docker_e2e_image.outputs.functional_image }} + NEEDS_BARE_IMAGE: ${{ needs.prepare_docker_e2e_image.outputs.needs_bare_image }} + NEEDS_FUNCTIONAL_IMAGE: ${{ needs.prepare_docker_e2e_image.outputs.needs_functional_image }} + PACKAGE_SHA256: ${{ needs.prepare_docker_e2e_image.outputs.package_sha256 }} + ARCHIVE_SHA256: ${{ needs.prepare_docker_e2e_image.outputs.image_archive_sha256 }} + OPENCLAW_SHARED_IMAGE_RUN_ATTEMPT: ${{ needs.prepare_docker_e2e_image.outputs.image_artifact_run_attempt }} + OPENCLAW_SHARED_IMAGE_RUN_ID: ${{ needs.prepare_docker_e2e_image.outputs.image_artifact_run_id }} + TARGET_SHA: ${{ needs.validate_selected_ref.outputs.selected_sha }} + WORKFLOW_SHA: ${{ needs.validate_selected_ref.outputs.workflow_sha }} + shell: bash + run: | + set -euo pipefail + images=() + if [[ "$NEEDS_BARE_IMAGE" == "1" ]]; then + images+=("$BARE_IMAGE") + fi + if [[ "$NEEDS_FUNCTIONAL_IMAGE" == "1" ]]; then + images+=("$FUNCTIONAL_IMAGE") + fi + OPENCLAW_SHARED_IMAGE_ARCHIVE_SHA256="$ARCHIVE_SHA256" \ + OPENCLAW_SHARED_IMAGE_PACKAGE_SHA256="$PACKAGE_SHA256" \ + bash .release-harness/scripts/docker/shared-image-artifact.sh \ + load .artifacts/docker-e2e-images docker-e2e "$TARGET_SHA" "$WORKFLOW_SHA" "${images[@]}" - name: Pull shared bare Docker E2E image - if: steps.plan.outputs.needs_bare_image == '1' + if: inputs.shared_image_policy != 'no-push-artifact' && steps.plan.outputs.needs_bare_image == '1' shell: bash run: | set -euo pipefail bash .release-harness/scripts/ci-docker-pull-retry.sh "${OPENCLAW_DOCKER_E2E_BARE_IMAGE}" - name: Pull shared functional Docker E2E image - if: steps.plan.outputs.needs_functional_image == '1' + if: inputs.shared_image_policy != 'no-push-artifact' && steps.plan.outputs.needs_functional_image == '1' shell: bash run: | set -euo pipefail @@ -1211,17 +1502,20 @@ jobs: - name: Run targeted Docker E2E lanes shell: bash + env: + ARTIFACT_SUFFIX: ${{ steps.plan.outputs.artifact_suffix }} + INCLUDE_RELEASE_PATH_SUITES: ${{ inputs.include_release_path_suites }} run: | set -euo pipefail export OPENCLAW_DOCKER_ALL_LANES="${DOCKER_E2E_LANES}" export OPENCLAW_DOCKER_ALL_PREFLIGHT=0 export OPENCLAW_DOCKER_ALL_FAIL_FAST=0 export OPENCLAW_DOCKER_ALL_INCLUDE_OPENWEBUI="${INCLUDE_OPENWEBUI}" - if [[ "${{ inputs.include_release_path_suites }}" == "true" ]]; then + if [[ "$INCLUDE_RELEASE_PATH_SUITES" == "true" ]]; then export OPENCLAW_DOCKER_ALL_PROFILE=release-path fi - export OPENCLAW_DOCKER_ALL_LOG_DIR=".artifacts/docker-tests/targeted-${{ steps.plan.outputs.artifact_suffix }}" - export OPENCLAW_DOCKER_ALL_TIMINGS_FILE=".artifacts/docker-tests/targeted-${{ steps.plan.outputs.artifact_suffix }}-timings.json" + export OPENCLAW_DOCKER_ALL_LOG_DIR=".artifacts/docker-tests/targeted-${ARTIFACT_SUFFIX}" + export OPENCLAW_DOCKER_ALL_TIMINGS_FILE=".artifacts/docker-tests/targeted-${ARTIFACT_SUFFIX}-timings.json" export OPENCLAW_DOCKER_ALL_PNPM_COMMAND="$(command -v pnpm)" if [[ "${{ steps.plan.outputs.needs_live_image }}" == "1" ]]; then OPENCLAW_DOCKER_BUILD_ON_MISSING=1 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" bash .release-harness/scripts/test-live-build-docker.sh @@ -1251,7 +1545,7 @@ jobs: if-no-files-found: error validate_docker_openwebui: - needs: [validate_selected_ref, prepare_docker_e2e_image] + needs: [validate_selected_ref, prepare_docker_e2e_image, docker_e2e_image_ready] if: inputs.include_openwebui && inputs.docker_lanes == '' && (inputs.release_test_profile == 'stable' || inputs.release_test_profile == 'full') name: Docker E2E (openwebui) continue-on-error: ${{ inputs.advisory }} @@ -1267,22 +1561,27 @@ jobs: OPENCLAW_DOCKER_E2E_SELECTED_SHA: ${{ needs.validate_selected_ref.outputs.selected_sha }} OPENCLAW_CURRENT_PACKAGE_TGZ: .artifacts/docker-e2e-package/openclaw-current.tgz OPENCLAW_DOCKER_ALL_RELEASE_PROFILE: ${{ inputs.release_test_profile }} + OPENCLAW_DOCKER_E2E_REQUIRE_LOCAL_IMAGE: ${{ inputs.shared_image_policy == 'no-push-artifact' && '1' || '0' }} OPENCLAW_SKIP_DOCKER_BUILD: "1" steps: - name: Checkout selected ref uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: + persist-credentials: false ref: ${{ needs.validate_selected_ref.outputs.selected_sha }} fetch-depth: 1 - name: Checkout trusted release harness uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: - ref: ${{ github.sha }} + repository: ${{ needs.validate_selected_ref.outputs.workflow_repository }} + ref: ${{ needs.validate_selected_ref.outputs.workflow_sha }} fetch-depth: 1 path: .release-harness + persist-credentials: false - name: Log in to GHCR for shared Docker E2E image + if: inputs.shared_image_policy != 'no-push-artifact' run: bash .release-harness/scripts/ci-docker-login-ghcr.sh env: GHCR_USERNAME: ${{ github.actor }} @@ -1324,18 +1623,74 @@ jobs: if: steps.plan.outputs.needs_package == '1' uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: - name: ${{ inputs.package_artifact_name || 'docker-e2e-package' }} + artifact-ids: ${{ needs.prepare_docker_e2e_image.outputs.package_artifact_id }} path: .artifacts/docker-e2e-package + run-id: ${{ needs.prepare_docker_e2e_image.outputs.package_artifact_run_id }} + github-token: ${{ github.token }} + + - name: Validate Docker E2E image artifact binding + if: inputs.shared_image_policy == 'no-push-artifact' && needs.prepare_docker_e2e_image.outputs.needs_e2e_image == '1' + env: + ARTIFACT_DIGEST: ${{ needs.prepare_docker_e2e_image.outputs.image_artifact_digest }} + ARTIFACT_ID: ${{ needs.prepare_docker_e2e_image.outputs.image_artifact_id }} + ARTIFACT_NAME: ${{ needs.prepare_docker_e2e_image.outputs.image_artifact_name }} + ARTIFACT_RUN_ATTEMPT: ${{ needs.prepare_docker_e2e_image.outputs.image_artifact_run_attempt }} + ARTIFACT_RUN_ID: ${{ needs.prepare_docker_e2e_image.outputs.image_artifact_run_id }} + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + bash .release-harness/scripts/docker/shared-image-artifact.sh \ + verify-upload "Docker E2E image" \ + "$ARTIFACT_ID" "$ARTIFACT_NAME" "$ARTIFACT_DIGEST" \ + "$ARTIFACT_RUN_ID" "$ARTIFACT_RUN_ATTEMPT" + + - name: Download Docker E2E image artifact + if: inputs.shared_image_policy == 'no-push-artifact' && needs.prepare_docker_e2e_image.outputs.needs_e2e_image == '1' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + artifact-ids: ${{ needs.prepare_docker_e2e_image.outputs.image_artifact_id }} + path: .artifacts/docker-e2e-images + run-id: ${{ needs.prepare_docker_e2e_image.outputs.image_artifact_run_id }} + github-token: ${{ github.token }} + + - name: Verify and load Docker E2E image artifact + if: inputs.shared_image_policy == 'no-push-artifact' && needs.prepare_docker_e2e_image.outputs.needs_e2e_image == '1' + env: + BARE_IMAGE: ${{ needs.prepare_docker_e2e_image.outputs.bare_image }} + FUNCTIONAL_IMAGE: ${{ needs.prepare_docker_e2e_image.outputs.functional_image }} + NEEDS_BARE_IMAGE: ${{ needs.prepare_docker_e2e_image.outputs.needs_bare_image }} + NEEDS_FUNCTIONAL_IMAGE: ${{ needs.prepare_docker_e2e_image.outputs.needs_functional_image }} + PACKAGE_SHA256: ${{ needs.prepare_docker_e2e_image.outputs.package_sha256 }} + ARCHIVE_SHA256: ${{ needs.prepare_docker_e2e_image.outputs.image_archive_sha256 }} + OPENCLAW_SHARED_IMAGE_RUN_ATTEMPT: ${{ needs.prepare_docker_e2e_image.outputs.image_artifact_run_attempt }} + OPENCLAW_SHARED_IMAGE_RUN_ID: ${{ needs.prepare_docker_e2e_image.outputs.image_artifact_run_id }} + TARGET_SHA: ${{ needs.validate_selected_ref.outputs.selected_sha }} + WORKFLOW_SHA: ${{ needs.validate_selected_ref.outputs.workflow_sha }} + shell: bash + run: | + set -euo pipefail + images=() + if [[ "$NEEDS_BARE_IMAGE" == "1" ]]; then + images+=("$BARE_IMAGE") + fi + if [[ "$NEEDS_FUNCTIONAL_IMAGE" == "1" ]]; then + images+=("$FUNCTIONAL_IMAGE") + fi + OPENCLAW_SHARED_IMAGE_ARCHIVE_SHA256="$ARCHIVE_SHA256" \ + OPENCLAW_SHARED_IMAGE_PACKAGE_SHA256="$PACKAGE_SHA256" \ + bash .release-harness/scripts/docker/shared-image-artifact.sh \ + load .artifacts/docker-e2e-images docker-e2e "$TARGET_SHA" "$WORKFLOW_SHA" "${images[@]}" - name: Pull shared bare Docker E2E image - if: steps.plan.outputs.needs_bare_image == '1' + if: inputs.shared_image_policy != 'no-push-artifact' && steps.plan.outputs.needs_bare_image == '1' shell: bash run: | set -euo pipefail bash .release-harness/scripts/ci-docker-pull-retry.sh "${OPENCLAW_DOCKER_E2E_BARE_IMAGE}" - name: Pull shared functional Docker E2E image - if: steps.plan.outputs.needs_functional_image == '1' + if: inputs.shared_image_policy != 'no-push-artifact' && steps.plan.outputs.needs_functional_image == '1' shell: bash run: | set -euo pipefail @@ -1389,7 +1744,7 @@ jobs: permissions: actions: read contents: read - packages: write + packages: read outputs: image: ${{ steps.image.outputs.image }} bare_image: ${{ steps.image.outputs.bare_image }} @@ -1399,6 +1754,24 @@ jobs: needs_functional_image: ${{ steps.plan.outputs.needs_functional_image }} needs_live_image: ${{ steps.plan.outputs.needs_live_image }} needs_package: ${{ steps.plan.outputs.needs_package }} + bare_exists: ${{ steps.image_exists.outputs.bare_exists }} + functional_exists: ${{ steps.image_exists.outputs.functional_exists }} + needs_registry_build: ${{ steps.image_exists.outputs.needs_build }} + package_sha256: ${{ steps.package.outputs.sha256 }} + package_version: ${{ steps.package.outputs.version }} + package_artifact_name: ${{ steps.upload_package.outputs.artifact-id && format('docker-e2e-package-{0}-{1}', github.run_id, github.run_attempt) || inputs.package_artifact_name }} + package_artifact_id: ${{ steps.upload_package.outputs.artifact-id || inputs.package_artifact_id }} + package_artifact_digest: ${{ steps.upload_package.outputs.artifact-digest || steps.input_package_artifact.outputs.artifact_digest }} + package_artifact_run_id: ${{ steps.upload_package.outputs.artifact-id && github.run_id || steps.input_package_artifact.outputs.run_id }} + package_artifact_run_attempt: ${{ steps.upload_package.outputs.artifact-id && github.run_attempt || steps.input_package_artifact.outputs.run_attempt }} + package_file_name: ${{ steps.package.outputs.file_name }} + package_source_sha: ${{ steps.package.outputs.source_sha }} + image_artifact_name: ${{ steps.image_artifact.outputs.artifact_name }} + image_archive_sha256: ${{ steps.image_artifact.outputs.archive_sha256 }} + image_artifact_id: ${{ steps.upload_image_artifact.outputs.artifact-id }} + image_artifact_digest: ${{ steps.upload_image_artifact.outputs.artifact-digest }} + image_artifact_run_id: ${{ github.run_id }} + image_artifact_run_attempt: ${{ github.run_attempt }} env: DOCKER_BUILD_SUMMARY: "false" DOCKER_BUILD_RECORD_UPLOAD: "false" @@ -1409,13 +1782,16 @@ jobs: with: ref: ${{ needs.validate_selected_ref.outputs.selected_sha }} fetch-depth: 1 + persist-credentials: false - name: Checkout trusted release harness uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: - ref: ${{ github.sha }} + repository: ${{ needs.validate_selected_ref.outputs.workflow_repository }} + ref: ${{ needs.validate_selected_ref.outputs.workflow_sha }} fetch-depth: 1 path: .release-harness + persist-credentials: false - name: Plan Docker E2E images id: plan @@ -1455,18 +1831,79 @@ jobs: node-version: ${{ env.NODE_VERSION }} install-bun: "true" + - name: Validate OpenClaw package artifact identity + id: input_package_artifact + if: steps.plan.outputs.needs_package == '1' && inputs.package_artifact_id != '' + env: + ARTIFACT_DIGEST: ${{ inputs.package_artifact_digest }} + ARTIFACT_ID: ${{ inputs.package_artifact_id }} + ARTIFACT_NAME: ${{ inputs.package_artifact_name }} + ARTIFACT_RUN_ATTEMPT: ${{ inputs.package_artifact_run_attempt }} + ARTIFACT_RUN_ID: ${{ inputs.package_artifact_run_id }} + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + [[ "$ARTIFACT_DIGEST" =~ ^[0-9a-f]{64}$ && + "$ARTIFACT_ID" =~ ^[1-9][0-9]*$ && + -n "${ARTIFACT_NAME// }" && + "$ARTIFACT_RUN_ATTEMPT" =~ ^[1-9][0-9]*$ && + "$ARTIFACT_RUN_ID" =~ ^[1-9][0-9]*$ ]] || { + echo "OpenClaw package artifact identity tuple is incomplete." >&2 + exit 1 + } + [[ "$ARTIFACT_NAME" == *"-${ARTIFACT_RUN_ID}-${ARTIFACT_RUN_ATTEMPT}" ]] || { + echo "OpenClaw package artifact name does not bind the declared producer run attempt." >&2 + exit 1 + } + artifact_json="$(gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}")" + jq -e \ + --arg digest "sha256:${ARTIFACT_DIGEST}" \ + --arg id "$ARTIFACT_ID" \ + --arg name "$ARTIFACT_NAME" \ + --arg run_id "$ARTIFACT_RUN_ID" \ + ' + (.id | tostring) == $id and + .name == $name and + .expired == false and + .digest == $digest and + (.workflow_run.id | tostring) == $run_id + ' <<< "$artifact_json" >/dev/null || { + echo "OpenClaw package artifact identity does not match the requested immutable tuple." >&2 + exit 1 + } + attempt_json="$( + gh api \ + "repos/${GITHUB_REPOSITORY}/actions/runs/${ARTIFACT_RUN_ID}/attempts/${ARTIFACT_RUN_ATTEMPT}" + )" + jq -e \ + --arg attempt "$ARTIFACT_RUN_ATTEMPT" \ + --arg run_id "$ARTIFACT_RUN_ID" \ + '(.id | tostring) == $run_id and (.run_attempt | tostring) == $attempt' \ + <<< "$attempt_json" >/dev/null || { + echo "OpenClaw package artifact producer run attempt does not match the requested tuple." >&2 + exit 1 + } + { + echo "artifact_digest=$ARTIFACT_DIGEST" + echo "run_id=$ARTIFACT_RUN_ID" + echo "run_attempt=$ARTIFACT_RUN_ATTEMPT" + } >> "$GITHUB_OUTPUT" + - name: Download current-run OpenClaw Docker E2E package - if: steps.plan.outputs.needs_package == '1' && inputs.package_artifact_name != '' && inputs.package_artifact_run_id == '' + if: steps.plan.outputs.needs_package == '1' && inputs.package_artifact_id != '' && inputs.package_artifact_run_id == github.run_id uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: - name: ${{ inputs.package_artifact_name }} + artifact-ids: ${{ inputs.package_artifact_id }} path: .artifacts/docker-e2e-package + run-id: ${{ inputs.package_artifact_run_id }} + github-token: ${{ github.token }} - name: Download previous-run OpenClaw Docker E2E package - if: steps.plan.outputs.needs_package == '1' && inputs.package_artifact_run_id != '' + if: steps.plan.outputs.needs_package == '1' && inputs.package_artifact_id != '' && inputs.package_artifact_run_id != github.run_id uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: - name: ${{ inputs.package_artifact_name || 'docker-e2e-package' }} + artifact-ids: ${{ inputs.package_artifact_id }} path: .artifacts/docker-e2e-package run-id: ${{ inputs.package_artifact_run_id }} github-token: ${{ github.token }} @@ -1484,11 +1921,33 @@ jobs: - name: Validate OpenClaw Docker E2E package id: package if: steps.plan.outputs.needs_package == '1' + env: + EXPECTED_PACKAGE_FILE_NAME: ${{ inputs.package_file_name }} + EXPECTED_PACKAGE_SHA256: ${{ inputs.package_sha256 }} + EXPECTED_PACKAGE_SOURCE_SHA: ${{ inputs.package_source_sha }} + EXPECTED_PACKAGE_VERSION: ${{ inputs.package_version }} + SELECTED_SHA: ${{ needs.validate_selected_ref.outputs.selected_sha }} + SHARED_IMAGE_POLICY: ${{ inputs.shared_image_policy }} shell: bash run: | set -euo pipefail mkdir -p .artifacts/docker-e2e-package target=".artifacts/docker-e2e-package/openclaw-current.tgz" + if [[ -n "${EXPECTED_PACKAGE_FILE_NAME// }" ]]; then + input_target=".artifacts/docker-e2e-package/${EXPECTED_PACKAGE_FILE_NAME}" + [[ -f "$input_target" ]] || { + echo "Declared package tarball is missing from the selected artifact." >&2 + exit 1 + } + input_digest="$(sha256sum "$input_target" | awk '{print $1}')" + [[ "$input_digest" == "$EXPECTED_PACKAGE_SHA256" ]] || { + echo "Declared package tarball SHA-256 differs from package_sha256." >&2 + exit 1 + } + if [[ "$input_target" != "$target" ]]; then + cp "$input_target" "$target" + fi + fi if [[ ! -f "$target" ]]; then mapfile -t tgzs < <(find .artifacts/docker-e2e-package -type f -name '*.tgz' | sort) if [[ "${#tgzs[@]}" -ne 1 ]]; then @@ -1504,8 +1963,56 @@ jobs: finished_at="$(date +%s)" echo "Docker E2E package tarball validation finished in $((finished_at - started_at))s." digest="$(sha256sum "$target" | awk '{print $1}')" + version="$(tar -xOf "$target" package/package.json | jq -r '.version')" + name="$(tar -xOf "$target" package/package.json | jq -r '.name')" + package_source_sha="$( + tar -xOf "$target" package/dist/build-info.json | + jq -er '.commit | select(type == "string" and test("^[0-9a-f]{40}$"))' + )" + if [[ -n "${EXPECTED_PACKAGE_SOURCE_SHA// }" ]]; then + [[ "$digest" == "$EXPECTED_PACKAGE_SHA256" && + "$name" == "openclaw" && + "$package_source_sha" == "$EXPECTED_PACKAGE_SOURCE_SHA" && + "$version" == "$EXPECTED_PACKAGE_VERSION" ]] || { + echo "Resolved package identity differs from the declared immutable tuple." >&2 + exit 1 + } + fi + if [[ "$SHARED_IMAGE_POLICY" == "no-push-artifact" ]]; then + if [[ -n "$EXPECTED_PACKAGE_SHA256" && "$digest" != "$EXPECTED_PACKAGE_SHA256" ]]; then + echo "Exact-target package SHA-256 differs from package_sha256." >&2 + exit 1 + fi + if [[ "$name" != "openclaw" || ( -n "$EXPECTED_PACKAGE_VERSION" && "$version" != "$EXPECTED_PACKAGE_VERSION" ) ]]; then + echo "Exact-target package name/version differs from declared package identity." >&2 + exit 1 + fi + [[ "$package_source_sha" == "$SELECTED_SHA" ]] || { + echo "Exact-target package build commit differs from the selected SHA." >&2 + exit 1 + } + metadata=".artifacts/docker-e2e-package/package-candidate.json" + if [[ -f "$metadata" ]]; then + jq -e \ + --arg sha "$SELECTED_SHA" \ + --arg digest "$digest" \ + --arg version "$version" \ + ' + .name == "openclaw" and + .packageSourceSha == $sha and + .sha256 == $digest and + .version == $version + ' "$metadata" >/dev/null || { + echo "Exact-target package metadata does not bind the selected SHA and tarball." >&2 + exit 1 + } + fi + fi tag="pkg-${digest:0:32}" echo "sha256=$digest" >> "$GITHUB_OUTPUT" + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "file_name=openclaw-current.tgz" >> "$GITHUB_OUTPUT" + echo "source_sha=$package_source_sha" >> "$GITHUB_OUTPUT" echo "tag=$tag" >> "$GITHUB_OUTPUT" { echo "Docker E2E package: \`$target\`" @@ -1513,10 +2020,11 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" - name: Upload OpenClaw Docker E2E package - if: steps.plan.outputs.needs_package == '1' && (inputs.package_artifact_name == '' || inputs.package_artifact_run_id != '') + id: upload_package + if: steps.plan.outputs.needs_package == '1' && (inputs.package_artifact_id == '' || inputs.package_artifact_run_id != github.run_id) uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: ${{ inputs.package_artifact_name || 'docker-e2e-package' }} + name: docker-e2e-package-${{ github.run_id }}-${{ github.run_attempt }} path: .artifacts/docker-e2e-package/openclaw-current.tgz if-no-files-found: error @@ -1528,12 +2036,18 @@ jobs: SELECTED_SHA: ${{ needs.validate_selected_ref.outputs.selected_sha }} PROVIDED_BARE_IMAGE: ${{ inputs.docker_e2e_bare_image }} PROVIDED_FUNCTIONAL_IMAGE: ${{ inputs.docker_e2e_functional_image }} + SHARED_IMAGE_POLICY: ${{ inputs.shared_image_policy }} run: | set -euo pipefail repository="${GITHUB_REPOSITORY,,}" image_tag="${PACKAGE_TAG:-$SELECTED_SHA}" - bare_image="${PROVIDED_BARE_IMAGE:-ghcr.io/${repository}-docker-e2e-bare:${image_tag}}" - functional_image="${PROVIDED_FUNCTIONAL_IMAGE:-ghcr.io/${repository}-docker-e2e-functional:${image_tag}}" + if [[ "$SHARED_IMAGE_POLICY" == "no-push-artifact" ]]; then + bare_image="openclaw-docker-e2e-bare:${image_tag}" + functional_image="openclaw-docker-e2e-functional:${image_tag}" + else + bare_image="${PROVIDED_BARE_IMAGE:-ghcr.io/${repository}-docker-e2e-bare:${image_tag}}" + functional_image="${PROVIDED_FUNCTIONAL_IMAGE:-ghcr.io/${repository}-docker-e2e-functional:${image_tag}}" + fi image="$functional_image" echo "image=$image" >> "$GITHUB_OUTPUT" echo "bare_image=$bare_image" >> "$GITHUB_OUTPUT" @@ -1542,7 +2056,7 @@ jobs: echo "Shared Docker E2E functional image: \`$functional_image\`" >> "$GITHUB_STEP_SUMMARY" - name: Log in to GHCR - if: steps.plan.outputs.needs_e2e_image == '1' + if: steps.plan.outputs.needs_e2e_image == '1' && (inputs.shared_image_policy == 'allow-push' || inputs.shared_image_policy == 'existing-only') run: bash .release-harness/scripts/ci-docker-login-ghcr.sh env: GHCR_USERNAME: ${{ github.actor }} @@ -1550,11 +2064,12 @@ jobs: - name: Check existing shared Docker E2E images id: image_exists - if: steps.plan.outputs.needs_e2e_image == '1' + if: steps.plan.outputs.needs_e2e_image == '1' && (inputs.shared_image_policy == 'allow-push' || inputs.shared_image_policy == 'existing-only') shell: bash env: PROVIDED_BARE_IMAGE: ${{ inputs.docker_e2e_bare_image }} PROVIDED_FUNCTIONAL_IMAGE: ${{ inputs.docker_e2e_functional_image }} + SHARED_IMAGE_POLICY: ${{ inputs.shared_image_policy }} run: | set -euo pipefail bare_exists=0 @@ -1585,21 +2100,162 @@ jobs: fi fi + if [[ "$SHARED_IMAGE_POLICY" == "existing-only" && "$needs_build" == "1" ]]; then + echo "shared_image_policy=existing-only forbids building or pushing missing shared images." >&2 + exit 1 + fi + echo "bare_exists=$bare_exists" >> "$GITHUB_OUTPUT" echo "functional_exists=$functional_exists" >> "$GITHUB_OUTPUT" echo "needs_build=$needs_build" >> "$GITHUB_OUTPUT" - name: Setup Docker builder - if: steps.image_exists.outputs.needs_build == '1' + if: inputs.shared_image_policy == 'no-push-artifact' && steps.plan.outputs.needs_e2e_image == '1' + uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1 + with: + max-cache-size-mb: 800000 + + - name: Build bare Docker E2E image artifact + if: inputs.shared_image_policy == 'no-push-artifact' && steps.plan.outputs.needs_bare_image == '1' + shell: bash + env: + IMAGE_REF: ${{ steps.image.outputs.bare_image }} + run: | + set -euo pipefail + timeout --kill-after=30s 45m docker buildx build \ + --load \ + --file ./scripts/e2e/Dockerfile \ + --target bare \ + --platform linux/amd64 \ + --tag "$IMAGE_REF" \ + --sbom=true \ + --provenance=mode=max \ + . + + - name: Build functional Docker E2E image artifact + if: inputs.shared_image_policy == 'no-push-artifact' && steps.plan.outputs.needs_functional_image == '1' + shell: bash + env: + IMAGE_REF: ${{ steps.image.outputs.functional_image }} + run: | + set -euo pipefail + timeout --kill-after=30s 45m docker buildx build \ + --load \ + --file ./scripts/e2e/Dockerfile \ + --target functional \ + --build-context openclaw_package=.artifacts/docker-e2e-package \ + --platform linux/amd64 \ + --tag "$IMAGE_REF" \ + --sbom=true \ + --provenance=mode=max \ + . + + - name: Pack Docker E2E image artifact + id: image_artifact + if: inputs.shared_image_policy == 'no-push-artifact' && steps.plan.outputs.needs_e2e_image == '1' + shell: bash + env: + BARE_IMAGE: ${{ steps.image.outputs.bare_image }} + FUNCTIONAL_IMAGE: ${{ steps.image.outputs.functional_image }} + NEEDS_BARE_IMAGE: ${{ steps.plan.outputs.needs_bare_image }} + NEEDS_FUNCTIONAL_IMAGE: ${{ steps.plan.outputs.needs_functional_image }} + PACKAGE_SHA256: ${{ steps.package.outputs.sha256 }} + SHARED_IMAGE_ARTIFACT_NAMESPACE: ${{ inputs.shared_image_artifact_namespace }} + TARGET_SHA: ${{ needs.validate_selected_ref.outputs.selected_sha }} + WORKFLOW_SHA: ${{ needs.validate_selected_ref.outputs.workflow_sha }} + run: | + set -euo pipefail + artifact_dir="${RUNNER_TEMP}/docker-e2e-shared-images" + artifact_name="docker-e2e-shared-images-${SHARED_IMAGE_ARTIFACT_NAMESPACE}-${TARGET_SHA:0:12}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + images=() + if [[ "$NEEDS_BARE_IMAGE" == "1" ]]; then + images+=("$BARE_IMAGE") + fi + if [[ "$NEEDS_FUNCTIONAL_IMAGE" == "1" ]]; then + images+=("$FUNCTIONAL_IMAGE") + fi + OPENCLAW_SHARED_IMAGE_PACKAGE_SHA256="$PACKAGE_SHA256" \ + bash .release-harness/scripts/docker/shared-image-artifact.sh \ + pack "$artifact_dir" docker-e2e "$TARGET_SHA" "$WORKFLOW_SHA" "${images[@]}" + archive_sha256="$(jq -er '.archive.sha256 | select(test("^[a-f0-9]{64}$"))' \ + "$artifact_dir/shared-image-artifact.json")" + { + echo "artifact_name=$artifact_name" + echo "artifact_path=$artifact_dir" + echo "archive_sha256=$archive_sha256" + } >> "$GITHUB_OUTPUT" + + - name: Upload Docker E2E image artifact + id: upload_image_artifact + if: inputs.shared_image_policy == 'no-push-artifact' && steps.plan.outputs.needs_e2e_image == '1' + 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 + + push_docker_e2e_images: + needs: [validate_selected_ref, prepare_docker_e2e_image] + if: inputs.shared_image_policy == 'allow-push' && needs.prepare_docker_e2e_image.outputs.needs_registry_build == '1' + runs-on: ${{ inputs.use_github_hosted_runners && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }} + timeout-minutes: ${{ inputs.release_test_profile == 'full' && 90 || 60 }} + permissions: + actions: read + contents: read + packages: write + env: + DOCKER_BUILD_SUMMARY: "false" + DOCKER_BUILD_RECORD_UPLOAD: "false" + steps: + - name: Checkout selected ref + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + ref: ${{ needs.validate_selected_ref.outputs.selected_sha }} + fetch-depth: 1 + persist-credentials: false + + - name: Download OpenClaw Docker E2E package + if: needs.prepare_docker_e2e_image.outputs.needs_functional_image == '1' && needs.prepare_docker_e2e_image.outputs.functional_exists != '1' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + artifact-ids: ${{ needs.prepare_docker_e2e_image.outputs.package_artifact_id }} + path: .artifacts/docker-e2e-package + run-id: ${{ needs.prepare_docker_e2e_image.outputs.package_artifact_run_id }} + github-token: ${{ github.token }} + + - name: Normalize OpenClaw Docker E2E package + if: needs.prepare_docker_e2e_image.outputs.needs_functional_image == '1' && needs.prepare_docker_e2e_image.outputs.functional_exists != '1' + shell: bash + run: | + set -euo pipefail + target=".artifacts/docker-e2e-package/openclaw-current.tgz" + if [[ ! -f "$target" ]]; then + mapfile -t tgzs < <(find .artifacts/docker-e2e-package -type f -name '*.tgz' | sort) + if [[ "${#tgzs[@]}" -ne 1 ]]; then + echo "Expected exactly one package tarball for the registry image build; found ${#tgzs[@]}." >&2 + exit 1 + fi + cp "${tgzs[0]}" "$target" + fi + + - name: Log in to GHCR + run: bash scripts/ci-docker-login-ghcr.sh + env: + GHCR_USERNAME: ${{ github.actor }} + GITHUB_TOKEN: ${{ github.token }} + + - name: Setup Docker builder uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1 with: max-cache-size-mb: 800000 - name: Build and push bare Docker E2E image - if: steps.plan.outputs.needs_bare_image == '1' && steps.image_exists.outputs.bare_exists != '1' + if: needs.prepare_docker_e2e_image.outputs.needs_bare_image == '1' && needs.prepare_docker_e2e_image.outputs.bare_exists != '1' shell: bash env: - IMAGE_REF: ${{ steps.image.outputs.bare_image }} + IMAGE_REF: ${{ needs.prepare_docker_e2e_image.outputs.bare_image }} run: | set -euo pipefail build_cmd=( @@ -1627,10 +2283,10 @@ jobs: done - name: Build and push functional Docker E2E image - if: steps.plan.outputs.needs_functional_image == '1' && steps.image_exists.outputs.functional_exists != '1' + if: needs.prepare_docker_e2e_image.outputs.needs_functional_image == '1' && needs.prepare_docker_e2e_image.outputs.functional_exists != '1' shell: bash env: - IMAGE_REF: ${{ steps.image.outputs.functional_image }} + IMAGE_REF: ${{ needs.prepare_docker_e2e_image.outputs.functional_image }} run: | set -euo pipefail build_cmd=( @@ -1658,6 +2314,37 @@ jobs: sleep "$sleep_seconds" done + docker_e2e_image_ready: + needs: [prepare_docker_e2e_image, push_docker_e2e_images] + if: always() && needs.prepare_docker_e2e_image.result != 'skipped' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + steps: + - name: Verify Docker E2E image preparation + env: + PREPARE_RESULT: ${{ needs.prepare_docker_e2e_image.result }} + PUSH_RESULT: ${{ needs.push_docker_e2e_images.result }} + NEEDS_REGISTRY_BUILD: ${{ needs.prepare_docker_e2e_image.outputs.needs_registry_build }} + SHARED_IMAGE_POLICY: ${{ inputs.shared_image_policy }} + shell: bash + run: | + set -euo pipefail + if [[ "$PREPARE_RESULT" != "success" ]]; then + echo "Docker E2E image preparation ended with ${PREPARE_RESULT}." >&2 + exit 1 + fi + if [[ "$SHARED_IMAGE_POLICY" == "allow-push" && "$NEEDS_REGISTRY_BUILD" == "1" ]]; then + if [[ "$PUSH_RESULT" != "success" ]]; then + echo "Docker E2E registry image publication ended with ${PUSH_RESULT}." >&2 + exit 1 + fi + elif [[ "$PUSH_RESULT" != "skipped" ]]; then + echo "Unexpected Docker E2E registry publication result: ${PUSH_RESULT}." >&2 + exit 1 + fi + prepare_live_test_image: needs: validate_selected_ref if: inputs.include_live_suites && (inputs.live_suite_filter == '' || startsWith(inputs.live_suite_filter, 'live-') || startsWith(inputs.live_suite_filter, 'docker-live-models')) @@ -1666,9 +2353,16 @@ jobs: timeout-minutes: 60 permissions: contents: read - packages: write + packages: read outputs: live_image: ${{ steps.image.outputs.live_image }} + image_exists: ${{ steps.image_exists.outputs.exists }} + image_artifact_name: ${{ steps.image_artifact.outputs.artifact_name }} + image_archive_sha256: ${{ steps.image_artifact.outputs.archive_sha256 }} + image_artifact_id: ${{ steps.upload_image_artifact.outputs.artifact-id }} + image_artifact_digest: ${{ steps.upload_image_artifact.outputs.artifact-digest }} + image_artifact_run_id: ${{ github.run_id }} + image_artifact_run_attempt: ${{ github.run_attempt }} env: DOCKER_BUILD_SUMMARY: "false" DOCKER_BUILD_RECORD_UPLOAD: "false" @@ -1678,23 +2372,39 @@ jobs: with: ref: ${{ needs.validate_selected_ref.outputs.selected_sha }} fetch-depth: 1 + persist-credentials: false + + - name: Checkout trusted release harness + if: inputs.shared_image_policy == 'no-push-artifact' + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + repository: ${{ needs.validate_selected_ref.outputs.workflow_repository }} + ref: ${{ needs.validate_selected_ref.outputs.workflow_sha }} + path: .release-harness + persist-credentials: false - name: Resolve shared live-test image tag id: image shell: bash env: SELECTED_SHA: ${{ needs.validate_selected_ref.outputs.selected_sha }} + SHARED_IMAGE_POLICY: ${{ inputs.shared_image_policy }} run: | set -euo pipefail repository="${GITHUB_REPOSITORY,,}" live_image_extensions="matrix,acpx" live_image_tag_suffix="${live_image_extensions//,/-}" - live_image="ghcr.io/${repository}-live-test:${SELECTED_SHA}-${live_image_tag_suffix}" + if [[ "$SHARED_IMAGE_POLICY" == "no-push-artifact" ]]; then + live_image="openclaw-live-test:${SELECTED_SHA}-${live_image_tag_suffix}" + else + live_image="ghcr.io/${repository}-live-test:${SELECTED_SHA}-${live_image_tag_suffix}" + fi echo "live_image=${live_image}" >> "$GITHUB_OUTPUT" echo "live_image_extensions=${live_image_extensions}" >> "$GITHUB_OUTPUT" echo "Shared live-test image: \`${live_image}\`" >> "$GITHUB_STEP_SUMMARY" - name: Log in to GHCR + if: inputs.shared_image_policy != 'no-push-artifact' run: bash scripts/ci-docker-login-ghcr.sh env: GHCR_USERNAME: ${{ github.actor }} @@ -1702,6 +2412,9 @@ jobs: - name: Check existing shared live-test image id: image_exists + if: inputs.shared_image_policy != 'no-push-artifact' + env: + SHARED_IMAGE_POLICY: ${{ inputs.shared_image_policy }} shell: bash run: | set -euo pipefail @@ -1709,17 +2422,21 @@ jobs: echo "Shared live-test image already exists: ${{ steps.image.outputs.live_image }}" echo "exists=1" >> "$GITHUB_OUTPUT" else + if [[ "$SHARED_IMAGE_POLICY" == "existing-only" ]]; then + echo "shared_image_policy=existing-only forbids building or pushing a missing live-test image." >&2 + exit 1 + fi echo "exists=0" >> "$GITHUB_OUTPUT" fi - name: Setup Docker builder - if: steps.image_exists.outputs.exists != '1' + if: inputs.shared_image_policy == 'no-push-artifact' uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1 with: max-cache-size-mb: 800000 - - name: Build and push shared live-test image - if: steps.image_exists.outputs.exists != '1' + - name: Build shared live-test image + if: inputs.shared_image_policy == 'no-push-artifact' uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2 with: context: . @@ -1731,11 +2448,128 @@ jobs: tags: ${{ steps.image.outputs.live_image }} sbom: true provenance: mode=max + load: true + push: false + + - name: Pack live-test image artifact + id: image_artifact + if: inputs.shared_image_policy == 'no-push-artifact' + env: + LIVE_IMAGE: ${{ steps.image.outputs.live_image }} + SHARED_IMAGE_ARTIFACT_NAMESPACE: ${{ inputs.shared_image_artifact_namespace }} + TARGET_SHA: ${{ needs.validate_selected_ref.outputs.selected_sha }} + WORKFLOW_SHA: ${{ needs.validate_selected_ref.outputs.workflow_sha }} + shell: bash + run: | + set -euo pipefail + artifact_dir="${RUNNER_TEMP}/live-test-shared-image" + artifact_name="live-test-shared-image-${SHARED_IMAGE_ARTIFACT_NAMESPACE}-${TARGET_SHA:0:12}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + bash .release-harness/scripts/docker/shared-image-artifact.sh \ + pack "$artifact_dir" live-test "$TARGET_SHA" "$WORKFLOW_SHA" "$LIVE_IMAGE" + archive_sha256="$(jq -er '.archive.sha256 | select(test("^[a-f0-9]{64}$"))' \ + "$artifact_dir/shared-image-artifact.json")" + { + echo "artifact_name=$artifact_name" + echo "artifact_path=$artifact_dir" + echo "archive_sha256=$archive_sha256" + } >> "$GITHUB_OUTPUT" + + - name: Upload live-test image artifact + id: upload_image_artifact + if: inputs.shared_image_policy == 'no-push-artifact' + 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 + + push_live_test_image: + needs: [validate_selected_ref, prepare_live_test_image] + if: inputs.shared_image_policy == 'allow-push' && needs.prepare_live_test_image.outputs.image_exists != '1' + runs-on: ${{ inputs.use_github_hosted_runners && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }} + timeout-minutes: 60 + permissions: + contents: read + packages: write + env: + DOCKER_BUILD_SUMMARY: "false" + DOCKER_BUILD_RECORD_UPLOAD: "false" + steps: + - name: Checkout selected ref + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + ref: ${{ needs.validate_selected_ref.outputs.selected_sha }} + fetch-depth: 1 + persist-credentials: false + + - name: Log in to GHCR + run: bash scripts/ci-docker-login-ghcr.sh + env: + GHCR_USERNAME: ${{ github.actor }} + GITHUB_TOKEN: ${{ github.token }} + + - name: Setup Docker builder + uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1 + with: + max-cache-size-mb: 800000 + + - name: Build and push shared live-test image + uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2 + with: + context: . + file: ./Dockerfile + target: build + build-args: | + OPENCLAW_EXTENSIONS=matrix,acpx + platforms: linux/amd64 + tags: ${{ needs.prepare_live_test_image.outputs.live_image }} + sbom: true + provenance: mode=max + load: false push: true + live_test_image_ready: + needs: [prepare_live_test_image, push_live_test_image] + if: always() && needs.prepare_live_test_image.result != 'skipped' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + steps: + - name: Verify live-test image preparation + env: + IMAGE_EXISTS: ${{ needs.prepare_live_test_image.outputs.image_exists }} + PREPARE_RESULT: ${{ needs.prepare_live_test_image.result }} + PUSH_RESULT: ${{ needs.push_live_test_image.result }} + SHARED_IMAGE_POLICY: ${{ inputs.shared_image_policy }} + shell: bash + run: | + set -euo pipefail + if [[ "$PREPARE_RESULT" != "success" ]]; then + echo "Live-test image preparation ended with ${PREPARE_RESULT}." >&2 + exit 1 + fi + if [[ "$SHARED_IMAGE_POLICY" == "allow-push" && "$IMAGE_EXISTS" != "1" ]]; then + if [[ "$PUSH_RESULT" != "success" ]]; then + echo "Live-test registry image publication ended with ${PUSH_RESULT}." >&2 + exit 1 + fi + elif [[ "$PUSH_RESULT" != "skipped" ]]; then + echo "Unexpected live-test registry publication result: ${PUSH_RESULT}." >&2 + exit 1 + fi + validate_live_models_docker: name: Docker live models (${{ matrix.provider_label }}) - needs: [validate_selected_ref, prepare_live_test_image, plan_release_workflow_matrices] + needs: + [ + validate_selected_ref, + prepare_live_test_image, + live_test_image_ready, + plan_release_workflow_matrices, + ] if: inputs.include_live_suites && inputs.live_model_providers == '' && (inputs.live_suite_filter == '' || inputs.live_suite_filter == 'docker-live-models') && needs.plan_release_workflow_matrices.outputs.live_models_count != '0' continue-on-error: ${{ inputs.advisory || inputs.live_advisory }} runs-on: ${{ inputs.use_github_hosted_runners && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }} @@ -1783,6 +2617,7 @@ jobs: OPENCLAW_LIVE_MODELS: ${{ matrix.models || 'modern' }} OPENCLAW_LIVE_MAX_MODELS: ${{ matrix.max_models || '6' }} OPENCLAW_LIVE_MODEL_TIMEOUT_MS: "45000" + OPENCLAW_LIVE_REQUIRE_LOCAL_IMAGE: ${{ inputs.shared_image_policy == 'no-push-artifact' && '1' || '0' }} OPENCLAW_SKIP_DOCKER_BUILD: "1" OPENCLAW_VITEST_MAX_WORKERS: "2" steps: @@ -1792,14 +2627,59 @@ jobs: with: ref: ${{ needs.validate_selected_ref.outputs.selected_sha }} fetch-depth: 1 + persist-credentials: false - name: Checkout trusted live Docker harness if: contains(matrix.profiles, inputs.release_test_profile) uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: - ref: ${{ github.sha }} + repository: ${{ needs.validate_selected_ref.outputs.workflow_repository }} + ref: ${{ needs.validate_selected_ref.outputs.workflow_sha }} fetch-depth: 1 path: .release-harness + persist-credentials: false + + - name: Validate live-test image artifact binding + if: contains(matrix.profiles, inputs.release_test_profile) && inputs.shared_image_policy == 'no-push-artifact' + env: + ARTIFACT_DIGEST: ${{ needs.prepare_live_test_image.outputs.image_artifact_digest }} + ARTIFACT_ID: ${{ needs.prepare_live_test_image.outputs.image_artifact_id }} + ARTIFACT_NAME: ${{ needs.prepare_live_test_image.outputs.image_artifact_name }} + ARTIFACT_RUN_ATTEMPT: ${{ needs.prepare_live_test_image.outputs.image_artifact_run_attempt }} + ARTIFACT_RUN_ID: ${{ needs.prepare_live_test_image.outputs.image_artifact_run_id }} + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + bash .release-harness/scripts/docker/shared-image-artifact.sh \ + verify-upload "live-test image" \ + "$ARTIFACT_ID" "$ARTIFACT_NAME" "$ARTIFACT_DIGEST" \ + "$ARTIFACT_RUN_ID" "$ARTIFACT_RUN_ATTEMPT" + + - name: Download live-test image artifact + if: contains(matrix.profiles, inputs.release_test_profile) && inputs.shared_image_policy == 'no-push-artifact' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + artifact-ids: ${{ needs.prepare_live_test_image.outputs.image_artifact_id }} + path: .artifacts/live-test-image + run-id: ${{ needs.prepare_live_test_image.outputs.image_artifact_run_id }} + github-token: ${{ github.token }} + + - name: Verify and load live-test image artifact + if: contains(matrix.profiles, inputs.release_test_profile) && inputs.shared_image_policy == 'no-push-artifact' + env: + ARCHIVE_SHA256: ${{ needs.prepare_live_test_image.outputs.image_archive_sha256 }} + LIVE_IMAGE: ${{ needs.prepare_live_test_image.outputs.live_image }} + OPENCLAW_SHARED_IMAGE_RUN_ATTEMPT: ${{ needs.prepare_live_test_image.outputs.image_artifact_run_attempt }} + OPENCLAW_SHARED_IMAGE_RUN_ID: ${{ needs.prepare_live_test_image.outputs.image_artifact_run_id }} + TARGET_SHA: ${{ needs.validate_selected_ref.outputs.selected_sha }} + WORKFLOW_SHA: ${{ needs.validate_selected_ref.outputs.workflow_sha }} + shell: bash + run: | + set -euo pipefail + OPENCLAW_SHARED_IMAGE_ARCHIVE_SHA256="$ARCHIVE_SHA256" \ + bash .release-harness/scripts/docker/shared-image-artifact.sh \ + load .artifacts/live-test-image live-test "$TARGET_SHA" "$WORKFLOW_SHA" "$LIVE_IMAGE" - name: Setup Node environment if: contains(matrix.profiles, inputs.release_test_profile) @@ -1813,7 +2693,7 @@ jobs: run: bash scripts/ci-hydrate-live-auth.sh - name: Log in to GHCR - if: contains(matrix.profiles, inputs.release_test_profile) + if: contains(matrix.profiles, inputs.release_test_profile) && inputs.shared_image_policy != 'no-push-artifact' run: bash .release-harness/scripts/ci-docker-login-ghcr.sh env: GHCR_USERNAME: ${{ github.actor }} @@ -1863,7 +2743,7 @@ jobs: validate_live_models_docker_targeted: name: Docker live models (selected providers) - needs: [validate_selected_ref, prepare_live_test_image] + needs: [validate_selected_ref, prepare_live_test_image, live_test_image_ready] if: inputs.include_live_suites && inputs.live_model_providers != '' && (inputs.live_suite_filter == '' || inputs.live_suite_filter == 'docker-live-models') continue-on-error: ${{ inputs.advisory || inputs.live_advisory }} runs-on: ${{ inputs.use_github_hosted_runners && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }} @@ -1907,6 +2787,7 @@ jobs: OPENCLAW_LIVE_IMAGE: ${{ needs.prepare_live_test_image.outputs.live_image }} OPENCLAW_LIVE_MAX_MODELS: "6" OPENCLAW_LIVE_MODEL_TIMEOUT_MS: "45000" + OPENCLAW_LIVE_REQUIRE_LOCAL_IMAGE: ${{ inputs.shared_image_policy == 'no-push-artifact' && '1' || '0' }} OPENCLAW_SKIP_DOCKER_BUILD: "1" OPENCLAW_VITEST_MAX_WORKERS: "2" steps: @@ -1915,13 +2796,58 @@ jobs: with: ref: ${{ needs.validate_selected_ref.outputs.selected_sha }} fetch-depth: 1 + persist-credentials: false - name: Checkout trusted live Docker harness uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: - ref: ${{ github.sha }} + repository: ${{ needs.validate_selected_ref.outputs.workflow_repository }} + ref: ${{ needs.validate_selected_ref.outputs.workflow_sha }} fetch-depth: 1 path: .release-harness + persist-credentials: false + + - name: Validate live-test image artifact binding + if: inputs.shared_image_policy == 'no-push-artifact' + env: + ARTIFACT_DIGEST: ${{ needs.prepare_live_test_image.outputs.image_artifact_digest }} + ARTIFACT_ID: ${{ needs.prepare_live_test_image.outputs.image_artifact_id }} + ARTIFACT_NAME: ${{ needs.prepare_live_test_image.outputs.image_artifact_name }} + ARTIFACT_RUN_ATTEMPT: ${{ needs.prepare_live_test_image.outputs.image_artifact_run_attempt }} + ARTIFACT_RUN_ID: ${{ needs.prepare_live_test_image.outputs.image_artifact_run_id }} + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + bash .release-harness/scripts/docker/shared-image-artifact.sh \ + verify-upload "live-test image" \ + "$ARTIFACT_ID" "$ARTIFACT_NAME" "$ARTIFACT_DIGEST" \ + "$ARTIFACT_RUN_ID" "$ARTIFACT_RUN_ATTEMPT" + + - name: Download live-test image artifact + if: inputs.shared_image_policy == 'no-push-artifact' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + artifact-ids: ${{ needs.prepare_live_test_image.outputs.image_artifact_id }} + path: .artifacts/live-test-image + run-id: ${{ needs.prepare_live_test_image.outputs.image_artifact_run_id }} + github-token: ${{ github.token }} + + - name: Verify and load live-test image artifact + if: inputs.shared_image_policy == 'no-push-artifact' + env: + ARCHIVE_SHA256: ${{ needs.prepare_live_test_image.outputs.image_archive_sha256 }} + LIVE_IMAGE: ${{ needs.prepare_live_test_image.outputs.live_image }} + OPENCLAW_SHARED_IMAGE_RUN_ATTEMPT: ${{ needs.prepare_live_test_image.outputs.image_artifact_run_attempt }} + OPENCLAW_SHARED_IMAGE_RUN_ID: ${{ needs.prepare_live_test_image.outputs.image_artifact_run_id }} + TARGET_SHA: ${{ needs.validate_selected_ref.outputs.selected_sha }} + WORKFLOW_SHA: ${{ needs.validate_selected_ref.outputs.workflow_sha }} + shell: bash + run: | + set -euo pipefail + OPENCLAW_SHARED_IMAGE_ARCHIVE_SHA256="$ARCHIVE_SHA256" \ + bash .release-harness/scripts/docker/shared-image-artifact.sh \ + load .artifacts/live-test-image live-test "$TARGET_SHA" "$WORKFLOW_SHA" "$LIVE_IMAGE" - name: Setup Node environment uses: ./.github/actions/setup-node-env @@ -1991,6 +2917,7 @@ jobs: run: bash scripts/ci-hydrate-live-auth.sh - name: Log in to GHCR + if: inputs.shared_image_policy != 'no-push-artifact' run: bash .release-harness/scripts/ci-docker-login-ghcr.sh env: GHCR_USERNAME: ${{ github.actor }} @@ -2305,7 +3232,9 @@ jobs: if: contains(matrix.profiles, inputs.release_test_profile) && (inputs.live_suite_filter == '' || inputs.live_suite_filter == matrix.suite_id || (inputs.live_suite_filter == 'native-live-src-gateway-profiles-anthropic' && startsWith(matrix.suite_id, 'native-live-src-gateway-profiles-anthropic-')) || (inputs.live_suite_filter == 'native-live-src-gateway-profiles-opencode-go' && startsWith(matrix.suite_id, 'native-live-src-gateway-profiles-opencode-go-'))) uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: - ref: ${{ github.sha }} + persist-credentials: false + repository: ${{ needs.validate_selected_ref.outputs.workflow_repository }} + ref: ${{ needs.validate_selected_ref.outputs.workflow_sha }} fetch-depth: 1 path: .release-harness @@ -2388,7 +3317,7 @@ jobs: validate_live_docker_provider_suites: name: Docker live suites (${{ matrix.label }}) - needs: [validate_selected_ref, prepare_live_test_image] + needs: [validate_selected_ref, prepare_live_test_image, live_test_image_ready] if: inputs.include_live_suites && !inputs.live_models_only && (inputs.live_suite_filter == '' || startsWith(inputs.live_suite_filter, 'live-')) continue-on-error: ${{ inputs.advisory || inputs.live_advisory }} runs-on: ${{ inputs.use_github_hosted_runners && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }} @@ -2546,6 +3475,7 @@ jobs: OPENCLAW_GEMINI_SETTINGS_JSON: ${{ secrets.OPENCLAW_GEMINI_SETTINGS_JSON }} FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }} OPENCLAW_LIVE_IMAGE: ${{ needs.prepare_live_test_image.outputs.live_image }} + OPENCLAW_LIVE_REQUIRE_LOCAL_IMAGE: ${{ inputs.shared_image_policy == 'no-push-artifact' && '1' || '0' }} OPENCLAW_SKIP_DOCKER_BUILD: "1" OPENCLAW_LIVE_VIDEO_GENERATION_SKIP_PROVIDERS: "" OPENCLAW_LIVE_VYDRA_VIDEO: "1" @@ -2557,14 +3487,59 @@ jobs: with: ref: ${{ needs.validate_selected_ref.outputs.selected_sha }} fetch-depth: 1 + persist-credentials: false - name: Checkout trusted live shard harness if: contains(matrix.profiles, inputs.release_test_profile) && (inputs.live_suite_filter == '' || inputs.live_suite_filter == matrix.suite_id || inputs.live_suite_filter == matrix.suite_group) uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: - ref: ${{ github.sha }} + repository: ${{ needs.validate_selected_ref.outputs.workflow_repository }} + ref: ${{ needs.validate_selected_ref.outputs.workflow_sha }} fetch-depth: 1 path: .release-harness + persist-credentials: false + + - name: Validate live-test image artifact binding + if: contains(matrix.profiles, inputs.release_test_profile) && (inputs.live_suite_filter == '' || inputs.live_suite_filter == matrix.suite_id || inputs.live_suite_filter == matrix.suite_group) && inputs.shared_image_policy == 'no-push-artifact' + env: + ARTIFACT_DIGEST: ${{ needs.prepare_live_test_image.outputs.image_artifact_digest }} + ARTIFACT_ID: ${{ needs.prepare_live_test_image.outputs.image_artifact_id }} + ARTIFACT_NAME: ${{ needs.prepare_live_test_image.outputs.image_artifact_name }} + ARTIFACT_RUN_ATTEMPT: ${{ needs.prepare_live_test_image.outputs.image_artifact_run_attempt }} + ARTIFACT_RUN_ID: ${{ needs.prepare_live_test_image.outputs.image_artifact_run_id }} + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + bash .release-harness/scripts/docker/shared-image-artifact.sh \ + verify-upload "live-test image" \ + "$ARTIFACT_ID" "$ARTIFACT_NAME" "$ARTIFACT_DIGEST" \ + "$ARTIFACT_RUN_ID" "$ARTIFACT_RUN_ATTEMPT" + + - name: Download live-test image artifact + if: contains(matrix.profiles, inputs.release_test_profile) && (inputs.live_suite_filter == '' || inputs.live_suite_filter == matrix.suite_id || inputs.live_suite_filter == matrix.suite_group) && inputs.shared_image_policy == 'no-push-artifact' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + artifact-ids: ${{ needs.prepare_live_test_image.outputs.image_artifact_id }} + path: .artifacts/live-test-image + run-id: ${{ needs.prepare_live_test_image.outputs.image_artifact_run_id }} + github-token: ${{ github.token }} + + - name: Verify and load live-test image artifact + if: contains(matrix.profiles, inputs.release_test_profile) && (inputs.live_suite_filter == '' || inputs.live_suite_filter == matrix.suite_id || inputs.live_suite_filter == matrix.suite_group) && inputs.shared_image_policy == 'no-push-artifact' + env: + ARCHIVE_SHA256: ${{ needs.prepare_live_test_image.outputs.image_archive_sha256 }} + LIVE_IMAGE: ${{ needs.prepare_live_test_image.outputs.live_image }} + OPENCLAW_SHARED_IMAGE_RUN_ATTEMPT: ${{ needs.prepare_live_test_image.outputs.image_artifact_run_attempt }} + OPENCLAW_SHARED_IMAGE_RUN_ID: ${{ needs.prepare_live_test_image.outputs.image_artifact_run_id }} + TARGET_SHA: ${{ needs.validate_selected_ref.outputs.selected_sha }} + WORKFLOW_SHA: ${{ needs.validate_selected_ref.outputs.workflow_sha }} + shell: bash + run: | + set -euo pipefail + OPENCLAW_SHARED_IMAGE_ARCHIVE_SHA256="$ARCHIVE_SHA256" \ + bash .release-harness/scripts/docker/shared-image-artifact.sh \ + load .artifacts/live-test-image live-test "$TARGET_SHA" "$WORKFLOW_SHA" "$LIVE_IMAGE" - name: Setup Node environment if: contains(matrix.profiles, inputs.release_test_profile) && (inputs.live_suite_filter == '' || inputs.live_suite_filter == matrix.suite_id || inputs.live_suite_filter == matrix.suite_group) @@ -2578,7 +3553,7 @@ jobs: run: bash scripts/ci-hydrate-live-auth.sh - name: Log in to GHCR - if: contains(matrix.profiles, inputs.release_test_profile) && (inputs.live_suite_filter == '' || inputs.live_suite_filter == matrix.suite_id || inputs.live_suite_filter == matrix.suite_group) + if: contains(matrix.profiles, inputs.release_test_profile) && (inputs.live_suite_filter == '' || inputs.live_suite_filter == matrix.suite_id || inputs.live_suite_filter == matrix.suite_group) && inputs.shared_image_policy != 'no-push-artifact' run: bash .release-harness/scripts/ci-docker-login-ghcr.sh env: GHCR_USERNAME: ${{ github.actor }} @@ -2781,7 +3756,9 @@ jobs: if: contains(matrix.profiles, inputs.release_test_profile) && (inputs.live_suite_filter == '' || inputs.live_suite_filter == matrix.suite_id || (inputs.live_suite_filter == 'native-live-extensions-media-video' && startsWith(matrix.suite_id, 'native-live-extensions-media-video-'))) uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: - ref: ${{ github.sha }} + persist-credentials: false + repository: ${{ needs.validate_selected_ref.outputs.workflow_repository }} + ref: ${{ needs.validate_selected_ref.outputs.workflow_sha }} fetch-depth: 1 path: .release-harness diff --git a/.github/workflows/openclaw-performance.yml b/.github/workflows/openclaw-performance.yml index 806442f764eb..292efe2ea594 100644 --- a/.github/workflows/openclaw-performance.yml +++ b/.github/workflows/openclaw-performance.yml @@ -42,10 +42,15 @@ on: required: false default: false type: boolean + publish_reports: + description: Publish completed reports to openclaw/clawgrit-reports + required: false + default: true + type: boolean kova_ref: description: openclaw/Kova Git ref to install required: false - default: a2dd84e7d65507e614afaff850d3932d18c859b6 + default: 24c26969e57d4d49f9d1a5071af85dd3d79daa2d type: string dispatch_id: description: Optional parent workflow dispatch identifier @@ -148,7 +153,7 @@ jobs: include_filters: "scenario:agent-cold-warm-message" expected_release_entries: "agent-cold-warm-message:mock-openai-provider" env: - KOVA_REF: ${{ inputs.kova_ref || 'a2dd84e7d65507e614afaff850d3932d18c859b6' }} + KOVA_REF: ${{ inputs.kova_ref || '24c26969e57d4d49f9d1a5071af85dd3d79daa2d' }} KOVA_HOME: ${{ github.workspace }}/.artifacts/kova/home/${{ matrix.lane }} PERFORMANCE_HELPER_DIR: ${{ github.workspace }}/.artifacts/performance-workflow REPORT_DIR: ${{ github.workspace }}/.artifacts/kova/reports/${{ matrix.lane }} @@ -281,6 +286,13 @@ jobs: git -C "$KOVA_SRC" remote add origin "https://github.com/${KOVA_REPOSITORY}.git" git -C "$KOVA_SRC" fetch --filter=blob:none --depth 1 origin "$KOVA_REF" git -C "$KOVA_SRC" checkout --detach FETCH_HEAD + npm --prefix "$KOVA_SRC" ci --ignore-scripts --no-audit --no-fund + node - "$KOVA_SRC" <<'NODE' + const root = process.argv[2]; + for (const dependency of ["mock-ai-provider", "zod"]) { + require.resolve(dependency, { paths: [root] }); + } + NODE cat > "$HOME/.local/bin/kova" <> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/openclaw-release-checks.yml b/.github/workflows/openclaw-release-checks.yml index 6241e7a6bae8..d1e2df4924c6 100644 --- a/.github/workflows/openclaw-release-checks.yml +++ b/.github/workflows/openclaw-release-checks.yml @@ -168,7 +168,7 @@ jobs: uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: persist-credentials: false - ref: ${{ github.ref_name }} + ref: ${{ github.sha }} path: workflow fetch-depth: 1 @@ -522,9 +522,14 @@ jobs: timeout-minutes: 15 permissions: contents: read - packages: write + packages: read outputs: + artifact_digest: ${{ steps.release_package_upload.outputs.artifact-digest }} + artifact_id: ${{ steps.release_package_upload.outputs.artifact-id }} artifact_name: ${{ steps.artifact.outputs.name }} + artifact_run_attempt: ${{ steps.artifact.outputs.run_attempt }} + artifact_run_id: ${{ steps.artifact.outputs.run_id }} + package_file_name: ${{ steps.artifact.outputs.file_name }} package_sha256: ${{ steps.package.outputs.sha256 }} package_version: ${{ steps.package.outputs.package_version }} source_sha: ${{ steps.package.outputs.source_sha }} @@ -532,13 +537,19 @@ jobs: - name: Checkout trusted workflow ref uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: - persist-credentials: true - ref: ${{ github.ref_name }} + persist-credentials: false + ref: ${{ github.sha }} fetch-depth: 0 - name: Set artifact metadata id: artifact - run: echo "name=release-package-under-test" >> "$GITHUB_OUTPUT" + run: | + { + echo "file_name=openclaw-current.tgz" + echo "name=release-package-under-test-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + echo "run_attempt=${GITHUB_RUN_ATTEMPT}" + echo "run_id=${GITHUB_RUN_ID}" + } >> "$GITHUB_OUTPUT" - name: Setup Node environment uses: ./.github/actions/setup-node-env @@ -570,11 +581,15 @@ jobs: digest="$(node -p "JSON.parse(require('fs').readFileSync('.artifacts/docker-e2e-package/package-candidate.json', 'utf8')).sha256")" version="$(node -p "JSON.parse(require('fs').readFileSync('.artifacts/docker-e2e-package/package-candidate.json', 'utf8')).version")" source_sha="$(node -p "JSON.parse(require('fs').readFileSync('.artifacts/docker-e2e-package/package-candidate.json', 'utf8')).packageSourceSha")" + if [[ "$source_sha" != "$PACKAGE_REF" ]]; then + echo "Release package source SHA differs from the selected release SHA: expected $PACKAGE_REF, found ${source_sha:-}." >&2 + exit 1 + fi echo "source_sha=$source_sha" >> "$GITHUB_OUTPUT" { echo "## Release package artifact" echo - echo "- Artifact: \`release-package-under-test\`" + echo "- Artifact: \`${{ steps.artifact.outputs.name }}\`" echo "- Package: \`$package_label\`" echo "- SHA-256: \`$digest\`" echo "- Version: \`$version\`" @@ -582,24 +597,65 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" - name: Upload release package artifact + id: release_package_upload uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: release-package-under-test + name: ${{ steps.artifact.outputs.name }} path: | - .artifacts/docker-e2e-package/openclaw-current.tgz + .artifacts/docker-e2e-package/${{ steps.artifact.outputs.file_name }} .artifacts/docker-e2e-package/package-candidate.json retention-days: 14 if-no-files-found: error + - name: Validate release package artifact binding + env: + ARTIFACT_DIGEST: ${{ steps.release_package_upload.outputs.artifact-digest }} + ARTIFACT_ID: ${{ steps.release_package_upload.outputs.artifact-id }} + ARTIFACT_NAME: ${{ steps.artifact.outputs.name }} + ARTIFACT_RUN_ATTEMPT: ${{ steps.artifact.outputs.run_attempt }} + ARTIFACT_RUN_ID: ${{ steps.artifact.outputs.run_id }} + PACKAGE_FILE_NAME: ${{ steps.artifact.outputs.file_name }} + PACKAGE_SHA256: ${{ steps.package.outputs.sha256 }} + PACKAGE_SOURCE_SHA: ${{ steps.package.outputs.source_sha }} + PACKAGE_VERSION: ${{ steps.package.outputs.package_version }} + run: | + set -euo pipefail + [[ "$ARTIFACT_ID" =~ ^[1-9][0-9]*$ ]] || { + echo "Release package artifact ID is missing or invalid." >&2 + exit 1 + } + [[ "$ARTIFACT_DIGEST" =~ ^[a-f0-9]{64}$ ]] || { + echo "Release package artifact digest is missing or invalid." >&2 + exit 1 + } + [[ "$ARTIFACT_RUN_ID" == "$GITHUB_RUN_ID" && + "$ARTIFACT_RUN_ATTEMPT" == "$GITHUB_RUN_ATTEMPT" ]] || { + echo "Release package artifact run binding is invalid." >&2 + exit 1 + } + [[ "$ARTIFACT_NAME" == "release-package-under-test-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" && + "$PACKAGE_FILE_NAME" == "openclaw-current.tgz" ]] || { + echo "Release package artifact name or tarball filename is invalid." >&2 + exit 1 + } + [[ "$PACKAGE_SHA256" =~ ^[a-f0-9]{64}$ && + "$PACKAGE_SOURCE_SHA" =~ ^[a-f0-9]{40}$ && + -n "${PACKAGE_VERSION// }" ]] || { + echo "Release package identity is incomplete." >&2 + exit 1 + } + install_smoke_release_checks: needs: [resolve_target] if: contains(fromJSON('["all","install-smoke"]'), needs.resolve_target.outputs.rerun_group) permissions: + actions: read contents: read - packages: write + packages: read uses: ./.github/workflows/install-smoke.yml with: ref: ${{ needs.resolve_target.outputs.revision }} + root_image_transport: no-push-artifact run_bun_global_install_smoke: true cross_os_release_checks: @@ -613,8 +669,13 @@ jobs: provider: ${{ needs.resolve_target.outputs.provider }} mode: ${{ needs.resolve_target.outputs.mode }} suite_filter: ${{ needs.resolve_target.outputs.cross_os_suite_filter }} + candidate_artifact_digest: ${{ needs.prepare_release_package.outputs.artifact_digest }} + candidate_artifact_id: ${{ needs.prepare_release_package.outputs.artifact_id }} candidate_artifact_name: ${{ needs.prepare_release_package.outputs.artifact_name }} - candidate_file_name: openclaw-current.tgz + candidate_artifact_run_attempt: ${{ needs.prepare_release_package.outputs.artifact_run_attempt }} + candidate_artifact_run_id: ${{ needs.prepare_release_package.outputs.artifact_run_id }} + candidate_file_name: ${{ needs.prepare_release_package.outputs.package_file_name }} + candidate_sha256: ${{ needs.prepare_release_package.outputs.package_sha256 }} candidate_version: ${{ needs.prepare_release_package.outputs.package_version }} candidate_source_sha: ${{ needs.prepare_release_package.outputs.source_sha }} openai_model: openai/gpt-5.5 @@ -636,7 +697,7 @@ jobs: permissions: actions: read contents: read - packages: write + packages: read pull-requests: read uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml with: @@ -652,6 +713,8 @@ jobs: include_live_suites: true release_test_profile: ${{ needs.resolve_target.outputs.release_profile }} live_suite_filter: ${{ needs.resolve_target.outputs.live_suite_filter }} + shared_image_artifact_namespace: release-live + shared_image_policy: no-push-artifact secrets: &live_e2e_release_secrets OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }} @@ -707,7 +770,7 @@ jobs: permissions: actions: read contents: read - packages: write + packages: read pull-requests: read uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml with: @@ -718,8 +781,18 @@ jobs: include_openwebui: ${{ needs.resolve_target.outputs.release_profile != 'beta' }} include_live_suites: false release_test_profile: ${{ needs.resolve_target.outputs.release_profile }} + package_artifact_digest: ${{ needs.prepare_release_package.outputs.artifact_digest }} + package_artifact_id: ${{ needs.prepare_release_package.outputs.artifact_id }} package_artifact_name: ${{ needs.prepare_release_package.outputs.artifact_name }} + package_artifact_run_attempt: ${{ needs.prepare_release_package.outputs.artifact_run_attempt }} + package_artifact_run_id: ${{ needs.prepare_release_package.outputs.artifact_run_id }} + package_file_name: ${{ needs.prepare_release_package.outputs.package_file_name }} + package_sha256: ${{ needs.prepare_release_package.outputs.package_sha256 }} + package_source_sha: ${{ needs.prepare_release_package.outputs.source_sha }} + package_version: ${{ needs.prepare_release_package.outputs.package_version }} codex_plugin_spec: ${{ needs.resolve_target.outputs.codex_plugin_spec }} + shared_image_artifact_namespace: release-docker + shared_image_policy: no-push-artifact secrets: *live_e2e_release_secrets package_acceptance_release_checks: @@ -729,21 +802,30 @@ jobs: permissions: actions: read contents: read - packages: write + packages: read pull-requests: read uses: ./.github/workflows/package-acceptance.yml with: advisory: false - workflow_ref: ${{ github.ref_name }} + workflow_ref: ${{ github.sha }} source: ${{ (needs.resolve_target.outputs.package_acceptance_package_spec != '' || needs.resolve_target.outputs.release_package_spec != '') && 'npm' || 'artifact' }} package_spec: ${{ needs.resolve_target.outputs.package_acceptance_package_spec || needs.resolve_target.outputs.release_package_spec || 'openclaw@beta' }} + artifact_digest: ${{ needs.prepare_release_package.outputs.artifact_digest }} + artifact_id: ${{ needs.prepare_release_package.outputs.artifact_id }} artifact_name: ${{ needs.prepare_release_package.outputs.artifact_name }} + artifact_run_attempt: ${{ needs.prepare_release_package.outputs.artifact_run_attempt }} + artifact_run_id: ${{ needs.prepare_release_package.outputs.artifact_run_id }} + package_file_name: ${{ needs.prepare_release_package.outputs.package_file_name }} package_sha256: ${{ (needs.resolve_target.outputs.package_acceptance_package_spec == '' && needs.resolve_target.outputs.release_package_spec == '') && needs.prepare_release_package.outputs.package_sha256 || '' }} + package_source_sha: ${{ needs.prepare_release_package.outputs.source_sha }} + package_version: ${{ needs.prepare_release_package.outputs.package_version }} suite_profile: custom docker_lanes: doctor-switch update-channel-switch skill-install update-corrupt-plugin upgrade-survivor published-upgrade-survivor root-managed-vps-upgrade update-restart-auth plugins-offline plugin-update plugin-binding-command-escape published_upgrade_survivor_baselines: ${{ needs.resolve_target.outputs.run_release_soak == 'true' && 'last-stable-4 2026.4.23 2026.5.2 2026.4.15' || '' }} published_upgrade_survivor_scenarios: ${{ needs.resolve_target.outputs.run_release_soak == 'true' && 'reported-issues' || '' }} telegram_mode: mock-openai + shared_image_artifact_namespace: release-package + shared_image_policy: no-push-artifact secrets: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }} @@ -840,7 +922,7 @@ jobs: - name: Checkout selected ref uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: - persist-credentials: true + persist-credentials: false ref: ${{ needs.resolve_target.outputs.revision }} fetch-depth: 1 @@ -959,7 +1041,7 @@ jobs: - name: Checkout selected ref uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: - persist-credentials: true + persist-credentials: false ref: ${{ needs.resolve_target.outputs.revision }} fetch-depth: 1 @@ -1070,7 +1152,7 @@ jobs: - name: Checkout selected ref uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: - persist-credentials: true + persist-credentials: false ref: ${{ needs.resolve_target.outputs.revision }} fetch-depth: 1 @@ -1234,7 +1316,7 @@ jobs: - name: Checkout selected ref uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: - persist-credentials: true + persist-credentials: false ref: ${{ needs.resolve_target.outputs.revision }} fetch-depth: 1 @@ -1308,7 +1390,7 @@ jobs: - name: Checkout selected ref uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: - persist-credentials: true + persist-credentials: false ref: ${{ needs.resolve_target.outputs.revision }} fetch-depth: 1 @@ -1432,7 +1514,7 @@ jobs: - name: Checkout selected ref uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: - persist-credentials: true + persist-credentials: false ref: ${{ needs.resolve_target.outputs.revision }} fetch-depth: 1 @@ -1573,7 +1655,7 @@ jobs: - name: Checkout selected ref uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: - persist-credentials: true + persist-credentials: false ref: ${{ needs.resolve_target.outputs.revision }} fetch-depth: 1 @@ -1698,7 +1780,7 @@ jobs: - name: Checkout selected ref uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: - persist-credentials: true + persist-credentials: false ref: ${{ needs.resolve_target.outputs.revision }} fetch-depth: 1 @@ -1820,7 +1902,7 @@ jobs: - name: Checkout selected ref uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: - persist-credentials: true + persist-credentials: false ref: ${{ needs.resolve_target.outputs.revision }} fetch-depth: 1 diff --git a/.github/workflows/package-acceptance.yml b/.github/workflows/package-acceptance.yml index 5408802131d1..76d8b2694472 100644 --- a/.github/workflows/package-acceptance.yml +++ b/.github/workflows/package-acceptance.yml @@ -35,7 +35,7 @@ on: default: "" type: string package_sha256: - description: Expected package SHA-256; required for source=url or source=trusted-url + description: Expected package SHA-256; required for source=url, source=trusted-url, or source=artifact required: false default: "" type: string @@ -54,6 +54,50 @@ on: required: false default: package-under-test type: string + artifact_id: + description: Immutable GitHub artifact id when source=artifact + required: false + default: "" + type: string + artifact_digest: + description: GitHub artifact service SHA-256 digest without the sha256 prefix + required: false + default: "" + type: string + artifact_run_attempt: + description: Producer run attempt when source=artifact + required: false + default: "" + type: string + package_file_name: + description: Exact package tarball filename when source=artifact + required: false + default: "" + type: string + package_source_sha: + description: Exact source commit recorded in the package when source=artifact + required: false + default: "" + type: string + package_version: + description: Exact package version when source=artifact + required: false + default: "" + type: string + shared_image_policy: + description: Shared Docker image transport for package acceptance + required: true + default: allow-push + type: choice + options: + - allow-push + - existing-only + - no-push-artifact + shared_image_artifact_namespace: + description: Unique artifact namespace when shared_image_policy=no-push-artifact + required: false + default: package-acceptance + type: string suite_profile: description: Acceptance profile required: true @@ -136,7 +180,7 @@ on: default: "" type: string package_sha256: - description: Expected package SHA-256; required for source=url or source=trusted-url + description: Expected package SHA-256; required for source=url, source=trusted-url, or source=artifact required: false default: "" type: string @@ -155,6 +199,46 @@ on: required: false default: package-under-test type: string + artifact_id: + description: Immutable GitHub artifact id when source=artifact + required: false + default: "" + type: string + artifact_digest: + description: GitHub artifact service SHA-256 digest without the sha256 prefix + required: false + default: "" + type: string + artifact_run_attempt: + description: Producer run attempt when source=artifact + required: false + default: "" + type: string + package_file_name: + description: Exact package tarball filename when source=artifact + required: false + default: "" + type: string + package_source_sha: + description: Exact source commit recorded in the package when source=artifact + required: false + default: "" + type: string + package_version: + description: Exact package version when source=artifact + required: false + default: "" + type: string + shared_image_policy: + description: "Shared Docker image transport: allow-push, existing-only, or no-push-artifact" + required: false + default: allow-push + type: string + shared_image_artifact_namespace: + description: Unique artifact namespace when shared_image_policy=no-push-artifact + required: false + default: package-acceptance + type: string suite_profile: description: "Acceptance profile: smoke, package, product, full, or custom" required: false @@ -190,6 +274,31 @@ on: required: false default: "" type: string + outputs: + package_artifact_digest: + description: GitHub artifact service digest for the canonical package + value: ${{ jobs.resolve_package.outputs.package_artifact_digest }} + package_artifact_id: + description: Immutable GitHub artifact id for the canonical package + value: ${{ jobs.resolve_package.outputs.package_artifact_id }} + package_artifact_run_attempt: + description: Producer run attempt for the canonical package artifact + value: ${{ jobs.resolve_package.outputs.package_artifact_run_attempt }} + package_artifact_run_id: + description: Producer run id for the canonical package artifact + value: ${{ jobs.resolve_package.outputs.package_artifact_run_id }} + package_file_name: + description: Canonical package tarball filename + value: ${{ jobs.resolve_package.outputs.package_file_name }} + package_source_sha: + description: Source commit recorded in the canonical package + value: ${{ jobs.resolve_package.outputs.package_source_sha }} + package_sha256: + description: Canonical OpenClaw package SHA-256 + value: ${{ jobs.resolve_package.outputs.package_sha256 }} + package_version: + description: Canonical OpenClaw package version + value: ${{ jobs.resolve_package.outputs.package_version }} secrets: OPENCLAW_TRUSTED_PACKAGE_TOKEN: required: false @@ -293,7 +402,7 @@ on: permissions: actions: read contents: read - packages: write + packages: read pull-requests: read concurrency: @@ -303,7 +412,7 @@ concurrency: env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" NODE_VERSION: "24.15.0" - PACKAGE_ARTIFACT_NAME: package-under-test + PACKAGE_ARTIFACT_NAME: package-under-test-${{ github.run_id }}-${{ github.run_attempt }} jobs: resolve_package: @@ -316,6 +425,11 @@ jobs: include_openwebui: ${{ steps.profile.outputs.include_openwebui }} include_release_path_suites: ${{ steps.profile.outputs.include_release_path_suites }} package_artifact_name: ${{ steps.profile.outputs.package_artifact_name }} + package_artifact_digest: ${{ steps.upload_package.outputs.artifact-digest }} + package_artifact_id: ${{ steps.upload_package.outputs.artifact-id }} + package_artifact_run_attempt: ${{ github.run_attempt }} + package_artifact_run_id: ${{ github.run_id }} + package_file_name: ${{ steps.resolve.outputs.package_file_name }} package_source_sha: ${{ steps.resolve.outputs.package_source_sha }} package_sha256: ${{ steps.resolve.outputs.sha256 }} package_version: ${{ steps.resolve.outputs.package_version }} @@ -329,6 +443,7 @@ jobs: with: ref: ${{ inputs.workflow_ref }} fetch-depth: 0 + persist-credentials: false - name: Setup Node environment uses: ./.github/actions/setup-node-env @@ -337,28 +452,81 @@ jobs: install-bun: ${{ inputs.source == 'ref' && 'true' || 'false' }} install-deps: "false" - - name: Download current-run package artifact input - if: inputs.source == 'artifact' && inputs.artifact_run_id == '' - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 - with: - name: ${{ inputs.artifact_name }} - path: .artifacts/package-candidate-input - - - name: Download previous-run package artifact input - if: inputs.source == 'artifact' && inputs.artifact_run_id != '' + - name: Validate package artifact input identity + id: input_artifact + if: inputs.source == 'artifact' env: + ARTIFACT_DIGEST: ${{ inputs.artifact_digest }} + ARTIFACT_ID: ${{ inputs.artifact_id }} GH_TOKEN: ${{ github.token }} - ARTIFACT_RUN_ID: ${{ inputs.artifact_run_id }} ARTIFACT_NAME: ${{ inputs.artifact_name }} + ARTIFACT_RUN_ATTEMPT: ${{ inputs.artifact_run_attempt }} + ARTIFACT_RUN_ID: ${{ inputs.artifact_run_id }} + EXPECTED_PACKAGE_SHA256: ${{ inputs.package_sha256 }} + EXPECTED_PACKAGE_FILE_NAME: ${{ inputs.package_file_name }} + EXPECTED_PACKAGE_SOURCE_SHA: ${{ inputs.package_source_sha }} + EXPECTED_PACKAGE_VERSION: ${{ inputs.package_version }} shell: bash run: | set -euo pipefail - if [[ -z "${ARTIFACT_NAME// }" ]]; then - echo "artifact_name is required when source=artifact." >&2 + if [[ ! "$ARTIFACT_DIGEST" =~ ^[0-9a-f]{64}$ || + ! "$ARTIFACT_ID" =~ ^[1-9][0-9]*$ || + -z "${ARTIFACT_NAME// }" || + ! "$ARTIFACT_RUN_ATTEMPT" =~ ^[1-9][0-9]*$ || + ! "$ARTIFACT_RUN_ID" =~ ^[1-9][0-9]*$ || + ! "$EXPECTED_PACKAGE_FILE_NAME" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*\.tgz$ || + ! "$EXPECTED_PACKAGE_SHA256" =~ ^[0-9a-f]{64}$ || + ! "$EXPECTED_PACKAGE_SOURCE_SHA" =~ ^[0-9a-f]{40}$ || + -z "${EXPECTED_PACKAGE_VERSION// }" ]]; then + echo "source=artifact requires the complete immutable artifact and package identity tuple." >&2 exit 1 fi - mkdir -p .artifacts/package-candidate-input - gh run download "$ARTIFACT_RUN_ID" -n "$ARTIFACT_NAME" -D .artifacts/package-candidate-input + [[ "$ARTIFACT_NAME" == *"-${ARTIFACT_RUN_ID}-${ARTIFACT_RUN_ATTEMPT}" ]] || { + echo "Package artifact name does not bind the declared producer run attempt." >&2 + exit 1 + } + artifact_json="$(gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}")" + jq -e \ + --arg digest "sha256:${ARTIFACT_DIGEST}" \ + --arg id "$ARTIFACT_ID" \ + --arg name "$ARTIFACT_NAME" \ + --arg run_id "$ARTIFACT_RUN_ID" \ + ' + (.id | tostring) == $id and + .name == $name and + .expired == false and + .digest == $digest and + (.workflow_run.id | tostring) == $run_id + ' <<< "$artifact_json" >/dev/null || { + echo "Package artifact identity does not match the requested immutable tuple." >&2 + exit 1 + } + attempt_json="$( + gh api \ + "repos/${GITHUB_REPOSITORY}/actions/runs/${ARTIFACT_RUN_ID}/attempts/${ARTIFACT_RUN_ATTEMPT}" + )" + jq -e \ + --arg attempt "$ARTIFACT_RUN_ATTEMPT" \ + --arg run_id "$ARTIFACT_RUN_ID" \ + '(.id | tostring) == $run_id and (.run_attempt | tostring) == $attempt' \ + <<< "$attempt_json" >/dev/null || { + echo "Package artifact producer run attempt does not match the requested tuple." >&2 + exit 1 + } + { + echo "artifact_digest=$ARTIFACT_DIGEST" + echo "run_attempt=$ARTIFACT_RUN_ATTEMPT" + echo "run_id=$ARTIFACT_RUN_ID" + } >> "$GITHUB_OUTPUT" + + - name: Download package artifact input + if: inputs.source == 'artifact' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + artifact-ids: ${{ inputs.artifact_id }} + path: .artifacts/package-candidate-input + run-id: ${{ inputs.artifact_run_id }} + github-token: ${{ github.token }} - name: Resolve package candidate id: resolve @@ -368,6 +536,9 @@ jobs: PACKAGE_SPEC: ${{ inputs.package_spec }} PACKAGE_URL: ${{ inputs.package_url }} PACKAGE_SHA256: ${{ inputs.package_sha256 }} + PACKAGE_FILE_NAME: ${{ inputs.package_file_name }} + PACKAGE_SOURCE_SHA: ${{ inputs.package_source_sha }} + PACKAGE_VERSION: ${{ inputs.package_version }} TRUSTED_SOURCE_ID: ${{ inputs.trusted_source_id }} OPENCLAW_TRUSTED_PACKAGE_TOKEN: ${{ secrets.OPENCLAW_TRUSTED_PACKAGE_TOKEN }} shell: bash @@ -376,6 +547,16 @@ jobs: artifact_dir="" if [[ "$SOURCE" == "artifact" ]]; then artifact_dir=".artifacts/package-candidate-input" + artifact_tarball="${artifact_dir}/${PACKAGE_FILE_NAME}" + [[ -f "$artifact_tarball" ]] || { + echo "Declared package tarball is missing from the selected artifact." >&2 + exit 1 + } + artifact_sha256="$(sha256sum "$artifact_tarball" | awk '{print $1}')" + [[ "$artifact_sha256" == "$PACKAGE_SHA256" ]] || { + echo "Selected artifact package SHA-256 differs from package_sha256." >&2 + exit 1 + } fi node scripts/resolve-openclaw-package-candidate.mjs \ @@ -390,6 +571,22 @@ jobs: --output-name openclaw-current.tgz \ --metadata .artifacts/docker-e2e-package/package-candidate.json \ --github-output "$GITHUB_OUTPUT" + echo "package_file_name=openclaw-current.tgz" >> "$GITHUB_OUTPUT" + if [[ "$SOURCE" == "artifact" ]]; then + jq -e \ + --arg digest "$PACKAGE_SHA256" \ + --arg source_sha "$PACKAGE_SOURCE_SHA" \ + --arg version "$PACKAGE_VERSION" \ + ' + .name == "openclaw" and + .sha256 == $digest and + .packageSourceSha == $source_sha and + .version == $version + ' .artifacts/docker-e2e-package/package-candidate.json >/dev/null || { + echo "Resolved package identity differs from the declared immutable tuple." >&2 + exit 1 + } + fi - name: Select acceptance profile id: profile @@ -492,6 +689,7 @@ jobs: node scripts/resolve-upgrade-survivor-baselines.mjs "${args[@]}" >/dev/null - name: Upload package-under-test artifact + id: upload_package uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: ${{ env.PACKAGE_ARTIFACT_NAME }} @@ -503,6 +701,12 @@ jobs: - name: Summarize package candidate env: + INPUT_ARTIFACT_DIGEST: ${{ steps.input_artifact.outputs.artifact_digest }} + INPUT_ARTIFACT_ID: ${{ inputs.artifact_id }} + INPUT_ARTIFACT_RUN_ATTEMPT: ${{ steps.input_artifact.outputs.run_attempt }} + INPUT_ARTIFACT_RUN_ID: ${{ steps.input_artifact.outputs.run_id }} + OUTPUT_ARTIFACT_DIGEST: ${{ steps.upload_package.outputs.artifact-digest }} + OUTPUT_ARTIFACT_ID: ${{ steps.upload_package.outputs.artifact-id }} PACKAGE_SHA256: ${{ steps.resolve.outputs.sha256 }} PACKAGE_VERSION: ${{ steps.resolve.outputs.package_version }} PACKAGE_REF: ${{ inputs.package_ref }} @@ -528,6 +732,14 @@ jobs: fi echo "- Version: \`${PACKAGE_VERSION}\`" echo "- SHA-256: \`${PACKAGE_SHA256}\`" + echo "- Artifact id: \`${OUTPUT_ARTIFACT_ID}\`" + echo "- Artifact digest: \`${OUTPUT_ARTIFACT_DIGEST}\`" + echo "- Artifact producer: run \`${GITHUB_RUN_ID}\`, attempt \`${GITHUB_RUN_ATTEMPT}\`" + if [[ "$SOURCE" == "artifact" ]]; then + echo "- Input artifact id: \`${INPUT_ARTIFACT_ID}\`" + echo "- Input artifact digest: \`${INPUT_ARTIFACT_DIGEST}\`" + echo "- Input artifact producer: run \`${INPUT_ARTIFACT_RUN_ID}\`, attempt \`${INPUT_ARTIFACT_RUN_ATTEMPT}\`" + fi echo "- Profile: \`${SUITE_PROFILE}\`" echo "- Published upgrade survivor baseline: \`${PUBLISHED_UPGRADE_SURVIVOR_BASELINE}\`" echo "- Published upgrade survivor baselines: \`${PUBLISHED_UPGRADE_SURVIVOR_BASELINES}\`" @@ -545,31 +757,42 @@ jobs: with: ref: ${{ inputs.workflow_ref }} fetch-depth: 1 + persist-credentials: false - name: Download package-under-test artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: - name: ${{ needs.resolve_package.outputs.package_artifact_name }} + artifact-ids: ${{ needs.resolve_package.outputs.package_artifact_id }} path: .artifacts/docker-e2e-package + run-id: ${{ needs.resolve_package.outputs.package_artifact_run_id }} + github-token: ${{ github.token }} - name: Enforce public package integrity env: + EXPECTED_PACKAGE_SHA256: ${{ needs.resolve_package.outputs.package_sha256 }} OPENCLAW_PACKAGE_TARBALL_CHECK_TIMINGS: "0" shell: bash run: | set -euo pipefail - node scripts/check-openclaw-package-tarball.mjs .artifacts/docker-e2e-package/openclaw-current.tgz + package=".artifacts/docker-e2e-package/openclaw-current.tgz" + actual_sha256="$(sha256sum "$package" | awk '{print $1}')" + [[ "$actual_sha256" == "$EXPECTED_PACKAGE_SHA256" ]] || { + echo "Canonical package artifact SHA-256 differs from the resolver output." >&2 + exit 1 + } + node scripts/check-openclaw-package-tarball.mjs "$package" docker_acceptance: - name: Docker product acceptance + name: Docker product acceptance (artifact-only) needs: [resolve_package, package_integrity] + if: inputs.shared_image_policy == 'no-push-artifact' permissions: actions: read contents: read - packages: write + packages: read pull-requests: read uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml - with: + with: &docker_acceptance_inputs advisory: ${{ inputs.advisory }} ref: ${{ needs.resolve_package.outputs.package_source_sha || inputs.workflow_ref }} include_repo_e2e: false @@ -580,9 +803,19 @@ jobs: published_upgrade_survivor_baselines: ${{ needs.resolve_package.outputs.published_upgrade_survivor_baselines }} published_upgrade_survivor_scenarios: ${{ needs.resolve_package.outputs.published_upgrade_survivor_scenarios }} package_artifact_name: ${{ needs.resolve_package.outputs.package_artifact_name }} + package_artifact_digest: ${{ needs.resolve_package.outputs.package_artifact_digest }} + package_artifact_id: ${{ needs.resolve_package.outputs.package_artifact_id }} + package_artifact_run_attempt: ${{ needs.resolve_package.outputs.package_artifact_run_attempt }} + package_artifact_run_id: ${{ needs.resolve_package.outputs.package_artifact_run_id }} + package_file_name: ${{ needs.resolve_package.outputs.package_file_name }} + package_sha256: ${{ needs.resolve_package.outputs.package_sha256 }} + package_source_sha: ${{ needs.resolve_package.outputs.package_source_sha }} + package_version: ${{ needs.resolve_package.outputs.package_version }} include_live_suites: ${{ needs.resolve_package.outputs.include_live_suites == 'true' }} live_models_only: false - secrets: + shared_image_artifact_namespace: ${{ inputs.shared_image_artifact_namespace }} + shared_image_policy: ${{ inputs.shared_image_policy }} + secrets: &docker_acceptance_secrets OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }} ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} @@ -630,6 +863,19 @@ jobs: OPENCLAW_GEMINI_SETTINGS_JSON: ${{ secrets.OPENCLAW_GEMINI_SETTINGS_JSON }} FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }} + docker_acceptance_registry: + name: Docker product acceptance (registry) + needs: [resolve_package, package_integrity] + if: inputs.shared_image_policy != 'no-push-artifact' + permissions: + actions: read + contents: read + packages: write + pull-requests: read + uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml + with: *docker_acceptance_inputs + secrets: *docker_acceptance_secrets + package_telegram: name: Telegram package acceptance needs: [resolve_package, package_integrity] @@ -639,6 +885,14 @@ jobs: advisory: ${{ inputs.advisory }} package_spec: ${{ inputs.package_spec }} package_artifact_name: ${{ needs.resolve_package.outputs.package_artifact_name }} + package_artifact_digest: ${{ needs.resolve_package.outputs.package_artifact_digest }} + package_artifact_id: ${{ needs.resolve_package.outputs.package_artifact_id }} + package_artifact_run_attempt: ${{ needs.resolve_package.outputs.package_artifact_run_attempt }} + package_artifact_run_id: ${{ needs.resolve_package.outputs.package_artifact_run_id }} + package_file_name: ${{ needs.resolve_package.outputs.package_file_name }} + package_sha256: ${{ needs.resolve_package.outputs.package_sha256 }} + package_source_sha: ${{ needs.resolve_package.outputs.package_source_sha }} + package_version: ${{ needs.resolve_package.outputs.package_version }} package_label: openclaw@${{ needs.resolve_package.outputs.package_version }} harness_ref: ${{ needs.resolve_package.outputs.package_source_sha || inputs.workflow_ref }} provider_mode: ${{ needs.resolve_package.outputs.telegram_mode }} @@ -650,7 +904,14 @@ jobs: summary: name: Verify package acceptance - needs: [resolve_package, package_integrity, docker_acceptance, package_telegram] + needs: + [ + resolve_package, + package_integrity, + docker_acceptance, + docker_acceptance_registry, + package_telegram, + ] if: always() runs-on: ubuntu-24.04 timeout-minutes: 5 @@ -658,7 +919,8 @@ jobs: - name: Verify package acceptance results env: ADVISORY: ${{ inputs.advisory }} - DOCKER_RESULT: ${{ needs.docker_acceptance.result }} + DOCKER_ARTIFACT_RESULT: ${{ needs.docker_acceptance.result }} + DOCKER_REGISTRY_RESULT: ${{ needs.docker_acceptance_registry.result }} PACKAGE_INTEGRITY_RESULT: ${{ needs.package_integrity.result }} PACKAGE_TELEGRAM_RESULT: ${{ needs.package_telegram.result }} RESOLVE_RESULT: ${{ needs.resolve_package.result }} @@ -666,11 +928,23 @@ jobs: shell: bash run: | set -euo pipefail + docker_result="$DOCKER_ARTIFACT_RESULT" + if [[ "$docker_result" == "skipped" ]]; then + docker_result="$DOCKER_REGISTRY_RESULT" + fi + if [[ "$DOCKER_ARTIFACT_RESULT" != "skipped" && "$DOCKER_REGISTRY_RESULT" != "skipped" ]]; then + echo "::error::Both Docker acceptance transports ran; expected exactly one." + exit 1 + fi + if [[ "$DOCKER_ARTIFACT_RESULT" == "skipped" && "$DOCKER_REGISTRY_RESULT" == "skipped" ]]; then + echo "::error::No Docker acceptance transport ran; expected exactly one." + exit 1 + fi failed=0 for item in \ "resolve_package=${RESOLVE_RESULT}" \ "package_integrity=${PACKAGE_INTEGRITY_RESULT}" \ - "docker_acceptance=${DOCKER_RESULT}" \ + "docker_acceptance=${docker_result}" \ "package_telegram=${PACKAGE_TELEGRAM_RESULT}" do name="${item%%=*}" diff --git a/.github/workflows/plugin-prerelease.yml b/.github/workflows/plugin-prerelease.yml index d25f8a934588..070416f03de0 100644 --- a/.github/workflows/plugin-prerelease.yml +++ b/.github/workflows/plugin-prerelease.yml @@ -59,7 +59,7 @@ jobs: ref: ${{ inputs.target_ref }} fetch-depth: 1 fetch-tags: false - persist-credentials: true + persist-credentials: false submodules: false - name: Build plugin prerelease manifest @@ -228,7 +228,7 @@ jobs: ref: ${{ needs.preflight.outputs.checkout_revision }} fetch-depth: 1 fetch-tags: false - persist-credentials: true + persist-credentials: false submodules: false - name: Setup Node environment @@ -264,7 +264,7 @@ jobs: ref: ${{ needs.preflight.outputs.checkout_revision }} fetch-depth: 1 fetch-tags: false - persist-credentials: true + persist-credentials: false submodules: false - name: Setup Node environment @@ -337,7 +337,7 @@ jobs: ref: ${{ needs.preflight.outputs.checkout_revision }} fetch-depth: 1 fetch-tags: false - persist-credentials: true + persist-credentials: false submodules: false - name: Setup Node environment @@ -369,7 +369,7 @@ jobs: ref: ${{ needs.preflight.outputs.checkout_revision }} fetch-depth: 1 fetch-tags: false - persist-credentials: true + persist-credentials: false submodules: false - name: Setup Node environment @@ -539,7 +539,7 @@ jobs: permissions: actions: read contents: read - packages: write + packages: read pull-requests: read uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml with: @@ -551,6 +551,8 @@ jobs: targeted_docker_lane_group_size: 4 include_live_suites: false live_models_only: false + shared_image_artifact_namespace: plugin-prerelease + shared_image_policy: no-push-artifact plugin-prerelease-suite: permissions: diff --git a/docs/reference/RELEASING.md b/docs/reference/RELEASING.md index 78d531d68fea..afa4f3c31545 100644 --- a/docs/reference/RELEASING.md +++ b/docs/reference/RELEASING.md @@ -272,13 +272,13 @@ A legacy fallback correction tag may reuse base-package evidence only when the c ## Release test boxes -`Full Release Validation` is how operators kick off all pre-release tests from one entrypoint. For a pinned commit proof on a fast-moving branch, use the helper so every child workflow runs from a temporary branch fixed at the target SHA: +`Full Release Validation` is how operators kick off all pre-release tests from one entrypoint. For a pinned commit proof on a fast-moving branch, use the helper so every child workflow runs from a temporary branch fixed at one trusted `main` workflow SHA while the requested commit remains the candidate under test: ```bash pnpm ci:full-release --sha ``` -The helper pushes `release-ci/-...`, dispatches `Full Release Validation` from that branch with `ref=`, verifies every child workflow `headSha` matches the target, then deletes the temporary branch. This avoids proving a newer `main` child run by accident. +The helper fetches current `origin/main`, pushes `release-ci/-...` at that trusted workflow commit, dispatches `Full Release Validation` from the temporary branch with `ref=` and `reuse_evidence=false`, verifies every child workflow `headSha` matches the pinned parent workflow SHA, then deletes the temporary branch. Pass `--workflow-sha ` to pin an older commit that is still reachable from current `origin/main`. The workflow itself never writes repository refs. This keeps main-only release tooling available without adding tooling commits to the candidate and avoids proving a newer `main` child run by accident. For release branch or tag validation, run it from the trusted `main` workflow ref and pass the release branch or tag as `ref`: @@ -294,9 +294,14 @@ gh workflow run full-release-validation.yml \ The workflow resolves the target ref, dispatches manual `CI` with `target_ref=`, then dispatches `OpenClaw Release Checks`. `OpenClaw Release Checks` fans out install smoke, cross-OS release checks, live/E2E Docker release-path coverage when soak is enabled, Package Acceptance with the canonical Telegram package E2E, QA Lab parity, live Matrix, and live Telegram. A full/all run is only acceptable when the `Full Release Validation` summary shows `normal_ci`, `plugin_prerelease`, and `release_checks` as successful, unless a focused rerun intentionally skipped the separate `Plugin Prerelease` child. Use the standalone `npm-telegram` child only for a focused published-package rerun with `release_package_spec` or `npm_telegram_package_spec`. The final verifier summary includes slowest-job tables for each child run, so the release manager can see the current critical path without downloading logs. +The product-performance child is artifact-only in this release path. The +umbrella dispatches it with `publish_reports=false`, and validation is rejected +unless its artifact-only guard proves that the Clawgrit report publisher stayed +skipped. + See [Full release validation](/reference/full-release-validation) for the complete stage matrix, exact workflow job names, stable versus full profile differences, artifacts, and focused rerun handles. -Child workflows are dispatched from the trusted ref that runs `Full Release Validation`, normally `--ref main`, even when the target `ref` points at an older release branch or tag. There is no separate Full Release Validation workflow-ref input; choose the trusted harness by choosing the workflow run ref. Do not use `--ref main -f ref=` for exact commit proof on moving `main`; raw commit SHAs cannot be workflow dispatch refs, so use `pnpm ci:full-release --sha ` to create the pinned temporary branch. +Child workflows are dispatched from the trusted ref that runs `Full Release Validation`, normally `--ref main`, even when the target `ref` points at an older release branch or tag. Every child run must use the exact parent workflow SHA; if `main` advances before a child dispatch resolves, the umbrella fails closed. There is no separate Full Release Validation workflow-ref input; choose the trusted harness by choosing the workflow run ref. Do not use `--ref main -f ref=` for exact commit proof on moving `main`; raw commit SHAs cannot be workflow dispatch refs, so use `pnpm ci:full-release --sha ` to create a temporary branch at trusted `origin/main` while keeping the target SHA as the candidate input. Use `release_profile` to select live/provider breadth: @@ -342,7 +347,15 @@ gh workflow run full-release-validation.yml \ Do not use the full umbrella as the first rerun after a focused fix. If one box fails, use the failed child workflow, job, Docker lane, package profile, model provider, or QA lane for the next proof. Run the full umbrella again only when the fix changed shared release orchestration or made earlier all-box evidence stale. The umbrella's final verifier re-checks the recorded child workflow run ids, so after a child workflow is rerun successfully, rerun only the failed `Verify full validation` parent job. -Release-metadata-only commits (changelog refreshes, version stamps) do not need a new full validation: `rerun_group=all` first checks for a prior green validation whose target differs only by release metadata paths and reuses that evidence, skipping every lane. Newer umbrella runs for the same `release/*` ref and rerun group supersede in-progress ones automatically. Pass `reuse_evidence=false` to force a fresh full run. +`rerun_group=all` may reuse a prior green umbrella run only when it validated +the exact same target SHA, release profile, effective soak setting, and +validation inputs. This is bounded recovery for rerunning the same candidate, +not cross-SHA evidence reuse. For a changed candidate, including a changelog or +version-only commit, rerun every package, artifact, install, Docker, or provider +gate affected by the changed paths or artifact hashes. Newer umbrella runs for +the same `release/*` +ref and rerun group supersede in-progress ones automatically. Pass +`reuse_evidence=false` to force a fresh full run. For bounded recovery, pass `rerun_group` to the umbrella. `all` is the real release-candidate run, `ci` runs only the normal CI child, `plugin-prerelease` runs only the release-only plugin child, `release-checks` runs every release box, and the narrower release groups are `install-smoke`, `cross-os`, `live-e2e`, `package`, `qa`, `qa-parity`, `qa-live`, and `npm-telegram`. Focused `npm-telegram` reruns require `release_package_spec` or `npm_telegram_package_spec`; full/all runs use the canonical package Telegram E2E inside Package Acceptance. Focused cross-OS reruns can add `cross_os_suite_filter=windows/packaged-upgrade` or another OS/suite filter. QA release-check failures block normal release validation, including required OpenClaw dynamic tool drift in the standard tier. Tideclaw alpha runs may still treat non-package-safety release-check lanes as advisory. With `release_profile=beta`, the `Run repo/live E2E validation` live-provider suites are advisory (warnings, not blockers); stable and full profiles keep them blocking. When `live_suite_filter` explicitly requests a gated QA live lane such as Discord, WhatsApp, or Slack, the matching `OPENCLAW_RELEASE_QA_*_LIVE_CI_ENABLED` repo variable must be enabled; otherwise input capture fails instead of silently skipping the lane. diff --git a/docs/reference/full-release-validation.md b/docs/reference/full-release-validation.md index b9ae790a0424..eabd12ab1e81 100644 --- a/docs/reference/full-release-validation.md +++ b/docs/reference/full-release-validation.md @@ -24,9 +24,21 @@ gh workflow run full-release-validation.yml \ ``` `provider` also accepts `anthropic` or `minimax` for cross-OS onboarding and the -end-to-end agent turn. Child workflows use the trusted workflow ref for the -harness and the input `ref` for the candidate under test, so new validation -logic stays available when validating an older release branch or tag. +end-to-end agent turn. Reusable child jobs resolve the called workflow harness +from `job.workflow_repository` and `job.workflow_sha`, while the input `ref` +selects the candidate under test. This keeps current trusted validation logic +available when validating an older release branch or tag. + +Every dispatched child must report the same workflow SHA as the parent +`Full Release Validation` run. If `main` moves between the parent and child +dispatches, the umbrella fails closed even when the child itself succeeds. For +an immutable exact-commit proof, use +`pnpm ci:full-release --sha `. The helper creates a temporary +`release-ci/*` ref pinned to current trusted `origin/main`, passes the target +SHA only as the candidate `ref`, disables evidence reuse, and deletes the ref +after validation. Pass `--workflow-sha ` to select an older +workflow commit still reachable from current `origin/main`. The workflow never +creates or updates repository refs itself. `release_profile=stable` and `release_profile=full` always run the exhaustive live/Docker soak. Pass `run_release_soak=true` to include the same soak lanes @@ -49,12 +61,15 @@ that plugin, then runs Codex CLI preflight and same-session OpenAI agent turns. ## Top-level stages For `rerun_group=all`, a `Check for reusable validation evidence` job runs -first: it looks for the newest prior green full validation whose target differs -from the current target only by release metadata paths (changelog, version -stamps; see `RELEASE_METADATA_PATHS` in `scripts/changed-lanes.mjs`). When such -evidence exists, every lane is skipped and the umbrella verifier re-checks the -evidence run instead, so changelog-only commits do not re-drive hours of -validation. Pass `reuse_evidence=false` to force a fresh full run. +first: it looks for the newest prior green full validation for the exact same +target SHA, release profile, effective soak setting, and validation inputs. +When such evidence exists, every lane is skipped and the umbrella verifier +re-checks the immutable parent artifact, child runs, and dispatch logs. This is +same-candidate rerun recovery only; it does not authorize cross-SHA reuse. For +a changed candidate, rerun every package, artifact, install, Docker, or provider +gate affected by that delta. Pass `reuse_evidence=false` to force a fresh full +run. Evidence reuse runs only when the umbrella itself was dispatched from +`main`; non-main workflow refs run the selected lanes fresh. Also for `rerun_group=all`, a `Verify Docker runtime image assets` job builds the `runtime-assets` Docker target with @@ -62,16 +77,35 @@ the `runtime-assets` Docker target with other stages and is enforced by the umbrella verifier; lanes no longer wait for it before dispatching. A narrower `rerun_group` skips this preflight. -| Stage | Details | -| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Target resolution | **Job:** `Resolve target ref`
**Child workflow:** none
**Proves:** resolves the release branch, tag, or full commit SHA and records selected inputs.
**Rerun:** rerun the umbrella if this fails. | -| Docker assets preflight | **Job:** `Verify Docker runtime image assets`
**Child workflow:** none
**Proves:** the `runtime-assets` Docker build target still succeeds before any other stage dispatches. Runs only for `rerun_group=all`.
**Rerun:** rerun the umbrella with `rerun_group=all`. | -| Vitest and normal CI | **Job:** `Run normal full CI`
**Child workflow:** `CI`
**Proves:** manual full CI graph against the target ref, including Linux Node lanes, bundled plugin shards, plugin and channel contract shards, Node 22 compatibility, `check-*`, `check-additional-*`, built-artifact smoke checks, docs checks, Python skills, Windows, macOS, Control UI i18n, and Android via the umbrella.
**Rerun:** `rerun_group=ci`. | -| Plugin prerelease | **Job:** `Run plugin prerelease validation`
**Child workflow:** `Plugin Prerelease`
**Proves:** release-only plugin static checks, agentic plugin coverage, full plugin batch shards, plugin prerelease Docker lanes, and a non-blocking `plugin-inspector-advisory` artifact for compatibility triage.
**Rerun:** `rerun_group=plugin-prerelease`. | -| Release checks | **Job:** `Run release/live/Docker/QA validation`
**Child workflow:** `OpenClaw Release Checks`
**Proves:** install smoke, cross-OS package checks, Package Acceptance, QA Lab parity, live Matrix, and live Telegram. Stable and full profiles also run exhaustive live/E2E suites and Docker release-path chunks; beta can opt in with `run_release_soak=true`.
**Rerun:** `rerun_group=release-checks` or a narrower release-checks handle. | -| Package Telegram | **Job:** `Run package Telegram E2E`
**Child workflow:** `NPM Telegram Beta E2E`
**Proves:** a focused published-package Telegram E2E when `release_package_spec` or `npm_telegram_package_spec` is set. Full candidate validation uses the canonical Package Acceptance Telegram E2E instead.
**Rerun:** `rerun_group=npm-telegram` with `release_package_spec` or `npm_telegram_package_spec`. | -| Product performance | **Job:** `Run product performance evidence`
**Child workflow:** `OpenClaw Performance`
**Proves:** release-profile performance run (`profile=release`, `repeat=3`, `fail_on_regression=true`) against the target SHA. Required (blocking) only for `rerun_group=all` or `rerun_group=performance`; not required for narrower rerun groups.
**Rerun:** `rerun_group=performance`. | -| Umbrella verifier | **Job:** `Verify full validation`
**Child workflow:** none
**Proves:** re-checks recorded child run conclusions and appends slowest-job tables from child workflows.
**Rerun:** rerun only this job after rerunning a failed child to green. | +| Stage | Details | +| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Target resolution | **Job:** `Resolve target ref`
**Child workflow:** none
**Proves:** resolves the release branch, tag, or full commit SHA and records selected inputs.
**Rerun:** rerun the umbrella if this fails. | +| Docker assets preflight | **Job:** `Verify Docker runtime image assets`
**Child workflow:** none
**Proves:** the `runtime-assets` Docker build target still succeeds before any other stage dispatches. Runs only for `rerun_group=all`.
**Rerun:** rerun the umbrella with `rerun_group=all`. | +| Vitest and normal CI | **Job:** `Run normal full CI`
**Child workflow:** `CI`
**Proves:** manual full CI graph against the target ref, including Linux Node lanes, bundled plugin shards, plugin and channel contract shards, Node 22 compatibility, `check-*`, `check-additional-*`, built-artifact smoke checks, docs checks, Python skills, Windows, macOS, Control UI i18n, and Android via the umbrella.
**Rerun:** `rerun_group=ci`. | +| Plugin prerelease | **Job:** `Run plugin prerelease validation`
**Child workflow:** `Plugin Prerelease`
**Proves:** release-only plugin static checks, agentic plugin coverage, full plugin batch shards, plugin prerelease Docker lanes, and a non-blocking `plugin-inspector-advisory` artifact for compatibility triage.
**Rerun:** `rerun_group=plugin-prerelease`. | +| Release checks | **Job:** `Run release/live/Docker/QA validation`
**Child workflow:** `OpenClaw Release Checks`
**Proves:** install smoke, cross-OS package checks, Package Acceptance, QA Lab parity, live Matrix, and live Telegram. Stable and full profiles also run exhaustive live/E2E suites and Docker release-path chunks; beta can opt in with `run_release_soak=true`.
**Rerun:** `rerun_group=release-checks` or a narrower release-checks handle. | +| Package Telegram | **Job:** `Run package Telegram E2E`
**Child workflow:** `NPM Telegram Beta E2E`
**Proves:** a focused published-package Telegram E2E when `release_package_spec` or `npm_telegram_package_spec` is set. Full candidate validation uses the canonical Package Acceptance Telegram E2E instead.
**Rerun:** `rerun_group=npm-telegram` with `release_package_spec` or `npm_telegram_package_spec`. | +| Product performance | **Job:** `Run product performance evidence`
**Child workflow:** `OpenClaw Performance`
**Proves:** release-profile performance run (`profile=release`, `repeat=3`, `fail_on_regression=true`, `publish_reports=false`) against the target SHA. Kova output stays in workflow artifacts and the child must prove its report publisher was skipped. Required (blocking) only for `rerun_group=all` or `rerun_group=performance`; not required for narrower rerun groups.
**Rerun:** `rerun_group=performance`. | +| Umbrella verifier | **Job:** `Verify full validation`
**Child workflow:** none
**Proves:** re-checks recorded child run conclusions and appends slowest-job tables from child workflows.
**Rerun:** rerun only this job after rerunning a failed child to green. | + +The umbrella always dispatches product performance in artifact-only mode. +`OpenClaw Performance` permits report publication only for scheduled runs or a +manual dispatch that explicitly sets `publish_reports=true`. The artifact-only +guard must complete successfully, proving the publisher job stayed skipped. +Fresh and reused evidence records +`controls.performanceReportPublication=artifact-only`; the verifier and reuse +selector reject evidence without the matching normalized performance-child +proof. + +The verifier uploads the canonical manifest as +`full-release-validation--`. Evidence tooling validates +its artifact ID, digest, producer run, and attempt before downloading that exact +artifact ID. It caps the downloaded ZIP, verifies its bytes against the REST +`sha256:` digest, and streams the only allowed bounded manifest entry without +extracting the archive. A stable-name alias remains temporarily for older +publish consumers. The verifier always prefers the attempt-qualified artifact; +as a transition, it accepts the stable name only for an attempt-1 manifest v2 +producer. It rejects that legacy name for later attempts and manifest v3. For `ref=main` with `rerun_group=all`, for `release/*` refs, and for Tideclaw alpha refs, a newer umbrella run supersedes an older one with the same ref and diff --git a/scripts/docker/shared-image-artifact.sh b/scripts/docker/shared-image-artifact.sh new file mode 100755 index 000000000000..59154e503f5e --- /dev/null +++ b/scripts/docker/shared-image-artifact.sh @@ -0,0 +1,381 @@ +#!/usr/bin/env bash +set -euo pipefail + +command_name="${1:?command is required}" +shift +artifact_dir="" +artifact_kind="" +target_sha="" +workflow_sha="" +image_refs=() +shared_package_sha256="${OPENCLAW_SHARED_IMAGE_PACKAGE_SHA256:-}" +shared_archive_sha256="${OPENCLAW_SHARED_IMAGE_ARCHIVE_SHA256:-}" +shared_run_id="${OPENCLAW_SHARED_IMAGE_RUN_ID:-}" +shared_run_attempt="${OPENCLAW_SHARED_IMAGE_RUN_ATTEMPT:-}" + +archive_name="shared-images.tar.zst" +manifest_path="" +archive_path="" + +fail() { + echo "$*" >&2 + exit 1 +} + +require_sha() { + local label="$1" + local value="$2" + if [[ ! "$value" =~ ^[a-f0-9]{40}$ ]]; then + fail "$label must be a lowercase full commit SHA." + fi +} + +require_positive_decimal() { + local label="$1" + local value="$2" + if [[ ! "$value" =~ ^[1-9][0-9]*$ ]]; then + fail "$label must be a positive decimal integer." + fi +} + +configure_image_artifact_inputs() { + if [[ "$#" -lt 5 ]]; then + fail "usage: $0 ..." + fi + artifact_dir="$1" + artifact_kind="$2" + target_sha="$3" + workflow_sha="$4" + image_refs=("${@:5}") + manifest_path="${artifact_dir}/shared-image-artifact.json" + archive_path="${artifact_dir}/${archive_name}" +} + +verify_uploaded_artifact() { + if [[ "$#" -ne 6 ]]; then + fail "usage: $0 verify-upload