mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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%%=*}"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+381
@@ -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
|
||||
@@ -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}`], {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user