diff --git a/.github/workflows/openclaw-npm-release.yml b/.github/workflows/openclaw-npm-release.yml index fb094a0d2aa8..2ffd05c5ebfc 100644 --- a/.github/workflows/openclaw-npm-release.yml +++ b/.github/workflows/openclaw-npm-release.yml @@ -1407,11 +1407,25 @@ 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_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" \ + --allow-prevalidated-ref + } publish_target="${PUBLISH_TARBALL_PATH}" if [[ -n "${publish_target}" ]]; then publish_target="./${publish_target}" @@ -1429,11 +1443,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 1c1c223ce7d0..b7002454f5ca 100644 --- a/.github/workflows/openclaw-release-publish.yml +++ b/.github/workflows/openclaw-release-publish.yml @@ -962,6 +962,7 @@ jobs: CHILD_WORKFLOW_REF: ${{ github.ref_name }} PARENT_WORKFLOW_SHA: ${{ github.sha }} PARENT_WORKFLOW_BRANCH: ${{ github.ref_name }} + PARENT_WORKFLOW_FULL_REF: ${{ github.ref }} RELEASE_TAG: ${{ inputs.tag }} PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }} FULL_RELEASE_VALIDATION_RUN_ID: ${{ inputs.full_release_validation_run_id }} @@ -2283,6 +2284,13 @@ jobs: if [[ "$(jq -r '.normal.shouldDispatch' "${clawhub_plan_path}")" == "true" ]]; then clawhub_dispatch_args=() append_clawhub_dispatch_args normal + if [[ "${PARENT_WORKFLOW_FULL_REF}" == "refs/heads/main" || + "${PARENT_WORKFLOW_FULL_REF}" =~ ^refs/tags/release-publish/[a-f0-9]{12}-[1-9][0-9]*$ ]]; then + clawhub_dispatch_args+=( + -f release_publish_full_ref="${PARENT_WORKFLOW_FULL_REF}" + -f release_publish_workflow_sha="${PARENT_WORKFLOW_SHA}" + ) + fi plugin_clawhub_run_id="$(dispatch_workflow_at_ref \ "$(jq -r '.normal.ref' "${clawhub_plan_path}")" \ "${TARGET_SHA}" \ diff --git a/.github/workflows/plugin-clawhub-release.yml b/.github/workflows/plugin-clawhub-release.yml index 142c82687f77..ec3cdb9265ee 100644 --- a/.github/workflows/plugin-clawhub-release.yml +++ b/.github/workflows/plugin-clawhub-release.yml @@ -28,6 +28,16 @@ on: description: Branch name of the approving OpenClaw Release Publish workflow run required: false type: string + release_publish_full_ref: + description: Exact full ref of the approving OpenClaw Release Publish workflow tooling + required: false + default: "" + type: string + release_publish_workflow_sha: + description: Exact workflow SHA of the approving OpenClaw Release Publish tooling + required: false + default: "" + type: string dry_run: description: Validate the full ClawHub artifact handoff without publishing. required: false @@ -61,6 +71,7 @@ jobs: matrix: ${{ steps.plan.outputs.matrix }} bootstrap_matrix: ${{ steps.plan.outputs.bootstrap_matrix }} missing_trusted_publisher_matrix: ${{ steps.plan.outputs.missing_trusted_publisher_matrix }} + trusted_tooling_identity_json: ${{ steps.tooling_identity.outputs.json }} steps: - name: Checkout uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -92,6 +103,55 @@ jobs: fi echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + - name: Capture trusted tooling identity + id: tooling_identity + env: + CALLER_FULL_REF: ${{ github.ref }} + CALLER_REF: ${{ github.ref_name }} + CALLER_RUN_ATTEMPT: ${{ github.run_attempt }} + CALLER_RUN_ID: ${{ github.run_id }} + CALLER_SHA: ${{ github.sha }} + TOOLING_FULL_REF: ${{ inputs.release_publish_full_ref }} + TOOLING_REF: ${{ inputs.release_publish_branch }} + TOOLING_SHA: ${{ inputs.release_publish_workflow_sha }} + run: | + set -euo pipefail + if [[ -z "${TOOLING_FULL_REF}${TOOLING_SHA}" ]]; then + echo "json=" >> "$GITHUB_OUTPUT" + exit 0 + fi + [[ -n "$TOOLING_FULL_REF" && -n "$TOOLING_REF" && -n "$TOOLING_SHA" ]] || { + echo "Trusted release tooling ref, full ref, and SHA must be provided together." >&2 + exit 1 + } + identity="$( + jq -cn \ + --arg repository "$GITHUB_REPOSITORY" \ + --arg workflow ".github/workflows/plugin-clawhub-release.yml" \ + --arg runId "$CALLER_RUN_ID" \ + --arg runAttempt "$CALLER_RUN_ATTEMPT" \ + --arg ref "$CALLER_REF" \ + --arg fullRef "$CALLER_FULL_REF" \ + --arg sha "$CALLER_SHA" \ + --arg toolingRef "$TOOLING_REF" \ + --arg toolingFullRef "$TOOLING_FULL_REF" \ + --arg toolingSha "$TOOLING_SHA" \ + '{ + version: 1, + repository: $repository, + workflow: $workflow, + runId: $runId, + runAttempt: $runAttempt, + ref: $ref, + fullRef: $fullRef, + sha: $sha, + toolingRef: $toolingRef, + toolingFullRef: $toolingFullRef, + toolingSha: $toolingSha + }' + )" + echo "json=${identity}" >> "$GITHUB_OUTPUT" + - name: Validate OIDC source matches workflow ref env: TARGET_SHA: ${{ steps.ref.outputs.sha }} @@ -388,7 +448,7 @@ jobs: needs: [preview_plugins_clawhub, pack_plugins_clawhub_artifacts, approve_plugins_clawhub_release] if: always() && github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true' && needs.pack_plugins_clawhub_artifacts.result == 'success' && (inputs.dry_run == true || needs.approve_plugins_clawhub_release.result == 'success') - uses: openclaw/clawhub/.github/workflows/package-publish.yml@d8096dfc039e86ab942ddf9ef117d04849fd84c1 + uses: openclaw/clawhub/.github/workflows/package-publish.yml@6dc1e2bd67a90b5e5c54b3a026dbdfe3691f1202 permissions: actions: read contents: read @@ -411,6 +471,7 @@ jobs: source_path: ${{ matrix.plugin.packageDir }} inspector_artifact_name: ${{ matrix.plugin.artifactName }}-inspector publish_json_artifact_name: ${{ matrix.plugin.artifactName }}-publish-json + trusted_tooling_identity_json: ${{ needs.preview_plugins_clawhub.outputs.trusted_tooling_identity_json }} verify_published_clawhub_package: needs: [preview_plugins_clawhub, publish_plugins_clawhub] diff --git a/.github/workflows/plugin-npm-release.yml b/.github/workflows/plugin-npm-release.yml index 17b6dfe3cabc..01168772224e 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" @@ -1103,6 +1105,7 @@ jobs: PACKAGE_NAME: ${{ matrix.plugin.packageName }} PACKAGE_VERSION: ${{ matrix.plugin.version }} PUBLISH_TAG: ${{ matrix.plugin.publishTag }} + 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,35 +1164,12 @@ 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 - } - 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 "npm token bootstrap release-publish tag does not resolve to 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 - } - if [[ "$WORKFLOW_REF" == "refs/heads/main" ]]; then - 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 - } - fi + 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" fi artifact_id="$(jq -er '.id' "$artifact_metadata")" artifact_digest="$(jq -er '.digest' "$artifact_metadata")" @@ -1318,8 +1298,16 @@ 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_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 @@ -1379,12 +1367,17 @@ 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_RUN_ID: ${{ inputs.release_publish_run_id }} 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" ]] || { @@ -1410,6 +1403,12 @@ 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" HOME="$publish_home" \ NPM_CONFIG_GLOBALCONFIG=/dev/null \ NPM_CONFIG_IGNORE_SCRIPTS=true \ diff --git a/scripts/plugin-npm-publish.sh b/scripts/plugin-npm-publish.sh index 7d4c0c65fec9..ecf2c31b9744 100644 --- a/scripts/plugin-npm-publish.sh +++ b/scripts/plugin-npm-publish.sh @@ -172,6 +172,24 @@ 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:-}" + ) + 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 +246,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[@]}" diff --git a/scripts/release-tooling-identity.d.mts b/scripts/release-tooling-identity.d.mts new file mode 100644 index 000000000000..e092b5c8a531 --- /dev/null +++ b/scripts/release-tooling-identity.d.mts @@ -0,0 +1,29 @@ +export type ReleaseToolingIdentity = { + fullRef: string; + ref: string; + releasePublishRunId?: string; + route: "main" | "prevalidated-branch" | "protected-tag"; + sha: string; +}; + +export type ReleaseToolingIdentityInput = { + allowPrevalidatedRef?: boolean; + releasePublishRunId?: string; + workflowFullRef: string; + workflowRef: string; + workflowSha: string; +}; + +export function validateReleaseToolingIdentity( + input: ReleaseToolingIdentityInput & { + mainComparisonStatus?: unknown; + tagRef?: unknown; + }, +): ReleaseToolingIdentity; + +export function verifyReleaseToolingIdentity( + input: ReleaseToolingIdentityInput & { + repository: string; + runGh?: (args: string[]) => string; + }, +): ReleaseToolingIdentity; diff --git a/scripts/release-tooling-identity.mjs b/scripts/release-tooling-identity.mjs new file mode 100644 index 000000000000..d31e9b84bba5 --- /dev/null +++ b/scripts/release-tooling-identity.mjs @@ -0,0 +1,267 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +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 GH_COMMAND_TIMEOUT_MS = 60_000; + +function fail(message) { + throw new Error(message); +} + +function isRecord(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +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 classifyIdentity({ + allowPrevalidatedRef, + releasePublishRunId, + 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."); + } + const runId = requiredString(releasePublishRunId, "release publish run id"); + if (!/^[1-9][0-9]*$/u.test(runId) || runId !== protectedMatch[2]) { + fail("protected release tooling tag run does not match the release publish run id."); + } + return { fullRef, ref, releasePublishRunId: runId, 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, + mainComparisonStatus, + releasePublishRunId, + tagRef, + workflowFullRef, + workflowRef, + workflowSha, +}) { + const identity = classifyIdentity({ + allowPrevalidatedRef, + releasePublishRunId, + 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" && + mainComparisonStatus !== "ahead" && + mainComparisonStatus !== "identical" + ) { + fail("main release tooling SHA is not reachable from current main."); + } + + return identity; +} + +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, + releasePublishRunId, + repository, + runGh = runReleaseToolingGh, + workflowFullRef, + workflowRef, + workflowSha, +}) { + const normalizedRepository = requireRepository(repository); + const identity = classifyIdentity({ + allowPrevalidatedRef, + releasePublishRunId, + 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 }); + } + return validateReleaseToolingIdentity({ + allowPrevalidatedRef, + releasePublishRunId, + tagRef, + workflowFullRef, + workflowRef, + workflowSha, + }); + } + + 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 }); + } + return validateReleaseToolingIdentity({ + allowPrevalidatedRef, + mainComparisonStatus: isRecord(comparison) ? comparison.status : undefined, + releasePublishRunId, + workflowFullRef, + workflowRef, + workflowSha, + }); + } + + return identity; +} + +function parseArgs(argv) { + const options = { + allowPrevalidatedRef: false, + releasePublishRunId: "", + repository: "", + workflowFullRef: "", + workflowRef: "", + workflowSha: "", + }; + if (argv.shift() !== "verify") { + fail("usage: release-tooling-identity.mjs verify [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 === "--repository") { + options.repository = 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 identity = verifyReleaseToolingIdentity(parseArgs([...argv])); + 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/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index 65a78cae3412..c2490d7963c7 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -1627,20 +1627,69 @@ describe("package acceptance workflow", () => { ); }); - it("allows protected SHA-pinned tooling tags to consume token-bootstrap evidence", () => { + 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.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).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(evidenceStep.run).toContain( - 'gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${workflow_tag}"', + expect(corePublish.env).toMatchObject({ + GH_TOKEN: "${{ github.token }}", + 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(evidenceStep.run).toContain('[[ "$remote_workflow_sha" == "$WORKFLOW_SHA" ]]'); - expect(evidenceStep.run).toContain('if [[ "$WORKFLOW_REF" == "refs/heads/main" ]]; then'); - expect(evidenceStep.run).toContain('git merge-base --is-ancestor "$WORKFLOW_SHA" origin/main'); + expect(corePublish.run).toContain("--allow-prevalidated-ref"); + 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_ID: "${{ inputs.release_publish_run_id }}", + 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_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"); }); it("binds release evidence validation to the exact trusted workflow ref", () => { @@ -6893,6 +6942,7 @@ describe("package artifact reuse", () => { ".github/workflows/plugin-clawhub-new.yml", "publish_bootstrap_plugins", ); + const publishOrchestration = workflowStep(releasePublishJob, "Dispatch publish workflows"); const postpublishEvidence = workflowStep(releasePublishJob, "Upload postpublish evidence"); expect(packageJson.scripts).toMatchObject({ @@ -6919,13 +6969,53 @@ describe("package artifact reuse", () => { "approve_plugins_clawhub_release", ]); expect(clawHubPublish.uses).toBe( - "openclaw/clawhub/.github/workflows/package-publish.yml@d8096dfc039e86ab942ddf9ef117d04849fd84c1", + "openclaw/clawhub/.github/workflows/package-publish.yml@6dc1e2bd67a90b5e5c54b3a026dbdfe3691f1202", ); expect(clawHubPublish.permissions).toMatchObject({ actions: "read", contents: "read", "id-token": "write", }); + expect(clawHubPublish.with?.trusted_tooling_identity_json).toBe( + "${{ needs.preview_plugins_clawhub.outputs.trusted_tooling_identity_json }}", + ); + const clawHubPreview = workflowJob(PLUGIN_CLAWHUB_RELEASE_WORKFLOW, "preview_plugins_clawhub"); + expect(clawHubPreview.outputs?.trusted_tooling_identity_json).toBe( + "${{ steps.tooling_identity.outputs.json }}", + ); + const toolingIdentity = workflowStep(clawHubPreview, "Capture trusted tooling identity"); + expect(toolingIdentity.env).toMatchObject({ + CALLER_FULL_REF: "${{ github.ref }}", + CALLER_REF: "${{ github.ref_name }}", + CALLER_RUN_ATTEMPT: "${{ github.run_attempt }}", + CALLER_RUN_ID: "${{ github.run_id }}", + CALLER_SHA: "${{ github.sha }}", + TOOLING_FULL_REF: "${{ inputs.release_publish_full_ref }}", + TOOLING_REF: "${{ inputs.release_publish_branch }}", + TOOLING_SHA: "${{ inputs.release_publish_workflow_sha }}", + }); + for (const field of [ + "version: 1", + "repository: $repository", + "workflow: $workflow", + "runId: $runId", + "runAttempt: $runAttempt", + "ref: $ref", + "fullRef: $fullRef", + "sha: $sha", + "toolingRef: $toolingRef", + "toolingFullRef: $toolingFullRef", + "toolingSha: $toolingSha", + ]) { + expect(toolingIdentity.run).toContain(field); + } + expect(publishOrchestration.env?.PARENT_WORKFLOW_FULL_REF).toBe("${{ github.ref }}"); + expect(publishOrchestration.run).toContain( + '-f release_publish_full_ref="${PARENT_WORKFLOW_FULL_REF}"', + ); + expect(publishOrchestration.run).toContain( + '-f release_publish_workflow_sha="${PARENT_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..95496608499e 100644 --- a/test/scripts/plugin-npm-extended-stable-workflow.test.ts +++ b/test/scripts/plugin-npm-extended-stable-workflow.test.ts @@ -413,7 +413,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 +468,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..cb15028f575b 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,21 @@ 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.lastIndexOf("verify_release_tooling_identity"); + 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("prints help before package or npm checks", () => { const result = runPluginPublishWrapper(["--help"]); diff --git a/test/scripts/release-tooling-identity.test.ts b/test/scripts/release-tooling-identity.test.ts new file mode 100644 index 000000000000..76c9b2262c73 --- /dev/null +++ b/test/scripts/release-tooling-identity.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it, vi } from "vitest"; +import { + validateReleaseToolingIdentity, + verifyReleaseToolingIdentity, +} from "../../scripts/release-tooling-identity.mjs"; + +const SHA = "a".repeat(40); +const OTHER_SHA = "b".repeat(40); +const RUN_ID = "12345"; +const REF = `release-publish/${SHA.slice(0, 12)}-${RUN_ID}`; +const FULL_REF = `refs/tags/${REF}`; + +function protectedIdentity( + overrides: Partial[0]> = {}, +) { + return { + releasePublishRunId: RUN_ID, + repository: "openclaw/openclaw", + workflowFullRef: FULL_REF, + workflowRef: REF, + workflowSha: SHA, + ...overrides, + }; +} + +describe("release tooling identity", () => { + 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, + releasePublishRunId: RUN_ID, + 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", + ], + ["wrong release run", { releasePublishRunId: "54321" }, "run 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", () => { + expect( + verifyReleaseToolingIdentity({ + allowPrevalidatedRef: true, + repository: "openclaw/openclaw", + runGh: vi.fn(() => { + throw new Error("prevalidated branches do not require a remote identity query"); + }), + workflowFullRef: "refs/heads/release/2026.8.1", + workflowRef: "release/2026.8.1", + workflowSha: SHA, + }), + ).toMatchObject({ route: "prevalidated-branch" }); + }); +});