fix(release): bind npm publishers to live tooling

This commit is contained in:
Vincent Koc
2026-08-20 20:17:17 -07:00
parent c89a3dde28
commit 20a01f9308
11 changed files with 695 additions and 54 deletions
+100 -10
View File
@@ -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");
@@ -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 }}");
+16 -1
View File
@@ -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"]);
@@ -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<Parameters<typeof verifyReleaseToolingIdentity>[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" });
});
});