fix(release): restore prerelease and release validation startup (#103834)

* fix(release): split image publication from validation

* fix(ci): honor install smoke caller input

* fix(ci): harden Docker rerun targeting
This commit is contained in:
Peter Steinberger
2026-07-10 18:46:08 +01:00
committed by GitHub
parent 61893247ec
commit b597a8d364
20 changed files with 2440 additions and 1706 deletions
+21 -12
View File
@@ -573,13 +573,14 @@ Multiple lanes are allowed:
docker_lanes: install-e2e bundled-channel-update-acpx
```
That skips the release chunk matrix and runs one targeted Docker job against the
prepared GHCR images and the selected package artifact. Rerun commands
generated inside GitHub artifacts include `package_artifact_run_id`,
`package_artifact_name`, `docker_e2e_bare_image`, and
`docker_e2e_functional_image` when available, so failed lanes can reuse the
exact tarball and prepared images from the failed run. When the fix changes
package contents, omit those reuse inputs so the workflow packs a new tarball.
That skips the release chunk matrix and runs one targeted Docker job against
the selected package. The default no-push path builds the required images for
that run and moves them through immutable workflow artifacts. The rerun helper
reads the exact selected target SHA from the failure artifact and repacks that
ref; manual dispatch does not accept the reusable workflow's internal package
artifact tuple. Generated commands add `docker_e2e_bare_image`,
`docker_e2e_functional_image`, and `shared_image_policy=existing-only` only for
GHCR-backed images; runner-local artifact images are rebuilt on a fresh rerun.
Live-only targeted reruns skip the E2E images and build only the live-test
image. Release-path normal mode fans out into smaller Docker chunk jobs:
@@ -765,11 +766,19 @@ gh workflow run openclaw-live-and-e2e-checks-reusable.yml \
-f live_models_only=false
```
That path still runs the prepare job, so it creates a new tarball for `<sha>`.
If the SHA-tagged GHCR bare/functional image already exists, CI skips rebuilding
that image and only uploads the fresh package artifact before the targeted lane
job. Do not rerun the full release path unless the failed lane list
or touched surface really requires it.
That path still runs the prepare job, so it creates a new tarball for `<sha>`
and, by default, rebuilds the required image into an immutable workflow
artifact for the targeted lane job. A generated command skips the image rebuild
only when it carries explicit GHCR image refs plus
`shared_image_policy=existing-only`. Do not rerun the full release path unless
the failed lane list or touched surface really requires it.
The helper never recovers the workflow-definition `--ref` from an artifact
command because full-release temporary branches are deleted. It uses the
repository default branch unless the operator sets
`OPENCLAW_DOCKER_E2E_WORKFLOW_REF`; this is separate from the artifact target
SHA passed as the workflow's `ref` input. An explicit target SHA override drops
recovered GHCR image refs unless the artifact proves they belong to that SHA.
## Docker Expected Timings
@@ -0,0 +1,893 @@
name: Install Smoke (Reusable)
on:
workflow_call:
inputs:
ref:
description: Git ref to validate
required: false
type: string
run_bun_global_install_smoke:
description: Run the Bun global install image-provider smoke
required: false
default: true
type: boolean
update_baseline_version:
description: Baseline openclaw version or dist-tag for installer update smoke
required: false
default: latest
type: string
permissions:
actions: read
contents: read
packages: read
concurrency:
group: ${{ github.workflow }}-workflow-call-${{ github.run_id }}
cancel-in-progress: false
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
preflight:
runs-on: ubuntu-24.04
outputs:
docs_only: ${{ steps.manifest.outputs.docs_only }}
run_install_smoke: ${{ steps.manifest.outputs.run_install_smoke }}
run_fast_install_smoke: ${{ steps.manifest.outputs.run_fast_install_smoke }}
run_full_install_smoke: ${{ steps.manifest.outputs.run_full_install_smoke }}
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 }}
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:
ref: ${{ inputs.ref || github.ref }}
fetch-depth: 1
fetch-tags: false
persist-credentials: false
submodules: false
- name: Build install-smoke CI manifest
id: manifest
env:
OPENCLAW_CI_WORKFLOW_BUN_GLOBAL_INSTALL_SMOKE: ${{ inputs.run_bun_global_install_smoke || 'false' }}
run: |
set -euo pipefail
workflow_bun_global_install_smoke="${OPENCLAW_CI_WORKFLOW_BUN_GLOBAL_INSTALL_SMOKE:-false}"
docs_only=false
run_fast_install_smoke=true
run_full_install_smoke=true
run_bun_global_install_smoke="$workflow_bun_global_install_smoke"
run_install_smoke=true
target_sha="$(git rev-parse HEAD)"
dockerfile_image="openclaw-dockerfile-smoke-local:${target_sha}"
{
echo "docs_only=$docs_only"
echo "run_install_smoke=$run_install_smoke"
echo "run_fast_install_smoke=$run_fast_install_smoke"
echo "run_full_install_smoke=$run_full_install_smoke"
echo "run_bun_global_install_smoke=$run_bun_global_install_smoke"
echo "target_sha=$target_sha"
echo "dockerfile_image=$dockerfile_image"
} >> "$GITHUB_OUTPUT"
install-smoke-fast:
needs: [preflight]
if: needs.preflight.outputs.run_fast_install_smoke == 'true' && 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: "1"
steps:
- name: Checkout CLI
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: ${{ needs.preflight.outputs.target_sha }}
persist-credentials: false
- name: Set up Blacksmith Docker Builder
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
with:
max-cache-size-mb: 800000
# Keep release smoke builds bounded and log-producing. The Blacksmith
# build action can leave jobs in-progress without step logs when a remote
# builder stalls; an explicit buildx invocation fails closed instead.
- name: Build root Dockerfile smoke image
run: |
timeout --kill-after=30s 45m docker buildx build \
--progress=plain \
--load \
--build-arg OPENCLAW_EXTENSIONS=matrix \
-t openclaw-dockerfile-smoke:local \
-t openclaw-ext-smoke:local \
-f ./Dockerfile \
.
- name: Run root Dockerfile CLI smoke
run: |
timeout --kill-after=30s 20m docker run --rm --entrypoint sh openclaw-dockerfile-smoke:local -lc '
which openclaw &&
openclaw --version &&
node -e "
const fs = require(\"node:fs\");
const path = require(\"node:path\");
const YAML = require(\"yaml\");
const workspace = YAML.parse(fs.readFileSync(\"/app/pnpm-workspace.yaml\", \"utf8\")) ?? {};
for (const [dep, rel] of Object.entries(workspace.patchedDependencies ?? {})) {
const absolute = path.join(\"/app\", rel);
if (!fs.existsSync(absolute)) {
throw new Error(\"missing patch for \" + dep + \": \" + rel);
}
}
"
'
- name: Run agents delete shared workspace Docker CLI smoke
env:
OPENCLAW_AGENTS_DELETE_SHARED_WORKSPACE_E2E_IMAGE: openclaw-dockerfile-smoke:local
OPENCLAW_AGENTS_DELETE_SHARED_WORKSPACE_E2E_SKIP_BUILD: "1"
run: bash scripts/e2e/agents-delete-shared-workspace-docker.sh
- name: Run Docker gateway network e2e
env:
OPENCLAW_GATEWAY_NETWORK_E2E_IMAGE: openclaw-dockerfile-smoke:local
OPENCLAW_GATEWAY_NETWORK_E2E_SKIP_BUILD: "1"
run: bash scripts/e2e/gateway-network-docker.sh
- name: Smoke test Dockerfile with matrix extension build arg
run: |
timeout --kill-after=30s 20m docker run --rm --entrypoint sh openclaw-ext-smoke:local -lc '
which openclaw &&
openclaw --version &&
node -e "
const Module = require(\"node:module\");
const matrixPackage = require(\"/app/extensions/matrix/package.json\");
const requireFromMatrix = Module.createRequire(\"/app/extensions/matrix/package.json\");
const runtimeDeps = Object.keys(matrixPackage.dependencies ?? {});
if (runtimeDeps.length === 0) {
throw new Error(
\"matrix package has no declared runtime dependencies; smoke cannot validate install mirroring\",
);
}
for (const dep of runtimeDeps) {
requireFromMatrix.resolve(dep);
}
const { spawnSync } = require(\"node:child_process\");
const run = spawnSync(\"openclaw\", [\"plugins\", \"list\", \"--json\"], { encoding: \"utf8\" });
if (run.status !== 0) {
process.stderr.write(run.stderr || run.stdout || \"plugins list failed\\n\");
process.exit(run.status ?? 1);
}
const parsed = JSON.parse(run.stdout);
const matrix = (parsed.plugins || []).find((entry) => entry.id === \"matrix\");
if (!matrix) {
throw new Error(\"matrix plugin missing from bundled plugin list\");
}
const matrixDiag = (parsed.diagnostics || []).filter(
(diag) =>
typeof diag.source === \"string\" &&
diag.source.includes(\"/extensions/matrix\") &&
typeof diag.message === \"string\" &&
diag.message.includes(\"extension entry escapes package directory\"),
);
if (matrixDiag.length > 0) {
throw new Error(
\"unexpected matrix diagnostics: \" +
matrixDiag.map((diag) => diag.message).join(\"; \"),
);
}
"
'
root_dockerfile_image:
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_ref: ${{ steps.image.outputs.image_ref }}
env:
DOCKER_BUILD_SUMMARY: "false"
DOCKER_BUILD_RECORD_UPLOAD: "false"
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
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: Set up Blacksmith Docker Builder
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
with:
max-cache-size-mb: 800000
- name: Build local root Dockerfile smoke image
env:
IMAGE_REF: ${{ needs.preflight.outputs.dockerfile_image }}
run: |
timeout --kill-after=30s 45m docker buildx build \
--progress=plain \
--load \
--build-arg OPENCLAW_EXTENSIONS=matrix \
-t "$IMAGE_REF" \
-f ./Dockerfile \
.
- name: Pack root Dockerfile image artifact
id: image_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
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:
IMAGE_REF: ${{ needs.preflight.outputs.dockerfile_image }}
run: echo "image_ref=$IMAGE_REF" >> "$GITHUB_OUTPUT"
- name: Summarize root image
env:
IMAGE_REF: ${{ needs.preflight.outputs.dockerfile_image }}
TARGET_SHA: ${{ needs.preflight.outputs.target_sha }}
run: |
{
echo "## Root Dockerfile smoke image"
echo
echo "- Target SHA: \`${TARGET_SHA}\`"
echo "- Image: \`${IMAGE_REF}\`"
echo "- Transport: immutable workflow artifact"
echo "- Artifact: \`${{ steps.image_artifact.outputs.artifact_name }}\`"
} >> "$GITHUB_STEP_SUMMARY"
root_dockerfile_image_ready:
needs: [preflight, 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:
PREPARE_RESULT: ${{ needs.root_dockerfile_image.result }}
run: |
set -euo pipefail
if [[ "$PREPARE_RESULT" != "success" ]]; then
echo "Root Dockerfile image preparation ended with ${PREPARE_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: "1"
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
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: Validate root Dockerfile image artifact binding
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
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
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
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 }}
run: |
timeout --kill-after=30s 20m docker run --rm --entrypoint sh "$IMAGE_REF" -lc '
which openclaw &&
openclaw --version &&
node -e "
const fs = require(\"node:fs\");
const path = require(\"node:path\");
const YAML = require(\"yaml\");
const workspace = YAML.parse(fs.readFileSync(\"/app/pnpm-workspace.yaml\", \"utf8\")) ?? {};
for (const [dep, rel] of Object.entries(workspace.patchedDependencies ?? {})) {
const absolute = path.join(\"/app\", rel);
if (!fs.existsSync(absolute)) {
throw new Error(\"missing patch for \" + dep + \": \" + rel);
}
}
"
'
- name: Run agents delete shared workspace Docker CLI smoke
env:
OPENCLAW_AGENTS_DELETE_SHARED_WORKSPACE_E2E_IMAGE: ${{ needs.root_dockerfile_image.outputs.image_ref }}
OPENCLAW_AGENTS_DELETE_SHARED_WORKSPACE_E2E_SKIP_BUILD: "1"
run: bash scripts/e2e/agents-delete-shared-workspace-docker.sh
- name: Run Docker gateway network e2e
env:
OPENCLAW_GATEWAY_NETWORK_E2E_IMAGE: ${{ needs.root_dockerfile_image.outputs.image_ref }}
OPENCLAW_GATEWAY_NETWORK_E2E_SKIP_BUILD: "1"
run: bash scripts/e2e/gateway-network-docker.sh
- name: Smoke test Dockerfile with matrix extension build arg
env:
IMAGE_REF: ${{ needs.root_dockerfile_image.outputs.image_ref }}
run: |
timeout --kill-after=30s 20m docker run --rm --entrypoint sh "$IMAGE_REF" -lc '
which openclaw &&
openclaw --version &&
node -e "
const Module = require(\"node:module\");
const matrixPackage = require(\"/app/extensions/matrix/package.json\");
const requireFromMatrix = Module.createRequire(\"/app/extensions/matrix/package.json\");
const runtimeDeps = Object.keys(matrixPackage.dependencies ?? {});
if (runtimeDeps.length === 0) {
throw new Error(
\"matrix package has no declared runtime dependencies; smoke cannot validate install mirroring\",
);
}
for (const dep of runtimeDeps) {
requireFromMatrix.resolve(dep);
}
const { spawnSync } = require(\"node:child_process\");
const run = spawnSync(\"openclaw\", [\"plugins\", \"list\", \"--json\"], { encoding: \"utf8\" });
if (run.status !== 0) {
process.stderr.write(run.stderr || run.stdout || \"plugins list failed\\n\");
process.exit(run.status ?? 1);
}
const parsed = JSON.parse(run.stdout);
const matrix = (parsed.plugins || []).find((entry) => entry.id === \"matrix\");
if (!matrix) {
throw new Error(\"matrix plugin missing from bundled plugin list\");
}
const matrixDiag = (parsed.diagnostics || []).filter(
(diag) =>
typeof diag.source === \"string\" &&
diag.source.includes(\"/extensions/matrix\") &&
typeof diag.message === \"string\" &&
diag.message.includes(\"extension entry escapes package directory\"),
);
if (matrixDiag.length > 0) {
throw new Error(
\"unexpected matrix diagnostics: \" +
matrixDiag.map((diag) => diag.message).join(\"; \"),
);
}
"
'
installer_smoke:
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: "1"
steps:
- name: Checkout trusted installer harness
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
repository: ${{ needs.preflight.outputs.workflow_repository }}
ref: ${{ needs.preflight.outputs.workflow_sha }}
persist-credentials: false
- name: Checkout candidate CLI
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: ${{ needs.preflight.outputs.target_sha }}
path: candidate
persist-credentials: false
- name: Checkout trusted image artifact helper
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: Validate root Dockerfile image artifact binding
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
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
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
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:
max-cache-size-mb: 800000
- name: Build installer smoke image
run: |
timeout --kill-after=30s 20m docker buildx build \
--progress=plain \
--load \
-t openclaw-install-smoke:local \
-f ./scripts/docker/install-sh-smoke/Dockerfile \
./scripts/docker
- name: Build installer non-root image
run: |
timeout --kill-after=30s 20m docker buildx build \
--progress=plain \
--load \
-t openclaw-install-nonroot:local \
-f ./scripts/docker/install-sh-nonroot/Dockerfile \
./scripts/docker
- name: Setup Node environment for installer smoke
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
install-deps: "true"
- name: Run installer docker tests
env:
OPENCLAW_INSTALL_URL: file:///tmp/openclaw-install.sh
OPENCLAW_INSTALL_CLI_URL: file:///tmp/openclaw-install-cli.sh
OPENCLAW_NO_ONBOARD: "1"
OPENCLAW_INSTALL_SMOKE_SKIP_CLI: "0"
OPENCLAW_INSTALL_SMOKE_SKIP_IMAGE_BUILD: "1"
OPENCLAW_INSTALL_NONROOT_SKIP_IMAGE_BUILD: "1"
OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT: "0"
OPENCLAW_INSTALL_SMOKE_SKIP_NPM_GLOBAL: "1"
OPENCLAW_INSTALL_SMOKE_SKIP_PREVIOUS: "1"
OPENCLAW_INSTALL_SMOKE_UPDATE_BASELINE: ${{ inputs.update_baseline_version || 'latest' }}
OPENCLAW_INSTALL_SMOKE_UPDATE_DIST_IMAGE: ${{ needs.root_dockerfile_image.outputs.image_ref }}
OPENCLAW_INSTALL_SMOKE_UPDATE_SKIP_LOCAL_BUILD: "1"
OPENCLAW_INSTALL_SMOKE_SOURCE_DIR: ${{ github.workspace }}/candidate
run: bash scripts/test-install-sh-docker.sh
- name: Run Rocky Linux installer smoke
run: |
timeout --kill-after=30s 20m docker run --rm \
--platform linux/amd64 \
-e OPENCLAW_NO_ONBOARD=1 \
-e OPENCLAW_NO_PROMPT=1 \
-v "$PWD/candidate/scripts/install.sh:/tmp/install.sh:ro" \
rockylinux:9@sha256:d644d203142cd5b54ad2a83a203e1dee68af2229f8fe32f52a30c6e1d3c3a9e0 \
bash -lc 'dnf install -y -q ca-certificates tar gzip xz findutils which sudo >/dev/null && bash /tmp/install.sh --install-method npm --version latest --no-onboard --no-prompt --verify && openclaw --version'
- name: Run Rocky Linux CLI installer smoke
run: |
timeout --kill-after=30s 20m docker run --rm \
--platform linux/amd64 \
-e OPENCLAW_NO_ONBOARD=1 \
-e OPENCLAW_NO_PROMPT=1 \
-v "$PWD/candidate/scripts/install-cli.sh:/tmp/install-cli.sh:ro" \
rockylinux:9@sha256:d644d203142cd5b54ad2a83a203e1dee68af2229f8fe32f52a30c6e1d3c3a9e0 \
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, 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: "1"
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
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: Validate root Dockerfile image artifact binding
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
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
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
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:
install-bun: "true"
install-deps: "true"
- name: Run Bun global install image-provider smoke
env:
OPENCLAW_BUN_GLOBAL_SMOKE_DIST_IMAGE: ${{ needs.root_dockerfile_image.outputs.image_ref }}
OPENCLAW_BUN_GLOBAL_SMOKE_HOST_BUILD: "0"
run: bash scripts/e2e/bun-global-install-smoke.sh
docker-e2e-fast:
needs: [preflight]
if: needs.preflight.outputs.run_fast_install_smoke == 'true' || needs.preflight.outputs.run_full_install_smoke == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 12
env:
DOCKER_BUILD_SUMMARY: "false"
DOCKER_BUILD_RECORD_UPLOAD: "false"
steps:
- name: Checkout CLI
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: ${{ needs.preflight.outputs.target_sha }}
persist-credentials: false
- name: Set up Blacksmith Docker Builder
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
with:
max-cache-size-mb: 800000
- name: Setup Node environment for package smoke
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
install-deps: "true"
File diff suppressed because it is too large Load Diff
@@ -6,7 +6,6 @@ on:
ref:
description: Ref, tag, or SHA to validate
required: true
default: main
type: string
include_repo_e2e:
description: Whether to run pnpm test:e2e plus repo-specific extra E2E lanes
@@ -51,10 +50,9 @@ on:
shared_image_policy:
description: Shared Docker image transport
required: true
default: allow-push
default: no-push-artifact
type: choice
options:
- allow-push
- existing-only
- no-push-artifact
shared_image_artifact_namespace:
@@ -223,9 +221,9 @@ on:
default: ""
type: string
shared_image_policy:
description: "Shared Docker image transport: allow-push, existing-only, or no-push-artifact"
description: "Shared Docker image transport: existing-only or no-push-artifact"
required: false
default: allow-push
default: no-push-artifact
type: string
shared_image_artifact_namespace:
description: Safe unique artifact namespace when shared_image_policy=no-push-artifact
@@ -277,6 +275,10 @@ on:
required: false
default: true
type: boolean
outputs:
publication_manifest:
description: Immutable tested-image artifact manifest for an explicit publisher workflow
value: ${{ jobs.collect_shared_image_publication.outputs.manifest }}
secrets:
OPENAI_API_KEY:
required: false
@@ -506,8 +508,6 @@ jobs:
fi
case "$SHARED_IMAGE_POLICY" in
allow-push)
;;
existing-only)
if [[ -z "${PROVIDED_BARE_IMAGE// }" && -z "${PROVIDED_FUNCTIONAL_IMAGE// }" ]]; then
echo "shared_image_policy=existing-only requires explicit shared image refs." >&2
@@ -529,7 +529,7 @@ jobs:
}
;;
*)
echo "shared_image_policy must be allow-push, existing-only, or no-push-artifact." >&2
echo "shared_image_policy must be existing-only or no-push-artifact." >&2
exit 1
;;
esac
@@ -2056,7 +2056,7 @@ jobs:
echo "Shared Docker E2E functional image: \`$functional_image\`" >> "$GITHUB_STEP_SUMMARY"
- name: Log in to GHCR
if: steps.plan.outputs.needs_e2e_image == '1' && (inputs.shared_image_policy == 'allow-push' || inputs.shared_image_policy == 'existing-only')
if: steps.plan.outputs.needs_e2e_image == '1' && inputs.shared_image_policy == 'existing-only'
run: bash .release-harness/scripts/ci-docker-login-ghcr.sh
env:
GHCR_USERNAME: ${{ github.actor }}
@@ -2064,7 +2064,7 @@ jobs:
- name: Check existing shared Docker E2E images
id: image_exists
if: steps.plan.outputs.needs_e2e_image == '1' && (inputs.shared_image_policy == 'allow-push' || inputs.shared_image_policy == 'existing-only')
if: steps.plan.outputs.needs_e2e_image == '1' && inputs.shared_image_policy == 'existing-only'
shell: bash
env:
PROVIDED_BARE_IMAGE: ${{ inputs.docker_e2e_bare_image }}
@@ -2196,126 +2196,8 @@ jobs:
compression-level: 0
retention-days: 7
push_docker_e2e_images:
needs: [validate_selected_ref, prepare_docker_e2e_image]
if: inputs.shared_image_policy == 'allow-push' && needs.prepare_docker_e2e_image.outputs.needs_registry_build == '1'
runs-on: ${{ inputs.use_github_hosted_runners && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
timeout-minutes: ${{ inputs.release_test_profile == 'full' && 90 || 60 }}
permissions:
actions: read
contents: read
packages: write
env:
DOCKER_BUILD_SUMMARY: "false"
DOCKER_BUILD_RECORD_UPLOAD: "false"
steps:
- name: Checkout selected ref
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
fetch-depth: 1
persist-credentials: false
- name: Download OpenClaw Docker E2E package
if: needs.prepare_docker_e2e_image.outputs.needs_functional_image == '1' && needs.prepare_docker_e2e_image.outputs.functional_exists != '1'
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
artifact-ids: ${{ needs.prepare_docker_e2e_image.outputs.package_artifact_id }}
path: .artifacts/docker-e2e-package
run-id: ${{ needs.prepare_docker_e2e_image.outputs.package_artifact_run_id }}
github-token: ${{ github.token }}
- name: Normalize OpenClaw Docker E2E package
if: needs.prepare_docker_e2e_image.outputs.needs_functional_image == '1' && needs.prepare_docker_e2e_image.outputs.functional_exists != '1'
shell: bash
run: |
set -euo pipefail
target=".artifacts/docker-e2e-package/openclaw-current.tgz"
if [[ ! -f "$target" ]]; then
mapfile -t tgzs < <(find .artifacts/docker-e2e-package -type f -name '*.tgz' | sort)
if [[ "${#tgzs[@]}" -ne 1 ]]; then
echo "Expected exactly one package tarball for the registry image build; found ${#tgzs[@]}." >&2
exit 1
fi
cp "${tgzs[0]}" "$target"
fi
- name: Log in to GHCR
run: bash scripts/ci-docker-login-ghcr.sh
env:
GHCR_USERNAME: ${{ github.actor }}
GITHUB_TOKEN: ${{ github.token }}
- name: Setup Docker builder
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
with:
max-cache-size-mb: 800000
- name: Build and push bare Docker E2E image
if: needs.prepare_docker_e2e_image.outputs.needs_bare_image == '1' && needs.prepare_docker_e2e_image.outputs.bare_exists != '1'
shell: bash
env:
IMAGE_REF: ${{ needs.prepare_docker_e2e_image.outputs.bare_image }}
run: |
set -euo pipefail
build_cmd=(
docker buildx build
--file ./scripts/e2e/Dockerfile
--target bare
--platform linux/amd64
--tag "$IMAGE_REF"
--sbom=true
--provenance=mode=max
--push
.
)
for attempt in 1 2 3 4; do
if "${build_cmd[@]}"; then
exit 0
fi
if [[ "$attempt" == "4" ]]; then
echo "::error::Failed to build Docker E2E bare image after ${attempt} attempts"
exit 1
fi
sleep_seconds=$((attempt * 20))
echo "Docker E2E bare image build failed; retrying in ${sleep_seconds}s (${attempt}/4)."
sleep "$sleep_seconds"
done
- name: Build and push functional Docker E2E image
if: needs.prepare_docker_e2e_image.outputs.needs_functional_image == '1' && needs.prepare_docker_e2e_image.outputs.functional_exists != '1'
shell: bash
env:
IMAGE_REF: ${{ needs.prepare_docker_e2e_image.outputs.functional_image }}
run: |
set -euo pipefail
build_cmd=(
docker buildx build
--file ./scripts/e2e/Dockerfile
--target functional
--build-context openclaw_package=.artifacts/docker-e2e-package
--platform linux/amd64
--tag "$IMAGE_REF"
--sbom=true
--provenance=mode=max
--push
.
)
for attempt in 1 2 3 4; do
if "${build_cmd[@]}"; then
exit 0
fi
if [[ "$attempt" == "4" ]]; then
echo "::error::Failed to build Docker E2E functional image after ${attempt} attempts"
exit 1
fi
sleep_seconds=$((attempt * 20))
echo "Docker E2E functional image build failed; retrying in ${sleep_seconds}s (${attempt}/4)."
sleep "$sleep_seconds"
done
docker_e2e_image_ready:
needs: [prepare_docker_e2e_image, push_docker_e2e_images]
needs: prepare_docker_e2e_image
if: always() && needs.prepare_docker_e2e_image.result != 'skipped'
runs-on: ubuntu-24.04
timeout-minutes: 5
@@ -2325,9 +2207,6 @@ jobs:
- name: Verify Docker E2E image preparation
env:
PREPARE_RESULT: ${{ needs.prepare_docker_e2e_image.result }}
PUSH_RESULT: ${{ needs.push_docker_e2e_images.result }}
NEEDS_REGISTRY_BUILD: ${{ needs.prepare_docker_e2e_image.outputs.needs_registry_build }}
SHARED_IMAGE_POLICY: ${{ inputs.shared_image_policy }}
shell: bash
run: |
set -euo pipefail
@@ -2335,15 +2214,6 @@ jobs:
echo "Docker E2E image preparation ended with ${PREPARE_RESULT}." >&2
exit 1
fi
if [[ "$SHARED_IMAGE_POLICY" == "allow-push" && "$NEEDS_REGISTRY_BUILD" == "1" ]]; then
if [[ "$PUSH_RESULT" != "success" ]]; then
echo "Docker E2E registry image publication ended with ${PUSH_RESULT}." >&2
exit 1
fi
elif [[ "$PUSH_RESULT" != "skipped" ]]; then
echo "Unexpected Docker E2E registry publication result: ${PUSH_RESULT}." >&2
exit 1
fi
prepare_live_test_image:
needs: validate_selected_ref
@@ -2485,53 +2355,8 @@ jobs:
compression-level: 0
retention-days: 7
push_live_test_image:
needs: [validate_selected_ref, prepare_live_test_image]
if: inputs.shared_image_policy == 'allow-push' && needs.prepare_live_test_image.outputs.image_exists != '1'
runs-on: ${{ inputs.use_github_hosted_runners && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
timeout-minutes: 60
permissions:
contents: read
packages: write
env:
DOCKER_BUILD_SUMMARY: "false"
DOCKER_BUILD_RECORD_UPLOAD: "false"
steps:
- name: Checkout selected ref
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
fetch-depth: 1
persist-credentials: false
- name: Log in to GHCR
run: bash scripts/ci-docker-login-ghcr.sh
env:
GHCR_USERNAME: ${{ github.actor }}
GITHUB_TOKEN: ${{ github.token }}
- name: Setup Docker builder
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
with:
max-cache-size-mb: 800000
- name: Build and push shared live-test image
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
with:
context: .
file: ./Dockerfile
target: build
build-args: |
OPENCLAW_EXTENSIONS=matrix,acpx
platforms: linux/amd64
tags: ${{ needs.prepare_live_test_image.outputs.live_image }}
sbom: true
provenance: mode=max
load: false
push: true
live_test_image_ready:
needs: [prepare_live_test_image, push_live_test_image]
needs: prepare_live_test_image
if: always() && needs.prepare_live_test_image.result != 'skipped'
runs-on: ubuntu-24.04
timeout-minutes: 5
@@ -2540,10 +2365,7 @@ jobs:
steps:
- name: Verify live-test image preparation
env:
IMAGE_EXISTS: ${{ needs.prepare_live_test_image.outputs.image_exists }}
PREPARE_RESULT: ${{ needs.prepare_live_test_image.result }}
PUSH_RESULT: ${{ needs.push_live_test_image.result }}
SHARED_IMAGE_POLICY: ${{ inputs.shared_image_policy }}
shell: bash
run: |
set -euo pipefail
@@ -2551,15 +2373,143 @@ jobs:
echo "Live-test image preparation ended with ${PREPARE_RESULT}." >&2
exit 1
fi
if [[ "$SHARED_IMAGE_POLICY" == "allow-push" && "$IMAGE_EXISTS" != "1" ]]; then
if [[ "$PUSH_RESULT" != "success" ]]; then
echo "Live-test registry image publication ended with ${PUSH_RESULT}." >&2
exit 1
fi
elif [[ "$PUSH_RESULT" != "skipped" ]]; then
echo "Unexpected live-test registry publication result: ${PUSH_RESULT}." >&2
exit 1
fi
collect_shared_image_publication:
needs: [validate_selected_ref, prepare_docker_e2e_image, prepare_live_test_image]
if: always() && inputs.shared_image_policy == 'no-push-artifact' && needs.validate_selected_ref.result == 'success'
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions: {}
outputs:
manifest: ${{ steps.manifest.outputs.publication_manifest }}
steps:
- name: Collect immutable tested-image artifacts
id: manifest
env:
DOCKER_OUTPUTS: ${{ toJSON(needs.prepare_docker_e2e_image.outputs) }}
DOCKER_RESULT: ${{ needs.prepare_docker_e2e_image.result }}
LIVE_OUTPUTS: ${{ toJSON(needs.prepare_live_test_image.outputs) }}
LIVE_RESULT: ${{ needs.prepare_live_test_image.result }}
TARGET_SHA: ${{ needs.validate_selected_ref.outputs.selected_sha }}
WORKFLOW_REPOSITORY: ${{ needs.validate_selected_ref.outputs.workflow_repository }}
WORKFLOW_SHA: ${{ needs.validate_selected_ref.outputs.workflow_sha }}
shell: bash
run: |
set -euo pipefail
node --input-type=module <<'NODE'
import fs from "node:fs";
const parseOutputs = (label, value) => {
try {
return JSON.parse(value || "{}");
} catch (error) {
throw new Error(`${label} outputs are not valid JSON`, { cause: error });
}
};
const requireMatch = (label, value, pattern) => {
if (typeof value !== "string" || !pattern.test(value)) {
throw new Error(`${label} is missing or invalid`);
}
return value;
};
const optionalMatch = (label, value, pattern) => {
if (!value) {
return "";
}
return requireMatch(label, value, pattern);
};
const artifactFrom = (label, outputs) => {
if (!outputs.image_artifact_id) {
return null;
}
return {
archiveSha256: requireMatch(
`${label} archive SHA-256`,
outputs.image_archive_sha256,
/^[a-f0-9]{64}$/u,
),
digest: requireMatch(
`${label} artifact digest`,
outputs.image_artifact_digest,
/^[a-f0-9]{64}$/u,
),
id: requireMatch(`${label} artifact ID`, outputs.image_artifact_id, /^[1-9][0-9]*$/u),
name: requireMatch(
`${label} artifact name`,
outputs.image_artifact_name,
/^[A-Za-z0-9][A-Za-z0-9._-]*$/u,
),
runAttempt: requireMatch(
`${label} run attempt`,
outputs.image_artifact_run_attempt,
/^[1-9][0-9]*$/u,
),
runId: requireMatch(
`${label} run ID`,
outputs.image_artifact_run_id,
/^[1-9][0-9]*$/u,
),
};
};
const docker = parseOutputs("Docker E2E", process.env.DOCKER_OUTPUTS);
const live = parseOutputs("live-test", process.env.LIVE_OUTPUTS);
const dockerArtifact = artifactFrom("Docker E2E", docker);
const liveArtifact = artifactFrom("live-test", live);
if (process.env.DOCKER_RESULT === "success" && docker.needs_e2e_image === "1" && !dockerArtifact) {
throw new Error("Docker E2E image preparation succeeded without an immutable artifact");
}
if (process.env.LIVE_RESULT === "success" && live.live_image && !liveArtifact) {
throw new Error("live-test image preparation succeeded without an immutable artifact");
}
const dockerImages = [];
if (docker.needs_bare_image === "1") {
dockerImages.push({ role: "bare", ref: docker.bare_image });
}
if (docker.needs_functional_image === "1") {
dockerImages.push({ role: "functional", ref: docker.functional_image });
}
if (dockerArtifact && dockerImages.length === 0) {
throw new Error("Docker E2E artifact has no declared images");
}
const manifest = {
schema: "openclaw.shared-image-publication/v1",
targetSha: requireMatch("target SHA", process.env.TARGET_SHA, /^[a-f0-9]{40}$/u),
workflowRepository: requireMatch(
"workflow repository",
process.env.WORKFLOW_REPOSITORY,
/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u,
),
workflowSha: requireMatch(
"workflow SHA",
process.env.WORKFLOW_SHA,
/^[a-f0-9]{40}$/u,
),
dockerE2e: dockerArtifact
? {
artifact: dockerArtifact,
images: dockerImages,
packageSha256: optionalMatch(
"Docker E2E package SHA-256",
docker.package_sha256,
/^[a-f0-9]{64}$/u,
),
}
: null,
liveTest: liveArtifact
? {
artifact: liveArtifact,
images: [{ role: "live", ref: live.live_image }],
}
: null,
};
fs.appendFileSync(
process.env.GITHUB_OUTPUT,
`publication_manifest=${JSON.stringify(manifest)}\n`,
);
NODE
validate_live_models_docker:
name: Docker live models (${{ matrix.provider_label }})
@@ -652,10 +652,9 @@ jobs:
actions: read
contents: read
packages: read
uses: ./.github/workflows/install-smoke.yml
uses: ./.github/workflows/install-smoke-reusable.yml
with:
ref: ${{ needs.resolve_target.outputs.revision }}
root_image_transport: no-push-artifact
run_bun_global_install_smoke: true
cross_os_release_checks:
@@ -8,7 +8,7 @@ on:
permissions:
actions: read
contents: read
packages: write
packages: read
pull-requests: read
concurrency:
@@ -23,7 +23,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:
@@ -32,6 +32,8 @@ jobs:
include_release_path_suites: true
include_openwebui: true
include_live_suites: true
shared_image_artifact_namespace: scheduled-live
shared_image_policy: no-push-artifact
secrets:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
@@ -79,3 +81,14 @@ jobs:
OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON }}
OPENCLAW_GEMINI_SETTINGS_JSON: ${{ secrets.OPENCLAW_GEMINI_SETTINGS_JSON }}
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
publish_shared_images:
name: Publish tested shared images
needs: live_and_openwebui_checks
permissions:
actions: read
contents: read
packages: write
uses: ./.github/workflows/openclaw-shared-image-publish-reusable.yml
with:
publication_manifest: ${{ needs.live_and_openwebui_checks.outputs.publication_manifest }}
@@ -0,0 +1,440 @@
name: OpenClaw Shared Image Publisher (Reusable)
on:
workflow_call:
inputs:
publication_manifest:
description: Immutable tested-image artifact manifest from a no-write validation workflow
required: true
type: string
use_github_hosted_runners:
description: Use GitHub-hosted runners instead of Blacksmith
required: false
default: false
type: boolean
# This boundary is intentionally separate from release validation. Only callers that
# explicitly grant packages:write can publish images; read-only reusable graphs stay valid.
permissions: {}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
validate_publication:
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions: {}
outputs:
target_sha: ${{ steps.validate.outputs.target_sha }}
workflow_repository: ${{ steps.validate.outputs.workflow_repository }}
workflow_sha: ${{ steps.validate.outputs.workflow_sha }}
has_docker_e2e: ${{ steps.validate.outputs.has_docker_e2e }}
docker_artifact_id: ${{ steps.validate.outputs.docker_artifact_id }}
docker_artifact_name: ${{ steps.validate.outputs.docker_artifact_name }}
docker_artifact_digest: ${{ steps.validate.outputs.docker_artifact_digest }}
docker_artifact_run_id: ${{ steps.validate.outputs.docker_artifact_run_id }}
docker_artifact_run_attempt: ${{ steps.validate.outputs.docker_artifact_run_attempt }}
docker_archive_sha256: ${{ steps.validate.outputs.docker_archive_sha256 }}
docker_package_sha256: ${{ steps.validate.outputs.docker_package_sha256 }}
docker_bare_source: ${{ steps.validate.outputs.docker_bare_source }}
docker_bare_destination: ${{ steps.validate.outputs.docker_bare_destination }}
docker_functional_source: ${{ steps.validate.outputs.docker_functional_source }}
docker_functional_destination: ${{ steps.validate.outputs.docker_functional_destination }}
has_live_test: ${{ steps.validate.outputs.has_live_test }}
live_artifact_id: ${{ steps.validate.outputs.live_artifact_id }}
live_artifact_name: ${{ steps.validate.outputs.live_artifact_name }}
live_artifact_digest: ${{ steps.validate.outputs.live_artifact_digest }}
live_artifact_run_id: ${{ steps.validate.outputs.live_artifact_run_id }}
live_artifact_run_attempt: ${{ steps.validate.outputs.live_artifact_run_attempt }}
live_archive_sha256: ${{ steps.validate.outputs.live_archive_sha256 }}
live_source: ${{ steps.validate.outputs.live_source }}
live_destination: ${{ steps.validate.outputs.live_destination }}
steps:
- name: Validate publication manifest and destinations
id: validate
env:
JOB_CONTEXT: ${{ toJSON(job) }}
PUBLICATION_MANIFEST: ${{ inputs.publication_manifest }}
shell: bash
run: |
set -euo pipefail
node --input-type=module <<'NODE'
import fs from "node:fs";
const fail = (message) => {
throw new Error(message);
};
const requireMatch = (label, value, pattern) => {
if (typeof value !== "string" || !pattern.test(value)) {
fail(`${label} is missing or invalid`);
}
return value;
};
const artifact = (label, value) => {
if (!value || typeof value !== "object" || Array.isArray(value)) {
fail(`${label} artifact is missing or invalid`);
}
const normalized = {
archiveSha256: requireMatch(
`${label} archive SHA-256`,
value.archiveSha256,
/^[a-f0-9]{64}$/u,
),
digest: requireMatch(`${label} digest`, value.digest, /^[a-f0-9]{64}$/u),
id: requireMatch(`${label} ID`, value.id, /^[1-9][0-9]*$/u),
name: requireMatch(`${label} name`, value.name, /^[A-Za-z0-9][A-Za-z0-9._-]*$/u),
runAttempt: requireMatch(
`${label} run attempt`,
value.runAttempt,
/^[1-9][0-9]*$/u,
),
runId: requireMatch(`${label} run ID`, value.runId, /^[1-9][0-9]*$/u),
};
if (!normalized.name.endsWith(`-${normalized.runId}-${normalized.runAttempt}`)) {
fail(`${label} name does not bind its producer run attempt`);
}
return normalized;
};
const imageMap = (label, images) => {
if (!Array.isArray(images) || images.length === 0) {
fail(`${label} images are missing`);
}
const result = new Map();
for (const image of images) {
const role = requireMatch(`${label} role`, image?.role, /^[a-z][a-z-]*$/u);
const ref = requireMatch(
`${label} image ref`,
image?.ref,
/^[A-Za-z0-9][A-Za-z0-9._/@:-]*$/u,
);
if (result.has(role)) {
fail(`${label} has duplicate role ${role}`);
}
result.set(role, ref);
}
return result;
};
const emit = (name, value) => {
const text = String(value ?? "");
if (text.includes("\n") || text.includes("\r")) {
fail(`output ${name} contains a newline`);
}
fs.appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${text}\n`);
};
let manifest;
let job;
try {
manifest = JSON.parse(process.env.PUBLICATION_MANIFEST || "");
job = JSON.parse(process.env.JOB_CONTEXT || "");
} catch (error) {
throw new Error("publication manifest or job context is not valid JSON", { cause: error });
}
if (manifest.schema !== "openclaw.shared-image-publication/v1") {
fail("unsupported shared-image publication schema");
}
const targetSha = requireMatch("target SHA", manifest.targetSha, /^[a-f0-9]{40}$/u);
const workflowRepository = requireMatch(
"workflow repository",
manifest.workflowRepository,
/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u,
);
const workflowSha = requireMatch(
"workflow SHA",
manifest.workflowSha,
/^[a-f0-9]{40}$/u,
);
if (
job.workflow_repository !== workflowRepository ||
job.workflow_sha !== workflowSha ||
workflowRepository.toLowerCase() !== process.env.GITHUB_REPOSITORY.toLowerCase()
) {
fail("publication manifest is not bound to this publisher workflow revision");
}
const repository = process.env.GITHUB_REPOSITORY.toLowerCase();
emit("target_sha", targetSha);
emit("workflow_repository", workflowRepository);
emit("workflow_sha", workflowSha);
const docker = manifest.dockerE2e;
emit("has_docker_e2e", docker ? "true" : "false");
if (docker) {
const tuple = artifact("Docker E2E", docker.artifact);
const images = imageMap("Docker E2E", docker.images);
const packageSha256 = docker.packageSha256
? requireMatch("Docker E2E package SHA-256", docker.packageSha256, /^[a-f0-9]{64}$/u)
: "";
const tag = packageSha256 ? `pkg-${packageSha256.slice(0, 32)}` : targetSha;
for (const role of images.keys()) {
if (role !== "bare" && role !== "functional") {
fail(`unsupported Docker E2E role: ${role}`);
}
}
for (const role of ["bare", "functional"]) {
const source = images.get(role) || "";
if (source && source !== `openclaw-docker-e2e-${role}:${tag}`) {
fail(`Docker E2E ${role} ref does not match the tested package identity`);
}
emit(`docker_${role}_source`, source);
emit(
`docker_${role}_destination`,
source ? `ghcr.io/${repository}-docker-e2e-${role}:${tag}` : "",
);
}
emit("docker_artifact_id", tuple.id);
emit("docker_artifact_name", tuple.name);
emit("docker_artifact_digest", tuple.digest);
emit("docker_artifact_run_id", tuple.runId);
emit("docker_artifact_run_attempt", tuple.runAttempt);
emit("docker_archive_sha256", tuple.archiveSha256);
emit("docker_package_sha256", packageSha256);
}
const live = manifest.liveTest;
emit("has_live_test", live ? "true" : "false");
if (live) {
const tuple = artifact("live-test", live.artifact);
const images = imageMap("live-test", live.images);
if (images.size !== 1 || !images.has("live")) {
fail("live-test publication must contain exactly the live image");
}
const source = images.get("live");
const tag = `${targetSha}-matrix-acpx`;
if (source !== `openclaw-live-test:${tag}`) {
fail("live-test ref does not match the tested target SHA");
}
emit("live_artifact_id", tuple.id);
emit("live_artifact_name", tuple.name);
emit("live_artifact_digest", tuple.digest);
emit("live_artifact_run_id", tuple.runId);
emit("live_artifact_run_attempt", tuple.runAttempt);
emit("live_archive_sha256", tuple.archiveSha256);
emit("live_source", source);
emit("live_destination", `ghcr.io/${repository}-live-test:${tag}`);
}
if (!docker && !live) {
fail("publication manifest contains no tested images");
}
NODE
publish_docker_e2e:
needs: validate_publication
if: needs.validate_publication.outputs.has_docker_e2e == 'true'
runs-on: ${{ inputs.use_github_hosted_runners && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
timeout-minutes: 30
permissions:
actions: read
contents: read
packages: write
steps:
- name: Checkout trusted publication harness
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
repository: ${{ needs.validate_publication.outputs.workflow_repository }}
ref: ${{ needs.validate_publication.outputs.workflow_sha }}
path: .release-harness
fetch-depth: 1
persist-credentials: false
- name: Verify Docker E2E image artifact binding
env:
ARTIFACT_DIGEST: ${{ needs.validate_publication.outputs.docker_artifact_digest }}
ARTIFACT_ID: ${{ needs.validate_publication.outputs.docker_artifact_id }}
ARTIFACT_NAME: ${{ needs.validate_publication.outputs.docker_artifact_name }}
ARTIFACT_RUN_ATTEMPT: ${{ needs.validate_publication.outputs.docker_artifact_run_attempt }}
ARTIFACT_RUN_ID: ${{ needs.validate_publication.outputs.docker_artifact_run_id }}
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
set -euo pipefail
bash .release-harness/scripts/docker/shared-image-artifact.sh \
verify-upload "Docker E2E image" \
"$ARTIFACT_ID" "$ARTIFACT_NAME" "$ARTIFACT_DIGEST" \
"$ARTIFACT_RUN_ID" "$ARTIFACT_RUN_ATTEMPT"
- name: Download Docker E2E image artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
artifact-ids: ${{ needs.validate_publication.outputs.docker_artifact_id }}
path: .artifacts/docker-e2e-images
run-id: ${{ needs.validate_publication.outputs.docker_artifact_run_id }}
github-token: ${{ github.token }}
- name: Verify and load Docker E2E images
env:
ARCHIVE_SHA256: ${{ needs.validate_publication.outputs.docker_archive_sha256 }}
BARE_IMAGE: ${{ needs.validate_publication.outputs.docker_bare_source }}
FUNCTIONAL_IMAGE: ${{ needs.validate_publication.outputs.docker_functional_source }}
PACKAGE_SHA256: ${{ needs.validate_publication.outputs.docker_package_sha256 }}
OPENCLAW_SHARED_IMAGE_RUN_ATTEMPT: ${{ needs.validate_publication.outputs.docker_artifact_run_attempt }}
OPENCLAW_SHARED_IMAGE_RUN_ID: ${{ needs.validate_publication.outputs.docker_artifact_run_id }}
TARGET_SHA: ${{ needs.validate_publication.outputs.target_sha }}
WORKFLOW_SHA: ${{ needs.validate_publication.outputs.workflow_sha }}
shell: bash
run: |
set -euo pipefail
images=()
[[ -z "$BARE_IMAGE" ]] || images+=("$BARE_IMAGE")
[[ -z "$FUNCTIONAL_IMAGE" ]] || images+=("$FUNCTIONAL_IMAGE")
OPENCLAW_SHARED_IMAGE_ARCHIVE_SHA256="$ARCHIVE_SHA256" \
OPENCLAW_SHARED_IMAGE_PACKAGE_SHA256="$PACKAGE_SHA256" \
bash .release-harness/scripts/docker/shared-image-artifact.sh \
load .artifacts/docker-e2e-images docker-e2e "$TARGET_SHA" "$WORKFLOW_SHA" "${images[@]}"
- name: Log in to GHCR
env:
GHCR_USERNAME: ${{ github.actor }}
GITHUB_TOKEN: ${{ github.token }}
run: bash .release-harness/scripts/ci-docker-login-ghcr.sh
- name: Publish tested Docker E2E images
env:
BARE_DESTINATION: ${{ needs.validate_publication.outputs.docker_bare_destination }}
BARE_SOURCE: ${{ needs.validate_publication.outputs.docker_bare_source }}
FUNCTIONAL_DESTINATION: ${{ needs.validate_publication.outputs.docker_functional_destination }}
FUNCTIONAL_SOURCE: ${{ needs.validate_publication.outputs.docker_functional_source }}
shell: bash
run: |
set -euo pipefail
publish_image() {
local source="$1"
local destination="$2"
[[ -z "$source" ]] && return 0
docker image tag "$source" "$destination"
for attempt in 1 2 3 4; do
if docker image push "$destination"; then
echo "Published tested image: \`$destination\`" >> "$GITHUB_STEP_SUMMARY"
return 0
fi
[[ "$attempt" == "4" ]] && return 1
sleep "$((attempt * 20))"
done
}
publish_image "$BARE_SOURCE" "$BARE_DESTINATION"
publish_image "$FUNCTIONAL_SOURCE" "$FUNCTIONAL_DESTINATION"
publish_live_test:
needs: validate_publication
if: needs.validate_publication.outputs.has_live_test == 'true'
runs-on: ${{ inputs.use_github_hosted_runners && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
timeout-minutes: 30
permissions:
actions: read
contents: read
packages: write
steps:
- name: Checkout trusted publication harness
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
repository: ${{ needs.validate_publication.outputs.workflow_repository }}
ref: ${{ needs.validate_publication.outputs.workflow_sha }}
path: .release-harness
fetch-depth: 1
persist-credentials: false
- name: Verify live-test image artifact binding
env:
ARTIFACT_DIGEST: ${{ needs.validate_publication.outputs.live_artifact_digest }}
ARTIFACT_ID: ${{ needs.validate_publication.outputs.live_artifact_id }}
ARTIFACT_NAME: ${{ needs.validate_publication.outputs.live_artifact_name }}
ARTIFACT_RUN_ATTEMPT: ${{ needs.validate_publication.outputs.live_artifact_run_attempt }}
ARTIFACT_RUN_ID: ${{ needs.validate_publication.outputs.live_artifact_run_id }}
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
set -euo pipefail
bash .release-harness/scripts/docker/shared-image-artifact.sh \
verify-upload "live-test image" \
"$ARTIFACT_ID" "$ARTIFACT_NAME" "$ARTIFACT_DIGEST" \
"$ARTIFACT_RUN_ID" "$ARTIFACT_RUN_ATTEMPT"
- name: Download live-test image artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
artifact-ids: ${{ needs.validate_publication.outputs.live_artifact_id }}
path: .artifacts/live-test-image
run-id: ${{ needs.validate_publication.outputs.live_artifact_run_id }}
github-token: ${{ github.token }}
- name: Verify and load live-test image
env:
ARCHIVE_SHA256: ${{ needs.validate_publication.outputs.live_archive_sha256 }}
LIVE_IMAGE: ${{ needs.validate_publication.outputs.live_source }}
OPENCLAW_SHARED_IMAGE_RUN_ATTEMPT: ${{ needs.validate_publication.outputs.live_artifact_run_attempt }}
OPENCLAW_SHARED_IMAGE_RUN_ID: ${{ needs.validate_publication.outputs.live_artifact_run_id }}
TARGET_SHA: ${{ needs.validate_publication.outputs.target_sha }}
WORKFLOW_SHA: ${{ needs.validate_publication.outputs.workflow_sha }}
shell: bash
run: |
set -euo pipefail
OPENCLAW_SHARED_IMAGE_ARCHIVE_SHA256="$ARCHIVE_SHA256" \
bash .release-harness/scripts/docker/shared-image-artifact.sh \
load .artifacts/live-test-image live-test "$TARGET_SHA" "$WORKFLOW_SHA" "$LIVE_IMAGE"
- name: Log in to GHCR
env:
GHCR_USERNAME: ${{ github.actor }}
GITHUB_TOKEN: ${{ github.token }}
run: bash .release-harness/scripts/ci-docker-login-ghcr.sh
- name: Publish tested live-test image
env:
DESTINATION: ${{ needs.validate_publication.outputs.live_destination }}
SOURCE: ${{ needs.validate_publication.outputs.live_source }}
shell: bash
run: |
set -euo pipefail
docker image tag "$SOURCE" "$DESTINATION"
for attempt in 1 2 3 4; do
if docker image push "$DESTINATION"; then
echo "Published tested image: \`$DESTINATION\`" >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
[[ "$attempt" == "4" ]] && exit 1
sleep "$((attempt * 20))"
done
verify_publication:
needs: [validate_publication, publish_docker_e2e, publish_live_test]
if: always()
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions: {}
steps:
- name: Verify requested image publication
env:
DOCKER_EXPECTED: ${{ needs.validate_publication.outputs.has_docker_e2e }}
DOCKER_RESULT: ${{ needs.publish_docker_e2e.result }}
LIVE_EXPECTED: ${{ needs.validate_publication.outputs.has_live_test }}
LIVE_RESULT: ${{ needs.publish_live_test.result }}
VALIDATE_RESULT: ${{ needs.validate_publication.result }}
shell: bash
run: |
set -euo pipefail
[[ "$VALIDATE_RESULT" == "success" ]] || {
echo "Publication manifest validation ended with ${VALIDATE_RESULT}." >&2
exit 1
}
requested=0
for item in \
"docker:$DOCKER_EXPECTED:$DOCKER_RESULT" \
"live:$LIVE_EXPECTED:$LIVE_RESULT"; do
IFS=: read -r label expected result <<< "$item"
if [[ "$expected" == "true" ]]; then
requested=1
[[ "$result" == "success" ]] || {
echo "${label} image publication ended with ${result}." >&2
exit 1
}
elif [[ "$result" != "skipped" ]]; then
echo "Unexpected ${label} image publication result: ${result}." >&2
exit 1
fi
done
[[ "$requested" == "1" ]] || {
echo "Publication manifest requested no images." >&2
exit 1
}
+6 -7
View File
@@ -87,10 +87,9 @@ on:
shared_image_policy:
description: Shared Docker image transport for package acceptance
required: true
default: allow-push
default: no-push-artifact
type: choice
options:
- allow-push
- existing-only
- no-push-artifact
shared_image_artifact_namespace:
@@ -230,9 +229,9 @@ on:
default: ""
type: string
shared_image_policy:
description: "Shared Docker image transport: allow-push, existing-only, or no-push-artifact"
description: "Shared Docker image transport: existing-only or no-push-artifact"
required: false
default: allow-push
default: no-push-artifact
type: string
shared_image_artifact_namespace:
description: Unique artifact namespace when shared_image_policy=no-push-artifact
@@ -864,13 +863,13 @@ jobs:
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
docker_acceptance_registry:
name: Docker product acceptance (registry)
name: Docker product acceptance (existing registry images)
needs: [resolve_package, package_integrity]
if: inputs.shared_image_policy != 'no-push-artifact'
if: inputs.shared_image_policy == 'existing-only'
permissions:
actions: read
contents: read
packages: write
packages: read
pull-requests: read
uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml
with: *docker_acceptance_inputs
+3 -1
View File
@@ -27,7 +27,7 @@ on:
permissions:
actions: read
contents: read
packages: write
packages: read
pull-requests: read
jobs:
@@ -42,5 +42,7 @@ jobs:
docker_lanes: update-migration
published_upgrade_survivor_baselines: ${{ inputs.baselines }}
published_upgrade_survivor_scenarios: ${{ inputs.scenarios }}
shared_image_artifact_namespace: update-migration
shared_image_policy: no-push-artifact
telegram_mode: none
secrets: inherit # zizmor: ignore[secrets-inherit] Maintainer-dispatched package acceptance lane intentionally forwards its declared live-test secret matrix.
+6 -6
View File
@@ -441,9 +441,9 @@ When debugging a failed package acceptance run, start at the `resolve_package` s
## Install smoke
The separate `Install Smoke` workflow no longer runs on pull requests or `main` pushes. It runs on a nightly schedule, on manual dispatch, and as a workflow call from release validation, and every run takes the full install-smoke path on GitHub-hosted runners:
The `Install Smoke` workflow no longer runs on pull requests or `main` pushes. Its nightly/manual wrapper and release validation both call the read-only `install-smoke-reusable.yml` core, and every run takes the full install-smoke path on GitHub-hosted runners:
- The root Dockerfile smoke image is built once per target SHA (or reused from GHCR as `ghcr.io/openclaw/openclaw-dockerfile-smoke:<sha>`), then the CLI smoke, the agents delete shared-workspace CLI smoke, the container gateway-network E2E, and the bundled `matrix` plugin build-arg smoke run against it. The plugin smoke verifies runtime dependency install mirroring and that the plugin loads without entry-escape diagnostics.
- The root Dockerfile smoke image is built once per target SHA, bound to the workflow revision and producer attempt in an immutable artifact, then loaded by the CLI smoke, agents delete shared-workspace CLI smoke, container gateway-network E2E, and bundled `matrix` plugin build-arg smoke. The plugin smoke verifies runtime dependency install mirroring and that the plugin loads without entry-escape diagnostics.
- QR package install and the installer/update Docker smokes (including Rocky Linux installer lanes and an update lane against a configurable `update_baseline_version` npm baseline) run as separate jobs so installer work does not wait behind the root image smokes.
The slow Bun global install image-provider smoke is separately gated by `run_bun_global_install_smoke`. It runs on the nightly schedule, defaults on for workflow calls from release checks, and manual `Install Smoke` dispatches can opt into it. Normal PR CI still runs the fast Bun launcher regression lane for Node-relevant changes. QR and installer Docker tests keep their own install-focused Dockerfiles.
@@ -475,11 +475,11 @@ A lane heavier than its effective cap can still start from an empty pool, then r
### Reusable live/E2E workflow
The reusable live/E2E workflow asks `scripts/test-docker-all.mjs --plan-json` which package, image kind, live image, lane, and credential coverage is required. `scripts/docker-e2e.mjs` then converts that plan into GitHub outputs and summaries. It either packs OpenClaw through `scripts/package-openclaw-for-docker.mjs`, downloads a current-run package artifact, or downloads a package artifact from `package_artifact_run_id`; validates the tarball inventory; builds and pushes package-digest-tagged bare/functional GHCR Docker E2E images through Blacksmith's Docker layer cache when the plan needs package-installed lanes; and reuses provided `docker_e2e_bare_image`/`docker_e2e_functional_image` inputs or existing package-digest images instead of rebuilding. Docker image pulls are retried with a bounded 180-second per-attempt timeout so a stuck registry/cache stream retries quickly instead of consuming most of the CI critical path.
The reusable live/E2E workflow asks `scripts/test-docker-all.mjs --plan-json` which package, image kind, live image, lane, and credential coverage is required. `scripts/docker-e2e.mjs` then converts that plan into GitHub outputs and summaries. It either packs OpenClaw through `scripts/package-openclaw-for-docker.mjs`, downloads a current-run package artifact, or downloads a package artifact from `package_artifact_run_id`, then validates the tarball inventory. The default `no-push-artifact` path builds package-digest-tagged bare/functional images through Blacksmith's Docker layer cache, packs the exact image bytes into an immutable workflow artifact, and has each consumer verify and load that artifact. `existing-only` instead requires explicit `docker_e2e_bare_image`/`docker_e2e_functional_image` GHCR refs and never builds or pushes. Those registry pulls use a bounded 180-second per-attempt timeout so a stuck stream retries quickly instead of consuming most of the CI critical path. After successful scheduled validation, `openclaw-scheduled-live-checks.yml` passes the immutable tested-image manifest to the separate package-write publisher; read-only release and prerelease callers never traverse that writer.
### Release-path chunks
Release Docker coverage runs smaller chunked jobs with `OPENCLAW_SKIP_DOCKER_BUILD=1` so each chunk pulls only the image kind it needs and executes multiple lanes through the same weighted scheduler:
Release Docker coverage runs smaller chunked jobs with `OPENCLAW_SKIP_DOCKER_BUILD=1` so each chunk verifies and loads only the artifact-backed image kind it needs (or pulls it under explicit `existing-only` reuse) and executes multiple lanes through the same weighted scheduler:
- `OPENCLAW_DOCKER_ALL_PROFILE=release-path`
- `OPENCLAW_DOCKER_ALL_CHUNK=core | package-update-openai | package-update-anthropic | package-update-core | plugins-runtime-plugins | plugins-runtime-services | plugins-runtime-install-a..h | openwebui`
@@ -488,14 +488,14 @@ Current release Docker chunks are `core`, `package-update-openai`, `package-upda
OpenWebUI runs as a standalone `openwebui` chunk on a dedicated large-disk Blacksmith runner whenever stable or full release-path coverage requests it, even when the reusable workflow routes supported jobs to GitHub-hosted runners. Keeping the external image pull separate prevents the large image from competing with the shared package and plugin images in `plugins-runtime-services`; legacy aggregate plugin/runtime chunks still include OpenWebUI for compatible manual reruns. Bundled-channel update lanes retry once for transient npm network failures.
Each chunk uploads `.artifacts/docker-tests/` with lane logs, timings, `summary.json`, `failures.json`, phase timings, scheduler plan JSON, slow-lane tables, and per-lane rerun commands. The workflow `docker_lanes` input runs selected lanes against the prepared images instead of the chunk jobs, which keeps failed-lane debugging bounded to one targeted Docker job and prepares, downloads, or reuses the package artifact for that run; if a selected lane is a live Docker lane, the targeted job builds the live-test image locally for that rerun. Generated per-lane GitHub rerun commands include `package_artifact_run_id`, `package_artifact_name`, and prepared image inputs when those values exist, so a failed lane can reuse the exact package and images from the failed run.
Each chunk uploads `.artifacts/docker-tests/` with lane logs, timings, `summary.json`, `failures.json`, phase timings, scheduler plan JSON, slow-lane tables, and per-lane rerun commands. The workflow `docker_lanes` input runs selected lanes against images prepared for that run instead of the chunk jobs, which keeps failed-lane debugging bounded to one targeted Docker job; if a selected lane is a live Docker lane, the targeted job builds the live-test image locally for that rerun. The rerun helper validates the failure artifact's exact selected target SHA and manual dispatch repacks that ref, because the internal reusable-workflow package tuple is not part of the `workflow_dispatch` schema. Generated commands include prepared image inputs and `shared_image_policy=existing-only` only when those inputs are GHCR-backed; runner-local artifact tags are omitted so a fresh runner rebuilds them. An explicit target override drops recovered GHCR image refs unless the artifact proves they match the override. Artifact-generated workflow-definition refs are also omitted because full-release temporary branches are deleted; dispatch uses the repository default branch unless the operator explicitly overrides it.
```bash
pnpm test:docker:rerun <run-id> # download Docker artifacts and print combined/per-lane targeted rerun commands
pnpm test:docker:timings <summary> # slow-lane and phase critical-path summaries
```
The scheduled live/E2E workflow runs the full release-path Docker suite daily.
The scheduled live/E2E workflow runs the full release-path Docker suite daily and, after it succeeds, invokes the explicit publisher for the exact tested image artifacts.
## Plugin Prerelease
@@ -264,6 +264,7 @@ Useful artifacts:
- `.github/workflows/openclaw-live-and-e2e-checks-reusable.yml`
- `.github/workflows/plugin-prerelease.yml`
- `.github/workflows/install-smoke.yml`
- `.github/workflows/install-smoke-reusable.yml`
- `.github/workflows/openclaw-cross-os-release-checks-reusable.yml`
- `.github/workflows/package-acceptance.yml`
- `.github/workflows/openclaw-performance.yml`
+115 -65
View File
@@ -2,8 +2,8 @@
// Builds cheap rerun commands from a Docker E2E GitHub run or local summary.
// For GitHub runs, the script downloads Docker E2E artifacts, reads
// summary/failures JSON, and prints targeted workflow commands for failed
// lanes, reusing package artifacts and prepared GHCR images when artifacts
// expose them.
// lanes, repacking the exact artifact target and reusing GHCR-backed prepared
// image refs when artifacts expose them.
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
@@ -93,8 +93,6 @@ function maybeGhcrImage(value) {
}
const TRUSTED_WORKFLOW_INPUTS = new Map([
["package_artifact_run_id", "packageArtifactRunId"],
["package_artifact_name", "packageArtifactName"],
["docker_e2e_bare_image", "bareImage"],
["docker_e2e_functional_image", "functionalImage"],
["published_upgrade_survivor_baseline", "publishedUpgradeSurvivorBaseline"],
@@ -103,18 +101,14 @@ const TRUSTED_WORKFLOW_INPUTS = new Map([
]);
const REUSE_INPUT_KEYS = [
"packageArtifactRunId",
"packageArtifactName",
"bareImage",
"functionalImage",
"workflowRef",
"publishedUpgradeSurvivorBaseline",
"publishedUpgradeSurvivorBaselines",
"publishedUpgradeSurvivorScenarios",
];
const WORKFLOW_INPUT_RE = /(?:^|\s)-f\s+([a-z0-9_]+)=('([^']*)'|[^\s]+)/gu;
const WORKFLOW_REF_RE = /(?:^|\s)--ref\s+('([^']*)'|[^\s]+)/u;
function trustedReuseInputsFromCommand(command) {
const text = String(command ?? "");
@@ -122,10 +116,6 @@ function trustedReuseInputsFromCommand(command) {
return {};
}
const inputs = {};
const refValue = text.match(WORKFLOW_REF_RE);
if (refValue) {
inputs.workflowRef = (refValue[2] ?? refValue[1] ?? "").replace(/^'/u, "").replace(/'$/u, "");
}
for (const match of text.matchAll(WORKFLOW_INPUT_RE)) {
const target = TRUSTED_WORKFLOW_INPUTS.get(match[1]);
const value = (match[3] ?? match[2] ?? "").replace(/^'/u, "").replace(/'$/u, "");
@@ -142,19 +132,47 @@ function trustedReuseInputsFromCommand(command) {
}
function reuseInputsFromJson(parsed) {
const packageArtifactRunId = parsed.github?.runId || "";
if (!packageArtifactRunId) {
return {};
}
const bareImage = maybeGhcrImage(parsed.images?.bare);
const functionalImage = maybeGhcrImage(parsed.images?.functional);
return {
bareImage: maybeGhcrImage(parsed.images?.bare),
functionalImage: maybeGhcrImage(parsed.images?.functional),
packageArtifactName:
parsed.packageArtifactName || parsed.artifacts?.packageName || "docker-e2e-package",
packageArtifactRunId,
...(bareImage ? { bareImage } : {}),
...(functionalImage ? { functionalImage } : {}),
};
}
function artifactTargetRef(parsed, file, required) {
const values = [parsed.ref, parsed.github?.selectedSha].filter(
(value) => value !== undefined && value !== null && value !== "",
);
const valid = values.every((value) => typeof value === "string" && /^[a-f0-9]{40}$/u.test(value));
if (!valid) {
if (required) {
throw new Error(`${file} has an invalid artifact target ref; expected a full commit SHA`);
}
return "";
}
const refs = [...new Set(values)];
if (refs.length > 1) {
if (required) {
throw new Error(`${file} has conflicting artifact target refs: ${refs.join(", ")}`);
}
return "";
}
return refs[0] || "";
}
function discardMismatchedPreparedImages(entry, explicitRef) {
if (!explicitRef || entry.artifactRef === explicitRef) {
return entry;
}
const reuseInputs = Object.fromEntries(
Object.entries(entry.reuseInputs ?? {}).filter(
([key]) => key !== "bareImage" && key !== "functionalImage",
),
);
return { ...entry, reuseInputs };
}
function sameReuseInputs(left, right) {
return REUSE_INPUT_KEYS.every((key) => (left?.[key] || "") === (right?.[key] || ""));
}
@@ -187,10 +205,7 @@ function groupByReuseInputs(entries) {
}
function ghWorkflowCommand(lanes, ref, workflow, reuseInputs = {}) {
const workflowRef =
reuseInputs.workflowRef ||
process.env.OPENCLAW_DOCKER_E2E_WORKFLOW_REF ||
process.env.GITHUB_REF_NAME;
const workflowRef = process.env.OPENCLAW_DOCKER_E2E_WORKFLOW_REF;
const releasePath = lanes.some(laneNeedsReleasePath);
const fields = [
"gh workflow run",
@@ -211,19 +226,15 @@ function ghWorkflowCommand(lanes, ref, workflow, reuseInputs = {}) {
"-f",
"live_models_only=false",
];
if (reuseInputs.packageArtifactRunId) {
fields.push("-f", `package_artifact_run_id=${shellQuote(reuseInputs.packageArtifactRunId)}`);
fields.push(
"-f",
`package_artifact_name=${shellQuote(reuseInputs.packageArtifactName || "docker-e2e-package")}`,
);
}
if (reuseInputs.bareImage) {
fields.push("-f", `docker_e2e_bare_image=${shellQuote(reuseInputs.bareImage)}`);
}
if (reuseInputs.functionalImage) {
fields.push("-f", `docker_e2e_functional_image=${shellQuote(reuseInputs.functionalImage)}`);
}
if (reuseInputs.bareImage || reuseInputs.functionalImage) {
fields.push("-f", "shared_image_policy=existing-only");
}
if (reuseInputs.publishedUpgradeSurvivorBaseline) {
fields.push(
"-f",
@@ -255,7 +266,7 @@ function failureName(failure) {
return failure.name || failure.lane || "";
}
function failedEntryFromRecord(failure, file, ref, workflow, reuseInputs) {
function failedEntryFromRecord(failure, file, artifactRef, reuseInputs) {
const lane = failureName(failure);
const targetable = failure.targetable !== false;
const workflowInputs = {
@@ -263,6 +274,7 @@ function failedEntryFromRecord(failure, file, ref, workflow, reuseInputs) {
...reuseInputs,
};
return {
artifactRef,
lane,
localRerunCommand: failure.rerunCommand,
logFile: failure.logFile,
@@ -299,27 +311,39 @@ function findFiles(rootDir, basenames, out = []) {
return out;
}
function failedLaneEntriesFromJson(file, ref, workflow) {
function failedLaneEntriesFromJson(file, explicitRef = "") {
const parsed = readJson(file);
const reuseInputs = reuseInputsFromJson(parsed);
const source = path.basename(file);
let failures;
if (source === "failures.json" && Array.isArray(parsed.lanes)) {
return parsed.lanes
.filter((lane) => failureName(lane))
.map((lane) => failedEntryFromRecord(lane, file, ref, workflow, reuseInputs));
failures = parsed.lanes.filter((lane) => failureName(lane));
} else {
const lanes = Array.isArray(parsed.lanes) ? parsed.lanes : [];
failures =
Array.isArray(parsed.failures) && parsed.failures.length > 0
? parsed.failures
: lanes.filter((lane) => lane.status !== 0);
failures = failures.filter((lane) => failureName(lane));
}
const lanes = Array.isArray(parsed.lanes) ? parsed.lanes : [];
const failures =
Array.isArray(parsed.failures) && parsed.failures.length > 0
? parsed.failures
: lanes.filter((lane) => lane.status !== 0);
return failures
.filter((lane) => failureName(lane))
.map((lane) => failedEntryFromRecord(lane, file, ref, workflow, reuseInputs));
const needsTargetRef = !explicitRef && failures.some((failure) => failure.targetable !== false);
const artifactRef = artifactTargetRef(parsed, file, needsTargetRef);
return failures.map((failure) =>
discardMismatchedPreparedImages(
failedEntryFromRecord(failure, file, artifactRef, reuseInputs),
explicitRef,
),
);
}
function mergeByLane(entries) {
function mergeArtifactRefs(left, right, lane) {
if (left && right && left !== right) {
throw new Error(`lane ${lane} has mixed artifact target refs: ${left}, ${right}`);
}
return left || right || "";
}
function mergeByLane(entries, explicitRef = "") {
const byLane = new Map();
for (const entry of entries) {
const existing = byLane.get(entry.lane);
@@ -327,6 +351,8 @@ function mergeByLane(entries) {
byLane.set(entry.lane, {
...existing,
...entry,
artifactRef:
explicitRef || mergeArtifactRefs(existing.artifactRef, entry.artifactRef, entry.lane),
localRerunCommand: existing.localRerunCommand || entry.localRerunCommand,
logFile: existing.logFile || entry.logFile,
reuseInputs: mergeReuseInputs(existing.reuseInputs, entry.reuseInputs),
@@ -334,12 +360,36 @@ function mergeByLane(entries) {
targetable: existing.targetable !== false && entry.targetable !== false,
});
} else {
byLane.set(entry.lane, entry);
byLane.set(entry.lane, { ...entry, artifactRef: explicitRef || entry.artifactRef });
}
}
return [...byLane.values()].toSorted((left, right) => left.lane.localeCompare(right.lane));
}
function resolveTargetRef(entries, explicitRef) {
const targetable = entries.filter((entry) => entry.targetable !== false);
if (targetable.length === 0) {
return "";
}
if (explicitRef) {
if (!/^[a-f0-9]{40}$/u.test(explicitRef)) {
throw new Error("--ref must be the exact lowercase 40-character target SHA");
}
return explicitRef;
}
const missing = targetable.filter((entry) => !entry.artifactRef);
if (missing.length > 0) {
throw new Error(
`Docker E2E artifacts are missing an exact target ref for: ${missing.map((entry) => entry.lane).join(", ")}; pass --ref explicitly`,
);
}
const refs = [...new Set(targetable.map((entry) => entry.artifactRef).filter(Boolean))];
if (refs.length > 1) {
throw new Error(`Docker E2E artifacts contain mixed target refs: ${refs.join(", ")}`);
}
return refs[0] || "";
}
function downloadDockerArtifacts(runId, repo, outputDir) {
fs.mkdirSync(outputDir, { recursive: true });
const artifacts = JSON.parse(
@@ -392,7 +442,9 @@ function safePathSegment(value) {
}
function defaultOutputDir(input) {
return fs.mkdtempSync(path.join(os.tmpdir(), `openclaw-docker-e2e-rerun-${safePathSegment(input)}-`));
return fs.mkdtempSync(
path.join(os.tmpdir(), `openclaw-docker-e2e-rerun-${safePathSegment(input)}-`),
);
}
function printEntries(entries, ref, workflow, runValue) {
@@ -402,7 +454,7 @@ function printEntries(entries, ref, workflow, runValue) {
}
console.log(`Ref: ${ref}`);
console.log(
"Targeted GitHub reruns reuse package artifacts and prepared GHCR images when the downloaded artifacts expose them.",
"Targeted GitHub reruns repack the exact artifact target and reuse GHCR-backed prepared image refs when the downloaded artifacts expose them.",
);
if (entries.length === 0) {
console.log("No failed Docker E2E lanes found.");
@@ -432,13 +484,13 @@ function printEntries(entries, ref, workflow, runValue) {
);
}
}
console.log("");
console.log("Per-lane GitHub reruns:");
for (const entry of workflowEntries) {
console.log(
`- ${entry.lane}: ${ghWorkflowCommand([entry.lane], ref, workflow, entry.reuseInputs)}`,
);
}
console.log("");
console.log("Per-lane GitHub reruns:");
for (const entry of workflowEntries) {
console.log(
`- ${entry.lane}: ${ghWorkflowCommand([entry.lane], ref, workflow, entry.reuseInputs)}`,
);
}
} else {
console.log("");
console.log("No targetable failed Docker E2E lanes found.");
@@ -460,22 +512,20 @@ function main() {
}
const isLocalJson = fs.existsSync(options.input) && fs.statSync(options.input).isFile();
if (isLocalJson) {
const ref = options.ref || process.env.GITHUB_SHA || "HEAD";
printEntries(
mergeByLane(failedLaneEntriesFromJson(options.input, ref, options.workflow)),
ref,
options.workflow,
);
const entries = mergeByLane(failedLaneEntriesFromJson(options.input, options.ref), options.ref);
const ref = resolveTargetRef(entries, options.ref);
printEntries(entries, ref, options.workflow);
} else {
const repo = options.repo || detectRepo();
const runLocal = runInfo(options.input, repo);
const ref = options.ref || runLocal.headSha || runLocal.headBranch;
const outputDir = options.dir || defaultOutputDir(options.input);
const artifactNames = downloadDockerArtifacts(options.input, repo, outputDir);
const files = findFiles(outputDir, new Set(["failures.json", "summary.json"]));
const entries = mergeByLane(
files.flatMap((file) => failedLaneEntriesFromJson(file, ref, options.workflow)),
files.flatMap((file) => failedLaneEntriesFromJson(file, options.ref)),
options.ref,
);
const ref = resolveTargetRef(entries, options.ref);
console.log(`Artifacts: ${artifactNames.join(", ")}`);
console.log(`Downloaded: ${outputDir}`);
printEntries(entries, ref, options.workflow, runLocal);
+25 -46
View File
@@ -265,31 +265,22 @@ function shellQuote(value) {
return `'${String(value).replaceAll("'", "'\\''")}'`;
}
function githubWorkflowRef() {
const explicit = process.env.OPENCLAW_DOCKER_E2E_WORKFLOW_REF;
if (explicit) {
return explicit;
}
const refName = process.env.GITHUB_REF_NAME;
if (refName) {
return refName;
}
const ref = process.env.GITHUB_REF;
if (ref?.startsWith("refs/heads/")) {
return ref.slice("refs/heads/".length);
}
if (ref?.startsWith("refs/tags/")) {
return ref.slice("refs/tags/".length);
}
return undefined;
function githubWorkflowRef(env = process.env) {
return env.OPENCLAW_DOCKER_E2E_WORKFLOW_REF || undefined;
}
function githubWorkflowRerunCommand(laneNames, ref) {
const workflowRef = githubWorkflowRef();
const releasePath = process.env.OPENCLAW_DOCKER_ALL_PROFILE === RELEASE_PATH_PROFILE;
function maybeGhcrImage(value) {
return typeof value === "string" && value.startsWith("ghcr.io/") ? value : "";
}
export function githubWorkflowRerunCommand(laneNames, ref, env = process.env) {
const workflowRef = githubWorkflowRef(env);
const releasePath = env.OPENCLAW_DOCKER_ALL_PROFILE === RELEASE_PATH_PROFILE;
const bareImage = maybeGhcrImage(env.OPENCLAW_DOCKER_E2E_BARE_IMAGE);
const functionalImage = maybeGhcrImage(env.OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE);
const fields = [
"gh workflow run",
shellQuote(process.env.OPENCLAW_DOCKER_E2E_WORKFLOW || DEFAULT_GITHUB_WORKFLOW),
shellQuote(env.OPENCLAW_DOCKER_E2E_WORKFLOW || DEFAULT_GITHUB_WORKFLOW),
...(workflowRef ? ["--ref", shellQuote(workflowRef)] : []),
"-f",
`ref=${shellQuote(ref)}`,
@@ -306,44 +297,32 @@ function githubWorkflowRerunCommand(laneNames, ref) {
"-f",
"live_models_only=false",
];
if (process.env.GITHUB_RUN_ID) {
fields.push("-f", `package_artifact_run_id=${shellQuote(process.env.GITHUB_RUN_ID)}`);
if (env.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC) {
fields.push(
"-f",
`package_artifact_name=${shellQuote(
process.env.OPENCLAW_DOCKER_E2E_PACKAGE_ARTIFACT_NAME || "docker-e2e-package",
)}`,
`published_upgrade_survivor_baseline=${shellQuote(env.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC)}`,
);
}
if (process.env.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC) {
if (env.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS) {
fields.push(
"-f",
`published_upgrade_survivor_baseline=${shellQuote(process.env.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC)}`,
`published_upgrade_survivor_baselines=${shellQuote(env.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS)}`,
);
}
if (process.env.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS) {
if (env.OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS) {
fields.push(
"-f",
`published_upgrade_survivor_baselines=${shellQuote(process.env.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS)}`,
`published_upgrade_survivor_scenarios=${shellQuote(env.OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS)}`,
);
}
if (process.env.OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS) {
fields.push(
"-f",
`published_upgrade_survivor_scenarios=${shellQuote(process.env.OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS)}`,
);
if (bareImage) {
fields.push("-f", `docker_e2e_bare_image=${shellQuote(bareImage)}`);
}
if (process.env.OPENCLAW_DOCKER_E2E_BARE_IMAGE) {
fields.push(
"-f",
`docker_e2e_bare_image=${shellQuote(process.env.OPENCLAW_DOCKER_E2E_BARE_IMAGE)}`,
);
if (functionalImage) {
fields.push("-f", `docker_e2e_functional_image=${shellQuote(functionalImage)}`);
}
if (process.env.OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE) {
fields.push(
"-f",
`docker_e2e_functional_image=${shellQuote(process.env.OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE)}`,
);
if (bareImage || functionalImage) {
fields.push("-f", "shared_image_policy=existing-only");
}
return fields.join(" ");
}
@@ -499,7 +478,7 @@ async function writeFailureIndex(logDir, summary) {
: undefined,
generatedAt: new Date().toISOString(),
lanes,
note: "Targeted GitHub reruns reuse this run's package artifact and shared Docker images when the generated command includes package_artifact_run_id and docker_e2e_*_image inputs.",
note: "Targeted GitHub reruns repack the exact selected ref and reuse only GHCR-backed shared images when the generated command includes docker_e2e_*_image inputs.",
images: summary.images,
packageArtifactName: process.env.OPENCLAW_DOCKER_E2E_PACKAGE_ARTIFACT_NAME || undefined,
ref,
+22 -2
View File
@@ -482,7 +482,20 @@ const GITHUB_WORKFLOW_OWNER_TEST_TARGETS = new Map([
"test/scripts/plugin-prerelease-test-plan.test.ts",
],
],
[".github/workflows/install-smoke.yml", ["test/scripts/test-install-sh-docker.test.ts"]],
[
".github/workflows/install-smoke.yml",
[
"test/scripts/install-smoke-no-push-workflow.test.ts",
"test/scripts/test-install-sh-docker.test.ts",
],
],
[
".github/workflows/install-smoke-reusable.yml",
[
"test/scripts/install-smoke-no-push-workflow.test.ts",
"test/scripts/test-install-sh-docker.test.ts",
],
],
[
".github/workflows/ios-periphery-comment.yml",
["test/scripts/ios-periphery-comment-workflow.test.ts"],
@@ -551,6 +564,10 @@ const GITHUB_WORKFLOW_OWNER_TEST_TARGETS = new Map([
"test/scripts/test-install-sh-docker.test.ts",
],
],
[
".github/workflows/openclaw-shared-image-publish-reusable.yml",
["test/scripts/release-no-push-workflow.test.ts"],
],
[
".github/workflows/openclaw-npm-release.yml",
[
@@ -577,7 +594,10 @@ const GITHUB_WORKFLOW_OWNER_TEST_TARGETS = new Map([
],
[
".github/workflows/openclaw-scheduled-live-checks.yml",
["test/scripts/package-acceptance-workflow.test.ts"],
[
"test/scripts/package-acceptance-workflow.test.ts",
"test/scripts/release-no-push-workflow.test.ts",
],
],
[
".github/workflows/openclaw-stable-main-closeout.yml",
+50
View File
@@ -13,6 +13,7 @@ import { tmpdir } from "node:os";
import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { describe, expect, it } from "vitest";
import { parse } from "yaml";
import { DEFAULT_RESOURCE_LIMITS } from "../../scripts/lib/docker-e2e-plan.mjs";
import {
appendBoundedShellCapture,
@@ -20,6 +21,7 @@ import {
describeDockerSchedulerLimits,
dockerPreflightContainerNames,
dockerPreflightSmokeCommand,
githubWorkflowRerunCommand,
LOG_TAIL_MAX_BYTES,
parseDockerAllCliArgs,
resolveDockerPreflightPlatform,
@@ -41,6 +43,19 @@ const limits = {
};
const posixIt = process.platform === "win32" ? it.skip : it;
const { createTempDir } = createScriptTestHarness();
const LIVE_E2E_WORKFLOW = ".github/workflows/openclaw-live-and-e2e-checks-reusable.yml";
function expectDeclaredDispatchInputs(command: string): void {
const workflow = parse(readFileSync(LIVE_E2E_WORKFLOW, "utf8")) as {
on?: { workflow_dispatch?: { inputs?: Record<string, unknown> } };
};
const declared = new Set(Object.keys(workflow.on?.workflow_dispatch?.inputs ?? {}));
const emitted = [...command.matchAll(/(?:^|\s)-f\s+([a-z0-9_]+)=/gu)].map((match) => match[1]);
expect(emitted.length).toBeGreaterThan(0);
for (const input of emitted) {
expect(declared.has(input), `undeclared workflow_dispatch input: ${input}`).toBe(true);
}
}
function activePool({
count = 0,
@@ -149,6 +164,41 @@ describe("scripts/test-docker-all scheduler", () => {
expect(result.stderr).not.toContain("at ");
});
it("reuses only registry-backed images in generated workflow reruns", () => {
const localCommand = githubWorkflowRerunCommand(["install-e2e"], "a".repeat(40), {
GITHUB_REF_NAME: "full-release-validation-temp-deleted",
GITHUB_RUN_ID: "12345",
OPENCLAW_DOCKER_E2E_BARE_IMAGE: "openclaw-docker-e2e-bare:local",
OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE: "openclaw-docker-e2e-functional:local",
OPENCLAW_DOCKER_E2E_PACKAGE_ARTIFACT_NAME: "docker-e2e-package",
});
expect(localCommand).not.toContain("--ref 'full-release-validation-temp-deleted'");
expect(localCommand).not.toContain("package_artifact_run_id=");
expect(localCommand).not.toContain("package_artifact_name=");
expect(localCommand).not.toContain("docker_e2e_bare_image=");
expect(localCommand).not.toContain("docker_e2e_functional_image=");
expect(localCommand).not.toContain("shared_image_policy=existing-only");
expectDeclaredDispatchInputs(localCommand);
const registryCommand = githubWorkflowRerunCommand(["install-e2e"], "b".repeat(40), {
OPENCLAW_DOCKER_E2E_BARE_IMAGE: "ghcr.io/openclaw/openclaw-docker-e2e-bare:test",
OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE: "ghcr.io/openclaw/openclaw-docker-e2e-functional:test",
OPENCLAW_DOCKER_E2E_WORKFLOW_REF: "main",
OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC: "openclaw@2026.5.3",
OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS: "openclaw@2026.5.3 openclaw@2026.5.2",
OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS: "plugin-dependency-cleanup",
});
expect(registryCommand).toContain("--ref 'main'");
expect(registryCommand).toContain(
"docker_e2e_bare_image='ghcr.io/openclaw/openclaw-docker-e2e-bare:test'",
);
expect(registryCommand).toContain(
"docker_e2e_functional_image='ghcr.io/openclaw/openclaw-docker-e2e-functional:test'",
);
expect(registryCommand).toContain("shared_image_policy=existing-only");
expectDeclaredDispatchInputs(registryCommand);
});
it("rejects loose numeric resource limit env vars before scheduling lanes", () => {
const logDir = mkdtempSync(`${tmpdir()}/openclaw-docker-all-`);
try {
+290 -22
View File
@@ -1,9 +1,21 @@
// Docker E2E Helper Cli tests cover docker e2e helper cli script behavior.
import { spawnSync } from "node:child_process";
import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { parse } from "yaml";
const LIVE_E2E_WORKFLOW = ".github/workflows/openclaw-live-and-e2e-checks-reusable.yml";
const EXACT_TARGET_REF = "1".repeat(40);
function runHelper(script: string, ...args: Array<string | Record<string, string>>) {
const maybeEnv = args.at(-1);
@@ -32,6 +44,25 @@ function downloadedDir(stdout: string) {
return dir;
}
function emittedWorkflowCommands(stdout: string): string[] {
return stdout
.split(/\r?\n/u)
.filter((line) => line.includes("gh workflow run"))
.map((line) => line.slice(line.indexOf("gh workflow run")));
}
function expectDeclaredDispatchInputs(command: string): void {
const workflow = parse(readFileSync(LIVE_E2E_WORKFLOW, "utf8")) as {
on?: { workflow_dispatch?: { inputs?: Record<string, unknown> } };
};
const declared = new Set(Object.keys(workflow.on?.workflow_dispatch?.inputs ?? {}));
const emitted = [...command.matchAll(/(?:^|\s)-f\s+([a-z0-9_]+)=/gu)].map((match) => match[1]);
expect(emitted.length).toBeGreaterThan(0);
for (const input of emitted) {
expect(declared.has(input), `undeclared workflow_dispatch input: ${input}`).toBe(true);
}
}
describe("Docker E2E helper CLIs", () => {
it("prints scheduler helper help without throwing a stack trace", () => {
const result = runHelper("scripts/docker-e2e.mjs", "--help");
@@ -154,7 +185,7 @@ describe("Docker E2E helper CLIs", () => {
const file = path.join(root, "summary.json");
writeFileSync(file, `${JSON.stringify({ filler: "x".repeat(128) })}\n`, "utf8");
const result = runHelper("scripts/docker-e2e-rerun.mjs", file, "--ref", "abc123", {
const result = runHelper("scripts/docker-e2e-rerun.mjs", file, "--ref", EXACT_TARGET_REF, {
OPENCLAW_DOCKER_E2E_JSON_ARTIFACT_MAX_BYTES: "64",
});
@@ -191,16 +222,18 @@ describe("Docker E2E helper CLIs", () => {
status: 0,
},
],
ref: "HEAD",
status: "failed",
}
: {
lanes: [cleanupFailure],
ref: "HEAD",
status: "failed",
};
const file = path.join(root, fileName);
writeFileSync(file, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
const result = runHelper("scripts/docker-e2e-rerun.mjs", file, "--ref", "abc123");
const result = runHelper("scripts/docker-e2e-rerun.mjs", file);
expect(result.status).toBe(0);
expect(result.stderr).toBe("");
@@ -237,7 +270,7 @@ describe("Docker E2E helper CLIs", () => {
"utf8",
);
const result = runHelper("scripts/docker-e2e-rerun.mjs", file, "--ref", "abc123");
const result = runHelper("scripts/docker-e2e-rerun.mjs", file, "--ref", EXACT_TARGET_REF);
expect(result.status).toBe(0);
expect(result.stderr).toBe("");
@@ -248,7 +281,154 @@ describe("Docker E2E helper CLIs", () => {
}
});
it("preserves whitelisted rerun inputs from artifact commands", () => {
it.each([
["failures.json", (targetRef: string) => ({ ref: targetRef })],
["summary.json", (targetRef: string) => ({ github: { selectedSha: targetRef } })],
] as const)(
"uses the exact artifact target from %s instead of the workflow head",
(name, refData) => {
const root = mkdtempSync(`${tmpdir()}/openclaw-docker-e2e-rerun-ref-`);
try {
const targetRef = "a".repeat(40);
const workflowHead = "b".repeat(40);
const file = path.join(root, name);
writeFileSync(
file,
`${JSON.stringify(
{
...refData(targetRef),
failures: [{ name: "gateway-network", status: 1 }],
lanes: [{ name: "gateway-network", status: 1 }],
status: "failed",
},
null,
2,
)}\n`,
"utf8",
);
const result = runHelper("scripts/docker-e2e-rerun.mjs", file, {
GITHUB_SHA: workflowHead,
});
expect(result.status, result.stderr).toBe(0);
expect(result.stderr).toBe("");
expect(result.stdout).toContain(`Ref: ${targetRef}`);
expect(result.stdout).toContain(`-f ref='${targetRef}'`);
expect(result.stdout).not.toContain(workflowHead);
} finally {
rmSync(root, { force: true, recursive: true });
}
},
);
it("lets an explicit target ref override artifact refs", () => {
const root = mkdtempSync(`${tmpdir()}/openclaw-docker-e2e-rerun-ref-override-`);
try {
const artifactRef = "a".repeat(40);
const explicitRef = "c".repeat(40);
const file = path.join(root, "failures.json");
writeFileSync(
file,
`${JSON.stringify({
images: { bare: "ghcr.io/openclaw/openclaw-bare:artifact-a" },
lanes: [{ name: "gateway-network", status: 1 }],
ref: artifactRef,
status: "failed",
})}\n`,
"utf8",
);
const result = runHelper("scripts/docker-e2e-rerun.mjs", file, "--ref", explicitRef);
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toContain(`Ref: ${explicitRef}`);
expect(result.stdout).toContain(`-f ref='${explicitRef}'`);
expect(result.stdout).not.toContain(`-f ref='${artifactRef}'`);
expect(result.stdout).not.toContain("docker_e2e_bare_image=");
expect(result.stdout).not.toContain("shared_image_policy=existing-only");
} finally {
rmSync(root, { force: true, recursive: true });
}
});
it("requires an artifact target ref when no explicit ref is supplied", () => {
const root = mkdtempSync(`${tmpdir()}/openclaw-docker-e2e-rerun-ref-missing-`);
try {
const file = path.join(root, "failures.json");
writeFileSync(
file,
`${JSON.stringify({
lanes: [{ name: "gateway-network", status: 1 }],
status: "failed",
})}\n`,
"utf8",
);
const result = runHelper("scripts/docker-e2e-rerun.mjs", file);
expect(result.status).toBe(1);
expect(result.stdout).toBe("");
expect(result.stderr).toContain("missing an exact target ref");
expect(result.stderr).toContain("pass --ref explicitly");
expect(result.stderr).not.toContain("HEAD");
} finally {
rmSync(root, { force: true, recursive: true });
}
});
it("rejects a non-exact artifact target for a targetable rerun", () => {
const root = mkdtempSync(`${tmpdir()}/openclaw-docker-e2e-rerun-ref-artifact-invalid-`);
try {
const file = path.join(root, "failures.json");
writeFileSync(
file,
`${JSON.stringify({
lanes: [{ name: "gateway-network", status: 1 }],
ref: "HEAD",
status: "failed",
})}\n`,
"utf8",
);
const result = runHelper("scripts/docker-e2e-rerun.mjs", file);
expect(result.status).toBe(1);
expect(result.stdout).toBe("");
expect(result.stderr).toContain("invalid artifact target ref");
expect(result.stderr).toContain("full commit SHA");
} finally {
rmSync(root, { force: true, recursive: true });
}
});
it.each(["abc123", "A".repeat(40), "main"])(
"rejects a non-exact explicit target ref: %s",
(explicitRef) => {
const root = mkdtempSync(`${tmpdir()}/openclaw-docker-e2e-rerun-ref-invalid-`);
try {
const file = path.join(root, "failures.json");
writeFileSync(
file,
`${JSON.stringify({
lanes: [{ name: "gateway-network", status: 1 }],
status: "failed",
})}\n`,
"utf8",
);
const result = runHelper("scripts/docker-e2e-rerun.mjs", file, "--ref", explicitRef);
expect(result.status).toBe(1);
expect(result.stdout).toBe("");
expect(result.stderr).toContain("exact lowercase 40-character target SHA");
} finally {
rmSync(root, { force: true, recursive: true });
}
},
);
it("preserves declared rerun inputs but ignores package and workflow refs", () => {
const root = mkdtempSync(`${tmpdir()}/openclaw-docker-e2e-rerun-inputs-`);
try {
const file = path.join(root, "failures.json");
@@ -259,11 +439,12 @@ describe("Docker E2E helper CLIs", () => {
lanes: [
{
ghWorkflowCommand:
"gh workflow run 'openclaw-live-and-e2e-checks-reusable.yml' --ref 'release/2026.6' -f package_artifact_run_id='12345' -f package_artifact_name='docker-e2e-package' -f docker_e2e_bare_image='ghcr.io/openclaw/openclaw-bare:test' -f published_upgrade_survivor_baselines='openclaw@2026.5.3' -f published_upgrade_survivor_scenarios='plugin-dependency-cleanup' -f unsafe_input='do-not-copy'",
"gh workflow run 'openclaw-live-and-e2e-checks-reusable.yml' --ref 'full-release-validation-temp-deleted' -f package_artifact_run_id='12345' -f package_artifact_name='docker-e2e-package' -f docker_e2e_bare_image='ghcr.io/openclaw/openclaw-bare:test' -f published_upgrade_survivor_baselines='openclaw@2026.5.3' -f published_upgrade_survivor_scenarios='plugin-dependency-cleanup' -f unsafe_input='do-not-copy'",
name: "published-upgrade-survivor-openclaw-2026-5-3",
status: 1,
},
],
ref: EXACT_TARGET_REF,
status: "failed",
},
null,
@@ -272,35 +453,40 @@ describe("Docker E2E helper CLIs", () => {
"utf8",
);
const result = runHelper("scripts/docker-e2e-rerun.mjs", file, "--ref", "abc123");
const result = runHelper("scripts/docker-e2e-rerun.mjs", file, "--ref", EXACT_TARGET_REF);
expect(result.status).toBe(0);
expect(result.stderr).toBe("");
const combinedCommand = result.stdout.match(/Combined GitHub rerun:\n([^\n]+)/u)?.[1] ?? "";
expect(combinedCommand).toContain("--ref 'release/2026.6'");
expect(combinedCommand).toContain("package_artifact_run_id='12345'");
expect(combinedCommand).not.toContain("--ref 'full-release-validation-temp-deleted'");
expect(combinedCommand).not.toContain("package_artifact_run_id=");
expect(combinedCommand).not.toContain("package_artifact_name=");
expect(combinedCommand).toContain(
"docker_e2e_bare_image='ghcr.io/openclaw/openclaw-bare:test'",
);
expect(combinedCommand).toContain(
"published_upgrade_survivor_baselines='openclaw@2026.5.3'",
);
expect(combinedCommand).toContain("shared_image_policy=existing-only");
expect(combinedCommand).toContain("published_upgrade_survivor_baselines='openclaw@2026.5.3'");
expect(combinedCommand).toContain(
"published_upgrade_survivor_scenarios='plugin-dependency-cleanup'",
);
expect(combinedCommand).not.toContain("unsafe_input");
expect(result.stdout).toContain("package_artifact_run_id='12345'");
expect(result.stdout).not.toContain("package_artifact_run_id=");
expect(result.stdout).not.toContain("package_artifact_name=");
expect(result.stdout).toContain(
"docker_e2e_bare_image='ghcr.io/openclaw/openclaw-bare:test'",
);
expect(result.stdout).toContain(
"published_upgrade_survivor_baselines='openclaw@2026.5.3'",
);
expect(result.stdout).toContain("shared_image_policy=existing-only");
expect(result.stdout).toContain("published_upgrade_survivor_baselines='openclaw@2026.5.3'");
expect(result.stdout).toContain(
"published_upgrade_survivor_scenarios='plugin-dependency-cleanup'",
);
expect(result.stdout).not.toContain("unsafe_input");
expect(result.stdout).not.toContain("do-not-copy");
const commands = emittedWorkflowCommands(result.stdout);
expect(commands.length).toBeGreaterThan(0);
for (const command of commands) {
expectDeclaredDispatchInputs(command);
}
} finally {
rmSync(root, { force: true, recursive: true });
}
@@ -336,7 +522,7 @@ describe("Docker E2E helper CLIs", () => {
"utf8",
);
const result = runHelper("scripts/docker-e2e-rerun.mjs", file, "--ref", "abc123");
const result = runHelper("scripts/docker-e2e-rerun.mjs", file, "--ref", EXACT_TARGET_REF);
expect(result.status).toBe(0);
expect(result.stderr).toBe("");
@@ -389,15 +575,13 @@ describe("Docker E2E helper CLIs", () => {
"utf8",
);
const result = runHelper("scripts/docker-e2e-rerun.mjs", file, "--ref", "abc123");
const result = runHelper("scripts/docker-e2e-rerun.mjs", file, "--ref", EXACT_TARGET_REF);
expect(result.status).toBe(0);
expect(result.stderr).toBe("");
const combinedCommand = result.stdout.match(/Combined GitHub rerun:\n([^\n]+)/u)?.[1] ?? "";
expect(combinedCommand).toContain("--ref 'release/2026.6'");
expect(combinedCommand).toContain(
"published_upgrade_survivor_baselines='openclaw@2026.5.3'",
);
expect(combinedCommand).not.toContain("--ref 'release/2026.6'");
expect(combinedCommand).toContain("published_upgrade_survivor_baselines='openclaw@2026.5.3'");
} finally {
rmSync(root, { force: true, recursive: true });
}
@@ -438,6 +622,7 @@ describe("Docker E2E helper CLIs", () => {
" fs.mkdirSync(path.join(dir, 'artifact'), { recursive: true });",
" fs.writeFileSync(path.join(dir, 'artifact', 'failures.json'), JSON.stringify({",
" lanes: [{ name: 'gateway-network', status: 1 }],",
" ref: 'd'.repeat(40),",
" status: 'failed',",
" }));",
" process.exit(0);",
@@ -478,6 +663,9 @@ describe("Docker E2E helper CLIs", () => {
expect(path.basename(secondDir)).toMatch(/^openclaw-docker-e2e-rerun-12345-/u);
expect(existsSync(path.join(firstDir, "artifact", "failures.json"))).toBe(true);
expect(existsSync(path.join(secondDir, "artifact", "failures.json"))).toBe(true);
expect(first.stdout).toContain(`-f ref='${"d".repeat(40)}'`);
expect(first.stdout).not.toContain("-f ref='abc123'");
expect(second.stdout).toContain(`-f ref='${"d".repeat(40)}'`);
} finally {
for (const dir of generatedDirs) {
rmSync(dir, { force: true, recursive: true });
@@ -485,4 +673,84 @@ describe("Docker E2E helper CLIs", () => {
rmSync(root, { force: true, recursive: true });
}
});
it("fails closed when downloaded artifacts contain mixed target refs", () => {
const root = mkdtempSync(`${tmpdir()}/openclaw-docker-e2e-rerun-mixed-refs-`);
try {
const binDir = path.join(root, "bin");
const outputDir = path.join(root, "artifacts");
const ghPath = path.join(binDir, "gh");
mkdirSync(binDir, { recursive: true });
writeFileSync(
ghPath,
[
"#!/usr/bin/env node",
"const fs = require('node:fs');",
"const path = require('node:path');",
"const args = process.argv.slice(2);",
"if (args[0] === 'run' && args[1] === 'view') {",
" console.log(JSON.stringify({ headBranch: 'main', headSha: 'f'.repeat(40), url: 'https://example.invalid/run', workflowName: 'Live E2E' }));",
" process.exit(0);",
"}",
"if (args[0] === 'api') {",
" console.log(JSON.stringify([",
" { expired: false, name: 'docker-e2e-a' },",
" { expired: false, name: 'docker-e2e-b' },",
" ]));",
" process.exit(0);",
"}",
"if (args[0] === 'run' && args[1] === 'download') {",
" const name = args[args.indexOf('--name') + 1];",
" const dir = args[args.indexOf('--dir') + 1];",
" const target = path.join(dir, name);",
" fs.mkdirSync(target, { recursive: true });",
" fs.writeFileSync(path.join(target, 'failures.json'), JSON.stringify({",
" lanes: [{ name: name.endsWith('-a') ? 'gateway-network' : 'install-e2e', status: 1 }],",
" ref: (name.endsWith('-a') ? 'a' : 'b').repeat(40),",
" status: 'failed',",
" }));",
" process.exit(0);",
"}",
"console.error(`unexpected gh args: ${args.join(' ')}`);",
"process.exit(1);",
"",
].join("\n"),
"utf8",
);
chmodSync(ghPath, 0o755);
const result = runHelper(
"scripts/docker-e2e-rerun.mjs",
"12345",
"--repo",
"openclaw/openclaw",
"--dir",
outputDir,
{ PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}` },
);
expect(result.status).toBe(1);
expect(result.stdout).toBe("");
expect(result.stderr).toContain("mixed target refs");
expect(result.stderr).toContain("a".repeat(40));
expect(result.stderr).toContain("b".repeat(40));
const explicitRef = "c".repeat(40);
const override = runHelper(
"scripts/docker-e2e-rerun.mjs",
"12345",
"--repo",
"openclaw/openclaw",
"--dir",
outputDir,
"--ref",
explicitRef,
{ PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}` },
);
expect(override.status, override.stderr).toBe(0);
expect(override.stdout).toContain(`-f ref='${explicitRef}'`);
} finally {
rmSync(root, { force: true, recursive: true });
}
});
});
@@ -3,10 +3,11 @@ import { describe, expect, it } from "vitest";
import { parse } from "yaml";
const INSTALL_SMOKE = ".github/workflows/install-smoke.yml";
const INSTALL_SMOKE_REUSABLE = ".github/workflows/install-smoke-reusable.yml";
const RELEASE_CHECKS = ".github/workflows/openclaw-release-checks.yml";
type WorkflowStep = {
env?: Record<string, unknown>;
env?: Record<string, string>;
id?: string;
if?: string;
name?: string;
@@ -16,7 +17,7 @@ type WorkflowStep = {
};
type WorkflowJob = {
env?: Record<string, unknown>;
env?: Record<string, string>;
if?: string;
needs?: string | string[];
outputs?: Record<string, unknown>;
@@ -29,6 +30,7 @@ type WorkflowJob = {
type Workflow = {
jobs: Record<string, WorkflowJob>;
on?: {
schedule?: unknown;
workflow_call?: { inputs?: Record<string, Record<string, unknown>> };
workflow_dispatch?: { inputs?: Record<string, Record<string, unknown>> };
};
@@ -52,29 +54,48 @@ function step(workflowJob: WorkflowJob, name: string): WorkflowStep {
}
describe("install smoke no-push root image transport", () => {
it("keeps registry transport as the default and validates the selected mode", () => {
it("keeps schedule/manual orchestration read-only and delegates to the reusable core", () => {
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(workflow.on?.schedule).toBeDefined();
expect(workflow.on?.workflow_dispatch?.inputs).toMatchObject({
run_bun_global_install_smoke: { default: false, type: "boolean" },
update_baseline_version: { default: "latest", type: "string" },
});
expect(callInput).toMatchObject({
default: "registry",
type: "string",
expect(workflow.on?.workflow_call).toBeUndefined();
expect(workflow.permissions).toEqual({
actions: "read",
contents: "read",
packages: "read",
});
expect(workflow.permissions).toMatchObject({
const delegated = job(workflow, "install_smoke");
expect(delegated.permissions).toEqual({
actions: "read",
contents: "read",
packages: "read",
});
expect(delegated.uses).toBe("./.github/workflows/install-smoke-reusable.yml");
expect(delegated.with).toMatchObject({
ref: "${{ github.sha }}",
run_bun_global_install_smoke:
"${{ github.event_name == 'schedule' || inputs.run_bun_global_install_smoke }}",
update_baseline_version: "${{ inputs.update_baseline_version || 'latest' }}",
});
expect(readFileSync(INSTALL_SMOKE, "utf8")).not.toContain("packages: write");
});
it("makes the reusable core artifact-only and rejects registry transport", () => {
const workflow = readWorkflow(INSTALL_SMOKE_REUSABLE);
expect(workflow.on?.schedule).toBeUndefined();
expect(workflow.on?.workflow_dispatch).toBeUndefined();
expect(workflow.on?.workflow_call?.inputs?.root_image_transport).toBeUndefined();
expect(workflow.permissions).toEqual({
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 }}",
);
@@ -86,96 +107,57 @@ describe("install smoke no-push root image transport", () => {
);
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.env).toEqual({
OPENCLAW_CI_WORKFLOW_BUN_GLOBAL_INSTALL_SMOKE:
"${{ inputs.run_bun_global_install_smoke || 'false' }}",
});
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(manifest.run).toContain(
'run_bun_global_install_smoke="$workflow_bun_global_install_smoke"',
);
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,
});
}
expect(manifest.run).not.toContain("event_name");
expect(manifest.run).not.toContain("workflow_call");
const text = readFileSync(INSTALL_SMOKE_REUSABLE, "utf8");
expect(text).not.toContain("packages: write");
expect(text).not.toContain("docker/login-action@");
expect(text).not.toContain("--push");
expect(workflow.jobs.push_root_dockerfile_image).toBeUndefined();
});
it("builds one local target image and uploads provenance-bound bytes", () => {
const workflow = readWorkflow(INSTALL_SMOKE);
const workflow = readWorkflow(INSTALL_SMOKE_REUSABLE);
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(producer.outputs).toMatchObject({
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_ref: "${{ steps.image.outputs.image_ref }}",
});
expect(producer.outputs?.image_exists).toBeUndefined();
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);
expect(step(producer, "Checkout trusted image artifact helper").if).toBeUndefined();
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.if).toBeUndefined();
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.if).toBeUndefined();
expect(pack.env).toMatchObject({
IMAGE_REF: "${{ needs.preflight.outputs.dockerfile_image }}",
TARGET_SHA: "${{ needs.preflight.outputs.target_sha }}",
@@ -187,16 +169,9 @@ describe("install smoke no-push root image transport", () => {
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.if).toBeUndefined();
expect(upload.uses).toBe("actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a");
expect(upload.with).toMatchObject({
"compression-level": 0,
@@ -205,58 +180,18 @@ describe("install smoke no-push root image transport", () => {
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()");
expect(ready.needs).toEqual(["preflight", "root_dockerfile_image"]);
const verify = step(ready, "Verify root Dockerfile image preparation");
expect(verify.env).toEqual({
PREPARE_RESULT: "${{ needs.root_dockerfile_image.result }}",
});
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" ]]');
expect(verify.run).not.toContain("PUSH_RESULT");
});
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);
}
it("verifies and loads the immutable artifact in every consumer", () => {
const workflow = readWorkflow(INSTALL_SMOKE_REUSABLE);
for (const jobName of [
"root_dockerfile_smokes",
"installer_smoke",
@@ -264,31 +199,19 @@ describe("install smoke no-push root image transport", () => {
]) {
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'",
);
expect(consumer.env?.OPENCLAW_DOCKER_E2E_REQUIRE_LOCAL_IMAGE, jobName).toBe("1");
expect(step(consumer, "Checkout trusted image artifact helper").if, jobName).toBeUndefined();
expect(
consumer.steps?.find((candidate) => candidate.name === "Log in to GHCR"),
jobName,
).toBeUndefined();
expect(
consumer.steps?.find((candidate) => candidate.name === "Pull root Dockerfile smoke image"),
jobName,
).toBeUndefined();
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.if, jobName).toBeUndefined();
expect(binding.env, jobName).toMatchObject({
ARCHIVE_SHA256: "${{ needs.root_dockerfile_image.outputs.archive_sha256 }}",
ARTIFACT_DIGEST: "${{ needs.root_dockerfile_image.outputs.artifact_digest }}",
@@ -299,94 +222,42 @@ describe("install smoke no-push root image transport", () => {
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.if, jobName).toBeUndefined();
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.if, jobName).toBeUndefined();
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.if, jobName).toBeUndefined();
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", () => {
it("selects the read-only reusable core 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.uses).toBe("./.github/workflows/install-smoke-reusable.yml");
expect(caller.permissions).toEqual({
actions: "read",
contents: "read",
@@ -394,7 +265,6 @@ describe("install smoke no-push root image transport", () => {
});
expect(caller.with).toMatchObject({
ref: "${{ needs.resolve_target.outputs.revision }}",
root_image_transport: "no-push-artifact",
run_bun_global_install_smoke: true,
});
});
@@ -1017,10 +1017,10 @@ describe("package artifact reuse", () => {
expect(workflow).toContain("DOCKER_E2E_LANES: ${{ matrix.group.docker_lanes }}");
expect(workflow).toContain("name: docker-e2e-${{ steps.plan.outputs.artifact_suffix }}");
expect(scheduler).toContain(
"published_upgrade_survivor_baseline=${shellQuote(process.env.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC)}",
"published_upgrade_survivor_baseline=${shellQuote(env.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC)}",
);
expect(scheduler).toContain(
"published_upgrade_survivor_baselines=${shellQuote(process.env.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS)}",
"published_upgrade_survivor_baselines=${shellQuote(env.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS)}",
);
expect(scheduler).toContain(
'["OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC", baseEnv.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC]',
+283 -47
View File
@@ -10,6 +10,11 @@ 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 INSTALL_SMOKE = ".github/workflows/install-smoke.yml";
const INSTALL_SMOKE_REUSABLE = ".github/workflows/install-smoke-reusable.yml";
const SHARED_IMAGE_PUBLISHER = ".github/workflows/openclaw-shared-image-publish-reusable.yml";
const SCHEDULED_LIVE = ".github/workflows/openclaw-scheduled-live-checks.yml";
const UPDATE_MIGRATION = ".github/workflows/update-migration.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";
@@ -35,7 +40,7 @@ type WorkflowJob = {
if?: string;
needs?: string | string[];
outputs?: Record<string, string>;
permissions?: Record<string, string>;
permissions?: PermissionMap;
steps?: WorkflowStep[];
uses?: string;
with?: Record<string, boolean | number | string>;
@@ -44,12 +49,111 @@ type WorkflowJob = {
type Workflow = {
jobs?: Record<string, WorkflowJob>;
on?: {
workflow_call?: { inputs?: Record<string, WorkflowInput> };
workflow_call?: {
inputs?: Record<string, WorkflowInput>;
outputs?: Record<string, { description?: string; value?: string }>;
};
workflow_dispatch?: { inputs?: Record<string, WorkflowInput> };
};
permissions?: Record<string, string>;
permissions?: PermissionMap;
};
type PermissionLevel = "none" | "read" | "write";
type PermissionMap = "read-all" | "write-all" | Record<string, PermissionLevel>;
const PERMISSION_RANK: Record<PermissionLevel, number> = { none: 0, read: 1, write: 2 };
function permissionAt(
permissions: PermissionMap | undefined,
scope: string,
inherited: PermissionLevel,
): PermissionLevel {
if (permissions === undefined) {
return inherited;
}
if (permissions === "read-all") {
return "read";
}
if (permissions === "write-all") {
return "write";
}
return permissions[scope] ?? "none";
}
function permissionScopes(...permissions: Array<PermissionMap | undefined>): string[] {
const scopes = new Set(["actions", "contents", "packages", "pull-requests"]);
for (const value of permissions) {
if (value && typeof value === "object") {
for (const scope of Object.keys(value)) {
scopes.add(scope);
}
}
}
return [...scopes].sort();
}
function reusablePermissionViolations(
callerPath: string,
callerJobName: string,
seen = new Set<string>(),
): string[] {
const caller = readWorkflow(callerPath);
const callerJob = job(caller, callerJobName);
if (!callerJob.uses?.startsWith("./.github/workflows/")) {
throw new Error(`${callerPath}:${callerJobName} is not a local reusable-workflow call`);
}
const ceiling = callerJob.permissions ?? caller.permissions;
return workflowPermissionViolations(
callerJob.uses.slice(2),
Object.fromEntries(
permissionScopes(ceiling).map((scope) => [scope, permissionAt(ceiling, scope, "none")]),
),
`${callerPath}:${callerJobName}`,
seen,
);
}
function workflowPermissionViolations(
workflowPath: string,
ceiling: Record<string, PermissionLevel>,
chain: string,
seen: Set<string>,
): string[] {
const visitKey = `${chain}->${workflowPath}`;
if (seen.has(visitKey)) {
return [];
}
seen.add(visitKey);
const workflow = readWorkflow(workflowPath);
const violations: string[] = [];
for (const [jobName, workflowJob] of Object.entries(workflow.jobs ?? {})) {
const requested = workflowJob.permissions ?? workflow.permissions;
const scopes = permissionScopes(requested, ceiling);
const effective: Record<string, PermissionLevel> = {};
for (const scope of scopes) {
const cap = ceiling[scope] ?? "none";
const level = permissionAt(requested, scope, cap);
effective[scope] = level;
if (PERMISSION_RANK[level] > PERMISSION_RANK[cap]) {
violations.push(
`${chain} -> ${workflowPath}:${jobName} requests ${scope}:${level} above caller ${scope}:${cap}`,
);
}
}
if (workflowJob.uses?.startsWith("./.github/workflows/")) {
violations.push(
...workflowPermissionViolations(
workflowJob.uses.slice(2),
effective,
`${chain} -> ${workflowPath}:${jobName}`,
seen,
),
);
}
}
return violations;
}
function readWorkflow(path: string): Workflow {
return parse(readFileSync(path, "utf8")) as Workflow;
}
@@ -71,10 +175,67 @@ function step(workflowJob: WorkflowJob, name: string): WorkflowStep {
}
function expectReadOnlyPackagePermission(workflowJob: WorkflowJob): void {
expect(workflowJob.permissions?.packages).toBe("read");
expect(permissionAt(workflowJob.permissions, "packages", "none")).toBe("read");
}
describe("release validation no-push transport", () => {
it("keeps every local reusable-workflow permission request within its caller ceiling", () => {
const readOnlyCalls = [
[PLUGIN_PRERELEASE, "plugin-prerelease-docker-suite"],
[RELEASE_CHECKS, "live_repo_e2e_release_checks"],
[RELEASE_CHECKS, "docker_e2e_release_checks"],
[RELEASE_CHECKS, "package_acceptance_release_checks"],
[RELEASE_CHECKS, "install_smoke_release_checks"],
[PACKAGE_ACCEPTANCE, "docker_acceptance"],
[PACKAGE_ACCEPTANCE, "docker_acceptance_registry"],
[INSTALL_SMOKE, "install_smoke"],
[SCHEDULED_LIVE, "live_and_openwebui_checks"],
[UPDATE_MIGRATION, "update_migration"],
] as const;
for (const [workflowPath, jobName] of readOnlyCalls) {
const callerWorkflow = readWorkflow(workflowPath);
const caller = job(callerWorkflow, jobName);
const callerPermissions = caller.permissions ?? callerWorkflow.permissions;
expect(
permissionAt(callerPermissions, "packages", "none"),
`${workflowPath}:${jobName}`,
).toBe("read");
expect(
reusablePermissionViolations(workflowPath, jobName),
`${workflowPath}:${jobName}`,
).toEqual([]);
}
const publisherCall = job(readWorkflow(SCHEDULED_LIVE), "publish_shared_images");
expect(publisherCall.uses).toBe(
"./.github/workflows/openclaw-shared-image-publish-reusable.yml",
);
expect(permissionAt(publisherCall.permissions, "packages", "none")).toBe("write");
expect(reusablePermissionViolations(SCHEDULED_LIVE, "publish_shared_images")).toEqual([]);
});
it("models conditional reusable jobs as permission requests before scheduling", () => {
const root = mkdtempSync(join(tmpdir(), "openclaw-permission-graph-"));
const fixture = join(root, "callee.yml");
try {
writeFileSync(
fixture,
`permissions:\n packages: read\njobs:\n safe:\n runs-on: ubuntu-latest\n skippedWriter:\n if: false\n runs-on: ubuntu-latest\n permissions:\n packages: write\n`,
);
const violations = workflowPermissionViolations(
fixture,
{ actions: "none", contents: "none", packages: "read", "pull-requests": "none" },
"fixture:caller",
new Set(),
);
expect(violations).toEqual([
`fixture:caller -> ${fixture}:skippedWriter requests packages:write above caller packages:read`,
]);
} finally {
rmSync(root, { force: true, recursive: true });
}
});
it("does not persist Git credentials in validation checkouts", () => {
for (const workflowPath of [PLUGIN_PRERELEASE, RELEASE_CHECKS]) {
const workflow = readWorkflow(workflowPath);
@@ -209,14 +370,14 @@ describe("release validation no-push transport", () => {
const standardAcceptance = job(packageAcceptance, "docker_acceptance");
const registryAcceptance = job(packageAcceptance, "docker_acceptance_registry");
expect(packageAcceptance.permissions?.packages).toBe("read");
expect(permissionAt(packageAcceptance.permissions, "packages", "none")).toBe("read");
expect(packageAcceptance.on?.workflow_dispatch?.inputs?.shared_image_policy).toMatchObject({
default: "allow-push",
options: ["allow-push", "existing-only", "no-push-artifact"],
default: "no-push-artifact",
options: ["existing-only", "no-push-artifact"],
type: "choice",
});
expect(packageAcceptance.on?.workflow_call?.inputs?.shared_image_policy).toMatchObject({
default: "allow-push",
default: "no-push-artifact",
type: "string",
});
expect(standardAcceptance.with?.shared_image_policy).toBe("${{ inputs.shared_image_policy }}");
@@ -236,8 +397,8 @@ describe("release validation no-push transport", () => {
});
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");
expect(registryAcceptance.if).toContain("shared_image_policy == 'existing-only'");
expectReadOnlyPackagePermission(registryAcceptance);
const pluginDocker = job(pluginPrerelease, "plugin-prerelease-docker-suite");
expectReadOnlyPackagePermission(pluginDocker);
@@ -260,10 +421,10 @@ describe("release validation no-push transport", () => {
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"],
default: "no-push-artifact",
options: ["existing-only", "no-push-artifact"],
});
expect(callPolicy).toMatchObject({ default: "allow-push", type: "string" });
expect(callPolicy).toMatchObject({ default: "no-push-artifact", type: "string" });
const validation = job(workflow, "validate_selected_ref");
expect(validation.outputs?.workflow_repository).toBe(
@@ -292,28 +453,29 @@ describe("release validation no-push transport", () => {
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");
expect(permissionAt(workflow.permissions, "actions", "none")).toBe("read");
expect(permissionAt(workflow.permissions, "packages", "none")).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();
expect(workflow.jobs?.push_docker_e2e_images).toBeUndefined();
expect(workflow.jobs?.push_live_test_image).toBeUndefined();
expect(
permissionAt(job(workflow, "docker_e2e_image_ready").permissions, "packages", "none"),
).toBe("none");
expect(
permissionAt(job(workflow, "live_test_image_ready").permissions, "packages", "none"),
).toBe("none");
const packageWriters = Object.entries(workflow.jobs ?? {}).filter(
([, workflowJob]) => workflowJob.permissions?.packages === "write",
([, workflowJob]) => permissionAt(workflowJob.permissions, "packages", "none") === "write",
);
expect(packageWriters).toEqual([]);
expect(workflow.on?.workflow_call?.outputs?.publication_manifest).toMatchObject({
value: "${{ jobs.collect_shared_image_publication.outputs.manifest }}",
});
const collector = job(workflow, "collect_shared_image_publication");
expect(step(collector, "Collect immutable tested-image artifacts").run).toContain(
"openclaw.shared-image-publication/v1",
);
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",
@@ -468,7 +630,7 @@ describe("release validation no-push transport", () => {
const artifactPackAndLoadSteps = Object.values(workflow.jobs ?? {}).flatMap((workflowJob) =>
(workflowJob.steps ?? []).filter((candidate) => candidate.env?.WORKFLOW_SHA !== undefined),
);
expect(artifactPackAndLoadSteps).toHaveLength(8);
expect(artifactPackAndLoadSteps).toHaveLength(9);
for (const artifactStep of artifactPackAndLoadSteps) {
expect(artifactStep.env?.WORKFLOW_SHA, artifactStep.name).toBe(
"${{ needs.validate_selected_ref.outputs.workflow_sha }}",
@@ -495,13 +657,13 @@ describe("release validation no-push transport", () => {
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(dockerLoginCondition).not.toContain("allow-push");
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'",
"shared_image_policy == 'existing-only'",
);
expect(step(liveProducer, "Check existing shared live-test image").if).toContain(
"shared_image_policy != 'no-push-artifact'",
@@ -512,19 +674,7 @@ describe("release validation no-push transport", () => {
.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 }}",
});
expect(shellPushSteps).toEqual([]);
for (const name of [
"validate_docker_e2e",
@@ -647,6 +797,92 @@ describe("release validation no-push transport", () => {
expect(liveBuild).toContain("Required local live-test image not found");
});
it("publishes only exact tested artifacts from an explicit write boundary", () => {
const publisher = readWorkflow(SHARED_IMAGE_PUBLISHER);
const scheduled = readWorkflow(SCHEDULED_LIVE);
expect(publisher.on?.workflow_dispatch).toBeUndefined();
expect(publisher.on?.workflow_call?.inputs?.publication_manifest).toMatchObject({
required: true,
type: "string",
});
expect(publisher.permissions).toEqual({});
expect(job(publisher, "validate_publication").permissions).toEqual({});
const writerNames = Object.entries(publisher.jobs ?? {})
.filter(
([, workflowJob]) => permissionAt(workflowJob.permissions, "packages", "none") === "write",
)
.map(([name]) => name)
.sort();
expect(writerNames).toEqual(["publish_docker_e2e", "publish_live_test"]);
const validation = step(
job(publisher, "validate_publication"),
"Validate publication manifest and destinations",
);
expect(validation.env?.JOB_CONTEXT).toBe("${{ toJSON(job) }}");
expect(validation.run).toContain("openclaw.shared-image-publication/v1");
expect(validation.run).toContain(
"publication manifest is not bound to this publisher workflow revision",
);
expect(validation.run).toContain("ghcr.io/${repository}-docker-e2e-${role}:${tag}");
expect(validation.run).toContain("ghcr.io/${repository}-live-test:${tag}");
for (const [jobName, label, downloadName, loadName, publishName] of [
[
"publish_docker_e2e",
"Docker E2E image",
"Download Docker E2E image artifact",
"Verify and load Docker E2E images",
"Publish tested Docker E2E images",
],
[
"publish_live_test",
"live-test image",
"Download live-test image artifact",
"Verify and load live-test image",
"Publish tested live-test image",
],
] as const) {
const publisherJob = job(publisher, jobName);
expect(publisherJob.permissions).toMatchObject({
actions: "read",
contents: "read",
packages: "write",
});
const checkout = step(publisherJob, "Checkout trusted publication harness");
expect(checkout.with).toMatchObject({
repository: "${{ needs.validate_publication.outputs.workflow_repository }}",
ref: "${{ needs.validate_publication.outputs.workflow_sha }}",
path: ".release-harness",
"persist-credentials": false,
});
expect(step(publisherJob, `Verify ${label} artifact binding`).run).toContain(
`verify-upload "${label}"`,
);
expect(step(publisherJob, downloadName).with).toMatchObject({
"github-token": "${{ github.token }}",
});
expect(step(publisherJob, loadName).run).toContain("shared-image-artifact.sh");
expect(step(publisherJob, publishName).run).toContain("docker image push");
}
const scheduledValidation = job(scheduled, "live_and_openwebui_checks");
expect(permissionAt(scheduled.permissions, "packages", "none")).toBe("read");
expectReadOnlyPackagePermission(scheduledValidation);
expect(scheduledValidation.with).toMatchObject({
shared_image_artifact_namespace: "scheduled-live",
shared_image_policy: "no-push-artifact",
});
const scheduledPublisher = job(scheduled, "publish_shared_images");
expect(permissionAt(scheduledPublisher.permissions, "packages", "none")).toBe("write");
expect(scheduledPublisher.uses).toBe(
"./.github/workflows/openclaw-shared-image-publish-reusable.yml",
);
expect(scheduledPublisher.with?.publication_manifest).toBe(
"${{ needs.live_and_openwebui_checks.outputs.publication_manifest }}",
);
});
it("keeps performance evidence artifact-only when dispatched by Full Release", () => {
const fullText = readFileSync(FULL_RELEASE, "utf8");
const performance = readWorkflow(PERFORMANCE);
+20 -12
View File
@@ -33,7 +33,8 @@ const NONROOT_RUNNER_PATH = "scripts/docker/install-sh-nonroot/run.sh";
const BUN_GLOBAL_SMOKE_PATH = "scripts/e2e/bun-global-install-smoke.sh";
const BUN_GLOBAL_ASSERTIONS_PATH = "scripts/e2e/lib/bun-global-install/assertions.mjs";
const DOCKER_E2E_PACKAGE_HELPER_PATH = "scripts/lib/docker-e2e-package.sh";
const INSTALL_SMOKE_WORKFLOW_PATH = ".github/workflows/install-smoke.yml";
const INSTALL_SMOKE_WORKFLOW_PATH = ".github/workflows/install-smoke-reusable.yml";
const INSTALL_SMOKE_WRAPPER_PATH = ".github/workflows/install-smoke.yml";
const RELEASE_CHECKS_WORKFLOW_PATH = ".github/workflows/openclaw-release-checks.yml";
const LIVE_E2E_WORKFLOW_PATH = ".github/workflows/openclaw-live-and-e2e-checks-reusable.yml";
const tempDirs = createTempDirTracker();
@@ -1470,12 +1471,20 @@ chmod +x "$BUN_INSTALL/bin/openclaw"
it("gates workflow Bun install smoke to scheduled and release-check runs", () => {
const workflow = readFileSync(INSTALL_SMOKE_WORKFLOW_PATH, "utf8");
const wrapper = readFileSync(INSTALL_SMOKE_WRAPPER_PATH, "utf8");
const releaseChecks = readFileSync(RELEASE_CHECKS_WORKFLOW_PATH, "utf8");
expect(workflow).not.toContain("pull_request:");
expect(workflow).not.toContain("branches: [main]");
expect(workflow).toContain("workflow_call:");
expect(workflow).toContain('cron: "17 3 * * *"');
expect(workflow).not.toContain("workflow_dispatch:");
expect(workflow).not.toContain("schedule:");
expect(wrapper).toContain('cron: "17 3 * * *"');
expect(wrapper).toContain("workflow_dispatch:");
expect(wrapper).toContain("uses: ./.github/workflows/install-smoke-reusable.yml");
expect(wrapper).toContain(
"github.event_name == 'schedule' || inputs.run_bun_global_install_smoke",
);
expect(workflow).toContain("run_bun_global_install_smoke:");
expect(workflow).toContain(
"if: needs.preflight.outputs.run_full_install_smoke == 'true' && needs.preflight.outputs.run_bun_global_install_smoke == 'true'",
@@ -1489,20 +1498,17 @@ chmod +x "$BUN_INSTALL/bin/openclaw"
expect(workflow).toContain(
"OPENCLAW_BUN_GLOBAL_SMOKE_DIST_IMAGE: ${{ needs.root_dockerfile_image.outputs.image_ref }}",
);
expect(workflow).toContain(
"github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call'",
);
expect(workflow).toContain(
"format('{0}-{1}-{2}', github.workflow, github.event_name, github.run_id)",
);
expect(workflow).toContain("cancel-in-progress: ${{ github.event_name != 'workflow_call' }}");
expect(workflow).toContain("group: ${{ github.workflow }}-workflow-call-${{ github.run_id }}");
expect(workflow).toContain("cancel-in-progress: false");
expect(workflow).not.toContain(
"github.event_name == 'workflow_call' || github.event_name == 'push'",
);
expect(workflow).not.toContain("github.event_name == 'pull_request'");
expect(workflow).not.toContain("node scripts/ci-changed-scope.mjs");
expect(workflow).toContain("OPENCLAW_CI_WORKFLOW_BUN_GLOBAL_INSTALL_SMOKE");
expect(workflow).toContain('if [ "$event_name" = "schedule" ]; then');
expect(workflow).toContain('run_bun_global_install_smoke="$workflow_bun_global_install_smoke"');
expect(workflow).not.toContain("OPENCLAW_CI_EVENT_NAME");
expect(workflow).not.toContain('if [ "$event_name"');
expect(workflow).toContain('echo "run_bun_global_install_smoke=$run_bun_global_install_smoke"');
expect(workflow).toContain("run_fast_install_smoke=true");
expect(workflow).toContain("run_full_install_smoke=true");
@@ -1511,7 +1517,9 @@ chmod +x "$BUN_INSTALL/bin/openclaw"
expect(workflow).toContain("run_fast_install_smoke");
expect(workflow).toContain("run_full_install_smoke");
expect(workflow).toContain("timeout --kill-after=30s 45m docker buildx build");
expect(workflow).toContain('timeout --kill-after=30s 600s docker pull "$IMAGE_REF"');
expect(workflow).not.toContain('docker pull "$IMAGE_REF"');
expect(workflow).not.toContain("packages: write");
expect(workflow).not.toContain("--push");
expect(workflow).not.toContain('timeout 300s docker pull "$IMAGE_REF"');
expect(workflow.match(/timeout --kill-after=30s 20m docker run --rm/g)?.length).toBe(6);
expect(workflow).not.toMatch(/(^|\n)\s+docker run --rm --entrypoint sh/u);
@@ -1535,7 +1543,7 @@ chmod +x "$BUN_INSTALL/bin/openclaw"
expect(workflow).not.toContain("type=gha");
expect(workflow).toContain('OPENCLAW_INSTALL_SMOKE_SKIP_NPM_GLOBAL: "1"');
expect(releaseChecks).toContain("install_smoke_release_checks:");
expect(releaseChecks).toContain("uses: ./.github/workflows/install-smoke.yml");
expect(releaseChecks).toContain("uses: ./.github/workflows/install-smoke-reusable.yml");
expect(releaseChecks).toContain("run_bun_global_install_smoke: true");
});