mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 03:45:46 -06:00
fix(release): authorize staged ClawHub publication
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user