@voxgig/sdkgen 4.14.0 → 4.16.0

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.
@@ -25,6 +25,15 @@ import { packageName, packageVersion, repoInfo } from '../helpers/packageMeta'
25
25
  // deliberate act by someone who can see the result, which is what a release
26
26
  // should be.
27
27
  //
28
+ // IT TAGS WHAT IT PUBLISHED. A release that reaches the registry but leaves
29
+ // no ref behind cannot be answered later: `@voxgig-sdk/github-sdk@0.0.3` went
30
+ // out by dispatch and the repository had only v0.0.1 and v0.0.2, so nothing
31
+ // in git said which tree the published tarball came from, and nothing
32
+ // downstream could pin the SDK by tag. npm's trusted publisher is bound to
33
+ // ONE workflow file, so the tag has to be cut inside this one -- and a ref
34
+ // pushed with GITHUB_TOKEN starts no further workflow run, so it cannot be
35
+ // delegated to a tag-triggered publisher either.
36
+ //
28
37
  // THE WORKFLOW FILE NAME IS PART OF THE TRUST CONFIGURATION. npm binds a
29
38
  // trusted publisher to the repository AND the workflow filename, so renaming
30
39
  // this file silently breaks publishing until the npm side is updated to
@@ -65,7 +74,7 @@ const PublishWorkflow = cmp(function PublishWorkflow(props: any) {
65
74
  Folder({ name: 'workflows' }, () => {
66
75
  for (const target of npmTargets) {
67
76
  File({ name: `publish-${target.name}.yml` }, () => {
68
- Content(publishWorkflow(model, target))
77
+ Content(publishWorkflow(model, target, npmTargets))
69
78
  })
70
79
  }
71
80
  })
@@ -84,8 +93,40 @@ const PublishWorkflow = cmp(function PublishWorkflow(props: any) {
84
93
  })
85
94
 
86
95
 
87
- function publishWorkflow(model: any, target: any): string {
96
+ // WHICH TAG A TARGET'S RELEASE CARRIES.
97
+ //
98
+ // A repo here publishes more than one npm package -- `ts/` and `js/` are
99
+ // separate packages from separate folders, on a lockstep version -- so a bare
100
+ // `v<version>` cut by each would be two targets racing for one tag name, and
101
+ // the loser would fail a release that had already published.
102
+ //
103
+ // The convention is the toolchain's own. @voxgig/apidef tags `v<version>` for
104
+ // its npm package and `go/v<version>` for its Go module; the generated root
105
+ // Makefile tags `<target>/v<version>` for every port. So: the ecosystem's
106
+ // PRIMARY npm target owns the bare tag, and any other npm target is prefixed
107
+ // with its own name.
108
+ //
109
+ // Resolved by comparing package names rather than hardcoding 'ts', so that a
110
+ // project that renamed or replaced its primary target still gets one bare tag
111
+ // -- and if none matches, every target is prefixed, which is wrong in no way
112
+ // that loses a release.
113
+ function isPrimaryNpm(model: any, target: any, npmTargets: any[]): boolean {
114
+ if (1 === npmTargets.length) {
115
+ return true
116
+ }
117
+ return packageName(model, target.name) === packageName(model, 'npm')
118
+ }
119
+
120
+
121
+ function releaseTag(model: any, target: any, npmTargets: any[]): string {
122
+ return isPrimaryNpm(model, target, npmTargets) ?
123
+ 'v$VERSION' : `${target.name}/v$VERSION`
124
+ }
125
+
126
+
127
+ function publishWorkflow(model: any, target: any, npmTargets: any[]): string {
88
128
  const name = target.name
129
+ const tag = releaseTag(model, target, npmTargets)
89
130
  // BY TARGET, NOT BY ECOSYSTEM. `packageName(model, 'npm')` resolves the
90
131
  // ecosystem's PRIMARY target — ts — so every npm target's workflow named
91
132
  // the ts package: publish-js.yml claimed `@voxgig-sdk/github-sdk` while
@@ -117,14 +158,25 @@ function publishWorkflow(model: any, target: any): string {
117
158
  # version by two mechanisms. Releasing is an act someone performs and
118
159
  # watches.
119
160
  #
120
- # TWO JOBS, BECAUSE THEY NEED DIFFERENT PRIVILEGES. A dependency lifecycle
161
+ # THREE JOBS, BECAUSE THEY NEED DIFFERENT PRIVILEGES. A dependency lifecycle
121
162
  # script can ask the runner for any OIDC token its job is permitted to mint,
122
163
  # so a job that both installs dependencies and holds \`id-token: write\` can be
123
- # made to publish before its own gates finish.
164
+ # made to publish before its own gates finish. The same reasoning keeps
165
+ # \`contents: write\` out of both: checkout persists its token into the git
166
+ # config for the whole job.
124
167
  #
125
168
  # verify contents: read, nothing else. Installs, builds and tests.
126
169
  # publish id-token: write. Installs no dependencies and runs no project
127
170
  # code; it packs what is already in the tree.
171
+ # tag contents: write. Runs git and nothing else.
172
+ #
173
+ # THE TAG IS CUT HERE BECAUSE IT CANNOT BE CUT ANYWHERE ELSE. npm binds the
174
+ # trusted publisher to ONE workflow file, so whatever must accompany a publish
175
+ # belongs inside it; and a ref pushed with GITHUB_TOKEN starts no further
176
+ # workflow run, so "tag, and let a tag-triggered publisher fire" does not
177
+ # work. A release that publishes but leaves no ref cannot be answered later:
178
+ # nothing in git says which tree the tarball came from, and nothing
179
+ # downstream can pin this SDK by tag.
128
180
 
129
181
  name: publish-${name}
130
182
 
@@ -236,6 +288,55 @@ jobs:
236
288
  if: steps.registry.outputs.published == 'false'
237
289
  working-directory: ${name}
238
290
  run: npm publish --access public
291
+
292
+ # Runs git and nothing else. No install, no project code — so the
293
+ # repository-write credential is never in scope while third-party code runs.
294
+ #
295
+ # It runs even when the publish step SKIPPED because the version was already
296
+ # on the registry: that is what makes re-dispatching after a partial release
297
+ # finish the job rather than leave a published version permanently untagged.
298
+ tag:
299
+ name: tag
300
+ needs: [verify, publish]
301
+ runs-on: ubuntu-latest
302
+ timeout-minutes: 10
303
+
304
+ # The ONLY job that may write to the repository, and the only one that
305
+ # runs no project code.
306
+ permissions:
307
+ contents: write
308
+
309
+ steps:
310
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
311
+ with:
312
+ # Tags are needed to tell "already tagged" from "not tagged yet".
313
+ fetch-depth: 0
314
+
315
+ - name: Tag the release
316
+ env:
317
+ VERSION: \${{ needs.verify.outputs.version }}
318
+ run: |
319
+ set -euo pipefail
320
+ TAG="${tag}"
321
+ HEAD_SHA="\$(git rev-parse HEAD)"
322
+
323
+ # EXISTENCE IS NOT ENOUGH. \`--verify\` only asks whether the name
324
+ # resolves; a tag created between the verify job's guard and this
325
+ # checkout could point anywhere. Treating that as idempotent would
326
+ # leave a green run with a published artifact from THIS commit and a
327
+ # release tag on a different one.
328
+ AT="\$(git rev-parse -q --verify "refs/tags/\$TAG^{commit}" 2>/dev/null || true)"
329
+ if [ -n "\$AT" ]; then
330
+ if [ "\$AT" != "\$HEAD_SHA" ]; then
331
+ echo "::error::\$TAG exists on \$AT, not this commit (\$HEAD_SHA)"
332
+ exit 1
333
+ fi
334
+ echo "\$TAG already points here — nothing to do" >> "\$GITHUB_STEP_SUMMARY"
335
+ exit 0
336
+ fi
337
+ git tag "\$TAG"
338
+ git push origin "refs/tags/\$TAG"
339
+ echo "pushed \$TAG" >> "\$GITHUB_STEP_SUMMARY"
239
340
  `
240
341
  }
241
342
 
@@ -255,6 +356,14 @@ function publishingDoc(model: any, targets: any[], repoUrl: string): string {
255
356
  `| \`${t.name}/\` | ${packageName(model, t.name)} | ` +
256
357
  `\`.github/workflows/publish-${t.name}.yml\` |`).join('\n')
257
358
 
359
+ // WHICH TAG EACH TARGET CUTS. Worth a table rather than a sentence: the
360
+ // primary npm target owns the bare `v<version>` and every other one is
361
+ // prefixed, so with more than one npm target the answer differs per row.
362
+ const tagRows = '| target | tag |\n|---|---|\n' + targets.map((t: any) =>
363
+ `| \`${t.name}/\` | \`` +
364
+ releaseTag(model, t, targets).replace('$VERSION', '<version>') +
365
+ '\` |').join('\n')
366
+
258
367
  return `# Publishing
259
368
 
260
369
  GENERATED by @voxgig/sdkgen — regenerated on every \`npm run generate\`.
@@ -292,6 +401,26 @@ It is a DISPATCH rather than a tag push because the root \`Makefile\` also
292
401
  publishes, with a vault-injected token. A tag-triggered workflow racing it
293
402
  would publish one version by two mechanisms.
294
403
 
404
+ ## The release is tagged for you
405
+
406
+ The workflow cuts the git tag itself, after a successful publish, so a
407
+ released version always has a ref naming the tree it came from. Nothing to
408
+ do by hand.
409
+
410
+ ${tagRows}
411
+
412
+ Re-dispatching a version that is already on the registry skips the publish
413
+ and still cuts a missing tag, so a partial release is finished by running it
414
+ again rather than repaired by hand. If the tag already exists on a DIFFERENT
415
+ commit the run fails rather than moving it: that combination means the
416
+ published artifact and the tag disagree, and only a person should decide
417
+ which is wrong.
418
+
419
+ The tag job is the only one that may write to the repository, and it runs
420
+ git and nothing else. \`contents: write\` never shares a job with project
421
+ code, for the same reason \`id-token: write\` does not — \`checkout\` persists
422
+ its token into the git config for the whole job.
423
+
295
424
  ## One-time set-up
296
425
 
297
426
  npm has to be told which repository and which workflow file may publish this
@@ -318,7 +447,7 @@ package goes out by hand from an authenticated machine:
318
447
 
319
448
  After that, register the publisher and every later release is a dispatch.
320
449
 
321
- ## Why the workflow has two jobs
450
+ ## Why the workflow has three jobs
322
451
 
323
452
  A dependency lifecycle script can ask the runner for any OIDC token the job
324
453
  it runs in is permitted to mint. A job that both installs dependencies and
@@ -326,8 +455,14 @@ holds \`id-token: write\` can therefore be made to publish as this package
326
455
  before its own gates finish.
327
456
 
328
457
  So \`verify\` installs, builds and tests under \`contents: read\` and holds no
329
- credential, and \`publish\` holds \`id-token: write\` while installing nothing
330
- and running no project code.
458
+ credential; \`publish\` holds \`id-token: write\` while installing nothing and
459
+ running no project code; and \`tag\` holds \`contents: write\` while running
460
+ git and nothing else.
461
+
462
+ The tag cannot be moved out into its own workflow: npm binds the trusted
463
+ publisher to ONE workflow filename, so anything that must accompany a publish
464
+ has to live inside this file. Nor can it be left to a tag-triggered
465
+ publisher — a ref pushed with \`GITHUB_TOKEN\` starts no further workflow run.
331
466
  `
332
467
  }
333
468
 
package/src/sdkgen.ts CHANGED
@@ -181,6 +181,32 @@ type SdkGenOptions = {
181
181
  }
182
182
 
183
183
  dryrun?: boolean
184
+
185
+ // WHERE AN OUT-OF-TREE ITEM GOES, DECIDED AT GENERATE TIME RATHER THAN IN
186
+ // THE MODEL. Keyed by item name:
187
+ //
188
+ // external: { 'seneca-provider': { path: '../..', sdkrel: '.sdksrc/acme-sdk', enclosing: true } }
189
+ //
190
+ // `output: path` in the model is a fact about ONE developer's checkout
191
+ // layout, and it is committed. The same model generated from a different
192
+ // layout is the same model — a different INVOCATION of it, not a second
193
+ // truth to encode — so the second layout belongs here and not in a second
194
+ // committed value that the two layouts then fight over.
195
+ //
196
+ // The case that forced it: a provider repo that carries a tagged checkout
197
+ // of its SDK in a subfolder and regenerates itself from it. The SDK's own
198
+ // committed model cannot describe that, because the SDK does not know it
199
+ // has been cloned into someone else's repo.
200
+ external?: Record<string, ExternalOverride>
201
+ }
202
+
203
+
204
+ // See SdkGenOptions.external. `enclosing` is separate from `path` on
205
+ // purpose — see checkExternalFolders.
206
+ type ExternalOverride = {
207
+ path?: string
208
+ sdkrel?: string
209
+ enclosing?: boolean
184
210
  }
185
211
 
186
212
 
@@ -317,12 +343,14 @@ function SdkGen(opts: SdkGenOptions) {
317
343
  // so resolve it ONCE: every destination is compared against it, and a
318
344
  // comparison between a relative and an absolute path is meaningless.
319
345
  const root = Path.resolve(folder)
346
+
347
+ const externalOverride = resolveExternalOverride(opts, log)
320
348
  // Snapshot the decision before preflight. In particular, do not check a
321
349
  // missing optional destination once for safety and AGAIN before writing:
322
350
  // if it appeared between those checks, the pass could write into content
323
351
  // that was never ownership-validated.
324
352
  const external: ExternalPlan[] =
325
- externalItems(model, root, ['target', 'docs'])
353
+ externalItems(model, root, ['target', 'docs'], externalOverride)
326
354
  .map((ext) => ({ ...ext, skip: externalSkipReason(ext, fs) }))
327
355
 
328
356
  // Before ANY file is written, in-tree included: a destination that turns
@@ -700,6 +728,11 @@ type ExternalSpec = {
700
728
  target: any
701
729
  folder: string
702
730
  active: boolean
731
+
732
+ // The caller asked, at generate time, to write into a folder that CONTAINS
733
+ // the SDK project. Never read from the model — see SdkGenOptions.external
734
+ // and checkExternalFolders.
735
+ enclosing: boolean
703
736
  }
704
737
 
705
738
 
@@ -721,6 +754,74 @@ type ExternalPlan = ExternalSpec & {
721
754
  // An INACTIVE target is still listed: it must be taken out of the in-tree
722
755
  // model (see withoutExternal) so that switching it off does not silently
723
756
  // relocate it into `<sdk-repo>/<target>/`. The generate loop skips it.
757
+ // The generate-time override of where out-of-tree items are written.
758
+ //
759
+ // Two ways in, merged, the environment winning:
760
+ //
761
+ // opts.external the caller holds the config — a build script it owns.
762
+ // SDKGEN_EXTERNAL JSON, for driving a checkout the caller does NOT own.
763
+ //
764
+ // The environment matters more than it looks. The case this exists for is a
765
+ // repository that carries a tagged checkout of its SDK and regenerates
766
+ // itself from it: the script doing that owns neither the SDK's model nor its
767
+ // `.sdk/build/sdkgen.js`, so any route that requires editing a file inside
768
+ // the checkout means patching someone else's repo on every clone — and the
769
+ // point of the exercise was that the clone is disposable.
770
+ //
771
+ // ONE JSON VARIABLE, not one variable per item per field. Item names carry
772
+ // hyphens (`seneca-provider`), so a
773
+ // `SDKGEN_EXTERNAL_SENECA_PROVIDER_PATH` scheme needs a name mangling with
774
+ // no inverse: `a-b` and `a_b` collide, and nothing can tell which was meant.
775
+ function resolveExternalOverride(
776
+ opts: SdkGenOptions, log: any,
777
+ ): Record<string, ExternalOverride> {
778
+ const declared = opts.external || {}
779
+ const raw = process.env.SDKGEN_EXTERNAL
780
+
781
+ if (null == raw || '' === raw.trim()) {
782
+ return declared
783
+ }
784
+
785
+ let parsed: any
786
+
787
+ try {
788
+ parsed = JSON.parse(raw)
789
+ }
790
+ catch (err: any) {
791
+ throw new SdkGenError(
792
+ 'SDKGEN_EXTERNAL is not valid JSON: ' + (err?.message || err) +
793
+ '\n Expected an object keyed by item name, for example:' +
794
+ '\n {"seneca-provider":{"path":"../..","sdkrel":".sdksrc/acme-sdk","enclosing":true}}' +
795
+ '\n Got: ' + raw.slice(0, 200))
796
+ }
797
+
798
+ if (null == parsed || 'object' !== typeof parsed || Array.isArray(parsed)) {
799
+ throw new SdkGenError(
800
+ 'SDKGEN_EXTERNAL must be a JSON OBJECT keyed by item name, for example:' +
801
+ '\n {"seneca-provider":{"path":"../..","sdkrel":".sdksrc/acme-sdk","enclosing":true}}' +
802
+ '\n Got: ' + raw.slice(0, 200))
803
+ }
804
+
805
+ const merged: Record<string, ExternalOverride> = { ...declared }
806
+
807
+ for (const name of Object.keys(parsed)) {
808
+ merged[name] = { ...(declared[name] || {}), ...(parsed[name] || {}) }
809
+ }
810
+
811
+ // LOUDLY. Generation writing somewhere other than the model says is
812
+ // exactly the thing whose only previous trace was one INFO line naming a
813
+ // resolved folder, and that cost a silent green run that emitted nothing.
814
+ const named = Object.keys(parsed).sort()
815
+ log.info({
816
+ point: 'external-override',
817
+ items: named.join(','),
818
+ note: 'SDKGEN_EXTERNAL overrides the output of: ' + named.join(', '),
819
+ })
820
+
821
+ return merged
822
+ }
823
+
824
+
724
825
  // Every item, of every kind, that generates OUTSIDE the SDK repo.
725
826
  //
726
827
  // Keyed by kind because `docs` needs exactly this and a second copy of it
@@ -733,19 +834,48 @@ type ExternalPlan = ExternalSpec & {
733
834
  // other, in whatever order the passes happen to run.
734
835
  function externalItems(
735
836
  model: any, folder: string, kinds: string[],
837
+ override: Record<string, ExternalOverride>,
736
838
  ): ExternalSpec[] {
737
839
  return kinds.flatMap((kind: string) => {
738
840
  const items = model?.main?.[KIT]?.[kind] || {}
739
841
 
740
842
  return Object.keys(items).sort()
741
- .map((name: string) => ({ kind, name, target: items[name] }))
843
+ .map((name: string) => {
844
+ // Keyed by NAME, across kinds: a docs item and a target that share a
845
+ // name share an override. They already share an output folder if both
846
+ // are pointed at one (the claim map refuses that), so a name that
847
+ // means two things is a problem before it reaches here.
848
+ const ov = override[name] || {}
849
+
850
+ // A SHALLOW CLONE, so the override never writes back into the model.
851
+ // The in-tree pass still reads it, and `target add` still round-trips
852
+ // it to disk; an override is for THIS RUN. Nothing compares these by
853
+ // identity — withoutExternal keys by kind:name.
854
+ const output = { ...(items[name]?.output || {}) }
855
+
856
+ if (null != ov.path && '' !== ov.path) {
857
+ output.path = ov.path
858
+ }
859
+ if (null != ov.sdkrel && '' !== ov.sdkrel) {
860
+ output.sdkrel = ov.sdkrel
861
+ }
862
+
863
+ return { kind, name, target: { ...items[name], output }, ov }
864
+ })
865
+ // AFTER the override, so it can send an item out of tree that the
866
+ // model generates in tree. The machinery needs nothing else for that:
867
+ // withoutExternal takes it out of the in-tree pass by kind:name, and
868
+ // every destination guard below runs on it either way.
742
869
  .filter((t: any) => {
743
- const path = t.target?.output?.path
870
+ const path = t.target.output.path
744
871
  return null != path && '' !== path
745
872
  })
746
873
  .map((t: any) => ({
747
- ...t,
874
+ kind: t.kind,
875
+ name: t.name,
876
+ target: t.target,
748
877
  folder: Path.resolve(folder, String(t.target.output.path)),
878
+ enclosing: true === t.ov.enclosing,
749
879
  active: false !== t.target.active,
750
880
  }))
751
881
  })
@@ -848,11 +978,31 @@ function checkExternalFolders(external: ExternalPlan[], root: string, fs: any) {
848
978
  ext.name + '/.')
849
979
  }
850
980
 
851
- if (folderContains(ext.folder, root)) {
981
+ // A DESTINATION THAT CONTAINS THE PROJECT is what `..` produces, and it
982
+ // is refused — UNLESS the caller said at generate time that it meant it.
983
+ //
984
+ // The legitimate case is a repository that carries a tagged checkout of
985
+ // its SDK in a subfolder and regenerates itself from it: the SDK then
986
+ // sits INSIDE its own output folder, and `output: path` resolves to an
987
+ // ancestor. Generation writes the files its components declare and
988
+ // prunes nothing, so the checkout survives its own run.
989
+ //
990
+ // `enclosing` is a separate flag from `path` on purpose, and only the
991
+ // override can set it — never the model. Overriding a path is one
992
+ // decision; writing over the directory holding the project is a second,
993
+ // much worse thing to get wrong, and it fails silently: a typo'd `..`
994
+ // fabricates a package tree over an unrelated repo, overwriting its
995
+ // package.json, README, LICENSE and CI in place. Saying it twice is the
996
+ // cost of keeping the typo caught for everyone who did not ask.
997
+ if (folderContains(ext.folder, root) && !ext.enclosing) {
852
998
  throw new SdkGenError(
853
999
  'External output path contains the SDK project.\n ' + where +
854
1000
  '\n Generation would write this package over the directory holding ' +
855
- 'the SDK project itself.')
1001
+ 'the SDK project itself.' +
1002
+ '\n If that is deliberate — the SDK is checked out INSIDE its own ' +
1003
+ 'output folder, and regenerates it — say so at generate time with ' +
1004
+ '`enclosing: true` in the ' + ext.name + ' entry of SDKGEN_EXTERNAL ' +
1005
+ 'or the `external` build option. It cannot be set in the model.')
856
1006
  }
857
1007
 
858
1008
  // ACROSS KINDS, not within one: `docs` and `target` are separate
@@ -876,6 +1026,20 @@ function checkExternalFolders(external: ExternalPlan[], root: string, fs: any) {
876
1026
 
877
1027
  if (true === ext.target.output.adopt) continue
878
1028
 
1029
+ // AN ENCLOSING DESTINATION ALWAYS HOLDS CONTENT: the SDK project itself
1030
+ // is inside it, along with whatever else the repository carries. The
1031
+ // emptiness check can therefore only ever say "yes, it has content", so
1032
+ // it carries no information here — and requiring `output: adopt` on top
1033
+ // of the opt-in would put the layout back in the SDK's COMMITTED model,
1034
+ // which is the coupling the generate-time override exists to break. An
1035
+ // SDK cloned into a repository it regenerates cannot have anticipated
1036
+ // being cloned there.
1037
+ //
1038
+ // The containment check above is what guards this case, and it is
1039
+ // stricter: it refuses unless the caller named this item and said
1040
+ // `enclosing` for it.
1041
+ if (ext.enclosing) continue
1042
+
879
1043
  const entries: string[] = fs.readdirSync(ext.folder)
880
1044
  .map((entry: any) => String(entry))
881
1045
 
@@ -936,6 +1100,17 @@ function externalSdkRel(ext: ExternalSpec, root: string, log: any): string {
936
1100
  // earlier one is a directory ABOVE it, which nothing in the model declares.
937
1101
  const named = derived.split('/').filter((seg) => '..' !== seg)
938
1102
 
1103
+ // NOT WHEN THE OUTPUT ENCLOSES THE PROJECT. There the walk back descends
1104
+ // rather than ascends — `.sdksrc/acme-sdk` names the subfolder holding the
1105
+ // checkout and then the checkout, both INSIDE the output folder and both
1106
+ // chosen by whoever asked for this layout. Nothing is above anything, so
1107
+ // the warning's complaint (directories the model does not declare) is
1108
+ // false, and its advice (declare `output: sdkrel`) would put one layout's
1109
+ // path into the other's committed model.
1110
+ if (ext.enclosing) {
1111
+ return derived
1112
+ }
1113
+
939
1114
  if (1 < named.length) {
940
1115
  log.warn({
941
1116
  point: 'external-sdkrel-derived', target: ext.name, sdkrel: derived,