From fa86caf94f64c66c3fed4955cb763ed6c4e80055 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 21 Aug 2026 00:24:31 -0700 Subject: [PATCH] fix(release): keep protected tooling trusted after main moves (#126881) * fix(release): keep protected tooling trusted after main moves * fix(release): cover protected tooling recovery paths * fix(release): honor live tooling contracts * fix(release): revalidate tooling at npm publish * fix(release): bind npm publishers to live tooling * fix(release): preserve trusted dispatch identity * fix(release): revalidate parent authorization * fix(release): bind ClawHub to release parent * docs(release): define frozen tooling identity * test(release): align ClawHub protected dispatch ref * fix(release): trust protected plugin npm preflight tooling * docs(release): scope protected writer guarantees * fix(release): keep protected tooling foundation npm-only * test(release): cover trusted npm preflight tooling --- .agents/skills/release-openclaw-ci/SKILL.md | 22 +- .github/workflows/full-release-validation.yml | 38 +- .github/workflows/openclaw-npm-release.yml | 105 ++- .../workflows/openclaw-release-publish.yml | 65 +- .github/workflows/plugin-npm-release.yml | 110 +++- docs/reference/RELEASING.md | 12 +- docs/reference/full-release-validation.md | 22 +- scripts/full-release-validation-at-sha.mts | 170 ++++- .../find-reusable-release-validation.sh | 128 +++- scripts/openclaw-npm-resume-run.mts | 98 ++- scripts/plugin-npm-publish.sh | 24 + scripts/release-candidate-checklist.mts | 51 ++ scripts/release-ci-summary.mjs | 99 ++- scripts/release-tooling-identity.d.mts | 47 ++ scripts/release-tooling-identity.mjs | 520 +++++++++++++++ ...idate-full-release-validation-evidence.mjs | 50 +- scripts/validate-release-publish-approval.mjs | 20 + .../find-reusable-release-validation.test.ts | 136 +++- .../full-release-validation-at-sha.test.ts | 239 ++++++- test/scripts/openclaw-npm-resume-run.test.ts | 113 +++- .../package-acceptance-workflow.test.ts | 605 +++++++++++++++++- ...lugin-npm-extended-stable-workflow.test.ts | 45 +- test/scripts/plugin-npm-publish.test.ts | 27 +- .../release-candidate-checklist.test.ts | 54 ++ test/scripts/release-ci-summary.test.ts | 108 ++++ test/scripts/release-tooling-identity.test.ts | 361 +++++++++++ ...e-full-release-validation-evidence.test.ts | 72 +++ .../validate-release-publish-approval.test.ts | 26 + 28 files changed, 3119 insertions(+), 248 deletions(-) create mode 100644 scripts/release-tooling-identity.d.mts create mode 100644 scripts/release-tooling-identity.mjs create mode 100644 test/scripts/release-tooling-identity.test.ts diff --git a/.agents/skills/release-openclaw-ci/SKILL.md b/.agents/skills/release-openclaw-ci/SKILL.md index 17d27993542a..d42d3a49c892 100644 --- a/.agents/skills/release-openclaw-ci/SKILL.md +++ b/.agents/skills/release-openclaw-ci/SKILL.md @@ -19,6 +19,9 @@ Use this with `$release-openclaw-maintainer` and `$openclaw-testing` when a rele Tooling SHA + rerun group**. Validation SHA maps to the Code SHA for product validation or the Release SHA for changelog-only validation; it is not a third release identity. A branch or temporary ref is context and transport. +- Freeze the candidate SHA/ref and Tooling SHA/ref once. Main lineage authorizes + the initial Tooling SHA selection; it does not authorize replacing that + tooling after `main` advances. - Apply a release firebreak after the Code SHA is frozen. Admit only confirmed product defects, package/provenance defects in the bytes to publish, security defects, or failures that make publication impossible. Queue other findings @@ -26,6 +29,10 @@ Use this with `$release-openclaw-maintainer` and `$openclaw-testing` when a rele - Use trusted `main` workflow revisions as immutable dispatch sources. Do not adopt newer main code, repair unrelated main CI, wait for broad main health, or expand a release fix because the workflow source lives on `main`. +- Once publication binds the Tooling SHA to an exact protected lightweight + `release-publish/<12sha>-` tag, that live tag-to-SHA mapping + remains authoritative when `main` advances. The suffix records tag-creation + provenance; it is not the current parent run id. - Touch `main` only for an operator-requested change or the smallest critical main-owned blocker that prevents this release and cannot be handled from the release branch. If the required main landing policy is blocked by unrelated @@ -79,9 +86,18 @@ Use this with `$release-openclaw-maintainer` and `$openclaw-testing` when a rele ## Run identity and retry budget -Record Validation SHA, Tooling SHA, target context ref, parent run id, attempt, -and phase before watching or recovering Full Release Validation. Keep Code SHA -and Release SHA separately in the lifecycle ledger. +Record Validation SHA, Tooling SHA/ref, target context ref, parent run id, +attempt, and phase before watching or recovering Full Release Validation. Keep +Code SHA and Release SHA separately in the lifecycle ledger. Record the +immutable Release Publish parent receipt separately from tag provenance. + +For the core and plugin npm mutations enforced by this foundation, re-read the +exact protected lightweight tag and revalidate the exact parent run tuple +immediately before each publish or dist-tag mutation. Reject a missing, moved, +annotated, or wrong-SHA tag; a repository, workflow, run id, attempt, tooling +identity, or parent-state mismatch; and any same-name branch. Never refresh +either identity from current `main`. Treat other privileged writers as blocked +until their dependent enforcement changes land. - Conceptual phases map to current inputs as follows: - `beta-publish`: `release_profile=beta`, `run_release_soak=false` diff --git a/.github/workflows/full-release-validation.yml b/.github/workflows/full-release-validation.yml index 4a56092e8127..dc1a82b8494d 100644 --- a/.github/workflows/full-release-validation.yml +++ b/.github/workflows/full-release-validation.yml @@ -13,6 +13,11 @@ on: required: false default: "" type: string + trusted_workflow_json: + description: Trusted release tooling identity JSON + required: false + default: "" + type: string target_context_ref: description: Optional canonical release branch or tag context for an exact-SHA target required: false @@ -160,7 +165,7 @@ env: # Read retries and one-shot dispatch recovery share this classifier; dispatch POSTs never retry. GH_TRANSIENT_SERVER_OR_NETWORK_PATTERN: "HTTP 5[0-9][0-9]|Server Error|invalid character .* looking for beginning of value|error connecting to|context deadline exceeded|connection reset by peer|connection refused|TLS handshake timeout|i/o timeout|network is unreachable|(^|[^A-Za-z0-9_])EOF([^A-Za-z0-9_]|$)|ETIMEDOUT|ECONNRESET|EAI_AGAIN" NODE_VERSION: "24.16.0" - RELEASE_ISOLATION_TOOLING_CONTRACT: "1" + RELEASE_ISOLATION_TOOLING_CONTRACT: "2" jobs: resolve_target: @@ -169,6 +174,7 @@ jobs: timeout-minutes: 10 outputs: sha: ${{ steps.resolve.outputs.sha }} + trusted_workflow_json: ${{ steps.tooling_identity.outputs.json }} live_suite_filter: ${{ steps.filters.outputs.live_suite_filter }} cross_os_suite_filter: ${{ steps.filters.outputs.cross_os_suite_filter }} steps: @@ -181,6 +187,28 @@ jobs: persist-credentials: false submodules: false + - name: Resolve trusted workflow identity + id: tooling_identity + env: + GH_TOKEN: ${{ github.token }} + REQUESTED_IDENTITY_JSON: ${{ inputs.trusted_workflow_json }} + WORKFLOW_CONTRACT: ${{ env.RELEASE_ISOLATION_TOOLING_CONTRACT }} + WORKFLOW_FULL_REF: ${{ github.ref }} + WORKFLOW_REF: ${{ github.ref_name }} + WORKFLOW_SHA: ${{ github.sha }} + run: | + set -euo pipefail + identity="$( + node workflow/scripts/release-tooling-identity.mjs resolve \ + --repository "$GITHUB_REPOSITORY" \ + --workflow-contract "$WORKFLOW_CONTRACT" \ + --workflow-ref "$WORKFLOW_REF" \ + --workflow-full-ref "$WORKFLOW_FULL_REF" \ + --workflow-sha "$WORKFLOW_SHA" \ + --requested-identity-json "$REQUESTED_IDENTITY_JSON" + )" + echo "json=${identity}" >> "$GITHUB_OUTPUT" + - name: Resolve target SHA id: resolve env: @@ -457,6 +485,7 @@ jobs: SKIP_PACKAGE_TELEGRAM_E2E: ${{ inputs.skip_package_telegram_e2e }} ALLOW_UNRELEASED_CHANGELOG: ${{ inputs.allow_unreleased_changelog || (inputs.target_context_ref == '' && (inputs.ref == 'main' || inputs.ref == 'refs/heads/main')) }} PLUGIN_PRERELEASE_NODE_EXCLUDE_PATTERNS_JSON: ${{ inputs.plugin_prerelease_node_exclude_patterns_json }} + TRUSTED_WORKFLOW_JSON: ${{ needs.resolve_target.outputs.trusted_workflow_json }} run: | set -euo pipefail # Lane-selection inputs must match the prior run's manifest exactly; @@ -492,10 +521,17 @@ jobs: allowUnreleasedChangelog: $allowUnreleasedChangelog, pluginPrereleaseNodeExcludePatternsJson: $pluginPrereleaseNodeExcludePatternsJson }')" + trusted_workflow_json="${TRUSTED_WORKFLOW_JSON}" + trusted_workflow_ref="$(jq -er '.ref | select(type == "string" and length > 0)' <<< "$trusted_workflow_json")" + trusted_workflow_full_ref="$(jq -er '.fullRef | select(type == "string" and length > 0)' <<< "$trusted_workflow_json")" + trusted_workflow_sha="$(jq -er '.sha | select(type == "string" and test("^[0-9a-f]{40}$"))' <<< "$trusted_workflow_json")" bash workflow/scripts/github/find-reusable-release-validation.sh \ --target-sha "$TARGET_SHA" \ --workflow-sha "$GITHUB_SHA" \ --workflow-ref "$WORKFLOW_REF" \ + --trusted-workflow-ref "$trusted_workflow_ref" \ + --trusted-workflow-full-ref "$trusted_workflow_full_ref" \ + --trusted-workflow-sha "$trusted_workflow_sha" \ --release-profile "$RELEASE_PROFILE" \ --run-release-soak "$RUN_RELEASE_SOAK" \ --inputs-json "$inputs_json" \ diff --git a/.github/workflows/openclaw-npm-release.yml b/.github/workflows/openclaw-npm-release.yml index f0c04f2fb0bc..152e53ca7448 100644 --- a/.github/workflows/openclaw-npm-release.yml +++ b/.github/workflows/openclaw-npm-release.yml @@ -33,6 +33,10 @@ 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 plugin_npm_run_id: description: Successful Plugin NPM Release run id for the exact extended-stable branch and release SHA required: false @@ -800,6 +804,7 @@ jobs: - name: Require trusted workflow ref for publish env: + GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ inputs.tag }} RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }} RELEASE_CANDIDATE_BRANCH: ${{ inputs.release_candidate_branch }} @@ -839,9 +844,13 @@ jobs: echo "SHA-pinned release-publish tag does not match the OpenClaw npm workflow SHA." >&2 exit 1 } - timeout --signal=TERM --kill-after=10s 120s git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main - git merge-base --is-ancestor "${WORKFLOW_SHA}" origin/main || { - echo "SHA-pinned OpenClaw npm workflow revision is not reachable from current main." >&2 + workflow_tag="${WORKFLOW_REF#refs/tags/}" + remote_workflow_sha="$( + gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${workflow_tag}" \ + --jq '.object | select(.type == "commit") | .sha | select(test("^[a-f0-9]{40}$"))' + )" + [[ "${remote_workflow_sha}" == "${WORKFLOW_SHA}" ]] || { + echo "SHA-pinned release-publish tag does not resolve to the OpenClaw npm workflow SHA." >&2 exit 1 } sha_pinned_release_publish=true @@ -860,6 +869,7 @@ jobs: FULL_RELEASE_VALIDATION_RUN_ATTEMPT: ${{ inputs.full_release_validation_run_attempt }} PLUGIN_NPM_RUN_ID: ${{ inputs.plugin_npm_run_id }} RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }} + RELEASE_PUBLISH_RUN_ATTEMPT: ${{ inputs.release_publish_run_attempt }} run: | set -euo pipefail if [[ -z "${PREFLIGHT_RUN_ID}" ]]; then @@ -889,11 +899,18 @@ jobs: echo "Workflow-dispatched real publish requires release_publish_run_id from the approved OpenClaw Release Publish workflow." >&2 exit 1 fi + if [[ -n "${RELEASE_PUBLISH_RUN_ID// }" && ! "${RELEASE_PUBLISH_RUN_ATTEMPT}" =~ ^[1-9][0-9]*$ ]]; then + echo "release_publish_run_id requires the exact positive release_publish_run_attempt." >&2 + exit 1 + fi - name: Validate release publish approval run env: GH_TOKEN: ${{ github.token }} RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }} + EXPECTED_RUN_ATTEMPT: ${{ inputs.release_publish_run_attempt }} + EXPECTED_WORKFLOW_FULL_REF: ${{ github.ref }} + EXPECTED_WORKFLOW_SHA: ${{ github.workflow_sha }} EXPECTED_WORKFLOW_BRANCH: ${{ github.ref_name }} run: | set -euo pipefail @@ -910,7 +927,7 @@ jobs: direct_recovery=true echo "Direct OpenClaw npm recovery with release_publish_run_id; relying on this workflow's npm-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}" --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 publish_openclaw_npm: @@ -1024,8 +1041,34 @@ jobs: if [[ "$RELEASE_NPM_DIST_TAG" == "extended-stable" && "$preflight_head_branch" == "$EXPECTED_EXTENDED_STABLE_BRANCH" ]]; then extended_stable_preflight=true fi - if [[ "$preflight_head_branch" != "main" && "refs/heads/${preflight_head_branch}" != "$WORKFLOW_REF" && "$extended_stable_preflight" != "true" ]]; then - echo "OpenClaw npm preflight run must come from main or the active protected release branch." >&2 + active_branch_preflight=false + if [[ "$preflight_head_branch" != release-publish/* && "refs/heads/${preflight_head_branch}" == "$WORKFLOW_REF" ]]; then + active_branch_preflight=true + fi + protected_release_publish_preflight=false + if [[ "$WORKFLOW_REF" =~ ^refs/tags/release-publish/([a-f0-9]{12})-[1-9][0-9]*$ ]]; then + workflow_sha_prefix="${BASH_REMATCH[1]}" + [[ "$WORKFLOW_SHA" =~ ^[a-f0-9]{40}$ && "${WORKFLOW_SHA:0:12}" == "$workflow_sha_prefix" ]] || { + echo "Protected release-publish tag does not match the workflow SHA at the npm publish boundary." >&2 + exit 1 + } + workflow_tag="${WORKFLOW_REF#refs/tags/}" + # The npm-release environment can wait after request validation. + # Re-read the tag here so a later move cannot authorize publication. + remote_workflow_sha="$( + gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${workflow_tag}" \ + --jq '.object | select(.type == "commit") | .sha | select(test("^[a-f0-9]{40}$"))' + )" + [[ "$remote_workflow_sha" == "$WORKFLOW_SHA" ]] || { + echo "Protected release-publish tag moved after npm-release approval." >&2 + exit 1 + } + if [[ "$preflight_head_branch" == "$workflow_tag" && "$preflight_head_sha" == "$WORKFLOW_SHA" ]]; then + protected_release_publish_preflight=true + fi + fi + if [[ "$preflight_head_branch" != "main" && "$active_branch_preflight" != "true" && "$extended_stable_preflight" != "true" && "$protected_release_publish_preflight" != "true" ]]; then + echo "OpenClaw npm preflight run must come from main, the active protected release branch, or the exact protected release-publish tag." >&2 exit 1 fi if ! git -C trusted-workflow cat-file -e "${preflight_head_sha}^{commit}" 2>/dev/null; then @@ -1062,14 +1105,40 @@ jobs: FULL_RELEASE_VALIDATION_RUN_ATTEMPT: ${{ inputs.full_release_validation_run_attempt }} EXPECTED_WORKFLOW_BRANCH: ${{ inputs.release_candidate_branch || github.ref_name }} STRICT_VALIDATOR_FILE: ${{ github.workspace }}/trusted-workflow/scripts/release-ci-summary.mjs + TRUSTED_WORKFLOW_FULL_REF: ${{ github.ref }} + TRUSTED_WORKFLOW_REF: ${{ github.ref_name }} + TRUSTED_WORKFLOW_SHA: ${{ github.workflow_sha }} + WORKFLOW_SHA: ${{ github.workflow_sha }} run: | set -euo pipefail EXPECTED_SHA="$(git rev-parse HEAD)" MANIFEST_FILE="full-release-validation/full-release-validation-manifest.json" export EXPECTED_SHA MANIFEST_FILE - timeout --signal=TERM --kill-after=10s 120s git fetch --filter=blob:none --no-tags origin +refs/heads/main:refs/remotes/origin/main - gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${FULL_RELEASE_VALIDATION_RUN_ID}/attempts/${FULL_RELEASE_VALIDATION_RUN_ATTEMPT}" | \ + trusted_workflow_commit_ref="refs/remotes/origin/main" + if [[ "${TRUSTED_WORKFLOW_FULL_REF}" =~ ^refs/tags/release-publish/[a-f0-9]{12}-[1-9][0-9]*$ ]]; then + trusted_workflow_commit_ref="refs/tags/${TRUSTED_WORKFLOW_REF}" + timeout --signal=TERM --kill-after=10s 120s git fetch --filter=blob:none --no-tags origin \ + "+${TRUSTED_WORKFLOW_FULL_REF}:${trusted_workflow_commit_ref}" + if [[ "$(git rev-parse "${trusted_workflow_commit_ref}^{commit}")" != "${WORKFLOW_SHA}" ]]; then + echo "Trusted release-publish tag moved after workflow dispatch." >&2 + exit 1 + fi + else + timeout --signal=TERM --kill-after=10s 120s git fetch --filter=blob:none --no-tags origin \ + +refs/heads/main:refs/remotes/origin/main + fi + TRUSTED_MAIN_REF="${trusted_workflow_commit_ref}" \ + gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${FULL_RELEASE_VALIDATION_RUN_ID}/attempts/${FULL_RELEASE_VALIDATION_RUN_ATTEMPT}" | \ + TRUSTED_MAIN_REF="${trusted_workflow_commit_ref}" \ node trusted-workflow/scripts/validate-full-release-validation-evidence.mjs + node "$STRICT_VALIDATOR_FILE" \ + --validate-run "$FULL_RELEASE_VALIDATION_RUN_ID" \ + --trusted-workflow-ref "$TRUSTED_WORKFLOW_REF" \ + --trusted-workflow-full-ref "$TRUSTED_WORKFLOW_FULL_REF" \ + --trusted-workflow-sha "$TRUSTED_WORKFLOW_SHA" \ + --json \ + --verifier-source-sha "$WORKFLOW_SHA" \ + --verifier-source-file "$STRICT_VALIDATOR_FILE" >/dev/null - name: Verify plugin npm release run metadata if: ${{ inputs.npm_dist_tag == 'extended-stable' }} @@ -1350,11 +1419,29 @@ jobs: id: publish env: BYPASS_EXTENDED_STABLE_GUARD: ${{ inputs.bypass_extended_stable_guard }} + GH_TOKEN: ${{ github.token }} OPENCLAW_PREPACK_PREPARED: "1" OPENCLAW_NPM_PUBLISH_TAG: ${{ inputs.npm_dist_tag }} PUBLISH_TARBALL_PATH: ${{ steps.preflight_provenance.outputs.tarball_path }} + RELEASE_PUBLISH_PARENT_STATE_POLICY: ${{ inputs.release_publish_run_id != '' && (github.actor == 'github-actions[bot]' && 'active' || 'manual-recovery') || '' }} + RELEASE_PUBLISH_RUN_ATTEMPT: ${{ inputs.release_publish_run_attempt }} + RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }} + WORKFLOW_FULL_REF: ${{ github.ref }} + WORKFLOW_REF: ${{ github.ref_name }} + WORKFLOW_SHA: ${{ github.workflow_sha }} run: | set -euo pipefail + verify_release_tooling_identity() { + node trusted-workflow/scripts/release-tooling-identity.mjs verify \ + --repository "$GITHUB_REPOSITORY" \ + --workflow-ref "$WORKFLOW_REF" \ + --workflow-full-ref "$WORKFLOW_FULL_REF" \ + --workflow-sha "$WORKFLOW_SHA" \ + --release-publish-run-id "$RELEASE_PUBLISH_RUN_ID" \ + --release-publish-run-attempt "$RELEASE_PUBLISH_RUN_ATTEMPT" \ + --release-publish-parent-state-policy "$RELEASE_PUBLISH_PARENT_STATE_POLICY" \ + --allow-prevalidated-ref + } publish_target="${PUBLISH_TARBALL_PATH}" if [[ -n "${publish_target}" ]]; then publish_target="./${publish_target}" @@ -1372,11 +1459,13 @@ jobs: echo "${package_name}@${package_version} is already published; reusing it." return 0 fi + verify_release_tooling_identity bash scripts/openclaw-npm-publish.sh --publish "./${tarball_path}" } while IFS=$'\t' read -r package_name tarball_name; do publish_if_missing "$package_name" "preflight-tarball/$tarball_name" done < <(jq -r '(.corePackageTarballs // [])[] | [.packageName, .tarballName] | @tsv' preflight-tarball/preflight-manifest.json) + verify_release_tooling_identity bash scripts/openclaw-npm-publish.sh --publish "${publish_target}" - name: Verify extended-stable registry readback diff --git a/.github/workflows/openclaw-release-publish.yml b/.github/workflows/openclaw-release-publish.yml index 43453da22484..b3325d9f32e4 100644 --- a/.github/workflows/openclaw-release-publish.yml +++ b/.github/workflows/openclaw-release-publish.yml @@ -219,12 +219,13 @@ jobs: echo "SHA-pinned release publish tag does not match workflow SHA ${WORKFLOW_SHA}." >&2 exit 1 fi - merge_base_sha="$( - gh api "repos/${GITHUB_REPOSITORY}/compare/${WORKFLOW_SHA}...main" \ - --jq '.merge_base_commit.sha | select(test("^[a-f0-9]{40}$"))' + workflow_tag="${WORKFLOW_REF#refs/tags/}" + remote_workflow_sha="$( + gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${workflow_tag}" \ + --jq '.object | select(.type == "commit") | .sha | select(test("^[a-f0-9]{40}$"))' )" - if [[ "${merge_base_sha}" != "${WORKFLOW_SHA}" ]]; then - echo "SHA-pinned release publish tag revision is not reachable from current main." >&2 + if [[ "${remote_workflow_sha}" != "${WORKFLOW_SHA}" ]]; then + echo "SHA-pinned release publish tag does not resolve to workflow SHA ${WORKFLOW_SHA}." >&2 exit 1 fi sha_pinned_release_publish=true @@ -328,6 +329,7 @@ jobs: PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }} RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }} RELEASE_TAG: ${{ inputs.tag }} + WORKFLOW_SHA: ${{ github.workflow_sha }} run: | set -euo pipefail @@ -395,8 +397,19 @@ jobs: if [[ "$RELEASE_NPM_DIST_TAG" == "extended-stable" && "$preflight_head_branch" == "$expected_extended_stable_branch" ]]; then extended_stable_preflight=true fi - if [[ "$preflight_head_branch" != "main" && "refs/heads/${preflight_head_branch}" != "$GITHUB_REF" && "$extended_stable_preflight" != "true" ]]; then - echo "OpenClaw npm preflight run must come from main or the active protected release branch." >&2 + active_branch_preflight=false + if [[ "$preflight_head_branch" != release-publish/* && "refs/heads/${preflight_head_branch}" == "$GITHUB_REF" ]]; then + active_branch_preflight=true + fi + protected_release_publish_preflight=false + if [[ "$GITHUB_REF" =~ ^refs/tags/release-publish/[a-f0-9]{12}-[1-9][0-9]*$ ]]; then + workflow_tag="${GITHUB_REF#refs/tags/}" + if [[ "$preflight_head_branch" == "$workflow_tag" && "$preflight_head_sha" == "$WORKFLOW_SHA" ]]; then + protected_release_publish_preflight=true + fi + fi + if [[ "$preflight_head_branch" != "main" && "$active_branch_preflight" != "true" && "$extended_stable_preflight" != "true" && "$protected_release_publish_preflight" != "true" ]]; then + echo "OpenClaw npm preflight run must come from main, the active protected release branch, or the exact protected release-publish tag." >&2 exit 1 fi if [[ "$preflight_conclusion" != "success" || "$preflight_event" != "workflow_dispatch" || "$preflight_path" != ".github/workflows/openclaw-npm-release.yml" ]]; then @@ -576,8 +589,10 @@ jobs: EXPECTED_SHA: ${{ steps.ref.outputs.sha }} EXPECTED_RELEASE_PROFILE: ${{ inputs.release_profile }} EXPECTED_WORKFLOW_BRANCH: ${{ github.ref_name }} + TRUSTED_WORKFLOW_FULL_REF: ${{ github.ref }} + TRUSTED_WORKFLOW_REF: ${{ github.ref_name }} + TRUSTED_WORKFLOW_SHA: ${{ github.workflow_sha }} RUN_JSON_FILE: ${{ runner.temp }}/full-release-validation-run.json - TRUSTED_MAIN_REF: refs/remotes/origin/main VALIDATOR_FILE: ${{ runner.temp }}/release-validation-tooling/validate-full-release-validation-evidence.mjs STRICT_VALIDATOR_FILE: ${{ runner.temp }}/release-validation-tooling/release-ci-summary.mjs run: | @@ -588,9 +603,30 @@ jobs: ls -la "${RUNNER_TEMP}/full-release-validation-manifest" >&2 || true exit 1 fi - git fetch --no-tags origin \ - +refs/heads/main:refs/remotes/origin/main - MANIFEST_FILE="$manifest" node "$VALIDATOR_FILE" < "$RUN_JSON_FILE" + trusted_workflow_commit_ref="refs/remotes/origin/main" + if [[ "${TRUSTED_WORKFLOW_FULL_REF}" =~ ^refs/tags/release-publish/[a-f0-9]{12}-[1-9][0-9]*$ ]]; then + trusted_workflow_commit_ref="refs/tags/${TRUSTED_WORKFLOW_REF}" + git fetch --no-tags origin \ + "+${TRUSTED_WORKFLOW_FULL_REF}:${trusted_workflow_commit_ref}" + if [[ "$(git rev-parse "${trusted_workflow_commit_ref}^{commit}")" != "${GITHUB_SHA}" ]]; then + echo "Trusted release-publish tag moved after workflow dispatch." >&2 + exit 1 + fi + else + git fetch --no-tags origin \ + +refs/heads/main:refs/remotes/origin/main + fi + TRUSTED_MAIN_REF="${trusted_workflow_commit_ref}" \ + MANIFEST_FILE="$manifest" \ + node "$VALIDATOR_FILE" < "$RUN_JSON_FILE" + node "$STRICT_VALIDATOR_FILE" \ + --validate-run "$FULL_RELEASE_VALIDATION_RUN_ID" \ + --trusted-workflow-ref "$TRUSTED_WORKFLOW_REF" \ + --trusted-workflow-full-ref "$TRUSTED_WORKFLOW_FULL_REF" \ + --trusted-workflow-sha "$TRUSTED_WORKFLOW_SHA" \ + --json \ + --verifier-source-sha "$GITHUB_SHA" \ + --verifier-source-file "$STRICT_VALIDATOR_FILE" >/dev/null workflow_name="$(jq -r '.workflowName // ""' "$manifest")" target_sha="$(jq -r '.targetSha // ""' "$manifest")" @@ -1491,7 +1527,9 @@ jobs: fi resume_state="$(node --import tsx "${GITHUB_WORKSPACE}/.release-harness/scripts/openclaw-npm-resume-run.mts" \ --repo "${GITHUB_REPOSITORY}" \ - --run-id "${OPENCLAW_NPM_RESUME_RUN_ID}")" + --run-id "${OPENCLAW_NPM_RESUME_RUN_ID}" \ + --trusted-workflow-ref "${PARENT_WORKFLOW_BRANCH}" \ + --trusted-workflow-full-ref "${GITHUB_REF}")" resume_url="$(printf '%s' "${resume_state}" | jq -er '.url')" openclaw_npm_expected_workflow_ref="$(printf '%s' "${resume_state}" | jq -er '.workflowRef')" openclaw_npm_expected_workflow_sha="$(printf '%s' "${resume_state}" | jq -er '.workflowSha')" @@ -2235,7 +2273,7 @@ jobs: bootstrap_workflow_sha="$(verify_bootstrap_workflow_sha)" fi - npm_args=(-f publish_scope="${PLUGIN_PUBLISH_SCOPE}" -f ref="${TARGET_SHA}" -f release_publish_run_id="${GITHUB_RUN_ID}") + npm_args=(-f publish_scope="${PLUGIN_PUBLISH_SCOPE}" -f ref="${TARGET_SHA}" -f release_publish_run_id="${GITHUB_RUN_ID}" -f release_publish_run_attempt="${GITHUB_RUN_ATTEMPT}") if [[ -n "${PLUGINS}" ]]; then npm_args+=(-f plugins="${PLUGINS}") fi @@ -2311,6 +2349,7 @@ jobs: -f full_release_validation_run_id="${FULL_RELEASE_VALIDATION_RUN_ID}" \ -f full_release_validation_run_attempt="${FULL_RELEASE_VALIDATION_RUN_ATTEMPT}" \ -f release_publish_run_id="${GITHUB_RUN_ID}" \ + -f release_publish_run_attempt="${GITHUB_RUN_ATTEMPT}" \ -f plugin_sdk_api_acknowledgement="${PLUGIN_SDK_API_ACKNOWLEDGEMENT}" \ -f npm_dist_tag="${RELEASE_NPM_DIST_TAG}")" echo "- OpenClaw npm run ID: \`${openclaw_npm_run_id}\`" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/plugin-npm-release.yml b/.github/workflows/plugin-npm-release.yml index 46348a15d47c..eb4a3ca00b99 100644 --- a/.github/workflows/plugin-npm-release.yml +++ b/.github/workflows/plugin-npm-release.yml @@ -22,6 +22,8 @@ on: - "scripts/lib/actions-artifact-archive.mjs" - "scripts/plugin-npm-publish.sh" - "scripts/plugin-publication-artifact.mjs" + - "scripts/release-tooling-identity.d.mts" + - "scripts/release-tooling-identity.mjs" - "scripts/plugin-npm-release-check.ts" - "scripts/plugin-npm-release-plan.ts" - "scripts/verify-plugin-npm-published-runtime.mts" @@ -47,6 +49,10 @@ 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 preflight_only: description: Prepare and verify immutable plugin npm artifacts without publishing required: true @@ -96,10 +102,38 @@ jobs: fetch-depth: 0 filter: blob:none + - name: Checkout trusted preflight tooling + if: github.event_name == 'workflow_dispatch' && inputs.preflight_only + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + ref: ${{ github.workflow_sha }} + path: .release-tooling + fetch-depth: 1 + sparse-checkout: | + scripts/lib/record-shared.mjs + scripts/release-tooling-identity.mjs + sparse-checkout-cone-mode: false + - name: Resolve checked-out ref id: ref run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + - name: Verify trusted preflight tooling identity + if: github.event_name == 'workflow_dispatch' && inputs.preflight_only + env: + GH_TOKEN: ${{ github.token }} + WORKFLOW_FULL_REF: ${{ github.ref }} + WORKFLOW_REF: ${{ github.ref_name }} + WORKFLOW_SHA: ${{ github.workflow_sha }} + run: | + set -euo pipefail + node .release-tooling/scripts/release-tooling-identity.mjs verify \ + --repository "$GITHUB_REPOSITORY" \ + --workflow-ref "$WORKFLOW_REF" \ + --workflow-full-ref "$WORKFLOW_FULL_REF" \ + --workflow-sha "$WORKFLOW_SHA" + - name: Validate ref is on a trusted publish branch env: NPM_DIST_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.npm_dist_tag || 'default' }} @@ -107,6 +141,7 @@ jobs: TRUSTED_PUBLISHER_PREFLIGHT: ${{ github.event_name == 'workflow_dispatch' && inputs.trusted_publisher_preflight || false }} PUBLISH_SCOPE: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_scope || '' }} RELEASE_PLUGINS: ${{ github.event_name == 'workflow_dispatch' && inputs.plugins || '' }} + RELEASE_PUBLISH_RUN_ATTEMPT: ${{ github.event_name == 'workflow_dispatch' && inputs.release_publish_run_attempt || '' }} RELEASE_PUBLISH_RUN_ID: ${{ github.event_name == 'workflow_dispatch' && inputs.release_publish_run_id || '' }} SOURCE_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }} WORKFLOW_REF: ${{ github.ref }} @@ -118,19 +153,19 @@ jobs: exit 1 fi if [[ "${PREFLIGHT_ONLY}" == "true" ]]; then - if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]] || [[ ! "${WORKFLOW_SHA}" =~ ^[0-9a-fA-F]{40}$ ]]; then - echo "Plugin npm preflight must run from a trusted main workflow revision." >&2 - exit 1 - fi if [[ ! "${SOURCE_REF}" =~ ^[0-9a-fA-F]{40}$ ]] || [[ "$(git rev-parse HEAD)" != "$(git rev-parse "${SOURCE_REF}^{commit}")" ]]; then echo "Plugin npm preflight requires ref to be the exact 40-character source SHA." >&2 exit 1 fi - if [[ -n "${RELEASE_PUBLISH_RUN_ID// }" ]]; then - echo "Plugin npm preflight must not include release_publish_run_id." >&2 + if [[ -n "${RELEASE_PUBLISH_RUN_ID// }" || -n "${RELEASE_PUBLISH_RUN_ATTEMPT// }" ]]; then + echo "Plugin npm preflight must not include a release publish parent run tuple." >&2 exit 1 fi fi + if [[ -n "${RELEASE_PUBLISH_RUN_ID// }" && ! "${RELEASE_PUBLISH_RUN_ATTEMPT}" =~ ^[1-9][0-9]*$ ]]; then + echo "release_publish_run_id requires the exact positive release_publish_run_attempt." >&2 + exit 1 + fi if [[ "${NPM_DIST_TAG}" == "extended-stable" ]]; then if [[ "${PUBLISH_SCOPE}" != "all-publishable" || -n "${RELEASE_PLUGINS// }" ]]; then echo "Extended-stable plugin publication requires publish_scope=all-publishable without an explicit plugin list." >&2 @@ -158,10 +193,6 @@ jobs: timeout --signal=TERM --kill-after=10s 120s git fetch --no-tags origin \ +refs/heads/main:refs/remotes/origin/main \ '+refs/heads/release/*:refs/remotes/origin/release/*' - if [[ "${PREFLIGHT_ONLY}" == "true" ]] && ! git merge-base --is-ancestor "${WORKFLOW_SHA}" origin/main; then - echo "Plugin npm preflight workflow revision is not reachable from main." >&2 - exit 1 - fi if git merge-base --is-ancestor HEAD origin/main; then exit 0 fi @@ -307,6 +338,9 @@ jobs: env: GH_TOKEN: ${{ github.token }} RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }} + EXPECTED_RUN_ATTEMPT: ${{ inputs.release_publish_run_attempt }} + EXPECTED_WORKFLOW_FULL_REF: ${{ github.ref }} + EXPECTED_WORKFLOW_SHA: ${{ github.workflow_sha }} EXPECTED_WORKFLOW_BRANCH: ${{ github.ref_name }} run: | set -euo pipefail @@ -323,7 +357,7 @@ jobs: direct_recovery=true echo "Direct Plugin NPM Release recovery with release_publish_run_id; relying on this workflow's npm-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}" --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 preview_plugin_pack: @@ -1103,6 +1137,9 @@ jobs: PACKAGE_NAME: ${{ matrix.plugin.packageName }} PACKAGE_VERSION: ${{ matrix.plugin.version }} PUBLISH_TAG: ${{ matrix.plugin.publishTag }} + RELEASE_PUBLISH_PARENT_STATE_POLICY: ${{ inputs.release_publish_run_id != '' && (github.actor == 'github-actions[bot]' && 'active' || 'manual-recovery') || '' }} + RELEASE_PUBLISH_RUN_ATTEMPT: ${{ inputs.release_publish_run_attempt }} + RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }} TARGET_SHA: ${{ needs.preview_plugins_npm.outputs.ref_revision }} WORKFLOW_HEAD_BRANCH: ${{ github.ref_name }} WORKFLOW_REF: ${{ github.ref }} @@ -1161,24 +1198,14 @@ jobs: exit 1 } if [[ "$publish_route" == "npm-token-bootstrap" ]]; then - sha_pinned_release_publish=false - if [[ "$WORKFLOW_REF" =~ ^refs/tags/release-publish/([a-f0-9]{12})-[1-9][0-9]*$ ]]; then - workflow_sha_prefix="${BASH_REMATCH[1]}" - [[ "$WORKFLOW_SHA" =~ ^[a-f0-9]{40}$ && "${WORKFLOW_SHA:0:12}" == "$workflow_sha_prefix" ]] || { - echo "npm token bootstrap release-publish tag does not match the workflow SHA." >&2 - exit 1 - } - sha_pinned_release_publish=true - fi - [[ "$WORKFLOW_REF" == "refs/heads/main" || "$sha_pinned_release_publish" == "true" ]] || { - echo "npm token bootstrap requires trusted main tooling or a protected SHA-pinned release-publish tag." >&2 - exit 1 - } - timeout --signal=TERM --kill-after=10s 120s git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main - git merge-base --is-ancestor "$WORKFLOW_SHA" origin/main || { - echo "npm token bootstrap workflow revision is not reachable from current main." >&2 - exit 1 - } + node scripts/release-tooling-identity.mjs verify \ + --repository "$GITHUB_REPOSITORY" \ + --workflow-ref "$WORKFLOW_HEAD_BRANCH" \ + --workflow-full-ref "$WORKFLOW_REF" \ + --workflow-sha "$WORKFLOW_SHA" \ + --release-publish-run-id "$RELEASE_PUBLISH_RUN_ID" \ + --release-publish-run-attempt "$RELEASE_PUBLISH_RUN_ATTEMPT" \ + --release-publish-parent-state-policy "$RELEASE_PUBLISH_PARENT_STATE_POLICY" fi artifact_id="$(jq -er '.id' "$artifact_metadata")" artifact_digest="$(jq -er '.digest' "$artifact_metadata")" @@ -1307,8 +1334,18 @@ jobs: - name: Publish with trusted publisher if: steps.publication_evidence.outputs.publish_route == 'npm-oidc' && steps.npm_package_version.outputs.already_published != 'true' env: + GH_TOKEN: ${{ github.token }} OPENCLAW_NPM_PUBLISH_AUTH_MODE: trusted-publisher OPENCLAW_PLUGIN_NPM_PUBLISH_TAG: ${{ inputs.npm_dist_tag == 'extended-stable' && inputs.npm_dist_tag || '' }} + OPENCLAW_RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }} + OPENCLAW_RELEASE_PUBLISH_RUN_ATTEMPT: ${{ inputs.release_publish_run_attempt }} + OPENCLAW_RELEASE_PUBLISH_PARENT_STATE_POLICY: ${{ inputs.release_publish_run_id != '' && (github.actor == 'github-actions[bot]' && 'active' || 'manual-recovery') || '' }} + OPENCLAW_RELEASE_TOOLING_ALLOW_PREVALIDATED_REF: "true" + OPENCLAW_RELEASE_TOOLING_FULL_REF: ${{ github.ref }} + OPENCLAW_RELEASE_TOOLING_IDENTITY_REQUIRED: "true" + OPENCLAW_RELEASE_TOOLING_REF: ${{ github.ref_name }} + OPENCLAW_RELEASE_TOOLING_REPOSITORY: ${{ github.repository }} + OPENCLAW_RELEASE_TOOLING_SHA: ${{ github.workflow_sha }} run: bash scripts/plugin-npm-publish.sh --repo-root .publication-target --publish "${{ matrix.plugin.packageDir }}" - name: Verify OIDC published runtime @@ -1368,12 +1405,19 @@ jobs: - name: Publish approved bootstrap tarball if: steps.publication_evidence.outputs.publish_route == 'npm-token-bootstrap' && steps.bootstrap_npm_package_version.outputs.already_published != 'true' env: + GH_TOKEN: ${{ github.token }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} PACKAGE_DIR: ${{ matrix.plugin.packageDir }} PACKAGE_NAME: ${{ steps.publication_evidence.outputs.package_name }} PACKAGE_VERSION: ${{ steps.publication_evidence.outputs.package_version }} PUBLISH_TAG: ${{ steps.publication_evidence.outputs.publish_tag }} + RELEASE_PUBLISH_PARENT_STATE_POLICY: ${{ inputs.release_publish_run_id != '' && (github.actor == 'github-actions[bot]' && 'active' || 'manual-recovery') || '' }} + RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }} + RELEASE_PUBLISH_RUN_ATTEMPT: ${{ inputs.release_publish_run_attempt }} TARBALL_PATH: ${{ steps.publication_evidence.outputs.tarball_path }} + WORKFLOW_FULL_REF: ${{ github.ref }} + WORKFLOW_REF: ${{ github.ref_name }} + WORKFLOW_SHA: ${{ github.workflow_sha }} run: | set -euo pipefail [[ "$PACKAGE_VERSION" == *"-beta."* && "$PUBLISH_TAG" == "beta" ]] || { @@ -1399,6 +1443,14 @@ jobs: unset NODE_AUTH_TOKEN NPM_TOKEN NODE_OPTIONS # A timeout can race a committed publish. On rerun, the preceding check # accepts only this tarball's exact integrity and shasum before skipping. + node scripts/release-tooling-identity.mjs verify \ + --repository "$GITHUB_REPOSITORY" \ + --workflow-ref "$WORKFLOW_REF" \ + --workflow-full-ref "$WORKFLOW_FULL_REF" \ + --workflow-sha "$WORKFLOW_SHA" \ + --release-publish-run-id "$RELEASE_PUBLISH_RUN_ID" \ + --release-publish-run-attempt "$RELEASE_PUBLISH_RUN_ATTEMPT" \ + --release-publish-parent-state-policy "$RELEASE_PUBLISH_PARENT_STATE_POLICY" HOME="$publish_home" \ NPM_CONFIG_GLOBALCONFIG=/dev/null \ NPM_CONFIG_IGNORE_SCRIPTS=true \ diff --git a/docs/reference/RELEASING.md b/docs/reference/RELEASING.md index fb23cbd9e11f..74bbb84ecf46 100644 --- a/docs/reference/RELEASING.md +++ b/docs/reference/RELEASING.md @@ -230,7 +230,7 @@ This checklist is the public shape of the release flow. Private credentials, sig 1. Start from current `main`: pull latest, confirm the target commit is pushed, and confirm `main` CI is green enough to branch from. 2. Create `release/YYYY.M.PATCH` from that commit. Backports are optional; apply only the operator-selected set. Bump every required version location, run `pnpm release:prep`, finish release fixes and required forward-ports, and review `src/plugins/compat/registry.ts` plus `src/commands/doctor/shared/deprecation-compat.ts`. -3. Freeze the product-complete pre-changelog commit as the **Code SHA** and record the trusted **Tooling SHA**. Run the deterministic source preflight, then use `node scripts/full-release-validation-at-sha.mjs --sha --target-ref release/YYYY.M.PATCH --workflow-sha `. Reuse that exact Tooling SHA for later release validation; never refresh it from moving `main`. Beta-publish uses `release_profile=beta` without soak; postpublish-confidence owns broad live, QA-live, mobile, and Parallels work. +3. Freeze the product-complete pre-changelog commit and target context as the **Code SHA/ref**, and record the trusted **Tooling SHA/ref**. Run the deterministic source preflight, then use `node scripts/full-release-validation-at-sha.mjs --sha --target-ref release/YYYY.M.PATCH --workflow-sha `. Reuse those exact identities for later release validation; never refresh the tooling from moving `main`. Beta-publish uses `release_profile=beta` without soak; postpublish-confidence owns broad live, QA-live, mobile, and Parallels work. 4. Classify failures before editing as product, harness/tooling/provenance, infrastructure/credential, or wrapper. Only confirmed product failure creates a new Code SHA. Use one diagnosis, one fix when needed, and one narrow retry, then reassess. 5. Only after the Code SHA is green, generate the top `CHANGELOG.md` section from merged PRs and direct commits since the last reachable shipped tag. Keep entries user-facing and deduplicated. When a divergent shipped tag or later forward-port re-associates already-released PRs, pass it explicitly as `--shipped-ref`. 6. Commit only `CHANGELOG.md`. This commit is the **Release SHA**. The complete diff from Code SHA to Release SHA must be exactly `CHANGELOG.md`; any other changed path returns the release to step 2. @@ -252,7 +252,7 @@ This checklist is the public shape of the release flow. Private credentials, sig `pnpm release:candidate` validates the current frozen branch tip by default (or the explicit `--target-sha`), and rejects a tag that already exists. It records evidence before the final signed tag is pushed. - `OpenClaw Release Publish` dispatches the selected or all-publishable plugin packages to npm and the same set to ClawHub in parallel, then promotes the prepared OpenClaw npm preflight artifact with the matching dist-tag once plugin npm publish succeeds. It keeps the GitHub release as a draft while it verifies registry readback, calls `Docker Release` with the immutable tag and Release SHA, and only then finalizes the GitHub release. The release checkout remains the product/data root, while planning and final verification execute from the exact trusted workflow-source checkout so an older release commit cannot silently use obsolete release tooling. Before any publish child starts, it renders and caches the exact GitHub release body. When the complete matching `CHANGELOG.md` section fits GitHub's 125,000-character limit and the renderer's matching 125,000-byte safety ceiling, the page contains that exact `## YYYY.M.PATCH` section including its heading. When the source section does not fit, the page keeps the exact grouped editorial notes and replaces the oversized contribution record with a stable link to the full record in the tag-pinned `CHANGELOG.md`; partial records and truncated bullets are never published. The workflow chooses that full or compact body before adding `### Release verification`; if the proof tail would exceed the limit, it keeps the canonical body and relies on the immutable attached evidence instead. Stable releases published to npm `latest` become the GitHub latest release, while stable maintenance releases kept on npm `beta` are created with GitHub `latest=false`. The workflow also uploads the preflight dependency evidence, the full-validation manifest, and postpublish registry verification evidence to the GitHub release for post-release incident response. It prints child run IDs immediately, auto-approves release environment gates the workflow token is allowed to approve, summarizes failed child jobs with log tails, creates the draft GitHub release page up front and promotes Windows and Android assets concurrently with the OpenClaw npm publish, waits for ClawHub whenever OpenClaw npm is being published, then runs the trusted-main beta verifier and uploads postpublish evidence for the GitHub release, npm package, selected plugin npm packages, selected ClawHub packages, child workflow run IDs, and optional NPM Telegram run ID. The ClawHub bootstrap verifier requires the exact trusted-main workflow path and SHA, producer and terminal run attempts, release SHA, requested package set, immutable package artifact tuple, and terminal registry readback artifact; a successful legacy release-ref run is not accepted. + `OpenClaw Release Publish` dispatches the selected or all-publishable plugin packages to npm and the same set to ClawHub in parallel, then promotes the prepared OpenClaw npm preflight artifact with the matching dist-tag once plugin npm publish succeeds. It keeps the GitHub release as a draft while it verifies registry readback, calls `Docker Release` with the immutable tag and Release SHA, and only then finalizes the GitHub release. The release checkout remains the product/data root, while planning and final verification execute from the exact trusted workflow-source checkout so an older release commit cannot silently use obsolete release tooling. Once publication binds the frozen Tooling SHA to an exact protected lightweight `release-publish/<12sha>-` tag, that live tag-to-SHA mapping remains authoritative when `main` advances; the suffix records tag-creation provenance, not the current parent run id. Core and plugin npm publishers re-read that exact tag and revalidate the exact parent run tuple immediately before each npm publish or dist-tag mutation, failing closed on a missing, moved, annotated, or wrong-SHA tag, parent mismatch, or disallowed parent state. Other privileged writers require their dependent enforcement changes before the protected-tag publication route is globally complete. Before any publish child starts, it renders and caches the exact GitHub release body. When the complete matching `CHANGELOG.md` section fits GitHub's 125,000-character limit and the renderer's matching 125,000-byte safety ceiling, the page contains that exact `## YYYY.M.PATCH` section including its heading. When the source section does not fit, the page keeps the exact grouped editorial notes and replaces the oversized contribution record with a stable link to the full record in the tag-pinned `CHANGELOG.md`; partial records and truncated bullets are never published. The workflow chooses that full or compact body before adding `### Release verification`; if the proof tail would exceed the limit, it keeps the canonical body and relies on the immutable attached evidence instead. Stable releases published to npm `latest` become the GitHub latest release, while stable maintenance releases kept on npm `beta` are created with GitHub `latest=false`. The workflow also uploads the preflight dependency evidence, the full-validation manifest, and postpublish registry verification evidence to the GitHub release for post-release incident response. It prints child run IDs immediately, auto-approves release environment gates the workflow token is allowed to approve, summarizes failed child jobs with log tails, creates the draft GitHub release page up front and promotes Windows and Android assets concurrently with the OpenClaw npm publish, waits for ClawHub whenever OpenClaw npm is being published, then runs the trusted-main beta verifier and uploads postpublish evidence for the GitHub release, npm package, selected plugin npm packages, selected ClawHub packages, child workflow run IDs, and optional NPM Telegram run ID. The ClawHub bootstrap verifier requires the exact trusted-main workflow path and SHA, producer and terminal run attempts, release SHA, requested package set, immutable package artifact tuple, and terminal registry readback artifact; a successful legacy release-ref run is not accepted. Then run the post-publish package acceptance against the published `openclaw@YYYY.M.PATCH-beta.N` or `openclaw@beta` package. If a pushed or published prerelease needs a fix, cut the next matching prerelease number; never delete or rewrite the old one. @@ -388,6 +388,14 @@ workflow itself never writes repository refs. Tideclaw alpha validation remains on its matching alpha branch and exact alpha tag rather than a regular `release/*` context. +That current-`main` lineage check authorizes the initial validation tooling +selection only. It is not permission to choose newer tooling after the +candidate SHA/ref and Tooling SHA/ref are frozen. Once publication binds the +Tooling SHA to the protected lightweight `release-publish/*` tag, the exact live +tag-to-SHA mapping and exact parent run tuple authorize the npm mutations +enforced by this foundation even if `main` has advanced. Other privileged +writers remain blocked until their dependent enforcement changes land. + After the Code SHA is green, commit only `CHANGELOG.md` and run the same helper with the Release SHA: ```bash diff --git a/docs/reference/full-release-validation.md b/docs/reference/full-release-validation.md index 685eee868bb8..447882446dcb 100644 --- a/docs/reference/full-release-validation.md +++ b/docs/reference/full-release-validation.md @@ -14,8 +14,9 @@ whole release. Run release preparation before freezing the Code SHA; it refreshes Control UI locale output when the background bot has not landed it yet, then enforces the same strict zero-fallback check used by release CI. -Freeze the product-complete pre-changelog commit as the **Code SHA** and select -one trusted workflow commit as the **Tooling SHA**, then run: +Freeze the product-complete pre-changelog commit and its target context as the +**Code SHA/ref**, and select one trusted workflow commit and context as the +**Tooling SHA/ref**, then run: ```bash TOOLING_SHA="" @@ -25,8 +26,10 @@ pnpm ci:full-release \ --workflow-sha "$TOOLING_SHA" ``` -Record the Tooling SHA once for the release and reuse it for later Code-SHA, -Release-SHA, and focused reruns. Do not refresh it from moving `main`. +Record the candidate SHA/ref and Tooling SHA/ref once for the release and reuse +them for later Code-SHA, Release-SHA, and focused reruns. Main lineage +authorizes the initial Tooling SHA selection; it does not authorize refreshing +the tooling from moving `main`. `provider` also accepts `anthropic` or `minimax` for cross-OS onboarding and the end-to-end agent turn. Regular `release/*` targets accept only the branch's final @@ -53,6 +56,17 @@ not declare the current release-isolation contract or the `expected_sha` dispatch input; it never silently substitutes newer tooling. The workflow never creates or updates repository refs itself. +The main-lineage requirement above applies to the initial validation tooling +selection. Once release publication binds that Tooling SHA to an exact protected +lightweight `release-publish/<12sha>-` tag, the live tag-to-SHA +mapping remains authoritative even when `main` advances. The suffix records +tag-creation provenance, not the current parent run id. Publication must re-read +that exact tag and revalidate the exact parent run tuple immediately before each +core or plugin npm publish or dist-tag mutation. A missing, moved, annotated, or +wrong-SHA tag, parent mismatch, or disallowed parent state fails closed. Other +privileged writers require their dependent enforcement changes before the +protected-tag publication route is globally complete. + ## Extended-stable exception Extended-stable publish requires a run whose workflow and target are both the diff --git a/scripts/full-release-validation-at-sha.mts b/scripts/full-release-validation-at-sha.mts index a9636209c6de..0fa14526230b 100644 --- a/scripts/full-release-validation-at-sha.mts +++ b/scripts/full-release-validation-at-sha.mts @@ -15,7 +15,7 @@ import { execGhRead } from "./lib/plain-gh.mjs"; const WORKFLOW = "full-release-validation.yml"; const TRUSTED_WORKFLOW_PATH = `.github/workflows/${WORKFLOW}`; -const RELEASE_ISOLATION_TOOLING_CONTRACT = "1"; +const RELEASE_ISOLATION_TOOLING_CONTRACT = "2"; const RELEASE_ISOLATION_TOOLING_CONTRACT_ENV = "RELEASE_ISOLATION_TOOLING_CONTRACT"; const RELEASE_EVIDENCE_VERIFIER_PATHS = [ "scripts/release-ci-summary.mjs", @@ -37,6 +37,7 @@ const RELEASE_CONTEXT_BRANCH_PATTERN = /^(?:release\/[0-9]{4}\.(?:[1-9]|1[0-2])\.[1-9][0-9]*|extended-stable\/[0-9]{4}\.(?:[1-9]|1[0-2])\.33)$/u; const RELEASE_TAG_PATTERN = /^v([0-9]{4}\.(?:[1-9]|1[0-2])\.[1-9][0-9]*(?:-(?:alpha|beta)\.[1-9][0-9]*)?)$/u; +const TRUSTED_WORKFLOW_TAG_PATTERN = /^release-publish\/([a-f0-9]{12})-[1-9][0-9]*$/u; const SHA_PATTERN = /^[a-f0-9]{40}$/u; const RERUN_GROUPS = new Set([ "all", @@ -72,6 +73,10 @@ type TemporaryRefParams = { parentConclusion: string; evidenceVerified: boolean; }; +type TrustedWorkflowHarness = { + contract: "1" | "2"; + verifierPath: string; +}; function stringValue(value: unknown, fallback = ""): string { return typeof value === "string" ? value : fallback; @@ -85,7 +90,7 @@ function displayValue(value: unknown): string { } function usage() { - console.error(`Usage: node scripts/full-release-validation-at-sha.mjs [--sha ] [--target-ref ] [--workflow-sha ] [--keep-branch] [--dry-run] [-- -f key=value ...] + console.error(`Usage: node scripts/full-release-validation-at-sha.mjs [--sha ] [--target-ref ] [--workflow-sha ] [--trusted-workflow-ref ] [--keep-branch] [--dry-run] [-- -f key=value ...] Creates temporary remote branches pinned to the exact Tooling SHA and Validation SHA, dispatches Full Release Validation with the full Validation SHA as its ref input @@ -140,6 +145,7 @@ export function parseArgs(argv: string[]) { const args = { sha: "", targetRef: "", + trustedWorkflowRef: "main", workflowSha: "", keepBranch: false, dryRun: false, @@ -162,6 +168,11 @@ export function parseArgs(argv: string[]) { i += 1; continue; } + if (arg === "--trusted-workflow-ref") { + args.trustedWorkflowRef = readOptionValue(argv, i, arg); + i += 1; + continue; + } if (arg === "--target-ref") { args.targetRef = readOptionValue(argv, i, arg); i += 1; @@ -243,6 +254,9 @@ export function parseArgs(argv: string[]) { if (Object.hasOwn(args.inputs, "expected_sha")) { throw new Error("SHA-pinned release validation reserves expected_sha for the resolved --sha"); } + if (Object.hasOwn(args.inputs, "trusted_workflow_json")) { + throw new Error("SHA-pinned release validation reserves trusted_workflow_json"); + } if ( args.targetRef && !RELEASE_CONTEXT_BRANCH_PATTERN.test(args.targetRef) && @@ -250,6 +264,19 @@ export function parseArgs(argv: string[]) { ) { throw new Error("--target-ref must be a canonical OpenClaw release branch or tag"); } + if ( + args.trustedWorkflowRef !== "main" && + !TRUSTED_WORKFLOW_TAG_PATTERN.test(args.trustedWorkflowRef) + ) { + throw new Error( + "--trusted-workflow-ref must be main or a protected release-publish/<12hex>- tag", + ); + } + if (args.trustedWorkflowRef !== "main" && !SHA_PATTERN.test(args.workflowSha.toLowerCase())) { + throw new Error( + "protected release-publish workflow refs require --workflow-sha with an explicit full Tooling SHA", + ); + } if ( RELEASE_CONTEXT_BRANCH_PATTERN.test(args.targetRef) && !SHA_PATTERN.test(args.workflowSha.toLowerCase()) @@ -396,22 +423,53 @@ export function releaseProfileForTarget( return releaseProfileForVersion(targetVersionForTarget(targetSha, readPackageJson)); } -function resolveTrustedWorkflowSha(requestedSha: string) { - run("git", ["fetch", "--no-tags", "origin", "refs/heads/main:refs/remotes/origin/main"], { - stdio: "inherit", - }); - const workflowSha = resolveSha(requestedSha || "origin/main"); - const ancestry = runStatus("git", [ - "merge-base", - "--is-ancestor", - workflowSha, - "refs/remotes/origin/main", - ]); - if (ancestry.status !== 0) { +export function verifyTrustedWorkflowRef( + workflowSha: string, + trustedWorkflowRef: string, + resolveRemoteTagSha: (tag: string) => string = (tag) => + run("git", ["ls-remote", "--tags", "origin", `refs/tags/${tag}`]).split(/\s+/u)[0] ?? "", + isMainAncestor: (sha: string) => boolean = (sha) => + runStatus("git", ["merge-base", "--is-ancestor", sha, "refs/remotes/origin/main"]).status === 0, +) { + if (trustedWorkflowRef === "main") { + if (!isMainAncestor(workflowSha)) { + throw new Error( + `Workflow SHA ${workflowSha} is not reachable from current origin/main; refusing an untrusted release harness.`, + ); + } + return; + } + + const tagMatch = trustedWorkflowRef.match(TRUSTED_WORKFLOW_TAG_PATTERN); + if (!tagMatch) { throw new Error( - `Workflow SHA ${workflowSha} is not reachable from current origin/main; refusing an untrusted release harness.`, + "trusted workflow ref must be main or a protected release-publish/<12hex>- tag", ); } + if (workflowSha.slice(0, 12) !== tagMatch[1]) { + throw new Error( + `Trusted workflow tag ${trustedWorkflowRef} does not match Tooling SHA ${workflowSha}`, + ); + } + const remoteTagSha = resolveRemoteTagSha(trustedWorkflowRef); + if (!remoteTagSha) { + throw new Error(`Trusted workflow tag ${trustedWorkflowRef} does not exist on origin`); + } + if (remoteTagSha.toLowerCase() !== workflowSha.toLowerCase()) { + throw new Error( + `Trusted workflow tag ${trustedWorkflowRef} resolves to ${remoteTagSha}, expected ${workflowSha}`, + ); + } +} + +function resolveTrustedWorkflowSha(requestedSha: string, trustedWorkflowRef: string) { + if (trustedWorkflowRef === "main") { + run("git", ["fetch", "--no-tags", "origin", "refs/heads/main:refs/remotes/origin/main"], { + stdio: "inherit", + }); + } + const workflowSha = resolveSha(requestedSha || "origin/main"); + verifyTrustedWorkflowRef(workflowSha, trustedWorkflowRef); return workflowSha; } @@ -557,15 +615,29 @@ export function releaseEvidenceVerificationArgs( parentRunId: unknown, verifierSourceSha: string, verifierSourceFile: string, + trustedWorkflowRef = "main", ) { if (!/^[1-9][0-9]*$/u.test(String(parentRunId))) { throw new Error("parent run ID must be a positive decimal"); } + const trustedWorkflowFullRef = + trustedWorkflowRef === "main" + ? "refs/heads/main" + : TRUSTED_WORKFLOW_TAG_PATTERN.test(trustedWorkflowRef) + ? `refs/tags/${trustedWorkflowRef}` + : ""; + if (!trustedWorkflowFullRef) { + throw new Error("trusted workflow ref must be main or a protected release-publish tag"); + } return [ "--validate-run", String(parentRunId), "--trusted-workflow-ref", - "main", + trustedWorkflowRef, + "--trusted-workflow-full-ref", + trustedWorkflowFullRef, + "--trusted-workflow-sha", + verifierSourceSha, "--json", "--verifier-source-sha", verifierSourceSha, @@ -589,7 +661,7 @@ export function assertTrustedWorkflowHarness( }).status === 0, readPath: (relativePath: string) => string = (relativePath) => run("git", ["show", `${workflowSha}:${relativePath}`]), -) { +): TrustedWorkflowHarness { if (!pathExists(TRUSTED_WORKFLOW_PATH)) { throw new Error( `trusted workflow SHA ${workflowSha} does not contain ${TRUSTED_WORKFLOW_PATH}`, @@ -604,23 +676,33 @@ export function assertTrustedWorkflowHarness( { cause: error }, ); } - if ( - !isJsonRecord(workflow) || - !isJsonRecord(workflow.env) || - workflow.env[RELEASE_ISOLATION_TOOLING_CONTRACT_ENV] !== RELEASE_ISOLATION_TOOLING_CONTRACT - ) { + const contract = + isJsonRecord(workflow) && isJsonRecord(workflow.env) + ? workflow.env[RELEASE_ISOLATION_TOOLING_CONTRACT_ENV] + : undefined; + if (contract !== "1" && contract !== RELEASE_ISOLATION_TOOLING_CONTRACT) { throw new Error( - `Tooling SHA ${workflowSha} does not declare ${RELEASE_ISOLATION_TOOLING_CONTRACT_ENV}=${RELEASE_ISOLATION_TOOLING_CONTRACT} in ${TRUSTED_WORKFLOW_PATH}`, + `Tooling SHA ${workflowSha} does not declare a supported ${RELEASE_ISOLATION_TOOLING_CONTRACT_ENV} in ${TRUSTED_WORKFLOW_PATH}`, + ); + } + const workflowInputs = + isJsonRecord(workflow) && + isJsonRecord(workflow.on) && + isJsonRecord(workflow.on.workflow_dispatch) && + isJsonRecord(workflow.on.workflow_dispatch.inputs) + ? workflow.on.workflow_dispatch.inputs + : undefined; + if (!workflowInputs || !Object.hasOwn(workflowInputs, "expected_sha")) { + throw new Error( + `Tooling SHA ${workflowSha} is missing workflow_dispatch input expected_sha in ${TRUSTED_WORKFLOW_PATH}`, ); } if ( - !isJsonRecord(workflow.on) || - !isJsonRecord(workflow.on.workflow_dispatch) || - !isJsonRecord(workflow.on.workflow_dispatch.inputs) || - !Object.hasOwn(workflow.on.workflow_dispatch.inputs, "expected_sha") + contract === RELEASE_ISOLATION_TOOLING_CONTRACT && + !Object.hasOwn(workflowInputs, "trusted_workflow_json") ) { throw new Error( - `Tooling SHA ${workflowSha} is missing workflow_dispatch input expected_sha in ${TRUSTED_WORKFLOW_PATH}`, + `Tooling SHA ${workflowSha} declares ${RELEASE_ISOLATION_TOOLING_CONTRACT_ENV}=2 but is missing workflow_dispatch input trusted_workflow_json in ${TRUSTED_WORKFLOW_PATH}`, ); } const verifierPath = RELEASE_EVIDENCE_VERIFIER_PATHS.find((relativePath) => @@ -631,7 +713,7 @@ export function assertTrustedWorkflowHarness( `trusted workflow SHA ${workflowSha} does not contain a supported release evidence verifier`, ); } - return verifierPath; + return { contract, verifierPath }; } export function releaseEvidenceVerifierPath(worktreeRoot: string) { @@ -645,7 +727,11 @@ export function releaseEvidenceVerifierPath(worktreeRoot: string) { return verifier; } -function verifyReleaseEvidence(parentRunId: string, workflowSha: string) { +function verifyReleaseEvidence( + parentRunId: string, + workflowSha: string, + trustedWorkflowRef: string, +) { const verifierWorktree = mkdtempSync(join(tmpdir(), "openclaw-release-verifier-")); try { run("git", ["worktree", "add", "--detach", verifierWorktree, workflowSha], { @@ -655,7 +741,7 @@ function verifyReleaseEvidence(parentRunId: string, workflowSha: string) { const evidence: unknown = JSON.parse( run(process.execPath, [ verifier, - ...releaseEvidenceVerificationArgs(parentRunId, workflowSha, verifier), + ...releaseEvidenceVerificationArgs(parentRunId, workflowSha, verifier, trustedWorkflowRef), ]), ); if ( @@ -684,8 +770,11 @@ function main() { args.inputs.release_profile ??= releaseProfileForVersion(targetVersion); args.inputs.allow_unreleased_changelog ??= args.targetRef ? "false" : "true"; const targetContextRef = verifyTargetRef(args.targetRef, targetSha, targetVersion); - const workflowSha = resolveTrustedWorkflowSha(args.workflowSha); - assertTrustedWorkflowHarness(workflowSha); + const workflowSha = resolveTrustedWorkflowSha(args.workflowSha, args.trustedWorkflowRef); + const trustedWorkflowHarness = assertTrustedWorkflowHarness(workflowSha); + if (trustedWorkflowHarness.contract === "1") { + args.inputs.reuse_evidence = "false"; + } const shortSha = workflowSha.slice(0, 12); const branch = `release-ci/${shortSha}-${Date.now()}`; const remoteBranchRef = `refs/heads/${branch}`; @@ -694,12 +783,25 @@ function main() { const dispatchInputs = { ref: targetSha, expected_sha: targetSha, + ...(trustedWorkflowHarness.contract === RELEASE_ISOLATION_TOOLING_CONTRACT + ? { + trusted_workflow_json: JSON.stringify({ + ref: args.trustedWorkflowRef, + fullRef: + args.trustedWorkflowRef === "main" + ? "refs/heads/main" + : `refs/tags/${args.trustedWorkflowRef}`, + sha: workflowSha, + }), + } + : {}), ...(targetContextRef !== targetSha ? { target_context_ref: targetContextRef } : {}), ...args.inputs, }; console.log(`Validation SHA: ${targetSha}`); console.log(`Tooling SHA: ${workflowSha}`); + console.log(`Trusted workflow ref: ${args.trustedWorkflowRef}`); console.log( `Frozen validation tuple: candidate=${targetSha} tooling=${workflowSha} rerun_group=${args.inputs.rerun_group}`, ); @@ -753,7 +855,7 @@ function main() { `Full Release Validation concluded ${parentConclusion.toLowerCase() || "without a conclusion"}: https://github.com/openclaw/openclaw/actions/runs/${parentRunId}`, ); } - verifyReleaseEvidence(parentRunId, workflowSha); + verifyReleaseEvidence(parentRunId, workflowSha, args.trustedWorkflowRef); evidenceVerified = true; } finally { if ( diff --git a/scripts/github/find-reusable-release-validation.sh b/scripts/github/find-reusable-release-validation.sh index aa6a923c2db2..352045dec7c4 100755 --- a/scripts/github/find-reusable-release-validation.sh +++ b/scripts/github/find-reusable-release-validation.sh @@ -12,6 +12,9 @@ WORKFLOW_FILE="full-release-validation.yml" TARGET_SHA="" VERIFIER_WORKFLOW_SHA="" WORKFLOW_REF="" +TRUSTED_WORKFLOW_REF="" +TRUSTED_WORKFLOW_FULL_REF="" +TRUSTED_WORKFLOW_SHA="" RELEASE_PROFILE="" RUN_RELEASE_SOAK="false" INPUTS_JSON="" @@ -27,6 +30,9 @@ usage() { cat >&2 <<'EOF' Usage: find-reusable-release-validation.sh --target-sha --workflow-sha \ --workflow-ref \ + [--trusted-workflow-ref ] \ + [--trusted-workflow-full-ref ] \ + [--trusted-workflow-sha ] \ --release-profile --inputs-json \ [--run-release-soak ] [--repo ] [--repo-dir ] \ [--workflow ] [--max-candidates ] [--github-output ] @@ -55,6 +61,18 @@ while [[ $# -gt 0 ]]; do WORKFLOW_REF="${2:-}" shift 2 ;; + --trusted-workflow-ref) + TRUSTED_WORKFLOW_REF="${2:-}" + shift 2 + ;; + --trusted-workflow-full-ref) + TRUSTED_WORKFLOW_FULL_REF="${2:-}" + shift 2 + ;; + --trusted-workflow-sha) + TRUSTED_WORKFLOW_SHA="${2:-}" + shift 2 + ;; --release-profile) RELEASE_PROFILE="${2:-}" shift 2 @@ -124,6 +142,16 @@ if [[ ! "$VERIFIER_WORKFLOW_SHA" =~ ^[0-9a-f]{40}$ ]]; then echo "Expected --workflow-sha to be a full lowercase commit SHA; got: ${VERIFIER_WORKFLOW_SHA}" >&2 exit 2 fi +TRUSTED_WORKFLOW_REF="${TRUSTED_WORKFLOW_REF:-main}" +TRUSTED_WORKFLOW_FULL_REF="${TRUSTED_WORKFLOW_FULL_REF:-refs/heads/main}" +TRUSTED_WORKFLOW_SHA="${TRUSTED_WORKFLOW_SHA:-${VERIFIER_WORKFLOW_SHA}}" +if [[ ! "$TRUSTED_WORKFLOW_SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo "Expected --trusted-workflow-sha to be a full lowercase commit SHA; got: ${TRUSTED_WORKFLOW_SHA}" >&2 + exit 2 +fi +if [[ "$TRUSTED_WORKFLOW_SHA" != "$VERIFIER_WORKFLOW_SHA" ]]; then + no_reuse "trusted workflow SHA does not match verifier source SHA" +fi if [[ "$WORKFLOW_REF" != "main" ]]; then expected_release_ref="release-ci/${VERIFIER_WORKFLOW_SHA:0:12}-" if [[ ! "$WORKFLOW_REF" =~ ^release-ci/[0-9a-f]{12}-[1-9][0-9]*$ ]] || @@ -149,18 +177,44 @@ if ! expected_inputs="$(jq -Sc 'if type == "object" then . else error("expected exit 2 fi -workflow_lineage="" -if ! workflow_lineage="$( - gh api "repos/${REPO}/compare/${VERIFIER_WORKFLOW_SHA}...main" -)"; then - no_reuse "could not verify workflow SHA against trusted main" -fi -if ! jq -e \ - --arg workflow_sha "$VERIFIER_WORKFLOW_SHA" ' - (.status == "ahead" or .status == "identical") - and .merge_base_commit.sha == $workflow_sha - ' <<< "$workflow_lineage" >/dev/null; then - no_reuse "workflow SHA is not on trusted main lineage" +trusted_workflow_route="" +if [[ "$TRUSTED_WORKFLOW_REF" == "main" ]]; then + if [[ "$TRUSTED_WORKFLOW_FULL_REF" != "refs/heads/main" ]]; then + no_reuse "trusted main workflow full ref is invalid" + fi + workflow_lineage="" + if ! workflow_lineage="$( + gh api "repos/${REPO}/compare/${TRUSTED_WORKFLOW_SHA}...main" + )"; then + no_reuse "could not verify workflow SHA against trusted main" + fi + if ! jq -e \ + --arg workflow_sha "$TRUSTED_WORKFLOW_SHA" ' + (.status == "ahead" or .status == "identical") + and .merge_base_commit.sha == $workflow_sha + ' <<< "$workflow_lineage" >/dev/null; then + no_reuse "workflow SHA is not on trusted main lineage" + fi + trusted_workflow_route="main" +elif [[ "$TRUSTED_WORKFLOW_REF" =~ ^release-publish/([0-9a-f]{12})-[1-9][0-9]*$ ]] && + [[ "$TRUSTED_WORKFLOW_FULL_REF" == "refs/tags/${TRUSTED_WORKFLOW_REF}" ]] && + [[ "$TRUSTED_WORKFLOW_REF" == "release-publish/${TRUSTED_WORKFLOW_SHA:0:12}-"* ]]; then + trusted_tag_json="" + if ! trusted_tag_json="$( + gh api "repos/${REPO}/git/ref/tags/${TRUSTED_WORKFLOW_REF}" + )"; then + no_reuse "could not verify protected trusted workflow tag" + fi + if ! jq -e \ + --arg workflow_sha "$TRUSTED_WORKFLOW_SHA" ' + .object.type == "commit" + and .object.sha == $workflow_sha + ' <<< "$trusted_tag_json" >/dev/null; then + no_reuse "protected trusted workflow tag moved or is not lightweight" + fi + trusted_workflow_route="protected-tag" +else + no_reuse "trusted workflow identity is not main or an exact protected tag" fi # Exact-target reuse still requires internally consistent version stamps @@ -190,7 +244,9 @@ for ((index = 0; index < run_count; index += 1)); do node "$VALIDATOR" \ --validate-run "$run_id" \ --repo "$REPO" \ - --trusted-workflow-ref main \ + --trusted-workflow-ref "$TRUSTED_WORKFLOW_REF" \ + --trusted-workflow-full-ref "$TRUSTED_WORKFLOW_FULL_REF" \ + --trusted-workflow-sha "$TRUSTED_WORKFLOW_SHA" \ --verifier-source-sha "$VERIFIER_WORKFLOW_SHA" \ --verifier-source-file "$VALIDATOR" \ --json @@ -217,14 +273,17 @@ for ((index = 0; index < run_count; index += 1)); do if ! jq -e \ --arg repo "$REPO" \ --arg run_id "$run_id" \ + --arg trusted_workflow_full_ref "$TRUSTED_WORKFLOW_FULL_REF" \ + --arg trusted_workflow_ref "$TRUSTED_WORKFLOW_REF" \ + --arg trusted_workflow_route "$trusted_workflow_route" \ --arg verifier_sha "$VERIFIER_WORKFLOW_SHA" ' . as $record | .schema == "openclaw.release-validation-evidence/v3" and .valid == true and .repository == $repo - and .producerOnTrustedMainLineage == true - and .trustedWorkflowRef == "main" - and .trustedWorkflowFullRef == "refs/heads/main" + and .producerOnTrustedMainLineage == ($trusted_workflow_route == "main") + and .trustedWorkflowRef == $trusted_workflow_ref + and .trustedWorkflowFullRef == $trusted_workflow_full_ref and .directRoot == true and .evidenceReuse == null and .rerunGroup == "all" @@ -239,7 +298,7 @@ for ((index = 0; index < run_count; index += 1)); do and (.root.artifact.digest | type == "string" and test("^sha256:[0-9a-f]{64}$")) and all($record.current, $record.root; . as $parent - | .producerOnTrustedMainLineage == true + | .producerOnTrustedMainLineage == ($trusted_workflow_route == "main") and .workflowRefType == "branch" and .workflowPath == ".github/workflows/full-release-validation.yml" and .workflowFullRef == ("refs/heads/" + .workflowRef) @@ -249,24 +308,31 @@ for ((index = 0; index < run_count; index += 1)); do .workflowRunPath == ".github/workflows/full-release-validation.yml" or .workflowRunPath == .workflowQualifiedPath ) - and ( + and if $trusted_workflow_route == "main" then ( - .workflowRef == "main" - and ( - (.manifestVersion == 3 and .workflowRefProof == "manifest-v3-branch") - or ( - .manifestVersion == 2 - and .workflowRefProof == "legacy-v2-main-ancestry" + ( + .workflowRef == "main" + and ( + (.manifestVersion == 3 and .workflowRefProof == "manifest-v3-branch") + or ( + .manifestVersion == 2 + and .workflowRefProof == "legacy-v2-main-ancestry" + ) ) ) + or ( + .manifestVersion == 3 + and .workflowRefProof == "manifest-v3-sha-pinned-main-ancestry" + and (.workflowRef | test("^release-ci/[0-9a-f]{12}-[1-9][0-9]*$")) + and (.workflowRef | startswith("release-ci/\($parent.workflowSha[0:12])-")) + ) ) - or ( - .manifestVersion == 3 - and .workflowRefProof == "manifest-v3-sha-pinned-main-ancestry" - and (.workflowRef | test("^release-ci/[0-9a-f]{12}-[1-9][0-9]*$")) - and (.workflowRef | startswith("release-ci/\($parent.workflowSha[0:12])-")) - ) - ) + else + .manifestVersion == 3 + and .workflowRefProof == "manifest-v3-protected-tag-exact-sha" + and (.workflowRef | test("^release-ci/[0-9a-f]{12}-[1-9][0-9]*$")) + and (.workflowRef | startswith("release-ci/\($parent.workflowSha[0:12])-")) + end ) and (.verifier.schemaVersion == 3) and (.verifier.sourceSha == $verifier_sha) diff --git a/scripts/openclaw-npm-resume-run.mts b/scripts/openclaw-npm-resume-run.mts index 3055af1ae3d7..c02560fd820e 100644 --- a/scripts/openclaw-npm-resume-run.mts +++ b/scripts/openclaw-npm-resume-run.mts @@ -22,6 +22,8 @@ export interface OpenClawNpmResumeValidationInput { run: ResumeRunRecord; tag: ResumeTagRecord; tagRef: ResumeTagRecord; + trustedWorkflowFullRef: unknown; + trustedWorkflowRef: unknown; } const SHA_PATTERN = /^[a-f0-9]{40}$/u; @@ -79,14 +81,6 @@ function requiredSha(value: unknown, label: string): string { return sha; } -function trustedWorkflowPath(path: string, branch: string): boolean { - return new Set([ - WORKFLOW_PATH, - `${WORKFLOW_PATH}@${branch}`, - `${WORKFLOW_PATH}@refs/tags/${branch}`, - ]).has(path); -} - export function validateOpenClawNpmResumeRun({ canonicalWorkflowId, compareStatus, @@ -94,41 +88,50 @@ export function validateOpenClawNpmResumeRun({ run, tag, tagRef, + trustedWorkflowFullRef, + trustedWorkflowRef, }: OpenClawNpmResumeValidationInput) { const url = requiredString(run?.html_url, "html_url"); - const branch = requiredString(run?.head_branch, "head_branch"); - const branchMatch = RELEASE_PUBLISH_REF_PATTERN.exec(branch); - if (!branchMatch) { + const workflowRef = requiredString(trustedWorkflowRef, "trusted workflow ref"); + const workflowFullRef = requiredString(trustedWorkflowFullRef, "trusted workflow full ref"); + const workflowRefMatch = RELEASE_PUBLISH_REF_PATTERN.exec(workflowRef); + if (!workflowRefMatch || workflowFullRef !== `refs/tags/${workflowRef}`) { fail(`OpenClaw npm resume run has an untrusted workflow ref: ${url}`); } + const branch = requiredString(run?.head_branch, "head_branch"); const sha = requiredSha(run?.head_sha, "head_sha"); const path = requiredString(run?.path, "path"); if ( run?.conclusion !== "success" || run?.event !== "workflow_dispatch" || - !trustedWorkflowPath(path, branch) || + path !== WORKFLOW_PATH || run?.workflow_id !== canonicalWorkflowId || - sha.slice(0, 12) !== branchMatch[1] + branch !== workflowRef || + sha.slice(0, 12) !== workflowRefMatch[1] ) { fail(`OpenClaw npm resume run has an untrusted workflow identity: ${url}`); } const tagObjectSha = requiredSha(tagRef?.object?.sha, "tooling tag object SHA"); - if (tagRef?.object?.type !== "tag") { - fail(`OpenClaw npm resume run tooling ref is not a signed annotated tag: ${url}`); - } - - const tagCommitSha = requiredSha(tag?.object?.sha, "tooling tag commit SHA"); - if ( - tag?.object?.type !== "commit" || - tagCommitSha !== sha || - tag?.verification?.verified !== true || - (compareStatus !== "ahead" && compareStatus !== "identical") - ) { - fail( - `OpenClaw npm resume run is not bound to a real, main-reachable protected tooling tag: ${url}`, - ); + if (tagRef?.object?.type === "commit") { + if (tagObjectSha !== sha) { + fail(`OpenClaw npm resume run protected tooling tag moved after dispatch: ${url}`); + } + } else if (tagRef?.object?.type === "tag") { + const tagCommitSha = requiredSha(tag?.object?.sha, "tooling tag commit SHA"); + if ( + tag?.object?.type !== "commit" || + tagCommitSha !== sha || + tag?.verification?.verified !== true || + (compareStatus !== "ahead" && compareStatus !== "identical") + ) { + fail( + `OpenClaw npm resume run is not bound to a real, main-reachable protected tooling tag: ${url}`, + ); + } + } else { + fail(`OpenClaw npm resume run tooling ref is not a protected tag: ${url}`); } if ( @@ -140,7 +143,7 @@ export function validateOpenClawNpmResumeRun({ return { url, - workflowRef: `refs/tags/${branch}`, + workflowRef: workflowFullRef, workflowSha: sha, tagObjectSha, }; @@ -177,10 +180,14 @@ function runGhCommand( export function resolveOpenClawNpmResumeRun({ repo, runId, + trustedWorkflowFullRef, + trustedWorkflowRef, runGh = runOpenClawNpmResumeGh, }: { repo: string; runId: string; + trustedWorkflowFullRef: string; + trustedWorkflowRef: string; runGh?: (args: string[]) => string; }) { if (!/^[1-9][0-9]*$/u.test(runId)) { @@ -192,14 +199,21 @@ export function resolveOpenClawNpmResumeRun({ const api = (endpoint: string): unknown => parseJson(runGh(["api", `repos/${repo}/${endpoint}`, "--method", "GET"]), endpoint); + const trustedRefMatch = RELEASE_PUBLISH_REF_PATTERN.exec(trustedWorkflowRef); + if (!trustedRefMatch || trustedWorkflowFullRef !== `refs/tags/${trustedWorkflowRef}`) { + fail( + "OpenClaw npm resume trusted workflow identity must be an exact protected release-publish tag.", + ); + } + const run = resumeRunRecord(api(`actions/runs/${runId}`)); const canonicalWorkflow = api(`actions/workflows/${WORKFLOW_PATH.split("/").at(-1)}`); - const branch = requiredString(run?.head_branch, "head_branch"); - const tagRef = resumeTagRecord(api(`git/ref/tags/${branch}`)); + const tagRef = resumeTagRecord(api(`git/ref/tags/${trustedWorkflowRef}`)); const tagObjectSha = requiredSha(tagRef?.object?.sha, "tooling tag object SHA"); - const tag = resumeTagRecord(api(`git/tags/${tagObjectSha}`)); const sha = requiredSha(run?.head_sha, "head_sha"); - const comparison = api(`compare/${sha}...main`); + const annotatedTag = tagRef?.object?.type === "tag"; + const tag = annotatedTag ? resumeTagRecord(api(`git/tags/${tagObjectSha}`)) : {}; + const comparison = annotatedTag ? api(`compare/${sha}...main`) : {}; const jobs = resumeJobRecords( parseJson( runGh(["run", "view", runId, "--repo", repo, "--json", "jobs", "--jq", ".jobs"]), @@ -214,17 +228,33 @@ export function resolveOpenClawNpmResumeRun({ run, tag, tagRef, + trustedWorkflowFullRef, + trustedWorkflowRef, }); } -function parseArgs(argv: string[]): { repo: string; runId: string } { - const options = { repo: "", runId: "" }; +function parseArgs(argv: string[]): { + repo: string; + runId: string; + trustedWorkflowFullRef: string; + trustedWorkflowRef: string; +} { + const options = { + repo: "", + runId: "", + trustedWorkflowFullRef: "", + trustedWorkflowRef: "", + }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === "--repo") { options.repo = argv[(index += 1)] ?? ""; } else if (arg === "--run-id") { options.runId = argv[(index += 1)] ?? ""; + } else if (arg === "--trusted-workflow-ref") { + options.trustedWorkflowRef = argv[(index += 1)] ?? ""; + } else if (arg === "--trusted-workflow-full-ref") { + options.trustedWorkflowFullRef = argv[(index += 1)] ?? ""; } else { fail(`Unknown argument: ${arg}`); } diff --git a/scripts/plugin-npm-publish.sh b/scripts/plugin-npm-publish.sh index 7d4c0c65fec9..4e6085db7cd2 100644 --- a/scripts/plugin-npm-publish.sh +++ b/scripts/plugin-npm-publish.sh @@ -172,6 +172,26 @@ if [[ "${mirror_auth_requirement}" == "required" && -z "${mirror_auth_token}" ]] exit 1 fi +verify_release_tooling_identity() { + if [[ "${OPENCLAW_RELEASE_TOOLING_IDENTITY_REQUIRED:-}" != "true" ]]; then + return 0 + fi + identity_args=( + verify + --repository "${OPENCLAW_RELEASE_TOOLING_REPOSITORY:-}" + --workflow-ref "${OPENCLAW_RELEASE_TOOLING_REF:-}" + --workflow-full-ref "${OPENCLAW_RELEASE_TOOLING_FULL_REF:-}" + --workflow-sha "${OPENCLAW_RELEASE_TOOLING_SHA:-}" + --release-publish-run-id "${OPENCLAW_RELEASE_PUBLISH_RUN_ID:-}" + --release-publish-run-attempt "${OPENCLAW_RELEASE_PUBLISH_RUN_ATTEMPT:-}" + --release-publish-parent-state-policy "${OPENCLAW_RELEASE_PUBLISH_PARENT_STATE_POLICY:-}" + ) + if [[ "${OPENCLAW_RELEASE_TOOLING_ALLOW_PREVALIDATED_REF:-}" == "true" ]]; then + identity_args+=(--allow-prevalidated-ref) + fi + node "${tooling_root}/scripts/release-tooling-identity.mjs" "${identity_args[@]}" +} + if [[ "${mode}" == "--pack" || "${mode}" == "--pack-dry-run" ]]; then { printf 'Publish command:' @@ -228,6 +248,9 @@ fi cleanup_files+=("${publish_userconfig}") chmod 0600 "${publish_userconfig}" printf '%s\n' "//registry.npmjs.org/:_authToken=${publish_auth_token}" > "${publish_userconfig}" + fi + verify_release_tooling_identity + if [[ -n "${publish_auth_token}" ]]; then NPM_CONFIG_USERCONFIG="${publish_userconfig}" run_with_manifest_overlay "${publish_cmd[@]}" else run_with_manifest_overlay "${publish_cmd[@]}" @@ -243,6 +266,7 @@ fi for dist_tag in "${mirror_dist_tags[@]}"; do [[ -n "${dist_tag}" ]] || continue echo "Mirroring ${package_name}@${package_version} onto dist-tag ${dist_tag}" + verify_release_tooling_identity if ! NPM_CONFIG_USERCONFIG="${mirror_userconfig}" \ npm dist-tag add "${package_name}@${package_version}" "${dist_tag}"; then if [[ "${mirror_auth_requirement}" == "required" ]]; then diff --git a/scripts/release-candidate-checklist.mts b/scripts/release-candidate-checklist.mts index 401fa9a536b5..6b53a33051fe 100644 --- a/scripts/release-candidate-checklist.mts +++ b/scripts/release-candidate-checklist.mts @@ -18,6 +18,7 @@ import { basename, dirname, join, resolve as resolvePath } from "node:path"; import { fileURLToPath } from "node:url"; import { isDeepStrictEqual } from "node:util"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { parse as parseYaml } from "yaml"; import { booleanFlag, parseFlagArgs, @@ -1205,6 +1206,47 @@ export function requireRunIdFromDispatchOutput(output: string, workflowFile: str return runId; } +export function fullReleaseTrustedWorkflowFields({ + workflowRef, + workflowSha, + workflowSource, +}: { + workflowRef: string; + workflowSha: string; + workflowSource: string; +}) { + const workflow: unknown = parseYaml(workflowSource); + const env = isRecord(workflow) && isRecord(workflow.env) ? workflow.env : undefined; + const contract = String(env?.RELEASE_ISOLATION_TOOLING_CONTRACT ?? ""); + if (contract === "1") { + return {}; + } + if (contract !== "2") { + throw new Error( + "Full Release Validation does not declare a supported release tooling contract", + ); + } + const workflowDispatch = + isRecord(workflow) && isRecord(workflow.on) && isRecord(workflow.on.workflow_dispatch) + ? workflow.on.workflow_dispatch + : undefined; + const inputs = + workflowDispatch && isRecord(workflowDispatch.inputs) ? workflowDispatch.inputs : undefined; + if (!inputs || !Object.hasOwn(inputs, "trusted_workflow_json")) { + throw new Error(`Full Release Validation contract ${contract} requires trusted_workflow_json`); + } + if (!/^[a-f0-9]{40}$/u.test(workflowSha)) { + throw new Error("Full Release Validation trusted workflow SHA must be a full lowercase SHA"); + } + return { + trusted_workflow_json: JSON.stringify({ + ref: workflowRef, + fullRef: `refs/heads/${workflowRef}`, + sha: workflowSha, + }), + }; +} + async function wait(ms: number) { await new Promise((resolve) => { setTimeout(resolve, ms); @@ -1819,9 +1861,18 @@ async function main() { if (!options.fullReleaseRunId && !options.skipDispatch) { const workflowFile = "full-release-validation.yml"; const targetContextRef = releaseBranchForTag(options.tag); + const trustedWorkflowFields = fullReleaseTrustedWorkflowFields({ + workflowRef: options.workflowRef, + workflowSha: toolingSha, + workflowSource: readFileSync( + join(TOOLING_ROOT, ".github", "workflows", workflowFile), + "utf8", + ), + }); options.fullReleaseRunId = dispatchWorkflow(options.repo, workflowFile, options.workflowRef, { ref: targetSha, ...(targetContextRef ? { target_context_ref: targetContextRef } : {}), + ...trustedWorkflowFields, provider: options.provider, mode: options.mode, release_profile: options.releaseProfile, diff --git a/scripts/release-ci-summary.mjs b/scripts/release-ci-summary.mjs index 328cce1c560c..7a32bb9e16fa 100755 --- a/scripts/release-ci-summary.mjs +++ b/scripts/release-ci-summary.mjs @@ -15,6 +15,8 @@ import { execGhRead, plainGhEnv, resolvePlainGhBin } from "./lib/plain-gh.mjs"; const DEFAULT_REPO = process.env.OPENCLAW_RELEASE_REPO || "openclaw/openclaw"; const RELEASE_EVIDENCE_SCHEMA = "openclaw.release-validation-evidence/v3"; const SHA_PINNED_BRANCH_PATTERN = /^release-ci\/[a-f0-9]{12}-[1-9][0-9]*$/u; +const TRUSTED_RELEASE_PUBLISH_TAG_PATTERN = + /^refs\/tags\/release-publish\/([a-f0-9]{12})-[1-9][0-9]*$/u; const RELEASE_EVIDENCE_SCRIPT = "scripts/release-ci-summary.mjs"; const RELEASE_EVIDENCE_FILE = fileURLToPath(import.meta.url); const RELEASE_EVIDENCE_REPO_ROOT = resolve(dirname(RELEASE_EVIDENCE_FILE), ".."); @@ -1149,8 +1151,26 @@ function loadValidatedParentEvidence({ client, manifestPath, repository, runId } }; } -function trustedWorkflowFullRef(workflowRef) { - return `refs/heads/${workflowRef}`; +function resolveTrustedWorkflowIdentity(workflowRef, workflowFullRef, workflowSha) { + const fullRef = workflowFullRef ?? `refs/heads/${workflowRef}`; + const protectedTag = TRUSTED_RELEASE_PUBLISH_TAG_PATTERN.exec(fullRef); + if (protectedTag) { + if (workflowRef !== fullRef.slice("refs/tags/".length)) { + throw new Error("trusted workflow tag name does not match its full ref"); + } + const sha = normalizeSha(workflowSha, "trusted workflow SHA"); + if (sha.slice(0, 12) !== protectedTag[1]) { + throw new Error("trusted workflow tag does not match its workflow SHA"); + } + return { fullRef, ref: workflowRef, sha, type: "tag" }; + } + if (fullRef !== `refs/heads/${workflowRef}`) { + throw new Error("trusted workflow full ref does not match its ref"); + } + if (workflowRef.startsWith("release-publish/")) { + throw new Error("trusted release-publish workflow ref must be a protected tag"); + } + return { fullRef, ref: workflowRef, sha: undefined, type: "branch" }; } function normalizeWorkflowPathRef(ref) { @@ -1160,11 +1180,31 @@ function normalizeWorkflowPathRef(ref) { return `refs/heads/${ref}`; } -export function validateTrustedProducerIdentity(evidence, client, verifier, trustedWorkflowRef) { +export function validateTrustedProducerIdentity( + evidence, + client, + verifier, + trustedWorkflowRef, + trustedWorkflowFullRef, + trustedWorkflowSha, +) { const { manifest, parentRun } = evidence; + const trustedIdentity = resolveTrustedWorkflowIdentity( + trustedWorkflowRef, + trustedWorkflowFullRef, + trustedWorkflowSha, + ); // Keep this predicate local: verifier source identity covers this file only. const shaPinned = SHA_PINNED_BRANCH_PATTERN.test(manifest.workflowRef ?? ""); - if (manifest.workflowRef !== trustedWorkflowRef && !shaPinned) { + const protectedTagRoute = trustedIdentity.type === "tag"; + if (protectedTagRoute) { + if (!shaPinned) { + throw new Error("protected-tag release evidence must use a canonical release-ci branch"); + } + if (manifest.workflowSha !== trustedIdentity.sha) { + throw new Error("protected-tag release evidence workflow SHA does not match trusted tooling"); + } + } else if (manifest.workflowRef !== trustedWorkflowRef && !shaPinned) { throw new Error( `release evidence producer must run from trusted workflow ref: ${trustedWorkflowRef}`, ); @@ -1180,7 +1220,7 @@ export function validateTrustedProducerIdentity(evidence, client, verifier, trus throw new Error("SHA-pinned release evidence target ref must equal its target SHA"); } } - const expectedFullRef = trustedWorkflowFullRef(manifest.workflowRef); + const expectedFullRef = `refs/heads/${manifest.workflowRef}`; const runPath = String(parentRun.path ?? ""); const [runWorkflowPath, runWorkflowFullRef] = runPath.split("@", 2); if (runWorkflowPath !== ".github/workflows/full-release-validation.yml") { @@ -1195,19 +1235,25 @@ export function validateTrustedProducerIdentity(evidence, client, verifier, trus if (manifest.workflowRefType !== "branch" || manifest.workflowFullRef !== expectedFullRef) { throw new Error("release evidence producer workflow full ref is not trusted"); } - workflowRefProof = shaPinned ? "manifest-v3-sha-pinned-main-ancestry" : "manifest-v3-branch"; + workflowRefProof = protectedTagRoute + ? "manifest-v3-protected-tag-exact-sha" + : shaPinned + ? "manifest-v3-sha-pinned-main-ancestry" + : "manifest-v3-branch"; } - const comparison = client.compareCommitLineage(manifest.workflowSha, verifier.sourceSha); - if ( - !["ahead", "identical"].includes(String(comparison.status)) || - comparison.merge_base_commit?.sha !== manifest.workflowSha - ) { - throw new Error("release evidence producer is not on the trusted main verifier lineage"); + if (!protectedTagRoute) { + const comparison = client.compareCommitLineage(manifest.workflowSha, verifier.sourceSha); + if ( + !["ahead", "identical"].includes(String(comparison.status)) || + comparison.merge_base_commit?.sha !== manifest.workflowSha + ) { + throw new Error("release evidence producer is not on the trusted main verifier lineage"); + } } return { - producerOnTrustedMainLineage: true, + producerOnTrustedMainLineage: !protectedTagRoute, workflowFullRef: expectedFullRef, workflowQualifiedPath: `${runWorkflowPath}@${expectedFullRef}`, workflowRefProof, @@ -1352,7 +1398,9 @@ function validateStrictChildRun({ child, client, parentEvidence, parentJobs, rep * manifestPath?: string, * repository?: string, * runId: string, + * trustedWorkflowFullRef?: string, * trustedWorkflowRef?: string, + * trustedWorkflowSha?: string, * verifierSourceContent?: string | Uint8Array, * verifierSourceSha: string, * }} options @@ -1362,7 +1410,9 @@ export function validateReleaseRunEvidence( manifestPath, repository = DEFAULT_REPO, runId, + trustedWorkflowFullRef, trustedWorkflowRef = "main", + trustedWorkflowSha, verifierSourceContent, verifierSourceSha, }, @@ -1374,6 +1424,11 @@ export function validateReleaseRunEvidence( trustedWorkflowRef, "trusted workflow ref", ); + const trustedIdentity = resolveTrustedWorkflowIdentity( + normalizedTrustedWorkflowRef, + trustedWorkflowFullRef, + trustedWorkflowSha, + ); const evidenceClient = client ?? createReleaseEvidenceClient(normalizedRepository); const verifier = resolveVerifierIdentity(verifierSourceSha, verifierSourceContent); const currentEvidence = loadValidatedParentEvidence({ @@ -1390,6 +1445,8 @@ export function validateReleaseRunEvidence( evidenceClient, verifier, normalizedTrustedWorkflowRef, + trustedIdentity.fullRef, + trustedIdentity.sha, ), ], ]); @@ -1428,6 +1485,8 @@ export function validateReleaseRunEvidence( evidenceClient, verifier, normalizedTrustedWorkflowRef, + trustedIdentity.fullRef, + trustedIdentity.sha, ), ); } @@ -1490,8 +1549,8 @@ export function validateReleaseRunEvidence( root, runReleaseSoak: rootEvidence.manifest.runReleaseSoak === "true", schema: RELEASE_EVIDENCE_SCHEMA, - producerOnTrustedMainLineage: true, - trustedWorkflowFullRef: trustedWorkflowFullRef(normalizedTrustedWorkflowRef), + producerOnTrustedMainLineage: trustedIdentity.type === "branch", + trustedWorkflowFullRef: trustedIdentity.fullRef, trustedWorkflowRef: normalizedTrustedWorkflowRef, valid: true, validationInputs: rootEvidence.manifest.validationInputs ?? null, @@ -1506,7 +1565,9 @@ function parseReleaseCiSummaryArgs(argv) { manifestPath: undefined, repository: DEFAULT_REPO, runId: undefined, + trustedWorkflowFullRef: undefined, trustedWorkflowRef: "main", + trustedWorkflowSha: undefined, validate: false, verifierSourceFile: undefined, verifierSourceSha: undefined, @@ -1523,6 +1584,10 @@ function parseReleaseCiSummaryArgs(argv) { options.manifestPath = argv[++index]; } else if (argument === "--trusted-workflow-ref") { options.trustedWorkflowRef = argv[++index]; + } else if (argument === "--trusted-workflow-full-ref") { + options.trustedWorkflowFullRef = argv[++index]; + } else if (argument === "--trusted-workflow-sha") { + options.trustedWorkflowSha = argv[++index]; } else if (argument === "--verifier-source-sha") { options.verifierSourceSha = argv[++index]; } else if (argument === "--verifier-source-file") { @@ -1563,7 +1628,7 @@ function printUsage() { [ "usage: release-ci-summary.mjs ", " release-ci-summary.mjs --watch [--interval seconds]", - " release-ci-summary.mjs --validate-run [--repo owner/name] [--trusted-workflow-ref main] [--manifest path] [--verifier-source-sha sha --verifier-source-file path] --json", + " release-ci-summary.mjs --validate-run [--repo owner/name] [--trusted-workflow-ref main --trusted-workflow-full-ref refs/heads/main] [--trusted-workflow-sha sha] [--manifest path] [--verifier-source-sha sha --verifier-source-file path] --json", ].join("\n"), ); } @@ -1662,7 +1727,9 @@ async function main() { manifestPath: options.manifestPath, repository, runId, + trustedWorkflowFullRef: options.trustedWorkflowFullRef, trustedWorkflowRef: options.trustedWorkflowRef, + trustedWorkflowSha: options.trustedWorkflowSha, verifierSourceContent: options.verifierSourceFile ? readFileSync(options.verifierSourceFile) : undefined, diff --git a/scripts/release-tooling-identity.d.mts b/scripts/release-tooling-identity.d.mts new file mode 100644 index 000000000000..ff0f31dbfa8d --- /dev/null +++ b/scripts/release-tooling-identity.d.mts @@ -0,0 +1,47 @@ +export type ReleaseToolingIdentity = { + fullRef: string; + ref: string; + route: "main" | "prevalidated-branch" | "protected-tag"; + sha: string; +}; + +export type ReleaseToolingIdentityInput = { + allowPrevalidatedRef?: boolean; + workflowFullRef: string; + workflowRef: string; + workflowSha: string; +}; + +export function resolveReleaseToolingIdentity( + input: { + requestedIdentityJson?: string; + workflowContract: string; + } & Pick, +): Pick; + +export function validateReleaseToolingIdentity( + input: ReleaseToolingIdentityInput & { + mainComparisonStatus?: unknown; + branchRef?: unknown; + tagRef?: unknown; + }, +): ReleaseToolingIdentity; + +export function verifyReleaseToolingIdentity( + input: ReleaseToolingIdentityInput & { + repository: string; + releasePublishParentStatePolicy?: "active" | "active-or-success" | "manual-recovery"; + releasePublishRunAttempt?: string; + releasePublishRunId?: string; + runGh?: (args: string[]) => string; + }, +): ReleaseToolingIdentity; + +export function validateReleasePublishParentRun(input: { + identity: Pick; + releasePublishParentStatePolicy: "active" | "active-or-success" | "manual-recovery"; + releasePublishRunAttempt: string; + releasePublishRunId: string; + repository: string; + run: unknown; +}): void; diff --git a/scripts/release-tooling-identity.mjs b/scripts/release-tooling-identity.mjs new file mode 100644 index 000000000000..3a4b2f7be209 --- /dev/null +++ b/scripts/release-tooling-identity.mjs @@ -0,0 +1,520 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { isRecord } from "./lib/record-shared.mjs"; + +const SHA_PATTERN = /^[a-f0-9]{40}$/u; +const RELEASE_PUBLISH_REF_PATTERN = /^release-publish\/([a-f0-9]{12})-([1-9][0-9]*)$/u; +const RELEASE_CI_REF_PATTERN = /^release-ci\/([a-f0-9]{12})-([1-9][0-9]*)$/u; +const DIRECT_WORKFLOW_REF_PATTERN = + /^(?:main|release\/[0-9]{4}\.(?:[1-9]|1[0-2])\.[1-9][0-9]*|extended-stable\/[0-9]{4}\.(?:[1-9]|1[0-2])\.33|tideclaw\/alpha\/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z)$/u; +const RELEASE_PUBLISH_PARENT_STATE_POLICIES = new Set([ + "active", + "active-or-success", + "manual-recovery", +]); +const GH_COMMAND_TIMEOUT_MS = 60_000; + +function fail(message) { + throw new Error(message); +} + +function requiredString(value, label) { + if (typeof value !== "string" || value.trim().length === 0) { + fail(`${label} is required.`); + } + return value.trim(); +} + +function requiredSha(value, label) { + const sha = requiredString(value, label); + if (!SHA_PATTERN.test(sha)) { + fail(`${label} must be a lowercase 40-character commit SHA.`); + } + return sha; +} + +function requireRepository(value) { + const repository = requiredString(value, "release tooling repository"); + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(repository)) { + fail("release tooling repository must be owner/name."); + } + return repository; +} + +function parseIdentityJson(value) { + const raw = requiredString(value, "requested release tooling identity"); + let identity; + try { + identity = JSON.parse(raw); + } catch (error) { + throw new Error("requested release tooling identity must be valid JSON.", { cause: error }); + } + if (!isRecord(identity)) { + fail("requested release tooling identity must be a JSON object."); + } + return { + fullRef: requiredString(identity.fullRef, "requested release tooling full ref"), + ref: requiredString(identity.ref, "requested release tooling ref"), + sha: requiredSha(identity.sha, "requested release tooling SHA"), + }; +} + +export function resolveReleaseToolingIdentity({ + requestedIdentityJson = "", + workflowContract, + workflowFullRef, + workflowRef, + workflowSha, +}) { + const contract = requiredString(workflowContract, "release tooling contract"); + if (contract !== "1" && contract !== "2") { + fail(`release tooling contract ${contract} is not supported.`); + } + const ref = requiredString(workflowRef, "workflow ref"); + const fullRef = requiredString(workflowFullRef, "workflow full ref"); + const sha = requiredSha(workflowSha, "workflow SHA"); + const directRoute = fullRef === `refs/heads/${ref}` && DIRECT_WORKFLOW_REF_PATTERN.test(ref); + const releaseCiMatch = fullRef === `refs/heads/${ref}` ? RELEASE_CI_REF_PATTERN.exec(ref) : null; + const protectedTagMatch = + fullRef === `refs/tags/${ref}` ? RELEASE_PUBLISH_REF_PATTERN.exec(ref) : null; + + if (releaseCiMatch && releaseCiMatch[1] !== sha.slice(0, 12)) { + fail("release-ci workflow ref does not match the workflow SHA."); + } + if (protectedTagMatch && protectedTagMatch[1] !== sha.slice(0, 12)) { + fail("protected workflow ref does not match the workflow SHA."); + } + if (!directRoute && !releaseCiMatch && !protectedTagMatch) { + fail("workflow ref is not a trusted direct, release-ci, or protected-tag route."); + } + + const requested = requestedIdentityJson.trim() + ? parseIdentityJson(requestedIdentityJson) + : undefined; + if (!requested) { + if (contract !== "1" && contract !== "2") { + fail(`release tooling contract ${contract} requires explicit trusted workflow identity.`); + } + if (!directRoute) { + fail("release-ci and protected-tag workflows require explicit trusted workflow identity."); + } + return { fullRef, ref, sha }; + } + + if (directRoute || protectedTagMatch) { + if (requested.ref !== ref || requested.fullRef !== fullRef || requested.sha !== sha) { + fail("direct workflow identity must match the executing workflow ref and SHA."); + } + return requested; + } + + const requestedProtectedTag = RELEASE_PUBLISH_REF_PATTERN.test(requested.ref); + const requestedMain = requested.ref === "main" && requested.fullRef === "refs/heads/main"; + if ( + requested.sha !== sha || + (!requestedMain && + (!requestedProtectedTag || requested.fullRef !== `refs/tags/${requested.ref}`)) + ) { + fail("release-ci workflow identity must be trusted main or an exact protected tag."); + } + return requested; +} + +function classifyIdentity({ allowPrevalidatedRef, workflowFullRef, workflowRef, workflowSha }) { + const ref = requiredString(workflowRef, "release tooling ref"); + const fullRef = requiredString(workflowFullRef, "release tooling full ref"); + const sha = requiredSha(workflowSha, "release tooling SHA"); + const protectedMatch = RELEASE_PUBLISH_REF_PATTERN.exec(ref); + + if (protectedMatch) { + if (fullRef !== `refs/tags/${ref}`) { + fail("protected release tooling identity must use the exact tag full ref."); + } + if (sha.slice(0, 12) !== protectedMatch[1]) { + fail("protected release tooling tag SHA prefix does not match the workflow SHA."); + } + return { fullRef, ref, route: "protected-tag", sha }; + } + + if ( + ref.startsWith("release-publish/") || + fullRef.startsWith("refs/tags/release-publish/") || + fullRef.startsWith("refs/heads/release-publish/") + ) { + fail("release-publish tooling identity must be an exact protected tag."); + } + + if (ref === "main" || fullRef === "refs/heads/main") { + if (ref !== "main" || fullRef !== "refs/heads/main") { + fail("main release tooling identity must use ref main and full ref refs/heads/main."); + } + return { fullRef, ref, route: "main", sha }; + } + + if (allowPrevalidatedRef !== true || fullRef !== `refs/heads/${ref}`) { + fail( + "release tooling identity is not trusted main, a protected tag, or a prevalidated branch.", + ); + } + return { fullRef, ref, route: "prevalidated-branch", sha }; +} + +export function validateReleaseToolingIdentity({ + allowPrevalidatedRef = false, + branchRef, + mainComparisonStatus, + tagRef, + workflowFullRef, + workflowRef, + workflowSha, +}) { + const identity = classifyIdentity({ + allowPrevalidatedRef, + workflowFullRef, + workflowRef, + workflowSha, + }); + + if (identity.route === "protected-tag") { + if ( + !isRecord(tagRef) || + tagRef.ref !== identity.fullRef || + !isRecord(tagRef.object) || + tagRef.object.type !== "commit" || + tagRef.object.sha !== identity.sha + ) { + fail( + "protected release tooling tag is missing, moved, annotated, or bound to the wrong SHA.", + ); + } + } else if (identity.route === "main") { + if (mainComparisonStatus !== "ahead" && mainComparisonStatus !== "identical") { + fail("main release tooling SHA is not reachable from current main."); + } + } else if ( + !isRecord(branchRef) || + branchRef.ref !== identity.fullRef || + !isRecord(branchRef.object) || + branchRef.object.type !== "commit" || + branchRef.object.sha !== identity.sha + ) { + fail("prevalidated release tooling branch is missing or moved from the workflow SHA."); + } + + return identity; +} + +export function validateReleasePublishParentRun({ + identity, + releasePublishParentStatePolicy, + releasePublishRunAttempt, + releasePublishRunId, + repository, + run, +}) { + const runId = requiredString(releasePublishRunId, "release publish run id"); + const runAttempt = requiredString(releasePublishRunAttempt, "release publish run attempt"); + if (!/^[1-9][0-9]*$/u.test(runId) || !/^[1-9][0-9]*$/u.test(runAttempt)) { + fail("release publish run id and attempt must be positive integers."); + } + const parentStatePolicy = requiredString( + releasePublishParentStatePolicy, + "release publish parent state policy", + ); + if (!RELEASE_PUBLISH_PARENT_STATE_POLICIES.has(parentStatePolicy)) { + fail(`release publish parent state policy ${parentStatePolicy} is not supported.`); + } + const normalizedRepository = requireRepository(repository); + const [workflowPath, workflowFullRef] = String(run?.path ?? "").split("@", 2); + const expected = { + event: "workflow_dispatch", + headBranch: identity.ref, + headSha: identity.sha, + repository: normalizedRepository, + runAttempt: Number(runAttempt), + runId: Number(runId), + workflowPath: ".github/workflows/openclaw-release-publish.yml", + }; + const actual = { + event: run?.event, + headBranch: run?.head_branch, + headSha: run?.head_sha, + repository: run?.repository?.full_name, + runAttempt: run?.run_attempt, + runId: run?.id, + workflowPath, + }; + for (const key of Object.keys(expected)) { + if (actual[key] !== expected[key]) { + fail(`release publish parent run ${key} does not match the trusted tooling identity.`); + } + } + if (workflowFullRef && workflowFullRef !== identity.fullRef) { + fail("release publish parent run workflow full ref does not match trusted tooling."); + } + const active = run?.status === "in_progress" && !run?.conclusion; + const completedSuccess = run?.status === "completed" && run?.conclusion === "success"; + const completedFailure = run?.status === "completed" && run?.conclusion === "failure"; + if ( + !active && + !(parentStatePolicy === "active-or-success" && completedSuccess) && + !(parentStatePolicy === "manual-recovery" && (completedSuccess || completedFailure)) + ) { + fail( + `release publish parent run state is not allowed by ${parentStatePolicy}: status=${run?.status ?? ""} conclusion=${run?.conclusion ?? ""}.`, + ); + } +} + +function parseJson(raw, label) { + try { + return JSON.parse(raw); + } catch (error) { + throw new Error(`${label} returned invalid JSON.`, { cause: error }); + } +} + +function runReleaseToolingGh(args) { + return execFileSync("gh", args, { + encoding: "utf8", + killSignal: "SIGKILL", + maxBuffer: 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + timeout: GH_COMMAND_TIMEOUT_MS, + }); +} + +export function verifyReleaseToolingIdentity({ + allowPrevalidatedRef = false, + releasePublishParentStatePolicy, + releasePublishRunAttempt, + releasePublishRunId, + repository, + runGh = runReleaseToolingGh, + workflowFullRef, + workflowRef, + workflowSha, +}) { + const normalizedRepository = requireRepository(repository); + const identity = classifyIdentity({ + allowPrevalidatedRef, + workflowFullRef, + workflowRef, + workflowSha, + }); + + if (identity.route === "protected-tag") { + let tagRef; + try { + tagRef = parseJson( + runGh([ + "api", + `repos/${normalizedRepository}/git/ref/tags/${identity.ref}`, + "--method", + "GET", + ]), + "protected release tooling tag", + ); + } catch (error) { + throw new Error("protected release tooling tag is missing or unreadable.", { cause: error }); + } + const validated = validateReleaseToolingIdentity({ + allowPrevalidatedRef, + tagRef, + workflowFullRef, + workflowRef, + workflowSha, + }); + validateParentRunIfRequested({ + identity: validated, + releasePublishParentStatePolicy, + releasePublishRunAttempt, + releasePublishRunId, + repository: normalizedRepository, + runGh, + }); + return validated; + } + + if (identity.route === "main") { + let comparison; + try { + comparison = parseJson( + runGh([ + "api", + `repos/${normalizedRepository}/compare/${identity.sha}...main`, + "--method", + "GET", + ]), + "main release tooling comparison", + ); + } catch (error) { + throw new Error("main release tooling ancestry could not be verified.", { cause: error }); + } + const validated = validateReleaseToolingIdentity({ + allowPrevalidatedRef, + mainComparisonStatus: isRecord(comparison) ? comparison.status : undefined, + workflowFullRef, + workflowRef, + workflowSha, + }); + validateParentRunIfRequested({ + identity: validated, + releasePublishParentStatePolicy, + releasePublishRunAttempt, + releasePublishRunId, + repository: normalizedRepository, + runGh, + }); + return validated; + } + + let branchRef; + try { + branchRef = parseJson( + runGh([ + "api", + `repos/${normalizedRepository}/git/ref/heads/${identity.ref}`, + "--method", + "GET", + ]), + "prevalidated release tooling branch", + ); + } catch (error) { + throw new Error("prevalidated release tooling branch is missing or unreadable.", { + cause: error, + }); + } + const validated = validateReleaseToolingIdentity({ + allowPrevalidatedRef, + branchRef, + workflowFullRef, + workflowRef, + workflowSha, + }); + validateParentRunIfRequested({ + identity: validated, + releasePublishParentStatePolicy, + releasePublishRunAttempt, + releasePublishRunId, + repository: normalizedRepository, + runGh, + }); + return validated; +} + +function validateParentRunIfRequested({ + identity, + releasePublishParentStatePolicy, + releasePublishRunAttempt, + releasePublishRunId, + repository, + runGh, +}) { + if (!releasePublishRunId && !releasePublishRunAttempt && !releasePublishParentStatePolicy) { + return; + } + if (!releasePublishRunId || !releasePublishRunAttempt || !releasePublishParentStatePolicy) { + fail("release publish run id, attempt, and parent state policy must be provided together."); + } + let run; + try { + run = parseJson( + runGh(["api", `repos/${repository}/actions/runs/${releasePublishRunId}`, "--method", "GET"]), + "release publish parent run", + ); + } catch (error) { + throw new Error("release publish parent run is missing or unreadable.", { cause: error }); + } + validateReleasePublishParentRun({ + identity, + releasePublishParentStatePolicy, + releasePublishRunAttempt, + releasePublishRunId, + repository, + run, + }); +} + +function parseArgs(argv) { + const options = { + allowPrevalidatedRef: false, + command: "", + releasePublishRunAttempt: "", + releasePublishRunId: "", + releasePublishParentStatePolicy: "", + repository: "", + requestedIdentityJson: "", + workflowContract: "", + workflowFullRef: "", + workflowRef: "", + workflowSha: "", + }; + options.command = argv.shift() ?? ""; + if (options.command !== "verify" && options.command !== "resolve") { + fail("usage: release-tooling-identity.mjs [options]"); + } + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--allow-prevalidated-ref") { + options.allowPrevalidatedRef = true; + continue; + } + const value = argv[(index += 1)] ?? ""; + if (arg === "--release-publish-run-id") { + options.releasePublishRunId = value; + } else if (arg === "--release-publish-run-attempt") { + options.releasePublishRunAttempt = value; + } else if (arg === "--release-publish-parent-state-policy") { + options.releasePublishParentStatePolicy = value; + } else if (arg === "--repository") { + options.repository = value; + } else if (arg === "--requested-identity-json") { + options.requestedIdentityJson = value; + } else if (arg === "--workflow-contract") { + options.workflowContract = value; + } else if (arg === "--workflow-full-ref") { + options.workflowFullRef = value; + } else if (arg === "--workflow-ref") { + options.workflowRef = value; + } else if (arg === "--workflow-sha") { + options.workflowSha = value; + } else { + fail(`unknown release tooling identity argument: ${arg}`); + } + } + return options; +} + +function main(argv = process.argv.slice(2)) { + const options = parseArgs([...argv]); + let identity; + if (options.command === "resolve") { + identity = resolveReleaseToolingIdentity(options); + const protectedMatch = RELEASE_PUBLISH_REF_PATTERN.exec(identity.ref); + verifyReleaseToolingIdentity({ + allowPrevalidatedRef: identity.ref !== "main" && !protectedMatch, + releasePublishParentStatePolicy: options.releasePublishParentStatePolicy, + releasePublishRunAttempt: options.releasePublishRunAttempt, + releasePublishRunId: options.releasePublishRunId, + repository: options.repository, + workflowFullRef: identity.fullRef, + workflowRef: identity.ref, + workflowSha: identity.sha, + }); + } else { + identity = verifyReleaseToolingIdentity(options); + } + process.stdout.write(`${JSON.stringify(identity)}\n`); +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} diff --git a/scripts/validate-full-release-validation-evidence.mjs b/scripts/validate-full-release-validation-evidence.mjs index 8d569c783247..64ca33bf4020 100755 --- a/scripts/validate-full-release-validation-evidence.mjs +++ b/scripts/validate-full-release-validation-evidence.mjs @@ -8,6 +8,8 @@ const FULL_RELEASE_WORKFLOW = "Full Release Validation"; const FULL_RELEASE_WORKFLOW_PATH = ".github/workflows/full-release-validation.yml"; const SHA_PATTERN = /^[a-f0-9]{40}$/u; const PINNED_BRANCH_PATTERN = /^release-ci\/([a-f0-9]{12})-([1-9][0-9]*)$/u; +const TRUSTED_RELEASE_PUBLISH_TAG_PATTERN = + /^refs\/tags\/release-publish\/([a-f0-9]{12})-[1-9][0-9]*$/u; const EXACT_TARGET_EVIDENCE_REUSE_POLICY = "exact-target-full-validation-v1"; const CHANGELOG_ONLY_EVIDENCE_REUSE_POLICY = "changelog-only-release-v1"; @@ -70,6 +72,8 @@ function displayValue(value) { * @property {string} expectedRepository * @property {string | number} expectedRunId * @property {string} expectedTargetSha + * @property {string} [expectedTrustedWorkflowFullRef] + * @property {string} [expectedTrustedWorkflowSha] * @property {string} [expectedWorkflowBranch] * @property {(sha: string) => boolean} [isTrustedMainAncestor] * @property {(params: { repository: string, runId: string, targetSha: string }) => StrictReleaseEvidence} [validateEvidenceReuseStrictly] @@ -114,11 +118,28 @@ export function validateFullReleaseValidationEvidence({ expectedRepository, expectedRunId, expectedTargetSha, + expectedTrustedWorkflowFullRef, + expectedTrustedWorkflowSha, expectedWorkflowBranch, isTrustedMainAncestor, validateEvidenceReuseStrictly, }) { const run = normalizeFullReleaseValidationRun(rawRun); + const trustedWorkflowFullRef = expectedTrustedWorkflowFullRef ?? "refs/heads/main"; + const protectedTag = TRUSTED_RELEASE_PUBLISH_TAG_PATTERN.exec(trustedWorkflowFullRef); + if (protectedTag) { + if (!SHA_PATTERN.test(expectedTrustedWorkflowSha ?? "")) { + throw new Error("Protected release-publish evidence requires an exact trusted workflow SHA."); + } + if (expectedTrustedWorkflowSha.slice(0, 12) !== protectedTag[1]) { + throw new Error("Protected release-publish tag does not match its trusted workflow SHA."); + } + } else if ( + !trustedWorkflowFullRef.startsWith("refs/heads/") || + trustedWorkflowFullRef.startsWith("refs/heads/release-publish/") + ) { + throw new Error("Trusted release-publish workflow ref must be an exact protected tag."); + } const checks = [ ["databaseId", String(expectedRunId)], ["workflowName", FULL_RELEASE_WORKFLOW], @@ -176,6 +197,11 @@ export function validateFullReleaseValidationEvidence({ const pinnedMatch = PINNED_BRANCH_PATTERN.exec(run.headBranch ?? ""); if (!pinnedMatch) { + if (protectedTag) { + throw new Error( + "Protected-tag release evidence must use a canonical release-ci producer branch.", + ); + } if (run.headBranch?.startsWith("release-ci/")) { throw new Error( `Referenced full release validation run ${expectedRunId} has untrusted head branch ${run.headBranch}.`, @@ -204,6 +230,14 @@ export function validateFullReleaseValidationEvidence({ `SHA-pinned validation target ref mismatch: expected ${expectedTargetSha}, got ${displayValue(manifest.targetRef)}.`, ); } + if (protectedTag) { + if (run.headSha !== expectedTrustedWorkflowSha) { + throw new Error( + `Protected-tag release evidence workflow SHA ${run.headSha} does not match trusted tooling ${expectedTrustedWorkflowSha}.`, + ); + } + return { run, source: "sha-pinned-protected-tag" }; + } if (!isTrustedMainAncestor?.(run.headSha)) { throw new Error( `SHA-pinned validation workflow ${run.headSha} is not reachable from current main.`, @@ -267,6 +301,9 @@ export function validateFullReleaseValidationEvidence({ * runId: string | number; * validatorFile?: string; * verifierSourceSha?: string; + * trustedWorkflowFullRef?: string; + * trustedWorkflowRef?: string; + * trustedWorkflowSha?: string; * }} params */ export function runStrictReleaseEvidenceValidation({ @@ -274,6 +311,9 @@ export function runStrictReleaseEvidenceValidation({ runId, validatorFile = fileURLToPath(new URL("./release-ci-summary.mjs", import.meta.url)), verifierSourceSha, + trustedWorkflowFullRef = "refs/heads/main", + trustedWorkflowRef = "main", + trustedWorkflowSha, }) { const verifierSourceArgs = verifierSourceSha ? ["--verifier-source-sha", verifierSourceSha, "--verifier-source-file", validatorFile] @@ -287,8 +327,11 @@ export function runStrictReleaseEvidenceValidation({ "--repo", repository, "--trusted-workflow-ref", - "main", + trustedWorkflowRef, + "--trusted-workflow-full-ref", + trustedWorkflowFullRef, "--json", + ...(trustedWorkflowSha ? ["--trusted-workflow-sha", trustedWorkflowSha] : []), ...verifierSourceArgs, ], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, @@ -336,12 +379,17 @@ function main() { expectedRepository: process.env.GITHUB_REPOSITORY, expectedRunId: process.env.FULL_RELEASE_VALIDATION_RUN_ID, expectedTargetSha: process.env.EXPECTED_SHA, + expectedTrustedWorkflowFullRef: process.env.TRUSTED_WORKFLOW_FULL_REF, + expectedTrustedWorkflowSha: process.env.TRUSTED_WORKFLOW_SHA, expectedWorkflowBranch: process.env.EXPECTED_WORKFLOW_BRANCH, isTrustedMainAncestor: (sha) => gitIsAncestor(sha, trustedMainRef), validateEvidenceReuseStrictly: ({ repository, runId }) => runStrictReleaseEvidenceValidation({ repository, runId, + trustedWorkflowFullRef: process.env.TRUSTED_WORKFLOW_FULL_REF, + trustedWorkflowRef: process.env.TRUSTED_WORKFLOW_REF, + trustedWorkflowSha: process.env.TRUSTED_WORKFLOW_SHA, validatorFile: process.env.STRICT_VALIDATOR_FILE ?? fileURLToPath(new URL("./release-ci-summary.mjs", import.meta.url)), diff --git a/scripts/validate-release-publish-approval.mjs b/scripts/validate-release-publish-approval.mjs index 96f96833290a..336877e92a30 100644 --- a/scripts/validate-release-publish-approval.mjs +++ b/scripts/validate-release-publish-approval.mjs @@ -11,6 +11,8 @@ const allowCompletedSuccessfulParent = process.env.ALLOW_COMPLETED_SUCCESSFUL_PA const approvalPath = process.env.APPROVAL_PATH ?? ""; const approvalKind = process.env.RELEASE_APPROVAL_KIND ?? "android"; const expectedRunAttempt = process.env.EXPECTED_RUN_ATTEMPT ?? ""; +const expectedWorkflowFullRef = process.env.EXPECTED_WORKFLOW_FULL_REF ?? ""; +const expectedWorkflowSha = process.env.EXPECTED_WORKFLOW_SHA ?? ""; const childWorkflowSha = process.env.CHILD_WORKFLOW_SHA ?? ""; function fail(message) { @@ -92,6 +94,9 @@ const checks = [ ["headBranch", expectedBranch], ["event", "workflow_dispatch"], ]; +if (process.env.GITHUB_REPOSITORY) { + checks.push(["repository", process.env.GITHUB_REPOSITORY]); +} for (const [key, expected] of checks) { if (run[key] !== expected) { @@ -101,6 +106,21 @@ for (const [key, expected] of checks) { } } +if (expectedWorkflowSha && run.headSha !== expectedWorkflowSha) { + fail( + `Referenced release publish run ${releasePublishRunId} must use tooling SHA ${expectedWorkflowSha}, got ${run.headSha ?? ""}.`, + ); +} +if (expectedWorkflowFullRef) { + const [workflowPath, workflowFullRef] = String(run.path ?? "").split("@", 2); + if (workflowPath !== ".github/workflows/openclaw-release-publish.yml") { + fail(`Referenced release publish run ${releasePublishRunId} has untrusted workflow path.`); + } + if (workflowFullRef && workflowFullRef !== expectedWorkflowFullRef) { + fail(`Referenced release publish run ${releasePublishRunId} has untrusted workflow full ref.`); + } +} + if (expectedRunAttempt && run.runAttempt !== positiveRunAttempt(expectedRunAttempt)) { fail( `Referenced release publish run ${releasePublishRunId} must use attempt ${expectedRunAttempt}, got ${run.runAttempt ?? ""}.`, diff --git a/test/scripts/find-reusable-release-validation.test.ts b/test/scripts/find-reusable-release-validation.test.ts index 414ea873af83..65267198ee0b 100644 --- a/test/scripts/find-reusable-release-validation.test.ts +++ b/test/scripts/find-reusable-release-validation.test.ts @@ -207,6 +207,7 @@ function normalizedEvidence(options: { validationInputs?: Record | null; verifierSha?: string | null; workflowRef?: string; + trustedWorkflowRef?: string; }): NormalizedEvidence { const runId = options.runId ?? "111"; const producerSha = options.producerSha ?? PRODUCER_SHA; @@ -215,6 +216,11 @@ function normalizedEvidence(options: { const workflowRef = options.workflowRef ?? "main"; const workflowFullRef = `refs/heads/${workflowRef}`; const shaPinned = workflowRef.startsWith("release-ci/"); + const trustedWorkflowRef = options.trustedWorkflowRef ?? "main"; + const protectedTagRoute = trustedWorkflowRef.startsWith("release-publish/"); + const trustedWorkflowFullRef = protectedTagRoute + ? `refs/tags/${trustedWorkflowRef}` + : "refs/heads/main"; const validationInputs = options.validationInputs === undefined ? DEFAULT_INPUTS : options.validationInputs; const npmTelegramRequired = @@ -269,14 +275,16 @@ function normalizedEvidence(options: { status: "completed", targetSha: options.targetSha, url: `https://example.test/runs/${runId}`, - producerOnTrustedMainLineage: true, + producerOnTrustedMainLineage: !protectedTagRoute, workflowFullRef, workflowPath: ".github/workflows/full-release-validation.yml", workflowQualifiedPath: `.github/workflows/full-release-validation.yml@${workflowFullRef}`, workflowRef, - workflowRefProof: shaPinned - ? "manifest-v3-sha-pinned-main-ancestry" - : "legacy-v2-main-ancestry", + workflowRefProof: protectedTagRoute + ? "manifest-v3-protected-tag-exact-sha" + : shaPinned + ? "manifest-v3-sha-pinned-main-ancestry" + : "legacy-v2-main-ancestry", workflowRefType: "branch", workflowRunPath: shaPinned ? `.github/workflows/full-release-validation.yml@${workflowFullRef}` @@ -362,9 +370,9 @@ function normalizedEvidence(options: { root, runReleaseSoak: soak, schema: "openclaw.release-validation-evidence/v3", - producerOnTrustedMainLineage: true, - trustedWorkflowFullRef: "refs/heads/main", - trustedWorkflowRef: "main", + producerOnTrustedMainLineage: !protectedTagRoute, + trustedWorkflowFullRef, + trustedWorkflowRef, valid: true, validationInputs, verifier: { @@ -404,16 +412,22 @@ import { join } from "node:path"; const runIndex = process.argv.indexOf("--validate-run"); const repoIndex = process.argv.indexOf("--repo"); const trustedRefIndex = process.argv.indexOf("--trusted-workflow-ref"); +const trustedFullRefIndex = process.argv.indexOf("--trusted-workflow-full-ref"); +const trustedShaIndex = process.argv.indexOf("--trusted-workflow-sha"); const verifierShaIndex = process.argv.indexOf("--verifier-source-sha"); const verifierFileIndex = process.argv.indexOf("--verifier-source-file"); if ( runIndex < 0 || repoIndex < 0 || trustedRefIndex < 0 || + trustedFullRefIndex < 0 || + trustedShaIndex < 0 || verifierShaIndex < 0 || verifierFileIndex < 0 || process.argv[repoIndex + 1] !== "openclaw/openclaw" || - process.argv[trustedRefIndex + 1] !== "main" || + process.argv[trustedRefIndex + 1] !== process.env.FAKE_TRUSTED_WORKFLOW_REF || + process.argv[trustedFullRefIndex + 1] !== process.env.FAKE_TRUSTED_WORKFLOW_FULL_REF || + process.argv[trustedShaIndex + 1] !== process.env.FAKE_TRUSTED_WORKFLOW_SHA || process.argv[verifierShaIndex + 1] !== process.env.FAKE_VERIFIER_SHA || process.argv[verifierFileIndex + 1] !== process.argv[1] || !process.argv.includes("--json") @@ -481,12 +495,22 @@ function runResolver(args: { repoDir: string; runReleaseSoak?: string; targetSha: string; + trustedTagSha?: string; + trustedTagType?: string; + trustedWorkflowFullRef?: string; + trustedWorkflowRef?: string; + trustedWorkflowSha?: string; validatorPath: string; verifierOnMain?: boolean; verifierSha?: string; workflowRef?: string; }) { const verifierSha = args.verifierSha ?? VERIFIER_SHA; + const trustedWorkflowRef = args.trustedWorkflowRef ?? "main"; + const trustedWorkflowFullRef = + args.trustedWorkflowFullRef ?? + (trustedWorkflowRef === "main" ? "refs/heads/main" : `refs/tags/${trustedWorkflowRef}`); + const trustedWorkflowSha = args.trustedWorkflowSha ?? verifierSha; writeFileSync( fixtureName(args.fixtures, `repos/${REPOSITORY}/compare/${verifierSha}...main`), JSON.stringify({ @@ -494,6 +518,17 @@ function runResolver(args: { status: args.verifierOnMain === false ? "diverged" : "ahead", }), ); + if (trustedWorkflowRef !== "main") { + writeFileSync( + fixtureName(args.fixtures, `repos/${REPOSITORY}/git/ref/tags/${trustedWorkflowRef}`), + JSON.stringify({ + object: { + sha: args.trustedTagSha ?? trustedWorkflowSha, + type: args.trustedTagType ?? "commit", + }, + }), + ); + } if (args.compareBaseSha) { writeFileSync( fixtureName( @@ -525,6 +560,12 @@ function runResolver(args: { verifierSha, "--workflow-ref", args.workflowRef ?? "main", + "--trusted-workflow-ref", + trustedWorkflowRef, + "--trusted-workflow-full-ref", + trustedWorkflowFullRef, + "--trusted-workflow-sha", + trustedWorkflowSha, "--release-profile", args.releaseProfile ?? "full", "--run-release-soak", @@ -542,6 +583,9 @@ function runResolver(args: { env: { ...process.env, FAKE_GH_FIXTURES: args.fixtures, + FAKE_TRUSTED_WORKFLOW_FULL_REF: trustedWorkflowFullRef, + FAKE_TRUSTED_WORKFLOW_REF: trustedWorkflowRef, + FAKE_TRUSTED_WORKFLOW_SHA: trustedWorkflowSha, FAKE_VALIDATOR_FIXTURES: args.fixtures, FAKE_VERIFIER_SHA: verifierSha, GITHUB_OUTPUT: "", @@ -593,6 +637,82 @@ describe("scripts/github/find-reusable-release-validation.sh", () => { }); }); + it("reuses strict evidence through the exact lightweight protected tooling tag", () => { + const { clone, priorSha } = getSharedRepo(); + const trustedWorkflowRef = `release-publish/${VERIFIER_SHA.slice(0, 12)}-456`; + const producerRef = `release-ci/${VERIFIER_SHA.slice(0, 12)}-122`; + const record = normalizedEvidence({ + producerSha: VERIFIER_SHA, + targetSha: priorSha, + trustedWorkflowRef, + workflowRef: producerRef, + }); + const { binDir, fixtures, validatorPath } = setUpFixtures([{ record, runId: "111" }]); + + const result = runResolver({ + binDir, + fixtures, + repoDir: clone, + targetSha: priorSha, + trustedWorkflowRef, + validatorPath, + verifierOnMain: false, + workflowRef: `release-ci/${VERIFIER_SHA.slice(0, 12)}-123`, + }); + + expect(result.status).toBe(0); + expect(parseOutput(result.stdout)).toMatchObject({ + evidence_run_id: "111", + reuse: "true", + }); + }); + + it.each([ + { + label: "moved protected tag", + options: { + trustedTagSha: "d".repeat(40), + }, + }, + { + label: "annotated protected tag", + options: { + trustedTagType: "tag", + }, + }, + { + label: "same-name branch", + options: { + trustedWorkflowFullRef: `refs/heads/release-publish/${VERIFIER_SHA.slice(0, 12)}-456`, + }, + }, + ])("rejects protected tooling identity drift: $label", ({ options }) => { + const { clone, priorSha } = getSharedRepo(); + const trustedWorkflowRef = `release-publish/${VERIFIER_SHA.slice(0, 12)}-456`; + const producerRef = `release-ci/${VERIFIER_SHA.slice(0, 12)}-122`; + const record = normalizedEvidence({ + producerSha: VERIFIER_SHA, + targetSha: priorSha, + trustedWorkflowRef, + workflowRef: producerRef, + }); + const { binDir, fixtures, validatorPath } = setUpFixtures([{ record, runId: "111" }]); + + const result = runResolver({ + binDir, + fixtures, + repoDir: clone, + targetSha: priorSha, + trustedWorkflowRef, + validatorPath, + workflowRef: `release-ci/${VERIFIER_SHA.slice(0, 12)}-123`, + ...options, + }); + + expect(result.status).toBe(0); + expect(parseOutput(result.stdout)).toMatchObject({ reuse: "false" }); + }); + it("reuses npm Telegram evidence only when its selectors match exactly", () => { const { clone, priorSha } = getSharedRepo(); const validationInputs = { diff --git a/test/scripts/full-release-validation-at-sha.test.ts b/test/scripts/full-release-validation-at-sha.test.ts index 0a05232e76b5..5e2348f48ca8 100644 --- a/test/scripts/full-release-validation-at-sha.test.ts +++ b/test/scripts/full-release-validation-at-sha.test.ts @@ -3,6 +3,7 @@ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { describe, expect, it } from "vitest"; +import { parse as parseYaml } from "yaml"; import { assertTrustedWorkflowHarness, FULL_RELEASE_WAIT_POLL_INTERVAL_MS, @@ -14,18 +15,25 @@ import { resolveRemoteTargetRefSha, shouldDeleteTemporaryWorkflowRef, verifyTargetRef, + verifyTrustedWorkflowRef, } from "../../scripts/full-release-validation-at-sha.mts"; const SCRIPT_PATH = resolve("scripts/full-release-validation-at-sha.mjs"); -const CURRENT_WORKFLOW_SOURCE = `name: Full Release Validation -env: - RELEASE_ISOLATION_TOOLING_CONTRACT: "1" -on: - workflow_dispatch: - inputs: - expected_sha: - required: false -`; +const CURRENT_WORKFLOW_SOURCE = readFileSync( + ".github/workflows/full-release-validation.yml", + "utf8", +); +const CONTRACT_ONE_WORKFLOW_SOURCE = CURRENT_WORKFLOW_SOURCE.replace( + 'RELEASE_ISOLATION_TOOLING_CONTRACT: "2"', + 'RELEASE_ISOLATION_TOOLING_CONTRACT: "1"', +).replace( + ` trusted_workflow_json: + description: Trusted release tooling identity JSON + required: true + type: string +`, + "", +); const LEGACY_WORKFLOW_SOURCE = `name: Full Release Validation on: workflow_dispatch: @@ -70,8 +78,10 @@ function createDispatchFixture(options: { workflowSource?: string } = {}) { join(checkout, "scripts", "release-ci-summary.mjs"), `const expected = [ "--validate-run", "123", - "--trusted-workflow-ref", "main", - "--json", + "--trusted-workflow-ref", process.env.MOCK_TRUSTED_WORKFLOW_REF, + "--trusted-workflow-full-ref", process.env.MOCK_TRUSTED_WORKFLOW_FULL_REF, + "--trusted-workflow-sha", process.env.MOCK_WORKFLOW_SHA, + "--json", "--verifier-source-sha", process.env.MOCK_WORKFLOW_SHA, "--verifier-source-file", process.argv[1], ]; @@ -89,12 +99,21 @@ console.log(JSON.stringify({ valid: true, current: { runId: "123" }, root: { run join(checkout, ".github", "workflows", "full-release-validation.yml"), options.workflowSource ?? CURRENT_WORKFLOW_SOURCE, ); + const workflow = parseYaml( + readFileSync(join(checkout, ".github", "workflows", "full-release-validation.yml"), "utf8"), + ) as { + on?: { workflow_dispatch?: { inputs?: Record } }; + }; + const declaredWorkflowInputs = Object.keys(workflow.on?.workflow_dispatch?.inputs ?? {}); writeFileSync(join(checkout, "package.json"), '{"version":"2026.8.1"}\n'); runGit(checkout, ["add", ".github/workflows/full-release-validation.yml", "package.json"]); runGit(checkout, ["commit", "-m", "test: trusted workflow contract"]); const workflowSha = runGit(checkout, ["rev-parse", "HEAD"]); + const trustedWorkflowTag = `release-publish/${workflowSha.slice(0, 12)}-123`; runGit(checkout, ["remote", "add", "origin", origin]); runGit(checkout, ["push", "-u", "origin", "main"]); + runGit(checkout, ["tag", trustedWorkflowTag, workflowSha]); + runGit(checkout, ["push", "origin", `refs/tags/${trustedWorkflowTag}`]); runGit(checkout, ["checkout", "-b", releaseRef]); writeFileSync(join(checkout, "target.txt"), "release target\n"); runGit(checkout, ["add", "target.txt"]); @@ -128,6 +147,17 @@ const fs = require("node:fs"); const args = process.argv.slice(2); fs.appendFileSync(process.env.MOCK_GH_CALLS, JSON.stringify(args) + "\\n"); if (args[0] === "workflow" && args[1] === "run") { + const declaredInputs = new Set(JSON.parse(process.env.MOCK_WORKFLOW_INPUTS)); + for (let index = 0; index < args.length; index += 1) { + if (args[index] !== "-f") continue; + const assignment = args[index + 1] || ""; + const key = assignment.slice(0, assignment.indexOf("=")); + if (!declaredInputs.has(key)) { + console.error("workflow input is not declared: " + key); + process.exit(2); + } + index += 1; + } console.log("https://github.com/openclaw/openclaw/actions/runs/123"); } else if (args[0] === "api" && args.at(-1).endsWith("/actions/runs/123")) { console.log(JSON.stringify({ status: "completed", conclusion: "success", head_sha: process.env.MOCK_WORKFLOW_SHA })); @@ -139,8 +169,13 @@ if (args[0] === "workflow" && args[1] === "run") { ); chmodSync(ghPath, 0o755); - const run = (extraArgs: string[] = []) => - spawnSync( + const run = (extraArgs: string[] = []) => { + const trustedRefIndex = extraArgs.indexOf("--trusted-workflow-ref"); + const trustedWorkflowRef = + trustedRefIndex >= 0 ? (extraArgs[trustedRefIndex + 1] ?? "") : "main"; + const trustedWorkflowFullRef = + trustedWorkflowRef === "main" ? "refs/heads/main" : `refs/tags/${trustedWorkflowRef}`; + return spawnSync( process.execPath, [SCRIPT_PATH, "--sha", targetSha, "--target-ref", releaseRef, ...extraArgs], { @@ -151,11 +186,15 @@ if (args[0] === "workflow" && args[1] === "run") { MOCK_GH_CALLS: ghCallsPath, MOCK_GIT_CALLS: gitCallsPath, MOCK_REAL_PATH: process.env.PATH, + MOCK_TRUSTED_WORKFLOW_FULL_REF: trustedWorkflowFullRef, + MOCK_TRUSTED_WORKFLOW_REF: trustedWorkflowRef, + MOCK_WORKFLOW_INPUTS: JSON.stringify(declaredWorkflowInputs), MOCK_WORKFLOW_SHA: workflowSha, PATH: `${binDir}:${process.env.PATH}`, }, }, ); + }; const readCalls = (path: string): string[][] => readFileSync(path, "utf8") .trim() @@ -174,6 +213,7 @@ if (args[0] === "workflow" && args[1] === "run") { releaseRef, run, targetSha, + trustedWorkflowTag, workflowSha, }; } @@ -186,6 +226,8 @@ describe("full-release-validation-at-sha", () => { "abc123", "--workflow-sha", "a".repeat(40), + "--trusted-workflow-ref", + `release-publish/${"a".repeat(12)}-123`, "--target-ref", "release/2026.7.1", "--keep-branch", @@ -206,6 +248,7 @@ describe("full-release-validation-at-sha", () => { }, sha: "abc123", targetRef: "release/2026.7.1", + trustedWorkflowRef: `release-publish/${"a".repeat(12)}-123`, workflowSha: "a".repeat(40), }); }); @@ -221,6 +264,16 @@ describe("full-release-validation-at-sha", () => { expect(() => parseArgs(["--", "-f"])).toThrow("-f requires a value"); }); + it("requires an exact Tooling SHA for protected workflow tags", () => { + const trustedTag = `release-publish/${"a".repeat(12)}-123`; + expect(() => parseArgs(["--trusted-workflow-ref", trustedTag])).toThrow( + "explicit full Tooling SHA", + ); + expect(() => + parseArgs(["--workflow-sha", "a".repeat(40), "--trusted-workflow-ref", "release/2026.8.1"]), + ).toThrow("protected release-publish"); + }); + it("rejects retry groups that are not controller APIs", () => { expect(() => parseArgs(["-f", "rerun_group=release-checks"])).toThrow( "rerun_group must be one of", @@ -403,6 +456,9 @@ describe("full-release-validation-at-sha", () => { expect(() => parseArgs(["--", `expected_sha=${"a".repeat(40)}`])).toThrow( "reserves expected_sha", ); + expect(() => parseArgs(["-f", "trusted_workflow_json={}"])).toThrow( + "reserves trusted_workflow_json", + ); }); it("validates direct and reused runs through the strict evidence verifier", () => { @@ -413,6 +469,10 @@ describe("full-release-validation-at-sha", () => { "123", "--trusted-workflow-ref", "main", + "--trusted-workflow-full-ref", + "refs/heads/main", + "--trusted-workflow-sha", + workflowSha, "--json", "--verifier-source-sha", workflowSha, @@ -422,6 +482,71 @@ describe("full-release-validation-at-sha", () => { expect(() => releaseEvidenceVerificationArgs("", workflowSha, verifier)).toThrow( "positive decimal", ); + const trustedTag = `release-publish/${workflowSha.slice(0, 12)}-123`; + expect(releaseEvidenceVerificationArgs("123", workflowSha, verifier, trustedTag)).toEqual([ + "--validate-run", + "123", + "--trusted-workflow-ref", + trustedTag, + "--trusted-workflow-full-ref", + `refs/tags/${trustedTag}`, + "--trusted-workflow-sha", + workflowSha, + "--json", + "--verifier-source-sha", + workflowSha, + "--verifier-source-file", + verifier, + ]); + expect(() => + releaseEvidenceVerificationArgs("123", workflowSha, verifier, "release/2026.8.1"), + ).toThrow("protected release-publish tag"); + }); + + it("accepts only exact protected workflow tags outside main ancestry", () => { + const workflowSha = "a".repeat(40); + const trustedTag = `release-publish/${workflowSha.slice(0, 12)}-123`; + + expect(() => + verifyTrustedWorkflowRef( + workflowSha, + "main", + () => "", + () => true, + ), + ).not.toThrow(); + expect(() => + verifyTrustedWorkflowRef( + workflowSha, + "main", + () => "", + () => false, + ), + ).toThrow("not reachable from current origin/main"); + expect(() => + verifyTrustedWorkflowRef( + workflowSha, + trustedTag, + () => workflowSha, + () => false, + ), + ).not.toThrow(); + expect(() => + verifyTrustedWorkflowRef( + workflowSha, + `release-publish/${"b".repeat(12)}-123`, + () => workflowSha, + ), + ).toThrow("does not match Tooling SHA"); + expect(() => verifyTrustedWorkflowRef(workflowSha, trustedTag, () => "")).toThrow( + "does not exist on origin", + ); + expect(() => verifyTrustedWorkflowRef(workflowSha, trustedTag, () => "c".repeat(40))).toThrow( + `expected ${workflowSha}`, + ); + expect(() => + verifyTrustedWorkflowRef(workflowSha, "release/2026.8.1", () => workflowSha), + ).toThrow("protected release-publish"); }); it("bounds polling for the exact workflow run", () => { @@ -461,7 +586,7 @@ describe("full-release-validation-at-sha", () => { }, () => CURRENT_WORKFLOW_SOURCE, ), - ).toBe(verifierPath); + ).toEqual({ contract: "2", verifierPath }); expect(checked).toEqual([workflowPath, verifierPath]); expect(() => assertTrustedWorkflowHarness("a".repeat(40), () => false)).toThrow(workflowPath); expect(() => @@ -477,15 +602,30 @@ describe("full-release-validation-at-sha", () => { () => true, () => LEGACY_WORKFLOW_SOURCE, ), - ).toThrow("does not declare RELEASE_ISOLATION_TOOLING_CONTRACT=1"); + ).toThrow("does not declare a supported RELEASE_ISOLATION_TOOLING_CONTRACT"); expect(() => assertTrustedWorkflowHarness( "b".repeat(40), () => true, () => - 'env:\n RELEASE_ISOLATION_TOOLING_CONTRACT: "1"\non:\n workflow_dispatch:\n inputs: {}\n', + 'env:\n RELEASE_ISOLATION_TOOLING_CONTRACT: "2"\non:\n workflow_dispatch:\n inputs: {}\n', ), ).toThrow(`Tooling SHA ${"b".repeat(40)} is missing workflow_dispatch input expected_sha`); + expect(() => + assertTrustedWorkflowHarness( + "b".repeat(40), + () => true, + () => + 'env:\n RELEASE_ISOLATION_TOOLING_CONTRACT: "2"\non:\n workflow_dispatch:\n inputs:\n expected_sha: {}\n', + ), + ).toThrow("missing workflow_dispatch input trusted_workflow_json"); + expect( + assertTrustedWorkflowHarness( + "b".repeat(40), + () => true, + () => CONTRACT_ONE_WORKFLOW_SOURCE, + ), + ).toEqual({ contract: "1", verifierPath }); }); it("retains a failed parent workflow ref for GitHub reruns", () => { @@ -576,6 +716,11 @@ describe("full-release-validation-at-sha", () => { target_context_ref: fixture.releaseRef, allow_unreleased_changelog: "false", }); + expect(JSON.parse(dispatchInputs.trusted_workflow_json ?? "{}")).toEqual({ + ref: "main", + fullRef: "refs/heads/main", + sha: fixture.workflowSha, + }); expect(ghCalls).toContainEqual(["api", "repos/openclaw/openclaw/actions/runs/123"]); expect(ghCalls.some((args) => args[0] === "graphql")).toBe(false); expect(ghCalls.some((args) => args[0] === "run" && args[1] === "watch")).toBe(false); @@ -604,10 +749,66 @@ describe("full-release-validation-at-sha", () => { } }); + it("dispatches non-main tooling only when its exact protected tag is supplied", () => { + const fixture = createDispatchFixture(); + try { + const result = fixture.run([ + "--workflow-sha", + fixture.workflowSha, + "--trusted-workflow-ref", + fixture.trustedWorkflowTag, + ]); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain(`Trusted workflow ref: ${fixture.trustedWorkflowTag}`); + expect(fixture.readCalls(fixture.gitCallsPath)).toContainEqual([ + "ls-remote", + "--tags", + "origin", + `refs/tags/${fixture.trustedWorkflowTag}`, + ]); + const dispatch = fixture + .readCalls(fixture.ghCallsPath) + .find((args) => args[0] === "workflow" && args[1] === "run"); + const trustedIdentity = dispatch + ?.find((arg) => arg.startsWith("trusted_workflow_json=")) + ?.slice("trusted_workflow_json=".length); + expect(JSON.parse(trustedIdentity ?? "{}")).toEqual({ + ref: fixture.trustedWorkflowTag, + fullRef: `refs/tags/${fixture.trustedWorkflowTag}`, + sha: fixture.workflowSha, + }); + } finally { + fixture.cleanup(); + } + }); + + it("disables evidence reuse and omits the contract 2 input for contract 1 tooling", () => { + const fixture = createDispatchFixture({ workflowSource: CONTRACT_ONE_WORKFLOW_SOURCE }); + try { + const result = fixture.run([ + "--workflow-sha", + fixture.workflowSha, + "--trusted-workflow-ref", + fixture.trustedWorkflowTag, + ]); + expect(result.status, result.stderr).toBe(0); + const dispatch = fixture + .readCalls(fixture.ghCallsPath) + .find((args) => args[0] === "workflow" && args[1] === "run"); + const assignments = (dispatch ?? []) + .filter((_value, index, values) => values[index - 1] === "-f") + .map((value) => value.split("=", 1)[0]); + expect(assignments).not.toContain("trusted_workflow_json"); + expect(dispatch).toContain("reuse_evidence=false"); + } finally { + fixture.cleanup(); + } + }); + it("rejects pinned old-schema tooling before either remote ref is pushed", () => { const fixture = createDispatchFixture({ workflowSource: - 'name: Full Release Validation\nenv:\n RELEASE_ISOLATION_TOOLING_CONTRACT: "1"\non:\n workflow_dispatch:\n', + 'name: Full Release Validation\nenv:\n RELEASE_ISOLATION_TOOLING_CONTRACT: "2"\non:\n workflow_dispatch:\n', }); try { const result = fixture.run(["--workflow-sha", fixture.workflowSha]); @@ -629,7 +830,9 @@ describe("full-release-validation-at-sha", () => { const result = fixture.run(["--workflow-sha", fixture.oldWorkflowSha]); expect(result.status).toBe(1); expect(result.stderr).toContain(`Tooling SHA ${fixture.oldWorkflowSha}`); - expect(result.stderr).toContain("does not declare RELEASE_ISOLATION_TOOLING_CONTRACT=1"); + expect(result.stderr).toContain( + "does not declare a supported RELEASE_ISOLATION_TOOLING_CONTRACT", + ); expect(fixture.readCalls(fixture.gitCallsPath).filter((args) => args[0] === "push")).toEqual( [], ); diff --git a/test/scripts/openclaw-npm-resume-run.test.ts b/test/scripts/openclaw-npm-resume-run.test.ts index 97dee1d6a30d..4de6afdace91 100644 --- a/test/scripts/openclaw-npm-resume-run.test.ts +++ b/test/scripts/openclaw-npm-resume-run.test.ts @@ -24,7 +24,7 @@ function fixture( head_branch: BRANCH, head_sha: SHA, html_url: URL, - path: `.github/workflows/openclaw-npm-release.yml@refs/tags/${BRANCH}`, + path: ".github/workflows/openclaw-npm-release.yml", workflow_id: 101, }, tag: { @@ -32,6 +32,8 @@ function fixture( verification: { verified: true }, }, tagRef: { object: { sha: TAG_OBJECT_SHA, type: "tag" } }, + trustedWorkflowFullRef: `refs/tags/${BRANCH}`, + trustedWorkflowRef: BRANCH, ...overrides, }; } @@ -80,8 +82,46 @@ describe("openclaw npm resume run identity", () => { }); }); + it("accepts a successful run bound to the exact lightweight protected tooling tag", () => { + expect( + validateOpenClawNpmResumeRun( + fixture({ + compareStatus: undefined, + tag: {}, + tagRef: { object: { sha: SHA, type: "commit" } }, + }), + ), + ).toEqual({ + tagObjectSha: SHA, + url: URL, + workflowRef: `refs/tags/${BRANCH}`, + workflowSha: SHA, + }); + }); + + it("accepts the canonical path shape returned by the Actions workflow run API", () => { + expect( + validateOpenClawNpmResumeRun( + fixture({ + run: { + conclusion: "success", + event: "workflow_dispatch", + head_branch: BRANCH, + head_sha: SHA, + html_url: URL, + path: ".github/workflows/openclaw-npm-release.yml", + workflow_id: 101, + }, + }), + ), + ).toMatchObject({ + workflowRef: `refs/tags/${BRANCH}`, + workflowSha: SHA, + }); + }); + it.each([ - ["branch", { run: { ...fixture().run, head_branch: "main" } }, "untrusted workflow ref"], + ["branch", { run: { ...fixture().run, head_branch: "main" } }, "untrusted workflow identity"], ["workflow", { run: { ...fixture().run, workflow_id: 999 } }, "untrusted workflow identity"], ["event", { run: { ...fixture().run, event: "push" } }, "untrusted workflow identity"], [ @@ -94,10 +134,29 @@ describe("openclaw npm resume run identity", () => { { run: { ...fixture().run, path: ".github/workflows/ci.yml" } }, "untrusted workflow identity", ], + [ + "same-name branch full ref", + { trustedWorkflowFullRef: `refs/heads/${BRANCH}` }, + "untrusted workflow ref", + ], + [ + "mismatched supplied ref", + { trustedWorkflowRef: `release-publish/${SHA.slice(0, 12)}-124` }, + "untrusted workflow ref", + ], [ "tag kind", - { tagRef: { object: { sha: TAG_OBJECT_SHA, type: "commit" } } }, - "not a signed annotated tag", + { tagRef: { object: { sha: TAG_OBJECT_SHA, type: "tree" } } }, + "not a protected tag", + ], + [ + "moved lightweight tag", + { + compareStatus: undefined, + tag: {}, + tagRef: { object: { sha: "c".repeat(40), type: "commit" } }, + }, + "moved after dispatch", ], [ "tag target", @@ -140,8 +199,52 @@ describe("openclaw npm resume run identity", () => { }); expect( - resolveOpenClawNpmResumeRun({ repo: "openclaw/openclaw", runGh, runId: "456" }), + resolveOpenClawNpmResumeRun({ + repo: "openclaw/openclaw", + runGh, + runId: "456", + trustedWorkflowFullRef: `refs/tags/${BRANCH}`, + trustedWorkflowRef: BRANCH, + }), ).toMatchObject({ workflowRef: `refs/tags/${BRANCH}`, workflowSha: SHA }); expect(runGh).toHaveBeenCalledTimes(6); }); + + it("loads a lightweight protected tag without requiring tag metadata or main ancestry", () => { + const lightweight = fixture({ + compareStatus: undefined, + tag: {}, + tagRef: { object: { sha: SHA, type: "commit" } }, + }); + const responses = new Map([ + [`api repos/openclaw/openclaw/actions/runs/456 --method GET`, lightweight.run], + [ + `api repos/openclaw/openclaw/actions/workflows/openclaw-npm-release.yml --method GET`, + { id: 101 }, + ], + [`api repos/openclaw/openclaw/git/ref/tags/${BRANCH} --method GET`, lightweight.tagRef], + [`run view 456 --repo openclaw/openclaw --json jobs --jq .jobs`, lightweight.jobs], + ]); + const runGh = vi.fn((args: string[]) => { + const response = responses.get(args.join(" ")); + if (!response) { + throw new Error(`Unexpected gh invocation: ${args.join(" ")}`); + } + return JSON.stringify(response); + }); + + expect( + resolveOpenClawNpmResumeRun({ + repo: "openclaw/openclaw", + runGh, + runId: "456", + trustedWorkflowFullRef: `refs/tags/${BRANCH}`, + trustedWorkflowRef: BRANCH, + }), + ).toMatchObject({ workflowRef: `refs/tags/${BRANCH}`, workflowSha: SHA }); + expect(runGh).toHaveBeenCalledTimes(4); + expect(runGh.mock.calls.flatMap(([args]) => args)).not.toContain( + `repos/openclaw/openclaw/compare/${SHA}...main`, + ); + }); }); diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index 223553a2a866..33c84509832a 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -931,8 +931,11 @@ function runOpenClawNpmTrustedRefGuard(overrides: Record) { throw new Error("Expected OpenClaw npm trusted ref guard"); } const binDir = tempDirs.make("openclaw-npm-trusted-ref-"); + const ghPath = `${binDir}/gh`; const gitPath = `${binDir}/git`; const timeoutPath = `${binDir}/timeout`; + writeFileSync(ghPath, `#!/bin/sh\nprintf '%s\\n' "\${MOCK_REMOTE_TAG_SHA}"\n`); + chmodSync(ghPath, 0o755); writeFileSync( gitPath, `#!/bin/sh\nif [ "$1" = "fetch" ]; then exit 0; fi\nif [ "$1" = "merge-base" ]; then [ "\${MOCK_WORKFLOW_ANCESTOR}" = "true" ]; exit $?; fi\nexit 2\n`, @@ -946,6 +949,8 @@ function runOpenClawNpmTrustedRefGuard(overrides: Record) { return spawnSync("bash", ["-c", script], { encoding: "utf8", env: { + GITHUB_REPOSITORY: "openclaw/openclaw", + MOCK_REMOTE_TAG_SHA: "a".repeat(40), MOCK_WORKFLOW_ANCESTOR: "true", PATH: `${binDir}:${process.env.PATH}`, RELEASE_NPM_DIST_TAG: "beta", @@ -957,6 +962,203 @@ function runOpenClawNpmTrustedRefGuard(overrides: Record) { }); } +function runPluginNpmPreflightToolingGuard(overrides: Record) { + const job = workflowJob(PLUGIN_NPM_RELEASE_WORKFLOW, "preview_plugins_npm"); + const script = workflowStep(job, "Verify trusted preflight tooling identity").run; + if (!script) { + throw new Error("Expected plugin npm preflight tooling identity guard"); + } + const workdir = tempDirs.make("plugin-npm-preflight-tooling-"); + const binDir = resolve(workdir, "bin"); + const toolingDir = resolve(workdir, ".release-tooling/scripts"); + const toolingLibDir = resolve(toolingDir, "lib"); + mkdirSync(binDir, { recursive: true }); + mkdirSync(toolingLibDir, { recursive: true }); + writeFileSync( + resolve(toolingDir, "release-tooling-identity.mjs"), + readFileSync(resolve(REPO_ROOT, "scripts/release-tooling-identity.mjs")), + ); + writeFileSync( + resolve(toolingLibDir, "record-shared.mjs"), + readFileSync(resolve(REPO_ROOT, "scripts/lib/record-shared.mjs")), + ); + writeFileSync( + resolve(binDir, "gh"), + `#!/usr/bin/env bash +set -euo pipefail +[[ "$1" == "api" ]] || exit 64 +case "$2" in + */git/ref/tags/*) + [[ "$MOCK_TAG_MISSING" != "true" ]] || exit 1 + jq -cn \ + --arg ref "$MOCK_TAG_FULL_REF" \ + --arg sha "$MOCK_TAG_SHA" \ + --arg type "$MOCK_TAG_TYPE" \ + '{ref: $ref, object: {sha: $sha, type: $type}}' + ;; + */compare/*) + jq -cn --arg status "$MOCK_COMPARE_STATUS" '{status: $status}' + ;; + *) + exit 64 + ;; +esac +`, + { mode: 0o755 }, + ); + return spawnSync("bash", ["-c", script], { + cwd: workdir, + encoding: "utf8", + env: { + GITHUB_REPOSITORY: "openclaw/openclaw", + MOCK_COMPARE_STATUS: "identical", + MOCK_TAG_FULL_REF: "", + MOCK_TAG_MISSING: "false", + MOCK_TAG_SHA: "", + MOCK_TAG_TYPE: "commit", + PATH: `${binDir}:${process.env.PATH}`, + ...overrides, + }, + }); +} + +type ProtectedPreflightConsumerParams = { + currentRef: string; + currentWorkflowSha: string; + liveTagSha?: string; + preflightHeadBranch: string; + preflightHeadSha: string; +}; + +function runReleasePublishPreflightConsumerGuard(params: ProtectedPreflightConsumerParams) { + const job = workflowJob(RELEASE_PUBLISH_WORKFLOW, "resolve_release_target"); + const script = workflowStep(job, "Download OpenClaw npm preflight manifest").run; + if (!script) { + throw new Error("Expected release publish preflight consumer guard"); + } + const workdir = tempDirs.make("release-publish-preflight-consumer-"); + const binDir = resolve(workdir, "bin"); + const runnerTemp = resolve(workdir, "runner"); + mkdirSync(binDir); + mkdirSync(runnerTemp); + writeFileSync( + resolve(binDir, "gh"), + `#!/usr/bin/env bash +set -euo pipefail +if [[ "$1" == "run" && "$2" == "download" ]]; then + exit 0 +fi +if [[ "$1" == "api" ]]; then + printf '%s\\n' "$MOCK_PREFLIGHT_RUN" + exit 0 +fi +exit 64 +`, + { mode: 0o755 }, + ); + return spawnSync("bash", ["-c", script], { + cwd: workdir, + encoding: "utf8", + env: { + GITHUB_OUTPUT: resolve(workdir, "github-output"), + GITHUB_REF: params.currentRef, + GITHUB_REPOSITORY: "openclaw/openclaw", + MOCK_PREFLIGHT_RUN: JSON.stringify({ + conclusion: "success", + event: "workflow_dispatch", + head_branch: params.preflightHeadBranch, + head_sha: params.preflightHeadSha, + path: ".github/workflows/openclaw-npm-release.yml", + run_attempt: 1, + }), + PATH: `${binDir}:${process.env.PATH}`, + PREFLIGHT_RUN_ID: "111", + RELEASE_NPM_DIST_TAG: "beta", + RELEASE_TAG: "v2026.8.1-beta.3", + RUNNER_TEMP: runnerTemp, + WORKFLOW_SHA: params.currentWorkflowSha, + }, + }); +} + +function runOpenClawNpmPreflightConsumerGuard(params: ProtectedPreflightConsumerParams) { + const job = workflowJob(OPENCLAW_NPM_RELEASE_WORKFLOW, "publish_openclaw_npm"); + const script = workflowStep(job, "Verify preflight run metadata").run; + if (!script) { + throw new Error("Expected OpenClaw npm preflight consumer guard"); + } + const workdir = tempDirs.make("openclaw-npm-preflight-consumer-"); + const binDir = resolve(workdir, "bin"); + mkdirSync(binDir); + writeFileSync( + resolve(binDir, "gh"), + `#!/usr/bin/env bash +set -euo pipefail +if [[ "$1" == "run" && "$2" == "view" ]]; then + printf '%s\\n' "$MOCK_PREFLIGHT_RUN" + exit 0 +fi +if [[ "$1" == "api" ]]; then + if [[ "$2" == *"/git/ref/tags/"* ]]; then + printf '%s\\n' "$MOCK_REMOTE_TAG_SHA" + exit 0 + fi + printf '1\\n' + exit 0 +fi +exit 64 +`, + { mode: 0o755 }, + ); + writeFileSync( + resolve(binDir, "git"), + `#!/usr/bin/env bash +set -euo pipefail +if [[ "$*" == "rev-parse HEAD" ]]; then + printf '%s\\n' "$MOCK_RELEASE_SHA" + exit 0 +fi +if [[ "$*" == *"cat-file -e"* || "$*" == *"merge-base --is-ancestor"* || "$*" == *" fetch "* ]]; then + exit 0 +fi +exit 64 +`, + { mode: 0o755 }, + ); + writeFileSync( + resolve(binDir, "node"), + `#!/usr/bin/env bash +cat >/dev/null +`, + { mode: 0o755 }, + ); + return spawnSync("bash", ["-c", script], { + cwd: workdir, + encoding: "utf8", + env: { + EXPECTED_EXTENDED_STABLE_BRANCH: "", + GITHUB_OUTPUT: resolve(workdir, "github-output"), + GITHUB_REPOSITORY: "openclaw/openclaw", + MOCK_PREFLIGHT_RUN: JSON.stringify({ + conclusion: "success", + event: "workflow_dispatch", + headBranch: params.preflightHeadBranch, + headSha: params.preflightHeadSha, + url: "https://github.com/openclaw/openclaw/actions/runs/111", + workflowName: "OpenClaw NPM Release", + }), + MOCK_RELEASE_SHA: "d".repeat(40), + MOCK_REMOTE_TAG_SHA: params.liveTagSha ?? params.currentWorkflowSha, + PATH: `${binDir}:${process.env.PATH}`, + PREFLIGHT_RUN_ID: "111", + RELEASE_NPM_DIST_TAG: "beta", + RUN_KIND: "preflight", + WORKFLOW_REF: params.currentRef, + WORKFLOW_SHA: params.currentWorkflowSha, + }, + }); +} + type ReleaseCheckArtifact = { expired: boolean; id: number; @@ -1224,6 +1426,8 @@ describe("package acceptance workflow", () => { expect(dispatch.run).toContain( '-f plugin_sdk_api_acknowledgement="${PLUGIN_SDK_API_ACKNOWLEDGEMENT}"', ); + expect(dispatch.run).toContain('--trusted-workflow-ref "${PARENT_WORKFLOW_BRANCH}"'); + expect(dispatch.run).toContain('--trusted-workflow-full-ref "${GITHUB_REF}"'); }); it("requires selected plugin names or complete immutable evidence for broad publication", () => { @@ -1318,11 +1522,11 @@ describe("package acceptance workflow", () => { expect(verifyStep.run).not.toContain("npm view openclaw@extended-stable version"); }); - it("accepts only main-reachable protected SHA-pinned release publish tags", () => { + it("accepts only exact protected SHA-pinned release publish tags", () => { const workflowSha = "a".repeat(40); const binDir = tempDirs.make("release-publish-gh-"); const ghPath = `${binDir}/gh`; - writeFileSync(ghPath, `#!/bin/sh\nprintf '%s\\n' "\${MOCK_MERGE_BASE_SHA}"\n`); + writeFileSync(ghPath, `#!/bin/sh\nprintf '%s\\n' "\${MOCK_REMOTE_TAG_SHA}"\n`); chmodSync(ghPath, 0o755); const pinnedEnv = { GITHUB_REPOSITORY: "openclaw/openclaw", @@ -1333,7 +1537,7 @@ describe("package acceptance workflow", () => { const valid = runReleasePublishInputValidation({ ...pinnedEnv, - MOCK_MERGE_BASE_SHA: workflowSha, + MOCK_REMOTE_TAG_SHA: workflowSha, }); expect(valid.status, valid.stderr).toBe(0); @@ -1346,13 +1550,13 @@ describe("package acceptance workflow", () => { "SHA-pinned release publish tag does not match workflow SHA", ); - const unreachable = runReleasePublishInputValidation({ + const moved = runReleasePublishInputValidation({ ...pinnedEnv, - MOCK_MERGE_BASE_SHA: "c".repeat(40), + MOCK_REMOTE_TAG_SHA: "c".repeat(40), }); - expect(unreachable.status).toBe(1); - expect(unreachable.stderr).toContain( - "SHA-pinned release publish tag revision is not reachable from current main", + expect(moved.status).toBe(1); + expect(moved.stderr).toContain( + "SHA-pinned release publish tag does not resolve to workflow SHA", ); }); @@ -1363,6 +1567,7 @@ describe("package acceptance workflow", () => { const valid = runOpenClawNpmTrustedRefGuard({ WORKFLOW_REF: protectedRef, WORKFLOW_SHA: workflowSha, + MOCK_REMOTE_TAG_SHA: workflowSha, }); expect(valid.status, valid.stderr).toBe(0); @@ -1375,26 +1580,327 @@ describe("package acceptance workflow", () => { "SHA-pinned release-publish tag does not match the OpenClaw npm workflow SHA", ); - const unreachable = runOpenClawNpmTrustedRefGuard({ - MOCK_WORKFLOW_ANCESTOR: "false", + const moved = runOpenClawNpmTrustedRefGuard({ + MOCK_REMOTE_TAG_SHA: "c".repeat(40), WORKFLOW_REF: protectedRef, WORKFLOW_SHA: workflowSha, }); - expect(unreachable.status).toBe(1); - expect(unreachable.stderr).toContain( - "SHA-pinned OpenClaw npm workflow revision is not reachable from current main", + expect(moved.status).toBe(1); + expect(moved.stderr).toContain( + "SHA-pinned release-publish tag does not resolve to the OpenClaw npm workflow SHA", ); }); - it("allows protected SHA-pinned tooling tags to consume token-bootstrap evidence", () => { + it("runs plugin npm preflight trust from the exact workflow tooling checkout", () => { + const job = workflowJob(PLUGIN_NPM_RELEASE_WORKFLOW, "preview_plugins_npm"); + const checkout = workflowStep(job, "Checkout trusted preflight tooling"); + const identity = workflowStep(job, "Verify trusted preflight tooling identity"); + const target = workflowStep(job, "Validate ref is on a trusted publish branch"); + + expect(checkout.if).toBe("github.event_name == 'workflow_dispatch' && inputs.preflight_only"); + expect(checkout.with).toMatchObject({ + "fetch-depth": 1, + path: ".release-tooling", + "persist-credentials": false, + ref: "${{ github.workflow_sha }}", + "sparse-checkout": "scripts/lib/record-shared.mjs\nscripts/release-tooling-identity.mjs\n", + "sparse-checkout-cone-mode": false, + }); + expect(identity.if).toBe("github.event_name == 'workflow_dispatch' && inputs.preflight_only"); + expect(identity.env).toMatchObject({ + GH_TOKEN: "${{ github.token }}", + WORKFLOW_FULL_REF: "${{ github.ref }}", + WORKFLOW_REF: "${{ github.ref_name }}", + WORKFLOW_SHA: "${{ github.workflow_sha }}", + }); + expect(identity.run).toContain( + "node .release-tooling/scripts/release-tooling-identity.mjs verify", + ); + expect(target.run).not.toContain('WORKFLOW_REF}" != "refs/heads/main'); + expect(target.run).not.toContain('git merge-base --is-ancestor "${WORKFLOW_SHA}" origin/main'); + }); + + it("accepts only the live exact lightweight protected tag for plugin npm preflight", () => { + const workflowSha = "a".repeat(40); + const workflowRef = `release-publish/${workflowSha.slice(0, 12)}-123`; + const workflowFullRef = `refs/tags/${workflowRef}`; + const baseEnv = { + MOCK_TAG_FULL_REF: workflowFullRef, + MOCK_TAG_SHA: workflowSha, + WORKFLOW_FULL_REF: workflowFullRef, + WORKFLOW_REF: workflowRef, + WORKFLOW_SHA: workflowSha, + }; + + const valid = runPluginNpmPreflightToolingGuard(baseEnv); + expect(valid.status, valid.stderr).toBe(0); + + for (const rejected of [ + { + name: "moved tag", + env: { ...baseEnv, MOCK_TAG_SHA: "b".repeat(40) }, + error: "missing, moved, annotated, or bound to the wrong SHA", + }, + { + name: "annotated tag", + env: { ...baseEnv, MOCK_TAG_TYPE: "tag" }, + error: "missing, moved, annotated, or bound to the wrong SHA", + }, + { + name: "wrong SHA prefix", + env: { + ...baseEnv, + MOCK_TAG_FULL_REF: `refs/tags/release-publish/${"b".repeat(12)}-123`, + WORKFLOW_FULL_REF: `refs/tags/release-publish/${"b".repeat(12)}-123`, + WORKFLOW_REF: `release-publish/${"b".repeat(12)}-123`, + }, + error: "SHA prefix does not match", + }, + { + name: "same-name branch", + env: { ...baseEnv, WORKFLOW_FULL_REF: `refs/heads/${workflowRef}` }, + error: "exact tag full ref", + }, + ]) { + const result = runPluginNpmPreflightToolingGuard(rejected.env); + expect(result.status, rejected.name).toBe(1); + expect(result.stderr, rejected.name).toContain(rejected.error); + } + }); + + it("binds aggregate preflight consumption to the exact protected tooling tag and SHA", () => { + const workflowSha = "a".repeat(40); + const workflowTag = `release-publish/${workflowSha.slice(0, 12)}-123`; + const valid = runReleasePublishPreflightConsumerGuard({ + currentRef: `refs/tags/${workflowTag}`, + currentWorkflowSha: workflowSha, + preflightHeadBranch: workflowTag, + preflightHeadSha: workflowSha, + }); + expect(valid.status, valid.stderr).toBe(0); + + for (const rejected of [ + { + currentRef: `refs/tags/${workflowTag}`, + preflightHeadBranch: `${workflowTag}-wrong`, + preflightHeadSha: workflowSha, + }, + { + currentRef: `refs/tags/${workflowTag}`, + preflightHeadBranch: workflowTag, + preflightHeadSha: "b".repeat(40), + }, + { + currentRef: `refs/heads/${workflowTag}`, + preflightHeadBranch: workflowTag, + preflightHeadSha: workflowSha, + }, + ]) { + const result = runReleasePublishPreflightConsumerGuard({ + ...rejected, + currentWorkflowSha: workflowSha, + }); + expect(result.status).toBe(1); + expect(result.stderr).toContain("exact protected release-publish tag"); + } + }); + + it("binds core npm preflight consumption to the exact protected tooling tag and SHA", () => { + const workflowSha = "a".repeat(40); + const workflowTag = `release-publish/${workflowSha.slice(0, 12)}-123`; + const valid = runOpenClawNpmPreflightConsumerGuard({ + currentRef: `refs/tags/${workflowTag}`, + currentWorkflowSha: workflowSha, + preflightHeadBranch: workflowTag, + preflightHeadSha: workflowSha, + }); + expect(valid.status, valid.stderr).toBe(0); + + for (const rejected of [ + { + currentRef: `refs/tags/${workflowTag}`, + preflightHeadBranch: `${workflowTag}-wrong`, + preflightHeadSha: workflowSha, + }, + { + currentRef: `refs/tags/${workflowTag}`, + preflightHeadBranch: workflowTag, + preflightHeadSha: "b".repeat(40), + }, + { + currentRef: `refs/heads/${workflowTag}`, + preflightHeadBranch: workflowTag, + preflightHeadSha: workflowSha, + }, + ]) { + const result = runOpenClawNpmPreflightConsumerGuard({ + ...rejected, + currentWorkflowSha: workflowSha, + }); + expect(result.status).toBe(1); + expect(result.stderr).toContain("exact protected release-publish tag"); + } + }); + + it("rejects a protected tooling tag moved after request validation and environment approval", () => { + const workflowSha = "a".repeat(40); + const workflowTag = `release-publish/${workflowSha.slice(0, 12)}-123`; + const protectedRef = `refs/tags/${workflowTag}`; + const predecessor = runOpenClawNpmTrustedRefGuard({ + MOCK_REMOTE_TAG_SHA: workflowSha, + WORKFLOW_REF: protectedRef, + WORKFLOW_SHA: workflowSha, + }); + expect(predecessor.status, predecessor.stderr).toBe(0); + + const consumer = runOpenClawNpmPreflightConsumerGuard({ + currentRef: protectedRef, + currentWorkflowSha: workflowSha, + liveTagSha: "b".repeat(40), + preflightHeadBranch: workflowTag, + preflightHeadSha: workflowSha, + }); + expect(consumer.status).toBe(1); + expect(consumer.stderr).toContain( + "Protected release-publish tag moved after npm-release approval", + ); + }); + + it("uses the canonical tooling identity verifier for token-bootstrap evidence", () => { const publishJob = workflowJob(PLUGIN_NPM_RELEASE_WORKFLOW, "publish_plugins_npm"); const evidenceStep = workflowStep(publishJob, "Consume immutable npm publication evidence"); - expect(evidenceStep.run).toContain("^refs/tags/release-publish/([a-f0-9]{12})-[1-9][0-9]*$"); - expect(evidenceStep.run).toContain( - '[[ "$WORKFLOW_REF" == "refs/heads/main" || "$sha_pinned_release_publish" == "true" ]]', + expect(evidenceStep.env?.RELEASE_PUBLISH_RUN_ID).toBe("${{ inputs.release_publish_run_id }}"); + expect(evidenceStep.env?.RELEASE_PUBLISH_RUN_ATTEMPT).toBe( + "${{ inputs.release_publish_run_attempt }}", ); - expect(evidenceStep.run).toContain('git merge-base --is-ancestor "$WORKFLOW_SHA" origin/main'); + expect(evidenceStep.env?.RELEASE_PUBLISH_PARENT_STATE_POLICY).toBe( + "${{ inputs.release_publish_run_id != '' && (github.actor == 'github-actions[bot]' && 'active' || 'manual-recovery') || '' }}", + ); + expect(evidenceStep.run).toContain("node scripts/release-tooling-identity.mjs verify"); + expect(evidenceStep.run).toContain('--workflow-ref "$WORKFLOW_HEAD_BRANCH"'); + expect(evidenceStep.run).toContain('--workflow-full-ref "$WORKFLOW_REF"'); + expect(evidenceStep.run).toContain('--workflow-sha "$WORKFLOW_SHA"'); + expect(evidenceStep.run).toContain('--release-publish-run-id "$RELEASE_PUBLISH_RUN_ID"'); + expect(evidenceStep.run).toContain( + '--release-publish-run-attempt "$RELEASE_PUBLISH_RUN_ATTEMPT"', + ); + expect(evidenceStep.run).toContain( + '--release-publish-parent-state-policy "$RELEASE_PUBLISH_PARENT_STATE_POLICY"', + ); + expect(evidenceStep.run).not.toContain("--allow-prevalidated-ref"); + }); + + it("revalidates protected tooling immediately before every core and plugin npm publish", () => { + const corePublish = workflowStep( + workflowJob(OPENCLAW_NPM_RELEASE_WORKFLOW, "publish_openclaw_npm"), + "Publish", + ); + expect(corePublish.env).toMatchObject({ + GH_TOKEN: "${{ github.token }}", + RELEASE_PUBLISH_PARENT_STATE_POLICY: + "${{ inputs.release_publish_run_id != '' && (github.actor == 'github-actions[bot]' && 'active' || 'manual-recovery') || '' }}", + RELEASE_PUBLISH_RUN_ATTEMPT: "${{ inputs.release_publish_run_attempt }}", + RELEASE_PUBLISH_RUN_ID: "${{ inputs.release_publish_run_id }}", + WORKFLOW_FULL_REF: "${{ github.ref }}", + WORKFLOW_REF: "${{ github.ref_name }}", + WORKFLOW_SHA: "${{ github.workflow_sha }}", + }); + expect(corePublish.run).toContain( + "node trusted-workflow/scripts/release-tooling-identity.mjs verify", + ); + expect(corePublish.run).toContain("--allow-prevalidated-ref"); + expect(corePublish.run).toContain( + '--release-publish-run-attempt "$RELEASE_PUBLISH_RUN_ATTEMPT"', + ); + expect(corePublish.run).toContain( + '--release-publish-parent-state-policy "$RELEASE_PUBLISH_PARENT_STATE_POLICY"', + ); + expect(corePublish.run).toMatch( + /verify_release_tooling_identity\s+bash scripts\/openclaw-npm-publish\.sh --publish "\.\/\$\{tarball_path\}"/u, + ); + expect(corePublish.run).toMatch( + /verify_release_tooling_identity\s+bash scripts\/openclaw-npm-publish\.sh --publish "\$\{publish_target\}"/u, + ); + + const pluginPublishJob = workflowJob(PLUGIN_NPM_RELEASE_WORKFLOW, "publish_plugins_npm"); + const oidcPublish = workflowStep(pluginPublishJob, "Publish with trusted publisher"); + expect(oidcPublish.env).toMatchObject({ + GH_TOKEN: "${{ github.token }}", + OPENCLAW_RELEASE_PUBLISH_RUN_ATTEMPT: "${{ inputs.release_publish_run_attempt }}", + OPENCLAW_RELEASE_PUBLISH_RUN_ID: "${{ inputs.release_publish_run_id }}", + OPENCLAW_RELEASE_PUBLISH_PARENT_STATE_POLICY: + "${{ inputs.release_publish_run_id != '' && (github.actor == 'github-actions[bot]' && 'active' || 'manual-recovery') || '' }}", + OPENCLAW_RELEASE_TOOLING_ALLOW_PREVALIDATED_REF: "true", + OPENCLAW_RELEASE_TOOLING_FULL_REF: "${{ github.ref }}", + OPENCLAW_RELEASE_TOOLING_IDENTITY_REQUIRED: "true", + OPENCLAW_RELEASE_TOOLING_REF: "${{ github.ref_name }}", + OPENCLAW_RELEASE_TOOLING_REPOSITORY: "${{ github.repository }}", + OPENCLAW_RELEASE_TOOLING_SHA: "${{ github.workflow_sha }}", + }); + + const bootstrapPublish = workflowStep(pluginPublishJob, "Publish approved bootstrap tarball"); + expect(bootstrapPublish.env).toMatchObject({ + GH_TOKEN: "${{ github.token }}", + RELEASE_PUBLISH_PARENT_STATE_POLICY: + "${{ inputs.release_publish_run_id != '' && (github.actor == 'github-actions[bot]' && 'active' || 'manual-recovery') || '' }}", + RELEASE_PUBLISH_RUN_ATTEMPT: "${{ inputs.release_publish_run_attempt }}", + RELEASE_PUBLISH_RUN_ID: "${{ inputs.release_publish_run_id }}", + WORKFLOW_FULL_REF: "${{ github.ref }}", + WORKFLOW_REF: "${{ github.ref_name }}", + WORKFLOW_SHA: "${{ github.workflow_sha }}", + }); + const identityIndex = + bootstrapPublish.run?.indexOf("node scripts/release-tooling-identity.mjs verify") ?? -1; + const publishIndex = bootstrapPublish.run?.indexOf('npm publish "$TARBALL_PATH"') ?? -1; + expect(identityIndex).toBeGreaterThan(-1); + expect(publishIndex).toBeGreaterThan(identityIndex); + expect(bootstrapPublish.run?.slice(identityIndex, publishIndex)).not.toContain("npm view"); + expect(bootstrapPublish.run).toContain( + '--release-publish-parent-state-policy "$RELEASE_PUBLISH_PARENT_STATE_POLICY"', + ); + + const pluginWrapper = readFileSync("scripts/plugin-npm-publish.sh", "utf8"); + expect(pluginWrapper).toContain( + '--release-publish-parent-state-policy "${OPENCLAW_RELEASE_PUBLISH_PARENT_STATE_POLICY:-}"', + ); + const distTagIndex = pluginWrapper.indexOf( + 'npm dist-tag add "${package_name}@${package_version}"', + ); + const distTagIdentityIndex = pluginWrapper.lastIndexOf( + "verify_release_tooling_identity", + distTagIndex, + ); + expect(distTagIdentityIndex).toBeGreaterThan(-1); + expect(distTagIndex).toBeGreaterThan(distTagIdentityIndex); + }); + + it("binds release evidence validation to the exact trusted workflow ref", () => { + for (const [workflowPath, jobName, stepName] of [ + [ + RELEASE_PUBLISH_WORKFLOW, + "resolve_release_target", + "Validate full release validation manifest", + ], + [ + OPENCLAW_NPM_RELEASE_WORKFLOW, + "publish_openclaw_npm", + "Verify full release validation evidence", + ], + ] as const) { + const step = workflowStep(workflowJob(workflowPath, jobName), stepName); + expect(step.env).toMatchObject({ + TRUSTED_WORKFLOW_FULL_REF: "${{ github.ref }}", + TRUSTED_WORKFLOW_REF: "${{ github.ref_name }}", + TRUSTED_WORKFLOW_SHA: "${{ github.workflow_sha }}", + }); + expect(step.run).toContain("^refs/tags/release-publish/[a-f0-9]{12}-[1-9][0-9]*$"); + expect(step.run).toContain('TRUSTED_MAIN_REF="${trusted_workflow_commit_ref}"'); + expect(step.run).toContain('--trusted-workflow-ref "$TRUSTED_WORKFLOW_REF"'); + expect(step.run).toContain('--trusted-workflow-full-ref "$TRUSTED_WORKFLOW_FULL_REF"'); + expect(step.run).toContain('--trusted-workflow-sha "$TRUSTED_WORKFLOW_SHA"'); + expect(step.run).toContain('--verifier-source-sha "$'); + } }); it("retries child environment approval when deployment propagation lags", () => { @@ -5174,6 +5680,7 @@ describe("package artifact reuse", () => { resolveTargetJob, "Checkout target package manifest", ); + const toolingIdentity = workflowStep(resolveTargetJob, "Resolve trusted workflow identity"); const releaseInputValidation = workflowStep(resolveTargetJob, "Validate release inputs"); const evidenceReuseStep = workflowStep(evidenceReuseJob, "Find reusable validation evidence"); const releaseChecksDispatchStep = workflowStep( @@ -5189,6 +5696,14 @@ describe("package artifact reuse", () => { default: false, type: "boolean", }, + trusted_workflow_json: { + default: "", + required: false, + type: "string", + }, + }); + expect(readWorkflow(FULL_RELEASE_VALIDATION_WORKFLOW).env).toMatchObject({ + RELEASE_ISOLATION_TOOLING_CONTRACT: "2", }); expect(workflow).toContain("CHILD_WORKFLOW_REF: ${{ github.ref_name }}"); expect(workflow).toContain('gh workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@" 2>&1'); @@ -5202,6 +5717,23 @@ describe("package artifact reuse", () => { expect(resolveTargetSteps.indexOf(targetManifestCheckout)).toBeLessThan( resolveTargetSteps.indexOf(releaseInputValidation), ); + expect(resolveTargetJob.outputs?.trusted_workflow_json).toBe( + "${{ steps.tooling_identity.outputs.json }}", + ); + expect(toolingIdentity.env).toMatchObject({ + GH_TOKEN: "${{ github.token }}", + REQUESTED_IDENTITY_JSON: "${{ inputs.trusted_workflow_json }}", + WORKFLOW_CONTRACT: "${{ env.RELEASE_ISOLATION_TOOLING_CONTRACT }}", + WORKFLOW_FULL_REF: "${{ github.ref }}", + WORKFLOW_REF: "${{ github.ref_name }}", + WORKFLOW_SHA: "${{ github.sha }}", + }); + expectTextToIncludeAll(toolingIdentity.run, [ + "node workflow/scripts/release-tooling-identity.mjs resolve", + '--workflow-contract "$WORKFLOW_CONTRACT"', + '--requested-identity-json "$REQUESTED_IDENTITY_JSON"', + 'echo "json=${identity}"', + ]); expectTextToIncludeAll(releaseInputValidation.run, [ 'target_version="$(jq -er', "does not belong to release branch", @@ -5227,6 +5759,7 @@ describe("package artifact reuse", () => { NPM_TELEGRAM_PROVIDER_MODE: "${{ inputs.npm_telegram_provider_mode }}", NPM_TELEGRAM_SCENARIO: "${{ inputs.npm_telegram_scenario }}", SKIP_PACKAGE_TELEGRAM_E2E: "${{ inputs.skip_package_telegram_e2e }}", + TRUSTED_WORKFLOW_JSON: "${{ needs.resolve_target.outputs.trusted_workflow_json }}", }); expectTextToIncludeAll(evidenceReuseStep.run, [ "npmTelegramPackageSpec: $npmTelegramPackageSpec", @@ -5234,6 +5767,12 @@ describe("package artifact reuse", () => { "npmTelegramScenario: $npmTelegramScenario", "skipPackageTelegramE2e: $skipPackageTelegramE2e", "allowUnreleasedChangelog: $allowUnreleasedChangelog", + 'trusted_workflow_ref="$(jq -er', + 'trusted_workflow_full_ref="$(jq -er', + 'trusted_workflow_sha="$(jq -er', + '--trusted-workflow-ref "$trusted_workflow_ref"', + '--trusted-workflow-full-ref "$trusted_workflow_full_ref"', + '--trusted-workflow-sha "$trusted_workflow_sha"', ]); expect(targetSummaryStep.env).toMatchObject({ SKIP_PACKAGE_TELEGRAM_E2E: "${{ inputs.skip_package_telegram_e2e }}", @@ -6214,14 +6753,14 @@ describe("package artifact reuse", () => { expect(trustedTooling.env?.WORKFLOW_SHA).toBe("${{ github.sha }}"); expect(validateManifest.env).toMatchObject({ RUN_JSON_FILE: "${{ runner.temp }}/full-release-validation-run.json", - TRUSTED_MAIN_REF: "refs/remotes/origin/main", + TRUSTED_WORKFLOW_FULL_REF: "${{ github.ref }}", + TRUSTED_WORKFLOW_REF: "${{ github.ref_name }}", VALIDATOR_FILE: "${{ runner.temp }}/release-validation-tooling/validate-full-release-validation-evidence.mjs", STRICT_VALIDATOR_FILE: "${{ runner.temp }}/release-validation-tooling/release-ci-summary.mjs", }); - expect(validateManifest.run).toContain( - 'MANIFEST_FILE="$manifest" node "$VALIDATOR_FILE" < "$RUN_JSON_FILE"', - ); + expect(validateManifest.run).toContain('MANIFEST_FILE="$manifest"'); + expect(validateManifest.run).toContain('node "$VALIDATOR_FILE" < "$RUN_JSON_FILE"'); expect(publishDownload.with?.name).toBe( "full-release-validation-${{ inputs.full_release_validation_run_id }}-${{ needs.resolve_release_target.outputs.full_release_validation_run_attempt }}", ); @@ -6661,6 +7200,28 @@ describe("package artifact reuse", () => { contents: "read", "id-token": "write", }); + expect(clawHubPublish.with?.trusted_tooling_identity_json).toBeUndefined(); + 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(); + expect( + readWorkflow(PLUGIN_CLAWHUB_RELEASE_WORKFLOW).on?.workflow_dispatch?.inputs + ?.release_publish_full_ref, + ).toBeUndefined(); + 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}"', + ); + expect(publishOrchestration.run).not.toContain("release_publish_full_ref"); + expect(publishOrchestration.run).not.toContain("release_publish_workflow_sha"); expect(clawHubBootstrapValidation.environment).toBe("clawhub-plugin-bootstrap"); expect(clawHubBootstrapPublish.environment).toBe("clawhub-plugin-bootstrap"); diff --git a/test/scripts/plugin-npm-extended-stable-workflow.test.ts b/test/scripts/plugin-npm-extended-stable-workflow.test.ts index 65048453e180..1c679ae0a5c6 100644 --- a/test/scripts/plugin-npm-extended-stable-workflow.test.ts +++ b/test/scripts/plugin-npm-extended-stable-workflow.test.ts @@ -156,18 +156,32 @@ describe("plugin npm extended-stable workflow", () => { const preview = workflow().jobs?.preview_plugins_npm; const previewSteps = preview?.steps ?? []; const trusted = step(preview, "Validate ref is on a trusted publish branch"); - expect(previewSteps.slice(0, 4).map((candidate) => candidate.name)).toEqual([ + expect(previewSteps.slice(0, 6).map((candidate) => candidate.name)).toEqual([ "Checkout", + "Checkout trusted preflight tooling", "Resolve checked-out ref", + "Verify trusted preflight tooling identity", "Validate ref is on a trusted publish branch", "Setup Node environment", ]); const trustedIndex = previewSteps.indexOf(trusted); - expect(trustedIndex).toBe(2); + expect(trustedIndex).toBe(4); for (const candidate of previewSteps.slice(0, trustedIndex)) { expect(candidate.uses?.startsWith("./"), candidate.name).not.toBe(true); expect(candidate.run ?? "", candidate.name).not.toMatch(/\b(?:bun|npm|pnpm)\b/u); } + const toolingIdentity = step(preview, "Verify trusted preflight tooling identity"); + expect(toolingIdentity.env).toMatchObject({ + WORKFLOW_FULL_REF: "${{ github.ref }}", + WORKFLOW_REF: "${{ github.ref_name }}", + WORKFLOW_SHA: "${{ github.workflow_sha }}", + }); + expect(toolingIdentity.run).toContain( + "node .release-tooling/scripts/release-tooling-identity.mjs verify", + ); + expect(toolingIdentity.run).toContain('--workflow-ref "$WORKFLOW_REF"'); + expect(toolingIdentity.run).toContain('--workflow-full-ref "$WORKFLOW_FULL_REF"'); + expect(toolingIdentity.run).toContain('--workflow-sha "$WORKFLOW_SHA"'); expect(step(preview, "Setup Node environment").uses).toBe("./.github/actions/setup-node-env"); expect(trusted.env).toMatchObject({ PREFLIGHT_ONLY: @@ -176,6 +190,8 @@ describe("plugin npm extended-stable workflow", () => { "${{ github.event_name == 'workflow_dispatch' && inputs.trusted_publisher_preflight || false }}", RELEASE_PUBLISH_RUN_ID: "${{ github.event_name == 'workflow_dispatch' && inputs.release_publish_run_id || '' }}", + RELEASE_PUBLISH_RUN_ATTEMPT: + "${{ github.event_name == 'workflow_dispatch' && inputs.release_publish_run_attempt || '' }}", SOURCE_REF: "${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}", WORKFLOW_REF: "${{ github.ref }}", WORKFLOW_SHA: "${{ github.workflow_sha }}", @@ -184,13 +200,13 @@ describe("plugin npm extended-stable workflow", () => { '[[ "${TRUSTED_PUBLISHER_PREFLIGHT}" == "true" && "${PREFLIGHT_ONLY}" != "true" ]]', ); expect(trusted.run).toContain("trusted_publisher_preflight requires preflight_only=true"); - expect(trusted.run).toContain('[[ "${WORKFLOW_REF}" != "refs/heads/main" ]]'); - expect(trusted.run).toContain('git merge-base --is-ancestor "${WORKFLOW_SHA}" origin/main'); expect(trusted.run).toContain('[[ ! "${SOURCE_REF}" =~ ^[0-9a-fA-F]{40}$ ]]'); expect(trusted.run).toContain( '[[ "$(git rev-parse HEAD)" != "$(git rev-parse "${SOURCE_REF}^{commit}")" ]]', ); - expect(trusted.run).toContain("preflight must not include release_publish_run_id"); + expect(trusted.run).toContain( + "Plugin npm preflight must not include a release publish parent run tuple.", + ); const preflightBranchRejection = trusted.run?.indexOf( "Plugin npm preflight target must be reachable from main or release/*.", ); @@ -413,7 +429,7 @@ describe("plugin npm extended-stable workflow", () => { .split("\n") .filter((line) => line.includes('npm publish "$TARBALL_PATH"')); - expect(gitFetchLines).toHaveLength(6); + expect(gitFetchLines).toHaveLength(5); expect( gitFetchLines.every((line) => line.includes("timeout --signal=TERM --kill-after=10s 120s")), ).toBe(true); @@ -468,18 +484,11 @@ describe("plugin npm extended-stable workflow", () => { expect(consume.run).toContain("--connect-timeout 10"); expect(consume.run).toContain("--max-time 120"); expect(consume.run).toContain("actions/artifacts/${artifact_id}/zip"); - expect(consume.run).toContain("sha_pinned_release_publish=false"); - expect(consume.run).toContain( - '[[ "$WORKFLOW_REF" =~ ^refs/tags/release-publish/([a-f0-9]{12})-[1-9][0-9]*$ ]]', - ); - expect(consume.run).toContain( - '[[ "$WORKFLOW_SHA" =~ ^[a-f0-9]{40}$ && "${WORKFLOW_SHA:0:12}" == "$workflow_sha_prefix" ]]', - ); - expect(consume.run).toContain("sha_pinned_release_publish=true"); - expect(consume.run).toContain( - '[[ "$WORKFLOW_REF" == "refs/heads/main" || "$sha_pinned_release_publish" == "true" ]]', - ); - expect(consume.run).toContain('git merge-base --is-ancestor "$WORKFLOW_SHA" origin/main'); + expect(consume.run).toContain("node scripts/release-tooling-identity.mjs verify"); + expect(consume.run).toContain('--workflow-ref "$WORKFLOW_HEAD_BRANCH"'); + expect(consume.run).toContain('--workflow-full-ref "$WORKFLOW_REF"'); + expect(consume.run).toContain('--workflow-sha "$WORKFLOW_SHA"'); + expect(consume.run).toContain('--release-publish-run-id "$RELEASE_PUBLISH_RUN_ID"'); expect( step(parsed.jobs?.publish_plugins_npm, "Checkout trusted publication tooling").with?.ref, ).toBe("${{ github.workflow_sha }}"); diff --git a/test/scripts/plugin-npm-publish.test.ts b/test/scripts/plugin-npm-publish.test.ts index de2f3656ab6a..c62d75e4d0b7 100644 --- a/test/scripts/plugin-npm-publish.test.ts +++ b/test/scripts/plugin-npm-publish.test.ts @@ -1,6 +1,6 @@ // Plugin NPM Publish tests cover publish wrapper argument safety. import { spawnSync } from "node:child_process"; -import { chmodSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { delimiter, join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; @@ -41,6 +41,31 @@ function makePackage(version: string): { packageDir: string; path: string; root: } describe("plugin npm publish wrapper", () => { + it("revalidates release tooling after preparation and immediately before npm publish", () => { + const source = readFileSync(scriptPath, "utf8"); + const buildIndex = source.indexOf("build_package_runtime"); + const identityIndex = source.indexOf("\n verify_release_tooling_identity", buildIndex); + const publishIndex = source.indexOf( + 'run_with_manifest_overlay "${publish_cmd[@]}"', + identityIndex, + ); + + expect(buildIndex).toBeGreaterThan(-1); + expect(identityIndex).toBeGreaterThan(buildIndex); + expect(publishIndex).toBeGreaterThan(identityIndex); + expect(source.slice(identityIndex, publishIndex)).not.toContain("npm view"); + }); + + it("revalidates release tooling immediately before every npm dist-tag mutation", () => { + const source = readFileSync(scriptPath, "utf8"); + const distTagIndex = source.indexOf('npm dist-tag add "${package_name}@${package_version}"'); + const identityIndex = source.lastIndexOf("verify_release_tooling_identity", distTagIndex); + + expect(identityIndex).toBeGreaterThan(-1); + expect(distTagIndex).toBeGreaterThan(identityIndex); + expect(source.slice(identityIndex, distTagIndex)).not.toContain("npm view"); + }); + it("prints help before package or npm checks", () => { const result = runPluginPublishWrapper(["--help"]); diff --git a/test/scripts/release-candidate-checklist.test.ts b/test/scripts/release-candidate-checklist.test.ts index c1f6b491f6c5..cc291713ec81 100644 --- a/test/scripts/release-candidate-checklist.test.ts +++ b/test/scripts/release-candidate-checklist.test.ts @@ -13,6 +13,7 @@ import { candidateCumulativeShippedPullRequests, candidateParallelsArgs, candidateParallelsShellCommand, + fullReleaseTrustedWorkflowFields, githubApi, isDirectReleaseCandidateExecution, parseArgs, @@ -1372,6 +1373,59 @@ describe("release candidate checklist", () => { ).toThrow("refusing to guess from recent workflow_dispatch runs"); }); + it("keeps contract 1 callers compatible and sends identity for contract 2", () => { + const workflowSha = "a".repeat(40); + const source = (contract: string, declareIdentity: boolean) => `env: + RELEASE_ISOLATION_TOOLING_CONTRACT: "${contract}" +on: + workflow_dispatch: + inputs: + expected_sha: {} +${declareIdentity ? " trusted_workflow_json: {}\n" : ""}`; + + expect( + fullReleaseTrustedWorkflowFields({ + workflowRef: "main", + workflowSha, + workflowSource: source("1", false), + }), + ).toEqual({}); + const fields = fullReleaseTrustedWorkflowFields({ + workflowRef: "main", + workflowSha, + workflowSource: source("2", true), + }); + expect(JSON.parse(fields.trusted_workflow_json ?? "{}")).toEqual({ + ref: "main", + fullRef: "refs/heads/main", + sha: workflowSha, + }); + expect(() => + fullReleaseTrustedWorkflowFields({ + workflowRef: "main", + workflowSha, + workflowSource: source("2", false), + }), + ).toThrow("contract 2 requires trusted_workflow_json"); + for (const contract of ["3", "4"]) { + expect(() => + fullReleaseTrustedWorkflowFields({ + workflowRef: "main", + workflowSha, + workflowSource: source(contract, true), + }), + ).toThrow("supported release tooling contract"); + } + }); + + it("threads the selected tooling identity into direct full validation dispatch", () => { + const source = readFileSync("scripts/release-candidate-checklist.mts", "utf8"); + + expect(source).toContain("const trustedWorkflowFields = fullReleaseTrustedWorkflowFields({"); + expect(source).toContain("workflowSha: toolingSha"); + expect(source).toContain("...trustedWorkflowFields"); + }); + it("falls back to a single compatible artifact from the same run", () => { expect( resolveArtifactName( diff --git a/test/scripts/release-ci-summary.test.ts b/test/scripts/release-ci-summary.test.ts index 073c6a4e019b..170181ea6674 100644 --- a/test/scripts/release-ci-summary.test.ts +++ b/test/scripts/release-ci-summary.test.ts @@ -1196,6 +1196,114 @@ describe("release CI summary child correlation", () => { }); }); + it("accepts canonical SHA-pinned v3 evidence exactly bound to a protected tooling tag", () => { + const workflowSha = "7".repeat(40); + const workflowRef = `release-ci/${workflowSha.slice(0, 12)}-1783705000000`; + const trustedWorkflowRef = `release-publish/${workflowSha.slice(0, 12)}-123`; + const fixture = trustedMainPackageFixture({ + manifestVersion: 3, + targetSha: "8".repeat(40), + workflowFullRef: `refs/heads/${workflowRef}`, + workflowRef, + workflowSha, + }); + fixture.manifest.targetRef = fixture.targetSha; + + expect( + validateReleaseRunEvidence( + { + repository: "openclaw/openclaw", + runId: fixture.runId, + trustedWorkflowFullRef: `refs/tags/${trustedWorkflowRef}`, + trustedWorkflowRef, + trustedWorkflowSha: workflowSha, + verifierSourceContent: readFileSync(SCRIPT), + verifierSourceSha: "c".repeat(40), + }, + fixture.client, + ), + ).toMatchObject({ + producerOnTrustedMainLineage: false, + trustedWorkflowFullRef: `refs/tags/${trustedWorkflowRef}`, + trustedWorkflowRef, + root: { + workflowRef, + workflowRefProof: "manifest-v3-protected-tag-exact-sha", + workflowSha, + }, + }); + }); + + it("rejects protected-tag evidence from a same-name branch or older ancestor", () => { + const trustedWorkflowSha = "7".repeat(40); + const trustedWorkflowRef = `release-publish/${trustedWorkflowSha.slice(0, 12)}-123`; + const validFixture = trustedMainPackageFixture({ + manifestVersion: 3, + workflowSha: trustedWorkflowSha, + }); + + expect(() => + validateReleaseRunEvidence( + { + repository: "openclaw/openclaw", + runId: validFixture.runId, + trustedWorkflowFullRef: `refs/heads/${trustedWorkflowRef}`, + trustedWorkflowRef, + trustedWorkflowSha, + verifierSourceContent: readFileSync(SCRIPT), + verifierSourceSha: "c".repeat(40), + }, + validFixture.client, + ), + ).toThrow("must be a protected tag"); + + const olderWorkflowSha = "6".repeat(40); + const olderWorkflowRef = `release-ci/${olderWorkflowSha.slice(0, 12)}-1783705000000`; + const olderFixture = trustedMainPackageFixture({ + manifestVersion: 3, + targetSha: "8".repeat(40), + workflowFullRef: `refs/heads/${olderWorkflowRef}`, + workflowRef: olderWorkflowRef, + workflowSha: olderWorkflowSha, + }); + olderFixture.manifest.targetRef = olderFixture.targetSha; + expect(() => + validateReleaseRunEvidence( + { + repository: "openclaw/openclaw", + runId: olderFixture.runId, + trustedWorkflowFullRef: `refs/tags/${trustedWorkflowRef}`, + trustedWorkflowRef, + trustedWorkflowSha, + verifierSourceContent: readFileSync(SCRIPT), + verifierSourceSha: "c".repeat(40), + }, + olderFixture.client, + ), + ).toThrow("does not match trusted tooling"); + + const sameNameFixture = trustedMainPackageFixture({ + manifestVersion: 3, + workflowFullRef: `refs/heads/${trustedWorkflowRef}`, + workflowRef: trustedWorkflowRef, + workflowSha: trustedWorkflowSha, + }); + expect(() => + validateReleaseRunEvidence( + { + repository: "openclaw/openclaw", + runId: sameNameFixture.runId, + trustedWorkflowFullRef: `refs/tags/${trustedWorkflowRef}`, + trustedWorkflowRef, + trustedWorkflowSha, + verifierSourceContent: readFileSync(SCRIPT), + verifierSourceSha: "c".repeat(40), + }, + sameNameFixture.client, + ), + ).toThrow("canonical release-ci branch"); + }); + it.each(["main", "refs/heads/main"])( "accepts a REST workflow path qualified with %s", (qualifiedRef) => { diff --git a/test/scripts/release-tooling-identity.test.ts b/test/scripts/release-tooling-identity.test.ts new file mode 100644 index 000000000000..2cd19ecf5bb6 --- /dev/null +++ b/test/scripts/release-tooling-identity.test.ts @@ -0,0 +1,361 @@ +import { describe, expect, it, vi } from "vitest"; +import { + resolveReleaseToolingIdentity, + validateReleasePublishParentRun, + validateReleaseToolingIdentity, + verifyReleaseToolingIdentity, +} from "../../scripts/release-tooling-identity.mjs"; + +const SHA = "a".repeat(40); +const OTHER_SHA = "b".repeat(40); +const RUN_ID = "12345"; +const PARENT_RUN_ID = "67890"; +const PARENT_RUN_ATTEMPT = "2"; +const REF = `release-publish/${SHA.slice(0, 12)}-${RUN_ID}`; +const FULL_REF = `refs/tags/${REF}`; + +function protectedIdentity( + overrides: Partial[0]> = {}, +) { + return { + repository: "openclaw/openclaw", + workflowFullRef: FULL_REF, + workflowRef: REF, + workflowSha: SHA, + ...overrides, + }; +} + +describe("release tooling identity", () => { + it.each([ + ["1", "main", "refs/heads/main"], + ["2", "release/2026.8.1", "refs/heads/release/2026.8.1"], + ["2", "tideclaw/alpha/2026-08-21-1200Z", "refs/heads/tideclaw/alpha/2026-08-21-1200Z"], + ])("derives contract %s identity for safe direct workflow ref %s", (contract, ref, fullRef) => { + expect( + resolveReleaseToolingIdentity({ + workflowContract: contract, + workflowFullRef: fullRef, + workflowRef: ref, + workflowSha: SHA, + }), + ).toEqual({ fullRef, ref, sha: SHA }); + }); + + it("rejects unsupported contract 3 even with explicit identity", () => { + expect(() => + resolveReleaseToolingIdentity({ + requestedIdentityJson: JSON.stringify({ + ref: "main", + fullRef: "refs/heads/main", + sha: SHA, + }), + workflowContract: "3", + workflowFullRef: "refs/heads/main", + workflowRef: "main", + workflowSha: SHA, + }), + ).toThrow("release tooling contract 3 is not supported"); + }); + + it.each([ + [ + "release-ci ref", + { + workflowContract: "2", + workflowFullRef: `refs/heads/release-ci/${SHA.slice(0, 12)}-123`, + workflowRef: `release-ci/${SHA.slice(0, 12)}-123`, + }, + ], + [ + "protected tag", + { + workflowContract: "2", + workflowFullRef: FULL_REF, + workflowRef: REF, + }, + ], + ])("requires explicit identity for $0", (_label, overrides) => { + const { workflowContract, workflowFullRef } = overrides; + const workflowRef = "workflowRef" in overrides ? overrides.workflowRef : "main"; + expect(() => + resolveReleaseToolingIdentity({ + workflowContract, + workflowFullRef, + workflowRef, + workflowSha: SHA, + }), + ).toThrow(/requires explicit trusted workflow identity|require explicit trusted workflow/u); + }); + + it("accepts explicit main identity for a matching release-ci workflow", () => { + const releaseCiRef = `release-ci/${SHA.slice(0, 12)}-123`; + expect( + resolveReleaseToolingIdentity({ + requestedIdentityJson: JSON.stringify({ + ref: "main", + fullRef: "refs/heads/main", + sha: SHA, + }), + workflowContract: "2", + workflowFullRef: `refs/heads/${releaseCiRef}`, + workflowRef: releaseCiRef, + workflowSha: SHA, + }), + ).toEqual({ ref: "main", fullRef: "refs/heads/main", sha: SHA }); + }); + + it("rejects explicit identity that does not match a direct workflow", () => { + expect(() => + resolveReleaseToolingIdentity({ + requestedIdentityJson: JSON.stringify({ + ref: "main", + fullRef: "refs/heads/main", + sha: OTHER_SHA, + }), + workflowContract: "2", + workflowFullRef: "refs/heads/main", + workflowRef: "main", + workflowSha: SHA, + }), + ).toThrow("must match the executing workflow ref and SHA"); + }); + + it("accepts only the live exact lightweight protected tag", () => { + const runGh = vi.fn(() => + JSON.stringify({ + ref: FULL_REF, + object: { sha: SHA, type: "commit" }, + }), + ); + + expect(verifyReleaseToolingIdentity({ ...protectedIdentity(), runGh })).toEqual({ + fullRef: FULL_REF, + ref: REF, + route: "protected-tag", + sha: SHA, + }); + expect(runGh).toHaveBeenCalledWith([ + "api", + `repos/openclaw/openclaw/git/ref/tags/${REF}`, + "--method", + "GET", + ]); + }); + + it.each([ + [ + "moved tag", + { + runGh: () => + JSON.stringify({ + ref: FULL_REF, + object: { sha: OTHER_SHA, type: "commit" }, + }), + }, + "missing, moved, annotated, or bound to the wrong SHA", + ], + [ + "deleted tag", + { + runGh: () => { + throw new Error("HTTP 404"); + }, + }, + "missing or unreadable", + ], + [ + "annotated tag", + { + runGh: () => + JSON.stringify({ + ref: FULL_REF, + object: { sha: OTHER_SHA, type: "tag" }, + }), + }, + "missing, moved, annotated, or bound to the wrong SHA", + ], + [ + "wrong SHA prefix", + { + workflowRef: `release-publish/${OTHER_SHA.slice(0, 12)}-${RUN_ID}`, + workflowFullRef: `refs/tags/release-publish/${OTHER_SHA.slice(0, 12)}-${RUN_ID}`, + }, + "SHA prefix does not match", + ], + ["same-name branch", { workflowFullRef: `refs/heads/${REF}` }, "exact tag full ref"], + ])("rejects $0", (_label, overrides, expectedError) => { + expect(() => + verifyReleaseToolingIdentity({ + ...protectedIdentity(), + ...overrides, + }), + ).toThrow(expectedError); + }); + + it.each(["ahead", "identical"])( + "accepts main tooling reachable from current main: %s", + (status) => { + const runGh = vi.fn(() => JSON.stringify({ status })); + expect( + verifyReleaseToolingIdentity({ + repository: "openclaw/openclaw", + runGh, + workflowFullRef: "refs/heads/main", + workflowRef: "main", + workflowSha: SHA, + }), + ).toMatchObject({ route: "main", sha: SHA }); + }, + ); + + it("rejects main tooling outside current main ancestry", () => { + expect(() => + validateReleaseToolingIdentity({ + mainComparisonStatus: "diverged", + workflowFullRef: "refs/heads/main", + workflowRef: "main", + workflowSha: SHA, + }), + ).toThrow("not reachable from current main"); + }); + + it("preserves explicitly prevalidated non-main branch routes", () => { + const runGh = vi.fn(() => + JSON.stringify({ + ref: "refs/heads/release/2026.8.1", + object: { sha: SHA, type: "commit" }, + }), + ); + expect( + verifyReleaseToolingIdentity({ + allowPrevalidatedRef: true, + repository: "openclaw/openclaw", + runGh, + workflowFullRef: "refs/heads/release/2026.8.1", + workflowRef: "release/2026.8.1", + workflowSha: SHA, + }), + ).toMatchObject({ route: "prevalidated-branch" }); + expect(runGh).toHaveBeenCalledWith([ + "api", + "repos/openclaw/openclaw/git/ref/heads/release/2026.8.1", + "--method", + "GET", + ]); + }); + + it("rejects a prevalidated branch moved after approval", () => { + expect(() => + verifyReleaseToolingIdentity({ + allowPrevalidatedRef: true, + repository: "openclaw/openclaw", + runGh: () => + JSON.stringify({ + ref: "refs/heads/release/2026.8.1", + object: { sha: OTHER_SHA, type: "commit" }, + }), + workflowFullRef: "refs/heads/release/2026.8.1", + workflowRef: "release/2026.8.1", + workflowSha: SHA, + }), + ).toThrow("branch is missing or moved"); + }); + + it("binds a distinct current parent run independently from tag provenance", () => { + const calls: string[][] = []; + const runGh = vi.fn((args: string[]) => { + calls.push(args); + if (args[1]?.includes("/git/ref/tags/")) { + return JSON.stringify({ + ref: FULL_REF, + object: { sha: SHA, type: "commit" }, + }); + } + return JSON.stringify({ + id: Number(PARENT_RUN_ID), + run_attempt: Number(PARENT_RUN_ATTEMPT), + repository: { full_name: "openclaw/openclaw" }, + path: `.github/workflows/openclaw-release-publish.yml@${FULL_REF}`, + event: "workflow_dispatch", + head_branch: REF, + head_sha: SHA, + status: "in_progress", + conclusion: null, + }); + }); + + expect( + verifyReleaseToolingIdentity({ + ...protectedIdentity(), + releasePublishParentStatePolicy: "active", + releasePublishRunAttempt: PARENT_RUN_ATTEMPT, + releasePublishRunId: PARENT_RUN_ID, + runGh, + }), + ).toMatchObject({ route: "protected-tag", sha: SHA }); + expect(PARENT_RUN_ID).not.toBe(RUN_ID); + expect(calls).toContainEqual([ + "api", + `repos/openclaw/openclaw/actions/runs/${PARENT_RUN_ID}`, + "--method", + "GET", + ]); + }); + + it.each([ + ["active", "in_progress", null, true], + ["active", "completed", "success", false], + ["active-or-success", "in_progress", null, true], + ["active-or-success", "completed", "success", true], + ["active-or-success", "completed", "failure", false], + ["manual-recovery", "in_progress", null, true], + ["manual-recovery", "completed", "success", true], + ["manual-recovery", "completed", "failure", true], + ["manual-recovery", "completed", "cancelled", false], + ] as const)( + "enforces parent state policy %s for %s/%s", + (releasePublishParentStatePolicy, status, conclusion, accepted) => { + const validate = () => + validateReleasePublishParentRun({ + identity: { ref: REF, fullRef: FULL_REF, sha: SHA }, + releasePublishParentStatePolicy, + releasePublishRunAttempt: PARENT_RUN_ATTEMPT, + releasePublishRunId: PARENT_RUN_ID, + repository: "openclaw/openclaw", + run: { + id: Number(PARENT_RUN_ID), + run_attempt: Number(PARENT_RUN_ATTEMPT), + repository: { full_name: "openclaw/openclaw" }, + path: `.github/workflows/openclaw-release-publish.yml@${FULL_REF}`, + event: "workflow_dispatch", + head_branch: REF, + head_sha: SHA, + status, + conclusion, + }, + }); + + if (accepted) { + expect(validate).not.toThrow(); + } else { + expect(validate).toThrow(`state is not allowed by ${releasePublishParentStatePolicy}`); + } + }, + ); + + it("requires the parent state policy with the exact parent run tuple", () => { + expect(() => + verifyReleaseToolingIdentity({ + ...protectedIdentity(), + releasePublishRunAttempt: PARENT_RUN_ATTEMPT, + releasePublishRunId: PARENT_RUN_ID, + runGh: () => + JSON.stringify({ + ref: FULL_REF, + object: { sha: SHA, type: "commit" }, + }), + }), + ).toThrow("run id, attempt, and parent state policy must be provided together"); + }); +}); diff --git a/test/scripts/validate-full-release-validation-evidence.test.ts b/test/scripts/validate-full-release-validation-evidence.test.ts index c5476ec9e0da..5ad2ea3ebf59 100644 --- a/test/scripts/validate-full-release-validation-evidence.test.ts +++ b/test/scripts/validate-full-release-validation-evidence.test.ts @@ -125,6 +125,78 @@ describe("full release validation evidence", () => { expect(isShaPinnedReleaseValidationBranch(pinnedBranch)).toBe(true); }); + it("accepts canonical SHA-pinned evidence exactly bound to a protected tooling tag", () => { + const isTrustedMainAncestor = vi.fn(() => false); + const trustedWorkflowRef = `release-publish/${workflowSha.slice(0, 12)}-123`; + const result = validateFullReleaseValidationEvidence({ + run: releaseRun(), + manifest: releaseManifest(), + expectedRepository: "openclaw/openclaw", + expectedRunId: "123", + expectedTargetSha: targetSha, + expectedTrustedWorkflowFullRef: `refs/tags/${trustedWorkflowRef}`, + expectedTrustedWorkflowSha: workflowSha, + isTrustedMainAncestor, + }); + + expect(result.source).toBe("sha-pinned-protected-tag"); + expect(isTrustedMainAncestor).not.toHaveBeenCalled(); + }); + + it("rejects protected-tag evidence from a same-name branch or older ancestor", () => { + const trustedWorkflowRef = `release-publish/${workflowSha.slice(0, 12)}-123`; + expect(() => + validateFullReleaseValidationEvidence({ + run: releaseRun(), + manifest: releaseManifest(), + expectedRepository: "openclaw/openclaw", + expectedRunId: "123", + expectedTargetSha: targetSha, + expectedTrustedWorkflowFullRef: `refs/heads/${trustedWorkflowRef}`, + expectedTrustedWorkflowSha: workflowSha, + isTrustedMainAncestor: () => true, + }), + ).toThrow("must be an exact protected tag"); + + const olderWorkflowSha = "c".repeat(40); + const olderBranch = `release-ci/${olderWorkflowSha.slice(0, 12)}-1783705000000`; + expect(() => + validateFullReleaseValidationEvidence({ + run: releaseRun({ + head_branch: olderBranch, + head_sha: olderWorkflowSha, + }), + manifest: releaseManifest({ + workflowFullRef: `refs/heads/${olderBranch}`, + workflowRef: olderBranch, + workflowSha: olderWorkflowSha, + }), + expectedRepository: "openclaw/openclaw", + expectedRunId: "123", + expectedTargetSha: targetSha, + expectedTrustedWorkflowFullRef: `refs/tags/${trustedWorkflowRef}`, + expectedTrustedWorkflowSha: workflowSha, + isTrustedMainAncestor: () => true, + }), + ).toThrow("does not match trusted tooling"); + + expect(() => + validateFullReleaseValidationEvidence({ + run: releaseRun({ head_branch: trustedWorkflowRef }), + manifest: releaseManifest({ + workflowFullRef: `refs/heads/${trustedWorkflowRef}`, + workflowRef: trustedWorkflowRef, + }), + expectedRepository: "openclaw/openclaw", + expectedRunId: "123", + expectedTargetSha: targetSha, + expectedTrustedWorkflowFullRef: `refs/tags/${trustedWorkflowRef}`, + expectedTrustedWorkflowSha: workflowSha, + isTrustedMainAncestor: () => true, + }), + ).toThrow("canonical release-ci producer branch"); + }); + it.each([pinnedBranch, `refs/heads/${pinnedBranch}`])( "accepts a REST workflow path qualified with %s", (qualifiedRef) => { diff --git a/test/scripts/validate-release-publish-approval.test.ts b/test/scripts/validate-release-publish-approval.test.ts index b2f643e5488c..86c0cfe2bc6c 100644 --- a/test/scripts/validate-release-publish-approval.test.ts +++ b/test/scripts/validate-release-publish-approval.test.ts @@ -15,6 +15,8 @@ function runApprovalScript( CHILD_WORKFLOW_SHA?: string; DIRECT_RELEASE_RECOVERY?: string; EXPECTED_WORKFLOW_BRANCH?: string; + EXPECTED_WORKFLOW_FULL_REF?: string; + EXPECTED_WORKFLOW_SHA?: string; EXPECTED_RUN_ATTEMPT?: string; APPROVAL_PATH?: string; GITHUB_REPOSITORY?: string; @@ -34,6 +36,8 @@ function runApprovalScript( CHILD_WORKFLOW_SHA: env.CHILD_WORKFLOW_SHA ?? "b".repeat(40), DIRECT_RELEASE_RECOVERY: env.DIRECT_RELEASE_RECOVERY ?? "false", EXPECTED_WORKFLOW_BRANCH: env.EXPECTED_WORKFLOW_BRANCH ?? "release/2026.6.21", + EXPECTED_WORKFLOW_FULL_REF: env.EXPECTED_WORKFLOW_FULL_REF ?? "", + EXPECTED_WORKFLOW_SHA: env.EXPECTED_WORKFLOW_SHA ?? "", EXPECTED_RUN_ATTEMPT: env.EXPECTED_RUN_ATTEMPT ?? "", APPROVAL_PATH: env.APPROVAL_PATH ?? "", GITHUB_REPOSITORY: env.GITHUB_REPOSITORY ?? "openclaw/openclaw", @@ -71,6 +75,7 @@ function approvalRun(overrides: Record = {}) { conclusion: null, event: "workflow_dispatch", headBranch: "release/2026.6.21", + repository: "openclaw/openclaw", status: "in_progress", url: "https://github.com/openclaw/openclaw/actions/runs/123", workflowName: "OpenClaw Release Publish", @@ -123,6 +128,27 @@ describe("scripts/validate-release-publish-approval.mjs", () => { expect(result.stdout).toBe(""); }); + it("binds the parent repository, workflow path, full ref, SHA, and attempt", () => { + const workflowSha = "d".repeat(40); + const fullRef = "refs/tags/release-publish/aaaaaaaaaaaa-111"; + const result = runApprovalScript( + approvalRun({ + headBranch: "release-publish/aaaaaaaaaaaa-111", + headSha: workflowSha, + path: `.github/workflows/openclaw-release-publish.yml@${fullRef}`, + runAttempt: 7, + }), + { + EXPECTED_RUN_ATTEMPT: "7", + EXPECTED_WORKFLOW_BRANCH: "release-publish/aaaaaaaaaaaa-111", + EXPECTED_WORKFLOW_FULL_REF: fullRef, + EXPECTED_WORKFLOW_SHA: workflowSha, + }, + ); + + expect(result.status, result.stderr).toBe(0); + }); + it("rejects completed runs for normal approval handoff", () => { const result = runApprovalScript(approvalRun({ conclusion: "success", status: "completed" }));