@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.
- package/bin/voxgig-sdkgen +1 -1
- package/dist/cmp/PublishWorkflow.d.ts +2 -0
- package/dist/cmp/PublishWorkflow.js +311 -0
- package/dist/cmp/PublishWorkflow.js.map +1 -0
- package/dist/sdkgen.d.ts +2 -1
- package/dist/sdkgen.js +6 -4
- package/dist/sdkgen.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/project/sdkgen-package.json +1 -1
- package/src/cmp/PublishWorkflow.ts +334 -0
- package/src/sdkgen.ts +2 -0
|
@@ -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,
|