@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.
- package/bin/voxgig-sdkgen +1 -1
- package/dist/cmp/PublishWorkflow.js +136 -7
- package/dist/cmp/PublishWorkflow.js.map +1 -1
- package/dist/sdkgen.d.ts +6 -0
- package/dist/sdkgen.js +131 -7
- 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 +142 -7
- package/src/sdkgen.ts +181 -6
|
@@ -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
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
|
330
|
-
|
|
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) =>
|
|
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
|
|
870
|
+
const path = t.target.output.path
|
|
744
871
|
return null != path && '' !== path
|
|
745
872
|
})
|
|
746
873
|
.map((t: any) => ({
|
|
747
|
-
|
|
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
|
-
|
|
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,
|