ci: add package acceptance workflow

This commit is contained in:
Peter Steinberger
2026-04-27 04:25:25 +01:00
parent 2a08848dd1
commit 76de167ca1
11 changed files with 955 additions and 22 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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

309
.github/workflows/package-acceptance.yml vendored Normal file
View File

@@ -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"

File diff suppressed because one or more lines are too long

View File

@@ -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=<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=<run-id> \
-f artifact_name=<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

View File

@@ -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

View File

@@ -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 <ref|npm|url|artifact> --output-dir <dir> [options]
Options:
--package-spec <spec> Published npm spec for source=npm.
--package-url <url> HTTPS tarball URL for source=url.
--package-sha256 <sha256> Expected tarball SHA-256 for source=url or source=artifact.
--artifact-dir <dir> Directory containing exactly one .tgz for source=artifact.
--output-name <name> Output tarball filename. Default: ${DEFAULT_OUTPUT_NAME}
--metadata <file> Write package metadata JSON.
--github-output <file> 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 || "<missing>"}`);
}
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);
});
}

View File

@@ -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");
});
});

View File

@@ -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",
});
});
});