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.
Files changed (228) hide show
  1. package/README.md +166 -0
  2. package/dist/bindings/index.d.ts +23 -0
  3. package/dist/bindings/index.js +29 -0
  4. package/dist/bindings/python.d.ts +33 -0
  5. package/dist/bindings/python.js +805 -0
  6. package/dist/bindings/python.test.d.ts +1 -0
  7. package/dist/bindings/python.test.js +316 -0
  8. package/dist/bindings/typescript.d.ts +39 -0
  9. package/dist/bindings/typescript.js +758 -0
  10. package/dist/bindings/typescript.test.d.ts +1 -0
  11. package/dist/bindings/typescript.test.js +264 -0
  12. package/dist/commands.d.ts +188 -0
  13. package/dist/commands.js +662 -0
  14. package/dist/commands.test.d.ts +1 -0
  15. package/dist/commands.test.js +215 -0
  16. package/dist/e2e.test.d.ts +1 -0
  17. package/dist/e2e.test.js +124 -0
  18. package/dist/hosts/browser.d.ts +10 -0
  19. package/dist/hosts/browser.js +94 -0
  20. package/dist/hosts/github.d.ts +66 -0
  21. package/dist/hosts/github.js +247 -0
  22. package/dist/hosts/openclaw.d.ts +4 -0
  23. package/dist/hosts/openclaw.js +94 -0
  24. package/dist/hosts/template.d.ts +15 -0
  25. package/dist/hosts/template.js +142 -0
  26. package/dist/hosts/web.d.ts +4 -0
  27. package/dist/hosts/web.js +89 -0
  28. package/dist/identity/name.d.ts +20 -0
  29. package/dist/identity/name.js +33 -0
  30. package/dist/identity/name.test.d.ts +1 -0
  31. package/dist/identity/name.test.js +37 -0
  32. package/dist/identity/read.d.ts +9 -0
  33. package/dist/identity/read.js +106 -0
  34. package/dist/identity/read.test.d.ts +1 -0
  35. package/dist/identity/read.test.js +76 -0
  36. package/dist/index.d.ts +6 -0
  37. package/dist/index.js +6 -0
  38. package/dist/introspect/index.d.ts +15 -0
  39. package/dist/introspect/index.js +63 -0
  40. package/dist/model.d.ts +236 -0
  41. package/dist/model.js +214 -0
  42. package/dist/ops.d.ts +255 -0
  43. package/dist/ops.js +297 -0
  44. package/dist/project/apply.d.ts +35 -0
  45. package/dist/project/apply.js +360 -0
  46. package/dist/project/apply.test.d.ts +1 -0
  47. package/dist/project/apply.test.js +285 -0
  48. package/dist/project/gate.d.ts +129 -0
  49. package/dist/project/gate.js +597 -0
  50. package/dist/project/history.d.ts +62 -0
  51. package/dist/project/history.js +41 -0
  52. package/dist/project/load.d.ts +21 -0
  53. package/dist/project/load.js +82 -0
  54. package/dist/project/lock.d.ts +28 -0
  55. package/dist/project/lock.js +42 -0
  56. package/dist/project/plan.d.ts +5 -0
  57. package/dist/project/plan.js +96 -0
  58. package/dist/report/coverage.d.ts +22 -0
  59. package/dist/report/coverage.js +72 -0
  60. package/dist/report/coverage.test.d.ts +1 -0
  61. package/dist/report/coverage.test.js +93 -0
  62. package/dist/surfaces/agent-plugins.d.ts +10 -0
  63. package/dist/surfaces/agent-plugins.js +36 -0
  64. package/dist/surfaces/agents.d.ts +35 -0
  65. package/dist/surfaces/agents.js +609 -0
  66. package/dist/surfaces/agents.template.d.ts +8 -0
  67. package/dist/surfaces/agents.template.js +21 -0
  68. package/dist/surfaces/agents.test.d.ts +1 -0
  69. package/dist/surfaces/agents.test.js +181 -0
  70. package/dist/surfaces/browser-extension.d.ts +101 -0
  71. package/dist/surfaces/browser-extension.js +1034 -0
  72. package/dist/surfaces/browser-extension.test.d.ts +1 -0
  73. package/dist/surfaces/browser-extension.test.js +145 -0
  74. package/dist/surfaces/claude.d.ts +2 -0
  75. package/dist/surfaces/claude.js +81 -0
  76. package/dist/surfaces/codex.d.ts +13 -0
  77. package/dist/surfaces/codex.js +101 -0
  78. package/dist/surfaces/codex.test.d.ts +1 -0
  79. package/dist/surfaces/codex.test.js +67 -0
  80. package/dist/surfaces/cursor.d.ts +2 -0
  81. package/dist/surfaces/cursor.js +50 -0
  82. package/dist/surfaces/distribution.test.d.ts +1 -0
  83. package/dist/surfaces/distribution.test.js +146 -0
  84. package/dist/surfaces/dsh.d.ts +25 -0
  85. package/dist/surfaces/dsh.js +147 -0
  86. package/dist/surfaces/dsh.test.d.ts +1 -0
  87. package/dist/surfaces/dsh.test.js +100 -0
  88. package/dist/surfaces/external.d.ts +9 -0
  89. package/dist/surfaces/external.js +4 -0
  90. package/dist/surfaces/gemini.d.ts +29 -0
  91. package/dist/surfaces/gemini.js +68 -0
  92. package/dist/surfaces/gemini.test.d.ts +1 -0
  93. package/dist/surfaces/gemini.test.js +74 -0
  94. package/dist/surfaces/hermes-native.d.ts +53 -0
  95. package/dist/surfaces/hermes-native.js +507 -0
  96. package/dist/surfaces/hermes-native.test.d.ts +1 -0
  97. package/dist/surfaces/hermes-native.test.js +156 -0
  98. package/dist/surfaces/kernel.d.ts +5 -0
  99. package/dist/surfaces/kernel.js +63 -0
  100. package/dist/surfaces/mcp-registry.d.ts +19 -0
  101. package/dist/surfaces/mcp-registry.js +160 -0
  102. package/dist/surfaces/mcp-registry.test.d.ts +1 -0
  103. package/dist/surfaces/mcp-registry.test.js +79 -0
  104. package/dist/surfaces/mcpb.d.ts +11 -0
  105. package/dist/surfaces/mcpb.js +118 -0
  106. package/dist/surfaces/mcpb.test.d.ts +1 -0
  107. package/dist/surfaces/mcpb.test.js +104 -0
  108. package/dist/surfaces/npm.d.ts +6 -0
  109. package/dist/surfaces/npm.js +72 -0
  110. package/dist/surfaces/openclaw-native.d.ts +93 -0
  111. package/dist/surfaces/openclaw-native.js +956 -0
  112. package/dist/surfaces/openclaw-native.test.d.ts +1 -0
  113. package/dist/surfaces/openclaw-native.test.js +246 -0
  114. package/dist/surfaces/pypi.d.ts +5 -0
  115. package/dist/surfaces/pypi.js +82 -0
  116. package/dist/surfaces/readme.d.ts +7 -0
  117. package/dist/surfaces/readme.js +181 -0
  118. package/dist/surfaces/registry.d.ts +6 -0
  119. package/dist/surfaces/registry.js +73 -0
  120. package/dist/surfaces/shared.d.ts +49 -0
  121. package/dist/surfaces/shared.js +112 -0
  122. package/dist/surfaces/skill.d.ts +7 -0
  123. package/dist/surfaces/skill.js +118 -0
  124. package/dist/surfaces/skill.test.d.ts +1 -0
  125. package/dist/surfaces/skill.test.js +87 -0
  126. package/dist/surfaces/web.d.ts +97 -0
  127. package/dist/surfaces/web.js +1285 -0
  128. package/dist/surfaces/web.test.d.ts +1 -0
  129. package/dist/surfaces/web.test.js +255 -0
  130. package/dist/surfaces/workflows.d.ts +2 -0
  131. package/dist/surfaces/workflows.js +809 -0
  132. package/dist/surfaces/workflows.test.d.ts +1 -0
  133. package/dist/surfaces/workflows.test.js +563 -0
  134. package/dist/toolfactory/cli.d.ts +2 -0
  135. package/dist/toolfactory/cli.js +65 -0
  136. package/dist/toolfactory/config.d.ts +3 -0
  137. package/dist/toolfactory/config.js +27 -0
  138. package/dist/toolfactory/mcp.d.ts +37 -0
  139. package/dist/toolfactory/mcp.js +269 -0
  140. package/dist/toolfactory/types.d.ts +31 -0
  141. package/dist/toolfactory/types.js +10 -0
  142. package/dist/toolfactory/web.d.ts +4 -0
  143. package/dist/toolfactory/web.js +40 -0
  144. package/dist/validate/agent-plugins.d.ts +5 -0
  145. package/dist/validate/agent-plugins.js +46 -0
  146. package/package.json +68 -0
  147. package/schemas/agent-plugins/mcp.schema.json +120 -0
  148. package/schemas/agent-plugins/plugin.schema.json +65 -0
  149. package/src/bindings/index.ts +47 -0
  150. package/src/bindings/python.test.ts +374 -0
  151. package/src/bindings/python.ts +862 -0
  152. package/src/bindings/typescript.test.ts +329 -0
  153. package/src/bindings/typescript.ts +818 -0
  154. package/src/commands.test.ts +253 -0
  155. package/src/commands.ts +886 -0
  156. package/src/e2e.test.ts +169 -0
  157. package/src/hosts/browser.ts +106 -0
  158. package/src/hosts/github.ts +321 -0
  159. package/src/hosts/openclaw.ts +110 -0
  160. package/src/hosts/template.ts +149 -0
  161. package/src/hosts/web.ts +111 -0
  162. package/src/identity/name.test.ts +43 -0
  163. package/src/identity/name.ts +39 -0
  164. package/src/identity/read.test.ts +92 -0
  165. package/src/identity/read.ts +119 -0
  166. package/src/index.ts +11 -0
  167. package/src/introspect/index.ts +72 -0
  168. package/src/model.ts +386 -0
  169. package/src/ops.ts +333 -0
  170. package/src/project/apply.test.ts +348 -0
  171. package/src/project/apply.ts +417 -0
  172. package/src/project/gate.ts +717 -0
  173. package/src/project/history.ts +46 -0
  174. package/src/project/load.ts +104 -0
  175. package/src/project/lock.ts +54 -0
  176. package/src/project/plan.ts +101 -0
  177. package/src/report/coverage.test.ts +108 -0
  178. package/src/report/coverage.ts +113 -0
  179. package/src/surfaces/agent-plugins.ts +47 -0
  180. package/src/surfaces/agents.template.ts +32 -0
  181. package/src/surfaces/agents.test.ts +206 -0
  182. package/src/surfaces/agents.ts +729 -0
  183. package/src/surfaces/browser-extension.test.ts +175 -0
  184. package/src/surfaces/browser-extension.ts +1095 -0
  185. package/src/surfaces/claude.ts +99 -0
  186. package/src/surfaces/codex.test.ts +71 -0
  187. package/src/surfaces/codex.ts +105 -0
  188. package/src/surfaces/cursor.ts +62 -0
  189. package/src/surfaces/distribution.test.ts +166 -0
  190. package/src/surfaces/dsh.test.ts +110 -0
  191. package/src/surfaces/dsh.ts +159 -0
  192. package/src/surfaces/external.ts +13 -0
  193. package/src/surfaces/gemini.test.ts +80 -0
  194. package/src/surfaces/gemini.ts +84 -0
  195. package/src/surfaces/hermes-native.test.ts +179 -0
  196. package/src/surfaces/hermes-native.ts +584 -0
  197. package/src/surfaces/kernel.ts +68 -0
  198. package/src/surfaces/mcp-registry.test.ts +99 -0
  199. package/src/surfaces/mcp-registry.ts +190 -0
  200. package/src/surfaces/mcpb.test.ts +112 -0
  201. package/src/surfaces/mcpb.ts +130 -0
  202. package/src/surfaces/npm.ts +78 -0
  203. package/src/surfaces/openclaw-native.test.ts +283 -0
  204. package/src/surfaces/openclaw-native.ts +1070 -0
  205. package/src/surfaces/pypi.ts +84 -0
  206. package/src/surfaces/readme.ts +218 -0
  207. package/src/surfaces/registry.ts +79 -0
  208. package/src/surfaces/shared.ts +140 -0
  209. package/src/surfaces/skill.test.ts +103 -0
  210. package/src/surfaces/skill.ts +136 -0
  211. package/src/surfaces/web.test.ts +303 -0
  212. package/src/surfaces/web.ts +1310 -0
  213. package/src/surfaces/workflows.test.ts +677 -0
  214. package/src/surfaces/workflows.ts +936 -0
  215. package/src/toolfactory/cli.ts +74 -0
  216. package/src/toolfactory/config.ts +33 -0
  217. package/src/toolfactory/mcp.ts +311 -0
  218. package/src/toolfactory/types.ts +52 -0
  219. package/src/toolfactory/web.ts +45 -0
  220. package/src/validate/agent-plugins.ts +53 -0
  221. package/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  222. package/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  223. package/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  224. package/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  225. package/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  226. package/web/dist/assets/index-CCXhQKK3.css +2 -0
  227. package/web/dist/assets/index-D1PwZ3qS.js +10 -0
  228. package/web/dist/index.html +14 -0
