fix(release): provide prerelease plugin companions to package QA (#120107)

This commit is contained in:
Vincent Koc
2026-08-07 13:31:41 +08:00
committed by GitHub
parent aea8b4f565
commit a0deb8edae
26 changed files with 1669 additions and 71 deletions
@@ -418,6 +418,8 @@ jobs:
include_release_path_suites: false
include_openwebui: false
include_live_suites: false
enable_prepublish_plugin_registry: true
published_upgrade_survivor_scenarios: ${{ (inputs.run_release_soak || inputs.release_profile == 'stable' || inputs.release_profile == 'full') && 'reported-issues' || '' }}
allow_unreleased_changelog: ${{ inputs.allow_unreleased_changelog || (inputs.target_context_ref == '' && (inputs.ref == 'main' || inputs.ref == 'refs/heads/main')) }}
release_test_profile: ${{ inputs.release_profile }}
shared_image_artifact_namespace: full-release
@@ -161,6 +161,24 @@ on:
package_version:
description: Package version
value: ${{ jobs.prepare_docker_e2e_image.outputs.package_version }}
prepublish_plugin_registry_artifact_name:
description: Immutable prerelease plugin registry artifact name
value: ${{ jobs.prepare_docker_e2e_image.outputs.prepublish_plugin_registry_artifact_name }}
prepublish_plugin_registry_artifact_id:
description: Immutable prerelease plugin registry artifact id
value: ${{ jobs.prepare_docker_e2e_image.outputs.prepublish_plugin_registry_artifact_id }}
prepublish_plugin_registry_artifact_digest:
description: Prerelease plugin registry artifact service digest
value: ${{ jobs.prepare_docker_e2e_image.outputs.prepublish_plugin_registry_artifact_digest }}
prepublish_plugin_registry_artifact_run_id:
description: Prerelease plugin registry artifact producer run id
value: ${{ jobs.prepare_docker_e2e_image.outputs.prepublish_plugin_registry_artifact_run_id }}
prepublish_plugin_registry_artifact_run_attempt:
description: Prerelease plugin registry artifact producer run attempt
value: ${{ jobs.prepare_docker_e2e_image.outputs.prepublish_plugin_registry_artifact_run_attempt }}
prepublish_plugin_registry_manifest_sha256:
description: Prerelease plugin registry manifest SHA-256
value: ${{ jobs.prepare_docker_e2e_image.outputs.prepublish_plugin_registry_manifest_sha256 }}
shared_image_artifact_name:
description: Immutable Docker image artifact name
value: ${{ jobs.prepare_docker_e2e_image.outputs.image_artifact_name }}
@@ -294,6 +312,41 @@ on:
required: false
default: ""
type: string
enable_prepublish_plugin_registry:
description: Build or reuse candidate-version official plugin packages for artifact/ref acceptance
required: false
default: false
type: boolean
prepublish_plugin_registry_artifact_name:
description: Existing immutable prerelease plugin registry artifact name
required: false
default: ""
type: string
prepublish_plugin_registry_artifact_id:
description: Existing immutable prerelease plugin registry artifact id
required: false
default: ""
type: string
prepublish_plugin_registry_artifact_digest:
description: Existing prerelease plugin registry artifact service SHA-256 digest
required: false
default: ""
type: string
prepublish_plugin_registry_artifact_run_id:
description: Producer run id for the prerelease plugin registry artifact
required: false
default: ""
type: string
prepublish_plugin_registry_artifact_run_attempt:
description: Producer run attempt for the prerelease plugin registry artifact
required: false
default: ""
type: string
prepublish_plugin_registry_manifest_sha256:
description: SHA-256 of prepublish-plugin-registry.json
required: false
default: ""
type: string
shared_image_policy:
description: "Shared Docker image transport: existing-only or no-push-artifact"
required: false
@@ -545,6 +598,13 @@ jobs:
PACKAGE_SHA256: ${{ inputs.package_sha256 }}
PACKAGE_SOURCE_SHA: ${{ inputs.package_source_sha }}
PACKAGE_VERSION: ${{ inputs.package_version }}
PREPUBLISH_PLUGIN_REGISTRY_ENABLED: ${{ inputs.enable_prepublish_plugin_registry }}
PREPUBLISH_PLUGIN_REGISTRY_ARTIFACT_DIGEST: ${{ inputs.prepublish_plugin_registry_artifact_digest }}
PREPUBLISH_PLUGIN_REGISTRY_ARTIFACT_ID: ${{ inputs.prepublish_plugin_registry_artifact_id }}
PREPUBLISH_PLUGIN_REGISTRY_ARTIFACT_NAME: ${{ inputs.prepublish_plugin_registry_artifact_name }}
PREPUBLISH_PLUGIN_REGISTRY_ARTIFACT_RUN_ATTEMPT: ${{ inputs.prepublish_plugin_registry_artifact_run_attempt }}
PREPUBLISH_PLUGIN_REGISTRY_ARTIFACT_RUN_ID: ${{ inputs.prepublish_plugin_registry_artifact_run_id }}
PREPUBLISH_PLUGIN_REGISTRY_MANIFEST_SHA256: ${{ inputs.prepublish_plugin_registry_manifest_sha256 }}
PROVIDED_BARE_IMAGE: ${{ inputs.docker_e2e_bare_image }}
PROVIDED_FUNCTIONAL_IMAGE: ${{ inputs.docker_e2e_functional_image }}
SHARED_IMAGE_ARTIFACT_NAMESPACE: ${{ inputs.shared_image_artifact_namespace }}
@@ -616,6 +676,36 @@ jobs:
}
fi
if [[ "$PREPUBLISH_PLUGIN_REGISTRY_ENABLED" == "true" ]]; then
prepublish_plugin_registry_tuple_present=0
for value in \
"$PREPUBLISH_PLUGIN_REGISTRY_ARTIFACT_DIGEST" \
"$PREPUBLISH_PLUGIN_REGISTRY_ARTIFACT_ID" \
"$PREPUBLISH_PLUGIN_REGISTRY_ARTIFACT_NAME" \
"$PREPUBLISH_PLUGIN_REGISTRY_ARTIFACT_RUN_ATTEMPT" \
"$PREPUBLISH_PLUGIN_REGISTRY_ARTIFACT_RUN_ID" \
"$PREPUBLISH_PLUGIN_REGISTRY_MANIFEST_SHA256"; do
if [[ -n "${value// }" ]]; then
prepublish_plugin_registry_tuple_present=1
fi
done
if [[ "$prepublish_plugin_registry_tuple_present" == "1" ]]; then
[[ "$PREPUBLISH_PLUGIN_REGISTRY_ARTIFACT_DIGEST" =~ ^[0-9a-f]{64}$ &&
"$PREPUBLISH_PLUGIN_REGISTRY_ARTIFACT_ID" =~ ^[1-9][0-9]*$ &&
-n "${PREPUBLISH_PLUGIN_REGISTRY_ARTIFACT_NAME// }" &&
"$PREPUBLISH_PLUGIN_REGISTRY_ARTIFACT_RUN_ATTEMPT" =~ ^[1-9][0-9]*$ &&
"$PREPUBLISH_PLUGIN_REGISTRY_ARTIFACT_RUN_ID" =~ ^[1-9][0-9]*$ &&
"$PREPUBLISH_PLUGIN_REGISTRY_MANIFEST_SHA256" =~ ^[0-9a-f]{64}$ ]] || {
echo "Prerelease plugin registry selection requires the complete immutable artifact tuple." >&2
exit 1
}
[[ "$PREPUBLISH_PLUGIN_REGISTRY_ARTIFACT_NAME" == *"-${PREPUBLISH_PLUGIN_REGISTRY_ARTIFACT_RUN_ID}-${PREPUBLISH_PLUGIN_REGISTRY_ARTIFACT_RUN_ATTEMPT}" ]] || {
echo "Prerelease plugin registry artifact name does not bind the producer run attempt." >&2
exit 1
}
fi
fi
image_tuple_present=0
for value in \
"$SHARED_IMAGE_ARTIFACT_DIGEST" \
@@ -1138,6 +1228,8 @@ jobs:
OPENCLAW_DOCKER_ALL_RELEASE_PROFILE: ${{ inputs.release_test_profile }}
OPENCLAW_CODEX_NPM_PLUGIN_SPEC: ${{ inputs.codex_plugin_spec }}
OPENCLAW_CURRENT_PACKAGE_TGZ: .artifacts/docker-e2e-package/openclaw-current.tgz
OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_CANDIDATE_VERSION: ${{ needs.prepare_docker_e2e_image.outputs.package_version }}
OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_MANIFEST_SHA256: ${{ needs.prepare_docker_e2e_image.outputs.prepublish_plugin_registry_manifest_sha256 }}
OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC: ${{ inputs.published_upgrade_survivor_baseline }}
OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS: ${{ inputs.published_upgrade_survivor_baselines }}
OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS: ${{ inputs.published_upgrade_survivor_scenarios }}
@@ -1217,6 +1309,49 @@ jobs:
run-id: ${{ needs.prepare_docker_e2e_image.outputs.package_artifact_run_id }}
github-token: ${{ github.token }}
- name: Validate prerelease plugin registry artifact binding
if: inputs.enable_prepublish_plugin_registry && steps.plan.outputs.needs_prepublish_plugin_registry == '1' && needs.prepare_docker_e2e_image.outputs.prepublish_plugin_registry_artifact_id != ''
env:
ARTIFACT_DIGEST: ${{ needs.prepare_docker_e2e_image.outputs.prepublish_plugin_registry_artifact_digest }}
ARTIFACT_ID: ${{ needs.prepare_docker_e2e_image.outputs.prepublish_plugin_registry_artifact_id }}
ARTIFACT_NAME: ${{ needs.prepare_docker_e2e_image.outputs.prepublish_plugin_registry_artifact_name }}
ARTIFACT_RUN_ATTEMPT: ${{ needs.prepare_docker_e2e_image.outputs.prepublish_plugin_registry_artifact_run_attempt }}
ARTIFACT_RUN_ID: ${{ needs.prepare_docker_e2e_image.outputs.prepublish_plugin_registry_artifact_run_id }}
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
set -euo pipefail
bash .release-harness/scripts/docker/shared-image-artifact.sh \
verify-upload "Prerelease plugin registry" \
"$ARTIFACT_ID" "$ARTIFACT_NAME" "$ARTIFACT_DIGEST" \
"$ARTIFACT_RUN_ID" "$ARTIFACT_RUN_ATTEMPT"
- name: Download prerelease plugin registry artifact
if: inputs.enable_prepublish_plugin_registry && steps.plan.outputs.needs_prepublish_plugin_registry == '1' && needs.prepare_docker_e2e_image.outputs.prepublish_plugin_registry_artifact_id != ''
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
artifact-ids: ${{ needs.prepare_docker_e2e_image.outputs.prepublish_plugin_registry_artifact_id }}
path: .artifacts/prepublish-plugin-registry
run-id: ${{ needs.prepare_docker_e2e_image.outputs.prepublish_plugin_registry_artifact_run_id }}
github-token: ${{ github.token }}
- name: Validate prerelease plugin registry contents
if: inputs.enable_prepublish_plugin_registry && steps.plan.outputs.needs_prepublish_plugin_registry == '1' && needs.prepare_docker_e2e_image.outputs.prepublish_plugin_registry_artifact_id != ''
env:
CANDIDATE_VERSION: ${{ needs.prepare_docker_e2e_image.outputs.package_version }}
EXPECTED_MANIFEST_SHA256: ${{ needs.prepare_docker_e2e_image.outputs.prepublish_plugin_registry_manifest_sha256 }}
REQUIRED_PACKAGES_JSON: ${{ steps.plan.outputs.required_prepublish_plugin_packages }}
SELECTED_SHA: ${{ needs.validate_selected_ref.outputs.selected_sha }}
shell: bash
run: |
set -euo pipefail
node .release-harness/scripts/prepublish-plugin-registry-artifact.mjs verify \
--artifact-dir .artifacts/prepublish-plugin-registry \
--source-sha "$SELECTED_SHA" \
--candidate-version "$CANDIDATE_VERSION" \
--manifest-sha256 "$EXPECTED_MANIFEST_SHA256" \
--required-packages-json "$REQUIRED_PACKAGES_JSON"
- name: Validate Docker E2E image artifact binding
if: contains(matrix.profiles, inputs.release_test_profile) && inputs.shared_image_policy == 'no-push-artifact' && needs.prepare_docker_e2e_image.outputs.needs_e2e_image == '1'
env:
@@ -1339,6 +1474,11 @@ jobs:
export OPENCLAW_DOCKER_ALL_LOG_DIR=".artifacts/docker-tests/release-${DOCKER_E2E_CHUNK}"
export OPENCLAW_DOCKER_ALL_TIMINGS_FILE=".artifacts/docker-tests/release-${DOCKER_E2E_CHUNK}-timings.json"
export OPENCLAW_DOCKER_ALL_PNPM_COMMAND="$(command -v pnpm)"
if [[ "${{ inputs.enable_prepublish_plugin_registry }}" == "true" &&
"${{ steps.plan.outputs.needs_prepublish_plugin_registry }}" == "1" &&
-n "${{ needs.prepare_docker_e2e_image.outputs.prepublish_plugin_registry_artifact_id }}" ]]; then
export OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR=".artifacts/prepublish-plugin-registry"
fi
if [[ "${{ steps.plan.outputs.needs_live_image }}" == "1" ]]; then
OPENCLAW_SKIP_DOCKER_BUILD=0 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" bash .release-harness/scripts/test-live-build-docker.sh
fi
@@ -1466,6 +1606,8 @@ jobs:
OPENCLAW_DOCKER_E2E_SELECTED_SHA: ${{ needs.validate_selected_ref.outputs.selected_sha }}
OPENCLAW_CODEX_NPM_PLUGIN_SPEC: ${{ inputs.codex_plugin_spec }}
OPENCLAW_CURRENT_PACKAGE_TGZ: .artifacts/docker-e2e-package/openclaw-current.tgz
OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_CANDIDATE_VERSION: ${{ needs.prepare_docker_e2e_image.outputs.package_version }}
OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_MANIFEST_SHA256: ${{ needs.prepare_docker_e2e_image.outputs.prepublish_plugin_registry_manifest_sha256 }}
OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC: ${{ inputs.published_upgrade_survivor_baseline }}
OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS: ${{ matrix.group.published_upgrade_survivor_baselines || inputs.published_upgrade_survivor_baselines }}
OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS: ${{ inputs.published_upgrade_survivor_scenarios }}
@@ -1544,6 +1686,49 @@ jobs:
run-id: ${{ needs.prepare_docker_e2e_image.outputs.package_artifact_run_id }}
github-token: ${{ github.token }}
- name: Validate targeted prerelease plugin registry artifact binding
if: inputs.enable_prepublish_plugin_registry && steps.plan.outputs.needs_prepublish_plugin_registry == '1' && needs.prepare_docker_e2e_image.outputs.prepublish_plugin_registry_artifact_id != ''
env:
ARTIFACT_DIGEST: ${{ needs.prepare_docker_e2e_image.outputs.prepublish_plugin_registry_artifact_digest }}
ARTIFACT_ID: ${{ needs.prepare_docker_e2e_image.outputs.prepublish_plugin_registry_artifact_id }}
ARTIFACT_NAME: ${{ needs.prepare_docker_e2e_image.outputs.prepublish_plugin_registry_artifact_name }}
ARTIFACT_RUN_ATTEMPT: ${{ needs.prepare_docker_e2e_image.outputs.prepublish_plugin_registry_artifact_run_attempt }}
ARTIFACT_RUN_ID: ${{ needs.prepare_docker_e2e_image.outputs.prepublish_plugin_registry_artifact_run_id }}
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
set -euo pipefail
bash .release-harness/scripts/docker/shared-image-artifact.sh \
verify-upload "Prerelease plugin registry" \
"$ARTIFACT_ID" "$ARTIFACT_NAME" "$ARTIFACT_DIGEST" \
"$ARTIFACT_RUN_ID" "$ARTIFACT_RUN_ATTEMPT"
- name: Download targeted prerelease plugin registry artifact
if: inputs.enable_prepublish_plugin_registry && steps.plan.outputs.needs_prepublish_plugin_registry == '1' && needs.prepare_docker_e2e_image.outputs.prepublish_plugin_registry_artifact_id != ''
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
artifact-ids: ${{ needs.prepare_docker_e2e_image.outputs.prepublish_plugin_registry_artifact_id }}
path: .artifacts/prepublish-plugin-registry
run-id: ${{ needs.prepare_docker_e2e_image.outputs.prepublish_plugin_registry_artifact_run_id }}
github-token: ${{ github.token }}
- name: Validate targeted prerelease plugin registry contents
if: inputs.enable_prepublish_plugin_registry && steps.plan.outputs.needs_prepublish_plugin_registry == '1' && needs.prepare_docker_e2e_image.outputs.prepublish_plugin_registry_artifact_id != ''
env:
CANDIDATE_VERSION: ${{ needs.prepare_docker_e2e_image.outputs.package_version }}
EXPECTED_MANIFEST_SHA256: ${{ needs.prepare_docker_e2e_image.outputs.prepublish_plugin_registry_manifest_sha256 }}
REQUIRED_PACKAGES_JSON: ${{ steps.plan.outputs.required_prepublish_plugin_packages }}
SELECTED_SHA: ${{ needs.validate_selected_ref.outputs.selected_sha }}
shell: bash
run: |
set -euo pipefail
node .release-harness/scripts/prepublish-plugin-registry-artifact.mjs verify \
--artifact-dir .artifacts/prepublish-plugin-registry \
--source-sha "$SELECTED_SHA" \
--candidate-version "$CANDIDATE_VERSION" \
--manifest-sha256 "$EXPECTED_MANIFEST_SHA256" \
--required-packages-json "$REQUIRED_PACKAGES_JSON"
- name: Validate Docker E2E image artifact binding
if: inputs.shared_image_policy == 'no-push-artifact' && needs.prepare_docker_e2e_image.outputs.needs_e2e_image == '1'
env:
@@ -1655,6 +1840,7 @@ jobs:
env:
ARTIFACT_SUFFIX: ${{ steps.plan.outputs.artifact_suffix }}
INCLUDE_RELEASE_PATH_SUITES: ${{ inputs.include_release_path_suites }}
PREPUBLISH_PLUGIN_REGISTRY_ENABLED: ${{ inputs.enable_prepublish_plugin_registry && steps.plan.outputs.needs_prepublish_plugin_registry == '1' && needs.prepare_docker_e2e_image.outputs.prepublish_plugin_registry_artifact_id != '' && '1' || '0' }}
run: |
set -euo pipefail
export OPENCLAW_DOCKER_ALL_LANES="${DOCKER_E2E_LANES}"
@@ -1667,6 +1853,9 @@ jobs:
export OPENCLAW_DOCKER_ALL_LOG_DIR=".artifacts/docker-tests/targeted-${ARTIFACT_SUFFIX}"
export OPENCLAW_DOCKER_ALL_TIMINGS_FILE=".artifacts/docker-tests/targeted-${ARTIFACT_SUFFIX}-timings.json"
export OPENCLAW_DOCKER_ALL_PNPM_COMMAND="$(command -v pnpm)"
if [[ "$PREPUBLISH_PLUGIN_REGISTRY_ENABLED" == "1" ]]; then
export OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR=".artifacts/prepublish-plugin-registry"
fi
if [[ "${{ steps.plan.outputs.needs_live_image }}" == "1" ]]; then
OPENCLAW_SKIP_DOCKER_BUILD=0 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" bash .release-harness/scripts/test-live-build-docker.sh
fi
@@ -1905,6 +2094,8 @@ jobs:
needs_functional_image: ${{ inputs.shared_image_artifact_id != '' && '1' || steps.plan.outputs.needs_functional_image }}
needs_live_image: ${{ steps.plan.outputs.needs_live_image }}
needs_package: ${{ steps.plan.outputs.needs_package }}
needs_prepublish_plugin_registry: ${{ steps.plan.outputs.needs_prepublish_plugin_registry }}
required_prepublish_plugin_packages: ${{ steps.plan.outputs.required_prepublish_plugin_packages }}
bare_exists: ${{ steps.image_exists.outputs.bare_exists }}
functional_exists: ${{ steps.image_exists.outputs.functional_exists }}
needs_registry_build: ${{ steps.image_exists.outputs.needs_build }}
@@ -1917,6 +2108,12 @@ jobs:
package_artifact_run_attempt: ${{ steps.upload_package.outputs.artifact-id && github.run_attempt || steps.input_package_artifact.outputs.run_attempt }}
package_file_name: ${{ steps.package.outputs.file_name }}
package_source_sha: ${{ steps.package.outputs.source_sha }}
prepublish_plugin_registry_artifact_name: ${{ inputs.enable_prepublish_plugin_registry && (steps.upload_prepublish_plugin_registry.outputs.artifact-id && format('docker-e2e-prepublish-plugin-registry-{0}-{1}', github.run_id, github.run_attempt) || inputs.prepublish_plugin_registry_artifact_name) || '' }}
prepublish_plugin_registry_artifact_id: ${{ inputs.enable_prepublish_plugin_registry && (steps.upload_prepublish_plugin_registry.outputs.artifact-id || inputs.prepublish_plugin_registry_artifact_id) || '' }}
prepublish_plugin_registry_artifact_digest: ${{ inputs.enable_prepublish_plugin_registry && (steps.upload_prepublish_plugin_registry.outputs.artifact-digest || inputs.prepublish_plugin_registry_artifact_digest) || '' }}
prepublish_plugin_registry_artifact_run_id: ${{ inputs.enable_prepublish_plugin_registry && (steps.upload_prepublish_plugin_registry.outputs.artifact-id && github.run_id || inputs.prepublish_plugin_registry_artifact_run_id) || '' }}
prepublish_plugin_registry_artifact_run_attempt: ${{ inputs.enable_prepublish_plugin_registry && (steps.upload_prepublish_plugin_registry.outputs.artifact-id && github.run_attempt || inputs.prepublish_plugin_registry_artifact_run_attempt) || '' }}
prepublish_plugin_registry_manifest_sha256: ${{ inputs.enable_prepublish_plugin_registry && steps.prepublish_plugin_registry.outputs.manifest_sha256 || '' }}
image_artifact_name: ${{ steps.image_artifact.outputs.artifact_name || inputs.shared_image_artifact_name }}
image_archive_sha256: ${{ steps.image_artifact.outputs.archive_sha256 || inputs.shared_image_archive_sha256 }}
image_artifact_id: ${{ steps.upload_image_artifact.outputs.artifact-id || inputs.shared_image_artifact_id }}
@@ -1984,7 +2181,7 @@ jobs:
echo "plan_json=$plan_path" >> "$GITHUB_OUTPUT"
- name: Setup Node environment
if: steps.plan.outputs.needs_package == '1' && inputs.package_artifact_name == '' && inputs.package_artifact_run_id == ''
if: (steps.plan.outputs.needs_package == '1' && inputs.package_artifact_name == '' && inputs.package_artifact_run_id == '') || (inputs.enable_prepublish_plugin_registry && steps.plan.outputs.needs_prepublish_plugin_registry == '1' && inputs.prepublish_plugin_registry_artifact_id == '')
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
@@ -2218,6 +2415,84 @@ jobs:
path: .artifacts/docker-e2e-package/openclaw-current.tgz
if-no-files-found: error
- name: Validate prerelease plugin registry artifact identity
if: inputs.enable_prepublish_plugin_registry && steps.plan.outputs.needs_prepublish_plugin_registry == '1' && inputs.prepublish_plugin_registry_artifact_id != ''
env:
ARTIFACT_DIGEST: ${{ inputs.prepublish_plugin_registry_artifact_digest }}
ARTIFACT_ID: ${{ inputs.prepublish_plugin_registry_artifact_id }}
ARTIFACT_NAME: ${{ inputs.prepublish_plugin_registry_artifact_name }}
ARTIFACT_RUN_ATTEMPT: ${{ inputs.prepublish_plugin_registry_artifact_run_attempt }}
ARTIFACT_RUN_ID: ${{ inputs.prepublish_plugin_registry_artifact_run_id }}
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
set -euo pipefail
bash .release-harness/scripts/docker/shared-image-artifact.sh \
verify-upload "Prerelease plugin registry" \
"$ARTIFACT_ID" "$ARTIFACT_NAME" "$ARTIFACT_DIGEST" \
"$ARTIFACT_RUN_ID" "$ARTIFACT_RUN_ATTEMPT"
- name: Download prerelease plugin registry artifact
if: inputs.enable_prepublish_plugin_registry && steps.plan.outputs.needs_prepublish_plugin_registry == '1' && inputs.prepublish_plugin_registry_artifact_id != ''
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
artifact-ids: ${{ inputs.prepublish_plugin_registry_artifact_id }}
path: .artifacts/prepublish-plugin-registry
run-id: ${{ inputs.prepublish_plugin_registry_artifact_run_id }}
github-token: ${{ github.token }}
- name: Pack prerelease plugin registry artifact
id: create_prepublish_plugin_registry
if: inputs.enable_prepublish_plugin_registry && steps.plan.outputs.needs_prepublish_plugin_registry == '1' && inputs.prepublish_plugin_registry_artifact_id == ''
env:
CANDIDATE_VERSION: ${{ steps.package.outputs.version }}
REQUIRED_PACKAGES_JSON: ${{ steps.plan.outputs.required_prepublish_plugin_packages }}
SELECTED_SHA: ${{ needs.validate_selected_ref.outputs.selected_sha }}
shell: bash
run: |
set -euo pipefail
result="$(
node .release-harness/scripts/prepublish-plugin-registry-artifact.mjs create \
--repo-root . \
--artifact-dir .artifacts/prepublish-plugin-registry \
--source-sha "$SELECTED_SHA" \
--candidate-version "$CANDIDATE_VERSION" \
--required-packages-json "$REQUIRED_PACKAGES_JSON"
)"
manifest_sha256="$(jq -er '.manifestSha256' <<< "$result")"
echo "manifest_sha256=$manifest_sha256" >> "$GITHUB_OUTPUT"
- name: Validate prerelease plugin registry artifact
id: prepublish_plugin_registry
if: inputs.enable_prepublish_plugin_registry && steps.plan.outputs.needs_prepublish_plugin_registry == '1'
env:
CANDIDATE_VERSION: ${{ steps.package.outputs.version }}
EXPECTED_MANIFEST_SHA256: ${{ steps.create_prepublish_plugin_registry.outputs.manifest_sha256 || inputs.prepublish_plugin_registry_manifest_sha256 }}
REQUIRED_PACKAGES_JSON: ${{ steps.plan.outputs.required_prepublish_plugin_packages }}
SELECTED_SHA: ${{ needs.validate_selected_ref.outputs.selected_sha }}
shell: bash
run: |
set -euo pipefail
result="$(
node .release-harness/scripts/prepublish-plugin-registry-artifact.mjs verify \
--artifact-dir .artifacts/prepublish-plugin-registry \
--source-sha "$SELECTED_SHA" \
--candidate-version "$CANDIDATE_VERSION" \
--manifest-sha256 "$EXPECTED_MANIFEST_SHA256" \
--required-packages-json "$REQUIRED_PACKAGES_JSON"
)"
manifest_sha256="$(jq -er '.manifestSha256' <<< "$result")"
echo "manifest_sha256=$manifest_sha256" >> "$GITHUB_OUTPUT"
- name: Upload prerelease plugin registry artifact
id: upload_prepublish_plugin_registry
if: inputs.enable_prepublish_plugin_registry && steps.plan.outputs.needs_prepublish_plugin_registry == '1' && inputs.prepublish_plugin_registry_artifact_id == ''
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: docker-e2e-prepublish-plugin-registry-${{ github.run_id }}-${{ github.run_attempt }}
path: .artifacts/prepublish-plugin-registry/
if-no-files-found: error
- name: Resolve shared Docker E2E image tags
id: image
shell: bash
@@ -2418,6 +2693,13 @@ jobs:
PACKAGE_SHA256: ${{ steps.package.outputs.sha256 }}
PACKAGE_SOURCE_SHA: ${{ steps.package.outputs.source_sha }}
PACKAGE_VERSION: ${{ steps.package.outputs.version }}
PREPUBLISH_PLUGIN_REGISTRY_ARTIFACT_DIGEST: ${{ steps.upload_prepublish_plugin_registry.outputs.artifact-digest || inputs.prepublish_plugin_registry_artifact_digest }}
PREPUBLISH_PLUGIN_REGISTRY_ARTIFACT_ID: ${{ steps.upload_prepublish_plugin_registry.outputs.artifact-id || inputs.prepublish_plugin_registry_artifact_id }}
PREPUBLISH_PLUGIN_REGISTRY_ARTIFACT_NAME: ${{ steps.upload_prepublish_plugin_registry.outputs.artifact-id && format('docker-e2e-prepublish-plugin-registry-{0}-{1}', github.run_id, github.run_attempt) || inputs.prepublish_plugin_registry_artifact_name }}
PREPUBLISH_PLUGIN_REGISTRY_ARTIFACT_RUN_ATTEMPT: ${{ steps.upload_prepublish_plugin_registry.outputs.artifact-id && github.run_attempt || inputs.prepublish_plugin_registry_artifact_run_attempt }}
PREPUBLISH_PLUGIN_REGISTRY_ARTIFACT_RUN_ID: ${{ steps.upload_prepublish_plugin_registry.outputs.artifact-id && github.run_id || inputs.prepublish_plugin_registry_artifact_run_id }}
PREPUBLISH_PLUGIN_REGISTRY_MANIFEST_SHA256: ${{ steps.prepublish_plugin_registry.outputs.manifest_sha256 }}
NEEDS_PREPUBLISH_PLUGIN_REGISTRY: ${{ inputs.enable_prepublish_plugin_registry && steps.plan.outputs.needs_prepublish_plugin_registry == '1' && '1' || '0' }}
shell: bash
run: |
set -euo pipefail
@@ -2439,6 +2721,18 @@ jobs:
--arg imageArchiveSha256 "$IMAGE_ARCHIVE_SHA256" \
'$ARGS.named')"
jq -e 'all(.[]; type == "string" and length > 0)' <<< "$json" >/dev/null
if [[ "$NEEDS_PREPUBLISH_PLUGIN_REGISTRY" == "1" ]]; then
registry_json="$(jq -cn \
--arg prepublishPluginRegistryArtifactName "$PREPUBLISH_PLUGIN_REGISTRY_ARTIFACT_NAME" \
--arg prepublishPluginRegistryArtifactId "$PREPUBLISH_PLUGIN_REGISTRY_ARTIFACT_ID" \
--arg prepublishPluginRegistryArtifactDigest "$PREPUBLISH_PLUGIN_REGISTRY_ARTIFACT_DIGEST" \
--arg prepublishPluginRegistryArtifactRunId "$PREPUBLISH_PLUGIN_REGISTRY_ARTIFACT_RUN_ID" \
--arg prepublishPluginRegistryArtifactRunAttempt "$PREPUBLISH_PLUGIN_REGISTRY_ARTIFACT_RUN_ATTEMPT" \
--arg prepublishPluginRegistryManifestSha256 "$PREPUBLISH_PLUGIN_REGISTRY_MANIFEST_SHA256" \
'$ARGS.named')"
jq -e 'all(.[]; type == "string" and length > 0)' <<< "$registry_json" >/dev/null
json="$(jq -cn --argjson base "$json" --argjson registry "$registry_json" '$base + $registry')"
fi
echo "json=$json" >> "$GITHUB_OUTPUT"
docker_e2e_image_ready:
+37 -1
View File
@@ -331,8 +331,15 @@ jobs:
RELEASE_PACKAGE_SPEC_INPUT: ${{ inputs.release_package_spec }}
RELEASE_PACKAGE_ACCEPTANCE_PACKAGE_SPEC_INPUT: ${{ inputs.package_acceptance_package_spec }}
RELEASE_CODEX_PLUGIN_SPEC_INPUT: ${{ inputs.codex_plugin_spec }}
CANDIDATE_ARTIFACT_JSON_INPUT: ${{ inputs.candidate_artifact_json }}
run: |
set -euo pipefail
if [[ -n "${CANDIDATE_ARTIFACT_JSON_INPUT// }" ]] &&
{ [[ -n "${RELEASE_PACKAGE_SPEC_INPUT// }" ]] ||
[[ -n "${RELEASE_PACKAGE_ACCEPTANCE_PACKAGE_SPEC_INPUT// }" ]]; }; then
echo "candidate_artifact_json cannot be combined with release package specs." >&2
exit 1
fi
qa_live_matrix_enabled=true
qa_live_buzz_enabled=true
qa_live_telegram_enabled=true
@@ -749,7 +756,29 @@ jobs:
(.imageArtifactDigest | hex64) and
(.imageArtifactRunId | tostring | digits) and
(.imageArtifactRunAttempt | tostring | digits) and
(.imageArchiveSha256 | hex64)' \
(.imageArchiveSha256 | hex64) and
(
(
(has("prepublishPluginRegistryArtifactName") | not) and
(has("prepublishPluginRegistryArtifactId") | not) and
(has("prepublishPluginRegistryArtifactDigest") | not) and
(has("prepublishPluginRegistryArtifactRunId") | not) and
(has("prepublishPluginRegistryArtifactRunAttempt") | not) and
(has("prepublishPluginRegistryManifestSha256") | not)
) or
(
(.prepublishPluginRegistryArtifactName | type == "string" and length > 0) and
(.prepublishPluginRegistryArtifactId | tostring | digits) and
(.prepublishPluginRegistryArtifactDigest | hex64) and
(.prepublishPluginRegistryArtifactRunId | tostring | digits) and
(.prepublishPluginRegistryArtifactRunAttempt | tostring | digits) and
(.prepublishPluginRegistryManifestSha256 | hex64) and
.prepublishPluginRegistryArtifactName ==
("docker-e2e-prepublish-plugin-registry-" +
(.prepublishPluginRegistryArtifactRunId | tostring) + "-" +
(.prepublishPluginRegistryArtifactRunAttempt | tostring))
)
)' \
<<< "$CANDIDATE_ARTIFACT_JSON" >/dev/null
install_smoke_release_checks:
@@ -901,6 +930,13 @@ jobs:
package_sha256: ${{ needs.prepare_release_package.outputs.package_sha256 }}
package_source_sha: ${{ needs.prepare_release_package.outputs.source_sha }}
package_version: ${{ needs.prepare_release_package.outputs.package_version }}
enable_prepublish_plugin_registry: ${{ needs.resolve_target.outputs.release_package_spec == '' }}
prepublish_plugin_registry_artifact_name: ${{ fromJSON(inputs.candidate_artifact_json || '{}').prepublishPluginRegistryArtifactName || '' }}
prepublish_plugin_registry_artifact_id: ${{ fromJSON(inputs.candidate_artifact_json || '{}').prepublishPluginRegistryArtifactId || '' }}
prepublish_plugin_registry_artifact_digest: ${{ fromJSON(inputs.candidate_artifact_json || '{}').prepublishPluginRegistryArtifactDigest || '' }}
prepublish_plugin_registry_artifact_run_id: ${{ fromJSON(inputs.candidate_artifact_json || '{}').prepublishPluginRegistryArtifactRunId || '' }}
prepublish_plugin_registry_artifact_run_attempt: ${{ fromJSON(inputs.candidate_artifact_json || '{}').prepublishPluginRegistryArtifactRunAttempt || '' }}
prepublish_plugin_registry_manifest_sha256: ${{ fromJSON(inputs.candidate_artifact_json || '{}').prepublishPluginRegistryManifestSha256 || '' }}
codex_plugin_spec: ${{ needs.resolve_target.outputs.codex_plugin_spec }}
shared_image_artifact_namespace: release-docker
shared_image_artifact_name: ${{ fromJSON(inputs.candidate_artifact_json || '{}').imageArtifactName || '' }}
@@ -33,6 +33,7 @@ jobs:
include_openwebui: true
include_live_suites: true
allow_unreleased_changelog: true
enable_prepublish_plugin_registry: true
shared_image_artifact_namespace: scheduled-live
shared_image_policy: no-push-artifact
secrets:
+7
View File
@@ -841,6 +841,13 @@ jobs:
package_sha256: ${{ needs.resolve_package.outputs.package_sha256 }}
package_source_sha: ${{ needs.resolve_package.outputs.package_source_sha }}
package_version: ${{ needs.resolve_package.outputs.package_version }}
enable_prepublish_plugin_registry: ${{ contains(fromJSON('["artifact","ref"]'), inputs.source) }}
prepublish_plugin_registry_artifact_name: ${{ fromJSON(inputs.candidate_artifact_json || '{}').prepublishPluginRegistryArtifactName || '' }}
prepublish_plugin_registry_artifact_id: ${{ fromJSON(inputs.candidate_artifact_json || '{}').prepublishPluginRegistryArtifactId || '' }}
prepublish_plugin_registry_artifact_digest: ${{ fromJSON(inputs.candidate_artifact_json || '{}').prepublishPluginRegistryArtifactDigest || '' }}
prepublish_plugin_registry_artifact_run_id: ${{ fromJSON(inputs.candidate_artifact_json || '{}').prepublishPluginRegistryArtifactRunId || '' }}
prepublish_plugin_registry_artifact_run_attempt: ${{ fromJSON(inputs.candidate_artifact_json || '{}').prepublishPluginRegistryArtifactRunAttempt || '' }}
prepublish_plugin_registry_manifest_sha256: ${{ fromJSON(inputs.candidate_artifact_json || '{}').prepublishPluginRegistryManifestSha256 || '' }}
include_live_suites: ${{ needs.resolve_package.outputs.include_live_suites == 'true' }}
live_models_only: false
shared_image_artifact_namespace: ${{ inputs.shared_image_artifact_namespace }}
+7
View File
@@ -574,6 +574,13 @@ jobs:
package_source_sha: ${{ fromJSON(inputs.candidate_artifact_json || '{}').packageSourceSha || '' }}
package_sha256: ${{ fromJSON(inputs.candidate_artifact_json || '{}').packageSha256 || '' }}
package_version: ${{ fromJSON(inputs.candidate_artifact_json || '{}').packageVersion || '' }}
enable_prepublish_plugin_registry: true
prepublish_plugin_registry_artifact_name: ${{ fromJSON(inputs.candidate_artifact_json || '{}').prepublishPluginRegistryArtifactName || '' }}
prepublish_plugin_registry_artifact_id: ${{ fromJSON(inputs.candidate_artifact_json || '{}').prepublishPluginRegistryArtifactId || '' }}
prepublish_plugin_registry_artifact_digest: ${{ fromJSON(inputs.candidate_artifact_json || '{}').prepublishPluginRegistryArtifactDigest || '' }}
prepublish_plugin_registry_artifact_run_id: ${{ fromJSON(inputs.candidate_artifact_json || '{}').prepublishPluginRegistryArtifactRunId || '' }}
prepublish_plugin_registry_artifact_run_attempt: ${{ fromJSON(inputs.candidate_artifact_json || '{}').prepublishPluginRegistryArtifactRunAttempt || '' }}
prepublish_plugin_registry_manifest_sha256: ${{ fromJSON(inputs.candidate_artifact_json || '{}').prepublishPluginRegistryManifestSha256 || '' }}
shared_image_artifact_name: ${{ fromJSON(inputs.candidate_artifact_json || '{}').imageArtifactName || '' }}
shared_image_artifact_id: ${{ fromJSON(inputs.candidate_artifact_json || '{}').imageArtifactId || '' }}
shared_image_artifact_digest: ${{ fromJSON(inputs.candidate_artifact_json || '{}').imageArtifactDigest || '' }}
+4
View File
@@ -29,6 +29,10 @@ function githubOutputs(plan) {
`needs_functional_image=${boolOutput(needs.functionalImage)}`,
`needs_live_image=${boolOutput(needs.liveImage)}`,
`needs_package=${boolOutput(needs.package)}`,
`needs_prepublish_plugin_registry=${boolOutput(needs.prepublishPluginRegistry)}`,
`required_prepublish_plugin_packages=${JSON.stringify(
plan.requiredPrepublishPluginPackages ?? [],
)}`,
];
}
@@ -5,6 +5,19 @@ export function resolveScenarioConfigSteps(scenario: string): Array<{
intent: string;
argv: string[];
}>;
export function resolveUpgradeSurvivorConfigSteps(scenario?: string): Array<{
id: string;
intent: string;
argv: string[];
}>;
export function resolveUpgradeSurvivorConfigStepsForBaseline(
scenario?: string,
baselineVersion?: string | null,
): Array<{
id: string;
intent: string;
argv: string[];
}>;
export function resolveUpgradeSurvivorOpenClawCommand(
argv: unknown,
params?: Record<string, unknown>,
@@ -200,6 +200,15 @@ const recipe = [
},
];
export function resolveUpgradeSurvivorConfigSteps(scenario = "base") {
const validateStep = recipe.at(-1);
return [
...recipe.slice(0, -1),
...resolveScenarioConfigSteps(scenario),
...(validateStep ? [validateStep] : []),
];
}
function selectedScenario() {
return process.env.OPENCLAW_UPGRADE_SURVIVOR_SCENARIO || "base";
}
@@ -252,6 +261,16 @@ function adaptStepForBaseline(step, baselineVersion, summary) {
return step;
}
export function resolveUpgradeSurvivorConfigStepsForBaseline(
scenario = "base",
baselineVersion = null,
) {
const summary = { skippedIntents: [] };
return resolveUpgradeSurvivorConfigSteps(scenario)
.map((step) => adaptStepForBaseline(step, baselineVersion, summary))
.filter(Boolean);
}
export function resolveUpgradeSurvivorOpenClawCommand(argv, params = {}) {
const platform = params.platform ?? process.platform;
if (platform === "win32") {
@@ -331,7 +350,7 @@ function applyRecipe() {
steps: [],
};
for (const step of [...recipe.slice(0, -1), ...scenarioSteps, recipe.at(-1)]) {
for (const step of resolveUpgradeSurvivorConfigSteps(scenario)) {
const adaptedStep = adaptStepForBaseline(step, baselineVersion, summary);
if (!adaptedStep) {
continue;
+56 -14
View File
@@ -3,6 +3,8 @@ set -Eeuo pipefail
source scripts/lib/openclaw-e2e-instance.sh
SCENARIO="${OPENCLAW_UPGRADE_SURVIVOR_SCENARIO:-base}"
export npm_config_loglevel=error
export npm_config_fund=false
export npm_config_audit=false
@@ -16,7 +18,9 @@ export GATEWAY_AUTH_TOKEN_REF="upgrade-survivor-token"
export OPENAI_API_KEY="sk-openclaw-upgrade-survivor"
export DISCORD_BOT_TOKEN="upgrade-survivor-discord-token"
export TELEGRAM_BOT_TOKEN="123456:upgrade-survivor-telegram-token"
export FEISHU_APP_SECRET="upgrade-survivor-feishu-secret"
if [ "$SCENARIO" = "feishu-channel" ]; then
export FEISHU_APP_SECRET="upgrade-survivor-feishu-secret"
fi
export MATRIX_ACCESS_TOKEN="upgrade-survivor-matrix-token"
export BRAVE_API_KEY="BSA_upgrade_survivor_brave_key"
@@ -43,7 +47,6 @@ PHASE_LOG="$ARTIFACT_ROOT/phases.jsonl"
BASELINE_RAW="${OPENCLAW_UPGRADE_SURVIVOR_BASELINE:?missing OPENCLAW_UPGRADE_SURVIVOR_BASELINE}"
CANDIDATE_KIND="${OPENCLAW_UPGRADE_SURVIVOR_CANDIDATE_KIND:-tarball}"
CANDIDATE_SPEC="${OPENCLAW_UPGRADE_SURVIVOR_CANDIDATE_SPEC:-${OPENCLAW_CURRENT_PACKAGE_TGZ:-}}"
SCENARIO="${OPENCLAW_UPGRADE_SURVIVOR_SCENARIO:-base}"
UPDATE_RESTART_MODE="${OPENCLAW_UPGRADE_SURVIVOR_UPDATE_RESTART_MODE:-manual}"
ROOT_MANAGED_VPS="${OPENCLAW_UPGRADE_SURVIVOR_ROOT_MANAGED_VPS:-0}"
COMMAND_TIMEOUT="${OPENCLAW_UPGRADE_SURVIVOR_COMMAND_TIMEOUT:-900s}"
@@ -365,16 +368,49 @@ TS
echo "Seeded source-only plugin shadow: $shadow_root"
}
configure_configured_plugin_install_fixture_registry() {
configured_plugin_installs_enabled || return 0
local fixture_root="$ARTIFACT_ROOT/configured-plugin-installs-npm-fixture"
configure_plugin_registry() {
local fixture_root="$ARTIFACT_ROOT/plugin-registry"
local package_dir="$fixture_root/package"
local tarball="$fixture_root/openclaw-brave-plugin-2026.5.2.tgz"
local port_file="$fixture_root/npm-registry-port"
local log_file="$fixture_root/npm-registry.log"
mkdir -p "$package_dir"
FIXTURE_PACKAGE_DIR="$package_dir" node <<'NODE'
local registry_args=()
if [ -n "${OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR:-}" ]; then
local manifest="$OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR/prepublish-plugin-registry.json"
local registry_rows
registry_rows="$(
PREPUBLISH_PLUGIN_REGISTRY_MANIFEST="$manifest" node <<'NODE'
const fs = require("node:fs");
const path = require("node:path");
const manifestPath = process.env.PREPUBLISH_PLUGIN_REGISTRY_MANIFEST;
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
if (!Array.isArray(manifest.packages) || manifest.packages.length === 0) {
throw new Error("prepublish plugin registry manifest must contain packages");
}
for (const entry of manifest.packages) {
if (
typeof entry.name !== "string" ||
typeof entry.version !== "string" ||
typeof entry.tarball !== "string" ||
path.basename(entry.tarball) !== entry.tarball
) {
throw new Error("invalid prepublish plugin registry package entry");
}
process.stdout.write(
`${entry.name}\t${entry.version}\t${path.join(path.dirname(manifestPath), entry.tarball)}\n`,
);
}
NODE
)"
while IFS=$'\t' read -r package_name package_version package_tarball; do
registry_args+=("$package_name" "$package_version" "$package_tarball")
done <<<"$registry_rows"
fi
if configured_plugin_installs_enabled; then
mkdir -p "$package_dir"
FIXTURE_PACKAGE_DIR="$package_dir" node <<'NODE'
const fs = require("node:fs");
const path = require("node:path");
const root = process.env.FIXTURE_PACKAGE_DIR;
@@ -424,13 +460,19 @@ fs.writeFileSync(
`module.exports = { id: "brave", name: "Brave Fixture", register() {} };\n`,
);
NODE
tar -czf "$tarball" -C "$fixture_root" package
tar -czf "$tarball" -C "$fixture_root" package
registry_args+=("@openclaw/brave-plugin" "2026.5.2" "$tarball")
fi
if [ "${#registry_args[@]}" -eq 0 ]; then
return 0
fi
mkdir -p "$fixture_root"
OPENCLAW_NPM_REGISTRY_UPSTREAM=https://registry.npmjs.org \
node scripts/e2e/lib/plugins/npm-registry-server.mjs \
"$port_file" \
"@openclaw/brave-plugin" \
"2026.5.2" \
"$tarball" \
"${registry_args[@]}" \
>"$log_file" 2>&1 &
plugin_registry_pid="$!"
@@ -448,7 +490,7 @@ NODE
done
openclaw_e2e_print_log "$log_file" >&2
echo "Timed out waiting for configured plugin install npm fixture registry." >&2
echo "Timed out waiting for upgrade survivor npm registry." >&2
return 1
}
@@ -1270,10 +1312,10 @@ phase assert-baseline assert_baseline_state
phase seed-legacy-runtime-deps-symlink seed_legacy_runtime_deps_symlink
phase resolve-candidate resolve_candidate_version
phase prepare-update-restart-probe prepare_update_restart_probe
phase configure-plugin-registry configure_plugin_registry
phase update-candidate update_candidate
phase root-managed-vps-cli-usable assert_root_managed_vps_cli_usable
phase assert-legacy-plugin-dependency-debris-before-doctor assert_legacy_plugin_dependency_debris_before_doctor
phase configure-configured-plugin-install-fixture-registry configure_configured_plugin_install_fixture_registry
phase doctor run_doctor
phase assert-legacy-plugin-dependency-debris-cleaned assert_legacy_plugin_dependency_debris_cleaned
phase assert-legacy-runtime-deps-symlink-repaired assert_legacy_runtime_deps_symlink_repaired
+70 -13
View File
@@ -56,6 +56,7 @@ LANE_ARTIFACT_SUFFIX="$(resolve_lane_artifact_suffix)"
LANE_ARTIFACT_SUFFIX="${LANE_ARTIFACT_SUFFIX//[^A-Za-z0-9_.-]/_}"
ARTIFACT_DIR="${OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_DIR:-$ROOT_DIR/.artifacts/upgrade-survivor/$LANE_ARTIFACT_SUFFIX}"
DOCKER_RUN_USER_ARGS=()
PREPUBLISH_PLUGIN_REGISTRY_ARGS=()
PROBE_ENV_ARGS=(
-e OPENCLAW_UPGRADE_SURVIVOR_PROBE_TIMEOUT_MS="$PROBE_TIMEOUT_MS"
-e OPENCLAW_UPGRADE_SURVIVOR_PROBE_ATTEMPT_TIMEOUT_MS="$PROBE_ATTEMPT_TIMEOUT_MS"
@@ -71,6 +72,19 @@ if [ -n "${OPENCLAW_UPGRADE_SURVIVOR_READYZ_ALLOW_DEGRADED:-}" ]; then
-e OPENCLAW_UPGRADE_SURVIVOR_READYZ_ALLOW_DEGRADED="$OPENCLAW_UPGRADE_SURVIVOR_READYZ_ALLOW_DEGRADED"
)
fi
if [ -n "${OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR:-}" ]; then
PREPUBLISH_PLUGIN_REGISTRY_DIR="$(
cd "$OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR" && pwd
)"
if [ ! -f "$PREPUBLISH_PLUGIN_REGISTRY_DIR/prepublish-plugin-registry.json" ]; then
echo "Prepublish plugin registry manifest is missing." >&2
exit 1
fi
PREPUBLISH_PLUGIN_REGISTRY_ARGS=(
-e OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR=/tmp/openclaw-prepublish-plugin-registry
-v "$PREPUBLISH_PLUGIN_REGISTRY_DIR:/tmp/openclaw-prepublish-plugin-registry:ro"
)
fi
cleanup_outer() {
docker_e2e_cleanup_package_tgz "${PACKAGE_TGZ:-}"
}
@@ -164,6 +178,7 @@ if [ "${OPENCLAW_UPGRADE_SURVIVOR_PUBLISHED_BASELINE:-0}" = "1" ]; then
"${PROBE_ENV_ARGS[@]}" \
-v "$ARTIFACT_DIR:/tmp/openclaw-upgrade-survivor-artifacts" \
-v "$HARNESS_ROOT_DIR/scripts/e2e/lib/upgrade-survivor/run.sh:/tmp/openclaw-upgrade-survivor-run.sh:ro" \
"${PREPUBLISH_PLUGIN_REGISTRY_ARGS[@]}" \
"${DOCKER_E2E_PACKAGE_ARGS[@]}" \
"${DOCKER_RUN_USER_ARGS[@]}" \
"$IMAGE_NAME" \
@@ -192,6 +207,7 @@ docker_e2e_run_with_harness \
-e OPENCLAW_UPGRADE_SURVIVOR_STATUS_BUDGET_SECONDS="$STATUS_BUDGET_SECONDS" \
"${PROBE_ENV_ARGS[@]}" \
-v "$ARTIFACT_DIR:/tmp/openclaw-upgrade-survivor-artifacts" \
"${PREPUBLISH_PLUGIN_REGISTRY_ARGS[@]}" \
"${DOCKER_E2E_PACKAGE_ARGS[@]}" \
"${DOCKER_RUN_USER_ARGS[@]}" \
"$IMAGE_NAME" \
@@ -224,7 +240,9 @@ export GATEWAY_AUTH_TOKEN_REF="upgrade-survivor-token"
export OPENAI_API_KEY="sk-openclaw-upgrade-survivor"
export DISCORD_BOT_TOKEN="upgrade-survivor-discord-token"
export TELEGRAM_BOT_TOKEN="123456:upgrade-survivor-telegram-token"
export FEISHU_APP_SECRET="upgrade-survivor-feishu-secret"
if [ "${OPENCLAW_UPGRADE_SURVIVOR_SCENARIO:-base}" = "feishu-channel" ]; then
export FEISHU_APP_SECRET="upgrade-survivor-feishu-secret"
fi
export BRAVE_API_KEY="BSA_upgrade_survivor_brave_key"
UPDATE_RESTART_MODE="${OPENCLAW_UPGRADE_SURVIVOR_UPDATE_RESTART_MODE:-manual}"
@@ -257,16 +275,49 @@ cleanup() {
}
trap cleanup EXIT
configure_configured_plugin_install_fixture_registry() {
[ "${OPENCLAW_UPGRADE_SURVIVOR_SCENARIO:-base}" = "configured-plugin-installs" ] || return 0
local fixture_root="$OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_ROOT/configured-plugin-installs-npm-fixture"
configure_plugin_registry() {
local fixture_root="$OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_ROOT/plugin-registry"
local package_dir="$fixture_root/package"
local tarball="$fixture_root/openclaw-brave-plugin-2026.5.2.tgz"
local port_file="$fixture_root/npm-registry-port"
local log_file="$fixture_root/npm-registry.log"
mkdir -p "$package_dir"
FIXTURE_PACKAGE_DIR="$package_dir" node <<'"'"'NODE'"'"'
local registry_args=()
if [ -n "${OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR:-}" ]; then
local manifest="$OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR/prepublish-plugin-registry.json"
local registry_rows
registry_rows="$(
PREPUBLISH_PLUGIN_REGISTRY_MANIFEST="$manifest" node <<'"'"'NODE'"'"'
const fs = require("node:fs");
const path = require("node:path");
const manifestPath = process.env.PREPUBLISH_PLUGIN_REGISTRY_MANIFEST;
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
if (!Array.isArray(manifest.packages) || manifest.packages.length === 0) {
throw new Error("prepublish plugin registry manifest must contain packages");
}
for (const entry of manifest.packages) {
if (
typeof entry.name !== "string" ||
typeof entry.version !== "string" ||
typeof entry.tarball !== "string" ||
path.basename(entry.tarball) !== entry.tarball
) {
throw new Error("invalid prepublish plugin registry package entry");
}
process.stdout.write(
`${entry.name}\t${entry.version}\t${path.join(path.dirname(manifestPath), entry.tarball)}\n`,
);
}
NODE
)"
while IFS=$'"'"'\t'"'"' read -r package_name package_version package_tarball; do
registry_args+=("$package_name" "$package_version" "$package_tarball")
done <<<"$registry_rows"
fi
if [ "${OPENCLAW_UPGRADE_SURVIVOR_SCENARIO:-base}" = "configured-plugin-installs" ]; then
mkdir -p "$package_dir"
FIXTURE_PACKAGE_DIR="$package_dir" node <<'"'"'NODE'"'"'
const fs = require("node:fs");
const path = require("node:path");
const root = process.env.FIXTURE_PACKAGE_DIR;
@@ -316,13 +367,19 @@ fs.writeFileSync(
`module.exports = { id: "brave", name: "Brave Fixture", register() {} };\n`,
);
NODE
tar -czf "$tarball" -C "$fixture_root" package
tar -czf "$tarball" -C "$fixture_root" package
registry_args+=("@openclaw/brave-plugin" "2026.5.2" "$tarball")
fi
if [ "${#registry_args[@]}" -eq 0 ]; then
return 0
fi
mkdir -p "$fixture_root"
OPENCLAW_NPM_REGISTRY_UPSTREAM=https://registry.npmjs.org \
node scripts/e2e/lib/plugins/npm-registry-server.mjs \
"$port_file" \
"@openclaw/brave-plugin" \
"2026.5.2" \
"$tarball" \
"${registry_args[@]}" \
>"$log_file" 2>&1 &
plugin_registry_pid="$!"
@@ -340,7 +397,7 @@ NODE
done
openclaw_e2e_print_log "$log_file" >&2
echo "Timed out waiting for configured plugin install npm fixture registry." >&2
echo "Timed out waiting for upgrade survivor npm registry." >&2
return 1
}
@@ -364,6 +421,7 @@ if [ "$UPDATE_RESTART_MODE" = "auto-auth" ]; then
prepare_update_restart_probe_current_install "$PORT" "$GATEWAY_LOG"
fi
configure_plugin_registry
echo "Running package update against the mounted tarball..."
update_args=(update --tag "${OPENCLAW_CURRENT_PACKAGE_TGZ:?missing OPENCLAW_CURRENT_PACKAGE_TGZ}" --yes --json)
if [ "$UPDATE_RESTART_MODE" != "auto-auth" ]; then
@@ -384,7 +442,6 @@ if [ "$UPDATE_RESTART_MODE" = "auto-auth" ]; then
echo "Skipping doctor repair until after restart proof."
else
echo "Running non-interactive doctor repair..."
configure_configured_plugin_install_fixture_registry
if ! openclaw_e2e_maybe_timeout "$command_timeout" openclaw doctor --fix --non-interactive >/tmp/openclaw-upgrade-survivor-doctor.log 2>&1; then
echo "openclaw doctor failed" >&2
openclaw_e2e_print_log /tmp/openclaw-upgrade-survivor-doctor.log >&2
+3
View File
@@ -43,12 +43,14 @@ export type DockerE2ePlan = {
lanes: DockerE2ePlanLane[];
mainLanes: DockerE2ePlanLane[];
omittedUnsupportedLanes: string[];
requiredPrepublishPluginPackages: string[];
needs: {
bareImage: boolean;
e2eImage: boolean;
functionalImage: boolean;
liveImage: boolean;
package: boolean;
prepublishPluginRegistry: boolean;
};
profile: string;
releaseProfile?: DockerE2eReleaseProfile;
@@ -79,6 +81,7 @@ export function lanesNeedE2eImageKind(
kind: DockerE2eImageKind,
): boolean;
export function lanesNeedOpenClawPackage(poolLanes: DockerE2eLane[]): boolean;
export function requiredPrepublishPluginPackagesForLanes(poolLanes: DockerE2eLane[]): string[];
export function findLaneByName(name: string): DockerE2eLane | undefined;
export function resolveDockerE2ePlan(options: DockerE2ePlanOptions): {
omittedUnsupportedLaneNames: string[];
+65
View File
@@ -5,6 +5,7 @@ import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { resolveUpgradeSurvivorConfigStepsForBaseline } from "../e2e/lib/upgrade-survivor/config-recipe.mjs";
import {
BUNDLED_PLUGIN_INSTALL_UNINSTALL_SHARDS,
DEFAULT_LIVE_RETRIES,
@@ -15,6 +16,7 @@ import {
releasePathChunkLanes,
tailLanes,
} from "./docker-e2e-scenarios.mjs";
import officialExternalChannelCatalog from "./official-external-channel-catalog.json" with { type: "json" };
export { DEFAULT_LIVE_RETRIES };
export { normalizeReleaseProfile };
@@ -636,11 +638,72 @@ function unique(values) {
return [...new Set(values.filter(Boolean))];
}
function upgradeSurvivorScenarioForLane(poolLane) {
if (!poolLane.upgradeSurvivorScenario) {
return undefined;
}
const match = /(?:^|\s)OPENCLAW_UPGRADE_SURVIVOR_SCENARIO=(?:'([^']+)'|"([^"]+)"|([^\s]+))/u.exec(
poolLane.command,
);
return match?.[1] ?? match?.[2] ?? match?.[3] ?? poolLane.upgradeSurvivorScenario;
}
function upgradeSurvivorBaselineVersionForLane(poolLane) {
const match =
/(?:^|\s)OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC=(?:'([^']+)'|"([^"]+)"|([^\s]+))/u.exec(
poolLane.command,
);
const spec = match?.[1] ?? match?.[2] ?? match?.[3];
return /(?:^|\/|@)(\d{4}\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/u.exec(spec ?? "")?.[1] ?? null;
}
function configuredChannelIdsForLane(poolLane, scenario) {
const channelIds = new Set();
const baselineVersion = upgradeSurvivorBaselineVersionForLane(poolLane);
for (const step of resolveUpgradeSurvivorConfigStepsForBaseline(scenario, baselineVersion)) {
if (step.argv?.[0] !== "config" || step.argv?.[1] !== "set") {
continue;
}
const match = /^channels\.([a-z0-9][a-z0-9-]*)$/u.exec(step.argv[2] ?? "");
if (match) {
channelIds.add(match[1]);
}
}
return channelIds;
}
export function requiredPrepublishPluginPackagesForLanes(poolLanes) {
const configuredChannelIds = new Set();
for (const poolLane of poolLanes) {
const scenario = upgradeSurvivorScenarioForLane(poolLane);
if (!scenario) {
continue;
}
for (const channelId of configuredChannelIdsForLane(poolLane, scenario)) {
configuredChannelIds.add(channelId);
}
}
return (officialExternalChannelCatalog.entries ?? [])
.filter((entry) => {
const channelId = entry.openclaw?.channel?.id;
const install = entry.openclaw?.install;
return (
typeof entry.name === "string" &&
configuredChannelIds.has(channelId) &&
install?.defaultChoice === "npm" &&
install?.npmSpec === entry.name
);
})
.map((entry) => entry.name)
.toSorted((a, b) => a.localeCompare(b));
}
function buildPlanJson(params) {
const scheduledLanes = [...params.orderedLanes, ...params.orderedTailLanes];
const imageKinds = unique(scheduledLanes.map((poolLane) => poolLane.e2eImageKind)).toSorted(
(a, b) => a.localeCompare(b),
);
const requiredPrepublishPluginPackages = requiredPrepublishPluginPackagesForLanes(scheduledLanes);
return {
chunk: params.releaseChunk || undefined,
credentials: unique(scheduledLanes.flatMap(laneCredentialRequirements)).toSorted((a, b) =>
@@ -661,12 +724,14 @@ function buildPlanJson(params) {
})),
mainLanes: params.orderedLanes.map((poolLane) => poolLane.name),
omittedUnsupportedLanes: params.omittedUnsupportedLaneNames,
requiredPrepublishPluginPackages,
needs: {
bareImage: imageKinds.includes("bare"),
e2eImage: imageKinds.length > 0,
functionalImage: imageKinds.includes("functional"),
liveImage: scheduledLanes.some((poolLane) => poolLane.needsLiveImage),
package: lanesNeedOpenClawPackage(scheduledLanes),
prepublishPluginRegistry: requiredPrepublishPluginPackages.length > 0,
},
profile: params.profile,
releaseProfile: params.releaseProfile,
+1
View File
@@ -16,6 +16,7 @@ export type DockerE2eLane = {
retryPatterns: RegExp[];
stateScenario?: string;
timeoutMs?: number;
upgradeSurvivorScenario?: string;
weight: number;
};
+39 -17
View File
@@ -16,12 +16,22 @@ const OPENWEBUI_TIMEOUT_MS = 20 * 60 * 1000;
const RELEASE_OPENWEBUI_COMMAND =
"OPENCLAW_OPENWEBUI_MODEL=openai/gpt-5.4-mini OPENCLAW_OPENWEBUI_PROVIDER_TIMEOUT_SECONDS=300 OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:openwebui";
export const BUNDLED_PLUGIN_INSTALL_UNINSTALL_SHARDS = 24;
const upgradeSurvivorCommand = "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:upgrade-survivor";
const rootManagedVpsUpgradeCommand =
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:root-managed-vps-upgrade";
const updateRestartAuthCommand = liveDockerScriptCommand(
"e2e/upgrade-survivor-docker.sh",
'OPENCLAW_DOCKER_E2E_REPO_ROOT="${OPENCLAW_DOCKER_E2E_REPO_ROOT:-$PWD}" OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC=${OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC:-openclaw@latest} OPENCLAW_UPGRADE_SURVIVOR_PUBLISHED_BASELINE=1 OPENCLAW_UPGRADE_SURVIVOR_UPDATE_RESTART_MODE=auto-auth OPENCLAW_UPGRADE_SURVIVOR_DOCKER_RUN_TIMEOUT=${OPENCLAW_UPGRADE_SURVIVOR_DOCKER_RUN_TIMEOUT:-1500s}',
const upgradeSurvivorCommand = upgradeSurvivorScriptCommand();
const publishedUpgradeSurvivorCommand = upgradeSurvivorScriptCommand(
"OPENCLAW_UPGRADE_SURVIVOR_PUBLISHED_BASELINE=1",
'export OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC="${OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC:-openclaw@latest}"; export OPENCLAW_UPGRADE_SURVIVOR_DOCKER_RUN_TIMEOUT="${OPENCLAW_UPGRADE_SURVIVOR_DOCKER_RUN_TIMEOUT:-1500s}"',
);
const rootManagedVpsUpgradeCommand = upgradeSurvivorScriptCommand(
"OPENCLAW_UPGRADE_SURVIVOR_PUBLISHED_BASELINE=1 OPENCLAW_UPGRADE_SURVIVOR_ROOT_MANAGED_VPS=1",
'export OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC="${OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC:-openclaw@2026.5.7}"; export OPENCLAW_UPGRADE_SURVIVOR_DOCKER_RUN_TIMEOUT="${OPENCLAW_UPGRADE_SURVIVOR_DOCKER_RUN_TIMEOUT:-1500s}"',
);
const updateRestartAuthCommand = upgradeSurvivorScriptCommand(
"OPENCLAW_UPGRADE_SURVIVOR_PUBLISHED_BASELINE=1 OPENCLAW_UPGRADE_SURVIVOR_UPDATE_RESTART_MODE=auto-auth",
'export OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC="${OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC:-openclaw@latest}"; export OPENCLAW_UPGRADE_SURVIVOR_DOCKER_RUN_TIMEOUT="${OPENCLAW_UPGRADE_SURVIVOR_DOCKER_RUN_TIMEOUT:-1500s}"',
);
const updateMigrationCommand = upgradeSurvivorScriptCommand(
"OPENCLAW_UPGRADE_SURVIVOR_PUBLISHED_BASELINE=1",
'export OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC="${OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC:-openclaw@2026.4.23}"; export OPENCLAW_UPGRADE_SURVIVOR_SCENARIO="${OPENCLAW_UPGRADE_SURVIVOR_SCENARIO:-plugin-deps-cleanup}"',
);
const updateRunPackageSelfUpgradeCommand =
"OPENCLAW_QA_ALLOW_UPDATE_RUN_SELF=1 OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:update-run-package-self-upgrade";
@@ -38,8 +48,18 @@ const LIVE_RETRY_PATTERNS = [
function liveDockerScriptCommand(script, envPrefix = "", options = {}) {
const prefix = envPrefix ? `${envPrefix} ` : "";
const shellPrelude = options.shellPrelude ? `${options.shellPrelude}; ` : "";
const skipBuild = options.skipBuild === false ? "" : "OPENCLAW_SKIP_DOCKER_BUILD=1 ";
return `${prefix}${skipBuild}bash -c 'harness="\${OPENCLAW_DOCKER_E2E_TRUSTED_HARNESS_DIR:-${LIVE_DOCKER_DEFAULT_HARNESS_DIR}}"; OPENCLAW_LIVE_DOCKER_REPO_ROOT="\${OPENCLAW_DOCKER_E2E_REPO_ROOT:-$PWD}" bash "$harness/scripts/${script}"'`;
return `${prefix}${skipBuild}bash -c '${shellPrelude}harness="\${OPENCLAW_DOCKER_E2E_TRUSTED_HARNESS_DIR:-${LIVE_DOCKER_DEFAULT_HARNESS_DIR}}"; OPENCLAW_LIVE_DOCKER_REPO_ROOT="\${OPENCLAW_DOCKER_E2E_REPO_ROOT:-$PWD}" bash "$harness/scripts/${script}"'`;
}
function upgradeSurvivorScriptCommand(envPrefix = "", shellPrelude = "") {
const rootPrefix = 'OPENCLAW_DOCKER_E2E_REPO_ROOT="${OPENCLAW_DOCKER_E2E_REPO_ROOT:-$PWD}"';
return liveDockerScriptCommand(
"e2e/upgrade-survivor-docker.sh",
envPrefix ? `${rootPrefix} ${envPrefix}` : rootPrefix,
{ shellPrelude },
);
}
function lane(name, command, options = {}) {
@@ -60,6 +80,7 @@ function lane(name, command, options = {}) {
resources: options.resources ?? [],
stateScenario: options.stateScenario,
timeoutMs: options.timeoutMs,
upgradeSurvivorScenario: options.upgradeSurvivorScenario,
weight: options.weight ?? 1,
};
}
@@ -161,25 +182,25 @@ function createPackageUpdateMaintenanceLanes() {
npmLane("upgrade-survivor", upgradeSurvivorCommand, {
stateScenario: "upgrade-survivor",
timeoutMs: 20 * 60 * 1000,
upgradeSurvivorScenario: "base",
weight: 3,
}),
npmLane("published-upgrade-survivor", publishedUpgradeSurvivorCommand, {
stateScenario: "upgrade-survivor",
timeoutMs: 25 * 60 * 1000,
upgradeSurvivorScenario: "base",
weight: 3,
}),
npmLane(
"published-upgrade-survivor",
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:published-upgrade-survivor",
{
stateScenario: "upgrade-survivor",
timeoutMs: 25 * 60 * 1000,
weight: 3,
},
),
npmLane("root-managed-vps-upgrade", rootManagedVpsUpgradeCommand, {
stateScenario: "upgrade-survivor",
timeoutMs: 25 * 60 * 1000,
upgradeSurvivorScenario: "base",
weight: 3,
}),
npmLane("update-restart-auth", updateRestartAuthCommand, {
stateScenario: "upgrade-survivor",
timeoutMs: 25 * 60 * 1000,
upgradeSurvivorScenario: "base",
weight: 3,
}),
npmLane("update-run-package-self-upgrade", updateRunPackageSelfUpgradeCommand, {
@@ -519,9 +540,10 @@ export const mainLanes = [
{ resources: ["npm"], stateScenario: "empty", weight: 3 },
),
...createPackageUpdateMaintenanceLanes(),
npmLane("update-migration", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:update-migration", {
npmLane("update-migration", updateMigrationCommand, {
stateScenario: "upgrade-survivor",
timeoutMs: 30 * 60 * 1000,
upgradeSurvivorScenario: "plugin-deps-cleanup",
weight: 3,
}),
lane("plugins", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:plugins", {
@@ -0,0 +1,30 @@
export const PREPUBLISH_PLUGIN_REGISTRY_MANIFEST: "prepublish-plugin-registry.json";
export function validatePrepublishPluginRegistryArtifact(params: {
artifactDir: string;
expectedCandidateVersion: string;
expectedManifestSha256: string;
expectedSourceSha: string;
requiredPackages: string[];
}): {
manifest: {
schema: string;
schemaVersion: number;
sourceSha: string;
candidateVersion: string;
packages: Array<{
name: string;
version: string;
tarball: string;
sha256: string;
}>;
};
manifestPath: string;
manifestSha256: string;
};
export function createPrepublishPluginRegistryArtifact(params: {
repoRoot: string;
outputDir: string;
sourceSha: string;
candidateVersion: string;
requiredPackages: string[];
}): ReturnType<typeof validatePrepublishPluginRegistryArtifact>;
@@ -0,0 +1,356 @@
#!/usr/bin/env node
import { execFileSync, spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
export const PREPUBLISH_PLUGIN_REGISTRY_MANIFEST = "prepublish-plugin-registry.json";
const SCHEMA = "openclaw.prepublish-plugin-registry/v1";
const SHA256_PATTERN = /^[0-9a-f]{64}$/u;
const SOURCE_SHA_PATTERN = /^[0-9a-f]{40}$/u;
const PACKAGE_NAME_PATTERN = /^@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/u;
const TARBALL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*\.tgz$/u;
function readJson(file, label) {
try {
return JSON.parse(fs.readFileSync(file, "utf8"));
} catch (error) {
throw new Error(`cannot read ${label} ${file}: ${String(error)}`, { cause: error });
}
}
function sha256File(file) {
return createHash("sha256").update(fs.readFileSync(file)).digest("hex");
}
function normalizeRequiredPackages(value) {
if (
!Array.isArray(value) ||
value.some((name) => typeof name !== "string" || !PACKAGE_NAME_PATTERN.test(name))
) {
throw new Error("required packages must be an array of scoped npm package names");
}
const sorted = [...new Set(value)].toSorted((a, b) => a.localeCompare(b));
if (sorted.length !== value.length) {
throw new Error("required packages must not contain duplicates");
}
return sorted;
}
function readTarballPackageJson(tarball) {
try {
return JSON.parse(
execFileSync("tar", ["-xOf", tarball, "package/package.json"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}),
);
} catch (error) {
throw new Error(`cannot read package/package.json from ${tarball}: ${String(error)}`, {
cause: error,
});
}
}
function validateManifestShape(manifest) {
if (
!manifest ||
typeof manifest !== "object" ||
Array.isArray(manifest) ||
manifest.schema !== SCHEMA ||
manifest.schemaVersion !== 1 ||
!SOURCE_SHA_PATTERN.test(manifest.sourceSha ?? "") ||
typeof manifest.candidateVersion !== "string" ||
manifest.candidateVersion.length === 0 ||
/\s/u.test(manifest.candidateVersion) ||
!Array.isArray(manifest.packages)
) {
throw new Error("prepublish plugin registry manifest has an invalid top-level shape");
}
const names = new Set();
const tarballs = new Set();
for (const entry of manifest.packages) {
if (
!entry ||
typeof entry !== "object" ||
Array.isArray(entry) ||
!PACKAGE_NAME_PATTERN.test(entry.name ?? "") ||
entry.version !== manifest.candidateVersion ||
!TARBALL_PATTERN.test(entry.tarball ?? "") ||
path.basename(entry.tarball) !== entry.tarball ||
!SHA256_PATTERN.test(entry.sha256 ?? "")
) {
throw new Error("prepublish plugin registry manifest contains an invalid package entry");
}
if (names.has(entry.name)) {
throw new Error(
`prepublish plugin registry manifest contains duplicate package ${entry.name}`,
);
}
if (tarballs.has(entry.tarball)) {
throw new Error(
`prepublish plugin registry manifest contains duplicate tarball ${entry.tarball}`,
);
}
names.add(entry.name);
tarballs.add(entry.tarball);
}
const sortedNames = [...names].toSorted((a, b) => a.localeCompare(b));
if (
JSON.stringify(sortedNames) !== JSON.stringify(manifest.packages.map((entry) => entry.name))
) {
throw new Error("prepublish plugin registry manifest packages must be sorted by name");
}
}
export function validatePrepublishPluginRegistryArtifact(params) {
if (!SOURCE_SHA_PATTERN.test(params.expectedSourceSha ?? "")) {
throw new Error("expectedSourceSha must be a full lowercase commit SHA");
}
if (
typeof params.expectedCandidateVersion !== "string" ||
params.expectedCandidateVersion.length === 0 ||
/\s/u.test(params.expectedCandidateVersion)
) {
throw new Error("expectedCandidateVersion must be a non-empty package version");
}
if (!SHA256_PATTERN.test(params.expectedManifestSha256 ?? "")) {
throw new Error("expectedManifestSha256 must be a lowercase SHA-256");
}
const artifactDir = path.resolve(params.artifactDir);
const manifestPath = path.join(artifactDir, PREPUBLISH_PLUGIN_REGISTRY_MANIFEST);
const manifestSha256 = sha256File(manifestPath);
if (manifestSha256 !== params.expectedManifestSha256) {
throw new Error("prepublish plugin registry manifest SHA-256 differs from the immutable tuple");
}
const manifest = readJson(manifestPath, "prepublish plugin registry manifest");
validateManifestShape(manifest);
if (manifest.sourceSha !== params.expectedSourceSha) {
throw new Error("prepublish plugin registry source SHA differs from the selected release SHA");
}
if (manifest.candidateVersion !== params.expectedCandidateVersion) {
throw new Error("prepublish plugin registry version differs from the root package candidate");
}
const requiredPackages = normalizeRequiredPackages(params.requiredPackages);
if (
JSON.stringify(manifest.packages.map((entry) => entry.name)) !==
JSON.stringify(requiredPackages)
) {
throw new Error("prepublish plugin registry package set differs from the Docker plan");
}
const expectedFiles = new Set([
PREPUBLISH_PLUGIN_REGISTRY_MANIFEST,
...manifest.packages.map((entry) => entry.tarball),
]);
const actualFiles = fs.readdirSync(artifactDir, { withFileTypes: true });
if (
actualFiles.some((entry) => !entry.isFile()) ||
actualFiles.length !== expectedFiles.size ||
actualFiles.some((entry) => !expectedFiles.has(entry.name))
) {
throw new Error(
"prepublish plugin registry artifact contains missing, extra, or non-file entries",
);
}
for (const entry of manifest.packages) {
const tarball = path.join(artifactDir, entry.tarball);
if (sha256File(tarball) !== entry.sha256) {
throw new Error(`prepublish plugin registry tarball SHA-256 mismatch for ${entry.name}`);
}
const packageJson = readTarballPackageJson(tarball);
if (packageJson.name !== entry.name || packageJson.version !== entry.version) {
throw new Error(`prepublish plugin registry tarball identity mismatch for ${entry.name}`);
}
}
return { manifest, manifestPath, manifestSha256 };
}
function findPublishablePlugin(repoRoot, packageName) {
const extensionsDir = path.join(repoRoot, "extensions");
const matches = fs
.readdirSync(extensionsDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.flatMap((entry) => {
const packageFile = path.join(extensionsDir, entry.name, "package.json");
if (!fs.existsSync(packageFile)) {
return [];
}
const packageJson = readJson(packageFile, "plugin package");
return packageJson.name === packageName
? [{ packageDir: `extensions/${entry.name}`, packageJson }]
: [];
});
if (matches.length !== 1) {
throw new Error(
`expected exactly one extension package for ${packageName}; found ${matches.length}`,
);
}
const match = matches[0];
if (match.packageJson.openclaw?.release?.publishToNpm !== true) {
throw new Error(`${packageName} is not marked openclaw.release.publishToNpm`);
}
return match;
}
function runChecked(command, args, cwd) {
// The CLI stdout is a JSON contract; child build and pack diagnostics belong on stderr.
const result = spawnSync(command, args, { cwd, encoding: "utf8", stdio: ["ignore", 2, 2] });
if (result.status !== 0) {
throw new Error(`${command} ${args.join(" ")} failed with status ${String(result.status)}`);
}
}
export function createPrepublishPluginRegistryArtifact(params) {
const repoRoot = path.resolve(params.repoRoot);
const outputDir = path.resolve(params.outputDir);
const requiredPackages = normalizeRequiredPackages(params.requiredPackages);
if (!SOURCE_SHA_PATTERN.test(params.sourceSha ?? "")) {
throw new Error("sourceSha must be a full lowercase commit SHA");
}
const actualSourceSha = execFileSync("git", ["rev-parse", "HEAD"], {
cwd: repoRoot,
encoding: "utf8",
}).trim();
if (actualSourceSha !== params.sourceSha) {
throw new Error("repository HEAD differs from the requested artifact source SHA");
}
const trackedChanges = execFileSync("git", ["status", "--porcelain=v1", "--untracked-files=no"], {
cwd: repoRoot,
encoding: "utf8",
}).trim();
if (trackedChanges) {
throw new Error("repository has tracked changes; refusing to package them under the HEAD SHA");
}
const rootPackage = readJson(path.join(repoRoot, "package.json"), "root package");
if (rootPackage.version !== params.candidateVersion) {
throw new Error("root package version differs from the requested candidate version");
}
fs.mkdirSync(outputDir, { recursive: true });
if (fs.readdirSync(outputDir).length !== 0) {
throw new Error(`prepublish plugin registry output directory must be empty: ${outputDir}`);
}
const packages = [];
for (const packageName of requiredPackages) {
const { packageDir, packageJson } = findPublishablePlugin(repoRoot, packageName);
if (packageJson.version !== params.candidateVersion) {
throw new Error(`${packageName} version differs from the root package candidate`);
}
runChecked(
process.execPath,
[path.join(repoRoot, "scripts/lib/plugin-npm-runtime-build.mjs"), packageDir],
repoRoot,
);
const before = new Set(fs.readdirSync(outputDir));
runChecked(
process.execPath,
[
path.join(repoRoot, "scripts/lib/plugin-npm-package-manifest.mjs"),
"--run",
packageDir,
"--",
"npm",
"pack",
"--json",
"--ignore-scripts",
"--pack-destination",
outputDir,
],
repoRoot,
);
const created = fs.readdirSync(outputDir).filter((name) => !before.has(name));
if (created.length !== 1 || !TARBALL_PATTERN.test(created[0])) {
throw new Error(`${packageName} pack must create exactly one .tgz`);
}
const tarball = created[0];
const tarballPath = path.join(outputDir, tarball);
const packedPackage = readTarballPackageJson(tarballPath);
if (packedPackage.name !== packageName || packedPackage.version !== params.candidateVersion) {
throw new Error(`${packageName} packed identity differs from the candidate`);
}
packages.push({
name: packageName,
version: params.candidateVersion,
tarball,
sha256: sha256File(tarballPath),
});
}
const manifest = {
schema: SCHEMA,
schemaVersion: 1,
sourceSha: params.sourceSha,
candidateVersion: params.candidateVersion,
packages,
};
const manifestPath = path.join(outputDir, PREPUBLISH_PLUGIN_REGISTRY_MANIFEST);
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
const manifestSha256 = sha256File(manifestPath);
return validatePrepublishPluginRegistryArtifact({
artifactDir: outputDir,
expectedCandidateVersion: params.candidateVersion,
expectedManifestSha256: manifestSha256,
expectedSourceSha: params.sourceSha,
requiredPackages,
});
}
function parseCliArgs(argv) {
const args = [...argv];
const command = args.shift();
const options = new Map();
while (args.length > 0) {
const name = args.shift();
const value = args.shift();
if (!name?.startsWith("--") || value === undefined) {
throw new Error(`invalid argument list near ${String(name)}`);
}
options.set(name, value);
}
const requiredPackages = JSON.parse(options.get("--required-packages-json") ?? "[]");
return { command, options, requiredPackages };
}
function main() {
const { command, options, requiredPackages } = parseCliArgs(process.argv.slice(2));
const common = {
artifactDir: options.get("--artifact-dir"),
expectedCandidateVersion: options.get("--candidate-version"),
expectedManifestSha256: options.get("--manifest-sha256"),
expectedSourceSha: options.get("--source-sha"),
requiredPackages,
};
const result =
command === "create"
? createPrepublishPluginRegistryArtifact({
repoRoot: options.get("--repo-root") ?? ".",
outputDir: common.artifactDir,
sourceSha: common.expectedSourceSha,
candidateVersion: common.expectedCandidateVersion,
requiredPackages,
})
: command === "verify"
? validatePrepublishPluginRegistryArtifact(common)
: undefined;
if (!result) {
throw new Error("usage: prepublish-plugin-registry-artifact.mjs <create|verify> [options]");
}
process.stdout.write(
`${JSON.stringify({
manifestPath: result.manifestPath,
manifestSha256: result.manifestSha256,
packages: result.manifest.packages.map((entry) => entry.name),
})}\n`,
);
}
const isMain =
process.argv[1] &&
fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
if (isMain) {
try {
main();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
}
+16
View File
@@ -28,6 +28,7 @@ import {
resolveDockerE2ePlan,
} from "./lib/docker-e2e-plan.mjs";
import { sleep } from "./lib/sleep.mjs";
import { validatePrepublishPluginRegistryArtifact } from "./prepublish-plugin-registry-artifact.mjs";
const SCRIPT_ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const ROOT_DIR = path.resolve(process.env.OPENCLAW_DOCKER_E2E_REPO_ROOT || SCRIPT_ROOT_DIR);
@@ -351,6 +352,7 @@ function buildLaneRerunCommand(name, baseEnv) {
["OPENCLAW_DOCKER_E2E_BARE_IMAGE", baseEnv.OPENCLAW_DOCKER_E2E_BARE_IMAGE],
["OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE", baseEnv.OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE],
["OPENCLAW_CURRENT_PACKAGE_TGZ", baseEnv.OPENCLAW_CURRENT_PACKAGE_TGZ],
["OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR", baseEnv.OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR],
["OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC", baseEnv.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC],
["OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS", baseEnv.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS],
["OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS", baseEnv.OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS],
@@ -1540,6 +1542,20 @@ async function main() {
allowFrozenTargetScenarioOmissions,
candidatePackageRoot: ROOT_DIR,
});
if (plan.needs.prepublishPluginRegistry && process.env.OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR) {
baseEnv.OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR = path.resolve(
process.env.OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR,
);
validatePrepublishPluginRegistryArtifact({
artifactDir: baseEnv.OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR,
expectedCandidateVersion: process.env.OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_CANDIDATE_VERSION,
expectedManifestSha256: process.env.OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_MANIFEST_SHA256,
expectedSourceSha: process.env.OPENCLAW_DOCKER_E2E_SELECTED_SHA,
requiredPackages: plan.requiredPrepublishPluginPackages,
});
} else {
delete baseEnv.OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR;
}
if (omittedUnsupportedLaneNames.length > 0 && !allowFrozenTargetScenarioOmissions) {
throw new Error(
`frozen target scenario omissions require trusted workflow opt-in: ${omittedUnsupportedLaneNames.join(", ")}`,
+27 -1
View File
@@ -3,6 +3,7 @@ import { spawn, spawnSync } from "node:child_process";
import {
chmodSync,
copyFileSync,
cpSync,
existsSync,
mkdirSync,
mkdtempSync,
@@ -167,12 +168,37 @@ describe("scripts/test-docker-all scheduler", () => {
const root = tempDirs.make("openclaw-docker-plan-isolated-harness-");
const scriptsDir = path.join(root, "scripts");
const libDir = path.join(scriptsDir, "lib");
const upgradeSurvivorDir = path.join(scriptsDir, "e2e/lib/upgrade-survivor");
mkdirSync(libDir, { recursive: true });
mkdirSync(upgradeSurvivorDir, { recursive: true });
copyFileSync("package.json", path.join(root, "package.json"));
copyFileSync("scripts/test-docker-all.mjs", path.join(scriptsDir, "test-docker-all.mjs"));
for (const fileName of ["docker-e2e-plan.mjs", "docker-e2e-scenarios.mjs", "sleep.mjs"]) {
copyFileSync(
"scripts/prepublish-plugin-registry-artifact.mjs",
path.join(scriptsDir, "prepublish-plugin-registry-artifact.mjs"),
);
copyFileSync(
"scripts/windows-cmd-helpers.mjs",
path.join(scriptsDir, "windows-cmd-helpers.mjs"),
);
for (const fileName of [
"docker-e2e-plan.mjs",
"docker-e2e-scenarios.mjs",
"official-external-channel-catalog.json",
"release-version.mjs",
"sleep.mjs",
]) {
copyFileSync(path.join("scripts/lib", fileName), path.join(libDir, fileName));
}
copyFileSync(
"scripts/e2e/lib/upgrade-survivor/config-recipe.mjs",
path.join(upgradeSurvivorDir, "config-recipe.mjs"),
);
cpSync(
"scripts/e2e/lib/upgrade-survivor/config-recipe",
path.join(upgradeSurvivorDir, "config-recipe"),
{ recursive: true },
);
const result = spawnSync(
process.execPath,
+29
View File
@@ -2233,6 +2233,35 @@ docker_e2e_docker_run_cmd run demo
}
});
it("starts the upgrade survivor plugin registry before updates without ambient Feishu config", () => {
const runner = readFileSync(UPGRADE_SURVIVOR_DOCKER_E2E_PATH, "utf8");
const publishedRunner = readFileSync(UPGRADE_SURVIVOR_RUN_SCRIPT, "utf8");
expect(runner.indexOf("\nconfigure_plugin_registry\n")).toBeLessThan(
runner.indexOf('\necho "Running package update against the mounted tarball..."\n'),
);
expect(
publishedRunner.indexOf("phase configure-plugin-registry configure_plugin_registry"),
).toBeLessThan(publishedRunner.indexOf("phase update-candidate update_candidate"));
expect(runner).toContain(
'if [ "${OPENCLAW_UPGRADE_SURVIVOR_SCENARIO:-base}" = "feishu-channel" ]; then',
);
expect(publishedRunner).toContain('if [ "$SCENARIO" = "feishu-channel" ]; then');
for (const script of [runner, publishedRunner]) {
const emptyRegistryGuardIndex = script.indexOf('if [ "${#registry_args[@]}" -eq 0 ]; then');
const fixtureDirectoryIndex = script.indexOf('mkdir -p "$fixture_root"');
const registryServerIndex = script.indexOf(
"OPENCLAW_NPM_REGISTRY_UPSTREAM=https://registry.npmjs.org",
);
expect(emptyRegistryGuardIndex).toBeGreaterThanOrEqual(0);
expect(fixtureDirectoryIndex).toBeGreaterThanOrEqual(0);
expect(registryServerIndex).toBeGreaterThanOrEqual(0);
expect(emptyRegistryGuardIndex).toBeLessThan(fixtureDirectoryIndex);
expect(fixtureDirectoryIndex).toBeLessThan(registryServerIndex);
expect(script).not.toContain('\nexport FEISHU_APP_SECRET="upgrade-survivor-feishu-secret"\n');
}
});
it("wraps package-backed scenario OpenClaw CLI calls with the shared timeout helper", () => {
const paths = [
CODEX_ON_DEMAND_DOCKER_E2E_PATH,
@@ -87,6 +87,26 @@ describe("Docker E2E helper CLIs", () => {
expect(result.stderr).not.toContain("at file:");
});
it("emits prerelease plugin registry planning outputs", () => {
const root = tempDirs.make("openclaw-docker-e2e-helper-plan-");
const file = path.join(root, "plan.json");
writeFileSync(
file,
`${JSON.stringify({
requiredPrepublishPluginPackages: ["@openclaw/discord", "@openclaw/feishu"],
needs: { prepublishPluginRegistry: true },
})}\n`,
);
const result = runHelper("scripts/docker-e2e.mjs", "github-outputs", file);
expect(result.status).toBe(0);
expect(result.stdout).toContain("needs_prepublish_plugin_registry=1");
expect(result.stdout).toContain(
'required_prepublish_plugin_packages=["@openclaw/discord","@openclaw/feishu"]',
);
});
it("rejects oversized scheduler helper JSON artifacts without a Node stack trace", () => {
const root = mkdtempSync(`${tmpdir()}/openclaw-docker-e2e-helper-`);
try {
+174 -23
View File
@@ -1,6 +1,6 @@
// Docker E2E Plan tests cover docker e2e plan script behavior.
import { execFileSync } from "node:child_process";
import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { chmodSync, copyFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
@@ -8,11 +8,13 @@ import {
RELEASE_PATH_PROFILE,
findLaneByName,
parseLaneSelection,
requiredPrepublishPluginPackagesForLanes,
resolveDockerE2ePlan,
} from "../../scripts/lib/docker-e2e-plan.mjs";
import {
allReleasePathLanes,
BUNDLED_PLUGIN_INSTALL_UNINSTALL_SHARDS,
mainLanes,
} from "../../scripts/lib/docker-e2e-scenarios.mjs";
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
@@ -81,6 +83,16 @@ function summarizeLane(lane: ReturnType<typeof planFor>["lanes"][number]) {
};
}
function trustedUpgradeSurvivorCommand(
envPrefix = "",
shellPrelude = "",
harnessDir = ".",
): string {
const prefix = envPrefix ? `${envPrefix} ` : "";
const prelude = shellPrelude ? `${shellPrelude}; ` : "";
return `OPENCLAW_DOCKER_E2E_REPO_ROOT="\${OPENCLAW_DOCKER_E2E_REPO_ROOT:-$PWD}" ${prefix}OPENCLAW_SKIP_DOCKER_BUILD=1 bash -c '${prelude}harness="\${OPENCLAW_DOCKER_E2E_TRUSTED_HARNESS_DIR:-${harnessDir}}"; OPENCLAW_LIVE_DOCKER_REPO_ROOT="\${OPENCLAW_DOCKER_E2E_REPO_ROOT:-$PWD}" bash "$harness/scripts/e2e/upgrade-survivor-docker.sh"'`;
}
function publishedUpgradeSurvivorLane(
name: string,
baselineSpec: string,
@@ -89,7 +101,10 @@ function publishedUpgradeSurvivorLane(
return {
command: `OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_DIR="$PWD/.artifacts/upgrade-survivor/${name}" OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC='${baselineSpec}' ${
scenario ? `OPENCLAW_UPGRADE_SURVIVOR_SCENARIO='${scenario}' ` : ""
}OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:published-upgrade-survivor`,
}${trustedUpgradeSurvivorCommand(
"OPENCLAW_UPGRADE_SURVIVOR_PUBLISHED_BASELINE=1",
'export OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC="${OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC:-openclaw@latest}"; export OPENCLAW_UPGRADE_SURVIVOR_DOCKER_RUN_TIMEOUT="${OPENCLAW_UPGRADE_SURVIVOR_DOCKER_RUN_TIMEOUT:-1500s}"',
)}`,
imageKind: "bare",
live: false,
name,
@@ -102,7 +117,10 @@ function publishedUpgradeSurvivorLane(
function updateMigrationLane(name: string, baselineSpec: string): ReturnType<typeof summarizeLane> {
return {
command: `OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_DIR="$PWD/.artifacts/upgrade-survivor/${name}" OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC='${baselineSpec}' OPENCLAW_UPGRADE_SURVIVOR_SCENARIO='plugin-deps-cleanup' OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:update-migration`,
command: `OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_DIR="$PWD/.artifacts/upgrade-survivor/${name}" OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC='${baselineSpec}' OPENCLAW_UPGRADE_SURVIVOR_SCENARIO='plugin-deps-cleanup' ${trustedUpgradeSurvivorCommand(
"OPENCLAW_UPGRADE_SURVIVOR_PUBLISHED_BASELINE=1",
'export OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC="${OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC:-openclaw@2026.4.23}"; export OPENCLAW_UPGRADE_SURVIVOR_SCENARIO="${OPENCLAW_UPGRADE_SURVIVOR_SCENARIO:-plugin-deps-cleanup}"',
)}`,
imageKind: "bare",
live: false,
name,
@@ -146,6 +164,26 @@ describe("scripts/lib/docker-e2e-plan", () => {
expect(plan.lanes.map((lane) => lane.name)).toEqual(["update-run-package-self-upgrade"]);
});
it("keeps trusted upgrade harness lanes without candidate package scripts", () => {
const plan = planFor({
candidatePackageRoot: writeCandidatePackage({}),
selectedLaneNames: [
"upgrade-survivor",
"published-upgrade-survivor",
"root-managed-vps-upgrade",
"update-migration",
"update-run-package-self-upgrade",
],
});
expect(plan.lanes.map((lane) => lane.name)).toEqual([
"upgrade-survivor",
"published-upgrade-survivor",
"root-managed-vps-upgrade",
"update-migration",
]);
});
it("fails when the selected candidate package manifest is missing", () => {
expect(() =>
planFor({
@@ -193,10 +231,21 @@ describe("scripts/lib/docker-e2e-plan", () => {
expect(plan.needs.functionalImage).toBe(true);
});
it("routes live Docker scripts through the nested trusted release harness", () => {
const sourceLane = allReleasePathLanes({ releaseProfile: "beta" }).find(
(candidate) => candidate.name === "live-codex-npm-plugin",
);
it("routes trusted Docker scripts through the nested release harness", () => {
const trustedScripts = new Map([
["live-codex-npm-plugin", "e2e/codex-npm-plugin-live-docker.sh"],
["upgrade-survivor", "e2e/upgrade-survivor-docker.sh"],
["published-upgrade-survivor", "e2e/upgrade-survivor-docker.sh"],
["root-managed-vps-upgrade", "e2e/upgrade-survivor-docker.sh"],
["update-migration", "e2e/upgrade-survivor-docker.sh"],
]);
const sourceLanes = [
...new Map(
[...allReleasePathLanes({ releaseProfile: "beta" }), ...mainLanes]
.filter((candidate) => trustedScripts.has(candidate.name))
.map((candidate) => [candidate.name, candidate]),
).values(),
];
const tempRoot = tempDirs.make("openclaw-release-harness-");
const nestedModule = join(
tempRoot,
@@ -206,9 +255,12 @@ describe("scripts/lib/docker-e2e-plan", () => {
"docker-e2e-scenarios.mjs",
);
expect(sourceLane?.command).toContain(
'harness="${OPENCLAW_DOCKER_E2E_TRUSTED_HARNESS_DIR:-.}"',
);
expect(sourceLanes).toHaveLength(trustedScripts.size);
for (const sourceLane of sourceLanes) {
expect(sourceLane.command).toContain(
'harness="${OPENCLAW_DOCKER_E2E_TRUSTED_HARNESS_DIR:-.}"',
);
}
mkdirSync(dirname(nestedModule), { recursive: true });
copyFileSync("scripts/lib/docker-e2e-scenarios.mjs", nestedModule);
@@ -221,10 +273,18 @@ describe("scripts/lib/docker-e2e-plan", () => {
`
import { pathToFileURL } from "node:url";
const scenarios = await import(pathToFileURL(process.argv[1]).href);
const lane = scenarios
.allReleasePathLanes({ releaseProfile: "beta" })
.find((candidate) => candidate.name === "live-codex-npm-plugin");
process.stdout.write(JSON.stringify(lane));
const names = ${JSON.stringify([...trustedScripts.keys()])};
const lanes = [
...new Map(
[
...scenarios.allReleasePathLanes({ releaseProfile: "beta" }),
...scenarios.mainLanes,
]
.filter((candidate) => names.includes(candidate.name))
.map((candidate) => [candidate.name, candidate]),
).values(),
];
process.stdout.write(JSON.stringify(lanes));
`,
nestedModule,
],
@@ -236,12 +296,51 @@ describe("scripts/lib/docker-e2e-plan", () => {
},
},
);
const lane = JSON.parse(laneJson) as { command: string };
const lanes = JSON.parse(laneJson) as Array<{ command: string; name: string }>;
expect(lane.command).toContain(
'harness="${OPENCLAW_DOCKER_E2E_TRUSTED_HARNESS_DIR:-.release-harness}"',
expect(lanes).toHaveLength(trustedScripts.size);
for (const lane of lanes) {
expect(lane.command).toContain(
'harness="${OPENCLAW_DOCKER_E2E_TRUSTED_HARNESS_DIR:-.release-harness}"',
);
expect(lane.command).toContain(`bash "$harness/scripts/${trustedScripts.get(lane.name)}"`);
expect(lane.command).not.toContain(`pnpm test:docker:${lane.name}`);
}
});
it("preserves expanded survivor env through the trusted harness wrapper", () => {
const root = tempDirs.make("openclaw-survivor-wrapper-");
const harnessRoot = join(root, ".release-harness");
const script = join(harnessRoot, "scripts/e2e/upgrade-survivor-docker.sh");
const output = join(root, "survivor-env.txt");
mkdirSync(dirname(script), { recursive: true });
writeFileSync(
script,
[
"#!/usr/bin/env bash",
'printf "%s|%s\\n" "$OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC" "$OPENCLAW_UPGRADE_SURVIVOR_SCENARIO" > "$OPENCLAW_TEST_OUTPUT"',
].join("\n"),
);
expect(lane.command).toContain('bash "$harness/scripts/e2e/codex-npm-plugin-live-docker.sh"');
chmodSync(script, 0o755);
const lane = requireFirstLane(
planFor({
selectedLaneNames: ["published-upgrade-survivor"],
upgradeSurvivorBaselines: "2026.7.2",
upgradeSurvivorScenarios: "feishu-channel",
}),
);
execFileSync("/bin/bash", ["-c", lane.command], {
cwd: root,
env: {
...process.env,
OPENCLAW_DOCKER_E2E_REPO_ROOT: root,
OPENCLAW_DOCKER_E2E_TRUSTED_HARNESS_DIR: harnessRoot,
OPENCLAW_TEST_OUTPUT: output,
},
});
expect(readFileSync(output, "utf8")).toBe("openclaw@2026.7.2|feishu-channel\n");
});
it("plans package-backed installer, Compose, and package artifact proofs", () => {
@@ -287,6 +386,7 @@ describe("scripts/lib/docker-e2e-plan", () => {
functionalImage: true,
liveImage: false,
package: true,
prepublishPluginRegistry: false,
});
});
@@ -303,6 +403,7 @@ describe("scripts/lib/docker-e2e-plan", () => {
functionalImage: true,
liveImage: true,
package: true,
prepublishPluginRegistry: true,
});
expect(plan.credentials).toEqual(["openai"]);
expect(plan.lanes.map((lane) => lane.name)).not.toContain("install-e2e-openai");
@@ -569,7 +670,7 @@ describe("scripts/lib/docker-e2e-plan", () => {
weight: 2,
},
{
command: "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:upgrade-survivor",
command: trustedUpgradeSurvivorCommand(),
imageKind: "bare",
live: false,
name: "upgrade-survivor",
@@ -579,7 +680,10 @@ describe("scripts/lib/docker-e2e-plan", () => {
weight: 3,
},
{
command: "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:published-upgrade-survivor",
command: trustedUpgradeSurvivorCommand(
"OPENCLAW_UPGRADE_SURVIVOR_PUBLISHED_BASELINE=1",
'export OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC="${OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC:-openclaw@latest}"; export OPENCLAW_UPGRADE_SURVIVOR_DOCKER_RUN_TIMEOUT="${OPENCLAW_UPGRADE_SURVIVOR_DOCKER_RUN_TIMEOUT:-1500s}"',
),
imageKind: "bare",
live: false,
name: "published-upgrade-survivor",
@@ -589,7 +693,10 @@ describe("scripts/lib/docker-e2e-plan", () => {
weight: 3,
},
{
command: "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:root-managed-vps-upgrade",
command: trustedUpgradeSurvivorCommand(
"OPENCLAW_UPGRADE_SURVIVOR_PUBLISHED_BASELINE=1 OPENCLAW_UPGRADE_SURVIVOR_ROOT_MANAGED_VPS=1",
'export OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC="${OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC:-openclaw@2026.5.7}"; export OPENCLAW_UPGRADE_SURVIVOR_DOCKER_RUN_TIMEOUT="${OPENCLAW_UPGRADE_SURVIVOR_DOCKER_RUN_TIMEOUT:-1500s}"',
),
imageKind: "bare",
live: false,
name: "root-managed-vps-upgrade",
@@ -599,8 +706,10 @@ describe("scripts/lib/docker-e2e-plan", () => {
weight: 3,
},
{
command:
'OPENCLAW_DOCKER_E2E_REPO_ROOT="${OPENCLAW_DOCKER_E2E_REPO_ROOT:-$PWD}" OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC=${OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC:-openclaw@latest} OPENCLAW_UPGRADE_SURVIVOR_PUBLISHED_BASELINE=1 OPENCLAW_UPGRADE_SURVIVOR_UPDATE_RESTART_MODE=auto-auth OPENCLAW_UPGRADE_SURVIVOR_DOCKER_RUN_TIMEOUT=${OPENCLAW_UPGRADE_SURVIVOR_DOCKER_RUN_TIMEOUT:-1500s} OPENCLAW_SKIP_DOCKER_BUILD=1 bash -c \'harness="${OPENCLAW_DOCKER_E2E_TRUSTED_HARNESS_DIR:-.}"; OPENCLAW_LIVE_DOCKER_REPO_ROOT="${OPENCLAW_DOCKER_E2E_REPO_ROOT:-$PWD}" bash "$harness/scripts/e2e/upgrade-survivor-docker.sh"\'',
command: trustedUpgradeSurvivorCommand(
"OPENCLAW_UPGRADE_SURVIVOR_PUBLISHED_BASELINE=1 OPENCLAW_UPGRADE_SURVIVOR_UPDATE_RESTART_MODE=auto-auth",
'export OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC="${OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC:-openclaw@latest}"; export OPENCLAW_UPGRADE_SURVIVOR_DOCKER_RUN_TIMEOUT="${OPENCLAW_UPGRADE_SURVIVOR_DOCKER_RUN_TIMEOUT:-1500s}"',
),
imageKind: "bare",
live: false,
name: "update-restart-auth",
@@ -1237,6 +1346,7 @@ describe("scripts/lib/docker-e2e-plan", () => {
functionalImage: false,
liveImage: true,
package: false,
prepublishPluginRegistry: false,
});
});
@@ -1310,6 +1420,7 @@ describe("scripts/lib/docker-e2e-plan", () => {
functionalImage: false,
liveImage: true,
package: true,
prepublishPluginRegistry: false,
});
});
@@ -1442,6 +1553,7 @@ describe("scripts/lib/docker-e2e-plan", () => {
functionalImage: true,
liveImage: true,
package: true,
prepublishPluginRegistry: false,
});
});
@@ -1470,6 +1582,7 @@ describe("scripts/lib/docker-e2e-plan", () => {
functionalImage: true,
liveImage: false,
package: true,
prepublishPluginRegistry: false,
});
});
@@ -1539,6 +1652,43 @@ describe("scripts/lib/docker-e2e-plan", () => {
]);
});
it("derives prerelease npm companions from selected survivor recipes", () => {
const basePlan = planFor({ selectedLaneNames: ["published-upgrade-survivor"] });
expect(basePlan.requiredPrepublishPluginPackages).toEqual(["@openclaw/discord"]);
expect(basePlan.needs.prepublishPluginRegistry).toBe(true);
const feishuPlan = planFor({
selectedLaneNames: ["published-upgrade-survivor"],
upgradeSurvivorBaselines: "2026.7.2",
upgradeSurvivorScenarios: "base feishu-channel",
});
expect(feishuPlan.requiredPrepublishPluginPackages).toEqual([
"@openclaw/discord",
"@openclaw/feishu",
]);
expect(
planFor({ selectedLaneNames: ["root-managed-vps-upgrade"] }).requiredPrepublishPluginPackages,
).toEqual(["@openclaw/discord"]);
expect(
planFor({ selectedLaneNames: ["update-migration"] }).requiredPrepublishPluginPackages,
).toEqual(["@openclaw/discord"]);
const legacyFeishuPlan = planFor({
selectedLaneNames: ["published-upgrade-survivor"],
upgradeSurvivorBaselines: "2026.3.13",
upgradeSurvivorScenarios: "feishu-channel",
});
expect(legacyFeishuPlan.requiredPrepublishPluginPackages).toEqual(["@openclaw/discord"]);
const selfUpgradeLane = findLaneByName("update-run-package-self-upgrade");
expect(selfUpgradeLane).toBeDefined();
expect(requiredPrepublishPluginPackagesForLanes([selfUpgradeLane!])).toEqual([]);
});
it("does not request a prerelease plugin registry for unrelated lanes", () => {
const plan = planFor({ selectedLaneNames: ["doctor-switch"] });
expect(plan.requiredPrepublishPluginPackages).toEqual([]);
expect(plan.needs.prepublishPluginRegistry).toBe(false);
});
it("maps installer E2E to provider-specific package install lanes", () => {
const selectedLaneNames = parseLaneSelection("install-e2e");
const plan = planFor({ selectedLaneNames });
@@ -1597,6 +1747,7 @@ describe("scripts/lib/docker-e2e-plan", () => {
functionalImage: true,
liveImage: false,
package: true,
prepublishPluginRegistry: false,
});
});
@@ -2293,6 +2293,35 @@ describe("package artifact reuse", () => {
expect(workflow).toContain("OPENCLAW_DOCKER_E2E_REPO_ROOT:");
expect(workflow).toContain("node .release-harness/scripts/test-docker-all.mjs --plan-json");
expect(workflow).toContain("node .release-harness/scripts/docker-e2e.mjs github-outputs");
expect(parsedWorkflow.on?.workflow_call?.inputs).toHaveProperty(
"enable_prepublish_plugin_registry",
);
expect(workflow).toContain("Pack prerelease plugin registry artifact");
expect(workflow).toContain("Validate prerelease plugin registry artifact");
expect(workflow).toContain("Download targeted prerelease plugin registry artifact");
expect(workflow).toContain("OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR");
expect(workflow).toContain("prepublishPluginRegistryManifestSha256");
expect(
workflowStep(
workflowJob(LIVE_E2E_WORKFLOW, "prepare_docker_e2e_image"),
"Pack prerelease plugin registry artifact",
).id,
).toBe("create_prepublish_plugin_registry");
expect(
workflowStep(
workflowJob(LIVE_E2E_WORKFLOW, "prepare_docker_e2e_image"),
"Validate prerelease plugin registry artifact",
).env?.EXPECTED_MANIFEST_SHA256,
).toBe(
"${{ steps.create_prepublish_plugin_registry.outputs.manifest_sha256 || inputs.prepublish_plugin_registry_manifest_sha256 }}",
);
expect(workflow).toContain(
"if: inputs.enable_prepublish_plugin_registry && steps.plan.outputs.needs_prepublish_plugin_registry == '1'",
);
expect(
workflowJob(LIVE_E2E_WORKFLOW, "prepare_docker_e2e_image").outputs
?.prepublish_plugin_registry_artifact_id,
).toContain("inputs.enable_prepublish_plugin_registry");
expect(workflow).toContain("bash .release-harness/scripts/ci-docker-pull-retry.sh");
const prepareDockerImage = workflowJob(LIVE_E2E_WORKFLOW, "prepare_docker_e2e_image");
expect(workflowStep(prepareDockerImage, "Plan Docker E2E images").env).toEqual({
@@ -2406,9 +2435,13 @@ describe("package artifact reuse", () => {
expect(prepare.uses).toBe("./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml");
expect(prepare.with).toMatchObject({
enable_prepublish_plugin_registry: true,
prepare_only: true,
shared_image_policy: "no-push-artifact",
});
expect(prepare.with?.published_upgrade_survivor_scenarios).toBe(
"${{ (inputs.run_release_soak || inputs.release_profile == 'stable' || inputs.release_profile == 'full') && 'reported-issues' || '' }}",
);
expect(pluginDispatch.run).toContain(
'args+=(-f candidate_artifact_json="$CANDIDATE_ARTIFACT_JSON")',
);
@@ -2418,6 +2451,14 @@ describe("package artifact reuse", () => {
expect(workflow).toContain("Shared release candidate preparation ended with");
});
it("enables prerelease plugin companions for scheduled ref validation", () => {
const scheduled = workflowJob(SCHEDULED_LIVE_CHECKS_WORKFLOW, "live_and_openwebui_checks");
expect(scheduled.with).toMatchObject({
enable_prepublish_plugin_registry: true,
ref: "${{ github.sha }}",
});
});
it("gives memory extension shards enough CPU without lowering their planner cost", () => {
const workflow = readFileSync(PLUGIN_PRERELEASE_WORKFLOW, "utf8");
@@ -3187,7 +3228,14 @@ describe("package artifact reuse", () => {
expect(dockerAcceptanceJob.with).toMatchObject({
allow_frozen_target_scenario_omissions:
"${{ inputs.allow_frozen_target_scenario_omissions || false }}",
enable_prepublish_plugin_registry:
'${{ contains(fromJSON(\'["artifact","ref"]\'), inputs.source) }}',
prepublish_plugin_registry_manifest_sha256:
"${{ fromJSON(inputs.candidate_artifact_json || '{}').prepublishPluginRegistryManifestSha256 || '' }}",
});
expect(workflow).toContain(
"candidate_artifact_json cannot be combined with release package specs.",
);
expect(workflow).toContain(
"live_repo_e2e_release_checks:\n name: Run repo/live E2E validation\n needs: [resolve_target]",
);
@@ -5561,6 +5609,51 @@ wait_for_run plugin-clawhub-new.yml 123 "${expectedSha}" || status=$?
});
expect(valid.status, valid.stderr).toBe(0);
const registryCandidate = {
...candidate,
prepublishPluginRegistryArtifactName: "docker-e2e-prepublish-plugin-registry-456-1",
prepublishPluginRegistryArtifactId: "790",
prepublishPluginRegistryArtifactDigest: "f".repeat(64),
prepublishPluginRegistryArtifactRunId: "456",
prepublishPluginRegistryArtifactRunAttempt: "1",
prepublishPluginRegistryManifestSha256: "1".repeat(64),
};
const validRegistry = spawnSync("bash", ["-c", validation ?? ""], {
encoding: "utf8",
env: {
...process.env,
CANDIDATE_ARTIFACT_JSON: JSON.stringify(registryCandidate),
SELECTED_SHA: selectedSha,
},
});
expect(validRegistry.status, validRegistry.stderr).toBe(0);
const partialRegistry = spawnSync("bash", ["-c", validation ?? ""], {
encoding: "utf8",
env: {
...process.env,
CANDIDATE_ARTIFACT_JSON: JSON.stringify({
...candidate,
prepublishPluginRegistryArtifactId: "790",
}),
SELECTED_SHA: selectedSha,
},
});
expect(partialRegistry.status).not.toBe(0);
const mismatchedRegistryName = spawnSync("bash", ["-c", validation ?? ""], {
encoding: "utf8",
env: {
...process.env,
CANDIDATE_ARTIFACT_JSON: JSON.stringify({
...registryCandidate,
prepublishPluginRegistryArtifactName: "docker-e2e-prepublish-plugin-registry-999-1",
}),
SELECTED_SHA: selectedSha,
},
});
expect(mismatchedRegistryName.status).not.toBe(0);
const mismatched = spawnSync("bash", ["-c", validation ?? ""], {
encoding: "utf8",
env: {
@@ -0,0 +1,281 @@
import { execFileSync, spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
PREPUBLISH_PLUGIN_REGISTRY_MANIFEST,
createPrepublishPluginRegistryArtifact,
validatePrepublishPluginRegistryArtifact,
} from "../../scripts/prepublish-plugin-registry-artifact.mjs";
const SOURCE_SHA = "a".repeat(40);
const VERSION = "2026.8.1-beta.1";
const PACKAGE_NAME = "@openclaw/discord";
const TARBALL = "openclaw-discord-2026.8.1-beta.1.tgz";
const SCRIPT = path.resolve("scripts/prepublish-plugin-registry-artifact.mjs");
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { force: true, recursive: true });
}
});
function sha256(file: string): string {
return createHash("sha256").update(readFileSync(file)).digest("hex");
}
function fixture() {
const root = mkdtempSync(path.join(tmpdir(), "openclaw-prepublish-plugin-registry-"));
tempDirs.push(root);
const packageRoot = path.join(root, "package");
const artifactDir = path.join(root, "artifact");
mkdirSync(packageRoot);
mkdirSync(artifactDir);
writeFileSync(
path.join(packageRoot, "package.json"),
`${JSON.stringify({ name: PACKAGE_NAME, version: VERSION })}\n`,
);
const tarballPath = path.join(artifactDir, TARBALL);
execFileSync("tar", ["-czf", tarballPath, "-C", root, "package"]);
const manifestPath = path.join(artifactDir, PREPUBLISH_PLUGIN_REGISTRY_MANIFEST);
const manifest = {
schema: "openclaw.prepublish-plugin-registry/v1",
schemaVersion: 1,
sourceSha: SOURCE_SHA,
candidateVersion: VERSION,
packages: [
{
name: PACKAGE_NAME,
version: VERSION,
tarball: TARBALL,
sha256: sha256(tarballPath),
},
],
};
const writeManifest = () => {
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
};
writeManifest();
return { artifactDir, manifest, manifestPath, tarballPath, writeManifest };
}
function validate(paths: ReturnType<typeof fixture>, overrides = {}) {
return validatePrepublishPluginRegistryArtifact({
artifactDir: paths.artifactDir,
expectedCandidateVersion: VERSION,
expectedManifestSha256: sha256(paths.manifestPath),
expectedSourceSha: SOURCE_SHA,
requiredPackages: [PACKAGE_NAME],
...overrides,
});
}
function firstPackage(paths: ReturnType<typeof fixture>) {
const [entry] = paths.manifest.packages;
if (!entry) {
throw new Error("fixture manifest must contain one package");
}
return entry;
}
function cliFixture() {
const repoRoot = mkdtempSync(path.join(tmpdir(), "openclaw-prepublish-plugin-cli-"));
tempDirs.push(repoRoot);
const packageDir = path.join(repoRoot, "extensions", "discord");
const scriptsDir = path.join(repoRoot, "scripts", "lib");
mkdirSync(packageDir, { recursive: true });
mkdirSync(scriptsDir, { recursive: true });
writeFileSync(
path.join(repoRoot, "package.json"),
`${JSON.stringify({ name: "openclaw", version: VERSION })}\n`,
);
writeFileSync(
path.join(packageDir, "package.json"),
`${JSON.stringify({
name: PACKAGE_NAME,
version: VERSION,
openclaw: { release: { publishToNpm: true } },
})}\n`,
);
writeFileSync(
path.join(scriptsDir, "plugin-npm-runtime-build.mjs"),
'console.log("runtime build stdout");\n',
);
writeFileSync(
path.join(scriptsDir, "plugin-npm-package-manifest.mjs"),
`import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
const repoRoot = process.cwd();
const packageDir = process.argv[process.argv.indexOf("--run") + 1];
const outputDir = process.argv[process.argv.indexOf("--pack-destination") + 1];
const staging = path.join(repoRoot, ".pack-fixture");
fs.mkdirSync(path.join(staging, "package"), { recursive: true });
fs.copyFileSync(path.join(repoRoot, packageDir, "package.json"), path.join(staging, "package", "package.json"));
execFileSync("tar", ["-czf", path.join(outputDir, "${TARBALL}"), "-C", staging, "package"]);
console.log("package manifest stdout");
`,
);
execFileSync("git", ["init"], { cwd: repoRoot });
execFileSync("git", ["config", "user.email", "release-test@example.invalid"], {
cwd: repoRoot,
});
execFileSync("git", ["config", "user.name", "Release Test"], { cwd: repoRoot });
execFileSync("git", ["config", "commit.gpgsign", "false"], { cwd: repoRoot });
execFileSync("git", ["add", "."], { cwd: repoRoot });
execFileSync("git", ["commit", "-m", "test: seed release source"], { cwd: repoRoot });
const sourceSha = execFileSync("git", ["rev-parse", "HEAD"], {
cwd: repoRoot,
encoding: "utf8",
}).trim();
return { repoRoot, sourceSha };
}
describe("prepublish plugin registry artifact", () => {
it("validates the immutable manifest, package set, hashes, and packed identity", () => {
const paths = fixture();
const result = validate(paths);
expect(result.manifest.packages.map((entry) => entry.name)).toEqual([PACKAGE_NAME]);
});
it("requires the complete immutable identity tuple", () => {
const paths = fixture();
const common = {
artifactDir: paths.artifactDir,
expectedCandidateVersion: VERSION,
expectedManifestSha256: sha256(paths.manifestPath),
expectedSourceSha: SOURCE_SHA,
requiredPackages: [PACKAGE_NAME],
};
for (const field of [
"expectedCandidateVersion",
"expectedManifestSha256",
"expectedSourceSha",
] as const) {
expect(() =>
validatePrepublishPluginRegistryArtifact({ ...common, [field]: undefined }),
).toThrow(field);
}
});
it("refuses to create an artifact from tracked changes under the same HEAD", () => {
const repoRoot = mkdtempSync(path.join(tmpdir(), "openclaw-prepublish-plugin-source-"));
tempDirs.push(repoRoot);
writeFileSync(
path.join(repoRoot, "package.json"),
`${JSON.stringify({ name: "openclaw", version: VERSION })}\n`,
);
execFileSync("git", ["init"], { cwd: repoRoot });
execFileSync("git", ["config", "user.email", "release-test@example.invalid"], {
cwd: repoRoot,
});
execFileSync("git", ["config", "user.name", "Release Test"], { cwd: repoRoot });
execFileSync("git", ["config", "commit.gpgsign", "false"], { cwd: repoRoot });
execFileSync("git", ["add", "package.json"], { cwd: repoRoot });
execFileSync("git", ["commit", "-m", "test: seed release source"], { cwd: repoRoot });
const sourceSha = execFileSync("git", ["rev-parse", "HEAD"], {
cwd: repoRoot,
encoding: "utf8",
}).trim();
writeFileSync(
path.join(repoRoot, "package.json"),
`${JSON.stringify({ name: "openclaw", version: `${VERSION}-dirty` })}\n`,
);
expect(() =>
createPrepublishPluginRegistryArtifact({
repoRoot,
outputDir: path.join(repoRoot, "artifact"),
sourceSha,
candidateVersion: VERSION,
requiredPackages: [],
}),
).toThrow("tracked changes");
});
it("keeps noisy package commands off the CLI JSON stdout contract", () => {
const { repoRoot, sourceSha } = cliFixture();
const artifactDir = path.join(repoRoot, "artifact");
const result = spawnSync(
process.execPath,
[
SCRIPT,
"create",
"--repo-root",
repoRoot,
"--artifact-dir",
artifactDir,
"--source-sha",
sourceSha,
"--candidate-version",
VERSION,
"--required-packages-json",
JSON.stringify([PACKAGE_NAME]),
],
{ cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
);
expect(result.status).toBe(0);
expect(JSON.parse(result.stdout)).toMatchObject({
manifestSha256: expect.stringMatching(/^[0-9a-f]{64}$/u),
packages: [PACKAGE_NAME],
});
expect(result.stderr).toContain("runtime build stdout");
expect(result.stderr).toContain("package manifest stdout");
});
it("rejects traversal and duplicate package entries", () => {
const traversal = fixture();
firstPackage(traversal).tarball = "../escape.tgz";
traversal.writeManifest();
expect(() => validate(traversal)).toThrow("invalid package entry");
const duplicate = fixture();
duplicate.manifest.packages.push({ ...firstPackage(duplicate) });
duplicate.writeManifest();
expect(() =>
validate(duplicate, { requiredPackages: [PACKAGE_NAME, "@openclaw/feishu"] }),
).toThrow("duplicate package");
});
it("rejects missing and extra artifact files", () => {
const missing = fixture();
unlinkSync(missing.tarballPath);
expect(() => validate(missing)).toThrow("missing, extra, or non-file");
const extra = fixture();
writeFileSync(path.join(extra.artifactDir, "extra.txt"), "unexpected");
expect(() => validate(extra)).toThrow("missing, extra, or non-file");
});
it("rejects hash, identity, version, source SHA, and required-set mismatches", () => {
const hash = fixture();
writeFileSync(hash.tarballPath, "tampered");
expect(() => validate(hash)).toThrow("tarball SHA-256 mismatch");
const identity = fixture();
firstPackage(identity).name = "@openclaw/feishu";
identity.writeManifest();
expect(() => validate(identity, { requiredPackages: ["@openclaw/feishu"] })).toThrow(
"tarball identity mismatch",
);
const version = fixture();
expect(() => validate(version, { expectedCandidateVersion: "2026.8.1-beta.2" })).toThrow(
"version differs",
);
const source = fixture();
expect(() => validate(source, { expectedSourceSha: "b".repeat(40) })).toThrow(
"source SHA differs",
);
const required = fixture();
expect(() => validate(required, { requiredPackages: ["@openclaw/feishu"] })).toThrow(
"package set differs",
);
});
});
@@ -68,6 +68,13 @@ const WORKFLOW_CALL_ONLY_INPUTS = new Set([
"package_source_sha",
"package_sha256",
"package_version",
"enable_prepublish_plugin_registry",
"prepublish_plugin_registry_artifact_name",
"prepublish_plugin_registry_artifact_id",
"prepublish_plugin_registry_artifact_digest",
"prepublish_plugin_registry_artifact_run_id",
"prepublish_plugin_registry_artifact_run_attempt",
"prepublish_plugin_registry_manifest_sha256",
"shared_image_artifact_name",
"shared_image_artifact_id",
"shared_image_artifact_digest",
@@ -9,6 +9,8 @@ import {
CONFIG_COMMAND_TIMEOUT_MS,
isReleaseBefore,
resolveScenarioConfigSteps,
resolveUpgradeSurvivorConfigSteps,
resolveUpgradeSurvivorConfigStepsForBaseline,
resolveUpgradeSurvivorOpenClawCommand,
runUpgradeSurvivorOpenClawStep,
} from "../../scripts/e2e/lib/upgrade-survivor/config-recipe.mjs";
@@ -78,6 +80,20 @@ describe("upgrade survivor config recipe command resolution", () => {
]);
});
it("inserts scenario config before final validation", () => {
const steps = resolveUpgradeSurvivorConfigSteps("feishu-channel");
expect(steps.find((step) => step.id === "channels-discord")).toBeDefined();
expect(steps.find((step) => step.id === "channels-feishu")).toBeDefined();
expect(steps.at(-1)?.id).toBe("validate");
});
it("removes unsupported scenario config for older baselines", () => {
const steps = resolveUpgradeSurvivorConfigStepsForBaseline("feishu-channel", "2026.3.13");
expect(steps.find((step) => step.id === "channels-discord")).toBeDefined();
expect(steps.find((step) => step.id === "channels-feishu")).toBeUndefined();
expect(steps.at(-1)?.id).toBe("validate");
});
it("bounds baseline config commands and reports spawn errors", () => {
const calls: unknown[] = [];
const timeoutError = Object.assign(new Error("spawnSync openclaw ETIMEDOUT"), {