ci(docker): reuse cached e2e images for reruns

This commit is contained in:
Peter Steinberger
2026-04-27 06:29:03 +01:00
parent 679e476183
commit 5e9a96fafb
11 changed files with 319 additions and 63 deletions

View File

@@ -270,16 +270,23 @@ Multiple lanes are allowed:
docker_lanes: install-e2e bundled-channel-update-acpx
```
That skips the three chunk matrix and runs one targeted Docker job against the
prepared GHCR images and a fresh OpenClaw npm tarball for the selected ref.
Reruns usually need that new tarball because the fix being tested changed the
package contents even if the SHA-tagged GHCR Docker image can be reused.
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.
Live-only targeted reruns skip the E2E images and build only the live-test
image. Release-path normal mode remains max three Docker chunk jobs:
image. Release-path normal mode is split into these Docker chunks:
- `core`
- `package-install`
- `package-update`
- `plugins-integrations`
- `plugins`
- `bundled-channel-deps`
- `service-integrations`
- `openwebui` when OpenWebUI coverage is requested
## Package Acceptance
@@ -340,7 +347,7 @@ Profiles:
package/update coverage.
- `product`: package profile plus broader product surfaces: MCP channels,
cron/subagent cleanup, OpenAI web search, and OpenWebUI.
- `full`: Docker release-path chunks with OpenWebUI.
- `full`: split Docker release-path chunks with OpenWebUI.
- `custom`: exact `docker_lanes` list for a focused rerun.
Candidate sources:

View File

@@ -33,6 +33,21 @@ on:
required: false
default: ""
type: string
package_artifact_run_id:
description: Prior run id containing package_artifact_name; blank uses this run or packs the selected ref
required: false
default: ""
type: string
docker_e2e_bare_image:
description: Existing bare Docker E2E image to reuse; blank derives from package SHA/ref
required: false
default: ""
type: string
docker_e2e_functional_image:
description: Existing functional Docker E2E image to reuse; blank derives from package SHA/ref
required: false
default: ""
type: string
include_live_suites:
description: Whether to run live-provider coverage
required: false
@@ -79,6 +94,21 @@ on:
required: false
default: ""
type: string
package_artifact_run_id:
description: Prior run id containing package_artifact_name; blank uses this run or packs the selected ref
required: false
default: ""
type: string
docker_e2e_bare_image:
description: Existing bare Docker E2E image to reuse; blank derives from package SHA/ref
required: false
default: ""
type: string
docker_e2e_functional_image:
description: Existing functional Docker E2E image to reuse; blank derives from package SHA/ref
required: false
default: ""
type: string
include_live_suites:
description: Whether to run live-provider coverage
required: false
@@ -398,12 +428,21 @@ jobs:
- chunk_id: core
label: core
timeout_minutes: 120
- chunk_id: package-install
label: package/install
timeout_minutes: 180
- chunk_id: package-update
label: package/update
timeout_minutes: 180
- chunk_id: plugins-integrations
label: plugins/integrations
timeout_minutes: 180
timeout_minutes: 90
- chunk_id: plugins
label: plugins
timeout_minutes: 90
- chunk_id: bundled-channel-deps
label: bundled/channel deps
timeout_minutes: 120
- chunk_id: service-integrations
label: service integrations
timeout_minutes: 90
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
@@ -452,6 +491,7 @@ jobs:
OPENCLAW_DOCKER_E2E_IMAGE: ${{ needs.prepare_docker_e2e_image.outputs.image }}
OPENCLAW_DOCKER_E2E_BARE_IMAGE: ${{ needs.prepare_docker_e2e_image.outputs.bare_image }}
OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE: ${{ needs.prepare_docker_e2e_image.outputs.functional_image }}
OPENCLAW_DOCKER_E2E_PACKAGE_ARTIFACT_NAME: ${{ inputs.package_artifact_name || 'docker-e2e-package' }}
OPENCLAW_CURRENT_PACKAGE_TGZ: .artifacts/docker-e2e-package/openclaw-current.tgz
OPENCLAW_SKIP_DOCKER_BUILD: "1"
INCLUDE_OPENWEBUI: ${{ inputs.include_openwebui }}
@@ -579,6 +619,7 @@ jobs:
OPENCLAW_DOCKER_E2E_IMAGE: ${{ needs.prepare_docker_e2e_image.outputs.image }}
OPENCLAW_DOCKER_E2E_BARE_IMAGE: ${{ needs.prepare_docker_e2e_image.outputs.bare_image }}
OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE: ${{ needs.prepare_docker_e2e_image.outputs.functional_image }}
OPENCLAW_DOCKER_E2E_PACKAGE_ARTIFACT_NAME: ${{ inputs.package_artifact_name || 'docker-e2e-package' }}
OPENCLAW_CURRENT_PACKAGE_TGZ: .artifacts/docker-e2e-package/openclaw-current.tgz
OPENCLAW_SKIP_DOCKER_BUILD: "1"
INCLUDE_OPENWEBUI: ${{ inputs.include_openwebui }}
@@ -656,7 +697,8 @@ jobs:
validate_docker_openwebui:
needs: [validate_selected_ref, prepare_docker_e2e_image]
if: inputs.include_openwebui && !inputs.include_release_path_suites && inputs.docker_lanes == ''
if: inputs.include_openwebui && inputs.docker_lanes == ''
name: Docker E2E (openwebui)
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: 75
env:
@@ -664,6 +706,8 @@ jobs:
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
OPENCLAW_DOCKER_E2E_IMAGE: ${{ needs.prepare_docker_e2e_image.outputs.image }}
OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE: ${{ needs.prepare_docker_e2e_image.outputs.functional_image }}
OPENCLAW_DOCKER_E2E_PACKAGE_ARTIFACT_NAME: ${{ inputs.package_artifact_name || 'docker-e2e-package' }}
OPENCLAW_CURRENT_PACKAGE_TGZ: .artifacts/docker-e2e-package/openclaw-current.tgz
OPENCLAW_SKIP_DOCKER_BUILD: "1"
steps:
- name: Checkout selected ref
@@ -695,8 +739,50 @@ jobs:
exit 1
}
- name: Run Open WebUI Docker E2E
run: pnpm test:docker:openwebui
- name: Plan and hydrate Open WebUI Docker E2E chunk
id: plan
uses: ./.github/actions/docker-e2e-plan
with:
mode: chunk
chunk: openwebui
include-openwebui: "true"
package-artifact-name: ${{ inputs.package_artifact_name || 'docker-e2e-package' }}
- name: Run Open WebUI Docker E2E chunk
shell: bash
run: |
set -euo pipefail
export OPENCLAW_DOCKER_ALL_PROFILE=release-path
export OPENCLAW_DOCKER_ALL_CHUNK=openwebui
export OPENCLAW_DOCKER_ALL_BUILD=0
export OPENCLAW_DOCKER_ALL_PREFLIGHT=0
export OPENCLAW_DOCKER_ALL_FAIL_FAST=0
export OPENCLAW_DOCKER_ALL_INCLUDE_OPENWEBUI=1
export OPENCLAW_DOCKER_ALL_LOG_DIR=".artifacts/docker-tests/release-openwebui"
export OPENCLAW_DOCKER_ALL_TIMINGS_FILE=".artifacts/docker-tests/release-openwebui-timings.json"
export OPENCLAW_DOCKER_ALL_PNPM_COMMAND="$(command -v pnpm)"
pnpm test:docker:all
- name: Summarize Open WebUI Docker E2E chunk
if: always()
shell: bash
run: |
set -euo pipefail
summary=".artifacts/docker-tests/release-openwebui/summary.json"
if [[ ! -f "$summary" ]]; then
echo "Docker Open WebUI summary missing: \`$summary\`" >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
node scripts/docker-e2e.mjs summary "$summary" "Docker E2E chunk: openwebui" >> "$GITHUB_STEP_SUMMARY"
- name: Upload Open WebUI Docker E2E artifacts
if: always()
uses: actions/upload-artifact@v7
with:
name: docker-e2e-openwebui
path: .artifacts/docker-tests/
if-no-files-found: ignore
prepare_docker_e2e_image:
needs: validate_selected_ref
@@ -704,6 +790,7 @@ jobs:
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: 90
permissions:
actions: read
contents: read
packages: write
outputs:
@@ -736,22 +823,31 @@ jobs:
hydrate-artifacts: "false"
- name: Setup Node environment
if: steps.plan.outputs.needs_package == '1' && inputs.package_artifact_name == ''
if: steps.plan.outputs.needs_package == '1' && inputs.package_artifact_name == '' && inputs.package_artifact_run_id == ''
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
install-bun: "true"
- name: Download provided OpenClaw Docker E2E package
if: steps.plan.outputs.needs_package == '1' && inputs.package_artifact_name != ''
- name: Download current-run OpenClaw Docker E2E package
if: steps.plan.outputs.needs_package == '1' && inputs.package_artifact_name != '' && inputs.package_artifact_run_id == ''
uses: actions/download-artifact@v8
with:
name: ${{ inputs.package_artifact_name }}
path: .artifacts/docker-e2e-package
- name: Download previous-run OpenClaw Docker E2E package
if: steps.plan.outputs.needs_package == '1' && inputs.package_artifact_run_id != ''
uses: actions/download-artifact@v8
with:
name: ${{ inputs.package_artifact_name || 'docker-e2e-package' }}
path: .artifacts/docker-e2e-package
run-id: ${{ inputs.package_artifact_run_id }}
github-token: ${{ github.token }}
- name: Pack OpenClaw package for Docker E2E
if: steps.plan.outputs.needs_package == '1' && inputs.package_artifact_name == ''
if: steps.plan.outputs.needs_package == '1' && inputs.package_artifact_name == '' && inputs.package_artifact_run_id == ''
shell: bash
run: |
set -euo pipefail
@@ -788,10 +884,10 @@ jobs:
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload OpenClaw Docker E2E package
if: steps.plan.outputs.needs_package == '1' && inputs.package_artifact_name == ''
if: steps.plan.outputs.needs_package == '1' && (inputs.package_artifact_name == '' || inputs.package_artifact_run_id != '')
uses: actions/upload-artifact@v7
with:
name: docker-e2e-package
name: ${{ inputs.package_artifact_name || 'docker-e2e-package' }}
path: .artifacts/docker-e2e-package/openclaw-current.tgz
if-no-files-found: error
@@ -801,12 +897,14 @@ jobs:
env:
PACKAGE_TAG: ${{ steps.package.outputs.tag }}
SELECTED_SHA: ${{ needs.validate_selected_ref.outputs.selected_sha }}
PROVIDED_BARE_IMAGE: ${{ inputs.docker_e2e_bare_image }}
PROVIDED_FUNCTIONAL_IMAGE: ${{ inputs.docker_e2e_functional_image }}
run: |
set -euo pipefail
repository="${GITHUB_REPOSITORY,,}"
image_tag="${PACKAGE_TAG:-$SELECTED_SHA}"
bare_image="ghcr.io/${repository}-docker-e2e-bare:${image_tag}"
functional_image="ghcr.io/${repository}-docker-e2e-functional:${image_tag}"
bare_image="${PROVIDED_BARE_IMAGE:-ghcr.io/${repository}-docker-e2e-bare:${image_tag}}"
functional_image="${PROVIDED_FUNCTIONAL_IMAGE:-ghcr.io/${repository}-docker-e2e-functional:${image_tag}}"
image="$functional_image"
echo "image=$image" >> "$GITHUB_OUTPUT"
echo "bare_image=$bare_image" >> "$GITHUB_OUTPUT"
@@ -826,6 +924,9 @@ jobs:
id: image_exists
if: steps.plan.outputs.needs_e2e_image == '1'
shell: bash
env:
PROVIDED_BARE_IMAGE: ${{ inputs.docker_e2e_bare_image }}
PROVIDED_FUNCTIONAL_IMAGE: ${{ inputs.docker_e2e_functional_image }}
run: |
set -euo pipefail
bare_exists=0
@@ -836,6 +937,9 @@ jobs:
if docker manifest inspect "${{ steps.image.outputs.bare_image }}" >/dev/null 2>&1; then
bare_exists=1
echo "Shared Docker E2E bare image already exists: ${{ steps.image.outputs.bare_image }}"
elif [[ -n "$PROVIDED_BARE_IMAGE" ]]; then
echo "Provided bare Docker E2E image does not exist: $PROVIDED_BARE_IMAGE" >&2
exit 1
else
needs_build=1
fi
@@ -845,6 +949,9 @@ jobs:
if docker manifest inspect "${{ steps.image.outputs.functional_image }}" >/dev/null 2>&1; then
functional_exists=1
echo "Shared Docker E2E functional image already exists: ${{ steps.image.outputs.functional_image }}"
elif [[ -n "$PROVIDED_FUNCTIONAL_IMAGE" ]]; then
echo "Provided functional Docker E2E image does not exist: $PROVIDED_FUNCTIONAL_IMAGE" >&2
exit 1
else
needs_build=1
fi
@@ -860,14 +967,12 @@ jobs:
- name: Build and push bare Docker E2E image
if: steps.plan.outputs.needs_bare_image == '1' && steps.image_exists.outputs.bare_exists != '1'
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
uses: useblacksmith/build-push-action@cbd1f60d194a98cb3be5523b15134501eaf0fbf3 # v2
with:
context: .
file: ./scripts/e2e/Dockerfile
target: bare
platforms: linux/amd64
cache-from: type=gha,scope=docker-e2e-bare
cache-to: type=gha,mode=max,scope=docker-e2e-bare
tags: ${{ steps.image.outputs.bare_image }}
sbom: true
provenance: mode=max
@@ -875,7 +980,7 @@ jobs:
- name: Build and push functional Docker E2E image
if: steps.plan.outputs.needs_functional_image == '1' && steps.image_exists.outputs.functional_exists != '1'
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
uses: useblacksmith/build-push-action@cbd1f60d194a98cb3be5523b15134501eaf0fbf3 # v2
with:
context: .
file: ./scripts/e2e/Dockerfile
@@ -883,10 +988,6 @@ jobs:
build-contexts: |
openclaw_package=.artifacts/docker-e2e-package
platforms: linux/amd64
cache-from: |
type=gha,scope=docker-e2e-bare
type=gha,scope=docker-e2e-functional
cache-to: type=gha,mode=max,scope=docker-e2e-functional
tags: ${{ steps.image.outputs.functional_image }}
sbom: true
provenance: mode=max

File diff suppressed because one or more lines are too long

View File

@@ -656,7 +656,7 @@ These Docker runners split into two buckets:
`OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=90000`. Override those env vars when you
explicitly want the larger exhaustive scan.
- `test:docker:all` builds the live Docker image once via `test:docker:live-build`, packs OpenClaw once as an npm tarball through `scripts/package-openclaw-for-docker.mjs`, then builds/reuses two `scripts/e2e/Dockerfile` images. The bare image is only the Node/Git runner for install/update/plugin-dependency lanes; those lanes mount the prebuilt tarball. The functional image installs the same tarball into `/app` for built-app functionality lanes. Docker lane definitions live in `scripts/lib/docker-e2e-scenarios.mjs`; planner logic lives in `scripts/lib/docker-e2e-plan.mjs`; `scripts/test-docker-all.mjs` executes the selected plan. The aggregate uses a weighted local scheduler: `OPENCLAW_DOCKER_ALL_PARALLELISM` controls process slots, while resource caps keep heavy live, npm-install, and multi-service lanes from all starting at once. If a single lane is heavier than the active caps, the scheduler can still start it when the pool is empty and then keeps it running alone until capacity is available again. Defaults are 10 slots, `OPENCLAW_DOCKER_ALL_LIVE_LIMIT=9`, `OPENCLAW_DOCKER_ALL_NPM_LIMIT=10`, and `OPENCLAW_DOCKER_ALL_SERVICE_LIMIT=7`; tune `OPENCLAW_DOCKER_ALL_WEIGHT_LIMIT` or `OPENCLAW_DOCKER_ALL_DOCKER_LIMIT` only when the Docker host has more headroom. The runner performs a Docker preflight by default, removes stale OpenClaw E2E containers, prints status every 30 seconds, stores successful lane timings in `.artifacts/docker-tests/lane-timings.json`, and uses those timings to start longer lanes first on later runs. Use `OPENCLAW_DOCKER_ALL_DRY_RUN=1` to print the weighted lane manifest without building or running Docker, or `node scripts/test-docker-all.mjs --plan-json` to print the CI plan for selected lanes, package/image needs, and credentials.
- `Package Acceptance` is the GitHub-native package gate for "does this installable tarball work as a product?" It resolves one candidate package from `source=npm`, `source=ref`, `source=url`, or `source=artifact`, uploads it as `package-under-test`, then runs the reusable Docker E2E lanes against that exact tarball instead of repacking the selected ref. `workflow_ref` selects the trusted workflow/harness scripts, while `package_ref` selects the source commit/branch/tag to pack when `source=ref`; this lets current acceptance logic validate older trusted commits. Profiles are ordered by breadth: `smoke` is quick install/channel/agent plus gateway/config, `package` is the package/update/plugin contract and the default native replacement for most Parallels package/update coverage, `product` adds MCP channels, cron/subagent cleanup, OpenAI web search, and OpenWebUI, and `full` runs the release-path Docker chunks with OpenWebUI. Release validation runs the `package` profile for the target ref with Telegram package QA enabled.
- `Package Acceptance` is the GitHub-native package gate for "does this installable tarball work as a product?" It resolves one candidate package from `source=npm`, `source=ref`, `source=url`, or `source=artifact`, uploads it as `package-under-test`, then runs the reusable Docker E2E lanes against that exact tarball instead of repacking the selected ref. `workflow_ref` selects the trusted workflow/harness scripts, while `package_ref` selects the source commit/branch/tag to pack when `source=ref`; this lets current acceptance logic validate older trusted commits. Profiles are ordered by breadth: `smoke` is quick install/channel/agent plus gateway/config, `package` is the package/update/plugin contract and the default native replacement for most Parallels package/update coverage, `product` adds MCP channels, cron/subagent cleanup, OpenAI web search, and OpenWebUI, and `full` runs the split release-path Docker chunks with OpenWebUI. Release validation runs the `package` profile for the target ref with Telegram package QA enabled. Targeted GitHub Docker rerun commands generated from artifacts include prior package artifact and prepared image inputs when available, so failed lanes can avoid rebuilding the package and images.
- Container smoke runners: `test:docker:openwebui`, `test:docker:onboard`, `test:docker:npm-onboard-channel-agent`, `test:docker:update-channel-switch`, `test:docker:session-runtime-context`, `test:docker:agents-delete-shared-workspace`, `test:docker:gateway-network`, `test:docker:browser-cdp-snapshot`, `test:docker:mcp-channels`, `test:docker:pi-bundle-mcp-tools`, `test:docker:cron-mcp-cleanup`, `test:docker:plugins`, `test:docker:plugin-update`, and `test:docker:config-reload` boot one or more real containers and verify higher-level integration paths.
The live-model Docker runners also bind-mount only the needed CLI auth homes (or all supported ones when the run is not narrowed), then copy them into the container home before the run so external-CLI OAuth can refresh tokens without mutating the host auth store:

View File

@@ -319,9 +319,9 @@ Release Docker coverage includes:
- full install smoke with the slow Bun global install smoke enabled
- repository E2E lanes
- release-path Docker chunks: `core`, `package-update`, and
`plugins-integrations`
- OpenWebUI coverage inside the plugins/integrations chunk
- release-path Docker chunks: `core`, `package-install`, `package-update`,
`plugins`, `bundled-channel-deps`, and `service-integrations`
- OpenWebUI coverage as the `openwebui` Docker chunk when requested
- live/E2E provider suites and Docker live model coverage when release checks
include live suites
@@ -329,7 +329,9 @@ Use Docker artifacts before rerunning. The release-path scheduler uploads
`.artifacts/docker-tests/` with lane logs, `summary.json`, `failures.json`,
phase timings, scheduler plan JSON, and rerun commands. For focused recovery,
use `docker_lanes=<lane[,lane]>` on the reusable live/E2E workflow instead of
rerunning all release chunks.
rerunning all release chunks. Generated rerun commands include prior
`package_artifact_run_id` and prepared Docker image inputs when available, so a
failed lane can reuse the same tarball and GHCR images.
### QA Lab

View File

@@ -1,8 +1,9 @@
#!/usr/bin/env node
// 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 that prepare a
// fresh OpenClaw tarball for the same ref before running only failed lanes.
// summary/failures JSON, and prints targeted workflow commands for failed
// lanes, reusing package artifacts and prepared GHCR images when artifacts
// expose them.
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
@@ -76,8 +77,44 @@ function shellQuote(value) {
return `'${String(value).replaceAll("'", "'\\''")}'`;
}
function ghWorkflowCommand(lanes, ref, workflow) {
return [
function maybeGhcrImage(value) {
return typeof value === "string" && value.startsWith("ghcr.io/") ? value : "";
}
function reuseInputsFromJson(parsed) {
const packageArtifactRunId = parsed.github?.runId || "";
if (!packageArtifactRunId) {
return {};
}
return {
bareImage: maybeGhcrImage(parsed.images?.bare),
functionalImage: maybeGhcrImage(parsed.images?.functional),
packageArtifactName:
parsed.packageArtifactName || parsed.artifacts?.packageName || "docker-e2e-package",
packageArtifactRunId,
};
}
function sameReuseInputs(left, right) {
return (
(left?.packageArtifactRunId || "") === (right?.packageArtifactRunId || "") &&
(left?.packageArtifactName || "") === (right?.packageArtifactName || "") &&
(left?.bareImage || "") === (right?.bareImage || "") &&
(left?.functionalImage || "") === (right?.functionalImage || "")
);
}
function commonReuseInputs(entries) {
const inputs = entries.map((entry) => entry.reuseInputs).filter(Boolean);
if (inputs.length === 0) {
return {};
}
const [first] = inputs;
return inputs.every((input) => sameReuseInputs(first, input)) ? first : {};
}
function ghWorkflowCommand(lanes, ref, workflow, reuseInputs = {}) {
const fields = [
"gh workflow run",
shellQuote(workflow),
"-f",
@@ -94,7 +131,21 @@ function ghWorkflowCommand(lanes, ref, workflow) {
"include_live_suites=false",
"-f",
"live_models_only=false",
].join(" ");
];
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)}`);
}
return fields.join(" ");
}
function detectRepo() {
@@ -115,15 +166,18 @@ function findFiles(rootDir, basenames, out = []) {
function failedLaneEntriesFromJson(file, ref, workflow) {
const parsed = readJson(file);
const reuseInputs = reuseInputsFromJson(parsed);
const source = path.basename(file);
if (source === "failures.json" && Array.isArray(parsed.lanes)) {
return parsed.lanes
.filter((lane) => lane.name)
.map((lane) => ({
ghWorkflowCommand: lane.ghWorkflowCommand,
ghWorkflowCommand:
lane.ghWorkflowCommand || ghWorkflowCommand([lane.name], ref, workflow, reuseInputs),
lane: lane.name,
localRerunCommand: lane.rerunCommand,
logFile: lane.logFile,
reuseInputs,
source: file,
status: lane.status,
}));
@@ -133,10 +187,11 @@ function failedLaneEntriesFromJson(file, ref, workflow) {
return lanes
.filter((lane) => lane.status !== 0 && lane.name)
.map((lane) => ({
ghWorkflowCommand: ghWorkflowCommand([lane.name], ref, workflow),
ghWorkflowCommand: ghWorkflowCommand([lane.name], ref, workflow, reuseInputs),
lane: lane.name,
localRerunCommand: lane.rerunCommand,
logFile: lane.logFile,
reuseInputs,
source: file,
status: lane.status,
}));
@@ -201,7 +256,7 @@ function printEntries(entries, ref, workflow, run) {
}
console.log(`Ref: ${ref}`);
console.log(
"Targeted GitHub reruns prepare a fresh OpenClaw npm tarball for that ref before lane execution.",
"Targeted GitHub reruns reuse package artifacts and prepared GHCR images when the downloaded artifacts expose them.",
);
if (entries.length === 0) {
console.log("No failed Docker E2E lanes found.");
@@ -215,6 +270,7 @@ function printEntries(entries, ref, workflow, run) {
entries.map((entry) => entry.lane),
ref,
workflow,
commonReuseInputs(entries),
),
);
console.log("");

View File

@@ -40,8 +40,23 @@ function inlineCode(value) {
return `\`${String(value ?? "").replaceAll("`", "\\`")}\``;
}
function formatSeconds(value) {
const seconds = Number(value);
if (!Number.isFinite(seconds) || seconds < 0) {
return "";
}
const rounded = Math.round(seconds);
const minutes = Math.floor(rounded / 60);
const rest = rounded % 60;
return minutes > 0 ? `${minutes}m ${rest}s` : `${rest}s`;
}
function summaryMarkdown(summary, title) {
const lanes = Array.isArray(summary.lanes) ? summary.lanes : [];
const slowest = lanes
.filter((lane) => Number.isFinite(Number(lane.elapsedSeconds)))
.toSorted((a, b) => Number(b.elapsedSeconds) - Number(a.elapsedSeconds))
.slice(0, 8);
const lines = [
`### ${title}`,
"",
@@ -57,12 +72,22 @@ function summaryMarkdown(summary, title) {
);
}
if (slowest.length > 0) {
lines.push("", "| Slowest lane | Duration | Status |", "| --- | ---: | --- |");
for (const lane of slowest) {
const status = lane.status === 0 ? "pass" : `fail ${lane.status}`;
lines.push(
`| ${inlineCode(lane.name)} | ${markdownCell(formatSeconds(lane.elapsedSeconds))} | ${markdownCell(status)} |`,
);
}
}
const phases = Array.isArray(summary.phases) ? summary.phases : [];
if (phases.length > 0) {
lines.push("", "| Phase | Seconds | Status | Image kind |", "| --- | ---: | --- | --- |");
lines.push("", "| Phase | Duration | Status | Image kind |", "| --- | ---: | --- | --- |");
for (const phase of phases) {
lines.push(
`| ${inlineCode(phase.name)} | ${markdownCell(phase.elapsedSeconds)} | ${markdownCell(phase.status)} | ${markdownCell(phase.imageKind)} |`,
`| ${inlineCode(phase.name)} | ${markdownCell(formatSeconds(phase.elapsedSeconds))} | ${markdownCell(phase.status)} | ${markdownCell(phase.imageKind)} |`,
);
}
}

View File

@@ -356,7 +356,7 @@ const releasePathChunks = {
weight: 3,
}),
],
"package-update": [
"package-install": [
npmLane(
"install-e2e",
"OPENCLAW_INSTALL_TAG=beta OPENCLAW_E2E_MODELS=both pnpm test:install:e2e",
@@ -370,6 +370,8 @@ const releasePathChunks = {
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:npm-onboard-channel-agent",
{ resources: ["service"], weight: 3 },
),
],
"package-update": [
npmLane("doctor-switch", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:doctor-switch", {
weight: 3,
}),
@@ -382,17 +384,21 @@ const releasePathChunks = {
},
),
],
"plugins-integrations": [
plugins: [
lane("plugins", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:plugins", {
resources: ["npm", "service"],
weight: 6,
}),
npmLane("plugin-update", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:plugin-update"),
],
"bundled-channel-deps": [
npmLane(
"bundled-channel-deps",
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:bundled-channel-deps",
{ resources: ["service"], weight: 3 },
),
],
"service-integrations": [
serviceLane(
"cron-mcp-cleanup",
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:cron-mcp-cleanup",
@@ -407,6 +413,12 @@ const releasePathChunks = {
{ timeoutMs: 8 * 60 * 1000 },
),
],
openwebui: [
serviceLane("openwebui", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:openwebui", {
timeoutMs: OPENWEBUI_TIMEOUT_MS,
weight: 5,
}),
],
};
export function releasePathChunkLanes(chunk, options = {}) {
@@ -416,22 +428,16 @@ export function releasePathChunkLanes(chunk, options = {}) {
`OPENCLAW_DOCKER_ALL_CHUNK must be one of: ${Object.keys(releasePathChunks).join(", ")}. Got: ${JSON.stringify(chunk)}`,
);
}
if (chunk !== "plugins-integrations" || !options.includeOpenWebUI) {
return base;
if (chunk === "openwebui" && !options.includeOpenWebUI) {
return [];
}
return [
...base,
serviceLane("openwebui", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:openwebui", {
timeoutMs: OPENWEBUI_TIMEOUT_MS,
weight: 5,
}),
];
return base;
}
export function allReleasePathLanes(options = {}) {
return Object.keys(releasePathChunks).flatMap((chunk) =>
releasePathChunkLanes(chunk, {
includeOpenWebUI: chunk === "plugins-integrations" && options.includeOpenWebUI,
includeOpenWebUI: options.includeOpenWebUI,
}),
);
}

View File

@@ -194,7 +194,7 @@ function shellQuote(value) {
}
function githubWorkflowRerunCommand(laneNames, ref) {
return [
const fields = [
"gh workflow run",
shellQuote(process.env.OPENCLAW_DOCKER_E2E_WORKFLOW || DEFAULT_GITHUB_WORKFLOW),
"-f",
@@ -211,7 +211,29 @@ function githubWorkflowRerunCommand(laneNames, ref) {
"include_live_suites=false",
"-f",
"live_models_only=false",
].join(" ");
];
if (process.env.GITHUB_RUN_ID) {
fields.push("-f", `package_artifact_run_id=${shellQuote(process.env.GITHUB_RUN_ID)}`);
fields.push(
"-f",
`package_artifact_name=${shellQuote(
process.env.OPENCLAW_DOCKER_E2E_PACKAGE_ARTIFACT_NAME || "docker-e2e-package",
)}`,
);
}
if (process.env.OPENCLAW_DOCKER_E2E_BARE_IMAGE) {
fields.push(
"-f",
`docker_e2e_bare_image=${shellQuote(process.env.OPENCLAW_DOCKER_E2E_BARE_IMAGE)}`,
);
}
if (process.env.OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE) {
fields.push(
"-f",
`docker_e2e_functional_image=${shellQuote(process.env.OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE)}`,
);
}
return fields.join(" ");
}
function buildLaneRerunCommand(name, baseEnv) {
@@ -301,6 +323,7 @@ async function writeRunSummary(logDir, summary) {
const file = path.join(logDir, "summary.json");
const payload = {
...summary,
packageArtifactName: process.env.OPENCLAW_DOCKER_E2E_PACKAGE_ARTIFACT_NAME || undefined,
finishedAt: new Date().toISOString(),
github: {
ref: process.env.GITHUB_REF_NAME || undefined,
@@ -346,7 +369,9 @@ async function writeFailureIndex(logDir, summary) {
: undefined,
generatedAt: new Date().toISOString(),
lanes,
note: "Targeted GitHub reruns prepare a fresh OpenClaw npm tarball for the selected ref before lane execution.",
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.",
images: summary.images,
packageArtifactName: process.env.OPENCLAW_DOCKER_E2E_PACKAGE_ARTIFACT_NAME || undefined,
ref,
runUrl: summary.github?.runUrl,
status: summary.status,

View File

@@ -45,6 +45,22 @@ describe("scripts/lib/docker-e2e-plan", () => {
expect(plan.lanes.map((lane) => lane.name)).not.toContain("openwebui");
});
it("plans Open WebUI only when release-path coverage requests it", () => {
const withoutOpenWebUI = planFor({
includeOpenWebUI: false,
planReleaseAll: true,
profile: RELEASE_PATH_PROFILE,
});
const withOpenWebUI = planFor({
includeOpenWebUI: true,
planReleaseAll: true,
profile: RELEASE_PATH_PROFILE,
});
expect(withoutOpenWebUI.lanes.map((lane) => lane.name)).not.toContain("openwebui");
expect(withOpenWebUI.lanes.map((lane) => lane.name)).toContain("openwebui");
});
it("plans a live-only selected lane without package e2e images", () => {
const plan = planFor({ selectedLaneNames: ["live-models"] });

View File

@@ -60,9 +60,18 @@ describe("package artifact reuse", () => {
const action = readFileSync(DOCKER_E2E_PLAN_ACTION, "utf8");
expect(workflow).toContain("package_artifact_name:");
expect(workflow).toContain("Download provided OpenClaw Docker E2E package");
expect(workflow).toContain("package_artifact_run_id:");
expect(workflow).toContain("docker_e2e_bare_image:");
expect(workflow).toContain("docker_e2e_functional_image:");
expect(workflow).toContain("Download current-run OpenClaw Docker E2E package");
expect(workflow).toContain("Download previous-run OpenClaw Docker E2E package");
expect(workflow).toContain("inputs.package_artifact_name != ''");
expect(workflow).toContain('image_tag="${PACKAGE_TAG:-$SELECTED_SHA}"');
expect(workflow).toContain(
'bare_image="${PROVIDED_BARE_IMAGE:-ghcr.io/${repository}-docker-e2e-bare:${image_tag}}"',
);
expect(workflow).toContain(
'functional_image="${PROVIDED_FUNCTIONAL_IMAGE:-ghcr.io/${repository}-docker-e2e-functional:${image_tag}}"',
);
expect(workflow).toContain(
"package-artifact-name: ${{ inputs.package_artifact_name || 'docker-e2e-package' }}",
);
@@ -70,6 +79,15 @@ describe("package artifact reuse", () => {
expect(action).toContain("name: ${{ inputs.package-artifact-name }}");
});
it("uses Blacksmith Docker build caching for prepared E2E images", () => {
const workflow = readFileSync(LIVE_E2E_WORKFLOW, "utf8");
expect(workflow).toContain("uses: useblacksmith/setup-docker-builder@");
expect(workflow).toContain("uses: useblacksmith/build-push-action@");
expect(workflow).not.toContain("cache-from: type=gha,scope=docker-e2e");
expect(workflow).not.toContain("cache-to: type=gha,mode=max,scope=docker-e2e");
});
it("allows the Telegram lane to run from reusable package acceptance artifacts", () => {
const workflow = readFileSync(NPM_TELEGRAM_WORKFLOW, "utf8");