fix(release): complete protected publish tooling

This commit is contained in:
Peter Steinberger
2026-07-15 10:37:45 +01:00
parent 3fccaec469
commit dfa246e6fd
6 changed files with 279 additions and 13 deletions

View File

@@ -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

View File

@@ -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[@]}"

View File

@@ -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

View File

@@ -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<v
version,
integrity,
attestations,
expectedWorkflowRef: process.env.OPENCLAW_NPM_EXPECTED_WORKFLOW_REF,
expectedWorkflowSha: process.env.OPENCLAW_NPM_EXPECTED_WORKFLOW_SHA,
});
console.log(
`openclaw-npm-postpublish-verify: registry signature and provenance attestation verified (${version})`,

View File

@@ -228,6 +228,107 @@ describe("npm registry provenance verification", () => {
"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,

View File

@@ -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<string, string>) {
});
}
function runOpenClawNpmTrustedRefGuard(overrides: Record<string, string>) {
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}")');