From 6cf06e8e7eb084bbae53795e12c09f61344f87e2 Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Fri, 12 Jun 2026 20:16:06 -0700 Subject: [PATCH] ci: split plugin ClawHub publishing paths * feat: partition clawhub plugin release candidates * fix: read clawhub trusted publisher config endpoint * feat: split clawhub plugin bootstrap workflow * ci: split plugin clawhub publish paths * ci: pin clawhub package publish workflow * ci: keep clawhub bootstrap token out of builds * ci: fix clawhub release dry-run gating * ci: align clawhub oidc publish refs * ci: make clawhub bootstrap recovery idempotent * ci: route clawhub repair candidates through bootstrap * ci: preserve tideclaw alpha clawhub guards * ci: simplify clawhub release ref handling * ci: extract clawhub release routing plan * ci: extract clawhub release runtime state * test: guard clawhub release helper executability * ci: pin ClawHub CLI for plugin publishing * ci: allow historical ClawHub dry-run validation * ci: fix ClawHub bootstrap token handoff --- .../workflows/openclaw-release-publish.yml | 214 +++++- .github/workflows/plugin-clawhub-new.yml | 504 ++++++++++++++ .github/workflows/plugin-clawhub-release.yml | 252 +++---- scripts/lib/openclaw-release-clawhub-plan.ts | 314 +++++++++ scripts/lib/plugin-clawhub-release.ts | 168 ++++- scripts/lib/release-beta-verifier.ts | 27 +- scripts/openclaw-release-clawhub-plan.ts | 14 + .../openclaw-release-clawhub-runtime-state.ts | 92 +++ scripts/plugin-clawhub-publish.sh | 8 + test/plugin-clawhub-release.test.ts | 635 +++++++++++++++++- .../package-acceptance-workflow.test.ts | 173 ++++- test/scripts/release-beta-verifier.test.ts | 7 + 12 files changed, 2197 insertions(+), 211 deletions(-) create mode 100644 .github/workflows/plugin-clawhub-new.yml create mode 100644 scripts/lib/openclaw-release-clawhub-plan.ts create mode 100755 scripts/openclaw-release-clawhub-plan.ts create mode 100755 scripts/openclaw-release-clawhub-runtime-state.ts diff --git a/.github/workflows/openclaw-release-publish.yml b/.github/workflows/openclaw-release-publish.yml index aeac4d085bf..b31c9bf8614 100644 --- a/.github/workflows/openclaw-release-publish.yml +++ b/.github/workflows/openclaw-release-publish.yml @@ -387,7 +387,9 @@ jobs: run: | set -euo pipefail - dispatch_workflow() { + dispatch_workflow_at_ref() { + local workflow_ref="$1" + shift local workflow="$1" shift @@ -397,7 +399,7 @@ jobs: -F per_page=100 \ --jq '[.workflow_runs[].id]')" - dispatch_output="$(gh workflow run --repo "$GITHUB_REPOSITORY" "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@" 2>&1)" + dispatch_output="$(gh workflow run --repo "$GITHUB_REPOSITORY" "$workflow" --ref "$workflow_ref" "$@" 2>&1)" printf '%s\n' "$dispatch_output" >&2 run_id="$( printf '%s\n' "$dispatch_output" | @@ -432,6 +434,10 @@ jobs: printf '%s\n' "${run_id}" } + dispatch_workflow() { + dispatch_workflow_at_ref "$CHILD_WORKFLOW_REF" "$@" + } + print_pending_deployments() { local workflow="$1" local run_id="$2" @@ -710,6 +716,71 @@ jobs: exit 1 } + resolve_clawhub_release_plan() { + local -a plan_args + + clawhub_plan_path="${RUNNER_TEMP}/openclaw-release-clawhub-plan.json" + plan_args=( + --release-tag "${RELEASE_TAG}" + --release-publish-branch "${CHILD_WORKFLOW_REF}" + --release-publish-run-id "${GITHUB_RUN_ID}" + --plugin-publish-scope "${PLUGIN_PUBLISH_SCOPE}" + ) + if [[ -n "${PLUGINS// }" ]]; then + plan_args+=(--plugins "${PLUGINS}") + fi + + CLAWHUB_REGISTRY="${CLAWHUB_REGISTRY:-https://clawhub.ai}" \ + node --import tsx scripts/openclaw-release-clawhub-plan.ts "${plan_args[@]}" > "${clawhub_plan_path}" + + echo "Resolved OpenClaw release ClawHub dispatch plan:" + cat "${clawhub_plan_path}" + + clawhub_workflow_ref="$(jq -r '.clawHubWorkflowRef' "${clawhub_plan_path}")" + normal_plugins="$(jq -r '.summary.normalPlugins' "${clawhub_plan_path}")" + bootstrap_plugins="$(jq -r '.summary.bootstrapPlugins' "${clawhub_plan_path}")" + missing_trusted_plugins="$(jq -r '.summary.missingTrustedPlugins' "${clawhub_plan_path}")" + normal_plugin_count="$(jq -r '.summary.normalCount' "${clawhub_plan_path}")" + bootstrap_plugin_count="$(jq -r '.summary.bootstrapCount' "${clawhub_plan_path}")" + missing_trusted_plugin_count="$(jq -r '.summary.missingTrustedPublisherCount' "${clawhub_plan_path}")" + + { + echo "### ClawHub release plan" + echo + echo "- Normal OIDC candidates: \`${normal_plugin_count}\`" + echo "- Bootstrap/repair candidates: \`${bootstrap_plugin_count}\`" + echo "- Existing-package trusted-publisher repairs: \`${missing_trusted_plugin_count}\`" + if [[ -n "${normal_plugins}" ]]; then + echo "- Normal plugins: \`${normal_plugins}\`" + fi + if [[ -n "${bootstrap_plugins}" ]]; then + echo "- Bootstrap/repair plugins: \`${bootstrap_plugins}\`" + fi + if [[ -n "${missing_trusted_plugins}" ]]; then + echo "- Trusted-publisher repair plugins: \`${missing_trusted_plugins}\`" + fi + } >> "$GITHUB_STEP_SUMMARY" + } + + append_clawhub_dispatch_args() { + local target="$1" + while IFS=$'\t' read -r key value; do + clawhub_dispatch_args+=(-f "${key}=${value}") + done < <(jq -r --arg target "${target}" '.[$target].inputs | to_entries[] | [.key, .value] | @tsv' "${clawhub_plan_path}") + } + + write_clawhub_runtime_state() { + local force_skip_clawhub="$1" + local output_path="$2" + node --import tsx scripts/openclaw-release-clawhub-runtime-state.ts \ + --repository "${GITHUB_REPOSITORY}" \ + --wait-for-clawhub "${WAIT_FOR_CLAWHUB}" \ + --force-skip-clawhub "${force_skip_clawhub}" \ + --normal-run-id "${plugin_clawhub_run_id:-}" \ + --bootstrap-run-id "${plugin_clawhub_bootstrap_run_id:-}" \ + --bootstrap-completed "${plugin_clawhub_bootstrap_completed:-false}" > "${output_path}" + } + create_or_update_github_release() { local release_version notes_version title notes_file changelog_file latest_arg prerelease_args release_version="${RELEASE_TAG#v}" @@ -798,7 +869,7 @@ jobs: } verify_published_release() { - local release_version evidence_path skip_clawhub + local release_version evidence_path skip_clawhub clawhub_runtime_state_path local -a verify_args skip_clawhub="${1:-false}" @@ -815,17 +886,18 @@ jobs: --dist-tag "${RELEASE_NPM_DIST_TAG}" --repo "${GITHUB_REPOSITORY}" --workflow-ref "${CHILD_WORKFLOW_REF}" + --clawhub-workflow-ref "${clawhub_workflow_ref}" --full-release-validation-run "${FULL_RELEASE_VALIDATION_RUN_ID}" --plugin-npm-run "${plugin_npm_run_id}" --openclaw-npm-run "${openclaw_npm_run_id}" --evidence-out "${evidence_path}" --skip-github-release ) - if [[ "${skip_clawhub}" == "true" || "${WAIT_FOR_CLAWHUB}" != "true" ]]; then - verify_args+=(--skip-clawhub) - else - verify_args+=(--plugin-clawhub-run "${plugin_clawhub_run_id}") - fi + clawhub_runtime_state_path="${RUNNER_TEMP}/openclaw-release-clawhub-runtime-state-verify.json" + write_clawhub_runtime_state "${skip_clawhub}" "${clawhub_runtime_state_path}" + while IFS= read -r arg; do + verify_args+=("${arg}") + done < <(jq -r '.verifierArgs[]' "${clawhub_runtime_state_path}") if [[ -n "${PLUGINS// }" ]]; then verify_args+=(--plugins "${PLUGINS}") fi @@ -841,7 +913,7 @@ jobs: } append_release_proof_to_github_release() { - local release_version body_file notes_file tarball integrity telegram_line clawhub_line + local release_version body_file notes_file tarball integrity telegram_line clawhub_line clawhub_bootstrap_line clawhub_runtime_state_path release_version="${RELEASE_TAG#v}" body_file="${RUNNER_TEMP}/release-body.md" @@ -855,11 +927,10 @@ jobs: else telegram_line="- npm Telegram beta E2E: not supplied" fi - if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then - clawhub_line="- plugin ClawHub publish: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${plugin_clawhub_run_id}" - else - clawhub_line="- plugin ClawHub publish: dispatched separately, not awaited by this proof: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${plugin_clawhub_run_id}" - fi + clawhub_runtime_state_path="${RUNNER_TEMP}/openclaw-release-clawhub-runtime-state-proof.json" + write_clawhub_runtime_state false "${clawhub_runtime_state_path}" + clawhub_line="$(jq -r '.proofLines.normal' "${clawhub_runtime_state_path}")" + clawhub_bootstrap_line="$(jq -r '.proofLines.bootstrap' "${clawhub_runtime_state_path}")" RELEASE_BODY_FILE="${body_file}" \ RELEASE_NOTES_FILE="${notes_file}" \ @@ -875,6 +946,7 @@ jobs: PLUGIN_NPM_RUN_ID="${plugin_npm_run_id}" \ OPENCLAW_NPM_RUN_ID="${openclaw_npm_run_id}" \ CLAWHUB_LINE="${clawhub_line}" \ + CLAWHUB_BOOTSTRAP_LINE="${clawhub_bootstrap_line}" \ TELEGRAM_LINE="${telegram_line}" \ node --input-type=module <<'NODE' import { readFileSync, writeFileSync } from "node:fs"; @@ -899,6 +971,7 @@ jobs: `- full release validation: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.FULL_RELEASE_VALIDATION_RUN_ID}`, `- plugin npm publish: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.PLUGIN_NPM_RUN_ID}`, process.env.CLAWHUB_LINE, + process.env.CLAWHUB_BOOTSTRAP_LINE, `- OpenClaw npm publish: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.OPENCLAW_NPM_RUN_ID}`, process.env.TELEGRAM_LINE, ].join("\n"); @@ -915,6 +988,7 @@ jobs: echo "### Publish sequence" echo echo "- Workflow ref: \`${CHILD_WORKFLOW_REF}\`" + echo "- ClawHub workflow ref: release tag \`${RELEASE_TAG}\`" echo "- Release tag: \`${RELEASE_TAG}\`" echo "- Release SHA: \`${TARGET_SHA}\`" echo "- Release approval: this workflow job" @@ -933,27 +1007,66 @@ jobs: guard_existing_public_release guard_openclaw_npm_not_already_published + resolve_clawhub_release_plan npm_args=(-f publish_scope="${PLUGIN_PUBLISH_SCOPE}" -f ref="${TARGET_SHA}" -f release_publish_run_id="${GITHUB_RUN_ID}") - clawhub_args=(-f publish_scope="${PLUGIN_PUBLISH_SCOPE}" -f ref="${TARGET_SHA}" -f release_publish_run_id="${GITHUB_RUN_ID}") if [[ -n "${PLUGINS}" ]]; then npm_args+=(-f plugins="${PLUGINS}") - clawhub_args+=(-f plugins="${PLUGINS}") fi plugin_npm_run_id="$(dispatch_workflow plugin-npm-release.yml "${npm_args[@]}")" - plugin_clawhub_run_id="$(dispatch_workflow plugin-clawhub-release.yml "${clawhub_args[@]}")" + plugin_clawhub_run_id="" + if [[ "$(jq -r '.normal.shouldDispatch' "${clawhub_plan_path}")" == "true" ]]; then + clawhub_dispatch_args=() + append_clawhub_dispatch_args normal + plugin_clawhub_run_id="$(dispatch_workflow_at_ref \ + "$(jq -r '.normal.ref' "${clawhub_plan_path}")" \ + "$(jq -r '.normal.workflow' "${clawhub_plan_path}")" \ + "${clawhub_dispatch_args[@]}")" + else + echo "- plugin-clawhub-release.yml: no normal OIDC candidates" >> "$GITHUB_STEP_SUMMARY" + fi + plugin_clawhub_bootstrap_run_id="" + plugin_clawhub_bootstrap_completed="false" + if [[ "$(jq -r '.bootstrap.shouldDispatch' "${clawhub_plan_path}")" == "true" ]]; then + clawhub_dispatch_args=() + append_clawhub_dispatch_args bootstrap + plugin_clawhub_bootstrap_run_id="$(dispatch_workflow_at_ref \ + "$(jq -r '.bootstrap.ref' "${clawhub_plan_path}")" \ + "$(jq -r '.bootstrap.workflow' "${clawhub_plan_path}")" \ + "${clawhub_dispatch_args[@]}")" + else + echo "- plugin-clawhub-new.yml: no bootstrap candidates" >> "$GITHUB_STEP_SUMMARY" + fi { echo "- Plugin npm run ID: \`${plugin_npm_run_id}\`" - echo "- Plugin ClawHub run ID: \`${plugin_clawhub_run_id}\`" + echo "- Plugin ClawHub run ID: \`${plugin_clawhub_run_id:-none}\`" + echo "- Plugin ClawHub bootstrap run ID: \`${plugin_clawhub_bootstrap_run_id:-none}\`" } >> "$GITHUB_STEP_SUMMARY" if ! wait_for_run plugin-npm-release.yml "${plugin_npm_run_id}"; then - echo "Plugin npm publish failed; cancelling ClawHub publish child ${plugin_clawhub_run_id}." >&2 - gh run cancel --repo "$GITHUB_REPOSITORY" "${plugin_clawhub_run_id}" >/dev/null 2>&1 || true + echo "Plugin npm publish failed; cancelling dispatched ClawHub child workflows." >&2 + if [[ -n "${plugin_clawhub_run_id}" ]]; then + gh run cancel --repo "$GITHUB_REPOSITORY" "${plugin_clawhub_run_id}" >/dev/null 2>&1 || true + fi + if [[ -n "${plugin_clawhub_bootstrap_run_id}" ]]; then + gh run cancel --repo "$GITHUB_REPOSITORY" "${plugin_clawhub_bootstrap_run_id}" >/dev/null 2>&1 || true + fi exit 1 fi + if [[ -n "${plugin_clawhub_bootstrap_run_id}" && "${WAIT_FOR_CLAWHUB}" == "true" ]]; then + echo "Waiting for plugin-clawhub-new.yml bootstrap to finish before continuing release publish." + if wait_for_run plugin-clawhub-new.yml "${plugin_clawhub_bootstrap_run_id}"; then + plugin_clawhub_bootstrap_completed="true" + else + if [[ -n "${plugin_clawhub_run_id}" ]]; then + gh run cancel --repo "$GITHUB_REPOSITORY" "${plugin_clawhub_run_id}" >/dev/null 2>&1 || true + fi + exit 1 + fi + fi + openclaw_npm_run_id="" if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then openclaw_npm_run_id="$(dispatch_workflow openclaw-npm-release.yml \ @@ -970,19 +1083,52 @@ jobs: clawhub_result="" clawhub_pid="" + clawhub_bootstrap_result="" + clawhub_bootstrap_pid="" if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then - clawhub_result="$RUNNER_TEMP/clawhub-result.txt" - wait_run_pid="" - wait_for_run_background plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "${clawhub_result}" - clawhub_pid="${wait_run_pid}" - else - wait_for_job_success plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "Validate release publish approval" - if approve_child_publish_environment plugin-clawhub-release.yml "${plugin_clawhub_run_id}"; then - : - else - echo "- plugin-clawhub-release.yml: child environment gate not ready; publish was left dispatched (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY" + if [[ -n "${plugin_clawhub_run_id}" ]]; then + clawhub_result="$RUNNER_TEMP/clawhub-result.txt" + wait_run_pid="" + wait_for_run_background plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "${clawhub_result}" + clawhub_pid="${wait_run_pid}" + fi + if [[ -n "${plugin_clawhub_bootstrap_run_id}" ]]; then + if [[ "${plugin_clawhub_bootstrap_completed}" == "true" ]]; then + echo "- plugin-clawhub-new.yml: bootstrap already completed before continuing" >> "$GITHUB_STEP_SUMMARY" + else + clawhub_bootstrap_result="$RUNNER_TEMP/clawhub-bootstrap-result.txt" + wait_run_pid="" + wait_for_run_background plugin-clawhub-new.yml "${plugin_clawhub_bootstrap_run_id}" "${clawhub_bootstrap_result}" + clawhub_bootstrap_pid="${wait_run_pid}" + fi + fi + else + if [[ -n "${plugin_clawhub_run_id}" ]]; then + wait_for_job_success plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "Validate release publish approval" + if approve_child_publish_environment plugin-clawhub-release.yml "${plugin_clawhub_run_id}"; then + : + else + echo "- plugin-clawhub-release.yml: child environment gate not ready; publish was left dispatched (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY" + fi + echo "- plugin-clawhub-release.yml: publish not awaited (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY" + else + echo "- plugin-clawhub-release.yml: no normal OIDC publish to await" >> "$GITHUB_STEP_SUMMARY" + fi + if [[ -n "${plugin_clawhub_bootstrap_run_id}" ]]; then + if [[ "${plugin_clawhub_bootstrap_completed}" == "true" ]]; then + echo "- plugin-clawhub-new.yml: bootstrap already completed before continuing" >> "$GITHUB_STEP_SUMMARY" + else + wait_for_job_success plugin-clawhub-new.yml "${plugin_clawhub_bootstrap_run_id}" "Validate release publish approval" + if approve_child_publish_environment plugin-clawhub-new.yml "${plugin_clawhub_bootstrap_run_id}"; then + : + else + echo "- plugin-clawhub-new.yml: child environment gate not ready; bootstrap was left dispatched (${plugin_clawhub_bootstrap_run_id})" >> "$GITHUB_STEP_SUMMARY" + fi + echo "- plugin-clawhub-new.yml: bootstrap not awaited (${plugin_clawhub_bootstrap_run_id})" >> "$GITHUB_STEP_SUMMARY" + fi + else + echo "- plugin-clawhub-new.yml: no bootstrap publish to await" >> "$GITHUB_STEP_SUMMARY" fi - echo "- plugin-clawhub-release.yml: publish not awaited (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY" fi openclaw_result="" @@ -1011,6 +1157,12 @@ jobs: if [[ -f "${clawhub_result}" && "$(cat "${clawhub_result}")" != "success" ]]; then failed=1 fi + if [[ -n "${clawhub_bootstrap_pid}" ]] && ! wait "${clawhub_bootstrap_pid}"; then + failed=1 + fi + if [[ -f "${clawhub_bootstrap_result}" && "$(cat "${clawhub_bootstrap_result}")" != "success" ]]; then + failed=1 + fi if [[ -n "${openclaw_npm_run_id}" && "${openclaw_failed}" == "0" ]]; then if [[ "${failed}" == "0" ]]; then diff --git a/.github/workflows/plugin-clawhub-new.yml b/.github/workflows/plugin-clawhub-new.yml new file mode 100644 index 00000000000..7e2e6073a07 --- /dev/null +++ b/.github/workflows/plugin-clawhub-new.yml @@ -0,0 +1,504 @@ +name: Plugin ClawHub New + +on: + workflow_dispatch: + inputs: + plugins: + description: Comma-separated plugin package names to bootstrap on ClawHub + required: true + type: string + ref: + description: Commit SHA on main, a release branch, or the matching Tideclaw alpha branch to publish from; defaults to the workflow ref + required: false + default: "" + type: string + release_publish_run_id: + description: Approved OpenClaw Release Publish workflow run id + required: false + type: string + release_publish_branch: + description: Branch name of the approving OpenClaw Release Publish workflow run + required: false + type: string + dry_run: + description: Validate the token-gated ClawHub bootstrap handoff without publishing. + required: false + default: false + type: boolean + +concurrency: + group: plugin-clawhub-new-${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }} + cancel-in-progress: false + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + NODE_VERSION: "24.15.0" + CLAWHUB_REGISTRY: "https://clawhub.ai" + CLAWHUB_CLI_PACKAGE: "clawhub@0.21.0" + +jobs: + resolve_bootstrap_plan: + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + ref_revision: ${{ steps.ref.outputs.sha }} + has_bootstrap_candidates: ${{ steps.plan.outputs.has_bootstrap_candidates }} + bootstrap_candidate_count: ${{ steps.plan.outputs.bootstrap_candidate_count }} + matrix: ${{ steps.plan.outputs.matrix }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + ref: ${{ github.ref }} + fetch-depth: 0 + + - name: Resolve checked-out ref + id: ref + env: + TARGET_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }} + run: | + set -euo pipefail + git fetch --no-tags origin \ + +refs/heads/main:refs/remotes/origin/main \ + '+refs/heads/release/*:refs/remotes/origin/release/*' + if [[ -n "${TARGET_REF}" ]]; then + if git rev-parse --verify --quiet "${TARGET_REF}^{commit}" >/dev/null; then + target_sha="$(git rev-parse "${TARGET_REF}^{commit}")" + elif git rev-parse --verify --quiet "origin/${TARGET_REF}^{commit}" >/dev/null; then + target_sha="$(git rev-parse "origin/${TARGET_REF}^{commit}")" + else + echo "Unable to resolve requested publish ref: ${TARGET_REF}" >&2 + exit 1 + fi + git checkout --detach "${target_sha}" + fi + echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + - name: Validate ref is on a trusted publish branch + env: + TRUSTED_PUBLISH_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }} + run: | + set -euo pipefail + if git merge-base --is-ancestor HEAD origin/main; then + exit 0 + fi + while IFS= read -r release_ref; do + if git merge-base --is-ancestor HEAD "${release_ref}"; then + exit 0 + fi + done < <(git for-each-ref --format='%(refname)' refs/remotes/origin/release) + if [[ "${TRUSTED_PUBLISH_BRANCH}" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then + alpha_branch="${TRUSTED_PUBLISH_BRANCH}" + git fetch --no-tags origin "+refs/heads/${alpha_branch}:refs/remotes/origin/${alpha_branch}" + if git merge-base --is-ancestor HEAD "refs/remotes/origin/${alpha_branch}"; then + exit 0 + fi + fi + echo "Plugin ClawHub bootstraps must target a commit reachable from main, release/*, or the matching Tideclaw alpha branch." >&2 + exit 1 + + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + node-version: ${{ env.NODE_VERSION }} + install-bun: "false" + + - name: Validate publishable plugin metadata + env: + RELEASE_PLUGINS: ${{ inputs.plugins }} + run: | + set -euo pipefail + if [[ -z "${RELEASE_PLUGINS// }" ]]; then + echo "Plugin ClawHub bootstrap requires at least one package name in plugins." >&2 + exit 1 + fi + pnpm release:plugins:clawhub:check -- --selection-mode selected --plugins "${RELEASE_PLUGINS}" + + - name: Resolve plugin bootstrap plan + id: plan + env: + RELEASE_PLUGINS: ${{ inputs.plugins }} + CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }} + run: | + set -euo pipefail + mkdir -p .local + node --import tsx scripts/plugin-clawhub-release-plan.ts \ + --selection-mode selected \ + --plugins "${RELEASE_PLUGINS}" > .local/plugin-clawhub-release-plan.json + + cat .local/plugin-clawhub-release-plan.json + + bootstrap_candidate_count="$(jq -r '(.bootstrapCandidates | length) + (.missingTrustedPublisher | length)' .local/plugin-clawhub-release-plan.json)" + selected_count="$(jq -r '.all | length' .local/plugin-clawhub-release-plan.json)" + matrix_json="$( + jq -c ' + [ + .bootstrapCandidates[]? + { + bootstrapMode: "publish", + requiresManualOverride: false + }, + .missingTrustedPublisher[]? + { + bootstrapMode: (if .alreadyPublished then "configure-only" else "publish" end), + requiresManualOverride: true + } + ] + ' .local/plugin-clawhub-release-plan.json + )" + has_bootstrap_candidates="false" + if [[ "${bootstrap_candidate_count}" != "0" ]]; then + has_bootstrap_candidates="true" + fi + + invalid_scope="$( + jq -r ' + (.bootstrapCandidates[]?, .missingTrustedPublisher[]?) + | select(.packageName | startswith("@openclaw/") | not) + | "- \(.packageName)@\(.version)" + ' .local/plugin-clawhub-release-plan.json + )" + if [[ -n "${invalid_scope}" ]]; then + echo "Plugin ClawHub bootstrap only supports @openclaw/* packages." >&2 + printf '%s\n' "${invalid_scope}" >&2 + exit 1 + fi + + not_bootstrap="$( + jq -r ' + (.bootstrapCandidates | map(.packageName)) as $bootstrapNames + | (.missingTrustedPublisher | map(.packageName)) as $repairNames + | .all[]? + | select(.packageName as $name | ($bootstrapNames + $repairNames | index($name) | not)) + | "- \(.packageName)@\(.version)" + ' .local/plugin-clawhub-release-plan.json + )" + if [[ -n "${not_bootstrap}" ]]; then + echo "Selected packages must all be first-publish bootstrap candidates or trusted-publisher repair candidates." >&2 + printf '%s\n' "${not_bootstrap}" >&2 + exit 1 + fi + if [[ "${selected_count}" == "0" || "${bootstrap_candidate_count}" == "0" ]]; then + echo "No selected packages require ClawHub bootstrap." >&2 + exit 1 + fi + + { + echo "bootstrap_candidate_count=${bootstrap_candidate_count}" + echo "has_bootstrap_candidates=${has_bootstrap_candidates}" + echo "matrix=${matrix_json}" + } >> "$GITHUB_OUTPUT" + + echo "ClawHub bootstrap candidates:" + jq -r ' + .bootstrapCandidates[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir)" + ' .local/plugin-clawhub-release-plan.json + echo "ClawHub trusted-publisher repair candidates:" + jq -r ' + .missingTrustedPublisher[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir), alreadyPublished=\(.alreadyPublished)" + ' .local/plugin-clawhub-release-plan.json + + - name: Validate Tideclaw alpha plugin channels + env: + TRUSTED_PUBLISH_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }} + run: | + set -euo pipefail + if [[ ! "${TRUSTED_PUBLISH_BRANCH}" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then + exit 0 + fi + invalid="$( + jq -r ' + (.bootstrapCandidates[]?, .missingTrustedPublisher[]?) + | select(.publishTag != "alpha" or .channel != "alpha") + | "- \(.packageName)@\(.version) [\(.publishTag)]" + ' .local/plugin-clawhub-release-plan.json + )" + if [[ -n "${invalid}" ]]; then + echo "Tideclaw alpha ClawHub bootstraps may only publish alpha plugin versions." >&2 + printf '%s\n' "${invalid}" >&2 + exit 1 + fi + + validate_release_publish_approval: + name: Validate release publish approval + needs: resolve_bootstrap_plan + if: github.event_name == 'workflow_dispatch' && needs.resolve_bootstrap_plan.outputs.has_bootstrap_candidates == 'true' + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Validate release publish approval run + env: + GH_TOKEN: ${{ github.token }} + RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }} + EXPECTED_WORKFLOW_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }} + run: | + set -euo pipefail + if [[ -z "${RELEASE_PUBLISH_RUN_ID// }" ]]; then + if [[ "${GITHUB_ACTOR}" == "github-actions[bot]" ]]; then + echo "Plugin ClawHub bootstrap dispatched by another workflow must include release_publish_run_id." >&2 + exit 1 + fi + echo "Direct Plugin ClawHub New dispatch; relying on this workflow's clawhub-plugin-bootstrap environment approval." + exit 0 + fi + direct_recovery=false + if [[ "${GITHUB_ACTOR}" != "github-actions[bot]" ]]; then + direct_recovery=true + echo "Direct Plugin ClawHub New recovery with release_publish_run_id; relying on this workflow's clawhub-plugin-bootstrap environment approval." + fi + RUN_JSON="$(gh run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)" + printf '%s' "$RUN_JSON" | DIRECT_RELEASE_RECOVERY="${direct_recovery}" node scripts/validate-release-publish-approval.mjs + + validate_bootstrap_trusted_publisher_cli: + needs: [resolve_bootstrap_plan, validate_release_publish_approval] + if: always() && github.event_name == 'workflow_dispatch' && inputs.dry_run != true && needs.resolve_bootstrap_plan.outputs.has_bootstrap_candidates == 'true' && needs.validate_release_publish_approval.result == 'success' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Validate pinned ClawHub trusted publisher CLI support + env: + CLAWHUB_CLI_PACKAGE: ${{ env.CLAWHUB_CLI_PACKAGE }} + run: | + set -euo pipefail + help_output="$( + npm exec --yes --package "${CLAWHUB_CLI_PACKAGE}" -- \ + clawhub package trusted-publisher set --help 2>&1 || true + )" + printf '%s\n' "${help_output}" + if ! grep -Fq "Usage: clawhub package trusted-publisher set" <<<"${help_output}"; then + echo "::error::CLAW-277 03 - Split OpenClaw plugin ClawHub publishing into OIDC release and token bootstrap workflows requires ${CLAWHUB_CLI_PACKAGE} to expose 'package trusted-publisher set' before token bootstrap publish can run. The pinned CLI returned parent help or no set command, so this workflow is stopping before creating a ClawHub package row." + exit 1 + fi + for required_flag in --repository --workflow-filename; do + if ! grep -Fq -- "${required_flag}" <<<"${help_output}"; then + echo "::error::CLAW-277 03 - Split OpenClaw plugin ClawHub publishing into OIDC release and token bootstrap workflows requires ${CLAWHUB_CLI_PACKAGE} trusted-publisher set help to include ${required_flag}." + exit 1 + fi + done + + publish_bootstrap_plugins: + needs: + [ + resolve_bootstrap_plan, + validate_release_publish_approval, + validate_bootstrap_trusted_publisher_cli, + ] + if: always() && github.event_name == 'workflow_dispatch' && needs.resolve_bootstrap_plan.outputs.has_bootstrap_candidates == 'true' && needs.validate_release_publish_approval.result == 'success' && (inputs.dry_run == true || needs.validate_bootstrap_trusted_publisher_cli.result == 'success') + runs-on: ubuntu-latest + environment: clawhub-plugin-bootstrap + permissions: + contents: read + strategy: + fail-fast: false + max-parallel: 8 + matrix: + plugin: ${{ fromJson(needs.resolve_bootstrap_plan.outputs.matrix) }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + ref: ${{ github.ref }} + fetch-depth: 0 + + - name: Checkout target revision + env: + TARGET_SHA: ${{ needs.resolve_bootstrap_plan.outputs.ref_revision }} + run: | + set -euo pipefail + git fetch --no-tags origin \ + +refs/heads/main:refs/remotes/origin/main \ + '+refs/heads/release/*:refs/remotes/origin/release/*' + git checkout --detach "${TARGET_SHA}" + + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + node-version: ${{ env.NODE_VERSION }} + install-bun: "true" + install-deps: "true" + + - name: Verify package-local runtime build + run: node scripts/check-plugin-npm-runtime-builds.mjs --package "${{ matrix.plugin.packageDir }}" + + - name: Install pinned ClawHub CLI wrapper + run: | + set -euo pipefail + cat > "${RUNNER_TEMP}/clawhub" <<'EOF' + #!/usr/bin/env bash + set -euo pipefail + exec npm exec --yes --package "${CLAWHUB_CLI_PACKAGE}" -- clawhub "$@" + EOF + chmod +x "${RUNNER_TEMP}/clawhub" + echo "${RUNNER_TEMP}" >> "${GITHUB_PATH}" + + - name: Write ClawHub token config + if: inputs.dry_run != true + env: + CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }} + CLAWHUB_TOKEN: ${{ secrets.CLAWHUB_TOKEN }} + run: | + set -euo pipefail + config_path="${RUNNER_TEMP}/clawhub-config.json" + CONFIG_PATH="${config_path}" node --input-type=module <<'NODE' + import { writeFileSync } from "node:fs"; + + const registry = process.env.CLAWHUB_REGISTRY?.trim(); + const token = process.env.CLAWHUB_TOKEN?.trim(); + const configPath = process.env.CONFIG_PATH; + if (!registry) { + throw new Error("CLAWHUB_REGISTRY is required for token-gated ClawHub bootstrap."); + } + if (!token) { + throw new Error("CLAWHUB_TOKEN is required for token-gated ClawHub bootstrap."); + } + if (!configPath) { + throw new Error("CONFIG_PATH is required."); + } + + writeFileSync(configPath, `${JSON.stringify({ registry, token }, null, 2)}\n`, { + encoding: "utf8", + mode: 0o600, + }); + NODE + echo "CLAWHUB_CONFIG_PATH=${config_path}" >> "${GITHUB_ENV}" + + - name: Publish ClawHub bootstrap package + env: + CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }} + SOURCE_REPO: ${{ github.repository }} + SOURCE_COMMIT: ${{ needs.resolve_bootstrap_plan.outputs.ref_revision }} + SOURCE_REF: ${{ github.ref }} + PACKAGE_TAG: ${{ matrix.plugin.publishTag }} + PACKAGE_DIR: ${{ matrix.plugin.packageDir }} + BOOTSTRAP_MODE: ${{ matrix.plugin.bootstrapMode }} + REQUIRES_MANUAL_OVERRIDE: ${{ matrix.plugin.requiresManualOverride && 'true' || 'false' }} + DRY_RUN: ${{ inputs.dry_run && 'true' || 'false' }} + OPENCLAW_PLUGIN_NPM_RUNTIME_BUILD: "0" + run: | + set -euo pipefail + if [[ "${BOOTSTRAP_MODE}" == "configure-only" ]]; then + echo "Skipping bootstrap publish because ${PACKAGE_DIR} version is already present on ClawHub; configuring trusted publisher only." + elif [[ "${DRY_RUN}" == "true" ]]; then + bash scripts/plugin-clawhub-publish.sh --dry-run "${PACKAGE_DIR}" + else + if [[ "${REQUIRES_MANUAL_OVERRIDE}" == "true" ]]; then + export OPENCLAW_CLAWHUB_MANUAL_OVERRIDE_REASON="GitHub Actions trusted publisher repair before OIDC migration" + fi + bash scripts/plugin-clawhub-publish.sh --publish "${PACKAGE_DIR}" + fi + + - name: Configure trusted publisher for normal OIDC releases + if: inputs.dry_run != true + env: + CLAWHUB_CLI_PACKAGE: ${{ env.CLAWHUB_CLI_PACKAGE }} + PACKAGE_NAME: ${{ matrix.plugin.packageName }} + run: | + set -euo pipefail + npm exec --yes --package "${CLAWHUB_CLI_PACKAGE}" -- \ + clawhub package trusted-publisher set "${PACKAGE_NAME}" \ + --repository openclaw/openclaw \ + --workflow-filename plugin-clawhub-release.yml + + verify_bootstrap_clawhub_package: + needs: [resolve_bootstrap_plan, publish_bootstrap_plugins] + if: github.event_name == 'workflow_dispatch' && inputs.dry_run != true && needs.resolve_bootstrap_plan.outputs.has_bootstrap_candidates == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + fail-fast: false + max-parallel: 8 + matrix: + plugin: ${{ fromJson(needs.resolve_bootstrap_plan.outputs.matrix) }} + steps: + - name: Verify bootstrap ClawHub package and trusted publisher + env: + CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }} + PACKAGE_NAME: ${{ matrix.plugin.packageName }} + PACKAGE_VERSION: ${{ matrix.plugin.version }} + PACKAGE_TAG: ${{ matrix.plugin.publishTag }} + run: | + set -euo pipefail + node --input-type=module <<'EOF' + const registry = (process.env.CLAWHUB_REGISTRY ?? "https://clawhub.ai").replace(/\/+$/, ""); + const packageName = process.env.PACKAGE_NAME; + const packageVersion = process.env.PACKAGE_VERSION; + const packageTag = process.env.PACKAGE_TAG; + if (!packageName || !packageVersion || !packageTag) { + throw new Error("Missing ClawHub bootstrap verification env."); + } + const encodedName = encodeURIComponent(packageName); + const encodedVersion = encodeURIComponent(packageVersion); + const detailUrl = `${registry}/api/v1/packages/${encodedName}`; + const trustedPublisherUrl = `${detailUrl}/trusted-publisher`; + const versionUrl = `${detailUrl}/versions/${encodedVersion}`; + const artifactUrl = `${versionUrl}/artifact/download`; + + async function fetchWithRetry(url, options = {}) { + let lastStatus = "unknown"; + for (let attempt = 1; attempt <= 12; attempt += 1) { + try { + const response = await fetch(url, { redirect: "manual", ...options }); + lastStatus = response.status; + if (response.status !== 429 && response.status < 500) { + return response; + } + } catch (error) { + lastStatus = error instanceof Error ? error.message : String(error); + } + await new Promise((resolve) => setTimeout(resolve, attempt * 5000)); + } + throw new Error(`${url} did not stabilize; last status ${lastStatus}.`); + } + + const detailResponse = await fetchWithRetry(detailUrl, { + headers: { accept: "application/json" }, + }); + if (!detailResponse.ok) { + throw new Error(`${detailUrl} returned HTTP ${detailResponse.status}.`); + } + const detail = await detailResponse.json(); + const tags = detail?.package?.tags ?? {}; + if (tags[packageTag] !== packageVersion) { + throw new Error( + `${packageName}: ClawHub tag ${packageTag} points to ${tags[packageTag] ?? ""}, expected ${packageVersion}.`, + ); + } + + const trustedPublisherResponse = await fetchWithRetry(trustedPublisherUrl, { + headers: { accept: "application/json" }, + }); + if (!trustedPublisherResponse.ok) { + throw new Error(`${trustedPublisherUrl} returned HTTP ${trustedPublisherResponse.status}.`); + } + const trustedPublisherDetail = await trustedPublisherResponse.json(); + const trustedPublisher = trustedPublisherDetail?.trustedPublisher; + if ( + trustedPublisher?.repository !== "openclaw/openclaw" || + trustedPublisher?.workflowFilename !== "plugin-clawhub-release.yml" || + trustedPublisher?.environment != null + ) { + throw new Error( + `${packageName}: trusted publisher config did not match openclaw/openclaw plugin-clawhub-release.yml without an environment pin.`, + ); + } + + const versionResponse = await fetchWithRetry(versionUrl); + if (!versionResponse.ok) { + throw new Error(`${versionUrl} returned HTTP ${versionResponse.status}.`); + } + const artifactResponse = await fetchWithRetry(artifactUrl, { method: "HEAD" }); + if (artifactResponse.status < 200 || artifactResponse.status >= 400) { + throw new Error(`${artifactUrl} returned HTTP ${artifactResponse.status}.`); + } + console.log(`${packageName}@${packageVersion} bootstrap verified on ClawHub.`); + EOF diff --git a/.github/workflows/plugin-clawhub-release.yml b/.github/workflows/plugin-clawhub-release.yml index 77b7a833da0..da9645a2cdd 100644 --- a/.github/workflows/plugin-clawhub-release.yml +++ b/.github/workflows/plugin-clawhub-release.yml @@ -16,7 +16,7 @@ on: required: false type: string ref: - description: Commit SHA on main, a release branch, or the matching Tideclaw alpha branch to publish from; defaults to the workflow ref + description: Dry-run target ref to validate; real OIDC publishes must dispatch the workflow with --ref set to the target release tag/ref required: false default: "" type: string @@ -24,6 +24,10 @@ on: description: Approved OpenClaw Release Publish workflow run id required: false type: string + release_publish_branch: + description: Branch name of the approving OpenClaw Release Publish workflow run + required: false + type: string dry_run: description: Validate the full ClawHub artifact handoff without publishing. required: false @@ -38,9 +42,7 @@ env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" NODE_VERSION: "24.15.0" CLAWHUB_REGISTRY: "https://clawhub.ai" - CLAWHUB_REPOSITORY: "openclaw/clawhub" - # Pinned to a reviewed ClawHub commit so release behavior stays reproducible. - CLAWHUB_REF: "c9bb13023598dcc547fdf4a93b9d42512b8c8854" + CLAWHUB_CLI_PACKAGE: "clawhub@0.21.0" jobs: preview_plugins_clawhub: @@ -50,9 +52,15 @@ jobs: outputs: ref_revision: ${{ steps.ref.outputs.sha }} has_candidates: ${{ steps.plan.outputs.has_candidates }} + has_bootstrap_candidates: ${{ steps.plan.outputs.has_bootstrap_candidates }} + has_missing_trusted_publisher: ${{ steps.plan.outputs.has_missing_trusted_publisher }} candidate_count: ${{ steps.plan.outputs.candidate_count }} + bootstrap_candidate_count: ${{ steps.plan.outputs.bootstrap_candidate_count }} + missing_trusted_publisher_count: ${{ steps.plan.outputs.missing_trusted_publisher_count }} skipped_published_count: ${{ steps.plan.outputs.skipped_published_count }} matrix: ${{ steps.plan.outputs.matrix }} + bootstrap_matrix: ${{ steps.plan.outputs.bootstrap_matrix }} + missing_trusted_publisher_matrix: ${{ steps.plan.outputs.missing_trusted_publisher_matrix }} steps: - name: Checkout uses: actions/checkout@v6 @@ -83,9 +91,27 @@ jobs: fi echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + - name: Validate OIDC source matches workflow ref + env: + TARGET_SHA: ${{ steps.ref.outputs.sha }} + WORKFLOW_SHA: ${{ github.sha }} + DRY_RUN: ${{ inputs.dry_run && 'true' || 'false' }} + run: | + set -euo pipefail + if [[ "${TARGET_SHA}" != "${WORKFLOW_SHA}" ]]; then + if [[ "${DRY_RUN}" == "true" ]]; then + echo "Dry-run publish target differs from workflow ref; allowing validation-only dispatch." + exit 0 + fi + echo "Plugin ClawHub OIDC publishes must run from the same ref that is being published." >&2 + echo "The ref input is only supported for dry_run=true." >&2 + echo "For real publishes, dispatch this workflow with --ref pointing at the target release tag/ref and omit the ref input." >&2 + exit 1 + fi + - name: Validate ref is on a trusted publish branch env: - WORKFLOW_REF: ${{ github.ref }} + TRUSTED_PUBLISH_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }} run: | set -euo pipefail if git merge-base --is-ancestor HEAD origin/main; then @@ -96,8 +122,8 @@ jobs: exit 0 fi done < <(git for-each-ref --format='%(refname)' refs/remotes/origin/release) - if [[ "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then - alpha_branch="${WORKFLOW_REF#refs/heads/}" + if [[ "${TRUSTED_PUBLISH_BRANCH}" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then + alpha_branch="${TRUSTED_PUBLISH_BRANCH}" git fetch --no-tags origin "+refs/heads/${alpha_branch}:refs/remotes/origin/${alpha_branch}" if git merge-base --is-ancestor HEAD "refs/remotes/origin/${alpha_branch}"; then exit 0 @@ -158,36 +184,78 @@ jobs: cat .local/plugin-clawhub-release-plan.json candidate_count="$(jq -r '.candidates | length' .local/plugin-clawhub-release-plan.json)" + bootstrap_candidate_count="$(jq -r '.bootstrapCandidates | length' .local/plugin-clawhub-release-plan.json)" + missing_trusted_publisher_count="$(jq -r '.missingTrustedPublisher | length' .local/plugin-clawhub-release-plan.json)" skipped_published_count="$(jq -r '.skippedPublished | length' .local/plugin-clawhub-release-plan.json)" has_candidates="false" if [[ "${candidate_count}" != "0" ]]; then has_candidates="true" fi + has_bootstrap_candidates="false" + if [[ "${bootstrap_candidate_count}" != "0" ]]; then + has_bootstrap_candidates="true" + fi + has_missing_trusted_publisher="false" + if [[ "${missing_trusted_publisher_count}" != "0" ]]; then + has_missing_trusted_publisher="true" + fi matrix_json="$(jq -c '.candidates' .local/plugin-clawhub-release-plan.json)" + bootstrap_matrix_json="$(jq -c '.bootstrapCandidates' .local/plugin-clawhub-release-plan.json)" + missing_trusted_publisher_matrix_json="$(jq -c '.missingTrustedPublisher' .local/plugin-clawhub-release-plan.json)" { echo "candidate_count=${candidate_count}" + echo "bootstrap_candidate_count=${bootstrap_candidate_count}" + echo "missing_trusted_publisher_count=${missing_trusted_publisher_count}" echo "skipped_published_count=${skipped_published_count}" echo "has_candidates=${has_candidates}" + echo "has_bootstrap_candidates=${has_bootstrap_candidates}" + echo "has_missing_trusted_publisher=${has_missing_trusted_publisher}" echo "matrix=${matrix_json}" + echo "bootstrap_matrix=${bootstrap_matrix_json}" + echo "missing_trusted_publisher_matrix=${missing_trusted_publisher_matrix_json}" } >> "$GITHUB_OUTPUT" echo "Plugin release candidates:" jq -r '.candidates[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir)"' .local/plugin-clawhub-release-plan.json + echo "Bootstrap candidates requiring token bootstrap:" + jq -r '.bootstrapCandidates[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir)"' .local/plugin-clawhub-release-plan.json + + echo "Missing trusted publisher candidates:" + jq -r '.missingTrustedPublisher[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir)"' .local/plugin-clawhub-release-plan.json + echo "Already published / skipped:" jq -r '.skippedPublished[]? | "- \(.packageName)@\(.version)"' .local/plugin-clawhub-release-plan.json + - name: Fail when trusted publisher is missing + if: steps.plan.outputs.missing_trusted_publisher_count != '0' + run: | + echo "::error::One or more ClawHub packages exist but do not have trusted publishing configured. Configure trusted publishing before running the normal OIDC publish workflow." + jq -r '.missingTrustedPublisher[]? | "::error::Missing trusted publisher: \(.packageName)@\(.version). Configure trusted publishing for openclaw/openclaw, workflow plugin-clawhub-release.yml."' .local/plugin-clawhub-release-plan.json + exit 1 + + - name: Fail normal publish when bootstrap is required + if: steps.plan.outputs.bootstrap_candidate_count != '0' + run: | + echo "::error::One or more ClawHub packages do not exist yet and require the token-gated Plugin ClawHub New bootstrap workflow before normal OIDC publish can run." + jq -r '.bootstrapCandidates[]? | "::error::Bootstrap required: \(.packageName)@\(.version). Dispatch plugin-clawhub-new.yml for this package, then rerun the normal release."' .local/plugin-clawhub-release-plan.json + exit 1 + - name: Fail manual publish when target versions already exist - if: github.event_name == 'workflow_dispatch' && inputs.publish_scope == 'selected' && steps.plan.outputs.skipped_published_count != '0' + if: github.event_name == 'workflow_dispatch' && inputs.dry_run != true && inputs.publish_scope == 'selected' && steps.plan.outputs.skipped_published_count != '0' run: | echo "::error::One or more selected plugin versions already exist on ClawHub. Bump the version before running a real publish." exit 1 - name: Validate Tideclaw alpha plugin channels - if: startsWith(github.ref, 'refs/heads/tideclaw/alpha/') + env: + TRUSTED_PUBLISH_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }} run: | set -euo pipefail + if [[ ! "${TRUSTED_PUBLISH_BRANCH}" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then + exit 0 + fi invalid="$( jq -r '.candidates[]? | select(.publishTag != "alpha" or .channel != "alpha") | "- \(.packageName)@\(.version) [\(.publishTag)]"' .local/plugin-clawhub-release-plan.json )" @@ -215,7 +283,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }} - EXPECTED_WORKFLOW_BRANCH: ${{ github.ref_name }} + EXPECTED_WORKFLOW_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }} run: | set -euo pipefail if [[ -z "${RELEASE_PUBLISH_RUN_ID// }" ]]; then @@ -234,99 +302,8 @@ jobs: RUN_JSON="$(gh run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)" printf '%s' "$RUN_JSON" | DIRECT_RELEASE_RECOVERY="${direct_recovery}" node scripts/validate-release-publish-approval.mjs - preview_plugin_pack: - needs: preview_plugins_clawhub - if: needs.preview_plugins_clawhub.outputs.has_candidates == 'true' - runs-on: ubuntu-latest - permissions: - contents: read - strategy: - fail-fast: false - max-parallel: 12 - matrix: - plugin: ${{ fromJson(needs.preview_plugins_clawhub.outputs.matrix) }} - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - persist-credentials: false - ref: ${{ github.ref }} - fetch-depth: 0 - - - name: Checkout target revision - env: - TARGET_SHA: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }} - run: | - set -euo pipefail - git fetch --no-tags origin \ - +refs/heads/main:refs/remotes/origin/main \ - '+refs/heads/release/*:refs/remotes/origin/release/*' - git checkout --detach "${TARGET_SHA}" - - - name: Setup Node environment - uses: ./.github/actions/setup-node-env - with: - node-version: ${{ env.NODE_VERSION }} - install-bun: "true" - install-deps: "true" - - - name: Checkout ClawHub CLI source - uses: actions/checkout@v6 - with: - persist-credentials: false - repository: ${{ env.CLAWHUB_REPOSITORY }} - ref: main - path: clawhub-source - fetch-depth: 0 - - - name: Checkout pinned ClawHub CLI revision - working-directory: clawhub-source - env: - CLAWHUB_REF: ${{ env.CLAWHUB_REF }} - run: git checkout --detach "${CLAWHUB_REF}" - - - name: Install ClawHub CLI dependencies - working-directory: clawhub-source - run: | - set -euo pipefail - for attempt in 1 2 3; do - if bun install --frozen-lockfile; then - exit 0 - fi - status="$?" - if [[ "${attempt}" == "3" ]]; then - exit "${status}" - fi - echo "bun install failed while preparing ClawHub CLI; retrying (${attempt}/3)." - rm -rf node_modules "${RUNNER_TEMP}/bun-install-cache" || true - sleep $((attempt * 15)) - done - - - name: Bootstrap ClawHub CLI - run: | - cat > "$RUNNER_TEMP/clawhub" <<'EOF' - #!/usr/bin/env bash - set -euo pipefail - exec bun "$GITHUB_WORKSPACE/clawhub-source/packages/clawhub/src/cli.ts" "$@" - EOF - chmod +x "$RUNNER_TEMP/clawhub" - echo "$RUNNER_TEMP" >> "$GITHUB_PATH" - - - name: Verify package-local runtime build - run: node scripts/check-plugin-npm-runtime-builds.mjs --package "${{ matrix.plugin.packageDir }}" - - - name: Preview publish command - env: - CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }} - SOURCE_REPO: ${{ github.repository }} - SOURCE_COMMIT: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }} - SOURCE_REF: ${{ github.ref }} - PACKAGE_TAG: ${{ matrix.plugin.publishTag }} - PACKAGE_DIR: ${{ matrix.plugin.packageDir }} - run: bash scripts/plugin-clawhub-publish.sh --dry-run "${PACKAGE_DIR}" - pack_plugins_clawhub_artifacts: - needs: [preview_plugins_clawhub, preview_plugin_pack, validate_release_publish_approval] + needs: [preview_plugins_clawhub, validate_release_publish_approval] if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true' runs-on: ubuntu-latest permissions: @@ -361,47 +338,19 @@ jobs: install-bun: "true" install-deps: "true" - - name: Checkout ClawHub CLI source - uses: actions/checkout@v6 - with: - persist-credentials: false - repository: ${{ env.CLAWHUB_REPOSITORY }} - ref: main - path: clawhub-source - fetch-depth: 0 + - name: Verify package-local runtime build + run: node scripts/check-plugin-npm-runtime-builds.mjs --package "${{ matrix.plugin.packageDir }}" - - name: Checkout pinned ClawHub CLI revision - working-directory: clawhub-source - env: - CLAWHUB_REF: ${{ env.CLAWHUB_REF }} - run: git checkout --detach "${CLAWHUB_REF}" - - - name: Install ClawHub CLI dependencies - working-directory: clawhub-source + - name: Install pinned ClawHub CLI wrapper run: | set -euo pipefail - for attempt in 1 2 3; do - if bun install --frozen-lockfile; then - exit 0 - fi - status="$?" - if [[ "${attempt}" == "3" ]]; then - exit "${status}" - fi - echo "bun install failed while preparing ClawHub CLI; retrying (${attempt}/3)." - rm -rf node_modules "${RUNNER_TEMP}/bun-install-cache" || true - sleep $((attempt * 15)) - done - - - name: Bootstrap ClawHub CLI - run: | - cat > "$RUNNER_TEMP/clawhub" <<'EOF' + cat > "${RUNNER_TEMP}/clawhub" <<'EOF' #!/usr/bin/env bash set -euo pipefail - exec bun "$GITHUB_WORKSPACE/clawhub-source/packages/clawhub/src/cli.ts" "$@" + exec npm exec --yes --package "${CLAWHUB_CLI_PACKAGE}" -- clawhub "$@" EOF - chmod +x "$RUNNER_TEMP/clawhub" - echo "$RUNNER_TEMP" >> "$GITHUB_PATH" + chmod +x "${RUNNER_TEMP}/clawhub" + echo "${RUNNER_TEMP}" >> "${GITHUB_PATH}" - name: Pack ClawHub package artifact env: @@ -422,19 +371,23 @@ jobs: if-no-files-found: error retention-days: 7 - approve_plugin_clawhub_release: + approve_plugins_clawhub_release: needs: [preview_plugins_clawhub, pack_plugins_clawhub_artifacts] - if: github.event_name == 'workflow_dispatch' && inputs.dry_run != true && needs.preview_plugins_clawhub.outputs.has_candidates == 'true' + if: always() && github.event_name == 'workflow_dispatch' && inputs.dry_run != true && needs.preview_plugins_clawhub.outputs.has_candidates == 'true' && needs.pack_plugins_clawhub_artifacts.result == 'success' runs-on: ubuntu-latest environment: clawhub-plugin-release - permissions: {} + permissions: + contents: read steps: - - name: Approve ClawHub package publish - run: echo "ClawHub package publish approved." + - name: Approve Plugin ClawHub release publish + run: | + echo "Approved CLAW-277 03 - Split OpenClaw plugin ClawHub publishing into OIDC release and token bootstrap workflows release publish gate." publish_plugins_clawhub: - needs: [preview_plugins_clawhub, pack_plugins_clawhub_artifacts, approve_plugin_clawhub_release] - if: always() && github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true' && needs.pack_plugins_clawhub_artifacts.result == 'success' && (inputs.dry_run == true || needs.approve_plugin_clawhub_release.result == 'success') + needs: + [preview_plugins_clawhub, pack_plugins_clawhub_artifacts, approve_plugins_clawhub_release] + if: always() && github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true' && needs.pack_plugins_clawhub_artifacts.result == 'success' && (inputs.dry_run == true || needs.approve_plugins_clawhub_release.result == 'success') + uses: openclaw/clawhub/.github/workflows/package-publish.yml@9d49df109d4ad3dc8a6ecf05d26b39f46d294721 permissions: actions: read contents: read @@ -444,19 +397,18 @@ jobs: max-parallel: 32 matrix: plugin: ${{ fromJson(needs.preview_plugins_clawhub.outputs.matrix) }} - uses: openclaw/clawhub/.github/workflows/package-publish.yml@c9bb13023598dcc547fdf4a93b9d42512b8c8854 with: - dry_run: ${{ inputs.dry_run }} - json: true package_artifact_name: ${{ matrix.plugin.artifactName }} + dry_run: ${{ inputs.dry_run }} registry: https://clawhub.ai site: https://clawhub.ai + tags: ${{ matrix.plugin.publishTag }} source_repo: ${{ github.repository }} source_commit: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }} source_ref: ${{ github.ref }} - tags: ${{ matrix.plugin.publishTag }} - secrets: - clawhub_token: ${{ secrets.CLAWHUB_TOKEN }} + source_path: ${{ matrix.plugin.packageDir }} + inspector_artifact_name: ${{ matrix.plugin.artifactName }}-inspector + publish_json_artifact_name: ${{ matrix.plugin.artifactName }}-publish-json verify_published_clawhub_package: needs: [preview_plugins_clawhub, publish_plugins_clawhub] diff --git a/scripts/lib/openclaw-release-clawhub-plan.ts b/scripts/lib/openclaw-release-clawhub-plan.ts new file mode 100644 index 00000000000..dbb06756ff7 --- /dev/null +++ b/scripts/lib/openclaw-release-clawhub-plan.ts @@ -0,0 +1,314 @@ +// OpenClaw release ClawHub plan script supports release workflow routing. +import { resolve } from "node:path"; +import { + collectPluginClawHubReleasePlan, + type PublishablePluginPackage, +} from "./plugin-clawhub-release.ts"; +import { + parsePluginReleaseSelection, + parsePluginReleaseSelectionMode, + type PluginReleaseSelectionMode, +} from "./plugin-npm-release.ts"; + +type ClawHubPlanPackage = Pick; + +type ClawHubDispatchInputs = Record; + +type ClawHubDispatchTarget = { + workflow: "plugin-clawhub-release.yml" | "plugin-clawhub-new.yml"; + ref: string; + shouldDispatch: boolean; + packages: string[]; + inputs: ClawHubDispatchInputs; +}; + +export type OpenClawReleaseClawHubPlanArgs = { + releaseTag: string; + releasePublishBranch: string; + releasePublishRunId: string; + pluginPublishScope: PluginReleaseSelectionMode; + plugins: string[]; +}; + +export type OpenClawReleaseClawHubPlan = { + clawHubWorkflowRef: string; + releasePublishBranch: string; + normal: ClawHubDispatchTarget; + bootstrap: ClawHubDispatchTarget; + summary: { + normalCount: number; + bootstrapCount: number; + missingTrustedPublisherCount: number; + normalPlugins: string; + bootstrapPlugins: string; + missingTrustedPlugins: string; + }; + verifier: { + clawHubWorkflowRef: string; + }; +}; + +export type OpenClawReleaseClawHubRuntimeStateArgs = { + repository: string; + waitForClawHub: boolean; + forceSkipClawHub: boolean; + normalRunId?: string; + bootstrapRunId?: string; + bootstrapCompleted: boolean; +}; + +export type OpenClawReleaseClawHubRuntimeState = { + verifierArgs: string[]; + proofLines: { + normal: string; + bootstrap: string; + }; +}; + +function requireArg(value: string | undefined, label: string): string { + const trimmed = value?.trim(); + if (!trimmed) { + throw new Error(`${label} is required.`); + } + return trimmed; +} + +function packageNames(packages: readonly ClawHubPlanPackage[]): string[] { + return packages.map((plugin) => plugin.packageName); +} + +function joinPackageNames(packages: readonly string[]): string { + return packages.join(","); +} + +function optionalArg(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +function runUrl(repository: string, runId: string): string { + return `https://github.com/${repository}/actions/runs/${runId}`; +} + +function assertNoPackageOverlap( + normalPackages: readonly string[], + bootstrapPackages: readonly string[], +) { + const normalPackageSet = new Set(normalPackages); + const overlap = bootstrapPackages.filter((packageName) => normalPackageSet.has(packageName)); + if (overlap.length > 0) { + throw new Error( + `ClawHub release plan routed package(s) to both normal and bootstrap workflows: ${overlap.join(", ")}.`, + ); + } +} + +function createDispatchTarget(params: { + workflow: ClawHubDispatchTarget["workflow"]; + ref: string; + packages: readonly string[]; + releasePublishRunId: string; + releasePublishBranch: string; + includePublishScope: boolean; +}): ClawHubDispatchTarget { + if (params.packages.length === 0) { + return { + workflow: params.workflow, + ref: params.ref, + shouldDispatch: false, + packages: [], + inputs: {}, + }; + } + + const plugins = joinPackageNames(params.packages); + return { + workflow: params.workflow, + ref: params.ref, + shouldDispatch: true, + packages: [...params.packages], + inputs: { + ...(params.includePublishScope ? { publish_scope: "selected" } : {}), + plugins, + release_publish_run_id: params.releasePublishRunId, + release_publish_branch: params.releasePublishBranch, + }, + }; +} + +export function buildOpenClawReleaseClawHubRuntimeState( + args: OpenClawReleaseClawHubRuntimeStateArgs, +): OpenClawReleaseClawHubRuntimeState { + const repository = requireArg(args.repository, "repository"); + const normalRunId = optionalArg(args.normalRunId); + const bootstrapRunId = optionalArg(args.bootstrapRunId); + + const shouldIncludeNormalRun = + !args.forceSkipClawHub && normalRunId !== undefined && args.waitForClawHub; + const shouldIncludeBootstrapRun = + !args.forceSkipClawHub && bootstrapRunId !== undefined && args.bootstrapCompleted; + const shouldVerifyClawHubPackages = + bootstrapRunId !== undefined && + args.bootstrapCompleted && + (normalRunId === undefined || args.waitForClawHub); + const shouldSkipClawHubPackages = + args.forceSkipClawHub || !(shouldIncludeNormalRun || shouldVerifyClawHubPackages); + + const verifierArgs = shouldSkipClawHubPackages ? ["--skip-clawhub"] : []; + if (shouldIncludeNormalRun) { + verifierArgs.push("--plugin-clawhub-run", normalRunId); + } + if (shouldIncludeBootstrapRun) { + verifierArgs.push("--plugin-clawhub-bootstrap-run", bootstrapRunId); + } + + let normalProofLine = "- plugin ClawHub publish: no normal OIDC candidates"; + if (normalRunId !== undefined && args.waitForClawHub) { + normalProofLine = `- plugin ClawHub publish: ${runUrl(repository, normalRunId)}`; + } else if (normalRunId !== undefined) { + normalProofLine = `- plugin ClawHub publish: dispatched separately, not awaited by this proof: ${runUrl(repository, normalRunId)}`; + } + + let bootstrapProofLine = "- plugin ClawHub bootstrap: not needed"; + if (bootstrapRunId !== undefined && (args.bootstrapCompleted || args.waitForClawHub)) { + bootstrapProofLine = `- plugin ClawHub bootstrap: ${runUrl(repository, bootstrapRunId)}`; + } else if (bootstrapRunId !== undefined) { + bootstrapProofLine = `- plugin ClawHub bootstrap: dispatched separately, not awaited by this proof: ${runUrl(repository, bootstrapRunId)}`; + } + + return { + verifierArgs, + proofLines: { + normal: normalProofLine, + bootstrap: bootstrapProofLine, + }, + }; +} + +export function parseOpenClawReleaseClawHubPlanArgs( + argv: string[], +): OpenClawReleaseClawHubPlanArgs { + const values = [...argv]; + if (values[0] === "--") { + values.shift(); + } + + let releaseTag: string | undefined; + let releasePublishBranch: string | undefined; + let releasePublishRunId: string | undefined; + let pluginPublishScope: PluginReleaseSelectionMode | undefined; + let plugins: string[] = []; + let pluginsFlagProvided = false; + + for (let index = 0; index < values.length; index += 1) { + const arg = values[index]; + const next = () => { + const value = values[index + 1]; + if (value === undefined || value.startsWith("-")) { + throw new Error(`${arg} requires a value.`); + } + index += 1; + return value; + }; + + switch (arg) { + case "--release-tag": + releaseTag = next(); + break; + case "--release-publish-branch": + releasePublishBranch = next(); + break; + case "--release-publish-run-id": + releasePublishRunId = next(); + break; + case "--plugin-publish-scope": + pluginPublishScope = parsePluginReleaseSelectionMode(next()); + break; + case "--plugins": + plugins = parsePluginReleaseSelection(next()); + pluginsFlagProvided = true; + break; + default: + throw new Error(`Unknown argument: ${arg}`); + } + } + + const resolvedPluginPublishScope = pluginPublishScope ?? "all-publishable"; + if (pluginsFlagProvided && plugins.length === 0) { + throw new Error("--plugins must include at least one package name."); + } + if (resolvedPluginPublishScope === "selected" && !pluginsFlagProvided) { + throw new Error("plugin-publish-scope=selected requires --plugins."); + } + if (resolvedPluginPublishScope === "all-publishable" && pluginsFlagProvided) { + throw new Error("plugin-publish-scope=all-publishable must not be combined with --plugins."); + } + + return { + releaseTag: requireArg(releaseTag, "--release-tag"), + releasePublishBranch: requireArg(releasePublishBranch, "--release-publish-branch"), + releasePublishRunId: requireArg(releasePublishRunId, "--release-publish-run-id"), + pluginPublishScope: resolvedPluginPublishScope, + plugins, + }; +} + +export async function buildOpenClawReleaseClawHubPlan( + args: OpenClawReleaseClawHubPlanArgs, + options: { + rootDir?: string; + fetchImpl?: typeof fetch; + registryBaseUrl?: string; + } = {}, +): Promise { + const releaseTag = requireArg(args.releaseTag, "releaseTag"); + const releasePublishBranch = requireArg(args.releasePublishBranch, "releasePublishBranch"); + const releasePublishRunId = requireArg(args.releasePublishRunId, "releasePublishRunId"); + const plan = await collectPluginClawHubReleasePlan({ + rootDir: options.rootDir ?? resolve("."), + selection: args.plugins, + selectionMode: args.pluginPublishScope, + fetchImpl: options.fetchImpl, + registryBaseUrl: options.registryBaseUrl, + }); + + const normalPackages = packageNames(plan.candidates); + const bootstrapPackages = [ + ...packageNames(plan.bootstrapCandidates), + ...packageNames(plan.missingTrustedPublisher), + ]; + const missingTrustedPlugins = packageNames(plan.missingTrustedPublisher); + assertNoPackageOverlap(normalPackages, bootstrapPackages); + + return { + clawHubWorkflowRef: releaseTag, + releasePublishBranch, + normal: createDispatchTarget({ + workflow: "plugin-clawhub-release.yml", + ref: releaseTag, + packages: normalPackages, + releasePublishRunId, + releasePublishBranch, + includePublishScope: true, + }), + bootstrap: createDispatchTarget({ + workflow: "plugin-clawhub-new.yml", + ref: releaseTag, + packages: bootstrapPackages, + releasePublishRunId, + releasePublishBranch, + includePublishScope: false, + }), + summary: { + normalCount: normalPackages.length, + bootstrapCount: bootstrapPackages.length, + missingTrustedPublisherCount: missingTrustedPlugins.length, + normalPlugins: joinPackageNames(normalPackages), + bootstrapPlugins: joinPackageNames(bootstrapPackages), + missingTrustedPlugins: joinPackageNames(missingTrustedPlugins), + }, + verifier: { + clawHubWorkflowRef: releaseTag, + }, + }; +} diff --git a/scripts/lib/plugin-clawhub-release.ts b/scripts/lib/plugin-clawhub-release.ts index b75834da3f6..287f624ea1f 100644 --- a/scripts/lib/plugin-clawhub-release.ts +++ b/scripts/lib/plugin-clawhub-release.ts @@ -60,15 +60,34 @@ type PluginReleasePlanItem = PublishablePluginPackage & { type PluginReleasePlan = { all: PluginReleasePlanItem[]; candidates: PluginReleasePlanItem[]; + bootstrapCandidates: PluginReleasePlanItem[]; + missingTrustedPublisher: PluginReleasePlanItem[]; skippedPublished: PluginReleasePlanItem[]; }; +type ClawHubTrustedPublisherDetail = { + trustedPublisher?: unknown; +}; + +type ClawHubTrustedPublisherConfig = { + repository?: unknown; + workflowFilename?: unknown; + environment?: unknown; +}; + +type PluginReleasePlanItemWithPackageState = PluginReleasePlanItem & { + packageExists: boolean; + hasTrustedPublisher: boolean; +}; + type ClawHubPublishablePluginPackageFilters = { extensionIds?: readonly string[]; packageNames?: readonly string[]; }; const CLAWHUB_DEFAULT_REGISTRY = "https://clawhub.ai"; +const OPENCLAW_PLUGIN_CLAWHUB_REPOSITORY = "openclaw/openclaw"; +const OPENCLAW_PLUGIN_CLAWHUB_WORKFLOW_FILENAME = "plugin-clawhub-release.yml"; const SAFE_EXTENSION_ID_RE = /^[a-z0-9][a-z0-9._-]*$/; const CLAWHUB_SHARED_RELEASE_INPUT_PATHS = [ ".github/workflows/plugin-clawhub-release.yml", @@ -357,6 +376,97 @@ async function isPluginVersionPublishedOnClawHub( ); } +async function doesClawHubPackageExist( + packageName: string, + options: { + fetchImpl?: typeof fetch; + registryBaseUrl?: string; + } = {}, +): Promise { + const fetchImpl = options.fetchImpl ?? fetch; + const url = new URL( + `/api/v1/packages/${encodeURIComponent(packageName)}`, + getRegistryBaseUrl(options.registryBaseUrl), + ); + const response = await fetchImpl(url, { + method: "GET", + headers: { + Accept: "application/json", + }, + }); + + if (response.status === 404) { + return false; + } + if (!response.ok) { + throw new Error( + `Failed to query ClawHub package ${packageName}: ${response.status} ${response.statusText}`, + ); + } + + return true; +} + +async function hasClawHubTrustedPublisher( + packageName: string, + options: { + fetchImpl?: typeof fetch; + registryBaseUrl?: string; + } = {}, +): Promise { + const fetchImpl = options.fetchImpl ?? fetch; + const url = new URL( + `/api/v1/packages/${encodeURIComponent(packageName)}/trusted-publisher`, + getRegistryBaseUrl(options.registryBaseUrl), + ); + const response = await fetchImpl(url, { + method: "GET", + headers: { + Accept: "application/json", + }, + }); + + if (!response.ok) { + throw new Error( + `Failed to query ClawHub trusted publisher for ${packageName}: ${response.status} ${response.statusText}`, + ); + } + + let trustedPublisherDetail: ClawHubTrustedPublisherDetail; + try { + trustedPublisherDetail = (await response.json()) as ClawHubTrustedPublisherDetail; + } catch (error) { + throw new Error(`Failed to parse ClawHub trusted publisher ${packageName} response.`, { + cause: error, + }); + } + + return isOpenClawPluginTrustedPublisher(trustedPublisherDetail.trustedPublisher); +} + +function isOpenClawPluginTrustedPublisher(value: unknown): boolean { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + const trustedPublisher = value as ClawHubTrustedPublisherConfig; + return ( + trustedPublisher.repository === OPENCLAW_PLUGIN_CLAWHUB_REPOSITORY && + trustedPublisher.workflowFilename === OPENCLAW_PLUGIN_CLAWHUB_WORKFLOW_FILENAME && + trustedPublisher.environment == null + ); +} + +function stripPackageReleaseState( + item: PluginReleasePlanItemWithPackageState, +): PluginReleasePlanItem { + const { + packageExists: _packageExists, + hasTrustedPublisher: _hasTrustedPublisher, + ...planItem + } = item; + return planItem; +} + export async function collectPluginClawHubReleasePlan(params?: { rootDir?: string; selection?: string[]; @@ -395,22 +505,56 @@ export async function collectPluginClawHubReleasePlan(params?: { assertPluginReleaseVersionFloors(selectedPublishable, "Plugin ClawHub release plan"); } - const all = await Promise.all( - selectedPublishable.map(async (plugin) => - Object.assign({}, plugin, { - alreadyPublished: await isPluginVersionPublishedOnClawHub( - plugin.packageName, - plugin.version, - { registryBaseUrl: params?.registryBaseUrl, fetchImpl: params?.fetchImpl }, - ), + const planned = await Promise.all( + selectedPublishable.map(async (plugin): Promise => { + const packageExists = await doesClawHubPackageExist(plugin.packageName, { + registryBaseUrl: params?.registryBaseUrl, + fetchImpl: params?.fetchImpl, + }); + const hasTrustedPublisher = packageExists + ? await hasClawHubTrustedPublisher(plugin.packageName, { + registryBaseUrl: params?.registryBaseUrl, + fetchImpl: params?.fetchImpl, + }) + : false; + const alreadyPublished = packageExists + ? await isPluginVersionPublishedOnClawHub(plugin.packageName, plugin.version, { + registryBaseUrl: params?.registryBaseUrl, + fetchImpl: params?.fetchImpl, + }) + : false; + + return { + extensionId: plugin.extensionId, + packageDir: plugin.packageDir, + packageName: plugin.packageName, + version: plugin.version, + channel: plugin.channel, + publishTag: plugin.publishTag, + packageExists, + hasTrustedPublisher, + alreadyPublished, artifactName: formatClawHubPackageArtifactName(plugin), - }), - ), + }; + }), ); + const all = planned.map(stripPackageReleaseState); return { all, - candidates: all.filter((plugin) => !plugin.alreadyPublished), - skippedPublished: all.filter((plugin) => plugin.alreadyPublished), + candidates: planned + .filter( + (plugin) => plugin.packageExists && plugin.hasTrustedPublisher && !plugin.alreadyPublished, + ) + .map(stripPackageReleaseState), + bootstrapCandidates: planned + .filter((plugin) => !plugin.packageExists) + .map(stripPackageReleaseState), + missingTrustedPublisher: planned + .filter((plugin) => plugin.packageExists && !plugin.hasTrustedPublisher) + .map(stripPackageReleaseState), + skippedPublished: planned + .filter((plugin) => plugin.alreadyPublished) + .map(stripPackageReleaseState), }; } diff --git a/scripts/lib/release-beta-verifier.ts b/scripts/lib/release-beta-verifier.ts index f049fda062f..fc5dd4fda16 100644 --- a/scripts/lib/release-beta-verifier.ts +++ b/scripts/lib/release-beta-verifier.ts @@ -18,6 +18,7 @@ export type ReleaseVerifyBetaArgs = { repo: string; registry: string; workflowRef?: string; + clawHubWorkflowRef?: string; pluginSelection: string[]; evidenceOut?: string; skipPostpublish: boolean; @@ -29,6 +30,7 @@ export type ReleaseVerifyBetaArgs = { openclawNpm?: string; pluginNpm?: string; pluginClawHub?: string; + pluginClawHubBootstrap?: string; npmTelegram?: string; }; }; @@ -119,7 +121,7 @@ export function parseReleaseVerifyBetaArgs(argv: string[]): ReleaseVerifyBetaArg const version = values.shift(); if (!version || version.startsWith("-")) { throw new Error( - "Usage: pnpm release:verify-beta -- [--workflow-ref REF] [--full-release-validation-run ID] [--openclaw-npm-run ID] [--plugin-npm-run ID] [--plugin-clawhub-run ID] [--npm-telegram-run ID] [--skip-github-release] [--skip-clawhub]", + "Usage: pnpm release:verify-beta -- [--workflow-ref REF] [--clawhub-workflow-ref REF] [--full-release-validation-run ID] [--openclaw-npm-run ID] [--plugin-npm-run ID] [--plugin-clawhub-run ID] [--plugin-clawhub-bootstrap-run ID] [--npm-telegram-run ID] [--skip-github-release] [--skip-clawhub]", ); } @@ -130,6 +132,7 @@ export function parseReleaseVerifyBetaArgs(argv: string[]): ReleaseVerifyBetaArg repo: DEFAULT_REPO, registry: DEFAULT_CLAWHUB_REGISTRY, workflowRef: undefined, + clawHubWorkflowRef: undefined, pluginSelection: [], evidenceOut: undefined, skipPostpublish: false, @@ -166,6 +169,9 @@ export function parseReleaseVerifyBetaArgs(argv: string[]): ReleaseVerifyBetaArg case "--workflow-ref": parsed.workflowRef = next(); break; + case "--clawhub-workflow-ref": + parsed.clawHubWorkflowRef = next(); + break; case "--plugins": parsed.pluginSelection = parsePluginReleaseSelection(next()); if (parsed.pluginSelection.length === 0) { @@ -187,6 +193,9 @@ export function parseReleaseVerifyBetaArgs(argv: string[]): ReleaseVerifyBetaArg case "--plugin-clawhub-run": parsed.workflowRuns.pluginClawHub = next(); break; + case "--plugin-clawhub-bootstrap-run": + parsed.workflowRuns.pluginClawHubBootstrap = next(); + break; case "--npm-telegram-run": parsed.workflowRuns.npmTelegram = next(); break; @@ -567,17 +576,31 @@ export async function verifyBetaRelease( ); } if (args.workflowRuns.pluginClawHub !== undefined) { + const clawHubWorkflowRef = args.clawHubWorkflowRef ?? args.workflowRef; workflowRuns.push( verifyWorkflowRun({ id: args.workflowRuns.pluginClawHub, label: "Plugin ClawHub Release", repo: args.repo, expectedWorkflowName: "Plugin ClawHub Release", - expectedHeadBranch: args.workflowRef, + expectedHeadBranch: clawHubWorkflowRef, rerunFailed: args.rerunFailedClawHub, }), ); } + if (args.workflowRuns.pluginClawHubBootstrap !== undefined) { + const clawHubWorkflowRef = args.clawHubWorkflowRef ?? args.workflowRef; + workflowRuns.push( + verifyWorkflowRun({ + id: args.workflowRuns.pluginClawHubBootstrap, + label: "Plugin ClawHub New", + repo: args.repo, + expectedWorkflowName: "Plugin ClawHub New", + expectedHeadBranch: clawHubWorkflowRef, + rerunFailed: false, + }), + ); + } if (args.workflowRuns.openclawNpm !== undefined) { workflowRuns.push( verifyWorkflowRun({ diff --git a/scripts/openclaw-release-clawhub-plan.ts b/scripts/openclaw-release-clawhub-plan.ts new file mode 100755 index 00000000000..be57fcb1041 --- /dev/null +++ b/scripts/openclaw-release-clawhub-plan.ts @@ -0,0 +1,14 @@ +#!/usr/bin/env -S node --import tsx +// OpenClaw release ClawHub plan CLI emits release workflow routing as JSON. + +import { pathToFileURL } from "node:url"; +import { + buildOpenClawReleaseClawHubPlan, + parseOpenClawReleaseClawHubPlanArgs, +} from "./lib/openclaw-release-clawhub-plan.ts"; + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + const args = parseOpenClawReleaseClawHubPlanArgs(process.argv.slice(2)); + const plan = await buildOpenClawReleaseClawHubPlan(args); + console.log(JSON.stringify(plan, null, 2)); +} diff --git a/scripts/openclaw-release-clawhub-runtime-state.ts b/scripts/openclaw-release-clawhub-runtime-state.ts new file mode 100755 index 00000000000..8721d26b9db --- /dev/null +++ b/scripts/openclaw-release-clawhub-runtime-state.ts @@ -0,0 +1,92 @@ +#!/usr/bin/env -S node --import tsx +import { buildOpenClawReleaseClawHubRuntimeState } from "./lib/openclaw-release-clawhub-plan.ts"; + +function parseBoolean(value: string, label: string): boolean { + if (value === "true") { + return true; + } + if (value === "false") { + return false; + } + throw new Error(`${label} must be true or false.`); +} + +function parseArgs(argv: string[]) { + const values = [...argv]; + if (values[0] === "--") { + values.shift(); + } + + let repository: string | undefined; + let waitForClawHub: boolean | undefined; + let forceSkipClawHub: boolean | undefined; + let normalRunId: string | undefined; + let bootstrapRunId: string | undefined; + let bootstrapCompleted: boolean | undefined; + + for (let index = 0; index < values.length; index += 1) { + const arg = values[index]; + const next = () => { + const value = values[index + 1]; + if (value === undefined || value.startsWith("-")) { + throw new Error(`${arg} requires a value.`); + } + index += 1; + return value; + }; + + switch (arg) { + case "--repository": + repository = next(); + break; + case "--wait-for-clawhub": + waitForClawHub = parseBoolean(next(), "--wait-for-clawhub"); + break; + case "--force-skip-clawhub": + forceSkipClawHub = parseBoolean(next(), "--force-skip-clawhub"); + break; + case "--normal-run-id": + normalRunId = next(); + break; + case "--bootstrap-run-id": + bootstrapRunId = next(); + break; + case "--bootstrap-completed": + bootstrapCompleted = parseBoolean(next(), "--bootstrap-completed"); + break; + default: + throw new Error(`Unknown argument: ${arg}`); + } + } + + if (!repository?.trim()) { + throw new Error("--repository is required."); + } + if (waitForClawHub === undefined) { + throw new Error("--wait-for-clawhub is required."); + } + if (forceSkipClawHub === undefined) { + throw new Error("--force-skip-clawhub is required."); + } + if (bootstrapCompleted === undefined) { + throw new Error("--bootstrap-completed is required."); + } + + return { + repository, + waitForClawHub, + forceSkipClawHub, + normalRunId, + bootstrapRunId, + bootstrapCompleted, + }; +} + +try { + const state = buildOpenClawReleaseClawHubRuntimeState(parseArgs(process.argv.slice(2))); + process.stdout.write(`${JSON.stringify(state, null, 2)}\n`); +} catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(message); + process.exit(1); +} diff --git a/scripts/plugin-clawhub-publish.sh b/scripts/plugin-clawhub-publish.sh index bb6616d6cdf..99b1479c84f 100644 --- a/scripts/plugin-clawhub-publish.sh +++ b/scripts/plugin-clawhub-publish.sh @@ -42,6 +42,7 @@ source_repo="${SOURCE_REPO:-${GITHUB_REPOSITORY:-openclaw/openclaw}}" source_commit="${SOURCE_COMMIT:-$(git -C "${invocation_root}" rev-parse HEAD)}" source_ref="${SOURCE_REF:-$(git -C "${invocation_root}" symbolic-ref -q HEAD || true)}" clawhub_workdir="${CLAWDHUB_WORKDIR:-${CLAWHUB_WORKDIR:-${invocation_root}}}" +manual_override_reason="${OPENCLAW_CLAWHUB_MANUAL_OVERRIDE_REASON:-}" pack_dir="$(mktemp -d "${RUNNER_TEMP:-/tmp}/openclaw-clawhub-pack.XXXXXX")" cleanup() { @@ -158,6 +159,13 @@ if [[ -n "${source_ref}" ]]; then ) fi +if [[ -n "${manual_override_reason}" ]]; then + publish_cmd+=( + --manual-override-reason + "${manual_override_reason}" + ) +fi + printf 'Publish command: CLAWHUB_WORKDIR=%q' "${clawhub_workdir}" printf ' %q' "${publish_cmd[@]}" printf '\n' diff --git a/test/plugin-clawhub-release.test.ts b/test/plugin-clawhub-release.test.ts index 66a31615f8d..c3d77897001 100644 --- a/test/plugin-clawhub-release.test.ts +++ b/test/plugin-clawhub-release.test.ts @@ -10,6 +10,11 @@ import { } from "node:fs"; import { delimiter, join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { + buildOpenClawReleaseClawHubPlan, + buildOpenClawReleaseClawHubRuntimeState, + parseOpenClawReleaseClawHubPlanArgs, +} from "../scripts/lib/openclaw-release-clawhub-plan.ts"; import { collectClawHubPublishablePluginPackages, collectClawHubVersionGateErrors, @@ -320,17 +325,212 @@ describe("resolveSelectedClawHubPublishablePluginPackages", () => { }); describe("collectPluginClawHubReleasePlan", () => { - it("skips versions that already exist on ClawHub", async () => { + it("keeps existing trusted packages with missing versions as normal candidates", async () => { const repoDir = createTempPluginRepo(); + const { fetchImpl, requests } = createClawHubPlanFetch({ + packages: { + "@openclaw/demo-plugin": { + status: 200, + body: { + package: {}, + owner: {}, + }, + }, + }, + trustedPublishers: { + "@openclaw/demo-plugin": { + status: 200, + body: { + trustedPublisher: { + repository: "openclaw/openclaw", + workflowFilename: "plugin-clawhub-release.yml", + }, + }, + }, + }, + versions: { + "@openclaw/demo-plugin@2026.4.1": 404, + }, + }); const plan = await collectPluginClawHubReleasePlan({ rootDir: repoDir, selection: ["@openclaw/demo-plugin"], - fetchImpl: async () => new Response("{}", { status: 200 }), + fetchImpl, + registryBaseUrl: "https://clawhub.ai", + }); + + expect(plan.candidates.map((plugin) => plugin.packageName)).toEqual(["@openclaw/demo-plugin"]); + expect(plan.bootstrapCandidates).toStrictEqual([]); + expect(plan.missingTrustedPublisher).toStrictEqual([]); + expect(requests).toEqual([ + "/api/v1/packages/%40openclaw%2Fdemo-plugin", + "/api/v1/packages/%40openclaw%2Fdemo-plugin/trusted-publisher", + "/api/v1/packages/%40openclaw%2Fdemo-plugin/versions/2026.4.1", + ]); + }); + + it("routes missing package rows to bootstrap candidates instead of normal candidates", async () => { + const repoDir = createTempPluginRepo(); + const { fetchImpl } = createClawHubPlanFetch({ + packages: { + "@openclaw/demo-plugin": { + status: 404, + }, + }, + }); + + const plan = await collectPluginClawHubReleasePlan({ + rootDir: repoDir, + selection: ["@openclaw/demo-plugin"], + fetchImpl, registryBaseUrl: "https://clawhub.ai", }); expect(plan.candidates).toStrictEqual([]); + expect(plan.bootstrapCandidates.map((plugin) => plugin.packageName)).toEqual([ + "@openclaw/demo-plugin", + ]); + expect(plan.bootstrapCandidates[0]).toMatchObject({ + alreadyPublished: false, + artifactName: "clawhub-package-openclaw-demo-plugin-2026.4.1", + packageName: "@openclaw/demo-plugin", + version: "2026.4.1", + }); + expect(plan.missingTrustedPublisher).toStrictEqual([]); + }); + + it("routes existing packages without trusted publisher config out of normal candidates", async () => { + const repoDir = createTempPluginRepo(); + const { fetchImpl } = createClawHubPlanFetch({ + packages: { + "@openclaw/demo-plugin": { + status: 200, + body: { + package: {}, + owner: {}, + }, + }, + }, + trustedPublishers: { + "@openclaw/demo-plugin": { + status: 200, + body: { + trustedPublisher: null, + }, + }, + }, + versions: { + "@openclaw/demo-plugin@2026.4.1": 404, + }, + }); + + const plan = await collectPluginClawHubReleasePlan({ + rootDir: repoDir, + selection: ["@openclaw/demo-plugin"], + fetchImpl, + registryBaseUrl: "https://clawhub.ai", + }); + + expect(plan.candidates).toStrictEqual([]); + expect(plan.bootstrapCandidates).toStrictEqual([]); + expect(plan.missingTrustedPublisher.map((plugin) => plugin.packageName)).toEqual([ + "@openclaw/demo-plugin", + ]); + expect(plan.missingTrustedPublisher[0]).toMatchObject({ + alreadyPublished: false, + artifactName: "clawhub-package-openclaw-demo-plugin-2026.4.1", + packageName: "@openclaw/demo-plugin", + version: "2026.4.1", + }); + }); + + it("routes environment-pinned trusted publisher config out of normal candidates", async () => { + const repoDir = createTempPluginRepo(); + const { fetchImpl } = createClawHubPlanFetch({ + packages: { + "@openclaw/demo-plugin": { + status: 200, + body: { + package: {}, + owner: {}, + }, + }, + }, + trustedPublishers: { + "@openclaw/demo-plugin": { + status: 200, + body: { + trustedPublisher: { + repository: "openclaw/openclaw", + workflowFilename: "plugin-clawhub-release.yml", + environment: "clawhub-plugin-release", + }, + }, + }, + }, + versions: { + "@openclaw/demo-plugin@2026.4.1": 404, + }, + }); + + const plan = await collectPluginClawHubReleasePlan({ + rootDir: repoDir, + selection: ["@openclaw/demo-plugin"], + fetchImpl, + registryBaseUrl: "https://clawhub.ai", + }); + + expect(plan.candidates).toStrictEqual([]); + expect(plan.bootstrapCandidates).toStrictEqual([]); + expect(plan.missingTrustedPublisher.map((plugin) => plugin.packageName)).toEqual([ + "@openclaw/demo-plugin", + ]); + }); + + it("skips versions that already exist on ClawHub", async () => { + const repoDir = createTempPluginRepo(); + const { fetchImpl } = createClawHubPlanFetch({ + packages: { + "@openclaw/demo-plugin": { + status: 200, + body: { + package: {}, + owner: {}, + }, + }, + }, + trustedPublishers: { + "@openclaw/demo-plugin": { + status: 200, + body: { + trustedPublisher: null, + }, + }, + }, + versions: { + "@openclaw/demo-plugin@2026.4.1": 200, + }, + }); + + const plan = await collectPluginClawHubReleasePlan({ + rootDir: repoDir, + selection: ["@openclaw/demo-plugin"], + fetchImpl, + registryBaseUrl: "https://clawhub.ai", + }); + + expect(plan.candidates).toStrictEqual([]); + expect(plan.bootstrapCandidates).toStrictEqual([]); + expect(plan.missingTrustedPublisher.map((plugin) => plugin.packageName)).toEqual([ + "@openclaw/demo-plugin", + ]); + expect(plan.missingTrustedPublisher[0]).toMatchObject({ + alreadyPublished: true, + artifactName: "clawhub-package-openclaw-demo-plugin-2026.4.1", + packageName: "@openclaw/demo-plugin", + version: "2026.4.1", + }); expect(plan.skippedPublished).toHaveLength(1); expect(plan.skippedPublished[0]).toEqual({ alreadyPublished: true, @@ -369,7 +569,31 @@ describe("collectPluginClawHubReleasePlan", () => { const plan = await collectPluginClawHubReleasePlan({ rootDir: repoDir, selection: ["@openclaw/demo-plugin"], - fetchImpl: async () => new Response("{}", { status: 404 }), + fetchImpl: createClawHubPlanFetch({ + packages: { + "@openclaw/demo-plugin": { + status: 200, + body: { + package: {}, + owner: {}, + }, + }, + }, + trustedPublishers: { + "@openclaw/demo-plugin": { + status: 200, + body: { + trustedPublisher: { + repository: "openclaw/openclaw", + workflowFilename: "plugin-clawhub-release.yml", + }, + }, + }, + }, + versions: { + "@openclaw/demo-plugin@2026.4.1": 404, + }, + }).fetchImpl, registryBaseUrl: "https://clawhub.ai", }); @@ -380,6 +604,280 @@ describe("collectPluginClawHubReleasePlan", () => { }); }); +describe("buildOpenClawReleaseClawHubPlan", () => { + it("emits a dispatch plan that keeps ClawHub children on the release tag", async () => { + const repoDir = createTempPluginRepo({ + extraExtensionIds: ["demo-two", "demo-three"], + }); + const { fetchImpl } = createClawHubPlanFetch({ + packages: { + "@openclaw/demo-plugin": { + status: 200, + body: { + package: {}, + owner: {}, + }, + }, + "@openclaw/demo-two": { + status: 404, + }, + "@openclaw/demo-three": { + status: 200, + body: { + package: {}, + owner: {}, + }, + }, + }, + trustedPublishers: { + "@openclaw/demo-plugin": { + status: 200, + body: { + trustedPublisher: { + repository: "openclaw/openclaw", + workflowFilename: "plugin-clawhub-release.yml", + }, + }, + }, + "@openclaw/demo-three": { + status: 200, + body: { + trustedPublisher: null, + }, + }, + }, + versions: { + "@openclaw/demo-plugin@2026.4.1": 404, + "@openclaw/demo-three@2026.4.1": 404, + }, + }); + + const plan = await buildOpenClawReleaseClawHubPlan( + { + releaseTag: "v2026.4.1-beta.1", + releasePublishBranch: "main", + releasePublishRunId: "12345", + pluginPublishScope: "all-publishable", + plugins: [], + }, + { + rootDir: repoDir, + fetchImpl, + registryBaseUrl: "https://clawhub.ai", + }, + ); + + expect(plan.clawHubWorkflowRef).toBe("v2026.4.1-beta.1"); + expect(plan.releasePublishBranch).toBe("main"); + expect(plan.normal).toEqual({ + workflow: "plugin-clawhub-release.yml", + ref: "v2026.4.1-beta.1", + shouldDispatch: true, + packages: ["@openclaw/demo-plugin"], + inputs: { + publish_scope: "selected", + plugins: "@openclaw/demo-plugin", + release_publish_run_id: "12345", + release_publish_branch: "main", + }, + }); + expect(plan.bootstrap).toEqual({ + workflow: "plugin-clawhub-new.yml", + ref: "v2026.4.1-beta.1", + shouldDispatch: true, + packages: ["@openclaw/demo-two", "@openclaw/demo-three"], + inputs: { + plugins: "@openclaw/demo-two,@openclaw/demo-three", + release_publish_run_id: "12345", + release_publish_branch: "main", + }, + }); + expect(new Set([...plan.normal.packages, ...plan.bootstrap.packages]).size).toBe(3); + expect(plan.summary).toEqual({ + normalCount: 1, + bootstrapCount: 2, + missingTrustedPublisherCount: 1, + normalPlugins: "@openclaw/demo-plugin", + bootstrapPlugins: "@openclaw/demo-two,@openclaw/demo-three", + missingTrustedPlugins: "@openclaw/demo-three", + }); + expect(plan.verifier).toEqual({ + clawHubWorkflowRef: "v2026.4.1-beta.1", + }); + }); + + it("routes already-published packages missing trusted publisher config to bootstrap repair", async () => { + const repoDir = createTempPluginRepo(); + const { fetchImpl } = createClawHubPlanFetch({ + packages: { + "@openclaw/demo-plugin": { + status: 200, + body: { + package: {}, + owner: {}, + }, + }, + }, + trustedPublishers: { + "@openclaw/demo-plugin": { + status: 200, + body: { + trustedPublisher: null, + }, + }, + }, + versions: { + "@openclaw/demo-plugin@2026.4.1": 200, + }, + }); + + const plan = await buildOpenClawReleaseClawHubPlan( + { + releaseTag: "v2026.4.1-beta.1", + releasePublishBranch: "release/2026.4.1", + releasePublishRunId: "12345", + pluginPublishScope: "selected", + plugins: ["@openclaw/demo-plugin"], + }, + { + rootDir: repoDir, + fetchImpl, + registryBaseUrl: "https://clawhub.ai", + }, + ); + + expect(plan.normal.shouldDispatch).toBe(false); + expect(plan.bootstrap).toMatchObject({ + workflow: "plugin-clawhub-new.yml", + ref: "v2026.4.1-beta.1", + shouldDispatch: true, + packages: ["@openclaw/demo-plugin"], + inputs: { + plugins: "@openclaw/demo-plugin", + release_publish_run_id: "12345", + release_publish_branch: "release/2026.4.1", + }, + }); + expect(plan.summary).toMatchObject({ + normalCount: 0, + bootstrapCount: 1, + missingTrustedPublisherCount: 1, + bootstrapPlugins: "@openclaw/demo-plugin", + missingTrustedPlugins: "@openclaw/demo-plugin", + }); + }); + + it("rejects incompatible all-publishable plugin selection args", () => { + expect(() => + parseOpenClawReleaseClawHubPlanArgs([ + "--release-tag", + "v2026.4.1-beta.1", + "--release-publish-branch", + "main", + "--release-publish-run-id", + "12345", + "--plugin-publish-scope", + "all-publishable", + "--plugins", + "@openclaw/demo-plugin", + ]), + ).toThrow("plugin-publish-scope=all-publishable must not be combined with --plugins."); + }); +}); + +describe("buildOpenClawReleaseClawHubRuntimeState", () => { + it("includes the normal ClawHub run in verifier args when the release waits for it", () => { + const state = buildOpenClawReleaseClawHubRuntimeState({ + repository: "openclaw/openclaw", + waitForClawHub: true, + forceSkipClawHub: false, + normalRunId: "111", + bootstrapRunId: "", + bootstrapCompleted: false, + }); + + expect(state.verifierArgs).toEqual(["--plugin-clawhub-run", "111"]); + expect(state.proofLines.normal).toBe( + "- plugin ClawHub publish: https://github.com/openclaw/openclaw/actions/runs/111", + ); + expect(state.proofLines.bootstrap).toBe("- plugin ClawHub bootstrap: not needed"); + }); + + it("includes a completed bootstrap run even when there is no normal ClawHub run", () => { + const state = buildOpenClawReleaseClawHubRuntimeState({ + repository: "openclaw/openclaw", + waitForClawHub: false, + forceSkipClawHub: false, + normalRunId: "", + bootstrapRunId: "222", + bootstrapCompleted: true, + }); + + expect(state.verifierArgs).toEqual(["--plugin-clawhub-bootstrap-run", "222"]); + expect(state.proofLines.normal).toBe("- plugin ClawHub publish: no normal OIDC candidates"); + expect(state.proofLines.bootstrap).toBe( + "- plugin ClawHub bootstrap: https://github.com/openclaw/openclaw/actions/runs/222", + ); + }); + + it("skips ClawHub verification for non-awaited incomplete runs while keeping proof links", () => { + const state = buildOpenClawReleaseClawHubRuntimeState({ + repository: "openclaw/openclaw", + waitForClawHub: false, + forceSkipClawHub: false, + normalRunId: "111", + bootstrapRunId: "222", + bootstrapCompleted: false, + }); + + expect(state.verifierArgs).toEqual(["--skip-clawhub"]); + expect(state.proofLines.normal).toBe( + "- plugin ClawHub publish: dispatched separately, not awaited by this proof: https://github.com/openclaw/openclaw/actions/runs/111", + ); + expect(state.proofLines.bootstrap).toBe( + "- plugin ClawHub bootstrap: dispatched separately, not awaited by this proof: https://github.com/openclaw/openclaw/actions/runs/222", + ); + }); + + it("keeps completed bootstrap run evidence when the normal ClawHub run is not awaited", () => { + const state = buildOpenClawReleaseClawHubRuntimeState({ + repository: "openclaw/openclaw", + waitForClawHub: false, + forceSkipClawHub: false, + normalRunId: "111", + bootstrapRunId: "222", + bootstrapCompleted: true, + }); + + expect(state.verifierArgs).toEqual(["--skip-clawhub", "--plugin-clawhub-bootstrap-run", "222"]); + expect(state.proofLines.normal).toBe( + "- plugin ClawHub publish: dispatched separately, not awaited by this proof: https://github.com/openclaw/openclaw/actions/runs/111", + ); + expect(state.proofLines.bootstrap).toBe( + "- plugin ClawHub bootstrap: https://github.com/openclaw/openclaw/actions/runs/222", + ); + }); + + it("forces skip-clawhub after a failed child run even if ClawHub runs completed", () => { + const state = buildOpenClawReleaseClawHubRuntimeState({ + repository: "openclaw/openclaw", + waitForClawHub: true, + forceSkipClawHub: true, + normalRunId: "111", + bootstrapRunId: "222", + bootstrapCompleted: true, + }); + + expect(state.verifierArgs).toEqual(["--skip-clawhub"]); + expect(state.proofLines.normal).toBe( + "- plugin ClawHub publish: https://github.com/openclaw/openclaw/actions/runs/111", + ); + expect(state.proofLines.bootstrap).toBe( + "- plugin ClawHub bootstrap: https://github.com/openclaw/openclaw/actions/runs/222", + ); + }); +}); + describe("plugin-clawhub-publish.sh", () => { it("previews the publish command through the ClawHub CLI dry-run preflight", () => { const repoDir = createTempPluginRepo(); @@ -449,6 +947,70 @@ exit 0 expect(invocations).toContain("--dry-run"); }); + it("passes a manual override reason when trusted publisher repair requires one", () => { + const repoDir = createTempPluginRepo(); + const binDir = join(repoDir, "bin"); + const markerPath = join(repoDir, "clawhub-invoked"); + mkdirSync(binDir, { recursive: true }); + const clawhubPath = join(binDir, "clawhub"); + writeFileSync( + clawhubPath, + `#!/usr/bin/env bash +set -euo pipefail +printf '%s\\n' "$*" >> ${JSON.stringify(markerPath)} +if [[ "\${1:-}" == "--workdir" ]]; then + shift 2 +fi +if [[ "\${1:-}" == "package" && "\${2:-}" == "pack" ]]; then + pack_destination="" + while [[ "$#" -gt 0 ]]; do + case "$1" in + --pack-destination) + pack_destination="\${2:-}" + shift 2 + ;; + *) + shift + ;; + esac + done + mkdir -p "$pack_destination" + pack_path="$pack_destination/openclaw-demo-plugin-2026.4.1.tgz" + printf 'fake tgz\\n' > "$pack_path" + printf '{"path":"%s","name":"@openclaw/demo-plugin","version":"2026.4.1"}\\n' "$pack_path" +fi +exit 0 +`, + ); + chmodSync(clawhubPath, 0o755); + + execFileSync( + "bash", + [ + join(process.cwd(), "scripts/plugin-clawhub-publish.sh"), + "--publish", + "extensions/demo-plugin", + ], + { + cwd: repoDir, + encoding: "utf8", + env: { + ...process.env, + OPENCLAW_CLAWHUB_MANUAL_OVERRIDE_REASON: + "GitHub Actions trusted publisher repair before OIDC migration", + OPENCLAW_PLUGIN_NPM_RUNTIME_BUILD: "0", + PATH: `${binDir}${delimiter}${process.env.PATH ?? ""}`, + }, + }, + ); + + const invocations = readFileSync(markerPath, "utf8"); + expect(invocations).toContain("package publish "); + expect(invocations).toContain( + "--manual-override-reason GitHub Actions trusted publisher repair before OIDC migration", + ); + }); + it("packs a reusable workflow artifact without publishing", () => { const repoDir = createTempPluginRepo(); const binDir = join(repoDir, "bin"); @@ -625,6 +1187,73 @@ function commitSharedReleaseToolingChange(repoDir: string) { return { baseRef, headRef }; } +function createClawHubPlanFetch(config: { + packages: Record< + string, + { + status: number; + body?: unknown; + } + >; + trustedPublishers?: Record< + string, + { + status: number; + body?: unknown; + } + >; + versions?: Record; +}) { + const requests: string[] = []; + const fetchImpl: typeof fetch = async (input) => { + const requestUrl = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const url = new URL(requestUrl); + requests.push(url.pathname); + + const packageMatch = url.pathname.match(/^\/api\/v1\/packages\/([^/]+)$/u); + if (packageMatch) { + const packageName = decodeURIComponent(packageMatch[1]); + const packageResponse = config.packages[packageName]; + if (!packageResponse) { + throw new Error(`Unexpected package detail request for ${packageName}`); + } + return new Response(JSON.stringify(packageResponse.body ?? {}), { + status: packageResponse.status, + }); + } + + const trustedPublisherMatch = url.pathname.match( + /^\/api\/v1\/packages\/([^/]+)\/trusted-publisher$/u, + ); + if (trustedPublisherMatch) { + const packageName = decodeURIComponent(trustedPublisherMatch[1]); + const trustedPublisherResponse = config.trustedPublishers?.[packageName]; + if (!trustedPublisherResponse) { + throw new Error(`Unexpected trusted-publisher request for ${packageName}`); + } + return new Response(JSON.stringify(trustedPublisherResponse.body ?? {}), { + status: trustedPublisherResponse.status, + }); + } + + const versionMatch = url.pathname.match(/^\/api\/v1\/packages\/([^/]+)\/versions\/([^/]+)$/u); + if (versionMatch) { + const packageName = decodeURIComponent(versionMatch[1]); + const version = decodeURIComponent(versionMatch[2]); + const status = config.versions?.[`${packageName}@${version}`]; + if (!status) { + throw new Error(`Unexpected version detail request for ${packageName}@${version}`); + } + return new Response("{}", { status }); + } + + throw new Error(`Unexpected ClawHub request to ${url.pathname}`); + }; + + return { fetchImpl, requests }; +} + function git(cwd: string, args: string[]) { return execFileSync("git", ["-C", cwd, ...args], { encoding: "utf8", diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index c9be5cb4e35..474e3ede518 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -1,5 +1,5 @@ // Package Acceptance Workflow tests cover package acceptance workflow script behavior. -import { readdirSync, readFileSync } from "node:fs"; +import { readdirSync, readFileSync, statSync } from "node:fs"; import { describe, expect, it } from "vitest"; import { parse } from "yaml"; @@ -56,6 +56,10 @@ function readWorkflow(path: string): Workflow { return parse(readFileSync(path, "utf8")) as Workflow; } +function isExecutable(path: string): boolean { + return (statSync(path).mode & 0o111) !== 0; +} + function workflowPaths(): string[] { return readdirSync(".github/workflows") .filter((name) => name.endsWith(".yml")) @@ -1378,7 +1382,6 @@ describe("package artifact reuse", () => { it("keeps release QA and repo E2E lanes off scarce 32-core runners", () => { const releaseChecksWorkflow = readFileSync(RELEASE_CHECKS_WORKFLOW, "utf8"); - const qaWorkflow = readFileSync(QA_LIVE_TRANSPORTS_WORKFLOW, "utf8"); const liveE2eWorkflow = readFileSync(LIVE_E2E_WORKFLOW, "utf8"); for (const jobName of [ @@ -1547,9 +1550,14 @@ describe("package artifact reuse", () => { }; const releaseWorkflow = readFileSync(RELEASE_PUBLISH_WORKFLOW, "utf8"); const clawHubWorkflow = readFileSync(".github/workflows/plugin-clawhub-release.yml", "utf8"); + const clawHubNewWorkflow = readFileSync(".github/workflows/plugin-clawhub-new.yml", "utf8"); const pluginNpmWorkflow = readFileSync(".github/workflows/plugin-npm-release.yml", "utf8"); const openclawNpmWorkflow = readFileSync(".github/workflows/openclaw-npm-release.yml", "utf8"); const approvalScript = readFileSync("scripts/validate-release-publish-approval.mjs", "utf8"); + const clawHubReleasePlanScript = readFileSync( + "scripts/lib/openclaw-release-clawhub-plan.ts", + "utf8", + ); const clawHubResolveRefIndex = clawHubWorkflow.indexOf("- name: Resolve checked-out ref"); const clawHubValidateRefIndex = clawHubWorkflow.indexOf( "- name: Validate ref is on a trusted publish branch", @@ -1571,30 +1579,80 @@ describe("package artifact reuse", () => { expect(packageJson.scripts?.["release:fast-pretag-check"]).toBe( "bash scripts/release-fast-pretag-check.sh", ); - expect(clawHubWorkflow).toContain('CLAWHUB_REF: "c9bb13023598dcc547fdf4a93b9d42512b8c8854"'); + expect(clawHubWorkflow).toContain('CLAWHUB_CLI_PACKAGE: "clawhub@0.21.0"'); + expect(clawHubWorkflow).not.toContain("CLAWHUB_REPOSITORY:"); + expect(clawHubWorkflow).not.toContain("CLAWHUB_REF:"); expect(clawHubWorkflow).toContain("pack_plugins_clawhub_artifacts:"); + expect(clawHubWorkflow).toContain("Verify package-local runtime build"); + expect(clawHubWorkflow).toContain("Install pinned ClawHub CLI wrapper"); expect(clawHubWorkflow).toContain("Pack ClawHub package artifact"); expect(clawHubWorkflow).toContain("Upload ClawHub package artifact"); + expect(clawHubWorkflow).toContain("Validate OIDC source matches workflow ref"); + expect(clawHubWorkflow).toContain( + "Dry-run target ref to validate; real OIDC publishes must dispatch the workflow with --ref set to the target release tag/ref", + ); + expect(clawHubWorkflow).toContain( + "Plugin ClawHub OIDC publishes must run from the same ref that is being published.", + ); + expect(clawHubWorkflow).toContain("The ref input is only supported for dry_run=true."); + expect(clawHubWorkflow).toContain( + "Dry-run publish target differs from workflow ref; allowing validation-only dispatch.", + ); + expect(clawHubWorkflow).toContain( + "github.event_name == 'workflow_dispatch' && inputs.dry_run != true && inputs.publish_scope == 'selected' && steps.plan.outputs.skipped_published_count != '0'", + ); + expect(clawHubWorkflow).toContain( + "uses: openclaw/clawhub/.github/workflows/package-publish.yml@9d49df109d4ad3dc8a6ecf05d26b39f46d294721", + ); expect(clawHubWorkflow).toContain("dry_run:"); expect(clawHubWorkflow).toContain("default: false"); - expect(clawHubWorkflow).toContain("approve_plugin_clawhub_release:"); + expect(clawHubWorkflow).not.toContain("approve_plugin_clawhub_release:"); + expect(clawHubWorkflow).toContain("approve_plugins_clawhub_release:"); + expect(clawHubWorkflow).toContain("environment: clawhub-plugin-release"); expect(clawHubWorkflow).toContain("inputs.dry_run != true"); - expect(clawHubWorkflow).toContain("Approve ClawHub package publish"); + expect(clawHubWorkflow).toContain("release_publish_branch:"); expect(clawHubWorkflow).toContain( - "always() && github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true' && needs.pack_plugins_clawhub_artifacts.result == 'success' && (inputs.dry_run == true || needs.approve_plugin_clawhub_release.result == 'success')", + "TRUSTED_PUBLISH_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }}", ); expect(clawHubWorkflow).toContain( - "uses: openclaw/clawhub/.github/workflows/package-publish.yml@c9bb13023598dcc547fdf4a93b9d42512b8c8854", + "EXPECTED_WORKFLOW_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }}", + ); + expect(clawHubWorkflow).toContain( + "always() && github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true' && needs.pack_plugins_clawhub_artifacts.result == 'success' && (inputs.dry_run == true || needs.approve_plugins_clawhub_release.result == 'success')", ); - expect(clawHubWorkflow).toContain("dry_run: ${{ inputs.dry_run }}"); expect(clawHubWorkflow).toContain("package_artifact_name: ${{ matrix.plugin.artifactName }}"); - expect(clawHubWorkflow).toContain("clawhub_token: ${{ secrets.CLAWHUB_TOKEN }}"); + expect(clawHubWorkflow).toContain("source_repo: ${{ github.repository }}"); + expect(clawHubWorkflow).toContain( + "source_commit: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }}", + ); + expect(clawHubWorkflow).toContain("source_ref: ${{ github.ref }}"); + expect(clawHubWorkflow).toContain("source_path: ${{ matrix.plugin.packageDir }}"); + expect(clawHubWorkflow).toContain( + "inspector_artifact_name: ${{ matrix.plugin.artifactName }}-inspector", + ); + expect(clawHubWorkflow).toContain( + "publish_json_artifact_name: ${{ matrix.plugin.artifactName }}-publish-json", + ); + expect(clawHubWorkflow).toContain("tags: ${{ matrix.plugin.publishTag }}"); + expect(clawHubWorkflow).toContain("dry_run: ${{ inputs.dry_run }}"); + expect(clawHubWorkflow).not.toContain("secrets.CLAWHUB_TOKEN"); + expect(clawHubWorkflow).not.toContain("clawhub_token:"); + expect(clawHubWorkflow).toContain("bootstrapCandidates"); + expect(clawHubWorkflow).toContain("missingTrustedPublisher"); + expect(clawHubWorkflow).toContain("bootstrap_candidate_count"); + expect(clawHubWorkflow).toContain("missing_trusted_publisher_count"); + expect(clawHubWorkflow).toContain("Bootstrap candidates requiring token bootstrap:"); + expect(clawHubWorkflow).toContain("Missing trusted publisher candidates:"); expect(clawHubWorkflow).toContain("verify_published_clawhub_package:"); expect(clawHubWorkflow).toContain("inputs.dry_run != true"); expect(clawHubWorkflow).toContain("Verify published ClawHub package"); expect(clawHubWorkflow).not.toContain("bash scripts/plugin-clawhub-publish.sh --publish"); expect(clawHubWorkflow).not.toContain("Write ClawHub token config"); - expect(clawHubWorkflow).toContain("bun install failed while preparing ClawHub CLI; retrying"); + expect(clawHubWorkflow).not.toContain("Checkout ClawHub CLI source"); + expect(clawHubWorkflow).not.toContain("packages/clawhub/src/cli.ts"); + expect(clawHubWorkflow).not.toContain( + "bun install failed while preparing ClawHub CLI; retrying", + ); expect(clawHubWorkflow).toContain("max-parallel: 32"); expect(clawHubResolveRefIndex).toBeGreaterThanOrEqual(0); expect(clawHubValidateRefIndex).toBeGreaterThan(clawHubResolveRefIndex); @@ -1602,6 +1660,29 @@ describe("package artifact reuse", () => { expect(clawHubMetadataIndex).toBeGreaterThan(clawHubSetupIndex); expect(releaseWorkflow).toContain("Plugin npm run ID"); expect(releaseWorkflow).toContain("Plugin ClawHub run ID"); + expect(releaseWorkflow).toContain("plugin-clawhub-new.yml"); + expect(releaseWorkflow).toContain("Plugin ClawHub bootstrap run ID"); + expect(releaseWorkflow).toContain("scripts/openclaw-release-clawhub-plan.ts"); + expect(releaseWorkflow).toContain("scripts/openclaw-release-clawhub-runtime-state.ts"); + expect(isExecutable("scripts/openclaw-release-clawhub-plan.ts")).toBe(true); + expect(isExecutable("scripts/openclaw-release-clawhub-runtime-state.ts")).toBe(true); + expect(releaseWorkflow).toContain("openclaw-release-clawhub-plan.json"); + expect(releaseWorkflow).toContain("openclaw-release-clawhub-runtime-state"); + expect(releaseWorkflow).toContain("bootstrap_plugins"); + expect(releaseWorkflow).toContain("missing_trusted_plugins"); + expect(releaseWorkflow).toContain(".summary.bootstrapPlugins"); + expect(releaseWorkflow).toContain(".summary.missingTrustedPlugins"); + expect(releaseWorkflow).toContain("append_clawhub_dispatch_args"); + expect(releaseWorkflow).toContain("write_clawhub_runtime_state"); + expect(releaseWorkflow).toContain(".[$target].inputs | to_entries[]"); + expect(releaseWorkflow).toContain(".verifierArgs[]"); + expect(releaseWorkflow).toContain(".proofLines.normal"); + expect(releaseWorkflow).toContain(".proofLines.bootstrap"); + expect(releaseWorkflow).toContain("Bootstrap/repair candidates:"); + expect(releaseWorkflow).toContain("Trusted-publisher repair plugins:"); + expect(releaseWorkflow).toContain( + "Waiting for plugin-clawhub-new.yml bootstrap to finish before continuing release publish.", + ); expect(releaseWorkflow).toContain("OpenClaw npm run ID"); expect(releaseWorkflow).toContain("npm_telegram_run_id"); expect(releaseWorkflow).toContain('release_publish_run_id="${GITHUB_RUN_ID}"'); @@ -1612,7 +1693,7 @@ describe("package artifact reuse", () => { ); expect(releaseWorkflow).toContain("registry tarball"); expect(releaseWorkflow).toContain("release SHA"); - expect(releaseWorkflow).toContain("not awaited by this proof"); + expect(clawHubReleasePlanScript).toContain("not awaited by this proof"); expect(releaseWorkflow).toContain("wait_for_job_success"); expect(releaseWorkflow).toContain("Validate release publish approval"); expect(releaseWorkflow).toContain('conclusion" == "skipped"'); @@ -1621,13 +1702,17 @@ describe("package artifact reuse", () => { expect(releaseWorkflow).toContain("release:verify-beta"); expect(releaseWorkflow).toContain('--workflow-ref "${CHILD_WORKFLOW_REF}"'); expect(releaseWorkflow).toContain("--skip-github-release"); + expect(clawHubReleasePlanScript).toContain("--plugin-clawhub-bootstrap-run"); expect(releaseWorkflow).toContain('verify_args+=(--plugins "${PLUGINS}")'); expect(releaseWorkflow).toContain("openclaw-release-postpublish-evidence"); expect(releaseWorkflow).toContain("Failed child job summary"); expect(releaseWorkflow).toContain("Workflow completion waits for ClawHub"); expect(releaseWorkflow).toContain("Workflow completion does not wait for ClawHub"); expect(releaseWorkflow).toContain('[[ "${WAIT_FOR_CLAWHUB}" == "true" ]]'); - expect(releaseWorkflow).toContain("--skip-clawhub"); + expect(releaseWorkflow).toContain( + '[[ -n "${plugin_clawhub_bootstrap_run_id}" && "${WAIT_FOR_CLAWHUB}" == "true" ]]', + ); + expect(clawHubReleasePlanScript).toContain("--skip-clawhub"); expect(pluginNpmWorkflow).toContain("Validate release publish approval run"); expect(clawHubWorkflow).toContain("Validate release publish approval run"); expect(openclawNpmWorkflow).toContain("Validate release publish approval run"); @@ -1651,9 +1736,71 @@ describe("package artifact reuse", () => { expect(approvalScript).toContain("must still be in_progress"); expect(approvalScript).toContain("completed with success/failure"); expect(pluginNpmWorkflow).toContain("environment: npm-release"); - expect(clawHubWorkflow).toContain("environment: clawhub-plugin-release"); + expect(clawHubWorkflow.match(/environment: clawhub-plugin-release/g)?.length).toBe(1); + expect(clawHubNewWorkflow).toContain("name: Plugin ClawHub New"); + expect(clawHubNewWorkflow).toContain('CLAWHUB_CLI_PACKAGE: "clawhub@0.21.0"'); + expect(clawHubNewWorkflow).not.toContain("CLAWHUB_REPOSITORY:"); + expect(clawHubNewWorkflow).not.toContain("CLAWHUB_REF:"); + expect(clawHubNewWorkflow).toContain("environment: clawhub-plugin-bootstrap"); + expect(clawHubNewWorkflow).toContain("secrets.CLAWHUB_TOKEN"); + expect(clawHubNewWorkflow).not.toContain( + "uses: openclaw/clawhub/.github/workflows/package-publish.yml", + ); + expect(clawHubNewWorkflow).not.toContain("clawhub_token:"); + expect(clawHubNewWorkflow).toContain("Validate pinned ClawHub trusted publisher CLI support"); + expect(clawHubNewWorkflow).toContain('npm exec --yes --package "${CLAWHUB_CLI_PACKAGE}"'); + expect(clawHubNewWorkflow).toContain( + "CLAW-277 03 - Split OpenClaw plugin ClawHub publishing into OIDC release and token bootstrap workflows", + ); + expect(clawHubNewWorkflow).toContain("Usage: clawhub package trusted-publisher set"); + expect(clawHubNewWorkflow).toContain("Write ClawHub token config"); + expect(clawHubNewWorkflow).toContain("CLAWHUB_CONFIG_PATH=${config_path}"); + expect(clawHubNewWorkflow).toContain( + "CLAWHUB_REGISTRY is required for token-gated ClawHub bootstrap.", + ); + expect(clawHubNewWorkflow).toContain( + "CLAWHUB_TOKEN is required for token-gated ClawHub bootstrap.", + ); + expect(clawHubNewWorkflow).toContain("JSON.stringify({ registry, token }, null, 2)"); + expect(clawHubNewWorkflow).toContain("Publish ClawHub bootstrap package"); + expect(clawHubNewWorkflow).toContain("bash scripts/plugin-clawhub-publish.sh --publish"); + expect(clawHubNewWorkflow).toContain("bootstrapMode"); + expect(clawHubNewWorkflow).toContain("BOOTSTRAP_MODE: ${{ matrix.plugin.bootstrapMode }}"); + expect(clawHubNewWorkflow).toContain("requiresManualOverride"); + expect(clawHubNewWorkflow).toContain( + 'OPENCLAW_CLAWHUB_MANUAL_OVERRIDE_REASON="GitHub Actions trusted publisher repair before OIDC migration"', + ); + expect(clawHubNewWorkflow).toContain("configure-only"); + expect(clawHubNewWorkflow).toContain( + "version is already present on ClawHub; configuring trusted publisher only", + ); + expect(clawHubNewWorkflow).toContain( + "EXPECTED_WORKFLOW_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }}", + ); + expect(clawHubNewWorkflow).toContain( + "TRUSTED_PUBLISH_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }}", + ); + expect(clawHubNewWorkflow).toContain('OPENCLAW_PLUGIN_NPM_RUNTIME_BUILD: "0"'); + expect(clawHubNewWorkflow).toContain("trusted-publisher set"); + expect(clawHubNewWorkflow).toContain("--workflow-filename plugin-clawhub-release.yml"); + expect(clawHubNewWorkflow).not.toContain("--environment clawhub-plugin-release"); + expect(clawHubNewWorkflow).toContain("trustedPublisher?.environment != null"); + expect(clawHubNewWorkflow).toContain("without an environment pin"); + expect(clawHubNewWorkflow).not.toContain("Checkout ClawHub CLI source"); + expect(clawHubNewWorkflow).not.toContain("packages/clawhub/src/cli.ts"); + expect(clawHubNewWorkflow).toContain("verify_bootstrap_clawhub_package:"); + expect(clawHubNewWorkflow).toContain("Verify bootstrap ClawHub package and trusted publisher"); + expect(clawHubNewWorkflow).toContain("/trusted-publisher"); + expect(clawHubNewWorkflow).toContain('trustedPublisher?.repository !== "openclaw/openclaw"'); expect(openclawNpmWorkflow).toContain("environment: npm-release"); expect(releaseWorkflow).toContain("default: from-validation"); + expect(releaseWorkflow).toContain('--release-publish-branch "${CHILD_WORKFLOW_REF}"'); + expect(releaseWorkflow).toContain('--release-publish-run-id "${GITHUB_RUN_ID}"'); + expect(releaseWorkflow).toContain("jq -r '.normal.ref' \"${clawhub_plan_path}\""); + expect(releaseWorkflow).toContain("jq -r '.normal.workflow' \"${clawhub_plan_path}\""); + expect(releaseWorkflow).toContain("jq -r '.bootstrap.ref' \"${clawhub_plan_path}\""); + expect(releaseWorkflow).toContain("jq -r '.bootstrap.workflow' \"${clawhub_plan_path}\""); + expect(releaseWorkflow).toContain('--clawhub-workflow-ref "${clawhub_workflow_ref}"'); expect(releaseWorkflow).toContain( 'if [[ "$EXPECTED_RELEASE_PROFILE" != "from-validation" && "$release_profile" != "$EXPECTED_RELEASE_PROFILE" ]]; then', ); diff --git a/test/scripts/release-beta-verifier.test.ts b/test/scripts/release-beta-verifier.test.ts index 38b9ecf9336..a14e8827f68 100644 --- a/test/scripts/release-beta-verifier.test.ts +++ b/test/scripts/release-beta-verifier.test.ts @@ -15,6 +15,7 @@ describe("parseReleaseVerifyBetaArgs", () => { repo: "openclaw/openclaw", registry: "https://clawhub.ai", workflowRef: undefined, + clawHubWorkflowRef: undefined, pluginSelection: [], evidenceOut: undefined, skipPostpublish: false, @@ -32,6 +33,8 @@ describe("parseReleaseVerifyBetaArgs", () => { "2026.5.10-beta.3", "--workflow-ref", "release/2026.5.10", + "--clawhub-workflow-ref", + "v2026.5.10-beta.3", "--plugins", "@openclaw/plugin-a,@openclaw/plugin-b", "--full-release-validation-run", @@ -42,6 +45,8 @@ describe("parseReleaseVerifyBetaArgs", () => { "22", "--plugin-clawhub-run", "33", + "--plugin-clawhub-bootstrap-run", + "34", "--npm-telegram-run", "44", "--evidence-out", @@ -58,6 +63,7 @@ describe("parseReleaseVerifyBetaArgs", () => { repo: "openclaw/openclaw", registry: "https://clawhub.ai", workflowRef: "release/2026.5.10", + clawHubWorkflowRef: "v2026.5.10-beta.3", pluginSelection: ["@openclaw/plugin-a", "@openclaw/plugin-b"], evidenceOut: ".artifacts/release-evidence.json", skipPostpublish: true, @@ -69,6 +75,7 @@ describe("parseReleaseVerifyBetaArgs", () => { openclawNpm: "11", pluginNpm: "22", pluginClawHub: "33", + pluginClawHubBootstrap: "34", npmTelegram: "44", }, });