mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-14 10:06:04 +00:00
390 lines
18 KiB
TypeScript
390 lines
18 KiB
TypeScript
import { readFileSync } from "node:fs";
|
|
import { describe, expect, it } from "vitest";
|
|
import { parse } from "yaml";
|
|
|
|
const workflowPath = ".github/workflows/plugin-npm-release.yml";
|
|
const metaPackagePath = "extensions/meta/package.json";
|
|
const metaManifestPath = "extensions/meta/openclaw.plugin.json";
|
|
|
|
type Step = {
|
|
env?: Record<string, string>;
|
|
if?: string;
|
|
name?: string;
|
|
run?: string;
|
|
uses?: string;
|
|
with?: Record<string, string | number>;
|
|
};
|
|
type Job = {
|
|
name?: string;
|
|
environment?: string;
|
|
if?: string;
|
|
needs?: string[] | string;
|
|
permissions?: Record<string, string>;
|
|
"runs-on"?: string;
|
|
steps?: Step[];
|
|
strategy?: { matrix?: { plugin?: string } };
|
|
};
|
|
type WorkflowInput = {
|
|
default?: boolean | string;
|
|
description?: string;
|
|
options?: string[];
|
|
required?: boolean;
|
|
type?: string;
|
|
};
|
|
type Workflow = {
|
|
on?: {
|
|
workflow_dispatch?: {
|
|
inputs?: Record<string, WorkflowInput>;
|
|
};
|
|
};
|
|
jobs?: Record<string, Job>;
|
|
};
|
|
|
|
function workflow(): Workflow {
|
|
return parse(readFileSync(workflowPath, "utf8")) as Workflow;
|
|
}
|
|
|
|
function step(job: Job | undefined, name: string): Step {
|
|
const found = job?.steps?.find((candidate) => candidate.name === name);
|
|
if (!found) {
|
|
throw new Error(`Missing workflow step: ${name}`);
|
|
}
|
|
return found;
|
|
}
|
|
|
|
describe("plugin npm extended-stable workflow", () => {
|
|
it("exposes only the default behavior and closed extended-stable override", () => {
|
|
expect(readFileSync(workflowPath, "utf8")).toContain(
|
|
"Plugin NPM Release [{0}] {1}', inputs.npm_dist_tag, inputs.ref",
|
|
);
|
|
const input = workflow().on?.workflow_dispatch?.inputs?.npm_dist_tag;
|
|
expect(input).toEqual({
|
|
description: "Optional npm dist-tag override",
|
|
required: true,
|
|
default: "default",
|
|
type: "choice",
|
|
options: ["default", "extended-stable"],
|
|
});
|
|
});
|
|
|
|
it("exposes a closed preflight-only mode", () => {
|
|
const inputs = workflow().on?.workflow_dispatch?.inputs;
|
|
expect(inputs?.preflight_only).toEqual({
|
|
description: "Prepare and verify immutable plugin npm artifacts without publishing",
|
|
required: true,
|
|
default: false,
|
|
type: "boolean",
|
|
});
|
|
expect(inputs?.ref?.description).toBe(
|
|
"Exact commit SHA; preflight accepts main/release ancestry, while publish mode also supports canonical extended-stable or matching Tideclaw alpha branches",
|
|
);
|
|
});
|
|
|
|
it("uses one override for check, plan, preview, pack, and publish", () => {
|
|
const parsed = workflow();
|
|
const raw = readFileSync(workflowPath, "utf8");
|
|
expect(raw.match(/--npm-dist-tag "\$\{NPM_DIST_TAG\}"/gu)).toHaveLength(2);
|
|
const expectedOverride =
|
|
"${{ inputs.npm_dist_tag == 'extended-stable' && inputs.npm_dist_tag || '' }}";
|
|
for (const name of [
|
|
"Preview publish command",
|
|
"Preview npm pack contents",
|
|
"Publish with trusted publisher",
|
|
]) {
|
|
expect(
|
|
step(
|
|
parsed.jobs?.[
|
|
name === "Publish with trusted publisher"
|
|
? "publish_plugins_npm"
|
|
: "preview_plugin_pack"
|
|
],
|
|
name,
|
|
).env,
|
|
).toMatchObject({ OPENCLAW_PLUGIN_NPM_PUBLISH_TAG: expectedOverride });
|
|
}
|
|
});
|
|
|
|
it("trusts only the canonical monthly branch at the exact checked-out SHA", () => {
|
|
const trusted = step(
|
|
workflow().jobs?.preview_plugins_npm,
|
|
"Validate ref is on a trusted publish branch",
|
|
);
|
|
expect(trusted.run).toContain("extended-stable/${release_year}.${release_month}.33");
|
|
expect(trusted.run).toContain("exact 40-character source SHA");
|
|
expect(trusted.run).toContain(
|
|
'[[ "${WORKFLOW_REF}" == "refs/heads/${extended_stable_branch}" ]]',
|
|
);
|
|
expect(trusted.run).toContain(
|
|
'[[ "$(git rev-parse HEAD)" == "$(git rev-parse "refs/remotes/origin/${extended_stable_branch}")" ]]',
|
|
);
|
|
});
|
|
|
|
it("binds preflight to an exact source SHA without release-publish approval", () => {
|
|
const preview = workflow().jobs?.preview_plugins_npm;
|
|
const previewSteps = preview?.steps ?? [];
|
|
const trusted = step(preview, "Validate ref is on a trusted publish branch");
|
|
expect(previewSteps.slice(0, 4).map((candidate) => candidate.name)).toEqual([
|
|
"Checkout",
|
|
"Resolve checked-out ref",
|
|
"Validate ref is on a trusted publish branch",
|
|
"Setup Node environment",
|
|
]);
|
|
const trustedIndex = previewSteps.indexOf(trusted);
|
|
expect(trustedIndex).toBe(2);
|
|
for (const candidate of previewSteps.slice(0, trustedIndex)) {
|
|
expect(candidate.uses?.startsWith("./"), candidate.name).not.toBe(true);
|
|
expect(candidate.run ?? "", candidate.name).not.toMatch(/\b(?:bun|npm|pnpm)\b/u);
|
|
}
|
|
expect(step(preview, "Setup Node environment").uses).toBe("./.github/actions/setup-node-env");
|
|
expect(trusted.env).toMatchObject({
|
|
PREFLIGHT_ONLY:
|
|
"${{ github.event_name == 'workflow_dispatch' && inputs.preflight_only || false }}",
|
|
RELEASE_PUBLISH_RUN_ID:
|
|
"${{ github.event_name == 'workflow_dispatch' && inputs.release_publish_run_id || '' }}",
|
|
SOURCE_REF: "${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}",
|
|
WORKFLOW_REF: "${{ github.ref }}",
|
|
WORKFLOW_SHA: "${{ github.workflow_sha }}",
|
|
});
|
|
expect(trusted.run).toContain('[[ "${WORKFLOW_REF}" != "refs/heads/main" ]]');
|
|
expect(trusted.run).toContain('git merge-base --is-ancestor "${WORKFLOW_SHA}" origin/main');
|
|
expect(trusted.run).toContain('[[ ! "${SOURCE_REF}" =~ ^[0-9a-fA-F]{40}$ ]]');
|
|
expect(trusted.run).toContain(
|
|
'[[ "$(git rev-parse HEAD)" != "$(git rev-parse "${SOURCE_REF}^{commit}")" ]]',
|
|
);
|
|
expect(trusted.run).toContain("preflight must not include release_publish_run_id");
|
|
const preflightBranchRejection = trusted.run?.indexOf(
|
|
"Plugin npm preflight target must be reachable from main or release/*.",
|
|
);
|
|
const tideclawBranch = trusted.run?.indexOf(
|
|
'if [[ "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/',
|
|
);
|
|
expect(preflightBranchRejection).toBeGreaterThan(-1);
|
|
expect(tideclawBranch).toBeGreaterThan(preflightBranchRejection ?? Number.MAX_SAFE_INTEGER);
|
|
});
|
|
|
|
it("prepares and independently reads back immutable package evidence", () => {
|
|
const parsed = workflow();
|
|
const preview = parsed.jobs?.preview_plugin_pack;
|
|
expect(preview?.if).toContain("inputs.preflight_only");
|
|
expect(preview?.strategy?.matrix?.plugin).toContain("all_matrix");
|
|
|
|
const prepare = step(preview, "Prepare immutable npm preflight artifact");
|
|
expect(prepare.env?.ARTIFACT_NAME).toBe(
|
|
"plugin-npm-package-source-${{ needs.preview_plugins_npm.outputs.ref_revision }}-${{ matrix.plugin.extensionId }}",
|
|
);
|
|
expect(prepare.if).toBeUndefined();
|
|
expect(prepare.run).toContain('bash scripts/plugin-npm-publish.sh --pack "${PACKAGE_DIR}"');
|
|
expect(prepare.run).toContain('raw.lastIndexOf("[")');
|
|
expect(prepare.run).toContain("npm can print bundled-dependency summaries");
|
|
expect(prepare.run).toContain("if (index === 0)");
|
|
expect(prepare.run).toContain(
|
|
"fs.writeFileSync(process.argv[3], `${JSON.stringify(pack, null, 2)}\\n`)",
|
|
);
|
|
expect(prepare.run).toContain('path.join(process.env.ARTIFACT_DIR, "preflight-manifest.json")');
|
|
expect(prepare.run).toContain('kind: "openclaw-plugin-npm-preflight"');
|
|
expect(prepare.run).toContain('mode: "preflight-only"');
|
|
expect(prepare.run).toContain("source_package_json_sha256=");
|
|
expect(prepare.run).toContain("packed_package_json_sha256=");
|
|
expect(prepare.run).toContain(
|
|
"sourcePackageJsonSha256: process.env.SOURCE_PACKAGE_JSON_SHA256",
|
|
);
|
|
expect(prepare.run).toContain("packageJsonSha256: process.env.PACKED_PACKAGE_JSON_SHA256");
|
|
expect(prepare.run).toContain("npmIntegrity: actualIntegrity");
|
|
expect(prepare.run).toContain("npmShasum: actualShasum");
|
|
expect(prepare.run).toContain('typeof pluginManifest.id !== "string"');
|
|
expect(prepare.run).not.toContain("pluginManifest.id !== process.env.EXTENSION_ID");
|
|
expect(prepare.run).toContain(
|
|
'trustPolicy: "workflow-main-and-target-main-or-release-ancestor"',
|
|
);
|
|
expect(prepare.run).toContain("npmPublish: false");
|
|
expect(prepare.run).toContain("environmentApproval: false");
|
|
expect(prepare.run).toContain("oidcWrite: false");
|
|
|
|
const upload = step(preview, "Upload immutable npm preflight artifact");
|
|
expect(upload.uses).toBe("actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a");
|
|
expect(upload.with).toMatchObject({
|
|
"compression-level": 0,
|
|
"if-no-files-found": "error",
|
|
overwrite: true,
|
|
"retention-days": 30,
|
|
});
|
|
|
|
const verify = parsed.jobs?.verify_plugin_npm_preflight;
|
|
expect(verify?.needs).toEqual(["preview_plugins_npm", "preview_plugin_pack"]);
|
|
expect(verify?.strategy?.matrix?.plugin).toContain("all_matrix");
|
|
expect(verify?.strategy?.matrix?.plugin).toContain("matrix");
|
|
expect(verify?.name).toBe("Preflight plugin npm package (${{ matrix.plugin.packageName }})");
|
|
const trustedCheckout = step(verify, "Checkout trusted npm preflight tooling");
|
|
expect(trustedCheckout.with?.ref).toBe("${{ github.workflow_sha }}");
|
|
const download = step(verify, "Download immutable npm preflight artifact");
|
|
expect(download.uses).toBe(
|
|
"actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c",
|
|
);
|
|
expect(download.with?.name).toBe(
|
|
"plugin-npm-package-source-${{ needs.preview_plugins_npm.outputs.ref_revision }}-${{ matrix.plugin.extensionId }}",
|
|
);
|
|
const readback = step(verify, "Validate npm preflight artifact readback");
|
|
expect(readback.run).toContain('git show "${SOURCE_SHA}:${PACKAGE_DIR}/package.json"');
|
|
expect(readback.run).toContain("Expected exactly one live package artifact named");
|
|
expect(readback.run).toContain('crypto.createHash("sha256")');
|
|
expect(readback.run).toContain('crypto.createHash("sha512")');
|
|
expect(readback.run).toContain('crypto.createHash("sha1")');
|
|
expect(readback.run).toContain('echo "npm_integrity=${npm_integrity}"');
|
|
expect(readback.run).toContain('echo "npm_shasum=${npm_shasum}"');
|
|
expect(readback.run).toContain(
|
|
"Packed plugin identity, package hashes, or install route changed",
|
|
);
|
|
expect(readback.run).toContain("manifest.package.pluginId !== pluginManifest.id");
|
|
expect(readback.run).not.toContain("manifest.package.pluginId !== process.env.EXTENSION_ID");
|
|
expect(readback.run).toContain(
|
|
'trustPolicy: "workflow-main-and-target-main-or-release-ancestor"',
|
|
);
|
|
expect(readback.run).not.toContain("target-main-release-or-tideclaw");
|
|
|
|
const route = step(verify, "Verify npm publication route readiness");
|
|
expect(route.env).toMatchObject({
|
|
EXPECTED_NPM_INTEGRITY: "${{ steps.publication_artifact.outputs.npm_integrity }}",
|
|
EXPECTED_NPM_SHASUM: "${{ steps.publication_artifact.outputs.npm_shasum }}",
|
|
});
|
|
expect(route.run).toContain("encodeURIComponent(packageName)");
|
|
expect(route.run).toContain("fetchNpmRegistryPackumentWithRetry");
|
|
expect(route.run).toContain("resolvePublishedNpmVersionRoute");
|
|
expect(route.run).toContain('distTags: packument["dist-tags"] ?? {}');
|
|
expect(route.run).toContain("const requestAttempts = 3");
|
|
expect(route.run).toContain("const requestTimeoutMs = 20_000");
|
|
expect(route.run).toContain("attempts: requestAttempts");
|
|
expect(route.run).toContain("timeoutMs: requestTimeoutMs");
|
|
expect(route.run).not.toContain("response.json()");
|
|
expect(route.run).toContain("packument.versions?.[packageVersion]?.dist");
|
|
expect(route.run).toContain("targetDist?.integrity !== expectedIntegrity");
|
|
expect(route.run).toContain("targetDist?.shasum !== expectedShasum");
|
|
expect(route.run).toContain("npm registry tarball identity does not match");
|
|
expect(route.run).toContain('observations.push("npm-token-bootstrap")');
|
|
expect(route.run).toContain('observations.push("npm-oidc")');
|
|
|
|
const evidence = step(verify, "Create immutable plugin npm publication evidence");
|
|
expect(evidence.env?.PUBLISH_ROUTE).toBe("${{ steps.publication_route.outputs.route }}");
|
|
expect(evidence.run).toContain("node scripts/plugin-publication-artifact.mjs create");
|
|
expect(evidence.run).toContain("--publisher-policy-id plugin-npm-release-workflow");
|
|
expect(evidence.run).toContain('--route "$PUBLISH_ROUTE"');
|
|
expect(evidence.run).toContain('artifact_name="${ARTIFACT_NAME_PREFIX}-${PUBLISH_ROUTE}-');
|
|
const evidenceUpload = step(verify, "Upload immutable plugin npm preflight evidence");
|
|
expect(evidenceUpload.with?.name).toBe("${{ steps.preflight_evidence.outputs.artifact_name }}");
|
|
expect(evidenceUpload.with?.path).toBe(
|
|
"${{ steps.preflight_evidence.outputs.artifact_path }}/*",
|
|
);
|
|
});
|
|
|
|
it("makes every publication capability unreachable in preflight mode", () => {
|
|
const parsed = workflow();
|
|
for (const jobName of [
|
|
"validate_release_publish_approval",
|
|
"publish_plugins_npm",
|
|
"verify_plugins_npm",
|
|
]) {
|
|
expect(parsed.jobs?.[jobName]?.if, jobName).toContain("!inputs.preflight_only");
|
|
}
|
|
|
|
for (const jobName of [
|
|
"preview_plugins_npm",
|
|
"preview_plugin_pack",
|
|
"verify_plugin_npm_preflight",
|
|
]) {
|
|
const job = parsed.jobs?.[jobName];
|
|
expect(job?.environment, jobName).toBeUndefined();
|
|
expect(job?.permissions?.["id-token"], jobName).not.toBe("write");
|
|
const serialized = JSON.stringify(job);
|
|
expect(serialized, jobName).not.toContain("secrets.");
|
|
expect(serialized, jobName).not.toContain("plugin-npm-publish.sh --publish");
|
|
expect(serialized, jobName).not.toMatch(/\bnpm publish\b/u);
|
|
expect(serialized, jobName).not.toMatch(/\bnpm dist-tag\b/u);
|
|
expect(serialized.replaceAll("clawHub: false", ""), jobName).not.toMatch(/\bclawhub\b/iu);
|
|
expect(serialized, jobName).not.toMatch(/\b(?:android|macos|windows)\b/iu);
|
|
}
|
|
});
|
|
|
|
it("attests the canonical Meta provider package and install route", () => {
|
|
const packageJson = JSON.parse(readFileSync(metaPackagePath, "utf8")) as {
|
|
name?: string;
|
|
openclaw?: {
|
|
install?: { npmSpec?: string };
|
|
release?: { publishToClawHub?: boolean; publishToNpm?: boolean };
|
|
};
|
|
};
|
|
const pluginManifest = JSON.parse(readFileSync(metaManifestPath, "utf8")) as { id?: string };
|
|
expect(packageJson.name).toBe("@openclaw/meta-provider");
|
|
expect(packageJson.openclaw?.install?.npmSpec).toBe("@openclaw/meta-provider");
|
|
expect(packageJson.openclaw?.release).toEqual({
|
|
publishToClawHub: true,
|
|
publishToNpm: true,
|
|
});
|
|
expect(pluginManifest.id).toBe("meta");
|
|
});
|
|
|
|
it("publishes extended-stable with OIDC only and verifies every package tag", () => {
|
|
const parsed = workflow();
|
|
const publish = step(parsed.jobs?.publish_plugins_npm, "Publish with trusted publisher");
|
|
expect(publish.env).toMatchObject({
|
|
OPENCLAW_NPM_PUBLISH_AUTH_MODE: "trusted-publisher",
|
|
});
|
|
expect(publish.env?.NODE_AUTH_TOKEN).toBeUndefined();
|
|
expect(publish.env?.NPM_TOKEN).toBeUndefined();
|
|
const bootstrap = step(
|
|
parsed.jobs?.publish_plugins_npm,
|
|
"Publish approved Meta bootstrap tarball",
|
|
);
|
|
expect(bootstrap.if).toContain("npm-token-bootstrap");
|
|
expect(bootstrap.env?.NPM_TOKEN).toBe("${{ secrets.NPM_TOKEN }}");
|
|
expect(bootstrap.run).toContain(
|
|
'[[ "$PACKAGE_NAME" == "@openclaw/meta-provider" && "$PACKAGE_DIR" == "extensions/meta" ]]',
|
|
);
|
|
expect(bootstrap.run).toContain("NPM_CONFIG_USERCONFIG");
|
|
expect(bootstrap.run).toContain("unset NODE_AUTH_TOKEN NPM_TOKEN NODE_OPTIONS");
|
|
expect(bootstrap.run).toContain('npm publish "$TARBALL_PATH"');
|
|
expect(bootstrap.run).toContain("--ignore-scripts");
|
|
expect(bootstrap.run).not.toContain("bash scripts/plugin-npm-publish.sh");
|
|
|
|
const consume = step(
|
|
parsed.jobs?.publish_plugins_npm,
|
|
"Consume immutable npm publication evidence",
|
|
);
|
|
expect(consume.run).toContain("node scripts/plugin-publication-artifact.mjs verify");
|
|
expect(consume.run).toContain("--run-state-policy same-run-producer-success");
|
|
expect(consume.run).toContain("producer_attempt");
|
|
expect(consume.run).toContain("last.producer_attempt");
|
|
expect(consume.run).toContain(
|
|
'--producer-job-name "Preflight plugin npm package (${PACKAGE_NAME})"',
|
|
);
|
|
expect(consume.run).toContain("--workflow-jobs-metadata");
|
|
expect(consume.run).toContain("--source-package-json-sha256");
|
|
expect(consume.run).toContain('[[ "$WORKFLOW_REF" == "refs/heads/main" ]]');
|
|
expect(consume.run).toContain('git merge-base --is-ancestor "$WORKFLOW_SHA" origin/main');
|
|
expect(
|
|
step(parsed.jobs?.publish_plugins_npm, "Checkout trusted publication tooling").with?.ref,
|
|
).toBe("${{ github.workflow_sha }}");
|
|
expect(
|
|
step(parsed.jobs?.publish_plugins_npm, "Setup trusted publication dependencies").if,
|
|
).toContain("npm-token-bootstrap");
|
|
expect(
|
|
step(parsed.jobs?.publish_plugins_npm, "Setup trusted publication dependencies").if,
|
|
).toContain("npm-readback");
|
|
expect(step(parsed.jobs?.publish_plugins_npm, "Checkout OIDC publication target").if).toContain(
|
|
"npm-oidc",
|
|
);
|
|
expect(parsed.jobs?.reconcile_plugins_npm).toBeUndefined();
|
|
expect(readFileSync(workflowPath, "utf8")).not.toContain(
|
|
'npm dist-tag add "${PACKAGE_NAME}@${PACKAGE_VERSION}" extended-stable',
|
|
);
|
|
|
|
const verify = parsed.jobs?.verify_plugins_npm;
|
|
expect(verify?.needs).toEqual(["preview_plugins_npm", "publish_plugins_npm"]);
|
|
expect(verify?.if).toContain("always()");
|
|
expect(verify?.if).toContain("has_candidates == 'false'");
|
|
expect(verify?.strategy?.matrix?.plugin).toContain("all_matrix");
|
|
const readback = step(verify, "Verify complete plugin registry readback");
|
|
expect(readback.run).toContain('npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version');
|
|
expect(readback.run).toContain('npm view "${PACKAGE_NAME}@extended-stable" version');
|
|
expect(readback.run).toContain("OIDC-only source workflow does not mutate tags");
|
|
});
|
|
});
|