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 3c0ea2aca3dc..e8aa6ccdff1b 100644 --- a/.github/workflows/full-release-validation.yml +++ b/.github/workflows/full-release-validation.yml @@ -59,6 +59,11 @@ on: - qa-live - npm-telegram - performance + reuse_evidence: + description: Reuse the newest prior green full validation only for the exact same target SHA and inputs + required: false + default: true + type: boolean live_suite_filter: description: Optional exact live/E2E suite id, or comma-separated QA live lanes such as qa-live-matrix,qa-live-telegram; blank runs all selected live suites required: false @@ -84,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 @@ -134,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 @@ -228,6 +238,105 @@ jobs: fi } >> "$GITHUB_STEP_SUMMARY" + evidence_reuse: + name: Check for reusable validation evidence + needs: [resolve_target] + if: inputs.rerun_group == 'all' && inputs.reuse_evidence && github.ref == 'refs/heads/main' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + outputs: + reuse: ${{ steps.find.outputs.reuse }} + evidence_run_id: ${{ steps.find.outputs.evidence_run_id }} + evidence_root_run_id: ${{ steps.find.outputs.evidence_root_run_id }} + evidence_run_url: ${{ steps.find.outputs.evidence_run_url }} + evidence_sha: ${{ steps.find.outputs.evidence_sha }} + evidence_manifest: ${{ steps.find.outputs.evidence_manifest }} + changed_paths: ${{ steps.find.outputs.changed_paths }} + steps: + - name: Checkout trusted workflow helper + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + ref: ${{ github.sha }} + path: workflow + fetch-depth: 1 + persist-credentials: false + submodules: false + + - name: Checkout target SHA + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + ref: ${{ needs.resolve_target.outputs.sha }} + path: target + fetch-depth: 1 + persist-credentials: false + submodules: false + + - name: Find reusable validation evidence + id: find + env: + GH_TOKEN: ${{ github.token }} + TARGET_SHA: ${{ needs.resolve_target.outputs.sha }} + RELEASE_PROFILE: ${{ inputs.release_profile }} + RUN_RELEASE_SOAK: ${{ inputs.run_release_soak || inputs.release_profile == 'stable' || inputs.release_profile == 'full' }} + PROVIDER: ${{ inputs.provider }} + MODE: ${{ inputs.mode }} + LIVE_SUITE_FILTER: ${{ inputs.live_suite_filter }} + CROSS_OS_SUITE_FILTER: ${{ inputs.cross_os_suite_filter }} + RELEASE_PACKAGE_SPEC: ${{ inputs.release_package_spec }} + PACKAGE_ACCEPTANCE_PACKAGE_SPEC: ${{ inputs.package_acceptance_package_spec }} + CODEX_PLUGIN_SPEC: ${{ inputs.codex_plugin_spec }} + run: | + set -euo pipefail + # Lane-selection inputs must match the prior run's manifest exactly; + # a default-input run must not stand in for a focused one. + inputs_json="$(jq -nc \ + --arg provider "$PROVIDER" \ + --arg mode "$MODE" \ + --arg liveSuiteFilter "$LIVE_SUITE_FILTER" \ + --arg crossOsSuiteFilter "$CROSS_OS_SUITE_FILTER" \ + --arg releasePackageSpec "$RELEASE_PACKAGE_SPEC" \ + --arg packageAcceptancePackageSpec "$PACKAGE_ACCEPTANCE_PACKAGE_SPEC" \ + --arg codexPluginSpec "$CODEX_PLUGIN_SPEC" \ + '{ + provider: $provider, + mode: $mode, + liveSuiteFilter: $liveSuiteFilter, + crossOsSuiteFilter: $crossOsSuiteFilter, + releasePackageSpec: $releasePackageSpec, + packageAcceptancePackageSpec: $packageAcceptancePackageSpec, + codexPluginSpec: $codexPluginSpec + }')" + bash workflow/scripts/github/find-reusable-release-validation.sh \ + --target-sha "$TARGET_SHA" \ + --workflow-sha "$GITHUB_SHA" \ + --release-profile "$RELEASE_PROFILE" \ + --run-release-soak "$RUN_RELEASE_SOAK" \ + --inputs-json "$inputs_json" \ + --repo "$GITHUB_REPOSITORY" \ + --repo-dir target \ + --github-output "$GITHUB_OUTPUT" + + - name: Summarize evidence reuse + env: + REUSE: ${{ steps.find.outputs.reuse }} + REUSE_REASON: ${{ steps.find.outputs.reuse_reason }} + EVIDENCE_RUN_URL: ${{ steps.find.outputs.evidence_run_url }} + 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 "- Exact-target reuse changed paths: \`${changed_paths_summary}\`" + else + echo "- No reusable evidence: ${REUSE_REASON:-unknown}" + fi + } >> "$GITHUB_STEP_SUMMARY" + docker_runtime_assets_preflight: name: Verify Docker runtime image assets needs: [resolve_target] @@ -242,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: @@ -272,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 @@ -280,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 @@ -303,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)" @@ -379,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')" @@ -437,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 @@ -445,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 @@ -468,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)" @@ -544,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')" @@ -602,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 }} @@ -620,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 @@ -643,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)" @@ -762,6 +914,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')" @@ -882,6 +1042,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 }} @@ -912,6 +1073,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") @@ -974,6 +1144,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="$( @@ -1034,6 +1212,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 @@ -1060,6 +1239,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 @@ -1068,6 +1256,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" @@ -1084,6 +1273,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 @@ -1135,6 +1325,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')" @@ -1194,6 +1392,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 @@ -1284,8 +1483,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/* && -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 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 @@ -1450,7 +1649,33 @@ jobs: plugin_prerelease_required=0 release_checks_required=0 performance_required=0 - if [[ "$RERUN_GROUP" == "all" && "$DOCKER_RUNTIME_ASSETS_PREFLIGHT_RESULT" != "success" ]]; then + if [[ "$RERUN_GROUP" == "all" && "$EVIDENCE_REUSE" == "true" ]]; then + # Lanes were skipped because a prior green validation covers this + # target; re-verify the chain-root run and its recorded child runs + # so evidence that went stale after resolution cannot pass. + evidence_state="$(gh_with_retry run view "$EVIDENCE_ROOT_RUN_ID" --json status,conclusion --jq '(.status // "") + "/" + (.conclusion // "")')" + if [[ "$evidence_state" != "completed/success" ]]; then + echo "::error::Reused evidence run ${EVIDENCE_ROOT_RUN_ID} is ${evidence_state}; evidence is no longer valid." + failed=1 + fi + while IFS= read -r evidence_child_run_id; do + [[ -n "$evidence_child_run_id" ]] || continue + evidence_child_state="$(gh_with_retry run view "$evidence_child_run_id" --json status,conclusion --jq '(.status // "") + "/" + (.conclusion // "")')" + if [[ "$evidence_child_state" != "completed/success" ]]; then + echo "::error::Reused evidence child run ${evidence_child_run_id} is ${evidence_child_state}; evidence is no longer valid." + failed=1 + fi + done < <(jq -r '[.childRuns.normalCi // "", .childRuns.pluginPrerelease // "", .childRuns.releaseChecks // "", .childRuns.npmTelegram // "", (.childRuns.productPerformance.runId // "")] | map(select(. != "")) | .[]' <<< "$EVIDENCE_MANIFEST") + if [[ "$failed" == "0" ]]; then + { + echo "### Reused validation evidence" + echo + echo "- Evidence run: ${EVIDENCE_RUN_URL}" + echo "- Evidence SHA: \`${EVIDENCE_SHA}\`" + echo "- Target SHA: \`${TARGET_SHA}\` (exact-target evidence reuse)" + } >> "$GITHUB_STEP_SUMMARY" + fi + elif [[ "$RERUN_GROUP" == "all" && "$DOCKER_RUNTIME_ASSETS_PREFLIGHT_RESULT" != "success" ]]; then echo "::error::Docker runtime-assets preflight ended with ${DOCKER_RUNTIME_ASSETS_PREFLIGHT_RESULT}." failed=1 elif [[ "$RERUN_GROUP" == "all" ]]; then @@ -1528,6 +1753,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 }} @@ -1540,6 +1766,14 @@ jobs: echo "Release checks were skipped by rerun group; skipping automatic release evidence update." exit 0 fi + # In reuse mode the child runs live on the chain-root validation run + # (the evidence consumer scrapes dispatch logs from the given run), + # 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} for the exact same target SHA and inputs." + GITHUB_RUN_ID_VALUE="$EVIDENCE_ROOT_RUN_ID" + fi if [[ -z "${RELEASES_DISPATCH_TOKEN// }" ]]; then echo "OPENCLAW_RELEASES_DISPATCH_TOKEN is not configured; skipping automatic release evidence update." exit 0 @@ -1616,15 +1850,70 @@ jobs: NPM_TELEGRAM_RUN_ID: ${{ needs.npm_telegram.outputs.run_id }} 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 }} + LIVE_SUITE_FILTER: ${{ inputs.live_suite_filter }} + CROSS_OS_SUITE_FILTER: ${{ inputs.cross_os_suite_filter }} + RELEASE_PACKAGE_SPEC: ${{ inputs.release_package_spec }} + PACKAGE_ACCEPTANCE_PACKAGE_SPEC: ${{ inputs.package_acceptance_package_spec }} + CODEX_PLUGIN_SPEC: ${{ inputs.codex_plugin_spec }} run: | set -euo pipefail manifest_dir="${RUNNER_TEMP}/full-release-validation" mkdir -p "$manifest_dir" + if [[ "$EVIDENCE_REUSE" == "true" ]]; then + # Inherit the evidence manifest (profile, soak, child runs) so future + # reuse lookups and evidence consumers keep resolving the chain root. + jq \ + --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: { + 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 jq -n \ --arg workflowName "Full Release Validation" \ --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" \ @@ -1637,11 +1926,14 @@ jobs: --arg performanceRunId "$PERFORMANCE_RUN_ID" \ --arg performanceConclusion "$PERFORMANCE_CONCLUSION" \ '{ - version: 2, + version: 3, workflowName: $workflowName, runId: $runId, runAttempt: $runAttempt, workflowRef: $workflowRef, + workflowSha: $workflowSha, + workflowFullRef: $workflowFullRef, + workflowRefType: $workflowRefType, targetRef: $targetRef, targetSha: $targetSha, releaseProfile: $releaseProfile, @@ -1649,7 +1941,8 @@ jobs: runReleaseSoak: $runReleaseSoak, controls: { stableSoakRequired: ($releaseProfile == "stable" or $releaseProfile == "full"), - performanceBlocking: true + performanceBlocking: true, + performanceReportPublication: "artifact-only" }, childRuns: { normalCi: $normalCiRunId, @@ -1665,9 +1958,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 319e212d406f..eebe4ce96c6e 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,20 +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 CLI + - name: Checkout trusted installer harness uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: - ref: ${{ inputs.ref || github.ref }} + 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: ${{ 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 @@ -424,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: @@ -494,17 +919,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 @@ -512,10 +949,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: @@ -540,7 +1075,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 a8ff9590f5de..0f6bf2af4c92 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 @@ -202,9 +242,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 }} @@ -338,7 +388,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 == '' @@ -348,7 +466,7 @@ jobs: ref: ${{ inputs.ref }} path: source fetch-depth: 0 - persist-credentials: true + persist-credentials: false submodules: recursive - name: Setup Node.js @@ -377,72 +495,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 @@ -473,13 +589,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 @@ -489,6 +635,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) { @@ -506,21 +653,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 @@ -564,7 +718,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 @@ -579,20 +733,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' }} @@ -600,21 +874,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: | @@ -623,10 +903,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 c8bb3fda23c1..b16074804115 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 @@ -168,11 +172,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 @@ -313,8 +362,9 @@ on: required: false permissions: + actions: read contents: read - packages: write + packages: read pull-requests: read env: @@ -327,7 +377,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: @@ -337,6 +421,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 @@ -364,6 +461,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" { @@ -496,7 +656,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 @@ -666,7 +827,13 @@ jobs: run: ${{ matrix.command }} 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 }} @@ -734,6 +901,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 }} @@ -751,12 +919,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 }} @@ -803,18 +972,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 @@ -913,7 +1138,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 @@ -929,7 +1155,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 }} @@ -997,6 +1229,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 }} @@ -1012,11 +1245,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 }} @@ -1064,18 +1299,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 @@ -1121,17 +1412,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 @@ -1161,8 +1455,8 @@ jobs: if-no-files-found: error validate_docker_openwebui: - needs: [validate_selected_ref, prepare_docker_e2e_image] - if: inputs.include_openwebui && !inputs.include_release_path_suites && inputs.docker_lanes == '' + 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 }} runs-on: ${{ inputs.use_github_hosted_runners && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }} @@ -1176,22 +1470,28 @@ jobs: OPENCLAW_DOCKER_E2E_REPO_ROOT: ${{ github.workspace }} 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 }} @@ -1231,18 +1531,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 @@ -1296,7 +1652,7 @@ jobs: permissions: actions: read contents: read - packages: write + packages: read outputs: image: ${{ steps.image.outputs.image }} bare_image: ${{ steps.image.outputs.bare_image }} @@ -1306,6 +1662,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" @@ -1316,13 +1690,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 @@ -1362,18 +1739,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 }} @@ -1391,11 +1829,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 @@ -1411,8 +1871,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\`" @@ -1420,10 +1928,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 @@ -1435,12 +1944,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" @@ -1449,7 +1964,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 }} @@ -1457,11 +1972,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 @@ -1492,21 +2008,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=( @@ -1534,10 +2191,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=( @@ -1565,6 +2222,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')) @@ -1573,9 +2261,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" @@ -1585,23 +2280,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 }} @@ -1609,6 +2320,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 @@ -1616,17 +2330,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: . @@ -1638,11 +2356,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 }} runs-on: ${{ inputs.use_github_hosted_runners && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }} @@ -1689,6 +2524,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: @@ -1698,14 +2534,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) @@ -1719,7 +2600,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 }} @@ -1769,7 +2650,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 }} runs-on: ${{ inputs.use_github_hosted_runners && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }} @@ -1812,6 +2693,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: @@ -1820,13 +2702,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 @@ -1896,6 +2823,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 }} @@ -2197,7 +3125,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 @@ -2275,7 +3205,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 }} runs-on: ${{ inputs.use_github_hosted_runners && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }} @@ -2403,6 +3333,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" @@ -2414,14 +3345,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 == 'live-gateway-advisory-docker' && startsWith(matrix.suite_id, 'live-gateway-advisory-docker-'))) 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 == 'live-gateway-advisory-docker' && startsWith(matrix.suite_id, 'live-gateway-advisory-docker-'))) @@ -2435,7 +3411,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 == 'live-gateway-advisory-docker' && startsWith(matrix.suite_id, 'live-gateway-advisory-docker-'))) + if: contains(matrix.profiles, inputs.release_test_profile) && (inputs.live_suite_filter == '' || inputs.live_suite_filter == matrix.suite_id || (inputs.live_suite_filter == 'live-gateway-advisory-docker' && startsWith(matrix.suite_id, 'live-gateway-advisory-docker-'))) && inputs.shared_image_policy != 'no-push-artifact' run: bash .release-harness/scripts/ci-docker-login-ghcr.sh env: GHCR_USERNAME: ${{ github.actor }} @@ -2633,7 +3609,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 c8e2f65dfbea..24a5a1287dc8 100644 --- a/.github/workflows/openclaw-performance.yml +++ b/.github/workflows/openclaw-performance.yml @@ -42,6 +42,11 @@ on: required: false default: false type: boolean + publish_reports: + description: Publish completed reports to openclaw/clawgrit-reports + required: false + default: false + type: boolean kova_ref: description: openclaw/Kova Git ref to install required: false @@ -608,7 +613,7 @@ jobs: - name: Prepare clawgrit reports checkout id: clawgrit_reports - if: ${{ steps.kova.outputs.report_json != '' && steps.clawgrit.outputs.present == 'true' }} + if: ${{ (github.event_name == 'schedule' || inputs.publish_reports == true) && steps.kova.outputs.report_json != '' && steps.clawgrit.outputs.present == 'true' }} env: CLAWGRIT_REPORTS_TOKEN: ${{ secrets.CLAWGRIT_REPORTS_TOKEN }} shell: bash @@ -631,7 +636,7 @@ jobs: echo "ready=true" >> "$GITHUB_OUTPUT" - name: Publish to clawgrit reports - if: ${{ steps.kova.outputs.report_json != '' && steps.clawgrit.outputs.present == 'true' && steps.clawgrit_reports.outputs.ready == 'true' }} + if: ${{ (github.event_name == 'schedule' || inputs.publish_reports == true) && steps.kova.outputs.report_json != '' && steps.clawgrit.outputs.present == 'true' && steps.clawgrit_reports.outputs.ready == 'true' }} env: CLAWGRIT_REPORTS_TOKEN: ${{ secrets.CLAWGRIT_REPORTS_TOKEN }} shell: bash @@ -710,3 +715,27 @@ jobs: } git -C "$reports_root" rebase FETCH_HEAD done + + - name: Confirm artifact-only report mode + if: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_reports != true }} + run: echo "Clawgrit report publication stayed disabled; Kova evidence is available only as workflow artifacts." >> "$GITHUB_STEP_SUMMARY" + + artifact_only_guard: + name: Verify artifact-only report mode + needs: [kova] + if: ${{ always() && github.event_name == 'workflow_dispatch' && inputs.publish_reports != true }} + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + steps: + - name: Verify report publisher stayed disabled + env: + KOVA_RESULT: ${{ needs.kova.result }} + run: | + set -euo pipefail + if [[ "$KOVA_RESULT" != "success" ]]; then + echo "::error::Artifact-only performance evidence requires the Kova job to succeed; got ${KOVA_RESULT}." + exit 1 + fi + echo "Clawgrit report publication stayed disabled; Kova evidence is available only as workflow artifacts." >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/openclaw-release-checks.yml b/.github/workflows/openclaw-release-checks.yml index 9c08b0d01868..746054831d67 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: @@ -648,6 +709,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 }} @@ -703,7 +766,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: @@ -714,8 +777,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: @@ -725,21 +798,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 }} @@ -836,7 +918,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 @@ -955,7 +1037,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 @@ -1066,7 +1148,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 @@ -1230,7 +1312,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 @@ -1304,7 +1386,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 @@ -1428,7 +1510,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 @@ -1568,7 +1650,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 @@ -1711,7 +1793,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 @@ -1851,7 +1933,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 9123a04b028b..6fe7241d68cd 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,26 +904,47 @@ 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 steps: - name: Verify package acceptance results env: - DOCKER_RESULT: ${{ needs.docker_acceptance.result }} + ADVISORY: ${{ inputs.advisory }} + 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 }} shell: bash run: | set -euo pipefail - advisory="${{ inputs.advisory }}" + 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 + advisory="$ADVISORY" 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 7f1a08a25819..9c71919c0303 100644 --- a/docs/reference/RELEASING.md +++ b/docs/reference/RELEASING.md @@ -433,19 +433,13 @@ Validation` or from the `main`/release workflow ref so workflow logic and ## 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`: @@ -473,16 +467,25 @@ 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. +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 the pinned temporary branch. +`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: @@ -549,6 +552,13 @@ 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. +`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, rerun every affected +package, artifact, install, Docker, and provider gate. 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 diff --git a/docs/reference/full-release-validation.md b/docs/reference/full-release-validation.md index 3f9e01194b7b..45f2bb952147 100644 --- a/docs/reference/full-release-validation.md +++ b/docs/reference/full-release-validation.md @@ -23,9 +23,22 @@ gh workflow run full-release-validation.yml \ -f release_profile=stable ``` -Child workflows use the trusted workflow ref for the harness and the input -`ref` for the candidate under test. That keeps new validation logic available -when validating an older release branch or tag. +`provider` also accepts `anthropic` or `minimax` for cross-OS onboarding and the +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 @@ -47,19 +60,58 @@ that plugin, then runs Codex CLI preflight and same-session OpenAI agent turns. ## Top-level stages -| 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. | -| 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 extension 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`. | -| 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. | +For `rerun_group=all`, a `Check for reusable validation evidence` job runs +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. -For `ref=main` and `rerun_group=all`, a newer umbrella supersedes an older one. -When the parent is cancelled, its monitor cancels any child workflow it already -dispatched. Release branch and tag validation runs do not cancel each other by -default. +Also for `rerun_group=all`, a `Verify Docker runtime image assets` job builds +the `runtime-assets` Docker target with +`OPENCLAW_EXTENSIONS=diagnostics-otel,codex`. It runs in parallel with the +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`, `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 +rerun group. When the parent is cancelled, its monitor cancels any child +workflow it already dispatched. Tag and pinned-SHA validation runs do not +cancel each other. ## Release checks stages 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