diff --git a/.github/workflows/openclaw-npm-release.yml b/.github/workflows/openclaw-npm-release.yml index ebca75a34d83..406f7653c8f4 100644 --- a/.github/workflows/openclaw-npm-release.yml +++ b/.github/workflows/openclaw-npm-release.yml @@ -554,6 +554,7 @@ jobs: - name: Checkout uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: + fetch-depth: 0 persist-credentials: false - name: Validate npm release request @@ -569,18 +570,33 @@ jobs: RELEASE_TAG: ${{ inputs.tag }} RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }} WORKFLOW_REF: ${{ github.ref }} + WORKFLOW_SHA: ${{ github.workflow_sha }} run: | set -euo pipefail tideclaw_alpha_publish=false extended_stable_publish=false + sha_pinned_release_publish=false if [[ "${RELEASE_TAG}" == *"-alpha."* && "${RELEASE_NPM_DIST_TAG}" == "alpha" && "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then tideclaw_alpha_publish=true fi if [[ "${RELEASE_NPM_DIST_TAG}" == "extended-stable" && "${WORKFLOW_REF}" == refs/heads/extended-stable/* ]]; then extended_stable_publish=true fi - if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]] && [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ ]] && [[ "${tideclaw_alpha_publish}" != "true" ]] && [[ "${extended_stable_publish}" != "true" ]]; then - echo "Real publish runs must be dispatched from main, release/YYYY.M.PATCH, the exact validated extended-stable branch, or a Tideclaw alpha branch for alpha prereleases. Use preflight_only=true for other branch validation." + 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 "SHA-pinned release-publish tag does not match the OpenClaw npm workflow SHA." >&2 + exit 1 + } + 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 + exit 1 + } + sha_pinned_release_publish=true + fi + if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]] && [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ ]] && [[ "${tideclaw_alpha_publish}" != "true" ]] && [[ "${extended_stable_publish}" != "true" ]] && [[ "${sha_pinned_release_publish}" != "true" ]]; then + echo "Real publish runs must be dispatched from main, release/YYYY.M.PATCH, a protected SHA-pinned release-publish tag, the exact validated extended-stable branch, or a Tideclaw alpha branch for alpha prereleases. Use preflight_only=true for other branch validation." exit 1 fi diff --git a/.github/workflows/openclaw-release-publish.yml b/.github/workflows/openclaw-release-publish.yml index 886ef371cd80..c0c7beacc545 100644 --- a/.github/workflows/openclaw-release-publish.yml +++ b/.github/workflows/openclaw-release-publish.yml @@ -1034,14 +1034,17 @@ jobs: if [[ "$state" != "$last_state" ]]; then echo "${workflow} still ${status} (updated ${updated_at}): ${url}" print_pending_deployments "${workflow}" "${run_id}" - approval_status=0 - approve_pending_deployments "${workflow}" "${run_id}" "${expected_sha}" || - approval_status=$? - if (( approval_status > 1 )); then - return 1 - fi last_state="$state" fi + # The deployment gate can appear after the run first reports + # waiting without changing updatedAt. Retry every poll so that + # propagation lag cannot strand an approved release. + approval_status=0 + approve_pending_deployments "${workflow}" "${run_id}" "${expected_sha}" || + approval_status=$? + if (( approval_status > 1 )); then + return 1 + fi sleep 30 done @@ -1838,7 +1841,9 @@ jobs: verify_args+=(--npm-telegram-run "${NPM_TELEGRAM_RUN_ID}") fi - node --import tsx \ + OPENCLAW_NPM_EXPECTED_WORKFLOW_REF="${GITHUB_REF}" \ + OPENCLAW_NPM_EXPECTED_WORKFLOW_SHA="${PARENT_WORKFLOW_SHA}" \ + node --import tsx \ "${GITHUB_WORKSPACE}/.release-harness/scripts/release-verify-beta.ts" \ "${verify_args[@]}" diff --git a/.github/workflows/plugin-npm-release.yml b/.github/workflows/plugin-npm-release.yml index ec8c04a45661..0a7e4c338097 100644 --- a/.github/workflows/plugin-npm-release.yml +++ b/.github/workflows/plugin-npm-release.yml @@ -1085,13 +1085,22 @@ jobs: exit 1 } if [[ "$publish_route" == "npm-token-bootstrap" ]]; then - [[ "$WORKFLOW_REF" == "refs/heads/main" ]] || { - echo "npm token bootstrap requires the trusted main plugin release workflow." >&2 + 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 } 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 main." >&2 + echo "npm token bootstrap workflow revision is not reachable from current main." >&2 exit 1 } fi diff --git a/scripts/openclaw-npm-postpublish-verify.ts b/scripts/openclaw-npm-postpublish-verify.ts index 59b46f3b507d..65a62aa9aabe 100644 --- a/scripts/openclaw-npm-postpublish-verify.ts +++ b/scripts/openclaw-npm-postpublish-verify.ts @@ -201,6 +201,12 @@ type NpmProvenanceStatement = { repository?: string; }; }; + resolvedDependencies?: Array<{ + digest?: { + gitCommit?: string; + }; + uri?: string; + }>; }; runDetails?: { builder?: { @@ -277,6 +283,10 @@ export function verifyNpmRegistrySignatures(params: { function resolveNpmProvenanceVerificationPolicy( statement: NpmProvenanceStatement, version: string, + expectedWorkflow?: { + ref?: string; + sha?: string; + }, ): NpmProvenanceVerificationPolicy { const parsedVersion = parseReleaseVersion(version); if (parsedVersion === null) { @@ -285,9 +295,37 @@ function resolveNpmProvenanceVerificationPolicy( const workflow = statement.predicate?.buildDefinition?.externalParameters?.workflow; const workflowRef = workflow?.ref; const expectedReleaseRef = `refs/heads/release/${parsedVersion.baseVersion}`; + const protectedReleasePublishMatch = + /^refs\/tags\/release-publish\/([a-f0-9]{12})-[1-9][0-9]*$/u.exec(workflowRef ?? ""); + let protectedReleasePublishTrusted = false; + if (protectedReleasePublishMatch) { + const expectedRef = expectedWorkflow?.ref; + const expectedSha = expectedWorkflow?.sha; + if ( + expectedRef !== workflowRef || + !/^[a-f0-9]{40}$/u.test(expectedSha ?? "") || + expectedSha?.slice(0, 12) !== protectedReleasePublishMatch[1] + ) { + throw new Error( + "npm provenance SHA-pinned release-publish ref does not match the approved workflow ref and SHA.", + ); + } + const expectedDependencyUri = `git+${NPM_PROVENANCE_REPOSITORY}@${workflowRef}`; + protectedReleasePublishTrusted = + statement.predicate?.buildDefinition?.resolvedDependencies?.some( + (dependency) => + dependency.uri === expectedDependencyUri && dependency.digest?.gitCommit === expectedSha, + ) === true; + if (!protectedReleasePublishTrusted) { + throw new Error( + "npm provenance does not bind the approved SHA-pinned release-publish ref to its workflow revision.", + ); + } + } const isTrustedRef = workflowRef === "refs/heads/main" || workflowRef === expectedReleaseRef || + protectedReleasePublishTrusted || (parsedVersion.channel === "alpha" && /^refs\/heads\/tideclaw\/alpha\/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$/u.test( workflowRef ?? "", @@ -320,6 +358,8 @@ async function verifySigstoreNpmProvenanceBundle( export async function verifyNpmProvenanceAttestation(params: { attestations: NpmRegistryAttestation[]; + expectedWorkflowRef?: string; + expectedWorkflowSha?: string; integrity: string; packageName: string; verifyBundle?: VerifyNpmProvenanceBundle; @@ -353,7 +393,10 @@ export async function verifyNpmProvenanceAttestation(params: { ) { let policy: NpmProvenanceVerificationPolicy; try { - policy = resolveNpmProvenanceVerificationPolicy(statement, params.version); + policy = resolveNpmProvenanceVerificationPolicy(statement, params.version, { + ref: params.expectedWorkflowRef, + sha: params.expectedWorkflowSha, + }); } catch (error) { policyError = error; continue; @@ -1156,6 +1199,8 @@ async function verifyPublishedRegistryProvenanceOnce(version: string): Promise { "https://github.com/openclaw/openclaw/.github/workflows/openclaw-npm-release.yml@refs/heads/release/2026.3.23", }); + verificationPolicy = undefined; + const protectedWorkflowSha = "a".repeat(40); + const protectedWorkflowRef = `refs/tags/release-publish/${protectedWorkflowSha.slice(0, 12)}-123`; + await expect( + verifyNpmProvenanceAttestation({ + packageName, + version, + integrity, + attestations: [ + { + predicateType: "https://slsa.dev/provenance/v1", + bundle: { + dsseEnvelope: { + payload: Buffer.from( + JSON.stringify({ + ...provenancePayload, + predicate: { + ...provenancePayload.predicate, + buildDefinition: { + ...provenancePayload.predicate.buildDefinition, + externalParameters: { + workflow: { + ...provenancePayload.predicate.buildDefinition.externalParameters + .workflow, + ref: protectedWorkflowRef, + }, + }, + resolvedDependencies: [ + { + uri: `git+https://github.com/openclaw/openclaw@${protectedWorkflowRef}`, + digest: { gitCommit: protectedWorkflowSha }, + }, + ], + }, + }, + }), + "utf8", + ).toString("base64"), + }, + }, + }, + ], + expectedWorkflowRef: protectedWorkflowRef, + expectedWorkflowSha: protectedWorkflowSha, + verifyBundle: async (_bundle, policy) => { + verificationPolicy = policy; + }, + }), + ).resolves.toBeUndefined(); + expect(verificationPolicy).toEqual({ + certificateIssuer: "https://token.actions.githubusercontent.com", + certificateIdentityURI: `https://github.com/openclaw/openclaw/.github/workflows/openclaw-npm-release.yml@${protectedWorkflowRef}`, + }); + + await expect( + verifyNpmProvenanceAttestation({ + packageName, + version, + integrity, + attestations: [ + { + predicateType: "https://slsa.dev/provenance/v1", + bundle: { + dsseEnvelope: { + payload: Buffer.from( + JSON.stringify({ + ...provenancePayload, + predicate: { + ...provenancePayload.predicate, + buildDefinition: { + ...provenancePayload.predicate.buildDefinition, + externalParameters: { + workflow: { + ...provenancePayload.predicate.buildDefinition.externalParameters + .workflow, + ref: protectedWorkflowRef, + }, + }, + resolvedDependencies: [ + { + uri: `git+https://github.com/openclaw/openclaw@${protectedWorkflowRef}`, + digest: { gitCommit: protectedWorkflowSha }, + }, + ], + }, + }, + }), + "utf8", + ).toString("base64"), + }, + }, + }, + ], + expectedWorkflowRef: protectedWorkflowRef, + expectedWorkflowSha: "b".repeat(40), + verifyBundle: async () => undefined, + }), + ).rejects.toThrow( + "npm provenance SHA-pinned release-publish ref does not match the approved workflow ref and SHA", + ); + await expect( verifyNpmProvenanceAttestation({ packageName, diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index 6f20abc3c6c9..10176359bf78 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -27,6 +27,7 @@ const DOCKER_E2E_PLAN_ACTION = ".github/actions/docker-e2e-plan/action.yml"; const RELEASE_CHECKS_WORKFLOW = ".github/workflows/openclaw-release-checks.yml"; const RELEASE_TELEGRAM_QA_WORKFLOW = ".github/workflows/openclaw-release-telegram-qa.yml"; const RELEASE_PUBLISH_WORKFLOW = ".github/workflows/openclaw-release-publish.yml"; +const OPENCLAW_NPM_RELEASE_WORKFLOW = ".github/workflows/openclaw-npm-release.yml"; const PLUGIN_CLAWHUB_RELEASE_WORKFLOW = ".github/workflows/plugin-clawhub-release.yml"; const PLUGIN_NPM_RELEASE_WORKFLOW = ".github/workflows/plugin-npm-release.yml"; const ANDROID_RELEASE_WORKFLOW = ".github/workflows/android-release.yml"; @@ -250,6 +251,33 @@ function runReleasePublishInputValidation(overrides: Record) { }); } +function runOpenClawNpmTrustedRefGuard(overrides: Record) { + const job = workflowJob(OPENCLAW_NPM_RELEASE_WORKFLOW, "validate_publish_request"); + const script = workflowStep(job, "Require trusted workflow ref for publish").run; + if (!script) { + throw new Error("Expected OpenClaw npm trusted ref guard"); + } + const binDir = tempDirs.make("openclaw-npm-trusted-ref-"); + const gitPath = `${binDir}/git`; + 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`, + ); + chmodSync(gitPath, 0o755); + return spawnSync("bash", ["-c", script], { + encoding: "utf8", + env: { + MOCK_WORKFLOW_ANCESTOR: "true", + PATH: `${binDir}:${process.env.PATH}`, + RELEASE_NPM_DIST_TAG: "beta", + RELEASE_TAG: "v2026.7.2-beta.1", + WORKFLOW_REF: "refs/heads/release/2026.7.2", + WORKFLOW_SHA: "a".repeat(40), + ...overrides, + }, + }); +} + function runReleaseChecksSummary(params: { currentAttempt: string; currentResult: "cancelled" | "failure" | "skipped" | "success"; @@ -381,6 +409,64 @@ describe("package acceptance workflow", () => { ); }); + it("allows protected SHA-pinned tooling tags through the core npm publish guard", () => { + const workflowSha = "a".repeat(40); + const protectedRef = `refs/tags/release-publish/${workflowSha.slice(0, 12)}-123`; + + const valid = runOpenClawNpmTrustedRefGuard({ + WORKFLOW_REF: protectedRef, + WORKFLOW_SHA: workflowSha, + }); + expect(valid.status, valid.stderr).toBe(0); + + const mismatchedName = runOpenClawNpmTrustedRefGuard({ + WORKFLOW_REF: `refs/tags/release-publish/${"b".repeat(12)}-123`, + WORKFLOW_SHA: workflowSha, + }); + expect(mismatchedName.status).toBe(1); + expect(mismatchedName.stderr).toContain( + "SHA-pinned release-publish tag does not match the OpenClaw npm workflow SHA", + ); + + const unreachable = runOpenClawNpmTrustedRefGuard({ + MOCK_WORKFLOW_ANCESTOR: "false", + 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", + ); + }); + + it("allows protected SHA-pinned tooling tags to consume 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.run).toContain('git merge-base --is-ancestor "$WORKFLOW_SHA" origin/main'); + }); + + it("retries child environment approval when deployment propagation lags", () => { + const publishJob = workflowJob(RELEASE_PUBLISH_WORKFLOW, "publish"); + const orchestration = workflowStep(publishJob, "Dispatch publish workflows").run; + if (!orchestration) { + throw new Error("Expected release publish orchestration script"); + } + const waitForRun = shellFunctionSource(orchestration, "wait_for_run"); + const stateAssignment = waitForRun.indexOf('last_state="$state"'); + const approvalRetry = waitForRun.indexOf( + 'approve_pending_deployments "${workflow}" "${run_id}" "${expected_sha}" ||', + ); + + expect(stateAssignment).toBeGreaterThan(-1); + expect(approvalRetry).toBeGreaterThan(stateAssignment); + expect(waitForRun).toContain("propagation lag cannot strand an approved release"); + }); + it("resolves broad release evidence and exact-binds every publish child", () => { const resolveJob = workflowJob(RELEASE_PUBLISH_WORKFLOW, "resolve_release_target"); const publishJob = workflowJob(RELEASE_PUBLISH_WORKFLOW, "publish"); @@ -3784,6 +3870,10 @@ describe("package artifact reuse", () => { '"${RELEASE_TAG}" == *"-alpha."* && "${RELEASE_NPM_DIST_TAG}" == "alpha"', ); expect(releaseWorkflow).toContain('--workflow-ref "${CHILD_WORKFLOW_REF}"'); + expect(releaseWorkflow).toContain('OPENCLAW_NPM_EXPECTED_WORKFLOW_REF="${GITHUB_REF}"'); + expect(releaseWorkflow).toContain( + 'OPENCLAW_NPM_EXPECTED_WORKFLOW_SHA="${PARENT_WORKFLOW_SHA}"', + ); expect(releaseWorkflow).toContain("--skip-github-release"); expect(clawHubReleasePlanScript).toContain("--plugin-clawhub-bootstrap-run"); expect(releaseWorkflow).toContain('verify_args+=(--plugins "${PLUGINS}")');