diff --git a/.agents/skills/blacksmith-testbox/SKILL.md b/.agents/skills/blacksmith-testbox/SKILL.md index cb9bf0b2602..ef53f45c78b 100644 --- a/.agents/skills/blacksmith-testbox/SKILL.md +++ b/.agents/skills/blacksmith-testbox/SKILL.md @@ -93,6 +93,14 @@ Only use Testbox in OpenClaw when the user explicitly wants CI-parity or the check truly depends on remote secrets/services that the local repo loop cannot provide. +For installable-package product proof, prefer the GitHub `Package Acceptance` +workflow over an ad hoc Testbox command. It resolves one package candidate +(`source=npm`, `source=ref`, `source=url`, or `source=artifact`), uploads it as +`package-under-test`, and runs the reusable Docker E2E lanes against that exact +tarball on GitHub/Blacksmith runners. Use `workflow_ref` for the trusted +workflow/harness code and `package_ref` for the source ref to pack when testing +an older trusted branch, tag, or SHA. + ## Setup: Warmup before coding If you decided Testbox is actually warranted, warm one up early. This returns diff --git a/.agents/skills/openclaw-testing/SKILL.md b/.agents/skills/openclaw-testing/SKILL.md index cee6e4774e2..cba803168e3 100644 --- a/.agents/skills/openclaw-testing/SKILL.md +++ b/.agents/skills/openclaw-testing/SKILL.md @@ -239,6 +239,7 @@ Good defaults: ```bash gh workflow run package-acceptance.yml --ref main \ -f source=npm \ + -f workflow_ref=main \ -f package_spec=openclaw@beta \ -f suite_profile=product ``` @@ -270,20 +271,46 @@ Npm candidate selection: Profiles: -- `smoke`: quick package install/channel/agent + gateway/config lanes. -- `package`: package, update, and plugin lanes; no OpenWebUI. -- `product`: package profile plus MCP channels, cron/subagent cleanup, OpenAI - web search, and OpenWebUI. +- `smoke`: quick confidence that the tarball installs, can onboard a channel, + can run an agent turn, and basic gateway/config lanes work. +- `package`: release-package contract. Adds installer/update, doctor install + switching, bundled plugin runtime deps, plugin install/update, and package + repair lanes. This is the default native replacement for most Parallels + 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. - `custom`: exact `docker_lanes` list for a focused rerun. Candidate sources: - `source=npm`: `openclaw@beta`, `openclaw@latest`, or an exact release version. -- `source=ref`: pack the trusted ref in the workflow. +- `source=ref`: pack `package_ref` using the trusted `workflow_ref` harness. + This intentionally separates old package commits from new workflow/test code. - `source=url`: HTTPS `.tgz` plus required `package_sha256`. - `source=artifact`: download one `.tgz` from `artifact_run_id`/`artifact_name`. +Ref model: + +- `gh workflow run ... --ref ` selects the workflow file revision + GitHub executes. +- `workflow_ref` is the trusted harness/script ref passed to reusable Docker + E2E. +- `package_ref` is the source ref to build when `source=ref`. It can be an + older branch/tag/SHA as long as it is reachable from an OpenClaw branch or + release tag. + +Example: run latest package acceptance harness against an older trusted commit: + +```bash +gh workflow run package-acceptance.yml --ref main \ + -f workflow_ref=main \ + -f source=ref \ + -f package_ref= \ + -f suite_profile=package \ + -f telegram_mode=none +``` + Use `telegram_mode=mock-openai` or `telegram_mode=live-frontier` only with `source=npm`; that path reuses the published npm Telegram E2E workflow and the `qa-live-shared` environment. diff --git a/.github/workflows/full-release-validation.yml b/.github/workflows/full-release-validation.yml index 4a514a12d51..d8d1e461817 100644 --- a/.github/workflows/full-release-validation.yml +++ b/.github/workflows/full-release-validation.yml @@ -96,7 +96,7 @@ jobs: echo "- Target SHA: \`${TARGET_SHA}\`" echo "- Child workflow ref: \`${WORKFLOW_REF}\`" echo "- Normal CI: \`CI\` with \`target_ref=${TARGET_REF}\`" - echo "- Release/live/Docker/QA: \`OpenClaw Release Checks\`" + echo "- Release/live/Docker/package/QA: \`OpenClaw Release Checks\`" if [[ -n "${NPM_TELEGRAM_PACKAGE_SPEC// }" ]]; then echo "- Post-publish Telegram E2E: \`${NPM_TELEGRAM_PACKAGE_SPEC}\`" else diff --git a/.github/workflows/openclaw-release-checks.yml b/.github/workflows/openclaw-release-checks.yml index c5203a6552f..bcd0974125a 100644 --- a/.github/workflows/openclaw-release-checks.yml +++ b/.github/workflows/openclaw-release-checks.yml @@ -214,6 +214,23 @@ jobs: OPENCLAW_GEMINI_SETTINGS_JSON: ${{ secrets.OPENCLAW_GEMINI_SETTINGS_JSON }} FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }} + package_acceptance_release_checks: + name: Run package acceptance + needs: [resolve_target] + permissions: + actions: read + contents: read + packages: write + pull-requests: read + uses: ./.github/workflows/package-acceptance.yml + with: + workflow_ref: ${{ github.ref_name }} + source: ref + package_ref: ${{ needs.resolve_target.outputs.ref }} + suite_profile: package + telegram_mode: none + secrets: inherit + qa_lab_parity_release_checks: name: Run QA Lab parity gate needs: [resolve_target] @@ -441,3 +458,40 @@ jobs: path: ${{ steps.run_lane.outputs.output_dir }} retention-days: 14 if-no-files-found: warn + + summary: + name: Verify release checks + needs: + - install_smoke_release_checks + - cross_os_release_checks + - live_and_e2e_release_checks + - package_acceptance_release_checks + - qa_lab_parity_release_checks + - qa_live_matrix_release_checks + - qa_live_telegram_release_checks + if: always() + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Verify release check results + shell: bash + run: | + set -euo pipefail + failed=0 + for item in \ + "install_smoke_release_checks=${{ needs.install_smoke_release_checks.result }}" \ + "cross_os_release_checks=${{ needs.cross_os_release_checks.result }}" \ + "live_and_e2e_release_checks=${{ needs.live_and_e2e_release_checks.result }}" \ + "package_acceptance_release_checks=${{ needs.package_acceptance_release_checks.result }}" \ + "qa_lab_parity_release_checks=${{ needs.qa_lab_parity_release_checks.result }}" \ + "qa_live_matrix_release_checks=${{ needs.qa_live_matrix_release_checks.result }}" \ + "qa_live_telegram_release_checks=${{ needs.qa_live_telegram_release_checks.result }}" + do + name="${item%%=*}" + result="${item#*=}" + if [[ "$result" != "success" && "$result" != "skipped" ]]; then + echo "::error::${name} ended with ${result}" + failed=1 + fi + done + exit "$failed" diff --git a/.github/workflows/package-acceptance.yml b/.github/workflows/package-acceptance.yml index 09d96e50492..6116973ed05 100644 --- a/.github/workflows/package-acceptance.yml +++ b/.github/workflows/package-acceptance.yml @@ -3,6 +3,11 @@ name: Package Acceptance on: workflow_dispatch: inputs: + workflow_ref: + description: Trusted repo ref for workflow scripts and Docker E2E harness + required: true + default: main + type: string source: description: Package candidate source required: true @@ -13,8 +18,8 @@ on: - ref - url - artifact - ref: - description: Trusted repo ref for workflow scripts, or package source when source=ref + package_ref: + description: Trusted package source ref when source=ref required: true default: main type: string @@ -68,6 +73,62 @@ on: - none - mock-openai - live-frontier + workflow_call: + inputs: + workflow_ref: + description: Trusted repo ref for workflow scripts and Docker E2E harness + required: false + default: main + type: string + source: + description: "Package candidate source: npm, ref, url, or artifact" + required: true + type: string + package_ref: + description: Trusted package source ref when source=ref + required: false + default: main + type: string + package_spec: + description: Published package spec when source=npm + required: false + default: openclaw@beta + type: string + package_url: + description: HTTPS .tgz URL when source=url + required: false + default: "" + type: string + package_sha256: + description: Expected package SHA-256; required for source=url + required: false + default: "" + type: string + artifact_run_id: + description: GitHub Actions run id when source=artifact + required: false + default: "" + type: string + artifact_name: + description: Artifact name containing one .tgz when source=artifact + required: false + default: package-under-test + type: string + suite_profile: + description: "Acceptance profile: smoke, package, product, full, or custom" + required: false + default: package + type: string + docker_lanes: + description: Comma/space separated Docker lanes when suite_profile=custom + required: false + default: "" + type: string + telegram_mode: + description: Optional published-npm Telegram QA lane + required: false + default: none + type: string permissions: actions: read @@ -104,8 +165,8 @@ jobs: - name: Checkout package workflow ref uses: actions/checkout@v6 with: - ref: ${{ inputs.ref }} - fetch-depth: 1 + ref: ${{ inputs.workflow_ref }} + fetch-depth: 0 - name: Setup Node environment uses: ./.github/actions/setup-node-env @@ -113,7 +174,7 @@ jobs: node-version: ${{ env.NODE_VERSION }} pnpm-version: ${{ env.PNPM_VERSION }} install-bun: ${{ inputs.source == 'ref' && 'true' || 'false' }} - install-deps: ${{ inputs.source == 'ref' && 'true' || 'false' }} + install-deps: "false" - name: Download package artifact input if: inputs.source == 'artifact' @@ -139,6 +200,7 @@ jobs: id: resolve env: SOURCE: ${{ inputs.source }} + PACKAGE_REF: ${{ inputs.package_ref }} PACKAGE_SPEC: ${{ inputs.package_spec }} PACKAGE_URL: ${{ inputs.package_url }} PACKAGE_SHA256: ${{ inputs.package_sha256 }} @@ -152,6 +214,7 @@ jobs: node scripts/resolve-openclaw-package-candidate.mjs \ --source "$SOURCE" \ + --package-ref "$PACKAGE_REF" \ --package-spec "$PACKAGE_SPEC" \ --package-url "$PACKAGE_URL" \ --package-sha256 "$PACKAGE_SHA256" \ @@ -241,14 +304,20 @@ jobs: env: PACKAGE_SHA256: ${{ steps.resolve.outputs.sha256 }} PACKAGE_VERSION: ${{ steps.resolve.outputs.package_version }} + PACKAGE_REF: ${{ inputs.package_ref }} SOURCE: ${{ inputs.source }} SUITE_PROFILE: ${{ inputs.suite_profile }} + WORKFLOW_REF: ${{ inputs.workflow_ref }} shell: bash run: | { echo "## Package acceptance" echo echo "- Source: \`${SOURCE}\`" + echo "- Workflow ref: \`${WORKFLOW_REF}\`" + if [[ "${SOURCE}" == "ref" ]]; then + echo "- Package ref: \`${PACKAGE_REF}\`" + fi echo "- Version: \`${PACKAGE_VERSION}\`" echo "- SHA-256: \`${PACKAGE_SHA256}\`" echo "- Profile: \`${SUITE_PROFILE}\`" @@ -259,7 +328,7 @@ jobs: needs: resolve_package uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml with: - ref: ${{ inputs.ref }} + ref: ${{ inputs.workflow_ref }} include_repo_e2e: false include_release_path_suites: ${{ needs.resolve_package.outputs.include_release_path_suites == 'true' }} include_openwebui: ${{ needs.resolve_package.outputs.include_openwebui == 'true' }} diff --git a/docs/ci.md b/docs/ci.md index 44f645bd65a..bdf13ad82f1 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -11,18 +11,20 @@ The CI runs on every push to `main` and every pull request. It uses smart scopin `Full Release Validation` is the manual umbrella workflow for "run everything before release." It accepts a branch, tag, or full commit SHA, dispatches the manual `CI` workflow with that target, and dispatches `OpenClaw Release Checks` -for install smoke, Docker release-path suites, live/E2E, OpenWebUI, QA Lab -parity, Matrix, and Telegram lanes. It can also run the post-publish `NPM -Telegram Beta E2E` workflow when a published package spec is provided. +for install smoke, package acceptance, Docker release-path suites, live/E2E, +OpenWebUI, QA Lab parity, Matrix, and Telegram lanes. It can also run the +post-publish `NPM Telegram Beta E2E` workflow when a published package spec is +provided. `Package Acceptance` is the side-run workflow for validating a package artifact -without blocking the release workflow. It resolves one candidate from a trusted -ref, a published npm spec, an HTTPS tarball URL with SHA-256, or a tarball -artifact from another GitHub Actions run, uploads it as `package-under-test`, -then reuses the Docker release/E2E scheduler with that tarball instead of -packing the selected ref. Profiles cover smoke, package, product, full, and -custom Docker lane selections. The optional Telegram lane is published-npm only -and reuses the `NPM Telegram Beta E2E` workflow. +without blocking the release workflow. It resolves one candidate from a +published npm spec, a trusted `package_ref` built with the selected +`workflow_ref` harness, an HTTPS tarball URL with SHA-256, or a tarball artifact +from another GitHub Actions run, uploads it as `package-under-test`, then reuses +the Docker release/E2E scheduler with that tarball instead of repacking the +workflow checkout. Profiles cover smoke, package, product, full, and custom +Docker lane selections. The optional Telegram lane is published-npm only and +reuses the `NPM Telegram Beta E2E` workflow. QA Lab has dedicated CI lanes outside the main smart-scoped workflow. The `Parity gate` workflow runs on matching PR changes and manual dispatch; it @@ -125,7 +127,7 @@ act as if every scoped area changed. CI workflow edits validate the Node CI graph plus workflow linting, but do not force Windows, Android, or macOS native builds by themselves; those platform lanes stay scoped to platform source changes. CI routing-only edits, selected cheap core-test fixture edits, and narrow plugin contract helper/test-routing edits use a fast Node-only manifest path: preflight, security, and a single `checks-fast-core` task. That path avoids build artifacts, Node 22 compatibility, channel contracts, full core shards, bundled-plugin shards, and additional guard matrices when the changed files are limited to the routing or helper surfaces that the fast task exercises directly. Windows Node checks are scoped to Windows-specific process/path wrappers, npm/pnpm/UI runner helpers, package manager config, and the CI workflow surfaces that execute that lane; unrelated source, plugin, install-smoke, and test-only changes stay on the Linux Node lanes so they do not reserve a 16-vCPU Windows worker for coverage that is already exercised by the normal test shards. -The separate `install-smoke` workflow reuses the same scope script through its own `preflight` job. It splits smoke coverage into `run_fast_install_smoke` and `run_full_install_smoke`. Pull requests run the fast path for Docker/package surfaces, bundled plugin package/manifest changes, and core plugin/channel/gateway/Plugin SDK surfaces that the Docker smoke jobs exercise. Source-only bundled plugin changes, test-only edits, and docs-only edits do not reserve Docker workers. The fast path builds the root Dockerfile image once, checks the CLI, runs the agents delete shared-workspace CLI smoke, runs the container gateway-network e2e, verifies a bundled extension build arg, and runs the bounded bundled-plugin Docker profile under a 240-second aggregate command timeout with each scenario's Docker run capped separately. The full path keeps QR package install and installer Docker/update coverage for nightly scheduled runs, manual dispatches, workflow-call release checks, and pull requests that truly touch installer/package/Docker surfaces. `main` pushes, including merge commits, do not force the full path; when changed-scope logic would request full coverage on a push, the workflow keeps the fast Docker smoke and leaves the full install smoke to nightly or release validation. The slow Bun global install image-provider smoke is separately gated by `run_bun_global_install_smoke`; it runs on the nightly schedule and from the release checks workflow, and manual `install-smoke` dispatches can opt into it, but pull requests and `main` pushes do not run it. QR and installer Docker tests keep their own install-focused Dockerfiles. Local `test:docker:all` prebuilds one shared live-test image, packs OpenClaw once as an npm tarball, and builds two shared `scripts/e2e/Dockerfile` images: a bare Node/Git runner for installer/update/plugin-dependency lanes and a functional image that installs the same tarball into `/app` for normal functionality lanes. Docker lane definitions live in `scripts/lib/docker-e2e-scenarios.mjs`, planner logic lives in `scripts/lib/docker-e2e-plan.mjs`, and the runner only executes the selected plan. The scheduler selects the image per lane with `OPENCLAW_DOCKER_E2E_BARE_IMAGE` and `OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE`, then runs lanes with `OPENCLAW_SKIP_DOCKER_BUILD=1`; tune the default main-pool slot count of 10 with `OPENCLAW_DOCKER_ALL_PARALLELISM` and the provider-sensitive tail-pool slot count of 10 with `OPENCLAW_DOCKER_ALL_TAIL_PARALLELISM`. Heavy lane caps default to `OPENCLAW_DOCKER_ALL_LIVE_LIMIT=9`, `OPENCLAW_DOCKER_ALL_NPM_LIMIT=10`, and `OPENCLAW_DOCKER_ALL_SERVICE_LIMIT=7` so npm install and multi-service lanes do not overcommit Docker while lighter lanes still fill available slots. Lane starts are staggered by 2 seconds by default to avoid local Docker daemon create storms; override with `OPENCLAW_DOCKER_ALL_START_STAGGER_MS=0` or another millisecond value. The local aggregate preflights Docker, removes stale OpenClaw E2E containers, emits active-lane status, persists lane timings for longest-first ordering, and supports `OPENCLAW_DOCKER_ALL_DRY_RUN=1` for scheduler inspection. It stops scheduling new pooled lanes after the first failure by default, and each lane has a 120-minute fallback timeout overrideable with `OPENCLAW_DOCKER_ALL_LANE_TIMEOUT_MS`; selected live/tail lanes use tighter per-lane caps. `OPENCLAW_DOCKER_ALL_LANES=` runs exact scheduler lanes, including release-only lanes such as `install-e2e` and split bundled update lanes such as `bundled-channel-update-acpx`, while skipping the cleanup smoke so agents can reproduce one failed lane. 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, then `scripts/docker-e2e.mjs` converts that plan into GitHub outputs and summaries. It either packs OpenClaw through `scripts/package-openclaw-for-docker.mjs` or downloads a caller-provided package artifact, validates the tarball inventory, builds and pushes package-digest-tagged bare/functional GHCR Docker E2E images when the plan needs package-installed lanes, and reuses those images when the same package digest has already been prepared. The release-path Docker suite runs as at most three 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 (`OPENCLAW_DOCKER_ALL_PROFILE=release-path`, `OPENCLAW_DOCKER_ALL_CHUNK=core|package-update|plugins-integrations`). Each chunk uploads `.artifacts/docker-tests/` with lane logs, timings, `summary.json`, `failures.json`, phase timings, scheduler plan JSON, and per-lane rerun commands. The workflow `docker_lanes` input runs selected lanes against the prepared images instead of the three chunk jobs, which keeps failed-lane debugging bounded to one targeted Docker job and prepares or downloads 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. Use `pnpm test:docker:rerun ` to download Docker artifacts from a GitHub run and print combined/per-lane targeted rerun commands; use `pnpm test:docker:timings ` for slow-lane and phase critical-path summaries. When Open WebUI is requested with the release-path suite, it runs inside the plugins/integrations chunk instead of reserving a fourth Docker worker; Open WebUI keeps a standalone job only for openwebui-only dispatches. The scheduled live/E2E workflow runs the full release-path Docker suite daily. The bundled update matrix is split by update target so repeated npm update and doctor repair passes can shard with other bundled checks. +The separate `install-smoke` workflow reuses the same scope script through its own `preflight` job. It splits smoke coverage into `run_fast_install_smoke` and `run_full_install_smoke`. Pull requests run the fast path for Docker/package surfaces, bundled plugin package/manifest changes, and core plugin/channel/gateway/Plugin SDK surfaces that the Docker smoke jobs exercise. Source-only bundled plugin changes, test-only edits, and docs-only edits do not reserve Docker workers. The fast path builds the root Dockerfile image once, checks the CLI, runs the agents delete shared-workspace CLI smoke, runs the container gateway-network e2e, verifies a bundled extension build arg, and runs the bounded bundled-plugin Docker profile under a 240-second aggregate command timeout with each scenario's Docker run capped separately. The full path keeps QR package install and installer Docker/update coverage for nightly scheduled runs, manual dispatches, workflow-call release checks, and pull requests that truly touch installer/package/Docker surfaces. `main` pushes, including merge commits, do not force the full path; when changed-scope logic would request full coverage on a push, the workflow keeps the fast Docker smoke and leaves the full install smoke to nightly or release validation. The slow Bun global install image-provider smoke is separately gated by `run_bun_global_install_smoke`; it runs on the nightly schedule and from the release checks workflow, and manual `install-smoke` dispatches can opt into it, but pull requests and `main` pushes do not run it. QR and installer Docker tests keep their own install-focused Dockerfiles. Local `test:docker:all` prebuilds one shared live-test image, packs OpenClaw once as an npm tarball, and builds two shared `scripts/e2e/Dockerfile` images: a bare Node/Git runner for installer/update/plugin-dependency lanes and a functional image that installs the same tarball into `/app` for normal functionality lanes. Docker lane definitions live in `scripts/lib/docker-e2e-scenarios.mjs`, planner logic lives in `scripts/lib/docker-e2e-plan.mjs`, and the runner only executes the selected plan. The scheduler selects the image per lane with `OPENCLAW_DOCKER_E2E_BARE_IMAGE` and `OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE`, then runs lanes with `OPENCLAW_SKIP_DOCKER_BUILD=1`; tune the default main-pool slot count of 10 with `OPENCLAW_DOCKER_ALL_PARALLELISM` and the provider-sensitive tail-pool slot count of 10 with `OPENCLAW_DOCKER_ALL_TAIL_PARALLELISM`. Heavy lane caps default to `OPENCLAW_DOCKER_ALL_LIVE_LIMIT=9`, `OPENCLAW_DOCKER_ALL_NPM_LIMIT=10`, and `OPENCLAW_DOCKER_ALL_SERVICE_LIMIT=7` so npm install and multi-service lanes do not overcommit Docker while lighter lanes still fill available slots. Lane starts are staggered by 2 seconds by default to avoid local Docker daemon create storms; override with `OPENCLAW_DOCKER_ALL_START_STAGGER_MS=0` or another millisecond value. The local aggregate preflights Docker, removes stale OpenClaw E2E containers, emits active-lane status, persists lane timings for longest-first ordering, and supports `OPENCLAW_DOCKER_ALL_DRY_RUN=1` for scheduler inspection. It stops scheduling new pooled lanes after the first failure by default, and each lane has a 120-minute fallback timeout overrideable with `OPENCLAW_DOCKER_ALL_LANE_TIMEOUT_MS`; selected live/tail lanes use tighter per-lane caps. `OPENCLAW_DOCKER_ALL_LANES=` runs exact scheduler lanes, including release-only lanes such as `install-e2e` and split bundled update lanes such as `bundled-channel-update-acpx`, while skipping the cleanup smoke so agents can reproduce one failed lane. 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, then `scripts/docker-e2e.mjs` converts that plan into GitHub outputs and summaries. It either packs OpenClaw through `scripts/package-openclaw-for-docker.mjs` or downloads a caller-provided package artifact, validates the tarball inventory, builds and pushes package-digest-tagged bare/functional GHCR Docker E2E images when the plan needs package-installed lanes, and reuses those images when the same package digest has already been prepared. The `Package Acceptance` workflow is the high-level package gate: it resolves a candidate from npm, a trusted `package_ref`, an HTTPS tarball plus SHA-256, or a prior workflow artifact, then passes that single `package-under-test` artifact into the reusable Docker E2E workflow. It keeps `workflow_ref` separate from `package_ref` so current harness logic can validate older trusted source commits without checking out old workflow code. Release checks run the `package` acceptance profile for the target ref; that profile covers package/update/plugin contracts and is the default GitHub-native replacement for most Parallels package/update coverage. The release-path Docker suite runs as at most three 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 (`OPENCLAW_DOCKER_ALL_PROFILE=release-path`, `OPENCLAW_DOCKER_ALL_CHUNK=core|package-update|plugins-integrations`). Each chunk uploads `.artifacts/docker-tests/` with lane logs, timings, `summary.json`, `failures.json`, phase timings, scheduler plan JSON, and per-lane rerun commands. The workflow `docker_lanes` input runs selected lanes against the prepared images instead of the three chunk jobs, which keeps failed-lane debugging bounded to one targeted Docker job and prepares or downloads 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. Use `pnpm test:docker:rerun ` to download Docker artifacts from a GitHub run and print combined/per-lane targeted rerun commands; use `pnpm test:docker:timings ` for slow-lane and phase critical-path summaries. When Open WebUI is requested with the release-path suite, it runs inside the plugins/integrations chunk instead of reserving a fourth Docker worker; Open WebUI keeps a standalone job only for openwebui-only dispatches. The scheduled live/E2E workflow runs the full release-path Docker suite daily. The bundled update matrix is split by update target so repeated npm update and doctor repair passes can shard with other bundled checks. Local changed-lane logic lives in `scripts/changed-lanes.mjs` and is executed by `scripts/check-changed.mjs`. That local check gate is stricter about architecture boundaries than the broad CI platform scope: core production changes run core prod and core test typecheck plus core lint/guards, core test-only changes run only core test typecheck plus core lint, extension production changes run extension prod and extension test typecheck plus extension lint, and extension test-only changes run extension test typecheck plus extension lint. Public Plugin SDK or plugin-contract changes expand to extension typecheck because extensions depend on those core contracts, but Vitest extension sweeps are explicit test work. Release metadata-only version bumps run targeted version/config/root-dependency checks. Unknown root/config changes fail safe to all check lanes. diff --git a/docs/help/testing.md b/docs/help/testing.md index 9e7b9084d05..5822ea4e05f 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -644,6 +644,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. 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. - 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: diff --git a/docs/reference/RELEASING.md b/docs/reference/RELEASING.md index 8433f5db357..0892d6bd7f3 100644 --- a/docs/reference/RELEASING.md +++ b/docs/reference/RELEASING.md @@ -52,20 +52,22 @@ OpenClaw has three public release lanes: - Run the manual `Full Release Validation` workflow before release approval when you need the whole release validation suite from one entrypoint. It accepts a branch, tag, or full commit SHA, dispatches manual `CI`, and - dispatches `OpenClaw Release Checks` for install smoke, Docker release-path - suites, live/E2E, OpenWebUI, QA Lab parity, Matrix, and Telegram lanes. + dispatches `OpenClaw Release Checks` for install smoke, package acceptance, + Docker release-path suites, live/E2E, OpenWebUI, QA Lab parity, Matrix, and + Telegram lanes. Provide `npm_telegram_package_spec` only after a package has been published and the post-publish Telegram E2E should run too. Example: `gh workflow run full-release-validation.yml --ref main -f ref=release/YYYY.M.D` - Run the manual `Package Acceptance` workflow when you want side-channel proof for a package candidate while release work continues. Use `source=npm` for `openclaw@beta`, `openclaw@latest`, or an exact release version; `source=ref` - to pack a trusted branch/tag/SHA; `source=url` for an HTTPS tarball with a - required SHA-256; or `source=artifact` for a tarball uploaded by another - GitHub Actions run. The workflow resolves the candidate to + to pack a trusted `package_ref` branch/tag/SHA with the current + `workflow_ref` harness; `source=url` for an HTTPS tarball with a required + SHA-256; or `source=artifact` for a tarball uploaded by another GitHub + Actions run. The workflow resolves the candidate to `package-under-test`, reuses the Docker E2E release scheduler against that tarball, and can optionally run published-npm Telegram QA. - Example: `gh workflow run package-acceptance.yml --ref main -f source=npm -f package_spec=openclaw@beta -f suite_profile=product` + Example: `gh workflow run package-acceptance.yml --ref main -f workflow_ref=main -f source=npm -f package_spec=openclaw@beta -f suite_profile=product` Common profiles: - `smoke`: install/channel/agent, gateway network, and config reload lanes - `package`: package/update/plugin lanes without OpenWebUI diff --git a/scripts/package-openclaw-for-docker.mjs b/scripts/package-openclaw-for-docker.mjs index 0d7003b4ff4..69226853f97 100644 --- a/scripts/package-openclaw-for-docker.mjs +++ b/scripts/package-openclaw-for-docker.mjs @@ -14,6 +14,7 @@ function parseArgs(argv) { outputDir: "", outputName: "", skipBuild: false, + sourceDir: ROOT_DIR, }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; @@ -27,6 +28,10 @@ function parseArgs(argv) { options.outputName = arg.slice("--output-name=".length); } else if (arg === "--skip-build") { options.skipBuild = true; + } else if (arg === "--source-dir") { + options.sourceDir = argv[(index += 1)] ?? ""; + } else if (arg?.startsWith("--source-dir=")) { + options.sourceDir = arg.slice("--source-dir=".length); } else { throw new Error(`unknown argument: ${arg}`); } @@ -34,10 +39,10 @@ function parseArgs(argv) { return options; } -function run(command, args) { +function run(command, args, cwd) { return new Promise((resolve, reject) => { const child = spawn(command, args, { - cwd: ROOT_DIR, + cwd, stdio: ["ignore", "pipe", "pipe"], }); child.stdout.pipe(process.stderr, { end: false }); @@ -53,10 +58,10 @@ function run(command, args) { }); } -async function runCapture(command, args) { +async function runCapture(command, args, cwd) { return await new Promise((resolve, reject) => { const child = spawn(command, args, { - cwd: ROOT_DIR, + cwd, stdio: ["ignore", "pipe", "pipe"], }); let stdout = ""; @@ -100,6 +105,7 @@ async function newestOpenClawTarball(outputDir, packOutput) { async function main() { const options = parseArgs(process.argv.slice(2)); + const sourceDir = path.resolve(ROOT_DIR, options.sourceDir || ROOT_DIR); const outputDir = path.resolve( ROOT_DIR, options.outputDir || path.join(".artifacts", "docker-e2e-package"), @@ -108,26 +114,28 @@ async function main() { if (!options.skipBuild) { console.error("==> Building OpenClaw package artifacts"); - await run("pnpm", ["build"]); + await run("pnpm", ["build"], sourceDir); } console.error("==> Writing OpenClaw package inventory"); - await run("node", [ - "--import", - "tsx", - "--input-type=module", - "-e", - "const { writePackageDistInventory } = await import('./src/infra/package-dist-inventory.ts'); await writePackageDistInventory(process.cwd());", - ]); + await run( + "node", + [ + "--import", + "tsx", + "--input-type=module", + "-e", + "const { writePackageDistInventory } = await import('./src/infra/package-dist-inventory.ts'); await writePackageDistInventory(process.cwd());", + ], + sourceDir, + ); console.error("==> Packing OpenClaw package"); - const packOutput = await runCapture("npm", [ - "pack", - "--silent", - "--ignore-scripts", - "--pack-destination", - outputDir, - ]); + const packOutput = await runCapture( + "npm", + ["pack", "--silent", "--ignore-scripts", "--pack-destination", outputDir], + sourceDir, + ); let tarball = await newestOpenClawTarball(outputDir, packOutput); if (options.outputName) { @@ -140,7 +148,11 @@ async function main() { } console.error("==> Checking OpenClaw package tarball"); - await run("node", ["scripts/check-openclaw-package-tarball.mjs", tarball]); + await run( + "node", + [path.join(ROOT_DIR, "scripts/check-openclaw-package-tarball.mjs"), tarball], + sourceDir, + ); process.stdout.write(`${tarball}\n`); } diff --git a/scripts/resolve-openclaw-package-candidate.mjs b/scripts/resolve-openclaw-package-candidate.mjs index e290c054886..fb207a219e3 100644 --- a/scripts/resolve-openclaw-package-candidate.mjs +++ b/scripts/resolve-openclaw-package-candidate.mjs @@ -4,6 +4,7 @@ import { spawn } from "node:child_process"; import { createHash } from "node:crypto"; import { createWriteStream } from "node:fs"; import fs from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; import { pipeline } from "node:stream/promises"; import { fileURLToPath } from "node:url"; @@ -18,6 +19,7 @@ function usage() { Options: --package-spec Published npm spec for source=npm. + --package-ref Trusted repo ref for source=ref. --package-url HTTPS tarball URL for source=url. --package-sha256 Expected tarball SHA-256 for source=url or source=artifact. --artifact-dir Directory containing exactly one .tgz for source=artifact. @@ -33,6 +35,7 @@ export function parseArgs(argv) { metadata: "", outputDir: "", outputName: DEFAULT_OUTPUT_NAME, + packageRef: "", packageSha256: "", packageSpec: "", packageUrl: "", @@ -59,6 +62,8 @@ export function parseArgs(argv) { options.outputName = readValue(arg); } else if (arg === "--package-sha256") { options.packageSha256 = readValue(arg).toLowerCase(); + } else if (arg === "--package-ref") { + options.packageRef = readValue(arg); } else if (arg === "--package-spec") { options.packageSpec = readValue(arg); } else if (arg === "--package-url") { @@ -167,6 +172,104 @@ async function findSingleTarball(dir) { return files[0]; } +async function revParseTrustedInputRef(ref) { + const candidates = [ref, `refs/remotes/origin/${ref}`, `refs/tags/${ref}`]; + for (const candidate of candidates) { + const resolved = await run("git", ["rev-parse", "--verify", `${candidate}^{commit}`], { + capture: true, + }).then( + (value) => value.trim(), + () => "", + ); + if (resolved) { + return resolved; + } + } + throw new Error(`package_ref does not resolve to a commit: ${ref}`); +} + +async function resolveTrustedRepoRef(ref) { + if (!ref || ref.trim() === "" || ref.startsWith("-")) { + throw new Error( + `package_ref must be a branch, tag, or full commit SHA; got: ${ref || ""}`, + ); + } + + await run("git", ["fetch", "--no-tags", "origin", "+refs/heads/*:refs/remotes/origin/*"]); + await run("git", ["fetch", "--tags", "origin", "+refs/tags/*:refs/tags/*"]); + + const selectedSha = await revParseTrustedInputRef(ref); + const isMainAncestor = await run("git", [ + "merge-base", + "--is-ancestor", + selectedSha, + "refs/remotes/origin/main", + ]).then( + () => true, + () => false, + ); + if (isMainAncestor) { + return { selectedSha, trustedReason: "main-ancestor" }; + } + + const releaseTags = (await run("git", ["tag", "--points-at", selectedSha], { capture: true })) + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter(Boolean); + if (releaseTags.some((tag) => tag.startsWith("v"))) { + return { selectedSha, trustedReason: "release-tag" }; + } + + const containingBranches = ( + await run( + "git", + [ + "for-each-ref", + "--format=%(refname:short)", + "--contains", + selectedSha, + "refs/remotes/origin", + ], + { capture: true }, + ) + ) + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter(Boolean); + if (containingBranches.some((branch) => branch.startsWith("origin/"))) { + return { selectedSha, trustedReason: "repository-branch-history" }; + } + + throw new Error( + `package_ref ${ref} resolved to ${selectedSha}, which is not reachable from an OpenClaw branch or release tag`, + ); +} + +async function preparePackageSourceWorktree(ref) { + const { selectedSha, trustedReason } = await resolveTrustedRepoRef(ref); + const sourceDir = path.join( + process.env.RUNNER_TEMP || os.tmpdir(), + `openclaw-package-source-${process.pid}`, + ); + await fs.rm(sourceDir, { recursive: true, force: true }); + await run("git", ["worktree", "add", "--detach", sourceDir, selectedSha]); + return { selectedSha, sourceDir, trustedReason }; +} + +async function installPackageSourceDeps(sourceDir) { + await run( + "pnpm", + [ + "install", + "--frozen-lockfile", + "--ignore-scripts=false", + "--config.engine-strict=false", + "--config.enable-pre-post-scripts=true", + ], + { cwd: sourceDir }, + ); +} + async function moveNewestPackedTarball(outputDir, packOutput, outputName) { let filename = ""; try { @@ -238,39 +341,68 @@ async function resolveCandidate(options) { const target = path.join(outputDir, options.outputName || DEFAULT_OUTPUT_NAME); await fs.mkdir(outputDir, { recursive: true }); await fs.rm(target, { force: true }); + let packageRef = ""; + let packageSourceSha = ""; + let packageTrustedReason = ""; + let packageWorktreeDir = ""; - if (options.source === "ref") { - await run("node", [ - "scripts/package-openclaw-for-docker.mjs", - "--output-dir", - outputDir, - "--output-name", - options.outputName || DEFAULT_OUTPUT_NAME, - ]); - } else if (options.source === "npm") { - validateOpenClawPackageSpec(options.packageSpec); - const packOutput = await run( - "npm", - ["pack", options.packageSpec, "--ignore-scripts", "--json", "--pack-destination", outputDir], - { capture: true }, - ); - await moveNewestPackedTarball(outputDir, packOutput, options.outputName || DEFAULT_OUTPUT_NAME); - } else if (options.source === "url") { - if (!options.packageUrl) { - throw new Error("source=url requires --package-url"); + try { + if (options.source === "ref") { + packageRef = options.packageRef || "main"; + const packageSource = await preparePackageSourceWorktree(packageRef); + packageWorktreeDir = packageSource.sourceDir; + packageSourceSha = packageSource.selectedSha; + packageTrustedReason = packageSource.trustedReason; + await installPackageSourceDeps(packageSource.sourceDir); + await run("node", [ + "scripts/package-openclaw-for-docker.mjs", + "--source-dir", + packageSource.sourceDir, + "--output-dir", + outputDir, + "--output-name", + options.outputName || DEFAULT_OUTPUT_NAME, + ]); + } else if (options.source === "npm") { + validateOpenClawPackageSpec(options.packageSpec); + const packOutput = await run( + "npm", + [ + "pack", + options.packageSpec, + "--ignore-scripts", + "--json", + "--pack-destination", + outputDir, + ], + { capture: true }, + ); + await moveNewestPackedTarball( + outputDir, + packOutput, + options.outputName || DEFAULT_OUTPUT_NAME, + ); + } else if (options.source === "url") { + if (!options.packageUrl) { + throw new Error("source=url requires --package-url"); + } + if (!options.packageSha256) { + throw new Error("source=url requires --package-sha256"); + } + await downloadUrl(options.packageUrl, target); + } else if (options.source === "artifact") { + if (!options.artifactDir) { + throw new Error("source=artifact requires --artifact-dir"); + } + const input = await findSingleTarball(options.artifactDir); + await fs.copyFile(input, target); + } else { + throw new Error(`source must be one of: ref, npm, url, artifact. Got: ${options.source}`); } - if (!options.packageSha256) { - throw new Error("source=url requires --package-sha256"); + } finally { + if (packageWorktreeDir) { + await run("git", ["worktree", "remove", "--force", packageWorktreeDir]).catch(() => {}); } - await downloadUrl(options.packageUrl, target); - } else if (options.source === "artifact") { - if (!options.artifactDir) { - throw new Error("source=artifact requires --artifact-dir"); - } - const input = await findSingleTarball(options.artifactDir); - await fs.copyFile(input, target); - } else { - throw new Error(`source must be one of: ref, npm, url, artifact. Got: ${options.source}`); } const digest = await assertExpectedSha256(target, options.packageSha256); @@ -278,7 +410,10 @@ async function resolveCandidate(options) { const pkg = await readPackageJson(target); const metadata = { name: pkg.name, + packageRef, packageSpec: options.packageSpec || "", + packageSourceSha, + packageTrustedReason, sha256: digest, source: options.source, tarball: path.relative(ROOT_DIR, target), diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index e802dc0bc5e..bca77db6009 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -5,24 +5,30 @@ const PACKAGE_ACCEPTANCE_WORKFLOW = ".github/workflows/package-acceptance.yml"; const LIVE_E2E_WORKFLOW = ".github/workflows/openclaw-live-and-e2e-checks-reusable.yml"; const DOCKER_E2E_PLAN_ACTION = ".github/actions/docker-e2e-plan/action.yml"; const NPM_TELEGRAM_WORKFLOW = ".github/workflows/npm-telegram-beta-e2e.yml"; +const RELEASE_CHECKS_WORKFLOW = ".github/workflows/openclaw-release-checks.yml"; describe("package acceptance workflow", () => { it("resolves candidate package sources before reusing Docker E2E lanes", () => { const workflow = readFileSync(PACKAGE_ACCEPTANCE_WORKFLOW, "utf8"); expect(workflow).toContain("name: Package Acceptance"); + expect(workflow).toContain("workflow_call:"); + expect(workflow).toContain("workflow_ref:"); + expect(workflow).toContain("package_ref:"); expect(workflow).toContain("source:"); expect(workflow).toContain("- npm"); expect(workflow).toContain("- ref"); expect(workflow).toContain("- url"); expect(workflow).toContain("- artifact"); expect(workflow).toContain("scripts/resolve-openclaw-package-candidate.mjs"); + expect(workflow).toContain('--package-ref "$PACKAGE_REF"'); expect(workflow).toContain('gh run download "$ARTIFACT_RUN_ID"'); expect(workflow).toContain("name: ${{ env.PACKAGE_ARTIFACT_NAME }}"); expect(workflow).toContain("pull-requests: read"); expect(workflow).toContain( "uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml", ); + expect(workflow).toContain("ref: ${{ inputs.workflow_ref }}"); expect(workflow).toContain( "package_artifact_name: ${{ needs.resolve_package.outputs.package_artifact_name }}", ); @@ -63,4 +69,13 @@ describe("package artifact reuse", () => { expect(workflow).toContain("provider_mode:"); expect(workflow).toContain("provider_mode must be mock-openai or live-frontier"); }); + + it("includes package acceptance in release checks", () => { + const workflow = readFileSync(RELEASE_CHECKS_WORKFLOW, "utf8"); + + expect(workflow).toContain("package_acceptance_release_checks:"); + expect(workflow).toContain("uses: ./.github/workflows/package-acceptance.yml"); + expect(workflow).toContain("package_ref: ${{ needs.resolve_target.outputs.ref }}"); + expect(workflow).toContain("suite_profile: package"); + }); }); diff --git a/test/scripts/resolve-openclaw-package-candidate.test.ts b/test/scripts/resolve-openclaw-package-candidate.test.ts index a3eb4f9422c..0ee6822ee99 100644 --- a/test/scripts/resolve-openclaw-package-candidate.test.ts +++ b/test/scripts/resolve-openclaw-package-candidate.test.ts @@ -28,6 +28,8 @@ describe("resolve-openclaw-package-candidate", () => { parseArgs([ "--source", "npm", + "--package-ref", + "release/2026.4.27", "--package-spec", "openclaw@beta", "--package-url", @@ -43,6 +45,7 @@ describe("resolve-openclaw-package-candidate", () => { artifactDir: ".", outputDir: ".artifacts/docker-e2e-package", packageSha256: "", + packageRef: "release/2026.4.27", packageSpec: "openclaw@beta", packageUrl: "", source: "npm",