From 43dc8d2bb38fab7f736d03ea8fe4ed92acc20e9c Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 21 Aug 2026 01:36:48 -0700 Subject: [PATCH] fix(release): authorize staged ClawHub publication --- .../workflows/openclaw-release-publish.yml | 216 +++++++-- .github/workflows/plugin-clawhub-release.yml | 362 +++++++++++++- .../lib/openclaw-clawhub-authorization.mts | 454 ++++++++++++++++++ scripts/lib/openclaw-release-clawhub-plan.ts | 38 +- test/plugin-clawhub-release.test.ts | 240 ++++++++- ...claw-release-clawhub-runtime-state.test.ts | 7 +- .../package-acceptance-workflow.test.ts | 337 ++++++++++++- 7 files changed, 1551 insertions(+), 103 deletions(-) create mode 100644 scripts/lib/openclaw-clawhub-authorization.mts diff --git a/.github/workflows/openclaw-release-publish.yml b/.github/workflows/openclaw-release-publish.yml index b3325d9f32e4..4f184bc53501 100644 --- a/.github/workflows/openclaw-release-publish.yml +++ b/.github/workflows/openclaw-release-publish.yml @@ -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 diff --git a/.github/workflows/plugin-clawhub-release.yml b/.github/workflows/plugin-clawhub-release.yml index 142c82687f77..d0f487002ffa 100644 --- a/.github/workflows/plugin-clawhub-release.yml +++ b/.github/workflows/plugin-clawhub-release.yml @@ -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: diff --git a/scripts/lib/openclaw-clawhub-authorization.mts b/scripts/lib/openclaw-clawhub-authorization.mts new file mode 100644 index 000000000000..103ad51dc097 --- /dev/null +++ b/scripts/lib/openclaw-clawhub-authorization.mts @@ -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; + 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(); + 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 } { + const [command, ...rest] = argv; + if (!command) { + throw new Error("Expected transactions or authorization command."); + } + const values = new Map(); + 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 ?? ""}.`); + } + values.set(flag.slice(2), value); + } + return { command, values }; +} + +function required(values: Map, 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)); +} diff --git a/scripts/lib/openclaw-release-clawhub-plan.ts b/scripts/lib/openclaw-release-clawhub-plan.ts index a4620b8cc8ca..b1649cd932c2 100644 --- a/scripts/lib/openclaw-release-clawhub-plan.ts +++ b/scripts/lib/openclaw-release-clawhub-plan.ts @@ -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", diff --git a/test/plugin-clawhub-release.test.ts b/test/plugin-clawhub-release.test.ts index f65e0df6a8e2..39330de5e6e2 100644 --- a/test/plugin-clawhub-release.test.ts +++ b/test/plugin-clawhub-release.test.ts @@ -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", diff --git a/test/scripts/openclaw-release-clawhub-runtime-state.test.ts b/test/scripts/openclaw-release-clawhub-runtime-state.test.ts index dedcf40559b8..d337f77d5e98 100644 --- a/test/scripts/openclaw-release-clawhub-runtime-state.test.ts +++ b/test/scripts/openclaw-release-clawhub-runtime-state.test.ts @@ -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", }, diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index 33c84509832a..7d7752161a47 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -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 = {}, @@ -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; @@ -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");