toolfactory 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +166 -0
- package/dist/bindings/index.d.ts +23 -0
- package/dist/bindings/index.js +29 -0
- package/dist/bindings/python.d.ts +33 -0
- package/dist/bindings/python.js +805 -0
- package/dist/bindings/python.test.d.ts +1 -0
- package/dist/bindings/python.test.js +316 -0
- package/dist/bindings/typescript.d.ts +39 -0
- package/dist/bindings/typescript.js +758 -0
- package/dist/bindings/typescript.test.d.ts +1 -0
- package/dist/bindings/typescript.test.js +264 -0
- package/dist/commands.d.ts +188 -0
- package/dist/commands.js +662 -0
- package/dist/commands.test.d.ts +1 -0
- package/dist/commands.test.js +215 -0
- package/dist/e2e.test.d.ts +1 -0
- package/dist/e2e.test.js +124 -0
- package/dist/hosts/browser.d.ts +10 -0
- package/dist/hosts/browser.js +94 -0
- package/dist/hosts/github.d.ts +66 -0
- package/dist/hosts/github.js +247 -0
- package/dist/hosts/openclaw.d.ts +4 -0
- package/dist/hosts/openclaw.js +94 -0
- package/dist/hosts/template.d.ts +15 -0
- package/dist/hosts/template.js +142 -0
- package/dist/hosts/web.d.ts +4 -0
- package/dist/hosts/web.js +89 -0
- package/dist/identity/name.d.ts +20 -0
- package/dist/identity/name.js +33 -0
- package/dist/identity/name.test.d.ts +1 -0
- package/dist/identity/name.test.js +37 -0
- package/dist/identity/read.d.ts +9 -0
- package/dist/identity/read.js +106 -0
- package/dist/identity/read.test.d.ts +1 -0
- package/dist/identity/read.test.js +76 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/introspect/index.d.ts +15 -0
- package/dist/introspect/index.js +63 -0
- package/dist/model.d.ts +236 -0
- package/dist/model.js +214 -0
- package/dist/ops.d.ts +255 -0
- package/dist/ops.js +297 -0
- package/dist/project/apply.d.ts +35 -0
- package/dist/project/apply.js +360 -0
- package/dist/project/apply.test.d.ts +1 -0
- package/dist/project/apply.test.js +285 -0
- package/dist/project/gate.d.ts +129 -0
- package/dist/project/gate.js +597 -0
- package/dist/project/history.d.ts +62 -0
- package/dist/project/history.js +41 -0
- package/dist/project/load.d.ts +21 -0
- package/dist/project/load.js +82 -0
- package/dist/project/lock.d.ts +28 -0
- package/dist/project/lock.js +42 -0
- package/dist/project/plan.d.ts +5 -0
- package/dist/project/plan.js +96 -0
- package/dist/report/coverage.d.ts +22 -0
- package/dist/report/coverage.js +72 -0
- package/dist/report/coverage.test.d.ts +1 -0
- package/dist/report/coverage.test.js +93 -0
- package/dist/surfaces/agent-plugins.d.ts +10 -0
- package/dist/surfaces/agent-plugins.js +36 -0
- package/dist/surfaces/agents.d.ts +35 -0
- package/dist/surfaces/agents.js +609 -0
- package/dist/surfaces/agents.template.d.ts +8 -0
- package/dist/surfaces/agents.template.js +21 -0
- package/dist/surfaces/agents.test.d.ts +1 -0
- package/dist/surfaces/agents.test.js +181 -0
- package/dist/surfaces/browser-extension.d.ts +101 -0
- package/dist/surfaces/browser-extension.js +1034 -0
- package/dist/surfaces/browser-extension.test.d.ts +1 -0
- package/dist/surfaces/browser-extension.test.js +145 -0
- package/dist/surfaces/claude.d.ts +2 -0
- package/dist/surfaces/claude.js +81 -0
- package/dist/surfaces/codex.d.ts +13 -0
- package/dist/surfaces/codex.js +101 -0
- package/dist/surfaces/codex.test.d.ts +1 -0
- package/dist/surfaces/codex.test.js +67 -0
- package/dist/surfaces/cursor.d.ts +2 -0
- package/dist/surfaces/cursor.js +50 -0
- package/dist/surfaces/distribution.test.d.ts +1 -0
- package/dist/surfaces/distribution.test.js +146 -0
- package/dist/surfaces/dsh.d.ts +25 -0
- package/dist/surfaces/dsh.js +147 -0
- package/dist/surfaces/dsh.test.d.ts +1 -0
- package/dist/surfaces/dsh.test.js +100 -0
- package/dist/surfaces/external.d.ts +9 -0
- package/dist/surfaces/external.js +4 -0
- package/dist/surfaces/gemini.d.ts +29 -0
- package/dist/surfaces/gemini.js +68 -0
- package/dist/surfaces/gemini.test.d.ts +1 -0
- package/dist/surfaces/gemini.test.js +74 -0
- package/dist/surfaces/hermes-native.d.ts +53 -0
- package/dist/surfaces/hermes-native.js +507 -0
- package/dist/surfaces/hermes-native.test.d.ts +1 -0
- package/dist/surfaces/hermes-native.test.js +156 -0
- package/dist/surfaces/kernel.d.ts +5 -0
- package/dist/surfaces/kernel.js +63 -0
- package/dist/surfaces/mcp-registry.d.ts +19 -0
- package/dist/surfaces/mcp-registry.js +160 -0
- package/dist/surfaces/mcp-registry.test.d.ts +1 -0
- package/dist/surfaces/mcp-registry.test.js +79 -0
- package/dist/surfaces/mcpb.d.ts +11 -0
- package/dist/surfaces/mcpb.js +118 -0
- package/dist/surfaces/mcpb.test.d.ts +1 -0
- package/dist/surfaces/mcpb.test.js +104 -0
- package/dist/surfaces/npm.d.ts +6 -0
- package/dist/surfaces/npm.js +72 -0
- package/dist/surfaces/openclaw-native.d.ts +93 -0
- package/dist/surfaces/openclaw-native.js +956 -0
- package/dist/surfaces/openclaw-native.test.d.ts +1 -0
- package/dist/surfaces/openclaw-native.test.js +246 -0
- package/dist/surfaces/pypi.d.ts +5 -0
- package/dist/surfaces/pypi.js +82 -0
- package/dist/surfaces/readme.d.ts +7 -0
- package/dist/surfaces/readme.js +181 -0
- package/dist/surfaces/registry.d.ts +6 -0
- package/dist/surfaces/registry.js +73 -0
- package/dist/surfaces/shared.d.ts +49 -0
- package/dist/surfaces/shared.js +112 -0
- package/dist/surfaces/skill.d.ts +7 -0
- package/dist/surfaces/skill.js +118 -0
- package/dist/surfaces/skill.test.d.ts +1 -0
- package/dist/surfaces/skill.test.js +87 -0
- package/dist/surfaces/web.d.ts +97 -0
- package/dist/surfaces/web.js +1285 -0
- package/dist/surfaces/web.test.d.ts +1 -0
- package/dist/surfaces/web.test.js +255 -0
- package/dist/surfaces/workflows.d.ts +2 -0
- package/dist/surfaces/workflows.js +809 -0
- package/dist/surfaces/workflows.test.d.ts +1 -0
- package/dist/surfaces/workflows.test.js +563 -0
- package/dist/toolfactory/cli.d.ts +2 -0
- package/dist/toolfactory/cli.js +65 -0
- package/dist/toolfactory/config.d.ts +3 -0
- package/dist/toolfactory/config.js +27 -0
- package/dist/toolfactory/mcp.d.ts +37 -0
- package/dist/toolfactory/mcp.js +269 -0
- package/dist/toolfactory/types.d.ts +31 -0
- package/dist/toolfactory/types.js +10 -0
- package/dist/toolfactory/web.d.ts +4 -0
- package/dist/toolfactory/web.js +40 -0
- package/dist/validate/agent-plugins.d.ts +5 -0
- package/dist/validate/agent-plugins.js +46 -0
- package/package.json +68 -0
- package/schemas/agent-plugins/mcp.schema.json +120 -0
- package/schemas/agent-plugins/plugin.schema.json +65 -0
- package/src/bindings/index.ts +47 -0
- package/src/bindings/python.test.ts +374 -0
- package/src/bindings/python.ts +862 -0
- package/src/bindings/typescript.test.ts +329 -0
- package/src/bindings/typescript.ts +818 -0
- package/src/commands.test.ts +253 -0
- package/src/commands.ts +886 -0
- package/src/e2e.test.ts +169 -0
- package/src/hosts/browser.ts +106 -0
- package/src/hosts/github.ts +321 -0
- package/src/hosts/openclaw.ts +110 -0
- package/src/hosts/template.ts +149 -0
- package/src/hosts/web.ts +111 -0
- package/src/identity/name.test.ts +43 -0
- package/src/identity/name.ts +39 -0
- package/src/identity/read.test.ts +92 -0
- package/src/identity/read.ts +119 -0
- package/src/index.ts +11 -0
- package/src/introspect/index.ts +72 -0
- package/src/model.ts +386 -0
- package/src/ops.ts +333 -0
- package/src/project/apply.test.ts +348 -0
- package/src/project/apply.ts +417 -0
- package/src/project/gate.ts +717 -0
- package/src/project/history.ts +46 -0
- package/src/project/load.ts +104 -0
- package/src/project/lock.ts +54 -0
- package/src/project/plan.ts +101 -0
- package/src/report/coverage.test.ts +108 -0
- package/src/report/coverage.ts +113 -0
- package/src/surfaces/agent-plugins.ts +47 -0
- package/src/surfaces/agents.template.ts +32 -0
- package/src/surfaces/agents.test.ts +206 -0
- package/src/surfaces/agents.ts +729 -0
- package/src/surfaces/browser-extension.test.ts +175 -0
- package/src/surfaces/browser-extension.ts +1095 -0
- package/src/surfaces/claude.ts +99 -0
- package/src/surfaces/codex.test.ts +71 -0
- package/src/surfaces/codex.ts +105 -0
- package/src/surfaces/cursor.ts +62 -0
- package/src/surfaces/distribution.test.ts +166 -0
- package/src/surfaces/dsh.test.ts +110 -0
- package/src/surfaces/dsh.ts +159 -0
- package/src/surfaces/external.ts +13 -0
- package/src/surfaces/gemini.test.ts +80 -0
- package/src/surfaces/gemini.ts +84 -0
- package/src/surfaces/hermes-native.test.ts +179 -0
- package/src/surfaces/hermes-native.ts +584 -0
- package/src/surfaces/kernel.ts +68 -0
- package/src/surfaces/mcp-registry.test.ts +99 -0
- package/src/surfaces/mcp-registry.ts +190 -0
- package/src/surfaces/mcpb.test.ts +112 -0
- package/src/surfaces/mcpb.ts +130 -0
- package/src/surfaces/npm.ts +78 -0
- package/src/surfaces/openclaw-native.test.ts +283 -0
- package/src/surfaces/openclaw-native.ts +1070 -0
- package/src/surfaces/pypi.ts +84 -0
- package/src/surfaces/readme.ts +218 -0
- package/src/surfaces/registry.ts +79 -0
- package/src/surfaces/shared.ts +140 -0
- package/src/surfaces/skill.test.ts +103 -0
- package/src/surfaces/skill.ts +136 -0
- package/src/surfaces/web.test.ts +303 -0
- package/src/surfaces/web.ts +1310 -0
- package/src/surfaces/workflows.test.ts +677 -0
- package/src/surfaces/workflows.ts +936 -0
- package/src/toolfactory/cli.ts +74 -0
- package/src/toolfactory/config.ts +33 -0
- package/src/toolfactory/mcp.ts +311 -0
- package/src/toolfactory/types.ts +52 -0
- package/src/toolfactory/web.ts +45 -0
- package/src/validate/agent-plugins.ts +53 -0
- package/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/web/dist/assets/index-CCXhQKK3.css +2 -0
- package/web/dist/assets/index-D1PwZ3qS.js +10 -0
- package/web/dist/index.html +14 -0
|
@@ -0,0 +1,677 @@
|
|
|
1
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
2
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { describe, expect, it } from "vitest";
|
|
6
|
+
import { parse as yamlParse } from "yaml";
|
|
7
|
+
import { bootstrapRepo } from "../hosts/github.js";
|
|
8
|
+
import type { Operation, Project, SurfaceId } from "../model.js";
|
|
9
|
+
import { gateSteps, packageSteps, registries } from "../project/gate.js";
|
|
10
|
+
import { sourcesZipName, zipName } from "./browser-extension.js";
|
|
11
|
+
import { surface } from "./workflows.js";
|
|
12
|
+
|
|
13
|
+
const echo: Operation = { name: "echo", inputSchema: { type: "object" }, requires: [] };
|
|
14
|
+
const shoot: Operation = { name: "shoot", inputSchema: { type: "object" }, requires: ["browser"] };
|
|
15
|
+
|
|
16
|
+
function project(surfaces: SurfaceId[], overrides: Partial<Project> = {}): Project {
|
|
17
|
+
return {
|
|
18
|
+
root: "/repo",
|
|
19
|
+
tool: {
|
|
20
|
+
schemaVersion: 1,
|
|
21
|
+
identity: "package.json",
|
|
22
|
+
binding: "typescript",
|
|
23
|
+
surfaces,
|
|
24
|
+
bundle: { runtime: "package" },
|
|
25
|
+
tests: { examples: {} },
|
|
26
|
+
config: {
|
|
27
|
+
properties: {
|
|
28
|
+
api_key: {
|
|
29
|
+
type: "string",
|
|
30
|
+
description: "Provider API key",
|
|
31
|
+
"x-toolfactory": { sensitive: true, tier: 4, url: "https://example.com/keys" },
|
|
32
|
+
},
|
|
33
|
+
region: { type: "string", description: "Deployment region" },
|
|
34
|
+
},
|
|
35
|
+
required: ["api_key"],
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
identity: { name: "hello", version: "0.1.0", description: "Say hello" },
|
|
39
|
+
identityExtra: {},
|
|
40
|
+
operations: [echo],
|
|
41
|
+
toolfactoryVersion: "0.1.0",
|
|
42
|
+
...overrides,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function emitted(target: Project): Record<string, string> {
|
|
47
|
+
return Object.fromEntries(
|
|
48
|
+
surface
|
|
49
|
+
.plan(target)
|
|
50
|
+
.map((file) => [
|
|
51
|
+
file.path,
|
|
52
|
+
file.kind === "file"
|
|
53
|
+
? file.content
|
|
54
|
+
: file.kind === "merge"
|
|
55
|
+
? JSON.stringify(file.patch)
|
|
56
|
+
: "",
|
|
57
|
+
]),
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Every generated file is a parseable document in its format — the one invariant that must never break. */
|
|
62
|
+
function expectAllYamlAndJsonParse(files: Record<string, string>): void {
|
|
63
|
+
for (const [path, content] of Object.entries(files)) {
|
|
64
|
+
if (path.endsWith(".yml") || path.endsWith(".yaml")) {
|
|
65
|
+
expect(() => yamlParse(content), path).not.toThrow();
|
|
66
|
+
} else if (path.endsWith(".json")) {
|
|
67
|
+
expect(() => JSON.parse(content), path).not.toThrow();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
describe("workflows", () => {
|
|
73
|
+
it("sets up Node without a package-manager cache for an instruction-only plugin", () => {
|
|
74
|
+
const target = project(["skill", "codex"]);
|
|
75
|
+
target.tool.runtime = "none";
|
|
76
|
+
const ci = yamlParse(emitted(target)[".github/workflows/ci.yml"] ?? "") as {
|
|
77
|
+
jobs: { test: { steps: Array<{ uses?: string; with?: Record<string, string> }> } };
|
|
78
|
+
};
|
|
79
|
+
const setupNode = ci.jobs.test.steps.find((step) => step.uses === "actions/setup-node@v7");
|
|
80
|
+
expect(setupNode?.with).toEqual({ "node-version": "${{ matrix.node-version }}" });
|
|
81
|
+
expect(ci.jobs.test.steps.some((step) => step.uses === "pnpm/action-setup@v4")).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("always emits ci.yml, release.yml, .env.example and renovate.json, never compose without a trigger", () => {
|
|
85
|
+
const files = emitted(project(["cli", "mcp"]));
|
|
86
|
+
expect(Object.keys(files).sort()).toEqual([
|
|
87
|
+
".env.example",
|
|
88
|
+
".github/workflows/ci.yml",
|
|
89
|
+
".github/workflows/release.yml",
|
|
90
|
+
"renovate.json",
|
|
91
|
+
]);
|
|
92
|
+
// No registry surface: the tag still gets its gate, its assets and its Release, no legs.
|
|
93
|
+
const bare = yamlParse(files[".github/workflows/release.yml"]);
|
|
94
|
+
expect(Object.keys(bare.jobs)).toEqual(["gate", "package", "release"]);
|
|
95
|
+
expect(bare.jobs.gate.outputs).toEqual({ sha: "${{ steps.tag.outputs.sha }}" });
|
|
96
|
+
expectAllYamlAndJsonParse(files);
|
|
97
|
+
|
|
98
|
+
const ci = yamlParse(files[".github/workflows/ci.yml"]);
|
|
99
|
+
expect(ci.jobs.test.strategy.matrix["node-version"]).toEqual(["22", "24"]);
|
|
100
|
+
// No skill/claude/openclaw-native selected: no extra validator installs, no matrix guard.
|
|
101
|
+
const steps = ci.jobs.test.steps as { run?: string; if?: string }[];
|
|
102
|
+
expect(steps.some((s) => s.run?.includes("agentskills"))).toBe(false);
|
|
103
|
+
expect(
|
|
104
|
+
steps.filter((s) => (s as { uses?: string }).uses).map((s) => (s as { uses?: string }).uses),
|
|
105
|
+
).toEqual(["actions/checkout@v7", "actions/setup-node@v7"]);
|
|
106
|
+
expect(steps.some((s) => s.run?.includes("claude-code"))).toBe(false);
|
|
107
|
+
expect(steps.find((s) => s.run === "npx toolfactory validate")?.if).toBeUndefined();
|
|
108
|
+
|
|
109
|
+
const renovate = JSON.parse(files["renovate.json"]);
|
|
110
|
+
expect(renovate.extends).toEqual(["config:recommended", "group:all"]);
|
|
111
|
+
expect(renovate.minimumReleaseAge).toBe("7 days");
|
|
112
|
+
// The generated workflows are SHA-locked projections: a Renovate PR editing one would make
|
|
113
|
+
// `toolfactory check` fail and the next `toolfactory build` revert it.
|
|
114
|
+
expect(renovate.ignorePaths).toContain(".github/workflows/**");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("ci.yml installs each selected surface's validator and guards the openclaw leg on the matrix", () => {
|
|
118
|
+
const files = emitted(project(["skill", "claude", "openclaw-native", "mcp"]));
|
|
119
|
+
const ci = yamlParse(files[".github/workflows/ci.yml"]);
|
|
120
|
+
const steps = ci.jobs.test.steps as { uses?: string; run?: string; if?: string }[];
|
|
121
|
+
expect(steps.some((s) => s.uses === "astral-sh/setup-uv@v6")).toBe(true);
|
|
122
|
+
expect(steps.some((s) => s.run?.includes("npm i -g @anthropic-ai/claude-code"))).toBe(true);
|
|
123
|
+
// openclaw-native needs Node >=24; skip that one step on the 22 leg rather than fail it.
|
|
124
|
+
expect(steps.find((s) => s.run === "npx toolfactory validate")?.if).toBe(
|
|
125
|
+
"matrix.node-version == '24'",
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("python binding uses a single uv + Python 3.12 toolchain, no node matrix", () => {
|
|
130
|
+
const python = project(["cli", "mcp"], {
|
|
131
|
+
tool: { ...project(["cli"]).tool, binding: "python" },
|
|
132
|
+
});
|
|
133
|
+
const ci = yamlParse(emitted(python)[".github/workflows/ci.yml"]);
|
|
134
|
+
expect(ci.jobs.test.strategy).toBeUndefined();
|
|
135
|
+
const steps = ci.jobs.test.steps as {
|
|
136
|
+
uses?: string;
|
|
137
|
+
with?: Record<string, string>;
|
|
138
|
+
run?: string;
|
|
139
|
+
}[];
|
|
140
|
+
expect(steps.find((s) => s.uses === "astral-sh/setup-uv@v6")?.with).toEqual({
|
|
141
|
+
"python-version": "3.12",
|
|
142
|
+
});
|
|
143
|
+
expect(steps.some((s) => s.run === "uv run --with pytest pytest -q")).toBe(true);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("release.yml runs the legs in the forced order npm -> oci -> mcp-registry -> clawhub, each gated on what the gate decided", () => {
|
|
147
|
+
const files = emitted(
|
|
148
|
+
project(
|
|
149
|
+
["npm", "mcp-registry", "clawhub", "openclaw-native", "skill", "claude", "web", "mcp"],
|
|
150
|
+
{
|
|
151
|
+
identity: {
|
|
152
|
+
name: "hello",
|
|
153
|
+
version: "0.1.0",
|
|
154
|
+
repository: "https://github.com/acme/hello.git",
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
),
|
|
158
|
+
);
|
|
159
|
+
expectAllYamlAndJsonParse(files);
|
|
160
|
+
const release = yamlParse(files[".github/workflows/release.yml"]);
|
|
161
|
+
expect(release.on.push).toEqual({ tags: ["v*"] });
|
|
162
|
+
// The re-run after adding a secret: a fresh run reads current secrets, `gh run rerun` does not.
|
|
163
|
+
expect(release.on.workflow_dispatch.inputs.tag).toMatchObject({
|
|
164
|
+
required: true,
|
|
165
|
+
type: "string",
|
|
166
|
+
});
|
|
167
|
+
// Presence is decided once, in the gate, and every leg obeys it at job level.
|
|
168
|
+
expect(Object.keys(release.jobs.gate.outputs)).toEqual([
|
|
169
|
+
"sha",
|
|
170
|
+
"npm",
|
|
171
|
+
"oci",
|
|
172
|
+
"mcp_registry",
|
|
173
|
+
"clawhub_package",
|
|
174
|
+
"clawhub_skill",
|
|
175
|
+
"pages",
|
|
176
|
+
]);
|
|
177
|
+
expect(release.jobs["publish-npm"].if).toBe("needs.gate.outputs.npm == 'true'");
|
|
178
|
+
expect(release.jobs["publish-mcp-registry"].if).toBe(
|
|
179
|
+
"needs.gate.outputs.mcp_registry == 'true'",
|
|
180
|
+
);
|
|
181
|
+
expect(release.jobs["publish-clawhub"].if).toBe(
|
|
182
|
+
"${{ !cancelled() && needs.package.result == 'success' && !contains(needs.*.result, 'failure') && needs.gate.outputs.clawhub_package == 'true' }}",
|
|
183
|
+
);
|
|
184
|
+
expect(release.jobs["pages-build"].if).toBe("needs.gate.outputs.pages == 'true'");
|
|
185
|
+
const presence = (
|
|
186
|
+
release.jobs.gate.steps as { id?: string; run?: string; env?: Record<string, string> }[]
|
|
187
|
+
).find((s) => s.id === "presence");
|
|
188
|
+
expect(presence?.env?.NPM_TOKEN).toBe("${{ secrets.NPM_TOKEN }}");
|
|
189
|
+
expect(presence?.env?.NPM_TRUSTED_PUBLISHER).toBe("${{ vars.NPM_TRUSTED_PUBLISHER }}");
|
|
190
|
+
expect(presence?.run).toContain('echo "npm=$npm" >> "$GITHUB_OUTPUT"');
|
|
191
|
+
expect(presence?.run).toContain('[ "$npm" = true ] && [ "$oci" = true ]');
|
|
192
|
+
// Skipped legs never skip the Release; failed ones do. It retracts dropped registries first.
|
|
193
|
+
expect(release.jobs.release.if).toBe(
|
|
194
|
+
"${{ !cancelled() && needs.package.result == 'success' && !contains(needs.*.result, 'failure') }}",
|
|
195
|
+
);
|
|
196
|
+
expect(release.jobs.release.needs).toContain("gate");
|
|
197
|
+
expect(release.jobs.release.env).not.toHaveProperty("NPM_TOKEN");
|
|
198
|
+
const releaseSteps = release.jobs.release.steps as {
|
|
199
|
+
run?: string;
|
|
200
|
+
with?: Record<string, unknown>;
|
|
201
|
+
}[];
|
|
202
|
+
expect(releaseSteps[0].with).toEqual({
|
|
203
|
+
ref: "${{ needs.gate.outputs.sha }}",
|
|
204
|
+
"fetch-depth": 0,
|
|
205
|
+
});
|
|
206
|
+
expect(releaseSteps.some((s) => s.run === "npx toolfactory unpublish")).toBe(true);
|
|
207
|
+
expect(releaseSteps.at(-1)?.with).toMatchObject({
|
|
208
|
+
tag_name: "${{ inputs.tag || github.ref_name }}",
|
|
209
|
+
target_commitish: "${{ needs.gate.outputs.sha }}",
|
|
210
|
+
});
|
|
211
|
+
// Cutting a tag from the Actions UI: gate resolves the commit, the Release creates the tag.
|
|
212
|
+
const gateSteps_ = release.jobs.gate.steps as {
|
|
213
|
+
id?: string;
|
|
214
|
+
run?: string;
|
|
215
|
+
with?: Record<string, unknown>;
|
|
216
|
+
}[];
|
|
217
|
+
expect(gateSteps_[0].with).toEqual({ "fetch-depth": 0 });
|
|
218
|
+
expect(gateSteps_.find((s) => s.id === "tag")?.run).toContain(
|
|
219
|
+
'git rev-parse -q --verify "refs/tags/$RELEASE_TAG^{commit}"',
|
|
220
|
+
);
|
|
221
|
+
expect(Object.keys(release.jobs)).toEqual([
|
|
222
|
+
"gate",
|
|
223
|
+
"package",
|
|
224
|
+
"publish-npm",
|
|
225
|
+
"publish-oci",
|
|
226
|
+
"publish-mcp-registry",
|
|
227
|
+
"publish-clawhub",
|
|
228
|
+
"publish-clawhub-skill",
|
|
229
|
+
"release",
|
|
230
|
+
"pages-build",
|
|
231
|
+
"pages-deploy",
|
|
232
|
+
]);
|
|
233
|
+
expect(release.jobs["publish-npm"].needs).toEqual(["gate", "package"]);
|
|
234
|
+
expect(release.jobs["publish-npm"].steps).toContainEqual({
|
|
235
|
+
uses: "actions/download-artifact@v8",
|
|
236
|
+
with: { name: "release-assets", path: "release-assets" },
|
|
237
|
+
});
|
|
238
|
+
expect(release.jobs["publish-npm"].env).not.toHaveProperty("NPM_TOKEN");
|
|
239
|
+
for (const step of release.jobs["publish-npm"].steps) {
|
|
240
|
+
if (step.name === "npm publish") {
|
|
241
|
+
expect(step.env.NPM_TOKEN).toBe(
|
|
242
|
+
"${{ vars.NPM_TRUSTED_PUBLISHER != 'true' && secrets.NPM_TOKEN || '' }}",
|
|
243
|
+
);
|
|
244
|
+
} else {
|
|
245
|
+
expect(step.env ?? {}).not.toHaveProperty("NPM_TOKEN");
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
expect(release.jobs["publish-oci"].needs).toBe("gate");
|
|
249
|
+
expect(release.jobs["publish-mcp-registry"].needs).toEqual([
|
|
250
|
+
"gate",
|
|
251
|
+
"publish-npm",
|
|
252
|
+
"publish-oci",
|
|
253
|
+
]);
|
|
254
|
+
// The ClawHub leg publishes the tarball `package` built, not the repository subdirectory:
|
|
255
|
+
// the reusable workflow has no build step of its own.
|
|
256
|
+
expect(release.jobs["publish-clawhub"].needs).toEqual([
|
|
257
|
+
"gate",
|
|
258
|
+
"package",
|
|
259
|
+
"publish-npm",
|
|
260
|
+
"publish-oci",
|
|
261
|
+
"publish-mcp-registry",
|
|
262
|
+
]);
|
|
263
|
+
expect(release.jobs["publish-clawhub"].with).toMatchObject({
|
|
264
|
+
package_artifact_name: "release-assets",
|
|
265
|
+
package_artifact_path: "openclaw-plugin-hello-0.1.0.tgz",
|
|
266
|
+
});
|
|
267
|
+
expect(release.jobs["publish-clawhub"].secrets.clawhub_token).toContain("CLAWHUB_TOKEN");
|
|
268
|
+
// ClawHub's skill catalog is a separate track: it needs only `gate` (no built tarball), and
|
|
269
|
+
// reuses ClawHub's own skill-publish.yml, which derives slug/version from SKILL.md itself.
|
|
270
|
+
expect(release.jobs["publish-clawhub-skill"]).toMatchObject({
|
|
271
|
+
needs: "gate",
|
|
272
|
+
uses: "openclaw/clawhub/.github/workflows/skill-publish.yml@main",
|
|
273
|
+
with: { skill_path: "skills/hello", dry_run: false },
|
|
274
|
+
});
|
|
275
|
+
expect(release.jobs["publish-clawhub-skill"].secrets.clawhub_token).toContain("CLAWHUB_TOKEN");
|
|
276
|
+
expect(release.jobs.release.needs.at(-1)).toBe("publish-clawhub-skill");
|
|
277
|
+
|
|
278
|
+
// Every job that runs steps declares its own permissions; the workflow-call leg inherits the
|
|
279
|
+
// file's `contents: read`.
|
|
280
|
+
const withoutPermissions = Object.entries(
|
|
281
|
+
release.jobs as Record<string, { permissions?: unknown }>,
|
|
282
|
+
)
|
|
283
|
+
.filter(([, job]) => job.permissions === undefined)
|
|
284
|
+
.map(([name]) => name);
|
|
285
|
+
expect(withoutPermissions).toEqual(["publish-clawhub", "publish-clawhub-skill"]);
|
|
286
|
+
expect(release.permissions).toEqual({ contents: "read" });
|
|
287
|
+
expect(release.jobs["publish-oci"].permissions).toEqual({
|
|
288
|
+
contents: "read",
|
|
289
|
+
packages: "write",
|
|
290
|
+
attestations: "write",
|
|
291
|
+
"id-token": "write",
|
|
292
|
+
});
|
|
293
|
+
expect(release.jobs.release.permissions).toEqual({
|
|
294
|
+
contents: "write",
|
|
295
|
+
packages: "write",
|
|
296
|
+
pages: "write",
|
|
297
|
+
"id-token": "write",
|
|
298
|
+
});
|
|
299
|
+
expect(release.jobs["pages-deploy"].permissions).toEqual({
|
|
300
|
+
pages: "write",
|
|
301
|
+
"id-token": "write",
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// The gate asserts the tag against the identity file's version and never writes it (§2.2).
|
|
305
|
+
const gateRuns = (release.jobs.gate.steps as { run?: string }[])
|
|
306
|
+
.map((s) => s.run)
|
|
307
|
+
.filter(Boolean);
|
|
308
|
+
// After the commit is resolved (a dispatched tag may not exist yet), before anything else.
|
|
309
|
+
expect(gateRuns[1]).toBe(
|
|
310
|
+
'test "v$(node -p "require(\'./package.json\').version")" = "$RELEASE_TAG"',
|
|
311
|
+
);
|
|
312
|
+
// The gate is the same check sequence as ci.yml: build precedes `toolfactory validate`,
|
|
313
|
+
// which runs every selected surface's own validator (no transcribed commands to drift).
|
|
314
|
+
expect(gateRuns.indexOf("npm run --if-present build")).toBeLessThan(
|
|
315
|
+
gateRuns.indexOf("npx toolfactory validate"),
|
|
316
|
+
);
|
|
317
|
+
expect(gateRuns.some((r) => r?.includes("openclaw plugins build"))).toBe(false);
|
|
318
|
+
// npm generates provenance itself under trusted publishing; --provenance is redundant. A
|
|
319
|
+
// configured publisher selects OIDC; otherwise the stored token is a fallback. A re-dispatch
|
|
320
|
+
// after adding either credential must not attempt to republish an immutable version.
|
|
321
|
+
expect(
|
|
322
|
+
(release.jobs["publish-npm"].steps as { run?: string }[]).some(
|
|
323
|
+
(s) =>
|
|
324
|
+
s.run?.includes("npm view hello@0.1.0 version") &&
|
|
325
|
+
s.run?.includes(
|
|
326
|
+
'elif [ "$NPM_TRUSTED_PUBLISHER" = true ]; then npm publish release-assets/hello-0.1.0.tgz --access public',
|
|
327
|
+
) &&
|
|
328
|
+
s.run?.includes(
|
|
329
|
+
'NODE_AUTH_TOKEN="$NPM_TOKEN" npm publish release-assets/hello-0.1.0.tgz --access public',
|
|
330
|
+
),
|
|
331
|
+
),
|
|
332
|
+
).toBe(true);
|
|
333
|
+
// The oci leg pushes the very image server.json's oci entry names.
|
|
334
|
+
const meta = (
|
|
335
|
+
release.jobs["publish-oci"].steps as { id?: string; with?: Record<string, string> }[]
|
|
336
|
+
).find((s) => s.id === "meta");
|
|
337
|
+
expect(meta?.with?.images).toBe("ghcr.io/acme/hello");
|
|
338
|
+
expect(meta?.with?.labels).toBe("io.modelcontextprotocol.server.name=io.github.acme/hello");
|
|
339
|
+
expect(files[".github/workflows/release.yml"]).toContain("git is the ledger");
|
|
340
|
+
expect(files[".github/workflows/release.yml"]).toContain("CLAWHUB_TOKEN");
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("clawhub without openclaw-native publishes nothing (there is no host package to publish)", () => {
|
|
344
|
+
const release = emitted(project(["clawhub", "npm"]))[".github/workflows/release.yml"];
|
|
345
|
+
expect(yamlParse(release).jobs["publish-clawhub"]).toBeUndefined();
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("clawhub + skill without openclaw-native still publishes the skill catalog leg", () => {
|
|
349
|
+
const release = yamlParse(
|
|
350
|
+
emitted(project(["clawhub", "skill"]))[".github/workflows/release.yml"],
|
|
351
|
+
);
|
|
352
|
+
expect(release.jobs["publish-clawhub"]).toBeUndefined();
|
|
353
|
+
expect(release.jobs["publish-clawhub-skill"].needs).toBe("gate");
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("compose.toolfactory.yaml appears only for a host-native surface and picks the browser image variant", () => {
|
|
357
|
+
expect(emitted(project(["cli"]))["compose.toolfactory.yaml"]).toBeUndefined();
|
|
358
|
+
|
|
359
|
+
const withBrowser = project(["openclaw-native"], { operations: [echo, shoot] });
|
|
360
|
+
const compose = yamlParse(emitted(withBrowser)["compose.toolfactory.yaml"]);
|
|
361
|
+
expect(compose.services.openclaw.image).toBe("ghcr.io/openclaw/openclaw:latest-browser");
|
|
362
|
+
// `--link` (a copy trips the install-time symlink scan) and a non-interactive accept.
|
|
363
|
+
expect(compose.services.openclaw.command[2]).toContain(
|
|
364
|
+
"openclaw plugins install --link /work/hosts/openclaw --force --accept-capabilities",
|
|
365
|
+
);
|
|
366
|
+
expect(compose.services.openclaw.env_file).toEqual([".env"]);
|
|
367
|
+
expect(compose.services.hermes).toBeUndefined();
|
|
368
|
+
expect(compose.volumes.PLUGIN_DATA).toEqual({});
|
|
369
|
+
|
|
370
|
+
const noBrowser = project(["hermes-native"]);
|
|
371
|
+
const composeHermes = yamlParse(emitted(noBrowser)["compose.toolfactory.yaml"]);
|
|
372
|
+
expect(composeHermes.services.hermes.command[2]).toContain(
|
|
373
|
+
"hermes plugins doctor /work/hosts/hermes/hello_hermes --ci",
|
|
374
|
+
);
|
|
375
|
+
expect(composeHermes.services.openclaw).toBeUndefined();
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it("adds the T4 live job, and workflow_dispatch, iff a config key is required and sensitive", () => {
|
|
379
|
+
const ci = yamlParse(emitted(project(["cli"]))[".github/workflows/ci.yml"]);
|
|
380
|
+
expect(ci.on.workflow_dispatch).toEqual({});
|
|
381
|
+
const live = ci.jobs.live;
|
|
382
|
+
expect(live.needs).toBe("test");
|
|
383
|
+
expect(live.environment).toBe("live-tests");
|
|
384
|
+
// A fork's pull request must never see the live-tests secrets.
|
|
385
|
+
expect(live.if).toBe(
|
|
386
|
+
"github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository",
|
|
387
|
+
);
|
|
388
|
+
// Every sensitive key, not only the required ones: an optional credential is still a secret.
|
|
389
|
+
expect(live.env).toEqual({ API_KEY: "${{ secrets.API_KEY }}" });
|
|
390
|
+
expect((live.steps as { run?: string }[]).at(-1)?.run).toBe("npm run --if-present test:live");
|
|
391
|
+
|
|
392
|
+
const python = project(["cli"], { tool: { ...project(["cli"]).tool, binding: "python" } });
|
|
393
|
+
expect(
|
|
394
|
+
(
|
|
395
|
+
yamlParse(emitted(python)[".github/workflows/ci.yml"]).jobs.live.steps as { run?: string }[]
|
|
396
|
+
).at(-1)?.run,
|
|
397
|
+
).toBe("uv run --with pytest pytest -q tests/test_live.py");
|
|
398
|
+
|
|
399
|
+
// api_key optional: nothing gates a live run, so no live job and no workflow_dispatch.
|
|
400
|
+
const noCredential = project(["cli"]);
|
|
401
|
+
noCredential.tool.config = { ...noCredential.tool.config, required: [] };
|
|
402
|
+
const plain = yamlParse(emitted(noCredential)[".github/workflows/ci.yml"]);
|
|
403
|
+
expect(plain.jobs.live).toBeUndefined();
|
|
404
|
+
expect(plain.on.workflow_dispatch).toBeUndefined();
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it(".env.example lists every config key uppercased, secrets and required marked", () => {
|
|
408
|
+
const env = emitted(project(["cli"]))[".env.example"];
|
|
409
|
+
expect(env).toContain("API_KEY=");
|
|
410
|
+
expect(env).toContain("(required, secret) — get one: https://example.com/keys");
|
|
411
|
+
expect(env).toContain("REGION=");
|
|
412
|
+
expect(env).toContain("(optional)");
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
/** Real invocation: an offline GitHub Actions linter, when one happens to be on PATH. */
|
|
417
|
+
describe("npm release authentication", () => {
|
|
418
|
+
it("executes token, OIDC, disabled, and already-published branches without registry writes", () => {
|
|
419
|
+
const target = project(["npm"]);
|
|
420
|
+
const release = yamlParse(emitted(target)[".github/workflows/release.yml"]);
|
|
421
|
+
const publish = (release.jobs["publish-npm"].steps as { name?: string; run?: string }[]).find(
|
|
422
|
+
(step) => step.name === "npm publish",
|
|
423
|
+
)?.run;
|
|
424
|
+
const gate = registries(target).find((registry) => registry.id === "npm")?.gate;
|
|
425
|
+
expect(publish).toBeDefined();
|
|
426
|
+
expect(gate).toBeDefined();
|
|
427
|
+
for (const scenario of [
|
|
428
|
+
{ trust: "", token: "", exists: false, enabled: false, output: "" },
|
|
429
|
+
{ trust: "false", token: "", exists: false, enabled: false, output: "" },
|
|
430
|
+
{
|
|
431
|
+
trust: "false",
|
|
432
|
+
token: "test-token",
|
|
433
|
+
exists: false,
|
|
434
|
+
enabled: true,
|
|
435
|
+
output: "publish:test-token",
|
|
436
|
+
},
|
|
437
|
+
{ trust: "true", token: "test-token", exists: false, enabled: true, output: "publish:oidc" },
|
|
438
|
+
{ trust: "true", token: "", exists: true, enabled: true, output: "already published" },
|
|
439
|
+
]) {
|
|
440
|
+
const env = {
|
|
441
|
+
...process.env,
|
|
442
|
+
NPM_TRUSTED_PUBLISHER: scenario.trust,
|
|
443
|
+
NPM_TOKEN: scenario.token,
|
|
444
|
+
NODE_AUTH_TOKEN: "",
|
|
445
|
+
};
|
|
446
|
+
const gated = spawnSync("bash", ["-c", gate as string], { env });
|
|
447
|
+
expect(gated.status === 0).toBe(scenario.enabled);
|
|
448
|
+
if (!scenario.enabled) continue;
|
|
449
|
+
// Stub the upstream CLI at the shell boundary; never contact or write to npm.
|
|
450
|
+
const stub = `npm() { if [ "$1" = view ]; then return ${scenario.exists ? 0 : 1}; fi; printf 'publish:%s\\n' "\${NODE_AUTH_TOKEN:-oidc}"; };`;
|
|
451
|
+
const output = execFileSync("bash", ["-c", `${stub}\n${publish}`], {
|
|
452
|
+
env,
|
|
453
|
+
encoding: "utf8",
|
|
454
|
+
});
|
|
455
|
+
expect(output).toContain(scenario.output);
|
|
456
|
+
if (scenario.exists) expect(output).not.toContain("publish:");
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
function actionlintAvailable(): boolean {
|
|
462
|
+
try {
|
|
463
|
+
execFileSync("actionlint", ["-version"], { stdio: "ignore" });
|
|
464
|
+
return true;
|
|
465
|
+
} catch {
|
|
466
|
+
return false;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
describe.skipIf(!actionlintAvailable())("actionlint", () => {
|
|
471
|
+
it("accepts the generated ci.yml and release.yml", () => {
|
|
472
|
+
const dir = mkdtempSync(join(tmpdir(), "tf-workflows-"));
|
|
473
|
+
const files = emitted(
|
|
474
|
+
project(
|
|
475
|
+
[
|
|
476
|
+
"npm",
|
|
477
|
+
"mcp-registry",
|
|
478
|
+
"clawhub",
|
|
479
|
+
"openclaw-native",
|
|
480
|
+
"hermes-native",
|
|
481
|
+
"skill",
|
|
482
|
+
"claude",
|
|
483
|
+
"web",
|
|
484
|
+
"mcp",
|
|
485
|
+
],
|
|
486
|
+
{
|
|
487
|
+
identity: {
|
|
488
|
+
name: "hello",
|
|
489
|
+
version: "0.1.0",
|
|
490
|
+
repository: "https://github.com/acme/hello.git",
|
|
491
|
+
},
|
|
492
|
+
},
|
|
493
|
+
),
|
|
494
|
+
);
|
|
495
|
+
const workflowPaths: string[] = [];
|
|
496
|
+
for (const [path, content] of Object.entries(files)) {
|
|
497
|
+
if (!path.startsWith(".github/workflows/")) continue;
|
|
498
|
+
mkdirSync(dirname(join(dir, path)), { recursive: true });
|
|
499
|
+
writeFileSync(join(dir, path), content);
|
|
500
|
+
workflowPaths.push(path);
|
|
501
|
+
}
|
|
502
|
+
// actionlint otherwise walks up looking for a git repo to find `.github/workflows`;
|
|
503
|
+
// point it straight at the files instead.
|
|
504
|
+
execFileSync("actionlint", workflowPaths, { cwd: dir });
|
|
505
|
+
});
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* `bootstrap-repo`: the one-time I/O the live job depends on. It shells to the official `gh`
|
|
510
|
+
* CLI, so the unit under test is the invocation list — which `--dry-run` returns without
|
|
511
|
+
* touching the network — and the fact that no secret value appears in it.
|
|
512
|
+
*/
|
|
513
|
+
describe("bootstrap-repo", () => {
|
|
514
|
+
it("prepares the live-tests environment through gh, with values only ever on stdin", () => {
|
|
515
|
+
const target = project(["cli"], {
|
|
516
|
+
root: "/repo",
|
|
517
|
+
identity: { name: "hello", repository: "https://github.com/acme/hello.git" },
|
|
518
|
+
});
|
|
519
|
+
const result = bootstrapRepo(target, { reviewers: ["ada"], dryRun: true });
|
|
520
|
+
expect(result.repository).toBe("acme/hello");
|
|
521
|
+
expect(result.secrets).toEqual(["API_KEY"]);
|
|
522
|
+
expect(result.commands).toEqual([
|
|
523
|
+
"gh api users/ada --jq .id",
|
|
524
|
+
'gh api --method PUT repos/acme/hello/environments/live-tests --input - <<< \'{"reviewers":[{"type":"User","id":"<id of ada>"}]}\'',
|
|
525
|
+
"gh secret set API_KEY --env live-tests --repo acme/hello # value from /repo/.env, on stdin",
|
|
526
|
+
]);
|
|
527
|
+
|
|
528
|
+
const anonymous = project(["cli"], { identity: { name: "hello" } });
|
|
529
|
+
expect(() => bootstrapRepo(anonymous, { dryRun: true })).toThrow(/GitHub repository URL/);
|
|
530
|
+
// No live credential is not "nothing to do": the release tokens still land, at repository scope.
|
|
531
|
+
const noCredential = project(["cli"], { identity: target.identity });
|
|
532
|
+
noCredential.tool.config = { ...noCredential.tool.config, required: [] };
|
|
533
|
+
const release = bootstrapRepo(noCredential, {
|
|
534
|
+
dryRun: true,
|
|
535
|
+
releaseSecrets: ["NPM_TOKEN"],
|
|
536
|
+
manual: ["npm: generic instructions replaced by the bootstrap outcome"],
|
|
537
|
+
});
|
|
538
|
+
expect(release.secrets).toEqual(["NPM_TOKEN"]);
|
|
539
|
+
expect(release.commands).toContain(
|
|
540
|
+
"gh secret set NPM_TOKEN --repo acme/hello # value from /repo/.env, on stdin",
|
|
541
|
+
);
|
|
542
|
+
expect(release.manual.join(" ")).toContain(
|
|
543
|
+
"granular access tokens with bypass 2FA are unsupported",
|
|
544
|
+
);
|
|
545
|
+
expect(release.manual.join(" ")).toContain("NPM_TRUSTED_PUBLISHER");
|
|
546
|
+
expect(release.manual.filter((step) => step.startsWith("npm:"))).toHaveLength(1);
|
|
547
|
+
});
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* The gate is a command and the workflows are its projection: one list, two renderings. The
|
|
552
|
+
* local runner skips the runner-only provisioning, so what it skips and what it keeps is pinned.
|
|
553
|
+
*/
|
|
554
|
+
describe("gate", () => {
|
|
555
|
+
it("lists the same steps CI runs, marking the runner-only ones", () => {
|
|
556
|
+
const target = project(["npm", "claude", "openclaw-native", "mcp"]);
|
|
557
|
+
const steps = gateSteps(target);
|
|
558
|
+
expect(steps.map((step) => step.name)).toEqual([
|
|
559
|
+
"install",
|
|
560
|
+
"build",
|
|
561
|
+
"toolfactory check",
|
|
562
|
+
"toolfactory build (output files)",
|
|
563
|
+
"Install Claude Code CLI",
|
|
564
|
+
"toolfactory validate",
|
|
565
|
+
"author checks",
|
|
566
|
+
"author tests",
|
|
567
|
+
"openclaw end-to-end (scripted model, no LLM key)",
|
|
568
|
+
]);
|
|
569
|
+
|
|
570
|
+
// `npm ci` and a global CLI install are the runner's job, not a checkout's: `toolfactory gate`
|
|
571
|
+
// skips every `when: "ci"` step and runs the rest here, in order.
|
|
572
|
+
expect(steps.filter((step) => step.when === "ci").map((step) => step.name)).toEqual([
|
|
573
|
+
"install",
|
|
574
|
+
"Install Claude Code CLI",
|
|
575
|
+
]);
|
|
576
|
+
expect(steps.filter((step) => step.when === "node24").map((step) => step.name)).toEqual([
|
|
577
|
+
"toolfactory validate",
|
|
578
|
+
"openclaw end-to-end (scripted model, no LLM key)",
|
|
579
|
+
]);
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
it("packages one asset per selected distribution surface into dist/release", () => {
|
|
583
|
+
const target = project([
|
|
584
|
+
"npm",
|
|
585
|
+
"pypi",
|
|
586
|
+
"openclaw-native",
|
|
587
|
+
"skill",
|
|
588
|
+
"claude",
|
|
589
|
+
"web",
|
|
590
|
+
"browser-extension",
|
|
591
|
+
"mcp",
|
|
592
|
+
]);
|
|
593
|
+
const runs = packageSteps(target).map((step) => step.run);
|
|
594
|
+
expect(runs).toContain("npm pack --pack-destination dist/release");
|
|
595
|
+
expect(runs).toContain("uv build --out-dir dist/release");
|
|
596
|
+
expect(runs.some((run) => run.includes("npm pack ./hosts/openclaw"))).toBe(true);
|
|
597
|
+
expect(runs).toContain(
|
|
598
|
+
'set -- skills .claude-plugin; for path in LICENSE LICENSE.md LICENSE.txt NOTICE NOTICE.md NOTICE.txt; do if [ -f "$path" ]; then set -- "$@" "$path"; fi; done; zip -qr dist/release/hello-plugin.zip "$@"',
|
|
599
|
+
);
|
|
600
|
+
const webBuild = runs.findIndex((run) => run.includes("npm -C web run build"));
|
|
601
|
+
const npmPack = runs.indexOf("npm pack --pack-destination dist/release");
|
|
602
|
+
const webArchive = runs.findIndex((run) => run.includes("hello-web.tar.gz"));
|
|
603
|
+
expect(webBuild).toBeGreaterThan(-1);
|
|
604
|
+
expect(webBuild).toBeLessThan(npmPack);
|
|
605
|
+
// Keep the release archive out of the npm tarball's dist directory.
|
|
606
|
+
expect(webArchive).toBeGreaterThan(npmPack);
|
|
607
|
+
// wxt zip -b <browser> per store, copied into dist/release under zipName()'s own names, plus
|
|
608
|
+
// the Firefox sources zip `wxt zip -b firefox` writes alongside it.
|
|
609
|
+
const zipStep = runs.find((run) => run.includes("wxt zip hosts/browser -b chrome"));
|
|
610
|
+
expect(zipStep).toContain("wxt zip hosts/browser -b firefox");
|
|
611
|
+
expect(zipStep).toContain("wxt zip hosts/browser -b edge");
|
|
612
|
+
for (const asset of [
|
|
613
|
+
zipName(target, "chrome"),
|
|
614
|
+
zipName(target, "firefox"),
|
|
615
|
+
zipName(target, "edge"),
|
|
616
|
+
sourcesZipName(target),
|
|
617
|
+
]) {
|
|
618
|
+
expect(zipStep).toContain(asset);
|
|
619
|
+
}
|
|
620
|
+
// The signed Firefox xpi is opt-in on the JWT pair, never a hard failure when absent.
|
|
621
|
+
expect(
|
|
622
|
+
runs.some((run) => run.includes("web-ext sign") && run.includes("FIREFOX_JWT_ISSUER")),
|
|
623
|
+
).toBe(true);
|
|
624
|
+
expect(runs.at(-1)).toBe(
|
|
625
|
+
"cp COVERAGE.md dist/release/ && npx toolfactory coverage > dist/release/coverage.json",
|
|
626
|
+
);
|
|
627
|
+
});
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* The publish-browser-ext leg (store submission) and its opt-in Safari sibling: the leg appears
|
|
632
|
+
* only with the surface, Safari only with the surface *and* `tool.json` `browserExtension.safari`.
|
|
633
|
+
*/
|
|
634
|
+
describe("browser-extension release legs", () => {
|
|
635
|
+
it("release.yml carries publish-browser-ext only with the surface, Safari only with the flag", () => {
|
|
636
|
+
const withIdentity = {
|
|
637
|
+
identity: {
|
|
638
|
+
name: "hello",
|
|
639
|
+
repository: "https://github.com/acme/hello.git",
|
|
640
|
+
version: "0.1.0",
|
|
641
|
+
},
|
|
642
|
+
};
|
|
643
|
+
const noExtension = project(["npm"], withIdentity);
|
|
644
|
+
expect(
|
|
645
|
+
yamlParse(emitted(noExtension)[".github/workflows/release.yml"]).jobs["publish-browser-ext"],
|
|
646
|
+
).toBeUndefined();
|
|
647
|
+
|
|
648
|
+
const withExtension = project(["browser-extension", "mcp"], withIdentity);
|
|
649
|
+
const release = yamlParse(emitted(withExtension)[".github/workflows/release.yml"]);
|
|
650
|
+
// browser-extension alone (no npm/pypi/mcp-registry/clawhub) still gets a release.yml.
|
|
651
|
+
expect(release.jobs["publish-browser-ext"]).toBeDefined();
|
|
652
|
+
expect(release.jobs["publish-browser-ext"].needs).toEqual(["gate", "package"]);
|
|
653
|
+
expect(release.jobs["publish-browser-ext"].permissions).toEqual({ contents: "read" });
|
|
654
|
+
const steps = release.jobs["publish-browser-ext"].steps as { run?: string; if?: string }[];
|
|
655
|
+
expect(steps.some((s) => s.run?.includes("wxt submit --dry-run"))).toBe(true);
|
|
656
|
+
const realSubmit = steps.find(
|
|
657
|
+
(s) => s.run?.includes("wxt submit") && !s.run.includes("--dry-run"),
|
|
658
|
+
);
|
|
659
|
+
expect(realSubmit).toBeDefined();
|
|
660
|
+
// release.yml runs only on a tag or a dispatched tag, so the job-level store gate is the whole condition.
|
|
661
|
+
expect(release.jobs["publish-browser-ext"].if).toContain("needs.gate.outputs.chrome == 'true'");
|
|
662
|
+
expect(release.jobs.release.needs).toContain("publish-browser-ext");
|
|
663
|
+
expect(release.jobs["publish-browser-ext-safari"]).toBeUndefined();
|
|
664
|
+
|
|
665
|
+
const withSafari = project(["browser-extension", "mcp"], {
|
|
666
|
+
...withIdentity,
|
|
667
|
+
tool: { ...project(["browser-extension"]).tool, browserExtension: { safari: true } },
|
|
668
|
+
});
|
|
669
|
+
const safariRelease = yamlParse(emitted(withSafari)[".github/workflows/release.yml"]);
|
|
670
|
+
expect(safariRelease.jobs["publish-browser-ext-safari"]).toMatchObject({
|
|
671
|
+
needs: "gate",
|
|
672
|
+
"runs-on": "macos-latest",
|
|
673
|
+
});
|
|
674
|
+
// The Safari leg submits to App Store Connect, not dist/release/: it never gates the release.
|
|
675
|
+
expect(safariRelease.jobs.release.needs).not.toContain("publish-browser-ext-safari");
|
|
676
|
+
});
|
|
677
|
+
});
|