mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
fix(release): bind npm publishers to live tooling
This commit is contained in:
@@ -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 }}");
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user