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
@@ -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
@@ -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}" \
+62 -1
View File
@@ -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]
+28 -29
View File
@@ -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 \
+21
View File
@@ -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[@]}"
+29
View File
@@ -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;
+267
View File
@@ -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);
}
}
+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" });
});
});