mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
ci(docker): reuse cached e2e images for reruns
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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)} |`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"] });
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user