fix(release): restore direct package Telegram acceptance (#129912)

* fix(release): provision package acceptance plugin registry

* fix(release): accept registry tuple in package dispatch

* fix(release): default Docker advisory input

* fix(release): bind artifact registry to package source

* fix(release): preserve package advisory dispatch

* fix(ci): bound hosted release gate checks

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>

* test(ci): update release-gate manifest fixture

* fix(ci): avoid duplicate release gate lint

* test(release): restore Docker helper fixture path

---------

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
Vincent Koc
2026-08-27 08:32:19 +08:00
committed by GitHub
parent 0b60846fa4
commit 8afa8f339e
11 changed files with 835 additions and 77 deletions
+40 -12
View File
@@ -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') }}
+9 -5
View File
@@ -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) ;;
@@ -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' || '' }}
+145 -14
View File
@@ -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 }}
+1 -1
View File
@@ -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
+33 -10
View File
@@ -192,7 +192,7 @@ Options:
--output-name <name> Output tarball filename. Default: ${DEFAULT_OUTPUT_NAME}
--metadata <file> Write package metadata JSON.
--plugin-registry-output-dir <dir>
Build an immutable registry for source=ref before cleanup.
Build an immutable registry for source=ref, npm, or artifact.
--required-plugin-packages-json <json>
Scoped package names to include in that registry.
--github-output <file> 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<ReturnType<typeof preparePackageSourceWorktree>> | 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";
}
+62 -9
View File
@@ -99,6 +99,7 @@ function evaluateWorkflowExpression(
headRepository?: string;
matrix?: Record<string, unknown>;
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", () => {
+23
View File
@@ -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", () => {
@@ -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<string, string> = {}) {
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<string, string>) {
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,
@@ -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:
@@ -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<boolean> {
);
}
async function createPackageTarball(
dir: string,
buildInfo?: string | { commit: string },
): Promise<string> {
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<void>((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<T>(
fixture: Awaited<ReturnType<typeof createArtifactFixture>>,
run: () => Promise<T>,
): Promise<T> {
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<string, unknown>;
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<string, unknown>;
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<void>((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(