@voxgig/sdkgen 4.12.0 → 4.13.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.
@@ -0,0 +1,334 @@
1
+ /* Copyright (c) 2024-2026 Voxgig Ltd, MIT License */
2
+
3
+ import { cmp, each, Content, File, Folder } from 'jostraca'
4
+
5
+ import {
6
+ KIT,
7
+ getModelPath
8
+ } from '../types'
9
+
10
+ import { packageName, packageVersion, repoInfo } from '../helpers/packageMeta'
11
+
12
+
13
+ // PUBLISHING AN npm TARGET FROM GITHUB ACTIONS, WITH NO TOKEN.
14
+ //
15
+ // A generated SDK is published from CI, and the credential is the whole
16
+ // problem: an NPM_TOKEN in repository secrets is long-lived, copyable, and
17
+ // readable by every workflow in the repo. GitHub OIDC Trusted Publishing
18
+ // replaces it — npm exchanges a short-lived OIDC token minted for one job in
19
+ // one repository for a publish credential, and attaches SLSA provenance on
20
+ // the way through.
21
+ //
22
+ // DISPATCH, NOT A TAG PUSH. The deploy Makefile in this same repo publishes
23
+ // with a vault-injected token, and a tag-triggered workflow racing it would
24
+ // publish the same version twice from two mechanisms. A dispatch is a
25
+ // deliberate act by someone who can see the result, which is what a release
26
+ // should be.
27
+ //
28
+ // THE WORKFLOW FILE NAME IS PART OF THE TRUST CONFIGURATION. npm binds a
29
+ // trusted publisher to the repository AND the workflow filename, so renaming
30
+ // this file silently breaks publishing until the npm side is updated to
31
+ // match. That is also why one file per target: `publish-ts.yml` can be
32
+ // trusted for the ts package without granting anything to another target's.
33
+ //
34
+ // SET-UP IS A MAINTAINER TASK, so what a human must do once is written to
35
+ // `.sdk/PUBLISHING.md` — beside the generator, not into the target's own
36
+ // docs, which describe the SDK to the people who INSTALL it and have no
37
+ // business being told how this repo releases.
38
+ const PublishWorkflow = cmp(function PublishWorkflow(props: any) {
39
+ const { ctx$ } = props
40
+ const { model } = ctx$
41
+
42
+ const targetMap = getModelPath(model, `main.${KIT}.target`) || {}
43
+
44
+ // npm ONLY. The mechanics here are npm's — `npm publish`, `npm trust`,
45
+ // npm's OIDC exchange — and a PyPI or RubyGems target needs a different
46
+ // workflow rather than this one with a word changed. Emitting nothing for
47
+ // them is the honest answer until those exist.
48
+ const npmTargets: any[] = []
49
+ each(targetMap, (t: any) => {
50
+ if (false === t.active) {
51
+ return
52
+ }
53
+ if ('npm' === (t.publish?.registry?.name || '')) {
54
+ npmTargets.push(t)
55
+ }
56
+ })
57
+
58
+ if (0 === npmTargets.length) {
59
+ return
60
+ }
61
+
62
+ const { repoUrl } = repoInfo(model)
63
+
64
+ Folder({ name: '.github' }, () => {
65
+ Folder({ name: 'workflows' }, () => {
66
+ for (const target of npmTargets) {
67
+ File({ name: `publish-${target.name}.yml` }, () => {
68
+ Content(publishWorkflow(model, target))
69
+ })
70
+ }
71
+ })
72
+ })
73
+
74
+ Folder({ name: '.sdk' }, () => {
75
+ File({ name: 'PUBLISHING.md' }, () => {
76
+ Content(publishingDoc(model, npmTargets, repoUrl))
77
+ })
78
+ })
79
+
80
+ ctx$.log.info({
81
+ point: 'generate-publish-workflow',
82
+ note: 'npm targets: ' + npmTargets.map((t: any) => t.name).join(','),
83
+ })
84
+ })
85
+
86
+
87
+ function publishWorkflow(model: any, target: any): string {
88
+ const name = target.name
89
+ // BY TARGET, NOT BY ECOSYSTEM. `packageName(model, 'npm')` resolves the
90
+ // ecosystem's PRIMARY target — ts — so every npm target's workflow named
91
+ // the ts package: publish-js.yml claimed `@voxgig-sdk/github-sdk` while
92
+ // `js/` publishes `@voxgig-sdk/github-js`. It would have checked the wrong
93
+ // package on the registry and told a maintainer to trust the wrong one,
94
+ // while `npm publish` shipped the right one — confidently wrong in three
95
+ // places at once.
96
+ const pkg = packageName(model, name)
97
+
98
+ return `# Generated by @voxgig/sdkgen. Do not edit.
99
+ #
100
+ # Publishes ${pkg} (the \`${name}/\` target) to npm, via GitHub OIDC Trusted
101
+ # Publishing — no NPM_TOKEN. npm exchanges a short-lived OIDC token minted
102
+ # for THIS job in THIS repository for a publish credential, and attaches SLSA
103
+ # provenance automatically.
104
+ #
105
+ # SET UP ONCE, by a maintainer with publish rights — see .sdk/PUBLISHING.md:
106
+ #
107
+ # npm trust github ${pkg} \\
108
+ # --repository <owner>/<repo> \\
109
+ # --file publish-${name}.yml \\
110
+ # --allow-publish
111
+ #
112
+ # THE FILENAME IS PART OF THAT CONFIGURATION. Renaming this file breaks
113
+ # publishing until the npm side is updated to match.
114
+ #
115
+ # BY DISPATCH, deliberately. The deploy Makefile publishes with a
116
+ # vault-injected token; a tag-triggered workflow racing it would publish one
117
+ # version by two mechanisms. Releasing is an act someone performs and
118
+ # watches.
119
+ #
120
+ # TWO JOBS, BECAUSE THEY NEED DIFFERENT PRIVILEGES. A dependency lifecycle
121
+ # script can ask the runner for any OIDC token its job is permitted to mint,
122
+ # so a job that both installs dependencies and holds \`id-token: write\` can be
123
+ # made to publish before its own gates finish.
124
+ #
125
+ # verify contents: read, nothing else. Installs, builds and tests.
126
+ # publish id-token: write. Installs no dependencies and runs no project
127
+ # code; it packs what is already in the tree.
128
+
129
+ name: publish-${name}
130
+
131
+ on:
132
+ workflow_dispatch:
133
+ inputs:
134
+ expect_sha:
135
+ description: 'Optional: refuse unless main is still at this commit'
136
+ type: string
137
+ default: ''
138
+
139
+ jobs:
140
+ verify:
141
+ name: verify
142
+ runs-on: ubuntu-latest
143
+ timeout-minutes: 20
144
+
145
+ # Deliberately the default-minimum. This job runs third-party code.
146
+ permissions:
147
+ contents: read
148
+
149
+ outputs:
150
+ version: \${{ steps.version.outputs.version }}
151
+
152
+ steps:
153
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
154
+
155
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
156
+ with:
157
+ node-version: 24.x
158
+
159
+ # RELEASES COME FROM main, INCLUDING FROM THE BUTTON. workflow_dispatch
160
+ # accepts any ref, so without this the Actions UI can release a branch.
161
+ - name: This ref is main, and is where the caller thinks it is
162
+ env:
163
+ EXPECT: \${{ inputs.expect_sha }}
164
+ run: |
165
+ set -euo pipefail
166
+ if [ "\${GITHUB_REF_NAME}" != "main" ]; then
167
+ echo "::error::releases come from main, not \${GITHUB_REF_NAME}"
168
+ exit 1
169
+ fi
170
+ if [ -n "\$EXPECT" ] && [ "\$EXPECT" != "\$GITHUB_SHA" ]; then
171
+ echo "::error::main is at \$GITHUB_SHA, not \$EXPECT"
172
+ exit 1
173
+ fi
174
+
175
+ - name: Resolve the version
176
+ id: version
177
+ working-directory: ${name}
178
+ run: |
179
+ set -euo pipefail
180
+ VERSION="\$(node -p "require('./package.json').version")"
181
+ test -n "\$VERSION" || { echo "::error::no version in ${name}/package.json"; exit 1; }
182
+ echo "version=\$VERSION" >> "\$GITHUB_OUTPUT"
183
+ echo "${pkg} \$VERSION"
184
+
185
+ - run: npm install
186
+ working-directory: ${name}
187
+
188
+ - run: npm run build
189
+ working-directory: ${name}
190
+
191
+ - run: npm test
192
+ working-directory: ${name}
193
+
194
+ publish:
195
+ name: npm publish
196
+ needs: verify
197
+ runs-on: ubuntu-latest
198
+ timeout-minutes: 15
199
+
200
+ # The ONLY job holding the publish credential — and it installs no
201
+ # dependencies and runs no project code. See the header.
202
+ permissions:
203
+ id-token: write
204
+ contents: read
205
+
206
+ steps:
207
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
208
+
209
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
210
+ with:
211
+ node-version: 24.x
212
+ registry-url: 'https://registry.npmjs.org'
213
+
214
+ # Trusted publishing requires npm >= 11.5.1. This is npm itself, not a
215
+ # project dependency: no package.json here is consulted.
216
+ - name: Use a trusted-publishing capable npm
217
+ run: npm install -g npm@latest
218
+
219
+ # THE REGISTRY IS THE SOURCE OF TRUTH FOR "IS THIS RELEASED", not the
220
+ # dispatch. A re-run would otherwise fail on a version conflict and
221
+ # report a red release that in fact succeeded.
222
+ - name: Is this version already on npm?
223
+ id: registry
224
+ env:
225
+ VERSION: \${{ needs.verify.outputs.version }}
226
+ run: |
227
+ set -euo pipefail
228
+ if npm view "${pkg}@\$VERSION" version >/dev/null 2>&1; then
229
+ echo "published=true" >> "\$GITHUB_OUTPUT"
230
+ echo "\$VERSION is already on npm — skipping publish"
231
+ else
232
+ echo "published=false" >> "\$GITHUB_OUTPUT"
233
+ fi
234
+
235
+ - name: Publish to npm
236
+ if: steps.registry.outputs.published == 'false'
237
+ working-directory: ${name}
238
+ run: npm publish --access public
239
+ `
240
+ }
241
+
242
+
243
+ function publishingDoc(model: any, targets: any[], repoUrl: string): string {
244
+ // The doc's worked example uses the FIRST npm target; the table names each
245
+ // target's own package, resolved per target.
246
+ const pkg = packageName(model, targets[0].name)
247
+ const repo = String(repoUrl || '')
248
+ .replace(/^git\+/, '')
249
+ .replace(/^(https?:\/\/)?(www\.)?github\.com[/:]/, '')
250
+ .replace(/\.git$/, '')
251
+ .replace(/\/+$/, '')
252
+ const owner = '' === repo ? '<owner>/<repo>' : repo
253
+
254
+ const rows = targets.map((t: any) =>
255
+ `| \`${t.name}/\` | ${packageName(model, t.name)} | ` +
256
+ `\`.github/workflows/publish-${t.name}.yml\` |`).join('\n')
257
+
258
+ return `# Publishing
259
+
260
+ GENERATED by @voxgig/sdkgen — regenerated on every \`npm run generate\`.
261
+
262
+ This file is for MAINTAINERS of this repository. It is in \`.sdk/\` on
263
+ purpose: the target directories document the SDK to the people who install
264
+ it, and how this repo releases is none of their business.
265
+
266
+ | target | package | workflow |
267
+ |---|---|---|
268
+ ${rows}
269
+
270
+ ## How a release happens
271
+
272
+ Publishing runs from GitHub Actions with **no NPM_TOKEN**. npm exchanges a
273
+ short-lived OIDC token — minted for one job in this repository — for a
274
+ publish credential, and attaches SLSA provenance as it goes.
275
+
276
+ 1. Bump the version in the model, then regenerate:
277
+
278
+ main: kit: target: ts: publish: version: '<x.y.z>'
279
+
280
+ 2. Commit and push to \`main\`, and let CI go green.
281
+ 3. Run the workflow from the Actions tab, or:
282
+
283
+ gh workflow run publish-${targets[0].name}.yml --ref main -f expect_sha=$(git rev-parse HEAD)
284
+
285
+ \`expect_sha\` is optional and worth using: it refuses if \`main\` moved between
286
+ the commit you checked and the run resolving.
287
+
288
+ It is a DISPATCH rather than a tag push because the root \`Makefile\` also
289
+ publishes, with a vault-injected token. A tag-triggered workflow racing it
290
+ would publish one version by two mechanisms.
291
+
292
+ ## One-time set-up
293
+
294
+ npm has to be told which repository and which workflow file may publish this
295
+ package. From a machine logged in to npm with publish rights (2FA is
296
+ required):
297
+
298
+ npm trust github ${pkg} \\
299
+ --repository ${owner} \\
300
+ --file publish-${targets[0].name}.yml \\
301
+ --allow-publish
302
+
303
+ Then \`npm trust list ${pkg}\` shows it, and
304
+ \`npm trust revoke ${pkg} --id=<id>\` removes it.
305
+
306
+ **The workflow filename is part of the configuration.** Renaming
307
+ \`publish-${targets[0].name}.yml\` breaks publishing until the npm side is
308
+ updated to match.
309
+
310
+ **A brand-new package cannot be set up this way.** npm only offers the
311
+ trusted-publisher settings once a version exists, so the FIRST release of a
312
+ package goes out by hand from an authenticated machine:
313
+
314
+ cd ts && npm publish --access public
315
+
316
+ After that, register the publisher and every later release is a dispatch.
317
+
318
+ ## Why the workflow has two jobs
319
+
320
+ A dependency lifecycle script can ask the runner for any OIDC token the job
321
+ it runs in is permitted to mint. A job that both installs dependencies and
322
+ holds \`id-token: write\` can therefore be made to publish as this package
323
+ before its own gates finish.
324
+
325
+ So \`verify\` installs, builds and tests under \`contents: read\` and holds no
326
+ credential, and \`publish\` holds \`id-token: write\` while installing nothing
327
+ and running no project code.
328
+ `
329
+ }
330
+
331
+
332
+ export {
333
+ PublishWorkflow
334
+ }
package/src/sdkgen.ts CHANGED
@@ -38,6 +38,7 @@ import { AgentGuide } from './cmp/AgentGuide'
38
38
  import { AgentGuideFeature } from './cmp/AgentGuideFeature'
39
39
  import { License } from './cmp/License'
40
40
  import { Security } from './cmp/Security'
41
+ import { PublishWorkflow } from './cmp/PublishWorkflow'
41
42
  import { Changelog } from './cmp/Changelog'
42
43
  import { Test } from './cmp/Test'
43
44
  import { TestControl, TEST_CONTROL_EXCLUDE } from './cmp/TestControl'
@@ -1083,6 +1084,7 @@ export {
1083
1084
  Deploy,
1084
1085
  License,
1085
1086
  Security,
1087
+ PublishWorkflow,
1086
1088
  Changelog,
1087
1089
  Entity,
1088
1090
  Feature,