fix(release): make validation proof no-write (#103737)

* fix(release): make validation proof no-write

* test(release): align no-write workflow contracts
This commit is contained in:
Vincent Koc
2026-07-10 08:18:44 -07:00
committed by GitHub
parent b745862361
commit cbe3731f77
30 changed files with 9323 additions and 858 deletions
File diff suppressed because it is too large Load Diff
+154 -19
View File
@@ -60,7 +60,7 @@ on:
- npm-telegram
- performance
reuse_evidence:
description: Reuse the newest prior green full validation when the target differs only by release metadata such as changelog and version stamps
description: Reuse the newest prior green full validation only for the exact same target SHA and inputs
required: false
default: true
type: boolean
@@ -89,6 +89,11 @@ on:
required: false
default: ""
type: string
dispatch_release_evidence:
description: Dispatch the validated run to openclaw/releases after child proof succeeds
required: false
default: false
type: boolean
package_acceptance_package_spec:
description: Optional published package spec for Package Acceptance; blank uses the SHA-built release artifact
required: false
@@ -139,10 +144,10 @@ jobs:
- name: Checkout trusted workflow helper
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: ${{ github.ref_name }}
ref: ${{ github.sha }}
path: workflow
fetch-depth: 1
persist-credentials: true
persist-credentials: false
submodules: false
- name: Resolve target SHA
@@ -236,7 +241,7 @@ jobs:
evidence_reuse:
name: Check for reusable validation evidence
needs: [resolve_target]
if: inputs.rerun_group == 'all' && inputs.reuse_evidence
if: inputs.rerun_group == 'all' && inputs.reuse_evidence && github.ref == 'refs/heads/main'
runs-on: ubuntu-24.04
timeout-minutes: 10
outputs:
@@ -251,10 +256,10 @@ jobs:
- name: Checkout trusted workflow helper
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: ${{ github.ref_name }}
ref: ${{ github.sha }}
path: workflow
fetch-depth: 1
persist-credentials: true
persist-credentials: false
submodules: false
- name: Checkout target SHA
@@ -263,7 +268,7 @@ jobs:
ref: ${{ needs.resolve_target.outputs.sha }}
path: target
fetch-depth: 1
persist-credentials: true
persist-credentials: false
submodules: false
- name: Find reusable validation evidence
@@ -319,13 +324,14 @@ jobs:
EVIDENCE_SHA: ${{ steps.find.outputs.evidence_sha }}
CHANGED_PATHS: ${{ steps.find.outputs.changed_paths }}
run: |
changed_paths_summary="$(jq -r 'if length == 0 then "none" else join(", ") end' <<< "${CHANGED_PATHS:-[]}")"
{
echo "## Validation evidence reuse"
echo
if [[ "$REUSE" == "true" ]]; then
echo "- Reusing evidence: ${EVIDENCE_RUN_URL}"
echo "- Evidence SHA: \`${EVIDENCE_SHA}\`"
echo "- Release-metadata-only delta: \`${CHANGED_PATHS:-none}\`"
echo "- Exact-target reuse changed paths: \`${changed_paths_summary}\`"
else
echo "- No reusable evidence: ${REUSE_REASON:-unknown}"
fi
@@ -345,7 +351,7 @@ jobs:
with:
ref: ${{ needs.resolve_target.outputs.sha }}
fetch-depth: 1
persist-credentials: true
persist-credentials: false
- name: Verify Docker runtime-assets prune path
env:
@@ -375,6 +381,7 @@ jobs:
TARGET_REF: ${{ inputs.ref }}
TARGET_SHA: ${{ needs.resolve_target.outputs.sha }}
CHILD_WORKFLOW_REF: ${{ github.ref_name }}
PARENT_WORKFLOW_SHA: ${{ github.sha }}
run: |
set -euo pipefail
@@ -383,7 +390,7 @@ jobs:
local dispatch_run_name="$2"
shift 2
local dispatch_output dispatch_status matches_json match_count run_id status conclusion url poll_count
local dispatch_output dispatch_status matches_json match_count run_id status conclusion url poll_count child_head_sha encoded_workflow_ref current_workflow_sha
gh_with_retry() {
local output status attempt
for attempt in 1 2 3 4 5 6; do
@@ -406,6 +413,14 @@ jobs:
printf '%s\n' "$output" >&2
return "$status"
}
encoded_workflow_ref="$(jq -rn --arg value "$CHILD_WORKFLOW_REF" '$value | @uri')"
current_workflow_sha="$(
gh_with_retry api "repos/${GITHUB_REPOSITORY}/commits/${encoded_workflow_ref}" --jq .sha
)"
if [[ "$current_workflow_sha" != "$PARENT_WORKFLOW_SHA" ]]; then
echo "::error::Child workflow ref ${CHILD_WORKFLOW_REF} moved to ${current_workflow_sha}, expected ${PARENT_WORKFLOW_SHA}; refusing dispatch." >&2
return 1
fi
# A failed dispatch POST can still create a run. Never retry it; recover only by exact run name.
set +e
dispatch_output="$(gh workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@" 2>&1)"
@@ -482,6 +497,14 @@ jobs:
}
trap cancel_child EXIT INT TERM
child_head_sha="$(fetch_child_run_json | jq -r '.head_sha // ""')"
if [[ "$child_head_sha" != "$PARENT_WORKFLOW_SHA" ]]; then
echo "::error::${workflow} child run used workflow SHA ${child_head_sha}, expected parent workflow SHA ${PARENT_WORKFLOW_SHA}."
cancel_child
trap - EXIT INT TERM
exit 1
fi
poll_count=0
while true; do
status="$(fetch_child_run_json | jq -r '.status')"
@@ -540,6 +563,7 @@ jobs:
TARGET_REF: ${{ inputs.ref }}
TARGET_SHA: ${{ needs.resolve_target.outputs.sha }}
CHILD_WORKFLOW_REF: ${{ github.ref_name }}
PARENT_WORKFLOW_SHA: ${{ github.sha }}
run: |
set -euo pipefail
@@ -548,7 +572,7 @@ jobs:
local dispatch_run_name="$2"
shift 2
local dispatch_output dispatch_status matches_json match_count run_id status conclusion url poll_count
local dispatch_output dispatch_status matches_json match_count run_id status conclusion url poll_count child_head_sha encoded_workflow_ref current_workflow_sha
gh_with_retry() {
local output status attempt
for attempt in 1 2 3 4 5 6; do
@@ -571,6 +595,14 @@ jobs:
printf '%s\n' "$output" >&2
return "$status"
}
encoded_workflow_ref="$(jq -rn --arg value "$CHILD_WORKFLOW_REF" '$value | @uri')"
current_workflow_sha="$(
gh_with_retry api "repos/${GITHUB_REPOSITORY}/commits/${encoded_workflow_ref}" --jq .sha
)"
if [[ "$current_workflow_sha" != "$PARENT_WORKFLOW_SHA" ]]; then
echo "::error::Child workflow ref ${CHILD_WORKFLOW_REF} moved to ${current_workflow_sha}, expected ${PARENT_WORKFLOW_SHA}; refusing dispatch." >&2
return 1
fi
# A failed dispatch POST can still create a run. Never retry it; recover only by exact run name.
set +e
dispatch_output="$(gh workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@" 2>&1)"
@@ -647,6 +679,14 @@ jobs:
}
trap cancel_child EXIT INT TERM
child_head_sha="$(fetch_child_run_json | jq -r '.head_sha // ""')"
if [[ "$child_head_sha" != "$PARENT_WORKFLOW_SHA" ]]; then
echo "::error::${workflow} child run used workflow SHA ${child_head_sha}, expected parent workflow SHA ${PARENT_WORKFLOW_SHA}."
cancel_child
trap - EXIT INT TERM
exit 1
fi
poll_count=0
while true; do
status="$(fetch_child_run_json | jq -r '.status')"
@@ -705,6 +745,7 @@ jobs:
TARGET_REF: ${{ inputs.ref }}
TARGET_SHA: ${{ needs.resolve_target.outputs.sha }}
CHILD_WORKFLOW_REF: ${{ github.ref_name }}
PARENT_WORKFLOW_SHA: ${{ github.sha }}
PROVIDER: ${{ inputs.provider }}
MODE: ${{ inputs.mode }}
RELEASE_PROFILE: ${{ inputs.release_profile }}
@@ -723,7 +764,7 @@ jobs:
local dispatch_run_name="$2"
shift 2
local dispatch_output dispatch_status matches_json match_count run_id status conclusion url poll_count run_json
local dispatch_output dispatch_status matches_json match_count run_id status conclusion url poll_count run_json child_head_sha encoded_workflow_ref current_workflow_sha
gh_with_retry() {
local output status attempt
for attempt in 1 2 3 4 5 6; do
@@ -746,6 +787,14 @@ jobs:
printf '%s\n' "$output" >&2
return "$status"
}
encoded_workflow_ref="$(jq -rn --arg value "$CHILD_WORKFLOW_REF" '$value | @uri')"
current_workflow_sha="$(
gh_with_retry api "repos/${GITHUB_REPOSITORY}/commits/${encoded_workflow_ref}" --jq .sha
)"
if [[ "$current_workflow_sha" != "$PARENT_WORKFLOW_SHA" ]]; then
echo "::error::Child workflow ref ${CHILD_WORKFLOW_REF} moved to ${current_workflow_sha}, expected ${PARENT_WORKFLOW_SHA}; refusing dispatch." >&2
return 1
fi
# A failed dispatch POST can still create a run. Never retry it; recover only by exact run name.
set +e
dispatch_output="$(gh workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@" 2>&1)"
@@ -881,6 +930,14 @@ jobs:
}
trap cancel_child EXIT INT TERM
child_head_sha="$(fetch_child_run_json | jq -r '.head_sha // ""')"
if [[ "$child_head_sha" != "$PARENT_WORKFLOW_SHA" ]]; then
echo "::error::${workflow} child run used workflow SHA ${child_head_sha}, expected parent workflow SHA ${PARENT_WORKFLOW_SHA}."
cancel_child
trap - EXIT INT TERM
exit 1
fi
poll_count=0
while true; do
status="$(fetch_child_run_json | jq -r '.status')"
@@ -1001,6 +1058,7 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
CHILD_WORKFLOW_REF: ${{ github.ref_name }}
PARENT_WORKFLOW_SHA: ${{ github.sha }}
TARGET_SHA: ${{ needs.resolve_target.outputs.sha }}
PACKAGE_SPEC: ${{ inputs.npm_telegram_package_spec || inputs.release_package_spec }}
PROVIDER_MODE: ${{ inputs.npm_telegram_provider_mode }}
@@ -1031,6 +1089,15 @@ jobs:
return "$status"
}
encoded_workflow_ref="$(jq -rn --arg value "$CHILD_WORKFLOW_REF" '$value | @uri')"
current_workflow_sha="$(
gh_with_retry api "repos/${GITHUB_REPOSITORY}/commits/${encoded_workflow_ref}" --jq .sha
)"
if [[ "$current_workflow_sha" != "$PARENT_WORKFLOW_SHA" ]]; then
echo "::error::Child workflow ref ${CHILD_WORKFLOW_REF} moved to ${current_workflow_sha}, expected ${PARENT_WORKFLOW_SHA}; refusing dispatch." >&2
exit 1
fi
args=(-f package_spec="$PACKAGE_SPEC" -f harness_ref="$TARGET_SHA" -f provider_mode="$PROVIDER_MODE")
if [[ -n "${SCENARIO// }" ]]; then
args+=(-f scenario="$SCENARIO")
@@ -1093,6 +1160,14 @@ jobs:
}
trap cancel_child EXIT INT TERM
child_head_sha="$(gh_with_retry run view "$run_id" --json headSha --jq '.headSha // ""')"
if [[ "$child_head_sha" != "$PARENT_WORKFLOW_SHA" ]]; then
echo "::error::npm-telegram-beta-e2e.yml child run used workflow SHA ${child_head_sha}, expected parent workflow SHA ${PARENT_WORKFLOW_SHA}."
cancel_child
trap - EXIT INT TERM
exit 1
fi
fail_fast_failed_jobs() {
local failed_jobs_json
failed_jobs_json="$(
@@ -1153,6 +1228,7 @@ jobs:
GH_TOKEN: ${{ github.token }}
TARGET_SHA: ${{ needs.resolve_target.outputs.sha }}
CHILD_WORKFLOW_REF: ${{ github.ref_name }}
PARENT_WORKFLOW_SHA: ${{ github.sha }}
run: |
set -euo pipefail
@@ -1179,6 +1255,15 @@ jobs:
return "$status"
}
encoded_workflow_ref="$(jq -rn --arg value "$CHILD_WORKFLOW_REF" '$value | @uri')"
current_workflow_sha="$(
gh_with_retry api "repos/${GITHUB_REPOSITORY}/commits/${encoded_workflow_ref}" --jq .sha
)"
if [[ "$current_workflow_sha" != "$PARENT_WORKFLOW_SHA" ]]; then
echo "::error::Child workflow ref ${CHILD_WORKFLOW_REF} moved to ${current_workflow_sha}, expected ${PARENT_WORKFLOW_SHA}; refusing dispatch." >&2
exit 1
fi
{
echo "### Product performance"
echo
@@ -1187,6 +1272,7 @@ jobs:
echo "- Repeat: \`3\`"
echo "- Deep profile: \`false\`"
echo "- Live OpenAI candidate: \`false\`"
echo "- Report publication: disabled (artifacts only)"
echo "- Release impact: blocking"
} >> "$GITHUB_STEP_SUMMARY"
@@ -1203,6 +1289,7 @@ jobs:
-f deep_profile=false \
-f live_openai_candidate=false \
-f fail_on_regression=true \
-f publish_reports=false \
-f dispatch_id="$dispatch_id" 2>&1)"
dispatch_status=$?
set -e
@@ -1254,6 +1341,14 @@ jobs:
}
trap cancel_child EXIT INT TERM
child_head_sha="$(gh_with_retry run view "$run_id" --json headSha --jq '.headSha // ""')"
if [[ "$child_head_sha" != "$PARENT_WORKFLOW_SHA" ]]; then
echo "::error::openclaw-performance.yml child run used workflow SHA ${child_head_sha}, expected parent workflow SHA ${PARENT_WORKFLOW_SHA}."
cancel_child
trap - EXIT INT TERM
exit 1
fi
poll_count=0
while true; do
status="$(gh_with_retry run view "$run_id" --json status --jq '.status')"
@@ -1319,6 +1414,7 @@ jobs:
RERUN_GROUP: ${{ inputs.rerun_group }}
TARGET_SHA: ${{ needs.resolve_target.outputs.sha }}
CHILD_WORKFLOW_REF: ${{ github.ref_name }}
PARENT_WORKFLOW_SHA: ${{ github.sha }}
run: |
set -euo pipefail
@@ -1409,8 +1505,8 @@ jobs:
head_sha="$(jq -r '.headSha // ""' <<< "$run_json")"
echo "${label}: ${status}/${conclusion} attempt ${attempt} head ${head_sha}: ${url}"
if [[ ( "$CHILD_WORKFLOW_REF" == release-ci/* || "$CHILD_WORKFLOW_REF" =~ ^extended-stable/[0-9]{4}\.([1-9]|1[0-2])\.33$ ) && -n "${TARGET_SHA// }" && "$head_sha" != "$TARGET_SHA" ]]; then
echo "::error::${label} child run used ${head_sha}, expected ${TARGET_SHA}. Dispatch Full Release Validation from a release-ci or extended-stable ref pinned to the target SHA, not a moving branch."
if [[ "$head_sha" != "$PARENT_WORKFLOW_SHA" ]]; then
echo "::error::${label} child run used workflow SHA ${head_sha}, expected parent workflow SHA ${PARENT_WORKFLOW_SHA}. Use the SHA-pinned release helper when a moving branch cannot stay fixed."
return 1
fi
@@ -1598,7 +1694,7 @@ jobs:
echo
echo "- Evidence run: ${EVIDENCE_RUN_URL}"
echo "- Evidence SHA: \`${EVIDENCE_SHA}\`"
echo "- Target SHA: \`${TARGET_SHA}\` (release-metadata-only delta)"
echo "- Target SHA: \`${TARGET_SHA}\` (exact-target evidence reuse)"
} >> "$GITHUB_STEP_SUMMARY"
fi
elif [[ "$RERUN_GROUP" == "all" && "$DOCKER_RUNTIME_ASSETS_PREFLIGHT_RESULT" != "success" ]]; then
@@ -1679,6 +1775,7 @@ jobs:
exit "$failed"
- name: Request release evidence update
if: ${{ inputs.dispatch_release_evidence }}
env:
RELEASES_DISPATCH_TOKEN: ${{ secrets.OPENCLAW_RELEASES_DISPATCH_TOKEN }}
TARGET_REF: ${{ inputs.ref }}
@@ -1698,7 +1795,7 @@ jobs:
# so durable evidence must reference that run id, not this wrapper.
notes="Automatically requested by Full Release Validation ${GITHUB_RUN_ID_VALUE} after child workflows completed; the parent summary re-checks current child run conclusions."
if [[ "$EVIDENCE_REUSE" == "true" && -n "${EVIDENCE_ROOT_RUN_ID// }" ]]; then
notes="Automatically requested by Full Release Validation ${GITHUB_RUN_ID_VALUE}, which reused green evidence from chain-root run ${EVIDENCE_ROOT_RUN_ID}; the release target differs from the validated SHA only by release metadata."
notes="Automatically requested by Full Release Validation ${GITHUB_RUN_ID_VALUE}, which reused green evidence from chain-root run ${EVIDENCE_ROOT_RUN_ID} for the exact same target SHA and inputs."
GITHUB_RUN_ID_VALUE="$EVIDENCE_ROOT_RUN_ID"
fi
if [[ -z "${RELEASES_DISPATCH_TOKEN// }" ]]; then
@@ -1778,7 +1875,10 @@ jobs:
PERFORMANCE_RUN_ID: ${{ needs.performance.outputs.run_id }}
PERFORMANCE_CONCLUSION: ${{ needs.performance.outputs.conclusion }}
EVIDENCE_REUSE: ${{ needs.evidence_reuse.outputs.reuse }}
EVIDENCE_RUN_ID: ${{ needs.evidence_reuse.outputs.evidence_run_id }}
EVIDENCE_ROOT_RUN_ID: ${{ needs.evidence_reuse.outputs.evidence_root_run_id }}
EVIDENCE_SHA: ${{ needs.evidence_reuse.outputs.evidence_sha }}
EVIDENCE_CHANGED_PATHS: ${{ needs.evidence_reuse.outputs.changed_paths }}
EVIDENCE_MANIFEST: ${{ needs.evidence_reuse.outputs.evidence_manifest }}
PROVIDER: ${{ inputs.provider }}
MODE: ${{ inputs.mode }}
@@ -1798,16 +1898,35 @@ jobs:
--arg runId "$GITHUB_RUN_ID" \
--arg runAttempt "$GITHUB_RUN_ATTEMPT" \
--arg workflowRef "$GITHUB_REF_NAME" \
--arg workflowSha "$GITHUB_SHA" \
--arg workflowFullRef "$GITHUB_REF" \
--arg workflowRefType "$GITHUB_REF_TYPE" \
--arg targetRef "$TARGET_REF" \
--arg targetSha "$TARGET_SHA" \
--arg evidenceRunId "$EVIDENCE_RUN_ID" \
--arg evidenceRootRunId "$EVIDENCE_ROOT_RUN_ID" \
--arg evidenceSha "$EVIDENCE_SHA" \
--argjson evidenceChangedPaths "$EVIDENCE_CHANGED_PATHS" \
'. + {
version: 3,
runId: $runId,
runAttempt: $runAttempt,
workflowRef: $workflowRef,
workflowSha: $workflowSha,
workflowFullRef: $workflowFullRef,
workflowRefType: $workflowRefType,
targetRef: $targetRef,
targetSha: $targetSha,
evidenceReuse: {runId: $evidenceRootRunId}
evidenceReuse: {
policy: "exact-target-full-validation-v1",
runId: $evidenceRootRunId,
selectedRunId: $evidenceRunId,
evidenceSha: $evidenceSha,
changedPaths: $evidenceChangedPaths
},
controls: ((.controls // {}) + {
performanceReportPublication: "artifact-only"
})
}' <<< "$EVIDENCE_MANIFEST" > "${manifest_dir}/full-release-validation-manifest.json"
exit 0
fi
@@ -1816,6 +1935,9 @@ jobs:
--arg runId "$GITHUB_RUN_ID" \
--arg runAttempt "$GITHUB_RUN_ATTEMPT" \
--arg workflowRef "$GITHUB_REF_NAME" \
--arg workflowSha "$GITHUB_SHA" \
--arg workflowFullRef "$GITHUB_REF" \
--arg workflowRefType "$GITHUB_REF_TYPE" \
--arg targetRef "$TARGET_REF" \
--arg targetSha "$TARGET_SHA" \
--arg releaseProfile "$RELEASE_PROFILE" \
@@ -1835,11 +1957,14 @@ jobs:
--arg packageAcceptancePackageSpec "$PACKAGE_ACCEPTANCE_PACKAGE_SPEC" \
--arg codexPluginSpec "$CODEX_PLUGIN_SPEC" \
'{
version: 2,
version: 3,
workflowName: $workflowName,
runId: $runId,
runAttempt: $runAttempt,
workflowRef: $workflowRef,
workflowSha: $workflowSha,
workflowFullRef: $workflowFullRef,
workflowRefType: $workflowRefType,
targetRef: $targetRef,
targetSha: $targetSha,
releaseProfile: $releaseProfile,
@@ -1856,7 +1981,8 @@ jobs:
},
controls: {
stableSoakRequired: ($releaseProfile == "stable" or $releaseProfile == "full"),
performanceBlocking: true
performanceBlocking: true,
performanceReportPublication: "artifact-only"
},
childRuns: {
normalCi: $normalCiRunId,
@@ -1872,9 +1998,18 @@ jobs:
}' > "${manifest_dir}/full-release-validation-manifest.json"
- name: Upload release validation manifest
if: ${{ success() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: full-release-validation-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/full-release-validation
if-no-files-found: error
- name: Upload legacy release validation manifest alias
if: ${{ success() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: full-release-validation-${{ github.run_id }}
path: ${{ runner.temp }}/full-release-validation
if-no-files-found: error
overwrite: true
+564 -36
View File
@@ -15,6 +15,14 @@ on:
required: false
default: latest
type: string
root_image_transport:
description: Root Dockerfile image transport
required: false
default: registry
type: choice
options:
- registry
- no-push-artifact
workflow_call:
inputs:
ref:
@@ -31,10 +39,16 @@ on:
required: false
default: latest
type: string
root_image_transport:
description: Root Dockerfile image transport
required: false
default: registry
type: string
permissions:
actions: read
contents: read
packages: write
packages: read
concurrency:
group: ${{ (github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call') && format('{0}-{1}-{2}', github.workflow, github.event_name, github.run_id) || format('{0}-{1}', github.workflow, github.ref) }}
@@ -54,7 +68,42 @@ jobs:
run_bun_global_install_smoke: ${{ steps.manifest.outputs.run_bun_global_install_smoke }}
target_sha: ${{ steps.manifest.outputs.target_sha }}
dockerfile_image: ${{ steps.manifest.outputs.dockerfile_image }}
root_image_transport: ${{ steps.manifest.outputs.root_image_transport }}
workflow_repository: ${{ steps.workflow.outputs.workflow_repository }}
workflow_sha: ${{ steps.workflow.outputs.workflow_sha }}
steps:
# github.workflow_sha identifies the caller during workflow_call. Resolve the called
# workflow SHA from job context so trusted harness checkouts cannot drift to candidate code.
- name: Resolve job workflow identity
id: workflow
env:
JOB_CONTEXT: ${{ toJSON(job) }}
shell: bash
run: |
set -euo pipefail
node --input-type=module <<'NODE'
import fs from "node:fs";
const job = JSON.parse(process.env.JOB_CONTEXT ?? "{}");
if (
typeof job.workflow_repository !== "string" ||
!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(job.workflow_repository)
) {
throw new Error("job.workflow_repository must be an owner/repository slug");
}
if (typeof job.workflow_sha !== "string" || !/^[0-9a-f]{40}$/u.test(job.workflow_sha)) {
throw new Error("job.workflow_sha must be a full lowercase commit SHA");
}
const outputPath = process.env.GITHUB_OUTPUT;
if (!outputPath) {
throw new Error("GITHUB_OUTPUT is required");
}
fs.appendFileSync(
outputPath,
`workflow_repository=${job.workflow_repository}\nworkflow_sha=${job.workflow_sha}\n`,
);
NODE
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
@@ -68,9 +117,12 @@ jobs:
id: manifest
env:
OPENCLAW_CI_EVENT_NAME: ${{ github.event_name }}
OPENCLAW_CI_ROOT_IMAGE_TRANSPORT: ${{ inputs.root_image_transport || 'registry' }}
OPENCLAW_CI_WORKFLOW_BUN_GLOBAL_INSTALL_SMOKE: ${{ inputs.run_bun_global_install_smoke || 'false' }}
run: |
set -euo pipefail
event_name="${OPENCLAW_CI_EVENT_NAME:-}"
root_image_transport="${OPENCLAW_CI_ROOT_IMAGE_TRANSPORT:-registry}"
workflow_bun_global_install_smoke="${OPENCLAW_CI_WORKFLOW_BUN_GLOBAL_INSTALL_SMOKE:-false}"
docs_only=false
run_fast_install_smoke=true
@@ -79,7 +131,18 @@ jobs:
run_install_smoke=true
target_sha="$(git rev-parse HEAD)"
owner="$(printf '%s' "${GITHUB_REPOSITORY_OWNER:-openclaw}" | tr '[:upper:]' '[:lower:]')"
dockerfile_image="ghcr.io/${owner}/openclaw-dockerfile-smoke:${target_sha}"
case "$root_image_transport" in
registry)
dockerfile_image="ghcr.io/${owner}/openclaw-dockerfile-smoke:${target_sha}"
;;
no-push-artifact)
dockerfile_image="openclaw-dockerfile-smoke-local:${target_sha}"
;;
*)
echo "root_image_transport must be registry or no-push-artifact." >&2
exit 1
;;
esac
if [ "$event_name" = "schedule" ]; then
run_bun_global_install_smoke=true
elif [ "$event_name" = "workflow_dispatch" ] || [ "$event_name" = "workflow_call" ]; then
@@ -95,6 +158,7 @@ jobs:
echo "run_bun_global_install_smoke=$run_bun_global_install_smoke"
echo "target_sha=$target_sha"
echo "dockerfile_image=$dockerfile_image"
echo "root_image_transport=$root_image_transport"
} >> "$GITHUB_OUTPUT"
install-smoke-fast:
@@ -104,11 +168,12 @@ jobs:
env:
DOCKER_BUILD_SUMMARY: "false"
DOCKER_BUILD_RECORD_UPLOAD: "false"
OPENCLAW_DOCKER_E2E_REQUIRE_LOCAL_IMAGE: "1"
steps:
- name: Checkout CLI
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: ${{ inputs.ref || github.ref }}
ref: ${{ needs.preflight.outputs.target_sha }}
persist-credentials: false
- name: Set up Blacksmith Docker Builder
@@ -210,7 +275,17 @@ jobs:
needs: [preflight]
if: needs.preflight.outputs.run_full_install_smoke == 'true'
runs-on: ubuntu-24.04
permissions:
contents: read
packages: read
outputs:
archive_sha256: ${{ steps.image_artifact.outputs.archive_sha256 }}
artifact_digest: ${{ steps.image_artifact_upload.outputs.artifact-digest }}
artifact_id: ${{ steps.image_artifact_upload.outputs.artifact-id }}
artifact_name: ${{ steps.image_artifact.outputs.artifact_name }}
artifact_run_attempt: ${{ steps.image_artifact.outputs.run_attempt }}
artifact_run_id: ${{ steps.image_artifact.outputs.run_id }}
image_exists: ${{ steps.existing.outputs.exists }}
image_ref: ${{ steps.image.outputs.image_ref }}
env:
DOCKER_BUILD_SUMMARY: "false"
@@ -219,10 +294,20 @@ jobs:
- name: Checkout CLI
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: ${{ inputs.ref || github.ref }}
ref: ${{ needs.preflight.outputs.target_sha }}
persist-credentials: false
- name: Checkout trusted image artifact helper
if: needs.preflight.outputs.root_image_transport == 'no-push-artifact'
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
repository: ${{ needs.preflight.outputs.workflow_repository }}
ref: ${{ needs.preflight.outputs.workflow_sha }}
path: .release-harness
persist-credentials: false
- name: Log in to GHCR
if: needs.preflight.outputs.root_image_transport == 'registry'
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
with:
registry: ghcr.io
@@ -231,6 +316,7 @@ jobs:
- name: Check for existing root Dockerfile smoke image
id: existing
if: needs.preflight.outputs.root_image_transport == 'registry'
env:
IMAGE_REF: ${{ needs.preflight.outputs.dockerfile_image }}
run: |
@@ -244,26 +330,60 @@ jobs:
fi
- name: Set up Blacksmith Docker Builder
if: steps.existing.outputs.exists != 'true'
if: needs.preflight.outputs.root_image_transport == 'no-push-artifact'
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
with:
max-cache-size-mb: 800000
# Build once with the matrix extension and publish by target SHA. Use a
# direct buildx command so release jobs emit Docker progress and time out.
- name: Build and push root Dockerfile smoke image
if: steps.existing.outputs.exists != 'true'
- name: Build local root Dockerfile smoke image
if: needs.preflight.outputs.root_image_transport == 'no-push-artifact'
env:
IMAGE_REF: ${{ needs.preflight.outputs.dockerfile_image }}
run: |
timeout --kill-after=30s 45m docker buildx build \
--progress=plain \
--push \
--load \
--build-arg OPENCLAW_EXTENSIONS=matrix \
-t "$IMAGE_REF" \
-f ./Dockerfile \
.
- name: Pack root Dockerfile image artifact
id: image_artifact
if: needs.preflight.outputs.root_image_transport == 'no-push-artifact'
env:
IMAGE_REF: ${{ needs.preflight.outputs.dockerfile_image }}
TARGET_SHA: ${{ needs.preflight.outputs.target_sha }}
WORKFLOW_SHA: ${{ needs.preflight.outputs.workflow_sha }}
run: |
set -euo pipefail
artifact_dir="${RUNNER_TEMP}/install-smoke-root-image"
artifact_name="install-smoke-root-image-${TARGET_SHA:0:12}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
bash .release-harness/scripts/docker/shared-image-artifact.sh \
pack "$artifact_dir" install-smoke-root "$TARGET_SHA" "$WORKFLOW_SHA" "$IMAGE_REF"
archive_sha256="$(
jq -er '.archive.sha256 | select(type == "string" and test("^[a-f0-9]{64}$"))' \
"$artifact_dir/shared-image-artifact.json"
)"
{
echo "archive_sha256=$archive_sha256"
echo "artifact_name=$artifact_name"
echo "artifact_path=$artifact_dir"
echo "run_attempt=$GITHUB_RUN_ATTEMPT"
echo "run_id=$GITHUB_RUN_ID"
} >> "$GITHUB_OUTPUT"
- name: Upload root Dockerfile image artifact
id: image_artifact_upload
if: needs.preflight.outputs.root_image_transport == 'no-push-artifact'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ${{ steps.image_artifact.outputs.artifact_name }}
path: ${{ steps.image_artifact.outputs.artifact_path }}
if-no-files-found: error
compression-level: 0
retention-days: 7
- name: Record root image output
id: image
env:
@@ -273,6 +393,7 @@ jobs:
- name: Summarize root image
env:
IMAGE_REF: ${{ needs.preflight.outputs.dockerfile_image }}
ROOT_IMAGE_TRANSPORT: ${{ needs.preflight.outputs.root_image_transport }}
TARGET_SHA: ${{ needs.preflight.outputs.target_sha }}
run: |
{
@@ -280,34 +401,29 @@ jobs:
echo
echo "- Target SHA: \`${TARGET_SHA}\`"
echo "- Image: \`${IMAGE_REF}\`"
echo "- Reused existing image: \`${{ steps.existing.outputs.exists }}\`"
echo "- Transport: \`${ROOT_IMAGE_TRANSPORT}\`"
if [[ "$ROOT_IMAGE_TRANSPORT" == "registry" ]]; then
echo "- Reused existing image: \`${{ steps.existing.outputs.exists }}\`"
else
echo "- Artifact: \`${{ steps.image_artifact.outputs.artifact_name }}\`"
fi
} >> "$GITHUB_STEP_SUMMARY"
qr_package_install_smoke:
needs: [preflight]
if: needs.preflight.outputs.run_full_install_smoke == 'true'
runs-on: ubuntu-24.04
steps:
- name: Checkout CLI
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: ${{ inputs.ref || github.ref }}
persist-credentials: false
- name: Run QR package install smoke
env:
OPENCLAW_QR_SMOKE_FORCE_INSTALL: "1"
run: bash scripts/e2e/qr-import-docker.sh
root_dockerfile_smokes:
push_root_dockerfile_image:
needs: [preflight, root_dockerfile_image]
if: needs.preflight.outputs.run_full_install_smoke == 'true'
if: needs.preflight.outputs.root_image_transport == 'registry' && needs.root_dockerfile_image.outputs.image_exists != 'true'
runs-on: ubuntu-24.04
permissions:
contents: read
packages: write
env:
DOCKER_BUILD_SUMMARY: "false"
DOCKER_BUILD_RECORD_UPLOAD: "false"
steps:
- name: Checkout CLI
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: ${{ inputs.ref || github.ref }}
ref: ${{ needs.preflight.outputs.target_sha }}
persist-credentials: false
- name: Log in to GHCR
@@ -317,11 +433,203 @@ jobs:
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Set up Blacksmith Docker Builder
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
with:
max-cache-size-mb: 800000
# The registry path publishes one matrix-extension image by target SHA.
# A direct buildx command keeps progress visible and fails on timeout.
- name: Build and push root Dockerfile smoke image
env:
IMAGE_REF: ${{ needs.root_dockerfile_image.outputs.image_ref }}
run: |
timeout --kill-after=30s 45m docker buildx build \
--progress=plain \
--push \
--build-arg OPENCLAW_EXTENSIONS=matrix \
-t "$IMAGE_REF" \
-f ./Dockerfile \
.
root_dockerfile_image_ready:
needs: [preflight, root_dockerfile_image, push_root_dockerfile_image]
if: always() && needs.preflight.result == 'success' && needs.preflight.outputs.run_full_install_smoke == 'true'
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
- name: Verify root Dockerfile image preparation
env:
IMAGE_EXISTS: ${{ needs.root_dockerfile_image.outputs.image_exists }}
PREPARE_RESULT: ${{ needs.root_dockerfile_image.result }}
PUSH_RESULT: ${{ needs.push_root_dockerfile_image.result }}
ROOT_IMAGE_TRANSPORT: ${{ needs.preflight.outputs.root_image_transport }}
run: |
set -euo pipefail
if [[ "$PREPARE_RESULT" != "success" ]]; then
echo "Root Dockerfile image preparation ended with ${PREPARE_RESULT}." >&2
exit 1
fi
if [[ "$ROOT_IMAGE_TRANSPORT" == "registry" && "$IMAGE_EXISTS" != "true" ]]; then
if [[ "$PUSH_RESULT" != "success" ]]; then
echo "Root Dockerfile registry image publication ended with ${PUSH_RESULT}." >&2
exit 1
fi
elif [[ "$PUSH_RESULT" != "skipped" ]]; then
echo "Unexpected root Dockerfile registry publication result: ${PUSH_RESULT}." >&2
exit 1
fi
qr_package_install_smoke:
needs: [preflight]
if: needs.preflight.outputs.run_full_install_smoke == 'true'
runs-on: ubuntu-24.04
steps:
- name: Checkout CLI
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: ${{ needs.preflight.outputs.target_sha }}
persist-credentials: false
- name: Run QR package install smoke
env:
OPENCLAW_QR_SMOKE_FORCE_INSTALL: "1"
run: bash scripts/e2e/qr-import-docker.sh
root_dockerfile_smokes:
needs: [preflight, root_dockerfile_image, root_dockerfile_image_ready]
if: needs.preflight.outputs.run_full_install_smoke == 'true'
runs-on: ubuntu-24.04
env:
OPENCLAW_DOCKER_E2E_REQUIRE_LOCAL_IMAGE: ${{ needs.preflight.outputs.root_image_transport == 'no-push-artifact' && '1' || '0' }}
steps:
- name: Checkout CLI
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: ${{ needs.preflight.outputs.target_sha }}
persist-credentials: false
- name: Checkout trusted image artifact helper
if: needs.preflight.outputs.root_image_transport == 'no-push-artifact'
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
repository: ${{ needs.preflight.outputs.workflow_repository }}
ref: ${{ needs.preflight.outputs.workflow_sha }}
path: .release-harness
persist-credentials: false
- name: Log in to GHCR
if: needs.preflight.outputs.root_image_transport == 'registry'
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Pull root Dockerfile smoke image
if: needs.preflight.outputs.root_image_transport == 'registry'
env:
IMAGE_REF: ${{ needs.root_dockerfile_image.outputs.image_ref }}
run: timeout --kill-after=30s 600s docker pull "$IMAGE_REF"
- name: Validate root Dockerfile image artifact binding
if: needs.preflight.outputs.root_image_transport == 'no-push-artifact'
env:
ARCHIVE_SHA256: ${{ needs.root_dockerfile_image.outputs.archive_sha256 }}
ARTIFACT_DIGEST: ${{ needs.root_dockerfile_image.outputs.artifact_digest }}
ARTIFACT_ID: ${{ needs.root_dockerfile_image.outputs.artifact_id }}
ARTIFACT_NAME: ${{ needs.root_dockerfile_image.outputs.artifact_name }}
ARTIFACT_RUN_ATTEMPT: ${{ needs.root_dockerfile_image.outputs.artifact_run_attempt }}
ARTIFACT_RUN_ID: ${{ needs.root_dockerfile_image.outputs.artifact_run_id }}
GH_TOKEN: ${{ github.token }}
TARGET_SHA: ${{ needs.preflight.outputs.target_sha }}
run: |
set -euo pipefail
[[ "$ARTIFACT_ID" =~ ^[1-9][0-9]*$ ]] || {
echo "Root image artifact ID is missing or invalid." >&2
exit 1
}
[[ "$ARTIFACT_DIGEST" =~ ^[a-f0-9]{64}$ ]] || {
echo "Root image artifact digest is missing or invalid." >&2
exit 1
}
[[ "$ARCHIVE_SHA256" =~ ^[a-f0-9]{64}$ ]] || {
echo "Root image archive SHA-256 is missing or invalid." >&2
exit 1
}
[[ "$ARTIFACT_RUN_ID" =~ ^[1-9][0-9]*$ ]] || {
echo "Root image artifact run ID is missing or invalid." >&2
exit 1
}
[[ "$ARTIFACT_RUN_ATTEMPT" =~ ^[1-9][0-9]*$ ]] || {
echo "Root image artifact run attempt is missing or invalid." >&2
exit 1
}
expected_artifact_name="install-smoke-root-image-${TARGET_SHA:0:12}-${ARTIFACT_RUN_ID}-${ARTIFACT_RUN_ATTEMPT}"
[[ "$ARTIFACT_NAME" == "$expected_artifact_name" ]] || {
echo "Root image artifact name does not match the target and producer run attempt." >&2
exit 1
}
artifact_json="$(gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}")"
jq -e \
--arg digest "sha256:${ARTIFACT_DIGEST}" \
--arg id "$ARTIFACT_ID" \
--arg name "$ARTIFACT_NAME" \
--arg run_id "$ARTIFACT_RUN_ID" \
'
(.id | tostring) == $id and
.name == $name and
.expired == false and
.digest == $digest and
(.workflow_run.id | tostring) == $run_id
' <<< "$artifact_json" >/dev/null || {
echo "Root image artifact identity does not match the requested immutable tuple." >&2
exit 1
}
attempt_json="$(
gh api \
"repos/${GITHUB_REPOSITORY}/actions/runs/${ARTIFACT_RUN_ID}/attempts/${ARTIFACT_RUN_ATTEMPT}"
)"
jq -e \
--arg attempt "$ARTIFACT_RUN_ATTEMPT" \
--arg run_id "$ARTIFACT_RUN_ID" \
'(.id | tostring) == $run_id and (.run_attempt | tostring) == $attempt' \
<<< "$attempt_json" >/dev/null || {
echo "Root image artifact producer run attempt does not match the requested tuple." >&2
exit 1
}
- name: Download root Dockerfile image artifact
if: needs.preflight.outputs.root_image_transport == 'no-push-artifact'
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
artifact-ids: ${{ needs.root_dockerfile_image.outputs.artifact_id }}
path: ${{ runner.temp }}/install-smoke-root-image
run-id: ${{ needs.root_dockerfile_image.outputs.artifact_run_id }}
github-token: ${{ github.token }}
- name: Verify and load root Dockerfile image artifact
if: needs.preflight.outputs.root_image_transport == 'no-push-artifact'
env:
IMAGE_REF: ${{ needs.root_dockerfile_image.outputs.image_ref }}
OPENCLAW_SHARED_IMAGE_ARCHIVE_SHA256: ${{ needs.root_dockerfile_image.outputs.archive_sha256 }}
OPENCLAW_SHARED_IMAGE_RUN_ATTEMPT: ${{ needs.root_dockerfile_image.outputs.artifact_run_attempt }}
OPENCLAW_SHARED_IMAGE_RUN_ID: ${{ needs.root_dockerfile_image.outputs.artifact_run_id }}
TARGET_SHA: ${{ needs.preflight.outputs.target_sha }}
WORKFLOW_SHA: ${{ needs.preflight.outputs.workflow_sha }}
run: |
set -euo pipefail
bash .release-harness/scripts/docker/shared-image-artifact.sh \
load "${RUNNER_TEMP}/install-smoke-root-image" install-smoke-root \
"$TARGET_SHA" "$WORKFLOW_SHA" "$IMAGE_REF"
- name: Require local root Dockerfile image
if: needs.preflight.outputs.root_image_transport == 'no-push-artifact'
env:
IMAGE_REF: ${{ needs.root_dockerfile_image.outputs.image_ref }}
run: docker image inspect "$IMAGE_REF" >/dev/null
- name: Run root Dockerfile CLI smoke
env:
IMAGE_REF: ${{ needs.root_dockerfile_image.outputs.image_ref }}
@@ -403,27 +711,39 @@ jobs:
'
installer_smoke:
needs: [preflight, root_dockerfile_image]
needs: [preflight, root_dockerfile_image, root_dockerfile_image_ready]
if: needs.preflight.outputs.run_full_install_smoke == 'true'
runs-on: ubuntu-24.04
env:
DOCKER_BUILD_SUMMARY: "false"
DOCKER_BUILD_RECORD_UPLOAD: "false"
OPENCLAW_DOCKER_E2E_REQUIRE_LOCAL_IMAGE: ${{ needs.preflight.outputs.root_image_transport == 'no-push-artifact' && '1' || '0' }}
steps:
- name: Checkout trusted installer harness
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: ${{ github.workflow_sha }}
repository: ${{ needs.preflight.outputs.workflow_repository }}
ref: ${{ needs.preflight.outputs.workflow_sha }}
persist-credentials: false
- name: Checkout candidate CLI
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: ${{ inputs.ref || github.ref }}
ref: ${{ needs.preflight.outputs.target_sha }}
path: candidate
persist-credentials: false
- name: Checkout trusted image artifact helper
if: needs.preflight.outputs.root_image_transport == 'no-push-artifact'
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
repository: ${{ needs.preflight.outputs.workflow_repository }}
ref: ${{ needs.preflight.outputs.workflow_sha }}
path: .release-harness
persist-credentials: false
- name: Log in to GHCR
if: needs.preflight.outputs.root_image_transport == 'registry'
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
with:
registry: ghcr.io
@@ -431,10 +751,108 @@ jobs:
password: ${{ github.token }}
- name: Pull root Dockerfile smoke image
if: needs.preflight.outputs.root_image_transport == 'registry'
env:
IMAGE_REF: ${{ needs.root_dockerfile_image.outputs.image_ref }}
run: timeout --kill-after=30s 600s docker pull "$IMAGE_REF"
- name: Validate root Dockerfile image artifact binding
if: needs.preflight.outputs.root_image_transport == 'no-push-artifact'
env:
ARCHIVE_SHA256: ${{ needs.root_dockerfile_image.outputs.archive_sha256 }}
ARTIFACT_DIGEST: ${{ needs.root_dockerfile_image.outputs.artifact_digest }}
ARTIFACT_ID: ${{ needs.root_dockerfile_image.outputs.artifact_id }}
ARTIFACT_NAME: ${{ needs.root_dockerfile_image.outputs.artifact_name }}
ARTIFACT_RUN_ATTEMPT: ${{ needs.root_dockerfile_image.outputs.artifact_run_attempt }}
ARTIFACT_RUN_ID: ${{ needs.root_dockerfile_image.outputs.artifact_run_id }}
GH_TOKEN: ${{ github.token }}
TARGET_SHA: ${{ needs.preflight.outputs.target_sha }}
run: |
set -euo pipefail
[[ "$ARTIFACT_ID" =~ ^[1-9][0-9]*$ ]] || {
echo "Root image artifact ID is missing or invalid." >&2
exit 1
}
[[ "$ARTIFACT_DIGEST" =~ ^[a-f0-9]{64}$ ]] || {
echo "Root image artifact digest is missing or invalid." >&2
exit 1
}
[[ "$ARCHIVE_SHA256" =~ ^[a-f0-9]{64}$ ]] || {
echo "Root image archive SHA-256 is missing or invalid." >&2
exit 1
}
[[ "$ARTIFACT_RUN_ID" =~ ^[1-9][0-9]*$ ]] || {
echo "Root image artifact run ID is missing or invalid." >&2
exit 1
}
[[ "$ARTIFACT_RUN_ATTEMPT" =~ ^[1-9][0-9]*$ ]] || {
echo "Root image artifact run attempt is missing or invalid." >&2
exit 1
}
expected_artifact_name="install-smoke-root-image-${TARGET_SHA:0:12}-${ARTIFACT_RUN_ID}-${ARTIFACT_RUN_ATTEMPT}"
[[ "$ARTIFACT_NAME" == "$expected_artifact_name" ]] || {
echo "Root image artifact name does not match the target and producer run attempt." >&2
exit 1
}
artifact_json="$(gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}")"
jq -e \
--arg digest "sha256:${ARTIFACT_DIGEST}" \
--arg id "$ARTIFACT_ID" \
--arg name "$ARTIFACT_NAME" \
--arg run_id "$ARTIFACT_RUN_ID" \
'
(.id | tostring) == $id and
.name == $name and
.expired == false and
.digest == $digest and
(.workflow_run.id | tostring) == $run_id
' <<< "$artifact_json" >/dev/null || {
echo "Root image artifact identity does not match the requested immutable tuple." >&2
exit 1
}
attempt_json="$(
gh api \
"repos/${GITHUB_REPOSITORY}/actions/runs/${ARTIFACT_RUN_ID}/attempts/${ARTIFACT_RUN_ATTEMPT}"
)"
jq -e \
--arg attempt "$ARTIFACT_RUN_ATTEMPT" \
--arg run_id "$ARTIFACT_RUN_ID" \
'(.id | tostring) == $run_id and (.run_attempt | tostring) == $attempt' \
<<< "$attempt_json" >/dev/null || {
echo "Root image artifact producer run attempt does not match the requested tuple." >&2
exit 1
}
- name: Download root Dockerfile image artifact
if: needs.preflight.outputs.root_image_transport == 'no-push-artifact'
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
artifact-ids: ${{ needs.root_dockerfile_image.outputs.artifact_id }}
path: ${{ runner.temp }}/install-smoke-root-image
run-id: ${{ needs.root_dockerfile_image.outputs.artifact_run_id }}
github-token: ${{ github.token }}
- name: Verify and load root Dockerfile image artifact
if: needs.preflight.outputs.root_image_transport == 'no-push-artifact'
env:
IMAGE_REF: ${{ needs.root_dockerfile_image.outputs.image_ref }}
OPENCLAW_SHARED_IMAGE_ARCHIVE_SHA256: ${{ needs.root_dockerfile_image.outputs.archive_sha256 }}
OPENCLAW_SHARED_IMAGE_RUN_ATTEMPT: ${{ needs.root_dockerfile_image.outputs.artifact_run_attempt }}
OPENCLAW_SHARED_IMAGE_RUN_ID: ${{ needs.root_dockerfile_image.outputs.artifact_run_id }}
TARGET_SHA: ${{ needs.preflight.outputs.target_sha }}
WORKFLOW_SHA: ${{ needs.preflight.outputs.workflow_sha }}
run: |
set -euo pipefail
bash .release-harness/scripts/docker/shared-image-artifact.sh \
load "${RUNNER_TEMP}/install-smoke-root-image" install-smoke-root \
"$TARGET_SHA" "$WORKFLOW_SHA" "$IMAGE_REF"
- name: Require local root Dockerfile image
if: needs.preflight.outputs.root_image_transport == 'no-push-artifact'
env:
IMAGE_REF: ${{ needs.root_dockerfile_image.outputs.image_ref }}
run: docker image inspect "$IMAGE_REF" >/dev/null
- name: Set up Blacksmith Docker Builder
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
with:
@@ -502,17 +920,29 @@ jobs:
bash -lc 'dnf install -y -q ca-certificates tar gzip xz findutils which sudo >/dev/null && bash /tmp/install-cli.sh --prefix /tmp/openclaw-cli --version latest --no-onboard && /tmp/openclaw-cli/bin/openclaw --version'
bun_global_install_smoke:
needs: [preflight, root_dockerfile_image]
needs: [preflight, root_dockerfile_image, root_dockerfile_image_ready]
if: needs.preflight.outputs.run_full_install_smoke == 'true' && needs.preflight.outputs.run_bun_global_install_smoke == 'true'
runs-on: ubuntu-24.04
env:
OPENCLAW_DOCKER_E2E_REQUIRE_LOCAL_IMAGE: ${{ needs.preflight.outputs.root_image_transport == 'no-push-artifact' && '1' || '0' }}
steps:
- name: Checkout CLI
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: ${{ inputs.ref || github.ref }}
ref: ${{ needs.preflight.outputs.target_sha }}
persist-credentials: false
- name: Checkout trusted image artifact helper
if: needs.preflight.outputs.root_image_transport == 'no-push-artifact'
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
repository: ${{ needs.preflight.outputs.workflow_repository }}
ref: ${{ needs.preflight.outputs.workflow_sha }}
path: .release-harness
persist-credentials: false
- name: Log in to GHCR
if: needs.preflight.outputs.root_image_transport == 'registry'
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
with:
registry: ghcr.io
@@ -520,10 +950,108 @@ jobs:
password: ${{ github.token }}
- name: Pull root Dockerfile smoke image
if: needs.preflight.outputs.root_image_transport == 'registry'
env:
IMAGE_REF: ${{ needs.root_dockerfile_image.outputs.image_ref }}
run: timeout --kill-after=30s 600s docker pull "$IMAGE_REF"
- name: Validate root Dockerfile image artifact binding
if: needs.preflight.outputs.root_image_transport == 'no-push-artifact'
env:
ARCHIVE_SHA256: ${{ needs.root_dockerfile_image.outputs.archive_sha256 }}
ARTIFACT_DIGEST: ${{ needs.root_dockerfile_image.outputs.artifact_digest }}
ARTIFACT_ID: ${{ needs.root_dockerfile_image.outputs.artifact_id }}
ARTIFACT_NAME: ${{ needs.root_dockerfile_image.outputs.artifact_name }}
ARTIFACT_RUN_ATTEMPT: ${{ needs.root_dockerfile_image.outputs.artifact_run_attempt }}
ARTIFACT_RUN_ID: ${{ needs.root_dockerfile_image.outputs.artifact_run_id }}
GH_TOKEN: ${{ github.token }}
TARGET_SHA: ${{ needs.preflight.outputs.target_sha }}
run: |
set -euo pipefail
[[ "$ARTIFACT_ID" =~ ^[1-9][0-9]*$ ]] || {
echo "Root image artifact ID is missing or invalid." >&2
exit 1
}
[[ "$ARTIFACT_DIGEST" =~ ^[a-f0-9]{64}$ ]] || {
echo "Root image artifact digest is missing or invalid." >&2
exit 1
}
[[ "$ARCHIVE_SHA256" =~ ^[a-f0-9]{64}$ ]] || {
echo "Root image archive SHA-256 is missing or invalid." >&2
exit 1
}
[[ "$ARTIFACT_RUN_ID" =~ ^[1-9][0-9]*$ ]] || {
echo "Root image artifact run ID is missing or invalid." >&2
exit 1
}
[[ "$ARTIFACT_RUN_ATTEMPT" =~ ^[1-9][0-9]*$ ]] || {
echo "Root image artifact run attempt is missing or invalid." >&2
exit 1
}
expected_artifact_name="install-smoke-root-image-${TARGET_SHA:0:12}-${ARTIFACT_RUN_ID}-${ARTIFACT_RUN_ATTEMPT}"
[[ "$ARTIFACT_NAME" == "$expected_artifact_name" ]] || {
echo "Root image artifact name does not match the target and producer run attempt." >&2
exit 1
}
artifact_json="$(gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}")"
jq -e \
--arg digest "sha256:${ARTIFACT_DIGEST}" \
--arg id "$ARTIFACT_ID" \
--arg name "$ARTIFACT_NAME" \
--arg run_id "$ARTIFACT_RUN_ID" \
'
(.id | tostring) == $id and
.name == $name and
.expired == false and
.digest == $digest and
(.workflow_run.id | tostring) == $run_id
' <<< "$artifact_json" >/dev/null || {
echo "Root image artifact identity does not match the requested immutable tuple." >&2
exit 1
}
attempt_json="$(
gh api \
"repos/${GITHUB_REPOSITORY}/actions/runs/${ARTIFACT_RUN_ID}/attempts/${ARTIFACT_RUN_ATTEMPT}"
)"
jq -e \
--arg attempt "$ARTIFACT_RUN_ATTEMPT" \
--arg run_id "$ARTIFACT_RUN_ID" \
'(.id | tostring) == $run_id and (.run_attempt | tostring) == $attempt' \
<<< "$attempt_json" >/dev/null || {
echo "Root image artifact producer run attempt does not match the requested tuple." >&2
exit 1
}
- name: Download root Dockerfile image artifact
if: needs.preflight.outputs.root_image_transport == 'no-push-artifact'
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
artifact-ids: ${{ needs.root_dockerfile_image.outputs.artifact_id }}
path: ${{ runner.temp }}/install-smoke-root-image
run-id: ${{ needs.root_dockerfile_image.outputs.artifact_run_id }}
github-token: ${{ github.token }}
- name: Verify and load root Dockerfile image artifact
if: needs.preflight.outputs.root_image_transport == 'no-push-artifact'
env:
IMAGE_REF: ${{ needs.root_dockerfile_image.outputs.image_ref }}
OPENCLAW_SHARED_IMAGE_ARCHIVE_SHA256: ${{ needs.root_dockerfile_image.outputs.archive_sha256 }}
OPENCLAW_SHARED_IMAGE_RUN_ATTEMPT: ${{ needs.root_dockerfile_image.outputs.artifact_run_attempt }}
OPENCLAW_SHARED_IMAGE_RUN_ID: ${{ needs.root_dockerfile_image.outputs.artifact_run_id }}
TARGET_SHA: ${{ needs.preflight.outputs.target_sha }}
WORKFLOW_SHA: ${{ needs.preflight.outputs.workflow_sha }}
run: |
set -euo pipefail
bash .release-harness/scripts/docker/shared-image-artifact.sh \
load "${RUNNER_TEMP}/install-smoke-root-image" install-smoke-root \
"$TARGET_SHA" "$WORKFLOW_SHA" "$IMAGE_REF"
- name: Require local root Dockerfile image
if: needs.preflight.outputs.root_image_transport == 'no-push-artifact'
env:
IMAGE_REF: ${{ needs.root_dockerfile_image.outputs.image_ref }}
run: docker image inspect "$IMAGE_REF" >/dev/null
- name: Setup Node environment for Bun smoke
uses: ./.github/actions/setup-node-env
with:
@@ -548,7 +1076,7 @@ jobs:
- name: Checkout CLI
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: ${{ inputs.ref || github.ref }}
ref: ${{ needs.preflight.outputs.target_sha }}
persist-credentials: false
- name: Set up Blacksmith Docker Builder
+192 -6
View File
@@ -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}")"
@@ -57,12 +57,27 @@ on:
default: ""
type: string
candidate_artifact_name:
description: Optional current-run artifact name containing the candidate OpenClaw tarball
description: Optional artifact name containing the candidate OpenClaw tarball
required: false
default: ""
type: string
candidate_artifact_id:
description: Immutable GitHub artifact id for candidate_artifact_name
required: false
default: ""
type: string
candidate_artifact_digest:
description: Exact upload-artifact SHA-256 digest for candidate_artifact_id
required: false
default: ""
type: string
candidate_artifact_run_id:
description: Optional workflow run id for candidate_artifact_name
description: Exact workflow run id that produced candidate_artifact_id
required: false
default: ""
type: string
candidate_artifact_run_attempt:
description: Exact workflow run attempt that produced candidate_artifact_id
required: false
default: ""
type: string
@@ -81,6 +96,11 @@ on:
required: false
default: ""
type: string
candidate_sha256:
description: Exact candidate tarball SHA-256
required: false
default: ""
type: string
openai_model:
description: OpenAI model for release cross-OS agent-turn smoke
required: false
@@ -141,12 +161,27 @@ on:
default: ""
type: string
candidate_artifact_name:
description: Optional current-run artifact name containing the candidate OpenClaw tarball
description: Optional artifact name containing the candidate OpenClaw tarball
required: false
default: ""
type: string
candidate_artifact_id:
description: Immutable GitHub artifact id for candidate_artifact_name
required: false
default: ""
type: string
candidate_artifact_digest:
description: Exact upload-artifact SHA-256 digest for candidate_artifact_id
required: false
default: ""
type: string
candidate_artifact_run_id:
description: Optional workflow run id for candidate_artifact_name
description: Exact workflow run id that produced candidate_artifact_id
required: false
default: ""
type: string
candidate_artifact_run_attempt:
description: Exact workflow run attempt that produced candidate_artifact_id
required: false
default: ""
type: string
@@ -165,6 +200,11 @@ on:
required: false
default: ""
type: string
candidate_sha256:
description: Exact candidate tarball SHA-256
required: false
default: ""
type: string
openai_model:
description: OpenAI model for release cross-OS agent-turn smoke
required: false
@@ -201,9 +241,19 @@ jobs:
runs-on: ubuntu-24.04
continue-on-error: ${{ inputs.advisory }}
outputs:
baseline_artifact_digest: ${{ steps.upload_baseline.outputs.artifact-digest }}
baseline_artifact_id: ${{ steps.upload_baseline.outputs.artifact-id }}
baseline_artifact_run_attempt: ${{ github.run_attempt }}
baseline_artifact_run_id: ${{ github.run_id }}
baseline_file_name: ${{ steps.baseline_metadata.outputs.file_name }}
baseline_sha256: ${{ steps.baseline_metadata.outputs.sha256 }}
baseline_spec: ${{ steps.baseline.outputs.value }}
candidate_artifact_digest: ${{ steps.upload_candidate.outputs.artifact-digest }}
candidate_artifact_id: ${{ steps.upload_candidate.outputs.artifact-id }}
candidate_artifact_run_attempt: ${{ github.run_attempt }}
candidate_artifact_run_id: ${{ github.run_id }}
candidate_file_name: ${{ steps.candidate_metadata.outputs.file_name }}
candidate_sha256: ${{ steps.candidate_metadata.outputs.sha256 }}
candidate_version: ${{ steps.candidate_metadata.outputs.version }}
matrix: ${{ steps.matrix.outputs.value }}
source_sha: ${{ steps.candidate_metadata.outputs.source_sha }}
@@ -337,7 +387,75 @@ jobs:
ref: ${{ steps.workflow_ref.outputs.value }}
path: workflow
fetch-depth: 1
persist-credentials: true
persist-credentials: false
- name: Validate provided candidate artifact binding
if: inputs.candidate_artifact_name != '' || inputs.candidate_artifact_id != '' || inputs.candidate_artifact_digest != '' || inputs.candidate_artifact_run_id != '' || inputs.candidate_artifact_run_attempt != '' || inputs.candidate_file_name != '' || inputs.candidate_sha256 != '' || inputs.candidate_version != '' || inputs.candidate_source_sha != ''
env:
ARTIFACT_DIGEST: ${{ inputs.candidate_artifact_digest }}
ARTIFACT_ID: ${{ inputs.candidate_artifact_id }}
ARTIFACT_NAME: ${{ inputs.candidate_artifact_name }}
ARTIFACT_RUN_ATTEMPT: ${{ inputs.candidate_artifact_run_attempt }}
ARTIFACT_RUN_ID: ${{ inputs.candidate_artifact_run_id }}
CANDIDATE_FILE_NAME: ${{ inputs.candidate_file_name }}
CANDIDATE_SHA256: ${{ inputs.candidate_sha256 }}
CANDIDATE_SOURCE_SHA: ${{ inputs.candidate_source_sha }}
CANDIDATE_VERSION: ${{ inputs.candidate_version }}
GH_TOKEN: ${{ github.token }}
INPUT_REF: ${{ inputs.ref }}
shell: bash
run: |
set -euo pipefail
if [[ -z "${ARTIFACT_NAME// }" ||
! "$ARTIFACT_ID" =~ ^[1-9][0-9]*$ ||
! "$ARTIFACT_DIGEST" =~ ^[a-f0-9]{64}$ ||
! "$ARTIFACT_RUN_ID" =~ ^[1-9][0-9]*$ ||
! "$ARTIFACT_RUN_ATTEMPT" =~ ^[1-9][0-9]*$ ||
-z "${CANDIDATE_FILE_NAME// }" ||
! "$CANDIDATE_SHA256" =~ ^[a-f0-9]{64}$ ||
! "$CANDIDATE_SOURCE_SHA" =~ ^[a-f0-9]{40}$ ||
-z "${CANDIDATE_VERSION// }" ]]; then
echo "Candidate artifact selection requires the complete immutable artifact and package identity tuple." >&2
exit 1
fi
[[ "$ARTIFACT_NAME" == *"-${ARTIFACT_RUN_ID}-${ARTIFACT_RUN_ATTEMPT}" ]] || {
echo "Candidate artifact name does not bind the declared producer run attempt." >&2
exit 1
}
if [[ "$INPUT_REF" =~ ^[a-f0-9]{40}$ && "$CANDIDATE_SOURCE_SHA" != "$INPUT_REF" ]]; then
echo "Candidate package source SHA does not match the selected exact ref." >&2
exit 1
fi
artifact_json="$(gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}")"
jq -e \
--arg digest "sha256:${ARTIFACT_DIGEST}" \
--arg id "$ARTIFACT_ID" \
--arg name "$ARTIFACT_NAME" \
--arg run_id "$ARTIFACT_RUN_ID" \
'
(.id | tostring) == $id and
.name == $name and
.expired == false and
.digest == $digest and
(.workflow_run.id | tostring) == $run_id
' <<< "$artifact_json" >/dev/null || {
echo "Candidate artifact identity does not match the requested immutable tuple." >&2
exit 1
}
attempt_json="$(
gh api \
"repos/${GITHUB_REPOSITORY}/actions/runs/${ARTIFACT_RUN_ID}/attempts/${ARTIFACT_RUN_ATTEMPT}"
)"
jq -e \
--arg attempt "$ARTIFACT_RUN_ATTEMPT" \
--arg run_id "$ARTIFACT_RUN_ID" \
'(.id | tostring) == $run_id and (.run_attempt | tostring) == $attempt' \
<<< "$attempt_json" >/dev/null || {
echo "Candidate artifact producer run attempt does not match the requested tuple." >&2
exit 1
}
- name: Checkout public source ref
if: inputs.candidate_artifact_name == ''
@@ -347,7 +465,7 @@ jobs:
ref: ${{ inputs.ref }}
path: source
fetch-depth: 0
persist-credentials: true
persist-credentials: false
submodules: recursive
- name: Setup Node.js
@@ -376,72 +494,70 @@ jobs:
--source-dir source \
--output-dir "${OUTPUT_DIR}"
- name: Download current-run candidate artifact
if: inputs.candidate_artifact_name != '' && inputs.candidate_artifact_run_id == ''
- name: Download provided candidate artifact
if: inputs.candidate_artifact_name != ''
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: ${{ inputs.candidate_artifact_name }}
path: ${{ runner.temp }}/openclaw-cross-os-release-checks/prepare/package
- name: Download previous-run candidate artifact
if: inputs.candidate_artifact_name != '' && inputs.candidate_artifact_run_id != ''
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: ${{ inputs.candidate_artifact_name }}
artifact-ids: ${{ inputs.candidate_artifact_id }}
path: ${{ runner.temp }}/openclaw-cross-os-release-checks/prepare/input
run-id: ${{ inputs.candidate_artifact_run_id }}
github-token: ${{ github.token }}
path: ${{ runner.temp }}/openclaw-cross-os-release-checks/prepare/package
- name: Capture provided candidate artifact metadata
- name: Resolve provided candidate package
if: inputs.candidate_artifact_name != ''
env:
PACKAGE_DIR: ${{ runner.temp }}/openclaw-cross-os-release-checks/prepare/package
INPUT_DIR: ${{ runner.temp }}/openclaw-cross-os-release-checks/prepare/input
OUTPUT_DIR: ${{ runner.temp }}/openclaw-cross-os-release-checks/prepare/package
INPUT_CANDIDATE_FILE_NAME: ${{ inputs.candidate_file_name }}
INPUT_CANDIDATE_SHA256: ${{ inputs.candidate_sha256 }}
INPUT_CANDIDATE_VERSION: ${{ inputs.candidate_version }}
INPUT_CANDIDATE_SOURCE_SHA: ${{ inputs.candidate_source_sha }}
CANDIDATE_JSON: ${{ runner.temp }}/openclaw-cross-os-release-checks/prepare/candidate.json
shell: bash
run: |
set -euo pipefail
node workflow/scripts/resolve-openclaw-package-candidate.mjs \
--source artifact \
--artifact-dir "$INPUT_DIR" \
--package-sha256 "$INPUT_CANDIDATE_SHA256" \
--output-dir "$OUTPUT_DIR" \
--output-name "$INPUT_CANDIDATE_FILE_NAME" \
--metadata "$OUTPUT_DIR/package-candidate.json"
actual_sha256="$(
jq -er '.sha256 | select(type == "string" and test("^[a-f0-9]{64}$"))' \
"$OUTPUT_DIR/package-candidate.json"
)"
actual_source_sha="$(
jq -er '.packageSourceSha | select(type == "string" and test("^[a-f0-9]{40}$"))' \
"$OUTPUT_DIR/package-candidate.json"
)"
actual_version="$(
jq -er '.version | select(type == "string" and length > 0)' \
"$OUTPUT_DIR/package-candidate.json"
)"
[[ "$actual_sha256" == "$INPUT_CANDIDATE_SHA256" &&
"$actual_source_sha" == "$INPUT_CANDIDATE_SOURCE_SHA" &&
"$actual_version" == "$INPUT_CANDIDATE_VERSION" ]] || {
echo "Resolved candidate package identity differs from the declared exact tuple." >&2
exit 1
}
export ACTUAL_SHA256="$actual_sha256"
export ACTUAL_SOURCE_SHA="$actual_source_sha"
export ACTUAL_VERSION="$actual_version"
node <<'NODE'
const fs = require("node:fs");
const path = require("node:path");
const packageDir = process.env.PACKAGE_DIR;
function resolveTarballFileName(value, label) {
const fileName = typeof value === "string" ? value.trim() : "";
if (
!fileName.endsWith(".tgz") ||
fileName.includes("\0") ||
fileName !== path.basename(fileName) ||
fileName !== path.win32.basename(fileName)
) {
throw new Error(`${label} must be a local .tgz filename.`);
}
return fileName;
}
const requestedFileName = process.env.INPUT_CANDIDATE_FILE_NAME.trim();
const files = fs.readdirSync(packageDir).filter((file) => file.endsWith(".tgz"));
const selectedCandidateFileName = requestedFileName || (files.length === 1 ? files[0] : "");
if (!selectedCandidateFileName) {
throw new Error(`Expected exactly one candidate .tgz in ${packageDir}; found ${files.length}.`);
}
const candidateFileName = resolveTarballFileName(
selectedCandidateFileName,
"candidate_file_name",
);
if (!fs.existsSync(path.join(packageDir, candidateFileName))) {
throw new Error(`Provided candidate artifact does not contain ${candidateFileName}.`);
}
const candidateVersion = process.env.INPUT_CANDIDATE_VERSION.trim();
if (!candidateVersion) {
throw new Error("candidate_version is required when candidate_artifact_name is provided.");
}
const sourceSha = process.env.INPUT_CANDIDATE_SOURCE_SHA.trim();
if (!/^[0-9a-f]{40}$/iu.test(sourceSha)) {
throw new Error("candidate_source_sha must be a full commit SHA when candidate_artifact_name is provided.");
}
fs.writeFileSync(
process.env.CANDIDATE_JSON,
`${JSON.stringify({ candidateFileName, candidateVersion, sourceSha }, null, 2)}\n`,
`${JSON.stringify(
{
candidateFileName: process.env.INPUT_CANDIDATE_FILE_NAME,
candidateSha256: process.env.ACTUAL_SHA256,
candidateVersion: process.env.ACTUAL_VERSION,
sourceSha: process.env.ACTUAL_SOURCE_SHA,
},
null,
2,
)}\n`,
);
NODE
@@ -472,13 +588,43 @@ jobs:
id: candidate_metadata
env:
CANDIDATE_JSON: ${{ runner.temp }}/openclaw-cross-os-release-checks/prepare/candidate.json
PACKAGE_DIR: ${{ runner.temp }}/openclaw-cross-os-release-checks/prepare/package
run: |
node <<'NODE' >>"$GITHUB_OUTPUT"
const crypto = require("node:crypto");
const fs = require("node:fs");
const path = require("node:path");
const payload = JSON.parse(fs.readFileSync(process.env.CANDIDATE_JSON, "utf8"));
process.stdout.write(`file_name=${payload.candidateFileName}\n`);
process.stdout.write(`version=${payload.candidateVersion}\n`);
process.stdout.write(`source_sha=${payload.sourceSha}\n`);
const fileName =
typeof payload.candidateFileName === "string" ? payload.candidateFileName.trim() : "";
const version =
typeof payload.candidateVersion === "string" ? payload.candidateVersion.trim() : "";
const sourceSha = typeof payload.sourceSha === "string" ? payload.sourceSha.trim() : "";
if (
!fileName.endsWith(".tgz") ||
fileName.includes("\0") ||
fileName !== path.basename(fileName) ||
fileName !== path.win32.basename(fileName)
) {
throw new Error("Candidate manifest file name must be a local .tgz filename.");
}
if (!version) {
throw new Error("Candidate manifest version is missing.");
}
if (!/^[0-9a-f]{40}$/u.test(sourceSha)) {
throw new Error("Candidate manifest source SHA must be a lowercase full commit SHA.");
}
const tarball = path.join(process.env.PACKAGE_DIR, fileName);
const sha256 = crypto.createHash("sha256").update(fs.readFileSync(tarball)).digest("hex");
const declaredSha256 =
typeof payload.candidateSha256 === "string" ? payload.candidateSha256.trim() : "";
if (declaredSha256 && declaredSha256 !== sha256) {
throw new Error("Candidate manifest SHA-256 differs from the candidate tarball.");
}
process.stdout.write(`file_name=${fileName}\n`);
process.stdout.write(`sha256=${sha256}\n`);
process.stdout.write(`version=${version}\n`);
process.stdout.write(`source_sha=${sourceSha}\n`);
NODE
- name: Capture baseline metadata
@@ -488,6 +634,7 @@ jobs:
BASELINE_PACK_JSON: ${{ runner.temp }}/openclaw-cross-os-release-checks/prepare/baseline/pack.json
run: |
node <<'NODE' >>"$GITHUB_OUTPUT"
const crypto = require("node:crypto");
const fs = require("node:fs");
const path = require("node:path");
function resolveTarballFileName(value, label) {
@@ -505,21 +652,28 @@ jobs:
const payload = JSON.parse(fs.readFileSync(process.env.BASELINE_PACK_JSON, "utf8"));
const entry = Array.isArray(payload) ? payload.at(-1) : null;
const fileName = resolveTarballFileName(entry?.filename, "Baseline npm pack filename");
const sha256 = crypto
.createHash("sha256")
.update(fs.readFileSync(path.join(path.dirname(process.env.BASELINE_PACK_JSON), fileName)))
.digest("hex");
process.stdout.write(`file_name=${fileName}\n`);
process.stdout.write(`sha256=${sha256}\n`);
NODE
- name: Upload candidate artifact
id: upload_candidate
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: openclaw-cross-os-release-checks-candidate-${{ github.run_id }}
name: openclaw-cross-os-release-checks-candidate-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/openclaw-cross-os-release-checks/prepare/package/${{ steps.candidate_metadata.outputs.file_name }}
if-no-files-found: error
- name: Upload baseline artifact
if: ${{ inputs.mode != 'fresh' }}
id: upload_baseline
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: openclaw-cross-os-release-checks-baseline-${{ github.run_id }}
name: openclaw-cross-os-release-checks-baseline-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/openclaw-cross-os-release-checks/prepare/baseline/${{ steps.baseline_metadata.outputs.file_name }}
if-no-files-found: error
@@ -563,7 +717,7 @@ jobs:
ref: ${{ needs.prepare.outputs.workflow_ref }}
path: workflow
fetch-depth: 1
persist-credentials: true
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
@@ -578,20 +732,140 @@ jobs:
lockfile-path: workflow/pnpm-lock.yaml
use-actions-cache: "false"
- name: Validate prepared candidate artifact binding
env:
ARTIFACT_DIGEST: ${{ needs.prepare.outputs.candidate_artifact_digest }}
ARTIFACT_ID: ${{ needs.prepare.outputs.candidate_artifact_id }}
ARTIFACT_NAME: ${{ format('openclaw-cross-os-release-checks-candidate-{0}-{1}', needs.prepare.outputs.candidate_artifact_run_id, needs.prepare.outputs.candidate_artifact_run_attempt) }}
ARTIFACT_RUN_ATTEMPT: ${{ needs.prepare.outputs.candidate_artifact_run_attempt }}
ARTIFACT_RUN_ID: ${{ needs.prepare.outputs.candidate_artifact_run_id }}
BASELINE_ARTIFACT_DIGEST: ${{ needs.prepare.outputs.baseline_artifact_digest }}
BASELINE_ARTIFACT_ID: ${{ needs.prepare.outputs.baseline_artifact_id }}
BASELINE_ARTIFACT_NAME: ${{ format('openclaw-cross-os-release-checks-baseline-{0}-{1}', needs.prepare.outputs.baseline_artifact_run_id, needs.prepare.outputs.baseline_artifact_run_attempt) }}
BASELINE_ARTIFACT_RUN_ATTEMPT: ${{ needs.prepare.outputs.baseline_artifact_run_attempt }}
BASELINE_ARTIFACT_RUN_ID: ${{ needs.prepare.outputs.baseline_artifact_run_id }}
BASELINE_SHA256: ${{ needs.prepare.outputs.baseline_sha256 }}
CANDIDATE_SHA256: ${{ needs.prepare.outputs.candidate_sha256 }}
CANDIDATE_SOURCE_SHA: ${{ needs.prepare.outputs.source_sha }}
CANDIDATE_VERSION: ${{ needs.prepare.outputs.candidate_version }}
GH_TOKEN: ${{ github.token }}
SUITE: ${{ matrix.suite }}
shell: bash
run: |
set -euo pipefail
[[ "$ARTIFACT_ID" =~ ^[1-9][0-9]*$ &&
"$ARTIFACT_DIGEST" =~ ^[a-f0-9]{64}$ &&
-n "${ARTIFACT_NAME// }" &&
"$ARTIFACT_RUN_ID" =~ ^[1-9][0-9]*$ &&
"$ARTIFACT_RUN_ATTEMPT" =~ ^[1-9][0-9]*$ ]] || {
echo "Prepared candidate artifact binding is incomplete." >&2
exit 1
}
[[ "$CANDIDATE_SHA256" =~ ^[a-f0-9]{64}$ &&
"$CANDIDATE_SOURCE_SHA" =~ ^[a-f0-9]{40}$ &&
-n "${CANDIDATE_VERSION// }" ]] || {
echo "Prepared candidate package identity is incomplete." >&2
exit 1
}
if [[ "$SUITE" == "packaged-upgrade" ]]; then
[[ "$BASELINE_ARTIFACT_ID" =~ ^[1-9][0-9]*$ &&
"$BASELINE_ARTIFACT_DIGEST" =~ ^[a-f0-9]{64}$ &&
-n "${BASELINE_ARTIFACT_NAME// }" &&
"$BASELINE_ARTIFACT_RUN_ID" =~ ^[1-9][0-9]*$ &&
"$BASELINE_ARTIFACT_RUN_ATTEMPT" =~ ^[1-9][0-9]*$ &&
"$BASELINE_SHA256" =~ ^[a-f0-9]{64}$ ]] || {
echo "Prepared baseline artifact binding is incomplete." >&2
exit 1
}
fi
node <<'NODE'
const apiUrl = process.env.GITHUB_API_URL || "https://api.github.com";
const repository = process.env.GITHUB_REPOSITORY;
const token = process.env.GH_TOKEN;
const suite = process.env.SUITE;
const tuples = [
{
digest: process.env.ARTIFACT_DIGEST,
id: process.env.ARTIFACT_ID,
label: "candidate",
name: process.env.ARTIFACT_NAME,
runAttempt: process.env.ARTIFACT_RUN_ATTEMPT,
runId: process.env.ARTIFACT_RUN_ID,
},
];
if (suite === "packaged-upgrade") {
tuples.push({
digest: process.env.BASELINE_ARTIFACT_DIGEST,
id: process.env.BASELINE_ARTIFACT_ID,
label: "baseline",
name: process.env.BASELINE_ARTIFACT_NAME,
runAttempt: process.env.BASELINE_ARTIFACT_RUN_ATTEMPT,
runId: process.env.BASELINE_ARTIFACT_RUN_ID,
});
}
const request = async (path) => {
const response = await fetch(`${apiUrl}/repos/${repository}/${path}`, {
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${token}`,
"X-GitHub-Api-Version": "2022-11-28",
},
});
if (!response.ok) {
throw new Error(`GitHub artifact API ${path} returned ${response.status}.`);
}
return response.json();
};
async function main() {
for (const tuple of tuples) {
const artifact = await request(`actions/artifacts/${tuple.id}`);
if (
String(artifact.id) !== tuple.id ||
artifact.name !== tuple.name ||
artifact.expired !== false ||
artifact.digest !== `sha256:${tuple.digest}` ||
String(artifact.workflow_run?.id) !== tuple.runId
) {
throw new Error(`Prepared ${tuple.label} artifact identity does not match.`);
}
const attempt = await request(
`actions/runs/${tuple.runId}/attempts/${tuple.runAttempt}`,
);
if (
String(attempt.id) !== tuple.runId ||
String(attempt.run_attempt) !== tuple.runAttempt
) {
throw new Error(`Prepared ${tuple.label} artifact run attempt does not match.`);
}
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
NODE
- name: Download candidate artifact
id: download_candidate
continue-on-error: true
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: openclaw-cross-os-release-checks-candidate-${{ github.run_id }}
artifact-ids: ${{ needs.prepare.outputs.candidate_artifact_id }}
path: ${{ runner.temp }}/openclaw-cross-os-release-checks/candidate
run-id: ${{ needs.prepare.outputs.candidate_artifact_run_id }}
github-token: ${{ github.token }}
- name: Retry candidate artifact download
if: ${{ steps.download_candidate.outcome == 'failure' }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: openclaw-cross-os-release-checks-candidate-${{ github.run_id }}
artifact-ids: ${{ needs.prepare.outputs.candidate_artifact_id }}
path: ${{ runner.temp }}/openclaw-cross-os-release-checks/candidate
run-id: ${{ needs.prepare.outputs.candidate_artifact_run_id }}
github-token: ${{ github.token }}
- name: Download baseline artifact
if: ${{ matrix.suite == 'packaged-upgrade' }}
@@ -599,21 +873,27 @@ jobs:
continue-on-error: true
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: openclaw-cross-os-release-checks-baseline-${{ github.run_id }}
artifact-ids: ${{ needs.prepare.outputs.baseline_artifact_id }}
path: ${{ runner.temp }}/openclaw-cross-os-release-checks/baseline
run-id: ${{ needs.prepare.outputs.baseline_artifact_run_id }}
github-token: ${{ github.token }}
- name: Retry baseline artifact download
if: ${{ matrix.suite == 'packaged-upgrade' && steps.download_baseline.outcome == 'failure' }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: openclaw-cross-os-release-checks-baseline-${{ github.run_id }}
artifact-ids: ${{ needs.prepare.outputs.baseline_artifact_id }}
path: ${{ runner.temp }}/openclaw-cross-os-release-checks/baseline
run-id: ${{ needs.prepare.outputs.baseline_artifact_run_id }}
github-token: ${{ github.token }}
- name: Verify release-check inputs
shell: bash
env:
CANDIDATE_TGZ: ${{ runner.temp }}/openclaw-cross-os-release-checks/candidate/${{ needs.prepare.outputs.candidate_file_name }}
EXPECTED_CANDIDATE_SHA256: ${{ needs.prepare.outputs.candidate_sha256 }}
BASELINE_TGZ: ${{ runner.temp }}/openclaw-cross-os-release-checks/baseline/${{ needs.prepare.outputs.baseline_file_name }}
EXPECTED_BASELINE_SHA256: ${{ needs.prepare.outputs.baseline_sha256 }}
OUTPUT_DIR: ${{ runner.temp }}/openclaw-cross-os-release-checks/${{ matrix.artifact_name }}-${{ matrix.suite }}
SUITE: ${{ matrix.suite }}
run: |
@@ -622,10 +902,40 @@ jobs:
echo "::error::candidate artifact missing: ${CANDIDATE_TGZ}"
exit 1
fi
actual_sha256="$(
node -e '
const crypto = require("node:crypto");
const fs = require("node:fs");
process.stdout.write(
crypto.createHash("sha256").update(fs.readFileSync(process.env.CANDIDATE_TGZ)).digest("hex"),
);
'
)"
if [[ ! "$EXPECTED_CANDIDATE_SHA256" =~ ^[a-f0-9]{64}$ ||
"$actual_sha256" != "$EXPECTED_CANDIDATE_SHA256" ]]; then
echo "::error::candidate artifact SHA-256 does not match the prepared package identity"
exit 1
fi
if [[ "${SUITE}" == "packaged-upgrade" ]] && [[ ! -f "${BASELINE_TGZ}" ]]; then
echo "::error::baseline artifact missing: ${BASELINE_TGZ}"
exit 1
fi
if [[ "${SUITE}" == "packaged-upgrade" ]]; then
actual_baseline_sha256="$(
node -e '
const crypto = require("node:crypto");
const fs = require("node:fs");
process.stdout.write(
crypto.createHash("sha256").update(fs.readFileSync(process.env.BASELINE_TGZ)).digest("hex"),
);
'
)"
if [[ ! "$EXPECTED_BASELINE_SHA256" =~ ^[a-f0-9]{64}$ ||
"$actual_baseline_sha256" != "$EXPECTED_BASELINE_SHA256" ]]; then
echo "::error::baseline artifact SHA-256 does not match the prepared package identity"
exit 1
fi
fi
- name: Run cross-OS release checks
shell: bash
File diff suppressed because it is too large Load Diff
+40 -3
View File
@@ -42,10 +42,15 @@ on:
required: false
default: false
type: boolean
publish_reports:
description: Publish completed reports to openclaw/clawgrit-reports
required: false
default: true
type: boolean
kova_ref:
description: openclaw/Kova Git ref to install
required: false
default: a2dd84e7d65507e614afaff850d3932d18c859b6
default: 24c26969e57d4d49f9d1a5071af85dd3d79daa2d
type: string
dispatch_id:
description: Optional parent workflow dispatch identifier
@@ -148,7 +153,7 @@ jobs:
include_filters: "scenario:agent-cold-warm-message"
expected_release_entries: "agent-cold-warm-message:mock-openai-provider"
env:
KOVA_REF: ${{ inputs.kova_ref || 'a2dd84e7d65507e614afaff850d3932d18c859b6' }}
KOVA_REF: ${{ inputs.kova_ref || '24c26969e57d4d49f9d1a5071af85dd3d79daa2d' }}
KOVA_HOME: ${{ github.workspace }}/.artifacts/kova/home/${{ matrix.lane }}
PERFORMANCE_HELPER_DIR: ${{ github.workspace }}/.artifacts/performance-workflow
REPORT_DIR: ${{ github.workspace }}/.artifacts/kova/reports/${{ matrix.lane }}
@@ -281,6 +286,13 @@ jobs:
git -C "$KOVA_SRC" remote add origin "https://github.com/${KOVA_REPOSITORY}.git"
git -C "$KOVA_SRC" fetch --filter=blob:none --depth 1 origin "$KOVA_REF"
git -C "$KOVA_SRC" checkout --detach FETCH_HEAD
npm --prefix "$KOVA_SRC" ci --ignore-scripts --no-audit --no-fund
node - "$KOVA_SRC" <<'NODE'
const root = process.argv[2];
for (const dependency of ["mock-ai-provider", "zod"]) {
require.resolve(dependency, { paths: [root] });
}
NODE
cat > "$HOME/.local/bin/kova" <<EOF
#!/usr/bin/env bash
export KOVA_HOME="${KOVA_HOME}"
@@ -740,7 +752,7 @@ jobs:
publish:
name: Publish ${{ matrix.title }} report
needs: [resolve_target, kova]
if: ${{ always() && needs.resolve_target.result == 'success' && needs.kova.result != 'cancelled' }}
if: ${{ always() && (github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.publish_reports == true)) && needs.resolve_target.result == 'success' && needs.kova.result != 'cancelled' }}
runs-on: ubuntu-24.04
timeout-minutes: 30
permissions:
@@ -1104,3 +1116,28 @@ jobs:
write_publish_failure "$attempt"
exit 1
done
artifact_only_guard:
name: Verify artifact-only report mode
needs: [resolve_target, kova, publish]
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:
PUBLISH_RESULT: ${{ needs.publish.result }}
shell: bash
run: |
set -euo pipefail
if [[ "$PUBLISH_RESULT" != "skipped" ]]; then
echo "::error::Artifact-only performance mode requires the report publisher to stay skipped; got ${PUBLISH_RESULT}."
exit 1
fi
{
echo "### Artifact-only report mode"
echo
echo "Clawgrit report publication stayed disabled; Kova evidence is available only as workflow artifacts."
} >> "$GITHUB_STEP_SUMMARY"
+105 -23
View File
@@ -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:-<missing>}." >&2
exit 1
fi
echo "source_sha=$source_sha" >> "$GITHUB_OUTPUT"
{
echo "## Release package artifact"
echo
echo "- Artifact: \`release-package-under-test\`"
echo "- Artifact: \`${{ steps.artifact.outputs.name }}\`"
echo "- Package: \`$package_label\`"
echo "- SHA-256: \`$digest\`"
echo "- Version: \`$version\`"
@@ -582,24 +597,65 @@ jobs:
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload release package artifact
id: release_package_upload
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: release-package-under-test
name: ${{ steps.artifact.outputs.name }}
path: |
.artifacts/docker-e2e-package/openclaw-current.tgz
.artifacts/docker-e2e-package/${{ steps.artifact.outputs.file_name }}
.artifacts/docker-e2e-package/package-candidate.json
retention-days: 14
if-no-files-found: error
- name: Validate release package artifact binding
env:
ARTIFACT_DIGEST: ${{ steps.release_package_upload.outputs.artifact-digest }}
ARTIFACT_ID: ${{ steps.release_package_upload.outputs.artifact-id }}
ARTIFACT_NAME: ${{ steps.artifact.outputs.name }}
ARTIFACT_RUN_ATTEMPT: ${{ steps.artifact.outputs.run_attempt }}
ARTIFACT_RUN_ID: ${{ steps.artifact.outputs.run_id }}
PACKAGE_FILE_NAME: ${{ steps.artifact.outputs.file_name }}
PACKAGE_SHA256: ${{ steps.package.outputs.sha256 }}
PACKAGE_SOURCE_SHA: ${{ steps.package.outputs.source_sha }}
PACKAGE_VERSION: ${{ steps.package.outputs.package_version }}
run: |
set -euo pipefail
[[ "$ARTIFACT_ID" =~ ^[1-9][0-9]*$ ]] || {
echo "Release package artifact ID is missing or invalid." >&2
exit 1
}
[[ "$ARTIFACT_DIGEST" =~ ^[a-f0-9]{64}$ ]] || {
echo "Release package artifact digest is missing or invalid." >&2
exit 1
}
[[ "$ARTIFACT_RUN_ID" == "$GITHUB_RUN_ID" &&
"$ARTIFACT_RUN_ATTEMPT" == "$GITHUB_RUN_ATTEMPT" ]] || {
echo "Release package artifact run binding is invalid." >&2
exit 1
}
[[ "$ARTIFACT_NAME" == "release-package-under-test-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" &&
"$PACKAGE_FILE_NAME" == "openclaw-current.tgz" ]] || {
echo "Release package artifact name or tarball filename is invalid." >&2
exit 1
}
[[ "$PACKAGE_SHA256" =~ ^[a-f0-9]{64}$ &&
"$PACKAGE_SOURCE_SHA" =~ ^[a-f0-9]{40}$ &&
-n "${PACKAGE_VERSION// }" ]] || {
echo "Release package identity is incomplete." >&2
exit 1
}
install_smoke_release_checks:
needs: [resolve_target]
if: contains(fromJSON('["all","install-smoke"]'), needs.resolve_target.outputs.rerun_group)
permissions:
actions: read
contents: read
packages: write
packages: read
uses: ./.github/workflows/install-smoke.yml
with:
ref: ${{ needs.resolve_target.outputs.revision }}
root_image_transport: no-push-artifact
run_bun_global_install_smoke: true
cross_os_release_checks:
@@ -613,8 +669,13 @@ jobs:
provider: ${{ needs.resolve_target.outputs.provider }}
mode: ${{ needs.resolve_target.outputs.mode }}
suite_filter: ${{ needs.resolve_target.outputs.cross_os_suite_filter }}
candidate_artifact_digest: ${{ needs.prepare_release_package.outputs.artifact_digest }}
candidate_artifact_id: ${{ needs.prepare_release_package.outputs.artifact_id }}
candidate_artifact_name: ${{ needs.prepare_release_package.outputs.artifact_name }}
candidate_file_name: openclaw-current.tgz
candidate_artifact_run_attempt: ${{ needs.prepare_release_package.outputs.artifact_run_attempt }}
candidate_artifact_run_id: ${{ needs.prepare_release_package.outputs.artifact_run_id }}
candidate_file_name: ${{ needs.prepare_release_package.outputs.package_file_name }}
candidate_sha256: ${{ needs.prepare_release_package.outputs.package_sha256 }}
candidate_version: ${{ needs.prepare_release_package.outputs.package_version }}
candidate_source_sha: ${{ needs.prepare_release_package.outputs.source_sha }}
openai_model: openai/gpt-5.5
@@ -636,7 +697,7 @@ jobs:
permissions:
actions: read
contents: read
packages: write
packages: read
pull-requests: read
uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml
with:
@@ -652,6 +713,8 @@ jobs:
include_live_suites: true
release_test_profile: ${{ needs.resolve_target.outputs.release_profile }}
live_suite_filter: ${{ needs.resolve_target.outputs.live_suite_filter }}
shared_image_artifact_namespace: release-live
shared_image_policy: no-push-artifact
secrets: &live_e2e_release_secrets
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
@@ -707,7 +770,7 @@ jobs:
permissions:
actions: read
contents: read
packages: write
packages: read
pull-requests: read
uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml
with:
@@ -718,8 +781,18 @@ jobs:
include_openwebui: ${{ needs.resolve_target.outputs.release_profile != 'beta' }}
include_live_suites: false
release_test_profile: ${{ needs.resolve_target.outputs.release_profile }}
package_artifact_digest: ${{ needs.prepare_release_package.outputs.artifact_digest }}
package_artifact_id: ${{ needs.prepare_release_package.outputs.artifact_id }}
package_artifact_name: ${{ needs.prepare_release_package.outputs.artifact_name }}
package_artifact_run_attempt: ${{ needs.prepare_release_package.outputs.artifact_run_attempt }}
package_artifact_run_id: ${{ needs.prepare_release_package.outputs.artifact_run_id }}
package_file_name: ${{ needs.prepare_release_package.outputs.package_file_name }}
package_sha256: ${{ needs.prepare_release_package.outputs.package_sha256 }}
package_source_sha: ${{ needs.prepare_release_package.outputs.source_sha }}
package_version: ${{ needs.prepare_release_package.outputs.package_version }}
codex_plugin_spec: ${{ needs.resolve_target.outputs.codex_plugin_spec }}
shared_image_artifact_namespace: release-docker
shared_image_policy: no-push-artifact
secrets: *live_e2e_release_secrets
package_acceptance_release_checks:
@@ -729,21 +802,30 @@ jobs:
permissions:
actions: read
contents: read
packages: write
packages: read
pull-requests: read
uses: ./.github/workflows/package-acceptance.yml
with:
advisory: false
workflow_ref: ${{ github.ref_name }}
workflow_ref: ${{ github.sha }}
source: ${{ (needs.resolve_target.outputs.package_acceptance_package_spec != '' || needs.resolve_target.outputs.release_package_spec != '') && 'npm' || 'artifact' }}
package_spec: ${{ needs.resolve_target.outputs.package_acceptance_package_spec || needs.resolve_target.outputs.release_package_spec || 'openclaw@beta' }}
artifact_digest: ${{ needs.prepare_release_package.outputs.artifact_digest }}
artifact_id: ${{ needs.prepare_release_package.outputs.artifact_id }}
artifact_name: ${{ needs.prepare_release_package.outputs.artifact_name }}
artifact_run_attempt: ${{ needs.prepare_release_package.outputs.artifact_run_attempt }}
artifact_run_id: ${{ needs.prepare_release_package.outputs.artifact_run_id }}
package_file_name: ${{ needs.prepare_release_package.outputs.package_file_name }}
package_sha256: ${{ (needs.resolve_target.outputs.package_acceptance_package_spec == '' && needs.resolve_target.outputs.release_package_spec == '') && needs.prepare_release_package.outputs.package_sha256 || '' }}
package_source_sha: ${{ needs.prepare_release_package.outputs.source_sha }}
package_version: ${{ needs.prepare_release_package.outputs.package_version }}
suite_profile: custom
docker_lanes: doctor-switch update-channel-switch skill-install update-corrupt-plugin upgrade-survivor published-upgrade-survivor root-managed-vps-upgrade update-restart-auth plugins-offline plugin-update plugin-binding-command-escape
published_upgrade_survivor_baselines: ${{ needs.resolve_target.outputs.run_release_soak == 'true' && 'last-stable-4 2026.4.23 2026.5.2 2026.4.15' || '' }}
published_upgrade_survivor_scenarios: ${{ needs.resolve_target.outputs.run_release_soak == 'true' && 'reported-issues' || '' }}
telegram_mode: mock-openai
shared_image_artifact_namespace: release-package
shared_image_policy: no-push-artifact
secrets:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
@@ -840,7 +922,7 @@ jobs:
- name: Checkout selected ref
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: true
persist-credentials: false
ref: ${{ needs.resolve_target.outputs.revision }}
fetch-depth: 1
@@ -959,7 +1041,7 @@ jobs:
- name: Checkout selected ref
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: true
persist-credentials: false
ref: ${{ needs.resolve_target.outputs.revision }}
fetch-depth: 1
@@ -1070,7 +1152,7 @@ jobs:
- name: Checkout selected ref
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: true
persist-credentials: false
ref: ${{ needs.resolve_target.outputs.revision }}
fetch-depth: 1
@@ -1234,7 +1316,7 @@ jobs:
- name: Checkout selected ref
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: true
persist-credentials: false
ref: ${{ needs.resolve_target.outputs.revision }}
fetch-depth: 1
@@ -1308,7 +1390,7 @@ jobs:
- name: Checkout selected ref
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: true
persist-credentials: false
ref: ${{ needs.resolve_target.outputs.revision }}
fetch-depth: 1
@@ -1432,7 +1514,7 @@ jobs:
- name: Checkout selected ref
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: true
persist-credentials: false
ref: ${{ needs.resolve_target.outputs.revision }}
fetch-depth: 1
@@ -1573,7 +1655,7 @@ jobs:
- name: Checkout selected ref
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: true
persist-credentials: false
ref: ${{ needs.resolve_target.outputs.revision }}
fetch-depth: 1
@@ -1698,7 +1780,7 @@ jobs:
- name: Checkout selected ref
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: true
persist-credentials: false
ref: ${{ needs.resolve_target.outputs.revision }}
fetch-depth: 1
@@ -1820,7 +1902,7 @@ jobs:
- name: Checkout selected ref
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: true
persist-credentials: false
ref: ${{ needs.resolve_target.outputs.revision }}
fetch-depth: 1
+301 -27
View File
@@ -35,7 +35,7 @@ on:
default: ""
type: string
package_sha256:
description: Expected package SHA-256; required for source=url or source=trusted-url
description: Expected package SHA-256; required for source=url, source=trusted-url, or source=artifact
required: false
default: ""
type: string
@@ -54,6 +54,50 @@ on:
required: false
default: package-under-test
type: string
artifact_id:
description: Immutable GitHub artifact id when source=artifact
required: false
default: ""
type: string
artifact_digest:
description: GitHub artifact service SHA-256 digest without the sha256 prefix
required: false
default: ""
type: string
artifact_run_attempt:
description: Producer run attempt when source=artifact
required: false
default: ""
type: string
package_file_name:
description: Exact package tarball filename when source=artifact
required: false
default: ""
type: string
package_source_sha:
description: Exact source commit recorded in the package when source=artifact
required: false
default: ""
type: string
package_version:
description: Exact package version when source=artifact
required: false
default: ""
type: string
shared_image_policy:
description: Shared Docker image transport for package acceptance
required: true
default: allow-push
type: choice
options:
- allow-push
- existing-only
- no-push-artifact
shared_image_artifact_namespace:
description: Unique artifact namespace when shared_image_policy=no-push-artifact
required: false
default: package-acceptance
type: string
suite_profile:
description: Acceptance profile
required: true
@@ -136,7 +180,7 @@ on:
default: ""
type: string
package_sha256:
description: Expected package SHA-256; required for source=url or source=trusted-url
description: Expected package SHA-256; required for source=url, source=trusted-url, or source=artifact
required: false
default: ""
type: string
@@ -155,6 +199,46 @@ on:
required: false
default: package-under-test
type: string
artifact_id:
description: Immutable GitHub artifact id when source=artifact
required: false
default: ""
type: string
artifact_digest:
description: GitHub artifact service SHA-256 digest without the sha256 prefix
required: false
default: ""
type: string
artifact_run_attempt:
description: Producer run attempt when source=artifact
required: false
default: ""
type: string
package_file_name:
description: Exact package tarball filename when source=artifact
required: false
default: ""
type: string
package_source_sha:
description: Exact source commit recorded in the package when source=artifact
required: false
default: ""
type: string
package_version:
description: Exact package version when source=artifact
required: false
default: ""
type: string
shared_image_policy:
description: "Shared Docker image transport: allow-push, existing-only, or no-push-artifact"
required: false
default: allow-push
type: string
shared_image_artifact_namespace:
description: Unique artifact namespace when shared_image_policy=no-push-artifact
required: false
default: package-acceptance
type: string
suite_profile:
description: "Acceptance profile: smoke, package, product, full, or custom"
required: false
@@ -190,6 +274,31 @@ on:
required: false
default: ""
type: string
outputs:
package_artifact_digest:
description: GitHub artifact service digest for the canonical package
value: ${{ jobs.resolve_package.outputs.package_artifact_digest }}
package_artifact_id:
description: Immutable GitHub artifact id for the canonical package
value: ${{ jobs.resolve_package.outputs.package_artifact_id }}
package_artifact_run_attempt:
description: Producer run attempt for the canonical package artifact
value: ${{ jobs.resolve_package.outputs.package_artifact_run_attempt }}
package_artifact_run_id:
description: Producer run id for the canonical package artifact
value: ${{ jobs.resolve_package.outputs.package_artifact_run_id }}
package_file_name:
description: Canonical package tarball filename
value: ${{ jobs.resolve_package.outputs.package_file_name }}
package_source_sha:
description: Source commit recorded in the canonical package
value: ${{ jobs.resolve_package.outputs.package_source_sha }}
package_sha256:
description: Canonical OpenClaw package SHA-256
value: ${{ jobs.resolve_package.outputs.package_sha256 }}
package_version:
description: Canonical OpenClaw package version
value: ${{ jobs.resolve_package.outputs.package_version }}
secrets:
OPENCLAW_TRUSTED_PACKAGE_TOKEN:
required: false
@@ -293,7 +402,7 @@ on:
permissions:
actions: read
contents: read
packages: write
packages: read
pull-requests: read
concurrency:
@@ -303,7 +412,7 @@ concurrency:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
NODE_VERSION: "24.15.0"
PACKAGE_ARTIFACT_NAME: package-under-test
PACKAGE_ARTIFACT_NAME: package-under-test-${{ github.run_id }}-${{ github.run_attempt }}
jobs:
resolve_package:
@@ -316,6 +425,11 @@ jobs:
include_openwebui: ${{ steps.profile.outputs.include_openwebui }}
include_release_path_suites: ${{ steps.profile.outputs.include_release_path_suites }}
package_artifact_name: ${{ steps.profile.outputs.package_artifact_name }}
package_artifact_digest: ${{ steps.upload_package.outputs.artifact-digest }}
package_artifact_id: ${{ steps.upload_package.outputs.artifact-id }}
package_artifact_run_attempt: ${{ github.run_attempt }}
package_artifact_run_id: ${{ github.run_id }}
package_file_name: ${{ steps.resolve.outputs.package_file_name }}
package_source_sha: ${{ steps.resolve.outputs.package_source_sha }}
package_sha256: ${{ steps.resolve.outputs.sha256 }}
package_version: ${{ steps.resolve.outputs.package_version }}
@@ -329,6 +443,7 @@ jobs:
with:
ref: ${{ inputs.workflow_ref }}
fetch-depth: 0
persist-credentials: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
@@ -337,28 +452,81 @@ jobs:
install-bun: ${{ inputs.source == 'ref' && 'true' || 'false' }}
install-deps: "false"
- name: Download current-run package artifact input
if: inputs.source == 'artifact' && inputs.artifact_run_id == ''
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: ${{ inputs.artifact_name }}
path: .artifacts/package-candidate-input
- name: Download previous-run package artifact input
if: inputs.source == 'artifact' && inputs.artifact_run_id != ''
- name: Validate package artifact input identity
id: input_artifact
if: inputs.source == 'artifact'
env:
ARTIFACT_DIGEST: ${{ inputs.artifact_digest }}
ARTIFACT_ID: ${{ inputs.artifact_id }}
GH_TOKEN: ${{ github.token }}
ARTIFACT_RUN_ID: ${{ inputs.artifact_run_id }}
ARTIFACT_NAME: ${{ inputs.artifact_name }}
ARTIFACT_RUN_ATTEMPT: ${{ inputs.artifact_run_attempt }}
ARTIFACT_RUN_ID: ${{ inputs.artifact_run_id }}
EXPECTED_PACKAGE_SHA256: ${{ inputs.package_sha256 }}
EXPECTED_PACKAGE_FILE_NAME: ${{ inputs.package_file_name }}
EXPECTED_PACKAGE_SOURCE_SHA: ${{ inputs.package_source_sha }}
EXPECTED_PACKAGE_VERSION: ${{ inputs.package_version }}
shell: bash
run: |
set -euo pipefail
if [[ -z "${ARTIFACT_NAME// }" ]]; then
echo "artifact_name is required when source=artifact." >&2
if [[ ! "$ARTIFACT_DIGEST" =~ ^[0-9a-f]{64}$ ||
! "$ARTIFACT_ID" =~ ^[1-9][0-9]*$ ||
-z "${ARTIFACT_NAME// }" ||
! "$ARTIFACT_RUN_ATTEMPT" =~ ^[1-9][0-9]*$ ||
! "$ARTIFACT_RUN_ID" =~ ^[1-9][0-9]*$ ||
! "$EXPECTED_PACKAGE_FILE_NAME" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*\.tgz$ ||
! "$EXPECTED_PACKAGE_SHA256" =~ ^[0-9a-f]{64}$ ||
! "$EXPECTED_PACKAGE_SOURCE_SHA" =~ ^[0-9a-f]{40}$ ||
-z "${EXPECTED_PACKAGE_VERSION// }" ]]; then
echo "source=artifact requires the complete immutable artifact and package identity tuple." >&2
exit 1
fi
mkdir -p .artifacts/package-candidate-input
gh run download "$ARTIFACT_RUN_ID" -n "$ARTIFACT_NAME" -D .artifacts/package-candidate-input
[[ "$ARTIFACT_NAME" == *"-${ARTIFACT_RUN_ID}-${ARTIFACT_RUN_ATTEMPT}" ]] || {
echo "Package artifact name does not bind the declared producer run attempt." >&2
exit 1
}
artifact_json="$(gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}")"
jq -e \
--arg digest "sha256:${ARTIFACT_DIGEST}" \
--arg id "$ARTIFACT_ID" \
--arg name "$ARTIFACT_NAME" \
--arg run_id "$ARTIFACT_RUN_ID" \
'
(.id | tostring) == $id and
.name == $name and
.expired == false and
.digest == $digest and
(.workflow_run.id | tostring) == $run_id
' <<< "$artifact_json" >/dev/null || {
echo "Package artifact identity does not match the requested immutable tuple." >&2
exit 1
}
attempt_json="$(
gh api \
"repos/${GITHUB_REPOSITORY}/actions/runs/${ARTIFACT_RUN_ID}/attempts/${ARTIFACT_RUN_ATTEMPT}"
)"
jq -e \
--arg attempt "$ARTIFACT_RUN_ATTEMPT" \
--arg run_id "$ARTIFACT_RUN_ID" \
'(.id | tostring) == $run_id and (.run_attempt | tostring) == $attempt' \
<<< "$attempt_json" >/dev/null || {
echo "Package artifact producer run attempt does not match the requested tuple." >&2
exit 1
}
{
echo "artifact_digest=$ARTIFACT_DIGEST"
echo "run_attempt=$ARTIFACT_RUN_ATTEMPT"
echo "run_id=$ARTIFACT_RUN_ID"
} >> "$GITHUB_OUTPUT"
- name: Download package artifact input
if: inputs.source == 'artifact'
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
artifact-ids: ${{ inputs.artifact_id }}
path: .artifacts/package-candidate-input
run-id: ${{ inputs.artifact_run_id }}
github-token: ${{ github.token }}
- name: Resolve package candidate
id: resolve
@@ -368,6 +536,9 @@ jobs:
PACKAGE_SPEC: ${{ inputs.package_spec }}
PACKAGE_URL: ${{ inputs.package_url }}
PACKAGE_SHA256: ${{ inputs.package_sha256 }}
PACKAGE_FILE_NAME: ${{ inputs.package_file_name }}
PACKAGE_SOURCE_SHA: ${{ inputs.package_source_sha }}
PACKAGE_VERSION: ${{ inputs.package_version }}
TRUSTED_SOURCE_ID: ${{ inputs.trusted_source_id }}
OPENCLAW_TRUSTED_PACKAGE_TOKEN: ${{ secrets.OPENCLAW_TRUSTED_PACKAGE_TOKEN }}
shell: bash
@@ -376,6 +547,16 @@ jobs:
artifact_dir=""
if [[ "$SOURCE" == "artifact" ]]; then
artifact_dir=".artifacts/package-candidate-input"
artifact_tarball="${artifact_dir}/${PACKAGE_FILE_NAME}"
[[ -f "$artifact_tarball" ]] || {
echo "Declared package tarball is missing from the selected artifact." >&2
exit 1
}
artifact_sha256="$(sha256sum "$artifact_tarball" | awk '{print $1}')"
[[ "$artifact_sha256" == "$PACKAGE_SHA256" ]] || {
echo "Selected artifact package SHA-256 differs from package_sha256." >&2
exit 1
}
fi
node scripts/resolve-openclaw-package-candidate.mjs \
@@ -390,6 +571,22 @@ jobs:
--output-name openclaw-current.tgz \
--metadata .artifacts/docker-e2e-package/package-candidate.json \
--github-output "$GITHUB_OUTPUT"
echo "package_file_name=openclaw-current.tgz" >> "$GITHUB_OUTPUT"
if [[ "$SOURCE" == "artifact" ]]; then
jq -e \
--arg digest "$PACKAGE_SHA256" \
--arg source_sha "$PACKAGE_SOURCE_SHA" \
--arg version "$PACKAGE_VERSION" \
'
.name == "openclaw" and
.sha256 == $digest and
.packageSourceSha == $source_sha and
.version == $version
' .artifacts/docker-e2e-package/package-candidate.json >/dev/null || {
echo "Resolved package identity differs from the declared immutable tuple." >&2
exit 1
}
fi
- name: Select acceptance profile
id: profile
@@ -492,6 +689,7 @@ jobs:
node scripts/resolve-upgrade-survivor-baselines.mjs "${args[@]}" >/dev/null
- name: Upload package-under-test artifact
id: upload_package
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ${{ env.PACKAGE_ARTIFACT_NAME }}
@@ -503,6 +701,12 @@ jobs:
- name: Summarize package candidate
env:
INPUT_ARTIFACT_DIGEST: ${{ steps.input_artifact.outputs.artifact_digest }}
INPUT_ARTIFACT_ID: ${{ inputs.artifact_id }}
INPUT_ARTIFACT_RUN_ATTEMPT: ${{ steps.input_artifact.outputs.run_attempt }}
INPUT_ARTIFACT_RUN_ID: ${{ steps.input_artifact.outputs.run_id }}
OUTPUT_ARTIFACT_DIGEST: ${{ steps.upload_package.outputs.artifact-digest }}
OUTPUT_ARTIFACT_ID: ${{ steps.upload_package.outputs.artifact-id }}
PACKAGE_SHA256: ${{ steps.resolve.outputs.sha256 }}
PACKAGE_VERSION: ${{ steps.resolve.outputs.package_version }}
PACKAGE_REF: ${{ inputs.package_ref }}
@@ -528,6 +732,14 @@ jobs:
fi
echo "- Version: \`${PACKAGE_VERSION}\`"
echo "- SHA-256: \`${PACKAGE_SHA256}\`"
echo "- Artifact id: \`${OUTPUT_ARTIFACT_ID}\`"
echo "- Artifact digest: \`${OUTPUT_ARTIFACT_DIGEST}\`"
echo "- Artifact producer: run \`${GITHUB_RUN_ID}\`, attempt \`${GITHUB_RUN_ATTEMPT}\`"
if [[ "$SOURCE" == "artifact" ]]; then
echo "- Input artifact id: \`${INPUT_ARTIFACT_ID}\`"
echo "- Input artifact digest: \`${INPUT_ARTIFACT_DIGEST}\`"
echo "- Input artifact producer: run \`${INPUT_ARTIFACT_RUN_ID}\`, attempt \`${INPUT_ARTIFACT_RUN_ATTEMPT}\`"
fi
echo "- Profile: \`${SUITE_PROFILE}\`"
echo "- Published upgrade survivor baseline: \`${PUBLISHED_UPGRADE_SURVIVOR_BASELINE}\`"
echo "- Published upgrade survivor baselines: \`${PUBLISHED_UPGRADE_SURVIVOR_BASELINES}\`"
@@ -545,31 +757,42 @@ jobs:
with:
ref: ${{ inputs.workflow_ref }}
fetch-depth: 1
persist-credentials: false
- name: Download package-under-test artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: ${{ needs.resolve_package.outputs.package_artifact_name }}
artifact-ids: ${{ needs.resolve_package.outputs.package_artifact_id }}
path: .artifacts/docker-e2e-package
run-id: ${{ needs.resolve_package.outputs.package_artifact_run_id }}
github-token: ${{ github.token }}
- name: Enforce public package integrity
env:
EXPECTED_PACKAGE_SHA256: ${{ needs.resolve_package.outputs.package_sha256 }}
OPENCLAW_PACKAGE_TARBALL_CHECK_TIMINGS: "0"
shell: bash
run: |
set -euo pipefail
node scripts/check-openclaw-package-tarball.mjs .artifacts/docker-e2e-package/openclaw-current.tgz
package=".artifacts/docker-e2e-package/openclaw-current.tgz"
actual_sha256="$(sha256sum "$package" | awk '{print $1}')"
[[ "$actual_sha256" == "$EXPECTED_PACKAGE_SHA256" ]] || {
echo "Canonical package artifact SHA-256 differs from the resolver output." >&2
exit 1
}
node scripts/check-openclaw-package-tarball.mjs "$package"
docker_acceptance:
name: Docker product acceptance
name: Docker product acceptance (artifact-only)
needs: [resolve_package, package_integrity]
if: inputs.shared_image_policy == 'no-push-artifact'
permissions:
actions: read
contents: read
packages: write
packages: read
pull-requests: read
uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml
with:
with: &docker_acceptance_inputs
advisory: ${{ inputs.advisory }}
ref: ${{ needs.resolve_package.outputs.package_source_sha || inputs.workflow_ref }}
include_repo_e2e: false
@@ -580,9 +803,19 @@ jobs:
published_upgrade_survivor_baselines: ${{ needs.resolve_package.outputs.published_upgrade_survivor_baselines }}
published_upgrade_survivor_scenarios: ${{ needs.resolve_package.outputs.published_upgrade_survivor_scenarios }}
package_artifact_name: ${{ needs.resolve_package.outputs.package_artifact_name }}
package_artifact_digest: ${{ needs.resolve_package.outputs.package_artifact_digest }}
package_artifact_id: ${{ needs.resolve_package.outputs.package_artifact_id }}
package_artifact_run_attempt: ${{ needs.resolve_package.outputs.package_artifact_run_attempt }}
package_artifact_run_id: ${{ needs.resolve_package.outputs.package_artifact_run_id }}
package_file_name: ${{ needs.resolve_package.outputs.package_file_name }}
package_sha256: ${{ needs.resolve_package.outputs.package_sha256 }}
package_source_sha: ${{ needs.resolve_package.outputs.package_source_sha }}
package_version: ${{ needs.resolve_package.outputs.package_version }}
include_live_suites: ${{ needs.resolve_package.outputs.include_live_suites == 'true' }}
live_models_only: false
secrets:
shared_image_artifact_namespace: ${{ inputs.shared_image_artifact_namespace }}
shared_image_policy: ${{ inputs.shared_image_policy }}
secrets: &docker_acceptance_secrets
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
@@ -630,6 +863,19 @@ jobs:
OPENCLAW_GEMINI_SETTINGS_JSON: ${{ secrets.OPENCLAW_GEMINI_SETTINGS_JSON }}
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
docker_acceptance_registry:
name: Docker product acceptance (registry)
needs: [resolve_package, package_integrity]
if: inputs.shared_image_policy != 'no-push-artifact'
permissions:
actions: read
contents: read
packages: write
pull-requests: read
uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml
with: *docker_acceptance_inputs
secrets: *docker_acceptance_secrets
package_telegram:
name: Telegram package acceptance
needs: [resolve_package, package_integrity]
@@ -639,6 +885,14 @@ jobs:
advisory: ${{ inputs.advisory }}
package_spec: ${{ inputs.package_spec }}
package_artifact_name: ${{ needs.resolve_package.outputs.package_artifact_name }}
package_artifact_digest: ${{ needs.resolve_package.outputs.package_artifact_digest }}
package_artifact_id: ${{ needs.resolve_package.outputs.package_artifact_id }}
package_artifact_run_attempt: ${{ needs.resolve_package.outputs.package_artifact_run_attempt }}
package_artifact_run_id: ${{ needs.resolve_package.outputs.package_artifact_run_id }}
package_file_name: ${{ needs.resolve_package.outputs.package_file_name }}
package_sha256: ${{ needs.resolve_package.outputs.package_sha256 }}
package_source_sha: ${{ needs.resolve_package.outputs.package_source_sha }}
package_version: ${{ needs.resolve_package.outputs.package_version }}
package_label: openclaw@${{ needs.resolve_package.outputs.package_version }}
harness_ref: ${{ needs.resolve_package.outputs.package_source_sha || inputs.workflow_ref }}
provider_mode: ${{ needs.resolve_package.outputs.telegram_mode }}
@@ -650,7 +904,14 @@ jobs:
summary:
name: Verify package acceptance
needs: [resolve_package, package_integrity, docker_acceptance, package_telegram]
needs:
[
resolve_package,
package_integrity,
docker_acceptance,
docker_acceptance_registry,
package_telegram,
]
if: always()
runs-on: ubuntu-24.04
timeout-minutes: 5
@@ -658,7 +919,8 @@ jobs:
- name: Verify package acceptance results
env:
ADVISORY: ${{ inputs.advisory }}
DOCKER_RESULT: ${{ needs.docker_acceptance.result }}
DOCKER_ARTIFACT_RESULT: ${{ needs.docker_acceptance.result }}
DOCKER_REGISTRY_RESULT: ${{ needs.docker_acceptance_registry.result }}
PACKAGE_INTEGRITY_RESULT: ${{ needs.package_integrity.result }}
PACKAGE_TELEGRAM_RESULT: ${{ needs.package_telegram.result }}
RESOLVE_RESULT: ${{ needs.resolve_package.result }}
@@ -666,11 +928,23 @@ jobs:
shell: bash
run: |
set -euo pipefail
docker_result="$DOCKER_ARTIFACT_RESULT"
if [[ "$docker_result" == "skipped" ]]; then
docker_result="$DOCKER_REGISTRY_RESULT"
fi
if [[ "$DOCKER_ARTIFACT_RESULT" != "skipped" && "$DOCKER_REGISTRY_RESULT" != "skipped" ]]; then
echo "::error::Both Docker acceptance transports ran; expected exactly one."
exit 1
fi
if [[ "$DOCKER_ARTIFACT_RESULT" == "skipped" && "$DOCKER_REGISTRY_RESULT" == "skipped" ]]; then
echo "::error::No Docker acceptance transport ran; expected exactly one."
exit 1
fi
failed=0
for item in \
"resolve_package=${RESOLVE_RESULT}" \
"package_integrity=${PACKAGE_INTEGRITY_RESULT}" \
"docker_acceptance=${DOCKER_RESULT}" \
"docker_acceptance=${docker_result}" \
"package_telegram=${PACKAGE_TELEGRAM_RESULT}"
do
name="${item%%=*}"
+8 -6
View File
@@ -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:
+17 -4
View File
@@ -272,13 +272,13 @@ A legacy fallback correction tag may reuse base-package evidence only when the c
## Release test boxes
`Full Release Validation` is how operators kick off all pre-release tests from one entrypoint. For a pinned commit proof on a fast-moving branch, use the helper so every child workflow runs from a temporary branch fixed at the target SHA:
`Full Release Validation` is how operators kick off all pre-release tests from one entrypoint. For a pinned commit proof on a fast-moving branch, use the helper so every child workflow runs from a temporary branch fixed at one trusted `main` workflow SHA while the requested commit remains the candidate under test:
```bash
pnpm ci:full-release --sha <full-sha>
```
The helper pushes `release-ci/<sha>-...`, dispatches `Full Release Validation` from that branch with `ref=<sha>`, 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/<workflow-sha>-...` at that trusted workflow commit, dispatches `Full Release Validation` from the temporary branch with `ref=<target-sha>` and `reuse_evidence=false`, verifies every child workflow `headSha` matches the pinned parent workflow SHA, then deletes the temporary branch. Pass `--workflow-sha <trusted-main-sha>` to pin an older commit that is still reachable from current `origin/main`. The workflow itself never writes repository refs. This keeps main-only release tooling available without adding tooling commits to the candidate and avoids proving a newer `main` child run by accident.
For release branch or tag validation, run it from the trusted `main` workflow ref and pass the release branch or tag as `ref`:
@@ -294,9 +294,14 @@ gh workflow run full-release-validation.yml \
The workflow resolves the target ref, dispatches manual `CI` with `target_ref=<release-ref>`, then dispatches `OpenClaw Release Checks`. `OpenClaw Release Checks` fans out install smoke, cross-OS release checks, live/E2E Docker release-path coverage when soak is enabled, Package Acceptance with the canonical Telegram package E2E, QA Lab parity, live Matrix, and live Telegram. A full/all run is only acceptable when the `Full Release Validation` summary shows `normal_ci`, `plugin_prerelease`, and `release_checks` as successful, unless a focused rerun intentionally skipped the separate `Plugin Prerelease` child. Use the standalone `npm-telegram` child only for a focused published-package rerun with `release_package_spec` or `npm_telegram_package_spec`. The final verifier summary includes slowest-job tables for each child run, so the release manager can see the current critical path without downloading logs.
The product-performance child is artifact-only in this release path. The
umbrella dispatches it with `publish_reports=false`, and validation is rejected
unless its artifact-only guard proves that the Clawgrit report publisher stayed
skipped.
See [Full release validation](/reference/full-release-validation) for the complete stage matrix, exact workflow job names, stable versus full profile differences, artifacts, and focused rerun handles.
Child workflows are dispatched from the trusted ref that runs `Full Release Validation`, normally `--ref main`, even when the target `ref` points at an older release branch or tag. There is no separate Full Release Validation workflow-ref input; choose the trusted harness by choosing the workflow run ref. Do not use `--ref main -f ref=<sha>` for exact commit proof on moving `main`; raw commit SHAs cannot be workflow dispatch refs, so use `pnpm ci:full-release --sha <sha>` to create the pinned temporary branch.
Child workflows are dispatched from the trusted ref that runs `Full Release Validation`, normally `--ref main`, even when the target `ref` points at an older release branch or tag. Every child run must use the exact parent workflow SHA; if `main` advances before a child dispatch resolves, the umbrella fails closed. There is no separate Full Release Validation workflow-ref input; choose the trusted harness by choosing the workflow run ref. Do not use `--ref main -f ref=<sha>` for exact commit proof on moving `main`; raw commit SHAs cannot be workflow dispatch refs, so use `pnpm ci:full-release --sha <target-sha>` to create a temporary branch at trusted `origin/main` while keeping the target SHA as the candidate input.
Use `release_profile` to select live/provider breadth:
@@ -342,7 +347,15 @@ gh workflow run full-release-validation.yml \
Do not use the full umbrella as the first rerun after a focused fix. If one box fails, use the failed child workflow, job, Docker lane, package profile, model provider, or QA lane for the next proof. Run the full umbrella again only when the fix changed shared release orchestration or made earlier all-box evidence stale. The umbrella's final verifier re-checks the recorded child workflow run ids, so after a child workflow is rerun successfully, rerun only the failed `Verify full validation` parent job.
Release-metadata-only commits (changelog refreshes, version stamps) do not need a new full validation: `rerun_group=all` first checks for a prior green validation whose target differs only by release metadata paths and reuses that evidence, skipping every lane. Newer umbrella runs for the same `release/*` ref and rerun group supersede in-progress ones automatically. Pass `reuse_evidence=false` to force a fresh full run.
`rerun_group=all` may reuse a prior green umbrella run only when it validated
the exact same target SHA, release profile, effective soak setting, and
validation inputs. This is bounded recovery for rerunning the same candidate,
not cross-SHA evidence reuse. For a changed candidate, including a changelog or
version-only commit, rerun every package, artifact, install, Docker, or provider
gate affected by the changed paths or artifact hashes. Newer umbrella runs for
the same `release/*`
ref and rerun group supersede in-progress ones automatically. Pass
`reuse_evidence=false` to force a fresh full run.
For bounded recovery, pass `rerun_group` to the umbrella. `all` is the real release-candidate run, `ci` runs only the normal CI child, `plugin-prerelease` runs only the release-only plugin child, `release-checks` runs every release box, and the narrower release groups are `install-smoke`, `cross-os`, `live-e2e`, `package`, `qa`, `qa-parity`, `qa-live`, and `npm-telegram`. Focused `npm-telegram` reruns require `release_package_spec` or `npm_telegram_package_spec`; full/all runs use the canonical package Telegram E2E inside Package Acceptance. Focused cross-OS reruns can add `cross_os_suite_filter=windows/packaged-upgrade` or another OS/suite filter. QA release-check failures block normal release validation, including required OpenClaw dynamic tool drift in the standard tier. Tideclaw alpha runs may still treat non-package-safety release-check lanes as advisory. With `release_profile=beta`, the `Run repo/live E2E validation` live-provider suites are advisory (warnings, not blockers); stable and full profiles keep them blocking. When `live_suite_filter` explicitly requests a gated QA live lane such as Discord, WhatsApp, or Slack, the matching `OPENCLAW_RELEASE_QA_*_LIVE_CI_ENABLED` repo variable must be enabled; otherwise input capture fails instead of silently skipping the lane.
+53 -19
View File
@@ -24,9 +24,21 @@ gh workflow run full-release-validation.yml \
```
`provider` also accepts `anthropic` or `minimax` for cross-OS onboarding and the
end-to-end agent turn. Child workflows use the trusted workflow ref for the
harness and the input `ref` for the candidate under test, so new validation
logic stays available when validating an older release branch or tag.
end-to-end agent turn. Reusable child jobs resolve the called workflow harness
from `job.workflow_repository` and `job.workflow_sha`, while the input `ref`
selects the candidate under test. This keeps current trusted validation logic
available when validating an older release branch or tag.
Every dispatched child must report the same workflow SHA as the parent
`Full Release Validation` run. If `main` moves between the parent and child
dispatches, the umbrella fails closed even when the child itself succeeds. For
an immutable exact-commit proof, use
`pnpm ci:full-release --sha <target-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 <trusted-main-sha>` to select an older
workflow commit still reachable from current `origin/main`. The workflow never
creates or updates repository refs itself.
`release_profile=stable` and `release_profile=full` always run the exhaustive
live/Docker soak. Pass `run_release_soak=true` to include the same soak lanes
@@ -49,12 +61,15 @@ that plugin, then runs Codex CLI preflight and same-session OpenAI agent turns.
## Top-level stages
For `rerun_group=all`, a `Check for reusable validation evidence` job runs
first: it looks for the newest prior green full validation whose target differs
from the current target only by release metadata paths (changelog, version
stamps; see `RELEASE_METADATA_PATHS` in `scripts/changed-lanes.mjs`). When such
evidence exists, every lane is skipped and the umbrella verifier re-checks the
evidence run instead, so changelog-only commits do not re-drive hours of
validation. Pass `reuse_evidence=false` to force a fresh full run.
first: it looks for the newest prior green full validation for the exact same
target SHA, release profile, effective soak setting, and validation inputs.
When such evidence exists, every lane is skipped and the umbrella verifier
re-checks the immutable parent artifact, child runs, and dispatch logs. This is
same-candidate rerun recovery only; it does not authorize cross-SHA reuse. For
a changed candidate, rerun every package, artifact, install, Docker, or provider
gate affected by that delta. Pass `reuse_evidence=false` to force a fresh full
run. Evidence reuse runs only when the umbrella itself was dispatched from
`main`; non-main workflow refs run the selected lanes fresh.
Also for `rerun_group=all`, a `Verify Docker runtime image assets` job builds
the `runtime-assets` Docker target with
@@ -62,16 +77,35 @@ the `runtime-assets` Docker target with
other stages and is enforced by the umbrella verifier; lanes no longer wait for
it before dispatching. A narrower `rerun_group` skips this preflight.
| Stage | Details |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Target resolution | **Job:** `Resolve target ref`<br />**Child workflow:** none<br />**Proves:** resolves the release branch, tag, or full commit SHA and records selected inputs.<br />**Rerun:** rerun the umbrella if this fails. |
| Docker assets preflight | **Job:** `Verify Docker runtime image assets`<br />**Child workflow:** none<br />**Proves:** the `runtime-assets` Docker build target still succeeds before any other stage dispatches. Runs only for `rerun_group=all`.<br />**Rerun:** rerun the umbrella with `rerun_group=all`. |
| Vitest and normal CI | **Job:** `Run normal full CI`<br />**Child workflow:** `CI`<br />**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.<br />**Rerun:** `rerun_group=ci`. |
| Plugin prerelease | **Job:** `Run plugin prerelease validation`<br />**Child workflow:** `Plugin Prerelease`<br />**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.<br />**Rerun:** `rerun_group=plugin-prerelease`. |
| Release checks | **Job:** `Run release/live/Docker/QA validation`<br />**Child workflow:** `OpenClaw Release Checks`<br />**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`.<br />**Rerun:** `rerun_group=release-checks` or a narrower release-checks handle. |
| Package Telegram | **Job:** `Run package Telegram E2E`<br />**Child workflow:** `NPM Telegram Beta E2E`<br />**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.<br />**Rerun:** `rerun_group=npm-telegram` with `release_package_spec` or `npm_telegram_package_spec`. |
| Product performance | **Job:** `Run product performance evidence`<br />**Child workflow:** `OpenClaw Performance`<br />**Proves:** release-profile performance run (`profile=release`, `repeat=3`, `fail_on_regression=true`) against the target SHA. Required (blocking) only for `rerun_group=all` or `rerun_group=performance`; not required for narrower rerun groups.<br />**Rerun:** `rerun_group=performance`. |
| Umbrella verifier | **Job:** `Verify full validation`<br />**Child workflow:** none<br />**Proves:** re-checks recorded child run conclusions and appends slowest-job tables from child workflows.<br />**Rerun:** rerun only this job after rerunning a failed child to green. |
| Stage | Details |
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Target resolution | **Job:** `Resolve target ref`<br />**Child workflow:** none<br />**Proves:** resolves the release branch, tag, or full commit SHA and records selected inputs.<br />**Rerun:** rerun the umbrella if this fails. |
| Docker assets preflight | **Job:** `Verify Docker runtime image assets`<br />**Child workflow:** none<br />**Proves:** the `runtime-assets` Docker build target still succeeds before any other stage dispatches. Runs only for `rerun_group=all`.<br />**Rerun:** rerun the umbrella with `rerun_group=all`. |
| Vitest and normal CI | **Job:** `Run normal full CI`<br />**Child workflow:** `CI`<br />**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.<br />**Rerun:** `rerun_group=ci`. |
| Plugin prerelease | **Job:** `Run plugin prerelease validation`<br />**Child workflow:** `Plugin Prerelease`<br />**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.<br />**Rerun:** `rerun_group=plugin-prerelease`. |
| Release checks | **Job:** `Run release/live/Docker/QA validation`<br />**Child workflow:** `OpenClaw Release Checks`<br />**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`.<br />**Rerun:** `rerun_group=release-checks` or a narrower release-checks handle. |
| Package Telegram | **Job:** `Run package Telegram E2E`<br />**Child workflow:** `NPM Telegram Beta E2E`<br />**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.<br />**Rerun:** `rerun_group=npm-telegram` with `release_package_spec` or `npm_telegram_package_spec`. |
| Product performance | **Job:** `Run product performance evidence`<br />**Child workflow:** `OpenClaw Performance`<br />**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.<br />**Rerun:** `rerun_group=performance`. |
| Umbrella verifier | **Job:** `Verify full validation`<br />**Child workflow:** none<br />**Proves:** re-checks recorded child run conclusions and appends slowest-job tables from child workflows.<br />**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-<run-id>-<run-attempt>`. 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
+381
View File
@@ -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 <pack|load> <artifact-dir> <kind> <target-sha> <workflow-sha> <image-ref>..."
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 <label> <artifact-id> <artifact-name> <artifact-digest> <run-id> <run-attempt>"
fi
local artifact_label="$1"
local artifact_id="$2"
local artifact_name="$3"
local artifact_digest="$4"
local artifact_run_id="$5"
local artifact_run_attempt="$6"
require_positive_decimal "$artifact_label artifact ID" "$artifact_id"
require_positive_decimal "$artifact_label producer run ID" "$artifact_run_id"
require_positive_decimal "$artifact_label producer run attempt" "$artifact_run_attempt"
if [[ ! "$artifact_digest" =~ ^[a-f0-9]{64}$ ]]; then
fail "$artifact_label artifact digest must be a lowercase SHA-256."
fi
if [[ -z "${artifact_name// }" || "$artifact_name" == *$'\n'* || "$artifact_name" == *$'\r'* ]]; then
fail "$artifact_label artifact name is missing or invalid."
fi
if [[ "$artifact_name" != *"-${artifact_run_id}-${artifact_run_attempt}" ]]; then
fail "$artifact_label artifact name does not bind the producer run attempt."
fi
if [[ ! "${GITHUB_REPOSITORY:-}" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then
fail "GITHUB_REPOSITORY is missing or invalid."
fi
if [[ -z "${GH_TOKEN:-}" ]]; then
fail "GH_TOKEN is required to verify the uploaded artifact."
fi
command -v gh >/dev/null
command -v jq >/dev/null
local artifact_json attempt_json
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 ||
fail "$artifact_label artifact identity does not match the immutable producer tuple."
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 ||
fail "$artifact_label producer run attempt does not match the immutable tuple."
}
require_common_inputs() {
require_sha "target SHA" "$target_sha"
require_sha "workflow SHA" "$workflow_sha"
if [[ -n "$shared_package_sha256" && ! "$shared_package_sha256" =~ ^[a-f0-9]{64}$ ]]; then
fail "shared package SHA-256 must be a lowercase digest."
fi
if [[ ! "$artifact_kind" =~ ^[a-z0-9][a-z0-9-]*$ ]]; then
fail "artifact kind must contain only lowercase letters, digits, and hyphens."
fi
if [[ "${#image_refs[@]}" -eq 0 ]]; then
fail "at least one image ref is required."
fi
local image_ref
declare -A seen_refs=()
for image_ref in "${image_refs[@]}"; do
if [[ ! "$image_ref" =~ ^[A-Za-z0-9][A-Za-z0-9._/@:-]*$ ]]; then
fail "image ref contains unsupported characters: $image_ref"
fi
if [[ -n "${seen_refs[$image_ref]:-}" ]]; then
fail "duplicate image ref: $image_ref"
fi
seen_refs["$image_ref"]=1
done
}
require_safe_pack_destination() {
if [[ -z "${RUNNER_TEMP:-}" || "$RUNNER_TEMP" != /* ]]; then
fail "RUNNER_TEMP must be an absolute path for artifact packing."
fi
if [[ "$artifact_dir" != /* ]]; then
fail "artifact directory must be absolute for artifact packing."
fi
local artifact_basename artifact_parent resolved_artifact_parent resolved_runner_temp runner_temp
runner_temp="${RUNNER_TEMP%/}"
artifact_basename="$(basename "$artifact_dir")"
artifact_parent="$(dirname "$artifact_dir")"
if [[ ! "$artifact_basename" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
fail "artifact directory name contains unsupported characters."
fi
resolved_runner_temp="$(cd "$RUNNER_TEMP" && pwd -P)"
resolved_artifact_parent="$(cd "$artifact_parent" && pwd -P)"
if [[ "$resolved_artifact_parent" != "$resolved_runner_temp" ]]; then
fail "artifact directory must be a child of RUNNER_TEMP."
fi
if [[ "$artifact_parent" != "$runner_temp" ||
"$artifact_dir" != "${runner_temp}/${artifact_basename}" ||
-L "$artifact_dir" ]]; then
fail "artifact directory must be a normalized path without symlink or parent traversal."
fi
}
pack_artifact() {
require_common_inputs
require_safe_pack_destination
command -v docker >/dev/null
command -v node >/dev/null
command -v sha256sum >/dev/null
command -v zstd >/dev/null
local image_list image_ref image_id image_tar archive_sha256 archive_size_bytes
local stage_dir stage_manifest_path stage_archive_path
image_list="$(mktemp)"
image_tar="$(mktemp)"
stage_dir="$(mktemp -d "${RUNNER_TEMP}/shared-image-artifact.XXXXXX")"
stage_manifest_path="${stage_dir}/shared-image-artifact.json"
stage_archive_path="${stage_dir}/${archive_name}"
cleanup_pack() {
rm -f "$image_list" "$image_tar"
if [[ -n "${stage_dir:-}" ]]; then
rm -rf -- "$stage_dir"
fi
}
trap cleanup_pack EXIT
for image_ref in "${image_refs[@]}"; do
image_id="$(docker image inspect --format '{{.Id}}' "$image_ref")"
if [[ ! "$image_id" =~ ^sha256:[a-f0-9]{64}$ ]]; then
fail "image has an invalid config digest: $image_ref"
fi
printf '%s\t%s\n' "$image_ref" "$image_id" >> "$image_list"
done
docker image save --output "$image_tar" "${image_refs[@]}"
zstd -T0 -10 --no-progress -f "$image_tar" -o "$stage_archive_path"
zstd -t "$stage_archive_path"
archive_sha256="$(sha256sum "$stage_archive_path" | awk '{print $1}')"
archive_size_bytes="$(wc -c < "$stage_archive_path" | tr -d '[:space:]')"
ARCHIVE_SHA256="$archive_sha256" \
ARCHIVE_SIZE_BYTES="$archive_size_bytes" \
node - "$stage_manifest_path" "$image_list" <<'NODE'
const fs = require("node:fs");
const [manifestPath, imageListPath] = process.argv.slice(2);
const images = fs
.readFileSync(imageListPath, "utf8")
.trim()
.split("\n")
.filter(Boolean)
.map((line) => {
const [ref, id] = line.split("\t");
return { ref, id };
});
const manifest = {
schema: "openclaw.shared-docker-image-artifact/v1",
schemaVersion: 1,
kind: process.env.ARTIFACT_KIND,
targetSha: process.env.TARGET_SHA,
workflowSha: process.env.WORKFLOW_SHA,
packageSha256: process.env.SHARED_PACKAGE_SHA256 || null,
packageSourceSha: process.env.SHARED_PACKAGE_SHA256 ? process.env.TARGET_SHA : null,
runId: Number(process.env.GITHUB_RUN_ID),
runAttempt: Number(process.env.GITHUB_RUN_ATTEMPT),
archive: {
filename: process.env.ARCHIVE_NAME,
format: "docker-tar+zstd",
sha256: process.env.ARCHIVE_SHA256,
sizeBytes: Number(process.env.ARCHIVE_SIZE_BYTES),
},
images,
conclusion: "success",
};
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
NODE
rm -rf -- "$artifact_dir"
mv -- "$stage_dir" "$artifact_dir"
stage_dir=""
cleanup_pack
trap - EXIT
}
load_artifact() {
require_common_inputs
if [[ ! "$shared_archive_sha256" =~ ^[a-f0-9]{64}$ ]]; then
fail "expected shared image archive SHA-256 must be a lowercase digest."
fi
command -v docker >/dev/null
command -v node >/dev/null
command -v sha256sum >/dev/null
command -v zstd >/dev/null
[[ -f "$manifest_path" ]] || fail "shared Docker image artifact manifest is missing: $manifest_path"
[[ -f "$archive_path" ]] || fail "shared Docker image archive is missing: $archive_path"
local validated_path
validated_path="$(mktemp)"
cleanup_load() {
rm -f "$validated_path"
}
trap cleanup_load EXIT
EXPECTED_IMAGES_JSON="$(
printf '%s\0' "${image_refs[@]}" |
node -e '
const fs = require("node:fs");
const refs = fs.readFileSync(0).toString("utf8").split("\0").filter(Boolean);
process.stdout.write(JSON.stringify(refs));
'
)" node - "$manifest_path" > "$validated_path" <<'NODE'
const fs = require("node:fs");
const [manifestPath] = process.argv.slice(2);
const value = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
const expectedRefs = JSON.parse(process.env.EXPECTED_IMAGES_JSON);
const fail = (message) => {
throw new Error(`invalid shared Docker image artifact: ${message}`);
};
if (value.schema !== "openclaw.shared-docker-image-artifact/v1") fail("schema");
if (value.schemaVersion !== 1) fail("schemaVersion");
if (value.kind !== process.env.ARTIFACT_KIND) fail("kind");
if (value.targetSha !== process.env.TARGET_SHA) fail("target SHA");
if (value.workflowSha !== process.env.WORKFLOW_SHA) fail("workflow SHA");
const expectedPackageSha256 = process.env.SHARED_PACKAGE_SHA256 || null;
const expectedPackageSourceSha = expectedPackageSha256 ? process.env.TARGET_SHA : null;
if (value.packageSha256 !== expectedPackageSha256) fail("package SHA-256");
if (value.packageSourceSha !== expectedPackageSourceSha) fail("package source SHA");
if (value.runId !== Number(process.env.SHARED_RUN_ID)) fail("run ID");
if (value.runAttempt !== Number(process.env.SHARED_RUN_ATTEMPT)) fail("run attempt");
if (value.conclusion !== "success") fail("conclusion");
if (value.archive?.filename !== "shared-images.tar.zst") fail("archive filename");
if (value.archive?.format !== "docker-tar+zstd") fail("archive format");
if (!/^[a-f0-9]{64}$/.test(value.archive?.sha256 ?? "")) fail("archive sha256");
if (value.archive.sha256 !== process.env.SHARED_ARCHIVE_SHA256) fail("expected archive sha256");
if (!Number.isSafeInteger(value.archive?.sizeBytes) || value.archive.sizeBytes <= 0) {
fail("archive size");
}
if (!Array.isArray(value.images) || value.images.length !== expectedRefs.length) {
fail("image count");
}
for (let index = 0; index < expectedRefs.length; index += 1) {
const image = value.images[index];
if (image?.ref !== expectedRefs[index]) fail(`image ref ${index}`);
if (!/^sha256:[a-f0-9]{64}$/.test(image?.id ?? "")) fail(`image id ${index}`);
}
process.stdout.write(`${value.archive.sha256}\n${value.archive.sizeBytes}\n`);
for (const image of value.images) {
process.stdout.write(`${image.ref}\t${image.id}\n`);
}
NODE
mapfile -t validated < "$validated_path"
if [[ "${#validated[@]}" -ne $((2 + ${#image_refs[@]})) ]]; then
fail "invalid shared Docker image artifact: validated manifest output length"
fi
local actual_archive_sha256 actual_archive_size
actual_archive_sha256="$(sha256sum "$archive_path" | awk '{print $1}')"
actual_archive_size="$(wc -c < "$archive_path" | tr -d '[:space:]')"
if [[ "$actual_archive_sha256" != "${validated[0]}" ]]; then
fail "shared Docker image artifact archive SHA-256 mismatch."
fi
if [[ "$actual_archive_size" != "${validated[1]}" ]]; then
fail "shared Docker image artifact archive size mismatch."
fi
zstd -t "$archive_path"
zstd -d --stdout "$archive_path" | docker image load
local index expected_ref expected_id actual_id
for index in "${!image_refs[@]}"; do
IFS=$'\t' read -r expected_ref expected_id <<< "${validated[$((index + 2))]}"
if [[ "$expected_ref" != "${image_refs[$index]}" ]]; then
fail "shared Docker image artifact ref mismatch after validation: ${image_refs[$index]}"
fi
actual_id="$(docker image inspect --format '{{.Id}}' "$expected_ref")"
if [[ "$actual_id" != "$expected_id" ]]; then
fail "shared Docker image artifact loaded ID mismatch for $expected_ref."
fi
done
cleanup_load
trap - EXIT
}
case "$command_name" in
pack | load)
configure_image_artifact_inputs "$@"
export ARTIFACT_KIND="$artifact_kind"
export TARGET_SHA="$target_sha"
export WORKFLOW_SHA="$workflow_sha"
export ARCHIVE_NAME="$archive_name"
export SHARED_PACKAGE_SHA256="$shared_package_sha256"
export SHARED_ARCHIVE_SHA256="$shared_archive_sha256"
if [[ "$command_name" == "pack" ]]; then
require_positive_decimal "GITHUB_RUN_ID" "${GITHUB_RUN_ID:-}"
require_positive_decimal "GITHUB_RUN_ATTEMPT" "${GITHUB_RUN_ATTEMPT:-}"
export SHARED_RUN_ID="$GITHUB_RUN_ID"
export SHARED_RUN_ATTEMPT="$GITHUB_RUN_ATTEMPT"
export ARCHIVE_SHA256=""
export ARCHIVE_SIZE_BYTES=""
pack_artifact
else
require_positive_decimal "OPENCLAW_SHARED_IMAGE_RUN_ID" "$shared_run_id"
require_positive_decimal "OPENCLAW_SHARED_IMAGE_RUN_ATTEMPT" "$shared_run_attempt"
export SHARED_RUN_ID="$shared_run_id"
export SHARED_RUN_ATTEMPT="$shared_run_attempt"
load_artifact
fi
;;
verify-upload)
verify_uploaded_artifact "$@"
;;
*)
echo "usage: $0 <pack|load|verify-upload> ..." >&2
exit 1
;;
esac
+49 -14
View File
@@ -9,14 +9,17 @@ const DEFAULT_INPUTS = {
mode: "both",
release_profile: "full",
rerun_group: "all",
reuse_evidence: "false",
};
function usage() {
console.error(`Usage: node scripts/full-release-validation-at-sha.mjs [--sha <sha>] [--branch <name>] [--keep-branch] [--dry-run] [-- -f key=value ...]
console.error(`Usage: node scripts/full-release-validation-at-sha.mjs [--sha <target-sha>] [--workflow-sha <trusted-main-ref>] [--branch <name>] [--keep-branch] [--dry-run] [-- -f key=value ...]
Creates a temporary remote branch pinned to the target commit, dispatches Full
Release Validation from that branch, watches the parent run, verifies all child
workflow head SHAs match, then deletes the temporary branch by default.`);
Creates a temporary remote branch pinned to trusted main release tooling,
dispatches Full Release Validation with the target commit as its ref input,
watches the parent run, verifies all child workflow head SHAs match the trusted
workflow SHA, then deletes the temporary branch by default. Exact-target
evidence reuse is disabled because it is trusted only from main.`);
}
function run(command, args, options = {}) {
@@ -53,6 +56,7 @@ function readOptionValue(argv, index, optionName) {
export function parseArgs(argv) {
const args = {
sha: "",
workflowSha: "",
branch: "",
keepBranch: false,
dryRun: false,
@@ -70,6 +74,11 @@ export function parseArgs(argv) {
i += 1;
continue;
}
if (arg === "--workflow-sha") {
args.workflowSha = readOptionValue(argv, i, arg);
i += 1;
continue;
}
if (arg === "--branch") {
args.branch = readOptionValue(argv, i, arg);
i += 1;
@@ -116,6 +125,9 @@ export function parseArgs(argv) {
throw new Error(`Unknown argument: ${arg}`);
}
if (args.inputs.reuse_evidence !== "false") {
throw new Error("SHA-pinned release validation always disables evidence reuse");
}
return args;
}
@@ -132,6 +144,25 @@ function resolveSha(requestedSha) {
return run("git", ["rev-parse", "--verify", `${rev}^{commit}`], { dryRun: false });
}
function resolveTrustedWorkflowSha(requestedSha) {
run("git", ["fetch", "--no-tags", "origin", "refs/heads/main:refs/remotes/origin/main"], {
stdio: "inherit",
});
const workflowSha = resolveSha(requestedSha || "origin/main");
const ancestry = runStatus("git", [
"merge-base",
"--is-ancestor",
workflowSha,
"refs/remotes/origin/main",
]);
if (ancestry.status !== 0) {
throw new Error(
`Workflow SHA ${workflowSha} is not reachable from current origin/main; refusing an untrusted release harness.`,
);
}
return workflowSha;
}
function collectRunId(dispatchOutput) {
const match = dispatchOutput.match(/actions\/runs\/(\d+)/);
return match?.[1] ?? "";
@@ -175,7 +206,7 @@ function childRunIds(parentRunId) {
return [...new Set([...log.matchAll(/actions\/runs\/(\d+)/g)].map((match) => match[1]))];
}
function verifyChildHeads(parentRunId, sha) {
function verifyChildHeads(parentRunId, workflowSha) {
const ids = childRunIds(parentRunId);
if (ids.length === 0) {
throw new Error(
@@ -188,29 +219,33 @@ function verifyChildHeads(parentRunId, sha) {
const json = run("gh", ["run", "view", id, "--json", "name,status,conclusion,headSha,url"]);
const child = JSON.parse(json);
const ok =
child.headSha === sha && child.status === "completed" && child.conclusion === "success";
child.headSha === workflowSha &&
child.status === "completed" &&
child.conclusion === "success";
console.log(
`${ok ? "ok" : "bad"} ${child.name} ${child.status}/${child.conclusion} ${child.headSha} ${child.url}`,
);
failed ||= !ok;
}
if (failed) {
throw new Error(`One or more child workflows failed or did not run at ${sha}.`);
throw new Error(`One or more child workflows failed or did not run at ${workflowSha}.`);
}
}
function main() {
const args = parseArgs(process.argv.slice(2));
const sha = resolveSha(args.sha);
const shortSha = sha.slice(0, 12);
const targetSha = resolveSha(args.sha);
const workflowSha = resolveTrustedWorkflowSha(args.workflowSha);
const shortSha = workflowSha.slice(0, 12);
const branch = sanitizeBranchPart(args.branch || `release-ci/${shortSha}-${Date.now()}`);
const remoteBranchRef = `refs/heads/${branch}`;
const dispatchInputs = { ref: sha, ...args.inputs };
const dispatchInputs = { ref: targetSha, ...args.inputs };
console.log(`Target SHA: ${sha}`);
console.log(`Target SHA: ${targetSha}`);
console.log(`Trusted workflow SHA: ${workflowSha}`);
console.log(`Temporary workflow ref: ${branch}`);
run("git", ["push", "origin", `${sha}:${remoteBranchRef}`], {
run("git", ["push", "origin", `${workflowSha}:${remoteBranchRef}`], {
dryRun: args.dryRun,
stdio: "inherit",
});
@@ -229,7 +264,7 @@ function main() {
parentRunId = collectRunId(dispatchOutput);
if (!parentRunId && !args.dryRun) {
for (let attempt = 0; attempt < 60; attempt += 1) {
parentRunId = findLatestRunId(branch, sha);
parentRunId = findLatestRunId(branch, workflowSha);
if (parentRunId) {
break;
}
@@ -256,7 +291,7 @@ function main() {
`Full Release Validation failed: https://github.com/openclaw/openclaw/actions/runs/${parentRunId}`,
);
}
verifyChildHeads(parentRunId, sha);
verifyChildHeads(parentRunId, workflowSha);
} finally {
if (!args.keepBranch) {
run("git", ["push", "origin", `:${remoteBranchRef}`], {
+108 -179
View File
@@ -1,15 +1,15 @@
#!/usr/bin/env bash
set -euo pipefail
# Finds a prior green Full Release Validation run whose evidence still covers
# the target SHA: same rerun scope, equal-or-broader release profile/soak, and
# a target delta that is release-metadata-only per check-release-metadata-only.
# Finds a prior green Full Release Validation run for the exact target SHA.
# Cross-SHA evidence reuse is intentionally left to the granular delta manifest,
# which can require fresh package/install/provider closure per changed artifact.
# Always exits 0 with reuse=true/false; callers fail open to a full validation.
REPO="${GH_REPO:-}"
WORKFLOW_FILE="full-release-validation.yml"
TARGET_SHA=""
WORKFLOW_SHA=""
VERIFIER_WORKFLOW_SHA=""
RELEASE_PROFILE=""
RUN_RELEASE_SOAK="false"
INPUTS_JSON=""
@@ -17,8 +17,9 @@ REPO_DIR="."
MAX_CANDIDATES=12
GITHUB_OUTPUT_FILE="${GITHUB_OUTPUT:-}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CLASSIFIER="${SCRIPT_DIR}/../check-release-metadata-only.mjs"
PREFLIGHT="${SCRIPT_DIR}/../release-preflight.mjs"
REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
VALIDATOR="${OPENCLAW_RELEASE_CI_SUMMARY_VALIDATOR:-${REPO_ROOT}/.agents/skills/release-openclaw-ci/scripts/release-ci-summary.mjs}"
usage() {
cat >&2 <<'EOF'
@@ -27,12 +28,12 @@ Usage: find-reusable-release-validation.sh --target-sha <sha> --workflow-sha <sh
[--run-release-soak <true|false>] [--repo <owner/repo>] [--repo-dir <path>] \
[--workflow <file>] [--max-candidates <n>] [--github-output <file>]
Scans recent successful Full Release Validation runs for a validation manifest
whose targetSha differs from --target-sha only by release metadata paths, whose
recorded lane-selection inputs match --inputs-json exactly, whose harness
(.github/workflows tree at the run's head SHA) matches --workflow-sha, and
whose recorded child runs are still green. Writes reuse=true plus evidence_*
outputs when found; reuse=false otherwise.
Scans recent successful Full Release Validation runs for an exact-target
validation manifest whose recorded lane-selection inputs match --inputs-json
and whose normalized strict-v3 evidence is accepted by the current trusted-main
verifier identified by --workflow-sha. The historical producer workflow SHA
remains independent. Writes reuse=true plus evidence_* outputs when found;
reuse=false otherwise.
EOF
}
@@ -43,7 +44,7 @@ while [[ $# -gt 0 ]]; do
shift 2
;;
--workflow-sha)
WORKFLOW_SHA="${2:-}"
VERIFIER_WORKFLOW_SHA="${2:-}"
shift 2
;;
--release-profile)
@@ -107,74 +108,43 @@ no_reuse() {
exit 0
}
profile_rank() {
case "$1" in
beta) echo 1 ;;
stable) echo 2 ;;
full) echo 3 ;;
*) echo 0 ;;
esac
}
if [[ ! "$TARGET_SHA" =~ ^[0-9a-f]{40}$ ]]; then
echo "Expected --target-sha to be a full lowercase commit SHA; got: ${TARGET_SHA}" >&2
exit 2
fi
if [[ ! "$WORKFLOW_SHA" =~ ^[0-9a-f]{40}$ ]]; then
echo "Expected --workflow-sha to be a full lowercase commit SHA; got: ${WORKFLOW_SHA}" >&2
if [[ ! "$VERIFIER_WORKFLOW_SHA" =~ ^[0-9a-f]{40}$ ]]; then
echo "Expected --workflow-sha to be a full lowercase commit SHA; got: ${VERIFIER_WORKFLOW_SHA}" >&2
exit 2
fi
if [[ -z "$REPO" ]]; then
echo "Expected --repo <owner/repo> or GH_REPO." >&2
exit 2
fi
current_rank="$(profile_rank "$RELEASE_PROFILE")"
if [[ "$current_rank" == "0" ]]; then
no_reuse "unknown release profile ${RELEASE_PROFILE}"
if [[ "$RUN_RELEASE_SOAK" != "true" && "$RUN_RELEASE_SOAK" != "false" ]]; then
echo "Expected --run-release-soak to be true or false; got: ${RUN_RELEASE_SOAK}" >&2
exit 2
fi
case "$RELEASE_PROFILE" in
beta|stable|full) ;;
*) no_reuse "unknown release profile ${RELEASE_PROFILE}" ;;
esac
expected_inputs=""
if ! expected_inputs="$(jq -Sc . <<< "$INPUTS_JSON" 2>/dev/null)" || [[ -z "$expected_inputs" ]]; then
if ! expected_inputs="$(jq -Sc 'if type == "object" then . else error("expected object") end' <<< "$INPUTS_JSON" 2>/dev/null)" || [[ -z "$expected_inputs" ]]; then
echo "Expected --inputs-json to be a JSON object of lane-selection inputs." >&2
exit 2
fi
# A metadata-only diff can still leave the target's version stamps mutually
# inconsistent (for example package.json bumped without the macOS plist);
# validate the target state before trusting any prior evidence.
# Exact-target reuse still requires internally consistent version stamps
# (for example package.json must agree with the macOS plist).
if ! (cd "$REPO_DIR" && node "$PREFLIGHT" --macos-versions-only >&2); then
no_reuse "target version metadata is inconsistent"
fi
# Evidence must come from an equivalent harness: workflows and their helper
# scripts run from the workflow ref, so the tree diff between the candidate
# run's head SHA and the current workflow SHA must itself be metadata-only.
harness_matches() {
local candidate_sha="$1"
if [[ "$candidate_sha" == "$WORKFLOW_SHA" ]]; then
return 0
fi
if ! git -C "$REPO_DIR" fetch --quiet --depth=1 origin "$candidate_sha" "$WORKFLOW_SHA"; then
return 1
fi
local harness_paths
if ! harness_paths="$(git -C "$REPO_DIR" diff --name-only "$candidate_sha" "$WORKFLOW_SHA")"; then
return 1
fi
if [[ -z "$harness_paths" ]]; then
return 0
fi
local -a harness_path_list=()
while IFS= read -r harness_path; do
[[ -n "$harness_path" ]] && harness_path_list+=("$harness_path")
done <<< "$harness_paths"
(cd "$REPO_DIR" && node "$CLASSIFIER" --base "$candidate_sha" --head "$WORKFLOW_SHA" -- "${harness_path_list[@]}")
}
runs_json=""
if ! runs_json="$(
gh api -X GET "repos/${REPO}/actions/workflows/${WORKFLOW_FILE}/runs" \
-F status=success -F event=workflow_dispatch -F per_page="$MAX_CANDIDATES" \
--jq '[.workflow_runs[] | {id, html_url, head_sha}]'
--jq '[.workflow_runs[] | {id}]'
)"; then
no_reuse "could not list prior successful validation runs"
fi
@@ -184,157 +154,116 @@ if [[ "$run_count" == "0" ]]; then
no_reuse "no prior successful validation runs"
fi
work_dir="$(mktemp -d)"
trap 'rm -rf "$work_dir"' EXIT
for ((index = 0; index < run_count; index += 1)); do
run_id="$(jq -r ".[${index}].id" <<< "$runs_json")"
run_url="$(jq -r ".[${index}].html_url" <<< "$runs_json")"
run_head_sha="$(jq -r ".[${index}].head_sha // \"\"" <<< "$runs_json")"
if [[ ! "$run_head_sha" =~ ^[0-9a-f]{40}$ ]] || ! harness_matches "$run_head_sha"; then
echo "[evidence-reuse] run ${run_id}: harness differs from the current workflow ref beyond release metadata; skipping" >&2
continue
fi
artifact_id=""
if ! artifact_id="$(
gh api "repos/${REPO}/actions/runs/${run_id}/artifacts?per_page=100" \
--jq "first(.artifacts[] | select(.name == \"full-release-validation-${run_id}\" and .expired == false) | .id)"
validation_record=""
if ! validation_record="$(
node "$VALIDATOR" \
--validate-run "$run_id" \
--repo "$REPO" \
--trusted-workflow-ref main \
--json
)"; then
echo "[evidence-reuse] run ${run_id}: artifact listing failed; skipping" >&2
echo "[evidence-reuse] run ${run_id}: shared evidence validator rejected the run; skipping" >&2
continue
fi
if [[ -z "${artifact_id// }" ]]; then
echo "[evidence-reuse] run ${run_id}: no validation manifest artifact; skipping" >&2
if ! jq -e \
--arg repo "$REPO" \
--arg run_id "$run_id" \
--arg verifier_sha "$VERIFIER_WORKFLOW_SHA" '
. as $record
| .schema == "openclaw.release-validation-evidence/v3"
and .valid == true
and .repository == $repo
and .producerOnTrustedMainLineage == true
and .trustedWorkflowRef == "main"
and .trustedWorkflowFullRef == "refs/heads/main"
and .directRoot == true
and .evidenceReuse == null
and .rerunGroup == "all"
and .controls.performanceReportPublication == "artifact-only"
and .conclusions.current == "success"
and .conclusions.root == "success"
and .conclusions.allRequiredSucceeded == true
and (.current == .root)
and (.root.runId | tostring) == $run_id
and (.root.workflowSha | type == "string" and test("^[0-9a-f]{40}$"))
and (.root.targetSha | type == "string" and test("^[0-9a-f]{40}$"))
and (.root.artifact.digest | type == "string" and test("^sha256:[0-9a-f]{64}$"))
and all($record.current, $record.root;
.producerOnTrustedMainLineage == true
and .workflowFullRef == "refs/heads/main"
and .workflowRefType == "branch"
and .workflowPath == ".github/workflows/full-release-validation.yml"
and .workflowQualifiedPath ==
".github/workflows/full-release-validation.yml@refs/heads/main"
and (
.workflowRunPath == ".github/workflows/full-release-validation.yml"
or .workflowRunPath ==
".github/workflows/full-release-validation.yml@refs/heads/main"
)
and (
(.manifestVersion == 3 and .workflowRefProof == "manifest-v3-branch")
or (
.manifestVersion == 2
and .workflowRefProof == "legacy-v2-main-ancestry"
)
)
)
and (.verifier.schemaVersion == 3)
and (.verifier.sourceSha == $verifier_sha)
and ([.children[].role] | sort) ==
["normalCi", "pluginPrerelease", "productPerformance", "releaseChecks"]
and ([.children[].runId] | length == (unique | length))
and ([.children[]
| select(.role == "productPerformance")
| .reportPublication] == ["artifact-only"])
and all(.children[];
.status == "completed"
and .conclusion == "success"
and .workflowSha == $record.root.workflowSha
and (.sourceParentRunId | tostring) == $run_id
)
' <<< "$validation_record" >/dev/null 2>&1; then
echo "[evidence-reuse] run ${run_id}: normalized evidence is not a strict direct-root full validation; skipping" >&2
continue
fi
manifest_zip="${work_dir}/manifest-${run_id}.zip"
manifest_path="${work_dir}/manifest-${run_id}.json"
if ! gh api "repos/${REPO}/actions/artifacts/${artifact_id}/zip" > "$manifest_zip"; then
echo "[evidence-reuse] run ${run_id}: manifest download failed; skipping" >&2
continue
fi
if ! unzip -p "$manifest_zip" full-release-validation-manifest.json > "$manifest_path" 2>/dev/null; then
echo "[evidence-reuse] run ${run_id}: manifest missing from artifact; skipping" >&2
continue
fi
if ! jq -e '
(.version >= 2)
and (.rerunGroup == "all")
and ((.targetSha // "") | test("^[0-9a-f]{40}$"))
' "$manifest_path" >/dev/null 2>&1; then
echo "[evidence-reuse] run ${run_id}: manifest is not a full-scope v2 manifest; skipping" >&2
continue
fi
prior_profile="$(jq -r '.releaseProfile // ""' "$manifest_path")"
prior_rank="$(profile_rank "$prior_profile")"
if (( prior_rank < current_rank )); then
echo "[evidence-reuse] run ${run_id}: profile ${prior_profile} does not cover ${RELEASE_PROFILE}; skipping" >&2
prior_profile="$(jq -r '.releaseProfile // ""' <<< "$validation_record")"
if [[ "$prior_profile" != "$RELEASE_PROFILE" ]]; then
echo "[evidence-reuse] run ${run_id}: profile ${prior_profile} differs from ${RELEASE_PROFILE}; skipping" >&2
continue
fi
# Lane selection (provider, mode, filters, package specs) changes what the
# prior run proved; only exact-match manifests are reusable. Manifests
# written before validationInputs existed never match.
manifest_inputs="$(jq -Sc '.validationInputs // empty' "$manifest_path")"
manifest_inputs="$(jq -Sc '.validationInputs // empty' <<< "$validation_record")"
if [[ -z "$manifest_inputs" || "$manifest_inputs" != "$expected_inputs" ]]; then
echo "[evidence-reuse] run ${run_id}: validation inputs differ from the current request; skipping" >&2
continue
fi
prior_soak="$(jq -r '.runReleaseSoak // "false"' "$manifest_path")"
if [[ "$RUN_RELEASE_SOAK" == "true" && "$prior_soak" != "true" ]]; then
echo "[evidence-reuse] run ${run_id}: no soak evidence; skipping" >&2
prior_soak="$(jq -r '.runReleaseSoak // false' <<< "$validation_record")"
if [[ "$prior_soak" != "$RUN_RELEASE_SOAK" ]]; then
echo "[evidence-reuse] run ${run_id}: soak ${prior_soak} differs from ${RUN_RELEASE_SOAK}; skipping" >&2
continue
fi
prior_sha="$(jq -r '.targetSha' "$manifest_path")"
# Track count/joined separately: empty-array expansion under `set -u` breaks
# on the bash 3.2 that macOS ships.
changed_paths=()
changed_path_count=0
changed_paths_joined=""
prior_sha="$(jq -r '.root.targetSha' <<< "$validation_record")"
if [[ "$prior_sha" != "$TARGET_SHA" ]]; then
compare_json=""
if ! compare_json="$(
gh api "repos/${REPO}/compare/${prior_sha}...${TARGET_SHA}" \
--jq '{status, file_count: ((.files // []) | length), files: [(.files // [])[].filename]}'
)"; then
echo "[evidence-reuse] run ${run_id}: compare ${prior_sha}...${TARGET_SHA} failed; skipping" >&2
continue
fi
compare_status="$(jq -r '.status' <<< "$compare_json")"
if [[ "$compare_status" != "ahead" ]]; then
echo "[evidence-reuse] run ${run_id}: target is ${compare_status} of prior evidence, not ahead; skipping" >&2
continue
fi
file_count="$(jq -r '.file_count' <<< "$compare_json")"
# The compare API truncates at 300 files; a truncated list cannot prove a
# metadata-only delta, so fall back to full validation.
if (( file_count >= 300 )); then
echo "[evidence-reuse] run ${run_id}: delta too large to classify (${file_count} files); skipping" >&2
continue
fi
while IFS= read -r changed_path; do
if [[ -n "$changed_path" ]]; then
changed_paths+=("$changed_path")
changed_path_count=$((changed_path_count + 1))
changed_paths_joined="${changed_paths_joined:+${changed_paths_joined} }${changed_path}"
fi
done < <(jq -r '.files[]' <<< "$compare_json")
if (( changed_path_count == 0 )); then
echo "[evidence-reuse] run ${run_id}: delta has no file changes" >&2
else
if ! git -C "$REPO_DIR" fetch --quiet --depth=1 origin "$prior_sha"; then
echo "[evidence-reuse] run ${run_id}: could not fetch prior SHA ${prior_sha}; skipping" >&2
continue
fi
if ! (cd "$REPO_DIR" && node "$CLASSIFIER" --base "$prior_sha" --head "$TARGET_SHA" -- "${changed_paths[@]}"); then
echo "[evidence-reuse] run ${run_id}: delta is not release-metadata-only; skipping" >&2
continue
fi
fi
fi
# Recorded child runs can be re-run to failure after the parent stays green;
# reuse only evidence whose children are still completed/success, matching
# the recheck the normal summary performs on its own children.
children_healthy=1
while IFS= read -r child_run_id; do
[[ -n "$child_run_id" ]] || continue
if ! child_state="$(gh api "repos/${REPO}/actions/runs/${child_run_id}" --jq '(.status // "") + "/" + (.conclusion // "")')"; then
echo "[evidence-reuse] run ${run_id}: could not verify child run ${child_run_id}; skipping" >&2
children_healthy=0
break
fi
if [[ "$child_state" != "completed/success" ]]; then
echo "[evidence-reuse] run ${run_id}: child run ${child_run_id} is ${child_state}; skipping" >&2
children_healthy=0
break
fi
done < <(jq -r '[.childRuns.normalCi // "", .childRuns.pluginPrerelease // "", .childRuns.releaseChecks // "", .childRuns.npmTelegram // "", (.childRuns.productPerformance.runId // "")] | map(select(. != "")) | .[]' "$manifest_path")
if [[ "$children_healthy" != "1" ]]; then
echo "[evidence-reuse] run ${run_id}: target ${prior_sha} differs from ${TARGET_SHA}; cross-SHA reuse requires granular artifact evidence" >&2
continue
fi
# A reused run may itself be a reuse manifest; evidenceReuse.runId points at
# the chain root that actually executed the lanes.
evidence_root_run_id="$(jq -r '.evidenceReuse.runId // empty' "$manifest_path")"
if [[ -z "${evidence_root_run_id// }" ]]; then
evidence_root_run_id="$run_id"
fi
echo "[evidence-reuse] reusing run ${run_id} (${run_url}) for ${TARGET_SHA}: prior sha ${prior_sha}, ${changed_path_count} metadata-only changed files" >&2
run_url="$(jq -r '.root.url' <<< "$validation_record")"
echo "[evidence-reuse] reusing exact-target run ${run_id} (${run_url}) for ${TARGET_SHA}" >&2
write_output reuse true
write_output evidence_run_id "$run_id"
write_output evidence_root_run_id "$evidence_root_run_id"
write_output evidence_root_run_id "$run_id"
write_output evidence_run_url "$run_url"
write_output evidence_sha "$prior_sha"
write_output changed_path_count "$changed_path_count"
write_output changed_paths "$changed_paths_joined"
write_output evidence_manifest "$(jq -c . "$manifest_path")"
write_output changed_path_count "0"
write_output changed_paths "[]"
write_output evidence_manifest "$(jq -c '.manifest' <<< "$validation_record")"
exit 0
done
+4
View File
@@ -93,6 +93,10 @@ docker_e2e_build_or_reuse() {
if [ "${OPENCLAW_SKIP_DOCKER_BUILD:-0}" = "1" ] || [ "$skip_build" = "1" ]; then
echo "Reusing Docker image: $image_name"
if ! docker_e2e_docker_cmd image inspect "$image_name" >/dev/null 2>&1; then
if [ "${OPENCLAW_DOCKER_E2E_REQUIRE_LOCAL_IMAGE:-0}" = "1" ]; then
echo "Required local Docker E2E image not found: $image_name" >&2
return 1
fi
echo "Docker image not found locally; pulling: $image_name"
if docker_e2e_docker_cmd pull "$image_name"; then
return 0
+4
View File
@@ -54,6 +54,10 @@ if [[ "${OPENCLAW_SKIP_DOCKER_BUILD:-}" == "1" ]]; then
if docker_e2e_docker_cmd image inspect "$LIVE_IMAGE_NAME" >/dev/null 2>&1; then
exit 0
fi
if [[ "${OPENCLAW_LIVE_REQUIRE_LOCAL_IMAGE:-0}" == "1" ]]; then
echo "Required local live-test image not found: $LIVE_IMAGE_NAME" >&2
exit 1
fi
echo "==> Live-test image not found locally; pulling: $LIVE_IMAGE_NAME"
if pull_live_image; then
exit 0
File diff suppressed because it is too large Load Diff
@@ -7,6 +7,8 @@ describe("full-release-validation-at-sha", () => {
parseArgs([
"--sha",
"abc123",
"--workflow-sha",
"origin/main",
"--branch",
"release/proof",
"--keep-branch",
@@ -23,17 +25,29 @@ describe("full-release-validation-at-sha", () => {
inputs: {
mode: "linux",
provider: "anthropic",
reuse_evidence: "false",
},
sha: "abc123",
workflowSha: "origin/main",
});
});
it("rejects missing option values", () => {
expect(() => parseArgs(["--sha", "--dry-run"])).toThrow("--sha requires a value");
expect(() => parseArgs(["--sha", "-h"])).toThrow("--sha requires a value");
expect(() => parseArgs(["--workflow-sha", "--dry-run"])).toThrow(
"--workflow-sha requires a value",
);
expect(() => parseArgs(["--workflow-sha", "-h"])).toThrow("--workflow-sha requires a value");
expect(() => parseArgs(["--branch"])).toThrow("--branch requires a value");
expect(() => parseArgs(["--branch", "-h"])).toThrow("--branch requires a value");
expect(() => parseArgs(["-f", "--dry-run"])).toThrow("-f requires a value");
expect(() => parseArgs(["-f", "-h"])).toThrow("-f requires a value");
});
it("cannot enable evidence reuse on a temporary SHA-pinned workflow ref", () => {
expect(() => parseArgs(["-f", "reuse_evidence=true"])).toThrow(
"always disables evidence reuse",
);
});
});
@@ -0,0 +1,401 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { parse } from "yaml";
const INSTALL_SMOKE = ".github/workflows/install-smoke.yml";
const RELEASE_CHECKS = ".github/workflows/openclaw-release-checks.yml";
type WorkflowStep = {
env?: Record<string, unknown>;
id?: string;
if?: string;
name?: string;
run?: string;
uses?: string;
with?: Record<string, unknown>;
};
type WorkflowJob = {
env?: Record<string, unknown>;
if?: string;
needs?: string | string[];
outputs?: Record<string, unknown>;
permissions?: Record<string, unknown>;
steps?: WorkflowStep[];
uses?: string;
with?: Record<string, unknown>;
};
type Workflow = {
jobs: Record<string, WorkflowJob>;
on?: {
workflow_call?: { inputs?: Record<string, Record<string, unknown>> };
workflow_dispatch?: { inputs?: Record<string, Record<string, unknown>> };
};
permissions?: Record<string, unknown>;
};
function readWorkflow(path: string): Workflow {
return parse(readFileSync(path, "utf8")) as Workflow;
}
function job(workflow: Workflow, name: string): WorkflowJob {
const found = workflow.jobs[name];
expect(found, name).toBeDefined();
return found!;
}
function step(workflowJob: WorkflowJob, name: string): WorkflowStep {
const found = workflowJob.steps?.find((candidate) => candidate.name === name);
expect(found, name).toBeDefined();
return found!;
}
describe("install smoke no-push root image transport", () => {
it("keeps registry transport as the default and validates the selected mode", () => {
const workflow = readWorkflow(INSTALL_SMOKE);
const dispatchInput = workflow.on?.workflow_dispatch?.inputs?.root_image_transport;
const callInput = workflow.on?.workflow_call?.inputs?.root_image_transport;
expect(dispatchInput).toMatchObject({
default: "registry",
options: ["registry", "no-push-artifact"],
type: "choice",
});
expect(callInput).toMatchObject({
default: "registry",
type: "string",
});
expect(workflow.permissions).toMatchObject({
actions: "read",
contents: "read",
packages: "read",
});
const preflight = job(workflow, "preflight");
expect(preflight.outputs?.root_image_transport).toBe(
"${{ steps.manifest.outputs.root_image_transport }}",
);
expect(preflight.outputs?.workflow_repository).toBe(
"${{ steps.workflow.outputs.workflow_repository }}",
);
expect(preflight.outputs?.workflow_sha).toBe("${{ steps.workflow.outputs.workflow_sha }}");
const workflowIdentity = step(preflight, "Resolve job workflow identity");
expect(workflowIdentity.env?.JOB_CONTEXT).toBe("${{ toJSON(job) }}");
expect(workflowIdentity.run).toContain(
"job.workflow_repository must be an owner/repository slug",
);
expect(workflowIdentity.run).toContain("job.workflow_sha must be a full lowercase commit SHA");
const manifest = step(preflight, "Build install-smoke CI manifest");
expect(manifest.env?.OPENCLAW_CI_ROOT_IMAGE_TRANSPORT).toBe(
"${{ inputs.root_image_transport || 'registry' }}",
);
expect(manifest.run).toContain("registry)");
expect(manifest.run).toContain("no-push-artifact)");
expect(manifest.run).toContain(
'dockerfile_image="ghcr.io/${owner}/openclaw-dockerfile-smoke:${target_sha}"',
);
expect(manifest.run).toContain(
'dockerfile_image="openclaw-dockerfile-smoke-local:${target_sha}"',
);
expect(manifest.run).toContain("root_image_transport must be registry or no-push-artifact");
const trustedCheckouts = Object.entries(workflow.jobs).flatMap(([jobName, workflowJob]) =>
(workflowJob.steps ?? [])
.filter((candidate) => candidate.name?.startsWith("Checkout trusted "))
.map((candidate) => ({ candidate, jobName })),
);
expect(trustedCheckouts).toHaveLength(5);
for (const { candidate, jobName } of trustedCheckouts) {
expect(candidate.with, jobName).toMatchObject({
repository: "${{ needs.preflight.outputs.workflow_repository }}",
ref: "${{ needs.preflight.outputs.workflow_sha }}",
"persist-credentials": false,
});
}
});
it("builds one local target image and uploads provenance-bound bytes", () => {
const workflow = readWorkflow(INSTALL_SMOKE);
const producer = job(workflow, "root_dockerfile_image");
expect(producer.permissions).toEqual({
contents: "read",
packages: "read",
});
expect(producer.outputs?.archive_sha256).toBe(
"${{ steps.image_artifact.outputs.archive_sha256 }}",
);
expect(producer.outputs?.artifact_digest).toBe(
"${{ steps.image_artifact_upload.outputs.artifact-digest }}",
);
expect(producer.outputs?.artifact_id).toBe(
"${{ steps.image_artifact_upload.outputs.artifact-id }}",
);
expect(producer.outputs?.artifact_name).toBe(
"${{ steps.image_artifact.outputs.artifact_name }}",
);
expect(producer.outputs?.artifact_run_attempt).toBe(
"${{ steps.image_artifact.outputs.run_attempt }}",
);
expect(producer.outputs?.artifact_run_id).toBe("${{ steps.image_artifact.outputs.run_id }}");
expect(producer.outputs?.image_exists).toBe("${{ steps.existing.outputs.exists }}");
expect(step(producer, "Checkout CLI").with).toMatchObject({
ref: "${{ needs.preflight.outputs.target_sha }}",
"persist-credentials": false,
});
const trustedCheckout = step(producer, "Checkout trusted image artifact helper");
expect(trustedCheckout.if).toBe(
"needs.preflight.outputs.root_image_transport == 'no-push-artifact'",
);
expect(trustedCheckout.with).toMatchObject({
repository: "${{ needs.preflight.outputs.workflow_repository }}",
ref: "${{ needs.preflight.outputs.workflow_sha }}",
path: ".release-harness",
"persist-credentials": false,
});
expect(step(producer, "Log in to GHCR").if).toBe(
"needs.preflight.outputs.root_image_transport == 'registry'",
);
expect(step(producer, "Check for existing root Dockerfile smoke image").if).toBe(
"needs.preflight.outputs.root_image_transport == 'registry'",
);
expect(
producer.steps?.some(
(candidate) => candidate.name === "Build and push root Dockerfile smoke image",
),
).toBe(false);
const localBuild = step(producer, "Build local root Dockerfile smoke image");
expect(localBuild.if).toBe(
"needs.preflight.outputs.root_image_transport == 'no-push-artifact'",
);
expect(localBuild.run).toContain("--load");
expect(localBuild.run).not.toContain("--push");
expect(localBuild.run).toContain('-t "$IMAGE_REF"');
const pack = step(producer, "Pack root Dockerfile image artifact");
expect(pack.if).toBe("needs.preflight.outputs.root_image_transport == 'no-push-artifact'");
expect(pack.env).toMatchObject({
IMAGE_REF: "${{ needs.preflight.outputs.dockerfile_image }}",
TARGET_SHA: "${{ needs.preflight.outputs.target_sha }}",
WORKFLOW_SHA: "${{ needs.preflight.outputs.workflow_sha }}",
});
expect(pack.run).toContain(
'artifact_name="install-smoke-root-image-${TARGET_SHA:0:12}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"',
);
expect(pack.run).toContain(
'pack "$artifact_dir" install-smoke-root "$TARGET_SHA" "$WORKFLOW_SHA" "$IMAGE_REF"',
);
expect(pack.run).toContain(
'jq -er \'.archive.sha256 | select(type == "string" and test("^[a-f0-9]{64}$"))\'',
);
expect(pack.run).toContain('echo "archive_sha256=$archive_sha256"');
expect(pack.run).toContain('echo "run_attempt=$GITHUB_RUN_ATTEMPT"');
expect(pack.run).toContain('echo "run_id=$GITHUB_RUN_ID"');
const upload = step(producer, "Upload root Dockerfile image artifact");
expect(upload.id).toBe("image_artifact_upload");
expect(upload.if).toBe("needs.preflight.outputs.root_image_transport == 'no-push-artifact'");
expect(upload.uses).toBe("actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a");
expect(upload.with).toMatchObject({
"compression-level": 0,
"if-no-files-found": "error",
name: "${{ steps.image_artifact.outputs.artifact_name }}",
path: "${{ steps.image_artifact.outputs.artifact_path }}",
});
const registryPublisher = job(workflow, "push_root_dockerfile_image");
expect(registryPublisher.permissions).toEqual({
contents: "read",
packages: "write",
});
expect(registryPublisher.if).toBe(
"needs.preflight.outputs.root_image_transport == 'registry' && needs.root_dockerfile_image.outputs.image_exists != 'true'",
);
expect(step(registryPublisher, "Checkout CLI").with).toMatchObject({
ref: "${{ needs.preflight.outputs.target_sha }}",
"persist-credentials": false,
});
expect(step(registryPublisher, "Log in to GHCR").if).toBeUndefined();
const registryBuild = step(registryPublisher, "Build and push root Dockerfile smoke image");
expect(registryBuild.run).toContain("--push");
expect(registryBuild.run).not.toContain("--load");
const writeScopedJobs = Object.entries(workflow.jobs)
.filter(([, candidate]) => candidate.permissions?.packages === "write")
.map(([name]) => name);
expect(writeScopedJobs).toEqual(["push_root_dockerfile_image"]);
const ready = job(workflow, "root_dockerfile_image_ready");
expect(ready.needs).toEqual([
"preflight",
"root_dockerfile_image",
"push_root_dockerfile_image",
]);
expect(ready.if).toContain("always()");
const verify = step(ready, "Verify root Dockerfile image preparation");
expect(verify.run).toContain('if [[ "$PREPARE_RESULT" != "success" ]]');
expect(verify.run).toContain(
'if [[ "$ROOT_IMAGE_TRANSPORT" == "registry" && "$IMAGE_EXISTS" != "true" ]]',
);
expect(verify.run).toContain('elif [[ "$PUSH_RESULT" != "skipped" ]]');
});
it("verifies and loads the artifact in every consumer without registry fallback", () => {
const workflow = readWorkflow(INSTALL_SMOKE);
for (const [jobName, checkoutName] of [
["install-smoke-fast", "Checkout CLI"],
["qr_package_install_smoke", "Checkout CLI"],
["root_dockerfile_smokes", "Checkout CLI"],
["installer_smoke", "Checkout candidate CLI"],
["bun_global_install_smoke", "Checkout CLI"],
["docker-e2e-fast", "Checkout CLI"],
]) {
const checkout = step(job(workflow, jobName), checkoutName);
expect(checkout.with?.ref, jobName).toBe("${{ needs.preflight.outputs.target_sha }}");
expect(checkout.with?.["persist-credentials"], jobName).toBe(false);
}
for (const jobName of [
"root_dockerfile_smokes",
"installer_smoke",
"bun_global_install_smoke",
]) {
const consumer = job(workflow, jobName);
expect(consumer.needs, jobName).toContain("root_dockerfile_image_ready");
expect(consumer.env?.OPENCLAW_DOCKER_E2E_REQUIRE_LOCAL_IMAGE, jobName).toBe(
"${{ needs.preflight.outputs.root_image_transport == 'no-push-artifact' && '1' || '0' }}",
);
const trustedCheckout = step(consumer, "Checkout trusted image artifact helper");
expect(trustedCheckout.if, jobName).toBe(
"needs.preflight.outputs.root_image_transport == 'no-push-artifact'",
);
expect(trustedCheckout.with, jobName).toMatchObject({
repository: "${{ needs.preflight.outputs.workflow_repository }}",
ref: "${{ needs.preflight.outputs.workflow_sha }}",
path: ".release-harness",
"persist-credentials": false,
});
expect(step(consumer, "Log in to GHCR").if, jobName).toBe(
"needs.preflight.outputs.root_image_transport == 'registry'",
);
expect(step(consumer, "Pull root Dockerfile smoke image").if, jobName).toBe(
"needs.preflight.outputs.root_image_transport == 'registry'",
);
const binding = step(consumer, "Validate root Dockerfile image artifact binding");
expect(binding.if, jobName).toBe(
"needs.preflight.outputs.root_image_transport == 'no-push-artifact'",
);
expect(binding.env, jobName).toMatchObject({
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 }}",
});
expect(binding.run, jobName).toContain('[[ "$ARTIFACT_ID" =~ ^[1-9][0-9]*$ ]]');
expect(binding.run, jobName).toContain('[[ "$ARTIFACT_DIGEST" =~ ^[a-f0-9]{64}$ ]]');
expect(binding.run, jobName).toContain('[[ "$ARCHIVE_SHA256" =~ ^[a-f0-9]{64}$ ]]');
expect(binding.run, jobName).toContain('[[ "$ARTIFACT_RUN_ID" =~ ^[1-9][0-9]*$ ]]');
expect(binding.run, jobName).toContain('[[ "$ARTIFACT_RUN_ATTEMPT" =~ ^[1-9][0-9]*$ ]]');
expect(binding.run, jobName).not.toContain(
'"$ARTIFACT_RUN_ATTEMPT" == "$GITHUB_RUN_ATTEMPT"',
);
expect(binding.run, jobName).toContain(
'expected_artifact_name="install-smoke-root-image-${TARGET_SHA:0:12}-${ARTIFACT_RUN_ID}-${ARTIFACT_RUN_ATTEMPT}"',
);
expect(binding.run, jobName).toContain(
"repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}",
);
expect(binding.run, jobName).toContain('--arg digest "sha256:${ARTIFACT_DIGEST}"');
expect(binding.run, jobName).toContain('--arg id "$ARTIFACT_ID"');
expect(binding.run, jobName).toContain('--arg name "$ARTIFACT_NAME"');
expect(binding.run, jobName).toContain("(.id | tostring) == $id");
expect(binding.run, jobName).toContain(".name == $name");
expect(binding.run, jobName).toContain(".expired == false");
expect(binding.run, jobName).toContain(".digest == $digest");
expect(binding.run, jobName).toContain("(.workflow_run.id | tostring) == $run_id");
expect(binding.run, jobName).toContain(
"repos/${GITHUB_REPOSITORY}/actions/runs/${ARTIFACT_RUN_ID}/attempts/${ARTIFACT_RUN_ATTEMPT}",
);
expect(binding.run, jobName).toContain("(.run_attempt | tostring) == $attempt");
const download = step(consumer, "Download root Dockerfile image artifact");
expect(download.if, jobName).toBe(
"needs.preflight.outputs.root_image_transport == 'no-push-artifact'",
);
expect(download.uses, jobName).toBe(
"actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c",
);
expect(download.with, jobName).toMatchObject({
"artifact-ids": "${{ needs.root_dockerfile_image.outputs.artifact_id }}",
"github-token": "${{ github.token }}",
path: "${{ runner.temp }}/install-smoke-root-image",
"run-id": "${{ needs.root_dockerfile_image.outputs.artifact_run_id }}",
});
expect(download.with?.name, jobName).toBeUndefined();
expect(
consumer.steps?.findIndex(
(candidate) => candidate.name === "Validate root Dockerfile image artifact binding",
),
jobName,
).toBeLessThan(
consumer.steps?.findIndex(
(candidate) => candidate.name === "Download root Dockerfile image artifact",
) ?? -1,
);
const load = step(consumer, "Verify and load root Dockerfile image artifact");
expect(load.if, jobName).toBe(
"needs.preflight.outputs.root_image_transport == 'no-push-artifact'",
);
expect(load.env, jobName).toMatchObject({
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 }}",
});
expect(load.run, jobName).toContain(
'load "${RUNNER_TEMP}/install-smoke-root-image" install-smoke-root',
);
expect(load.run, jobName).toContain("set -euo pipefail");
expect(load.run, jobName).toContain('"$TARGET_SHA" "$WORKFLOW_SHA" "$IMAGE_REF"');
const requireLocal = step(consumer, "Require local root Dockerfile image");
expect(requireLocal.if, jobName).toBe(
"needs.preflight.outputs.root_image_transport == 'no-push-artifact'",
);
expect(requireLocal.run, jobName).toBe('docker image inspect "$IMAGE_REF" >/dev/null');
}
expect(job(workflow, "install-smoke-fast").env?.OPENCLAW_DOCKER_E2E_REQUIRE_LOCAL_IMAGE).toBe(
"1",
);
});
it("selects no-push transport with read-only package access from release checks", () => {
const release = readWorkflow(RELEASE_CHECKS);
const caller = job(release, "install_smoke_release_checks");
expect(caller.uses).toBe("./.github/workflows/install-smoke.yml");
expect(caller.permissions).toEqual({
actions: "read",
contents: "read",
packages: "read",
});
expect(caller.with).toMatchObject({
ref: "${{ needs.resolve_target.outputs.revision }}",
root_image_transport: "no-push-artifact",
run_bun_global_install_smoke: true,
});
});
});
@@ -2,13 +2,55 @@
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { parse } from "yaml";
const WORKFLOW_PATH = ".github/workflows/openclaw-cross-os-release-checks-reusable.yml";
const RELEASE_CHECKS_PATH = ".github/workflows/openclaw-release-checks.yml";
const WRAPPER_PATH = "scripts/github/run-openclaw-cross-os-release-checks.sh";
const SCRIPT_PATH = "scripts/openclaw-cross-os-release-checks.ts";
const HARNESS = "bash workflow/scripts/github/run-openclaw-cross-os-release-checks.sh";
const BASH_BIN = process.platform === "win32" ? "bash" : "/bin/bash";
type WorkflowStep = {
env?: Record<string, unknown>;
id?: string;
if?: string;
name?: string;
run?: string;
uses?: string;
with?: Record<string, unknown>;
};
type WorkflowJob = {
outputs?: Record<string, unknown>;
steps?: WorkflowStep[];
with?: Record<string, unknown>;
};
type Workflow = {
jobs: Record<string, WorkflowJob>;
on?: {
workflow_call?: { inputs?: Record<string, Record<string, unknown>> };
workflow_dispatch?: { inputs?: Record<string, Record<string, unknown>> };
};
};
function readWorkflow(path: string): Workflow {
return parse(readFileSync(path, "utf8")) as Workflow;
}
function job(workflow: Workflow, name: string): WorkflowJob {
const found = workflow.jobs[name];
expect(found, name).toBeDefined();
return found!;
}
function step(workflowJob: WorkflowJob, name: string): WorkflowStep {
const found = workflowJob.steps?.find((candidate) => candidate.name === name);
expect(found, name).toBeDefined();
return found!;
}
describe("cross-OS release checks workflow", () => {
it("runs the TypeScript release harness through the Windows-safe wrapper", () => {
const workflow = readFileSync(WORKFLOW_PATH, "utf8");
@@ -28,7 +70,7 @@ describe("cross-OS release checks workflow", () => {
it("keeps release artifact tarball filenames local before upload paths use them", () => {
const workflow = readFileSync(WORKFLOW_PATH, "utf8");
expect(workflow.match(/function resolveTarballFileName/g)).toHaveLength(2);
expect(workflow.match(/function resolveTarballFileName/g)).toHaveLength(1);
expect(workflow.match(/path\.win32\.basename\(fileName\)/g)).toHaveLength(2);
expect(workflow).toContain("candidate_file_name");
expect(workflow).toContain("Baseline npm pack filename");
@@ -37,6 +79,260 @@ describe("cross-OS release checks workflow", () => {
expect(workflow).toContain("process.stdout.write(`file_name=${fileName}\\n`);");
});
it("binds the prepared release package to an immutable artifact and package tuple", () => {
const release = readWorkflow(RELEASE_CHECKS_PATH);
const producer = job(release, "prepare_release_package");
expect(producer.outputs).toMatchObject({
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 }}",
});
expect(step(producer, "Checkout trusted workflow ref").with).toMatchObject({
ref: "${{ github.sha }}",
"persist-credentials": false,
});
const metadata = step(producer, "Set artifact metadata");
expect(metadata.run).toContain(
"name=release-package-under-test-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}",
);
expect(metadata.run).toContain("file_name=openclaw-current.tgz");
expect(metadata.run).toContain("run_attempt=${GITHUB_RUN_ATTEMPT}");
expect(metadata.run).toContain("run_id=${GITHUB_RUN_ID}");
const upload = step(producer, "Upload release package artifact");
expect(upload.id).toBe("release_package_upload");
expect(upload.with).toMatchObject({
name: "${{ steps.artifact.outputs.name }}",
"if-no-files-found": "error",
});
const binding = step(producer, "Validate release package artifact binding");
expect(binding.env).toMatchObject({
ARTIFACT_DIGEST: "${{ steps.release_package_upload.outputs.artifact-digest }}",
ARTIFACT_ID: "${{ steps.release_package_upload.outputs.artifact-id }}",
ARTIFACT_RUN_ATTEMPT: "${{ steps.artifact.outputs.run_attempt }}",
ARTIFACT_RUN_ID: "${{ steps.artifact.outputs.run_id }}",
PACKAGE_SHA256: "${{ steps.package.outputs.sha256 }}",
PACKAGE_SOURCE_SHA: "${{ steps.package.outputs.source_sha }}",
PACKAGE_VERSION: "${{ steps.package.outputs.package_version }}",
});
expect(binding.run).toContain('[[ "$ARTIFACT_DIGEST" =~ ^[a-f0-9]{64}$ ]]');
expect(binding.run).toContain('"$ARTIFACT_RUN_ID" == "$GITHUB_RUN_ID"');
expect(binding.run).toContain('"$ARTIFACT_RUN_ATTEMPT" == "$GITHUB_RUN_ATTEMPT"');
expect(binding.run).toContain('"$PACKAGE_SHA256" =~ ^[a-f0-9]{64}$');
expect(binding.run).toContain('"$PACKAGE_SOURCE_SHA" =~ ^[a-f0-9]{40}$');
const crossOs = job(release, "cross_os_release_checks");
expect(crossOs.with).toMatchObject({
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_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_source_sha: "${{ needs.prepare_release_package.outputs.source_sha }}",
candidate_version: "${{ needs.prepare_release_package.outputs.package_version }}",
});
expect(job(release, "docker_e2e_release_checks").with).toMatchObject({
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 }}",
});
expect(job(release, "package_acceptance_release_checks").with).toMatchObject({
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_source_sha: "${{ needs.prepare_release_package.outputs.source_sha }}",
package_version: "${{ needs.prepare_release_package.outputs.package_version }}",
workflow_ref: "${{ github.sha }}",
});
});
it("downloads and re-exports exact candidate artifacts only by immutable id", () => {
const workflow = readWorkflow(WORKFLOW_PATH);
for (const inputName of [
"candidate_artifact_digest",
"candidate_artifact_id",
"candidate_artifact_name",
"candidate_artifact_run_attempt",
"candidate_artifact_run_id",
"candidate_file_name",
"candidate_sha256",
"candidate_source_sha",
"candidate_version",
]) {
expect(workflow.on?.workflow_dispatch?.inputs?.[inputName], inputName).toMatchObject({
default: "",
type: "string",
});
expect(workflow.on?.workflow_call?.inputs?.[inputName], inputName).toMatchObject({
default: "",
type: "string",
});
}
const prepare = job(workflow, "prepare");
expect(prepare.outputs).toMatchObject({
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_sha256: "${{ steps.baseline_metadata.outputs.sha256 }}",
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_sha256: "${{ steps.candidate_metadata.outputs.sha256 }}",
candidate_version: "${{ steps.candidate_metadata.outputs.version }}",
source_sha: "${{ steps.candidate_metadata.outputs.source_sha }}",
});
for (const [jobName, workflowJob] of Object.entries(workflow.jobs)) {
for (const checkout of workflowJob.steps?.filter((candidate) =>
candidate.uses?.startsWith("actions/checkout@"),
) ?? []) {
expect(checkout.with?.["persist-credentials"], `${jobName}:${checkout.name}`).toBe(false);
}
}
const inputBinding = step(prepare, "Validate provided candidate artifact binding");
expect(inputBinding.env).toMatchObject({
ARTIFACT_DIGEST: "${{ inputs.candidate_artifact_digest }}",
ARTIFACT_ID: "${{ inputs.candidate_artifact_id }}",
ARTIFACT_RUN_ATTEMPT: "${{ inputs.candidate_artifact_run_attempt }}",
ARTIFACT_RUN_ID: "${{ inputs.candidate_artifact_run_id }}",
CANDIDATE_SHA256: "${{ inputs.candidate_sha256 }}",
CANDIDATE_SOURCE_SHA: "${{ inputs.candidate_source_sha }}",
CANDIDATE_VERSION: "${{ inputs.candidate_version }}",
});
expect(inputBinding.run).toContain('! "$ARTIFACT_ID" =~ ^[1-9][0-9]*$');
expect(inputBinding.run).toContain('! "$ARTIFACT_DIGEST" =~ ^[a-f0-9]{64}$');
expect(inputBinding.run).toContain(
'[[ "$ARTIFACT_NAME" == *"-${ARTIFACT_RUN_ID}-${ARTIFACT_RUN_ATTEMPT}" ]]',
);
expect(inputBinding.run).toContain('--arg digest "sha256:${ARTIFACT_DIGEST}"');
expect(inputBinding.run).toContain(
"actions/runs/${ARTIFACT_RUN_ID}/attempts/${ARTIFACT_RUN_ATTEMPT}",
);
expect(inputBinding.run).toContain('"$CANDIDATE_SOURCE_SHA" != "$INPUT_REF"');
const inputDownload = step(prepare, "Download provided candidate artifact");
expect(inputDownload.with).toMatchObject({
"artifact-ids": "${{ inputs.candidate_artifact_id }}",
"run-id": "${{ inputs.candidate_artifact_run_id }}",
});
expect(inputDownload.with?.name).toBeUndefined();
expect(
prepare.steps?.findIndex(
(candidate) => candidate.name === "Validate provided candidate artifact binding",
),
).toBeLessThan(
prepare.steps?.findIndex(
(candidate) => candidate.name === "Download provided candidate artifact",
) ?? -1,
);
const resolve = step(prepare, "Resolve provided candidate package");
expect(resolve.run).toContain("resolve-openclaw-package-candidate.mjs");
expect(resolve.run).toContain("--source artifact");
expect(resolve.run).toContain('--package-sha256 "$INPUT_CANDIDATE_SHA256"');
expect(resolve.run).toContain('"$actual_sha256" == "$INPUT_CANDIDATE_SHA256"');
expect(resolve.run).toContain('"$actual_source_sha" == "$INPUT_CANDIDATE_SOURCE_SHA"');
expect(resolve.run).toContain('"$actual_version" == "$INPUT_CANDIDATE_VERSION"');
const upload = step(prepare, "Upload candidate artifact");
expect(upload.id).toBe("upload_candidate");
expect(upload.with?.name).toBe(
"openclaw-cross-os-release-checks-candidate-${{ github.run_id }}-${{ github.run_attempt }}",
);
const baselineUpload = step(prepare, "Upload baseline artifact");
expect(baselineUpload.id).toBe("upload_baseline");
expect(baselineUpload.with?.name).toBe(
"openclaw-cross-os-release-checks-baseline-${{ github.run_id }}-${{ github.run_attempt }}",
);
const consumer = job(workflow, "cross_os_release_checks");
const binding = step(consumer, "Validate prepared candidate artifact binding");
expect(binding.env).toMatchObject({
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 }}",
});
expect(binding.run).not.toContain('"$ARTIFACT_RUN_ATTEMPT" == "$GITHUB_RUN_ATTEMPT"');
expect(binding.run).not.toContain('"$BASELINE_ARTIFACT_RUN_ATTEMPT" == "$GITHUB_RUN_ATTEMPT"');
expect(binding.run).toContain("actions/artifacts/${tuple.id}");
expect(binding.run).toContain("artifact.expired !== false");
expect(binding.run).toContain("artifact.digest !== `sha256:${tuple.digest}`");
expect(binding.run).toContain("String(artifact.workflow_run?.id) !== tuple.runId");
expect(binding.run).toContain("actions/runs/${tuple.runId}/attempts/${tuple.runAttempt}");
expect(binding.run).toContain("String(attempt.run_attempt) !== tuple.runAttempt");
for (const name of ["Download candidate artifact", "Retry candidate artifact download"]) {
const download = step(consumer, name);
expect(download.with?.["artifact-ids"], name).toBe(
"${{ needs.prepare.outputs.candidate_artifact_id }}",
);
expect(download.with?.["github-token"], name).toBe("${{ github.token }}");
expect(download.with?.["run-id"], name).toBe(
"${{ needs.prepare.outputs.candidate_artifact_run_id }}",
);
expect(download.with?.name, name).toBeUndefined();
}
for (const name of ["Download baseline artifact", "Retry baseline artifact download"]) {
const download = step(consumer, name);
expect(download.with?.["artifact-ids"], name).toBe(
"${{ needs.prepare.outputs.baseline_artifact_id }}",
);
expect(download.with?.["github-token"], name).toBe("${{ github.token }}");
expect(download.with?.["run-id"], name).toBe(
"${{ needs.prepare.outputs.baseline_artifact_run_id }}",
);
expect(download.with?.name, name).toBeUndefined();
}
const verify = step(consumer, "Verify release-check inputs");
expect(verify.env?.EXPECTED_CANDIDATE_SHA256).toBe(
"${{ needs.prepare.outputs.candidate_sha256 }}",
);
expect(verify.run).toContain('"$actual_sha256" != "$EXPECTED_CANDIDATE_SHA256"');
expect(verify.env?.EXPECTED_BASELINE_SHA256).toBe(
"${{ needs.prepare.outputs.baseline_sha256 }}",
);
expect(verify.run).toContain('"$actual_baseline_sha256" != "$EXPECTED_BASELINE_SHA256"');
});
it("executes the release harness directly with Node", () => {
const wrapper = readFileSync(WRAPPER_PATH, "utf8");
const script = readFileSync(SCRIPT_PATH, "utf8");
@@ -76,11 +76,10 @@ describe("extended-stable Full Release Validation workflow", () => {
expect(run).toContain(child.target);
}
expect(fullValidation).toContain("PARENT_WORKFLOW_SHA: ${{ github.sha }}");
expect(fullValidation).toContain('if [[ "$head_sha" != "$PARENT_WORKFLOW_SHA" ]]');
expect(fullValidation).toContain(
'"$CHILD_WORKFLOW_REF" =~ ^extended-stable/[0-9]{4}\\.([1-9]|1[0-2])\\.33$',
);
expect(fullValidation).toContain(
"Dispatch Full Release Validation from a release-ci or extended-stable ref pinned to the target SHA",
"child run used workflow SHA ${head_sha}, expected parent workflow SHA ${PARENT_WORKFLOW_SHA}",
);
});
@@ -80,12 +80,22 @@ describe("OpenClaw performance workflow", () => {
expect(workflow).toContain("Optional parent workflow dispatch identifier");
});
it("pins the Kova evaluator that reads agent payloads", () => {
it("pins the Kova evaluator with release validation contracts", () => {
const workflow = readFileSync(WORKFLOW, "utf8");
const kovaRef = "a2dd84e7d65507e614afaff850d3932d18c859b6";
const kovaRef = "24c26969e57d4d49f9d1a5071af85dd3d79daa2d";
const install = findStep("Install OCM and Kova");
const installRun = install.run ?? "";
expect(workflow).toContain(`default: ${kovaRef}`);
expect(workflow).toContain(`inputs.kova_ref || '${kovaRef}'`);
expect(installRun).toContain(
'npm --prefix "$KOVA_SRC" ci --ignore-scripts --no-audit --no-fund',
);
expect(installRun).toContain('for (const dependency of ["mock-ai-provider", "zod"])');
expect(installRun).toContain("require.resolve(dependency, { paths: [root] })");
expect(
installRun.indexOf('npm --prefix "$KOVA_SRC" ci --ignore-scripts --no-audit --no-fund'),
).toBeLessThan(installRun.indexOf('cat > "$HOME/.local/bin/kova"'));
});
it("resolves each target once before benchmark and publication fan out", () => {
@@ -144,7 +154,7 @@ describe("OpenClaw performance workflow", () => {
expect(publisher?.needs).toEqual(["resolve_target", "kova"]);
expect(publisher?.if).toBe(
"${{ always() && needs.resolve_target.result == 'success' && needs.kova.result != 'cancelled' }}",
"${{ always() && (github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.publish_reports == true)) && needs.resolve_target.result == 'success' && needs.kova.result != 'cancelled' }}",
);
expect(publisher?.["runs-on"]).toBe("ubuntu-24.04");
expect(publisher?.permissions?.actions).toBe("read");
@@ -160,6 +170,32 @@ describe("OpenClaw performance workflow", () => {
expect(pushIndex).toBeGreaterThan(appTokenIndex);
});
it("keeps report publication opt-out artifact-only for final release validation", () => {
const workflowText = readFileSync(WORKFLOW, "utf8");
const fullReleaseText = readFileSync(".github/workflows/full-release-validation.yml", "utf8");
const publisher = readWorkflow().jobs?.publish;
expect(workflowText).toContain("publish_reports:");
expect(workflowText).toContain("default: true");
expect(publisher?.if).toContain("inputs.publish_reports == true");
expect(fullReleaseText).toContain("-f publish_reports=false");
expect(fullReleaseText).toContain("Report publication: disabled (artifacts only)");
});
it("fails closed when artifact-only mode does not keep the publisher skipped", () => {
const guard = readWorkflow().jobs?.artifact_only_guard;
const verify = findStep("Verify report publisher stayed disabled", "artifact_only_guard");
expect(guard?.needs).toEqual(["resolve_target", "kova", "publish"]);
expect(guard?.if).toBe(
"${{ always() && github.event_name == 'workflow_dispatch' && inputs.publish_reports != true }}",
);
expect(guard?.permissions?.contents).toBe("read");
expect(verify.env?.PUBLISH_RESULT).toBe("${{ needs.publish.result }}");
expect(verify.run).toContain('[[ "$PUBLISH_RESULT" != "skipped" ]]');
expect(verify.run).toContain("Artifact-only performance mode requires");
});
it("mints only a short-lived repo-scoped ClawSweeper app token", () => {
const workflowText = readFileSync(WORKFLOW, "utf8");
const publisher = readWorkflow().jobs?.publish;
+200 -19
View File
@@ -140,6 +140,8 @@ function expectTextToIncludeAll(text: string | undefined, snippets: string[]): v
function runPackageAcceptanceSummary(params: {
advisory?: boolean;
dockerArtifactResult?: string;
dockerRegistryResult?: string;
telegramEnabled: boolean;
telegramResult: string;
}) {
@@ -152,7 +154,8 @@ function runPackageAcceptanceSummary(params: {
encoding: "utf8",
env: {
ADVISORY: String(params.advisory ?? false),
DOCKER_RESULT: "success",
DOCKER_ARTIFACT_RESULT: params.dockerArtifactResult ?? "success",
DOCKER_REGISTRY_RESULT: params.dockerRegistryResult ?? "skipped",
PACKAGE_INTEGRITY_RESULT: "success",
PACKAGE_TELEGRAM_RESULT: params.telegramResult,
PATH: process.env.PATH,
@@ -162,6 +165,34 @@ function runPackageAcceptanceSummary(params: {
});
}
function runNpmTelegramInputValidation(overrides: Record<string, string>) {
const job = workflowJob(NPM_TELEGRAM_WORKFLOW, "run_package_telegram_e2e");
const script = workflowStep(job, "Validate inputs and secrets").run;
if (!script) {
throw new Error("Expected npm Telegram input validation script");
}
return spawnSync("bash", ["-c", script], {
encoding: "utf8",
env: {
OPENCLAW_QA_CONVEX_SECRET_CI: "test-secret",
OPENCLAW_QA_CONVEX_SITE_URL: "https://example.invalid",
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_SPEC: "openclaw@beta",
PACKAGE_VERSION: "",
PATH: process.env.PATH,
PROVIDER_MODE: "mock-openai",
...overrides,
},
});
}
describe("package acceptance workflow", () => {
it("verifies immutable postpublish evidence before stable closeout reads it", () => {
const workflow = readFileSync(STABLE_MAIN_CLOSEOUT_WORKFLOW, "utf8");
@@ -492,7 +523,8 @@ describe("package acceptance workflow", () => {
expect(workflow).toContain('--trusted-source-id "$TRUSTED_SOURCE_ID"');
expect(workflow).toContain("scripts/resolve-openclaw-package-candidate.mjs");
expect(workflow).toContain('--package-ref "$PACKAGE_REF"');
expect(workflow).toContain('gh run download "$ARTIFACT_RUN_ID"');
expect(workflow).toContain("artifact-ids: ${{ inputs.artifact_id }}");
expect(workflow).toContain("actions/artifacts/${ARTIFACT_ID}");
expect(workflow).toContain("name: ${{ env.PACKAGE_ARTIFACT_NAME }}");
expect(workflow).toContain("pull-requests: read");
expect(workflow).toContain(
@@ -506,9 +538,8 @@ describe("package acceptance workflow", () => {
);
expect(workflow).toContain("package_integrity:");
expect(workflow).toContain("name: Package integrity");
expect(workflow).toContain(
"node scripts/check-openclaw-package-tarball.mjs .artifacts/docker-e2e-package/openclaw-current.tgz",
);
expect(workflow).toContain('node scripts/check-openclaw-package-tarball.mjs "$package"');
expect(workflow).toContain('[[ "$actual_sha256" == "$EXPECTED_PACKAGE_SHA256" ]]');
expect(workflow).toContain("needs: [resolve_package, package_integrity]");
expect(workflow).toContain("package_integrity=${PACKAGE_INTEGRITY_RESULT}");
});
@@ -544,6 +575,30 @@ describe("package acceptance workflow", () => {
expect(workflow).toContain(
"package_artifact_name: ${{ needs.resolve_package.outputs.package_artifact_name }}",
);
expect(workflow).toContain(
"package_artifact_digest: ${{ needs.resolve_package.outputs.package_artifact_digest }}",
);
expect(workflow).toContain(
"package_artifact_id: ${{ needs.resolve_package.outputs.package_artifact_id }}",
);
expect(workflow).toContain(
"package_artifact_run_attempt: ${{ needs.resolve_package.outputs.package_artifact_run_attempt }}",
);
expect(workflow).toContain(
"package_artifact_run_id: ${{ needs.resolve_package.outputs.package_artifact_run_id }}",
);
expect(workflow).toContain(
"package_file_name: ${{ needs.resolve_package.outputs.package_file_name }}",
);
expect(workflow).toContain(
"package_sha256: ${{ needs.resolve_package.outputs.package_sha256 }}",
);
expect(workflow).toContain(
"package_source_sha: ${{ needs.resolve_package.outputs.package_source_sha }}",
);
expect(workflow).toContain(
"package_version: ${{ needs.resolve_package.outputs.package_version }}",
);
expect(workflow).toContain("telegram_scenarios:");
expect(workflow).toContain("scenario: ${{ inputs.telegram_scenarios }}");
expect(workflow).toContain(
@@ -573,7 +628,7 @@ describe("package acceptance workflow", () => {
expect(workflow).toContain("Published upgrade survivor scenarios:");
});
it("requires pinned full release child workflows to run at the resolved target SHA", () => {
it("requires full release child workflows to run at the parent workflow SHA", () => {
const workflow = readFileSync(FULL_RELEASE_VALIDATION_WORKFLOW, "utf8");
const releaseChecksWorkflow = readFileSync(RELEASE_CHECKS_WORKFLOW, "utf8");
const performanceJob = workflow.slice(
@@ -583,6 +638,7 @@ describe("package acceptance workflow", () => {
expect(workflow).toContain("TARGET_SHA: ${{ needs.resolve_target.outputs.sha }}");
expect(workflow).toContain("CHILD_WORKFLOW_REF: ${{ github.ref_name }}");
expect(workflow).toContain("PARENT_WORKFLOW_SHA: ${{ github.sha }}");
expect(workflow).toContain("release_package_spec:");
expect(workflow).toContain('args+=(-f release_package_spec="$RELEASE_PACKAGE_SPEC")');
expect(workflow).toContain("package_acceptance_package_spec:");
@@ -599,8 +655,12 @@ describe("package acceptance workflow", () => {
);
expect(workflow).toContain("--json status,conclusion,url,attempt,headSha,jobs");
expect(workflow).toContain(
'[[ ( "$CHILD_WORKFLOW_REF" == release-ci/* || "$CHILD_WORKFLOW_REF" =~ ^extended-stable/[0-9]{4}\\.([1-9]|1[0-2])\\.33$ ) && -n "${TARGET_SHA// }" && "$head_sha" != "$TARGET_SHA" ]]',
'gh_with_retry api "repos/${GITHUB_REPOSITORY}/commits/${encoded_workflow_ref}" --jq .sha',
);
expect(workflow).toContain(
"Child workflow ref ${CHILD_WORKFLOW_REF} moved to ${current_workflow_sha}, expected ${PARENT_WORKFLOW_SHA}; refusing dispatch.",
);
expect(workflow).toContain('if [[ "$head_sha" != "$PARENT_WORKFLOW_SHA" ]]; then');
expect(workflow).toContain('gh workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@" 2>&1');
expect(performanceJob).toContain(
'dispatch_id="full-release-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"',
@@ -615,9 +675,11 @@ describe("package acceptance workflow", () => {
expect(performanceJob).not.toContain(
"did not return an Actions run URL; refusing to guess from recent workflow_dispatch runs",
);
expect(workflow).toContain("child run used ${head_sha}, expected ${TARGET_SHA}");
expect(workflow).toContain(
"Dispatch Full Release Validation from a release-ci or extended-stable ref pinned to the target SHA",
"child run used workflow SHA ${head_sha}, expected parent workflow SHA ${PARENT_WORKFLOW_SHA}",
);
expect(workflow).toContain(
"Use the SHA-pinned release helper when a moving branch cannot stay fixed",
);
expect(workflow).toContain("| Child | Result | Minutes | Head SHA | Run |");
expect(releaseChecksWorkflow).toContain("refs/heads/release-ci/[0-9a-f]{12}-[0-9]+");
@@ -647,6 +709,10 @@ describe("package acceptance workflow", () => {
expect(script).not.toContain("gh_with_retry workflow run");
expectTextToIncludeAll(script, [
"A failed dispatch POST can still create a run. Never retry it",
'encoded_workflow_ref="$(jq -rn --arg value "$CHILD_WORKFLOW_REF"',
'gh_with_retry api "repos/${GITHUB_REPOSITORY}/commits/${encoded_workflow_ref}" --jq .sha',
'"$current_workflow_sha" != "$PARENT_WORKFLOW_SHA"',
"refusing dispatch.",
"set +e",
"dispatch_status=$?",
'if [[ "$dispatch_status" -ne 0 && ! "$dispatch_output" =~ $GH_TRANSIENT_SERVER_OR_NETWORK_PATTERN ]]',
@@ -716,7 +782,7 @@ describe("package acceptance workflow", () => {
const workflow = readFileSync(FULL_RELEASE_VALIDATION_WORKFLOW, "utf8");
const retryCalls = workflow.split("\n").filter((line) => line.includes("gh_with_retry "));
expect(retryCalls).toHaveLength(30);
expect(retryCalls).toHaveLength(37);
for (const call of retryCalls) {
expect(call).toMatch(/gh_with_retry (api|run view)/u);
}
@@ -760,6 +826,57 @@ describe("package acceptance workflow", () => {
});
describe("package artifact reuse", () => {
it("binds package acceptance input artifacts to the complete producer tuple", () => {
const resolvePackage = workflowJob(PACKAGE_ACCEPTANCE_WORKFLOW, "resolve_package");
expect(
workflowStep(resolvePackage, "Checkout package workflow ref").with?.["persist-credentials"],
).toBe(false);
const identity = workflowStep(resolvePackage, "Validate package artifact input identity");
expect(identity.env).toMatchObject({
ARTIFACT_DIGEST: "${{ inputs.artifact_digest }}",
ARTIFACT_ID: "${{ inputs.artifact_id }}",
ARTIFACT_NAME: "${{ inputs.artifact_name }}",
ARTIFACT_RUN_ATTEMPT: "${{ inputs.artifact_run_attempt }}",
ARTIFACT_RUN_ID: "${{ inputs.artifact_run_id }}",
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 }}",
});
expectTextToIncludeAll(identity.run, [
"source=artifact requires the complete immutable artifact and package identity tuple.",
'[[ "$ARTIFACT_NAME" == *"-${ARTIFACT_RUN_ID}-${ARTIFACT_RUN_ATTEMPT}" ]]',
'--arg digest "sha256:${ARTIFACT_DIGEST}"',
"actions/runs/${ARTIFACT_RUN_ID}/attempts/${ARTIFACT_RUN_ATTEMPT}",
]);
expect(workflowStep(resolvePackage, "Download package artifact input").with).toMatchObject({
"artifact-ids": "${{ inputs.artifact_id }}",
"github-token": "${{ github.token }}",
"run-id": "${{ inputs.artifact_run_id }}",
});
const resolve = workflowStep(resolvePackage, "Resolve package candidate");
expect(resolve.env).toMatchObject({
PACKAGE_FILE_NAME: "${{ inputs.package_file_name }}",
PACKAGE_SHA256: "${{ inputs.package_sha256 }}",
PACKAGE_SOURCE_SHA: "${{ inputs.package_source_sha }}",
PACKAGE_VERSION: "${{ inputs.package_version }}",
});
expectTextToIncludeAll(resolve.run, [
'artifact_tarball="${artifact_dir}/${PACKAGE_FILE_NAME}"',
"Selected artifact package SHA-256 differs from package_sha256.",
"Resolved package identity differs from the declared immutable tuple.",
]);
const packageIntegrity = workflowJob(PACKAGE_ACCEPTANCE_WORKFLOW, "package_integrity");
expect(
workflowStep(packageIntegrity, "Download package-under-test artifact").with,
).toMatchObject({
"artifact-ids": "${{ needs.resolve_package.outputs.package_artifact_id }}",
"github-token": "${{ github.token }}",
"run-id": "${{ needs.resolve_package.outputs.package_artifact_run_id }}",
});
});
it("lets reusable Docker E2E consume an already resolved package artifact", () => {
const workflow = readFileSync(LIVE_E2E_WORKFLOW, "utf8");
const packageJson = readFileSync(PACKAGE_JSON, "utf8");
@@ -767,7 +884,14 @@ describe("package artifact reuse", () => {
const publishedUpgradeSurvivor = readFileSync(UPGRADE_SURVIVOR_RUN_SCRIPT, "utf8");
expect(workflow).toContain("package_artifact_name:");
expect(workflow).toContain("package_artifact_digest:");
expect(workflow).toContain("package_artifact_id:");
expect(workflow).toContain("package_artifact_run_attempt:");
expect(workflow).toContain("package_artifact_run_id:");
expect(workflow).toContain("package_file_name:");
expect(workflow).toContain("package_source_sha:");
expect(workflow).toContain("package_sha256:");
expect(workflow).toContain("package_version:");
expect(workflow).toContain("published_upgrade_survivor_baseline:");
expect(workflow).toContain("published_upgrade_survivor_baselines:");
expect(workflow).toContain("published_upgrade_survivor_scenarios:");
@@ -785,14 +909,19 @@ describe("package artifact reuse", () => {
);
expect(workflow).toContain("Download current-run OpenClaw Docker E2E package");
expect(workflow).toContain("Download previous-run OpenClaw Docker E2E package");
expect(workflow).toContain("inputs.package_artifact_name != ''");
expect(workflow).toContain("inputs.package_artifact_id != ''");
expect(workflow).toContain(
'bare_image="${PROVIDED_BARE_IMAGE:-ghcr.io/${repository}-docker-e2e-bare:${image_tag}}"',
);
expect(workflow).toContain(
'functional_image="${PROVIDED_FUNCTIONAL_IMAGE:-ghcr.io/${repository}-docker-e2e-functional:${image_tag}}"',
);
expect(workflow).toContain("name: ${{ inputs.package_artifact_name || 'docker-e2e-package' }}");
expect(workflow).toContain("artifact-ids: ${{ inputs.package_artifact_id }}");
expect(workflow).toContain(
'[[ "$ARTIFACT_NAME" == *"-${ARTIFACT_RUN_ID}-${ARTIFACT_RUN_ATTEMPT}" ]]',
);
expect(workflow).toContain('--arg digest "sha256:${ARTIFACT_DIGEST}"');
expect(workflow).toContain("actions/runs/${ARTIFACT_RUN_ID}/attempts/${ARTIFACT_RUN_ATTEMPT}");
expect(workflow).not.toContain("uses: ./.github/actions/docker-e2e-plan");
expect(workflow).toContain("Checkout trusted release harness");
expect(workflow).toContain("OPENCLAW_DOCKER_E2E_REPO_ROOT:");
@@ -1904,9 +2033,7 @@ describe("package artifact reuse", () => {
const workflow = readFileSync(PACKAGE_ACCEPTANCE_WORKFLOW, "utf8");
expect(workflow).toContain("package_telegram:");
expect(workflow).toContain(
"needs: [resolve_package, package_integrity, docker_acceptance, package_telegram]",
);
expect(workflow).toContain("docker_acceptance_registry,");
expect(workflow).toContain("PACKAGE_TELEGRAM_RESULT:");
expect(workflow).toContain("package_telegram=${PACKAGE_TELEGRAM_RESULT}");
expect(workflow).not.toContain("npm_telegram:");
@@ -1935,6 +2062,18 @@ describe("package artifact reuse", () => {
expect(result.stdout).toContain("::error::package_telegram ended with skipped");
});
it("rejects package acceptance when no Docker transport ran", () => {
const result = runPackageAcceptanceSummary({
dockerArtifactResult: "skipped",
dockerRegistryResult: "skipped",
telegramEnabled: false,
telegramResult: "skipped",
});
expect(result.status).toBe(1);
expect(result.stdout).toContain("::error::No Docker acceptance transport ran");
});
it("preserves advisory handling for an unexpectedly skipped Telegram lane", () => {
const result = runPackageAcceptanceSummary({
advisory: true,
@@ -1985,6 +2124,7 @@ describe("package artifact reuse", () => {
CHILD_WORKFLOW_REF: "${{ github.ref_name }}",
GH_TOKEN: "${{ github.token }}",
PACKAGE_SPEC: "${{ inputs.npm_telegram_package_spec || inputs.release_package_spec }}",
PARENT_WORKFLOW_SHA: "${{ github.sha }}",
PROVIDER_MODE: "${{ inputs.npm_telegram_provider_mode }}",
SCENARIO: "${{ inputs.npm_telegram_scenario }}",
TARGET_SHA: "${{ needs.resolve_target.outputs.sha }}",
@@ -1994,6 +2134,7 @@ describe("package artifact reuse", () => {
'dispatch_output="$(gh workflow run npm-telegram-beta-e2e.yml --ref "$CHILD_WORKFLOW_REF" "${args[@]}" 2>&1)"',
".display_title == env.DISPATCH_RUN_NAME and .head_branch == env.CHILD_WORKFLOW_REF",
"The dispatch was not retried to avoid creating a duplicate child.",
'if [[ "$child_head_sha" != "$PARENT_WORKFLOW_SHA" ]]; then',
'-f harness_ref="$TARGET_SHA"',
'args=(-f package_spec="$PACKAGE_SPEC"',
'args+=(-f scenario="$SCENARIO")',
@@ -2056,45 +2197,85 @@ describe("package artifact reuse", () => {
"Download package-under-test artifact from release run",
);
const validateStep = workflowStep(job, "Validate inputs and secrets");
const identityStep = workflowStep(job, "Validate package artifact identity");
const runStep = workflowStep(job, "Run package Telegram E2E");
expect(currentRunDownload).toEqual({
if: "inputs.package_artifact_name != '' && inputs.package_artifact_run_id == ''",
if: "inputs.package_artifact_name != '' && inputs.package_artifact_run_id == github.run_id",
name: "Download package-under-test artifact",
uses: DOWNLOAD_ARTIFACT_V8,
with: {
name: "${{ inputs.package_artifact_name }}",
"artifact-ids": "${{ inputs.package_artifact_id }}",
"github-token": "${{ github.token }}",
path: ".artifacts/telegram-package-under-test",
"run-id": "${{ inputs.package_artifact_run_id }}",
},
});
expect(releaseRunDownload).toEqual({
if: "inputs.package_artifact_name != '' && inputs.package_artifact_run_id != ''",
if: "inputs.package_artifact_name != '' && inputs.package_artifact_run_id != github.run_id",
name: "Download package-under-test artifact from release run",
uses: DOWNLOAD_ARTIFACT_V8,
with: {
"artifact-ids": "${{ inputs.package_artifact_id }}",
"github-token": "${{ github.token }}",
name: "${{ inputs.package_artifact_name }}",
path: ".artifacts/telegram-package-under-test",
"run-id": "${{ inputs.package_artifact_run_id }}",
},
});
expectTextToIncludeAll(validateStep.run, [
'if [[ -z "${PACKAGE_ARTIFACT_NAME// }" ]]; then',
"Artifact-backed Telegram E2E requires all artifact identity fields or none.",
"package_spec must be openclaw@alpha",
"Artifact-backed Telegram E2E requires the complete immutable artifact and package identity tuple.",
]);
expect(identityStep.env).toMatchObject({
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 }}",
});
expectTextToIncludeAll(identityStep.run, [
'[[ "$ARTIFACT_NAME" == *"-${ARTIFACT_RUN_ID}-${ARTIFACT_RUN_ATTEMPT}" ]]',
"actions/artifacts/${ARTIFACT_ID}",
'--arg digest "sha256:${ARTIFACT_DIGEST}"',
"actions/runs/${ARTIFACT_RUN_ID}/attempts/${ARTIFACT_RUN_ATTEMPT}",
"Package Telegram artifact producer run attempt does not match the requested tuple.",
]);
expect(runStep.env).toMatchObject({
PACKAGE_FILE_NAME: "${{ inputs.package_file_name || '' }}",
PACKAGE_SHA256: "${{ inputs.package_sha256 || '' }}",
PACKAGE_SOURCE_SHA: "${{ inputs.package_source_sha || '' }}",
PACKAGE_VERSION: "${{ inputs.package_version || '' }}",
});
expectTextToIncludeAll(runStep.run, [
'declared_package_tgz="${package_dir}/${PACKAGE_FILE_NAME}"',
'manifest="${package_dir}/preflight-manifest.json"',
'candidate_manifest="${package_dir}/package-candidate.json"',
'find "${package_dir}" -type f -name "*.tgz"',
"package artifact manifest contains duplicate package metadata",
"package artifact tarball set does not match preflight manifest",
"package candidate manifest does not match the OpenClaw tarball",
"Package Telegram artifact SHA-256 differs from package_sha256.",
"package candidate digest mismatch",
"Package Telegram artifact tarball differs from package_file_name.",
"Package Telegram artifact source SHA/version differs from the declared identity.",
'export OPENCLAW_NPM_TELEGRAM_PACKAGE_DIR="${package_dir}"',
'export OPENCLAW_NPM_TELEGRAM_PACKAGE_TGZ="${package_tgz}"',
]);
});
it("rejects partial npm Telegram artifact identity instead of falling back to npm", () => {
const result = runNpmTelegramInputValidation({
PACKAGE_ARTIFACT_ID: "123",
});
expect(result.status).toBe(1);
expect(result.stderr).toContain(
"Artifact-backed Telegram E2E requires all artifact identity fields or none.",
);
});
it("lets CI Telegram consumers wait on Convex leases instead of GitHub concurrency", () => {
const telegramJobs = [
[NPM_TELEGRAM_WORKFLOW, "run_package_telegram_e2e", "Run package Telegram E2E"],
@@ -287,7 +287,7 @@ describe("scripts/lib/plugin-prerelease-test-plan.mjs", () => {
with: {
"fetch-depth": 1,
"fetch-tags": false,
"persist-credentials": true,
"persist-credentials": false,
ref: "${{ needs.preflight.outputs.checkout_revision }}",
submodules: false,
},
@@ -487,7 +487,7 @@ describe("scripts/lib/plugin-prerelease-test-plan.mjs", () => {
permissions: {
actions: "read",
contents: "read",
packages: "write",
packages: "read",
"pull-requests": "read",
},
uses: "./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml",
@@ -499,6 +499,8 @@ describe("scripts/lib/plugin-prerelease-test-plan.mjs", () => {
include_repo_e2e: false,
live_models_only: false,
ref: "${{ needs.preflight.outputs.checkout_revision }}",
shared_image_artifact_namespace: "plugin-prerelease",
shared_image_policy: "no-push-artifact",
targeted_docker_lane_group_size: 4,
},
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,788 @@
import { spawnSync } from "node:child_process";
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { parse } from "yaml";
const FULL_RELEASE = ".github/workflows/full-release-validation.yml";
const RELEASE_CHECKS = ".github/workflows/openclaw-release-checks.yml";
const PACKAGE_ACCEPTANCE = ".github/workflows/package-acceptance.yml";
const PLUGIN_PRERELEASE = ".github/workflows/plugin-prerelease.yml";
const LIVE_E2E = ".github/workflows/openclaw-live-and-e2e-checks-reusable.yml";
const PERFORMANCE = ".github/workflows/openclaw-performance.yml";
const LIVE_BUILD = "scripts/test-live-build-docker.sh";
const DOCKER_E2E_IMAGE_HELPER = "scripts/lib/docker-e2e-image.sh";
type WorkflowInput = {
default?: boolean | number | string;
options?: string[];
type?: string;
};
type WorkflowStep = {
env?: Record<string, string>;
id?: string;
if?: string;
name?: string;
run?: string;
uses?: string;
with?: Record<string, boolean | number | string>;
};
type WorkflowJob = {
env?: Record<string, string>;
if?: string;
needs?: string | string[];
outputs?: Record<string, string>;
permissions?: Record<string, string>;
steps?: WorkflowStep[];
uses?: string;
with?: Record<string, boolean | number | string>;
};
type Workflow = {
jobs?: Record<string, WorkflowJob>;
on?: {
workflow_call?: { inputs?: Record<string, WorkflowInput> };
workflow_dispatch?: { inputs?: Record<string, WorkflowInput> };
};
permissions?: Record<string, string>;
};
function readWorkflow(path: string): Workflow {
return parse(readFileSync(path, "utf8")) as Workflow;
}
function job(workflow: Workflow, name: string): WorkflowJob {
const value = workflow.jobs?.[name];
if (!value) {
throw new Error(`missing workflow job ${name}`);
}
return value;
}
function step(workflowJob: WorkflowJob, name: string): WorkflowStep {
const value = workflowJob.steps?.find((candidate) => candidate.name === name);
if (!value) {
throw new Error(`missing workflow step ${name}`);
}
return value;
}
function expectReadOnlyPackagePermission(workflowJob: WorkflowJob): void {
expect(workflowJob.permissions?.packages).toBe("read");
}
describe("release validation no-push transport", () => {
it("does not persist Git credentials in validation checkouts", () => {
for (const workflowPath of [PLUGIN_PRERELEASE, RELEASE_CHECKS]) {
const workflow = readWorkflow(workflowPath);
const checkoutSteps = Object.values(workflow.jobs ?? {}).flatMap(
(workflowJob) =>
workflowJob.steps?.filter((candidate) =>
candidate.uses?.startsWith("actions/checkout@"),
) ?? [],
);
expect(checkoutSteps, workflowPath).not.toHaveLength(0);
for (const checkout of checkoutSteps) {
expect(checkout.with?.["persist-credentials"], `${workflowPath}:${checkout.name}`).toBe(
false,
);
}
}
});
it("runs evidence reuse from an immutable trusted-main workflow checkout", () => {
const full = readWorkflow(FULL_RELEASE);
for (const jobName of ["resolve_target", "evidence_reuse"]) {
const checkout = step(job(full, jobName), "Checkout trusted workflow helper");
expect(checkout.with?.ref, jobName).toBe("${{ github.sha }}");
expect(checkout.with?.ref, jobName).not.toBe("${{ github.ref_name }}");
expect(checkout.with?.["persist-credentials"], jobName).toBe(false);
}
const evidenceReuse = job(full, "evidence_reuse");
expect(step(evidenceReuse, "Checkout target SHA").with?.["persist-credentials"]).toBe(false);
const dockerAssets = job(full, "docker_runtime_assets_preflight");
expect(step(dockerAssets, "Checkout target SHA").with?.["persist-credentials"]).toBe(false);
expect(evidenceReuse.if).toContain("github.ref == 'refs/heads/main'");
expect(
evidenceReuse.steps?.find(
(candidate) => candidate.name === "Require trusted main workflow ref",
),
).toBeUndefined();
const releaseChecks = readWorkflow(RELEASE_CHECKS);
const releaseHelper = step(
job(releaseChecks, "resolve_target"),
"Checkout trusted workflow helper",
);
expect(releaseHelper.with?.ref).toBe("${{ github.sha }}");
expect(releaseHelper.with?.ref).not.toBe("${{ github.ref_name }}");
expect(releaseHelper.with?.["persist-credentials"]).toBe(false);
});
it("rejects every child whose workflow SHA differs from the parent workflow SHA", () => {
const full = readWorkflow(FULL_RELEASE);
for (const [jobName, stepName] of [
["normal_ci", "Dispatch and monitor CI"],
["plugin_prerelease", "Dispatch and monitor plugin prerelease"],
["release_checks", "Dispatch and monitor release checks"],
["npm_telegram", "Dispatch and monitor npm Telegram E2E"],
["performance", "Dispatch and monitor OpenClaw Performance"],
] as const) {
const dispatch = step(job(full, jobName), stepName);
expect(dispatch.env?.PARENT_WORKFLOW_SHA, jobName).toBe("${{ github.sha }}");
expect(dispatch.run, jobName).toContain('"$child_head_sha" != "$PARENT_WORKFLOW_SHA"');
expect(dispatch.run, jobName).toContain("expected parent workflow SHA");
}
const verify = step(job(full, "summary"), "Verify child workflow results");
expect(verify.env?.PARENT_WORKFLOW_SHA).toBe("${{ github.sha }}");
expect(verify.run).toContain('"$head_sha" != "$PARENT_WORKFLOW_SHA"');
expect(verify.run).not.toContain('"$head_sha" != "$TARGET_SHA"');
});
it("publishes an attempt-qualified canonical manifest plus a temporary legacy alias", () => {
const summary = job(readWorkflow(FULL_RELEASE), "summary");
expect(step(summary, "Upload release validation manifest").with).toMatchObject({
name: "full-release-validation-${{ github.run_id }}-${{ github.run_attempt }}",
});
expect(step(summary, "Upload legacy release validation manifest alias").with).toMatchObject({
name: "full-release-validation-${{ github.run_id }}",
overwrite: true,
});
});
it("pins every Full Release Docker caller to artifact-only transport", () => {
const fullText = readFileSync(FULL_RELEASE, "utf8");
const release = readWorkflow(RELEASE_CHECKS);
const packageAcceptance = readWorkflow(PACKAGE_ACCEPTANCE);
const pluginPrerelease = readWorkflow(PLUGIN_PRERELEASE);
expect(fullText).toContain("dispatch_and_wait plugin-prerelease.yml");
expect(fullText).toContain("dispatch_and_wait openclaw-release-checks.yml");
expect(fullText).toContain("gh workflow run openclaw-performance.yml");
const preparePackage = job(release, "prepare_release_package");
const live = job(release, "live_repo_e2e_release_checks");
const docker = job(release, "docker_e2e_release_checks");
const acceptance = job(release, "package_acceptance_release_checks");
expectReadOnlyPackagePermission(preparePackage);
expectReadOnlyPackagePermission(live);
expectReadOnlyPackagePermission(docker);
expectReadOnlyPackagePermission(acceptance);
expect(step(preparePackage, "Resolve release package artifact").run).toContain(
'if [[ "$source_sha" != "$PACKAGE_REF" ]]',
);
expect(live.with).toMatchObject({
shared_image_artifact_namespace: "release-live",
shared_image_policy: "no-push-artifact",
});
expect(docker.with).toMatchObject({
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 }}",
shared_image_artifact_namespace: "release-docker",
shared_image_policy: "no-push-artifact",
});
expect(acceptance.with).toMatchObject({
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_source_sha: "${{ needs.prepare_release_package.outputs.source_sha }}",
package_version: "${{ needs.prepare_release_package.outputs.package_version }}",
shared_image_artifact_namespace: "release-package",
shared_image_policy: "no-push-artifact",
});
const standardAcceptance = job(packageAcceptance, "docker_acceptance");
const registryAcceptance = job(packageAcceptance, "docker_acceptance_registry");
expect(packageAcceptance.permissions?.packages).toBe("read");
expect(packageAcceptance.on?.workflow_dispatch?.inputs?.shared_image_policy).toMatchObject({
default: "allow-push",
options: ["allow-push", "existing-only", "no-push-artifact"],
type: "choice",
});
expect(packageAcceptance.on?.workflow_call?.inputs?.shared_image_policy).toMatchObject({
default: "allow-push",
type: "string",
});
expect(standardAcceptance.with?.shared_image_policy).toBe("${{ inputs.shared_image_policy }}");
expect(standardAcceptance.with?.shared_image_artifact_namespace).toBe(
"${{ inputs.shared_image_artifact_namespace }}",
);
expect(standardAcceptance.with).toMatchObject({
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 }}",
});
expect(standardAcceptance.if).toContain("shared_image_policy == 'no-push-artifact'");
expectReadOnlyPackagePermission(standardAcceptance);
expect(registryAcceptance.if).toContain("shared_image_policy != 'no-push-artifact'");
expect(registryAcceptance.permissions?.packages).toBe("write");
const pluginDocker = job(pluginPrerelease, "plugin-prerelease-docker-suite");
expectReadOnlyPackagePermission(pluginDocker);
expect(pluginDocker.with).toMatchObject({
shared_image_artifact_namespace: "plugin-prerelease",
shared_image_policy: "no-push-artifact",
});
expect(
new Set([
live.with?.shared_image_artifact_namespace,
docker.with?.shared_image_artifact_namespace,
acceptance.with?.shared_image_artifact_namespace,
pluginDocker.with?.shared_image_artifact_namespace,
]).size,
).toBe(4);
});
it("builds shared images locally, verifies artifacts, and cannot fall back to a registry", () => {
const workflow = readWorkflow(LIVE_E2E);
const dispatchPolicy = workflow.on?.workflow_dispatch?.inputs?.shared_image_policy;
const callPolicy = workflow.on?.workflow_call?.inputs?.shared_image_policy;
expect(dispatchPolicy).toMatchObject({
default: "allow-push",
options: ["allow-push", "existing-only", "no-push-artifact"],
});
expect(callPolicy).toMatchObject({ default: "allow-push", type: "string" });
const validation = job(workflow, "validate_selected_ref");
expect(validation.outputs?.workflow_repository).toBe(
"${{ steps.workflow.outputs.workflow_repository }}",
);
expect(validation.outputs?.workflow_sha).toBe("${{ steps.workflow.outputs.workflow_sha }}");
const workflowIdentity = step(validation, "Resolve job workflow identity");
expect(workflowIdentity.env?.JOB_CONTEXT).toBe("${{ toJSON(job) }}");
expect(workflowIdentity.run).toContain(
"job.workflow_repository must be an owner/repository slug",
);
expect(workflowIdentity.run).toContain("job.workflow_sha must be a full lowercase commit SHA");
const trustedCheckouts = Object.entries(workflow.jobs ?? {}).flatMap(([jobName, workflowJob]) =>
(workflowJob.steps ?? [])
.filter((candidate) => candidate.name?.startsWith("Checkout trusted "))
.map((candidate) => ({ candidate, jobName })),
);
expect(trustedCheckouts).toHaveLength(12);
for (const { candidate, jobName } of trustedCheckouts) {
expect(candidate.with, jobName).toMatchObject({
repository: "${{ needs.validate_selected_ref.outputs.workflow_repository }}",
ref: "${{ needs.validate_selected_ref.outputs.workflow_sha }}",
"persist-credentials": false,
});
}
const dockerProducer = job(workflow, "prepare_docker_e2e_image");
const liveProducer = job(workflow, "prepare_live_test_image");
const dockerPublisher = job(workflow, "push_docker_e2e_images");
const livePublisher = job(workflow, "push_live_test_image");
expect(workflow.permissions?.actions).toBe("read");
expect(workflow.permissions?.packages).toBe("read");
expectReadOnlyPackagePermission(dockerProducer);
expectReadOnlyPackagePermission(liveProducer);
expect(dockerPublisher.permissions?.packages).toBe("write");
expect(livePublisher.permissions?.packages).toBe("write");
expect(dockerPublisher.if).toContain("shared_image_policy == 'allow-push'");
expect(livePublisher.if).toContain("shared_image_policy == 'allow-push'");
expect(job(workflow, "docker_e2e_image_ready").permissions?.packages).toBeUndefined();
expect(job(workflow, "live_test_image_ready").permissions?.packages).toBeUndefined();
const packageWriters = Object.entries(workflow.jobs ?? {}).filter(
([, workflowJob]) => workflowJob.permissions?.packages === "write",
);
expect(packageWriters.map(([name]) => name).sort()).toEqual([
"push_docker_e2e_images",
"push_live_test_image",
]);
for (const [, workflowJob] of packageWriters) {
expect(workflowJob.if).toContain("shared_image_policy == 'allow-push'");
}
const validateSelectedRef = step(
job(workflow, "validate_selected_ref"),
"Validate selected ref",
);
const dispatchInputs = workflow.on?.workflow_dispatch?.inputs ?? {};
for (const inputName of [
"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",
]) {
expect(dispatchInputs[inputName], inputName).toBeUndefined();
expect(workflow.on?.workflow_call?.inputs?.[inputName], inputName).toBeDefined();
}
expect(validateSelectedRef.env?.PACKAGE_ARTIFACT_DIGEST).toBe(
"${{ inputs.package_artifact_digest }}",
);
expect(validateSelectedRef.env?.PACKAGE_ARTIFACT_RUN_ATTEMPT).toBe(
"${{ inputs.package_artifact_run_attempt }}",
);
expect(validateSelectedRef.env?.PACKAGE_ARTIFACT_RUN_ID).toBe(
"${{ inputs.package_artifact_run_id }}",
);
expect(validateSelectedRef.env?.PACKAGE_ARTIFACT_ID).toBe("${{ inputs.package_artifact_id }}");
expect(validateSelectedRef.env?.PACKAGE_FILE_NAME).toBe("${{ inputs.package_file_name }}");
expect(validateSelectedRef.env?.PACKAGE_SOURCE_SHA).toBe("${{ inputs.package_source_sha }}");
expect(validateSelectedRef.run).toContain(
"Package artifact selection requires the complete immutable artifact and package identity tuple.",
);
expect(validateSelectedRef.run).toContain('"$PACKAGE_SOURCE_SHA" == "$selected_sha"');
for (const name of [
"prepare_docker_e2e_image",
"prepare_live_test_image",
"validate_live_models_docker",
"validate_live_models_docker_targeted",
"validate_live_docker_provider_suites",
]) {
const checkoutSteps = job(workflow, name).steps?.filter((candidate) =>
candidate.uses?.startsWith("actions/checkout@"),
);
expect(checkoutSteps, name).not.toHaveLength(0);
for (const checkout of checkoutSteps ?? []) {
expect(checkout.with?.["persist-credentials"], `${name}:${checkout.name}`).toBe(false);
}
}
expect(dockerProducer.outputs?.image_artifact_name).toContain("image_artifact");
expect(liveProducer.outputs?.image_artifact_name).toContain("image_artifact");
for (const producer of [dockerProducer, liveProducer]) {
expect(producer.outputs?.image_archive_sha256).toContain("archive_sha256");
expect(producer.outputs?.image_artifact_id).toContain("artifact-id");
expect(producer.outputs?.image_artifact_digest).toContain("artifact-digest");
expect(producer.outputs?.image_artifact_run_id).toBe("${{ github.run_id }}");
expect(producer.outputs?.image_artifact_run_attempt).toBe("${{ github.run_attempt }}");
}
expect(dockerProducer.outputs?.package_artifact_id).toContain("artifact-id");
expect(dockerProducer.outputs?.package_artifact_digest).toContain("artifact-digest");
expect(dockerProducer.outputs?.package_artifact_run_attempt).toContain("run_attempt");
expect(dockerProducer.outputs?.package_artifact_run_id).toContain("run_id");
expect(dockerProducer.outputs?.package_file_name).toContain("file_name");
expect(dockerProducer.outputs?.package_source_sha).toContain("source_sha");
const packageIdentity = step(dockerProducer, "Validate OpenClaw package artifact identity");
expect(packageIdentity.env).toMatchObject({
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 }}",
});
expect(packageIdentity.run).toContain('--arg digest "sha256:${ARTIFACT_DIGEST}"');
expect(packageIdentity.run).toContain(
"actions/runs/${ARTIFACT_RUN_ID}/attempts/${ARTIFACT_RUN_ATTEMPT}",
);
expect(packageIdentity.run).toContain("artifact_digest=$ARTIFACT_DIGEST");
for (const [name, condition] of [
[
"Download current-run OpenClaw Docker E2E package",
"inputs.package_artifact_run_id == github.run_id",
],
[
"Download previous-run OpenClaw Docker E2E package",
"inputs.package_artifact_run_id != github.run_id",
],
] as const) {
const packageDownload = step(dockerProducer, name);
expect(packageDownload.if).toContain(condition);
expect(packageDownload.with).toMatchObject({
"artifact-ids": "${{ inputs.package_artifact_id }}",
"github-token": "${{ github.token }}",
"run-id": "${{ inputs.package_artifact_run_id }}",
});
}
for (const name of [
"Build bare Docker E2E image artifact",
"Build functional Docker E2E image artifact",
]) {
const build = step(dockerProducer, name);
expect(build.if).toContain("shared_image_policy == 'no-push-artifact'");
expect(build.run).toContain("--load");
expect(build.run).not.toContain("--push");
}
const packDockerArtifact = step(dockerProducer, "Pack Docker E2E image artifact");
expect(packDockerArtifact.env?.PACKAGE_SHA256).toBe("${{ steps.package.outputs.sha256 }}");
expect(packDockerArtifact.run).toContain("shared-image-artifact.sh");
expect(packDockerArtifact.run).toContain(
"docker-e2e-shared-images-${SHARED_IMAGE_ARTIFACT_NAMESPACE}-${TARGET_SHA:0:12}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}",
);
expect(packDockerArtifact.run).toContain(
'OPENCLAW_SHARED_IMAGE_PACKAGE_SHA256="$PACKAGE_SHA256"',
);
expect(packDockerArtifact.run).toContain("archive_sha256=");
const validatePackage = step(dockerProducer, "Validate OpenClaw Docker E2E package");
expect(validatePackage.env).toMatchObject({
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 }}",
});
expect(validatePackage.run).toContain('"$SHARED_IMAGE_POLICY" == "no-push-artifact"');
expect(validatePackage.run).toContain(
"Resolved package identity differs from the declared immutable tuple.",
);
expect(validatePackage.run).toContain("package/dist/build-info.json");
expect(validatePackage.run).toContain('[[ "$package_source_sha" == "$SELECTED_SHA" ]]');
const targetedRun = step(
job(workflow, "validate_docker_lanes"),
"Run targeted Docker E2E lanes",
);
expect(targetedRun.env).toMatchObject({
ARTIFACT_SUFFIX: "${{ steps.plan.outputs.artifact_suffix }}",
INCLUDE_RELEASE_PATH_SUITES: "${{ inputs.include_release_path_suites }}",
});
expect(targetedRun.run).toContain('if [[ "$INCLUDE_RELEASE_PATH_SUITES" == "true" ]]');
expect(targetedRun.run).not.toContain("${{ inputs.");
for (const workflowJob of Object.values(workflow.jobs ?? {})) {
for (const workflowStep of workflowJob.steps ?? []) {
for (const inputName of ["shared_image_policy", "package_sha256", "package_version"]) {
expect(workflowStep.run ?? "", `${workflowStep.name}:${inputName}`).not.toContain(
`\${{ inputs.${inputName} }}`,
);
}
}
}
expect(readFileSync(LIVE_E2E, "utf8")).not.toContain("fromJSON(toJSON(job)).workflow_");
expect(readFileSync(LIVE_E2E, "utf8")).not.toContain("${{ github.workflow_sha }}");
const artifactPackAndLoadSteps = Object.values(workflow.jobs ?? {}).flatMap((workflowJob) =>
(workflowJob.steps ?? []).filter((candidate) => candidate.env?.WORKFLOW_SHA !== undefined),
);
expect(artifactPackAndLoadSteps).toHaveLength(8);
for (const artifactStep of artifactPackAndLoadSteps) {
expect(artifactStep.env?.WORKFLOW_SHA, artifactStep.name).toBe(
"${{ needs.validate_selected_ref.outputs.workflow_sha }}",
);
}
expect(step(dockerProducer, "Upload Docker E2E image artifact")).toMatchObject({
id: "upload_image_artifact",
if: "inputs.shared_image_policy == 'no-push-artifact' && steps.plan.outputs.needs_e2e_image == '1'",
with: { "if-no-files-found": "error" },
});
expect(step(liveProducer, "Pack live-test image artifact").run).toContain(
"shared-image-artifact.sh",
);
expect(step(liveProducer, "Pack live-test image artifact").run).toContain(
"live-test-shared-image-${SHARED_IMAGE_ARTIFACT_NAMESPACE}-${TARGET_SHA:0:12}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}",
);
expect(step(liveProducer, "Upload live-test image artifact")).toMatchObject({
id: "upload_image_artifact",
if: "inputs.shared_image_policy == 'no-push-artifact'",
with: { "if-no-files-found": "error" },
});
expect(step(liveProducer, "Build shared live-test image").with).toMatchObject({
load: true,
push: false,
});
const dockerLoginCondition = step(dockerProducer, "Log in to GHCR").if;
expect(dockerLoginCondition).toContain("shared_image_policy == 'allow-push'");
expect(dockerLoginCondition).toContain("shared_image_policy == 'existing-only'");
expect(step(liveProducer, "Log in to GHCR").if).toContain(
"shared_image_policy != 'no-push-artifact'",
);
expect(step(dockerProducer, "Check existing shared Docker E2E images").if).toContain(
"shared_image_policy == 'allow-push'",
);
expect(step(liveProducer, "Check existing shared live-test image").if).toContain(
"shared_image_policy != 'no-push-artifact'",
);
const shellPushSteps = Object.entries(workflow.jobs ?? {}).flatMap(([jobName, workflowJob]) =>
(workflowJob.steps ?? [])
.filter((candidate) => candidate.run?.includes("--push"))
.map((candidate) => ({ candidate, jobName })),
);
expect(shellPushSteps.map(({ candidate }) => candidate.name).sort()).toEqual([
"Build and push bare Docker E2E image",
"Build and push functional Docker E2E image",
]);
for (const { jobName } of shellPushSteps) {
expect(jobName).toBe("push_docker_e2e_images");
}
expect(step(livePublisher, "Build and push shared live-test image").with?.push).toBe(true);
expect(step(dockerPublisher, "Download OpenClaw Docker E2E package").with).toMatchObject({
"artifact-ids": "${{ needs.prepare_docker_e2e_image.outputs.package_artifact_id }}",
"github-token": "${{ github.token }}",
"run-id": "${{ needs.prepare_docker_e2e_image.outputs.package_artifact_run_id }}",
});
for (const name of [
"validate_docker_e2e",
"validate_docker_lanes",
"validate_docker_openwebui",
]) {
const consumer = job(workflow, name);
expect(consumer.needs).toContain("docker_e2e_image_ready");
expect(consumer.env?.OPENCLAW_DOCKER_E2E_REQUIRE_LOCAL_IMAGE).toContain("no-push-artifact");
expect(step(consumer, "Download OpenClaw Docker E2E package").with).toMatchObject({
"artifact-ids": "${{ needs.prepare_docker_e2e_image.outputs.package_artifact_id }}",
"github-token": "${{ github.token }}",
"run-id": "${{ needs.prepare_docker_e2e_image.outputs.package_artifact_run_id }}",
});
const binding = step(consumer, "Validate Docker E2E image artifact binding");
expect(binding.if).toContain("shared_image_policy == 'no-push-artifact'");
expect(binding.env).toMatchObject({
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 }}",
});
expect(binding.run).toContain('verify-upload "Docker E2E image"');
expect(binding.run).toContain('"$ARTIFACT_ID" "$ARTIFACT_NAME" "$ARTIFACT_DIGEST"');
expect(binding.run).toContain('"$ARTIFACT_RUN_ID" "$ARTIFACT_RUN_ATTEMPT"');
const download = step(consumer, "Download Docker E2E image artifact");
expect(download.if).toContain("shared_image_policy == 'no-push-artifact'");
expect(download.with).toMatchObject({
"artifact-ids": "${{ needs.prepare_docker_e2e_image.outputs.image_artifact_id }}",
"github-token": "${{ github.token }}",
"run-id": "${{ needs.prepare_docker_e2e_image.outputs.image_artifact_run_id }}",
});
expect(consumer.steps?.indexOf(binding) ?? -1).toBeLessThan(
consumer.steps?.indexOf(download) ?? -1,
);
const loadArtifact = step(consumer, "Verify and load Docker E2E image artifact");
expect(loadArtifact.env?.ARCHIVE_SHA256).toBe(
"${{ needs.prepare_docker_e2e_image.outputs.image_archive_sha256 }}",
);
expect(loadArtifact.env?.PACKAGE_SHA256).toBe(
"${{ needs.prepare_docker_e2e_image.outputs.package_sha256 }}",
);
expect(loadArtifact.env?.OPENCLAW_SHARED_IMAGE_RUN_ATTEMPT).toBe(
"${{ needs.prepare_docker_e2e_image.outputs.image_artifact_run_attempt }}",
);
expect(loadArtifact.env?.OPENCLAW_SHARED_IMAGE_RUN_ID).toBe(
"${{ needs.prepare_docker_e2e_image.outputs.image_artifact_run_id }}",
);
expect(loadArtifact.run).toContain("shared-image-artifact.sh");
expect(loadArtifact.run).toContain('OPENCLAW_SHARED_IMAGE_ARCHIVE_SHA256="$ARCHIVE_SHA256"');
expect(loadArtifact.run).toContain('OPENCLAW_SHARED_IMAGE_PACKAGE_SHA256="$PACKAGE_SHA256"');
expect(step(consumer, "Log in to GHCR for shared Docker E2E image").if).toContain(
"shared_image_policy != 'no-push-artifact'",
);
for (const pullName of [
"Pull shared bare Docker E2E image",
"Pull shared functional Docker E2E image",
]) {
expect(step(consumer, pullName).if).toContain("shared_image_policy != 'no-push-artifact'");
}
}
for (const name of [
"validate_live_models_docker",
"validate_live_models_docker_targeted",
"validate_live_docker_provider_suites",
]) {
const consumer = job(workflow, name);
expect(consumer.needs).toContain("live_test_image_ready");
expect(consumer.env?.OPENCLAW_LIVE_REQUIRE_LOCAL_IMAGE).toContain("no-push-artifact");
const binding = step(consumer, "Validate live-test image artifact binding");
expect(binding.if).toContain("shared_image_policy == 'no-push-artifact'");
expect(binding.env).toMatchObject({
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 }}",
});
expect(binding.run).toContain('verify-upload "live-test image"');
expect(binding.run).toContain('"$ARTIFACT_ID" "$ARTIFACT_NAME" "$ARTIFACT_DIGEST"');
expect(binding.run).toContain('"$ARTIFACT_RUN_ID" "$ARTIFACT_RUN_ATTEMPT"');
const download = step(consumer, "Download live-test image artifact");
expect(download.if).toContain("shared_image_policy == 'no-push-artifact'");
expect(download.with).toMatchObject({
"artifact-ids": "${{ needs.prepare_live_test_image.outputs.image_artifact_id }}",
"github-token": "${{ github.token }}",
"run-id": "${{ needs.prepare_live_test_image.outputs.image_artifact_run_id }}",
});
expect(consumer.steps?.indexOf(binding) ?? -1).toBeLessThan(
consumer.steps?.indexOf(download) ?? -1,
);
const loadArtifact = step(consumer, "Verify and load live-test image artifact");
expect(loadArtifact.env?.ARCHIVE_SHA256).toBe(
"${{ needs.prepare_live_test_image.outputs.image_archive_sha256 }}",
);
expect(loadArtifact.env?.OPENCLAW_SHARED_IMAGE_RUN_ATTEMPT).toBe(
"${{ needs.prepare_live_test_image.outputs.image_artifact_run_attempt }}",
);
expect(loadArtifact.env?.OPENCLAW_SHARED_IMAGE_RUN_ID).toBe(
"${{ needs.prepare_live_test_image.outputs.image_artifact_run_id }}",
);
expect(loadArtifact.run).toContain("shared-image-artifact.sh");
expect(loadArtifact.run).toContain('OPENCLAW_SHARED_IMAGE_ARCHIVE_SHA256="$ARCHIVE_SHA256"');
expect(step(consumer, "Log in to GHCR").if).toContain(
"shared_image_policy != 'no-push-artifact'",
);
}
const liveBuild = readFileSync(LIVE_BUILD, "utf8");
const requireLocalIndex = liveBuild.indexOf("OPENCLAW_LIVE_REQUIRE_LOCAL_IMAGE");
const pullIndex = liveBuild.indexOf("Live-test image not found locally; pulling");
expect(requireLocalIndex).toBeGreaterThanOrEqual(0);
expect(pullIndex).toBeGreaterThan(requireLocalIndex);
expect(liveBuild).toContain("Required local live-test image not found");
});
it("keeps performance evidence artifact-only when dispatched by Full Release", () => {
const fullText = readFileSync(FULL_RELEASE, "utf8");
const performance = readWorkflow(PERFORMANCE);
const publisher = job(performance, "publish");
const dangerousSteps = [
"Prepare clawgrit report commit",
"Create clawgrit reports app token",
"Publish to clawgrit reports",
];
expect(performance.on?.workflow_dispatch?.inputs?.publish_reports).toMatchObject({
default: true,
type: "boolean",
});
expect(fullText).toContain("-f publish_reports=false");
expect(fullText).toContain("Report publication: disabled (artifacts only)");
expect(fullText).toContain('performanceReportPublication: "artifact-only"');
expect(publisher.if).toContain("inputs.publish_reports == true");
const guard = job(performance, "artifact_only_guard");
expect(guard.if).toContain("inputs.publish_reports != true");
expect(step(guard, "Verify report publisher stayed disabled").run).toContain(
'[[ "$PUBLISH_RESULT" != "skipped" ]]',
);
for (const name of dangerousSteps) {
expect(step(publisher, name)).toBeDefined();
}
for (const [name, workflowJob] of Object.entries(performance.jobs ?? {})) {
if (name === "publish") {
continue;
}
const text = JSON.stringify(workflowJob);
expect(text).not.toContain("CLAWGRIT_REPORTS_APP_TOKEN");
expect(text).not.toContain("create-github-app-token");
expect(text).not.toContain("git push");
}
});
it("fails a missing required local live image before any registry pull", () => {
const root = mkdtempSync(join(tmpdir(), "openclaw-live-local-image-"));
const bin = join(root, "bin");
const calls = join(root, "docker.log");
try {
mkdirSync(bin);
writeFileSync(calls, "");
const docker = join(bin, "docker");
writeFileSync(
docker,
`#!/usr/bin/env bash
set -euo pipefail
printf '%s\\n' "$*" >> "$FAKE_DOCKER_LOG"
if [[ "$1" == "image" && "$2" == "inspect" ]]; then
exit 1
fi
if [[ "$1" == "pull" ]]; then
exit 0
fi
exit 2
`,
);
chmodSync(docker, 0o755);
const result = spawnSync("bash", [resolve(LIVE_BUILD)], {
encoding: "utf8",
env: {
...process.env,
DOCKER_COMMAND_TIMEOUT: "5s",
FAKE_DOCKER_LOG: calls,
OPENCLAW_LIVE_IMAGE: "openclaw-live-test:required-local",
OPENCLAW_LIVE_REQUIRE_LOCAL_IMAGE: "1",
OPENCLAW_SKIP_DOCKER_BUILD: "1",
PATH: `${bin}:${process.env.PATH ?? ""}`,
},
});
expect(result.status).toBe(1);
expect(result.stderr).toContain(
"Required local live-test image not found: openclaw-live-test:required-local",
);
expect(readFileSync(calls, "utf8")).toBe("image inspect openclaw-live-test:required-local\n");
} finally {
rmSync(root, { force: true, recursive: true });
}
});
it("fails a missing required local Docker E2E image before pull or build fallback", () => {
const root = mkdtempSync(join(tmpdir(), "openclaw-docker-e2e-local-image-"));
const bin = join(root, "bin");
const calls = join(root, "docker.log");
try {
mkdirSync(bin);
writeFileSync(calls, "");
const docker = join(bin, "docker");
writeFileSync(
docker,
`#!/usr/bin/env bash
set -euo pipefail
printf '%s\\n' "$*" >> "$FAKE_DOCKER_LOG"
if [[ "$1" == "image" && "$2" == "inspect" ]]; then
exit 1
fi
if [[ "$1" == "pull" ]]; then
exit 0
fi
exit 2
`,
);
chmodSync(docker, 0o755);
const result = spawnSync(
"bash",
[
"-c",
`source "$1"
docker_e2e_build_or_reuse "openclaw-e2e:required-local" "required local image test"`,
"bash",
resolve(DOCKER_E2E_IMAGE_HELPER),
],
{
encoding: "utf8",
env: {
...process.env,
FAKE_DOCKER_LOG: calls,
OPENCLAW_DOCKER_BUILD_ON_MISSING: "1",
OPENCLAW_DOCKER_E2E_REQUIRE_LOCAL_IMAGE: "1",
OPENCLAW_SKIP_DOCKER_BUILD: "1",
PATH: `${bin}:${process.env.PATH ?? ""}`,
},
},
);
expect(result.status).toBe(1);
expect(result.stderr).toContain(
"Required local Docker E2E image not found: openclaw-e2e:required-local",
);
expect(readFileSync(calls, "utf8")).toBe("image inspect openclaw-e2e:required-local\n");
} finally {
rmSync(root, { force: true, recursive: true });
}
});
});
@@ -14,6 +14,20 @@ const PROFILE_GATED_STATIC_MATRIX_ALLOWLIST = [
"validate_live_media_provider_suites",
];
// Direct dispatches build from the selected ref. Only trusted workflow callers
// may provide the complete immutable package artifact tuple.
const WORKFLOW_CALL_ONLY_INPUTS = new Set([
"package_artifact_name",
"package_artifact_id",
"package_artifact_digest",
"package_artifact_run_id",
"package_artifact_run_attempt",
"package_file_name",
"package_source_sha",
"package_sha256",
"package_version",
]);
const PROFILE_EXPECTATIONS = [
{
profile: "minimum",
@@ -98,10 +112,17 @@ describe("scripts/plan-release-workflow-matrix.mjs", () => {
}
}
for (const trigger of ["workflow_call", "workflow_dispatch"]) {
expect(Object.keys(definition.on[trigger].inputs)).toEqual(
expect.arrayContaining([...referencedInputs]),
);
expect(Object.keys(definition.on.workflow_call.inputs)).toEqual(
expect.arrayContaining([...referencedInputs]),
);
expect(Object.keys(definition.on.workflow_dispatch.inputs)).toEqual(
expect.arrayContaining(
[...referencedInputs].filter((input) => !WORKFLOW_CALL_ONLY_INPUTS.has(input)),
),
);
for (const input of WORKFLOW_CALL_ONLY_INPUTS) {
expect(definition.on.workflow_call.inputs).toHaveProperty(input);
expect(definition.on.workflow_dispatch.inputs).not.toHaveProperty(input);
}
expect(definition.on.workflow_dispatch.inputs.live_advisory).toEqual(
definition.on.workflow_call.inputs.live_advisory,
+467
View File
@@ -0,0 +1,467 @@
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { describe, expect, it } from "vitest";
const HELPER = resolve("scripts/docker/shared-image-artifact.sh");
const TARGET_SHA = "a".repeat(40);
const WORKFLOW_SHA = "b".repeat(40);
const PACKAGE_SHA256 = "c".repeat(64);
const ARTIFACT_DIGEST = "d".repeat(64);
const ARTIFACT_ID = "789";
const ARTIFACT_NAME = "docker-e2e-shared-images-release-aabbccddeeff-123456-2";
const ARTIFACT_RUN_ATTEMPT = "2";
const ARTIFACT_RUN_ID = "123456";
const IMAGE_REFS = ["openclaw-docker-e2e-bare:pkg-test", "openclaw-docker-e2e-functional:pkg-test"];
function imageId(ref: string): string {
return `sha256:${createHash("sha256").update(ref).digest("hex")}`;
}
function writeExecutable(path: string, contents: string): void {
writeFileSync(path, contents);
chmodSync(path, 0o755);
}
function runHelper(params: {
artifactDir: string;
command: "load" | "pack";
env: NodeJS.ProcessEnv;
imageRefs?: string[];
}) {
return spawnSync(
"bash",
[
HELPER,
params.command,
params.artifactDir,
"docker-e2e",
TARGET_SHA,
WORKFLOW_SHA,
...(params.imageRefs ?? IMAGE_REFS),
],
{
encoding: "utf8",
env: params.env,
},
);
}
function verifyUploadedArtifact(
fixture: ReturnType<typeof createFixture>,
params: {
artifactDigest?: string;
artifactName?: string;
env?: NodeJS.ProcessEnv;
runAttempt?: string;
runId?: string;
} = {},
) {
return spawnSync(
"bash",
[
HELPER,
"verify-upload",
"Docker E2E image",
ARTIFACT_ID,
params.artifactName ?? ARTIFACT_NAME,
params.artifactDigest ?? ARTIFACT_DIGEST,
params.runId ?? ARTIFACT_RUN_ID,
params.runAttempt ?? ARTIFACT_RUN_ATTEMPT,
],
{
encoding: "utf8",
env: { ...fixture.env, ...params.env },
},
);
}
function createFixture() {
const root = mkdtempSync(join(tmpdir(), "openclaw-shared-image-artifact-"));
const bin = join(root, "bin");
const artifactDir = join(root, "artifact");
const dockerLog = join(root, "docker.log");
const ghLog = join(root, "gh.log");
mkdirSync(bin);
writeFileSync(dockerLog, "");
writeFileSync(ghLog, "");
writeExecutable(
join(bin, "docker"),
`#!/usr/bin/env bash
set -euo pipefail
printf '%s\\n' "$*" >> "$FAKE_DOCKER_LOG"
image_id() {
printf '%s' "$1" | sha256sum | awk '{print "sha256:" $1}'
}
if [[ "$1" == "image" && "$2" == "inspect" ]]; then
ref="\${5:?image ref required}"
id="$(image_id "$ref")"
if [[ "\${FAKE_DOCKER_FORCE_ID_MISMATCH:-0}" == "1" ]]; then
id="sha256:$(printf 'mismatch:%s' "$ref" | sha256sum | awk '{print $1}')"
fi
printf '%s\\n' "$id"
exit 0
fi
if [[ "$1" == "image" && "$2" == "save" ]]; then
shift 2
output=""
refs=()
while [[ "$#" -gt 0 ]]; do
case "$1" in
--output)
output="\${2:?output path required}"
shift 2
;;
*)
refs+=("$1")
shift
;;
esac
done
: > "$output"
for ref in "\${refs[@]}"; do
printf '%s\\t%s\\n' "$ref" "$(image_id "$ref")" >> "$output"
done
exit 0
fi
if [[ "$1" == "image" && "$2" == "load" ]]; then
cat >/dev/null
exit 0
fi
echo "unexpected docker invocation: $*" >&2
exit 2
`,
);
writeExecutable(
join(bin, "zstd"),
`#!/usr/bin/env bash
set -euo pipefail
if [[ "$1" == "-t" ]]; then
test -s "$2"
exit 0
fi
if [[ "$1" == "-d" && "$2" == "--stdout" ]]; then
cat "$3"
exit 0
fi
source_path=""
output_path=""
while [[ "$#" -gt 0 ]]; do
case "$1" in
-f)
source_path="\${2:?source path required}"
shift 2
;;
-o)
output_path="\${2:?output path required}"
shift 2
;;
*)
shift
;;
esac
done
cp "$source_path" "$output_path"
`,
);
writeExecutable(
join(bin, "gh"),
`#!/usr/bin/env bash
set -euo pipefail
printf '%s\\n' "$*" >> "$FAKE_GH_LOG"
[[ "$1" == "api" ]] || {
echo "unexpected gh invocation: $*" >&2
exit 2
}
path="$2"
case "$path" in
"repos/\${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}")
printf '{"id":%s,"name":"%s","expired":%s,"digest":"sha256:%s","workflow_run":{"id":%s}}\\n' \
"$FAKE_ARTIFACT_ID" "$FAKE_ARTIFACT_NAME" "$FAKE_ARTIFACT_EXPIRED" \
"$FAKE_ARTIFACT_DIGEST" "$FAKE_ARTIFACT_RUN_ID"
;;
"repos/\${GITHUB_REPOSITORY}/actions/runs/${ARTIFACT_RUN_ID}/attempts/${ARTIFACT_RUN_ATTEMPT}")
printf '{"id":%s,"run_attempt":%s}\\n' \
"$FAKE_ATTEMPT_RUN_ID" "$FAKE_ARTIFACT_RUN_ATTEMPT"
;;
*)
echo "unexpected gh api path: $path" >&2
exit 2
;;
esac
`,
);
const env = {
...process.env,
FAKE_ARTIFACT_DIGEST: ARTIFACT_DIGEST,
FAKE_ARTIFACT_EXPIRED: "false",
FAKE_ARTIFACT_ID: ARTIFACT_ID,
FAKE_ARTIFACT_NAME: ARTIFACT_NAME,
FAKE_ARTIFACT_RUN_ATTEMPT: ARTIFACT_RUN_ATTEMPT,
FAKE_ARTIFACT_RUN_ID: ARTIFACT_RUN_ID,
FAKE_ATTEMPT_RUN_ID: ARTIFACT_RUN_ID,
FAKE_DOCKER_LOG: dockerLog,
FAKE_GH_LOG: ghLog,
GH_TOKEN: "test-token",
GITHUB_REPOSITORY: "openclaw/openclaw",
GITHUB_RUN_ATTEMPT: "2",
GITHUB_RUN_ID: "123456",
PATH: `${bin}:${process.env.PATH ?? ""}`,
RUNNER_TEMP: root,
OPENCLAW_SHARED_IMAGE_PACKAGE_SHA256: PACKAGE_SHA256,
};
return { artifactDir, dockerLog, env, ghLog, root };
}
function expectedArchiveEnv(fixture: ReturnType<typeof createFixture>): NodeJS.ProcessEnv {
const manifest = JSON.parse(
readFileSync(join(fixture.artifactDir, "shared-image-artifact.json"), "utf8"),
);
return {
...fixture.env,
OPENCLAW_SHARED_IMAGE_ARCHIVE_SHA256: manifest.archive.sha256,
OPENCLAW_SHARED_IMAGE_RUN_ATTEMPT: String(manifest.runAttempt),
OPENCLAW_SHARED_IMAGE_RUN_ID: String(manifest.runId),
};
}
describe("shared Docker image artifacts", () => {
it("binds uploaded artifacts to the exact service tuple and producer attempt", () => {
const fixture = createFixture();
try {
const verified = verifyUploadedArtifact(fixture);
expect(verified.status, `${verified.stdout}\n${verified.stderr}`).toBe(0);
expect(readFileSync(fixture.ghLog, "utf8")).toContain(
`api repos/openclaw/openclaw/actions/artifacts/${ARTIFACT_ID}`,
);
expect(readFileSync(fixture.ghLog, "utf8")).toContain(
`api repos/openclaw/openclaw/actions/runs/${ARTIFACT_RUN_ID}/attempts/${ARTIFACT_RUN_ATTEMPT}`,
);
const digestMismatch = verifyUploadedArtifact(fixture, {
artifactDigest: "e".repeat(64),
});
expect(digestMismatch.status).not.toBe(0);
expect(digestMismatch.stderr).toContain(
"artifact identity does not match the immutable producer tuple",
);
const attemptMismatch = verifyUploadedArtifact(fixture, {
env: { FAKE_ARTIFACT_RUN_ATTEMPT: "3" },
});
expect(attemptMismatch.status).not.toBe(0);
expect(attemptMismatch.stderr).toContain(
"producer run attempt does not match the immutable tuple",
);
} finally {
rmSync(fixture.root, { force: true, recursive: true });
}
});
it("packs provenance-bound images and verifies them before loading", () => {
const fixture = createFixture();
try {
const packed = runHelper({
artifactDir: fixture.artifactDir,
command: "pack",
env: fixture.env,
});
expect(packed.status, `${packed.stdout}\n${packed.stderr}`).toBe(0);
const archive = readFileSync(join(fixture.artifactDir, "shared-images.tar.zst"));
const manifest = JSON.parse(
readFileSync(join(fixture.artifactDir, "shared-image-artifact.json"), "utf8"),
);
expect(manifest).toEqual({
archive: {
filename: "shared-images.tar.zst",
format: "docker-tar+zstd",
sha256: createHash("sha256").update(archive).digest("hex"),
sizeBytes: archive.length,
},
conclusion: "success",
images: IMAGE_REFS.map((ref) => ({ id: imageId(ref), ref })),
kind: "docker-e2e",
packageSha256: PACKAGE_SHA256,
packageSourceSha: TARGET_SHA,
runAttempt: 2,
runId: 123456,
schema: "openclaw.shared-docker-image-artifact/v1",
schemaVersion: 1,
targetSha: TARGET_SHA,
workflowSha: WORKFLOW_SHA,
});
writeFileSync(fixture.dockerLog, "");
const loaded = runHelper({
artifactDir: fixture.artifactDir,
command: "load",
env: { ...expectedArchiveEnv(fixture), GITHUB_RUN_ATTEMPT: "3" },
});
expect(loaded.status, `${loaded.stdout}\n${loaded.stderr}`).toBe(0);
const calls = readFileSync(fixture.dockerLog, "utf8");
expect(calls).toContain("image load");
for (const ref of IMAGE_REFS) {
expect(calls).toContain(`image inspect --format {{.Id}} ${ref}`);
}
} finally {
rmSync(fixture.root, { force: true, recursive: true });
}
});
it("fails before loading when provenance or archive bytes differ", () => {
const fixture = createFixture();
try {
const packed = runHelper({
artifactDir: fixture.artifactDir,
command: "pack",
env: fixture.env,
});
expect(packed.status, packed.stderr).toBe(0);
for (const variant of [
{
env: {
...expectedArchiveEnv(fixture),
OPENCLAW_SHARED_IMAGE_RUN_ID: "654321",
},
imageRefs: IMAGE_REFS,
expected: "run ID",
},
{
env: expectedArchiveEnv(fixture),
imageRefs: [IMAGE_REFS[0], "openclaw-docker-e2e-functional:wrong"],
expected: "image ref 1",
},
{
env: {
...expectedArchiveEnv(fixture),
OPENCLAW_SHARED_IMAGE_PACKAGE_SHA256: "d".repeat(64),
},
imageRefs: IMAGE_REFS,
expected: "package SHA-256",
},
]) {
writeFileSync(fixture.dockerLog, "");
const result = runHelper({
artifactDir: fixture.artifactDir,
command: "load",
env: variant.env,
imageRefs: variant.imageRefs,
});
expect(result.status).not.toBe(0);
expect(result.stderr).toContain(variant.expected);
expect(readFileSync(fixture.dockerLog, "utf8")).not.toContain("image load");
}
const archivePath = join(fixture.artifactDir, "shared-images.tar.zst");
writeFileSync(archivePath, `${readFileSync(archivePath, "utf8")}tampered`);
writeFileSync(fixture.dockerLog, "");
const tampered = runHelper({
artifactDir: fixture.artifactDir,
command: "load",
env: expectedArchiveEnv(fixture),
});
expect(tampered.status).not.toBe(0);
expect(tampered.stderr).toContain("archive SHA-256 mismatch");
expect(readFileSync(fixture.dockerLog, "utf8")).not.toContain("image load");
} finally {
rmSync(fixture.root, { force: true, recursive: true });
}
});
it("rejects unsafe pack destinations and loaded image ID drift", () => {
const fixture = createFixture();
try {
const unsafe = runHelper({
artifactDir: fixture.root,
command: "pack",
env: fixture.env,
});
expect(unsafe.status).not.toBe(0);
expect(unsafe.stderr).toContain("artifact directory must be a child of RUNNER_TEMP");
const packed = runHelper({
artifactDir: fixture.artifactDir,
command: "pack",
env: fixture.env,
});
expect(packed.status, packed.stderr).toBe(0);
writeFileSync(fixture.dockerLog, "");
const mismatch = runHelper({
artifactDir: fixture.artifactDir,
command: "load",
env: { ...expectedArchiveEnv(fixture), FAKE_DOCKER_FORCE_ID_MISMATCH: "1" },
});
expect(mismatch.status).not.toBe(0);
expect(mismatch.stderr).toContain("loaded ID mismatch");
expect(readFileSync(fixture.dockerLog, "utf8")).toContain("image load");
} finally {
rmSync(fixture.root, { force: true, recursive: true });
}
});
it("requires an external expected archive digest before loading", () => {
const fixture = createFixture();
try {
const packed = runHelper({
artifactDir: fixture.artifactDir,
command: "pack",
env: fixture.env,
});
expect(packed.status, packed.stderr).toBe(0);
const missingRunEnv = expectedArchiveEnv(fixture);
delete missingRunEnv.OPENCLAW_SHARED_IMAGE_RUN_ID;
delete missingRunEnv.OPENCLAW_SHARED_IMAGE_RUN_ATTEMPT;
const missingRun = runHelper({
artifactDir: fixture.artifactDir,
command: "load",
env: missingRunEnv,
});
expect(missingRun.status).not.toBe(0);
expect(missingRun.stderr).toContain("OPENCLAW_SHARED_IMAGE_RUN_ID");
expect(readFileSync(fixture.dockerLog, "utf8")).not.toContain("image load");
const missing = runHelper({
artifactDir: fixture.artifactDir,
command: "load",
env: {
...fixture.env,
OPENCLAW_SHARED_IMAGE_RUN_ATTEMPT: "2",
OPENCLAW_SHARED_IMAGE_RUN_ID: "123456",
},
});
expect(missing.status).not.toBe(0);
expect(missing.stderr).toContain("expected shared image archive SHA-256");
expect(readFileSync(fixture.dockerLog, "utf8")).not.toContain("image load");
const mismatched = runHelper({
artifactDir: fixture.artifactDir,
command: "load",
env: {
...expectedArchiveEnv(fixture),
OPENCLAW_SHARED_IMAGE_ARCHIVE_SHA256: "d".repeat(64),
},
});
expect(mismatched.status).not.toBe(0);
expect(mismatched.stderr).toContain("expected archive sha256");
expect(readFileSync(fixture.dockerLog, "utf8")).not.toContain("image load");
} finally {
rmSync(fixture.root, { force: true, recursive: true });
}
});
});
+3 -2
View File
@@ -1432,11 +1432,12 @@ chmod +x "$BUN_INSTALL/bin/openclaw"
};
expect(step("Checkout trusted installer harness").with).toMatchObject({
ref: "${{ github.workflow_sha }}",
repository: "${{ needs.preflight.outputs.workflow_repository }}",
ref: "${{ needs.preflight.outputs.workflow_sha }}",
"persist-credentials": false,
});
expect(step("Checkout candidate CLI").with).toMatchObject({
ref: "${{ inputs.ref || github.ref }}",
ref: "${{ needs.preflight.outputs.target_sha }}",
path: "candidate",
"persist-credentials": false,
});