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
This commit is contained in:
Patrick Erichsen
2026-06-12 20:16:06 -07:00
committed by GitHub
parent ded3a93058
commit 6cf06e8e7e
12 changed files with 2197 additions and 211 deletions

View File

@@ -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

504
.github/workflows/plugin-clawhub-new.yml vendored Normal file
View File

@@ -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] ?? "<missing>"}, 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

View File

@@ -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]

View File

@@ -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<PublishablePluginPackage, "packageName">;
type ClawHubDispatchInputs = Record<string, string>;
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<OpenClawReleaseClawHubPlan> {
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,
},
};
}

View File

@@ -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<boolean> {
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<boolean> {
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<PluginReleasePlanItemWithPackageState> => {
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),
};
}

View File

@@ -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 -- <version> [--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 -- <version> [--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({

View File

@@ -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));
}

View File

@@ -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);
}

View File

@@ -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'

View File

@@ -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<string, number>;
}) {
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",

View File

@@ -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',
);

View File

@@ -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",
},
});