diff --git a/.agents/skills/openclaw-testing/SKILL.md b/.agents/skills/openclaw-testing/SKILL.md index ad3fff2ea01..a64d5ac69cb 100644 --- a/.agents/skills/openclaw-testing/SKILL.md +++ b/.agents/skills/openclaw-testing/SKILL.md @@ -142,6 +142,41 @@ image. Release-path normal mode remains max three Docker chunk jobs: - `package-update` - `plugins-integrations` +## Package Acceptance + +Use the manual `Package Acceptance` workflow when the question is "does this +installable package work as a product?" rather than "does this source diff pass +Vitest?" + +Good defaults: + +```bash +gh workflow run package-acceptance.yml --ref main \ + -f source=npm \ + -f package_spec=openclaw@beta \ + -f suite_profile=product +``` + +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. +- `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=url`: HTTPS `.tgz` plus required `package_sha256`. +- `source=artifact`: download one `.tgz` from `artifact_run_id`/`artifact_name`. + +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. + Docker E2E images never copy repo sources as the app under test: the bare image is a Node/Git runner, and the functional image installs the same prebuilt npm tarball that bare lanes mount. `scripts/package-openclaw-for-docker.mjs` is the diff --git a/.github/actions/docker-e2e-plan/action.yml b/.github/actions/docker-e2e-plan/action.yml index 4dbb354157d..ffb53edae24 100644 --- a/.github/actions/docker-e2e-plan/action.yml +++ b/.github/actions/docker-e2e-plan/action.yml @@ -26,6 +26,10 @@ inputs: description: Whether to download/pull artifacts required by the plan. required: false default: "true" + package-artifact-name: + description: Workflow artifact name containing openclaw-current.tgz. + required: false + default: docker-e2e-package outputs: credentials: description: Comma-separated credential groups required by selected lanes. @@ -108,7 +112,7 @@ runs: if: inputs.hydrate-artifacts == 'true' && steps.plan.outputs.needs_package == '1' uses: actions/download-artifact@v8 with: - name: docker-e2e-package + name: ${{ inputs.package-artifact-name }} path: .artifacts/docker-e2e-package - name: Pull shared bare Docker E2E image diff --git a/.github/workflows/npm-telegram-beta-e2e.yml b/.github/workflows/npm-telegram-beta-e2e.yml index a76f46f9fbd..960abc15c81 100644 --- a/.github/workflows/npm-telegram-beta-e2e.yml +++ b/.github/workflows/npm-telegram-beta-e2e.yml @@ -20,6 +20,29 @@ on: description: Optional comma-separated Telegram scenario ids required: false type: string + workflow_call: + inputs: + package_spec: + description: Published OpenClaw package spec to test + required: true + type: string + provider_mode: + description: QA provider mode + required: false + default: mock-openai + type: string + scenario: + description: Optional comma-separated Telegram scenario ids + required: false + default: "" + type: string + secrets: + OPENAI_API_KEY: + required: false + OPENCLAW_QA_CONVEX_SITE_URL: + required: false + OPENCLAW_QA_CONVEX_SECRET_CI: + required: false permissions: contents: read @@ -90,6 +113,13 @@ jobs: echo "package_spec must be openclaw@beta, openclaw@latest, or an exact OpenClaw release version; got: ${PACKAGE_SPEC}" >&2 exit 1 fi + case "${PROVIDER_MODE}" in + mock-openai | live-frontier) ;; + *) + echo "provider_mode must be mock-openai or live-frontier; got: ${PROVIDER_MODE}" >&2 + exit 1 + ;; + esac require_var() { local key="$1" diff --git a/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml b/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml index 5e4ad9cbf80..753dfe7d9fe 100644 --- a/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml +++ b/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml @@ -28,6 +28,11 @@ on: required: false default: "" type: string + package_artifact_name: + description: Existing workflow artifact containing openclaw-current.tgz; blank packs the selected ref + required: false + default: "" + type: string include_live_suites: description: Whether to run live-provider coverage required: false @@ -69,6 +74,11 @@ on: required: false default: "" type: string + package_artifact_name: + description: Existing workflow artifact containing openclaw-current.tgz; blank packs the selected ref + required: false + default: "" + type: string include_live_suites: description: Whether to run live-provider coverage required: false @@ -477,6 +487,7 @@ jobs: mode: chunk chunk: ${{ matrix.chunk_id }} include-openwebui: ${{ inputs.include_openwebui }} + package-artifact-name: ${{ inputs.package_artifact_name || 'docker-e2e-package' }} - name: Run Docker E2E chunk shell: bash @@ -603,6 +614,7 @@ jobs: mode: targeted lanes: ${{ inputs.docker_lanes }} include-openwebui: ${{ inputs.include_openwebui }} + package-artifact-name: ${{ inputs.package_artifact_name || 'docker-e2e-package' }} - name: Run targeted Docker E2E lanes shell: bash @@ -713,23 +725,6 @@ jobs: ref: ${{ needs.validate_selected_ref.outputs.selected_sha }} fetch-depth: 1 - - name: Resolve shared Docker E2E image tags - id: image - shell: bash - env: - SELECTED_SHA: ${{ needs.validate_selected_ref.outputs.selected_sha }} - run: | - set -euo pipefail - repository="${GITHUB_REPOSITORY,,}" - bare_image="ghcr.io/${repository}-docker-e2e-bare:${SELECTED_SHA}" - functional_image="ghcr.io/${repository}-docker-e2e-functional:${SELECTED_SHA}" - image="$functional_image" - echo "image=$image" >> "$GITHUB_OUTPUT" - echo "bare_image=$bare_image" >> "$GITHUB_OUTPUT" - echo "functional_image=$functional_image" >> "$GITHUB_OUTPUT" - echo "Shared Docker E2E bare image: \`$bare_image\`" >> "$GITHUB_STEP_SUMMARY" - echo "Shared Docker E2E functional image: \`$functional_image\`" >> "$GITHUB_STEP_SUMMARY" - - name: Plan Docker E2E images id: plan uses: ./.github/actions/docker-e2e-plan @@ -741,15 +736,22 @@ jobs: hydrate-artifacts: "false" - name: Setup Node environment - if: steps.plan.outputs.needs_package == '1' + if: steps.plan.outputs.needs_package == '1' && inputs.package_artifact_name == '' 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 != '' + uses: actions/download-artifact@v8 + with: + name: ${{ inputs.package_artifact_name }} + path: .artifacts/docker-e2e-package + - name: Pack OpenClaw package for Docker E2E - if: steps.plan.outputs.needs_package == '1' + if: steps.plan.outputs.needs_package == '1' && inputs.package_artifact_name == '' shell: bash run: | set -euo pipefail @@ -758,14 +760,60 @@ jobs: --output-dir .artifacts/docker-e2e-package \ --output-name openclaw-current.tgz - - name: Upload OpenClaw Docker E2E package + - name: Validate OpenClaw Docker E2E package + id: package if: steps.plan.outputs.needs_package == '1' + shell: bash + run: | + set -euo pipefail + mkdir -p .artifacts/docker-e2e-package + target=".artifacts/docker-e2e-package/openclaw-current.tgz" + if [[ ! -f "$target" ]]; then + mapfile -t tgzs < <(find .artifacts/docker-e2e-package -type f -name '*.tgz' | sort) + if [[ "${#tgzs[@]}" -ne 1 ]]; then + echo "Expected exactly one package tarball in .artifacts/docker-e2e-package; found ${#tgzs[@]}." >&2 + printf '%s\n' "${tgzs[@]}" >&2 + exit 1 + fi + cp "${tgzs[0]}" "$target" + fi + node scripts/check-openclaw-package-tarball.mjs "$target" + digest="$(sha256sum "$target" | awk '{print $1}')" + tag="pkg-${digest:0:32}" + echo "sha256=$digest" >> "$GITHUB_OUTPUT" + echo "tag=$tag" >> "$GITHUB_OUTPUT" + { + echo "Docker E2E package: \`$target\`" + echo "Docker E2E package SHA-256: \`$digest\`" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload OpenClaw Docker E2E package + if: steps.plan.outputs.needs_package == '1' && inputs.package_artifact_name == '' uses: actions/upload-artifact@v7 with: name: docker-e2e-package path: .artifacts/docker-e2e-package/openclaw-current.tgz if-no-files-found: error + - name: Resolve shared Docker E2E image tags + id: image + shell: bash + env: + PACKAGE_TAG: ${{ steps.package.outputs.tag }} + SELECTED_SHA: ${{ needs.validate_selected_ref.outputs.selected_sha }} + 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}" + image="$functional_image" + echo "image=$image" >> "$GITHUB_OUTPUT" + echo "bare_image=$bare_image" >> "$GITHUB_OUTPUT" + echo "functional_image=$functional_image" >> "$GITHUB_OUTPUT" + echo "Shared Docker E2E bare image: \`$bare_image\`" >> "$GITHUB_STEP_SUMMARY" + echo "Shared Docker E2E functional image: \`$functional_image\`" >> "$GITHUB_STEP_SUMMARY" + - name: Log in to GHCR if: steps.plan.outputs.needs_e2e_image == '1' uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4 diff --git a/.github/workflows/package-acceptance.yml b/.github/workflows/package-acceptance.yml new file mode 100644 index 00000000000..5ec2d0c76b1 --- /dev/null +++ b/.github/workflows/package-acceptance.yml @@ -0,0 +1,309 @@ +name: Package Acceptance + +on: + workflow_dispatch: + inputs: + source: + description: Package candidate source + required: true + default: npm + type: choice + options: + - npm + - ref + - url + - artifact + ref: + description: Trusted repo ref for workflow scripts, or package source when source=ref + required: true + 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 + required: true + default: package + type: choice + options: + - smoke + - package + - product + - full + - custom + 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: true + default: none + type: choice + options: + - none + - mock-openai + - live-frontier + +permissions: + actions: read + contents: read + packages: write + +concurrency: + group: package-acceptance-${{ github.run_id }} + cancel-in-progress: false + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + NODE_VERSION: "24.x" + PNPM_VERSION: "10.33.0" + PACKAGE_ARTIFACT_NAME: package-under-test + +jobs: + resolve_package: + name: Resolve package candidate + runs-on: ubuntu-24.04 + timeout-minutes: 60 + outputs: + docker_lanes: ${{ steps.profile.outputs.docker_lanes }} + include_live_suites: ${{ steps.profile.outputs.include_live_suites }} + include_openwebui: ${{ steps.profile.outputs.include_openwebui }} + include_release_path_suites: ${{ steps.profile.outputs.include_release_path_suites }} + package_artifact_name: ${{ steps.profile.outputs.package_artifact_name }} + package_sha256: ${{ steps.resolve.outputs.sha256 }} + package_version: ${{ steps.resolve.outputs.package_version }} + telegram_enabled: ${{ steps.profile.outputs.telegram_enabled }} + telegram_mode: ${{ steps.profile.outputs.telegram_mode }} + steps: + - name: Checkout package workflow ref + uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref }} + fetch-depth: 1 + + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + node-version: ${{ env.NODE_VERSION }} + pnpm-version: ${{ env.PNPM_VERSION }} + install-bun: ${{ inputs.source == 'ref' && 'true' || 'false' }} + install-deps: ${{ inputs.source == 'ref' && 'true' || 'false' }} + + - name: Download package artifact input + if: inputs.source == 'artifact' + env: + GH_TOKEN: ${{ github.token }} + ARTIFACT_RUN_ID: ${{ inputs.artifact_run_id }} + ARTIFACT_NAME: ${{ inputs.artifact_name }} + shell: bash + run: | + set -euo pipefail + if [[ -z "${ARTIFACT_RUN_ID// }" ]]; then + echo "artifact_run_id is required when source=artifact." >&2 + exit 1 + fi + if [[ -z "${ARTIFACT_NAME// }" ]]; then + echo "artifact_name is required when source=artifact." >&2 + exit 1 + fi + mkdir -p .artifacts/package-candidate-input + gh run download "$ARTIFACT_RUN_ID" -n "$ARTIFACT_NAME" -D .artifacts/package-candidate-input + + - name: Resolve package candidate + id: resolve + env: + SOURCE: ${{ inputs.source }} + PACKAGE_SPEC: ${{ inputs.package_spec }} + PACKAGE_URL: ${{ inputs.package_url }} + PACKAGE_SHA256: ${{ inputs.package_sha256 }} + shell: bash + run: | + set -euo pipefail + artifact_dir="" + if [[ "$SOURCE" == "artifact" ]]; then + artifact_dir=".artifacts/package-candidate-input" + fi + + node scripts/resolve-openclaw-package-candidate.mjs \ + --source "$SOURCE" \ + --package-spec "$PACKAGE_SPEC" \ + --package-url "$PACKAGE_URL" \ + --package-sha256 "$PACKAGE_SHA256" \ + --artifact-dir "${artifact_dir:-.}" \ + --output-dir .artifacts/docker-e2e-package \ + --output-name openclaw-current.tgz \ + --metadata .artifacts/docker-e2e-package/package-candidate.json \ + --github-output "$GITHUB_OUTPUT" + + - name: Select acceptance profile + id: profile + env: + SOURCE: ${{ inputs.source }} + SUITE_PROFILE: ${{ inputs.suite_profile }} + CUSTOM_DOCKER_LANES: ${{ inputs.docker_lanes }} + TELEGRAM_MODE: ${{ inputs.telegram_mode }} + shell: bash + run: | + set -euo pipefail + + include_release_path_suites=false + include_openwebui=false + include_live_suites=false + docker_lanes="" + + case "$SUITE_PROFILE" in + smoke) + docker_lanes="npm-onboard-channel-agent gateway-network config-reload" + ;; + package) + docker_lanes="install-e2e npm-onboard-channel-agent doctor-switch update-channel-switch bundled-channel-deps plugins plugin-update" + ;; + product) + docker_lanes="install-e2e npm-onboard-channel-agent doctor-switch update-channel-switch bundled-channel-deps plugins plugin-update mcp-channels cron-mcp-cleanup openai-web-search-minimal openwebui" + include_openwebui=true + ;; + full) + include_release_path_suites=true + include_openwebui=true + ;; + custom) + docker_lanes="$CUSTOM_DOCKER_LANES" + if [[ -z "${docker_lanes// }" ]]; then + echo "docker_lanes is required when suite_profile=custom." >&2 + exit 1 + fi + if [[ "$docker_lanes" == *"openwebui"* ]]; then + include_openwebui=true + fi + ;; + *) + echo "Unknown suite_profile: $SUITE_PROFILE" >&2 + exit 1 + ;; + esac + + telegram_enabled=false + if [[ "$TELEGRAM_MODE" != "none" ]]; then + if [[ "$SOURCE" != "npm" ]]; then + echo "telegram_mode requires source=npm because the Telegram workflow installs a published package spec." >&2 + exit 1 + fi + telegram_enabled=true + fi + + { + echo "docker_lanes=$docker_lanes" + echo "include_release_path_suites=$include_release_path_suites" + echo "include_openwebui=$include_openwebui" + echo "include_live_suites=$include_live_suites" + echo "telegram_enabled=$telegram_enabled" + echo "telegram_mode=$TELEGRAM_MODE" + echo "package_artifact_name=${PACKAGE_ARTIFACT_NAME}" + } >> "$GITHUB_OUTPUT" + + - name: Upload package-under-test artifact + uses: actions/upload-artifact@v7 + with: + name: ${{ env.PACKAGE_ARTIFACT_NAME }} + path: | + .artifacts/docker-e2e-package/openclaw-current.tgz + .artifacts/docker-e2e-package/package-candidate.json + retention-days: 14 + if-no-files-found: error + + - name: Summarize package candidate + env: + PACKAGE_SHA256: ${{ steps.resolve.outputs.sha256 }} + PACKAGE_VERSION: ${{ steps.resolve.outputs.package_version }} + SOURCE: ${{ inputs.source }} + SUITE_PROFILE: ${{ inputs.suite_profile }} + shell: bash + run: | + { + echo "## Package acceptance" + echo + echo "- Source: \`${SOURCE}\`" + echo "- Version: \`${PACKAGE_VERSION}\`" + echo "- SHA-256: \`${PACKAGE_SHA256}\`" + echo "- Profile: \`${SUITE_PROFILE}\`" + } >> "$GITHUB_STEP_SUMMARY" + + docker_acceptance: + name: Docker product acceptance + needs: resolve_package + uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml + with: + ref: ${{ inputs.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' }} + docker_lanes: ${{ needs.resolve_package.outputs.docker_lanes }} + package_artifact_name: ${{ needs.resolve_package.outputs.package_artifact_name }} + include_live_suites: ${{ needs.resolve_package.outputs.include_live_suites == 'true' }} + live_models_only: false + secrets: inherit + + npm_telegram: + name: Published npm Telegram acceptance + needs: resolve_package + if: needs.resolve_package.outputs.telegram_enabled == 'true' + uses: ./.github/workflows/npm-telegram-beta-e2e.yml + with: + package_spec: ${{ inputs.package_spec }} + provider_mode: ${{ needs.resolve_package.outputs.telegram_mode }} + secrets: inherit + + summary: + name: Verify package acceptance + needs: [resolve_package, docker_acceptance, npm_telegram] + if: always() + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Verify package acceptance results + env: + DOCKER_RESULT: ${{ needs.docker_acceptance.result }} + NPM_TELEGRAM_RESULT: ${{ needs.npm_telegram.result }} + RESOLVE_RESULT: ${{ needs.resolve_package.result }} + shell: bash + run: | + set -euo pipefail + failed=0 + for item in \ + "resolve_package=${RESOLVE_RESULT}" \ + "docker_acceptance=${DOCKER_RESULT}" \ + "npm_telegram=${NPM_TELEGRAM_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/docs/ci.md b/docs/ci.md index 74497fb47dc..44f645bd65a 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -15,6 +15,15 @@ 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. +`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. + 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 builds the private QA runtime and compares the mock GPT-5.5 and Opus 4.6 @@ -116,7 +125,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 packs OpenClaw through `scripts/package-openclaw-for-docker.mjs`, validates the tarball inventory, builds and pushes one SHA-tagged bare GHCR Docker E2E image when the plan needs install/update/plugin-dependency lanes, and builds one SHA-tagged functional GHCR Docker E2E image when the plan needs package-installed functionality lanes; if either SHA-tagged image already exists, the workflow skips rebuilding that image but still creates the fresh tarball artifact required by targeted reruns. 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 a fresh npm tarball for the selected ref; 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 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 f8eff284744..9e7b9084d05 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -151,6 +151,42 @@ runs the same lanes before release approval. - GitHub Actions exposes this lane as the manual maintainer workflow `NPM Telegram Beta E2E`. It does not run on merge. The workflow uses the `qa-live-shared` environment and Convex CI credential leases. +- GitHub Actions also exposes `Package Acceptance` for side-run product proof + against one candidate package. It accepts a trusted ref, published npm spec, + HTTPS tarball URL plus SHA-256, or tarball artifact from another run, uploads + the normalized `openclaw-current.tgz` as `package-under-test`, then runs the + existing Docker E2E scheduler with smoke, package, product, full, or custom + lane profiles. Published npm candidates can additionally run the Telegram QA + workflow. + - Latest beta product proof: + +```bash +gh workflow run package-acceptance.yml --ref main \ + -f source=npm \ + -f package_spec=openclaw@beta \ + -f suite_profile=product +``` + +- Exact tarball URL proof requires a digest: + +```bash +gh workflow run package-acceptance.yml --ref main \ + -f source=url \ + -f package_url=https://registry.npmjs.org/openclaw/-/openclaw-VERSION.tgz \ + -f package_sha256= \ + -f suite_profile=package +``` + +- Artifact proof downloads a tarball artifact from another Actions run: + +```bash +gh workflow run package-acceptance.yml --ref main \ + -f source=artifact \ + -f artifact_run_id= \ + -f artifact_name= \ + -f suite_profile=smoke +``` + - `pnpm test:docker:bundled-channel-deps` - Packs and installs the current OpenClaw build in Docker, starts the Gateway with OpenAI configured, then enables bundled channel/plugins via config diff --git a/docs/reference/RELEASING.md b/docs/reference/RELEASING.md index 0a31abfc90d..8433f5db357 100644 --- a/docs/reference/RELEASING.md +++ b/docs/reference/RELEASING.md @@ -57,6 +57,22 @@ OpenClaw has three public release 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 + `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` + Common profiles: + - `smoke`: install/channel/agent, gateway network, and config reload lanes + - `package`: package/update/plugin lanes without OpenWebUI + - `product`: package profile plus MCP channels, cron/subagent cleanup, + OpenAI web search, and OpenWebUI + - `full`: Docker release-path chunks with OpenWebUI + - `custom`: exact `docker_lanes` selection for a focused rerun - Run the manual `CI` workflow directly when you only need full normal CI coverage for the release candidate. Manual CI dispatches bypass changed scoping and force the Linux Node shards, bundled-plugin shards, channel diff --git a/scripts/resolve-openclaw-package-candidate.mjs b/scripts/resolve-openclaw-package-candidate.mjs new file mode 100644 index 00000000000..e290c054886 --- /dev/null +++ b/scripts/resolve-openclaw-package-candidate.mjs @@ -0,0 +1,330 @@ +#!/usr/bin/env node +// Normalizes package-acceptance inputs into the tarball shape consumed by Docker E2E. +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { createWriteStream } from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { pipeline } from "node:stream/promises"; +import { fileURLToPath } from "node:url"; + +const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const DEFAULT_OUTPUT_NAME = "openclaw-current.tgz"; +export const OPENCLAW_PACKAGE_SPEC_RE = + /^openclaw@(beta|latest|[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-[1-9][0-9]*|-beta\.[1-9][0-9]*)?)$/u; + +function usage() { + return `Usage: node scripts/resolve-openclaw-package-candidate.mjs --source --output-dir [options] + +Options: + --package-spec Published npm spec for source=npm. + --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. + --output-name Output tarball filename. Default: ${DEFAULT_OUTPUT_NAME} + --metadata Write package metadata JSON. + --github-output Append tarball, sha256, package name/version outputs.`; +} + +export function parseArgs(argv) { + const options = { + artifactDir: "", + githubOutput: "", + metadata: "", + outputDir: "", + outputName: DEFAULT_OUTPUT_NAME, + packageSha256: "", + packageSpec: "", + packageUrl: "", + source: "", + }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + const readValue = (name) => { + const value = argv[(index += 1)]; + if (value === undefined) { + throw new Error(`${name} requires a value`); + } + return value; + }; + if (arg === "--artifact-dir") { + options.artifactDir = readValue(arg); + } else if (arg === "--github-output") { + options.githubOutput = readValue(arg); + } else if (arg === "--metadata") { + options.metadata = readValue(arg); + } else if (arg === "--output-dir") { + options.outputDir = readValue(arg); + } else if (arg === "--output-name") { + options.outputName = readValue(arg); + } else if (arg === "--package-sha256") { + options.packageSha256 = readValue(arg).toLowerCase(); + } else if (arg === "--package-spec") { + options.packageSpec = readValue(arg); + } else if (arg === "--package-url") { + options.packageUrl = readValue(arg); + } else if (arg === "--source") { + options.source = readValue(arg); + } else if (arg === "--help" || arg === "-h") { + options.help = true; + } else { + throw new Error(`unknown argument: ${arg}`); + } + } + return options; +} + +export function validateOpenClawPackageSpec(spec) { + if (!OPENCLAW_PACKAGE_SPEC_RE.test(spec)) { + throw new Error( + `package_spec must be openclaw@beta, openclaw@latest, or an exact OpenClaw release version; got: ${spec}`, + ); + } +} + +function run(command, args, options = {}) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: options.cwd ?? ROOT_DIR, + stdio: options.capture ? ["ignore", "pipe", "pipe"] : ["ignore", "inherit", "inherit"], + }); + let stdout = ""; + let stderr = ""; + if (options.capture) { + child.stdout.on("data", (chunk) => { + stdout += String(chunk); + }); + child.stderr.on("data", (chunk) => { + stderr += String(chunk); + }); + } + child.on("error", reject); + child.on("close", (status, signal) => { + if (status === 0) { + resolve(stdout); + return; + } + const detail = stderr.trim() ? `\n${stderr.trim()}` : ""; + reject(new Error(`${command} ${args.join(" ")} failed with ${status ?? signal}${detail}`)); + }); + }); +} + +async function walkFiles(dir) { + const entries = await fs.readdir(dir, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + const absolute = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...(await walkFiles(absolute))); + } else if (entry.isFile()) { + files.push(absolute); + } + } + return files; +} + +async function sha256(file) { + const hash = createHash("sha256"); + const handle = await fs.open(file, "r"); + try { + for await (const chunk of handle.createReadStream()) { + hash.update(chunk); + } + } finally { + await handle.close(); + } + return hash.digest("hex"); +} + +function assertSha256(value) { + if (!/^[a-f0-9]{64}$/u.test(value)) { + throw new Error(`package_sha256 must be a lowercase or uppercase 64-character SHA-256 digest`); + } +} + +async function assertExpectedSha256(file, expected) { + if (!expected) { + return await sha256(file); + } + assertSha256(expected); + const actual = await sha256(file); + if (actual !== expected.toLowerCase()) { + throw new Error(`package SHA-256 mismatch: expected ${expected}, got ${actual}`); + } + return actual; +} + +async function findSingleTarball(dir) { + const files = (await walkFiles(path.resolve(ROOT_DIR, dir))) + .filter((file) => /\.t(?:ar\.)?gz$/u.test(path.basename(file))) + .toSorted((a, b) => a.localeCompare(b)); + if (files.length !== 1) { + throw new Error( + `source=artifact requires exactly one .tgz under ${dir}; found ${files.length}: ${files.join(", ")}`, + ); + } + return files[0]; +} + +async function moveNewestPackedTarball(outputDir, packOutput, outputName) { + let filename = ""; + try { + const parsed = JSON.parse(packOutput); + if (Array.isArray(parsed)) { + filename = parsed.find((entry) => typeof entry?.filename === "string")?.filename ?? ""; + } + } catch {} + if (!filename) { + for (const line of packOutput.split(/\r?\n/u)) { + const trimmed = line.trim(); + if (/^openclaw-.*\.tgz$/u.test(trimmed)) { + filename = trimmed; + } + } + } + if (!filename) { + const entries = await fs.readdir(outputDir); + filename = entries + .filter((entry) => /^openclaw-.*\.tgz$/u.test(entry)) + .toSorted((a, b) => a.localeCompare(b)) + .at(-1); + } + if (!filename) { + throw new Error(`npm pack produced no OpenClaw tarball in ${outputDir}`); + } + const packed = path.join(outputDir, filename); + const target = path.join(outputDir, outputName); + if (packed !== target) { + await fs.rm(target, { force: true }); + await fs.rename(packed, target); + } + return target; +} + +async function downloadUrl(url, target) { + const parsed = new URL(url); + if (parsed.protocol !== "https:") { + throw new Error(`package_url must use https: ${url}`); + } + const response = await fetch(parsed); + if (!response.ok || !response.body) { + throw new Error(`failed to download package_url: HTTP ${response.status}`); + } + await pipeline(response.body, createWriteStream(target)); +} + +async function readPackageJson(tarball) { + const raw = await run("tar", ["-xOf", tarball, "package/package.json"], { capture: true }); + const pkg = JSON.parse(raw); + return { + name: typeof pkg.name === "string" ? pkg.name : "", + version: typeof pkg.version === "string" ? pkg.version : "", + }; +} + +async function appendGithubOutputs(file, outputs) { + if (!file) { + return; + } + const body = Object.entries(outputs) + .map(([key, value]) => `${key}=${String(value).replace(/\n/gu, " ")}`) + .join("\n"); + await fs.appendFile(file, `${body}\n`); +} + +async function resolveCandidate(options) { + const outputDir = path.resolve(ROOT_DIR, options.outputDir); + const target = path.join(outputDir, options.outputName || DEFAULT_OUTPUT_NAME); + await fs.mkdir(outputDir, { recursive: true }); + await fs.rm(target, { force: true }); + + 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"); + } + 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}`); + } + + const digest = await assertExpectedSha256(target, options.packageSha256); + await run("node", ["scripts/check-openclaw-package-tarball.mjs", target]); + const pkg = await readPackageJson(target); + const metadata = { + name: pkg.name, + packageSpec: options.packageSpec || "", + sha256: digest, + source: options.source, + tarball: path.relative(ROOT_DIR, target), + version: pkg.version, + }; + + if (pkg.name !== "openclaw") { + throw new Error(`package candidate must be named "openclaw"; got: ${pkg.name || ""}`); + } + if (!pkg.version) { + throw new Error("package candidate package.json has no version"); + } + + if (options.metadata) { + await fs.mkdir(path.dirname(path.resolve(ROOT_DIR, options.metadata)), { recursive: true }); + await fs.writeFile( + path.resolve(ROOT_DIR, options.metadata), + `${JSON.stringify(metadata, null, 2)}\n`, + ); + } + await appendGithubOutputs(options.githubOutput, { + package_name: pkg.name, + package_version: pkg.version, + sha256: digest, + tarball: metadata.tarball, + }); + return metadata; +} + +export async function main(argv = process.argv.slice(2)) { + const options = parseArgs(argv); + if (options.help) { + console.log(usage()); + return; + } + if (!options.outputDir) { + throw new Error("--output-dir is required"); + } + const metadata = await resolveCandidate(options); + console.log(JSON.stringify(metadata, null, 2)); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + await main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + console.error(usage()); + process.exit(1); + }); +} diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts new file mode 100644 index 00000000000..5f7cd12731b --- /dev/null +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -0,0 +1,65 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +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"; + +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("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('gh run download "$ARTIFACT_RUN_ID"'); + expect(workflow).toContain("name: ${{ env.PACKAGE_ARTIFACT_NAME }}"); + expect(workflow).toContain( + "uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml", + ); + expect(workflow).toContain( + "package_artifact_name: ${{ needs.resolve_package.outputs.package_artifact_name }}", + ); + }); + + it("offers bounded product profiles and keeps Telegram published-npm only", () => { + const workflow = readFileSync(PACKAGE_ACCEPTANCE_WORKFLOW, "utf8"); + + expect(workflow).toContain("suite_profile:"); + expect(workflow).toContain("npm-onboard-channel-agent gateway-network config-reload"); + expect(workflow).toContain("install-e2e npm-onboard-channel-agent doctor-switch"); + expect(workflow).toContain("include_release_path_suites=true"); + expect(workflow).toContain("telegram_mode requires source=npm"); + expect(workflow).toContain("uses: ./.github/workflows/npm-telegram-beta-e2e.yml"); + }); +}); + +describe("package artifact reuse", () => { + it("lets reusable Docker E2E consume an already resolved package artifact", () => { + const workflow = readFileSync(LIVE_E2E_WORKFLOW, "utf8"); + 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("inputs.package_artifact_name != ''"); + expect(workflow).toContain('image_tag="${PACKAGE_TAG:-$SELECTED_SHA}"'); + expect(workflow).toContain( + "package-artifact-name: ${{ inputs.package_artifact_name || 'docker-e2e-package' }}", + ); + expect(action).toContain("package-artifact-name:"); + expect(action).toContain("name: ${{ inputs.package-artifact-name }}"); + }); + + it("allows the npm Telegram lane to run from reusable package acceptance", () => { + const workflow = readFileSync(NPM_TELEGRAM_WORKFLOW, "utf8"); + + expect(workflow).toContain("workflow_call:"); + expect(workflow).toContain("provider_mode:"); + expect(workflow).toContain("provider_mode must be mock-openai or live-frontier"); + }); +}); diff --git a/test/scripts/resolve-openclaw-package-candidate.test.ts b/test/scripts/resolve-openclaw-package-candidate.test.ts new file mode 100644 index 00000000000..a3eb4f9422c --- /dev/null +++ b/test/scripts/resolve-openclaw-package-candidate.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { + parseArgs, + validateOpenClawPackageSpec, +} from "../../scripts/resolve-openclaw-package-candidate.mjs"; + +describe("resolve-openclaw-package-candidate", () => { + it("accepts only OpenClaw release package specs for npm candidates", () => { + expect(() => validateOpenClawPackageSpec("openclaw@beta")).not.toThrow(); + expect(() => validateOpenClawPackageSpec("openclaw@latest")).not.toThrow(); + expect(() => validateOpenClawPackageSpec("openclaw@2026.4.27")).not.toThrow(); + expect(() => validateOpenClawPackageSpec("openclaw@2026.4.27-1")).not.toThrow(); + expect(() => validateOpenClawPackageSpec("openclaw@2026.4.27-beta.2")).not.toThrow(); + + expect(() => validateOpenClawPackageSpec("@evil/openclaw@1.0.0")).toThrow( + "package_spec must be openclaw@beta", + ); + expect(() => validateOpenClawPackageSpec("openclaw@canary")).toThrow( + "package_spec must be openclaw@beta", + ); + expect(() => validateOpenClawPackageSpec("openclaw@2026.04.27")).toThrow( + "package_spec must be openclaw@beta", + ); + }); + + it("parses optional empty workflow inputs without rejecting the command line", () => { + expect( + parseArgs([ + "--source", + "npm", + "--package-spec", + "openclaw@beta", + "--package-url", + "", + "--package-sha256", + "", + "--artifact-dir", + ".", + "--output-dir", + ".artifacts/docker-e2e-package", + ]), + ).toMatchObject({ + artifactDir: ".", + outputDir: ".artifacts/docker-e2e-package", + packageSha256: "", + packageSpec: "openclaw@beta", + packageUrl: "", + source: "npm", + }); + }); +});