diff --git a/.github/workflows/full-release-validation.yml b/.github/workflows/full-release-validation.yml index 67a0ed600ab1..42f56d68aa60 100644 --- a/.github/workflows/full-release-validation.yml +++ b/.github/workflows/full-release-validation.yml @@ -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 diff --git a/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml b/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml index bafd5968b7b9..086f7673de60 100644 --- a/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml +++ b/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml @@ -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: diff --git a/.github/workflows/openclaw-release-checks.yml b/.github/workflows/openclaw-release-checks.yml index 23581fc2e4c9..4f285dd9b7b6 100644 --- a/.github/workflows/openclaw-release-checks.yml +++ b/.github/workflows/openclaw-release-checks.yml @@ -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 || '' }} diff --git a/.github/workflows/openclaw-scheduled-live-checks.yml b/.github/workflows/openclaw-scheduled-live-checks.yml index 4a082703f5d4..a9fbc43849c3 100644 --- a/.github/workflows/openclaw-scheduled-live-checks.yml +++ b/.github/workflows/openclaw-scheduled-live-checks.yml @@ -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: diff --git a/.github/workflows/package-acceptance.yml b/.github/workflows/package-acceptance.yml index be2ae7463f3c..272f8cfb5f63 100644 --- a/.github/workflows/package-acceptance.yml +++ b/.github/workflows/package-acceptance.yml @@ -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 }} diff --git a/.github/workflows/plugin-prerelease.yml b/.github/workflows/plugin-prerelease.yml index 7fb9b88e8c03..dccfa2daefe0 100644 --- a/.github/workflows/plugin-prerelease.yml +++ b/.github/workflows/plugin-prerelease.yml @@ -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 || '' }} diff --git a/scripts/docker-e2e.mjs b/scripts/docker-e2e.mjs index f67db1c8b350..4396e7859d04 100644 --- a/scripts/docker-e2e.mjs +++ b/scripts/docker-e2e.mjs @@ -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 ?? [], + )}`, ]; } diff --git a/scripts/e2e/lib/upgrade-survivor/config-recipe.d.mts b/scripts/e2e/lib/upgrade-survivor/config-recipe.d.mts index 5a00b89d138e..87d2c191c0f4 100644 --- a/scripts/e2e/lib/upgrade-survivor/config-recipe.d.mts +++ b/scripts/e2e/lib/upgrade-survivor/config-recipe.d.mts @@ -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, diff --git a/scripts/e2e/lib/upgrade-survivor/config-recipe.mjs b/scripts/e2e/lib/upgrade-survivor/config-recipe.mjs index c8535c6821a6..e6b8986b8103 100644 --- a/scripts/e2e/lib/upgrade-survivor/config-recipe.mjs +++ b/scripts/e2e/lib/upgrade-survivor/config-recipe.mjs @@ -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; diff --git a/scripts/e2e/lib/upgrade-survivor/run.sh b/scripts/e2e/lib/upgrade-survivor/run.sh index 08bcf0eece69..e8dd9a7ded6c 100644 --- a/scripts/e2e/lib/upgrade-survivor/run.sh +++ b/scripts/e2e/lib/upgrade-survivor/run.sh @@ -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 diff --git a/scripts/e2e/upgrade-survivor-docker.sh b/scripts/e2e/upgrade-survivor-docker.sh index a8b464124ef9..dbfddfa0783f 100755 --- a/scripts/e2e/upgrade-survivor-docker.sh +++ b/scripts/e2e/upgrade-survivor-docker.sh @@ -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 diff --git a/scripts/lib/docker-e2e-plan.d.mts b/scripts/lib/docker-e2e-plan.d.mts index a151e597d24d..158f586971a6 100644 --- a/scripts/lib/docker-e2e-plan.d.mts +++ b/scripts/lib/docker-e2e-plan.d.mts @@ -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[]; diff --git a/scripts/lib/docker-e2e-plan.mjs b/scripts/lib/docker-e2e-plan.mjs index 8e9a0fc3a3ca..a3fa3c84ee33 100644 --- a/scripts/lib/docker-e2e-plan.mjs +++ b/scripts/lib/docker-e2e-plan.mjs @@ -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, diff --git a/scripts/lib/docker-e2e-scenarios.d.mts b/scripts/lib/docker-e2e-scenarios.d.mts index b8d9412c58b1..46ae9bc15c6f 100644 --- a/scripts/lib/docker-e2e-scenarios.d.mts +++ b/scripts/lib/docker-e2e-scenarios.d.mts @@ -16,6 +16,7 @@ export type DockerE2eLane = { retryPatterns: RegExp[]; stateScenario?: string; timeoutMs?: number; + upgradeSurvivorScenario?: string; weight: number; }; diff --git a/scripts/lib/docker-e2e-scenarios.mjs b/scripts/lib/docker-e2e-scenarios.mjs index 0e5cfb80f99f..37652e29a98a 100644 --- a/scripts/lib/docker-e2e-scenarios.mjs +++ b/scripts/lib/docker-e2e-scenarios.mjs @@ -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", { diff --git a/scripts/prepublish-plugin-registry-artifact.d.mts b/scripts/prepublish-plugin-registry-artifact.d.mts new file mode 100644 index 000000000000..eb709e9afe33 --- /dev/null +++ b/scripts/prepublish-plugin-registry-artifact.d.mts @@ -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; diff --git a/scripts/prepublish-plugin-registry-artifact.mjs b/scripts/prepublish-plugin-registry-artifact.mjs new file mode 100644 index 000000000000..73e00d1a7143 --- /dev/null +++ b/scripts/prepublish-plugin-registry-artifact.mjs @@ -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 [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); + } +} diff --git a/scripts/test-docker-all.mjs b/scripts/test-docker-all.mjs index b9840a951c71..02f5ca705521 100644 --- a/scripts/test-docker-all.mjs +++ b/scripts/test-docker-all.mjs @@ -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(", ")}`, diff --git a/test/scripts/docker-all-scheduler.test.ts b/test/scripts/docker-all-scheduler.test.ts index 2cc8f801b624..bc90a01253fb 100644 --- a/test/scripts/docker-all-scheduler.test.ts +++ b/test/scripts/docker-all-scheduler.test.ts @@ -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, diff --git a/test/scripts/docker-build-helper.test.ts b/test/scripts/docker-build-helper.test.ts index e76d8044d5ca..b5af6c31ec7b 100644 --- a/test/scripts/docker-build-helper.test.ts +++ b/test/scripts/docker-build-helper.test.ts @@ -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, diff --git a/test/scripts/docker-e2e-helper-cli.test.ts b/test/scripts/docker-e2e-helper-cli.test.ts index 82e4b181b9c2..ce90c0974d8f 100644 --- a/test/scripts/docker-e2e-helper-cli.test.ts +++ b/test/scripts/docker-e2e-helper-cli.test.ts @@ -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 { diff --git a/test/scripts/docker-e2e-plan.test.ts b/test/scripts/docker-e2e-plan.test.ts index 5513b3d3b5ac..f7afc546f1ee 100644 --- a/test/scripts/docker-e2e-plan.test.ts +++ b/test/scripts/docker-e2e-plan.test.ts @@ -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["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 { 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, }); }); diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index 2961293532a1..5744f593976b 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -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: { diff --git a/test/scripts/prepublish-plugin-registry-artifact.test.ts b/test/scripts/prepublish-plugin-registry-artifact.test.ts new file mode 100644 index 000000000000..60ec669cffe1 --- /dev/null +++ b/test/scripts/prepublish-plugin-registry-artifact.test.ts @@ -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, overrides = {}) { + return validatePrepublishPluginRegistryArtifact({ + artifactDir: paths.artifactDir, + expectedCandidateVersion: VERSION, + expectedManifestSha256: sha256(paths.manifestPath), + expectedSourceSha: SOURCE_SHA, + requiredPackages: [PACKAGE_NAME], + ...overrides, + }); +} + +function firstPackage(paths: ReturnType) { + 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", + ); + }); +}); diff --git a/test/scripts/release-workflow-matrix-plan.test.ts b/test/scripts/release-workflow-matrix-plan.test.ts index 4adc1ee3eba6..85540a43d55e 100644 --- a/test/scripts/release-workflow-matrix-plan.test.ts +++ b/test/scripts/release-workflow-matrix-plan.test.ts @@ -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", diff --git a/test/scripts/upgrade-survivor-config-recipe.test.ts b/test/scripts/upgrade-survivor-config-recipe.test.ts index d1305d3e0020..a618c6a71bb3 100644 --- a/test/scripts/upgrade-survivor-config-recipe.test.ts +++ b/test/scripts/upgrade-survivor-config-recipe.test.ts @@ -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"), {