fix(release): authorize staged ClawHub publication

This commit is contained in:
Vincent Koc
2026-08-21 01:36:48 -07:00
parent b6d7394a98
commit 43dc8d2bb3
7 changed files with 1551 additions and 103 deletions
+181 -35
View File
@@ -63,7 +63,7 @@ on:
required: false
type: string
publish_openclaw_npm:
description: Publish the OpenClaw npm package after plugin npm succeeds; ClawHub may still run
description: Publish OpenClaw npm after plugin npm succeeds; normal ClawHub publication is staged under exact protected tooling and verified after this parent succeeds
required: true
default: true
type: boolean
@@ -83,7 +83,7 @@ on:
- stable
- full
wait_for_clawhub:
description: Wait for and auto-approve ClawHub plugin publish; otherwise approve and monitor the detached runs separately
description: Wait for ClawHub bootstrap/repair completion; normal OIDC publication always stops at staged success and verifies after this exact parent succeeds
required: true
default: false
type: boolean
@@ -892,6 +892,148 @@ jobs:
if-no-files-found: error
retention-days: 30
- name: Prepare ClawHub parent authorization
id: clawhub_authorization
env:
GH_TOKEN: ${{ github.token }}
PARENT_WORKFLOW_FULL_REF: ${{ github.ref }}
PARENT_WORKFLOW_REF: ${{ github.ref_name }}
PARENT_WORKFLOW_SHA: ${{ github.sha }}
PLAN_PATH: ${{ runner.temp }}/openclaw-release-clawhub-plan.json
run: |
set -euo pipefail
should_dispatch="$(jq -r '.normal.shouldDispatch' "${PLAN_PATH}")"
if [[ "${should_dispatch}" != "true" ]]; then
echo "normal_run_id=" >> "${GITHUB_OUTPUT}"
echo "normal_run_attempt=" >> "${GITHUB_OUTPUT}"
echo "authorization_artifact_name=" >> "${GITHUB_OUTPUT}"
exit 0
fi
workflow="$(jq -er '.normal.workflow' "${PLAN_PATH}")"
workflow_ref="$(jq -er '.normal.ref' "${PLAN_PATH}")"
expected_sha="$(jq -er '.bootstrapWorkflowSha' "${PLAN_PATH}")"
inputs_json="$(jq -c '.normal.inputs' "${PLAN_PATH}")"
encoded_ref="$(jq -rn --arg value "${workflow_ref}" '$value | @uri')"
resolved_sha="$(
gh api "repos/${GITHUB_REPOSITORY}/commits/${encoded_ref}" \
--jq '.sha | select(test("^[a-f0-9]{40}$"))'
)"
[[ "${resolved_sha}" == "${expected_sha}" ]] || {
echo "ClawHub tooling ref resolved to ${resolved_sha}, expected ${expected_sha}." >&2
exit 1
}
expected_full_ref="refs/tags/${workflow_ref}"
if [[ "${workflow_ref}" == "main" ]]; then
expected_full_ref="refs/heads/main"
fi
[[ "${PARENT_WORKFLOW_REF}" == "${workflow_ref}" &&
"${PARENT_WORKFLOW_FULL_REF}" == "${expected_full_ref}" &&
"${PARENT_WORKFLOW_SHA}" == "${expected_sha}" ]] || {
echo "ClawHub parent tooling identity does not match the protected child ref." >&2
exit 1
}
dispatch_path="${RUNNER_TEMP}/clawhub-dispatch.json"
jq -n --arg ref "${workflow_ref}" --argjson inputs "${inputs_json}" \
'{ref: $ref, inputs: $inputs}' > "${dispatch_path}"
dispatch_response="$(
gh api \
--method POST \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2026-03-10" \
"repos/${GITHUB_REPOSITORY}/actions/workflows/${workflow}/dispatches" \
--input "${dispatch_path}"
)"
child_run_id="$(jq -er '.workflow_run_id | tostring' <<<"${dispatch_response}")"
child_run_attempt="1"
child_run="$(
gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${child_run_id}/attempts/${child_run_attempt}"
)"
jq -e \
--arg repository "${GITHUB_REPOSITORY}" \
--arg run_id "${child_run_id}" \
--arg sha "${expected_sha}" \
--arg ref "${workflow_ref}" \
'.repository.full_name == $repository and
(.id | tostring) == $run_id and .run_attempt == 1 and
.path == ".github/workflows/plugin-clawhub-release.yml" and
.head_branch == $ref and .head_sha == $sha and
.event == "workflow_dispatch"' <<<"${child_run}" >/dev/null
transaction_artifact="openclaw-clawhub-transactions-v2-${child_run_id}-${child_run_attempt}"
deadline=$((SECONDS + 5400))
while true; do
artifact_count="$(
gh api \
"repos/${GITHUB_REPOSITORY}/actions/runs/${child_run_id}/artifacts?name=${transaction_artifact}" \
--jq '[.artifacts[] | select(.expired == false)] | length'
)"
if [[ "${artifact_count}" == "1" ]]; then
break
fi
if (( artifact_count > 1 )); then
echo "ClawHub child produced duplicate transaction manifest artifacts." >&2
exit 1
fi
run_state="$(gh run view "${child_run_id}" --repo "${GITHUB_REPOSITORY}" --json status,conclusion)"
if [[ "$(jq -r '.status' <<<"${run_state}")" == "completed" ]]; then
echo "ClawHub child completed before producing its transaction manifest." >&2
exit 1
fi
if (( SECONDS >= deadline )); then
echo "ClawHub child did not produce its transaction manifest within 90 minutes." >&2
exit 1
fi
sleep 20
done
transaction_dir="${RUNNER_TEMP}/openclaw-clawhub-transactions"
authorization_dir="${RUNNER_TEMP}/openclaw-clawhub-parent-authorization"
mkdir -p "${transaction_dir}" "${authorization_dir}"
gh run download "${child_run_id}" \
--repo "${GITHUB_REPOSITORY}" \
--name "${transaction_artifact}" \
--dir "${transaction_dir}"
final_resolved_sha="$(
gh api "repos/${GITHUB_REPOSITORY}/commits/${encoded_ref}" \
--jq '.sha | select(test("^[a-f0-9]{40}$"))'
)"
[[ "${final_resolved_sha}" == "${expected_sha}" ]] || {
echo "ClawHub tooling ref moved from ${expected_sha} to ${final_resolved_sha} before parent authorization." >&2
exit 1
}
node --import tsx \
"${GITHUB_WORKSPACE}/.release-harness/scripts/lib/openclaw-clawhub-authorization.mts" \
authorization \
--manifest "${transaction_dir}/transactions.json" \
--repository "${GITHUB_REPOSITORY}" \
--run-id "${GITHUB_RUN_ID}" \
--run-attempt "${GITHUB_RUN_ATTEMPT}" \
--child-run-id "${child_run_id}" \
--child-run-attempt "${child_run_attempt}" \
--ref "${PARENT_WORKFLOW_REF}" \
--full-ref "${PARENT_WORKFLOW_FULL_REF}" \
--head-sha "${PARENT_WORKFLOW_SHA}" \
--output "${authorization_dir}/authorization.json"
authorization_artifact="openclaw-clawhub-parent-authorization-v2-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${child_run_id}-${child_run_attempt}"
{
echo "normal_run_id=${child_run_id}"
echo "normal_run_attempt=${child_run_attempt}"
echo "authorization_artifact_name=${authorization_artifact}"
} >> "${GITHUB_OUTPUT}"
echo "- Plugin ClawHub run ID: \`${child_run_id}\`" >> "${GITHUB_STEP_SUMMARY}"
- name: Upload ClawHub parent authorization
if: ${{ steps.clawhub_authorization.outputs.normal_run_id != '' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ${{ steps.clawhub_authorization.outputs.authorization_artifact_name }}
path: ${{ runner.temp }}/openclaw-clawhub-parent-authorization/authorization.json
if-no-files-found: error
retention-days: 30
- name: Prepare GitHub release notes
if: ${{ inputs.publish_openclaw_npm }}
env:
@@ -962,6 +1104,8 @@ jobs:
CHILD_WORKFLOW_REF: ${{ github.ref_name }}
PARENT_WORKFLOW_SHA: ${{ github.sha }}
PARENT_WORKFLOW_BRANCH: ${{ github.ref_name }}
PARENT_WORKFLOW_FULL_REF: ${{ github.ref }}
PREPARED_CLAWHUB_RUN_ID: ${{ steps.clawhub_authorization.outputs.normal_run_id }}
RELEASE_TAG: ${{ inputs.tag }}
PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }}
FULL_RELEASE_VALIDATION_RUN_ID: ${{ inputs.full_release_validation_run_id }}
@@ -1294,6 +1438,7 @@ jobs:
local run_id="$2"
local job_name="$3"
local expected_sha="$4"
local allow_skipped="${5:-true}"
local jobs_json job_json run_status run_conclusion status conclusion url deadline
if ! verify_child_run_sha "$workflow" "$run_id" "$expected_sha"; then
@@ -1310,7 +1455,8 @@ jobs:
conclusion="$(printf '%s' "$job_json" | jq -r '.conclusion // ""')"
url="$(printf '%s' "$job_json" | jq -r '.url // ""')"
if [[ "$status" == "completed" ]]; then
if [[ "$conclusion" == "success" || "$conclusion" == "skipped" ]]; then
if [[ "$conclusion" == "success" ||
( "$allow_skipped" == "true" && "$conclusion" == "skipped" ) ]]; then
if ! verify_child_run_sha "$workflow" "$run_id" "$expected_sha"; then
return 1
fi
@@ -2227,16 +2373,17 @@ jobs:
bootstrap_summary_ref="$(jq -er '.bootstrap.ref | select(type == "string" and length > 0)' "${CLAWHUB_PLAN_PATH}")"
bootstrap_summary_sha="$(jq -er '.bootstrapWorkflowSha | select(test("^[a-f0-9]{40}$"))' "${CLAWHUB_PLAN_PATH}")"
normal_summary_ref="$(jq -er '.normal.ref | select(type == "string" and length > 0)' "${CLAWHUB_PLAN_PATH}")"
{
echo "### Publish sequence"
echo
echo "- Workflow ref: \`${CHILD_WORKFLOW_REF}\`"
echo "- Normal ClawHub workflow ref: release tag \`${RELEASE_TAG}\`"
echo "- Normal ClawHub tooling ref: \`${normal_summary_ref}\` at \`${bootstrap_summary_sha}\`"
echo "- ClawHub bootstrap workflow ref: \`${bootstrap_summary_ref}\` at \`${bootstrap_summary_sha}\`"
echo "- Release tag: \`${RELEASE_TAG}\`"
echo "- Release SHA: \`${TARGET_SHA}\`"
echo "- Release approval: this workflow job"
echo "- Plugin npm and ClawHub publish: dispatched in parallel"
echo "- Normal ClawHub publication: staged under exact tooling; detached verification requires this exact parent attempt to succeed"
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then
echo "- OpenClaw npm publish: starts after plugin npm succeeds"
else
@@ -2249,9 +2396,9 @@ jobs:
echo "- Windows Hub promotion: promoted concurrently with the OpenClaw npm publish; required before the GitHub release can be published"
fi
if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then
echo "- Workflow completion waits for ClawHub"
echo "- ClawHub bootstrap/repair completion: awaited"
else
echo "- Workflow completion does not wait for ClawHub; monitor the dispatched ClawHub run separately"
echo "- ClawHub bootstrap/repair completion: detached; monitor that bootstrap run separately"
fi
} >> "$GITHUB_STEP_SUMMARY"
@@ -2279,16 +2426,20 @@ jobs:
fi
plugin_npm_run_id="$(dispatch_workflow plugin-npm-release.yml "${npm_args[@]}")"
plugin_clawhub_run_id=""
plugin_clawhub_run_id="${PREPARED_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}")" \
"${TARGET_SHA}" \
"$(jq -r '.normal.workflow' "${clawhub_plan_path}")" \
"${clawhub_dispatch_args[@]}")"
[[ -n "${plugin_clawhub_run_id}" ]] || {
echo "Normal ClawHub publication was planned without a prepared authorized child run." >&2
exit 1
}
verify_child_run_sha plugin-clawhub-release.yml \
"${plugin_clawhub_run_id}" \
"${PARENT_WORKFLOW_SHA}"
else
[[ -z "${plugin_clawhub_run_id}" ]] || {
echo "Prepared ClawHub child exists when the release plan has no normal candidates." >&2
exit 1
}
echo "- plugin-clawhub-release.yml: no normal OIDC candidates" >> "$GITHUB_STEP_SUMMARY"
fi
plugin_clawhub_bootstrap_run_id=""
@@ -2321,6 +2472,20 @@ jobs:
exit 1
fi
if [[ -n "${plugin_clawhub_run_id}" ]]; then
approve_child_publish_environment \
plugin-clawhub-release.yml \
"${plugin_clawhub_run_id}" \
"${PARENT_WORKFLOW_SHA}"
wait_for_job_success \
plugin-clawhub-release.yml \
"${plugin_clawhub_run_id}" \
"Confirm staged ClawHub publication" \
"${PARENT_WORKFLOW_SHA}" \
false
echo "- plugin-clawhub-release.yml: staged; detached verification awaits exact parent success (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY"
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}" "${bootstrap_workflow_sha}"; then
@@ -2358,17 +2523,9 @@ jobs:
echo "- OpenClaw npm publish: skipped by input" >> "$GITHUB_STEP_SUMMARY"
fi
clawhub_result=""
clawhub_pid=""
clawhub_bootstrap_result=""
clawhub_bootstrap_pid=""
if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then
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}" "${TARGET_SHA}" "${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"
@@ -2380,11 +2537,8 @@ jobs:
fi
fi
else
# Detached mode deliberately leaves protected ClawHub approvals to
# the operator monitoring the linked child runs. Polling for those
# gates here would put plugin fan-out back on the core release path.
if [[ -n "${plugin_clawhub_run_id}" ]]; then
echo "- plugin-clawhub-release.yml: detached; approval and publish not awaited (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY"
echo "- plugin-clawhub-release.yml: staged; detached verification awaits exact parent success (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY"
else
echo "- plugin-clawhub-release.yml: no normal OIDC publish to await" >> "$GITHUB_STEP_SUMMARY"
fi
@@ -2447,14 +2601,6 @@ jobs:
fi
clawhub_failed=0
if [[ -n "${clawhub_pid}" ]] && ! wait "${clawhub_pid}"; then
failed=1
clawhub_failed=1
fi
if [[ -f "${clawhub_result}" && "$(cat "${clawhub_result}")" != "success" ]]; then
failed=1
clawhub_failed=1
fi
if [[ -n "${clawhub_bootstrap_pid}" ]] && ! wait "${clawhub_bootstrap_pid}"; then
failed=1
clawhub_failed=1
+347 -15
View File
@@ -16,7 +16,12 @@ on:
required: false
type: string
ref:
description: Dry-run target ref to validate; real OIDC publishes must dispatch the workflow with --ref set to the target release tag/ref
description: Exact candidate SHA to package when protected tooling runs from another ref
required: false
default: ""
type: string
release_tag:
description: Exact release tag bound to the candidate source revision
required: false
default: ""
type: string
@@ -24,10 +29,24 @@ on:
description: Approved OpenClaw Release Publish workflow run id
required: false
type: string
release_publish_run_attempt:
description: Exact approved OpenClaw Release Publish workflow run attempt
required: false
type: string
release_publish_branch:
description: Branch name of the approving OpenClaw Release Publish workflow run
required: false
type: string
release_publish_full_ref:
description: Exact full ref of the approving OpenClaw Release Publish workflow tooling
required: false
default: ""
type: string
release_publish_workflow_sha:
description: Exact workflow SHA of the approving OpenClaw Release Publish tooling
required: false
default: ""
type: string
dry_run:
description: Validate the full ClawHub artifact handoff without publishing.
required: false
@@ -48,6 +67,7 @@ jobs:
preview_plugins_clawhub:
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
outputs:
ref_revision: ${{ steps.ref.outputs.sha }}
@@ -61,6 +81,8 @@ jobs:
matrix: ${{ steps.plan.outputs.matrix }}
bootstrap_matrix: ${{ steps.plan.outputs.bootstrap_matrix }}
missing_trusted_publisher_matrix: ${{ steps.plan.outputs.missing_trusted_publisher_matrix }}
trusted_tooling_identity_json: ${{ steps.tooling_identity.outputs.json }}
parent_run_attempt: ${{ steps.parent_attempt.outputs.attempt }}
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
@@ -92,9 +114,110 @@ jobs:
fi
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Resolve release parent attempt
id: parent_attempt
env:
GH_TOKEN: ${{ github.token }}
PARENT_RUN_ATTEMPT: ${{ inputs.release_publish_run_attempt }}
PARENT_RUN_ID: ${{ inputs.release_publish_run_id }}
run: |
set -euo pipefail
if [[ -z "${PARENT_RUN_ID// }" ]]; then
[[ -z "${PARENT_RUN_ATTEMPT// }" ]] || {
echo "release_publish_run_attempt requires release_publish_run_id." >&2
exit 1
}
echo "attempt=" >> "$GITHUB_OUTPUT"
exit 0
fi
if [[ -z "${PARENT_RUN_ATTEMPT// }" ]]; then
[[ "${GITHUB_ACTOR}" != "github-actions[bot]" ]] || {
echo "Automated ClawHub publication requires the exact parent run attempt." >&2
exit 1
}
PARENT_RUN_ATTEMPT="$(
gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${PARENT_RUN_ID}" --jq '.run_attempt'
)"
fi
[[ "${PARENT_RUN_ID}" =~ ^[1-9][0-9]*$ &&
"${PARENT_RUN_ATTEMPT}" =~ ^[1-9][0-9]*$ ]] || {
echo "ClawHub publication requires a valid parent run id and attempt." >&2
exit 1
}
echo "attempt=${PARENT_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT"
- name: Capture trusted tooling identity
id: tooling_identity
env:
CANDIDATE_SHA: ${{ steps.ref.outputs.sha }}
CALLER_FULL_REF: ${{ github.ref }}
CALLER_REF: ${{ github.ref_name }}
CALLER_RUN_ATTEMPT: ${{ github.run_attempt }}
CALLER_RUN_ID: ${{ github.run_id }}
CALLER_SHA: ${{ github.sha }}
PARENT_RUN_ATTEMPT: ${{ steps.parent_attempt.outputs.attempt }}
PARENT_RUN_ID: ${{ inputs.release_publish_run_id }}
TOOLING_FULL_REF: ${{ inputs.release_publish_full_ref }}
TOOLING_REF: ${{ inputs.release_publish_branch }}
TOOLING_SHA: ${{ inputs.release_publish_workflow_sha }}
run: |
set -euo pipefail
if [[ "${{ inputs.dry_run && 'true' || 'false' }}" == "true" && -z "${PARENT_RUN_ID}" ]]; then
echo "json=" >> "$GITHUB_OUTPUT"
exit 0
fi
identity="$(
jq -cn \
--arg candidateRepository "$GITHUB_REPOSITORY" \
--arg candidateSha "$CANDIDATE_SHA" \
--arg fullRef "$CALLER_FULL_REF" \
--arg parentRepository "$GITHUB_REPOSITORY" \
--arg parentRunAttempt "$PARENT_RUN_ATTEMPT" \
--arg parentRunId "$PARENT_RUN_ID" \
--arg parentWorkflow ".github/workflows/openclaw-release-publish.yml" \
--arg ref "$CALLER_REF" \
--arg repository "$GITHUB_REPOSITORY" \
--arg runAttempt "$CALLER_RUN_ATTEMPT" \
--arg runId "$CALLER_RUN_ID" \
--arg sha "$CALLER_SHA" \
--arg toolingFullRef "$TOOLING_FULL_REF" \
--arg toolingRef "$TOOLING_REF" \
--arg toolingSha "$TOOLING_SHA" \
--arg workflow ".github/workflows/plugin-clawhub-release.yml" \
'{
candidateRepository: $candidateRepository,
candidateSha: $candidateSha,
fullRef: $fullRef,
parentRepository: $parentRepository,
parentRunAttempt: $parentRunAttempt,
parentRunId: $parentRunId,
parentWorkflow: $parentWorkflow,
ref: $ref,
repository: $repository,
runAttempt: $runAttempt,
runId: $runId,
sha: $sha,
toolingFullRef: $toolingFullRef,
toolingRef: $toolingRef,
toolingSha: $toolingSha,
version: 2,
workflow: $workflow
}'
)"
echo "json=${identity}" >> "$GITHUB_OUTPUT"
- name: Validate OIDC source matches workflow ref
env:
RELEASE_PUBLISH_FULL_REF: ${{ inputs.release_publish_full_ref }}
RELEASE_PUBLISH_RUN_ATTEMPT: ${{ steps.parent_attempt.outputs.attempt }}
RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }}
RELEASE_PUBLISH_TOOLING_REF: ${{ inputs.release_publish_branch }}
RELEASE_PUBLISH_TOOLING_SHA: ${{ inputs.release_publish_workflow_sha }}
RELEASE_TAG: ${{ inputs.release_tag }}
TARGET_REF: ${{ inputs.ref }}
TARGET_SHA: ${{ steps.ref.outputs.sha }}
WORKFLOW_FULL_REF: ${{ github.ref }}
WORKFLOW_REF: ${{ github.ref_name }}
WORKFLOW_SHA: ${{ github.sha }}
DRY_RUN: ${{ inputs.dry_run && 'true' || 'false' }}
run: |
@@ -104,10 +227,43 @@ jobs:
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
[[ "$TARGET_REF" =~ ^[a-f0-9]{40}$ && "$TARGET_REF" == "$TARGET_SHA" ]] || {
echo "Split-ref ClawHub publication requires ref to be the exact candidate SHA." >&2
exit 1
}
if [[ "$WORKFLOW_FULL_REF" == "refs/heads/main" ]]; then
[[ "$WORKFLOW_REF" == "main" ]] || {
echo "Split-ref ClawHub main tooling ref is inconsistent." >&2
exit 1
}
else
[[ "$WORKFLOW_FULL_REF" =~ ^refs/tags/release-publish/([a-f0-9]{12})-[1-9][0-9]*$ &&
"${BASH_REMATCH[1]}" == "${WORKFLOW_SHA:0:12}" ]] || {
echo "Split-ref ClawHub publication requires current main or an exact SHA-prefixed protected tooling tag." >&2
exit 1
}
fi
[[ "$RELEASE_PUBLISH_TOOLING_REF" == "$WORKFLOW_REF" &&
"$RELEASE_PUBLISH_FULL_REF" == "$WORKFLOW_FULL_REF" &&
"$RELEASE_PUBLISH_TOOLING_SHA" == "$WORKFLOW_SHA" ]] || {
echo "Split-ref ClawHub publication tooling identity does not match the executing workflow." >&2
exit 1
}
[[ "$RELEASE_PUBLISH_RUN_ID" =~ ^[1-9][0-9]*$ &&
"$RELEASE_PUBLISH_RUN_ATTEMPT" =~ ^[1-9][0-9]*$ ]] || {
echo "Split-ref ClawHub publication requires the exact parent run id and attempt." >&2
exit 1
}
[[ "$RELEASE_TAG" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-[a-z]+(\.[1-9][0-9]*)?)?$ ]] || {
echo "Split-ref ClawHub publication requires an exact release tag." >&2
exit 1
}
git fetch --no-tags origin "+refs/tags/${RELEASE_TAG}:refs/tags/${RELEASE_TAG}"
[[ "$(git rev-parse "${RELEASE_TAG}^{commit}")" == "$TARGET_SHA" ]] || {
echo "ClawHub release tag does not resolve to the frozen candidate SHA." >&2
exit 1
}
echo "Using trusted tooling ${WORKFLOW_SHA} with frozen candidate ${TARGET_SHA}."
fi
- name: Validate ref is on a trusted publish branch
@@ -285,6 +441,9 @@ jobs:
ALLOW_COMPLETED_SUCCESSFUL_PARENT: "true"
GH_TOKEN: ${{ github.token }}
RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }}
EXPECTED_RUN_ATTEMPT: ${{ needs.preview_plugins_clawhub.outputs.parent_run_attempt }}
EXPECTED_WORKFLOW_FULL_REF: ${{ inputs.release_publish_full_ref || github.ref }}
EXPECTED_WORKFLOW_SHA: ${{ inputs.release_publish_workflow_sha || github.sha }}
EXPECTED_WORKFLOW_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }}
run: |
set -euo pipefail
@@ -301,7 +460,7 @@ jobs:
direct_recovery=true
echo "Direct Plugin ClawHub Release recovery with release_publish_run_id; relying on this workflow's clawhub-plugin-release environment approval."
fi
RUN_JSON="$(gh run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)"
RUN_JSON="$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${RELEASE_PUBLISH_RUN_ID}/attempts/${EXPECTED_RUN_ATTEMPT}" --jq '{workflowName: .name, headBranch: .head_branch, headSha: .head_sha, event, status, conclusion, url: .html_url, runAttempt: .run_attempt, repository: .repository.full_name, path}')"
printf '%s' "$RUN_JSON" | DIRECT_RELEASE_RECOVERY="${direct_recovery}" node scripts/validate-release-publish-approval.mjs
pack_plugins_clawhub_artifacts:
@@ -358,7 +517,7 @@ jobs:
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
SOURCE_REPO: ${{ github.repository }}
SOURCE_COMMIT: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }}
SOURCE_REF: ${{ github.ref }}
SOURCE_REF: ${{ inputs.release_tag != '' && format('refs/tags/{0}', inputs.release_tag) || github.ref }}
PACKAGE_TAG: ${{ matrix.plugin.publishTag }}
PACKAGE_DIR: ${{ matrix.plugin.packageDir }}
OPENCLAW_CLAWHUB_PACK_OUTPUT_DIR: ${{ runner.temp }}/clawhub-package-artifact
@@ -372,9 +531,72 @@ jobs:
if-no-files-found: error
retention-days: 7
approve_plugins_clawhub_release:
aggregate_clawhub_transactions:
name: Aggregate ClawHub transaction manifest
needs: [preview_plugins_clawhub, pack_plugins_clawhub_artifacts]
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'
if: github.event_name == 'workflow_dispatch' && inputs.dry_run != true && needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
steps:
- name: Checkout transaction tooling
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
ref: ${{ github.sha }}
fetch-depth: 1
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
install-bun: "false"
- name: Download packed ClawHub artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
pattern: clawhub-package-*
path: ${{ runner.temp }}/clawhub-package-artifacts
- name: Build exact ClawHub transaction manifest
env:
CANDIDATE_SHA: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }}
MATRIX_JSON: ${{ needs.preview_plugins_clawhub.outputs.matrix }}
TOOLING_FULL_REF: ${{ inputs.release_publish_full_ref }}
TOOLING_REF: ${{ inputs.release_publish_branch }}
TOOLING_SHA: ${{ inputs.release_publish_workflow_sha }}
run: |
set -euo pipefail
output_dir="${RUNNER_TEMP}/openclaw-clawhub-transactions"
mkdir -p "${output_dir}"
node --import tsx scripts/lib/openclaw-clawhub-authorization.mts transactions \
--artifacts-dir "${RUNNER_TEMP}/clawhub-package-artifacts" \
--matrix-json "${MATRIX_JSON}" \
--candidate-repository "${GITHUB_REPOSITORY}" \
--candidate-sha "${CANDIDATE_SHA}" \
--child-repository "${GITHUB_REPOSITORY}" \
--child-run-id "${GITHUB_RUN_ID}" \
--child-run-attempt "${GITHUB_RUN_ATTEMPT}" \
--child-ref "${GITHUB_REF_NAME}" \
--child-full-ref "${GITHUB_REF}" \
--child-head-sha "${GITHUB_SHA}" \
--tooling-ref "${TOOLING_REF}" \
--tooling-full-ref "${TOOLING_FULL_REF}" \
--tooling-sha "${TOOLING_SHA}" \
--output "${output_dir}/transactions.json"
- name: Upload ClawHub transaction manifest
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: openclaw-clawhub-transactions-v2-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/openclaw-clawhub-transactions/transactions.json
if-no-files-found: error
retention-days: 30
approve_plugins_clawhub_release:
needs: [preview_plugins_clawhub, aggregate_clawhub_transactions]
if: always() && github.event_name == 'workflow_dispatch' && inputs.dry_run != true && needs.preview_plugins_clawhub.outputs.has_candidates == 'true' && needs.aggregate_clawhub_transactions.result == 'success'
runs-on: ubuntu-latest
environment: clawhub-plugin-release
permissions:
@@ -386,9 +608,14 @@ jobs:
publish_plugins_clawhub:
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@d8096dfc039e86ab942ddf9ef117d04849fd84c1
[
preview_plugins_clawhub,
pack_plugins_clawhub_artifacts,
aggregate_clawhub_transactions,
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.aggregate_clawhub_transactions.result == 'skipped' && needs.approve_plugins_clawhub_release.result == 'skipped') || (inputs.dry_run != true && needs.aggregate_clawhub_transactions.result == 'success' && needs.approve_plugins_clawhub_release.result == 'success'))
uses: openclaw/clawhub/.github/workflows/package-publish.yml@4bc87f53c8a6eb75317d83aec835b58aa892d11a
permissions:
actions: read
contents: read
@@ -401,22 +628,25 @@ jobs:
with:
package_artifact_name: ${{ matrix.plugin.artifactName }}
dry_run: ${{ inputs.dry_run }}
wait_for_publication: false
registry: https://clawhub.ai
site: https://clawhub.ai
family: ${{ contains(fromJson('["@openclaw/acpx","@openclaw/diffs","@openclaw/feishu"]'), matrix.plugin.packageName) && 'bundle-plugin' || '' }}
tags: ${{ matrix.plugin.publishTag }}
source_repo: ${{ github.repository }}
source_commit: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }}
source_ref: ${{ github.ref }}
source_ref: ${{ inputs.release_tag != '' && format('refs/tags/{0}', inputs.release_tag) || github.ref }}
source_path: ${{ matrix.plugin.packageDir }}
inspector_artifact_name: ${{ matrix.plugin.artifactName }}-inspector
publish_json_artifact_name: ${{ matrix.plugin.artifactName }}-publish-json
trusted_tooling_identity_json: ${{ needs.preview_plugins_clawhub.outputs.trusted_tooling_identity_json }}
verify_published_clawhub_package:
verify_staged_clawhub_packages:
name: Verify staged ClawHub package (${{ matrix.plugin.packageName }})
needs: [preview_plugins_clawhub, publish_plugins_clawhub]
if: github.event_name == 'workflow_dispatch' && inputs.dry_run != true && needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
runs-on: ubuntu-latest
timeout-minutes: 45
timeout-minutes: 10
permissions:
actions: read
contents: read
@@ -426,6 +656,108 @@ jobs:
matrix:
plugin: ${{ fromJson(needs.preview_plugins_clawhub.outputs.matrix) }}
steps:
- name: Download ClawHub publish result
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: ${{ matrix.plugin.artifactName }}-publish-json
path: ${{ runner.temp }}/clawhub-publish-json
- name: Require staged publication
env:
PACKAGE_NAME: ${{ matrix.plugin.packageName }}
PACKAGE_VERSION: ${{ matrix.plugin.version }}
run: |
set -euo pipefail
publish_json="${RUNNER_TEMP}/clawhub-publish-json/package-publish.json"
jq -e \
--arg name "${PACKAGE_NAME}" \
--arg version "${PACKAGE_VERSION}" \
'.name == $name and .version == $version and
.status == "pending-publication" and .publicationStatus == "pending" and
(.attemptId | type == "string" and length > 0)' \
"${publish_json}" >/dev/null
clawhub_staged:
name: Confirm staged ClawHub publication
needs: [preview_plugins_clawhub, verify_staged_clawhub_packages]
if: always() && github.event_name == 'workflow_dispatch' && inputs.dry_run != true && needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Confirm every package is staged
env:
STAGED_RESULT: ${{ needs.verify_staged_clawhub_packages.result }}
run: |
set -euo pipefail
[[ "${STAGED_RESULT}" == "success" ]] || {
echo "One or more ClawHub packages did not reach staged publication." >&2
exit 1
}
verify_published_clawhub_package:
name: Verify published ClawHub package after parent success (${{ matrix.plugin.packageName }})
needs: [preview_plugins_clawhub, clawhub_staged]
if: github.event_name == 'workflow_dispatch' && inputs.dry_run != true && needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
runs-on: ubuntu-latest
timeout-minutes: 90
permissions:
actions: read
contents: read
strategy:
fail-fast: false
max-parallel: 32
matrix:
plugin: ${{ fromJson(needs.preview_plugins_clawhub.outputs.matrix) }}
steps:
- name: Require exact successful release parent attempt
env:
GH_TOKEN: ${{ github.token }}
PARENT_FULL_REF: ${{ inputs.release_publish_full_ref }}
PARENT_REF: ${{ inputs.release_publish_branch }}
PARENT_RUN_ATTEMPT: ${{ needs.preview_plugins_clawhub.outputs.parent_run_attempt }}
PARENT_RUN_ID: ${{ inputs.release_publish_run_id }}
PARENT_SHA: ${{ inputs.release_publish_workflow_sha }}
run: |
set -euo pipefail
deadline=$((SECONDS + 3600))
while true; do
run_json="$(
gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${PARENT_RUN_ID}/attempts/${PARENT_RUN_ATTEMPT}"
)"
jq -e \
--arg repository "${GITHUB_REPOSITORY}" \
--arg run_id "${PARENT_RUN_ID}" \
--arg attempt "${PARENT_RUN_ATTEMPT}" \
--arg ref "${PARENT_REF}" \
--arg sha "${PARENT_SHA}" \
'.repository.full_name == $repository and
(.id | tostring) == $run_id and
(.run_attempt | tostring) == $attempt and
.path == ".github/workflows/openclaw-release-publish.yml" and
.head_branch == $ref and .head_sha == $sha and
.event == "workflow_dispatch"' <<<"${run_json}" >/dev/null
status="$(jq -r '.status' <<<"${run_json}")"
conclusion="$(jq -r '.conclusion // ""' <<<"${run_json}")"
if [[ "${status}" == "completed" ]]; then
[[ "${conclusion}" == "success" ]] || {
echo "Exact release parent attempt finished with ${conclusion}; refusing public verification." >&2
exit 1
}
break
fi
if (( SECONDS >= deadline )); then
echo "Exact release parent attempt did not finish within 60 minutes." >&2
exit 1
fi
sleep 20
done
[[ "${PARENT_FULL_REF}" == "refs/heads/main" ||
"${PARENT_FULL_REF}" == "refs/tags/${PARENT_REF}" ]] || {
echo "Exact release parent full ref is not current main or the protected tooling tag." >&2
exit 1
}
- name: Checkout verification tooling
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
@@ -0,0 +1,454 @@
import { createHash } from "node:crypto";
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { inspectPackageTarballBytes } from "../plugin-publication-artifact.mjs";
const SHA_PATTERN = /^[a-f0-9]{40}$/u;
const POSITIVE_INTEGER_PATTERN = /^[1-9][0-9]*$/u;
const PROTECTED_TOOLING_REF_PATTERN = /^release-publish\/([a-f0-9]{12})-[1-9][0-9]*$/u;
type PluginMatrixEntry = {
artifactName: string;
packageName: string;
version: string;
};
export type ClawHubPackageTransaction = {
inventoryDigest: string;
name: string;
version: string;
};
export type ClawHubTransactionManifest = {
candidateRepository: string;
candidateSha: string;
childFullRef: string;
childHeadSha: string;
childRef: string;
childRepository: string;
childRunAttempt: string;
childRunId: string;
childWorkflow: ".github/workflows/plugin-clawhub-release.yml";
packages: ClawHubPackageTransaction[];
toolingFullRef: string;
toolingRef: string;
toolingSha: string;
version: 2;
};
export type ClawHubParentAuthorization = {
authorizationRoute: "automated-awaited" | "automated-detached";
candidateRepository: string;
candidateSha: string;
childFullRef: string;
childHeadSha: string;
childRef: string;
childRepository: string;
childRunAttempt: string;
childRunId: string;
childWorkflow: ".github/workflows/plugin-clawhub-release.yml";
fullRef: string;
headSha: string;
kind: "openclaw-clawhub-parent-authorization";
packages: ClawHubPackageTransaction[];
ref: string;
repository: string;
runAttempt: string;
runId: string;
toolingFullRef: string;
toolingRef: string;
toolingSha: string;
version: 2;
workflow: ".github/workflows/openclaw-release-publish.yml";
};
function requireString(value: unknown, label: string): string {
if (typeof value !== "string" || !value.trim()) {
throw new Error(`${label} is required.`);
}
return value.trim();
}
function requireSha(value: unknown, label: string): string {
const sha = requireString(value, label);
if (!SHA_PATTERN.test(sha)) {
throw new Error(`${label} must be a full lowercase commit SHA.`);
}
return sha;
}
function requirePositiveInteger(value: unknown, label: string): string {
const result = requireString(value, label);
if (!POSITIVE_INTEGER_PATTERN.test(result)) {
throw new Error(`${label} must be a positive integer.`);
}
return result;
}
function requireRepository(value: unknown, label: string): string {
const repository = requireString(value, label);
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(repository)) {
throw new Error(`${label} must be an owner/repository pair.`);
}
return repository;
}
function requireRefPair(refValue: unknown, fullRefValue: unknown, label: string) {
const ref = requireString(refValue, `${label} ref`);
const fullRef = requireString(fullRefValue, `${label} full ref`);
if (fullRef !== `refs/heads/${ref}` && fullRef !== `refs/tags/${ref}`) {
throw new Error(`${label} ref does not match its full ref.`);
}
return { ref, fullRef };
}
function requireToolingIdentity(refValue: unknown, fullRefValue: unknown, shaValue: unknown) {
const { ref, fullRef } = requireRefPair(refValue, fullRefValue, "tooling");
const sha = requireSha(shaValue, "tooling SHA");
if (ref === "main" && fullRef === "refs/heads/main") {
return { ref, fullRef, sha };
}
const match = PROTECTED_TOOLING_REF_PATTERN.exec(ref);
if (!match || fullRef !== `refs/tags/${ref}` || match[1] !== sha.slice(0, 12)) {
throw new Error(
"tooling must use current main or an exact SHA-prefixed protected release-publish tag.",
);
}
return { ref, fullRef, sha };
}
function compareCodeUnits(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}
export function buildClawHubInventoryDigest(
inventory: readonly {
path: string;
sizeBytes: number;
sha256?: string;
type: string;
}[],
): string {
const files = inventory
.filter((entry) => entry.type === "file")
.map((entry) => {
if (!entry.path.startsWith("package/") || !entry.sha256) {
throw new Error(`ClawHub package inventory contains an invalid file: ${entry.path}`);
}
return {
path: entry.path.slice("package/".length),
size: entry.sizeBytes,
sha256: entry.sha256.toLowerCase(),
};
})
.toSorted((left, right) => left.path.localeCompare(right.path));
if (files.length === 0) {
throw new Error("ClawHub package inventory must contain at least one file.");
}
const payload = files.map((file) => `${file.path}\0${file.size}\0${file.sha256}`).join("\n");
return createHash("sha256").update(payload).digest("hex");
}
function parsePluginMatrix(raw: string): PluginMatrixEntry[] {
const value = JSON.parse(raw) as unknown;
if (!Array.isArray(value) || value.length === 0 || value.length > 512) {
throw new Error("ClawHub plugin matrix must contain 1 through 512 entries.");
}
const entries = value.map((item, index) => {
if (!item || typeof item !== "object" || Array.isArray(item)) {
throw new Error(`ClawHub plugin matrix entry ${index} is invalid.`);
}
const record = item as Record<string, unknown>;
const entry = {
artifactName: requireString(record.artifactName, `matrix entry ${index} artifactName`),
packageName: requireString(record.packageName, `matrix entry ${index} packageName`),
version: requireString(record.version, `matrix entry ${index} version`),
};
if (
entry.artifactName === "." ||
entry.artifactName === ".." ||
entry.artifactName.includes("/") ||
entry.artifactName.includes("\\")
) {
throw new Error(`ClawHub plugin matrix entry ${index} artifactName is unsafe.`);
}
return entry;
});
const packageNames = new Set(entries.map((entry) => entry.packageName));
const artifactNames = new Set(entries.map((entry) => entry.artifactName));
if (packageNames.size !== entries.length || artifactNames.size !== entries.length) {
throw new Error("ClawHub plugin matrix contains duplicate packages or artifacts.");
}
return entries.toSorted((left, right) => compareCodeUnits(left.packageName, right.packageName));
}
function findOnlyTarball(artifactDir: string): string {
const names = readdirSync(artifactDir).filter((name) => name.endsWith(".tgz"));
if (names.length !== 1) {
throw new Error(`Expected exactly one .tgz in ${artifactDir}; found ${names.length}.`);
}
return join(artifactDir, names[0]!);
}
export function buildClawHubTransactionManifest(params: {
artifactsDir: string;
matrix: PluginMatrixEntry[];
candidateRepository: string;
candidateSha: string;
childRepository: string;
childRunId: string;
childRunAttempt: string;
childRef: string;
childFullRef: string;
childHeadSha: string;
toolingRef: string;
toolingFullRef: string;
toolingSha: string;
}): ClawHubTransactionManifest {
const candidateRepository = requireRepository(params.candidateRepository, "candidate repository");
const candidateSha = requireSha(params.candidateSha, "candidate SHA");
const childRepository = requireRepository(params.childRepository, "child repository");
const childRunId = requirePositiveInteger(params.childRunId, "child run id");
const childRunAttempt = requirePositiveInteger(params.childRunAttempt, "child run attempt");
const child = requireRefPair(params.childRef, params.childFullRef, "child");
const childHeadSha = requireSha(params.childHeadSha, "child head SHA");
const tooling = requireToolingIdentity(
params.toolingRef,
params.toolingFullRef,
params.toolingSha,
);
if (
candidateRepository !== childRepository ||
child.ref !== tooling.ref ||
child.fullRef !== tooling.fullRef ||
childHeadSha !== tooling.sha
) {
throw new Error("ClawHub child, candidate, and tooling identity are inconsistent.");
}
const artifactsDir = resolve(params.artifactsDir);
const packages = params.matrix.map((entry) => {
const tarballPath = findOnlyTarball(join(artifactsDir, entry.artifactName));
const inspection = inspectPackageTarballBytes(readFileSync(tarballPath));
if (
inspection.packageManifest.name !== entry.packageName ||
inspection.packageManifest.version !== entry.version
) {
throw new Error(
`Packed ClawHub identity does not match ${entry.packageName}@${entry.version}.`,
);
}
return {
inventoryDigest: buildClawHubInventoryDigest(inspection.inventory),
name: entry.packageName,
version: entry.version,
};
});
return {
version: 2,
candidateRepository,
candidateSha,
childRepository,
childWorkflow: ".github/workflows/plugin-clawhub-release.yml",
childRunId,
childRunAttempt,
childRef: child.ref,
childFullRef: child.fullRef,
childHeadSha,
toolingRef: tooling.ref,
toolingFullRef: tooling.fullRef,
toolingSha: tooling.sha,
packages,
};
}
export function buildClawHubParentAuthorization(params: {
manifest: ClawHubTransactionManifest;
repository: string;
runId: string;
runAttempt: string;
childRunId: string;
childRunAttempt: string;
ref: string;
fullRef: string;
headSha: string;
authorizationRoute?: "automated-awaited" | "automated-detached";
}): ClawHubParentAuthorization {
if (
params.manifest.version !== 2 ||
params.manifest.childWorkflow !== ".github/workflows/plugin-clawhub-release.yml"
) {
throw new Error("ClawHub transaction manifest contract is invalid.");
}
const candidateRepository = requireRepository(
params.manifest.candidateRepository,
"manifest candidate repository",
);
const candidateSha = requireSha(params.manifest.candidateSha, "manifest candidate SHA");
const childRepository = requireRepository(
params.manifest.childRepository,
"manifest child repository",
);
const childRunId = requirePositiveInteger(params.manifest.childRunId, "manifest child run id");
const childRunAttempt = requirePositiveInteger(
params.manifest.childRunAttempt,
"manifest child run attempt",
);
const expectedChildRunId = requirePositiveInteger(params.childRunId, "expected child run id");
const expectedChildRunAttempt = requirePositiveInteger(
params.childRunAttempt,
"expected child run attempt",
);
const child = requireRefPair(
params.manifest.childRef,
params.manifest.childFullRef,
"manifest child",
);
const childHeadSha = requireSha(params.manifest.childHeadSha, "manifest child head SHA");
if (
!Array.isArray(params.manifest.packages) ||
params.manifest.packages.length === 0 ||
params.manifest.packages.length > 512
) {
throw new Error("ClawHub transaction manifest package inventory is invalid.");
}
const packageNames = new Set<string>();
const packages = params.manifest.packages.map((entry, index) => {
const name = requireString(entry?.name, `manifest package ${index} name`);
const version = requireString(entry?.version, `manifest package ${index} version`);
const inventoryDigest = requireString(
entry?.inventoryDigest,
`manifest package ${index} inventory digest`,
);
if (!/^[a-f0-9]{64}$/u.test(inventoryDigest) || packageNames.has(name)) {
throw new Error(`ClawHub transaction manifest package ${index} is invalid.`);
}
packageNames.add(name);
return { name, version, inventoryDigest };
});
const sortedPackages = packages.toSorted((left, right) =>
compareCodeUnits(left.name, right.name),
);
if (JSON.stringify(packages) !== JSON.stringify(sortedPackages)) {
throw new Error("ClawHub transaction manifest packages must use canonical name ordering.");
}
const repository = requireRepository(params.repository, "parent repository");
const runId = requirePositiveInteger(params.runId, "parent run id");
const runAttempt = requirePositiveInteger(params.runAttempt, "parent run attempt");
const parent = requireRefPair(params.ref, params.fullRef, "parent");
const headSha = requireSha(params.headSha, "parent head SHA");
const tooling = requireToolingIdentity(
params.manifest.toolingRef,
params.manifest.toolingFullRef,
params.manifest.toolingSha,
);
if (
repository !== childRepository ||
candidateRepository !== childRepository ||
child.ref !== tooling.ref ||
child.fullRef !== tooling.fullRef ||
childHeadSha !== tooling.sha ||
childRunId !== expectedChildRunId ||
childRunAttempt !== expectedChildRunAttempt ||
parent.ref !== tooling.ref ||
parent.fullRef !== tooling.fullRef ||
headSha !== tooling.sha
) {
throw new Error("ClawHub parent authorization does not match the protected tooling identity.");
}
return {
version: 2,
kind: "openclaw-clawhub-parent-authorization",
repository,
workflow: ".github/workflows/openclaw-release-publish.yml",
runId,
runAttempt,
ref: parent.ref,
fullRef: parent.fullRef,
headSha,
childWorkflow: params.manifest.childWorkflow,
childRepository,
childRunId,
childRunAttempt,
childRef: child.ref,
childFullRef: child.fullRef,
childHeadSha,
candidateRepository,
candidateSha,
toolingRef: tooling.ref,
toolingFullRef: tooling.fullRef,
toolingSha: tooling.sha,
packages,
authorizationRoute: params.authorizationRoute ?? "automated-awaited",
};
}
function parseArgs(argv: string[]): { command: string; values: Map<string, string> } {
const [command, ...rest] = argv;
if (!command) {
throw new Error("Expected transactions or authorization command.");
}
const values = new Map<string, string>();
for (let index = 0; index < rest.length; index += 2) {
const flag = rest[index];
const value = rest[index + 1];
if (!flag?.startsWith("--") || value === undefined || value.startsWith("--")) {
throw new Error(`Invalid argument near ${flag ?? "<end>"}.`);
}
values.set(flag.slice(2), value);
}
return { command, values };
}
function required(values: Map<string, string>, name: string): string {
return requireString(values.get(name), `--${name}`);
}
export function runOpenClawClawHubAuthorizationCli(argv: string[]): void {
const { command, values } = parseArgs(argv);
if (command === "transactions") {
const manifest = buildClawHubTransactionManifest({
artifactsDir: required(values, "artifacts-dir"),
matrix: parsePluginMatrix(required(values, "matrix-json")),
candidateRepository: required(values, "candidate-repository"),
candidateSha: required(values, "candidate-sha"),
childRepository: required(values, "child-repository"),
childRunId: required(values, "child-run-id"),
childRunAttempt: required(values, "child-run-attempt"),
childRef: required(values, "child-ref"),
childFullRef: required(values, "child-full-ref"),
childHeadSha: required(values, "child-head-sha"),
toolingRef: required(values, "tooling-ref"),
toolingFullRef: required(values, "tooling-full-ref"),
toolingSha: required(values, "tooling-sha"),
});
writeFileSync(required(values, "output"), `${JSON.stringify(manifest, null, 2)}\n`);
return;
}
if (command === "authorization") {
const manifest = JSON.parse(
readFileSync(required(values, "manifest"), "utf8"),
) as ClawHubTransactionManifest;
const authorization = buildClawHubParentAuthorization({
manifest,
repository: required(values, "repository"),
runId: required(values, "run-id"),
runAttempt: required(values, "run-attempt"),
childRunId: required(values, "child-run-id"),
childRunAttempt: required(values, "child-run-attempt"),
ref: required(values, "ref"),
fullRef: required(values, "full-ref"),
headSha: required(values, "head-sha"),
authorizationRoute: "automated-awaited",
});
writeFileSync(required(values, "output"), `${JSON.stringify(authorization, null, 2)}\n`);
return;
}
throw new Error(`Unknown command: ${command}`);
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
runOpenClawClawHubAuthorizationCli(process.argv.slice(2));
}
+22 -16
View File
@@ -142,6 +142,8 @@ function createDispatchTarget(params: {
bootstrapWorkflowSha?: string;
releaseTag?: string;
releasePublishRunAttempt?: string;
releasePublishFullRef?: string;
releasePublishWorkflowSha?: string;
targetRef?: string;
}): ClawHubDispatchTarget {
if (params.packages.length === 0) {
@@ -170,6 +172,12 @@ function createDispatchTarget(params: {
...(params.releasePublishRunAttempt
? { release_publish_run_attempt: params.releasePublishRunAttempt }
: {}),
...(params.releasePublishFullRef
? { release_publish_full_ref: params.releasePublishFullRef }
: {}),
...(params.releasePublishWorkflowSha
? { release_publish_workflow_sha: params.releasePublishWorkflowSha }
: {}),
plugins,
release_publish_run_id: params.releasePublishRunId,
release_publish_branch: params.releasePublishBranch,
@@ -184,30 +192,18 @@ export function buildOpenClawReleaseClawHubRuntimeState(
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 shouldSkipClawHubPackages = args.forceSkipClawHub || !shouldIncludeBootstrapRun;
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)}`;
if (normalRunId !== undefined) {
normalProofLine = `- plugin ClawHub publish: staged; detached verification follows exact parent success: ${runUrl(repository, normalRunId)}`;
}
let bootstrapProofLine = "- plugin ClawHub bootstrap: not needed";
@@ -335,6 +331,11 @@ export async function buildOpenClawReleaseClawHubPlan(
"releasePublishRunAttempt",
);
const releasePublishRunId = requireArg(args.releasePublishRunId, "releasePublishRunId");
if (releasePublishBranch !== bootstrapWorkflowRef) {
throw new Error("releasePublishBranch must match the exact trusted workflow ref.");
}
const releasePublishFullRef =
bootstrapWorkflowRef === "main" ? "refs/heads/main" : `refs/tags/${bootstrapWorkflowRef}`;
const plan = await collectPluginClawHubReleasePlan({
rootDir: options.rootDir ?? resolve("."),
selection: args.plugins,
@@ -357,11 +358,16 @@ export async function buildOpenClawReleaseClawHubPlan(
releasePublishBranch,
normal: createDispatchTarget({
workflow: "plugin-clawhub-release.yml",
ref: releaseTag,
ref: bootstrapWorkflowRef,
packages: normalPackages,
releasePublishRunId,
releasePublishBranch,
releasePublishRunAttempt,
releasePublishFullRef,
releasePublishWorkflowSha: bootstrapWorkflowSha,
includePublishScope: true,
releaseTag,
targetRef: releaseSha,
}),
bootstrap: createDispatchTarget({
workflow: "plugin-clawhub-new.yml",
+221 -19
View File
@@ -12,6 +12,11 @@ import {
import { delimiter, join } from "node:path";
import { gzipSync } from "node:zlib";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
buildClawHubInventoryDigest,
buildClawHubParentAuthorization,
buildClawHubTransactionManifest,
} from "../scripts/lib/openclaw-clawhub-authorization.mts";
import {
buildOpenClawReleaseClawHubPlan,
buildOpenClawReleaseClawHubRuntimeState,
@@ -97,6 +102,196 @@ function createClawPackBytes(
);
}
describe("ClawHub v2 release authorization", () => {
const toolingSha = "d".repeat(40);
const toolingRef = `release-publish/${toolingSha.slice(0, 12)}-12345`;
const toolingFullRef = `refs/tags/${toolingRef}`;
it("builds the exact ClawHub-compatible inventory digest", () => {
const files = [
{
path: "package/z.txt",
sizeBytes: 3,
sha256: createHash("sha256").update("zzz").digest("hex"),
type: "file",
},
{ path: "package/dir", sizeBytes: 0, type: "directory" },
{
path: "package/a.txt",
sizeBytes: 1,
sha256: createHash("sha256").update("a").digest("hex"),
type: "file",
},
];
const expectedPayload = [
`a.txt\0${files[2].sizeBytes}\0${files[2].sha256}`,
`z.txt\0${files[0].sizeBytes}\0${files[0].sha256}`,
].join("\n");
expect(buildClawHubInventoryDigest(files)).toBe(
createHash("sha256").update(expectedPayload).digest("hex"),
);
});
it("binds candidate, protected tooling, child run, and exact package inventory", () => {
const artifactsDir = makeTempRepoRoot(tempDirs, "clawhub-v2-artifacts-");
const artifactName = "clawhub-package-openclaw-demo-plugin-2026.4.1";
const artifactDir = join(artifactsDir, artifactName);
mkdirSync(artifactDir, { recursive: true });
writeFileSync(
join(artifactDir, "openclaw-demo-plugin-2026.4.1.tgz"),
createClawPackBytes("@openclaw/demo-plugin", "2026.4.1"),
);
const manifest = buildClawHubTransactionManifest({
artifactsDir,
matrix: [
{
artifactName,
packageName: "@openclaw/demo-plugin",
version: "2026.4.1",
},
],
candidateRepository: "openclaw/openclaw",
candidateSha: "a".repeat(40),
childRepository: "openclaw/openclaw",
childRunId: "456",
childRunAttempt: "1",
childRef: toolingRef,
childFullRef: toolingFullRef,
childHeadSha: toolingSha,
toolingRef,
toolingFullRef,
toolingSha,
});
expect(manifest).toMatchObject({
version: 2,
candidateRepository: "openclaw/openclaw",
candidateSha: "a".repeat(40),
childRunId: "456",
childRunAttempt: "1",
childHeadSha: toolingSha,
toolingSha,
packages: [
{
name: "@openclaw/demo-plugin",
version: "2026.4.1",
inventoryDigest: expect.stringMatching(/^[a-f0-9]{64}$/u),
},
],
});
const authorization = buildClawHubParentAuthorization({
manifest,
repository: "openclaw/openclaw",
runId: "123",
runAttempt: "2",
childRunId: "456",
childRunAttempt: "1",
ref: toolingRef,
fullRef: toolingFullRef,
headSha: toolingSha,
});
expect(Object.keys(authorization).sort()).toEqual([
"authorizationRoute",
"candidateRepository",
"candidateSha",
"childFullRef",
"childHeadSha",
"childRef",
"childRepository",
"childRunAttempt",
"childRunId",
"childWorkflow",
"fullRef",
"headSha",
"kind",
"packages",
"ref",
"repository",
"runAttempt",
"runId",
"toolingFullRef",
"toolingRef",
"toolingSha",
"version",
"workflow",
]);
expect(authorization).toMatchObject({
authorizationRoute: "automated-awaited",
kind: "openclaw-clawhub-parent-authorization",
runId: "123",
runAttempt: "2",
packages: manifest.packages,
});
});
it("rejects protected-tag substitution before producing authorization", () => {
const manifest = {
version: 2 as const,
candidateRepository: "openclaw/openclaw",
candidateSha: "a".repeat(40),
childRepository: "openclaw/openclaw",
childWorkflow: ".github/workflows/plugin-clawhub-release.yml" as const,
childRunId: "456",
childRunAttempt: "1",
childRef: toolingRef,
childFullRef: toolingFullRef,
childHeadSha: toolingSha,
toolingRef,
toolingFullRef,
toolingSha,
packages: [
{
name: "@openclaw/demo-plugin",
version: "2026.4.1",
inventoryDigest: "e".repeat(64),
},
],
};
expect(() =>
buildClawHubParentAuthorization({
manifest,
repository: "openclaw/openclaw",
runId: "123",
runAttempt: "2",
childRunId: "456",
childRunAttempt: "1",
ref: toolingRef,
fullRef: `refs/heads/${toolingRef}`,
headSha: toolingSha,
}),
).toThrow("does not match the protected tooling identity");
expect(() =>
buildClawHubParentAuthorization({
manifest,
repository: "openclaw/openclaw",
runId: "123",
runAttempt: "2",
childRunId: "456",
childRunAttempt: "1",
ref: toolingRef,
fullRef: toolingFullRef,
headSha: "b".repeat(40),
}),
).toThrow("does not match the protected tooling identity");
expect(() =>
buildClawHubParentAuthorization({
manifest,
repository: "openclaw/openclaw",
runId: "123",
runAttempt: "2",
childRunId: "789",
childRunAttempt: "1",
ref: toolingRef,
fullRef: toolingFullRef,
headSha: toolingSha,
}),
).toThrow("does not match the protected tooling identity");
});
});
describe("resolveChangedClawHubPublishablePluginPackages", () => {
const publishablePlugins: PublishablePluginPackage[] = [
{
@@ -1249,7 +1444,8 @@ describe("collectPluginClawHubReleasePlan", () => {
});
describe("buildOpenClawReleaseClawHubPlan", () => {
it("emits a dispatch plan that keeps ClawHub children on the release tag", async () => {
it("emits a split-ref plan with protected tooling and a frozen candidate", async () => {
const toolingRef = `release-publish/${"d".repeat(12)}-12345`;
const repoDir = createTempPluginRepo({
extraExtensionIds: ["demo-two", "demo-three"],
});
@@ -1298,11 +1494,11 @@ describe("buildOpenClawReleaseClawHubPlan", () => {
const plan = await buildOpenClawReleaseClawHubPlan(
{
bootstrapWorkflowRef: `release-publish/${"d".repeat(12)}-12345`,
bootstrapWorkflowRef: toolingRef,
bootstrapWorkflowSha: "d".repeat(40),
releaseTag: "v2026.4.1-beta.1",
releaseSha: "a".repeat(40),
releasePublishBranch: "main",
releasePublishBranch: toolingRef,
releasePublishRunAttempt: "2",
releasePublishRunId: "12345",
pluginPublishScope: "all-publishable",
@@ -1317,22 +1513,27 @@ describe("buildOpenClawReleaseClawHubPlan", () => {
expect(plan.clawHubWorkflowRef).toBe("v2026.4.1-beta.1");
expect(plan.bootstrapWorkflowSha).toBe("d".repeat(40));
expect(plan.releasePublishBranch).toBe("main");
expect(plan.releasePublishBranch).toBe(toolingRef);
expect(plan.normal).toEqual({
workflow: "plugin-clawhub-release.yml",
ref: "v2026.4.1-beta.1",
ref: toolingRef,
shouldDispatch: true,
packages: ["@openclaw/demo-plugin"],
inputs: {
publish_scope: "selected",
ref: "a".repeat(40),
release_tag: "v2026.4.1-beta.1",
plugins: "@openclaw/demo-plugin",
release_publish_run_attempt: "2",
release_publish_run_id: "12345",
release_publish_branch: "main",
release_publish_branch: toolingRef,
release_publish_full_ref: `refs/tags/${toolingRef}`,
release_publish_workflow_sha: "d".repeat(40),
},
});
expect(plan.bootstrap).toEqual({
workflow: "plugin-clawhub-new.yml",
ref: `release-publish/${"d".repeat(12)}-12345`,
ref: toolingRef,
shouldDispatch: true,
packages: ["@openclaw/demo-two", "@openclaw/demo-three"],
inputs: {
@@ -1342,7 +1543,7 @@ describe("buildOpenClawReleaseClawHubPlan", () => {
plugins: "@openclaw/demo-two,@openclaw/demo-three",
release_publish_run_attempt: "2",
release_publish_run_id: "12345",
release_publish_branch: "main",
release_publish_branch: toolingRef,
},
});
expect(new Set([...plan.normal.packages, ...plan.bootstrap.packages]).size).toBe(3);
@@ -1360,6 +1561,7 @@ describe("buildOpenClawReleaseClawHubPlan", () => {
});
it("routes already-published packages missing trusted publisher config to bootstrap repair", async () => {
const toolingRef = `release-publish/${"d".repeat(12)}-12345`;
const repoDir = createTempPluginRepo();
const { fetchImpl } = createClawHubPlanFetch({
packages: {
@@ -1386,11 +1588,11 @@ describe("buildOpenClawReleaseClawHubPlan", () => {
const plan = await buildOpenClawReleaseClawHubPlan(
{
bootstrapWorkflowRef: `release-publish/${"d".repeat(12)}-12345`,
bootstrapWorkflowRef: toolingRef,
bootstrapWorkflowSha: "d".repeat(40),
releaseTag: "v2026.4.1-beta.1",
releaseSha: "b".repeat(40),
releasePublishBranch: "release/2026.4.1",
releasePublishBranch: toolingRef,
releasePublishRunAttempt: "3",
releasePublishRunId: "12345",
pluginPublishScope: "selected",
@@ -1406,7 +1608,7 @@ describe("buildOpenClawReleaseClawHubPlan", () => {
expect(plan.normal.shouldDispatch).toBe(false);
expect(plan.bootstrap).toMatchObject({
workflow: "plugin-clawhub-new.yml",
ref: `release-publish/${"d".repeat(12)}-12345`,
ref: toolingRef,
shouldDispatch: true,
packages: ["@openclaw/demo-plugin"],
inputs: {
@@ -1416,7 +1618,7 @@ describe("buildOpenClawReleaseClawHubPlan", () => {
plugins: "@openclaw/demo-plugin",
release_publish_run_attempt: "3",
release_publish_run_id: "12345",
release_publish_branch: "release/2026.4.1",
release_publish_branch: toolingRef,
},
});
expect(plan.summary).toMatchObject({
@@ -1553,7 +1755,7 @@ describe("runPluginClawHubReleaseCheck", () => {
});
describe("buildOpenClawReleaseClawHubRuntimeState", () => {
it("includes the normal ClawHub run in verifier args when the release waits for it", () => {
it("keeps normal ClawHub verification detached from the release parent", () => {
const state = buildOpenClawReleaseClawHubRuntimeState({
repository: "openclaw/openclaw",
waitForClawHub: true,
@@ -1563,9 +1765,9 @@ describe("buildOpenClawReleaseClawHubRuntimeState", () => {
bootstrapCompleted: false,
});
expect(state.verifierArgs).toEqual(["--plugin-clawhub-run", "111"]);
expect(state.verifierArgs).toEqual(["--skip-clawhub"]);
expect(state.proofLines.normal).toBe(
"- plugin ClawHub publish: https://github.com/openclaw/openclaw/actions/runs/111",
"- plugin ClawHub publish: staged; detached verification follows exact parent success: https://github.com/openclaw/openclaw/actions/runs/111",
);
expect(state.proofLines.bootstrap).toBe("- plugin ClawHub bootstrap: not needed");
});
@@ -1599,7 +1801,7 @@ describe("buildOpenClawReleaseClawHubRuntimeState", () => {
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",
"- plugin ClawHub publish: staged; detached verification follows exact parent success: 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",
@@ -1616,9 +1818,9 @@ describe("buildOpenClawReleaseClawHubRuntimeState", () => {
bootstrapCompleted: true,
});
expect(state.verifierArgs).toEqual(["--skip-clawhub", "--plugin-clawhub-bootstrap-run", "222"]);
expect(state.verifierArgs).toEqual(["--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",
"- plugin ClawHub publish: staged; detached verification follows exact parent success: https://github.com/openclaw/openclaw/actions/runs/111",
);
expect(state.proofLines.bootstrap).toBe(
"- plugin ClawHub bootstrap: https://github.com/openclaw/openclaw/actions/runs/222",
@@ -1637,7 +1839,7 @@ describe("buildOpenClawReleaseClawHubRuntimeState", () => {
expect(state.verifierArgs).toEqual(["--skip-clawhub"]);
expect(state.proofLines.normal).toBe(
"- plugin ClawHub publish: https://github.com/openclaw/openclaw/actions/runs/111",
"- plugin ClawHub publish: staged; detached verification follows exact parent success: https://github.com/openclaw/openclaw/actions/runs/111",
);
expect(state.proofLines.bootstrap).toBe(
"- plugin ClawHub bootstrap: https://github.com/openclaw/openclaw/actions/runs/222",
@@ -12,7 +12,7 @@ function runRuntimeStateScript(args: string[]) {
}
describe("scripts/openclaw-release-clawhub-runtime-state.ts", () => {
it("emits verifier args and proof lines for awaited ClawHub runs", () => {
it("keeps normal verification detached while retaining completed bootstrap evidence", () => {
const result = runRuntimeStateScript([
"--repository",
"openclaw/openclaw",
@@ -30,9 +30,10 @@ describe("scripts/openclaw-release-clawhub-runtime-state.ts", () => {
expect(result.status).toBe(0);
expect(JSON.parse(result.stdout)).toEqual({
verifierArgs: ["--plugin-clawhub-run", "123", "--plugin-clawhub-bootstrap-run", "456"],
verifierArgs: ["--plugin-clawhub-bootstrap-run", "456"],
proofLines: {
normal: "- plugin ClawHub publish: https://github.com/openclaw/openclaw/actions/runs/123",
normal:
"- plugin ClawHub publish: staged; detached verification follows exact parent success: https://github.com/openclaw/openclaw/actions/runs/123",
bootstrap:
"- plugin ClawHub bootstrap: https://github.com/openclaw/openclaw/actions/runs/456",
},
+322 -15
View File
@@ -2,6 +2,7 @@
import { execFileSync, spawnSync } from "node:child_process";
import {
chmodSync,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
@@ -516,6 +517,160 @@ function expectTextToIncludeAll(text: string | undefined, snippets: string[]): v
}
}
function runClawHubParentAuthorizationStep(
overrides: {
artifactCount?: number;
manifestChildRunAttempt?: string;
manifestChildRunId?: string;
refShas?: string[];
runAttempt?: number;
runId?: string;
} = {},
) {
const step = workflowStep(
workflowJob(RELEASE_PUBLISH_WORKFLOW, "publish"),
"Prepare ClawHub parent authorization",
);
const script = (step.run ?? "").replaceAll("${GITHUB_WORKSPACE}/.release-harness", REPO_ROOT);
const workdir = tempDirs.make("clawhub-parent-authorization-");
const fakeBin = resolve(workdir, "bin");
const callsPath = resolve(workdir, "gh-calls.jsonl");
const outputPath = resolve(workdir, "github-output");
const summaryPath = resolve(workdir, "github-summary");
const manifestPath = resolve(workdir, "transactions.json");
const planPath = resolve(workdir, "plan.json");
const refPollsPath = resolve(workdir, "ref-polls");
const toolingSha = "d".repeat(40);
const toolingRef = `release-publish/${toolingSha.slice(0, 12)}-12345`;
const childRunId = "456";
const childRunAttempt = "1";
mkdirSync(fakeBin);
writeFileSync(outputPath, "");
writeFileSync(summaryPath, "");
writeFileSync(callsPath, "");
writeFileSync(
planPath,
`${JSON.stringify({
bootstrapWorkflowSha: toolingSha,
normal: {
inputs: {},
ref: toolingRef,
shouldDispatch: true,
workflow: "plugin-clawhub-release.yml",
},
})}\n`,
);
writeFileSync(
manifestPath,
`${JSON.stringify({
version: 2,
candidateRepository: "openclaw/openclaw",
candidateSha: "a".repeat(40),
childRepository: "openclaw/openclaw",
childWorkflow: ".github/workflows/plugin-clawhub-release.yml",
childRunId: overrides.manifestChildRunId ?? childRunId,
childRunAttempt: overrides.manifestChildRunAttempt ?? childRunAttempt,
childRef: toolingRef,
childFullRef: `refs/tags/${toolingRef}`,
childHeadSha: toolingSha,
toolingRef,
toolingFullRef: `refs/tags/${toolingRef}`,
toolingSha,
packages: [
{
name: "@openclaw/demo-plugin",
version: "2026.8.1-beta.3",
inventoryDigest: "e".repeat(64),
},
],
})}\n`,
);
writeFileSync(
resolve(fakeBin, "gh"),
`#!${process.execPath}
const fs = require("node:fs");
const path = require("node:path");
const args = process.argv.slice(2);
fs.appendFileSync(process.env.MOCK_GH_CALLS, JSON.stringify(args) + "\\n");
if (args[0] === "api" && args.some((arg) => arg.includes("/commits/"))) {
const shas = JSON.parse(process.env.MOCK_GH_REF_SHAS);
let index = 0;
try { index = Number(fs.readFileSync(process.env.MOCK_GH_REF_POLLS, "utf8")); } catch {}
fs.writeFileSync(process.env.MOCK_GH_REF_POLLS, String(index + 1));
console.log(shas[Math.min(index, shas.length - 1)]);
} else if (args[0] === "api" && args.includes("--method") && args.includes("POST")) {
console.log(JSON.stringify({ workflow_run_id: Number(process.env.MOCK_GH_CHILD_RUN_ID) }));
} else if (args[0] === "api" && args.some((arg) => arg.includes("/attempts/"))) {
console.log(JSON.stringify({
repository: { full_name: "openclaw/openclaw" },
id: Number(process.env.MOCK_GH_RUN_RESPONSE_ID),
run_attempt: Number(process.env.MOCK_GH_RUN_RESPONSE_ATTEMPT),
path: ".github/workflows/plugin-clawhub-release.yml",
head_branch: process.env.MOCK_GH_TOOLING_REF,
head_sha: process.env.MOCK_GH_TOOLING_SHA,
event: "workflow_dispatch",
}));
} else if (args[0] === "api" && args.some((arg) => arg.includes("/artifacts?name="))) {
console.log(process.env.MOCK_GH_ARTIFACT_COUNT);
} else if (args[0] === "run" && args[1] === "download") {
const dir = args[args.indexOf("--dir") + 1];
fs.mkdirSync(dir, { recursive: true });
fs.copyFileSync(process.env.MOCK_GH_MANIFEST, path.join(dir, "transactions.json"));
} else if (args[0] === "run" && args[1] === "view") {
console.log(JSON.stringify({ status: "in_progress", conclusion: "" }));
} else {
console.error("Unexpected mock gh invocation: " + JSON.stringify(args));
process.exit(64);
}
`,
{ mode: 0o755 },
);
const result = spawnSync("bash", ["-c", script], {
cwd: REPO_ROOT,
encoding: "utf8",
env: {
...process.env,
GH_TOKEN: "test-token",
GITHUB_OUTPUT: outputPath,
GITHUB_REPOSITORY: "openclaw/openclaw",
GITHUB_RUN_ATTEMPT: "2",
GITHUB_RUN_ID: "123",
GITHUB_STEP_SUMMARY: summaryPath,
GITHUB_WORKSPACE: REPO_ROOT,
MOCK_GH_ARTIFACT_COUNT: String(overrides.artifactCount ?? 1),
MOCK_GH_CHILD_RUN_ID: childRunId,
MOCK_GH_CALLS: callsPath,
MOCK_GH_MANIFEST: manifestPath,
MOCK_GH_REF_POLLS: refPollsPath,
MOCK_GH_REF_SHAS: JSON.stringify(overrides.refShas ?? [toolingSha]),
MOCK_GH_RUN_RESPONSE_ATTEMPT: String(overrides.runAttempt ?? 1),
MOCK_GH_RUN_RESPONSE_ID: overrides.runId ?? childRunId,
MOCK_GH_TOOLING_REF: toolingRef,
MOCK_GH_TOOLING_SHA: toolingSha,
PARENT_WORKFLOW_FULL_REF: `refs/tags/${toolingRef}`,
PARENT_WORKFLOW_REF: toolingRef,
PARENT_WORKFLOW_SHA: toolingSha,
PATH: `${fakeBin}:${process.env.PATH}`,
PLAN_PATH: planPath,
RUNNER_TEMP: workdir,
},
});
const authorizationPath = resolve(
workdir,
"openclaw-clawhub-parent-authorization",
"authorization.json",
);
return {
authorization: existsSync(authorizationPath)
? JSON.parse(readFileSync(authorizationPath, "utf8"))
: undefined,
calls: readFileSync(callsPath, "utf8"),
output: readFileSync(outputPath, "utf8"),
result,
};
}
function runFullReleaseChildDispatch(
child: (typeof FULL_RELEASE_CHILD_DISPATCHES)[number],
overrides: Record<string, string> = {},
@@ -1947,8 +2102,12 @@ describe("package acceptance workflow", () => {
expect(publishOrchestration.env?.PARENT_WORKFLOW_SHA).toBe("${{ github.sha }}");
expect(publishOrchestration.env?.CHILD_WORKFLOW_REF).toBe("${{ github.ref_name }}");
expect(readFileSync(RELEASE_PUBLISH_WORKFLOW, "utf8")).toContain(
"otherwise approve and monitor the detached runs separately",
const releasePublishWorkflow = readFileSync(RELEASE_PUBLISH_WORKFLOW, "utf8");
expect(releasePublishWorkflow).toContain(
"normal OIDC publication always stops at staged success and verifies after this exact parent succeeds",
);
expect(releasePublishWorkflow).not.toContain(
"Wait for and auto-approve ClawHub plugin publish",
);
expectTextToIncludeAll(publishOrchestration.run, [
'gh api "repos/${GITHUB_REPOSITORY}/commits/${encoded_workflow_ref}"',
@@ -1960,9 +2119,60 @@ describe("package acceptance workflow", () => {
'wait_for_run android-release.yml "${android_release_run_id}" "${TARGET_SHA}"',
'wait_for_run plugin-npm-release.yml "${plugin_npm_run_id}" "${PARENT_WORKFLOW_SHA}"',
'wait_for_run_background openclaw-npm-release.yml "${openclaw_npm_run_id}" "${PARENT_WORKFLOW_SHA}"',
"plugin-clawhub-release.yml: detached; approval and publish not awaited",
"plugin-clawhub-release.yml: staged; detached verification awaits exact parent success",
"plugin-clawhub-new.yml: detached; approvals and bootstrap not awaited",
'echo "- Normal ClawHub tooling ref: \\`${normal_summary_ref}\\` at \\`${bootstrap_summary_sha}\\`"',
"Normal ClawHub publication: staged under exact tooling; detached verification requires this exact parent attempt to succeed",
"ClawHub bootstrap/repair completion: awaited",
"ClawHub bootstrap/repair completion: detached; monitor that bootstrap run separately",
]);
expect(publishOrchestration.run).not.toContain(
'echo "- Normal ClawHub workflow ref: release tag \\`${RELEASE_TAG}\\`"',
);
expect(publishOrchestration.run).not.toContain("Workflow completion waits for ClawHub");
});
it("executes fail-closed parent authorization against exact child and tooling evidence", () => {
const valid = runClawHubParentAuthorizationStep();
expect(valid.authorization, JSON.stringify(valid, null, 2)).toBeDefined();
expect(valid).toMatchObject({
result: { status: 0 },
authorization: {
childRunId: "456",
childRunAttempt: "1",
runId: "123",
runAttempt: "2",
},
});
});
it.each([
["child run id", { runId: "789" }],
["child run attempt", { runAttempt: 2 }],
["manifest child run id", { manifestChildRunId: "789" }],
["manifest child run attempt", { manifestChildRunAttempt: "2" }],
])("rejects %s substitution before parent authorization", (_label, overrides) => {
const result = runClawHubParentAuthorizationStep(overrides);
expect(result.result.status).not.toBe(0);
expect(result.authorization).toBeUndefined();
});
it("rejects ambiguous transaction artifacts before download", () => {
const result = runClawHubParentAuthorizationStep({ artifactCount: 2 });
expect(result.result.status).not.toBe(0);
expect(result.result.stderr).toContain(
"ClawHub child produced duplicate transaction manifest artifacts.",
);
expect(result.authorization).toBeUndefined();
});
it("rejects protected tooling ref movement before parent authorization", () => {
const result = runClawHubParentAuthorizationStep({
refShas: ["d".repeat(40), "f".repeat(40)],
});
expect(result.result.status).not.toBe(0);
expect(result.result.stderr).toContain("ClawHub tooling ref moved");
expect(result.authorization).toBeUndefined();
});
it("compares dependency evidence zip contents independently of archive timestamps", () => {
@@ -7148,6 +7358,78 @@ describe("package artifact reuse", () => {
]);
});
it("resolves the exact parent attempt for direct ClawHub recovery", () => {
const previewJob = workflowJob(PLUGIN_CLAWHUB_RELEASE_WORKFLOW, "preview_plugins_clawhub");
const resolveAttempt = workflowStep(previewJob, "Resolve release parent attempt");
const workdir = tempDirs.make("clawhub-parent-attempt-");
const binDir = resolve(workdir, "bin");
const outputPath = resolve(workdir, "output");
const callsPath = resolve(workdir, "calls");
mkdirSync(binDir);
writeFileSync(outputPath, "");
writeFileSync(callsPath, "");
writeFileSync(
resolve(binDir, "gh"),
`#!/bin/sh
printf '%s\n' "$*" >> "$MOCK_GH_CALLS"
if [ "$1" = "api" ] &&
[ "$2" = "repos/openclaw/openclaw/actions/runs/456" ] &&
[ "$3" = "--jq" ] &&
[ "$4" = ".run_attempt" ]; then
printf '%s\n' 3
exit 0
fi
exit 64
`,
{ mode: 0o755 },
);
const result = spawnSync("bash", ["-c", resolveAttempt.run ?? ""], {
cwd: REPO_ROOT,
encoding: "utf8",
env: {
...process.env,
GH_TOKEN: "test-token",
GITHUB_ACTOR: "release-maintainer",
GITHUB_OUTPUT: outputPath,
GITHUB_REPOSITORY: "openclaw/openclaw",
MOCK_GH_CALLS: callsPath,
PARENT_RUN_ATTEMPT: "",
PARENT_RUN_ID: "456",
PATH: `${binDir}:${process.env.PATH ?? ""}`,
},
});
expect(result.status, result.stderr).toBe(0);
expect(readFileSync(outputPath, "utf8")).toBe("attempt=3\n");
expect(readFileSync(callsPath, "utf8")).toBe(
"api repos/openclaw/openclaw/actions/runs/456 --jq .run_attempt\n",
);
expect(previewJob.permissions).toMatchObject({ actions: "read", contents: "read" });
expect(previewJob.outputs?.parent_run_attempt).toBe(
"${{ steps.parent_attempt.outputs.attempt }}",
);
expect(
workflowStep(previewJob, "Capture trusted tooling identity").env?.PARENT_RUN_ATTEMPT,
).toBe("${{ steps.parent_attempt.outputs.attempt }}");
expect(
workflowStep(previewJob, "Validate OIDC source matches workflow ref").env
?.RELEASE_PUBLISH_RUN_ATTEMPT,
).toBe("${{ steps.parent_attempt.outputs.attempt }}");
expect(
workflowStep(
workflowJob(PLUGIN_CLAWHUB_RELEASE_WORKFLOW, "validate_release_publish_approval"),
"Validate release publish approval run",
).env?.EXPECTED_RUN_ATTEMPT,
).toBe("${{ needs.preview_plugins_clawhub.outputs.parent_run_attempt }}");
expect(
workflowStep(
workflowJob(PLUGIN_CLAWHUB_RELEASE_WORKFLOW, "verify_published_clawhub_package"),
"Require exact successful release parent attempt",
).env?.PARENT_RUN_ATTEMPT,
).toBe("${{ needs.preview_plugins_clawhub.outputs.parent_run_attempt }}");
});
it("keeps release publication ownership and artifact boundaries wired", () => {
const packageJson = JSON.parse(readFileSync(PACKAGE_JSON, "utf8")) as {
scripts?: Record<string, string>;
@@ -7184,44 +7466,69 @@ describe("package artifact reuse", () => {
workflowStep(releasePublishJob, "Install trusted release tooling dependencies"),
).toBeDefined();
expect(workflowStep(releasePublishJob, "Resolve ClawHub release plan")).toBeDefined();
expect(workflowStep(releasePublishJob, "Prepare ClawHub parent authorization")).toBeDefined();
expect(workflowStep(releasePublishJob, "Upload ClawHub parent authorization")).toBeDefined();
expect(workflowStep(releasePublishJob, "Dispatch publish workflows")).toBeDefined();
expect(clawHubApproval.environment).toBe("clawhub-plugin-release");
expect(clawHubPublish.needs).toEqual([
"preview_plugins_clawhub",
"pack_plugins_clawhub_artifacts",
"aggregate_clawhub_transactions",
"approve_plugins_clawhub_release",
]);
expect(clawHubPublish.uses).toBe(
"openclaw/clawhub/.github/workflows/package-publish.yml@d8096dfc039e86ab942ddf9ef117d04849fd84c1",
"openclaw/clawhub/.github/workflows/package-publish.yml@4bc87f53c8a6eb75317d83aec835b58aa892d11a",
);
expect(clawHubPublish.permissions).toMatchObject({
actions: "read",
contents: "read",
"id-token": "write",
});
expect(clawHubPublish.with?.trusted_tooling_identity_json).toBeUndefined();
expect(clawHubPublish.with).toMatchObject({
wait_for_publication: false,
source_commit: "${{ needs.preview_plugins_clawhub.outputs.ref_revision }}",
source_ref:
"${{ inputs.release_tag != '' && format('refs/tags/{0}', inputs.release_tag) || github.ref }}",
trusted_tooling_identity_json:
"${{ needs.preview_plugins_clawhub.outputs.trusted_tooling_identity_json }}",
});
const clawHubPreview = workflowJob(PLUGIN_CLAWHUB_RELEASE_WORKFLOW, "preview_plugins_clawhub");
expect(
readWorkflow(PLUGIN_CLAWHUB_RELEASE_WORKFLOW).on?.workflow_dispatch?.inputs
?.release_publish_run_attempt,
).toBeUndefined();
).toMatchObject({ required: false, type: "string" });
expect(
readWorkflow(PLUGIN_CLAWHUB_RELEASE_WORKFLOW).on?.workflow_dispatch?.inputs
?.release_publish_full_ref,
).toBeUndefined();
).toMatchObject({ required: false, type: "string" });
expect(
readWorkflow(PLUGIN_CLAWHUB_RELEASE_WORKFLOW).on?.workflow_dispatch?.inputs
?.release_publish_workflow_sha,
).toBeUndefined();
expect(clawHubPreview.outputs?.trusted_tooling_identity_json).toBeUndefined();
const publishOrchestration = workflowStep(releasePublishJob, "Dispatch publish workflows");
expect(publishOrchestration.env?.PARENT_WORKFLOW_FULL_REF).toBeUndefined();
expect(publishOrchestration.run).toContain(
'wait_for_run_background plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "${TARGET_SHA}"',
).toMatchObject({ required: false, type: "string" });
expect(clawHubPreview.outputs?.trusted_tooling_identity_json).toBe(
"${{ steps.tooling_identity.outputs.json }}",
);
expect(publishOrchestration.run).not.toContain("release_publish_full_ref");
expect(publishOrchestration.run).not.toContain("release_publish_workflow_sha");
const publishOrchestration = workflowStep(releasePublishJob, "Dispatch publish workflows");
expect(publishOrchestration.env?.PARENT_WORKFLOW_FULL_REF).toBe("${{ github.ref }}");
expect(publishOrchestration.env?.PREPARED_CLAWHUB_RUN_ID).toBe(
"${{ steps.clawhub_authorization.outputs.normal_run_id }}",
);
expect(publishOrchestration.run).toContain('"Confirm staged ClawHub publication"');
expect(publishOrchestration.run).not.toContain(
"wait_for_run_background plugin-clawhub-release.yml",
);
expect(workflowJob(PLUGIN_CLAWHUB_RELEASE_WORKFLOW, "clawhub_staged").name).toBe(
"Confirm staged ClawHub publication",
);
const detachedVerifier = workflowJob(
PLUGIN_CLAWHUB_RELEASE_WORKFLOW,
"verify_published_clawhub_package",
);
expect(detachedVerifier.needs).toContain("clawhub_staged");
expect(
workflowStep(detachedVerifier, "Require exact successful release parent attempt").run,
).toContain("Exact release parent attempt finished with");
expect(clawHubBootstrapValidation.environment).toBe("clawhub-plugin-bootstrap");
expect(clawHubBootstrapPublish.environment).toBe("clawhub-plugin-bootstrap");