@@ -0,0 +1,809 @@
1
+ /**
2
+ * Surface "workflows": the CI/release/test-harness/credential/dependency-update files, always
3
+ * generated (never in `tool.json.surfaces`; `project/plan.ts` adds this surface unconditionally).
4
+ *
5
+ * Every command these workflows run comes from `src/project/gate.ts` — the gate is a command and
6
+ * this file is its GitHub projection, so a project with no GitHub at all still has the same
7
+ * definition of green. Only the `uses:` steps (checkout, toolchains, caches, publishers) are this
8
+ * file's own.
9
+ *
10
+ * - `.github/workflows/ci.yml` — T0/T1 always: toolchain per binding, then `gateSteps`.
11
+ * - `.github/workflows/release.yml` — always: the gate (which also decides, once, which registries
12
+ * this tag can actually publish to), `packageSteps` uploaded as one artifact, one publish job
13
+ * per registry in the forced order §7 requires, the GitHub Release, and the Pages pair. A
14
+ * project with no registry surface still gets the tag's Release carrying those assets.
15
+ * - `compose.toolfactory.yaml` — only when a host-native surface (openclaw-native /
16
+ * hermes-native) is selected: the T3 container harness, one service per selected host.
17
+ * - `.env.example` — always: every `tool.json.config` property, uppercased, secrets marked.
18
+ * - `renovate.json` — always: `config:recommended`, minus the paths toolfactory itself owns.
19
+ */
20
+ import { stringify as yamlStringify } from "yaml";
21
+ import { LIVE_TEST_COMMAND } from "../bindings/python.js";
22
+ import { githubOwner } from "../identity/name.js";
23
+ import { isInstructionOnly, } from "../model.js";
24
+ import { bootstrapSteps, gateSteps, gateVariable, MCP_PUBLISHER_FETCH, npmTarball, openclawTarball, outputsStep, PACKAGE_MANAGER_COMMANDS, packageSteps, RELEASE_DIR, RELEASE_SECRET_NAMES, registries, tagVersionAssert, toolfactoryCli, unpublishStep, WEB_BUILD, } from "../project/gate.js";
25
+ import { HOST_DIR as BROWSER_HOST_DIR, sourcesZipName, zipName } from "./browser-extension.js";
26
+ import { HERMES_PIN, hermesInstall, pluginDir as hermesPluginDir } from "./hermes-native.js";
27
+ import { ociImage, registryName } from "./mcp-registry.js";
28
+ import { HOST_DIR as OPENCLAW_HOST_DIR } from "./openclaw-native.js";
29
+ import { compact, configProperties, envName, has, isSensitive, liveCredentials, npmName, requiredConfig, } from "./shared.js";
30
+ import { skillPath } from "./skill.js";
31
+ const YAML_HEADER = "# Generated by toolfactory. Do not edit; run `toolfactory build` to regenerate.\n\n";
32
+ /** The one artifact the `package` job uploads and every later job reads. */
33
+ const RELEASE_ARTIFACT = "release-assets";
34
+ function yml(document) {
35
+ // lineWidth: 0 disables folding — a long `run:` one-liner stays one line instead of being
36
+ // wrapped mid-command, which would still parse but reads (and diffs) badly.
37
+ // aliasDuplicateObjects: false — shared step objects must not become YAML anchors.
38
+ return yamlStringify(document, { lineWidth: 0, aliasDuplicateObjects: false });
39
+ }
40
+ /**
41
+ * The tag `release.yml` is releasing. On `push: tags` it is the pushed tag; on `workflow_dispatch`
42
+ * it is the `tag` input — either an existing tag (the operator's re-run after minting a missing
43
+ * secret, which `gh run rerun` cannot be because a re-run replays the original run's secret
44
+ * snapshot) or a new one, cut at the dispatched branch's head by the Release itself. `gate`
45
+ * resolves the commit once (`outputs.sha`); every other checkout, the assert, the image tag and
46
+ * the Release read it, so a dispatched run releases that commit and never the branch's later head.
47
+ */
48
+ const TAG_NAME = "${{ inputs.tag || github.ref_name }}";
49
+ const RELEASE_SHA = "${{ needs.gate.outputs.sha }}";
50
+ /** `actions/checkout`, pinned to the released tag inside `release.yml` and to the event elsewhere. */
51
+ function checkoutStep(ref, extra) {
52
+ const options = compact({ ref, ...extra });
53
+ return compact({
54
+ uses: "actions/checkout@v7",
55
+ with: Object.keys(options).length ? options : undefined,
56
+ });
57
+ }
58
+ /** A single job dependency as GitHub Actions spells it: a bare string for one, an array for many. */
59
+ function needsOf(jobs) {
60
+ if (jobs.length === 0)
61
+ return undefined;
62
+ // A copy: callers keep pushing onto the leg list after reading it.
63
+ return jobs.length === 1 ? jobs[0] : [...jobs];
64
+ }
65
+ // ---------------------------------------------------------------------------------------------
66
+ // Provisioning: the `uses:` half, which only CI has. Everything else is a gate.ts command.
67
+ // ---------------------------------------------------------------------------------------------
68
+ /** The package-manager action each row needs before `setup-node`'s cache can see it. */
69
+ const SETUP_ACTIONS = {
70
+ npm: [],
71
+ pnpm: [{ uses: "pnpm/action-setup@v4" }],
72
+ };
73
+ /** Checkout plus the language toolchain: everything a job needs before it can run a command. */
74
+ function toolchainSteps(project, node, ref) {
75
+ const pmName = project.packageManager ?? "npm";
76
+ const steps = [checkoutStep(ref)];
77
+ if (project.tool.binding === "typescript") {
78
+ // uv runs the agentskills validator; the python toolchain has it anyway.
79
+ if (!isInstructionOnly(project.tool))
80
+ steps.push(...SETUP_ACTIONS[pmName]);
81
+ steps.push({
82
+ uses: "actions/setup-node@v7",
83
+ with: {
84
+ "node-version": node,
85
+ ...(isInstructionOnly(project.tool) ? {} : { cache: pmName }),
86
+ },
87
+ });
88
+ if (has(project, "skill") || has(project, "hermes-native"))
89
+ steps.push({ uses: "astral-sh/setup-uv@v6" });
90
+ }
91
+ else {
92
+ // Node only runs `npx toolfactory`; 24 also covers the openclaw CLI when selected.
93
+ steps.push({ uses: "actions/setup-node@v7", with: { "node-version": node } }, { uses: "astral-sh/setup-uv@v6", with: { "python-version": "3.12" } });
94
+ }
95
+ return steps;
96
+ }
97
+ /**
98
+ * Hermes has no published wheel carrying `plugins doctor`, so `toolfactory validate` needs its
99
+ * installer. One install per pin: the checkout, its venv and the launcher come back from the
100
+ * cache, so the installer only runs when the pin moves. GitHub throttles anonymous clones from
101
+ * shared runner addresses (the failure mode the installer's own fallbacks exist for), so that one
102
+ * run clones with the job's token.
103
+ */
104
+ function hermesSteps(project) {
105
+ if (!has(project, "hermes-native"))
106
+ return [];
107
+ return [
108
+ {
109
+ name: "Restore Hermes",
110
+ id: "hermes",
111
+ uses: "actions/cache@v4",
112
+ with: {
113
+ path: ["~/.hermes", "~/.local/bin/hermes*", "~/.local/share/uv/python"].join("\n"),
114
+ key: `hermes-\${{ runner.os }}-${HERMES_PIN.commit}`,
115
+ },
116
+ },
117
+ {
118
+ name: "Install Hermes",
119
+ if: "steps.hermes.outputs.cache-hit != 'true'",
120
+ env: {
121
+ GIT_CONFIG_COUNT: "1",
122
+ GIT_CONFIG_KEY_0: "url.https://x-access-token:${{ github.token }}@github.com/.insteadOf",
123
+ GIT_CONFIG_VALUE_0: "https://github.com/",
124
+ },
125
+ run: hermesInstall(),
126
+ },
127
+ { name: "Hermes on PATH", run: 'echo "$HOME/.local/bin" >> "$GITHUB_PATH"' },
128
+ ];
129
+ }
130
+ /** One gate.ts step as a GitHub step; `node24` becomes the matrix guard where a matrix exists. */
131
+ function actionStep(step, matrix) {
132
+ return compact({
133
+ name: step.name,
134
+ run: step.run,
135
+ env: step.env,
136
+ if: matrix && step.when === "node24" ? "matrix.node-version == '24'" : undefined,
137
+ });
138
+ }
139
+ // ---------------------------------------------------------------------------------------------
140
+ // ci.yml — always.
141
+ // ---------------------------------------------------------------------------------------------
142
+ /**
143
+ * T4: the live tier runs the binding's live test with the real credentials, so it exists iff
144
+ * the generated live test does — when `config` declares a key that is both required and
145
+ * sensitive. Its secrets come from the `live-tests` environment, whose required reviewers
146
+ * `toolfactory bootstrap-repo` sets, and the `if` keeps a fork's pull request away from them
147
+ * (an environment gates deployments, not the checkout an untrusted PR ships).
148
+ */
149
+ function liveJob(project) {
150
+ if (liveCredentials(project).length === 0)
151
+ return undefined;
152
+ const properties = configProperties(project);
153
+ const secrets = Object.keys(properties).filter((key) => isSensitive(properties[key]));
154
+ const pmName = project.packageManager ?? "npm";
155
+ const pm = PACKAGE_MANAGER_COMMANDS[pmName];
156
+ const steps = project.tool.binding === "typescript"
157
+ ? [
158
+ { uses: "actions/checkout@v7" },
159
+ ...SETUP_ACTIONS[pmName],
160
+ { uses: "actions/setup-node@v7", with: { "node-version": "24", cache: pmName } },
161
+ { run: pm.install },
162
+ { run: pm.run("build") },
163
+ { name: "live tests", run: pm.run("test:live") },
164
+ ]
165
+ : [
166
+ { uses: "actions/checkout@v7" },
167
+ { uses: "astral-sh/setup-uv@v6", with: { "python-version": "3.12" } },
168
+ { run: "uv sync" },
169
+ { name: "live tests", run: LIVE_TEST_COMMAND },
170
+ ];
171
+ return {
172
+ needs: "test",
173
+ if: "github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository",
174
+ "runs-on": "ubuntu-latest",
175
+ environment: "live-tests",
176
+ env: Object.fromEntries(secrets.map((key) => [envName(key), `\${{ secrets.${envName(key)} }}`])),
177
+ steps,
178
+ };
179
+ }
180
+ function ciDocument(project) {
181
+ const typescript = project.tool.binding === "typescript";
182
+ const node = typescript ? "${{ matrix.node-version }}" : "24";
183
+ const job = compact({
184
+ "runs-on": "ubuntu-latest",
185
+ strategy: typescript ? { matrix: { "node-version": ["22", "24"] } } : undefined,
186
+ steps: [
187
+ ...toolchainSteps(project, node),
188
+ ...hermesSteps(project),
189
+ ...gateSteps(project).map((step) => actionStep(step, typescript)),
190
+ ],
191
+ });
192
+ const live = liveJob(project);
193
+ return {
194
+ name: "CI",
195
+ on: compact({
196
+ push: { branches: ["main"] },
197
+ pull_request: {},
198
+ // The live tier is also the one job a maintainer wants to fire by hand.
199
+ workflow_dispatch: live ? {} : undefined,
200
+ }),
201
+ permissions: { contents: "read" },
202
+ jobs: compact({ test: job, live }),
203
+ };
204
+ }
205
+ // ---------------------------------------------------------------------------------------------
206
+ // The browser extension's publish-* legs: store submission (stored secrets, no OIDC — none of
207
+ // the three stores support it) and the opt-in Safari host-native leg. Every store's secret names
208
+ // come from `registries(project)` (§1), the same table `secrets` and `unpublish` read. `wxt
209
+ // submit` reads each credential from the environment itself under the CLI's UPPER_SNAKE_CASE
210
+ // convention (verified live, `publish-browser-extension@6.1.1`'s resolveConfig): the shell only
211
+ // has to decide which store's `--<store>-zip` flag to pass, so an unconfigured store is a no-op,
212
+ // never a validation error.
213
+ // ---------------------------------------------------------------------------------------------
214
+ /** The three store rows, in submit order; absent from `rows` when the store is not applicable. */
215
+ const STORES = ["chrome", "firefox", "edge"];
216
+ function storeRows(rows) {
217
+ return STORES.map((id) => rows.find((row) => row.id === id)).filter((row) => row !== undefined);
218
+ }
219
+ /** `wxt submit --dry-run` (zip + payload check) or the real submission, gated per store. */
220
+ function browserExtSubmitScript(project, rows, dryRun) {
221
+ const zip = (browser) => `${RELEASE_ARTIFACT}/${zipName(project, browser)}`;
222
+ const present = (names) => names.map((name) => `[ -n "$${name}" ]`).join(" && ");
223
+ const flags = {
224
+ chrome: `--chrome-zip ${zip("chrome")}`,
225
+ firefox: `--firefox-zip ${zip("firefox")} --firefox-sources-zip ${RELEASE_ARTIFACT}/${sourcesZipName(project)}`,
226
+ edge: `--edge-zip ${zip("edge")}`,
227
+ };
228
+ return [
229
+ 'flags=""',
230
+ ...storeRows(rows).map((row) => `if ${present(row.secrets)}; then flags="$flags ${flags[row.id]}"; fi`),
231
+ 'if [ -z "$flags" ]; then echo "No browser store credentials configured; skipping wxt submit."; exit 0; fi',
232
+ `npm --prefix ${BROWSER_HOST_DIR} exec --no -- wxt submit ${dryRun ? "--dry-run " : ""}$flags`,
233
+ ].join("\n");
234
+ }
235
+ function browserExtPublishJob(project, rows) {
236
+ const stores = storeRows(rows);
237
+ return {
238
+ needs: needsOf(["gate", "package"]),
239
+ if: stores.map((row) => `needs.gate.outputs.${gateVariable(row)} == 'true'`).join(" || "),
240
+ "runs-on": "ubuntu-latest",
241
+ permissions: { contents: "read" },
242
+ env: {
243
+ // Constant, not a secret: Chrome Web Store API v2 (mandatory — v1.1 shuts off 15 Oct 2026)
244
+ // authenticates with a service account, not v1.1's client-id/secret/refresh-token triple,
245
+ // and `publish-browser-extension` switches its whole config shape on this value.
246
+ CHROME_API_VERSION: "v2",
247
+ ...Object.fromEntries(stores.flatMap((row) => row.secrets).map((name) => [name, `\${{ secrets.${name} }}`])),
248
+ },
249
+ steps: [
250
+ ...toolchainSteps(project, "24", RELEASE_SHA),
251
+ // Only the `wxt`/`publish-browser-extension` binary is needed — the zips it submits are the
252
+ // `package` job's own, downloaded below, not rebuilt.
253
+ { run: `npm --prefix ${BROWSER_HOST_DIR} install` },
254
+ {
255
+ uses: "actions/download-artifact@v8",
256
+ with: { name: RELEASE_ARTIFACT, path: RELEASE_ARTIFACT },
257
+ },
258
+ {
259
+ // Not a credential check for every store: the dry run reaches the network only for
260
+ // Chrome (API v2's `fetchStatus`) and Firefox (`GET /api/v5/addons/addon/{id}`). Edge's
261
+ // dry run makes no request at all, so what this proves there is the zip, not the token.
262
+ name: "wxt submit --dry-run (zips, plus a live credential read for Chrome and Firefox)",
263
+ run: browserExtSubmitScript(project, rows, true),
264
+ },
265
+ { name: "wxt submit", run: browserExtSubmitScript(project, rows, false) },
266
+ ],
267
+ };
268
+ }
269
+ /**
270
+ * Safari has no headless publish path (§6): `xcrun safari-web-extension-converter` is macOS-only
271
+ * and Apple's own Xcode archive/export is the only route to App Store Connect. Opt-in
272
+ * (`tool.json` `browserExtension.safari`) and never in the default matrix — a `macos-latest`
273
+ * runner and a paid Apple Developer account are real costs the surface must not impose by
274
+ * default. Documented as the host-native leg: toolfactory scaffolds the payload
275
+ * (`wxt build -b safari`, any OS) and converts + signs it here; the archive/export invocation is
276
+ * Apple's own and unverifiable without macOS, so an author adjusting scheme/entitlements for
277
+ * their own app id is expected.
278
+ */
279
+ function browserExtSafariJob(project, safari) {
280
+ return {
281
+ needs: "gate",
282
+ if: `needs.gate.outputs.${gateVariable(safari)} == 'true'`,
283
+ "runs-on": "macos-latest",
284
+ permissions: { contents: "read" },
285
+ env: Object.fromEntries(safari.secrets.map((name) => [name, `\${{ secrets.${name} }}`])),
286
+ steps: [
287
+ ...toolchainSteps(project, "24", RELEASE_SHA),
288
+ { run: `npm --prefix ${BROWSER_HOST_DIR} install` },
289
+ // `npm exec` never changes the working directory, so `wxt` needs its root passed
290
+ // explicitly (verified live against `wxt zip`, the same CLI).
291
+ {
292
+ run: `npm --prefix ${BROWSER_HOST_DIR} exec --no -- wxt build ${BROWSER_HOST_DIR} -b safari`,
293
+ },
294
+ {
295
+ name: "Convert to an Xcode project",
296
+ run: `xcrun safari-web-extension-converter ${BROWSER_HOST_DIR}/.output/safari-mv2 --project-location dist/safari --no-open --no-prompt`,
297
+ },
298
+ {
299
+ name: "Archive and export (App Store Connect API key)",
300
+ run: [
301
+ `key="dist/safari/AuthKey_$ASC_KEY_ID.p8" && printf '%s' "$ASC_PRIVATE_KEY" > "$key"`,
302
+ `project="$(ls dist/safari/*.xcodeproj)" && scheme="$(basename "$project" .xcodeproj)"`,
303
+ `xcodebuild archive -project "$project" -scheme "$scheme" -archivePath dist/safari/build.xcarchive`,
304
+ `xcodebuild -exportArchive -archivePath dist/safari/build.xcarchive -exportPath dist/safari/export -authenticationKeyPath "$key" -authenticationKeyID "$ASC_KEY_ID" -authenticationKeyIssuerID "$ASC_ISSUER_ID"`,
305
+ ].join(" && "),
306
+ },
307
+ ],
308
+ };
309
+ }
310
+ // ---------------------------------------------------------------------------------------------
311
+ // release.yml — always: the Release carries the assets; each registry leg runs iff `gate` says it can.
312
+ // ---------------------------------------------------------------------------------------------
313
+ /**
314
+ * Which registries this tag can publish to, decided once. Each gated row's `gate` runs with that
315
+ * row's secrets and confirm variable in its environment and the answer becomes a job output,
316
+ * because `secrets` and `env` are unreadable in a job-level `if` and the reusable-workflow legs
317
+ * cannot be gated inside a `run:`. A row that cannot publish prints one notice pointing at its page.
318
+ */
319
+ function presenceStep(rows) {
320
+ const gated = rows.filter((row) => row.gate);
321
+ const env = { GH_TOKEN: "${{ github.token }}" };
322
+ for (const row of gated) {
323
+ for (const name of row.secrets)
324
+ env[name] = `\${{ secrets.${name} }}`;
325
+ if (row.confirmVariable)
326
+ env[row.confirmVariable] = `\${{ vars.${row.confirmVariable} }}`;
327
+ }
328
+ const script = gated.map((row) => {
329
+ const v = gateVariable(row);
330
+ return `${v}=false; if ${row.gate}; then ${v}=true; else echo "::notice::${row.id}: not published at this tag — ${row.url}"; fi; echo "${v}=$${v}" >> "$GITHUB_OUTPUT"`;
331
+ });
332
+ return {
333
+ id: "presence",
334
+ name: "which registries this tag can publish to",
335
+ env,
336
+ run: script.join("\n"),
337
+ };
338
+ }
339
+ function presenceOutputs(rows) {
340
+ const gated = rows.filter((row) => row.gate);
341
+ return gated.length
342
+ ? Object.fromEntries(gated.map((row) => [
343
+ gateVariable(row),
344
+ `\${{ steps.presence.outputs.${gateVariable(row)} }}`,
345
+ ]))
346
+ : undefined;
347
+ }
348
+ function releaseDocument(project) {
349
+ const npmSelected = has(project, "npm");
350
+ const pypiSelected = has(project, "pypi");
351
+ const mcpRegistrySelected = has(project, "mcp-registry");
352
+ // ClawHub publishes the openclaw-native package; without that host there is nothing to publish.
353
+ const clawhubSelected = has(project, "clawhub") && has(project, "openclaw-native");
354
+ // ClawHub's skill catalog is a separate track from its plugin catalog above: it publishes
355
+ // straight from the checkout (no openclaw-native package needed), so `skill` is all it takes.
356
+ const clawhubSkillSelected = has(project, "clawhub") && has(project, "skill");
357
+ const browserExtSelected = has(project, "browser-extension");
358
+ const rows = registries(project);
359
+ const gated = (id) => {
360
+ const row = rows.find((entry) => entry.id === id);
361
+ return row?.gate ? `needs.gate.outputs.${gateVariable(row)} == 'true'` : undefined;
362
+ };
363
+ const cli = toolfactoryCli(project);
364
+ const assert = tagVersionAssert(project);
365
+ const jobs = {
366
+ gate: compact({
367
+ "runs-on": "ubuntu-latest",
368
+ permissions: compact({ contents: "read", pages: has(project, "web") ? "read" : undefined }),
369
+ env: { RELEASE_TAG: TAG_NAME },
370
+ outputs: { sha: "${{ steps.tag.outputs.sha }}", ...presenceOutputs(rows) },
371
+ steps: [
372
+ // The whole history: the tag may exist (a re-run) or not yet (a dispatch cuts it), and
373
+ // `unpublish` reads tool.json at the previous tag.
374
+ checkoutStep(undefined, { "fetch-depth": 0 }),
375
+ ...toolchainSteps(project, "24").slice(1),
376
+ {
377
+ id: "tag",
378
+ name: "the commit this tag releases",
379
+ run: 'sha=$(git rev-parse -q --verify "refs/tags/$RELEASE_TAG^{commit}" 2>/dev/null || git rev-parse HEAD) && git checkout -q "$sha" && echo "sha=$sha" >> "$GITHUB_OUTPUT"',
380
+ },
381
+ ...(assert ? [actionStep(assert, false)] : []),
382
+ ...hermesSteps(project),
383
+ ...gateSteps(project).map((step) => actionStep(step, false)),
384
+ presenceStep(rows),
385
+ ],
386
+ }),
387
+ package: compact({
388
+ needs: "gate",
389
+ "runs-on": "ubuntu-latest",
390
+ permissions: { contents: "read" },
391
+ // The Firefox self-hosted xpi packageSteps emits (gate.ts) is opt-in on this pair: present,
392
+ // it signs; absent, that one step no-ops and the job still packages the unsigned zips.
393
+ env: has(project, "browser-extension")
394
+ ? {
395
+ FIREFOX_JWT_ISSUER: "${{ secrets.FIREFOX_JWT_ISSUER }}",
396
+ FIREFOX_JWT_SECRET: "${{ secrets.FIREFOX_JWT_SECRET }}",
397
+ }
398
+ : undefined,
399
+ steps: [
400
+ ...toolchainSteps(project, "24", RELEASE_SHA),
401
+ ...packageSteps(project).map((step) => actionStep(step, false)),
402
+ {
403
+ uses: "actions/upload-artifact@v7",
404
+ with: { name: RELEASE_ARTIFACT, path: `${RELEASE_DIR}/` },
405
+ },
406
+ ],
407
+ }),
408
+ };
409
+ const priorLegs = [];
410
+ if (npmSelected) {
411
+ const tarball = `${RELEASE_ARTIFACT}/${npmTarball(project)}`;
412
+ jobs["publish-npm"] = compact({
413
+ needs: ["gate", "package"],
414
+ if: gated("npm"),
415
+ "runs-on": "ubuntu-latest",
416
+ permissions: { "id-token": "write", contents: "read" },
417
+ env: { NPM_TRUSTED_PUBLISHER: "${{ vars.NPM_TRUSTED_PUBLISHER }}" },
418
+ steps: [
419
+ checkoutStep(RELEASE_SHA),
420
+ {
421
+ uses: "actions/setup-node@v7",
422
+ with: { "node-version": "24", "registry-url": "https://registry.npmjs.org" },
423
+ },
424
+ {
425
+ uses: "actions/download-artifact@v8",
426
+ with: { name: RELEASE_ARTIFACT, path: RELEASE_ARTIFACT },
427
+ },
428
+ // OIDC is selected only after bootstrap-repo verified `npm trust` and set the repository
429
+ // variable. A token remains the fallback for a new package and for an existing package
430
+ // whose publisher was not configured. npm versions are immutable, so a dispatch retry
431
+ // after adding credentials leaves an already-published version alone.
432
+ {
433
+ name: "npm publish",
434
+ env: {
435
+ NPM_TOKEN: "${{ vars.NPM_TRUSTED_PUBLISHER != 'true' && secrets.NPM_TOKEN || '' }}",
436
+ },
437
+ run: `if npm view ${npmName(project)}@${project.identity.version ?? "0.0.0"} version >/dev/null 2>&1; then echo "::notice::npm: ${npmName(project)}@${project.identity.version ?? "0.0.0"} already published; skipping immutable version."; elif [ "$NPM_TRUSTED_PUBLISHER" = true ]; then npm publish ${tarball} --access public; else NODE_AUTH_TOKEN="$NPM_TOKEN" npm publish ${tarball} --access public; fi`,
438
+ },
439
+ ],
440
+ });
441
+ priorLegs.push("publish-npm");
442
+ }
443
+ if (pypiSelected) {
444
+ jobs["publish-pypi"] = compact({
445
+ needs: "gate",
446
+ if: gated("pypi"),
447
+ "runs-on": "ubuntu-latest",
448
+ environment: "pypi",
449
+ permissions: { "id-token": "write" },
450
+ steps: [
451
+ checkoutStep(RELEASE_SHA),
452
+ { uses: "astral-sh/setup-uv@v6" },
453
+ { run: "uv build" },
454
+ {
455
+ name: "Publish package distributions to PyPI",
456
+ uses: "pypa/gh-action-pypi-publish@release/v1",
457
+ },
458
+ ],
459
+ });
460
+ priorLegs.push("publish-pypi");
461
+ }
462
+ const owner = githubOwner(project.identity.repository);
463
+ if (mcpRegistrySelected && owner) {
464
+ // The image the `mcp-registry` surface already named in `server.json`'s oci entry, and the
465
+ // root Dockerfile it emitted alongside it. `type=semver` turns the v-prefixed tag into the
466
+ // bare version the entry carries — the gate has already asserted they agree.
467
+ const image = ociImage(project, owner).replace(/:[^:]*$/, "");
468
+ jobs["publish-oci"] = compact({
469
+ needs: "gate",
470
+ if: gated("oci"),
471
+ "runs-on": "ubuntu-latest",
472
+ permissions: {
473
+ contents: "read",
474
+ packages: "write",
475
+ attestations: "write",
476
+ "id-token": "write",
477
+ },
478
+ steps: [
479
+ checkoutStep(RELEASE_SHA),
480
+ {
481
+ uses: "docker/login-action@v4",
482
+ with: {
483
+ registry: "ghcr.io",
484
+ username: "${{ github.actor }}",
485
+ password: "${{ secrets.GITHUB_TOKEN }}",
486
+ },
487
+ },
488
+ {
489
+ id: "meta",
490
+ uses: "docker/metadata-action@v6",
491
+ with: {
492
+ images: image,
493
+ tags: `type=semver,pattern={{version}},value=${TAG_NAME}`,
494
+ // The registry's OCI ownership check reads this label back out of the image config.
495
+ labels: `io.modelcontextprotocol.server.name=${registryName(project)}`,
496
+ },
497
+ },
498
+ {
499
+ uses: "docker/build-push-action@v7",
500
+ with: {
501
+ context: ".",
502
+ push: true,
503
+ tags: "${{ steps.meta.outputs.tags }}",
504
+ labels: "${{ steps.meta.outputs.labels }}",
505
+ },
506
+ },
507
+ ],
508
+ });
509
+ priorLegs.push("publish-oci");
510
+ }
511
+ if (mcpRegistrySelected) {
512
+ jobs["publish-mcp-registry"] = compact({
513
+ // After every package leg (§7): mcp-publisher validates that each `packages[]` entry
514
+ // already exists in its own registry, the oci image included — so the gate lets it run only
515
+ // when every package leg does.
516
+ needs: needsOf(["gate", ...priorLegs]),
517
+ if: gated("mcp-registry"),
518
+ "runs-on": "ubuntu-latest",
519
+ permissions: { "id-token": "write", contents: "read" },
520
+ steps: [
521
+ checkoutStep(RELEASE_SHA),
522
+ { run: MCP_PUBLISHER_FETCH },
523
+ { run: "./mcp-publisher login github-oidc && ./mcp-publisher publish" },
524
+ ],
525
+ });
526
+ priorLegs.push("publish-mcp-registry");
527
+ }
528
+ const comments = [
529
+ "`gate` decides once which registries this tag can publish to (a job-level `if` cannot read",
530
+ "secrets) and every leg obeys its output, so a tag with no secrets is still green: gate,",
531
+ "package, the Release, and whatever else applies. After adding a secret, re-run with",
532
+ "`gh workflow run release.yml -f tag=vX.Y.Z` — never `gh run rerun`, which replays the",
533
+ "original run's secret snapshot. `release` first retracts every registry a surface dropped",
534
+ "since the previous tag published to (git is the ledger; `toolfactory unpublish`), and",
535
+ "`toolfactory secrets status` / `bootstrap-repo` cover the one-time steps that are left.",
536
+ ];
537
+ if (clawhubSelected) {
538
+ jobs["publish-clawhub"] = compact({
539
+ if: "${{ !cancelled() && needs.package.result == 'success' && !contains(needs.*.result, 'failure') && needs.gate.outputs.clawhub_package == 'true' }}",
540
+ // Last (§7): content-fingerprint deduped by ClawHub, therefore safe to retry. The reusable
541
+ // workflow has no build step, so it publishes the tarball the `package` job already built
542
+ // rather than the repository subdirectory. It is a workflow call, so it has no `permissions`
543
+ // block of its own and inherits the file's `contents: read`.
544
+ needs: needsOf(["gate", "package", ...priorLegs]),
545
+ uses: "openclaw/clawhub/.github/workflows/package-publish.yml@main",
546
+ with: {
547
+ package_artifact_name: RELEASE_ARTIFACT,
548
+ package_artifact_path: openclawTarball(project),
549
+ dry_run: false,
550
+ wait_for_publication: true,
551
+ },
552
+ secrets: { clawhub_token: "${{ secrets.CLAWHUB_TOKEN }}" },
553
+ });
554
+ priorLegs.push("publish-clawhub");
555
+ comments.push("", "ClawHub's GitHub-OIDC trusted publishing covers `workflow_dispatch` only, not", "`push: tags` — this leg is the one that is not secretless (§7): it needs a stored", "CLAWHUB_TOKEN repo secret, and `clawhub package trusted-publisher set` does not help here.");
556
+ }
557
+ if (clawhubSkillSelected) {
558
+ // A separate ClawHub track from the package leg above: publishes skills/<N> to ClawHub's
559
+ // skill catalog straight from the checkout, so it only needs `gate`, not the built tarball.
560
+ // ClawHub's own reusable workflow derives slug/name/version from SKILL.md and dedupes by
561
+ // content fingerprint, same as `clawhub package publish` above — safe to retry.
562
+ jobs["publish-clawhub-skill"] = compact({
563
+ if: gated("clawhub-skill"),
564
+ needs: "gate",
565
+ uses: "openclaw/clawhub/.github/workflows/skill-publish.yml@main",
566
+ with: { skill_path: skillPath(project).replace(/\/SKILL\.md$/, ""), dry_run: false },
567
+ secrets: { clawhub_token: "${{ secrets.CLAWHUB_TOKEN }}" },
568
+ });
569
+ priorLegs.push("publish-clawhub-skill");
570
+ comments.push("", "ClawHub skill publishing has no OIDC trusted-publishing path yet (V1 is token-only,", "per its own docs) — this leg reuses the same stored CLAWHUB_TOKEN as publish-clawhub.");
571
+ }
572
+ if (browserExtSelected) {
573
+ jobs["publish-browser-ext"] = browserExtPublishJob(project, rows);
574
+ priorLegs.push("publish-browser-ext");
575
+ comments.push("", "None of Chrome Web Store, Firefox Add-ons or Edge Add-ons supports GitHub OIDC — like", "CLAWHUB_TOKEN above, publish-browser-ext is a stored-secrets leg, gated per store on that", "store's own secrets (nine names across the three stores); a store left unconfigured is", "skipped, never a failure, and the leg's own dry run runs on every push and pull request.");
576
+ const safari = rows.find((row) => row.id === "safari");
577
+ if (safari) {
578
+ // Opt-in and outside the release graph on purpose: it submits to App Store Connect, not
579
+ // dist/release/, so nothing downstream needs to wait on it.
580
+ jobs["publish-browser-ext-safari"] = browserExtSafariJob(project, safari);
581
+ comments.push("", "publish-browser-ext-safari (opt-in, tool.json browserExtension.safari) is the macOS", "host-native leg: xcrun safari-web-extension-converter, then an Xcode archive/export", "signed with an App Store Connect API key. Runs on macos-latest, needs only `gate`, and", "does not gate the release — it submits to App Store Connect, not dist/release/.");
582
+ }
583
+ }
584
+ jobs.release = {
585
+ // The tag's own release, after every leg: the assets are the same ones the registries got.
586
+ // Skipped legs must not skip it, only failed ones; hence the explicit condition.
587
+ needs: needsOf(["gate", "package", ...priorLegs]),
588
+ if: "${{ !cancelled() && needs.package.result == 'success' && !contains(needs.*.result, 'failure') }}",
589
+ "runs-on": "ubuntu-latest",
590
+ // Retraction needs what publishing needed: packages and pages to delete, OIDC for the MCP
591
+ // registry, and the stored tokens the dropped registries' rows name.
592
+ permissions: { contents: "write", packages: "write", pages: "write", "id-token": "write" },
593
+ env: {
594
+ RELEASE_TAG: TAG_NAME,
595
+ },
596
+ steps: [
597
+ // The whole history: `unpublish` reads tool.json at the previous tag.
598
+ checkoutStep(RELEASE_SHA, { "fetch-depth": 0 }),
599
+ ...toolchainSteps(project, "24", RELEASE_SHA).slice(1),
600
+ ...bootstrapSteps(project).map((step) => actionStep(step, false)),
601
+ {
602
+ ...actionStep(unpublishStep(project), false),
603
+ env: {
604
+ GH_TOKEN: "${{ github.token }}",
605
+ ...Object.fromEntries(RELEASE_SECRET_NAMES.map((name) => [name, `\${{ secrets.${name} }}`])),
606
+ },
607
+ },
608
+ {
609
+ uses: "actions/download-artifact@v8",
610
+ with: { name: RELEASE_ARTIFACT, path: RELEASE_ARTIFACT },
611
+ },
612
+ {
613
+ uses: "softprops/action-gh-release@v3",
614
+ with: {
615
+ tag_name: TAG_NAME,
616
+ // A tag the dispatch is cutting does not exist yet: the Release creates it here.
617
+ target_commitish: RELEASE_SHA,
618
+ generate_release_notes: true,
619
+ files: `${RELEASE_ARTIFACT}/*`,
620
+ fail_on_unmatched_files: true,
621
+ },
622
+ },
623
+ ],
624
+ };
625
+ if (has(project, "web")) {
626
+ jobs["pages-build"] = compact({
627
+ needs: "gate",
628
+ if: gated("pages"),
629
+ "runs-on": "ubuntu-latest",
630
+ permissions: { contents: "read" },
631
+ steps: [
632
+ ...toolchainSteps(project, "24", RELEASE_SHA),
633
+ ...[...bootstrapSteps(project), outputsStep(project)].map((step) => actionStep(step, false)),
634
+ { uses: "actions/configure-pages@v5" },
635
+ {
636
+ name: "web build",
637
+ // A project page is served from /<repo>/, so Vite's base has to say so or every asset
638
+ // 404s. `web/vite.config.ts` reads PAGES_BASE and defaults to "/" for `npm run dev`.
639
+ env: { PAGES_BASE: "/${{ github.event.repository.name }}/" },
640
+ run: WEB_BUILD,
641
+ },
642
+ {
643
+ name: "publish the schema and the coverage report alongside the app",
644
+ run: `cp dev.toolfactory/tool.schema.json COVERAGE.md web/dist/ && ${cli} coverage > web/dist/coverage.json`,
645
+ },
646
+ { uses: "actions/upload-pages-artifact@v3", with: { path: "web/dist" } },
647
+ ],
648
+ });
649
+ jobs["pages-deploy"] = {
650
+ needs: "pages-build",
651
+ "runs-on": "ubuntu-latest",
652
+ permissions: { pages: "write", "id-token": "write" },
653
+ environment: { name: "github-pages", url: "${{ steps.deployment.outputs.page_url }}" },
654
+ steps: [{ id: "deployment", uses: "actions/deploy-pages@v5" }],
655
+ };
656
+ comments.push("", "Pages must be enabled with Source = GitHub Actions once (`toolfactory bootstrap-repo` does", "it through gh); until then the gate skips the Pages pair with a notice.");
657
+ }
658
+ const doc = {
659
+ name: "Release",
660
+ on: {
661
+ push: { tags: ["v*"] },
662
+ workflow_dispatch: {
663
+ inputs: {
664
+ tag: {
665
+ description: "The v* tag to release: an existing one to publish again (e.g. after adding a secret), or a new one to cut at this branch's head",
666
+ required: true,
667
+ type: "string",
668
+ },
669
+ },
670
+ },
671
+ },
672
+ permissions: { contents: "read" },
673
+ jobs,
674
+ };
675
+ return { doc, comments };
676
+ }
677
+ // ---------------------------------------------------------------------------------------------
678
+ // compose.toolfactory.yaml — when openclaw-native or hermes-native is selected.
679
+ // ---------------------------------------------------------------------------------------------
680
+ function composeDocument(project) {
681
+ const openclawSelected = has(project, "openclaw-native");
682
+ const hermesSelected = has(project, "hermes-native");
683
+ if (!openclawSelected && !hermesSelected)
684
+ return undefined;
685
+ const requiresBrowser = project.operations.some((op) => op.requires.includes("browser"));
686
+ const services = {};
687
+ if (openclawSelected) {
688
+ services.openclaw = {
689
+ image: `ghcr.io/openclaw/openclaw:latest${requiresBrowser ? "-browser" : ""}`,
690
+ volumes: [".:/work", "PLUGIN_DATA:/data"],
691
+ env_file: [".env"],
692
+ // `--link` registers the mounted checkout in place: a plain install copies the whole tree,
693
+ // node_modules/openclaw included, and trips the install-time symlink scan on the `file:`
694
+ // core dependency. `--accept-capabilities` is what makes the install non-interactive.
695
+ command: [
696
+ "sh",
697
+ "-c",
698
+ `openclaw plugins install --link /work/${OPENCLAW_HOST_DIR} --force --accept-capabilities && openclaw plugins inspect ${project.identity.name} --runtime --json`,
699
+ ],
700
+ };
701
+ }
702
+ if (hermesSelected) {
703
+ services.hermes = {
704
+ // No published Hermes image exists; its own installer, pinned, in a stock image is the host.
705
+ image: "buildpack-deps:bookworm",
706
+ volumes: [".:/work", "PLUGIN_DATA:/data"],
707
+ environment: { HERMES_HOME: "/data" },
708
+ env_file: [".env"],
709
+ command: [
710
+ "bash",
711
+ "-c",
712
+ `${hermesInstall()} && hermes plugins doctor /work/${hermesPluginDir(project)} --ci`,
713
+ ],
714
+ };
715
+ }
716
+ return { services, volumes: { PLUGIN_DATA: {} } };
717
+ }
718
+ // ---------------------------------------------------------------------------------------------
719
+ // .env.example — always: every config key, from the one credential declaration (§6).
720
+ // ---------------------------------------------------------------------------------------------
721
+ function envExample(project) {
722
+ const properties = configProperties(project);
723
+ const required = new Set(requiredConfig(project));
724
+ const keys = Object.keys(properties);
725
+ const lines = [
726
+ "# Generated by toolfactory from dev.toolfactory/tool.json `config`. Copy to `.env` and fill in.",
727
+ ];
728
+ if (keys.length === 0)
729
+ lines.push("# No configuration keys are declared yet.");
730
+ for (const key of keys) {
731
+ const property = properties[key];
732
+ const sensitive = isSensitive(property);
733
+ const meta = (property["x-toolfactory"] ?? {});
734
+ const description = typeof property.description === "string" ? property.description : key;
735
+ const tags = [required.has(key) ? "required" : "optional", sensitive ? "secret" : undefined]
736
+ .filter(Boolean)
737
+ .join(", ");
738
+ lines.push("", `# ${description} (${tags})${meta.url ? ` — get one: ${meta.url}` : ""}`, `${envName(key)}=`);
739
+ }
740
+ const release = registries(project).filter((row) => row.secrets.length);
741
+ if (release.length) {
742
+ lines.push("", "# Release credentials (repository secrets; `toolfactory bootstrap-repo` pushes them from .env,", "# `toolfactory secrets status` explains each). Leave one empty to skip that registry.");
743
+ for (const row of release) {
744
+ lines.push("", `# ${row.id}: ${row.url}`, ...row.secrets.map((name) => `${name}=`));
745
+ }
746
+ }
747
+ return `${lines.join("\n")}\n`;
748
+ }
749
+ // ---------------------------------------------------------------------------------------------
750
+ // renovate.json — always: config:recommended already spans npm/PyPI/Actions/Docker (C9).
751
+ // ---------------------------------------------------------------------------------------------
752
+ function renovatePatch() {
753
+ return {
754
+ $schema: "https://docs.renovatebot.com/renovate-schema.json",
755
+ // `group:all` is Renovate's own preset for one PR per run rather than one per dependency;
756
+ // writing the rule out by hand would only own a key the author needs for their own pins.
757
+ extends: ["config:recommended", "group:all"],
758
+ // Mirrors OpenClaw's own minimumReleaseAge.
759
+ minimumReleaseAge: "7 days",
760
+ // These files are projections of dev.toolfactory/tool.json and are SHA-locked: a Renovate PR
761
+ // editing one makes `toolfactory check` fail and the next `toolfactory build` reverts it.
762
+ // Upgrading what they pin is a toolfactory version bump (§8 C6).
763
+ ignorePaths: [".github/workflows/**", "hosts/*/package.json", "web/package.json"],
764
+ };
765
+ }
766
+ export const surface = {
767
+ id: "workflows",
768
+ plan(project) {
769
+ const files = [
770
+ {
771
+ kind: "file",
772
+ path: ".github/workflows/ci.yml",
773
+ content: YAML_HEADER + yml(ciDocument(project)),
774
+ },
775
+ ];
776
+ const release = releaseDocument(project);
777
+ if (release) {
778
+ const header = [
779
+ YAML_HEADER.trimEnd(),
780
+ ...release.comments.map((line) => (line ? `# ${line}` : "#")),
781
+ "",
782
+ "",
783
+ ].join("\n");
784
+ files.push({
785
+ kind: "file",
786
+ path: ".github/workflows/release.yml",
787
+ content: header + yml(release.doc),
788
+ });
789
+ }
790
+ const compose = composeDocument(project);
791
+ if (compose) {
792
+ files.push({
793
+ kind: "file",
794
+ path: "compose.toolfactory.yaml",
795
+ content: YAML_HEADER + yml(compose),
796
+ });
797
+ }
798
+ files.push({ kind: "file", path: ".env.example", content: envExample(project) });
799
+ // A merge file: an author's own keys (a `customManagers` for pins in their source) survive.
800
+ files.push({
801
+ kind: "merge",
802
+ path: "renovate.json",
803
+ format: "json",
804
+ patch: renovatePatch(),
805
+ owned: ["extends", "ignorePaths"],
806
+ });
807
+ return files;
808
+ },
809
+ };