diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd7632bbbf48..bedce13defa8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -545,12 +545,13 @@ jobs: OPENCLAW_CI_CHANGED_PATHS_JSON: ${{ steps.changed_scope.outputs.changed_paths_json || 'null' }} OPENCLAW_CI_CHECKOUT_REVISION: ${{ steps.checkout_ref.outputs.sha }} OPENCLAW_CI_HISTORICAL_TARGET: ${{ steps.historical_target.outputs.eligible || 'false' }} + OPENCLAW_CI_RELEASE_GATE: ${{ inputs.release_gate && 'true' || 'false' }} OPENCLAW_CI_RELEASE_CANDIDATE_TARGET: ${{ steps.release_candidate_target.outputs.eligible || 'false' }} OPENCLAW_CI_TARGET_CONTEXT_TARGET: ${{ steps.target_context_target.outputs.eligible || 'false' }} OPENCLAW_CI_WORKFLOW_REVISION: ${{ github.sha }} OPENCLAW_CI_REPOSITORY: ${{ github.repository }} OPENCLAW_CI_EVENT_NAME: ${{ github.event_name }} - OPENCLAW_CI_RUNNER_BACKEND: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 'github' || vars.OPENCLAW_CI_RUNNER_BACKEND }} + OPENCLAW_CI_RUNNER_BACKEND: ${{ (github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)) && 'github' || vars.OPENCLAW_CI_RUNNER_BACKEND }} run: | manifest_node_args=() if [ "${GITHUB_EVENT_NAME:-}" = "workflow_dispatch" ]; then @@ -668,6 +669,7 @@ jobs: })(); const docsOnly = parseBoolean(process.env.OPENCLAW_CI_DOCS_ONLY); const docsChanged = parseBoolean(process.env.OPENCLAW_CI_DOCS_CHANGED); + const releaseGate = parseBoolean(process.env.OPENCLAW_CI_RELEASE_GATE); const runNode = parseBoolean(process.env.OPENCLAW_CI_RUN_NODE) && !docsOnly; const runNodeFastOnly = runNode && parseBoolean(process.env.OPENCLAW_CI_RUN_NODE_FAST_ONLY); @@ -755,6 +757,24 @@ jobs: }); } } + if (releaseGate) { + checksFastCoreTasks.push( + ...Array.from({ length: 5 }, (_, index) => { + const stripe = index + 1; + return { + check_name: `checks-fast-release-lint-core-${stripe}`, + runtime: "node", + stripe, + task: `release-lint-core-${stripe}`, + }; + }), + { + check_name: "checks-fast-release-lint-extensions", + runtime: "node", + task: "release-lint-extensions", + }, + ); + } const compactPullRequest = isCanonicalRepository && eventName === "pull_request"; const dockerSeedLanes = @@ -1930,11 +1950,11 @@ jobs: max-parallel: 12 matrix: ${{ fromJson(needs.preflight.outputs.checks_fast_core_matrix) }} env: - CHECKOUT_BASE_SHA: ${{ matrix.task == 'baseline-ratchets' && needs.preflight.outputs.diff_base_revision || '' }} + CHECKOUT_BASE_SHA: ${{ (matrix.task == 'baseline-ratchets' || startsWith(matrix.task, 'release-lint-')) && needs.preflight.outputs.diff_base_revision || '' }} steps: - *linux_node_checkout_step - name: Prepare release-gate ratchet merge tree - if: matrix.task == 'baseline-ratchets' && github.event_name == 'workflow_dispatch' && inputs.release_gate + if: (matrix.task == 'baseline-ratchets' || startsWith(matrix.task, 'release-lint-')) && github.event_name == 'workflow_dispatch' && inputs.release_gate env: GH_TOKEN: ${{ github.token }} PULL_REQUEST_NUMBER: ${{ inputs.pull_request_number }} @@ -1976,7 +1996,6 @@ jobs: fi git checkout --detach "$merge_sha" echo "RATCHET_BASE_REF=${frozen_base_sha}" >> "$GITHUB_ENV" - echo "RATCHET_RELEASE_MERGE_TREE=true" >> "$GITHUB_ENV" - name: Setup Node environment uses: ./.ci-harness/.github/actions/setup-node-env @@ -2046,9 +2065,14 @@ jobs: fi pnpm check:max-lines-ratchet --base "$base_ref" pnpm check:assertion-safety --base "$base_ref" - if [[ "${RATCHET_RELEASE_MERGE_TREE:-}" == "true" ]]; then - node --import tsx scripts/run-oxlint-shards.mts --only=core --only=extensions --threads=1 - fi + ;; + release-lint-core-*) + stripe="${TASK#release-lint-core-}" + node --import tsx scripts/run-oxlint-shards.mts \ + --only=core --split-core --core-stripe="${stripe}/5" --threads=1 + ;; + release-lint-extensions) + node --import tsx scripts/run-oxlint-shards.mts --only=extensions --threads=1 ;; bun-launcher) OPENCLAW_E2E_SKIP_BUILD=1 OPENCLAW_TEST_BUN_LAUNCHER=1 pnpm test test/openclaw-launcher.e2e.test.ts @@ -2636,9 +2660,10 @@ jobs: FROZEN_TARGET: ${{ needs.preflight.outputs.frozen_target }} HISTORICAL_TARGET: ${{ needs.preflight.outputs.compatibility_target }} FORMAT_CHECK: ${{ needs.preflight.outputs.run_format_check }} + RELEASE_GATE: ${{ inputs.release_gate && 'true' || 'false' }} RUN_CONTROL_UI_I18N: ${{ needs.preflight.outputs.run_control_ui_i18n }} RUN_UI_TESTS: ${{ needs.preflight.outputs.run_ui_tests }} - RUNNER_BACKEND: ${{ (vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' || vars.OPENCLAW_CI_RUNNER_BACKEND == 'hybrid' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)) && 'github' || vars.OPENCLAW_CI_RUNNER_BACKEND }} + RUNNER_BACKEND: ${{ (vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' || vars.OPENCLAW_CI_RUNNER_BACKEND == 'hybrid' || github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)) && 'github' || vars.OPENCLAW_CI_RUNNER_BACKEND }} OPENCLAW_LOCAL_CHECK: "0" TASK: ${{ matrix.task }} PR_BASE_SHA: ${{ github.event_name == 'pull_request' && needs.preflight.outputs.diff_base_revision || '' }} @@ -2735,9 +2760,12 @@ jobs: lint_args=(--threads=8) if [ "$RUNNER_BACKEND" = "github" ]; then # Five dedicated hosted jobs own the aggregated core Programs. Keep - # extension preparation, optional UI checks, and formatting in - # this sixth lane so each 4-core runner stays below ~6m. + # extension preparation in this sixth lane except when the exact + # merge-tree release lint job already owns it. lint_args=(--only=extensions --only=scripts --threads=1) + if [ "$RELEASE_GATE" = "true" ]; then + lint_args=(--only=scripts --threads=1) + fi export GOMAXPROCS=2 elif [ "$(nproc)" -lt 8 ]; then # Fork PRs build each semantic Program once. Bound both oxlint @@ -2875,7 +2903,7 @@ jobs: contents: read name: check-lint-core-${{ matrix.stripe }} needs: [preflight] - if: ${{ needs.preflight.outputs.run_check == 'true' && (vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' || vars.OPENCLAW_CI_RUNNER_BACKEND == 'hybrid' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)) }} + if: ${{ needs.preflight.outputs.run_check == 'true' && (vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' || vars.OPENCLAW_CI_RUNNER_BACKEND == 'hybrid' || github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)) }} runs-on: ubuntu-24.04 timeout-minutes: 15 strategy: @@ -2910,7 +2938,7 @@ jobs: contents: read name: check-test-types-core-${{ matrix.stripe }} needs: [preflight] - if: ${{ needs.preflight.outputs.run_check == 'true' && (vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' || vars.OPENCLAW_CI_RUNNER_BACKEND == 'hybrid' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)) }} + if: ${{ needs.preflight.outputs.run_check == 'true' && (vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' || vars.OPENCLAW_CI_RUNNER_BACKEND == 'hybrid' || github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)) }} # Measured 232-269s per stripe on hosted 4-core; hybrid attempt 1 rides # the 8 vCPU class like the compact large rows, retries stay hosted. runs-on: ${{ vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' && 'ubuntu-24.04' || (vars.OPENCLAW_CI_RUNNER_BACKEND == 'hybrid' && github.run_attempt > 1) && 'ubuntu-24.04' || github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (vars.OPENCLAW_CI_RUNNER_BACKEND == 'hybrid' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)) && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-24.04') }} diff --git a/.github/workflows/npm-telegram-beta-e2e.yml b/.github/workflows/npm-telegram-beta-e2e.yml index f781317aa239..adb2fa96bf25 100644 --- a/.github/workflows/npm-telegram-beta-e2e.yml +++ b/.github/workflows/npm-telegram-beta-e2e.yml @@ -409,11 +409,15 @@ jobs: echo "Artifact-backed Telegram E2E requires the complete prerelease plugin registry tuple." >&2 exit 1 fi - expected_registry_name="docker-e2e-prepublish-plugin-registry-${PREPUBLISH_PLUGIN_REGISTRY_ARTIFACT_RUN_ID}-${PREPUBLISH_PLUGIN_REGISTRY_ARTIFACT_RUN_ATTEMPT}" - if [[ "$PREPUBLISH_PLUGIN_REGISTRY_ARTIFACT_NAME" != "$expected_registry_name" ]]; then - echo "Prerelease plugin registry artifact name does not match its producer run." >&2 - exit 1 - fi + expected_registry_suffix="-${PREPUBLISH_PLUGIN_REGISTRY_ARTIFACT_RUN_ID}-${PREPUBLISH_PLUGIN_REGISTRY_ARTIFACT_RUN_ATTEMPT}" + case "$PREPUBLISH_PLUGIN_REGISTRY_ARTIFACT_NAME" in + "docker-e2e-prepublish-plugin-registry${expected_registry_suffix}" | \ + "package-acceptance-telegram-plugin-registry${expected_registry_suffix}") ;; + *) + echo "Prerelease plugin registry artifact name does not match its producer run." >&2 + exit 1 + ;; + esac fi case "${PROVIDER_MODE}" in mock-openai | live-frontier) ;; diff --git a/.github/workflows/openclaw-release-checks.yml b/.github/workflows/openclaw-release-checks.yml index e79dd84613e9..a75f920d89a5 100644 --- a/.github/workflows/openclaw-release-checks.yml +++ b/.github/workflows/openclaw-release-checks.yml @@ -1307,6 +1307,7 @@ jobs: package_sha256: ${{ (needs.resolve_target.outputs.package_acceptance_package_spec == '' && needs.resolve_target.outputs.package_mode != 'published') && 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 }} + prepublish_plugin_registry_json: ${{ needs.prepare_release_package.outputs.prepublish_plugin_registry_json }} suite_profile: custom docker_lanes: release-typed-onboarding doctor-switch update-channel-switch skill-install update-corrupt-plugin upgrade-survivor published-upgrade-survivor root-managed-vps-upgrade update-restart-auth plugins-offline plugin-update plugin-binding-command-escape published_upgrade_survivor_baselines: ${{ needs.resolve_target.outputs.run_release_soak == 'true' && 'last-stable-4 2026.4.23 2026.5.2 2026.4.15' || '' }} diff --git a/.github/workflows/package-acceptance.yml b/.github/workflows/package-acceptance.yml index f960f1922f3c..c9ed57b57cd5 100644 --- a/.github/workflows/package-acceptance.yml +++ b/.github/workflows/package-acceptance.yml @@ -249,6 +249,11 @@ on: required: false default: "" type: string + prepublish_plugin_registry_json: + description: Immutable prerelease plugin registry tuple from the package producer + required: false + default: "" + type: string suite_profile: description: "Acceptance profile: smoke, package, telegram, product, full, or custom" required: false @@ -428,6 +433,7 @@ env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" NODE_VERSION: "24.19.0" PACKAGE_ARTIFACT_NAME: package-under-test-${{ github.run_id }}-${{ github.run_attempt }} + TELEGRAM_PLUGIN_REGISTRY_ARTIFACT_NAME: package-acceptance-telegram-plugin-registry-${{ github.run_id }}-${{ github.run_attempt }} jobs: resolve_package: @@ -448,6 +454,7 @@ jobs: package_source_sha: ${{ steps.resolve.outputs.package_source_sha }} package_sha256: ${{ steps.resolve.outputs.sha256 }} package_version: ${{ steps.resolve.outputs.package_version }} + prepublish_plugin_registry_json: ${{ steps.registry_identity.outputs.json }} published_upgrade_survivor_baseline: ${{ steps.upgrade_survivor_baselines.outputs.baseline }} published_upgrade_survivor_baselines: ${{ steps.upgrade_survivor_baselines.outputs.baselines }} published_upgrade_survivor_scenarios: ${{ inputs.published_upgrade_survivor_scenarios }} @@ -471,6 +478,80 @@ jobs: install-bun: ${{ inputs.source == 'ref' && 'true' || 'false' }} install-deps: "true" + - name: Validate prerelease plugin registry input + id: registry_input + env: + CANDIDATE_ARTIFACT_JSON: ${{ inputs.candidate_artifact_json || '' }} + PREPUBLISH_PLUGIN_REGISTRY_JSON: ${{ inputs.prepublish_plugin_registry_json || '' }} + shell: bash + run: | + set -euo pipefail + normalize_registry_tuple() { + local json="$1" + local source="$2" + [[ -n "${json// }" ]] || return 0 + jq -cer \ + --arg source "$source" \ + ' + def digits: test("^[1-9][0-9]*$"); + def hex64: test("^[a-f0-9]{64}$"); + def fields: [ + "prepublishPluginRegistryArtifactName", "prepublishPluginRegistryArtifactId", + "prepublishPluginRegistryArtifactDigest", "prepublishPluginRegistryArtifactRunId", + "prepublishPluginRegistryArtifactRunAttempt", "prepublishPluginRegistryManifestSha256" + ]; + def tuple: + . as $input + | reduce fields[] as $field ({}; .[$field] = $input[$field]) + | .prepublishPluginRegistryArtifactId |= tostring + | .prepublishPluginRegistryArtifactRunId |= tostring + | .prepublishPluginRegistryArtifactRunAttempt |= tostring; + if type != "object" then error("not an object") else . end + | . as $input + | [fields[] as $field | select($input | has($field))] as $present + | if ($present | length) == 0 and $source == "candidate" then empty + elif ($present | length) != 6 or + ($source == "direct" and ((keys | sort) != (fields | sort))) + then error("incomplete or unexpected fields") + else tuple + end + | . as $tuple + | select( + ( + .prepublishPluginRegistryArtifactName == + ("docker-e2e-prepublish-plugin-registry-" + + $tuple.prepublishPluginRegistryArtifactRunId + "-" + + $tuple.prepublishPluginRegistryArtifactRunAttempt) or + .prepublishPluginRegistryArtifactName == + ("package-acceptance-telegram-plugin-registry-" + + $tuple.prepublishPluginRegistryArtifactRunId + "-" + + $tuple.prepublishPluginRegistryArtifactRunAttempt) + ) and + ([ + .prepublishPluginRegistryArtifactId, + .prepublishPluginRegistryArtifactRunId, + .prepublishPluginRegistryArtifactRunAttempt + ] | all(.[]; digits)) and + ([ + .prepublishPluginRegistryArtifactDigest, + .prepublishPluginRegistryManifestSha256 + ] | all(.[]; hex64)) + ) + ' <<< "$json" 2>/dev/null || { + echo "Prerelease plugin registry JSON must contain one complete immutable tuple." >&2 + exit 1 + } + } + + candidate_registry="$(normalize_registry_tuple "$CANDIDATE_ARTIFACT_JSON" candidate)" + direct_registry="$(normalize_registry_tuple "$PREPUBLISH_PLUGIN_REGISTRY_JSON" direct)" + if [[ -n "$candidate_registry" && -n "$direct_registry" && + "$candidate_registry" != "$direct_registry" ]]; then + echo "Prerelease plugin registry inputs disagree." >&2 + exit 1 + fi + printf 'json=%s\n' "${direct_registry:-$candidate_registry}" >> "$GITHUB_OUTPUT" + - name: Validate package artifact input identity id: input_artifact if: inputs.source == 'artifact' @@ -558,6 +639,8 @@ jobs: PACKAGE_FILE_NAME: ${{ inputs.package_file_name }} PACKAGE_SOURCE_SHA: ${{ inputs.package_source_sha }} PACKAGE_VERSION: ${{ inputs.package_version }} + PREPUBLISH_PLUGIN_REGISTRY_JSON: ${{ steps.registry_input.outputs.json }} + TELEGRAM_MODE: ${{ inputs.telegram_mode }} TRUSTED_SOURCE_ID: ${{ inputs.trusted_source_id }} OPENCLAW_TRUSTED_PACKAGE_TOKEN: ${{ secrets.OPENCLAW_TRUSTED_PACKAGE_TOKEN }} shell: bash @@ -578,6 +661,15 @@ jobs: } fi + set -- + if [[ -z "$PREPUBLISH_PLUGIN_REGISTRY_JSON" && + "$TELEGRAM_MODE" != "none" && + ("$SOURCE" == "ref" || "$SOURCE" == "artifact") ]]; then + set -- \ + --plugin-registry-output-dir .artifacts/package-acceptance-telegram-plugin-registry \ + --required-plugin-packages-json '["@openclaw/codex"]' + fi + node --import tsx scripts/resolve-openclaw-package-candidate.mts \ --source "$SOURCE" \ --package-ref "$PACKAGE_REF" \ @@ -589,7 +681,8 @@ jobs: --output-dir .artifacts/docker-e2e-package \ --output-name openclaw-current.tgz \ --metadata .artifacts/docker-e2e-package/package-candidate.json \ - --github-output "$GITHUB_OUTPUT" + --github-output "$GITHUB_OUTPUT" \ + "$@" echo "package_file_name=openclaw-current.tgz" >> "$GITHUB_OUTPUT" if [[ "$SOURCE" == "artifact" ]]; then jq -e \ @@ -747,6 +840,44 @@ jobs: retention-days: 14 if-no-files-found: error + - name: Upload Telegram prerelease plugin registry artifact + id: upload_telegram_registry + if: steps.resolve.outputs.plugin_registry_manifest_sha256 != '' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: ${{ env.TELEGRAM_PLUGIN_REGISTRY_ARTIFACT_NAME }} + path: .artifacts/package-acceptance-telegram-plugin-registry/ + retention-days: 14 + if-no-files-found: error + + - name: Prepare prerelease plugin registry identity + id: registry_identity + env: + GENERATED_ARTIFACT_DIGEST: ${{ steps.upload_telegram_registry.outputs.artifact-digest }} + GENERATED_ARTIFACT_ID: ${{ steps.upload_telegram_registry.outputs.artifact-id }} + GENERATED_MANIFEST_SHA256: ${{ steps.resolve.outputs.plugin_registry_manifest_sha256 }} + SUPPLIED_REGISTRY_JSON: ${{ steps.registry_input.outputs.json }} + shell: bash + run: | + set -euo pipefail + if [[ -n "$GENERATED_ARTIFACT_ID" ]]; then + [[ -z "$SUPPLIED_REGISTRY_JSON" ]] || { + echo "Package Acceptance generated a registry despite a supplied immutable tuple." >&2 + exit 1 + } + SUPPLIED_REGISTRY_JSON="$( + jq -cn \ + --arg prepublishPluginRegistryArtifactName "$TELEGRAM_PLUGIN_REGISTRY_ARTIFACT_NAME" \ + --arg prepublishPluginRegistryArtifactId "$GENERATED_ARTIFACT_ID" \ + --arg prepublishPluginRegistryArtifactDigest "$GENERATED_ARTIFACT_DIGEST" \ + --arg prepublishPluginRegistryArtifactRunId "$GITHUB_RUN_ID" \ + --arg prepublishPluginRegistryArtifactRunAttempt "$GITHUB_RUN_ATTEMPT" \ + --arg prepublishPluginRegistryManifestSha256 "$GENERATED_MANIFEST_SHA256" \ + '$ARGS.named' + )" + fi + printf 'json=%s\n' "$SUPPLIED_REGISTRY_JSON" >> "$GITHUB_OUTPUT" + - name: Summarize package candidate env: INPUT_ARTIFACT_DIGEST: ${{ steps.input_artifact.outputs.artifact_digest }} @@ -909,7 +1040,7 @@ jobs: pull-requests: read uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml with: &docker_acceptance_inputs - advisory: ${{ inputs.advisory }} + advisory: ${{ inputs.advisory || false }} ref: ${{ needs.resolve_package.outputs.package_source_sha || inputs.workflow_ref }} include_repo_e2e: false include_release_path_suites: ${{ needs.resolve_package.outputs.include_release_path_suites == 'true' }} @@ -929,12 +1060,12 @@ jobs: 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 || '' }} + prepublish_plugin_registry_artifact_name: ${{ startsWith(fromJSON(needs.resolve_package.outputs.prepublish_plugin_registry_json || '{}').prepublishPluginRegistryArtifactName || '', 'docker-e2e-prepublish-plugin-registry-') && fromJSON(needs.resolve_package.outputs.prepublish_plugin_registry_json || '{}').prepublishPluginRegistryArtifactName || '' }} + prepublish_plugin_registry_artifact_id: ${{ startsWith(fromJSON(needs.resolve_package.outputs.prepublish_plugin_registry_json || '{}').prepublishPluginRegistryArtifactName || '', 'docker-e2e-prepublish-plugin-registry-') && fromJSON(needs.resolve_package.outputs.prepublish_plugin_registry_json || '{}').prepublishPluginRegistryArtifactId || '' }} + prepublish_plugin_registry_artifact_digest: ${{ startsWith(fromJSON(needs.resolve_package.outputs.prepublish_plugin_registry_json || '{}').prepublishPluginRegistryArtifactName || '', 'docker-e2e-prepublish-plugin-registry-') && fromJSON(needs.resolve_package.outputs.prepublish_plugin_registry_json || '{}').prepublishPluginRegistryArtifactDigest || '' }} + prepublish_plugin_registry_artifact_run_id: ${{ startsWith(fromJSON(needs.resolve_package.outputs.prepublish_plugin_registry_json || '{}').prepublishPluginRegistryArtifactName || '', 'docker-e2e-prepublish-plugin-registry-') && fromJSON(needs.resolve_package.outputs.prepublish_plugin_registry_json || '{}').prepublishPluginRegistryArtifactRunId || '' }} + prepublish_plugin_registry_artifact_run_attempt: ${{ startsWith(fromJSON(needs.resolve_package.outputs.prepublish_plugin_registry_json || '{}').prepublishPluginRegistryArtifactName || '', 'docker-e2e-prepublish-plugin-registry-') && fromJSON(needs.resolve_package.outputs.prepublish_plugin_registry_json || '{}').prepublishPluginRegistryArtifactRunAttempt || '' }} + prepublish_plugin_registry_manifest_sha256: ${{ startsWith(fromJSON(needs.resolve_package.outputs.prepublish_plugin_registry_json || '{}').prepublishPluginRegistryArtifactName || '', 'docker-e2e-prepublish-plugin-registry-') && fromJSON(needs.resolve_package.outputs.prepublish_plugin_registry_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 }} @@ -1023,12 +1154,12 @@ 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 }} - 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 || '' }} + prepublish_plugin_registry_artifact_name: ${{ fromJSON(needs.resolve_package.outputs.prepublish_plugin_registry_json || '{}').prepublishPluginRegistryArtifactName || '' }} + prepublish_plugin_registry_artifact_id: ${{ fromJSON(needs.resolve_package.outputs.prepublish_plugin_registry_json || '{}').prepublishPluginRegistryArtifactId || '' }} + prepublish_plugin_registry_artifact_digest: ${{ fromJSON(needs.resolve_package.outputs.prepublish_plugin_registry_json || '{}').prepublishPluginRegistryArtifactDigest || '' }} + prepublish_plugin_registry_artifact_run_id: ${{ fromJSON(needs.resolve_package.outputs.prepublish_plugin_registry_json || '{}').prepublishPluginRegistryArtifactRunId || '' }} + prepublish_plugin_registry_artifact_run_attempt: ${{ fromJSON(needs.resolve_package.outputs.prepublish_plugin_registry_json || '{}').prepublishPluginRegistryArtifactRunAttempt || '' }} + prepublish_plugin_registry_manifest_sha256: ${{ fromJSON(needs.resolve_package.outputs.prepublish_plugin_registry_json || '{}').prepublishPluginRegistryManifestSha256 || '' }} package_label: openclaw@${{ needs.resolve_package.outputs.package_version }} harness_ref: ${{ inputs.workflow_ref }} provider_mode: ${{ needs.resolve_package.outputs.telegram_mode }} diff --git a/docs/ci.md b/docs/ci.md index 56f07f18442f..7f06ac7d1005 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -185,7 +185,7 @@ Barnacle treats bug-labeled issues as verification candidates rather than inacti Ordinary manual CI dispatches run the same job graph as normal CI but force every non-Android scoped lane on: Linux Node shards, bundled-plugin shards, plugin and channel contract shards, Node 22 compatibility, `check-*`, `check-additional-*`, built-artifact smoke checks, docs checks, Python skills, Windows, macOS, iOS build, and Control UI/native app i18n. Node 22 compatibility runs in Full Release Validation and manual dispatches only; push and pull request CI skip it. The exact-head `release_gate` fallback instead keeps the pull request's macOS, iOS, and generated-native-locale scope, including conservative release screenshot capture for screenshot-pipeline owners. Automatic source PRs and release gates verify native extraction inventory and Android/Apple localization safety without requiring translated or platform-generated output in the same PR. The serialized Native App Locale Refresh workflow rebuilds those artifacts in one isolated PR and enables exact-head auto-merge after required checks pass. Full native parity remains blocking for generated-artifact PRs, generated-scope release gates, ordinary manual CI, Full Release Validation, and release prep. Control UI locale parity remains advisory on automatic PR and `main` runs and blocking on manual/release CI. Standalone manual CI dispatches run Android only with `include_android=true` (the `release_gate` input also forces Android); the full release umbrella enables Android by passing `include_android=true` without setting `release_gate`. Plugin prerelease static checks, the release-only `agentic-plugins` shard, the full extension batch sweep, and plugin prerelease Docker lanes are excluded from CI. The Docker prerelease suite runs only when `Full Release Validation` dispatches the separate `Plugin Prerelease` workflow with the release-validation gate enabled. -PR baseline ratchets derive their comparison state from the checked-out synthetic merge tree and verify its head parent against the event head. The max-lines entry chains the environment-variable budget with the same fork-point ref before the assertion-safety check, so production source growth cannot first surface on `main`. Manual runs use a unique concurrency group so a release-candidate full suite is not cancelled by another push or PR run on the same ref. The optional `target_ref` input lets a trusted caller run that graph against a branch, tag, or full commit SHA while using the workflow file from the selected dispatch ref; ratchet baselines are compared with the target's merge base against the default-branch head resolved for that run. The `release_gate` input is an exact-SHA maintainer fallback for capacity-stalled PR CI: it requires `target_ref` to be a full commit SHA that matches the dispatched branch head and `pull_request_number` to identify the open PR whose merge tree is validated. +PR baseline ratchets derive their comparison state from the checked-out synthetic merge tree and verify its head parent against the event head. The max-lines entry chains the environment-variable budget with the same fork-point ref before the assertion-safety check, so production source growth cannot first surface on `main`. Manual runs use a unique concurrency group so a release-candidate full suite is not cancelled by another push or PR run on the same ref. The optional `target_ref` input lets a trusted caller run that graph against a branch, tag, or full commit SHA while using the workflow file from the selected dispatch ref; ratchet baselines are compared with the target's merge base against the default-branch head resolved for that run. The `release_gate` input is an exact-SHA maintainer fallback for capacity-stalled PR CI: it requires `target_ref` to be a full commit SHA that matches the dispatched branch head and `pull_request_number` to identify the open PR whose merge tree is validated. Release-gate merge-tree lint uses the same five core stripes as hosted PR CI plus one extension stripe, so no single hosted runner owns the full type-aware lint workload. ```bash gh workflow run ci.yml --ref release/YYYY.M.PATCH diff --git a/scripts/resolve-openclaw-package-candidate.mts b/scripts/resolve-openclaw-package-candidate.mts index b26e0222415e..968fa969b8db 100644 --- a/scripts/resolve-openclaw-package-candidate.mts +++ b/scripts/resolve-openclaw-package-candidate.mts @@ -192,7 +192,7 @@ Options: --output-name Output tarball filename. Default: ${DEFAULT_OUTPUT_NAME} --metadata Write package metadata JSON. --plugin-registry-output-dir - Build an immutable registry for source=ref before cleanup. + Build an immutable registry for source=ref, npm, or artifact. --required-plugin-packages-json Scoped package names to include in that registry. --github-output Append tarball, sha256, package name/version outputs.`; @@ -1660,6 +1660,7 @@ async function resolveCandidate(options: PackageCandidateOptions) { let packageTrustedReason = ""; let packageTrustedSourceId = ""; let packageWorktreeDir = ""; + let packageBuildSourceSha: string | undefined; let pluginRegistrySource: Awaited> | undefined; let artifactMetadata: ArtifactMetadata = {}; let pluginRegistryIdentity: @@ -1705,12 +1706,6 @@ async function resolveCandidate(options: PackageCandidateOptions) { packOutput, options.outputName || DEFAULT_OUTPUT_NAME, ); - if (options.pluginRegistryOutputDir) { - pluginRegistrySource = await preparePackageSourceWorktree(options.packageRef); - packageWorktreeDir = pluginRegistrySource.sourceDir; - packageRef = options.packageRef; - await installPackageSourceDeps(pluginRegistrySource.sourceDir); - } } else if (options.source === "url" || options.source === "trusted-url") { if (!options.packageUrl) { throw new Error(`${options.source} requires --package-url`); @@ -1749,15 +1744,43 @@ async function resolveCandidate(options: PackageCandidateOptions) { : ""; const input = await findSingleTarball(options.artifactDir); await fs.copyFile(input, target); + packageBuildSourceSha = await readPackageBuildSourceSha(target); + if (packageSourceSha && packageBuildSourceSha && packageSourceSha !== packageBuildSourceSha) { + throw new Error( + `artifact packageSourceSha ${packageSourceSha} does not match package build-info commit ${packageBuildSourceSha}`, + ); + } + if (!packageSourceSha && packageBuildSourceSha) { + packageSourceSha = packageBuildSourceSha; + packageTrustedReason = "package-build-info"; + } + if (options.pluginRegistryOutputDir) { + if (!packageBuildSourceSha) { + throw new Error( + "source=artifact requires a valid package build-info commit for prerelease plugin registry creation", + ); + } + packageTrustedReason ||= "package-build-info"; + } } else { throw new Error( `source must be one of: ref, npm, url, trusted-url, artifact. Got: ${options.source}`, ); } if (options.pluginRegistryOutputDir && !pluginRegistrySource) { - throw new Error( - "--plugin-registry-output-dir is only supported with source=ref or source=npm", + if (options.source !== "npm" && options.source !== "artifact") { + throw new Error( + "--plugin-registry-output-dir is only supported with source=ref, source=npm, or source=artifact", + ); + } + if (options.source === "npm") { + packageRef = options.packageRef; + } + pluginRegistrySource = await preparePackageSourceWorktree( + options.source === "npm" ? packageRef : packageSourceSha, ); + packageWorktreeDir = pluginRegistrySource.sourceDir; + await installPackageSourceDeps(pluginRegistrySource.sourceDir); } if (options.pluginRegistryOutputDir && pluginRegistrySource) { const requiredPackages = JSON.parse(options.requiredPluginPackagesJson) as string[]; @@ -1798,7 +1821,7 @@ async function resolveCandidate(options: PackageCandidateOptions) { ); const pkg = await readPackageJson(target); if (!packageSourceSha) { - packageSourceSha = await readPackageBuildSourceSha(target); + packageSourceSha = packageBuildSourceSha ?? (await readPackageBuildSourceSha(target)); if (packageSourceSha && !packageTrustedReason) { packageTrustedReason = "package-build-info"; } diff --git a/test/scripts/ci-workflow-guards.test.ts b/test/scripts/ci-workflow-guards.test.ts index 0a757caa03dc..f6e764df11fd 100644 --- a/test/scripts/ci-workflow-guards.test.ts +++ b/test/scripts/ci-workflow-guards.test.ts @@ -99,6 +99,7 @@ function evaluateWorkflowExpression( headRepository?: string; matrix?: Record; repository: string; + runCheck?: boolean; runnerBackend?: "" | "blacksmith" | "github" | "hybrid"; runAttempt: number; }, @@ -136,6 +137,13 @@ function evaluateWorkflowExpression( : {}, }, matrix: context.matrix ?? {}, + needs: { + preflight: { + outputs: { + run_check: String(context.runCheck ?? true), + }, + }, + }, vars: { OPENCLAW_CI_RUNNER_BACKEND: context.runnerBackend ?? "", }, @@ -216,6 +224,7 @@ function runCiManifestFixture(options: { qaSmokePlan?: boolean; formatCheck?: boolean; releaseCandidateCompatibility?: boolean; + releaseGate?: boolean; targetContextCompatibility?: boolean; nodeFastOnly?: boolean; nodeFastPluginContracts?: boolean; @@ -404,6 +413,7 @@ function runCiManifestFixture(options: { (options.eventName ?? "workflow_dispatch") === "workflow_dispatch" ? "true" : "false", + OPENCLAW_CI_RELEASE_GATE: String(options.releaseGate ?? false), OPENCLAW_CI_RELEASE_CANDIDATE_TARGET: options.releaseCandidateCompatibility === true ? "true" : "false", OPENCLAW_CI_TARGET_CONTEXT_TARGET: @@ -6468,6 +6478,13 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}" ); const checkShardRun = checkShardStep.run; const hostedCoreLint = workflow.jobs["check-lint-hosted-core-shard"]; + const hostedCoreTypes = workflow.jobs["check-test-types-hosted-core-shard"]; + const manualDispatch = { + eventName: "workflow_dispatch", + repository: "openclaw/openclaw", + runnerBackend: "blacksmith", + runAttempt: 1, + } as const; const untrustedForkPullRequest = { authorAssociation: "NONE", eventName: "pull_request", @@ -6478,8 +6495,11 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}" } as const; expect(manifestStep.env.OPENCLAW_CI_RUNNER_BACKEND).toBe( - "${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 'github' || vars.OPENCLAW_CI_RUNNER_BACKEND }}", + "${{ (github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)) && 'github' || vars.OPENCLAW_CI_RUNNER_BACKEND }}", ); + expect( + evaluateWorkflowExpression(manifestStep.env.OPENCLAW_CI_RUNNER_BACKEND, manualDispatch), + ).toBe("github"); expect( evaluateWorkflowExpression( manifestStep.env.OPENCLAW_CI_RUNNER_BACKEND, @@ -6489,9 +6509,15 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}" expect( evaluateWorkflowExpression(checkShardStep.env.RUNNER_BACKEND, untrustedForkPullRequest), ).toBe("github"); + expect(evaluateWorkflowExpression(checkShardStep.env.RUNNER_BACKEND, manualDispatch)).toBe( + "github", + ); + expect(checkShardStep.env.RELEASE_GATE).toBe("${{ inputs.release_gate && 'true' || 'false' }}"); expect(manifestStep.run).toContain("runnerBackend: process.env.OPENCLAW_CI_RUNNER_BACKEND"); expect(checkShardRun).toContain('if [ "$RUNNER_BACKEND" = "github" ]; then'); expect(checkShardRun).toContain("lint_args=(--only=extensions --only=scripts --threads=1)"); + expect(checkShardRun).toContain('if [ "$RELEASE_GATE" = "true" ]; then'); + expect(checkShardRun).toContain("lint_args=(--only=scripts --threads=1)"); expect(checkShardRun).toContain('elif [ "$(nproc)" -lt 8 ]; then'); expect(checkShardRun).toContain("lint_args=(--threads=1)"); expect(checkShardRun).not.toContain("lint_args=(--split-core --threads=1)"); @@ -6504,9 +6530,11 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}" expect(hostedCoreLint.if).toContain( "github.event.pull_request.head.repo.full_name != github.repository", ); - expect(workflow.jobs["check-test-types-hosted-core-shard"].if).toContain( + expect(hostedCoreTypes.if).toContain( "github.event.pull_request.head.repo.full_name != github.repository", ); + expect(evaluateWorkflowExpression(hostedCoreLint.if, manualDispatch)).toBe(true); + expect(evaluateWorkflowExpression(hostedCoreTypes.if, manualDispatch)).toBe(true); expect(hostedCoreLint["runs-on"]).toBe("ubuntu-24.04"); expect(hostedCoreLint.strategy).toEqual({ "fail-fast": false, @@ -6544,14 +6572,14 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}" "pull-requests": "read", }); expect(checksFastJob.env.CHECKOUT_BASE_SHA).toBe( - "${{ matrix.task == 'baseline-ratchets' && needs.preflight.outputs.diff_base_revision || '' }}", + "${{ (matrix.task == 'baseline-ratchets' || startsWith(matrix.task, 'release-lint-')) && needs.preflight.outputs.diff_base_revision || '' }}", ); expect(checkout.run).toContain( 'fetch_refs+=("+${CHECKOUT_BASE_SHA}:refs/remotes/origin/ci-ratchet-base")', ); expect(checkout.run).toContain('"${fetch_refs[@]}" || return 1'); expect(releaseGateMerge.if).toBe( - "matrix.task == 'baseline-ratchets' && github.event_name == 'workflow_dispatch' && inputs.release_gate", + "(matrix.task == 'baseline-ratchets' || startsWith(matrix.task, 'release-lint-')) && github.event_name == 'workflow_dispatch' && inputs.release_gate", ); expect(checksFastRun.run).toContain("baseline-ratchets)"); expect(checksFastRun.run).toContain("coercion-helpers)"); @@ -6603,9 +6631,6 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}" expect(releaseGateMerge.run).toContain( 'echo "RATCHET_BASE_REF=${frozen_base_sha}" >> "$GITHUB_ENV"', ); - expect(releaseGateMerge.run).toContain( - 'echo "RATCHET_RELEASE_MERGE_TREE=true" >> "$GITHUB_ENV"', - ); expect(checksFastRun.run).not.toContain("PROTOCOL_MANUAL_BASE_SHA"); expect(checksFastRun.run).toContain( '"+${PROTOCOL_SINCE_BASE_SHA}:refs/remotes/origin/protocol-since-base"', @@ -6631,10 +6656,10 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}" ); expect(maxLinesRatchet).toContain("checkEnvVarCount(envVarCountArgs(argv), root);"); expect(checksFastRun.run).toContain( - 'if [[ "${RATCHET_RELEASE_MERGE_TREE:-}" == "true" ]]; then', + '--only=core --split-core --core-stripe="${stripe}/5" --threads=1', ); expect(checksFastRun.run).toContain( - "node --import tsx scripts/run-oxlint-shards.mts --only=core --only=extensions --threads=1", + "node --import tsx scripts/run-oxlint-shards.mts --only=extensions --threads=1", ); expect(checksFastRun.run).not.toContain( "node scripts/run-oxlint.mjs src ui/src packages extensions", @@ -6665,6 +6690,34 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}" task: "coercion-helpers", }, ]); + + const releaseGate = runCiManifestFixture({ + bundledPlanner: true, + eventName: "workflow_dispatch", + historicalCompatibility: false, + releaseGate: true, + }); + expect(releaseGate.status, releaseGate.output).toBe(0); + expect( + JSON.parse( + expectDefined(releaseGate.outputs.checks_fast_core_matrix, "release-gate checks matrix"), + ).include.filter((entry: { task: string }) => entry.task.startsWith("release-lint-")), + ).toEqual([ + ...Array.from({ length: 5 }, (_, index) => { + const stripe = index + 1; + return { + check_name: `checks-fast-release-lint-core-${stripe}`, + runtime: "node", + stripe, + task: `release-lint-core-${stripe}`, + }; + }), + { + check_name: "checks-fast-release-lint-extensions", + runtime: "node", + task: "release-lint-extensions", + }, + ]); }); it("uses target-owned CI plans and capabilities for older release checkouts", () => { diff --git a/test/scripts/docker-build-helper.test.ts b/test/scripts/docker-build-helper.test.ts index a7caaacf8c82..e05a8c63c76c 100644 --- a/test/scripts/docker-build-helper.test.ts +++ b/test/scripts/docker-build-helper.test.ts @@ -4759,6 +4759,29 @@ source "$ROOT_DIR/scripts/lib/docker-e2e-logs.sh" '-v "$harness_root/test/e2e/qa-lab:/app/test/e2e/qa-lab:ro"', '-v "$harness_root/test/helpers:/app/test/helpers:ro"', ]); + + const script = repoRootShell` +export DOCKER_E2E_HARNESS_ROOT_DIR=/trusted-harness +source "$ROOT_DIR/scripts/lib/docker-e2e-package.sh" +docker_e2e_harness_mount_args +for ((index = 1; index < \${#DOCKER_E2E_HARNESS_ARGS[@]}; index += 2)); do + printf "%s\\n" "\${DOCKER_E2E_HARNESS_ARGS[$index]}" +done +`; + const mounts = execFileSync("bash", ["-lc", script], { encoding: "utf8" }).trim().split("\n"); + + expect(mounts).toEqual([ + "/trusted-harness/scripts/e2e:/app/scripts/e2e:ro", + "/trusted-harness/scripts/lib:/app/scripts/lib:ro", + "/trusted-harness/packages/gateway-client/src:/app/packages/gateway-client/src:ro", + "/trusted-harness/packages/normalization-core/package.json:/app/packages/normalization-core/package.json:ro", + "/trusted-harness/packages/normalization-core/src:/app/packages/normalization-core/src:ro", + "/trusted-harness/tsconfig.json:/app/tsconfig.json:ro", + "/trusted-harness/test/e2e/qa-lab:/app/test/e2e/qa-lab:ro", + "/trusted-harness/test/helpers:/app/test/helpers:ro", + "/trusted-harness/scripts/prepublish-plugin-registry-artifact.mjs:/app/scripts/prepublish-plugin-registry-artifact.mjs:ro", + "/trusted-harness/scripts/windows-cmd-helpers.mjs:/app/scripts/windows-cmd-helpers.mjs:ro", + ]); }); it("preserves pnpm lookup paths for scheduled Docker child lanes", () => { diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index acc30173d342..16df52af1a70 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -1,5 +1,6 @@ // Package Acceptance Workflow tests cover package acceptance workflow script behavior. import { execFileSync, spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; import { chmodSync, mkdirSync, @@ -813,6 +814,101 @@ function runPackageAcceptanceProfile(params: { return { outputs, result }; } +function runPackageAcceptanceRegistryInputValidation(params: { + candidateArtifactJson?: string; + prepublishPluginRegistryJson?: string; +}) { + const job = workflowJob(PACKAGE_ACCEPTANCE_WORKFLOW, "resolve_package"); + const script = workflowStep(job, "Validate prerelease plugin registry input").run; + if (!script) { + throw new Error("Expected package acceptance registry input validation script"); + } + const workdir = tempDirs.make("package-acceptance-registry-input-"); + const outputPath = resolve(workdir, "github-output"); + const result = spawnSync("bash", ["-c", script], { + encoding: "utf8", + env: { + CANDIDATE_ARTIFACT_JSON: params.candidateArtifactJson ?? "", + GITHUB_OUTPUT: outputPath, + PATH: process.env.PATH, + PREPUBLISH_PLUGIN_REGISTRY_JSON: params.prepublishPluginRegistryJson ?? "", + }, + }); + const output = result.status === 0 ? readFileSync(outputPath, "utf8") : ""; + return { output, result }; +} + +function packageAcceptanceRegistryTuple(overrides: Record = {}) { + return { + prepublishPluginRegistryArtifactName: "docker-e2e-prepublish-plugin-registry-123-2", + prepublishPluginRegistryArtifactId: "456", + prepublishPluginRegistryArtifactDigest: "a".repeat(64), + prepublishPluginRegistryArtifactRunId: "123", + prepublishPluginRegistryArtifactRunAttempt: "2", + prepublishPluginRegistryManifestSha256: "b".repeat(64), + ...overrides, + }; +} + +function runPackageAcceptanceResolveScript(params: { + prepublishPluginRegistryJson?: string; + source: "artifact" | "npm" | "ref" | "trusted-url" | "url"; + telegramMode: "mock-openai" | "none"; +}) { + const job = workflowJob(PACKAGE_ACCEPTANCE_WORKFLOW, "resolve_package"); + const script = workflowStep(job, "Resolve package candidate").run; + if (!script) { + throw new Error("Expected package acceptance resolve script"); + } + const workdir = tempDirs.make("package-acceptance-resolve-"); + const binDir = resolve(workdir, "bin"); + const capturePath = resolve(workdir, "node-args"); + const outputPath = resolve(workdir, "github-output"); + const artifactDir = resolve(workdir, ".artifacts/package-candidate-input"); + mkdirSync(binDir, { recursive: true }); + mkdirSync(artifactDir, { recursive: true }); + const nodePath = resolve(binDir, "node"); + const artifactPath = resolve(artifactDir, "openclaw-current.tgz"); + const artifactBody = "package acceptance artifact"; + writeFileSync(artifactPath, artifactBody); + writeFileSync( + nodePath, + `#!/bin/sh +printf "%s\\n" "$@" > "$CAPTURE_PATH" +if [ "$SOURCE" = "artifact" ]; then + mkdir -p .artifacts/docker-e2e-package + printf '{"name":"openclaw","sha256":"%s","packageSourceSha":"%s","version":"%s"}\\n' \ + "$PACKAGE_SHA256" "$PACKAGE_SOURCE_SHA" "$PACKAGE_VERSION" \ + > .artifacts/docker-e2e-package/package-candidate.json +fi +`, + ); + chmodSync(nodePath, 0o755); + const result = spawnSync("bash", ["-c", script], { + cwd: workdir, + encoding: "utf8", + env: { + CAPTURE_PATH: capturePath, + GITHUB_OUTPUT: outputPath, + OPENCLAW_TRUSTED_PACKAGE_TOKEN: "", + PACKAGE_FILE_NAME: "openclaw-current.tgz", + PACKAGE_REF: "HEAD", + PACKAGE_SHA256: createHash("sha256").update(artifactBody).digest("hex"), + PACKAGE_SOURCE_SHA: "a".repeat(40), + PACKAGE_SPEC: "openclaw@beta", + PACKAGE_URL: "https://example.invalid/openclaw.tgz", + PACKAGE_VERSION: "2026.8.26", + PATH: `${binDir}:${process.env.PATH}`, + PREPUBLISH_PLUGIN_REGISTRY_JSON: params.prepublishPluginRegistryJson ?? "", + SOURCE: params.source, + TELEGRAM_MODE: params.telegramMode, + TRUSTED_SOURCE_ID: "", + }, + }); + const args = result.status === 0 ? readFileSync(capturePath, "utf8") : ""; + return { args, result }; +} + function runNpmTelegramInputValidation(overrides: Record) { const job = workflowJob(NPM_TELEGRAM_WORKFLOW, "run_package_telegram_e2e"); const script = workflowStep(job, "Validate inputs and secrets").run; @@ -2605,6 +2701,17 @@ describe("package acceptance workflow", () => { description: "Acceptance profile: smoke, package, telegram, product, full, or custom", options: ["smoke", "package", "telegram", "product", "full", "custom"], }); + const dispatchInputs = parsedWorkflow.on?.workflow_dispatch?.inputs; + const callInputs = parsedWorkflow.on?.workflow_call?.inputs; + expect(dispatchInputs?.prepublish_plugin_registry_json).toBeUndefined(); + expect(dispatchInputs?.advisory).toEqual(callInputs?.advisory); + expect(callInputs?.advisory).toEqual({ + description: "Treat acceptance failures as advisory for the caller", + required: false, + default: false, + type: "boolean", + }); + expect(Object.keys(dispatchInputs ?? {})).toHaveLength(25); expect(parsedWorkflow.on?.workflow_dispatch?.inputs?.telegram_advisory).toBeUndefined(); expect(parsedWorkflow.on?.workflow_call?.inputs?.suite_profile).toMatchObject({ default: "package", @@ -2684,17 +2791,17 @@ describe("package acceptance workflow", () => { ); const registryInputs = { prepublish_plugin_registry_artifact_name: - "${{ fromJSON(inputs.candidate_artifact_json || '{}').prepublishPluginRegistryArtifactName || '' }}", + "${{ fromJSON(needs.resolve_package.outputs.prepublish_plugin_registry_json || '{}').prepublishPluginRegistryArtifactName || '' }}", prepublish_plugin_registry_artifact_id: - "${{ fromJSON(inputs.candidate_artifact_json || '{}').prepublishPluginRegistryArtifactId || '' }}", + "${{ fromJSON(needs.resolve_package.outputs.prepublish_plugin_registry_json || '{}').prepublishPluginRegistryArtifactId || '' }}", prepublish_plugin_registry_artifact_digest: - "${{ fromJSON(inputs.candidate_artifact_json || '{}').prepublishPluginRegistryArtifactDigest || '' }}", + "${{ fromJSON(needs.resolve_package.outputs.prepublish_plugin_registry_json || '{}').prepublishPluginRegistryArtifactDigest || '' }}", prepublish_plugin_registry_artifact_run_id: - "${{ fromJSON(inputs.candidate_artifact_json || '{}').prepublishPluginRegistryArtifactRunId || '' }}", + "${{ fromJSON(needs.resolve_package.outputs.prepublish_plugin_registry_json || '{}').prepublishPluginRegistryArtifactRunId || '' }}", prepublish_plugin_registry_artifact_run_attempt: - "${{ fromJSON(inputs.candidate_artifact_json || '{}').prepublishPluginRegistryArtifactRunAttempt || '' }}", + "${{ fromJSON(needs.resolve_package.outputs.prepublish_plugin_registry_json || '{}').prepublishPluginRegistryArtifactRunAttempt || '' }}", prepublish_plugin_registry_manifest_sha256: - "${{ fromJSON(inputs.candidate_artifact_json || '{}').prepublishPluginRegistryManifestSha256 || '' }}", + "${{ fromJSON(needs.resolve_package.outputs.prepublish_plugin_registry_json || '{}').prepublishPluginRegistryManifestSha256 || '' }}", }; expect(packageTelegram.with).toMatchObject(registryInputs); const registryInputSchema = Object.fromEntries( @@ -2713,7 +2820,11 @@ describe("package acceptance workflow", () => { "Prerelease plugin registry inputs require an artifact-backed OpenClaw package.", ); expect(npmTelegramWorkflow).toContain( - 'expected_registry_name="docker-e2e-prepublish-plugin-registry-${PREPUBLISH_PLUGIN_REGISTRY_ARTIFACT_RUN_ID}-${PREPUBLISH_PLUGIN_REGISTRY_ARTIFACT_RUN_ATTEMPT}"', + 'expected_registry_suffix="-${PREPUBLISH_PLUGIN_REGISTRY_ARTIFACT_RUN_ID}-${PREPUBLISH_PLUGIN_REGISTRY_ARTIFACT_RUN_ATTEMPT}"', + ); + expect(npmTelegramWorkflow).toContain( + '"docker-e2e-prepublish-plugin-registry${expected_registry_suffix}" | \\\n' + + ' "package-acceptance-telegram-plugin-registry${expected_registry_suffix}"', ); expect(npmTelegramWorkflow).not.toContain( "Prerelease plugin registry and package artifacts must come from the same workflow run attempt.", @@ -2729,6 +2840,17 @@ describe("package acceptance workflow", () => { expect(dockerAcceptance.with?.ref).toBe( "${{ needs.resolve_package.outputs.package_source_sha || inputs.workflow_ref }}", ); + expect(dockerAcceptance.with?.advisory).toBe("${{ inputs.advisory || false }}"); + expect(dockerAcceptanceRegistry.with?.advisory).toBe("${{ inputs.advisory || false }}"); + expect(dockerAcceptance.with?.prepublish_plugin_registry_artifact_name).toContain( + "startsWith(", + ); + expect(dockerAcceptance.with?.prepublish_plugin_registry_artifact_name).toContain( + "'docker-e2e-prepublish-plugin-registry-'", + ); + expect(packageTelegram.with?.prepublish_plugin_registry_artifact_name).not.toContain( + "startsWith(", + ); expect(npm12Install.if).toBe("inputs.suite_profile != 'telegram'"); expect(dockerAcceptance.if).toBe( "inputs.suite_profile != 'telegram' && inputs.shared_image_policy == 'no-push-artifact'", @@ -2771,6 +2893,101 @@ describe("package acceptance workflow", () => { expect(workflow).toContain("Published upgrade survivor scenarios:"); }); + it("normalizes one closed prerelease registry tuple before child workflows", () => { + const tuple = packageAcceptanceRegistryTuple(); + const direct = runPackageAcceptanceRegistryInputValidation({ + prepublishPluginRegistryJson: JSON.stringify(tuple), + }); + expect(direct.result.status, direct.result.stderr).toBe(0); + expect(direct.output).toContain(`json=${JSON.stringify(tuple)}\n`); + + const candidate = runPackageAcceptanceRegistryInputValidation({ + candidateArtifactJson: JSON.stringify({ + imageArtifactName: "image-123-2", + ...tuple, + }), + }); + expect(candidate.result.status, candidate.result.stderr).toBe(0); + expect(candidate.output).toContain(`json=${JSON.stringify(tuple)}\n`); + + const identical = runPackageAcceptanceRegistryInputValidation({ + candidateArtifactJson: JSON.stringify(tuple), + prepublishPluginRegistryJson: JSON.stringify(tuple), + }); + expect(identical.result.status, identical.result.stderr).toBe(0); + expect(identical.output).toContain(`json=${JSON.stringify(tuple)}\n`); + }); + + it("rejects partial or ambiguous prerelease registry tuples before child workflows", () => { + const tuple = packageAcceptanceRegistryTuple(); + const partial = runPackageAcceptanceRegistryInputValidation({ + prepublishPluginRegistryJson: JSON.stringify({ + prepublishPluginRegistryArtifactId: tuple.prepublishPluginRegistryArtifactId, + }), + }); + expect(partial.result.status).toBe(1); + expect(partial.result.stderr).toContain( + "Prerelease plugin registry JSON must contain one complete immutable tuple.", + ); + + const ambiguous = runPackageAcceptanceRegistryInputValidation({ + candidateArtifactJson: JSON.stringify(tuple), + prepublishPluginRegistryJson: JSON.stringify( + packageAcceptanceRegistryTuple({ + prepublishPluginRegistryArtifactId: "789", + }), + ), + }); + expect(ambiguous.result.status).toBe(1); + expect(ambiguous.result.stderr).toContain("Prerelease plugin registry inputs disagree."); + }); + + it("generates a Telegram-only registry only for direct ref or artifact candidates", () => { + for (const source of ["ref", "artifact"] as const) { + const generated = runPackageAcceptanceResolveScript({ + source, + telegramMode: "mock-openai", + }); + expect(generated.result.status, generated.result.stderr).toBe(0); + expect(generated.args).toContain("--plugin-registry-output-dir\n"); + expect(generated.args).toContain(".artifacts/package-acceptance-telegram-plugin-registry\n"); + expect(generated.args).toContain('--required-plugin-packages-json\n["@openclaw/codex"]\n'); + } + + for (const source of ["npm", "url", "trusted-url"] as const) { + const skipped = runPackageAcceptanceResolveScript({ + source, + telegramMode: "mock-openai", + }); + expect(skipped.result.status, skipped.result.stderr).toBe(0); + expect(skipped.args).not.toContain("--plugin-registry-output-dir"); + } + + const telegramDisabled = runPackageAcceptanceResolveScript({ + source: "ref", + telegramMode: "none", + }); + expect(telegramDisabled.result.status, telegramDisabled.result.stderr).toBe(0); + expect(telegramDisabled.args).not.toContain("--plugin-registry-output-dir"); + }); + + it("reuses a supplied registry and keeps generated artifact names distinct from Docker", () => { + const tuple = packageAcceptanceRegistryTuple(); + const reused = runPackageAcceptanceResolveScript({ + prepublishPluginRegistryJson: JSON.stringify(tuple), + source: "ref", + telegramMode: "mock-openai", + }); + expect(reused.result.status, reused.result.stderr).toBe(0); + expect(reused.args).not.toContain("--plugin-registry-output-dir"); + + const workflow = readFileSync(PACKAGE_ACCEPTANCE_WORKFLOW, "utf8"); + expect(workflow).toContain( + "package-acceptance-telegram-plugin-registry-${{ github.run_id }}-${{ github.run_attempt }}", + ); + expect(workflow).toContain('"docker-e2e-prepublish-plugin-registry-" +'); + }); + it("selects one normalized Telegram scenario without enabling broad acceptance lanes", () => { const { outputs, result } = runPackageAcceptanceProfile({ suiteProfile: "telegram", @@ -4544,6 +4761,8 @@ describe("package artifact reuse", () => { "${{ (needs.resolve_target.outputs.package_acceptance_package_spec == '' && needs.resolve_target.outputs.package_mode != 'published') && 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 }}", + prepublish_plugin_registry_json: + "${{ needs.prepare_release_package.outputs.prepublish_plugin_registry_json }}", suite_profile: "custom", }); expect(releaseChecksTargetSummary.env).toMatchObject({ @@ -4556,7 +4775,7 @@ describe("package artifact reuse", () => { enable_prepublish_plugin_registry: '${{ contains(fromJSON(\'["artifact","ref"]\'), inputs.source) }}', prepublish_plugin_registry_manifest_sha256: - "${{ fromJSON(inputs.candidate_artifact_json || '{}').prepublishPluginRegistryManifestSha256 || '' }}", + "${{ startsWith(fromJSON(needs.resolve_package.outputs.prepublish_plugin_registry_json || '{}').prepublishPluginRegistryArtifactName || '', 'docker-e2e-prepublish-plugin-registry-') && fromJSON(needs.resolve_package.outputs.prepublish_plugin_registry_json || '{}').prepublishPluginRegistryManifestSha256 || '' }}", }); expect(workflow).toContain( "candidate_artifact_json cannot be combined with release package specs.", @@ -5872,6 +6091,14 @@ describe("package artifact reuse", () => { expect(runNpmTelegramInputValidation(packageTuple).status).toBe(0); expect(runNpmTelegramInputValidation({ ...packageTuple, ...registryTuple }).status).toBe(0); + expect( + runNpmTelegramInputValidation({ + ...packageTuple, + ...registryTuple, + PREPUBLISH_PLUGIN_REGISTRY_ARTIFACT_NAME: + "package-acceptance-telegram-plugin-registry-123-1", + }).status, + ).toBe(0); const partial = runNpmTelegramInputValidation({ ...packageTuple, diff --git a/test/scripts/plugin-prerelease-test-plan.test.ts b/test/scripts/plugin-prerelease-test-plan.test.ts index b66a080ab1d5..d305aac37c73 100644 --- a/test/scripts/plugin-prerelease-test-plan.test.ts +++ b/test/scripts/plugin-prerelease-test-plan.test.ts @@ -588,13 +588,14 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => { "${{ github.event_name == 'workflow_dispatch' && 'false' || steps.docs_scope.outputs.docs_only }}", OPENCLAW_CI_EVENT_NAME: "${{ github.event_name }}", OPENCLAW_CI_HISTORICAL_TARGET: "${{ steps.historical_target.outputs.eligible || 'false' }}", + OPENCLAW_CI_RELEASE_GATE: "${{ inputs.release_gate && 'true' || 'false' }}", OPENCLAW_CI_RELEASE_CANDIDATE_TARGET: "${{ steps.release_candidate_target.outputs.eligible || 'false' }}", OPENCLAW_CI_TARGET_CONTEXT_TARGET: "${{ steps.target_context_target.outputs.eligible || 'false' }}", OPENCLAW_CI_REPOSITORY: "${{ github.repository }}", OPENCLAW_CI_RUNNER_BACKEND: - "${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 'github' || vars.OPENCLAW_CI_RUNNER_BACKEND }}", + "${{ (github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)) && 'github' || vars.OPENCLAW_CI_RUNNER_BACKEND }}", OPENCLAW_CI_RUN_ANDROID: "${{ github.event_name == 'workflow_dispatch' && (inputs.release_gate || inputs.include_android) && 'true' || steps.changed_scope.outputs.run_android || 'false' }}", OPENCLAW_CI_RUN_CONTROL_UI_I18N: diff --git a/test/scripts/resolve-openclaw-package-candidate.test.ts b/test/scripts/resolve-openclaw-package-candidate.test.ts index a18ad8458c0b..08c6172d4e62 100644 --- a/test/scripts/resolve-openclaw-package-candidate.test.ts +++ b/test/scripts/resolve-openclaw-package-candidate.test.ts @@ -1,7 +1,7 @@ // Resolve Openclaw Package Candidate tests cover resolve openclaw package candidate script behavior. import { execFile, spawn } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; -import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { access, chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; @@ -14,6 +14,7 @@ import { downloadUrl, findSingleTarballForTest, loadTrustedPackageSource, + main, moveNewestPackedTarballForTest, parseArgs, readArtifactPackageCandidateMetadata, @@ -22,8 +23,10 @@ import { runCommandForTest, validateOpenClawPackageSpec, } from "../../scripts/resolve-openclaw-package-candidate.mts"; +import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js"; const tempDirs: string[] = []; +const autoTempDirs = useAutoCleanupTempDirTracker(afterEach); type LookupAddress = { address: string; family: number }; @@ -42,6 +45,99 @@ async function missing(file: string): Promise { ); } +async function createPackageTarball( + dir: string, + buildInfo?: string | { commit: string }, +): Promise { + const root = path.join(dir, "package"); + await mkdir(path.join(root, "dist"), { recursive: true }); + await writeFile( + path.join(root, "package.json"), + JSON.stringify({ name: "openclaw", version: "2026.8.1" }), + ); + if (buildInfo !== undefined) { + await writeFile( + path.join(root, "dist", "build-info.json"), + typeof buildInfo === "string" ? buildInfo : JSON.stringify(buildInfo), + ); + } + const tarball = path.join(dir, "openclaw.tgz"); + await new Promise((resolve, reject) => { + execFile("tar", ["-czf", tarball, "-C", dir, "package"], (error) => { + if (error) { + reject(toLintErrorObject(error, "Non-Error rejection")); + return; + } + resolve(); + }); + }); + return tarball; +} + +async function createArtifactFixture( + prefix: string, + { + buildInfo, + packageSourceSha, + }: { buildInfo?: string | { commit: string }; packageSourceSha?: string }, +) { + const dir = autoTempDirs.make(prefix); + const artifactDir = path.join(dir, "artifact"); + const binDir = path.join(dir, "bin"); + const gitLog = path.join(dir, "git.log"); + const nodeLog = path.join(dir, "node.log"); + await mkdir(artifactDir); + await mkdir(binDir); + await createPackageTarball(artifactDir, buildInfo); + if (packageSourceSha !== undefined) { + await writeFile( + path.join(artifactDir, "package-candidate.json"), + JSON.stringify({ packageSourceSha }), + ); + } + await writeFile( + path.join(binDir, "git"), + `#!/bin/sh +printf '%s\\n' "$*" >> "$FAKE_GIT_LOG" +exit 99 +`, + ); + await writeFile( + path.join(binDir, "node"), + `#!/bin/sh +printf '%s\\n' "$*" >> "$FAKE_NODE_LOG" +exit 0 +`, + ); + await chmod(path.join(binDir, "git"), 0o755); + await chmod(path.join(binDir, "node"), 0o755); + return { + artifactDir, + binDir, + dir, + gitLog, + nodeLog, + registryDir: path.join(dir, "registry"), + }; +} + +async function withArtifactFixtureCommands( + fixture: Awaited>, + run: () => Promise, +): Promise { + const previousPath = process.env.PATH; + process.env.FAKE_GIT_LOG = fixture.gitLog; + process.env.FAKE_NODE_LOG = fixture.nodeLog; + process.env.PATH = `${fixture.binDir}:${previousPath}`; + try { + return await run(); + } finally { + process.env.PATH = previousPath; + delete process.env.FAKE_GIT_LOG; + delete process.env.FAKE_NODE_LOG; + } +} + function isProcessAlive(pid: number): boolean { try { process.kill(pid, 0); @@ -1267,6 +1363,191 @@ describe("resolve-openclaw-package-candidate", () => { }); }); + it.each([ + ["without a registry", false], + ["with a registry", true], + ])("rejects artifact provenance mismatches %s before side effects", async (_label, registry) => { + const metadataSha = "66ce632b9b7c5c7fdd3e66c739687d51638ad6e2"; + const buildInfoSha = "77df743c0c8d6d80ee4f77d84a798e62749be7f3"; + const fixture = await createArtifactFixture("openclaw-artifact-provenance-mismatch-", { + buildInfo: { commit: buildInfoSha.toUpperCase() }, + packageSourceSha: metadataSha.toUpperCase(), + }); + + await withArtifactFixtureCommands(fixture, async () => { + await expect( + main([ + "--source", + "artifact", + "--artifact-dir", + fixture.artifactDir, + "--output-dir", + path.join(fixture.dir, "output"), + ...(registry + ? [ + "--plugin-registry-output-dir", + fixture.registryDir, + "--required-plugin-packages-json", + '["@openclaw/codex"]', + ] + : []), + ]), + ).rejects.toThrow( + `artifact packageSourceSha ${metadataSha} does not match package build-info commit ${buildInfoSha}`, + ); + }); + await expect(missing(fixture.gitLog)).resolves.toBe(true); + await expect(missing(fixture.registryDir)).resolves.toBe(true); + }); + + it("uses normalized artifact build-info provenance when metadata is absent", async () => { + const sourceSha = "66ce632b9b7c5c7fdd3e66c739687d51638ad6e2"; + const fixture = await createArtifactFixture("openclaw-artifact-build-info-fallback-", { + buildInfo: { commit: sourceSha.toUpperCase() }, + }); + const metadataPath = path.join(fixture.dir, "resolved.json"); + + await withArtifactFixtureCommands(fixture, async () => { + await main([ + "--source", + "artifact", + "--artifact-dir", + fixture.artifactDir, + "--output-dir", + path.join(fixture.dir, "output"), + "--metadata", + metadataPath, + ]); + }); + + const metadata = JSON.parse(await readFile(metadataPath, "utf8")) as Record; + expect(metadata.packageSourceSha).toBe(sourceSha); + expect(metadata.packageTrustedReason).toBe("package-build-info"); + }); + + it("requires artifact build-info only when preparing a registry", async () => { + const sourceSha = "66ce632b9b7c5c7fdd3e66c739687d51638ad6e2"; + const fixture = await createArtifactFixture("openclaw-artifact-missing-build-info-", { + packageSourceSha: sourceSha, + }); + const metadataPath = path.join(fixture.dir, "resolved.json"); + + await withArtifactFixtureCommands(fixture, async () => { + await main([ + "--source", + "artifact", + "--artifact-dir", + fixture.artifactDir, + "--output-dir", + path.join(fixture.dir, "output-without-registry"), + "--metadata", + metadataPath, + ]); + await expect( + main([ + "--source", + "artifact", + "--artifact-dir", + fixture.artifactDir, + "--output-dir", + path.join(fixture.dir, "output-with-registry"), + "--plugin-registry-output-dir", + fixture.registryDir, + "--required-plugin-packages-json", + '["@openclaw/codex"]', + ]), + ).rejects.toThrow( + "source=artifact requires a valid package build-info commit for prerelease plugin registry creation", + ); + }); + + const metadata = JSON.parse(await readFile(metadataPath, "utf8")) as Record; + expect(metadata.packageSourceSha).toBe(sourceSha); + await expect(missing(fixture.gitLog)).resolves.toBe(true); + await expect(missing(fixture.registryDir)).resolves.toBe(true); + }); + + it("rejects malformed artifact build-info before package validation", async () => { + const fixture = await createArtifactFixture("openclaw-artifact-malformed-build-info-", { + buildInfo: "{not-json", + packageSourceSha: "66ce632b9b7c5c7fdd3e66c739687d51638ad6e2", + }); + + await withArtifactFixtureCommands(fixture, async () => { + await expect( + main([ + "--source", + "artifact", + "--artifact-dir", + fixture.artifactDir, + "--output-dir", + path.join(fixture.dir, "output"), + ]), + ).rejects.toBeInstanceOf(SyntaxError); + }); + await expect(missing(fixture.nodeLog)).resolves.toBe(true); + }); + + it("validates the normalized artifact source SHA before registry preparation", async () => { + const dir = autoTempDirs.make("openclaw-artifact-registry-source-"); + const artifactDir = path.join(dir, "artifact"); + const binDir = path.join(dir, "bin"); + const gitLog = path.join(dir, "git.log"); + const sourceSha = "66ce632b9b7c5c7fdd3e66c739687d51638ad6e2"; + await mkdir(artifactDir); + await mkdir(binDir); + await createPackageTarball(artifactDir, { commit: sourceSha }); + await writeFile( + path.join(artifactDir, "package-candidate.json"), + JSON.stringify({ packageSourceSha: sourceSha.toUpperCase() }), + ); + const fakeGit = path.join(binDir, "git"); + await writeFile( + fakeGit, + `#!/bin/sh +printf '%s\\n' "$*" >> "$FAKE_GIT_LOG" +case "$1" in + fetch) exit 0 ;; + rev-parse) printf '%s\\n' "$FAKE_SOURCE_SHA" ;; + merge-base) exit 1 ;; + tag | for-each-ref) exit 0 ;; + *) exit 99 ;; +esac +`, + ); + await chmod(fakeGit, 0o755); + + const previousPath = process.env.PATH; + process.env.FAKE_GIT_LOG = gitLog; + process.env.FAKE_SOURCE_SHA = sourceSha; + process.env.PATH = `${binDir}:${previousPath}`; + try { + await expect( + main([ + "--source", + "artifact", + "--artifact-dir", + artifactDir, + "--output-dir", + path.join(dir, "output"), + "--plugin-registry-output-dir", + path.join(dir, "registry"), + "--required-plugin-packages-json", + '["@openclaw/codex"]', + ]), + ).rejects.toThrow( + `package_ref ${sourceSha} resolved to ${sourceSha}, which is not reachable from an OpenClaw branch or release tag`, + ); + } finally { + process.env.PATH = previousPath; + delete process.env.FAKE_GIT_LOG; + delete process.env.FAKE_SOURCE_SHA; + } + await expect(readFile(gitLog, "utf8")).resolves.toContain( + `rev-parse --verify ${sourceSha}^{commit}`, + ); + }); + it("normalizes whitespace-only artifact package source SHAs to absent", async () => { const dir = await mkdtemp(path.join(tmpdir(), "openclaw-package-candidate-empty-sha-")); tempDirs.push(dir); @@ -1341,22 +1622,8 @@ describe("resolve-openclaw-package-candidate", () => { it("reads the source SHA from packed npm build metadata", async () => { const dir = await mkdtemp(path.join(tmpdir(), "openclaw-package-build-info-")); tempDirs.push(dir); - const root = path.join(dir, "package"); - await mkdir(path.join(root, "dist"), { recursive: true }); - await writeFile(path.join(root, "package.json"), JSON.stringify({ name: "openclaw" })); - await writeFile( - path.join(root, "dist", "build-info.json"), - JSON.stringify({ commit: "66CE632B9B7C5C7FDD3E66C739687D51638AD6E2" }), - ); - const tarball = path.join(dir, "openclaw.tgz"); - await new Promise((resolve, reject) => { - execFile("tar", ["-czf", tarball, "-C", dir, "package"], (error) => { - if (error) { - reject(toLintErrorObject(error, "Non-Error rejection")); - return; - } - resolve(); - }); + const tarball = await createPackageTarball(dir, { + commit: "66CE632B9B7C5C7FDD3E66C739687D51638AD6E2", }); await expect(readPackageBuildSourceSha(tarball)).resolves.toBe(