@smoothbricks/cli 0.11.15 → 0.11.17
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/README.md +43 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +44 -0
- package/dist/github-ci/index.d.ts +1 -1
- package/dist/github-ci/index.d.ts.map +1 -1
- package/dist/github-ci/index.js +7 -10
- package/dist/lib/json.d.ts +9 -0
- package/dist/lib/json.d.ts.map +1 -1
- package/dist/lib/json.js +65 -53
- package/dist/lib/secret-names.d.ts +35 -0
- package/dist/lib/secret-names.d.ts.map +1 -0
- package/dist/lib/secret-names.js +61 -0
- package/dist/monorepo/ci-workflow.d.ts.map +1 -1
- package/dist/monorepo/ci-workflow.js +15 -6
- package/dist/monorepo/managed-files.d.ts +9 -0
- package/dist/monorepo/managed-files.d.ts.map +1 -1
- package/dist/monorepo/managed-files.js +28 -2
- package/dist/monorepo/publish-workflow.d.ts +10 -1
- package/dist/monorepo/publish-workflow.d.ts.map +1 -1
- package/dist/monorepo/publish-workflow.js +110 -22
- package/dist/release/index.d.ts +1 -1
- package/dist/release/index.d.ts.map +1 -1
- package/dist/release/index.js +26 -4
- package/dist/release/private-npm.d.ts +5 -0
- package/dist/release/private-npm.d.ts.map +1 -1
- package/dist/release/private-npm.js +26 -5
- package/dist/secrets/commands.d.ts +45 -0
- package/dist/secrets/commands.d.ts.map +1 -0
- package/dist/secrets/commands.js +189 -0
- package/dist/secrets/index.d.ts +83 -0
- package/dist/secrets/index.d.ts.map +1 -0
- package/dist/secrets/index.js +149 -0
- package/dist/wrangler/deploy-stage.d.ts +13 -2
- package/dist/wrangler/deploy-stage.d.ts.map +1 -1
- package/dist/wrangler/deploy-stage.js +39 -54
- package/dist/wrangler/deployed-version.d.ts +44 -0
- package/dist/wrangler/deployed-version.d.ts.map +1 -0
- package/dist/wrangler/deployed-version.js +87 -0
- package/dist/wrangler/live-version.d.ts +146 -0
- package/dist/wrangler/live-version.d.ts.map +1 -0
- package/dist/wrangler/live-version.js +326 -0
- package/managed/raw/tooling/direnv/devenv.smoo.nix +4 -2
- package/managed/raw/tooling/direnv/github-actions-bootstrap.sh +4 -1
- package/managed/raw/tooling/direnv/toolchain-stamp.ts +88 -0
- package/managed/templates/github/actions/setup-devenv/action.yml +1 -1
- package/package.json +2 -2
- package/src/cli.ts +46 -1
- package/src/github-ci/index.test.ts +92 -8
- package/src/github-ci/index.ts +7 -10
- package/src/lib/json.ts +9 -0
- package/src/lib/secret-names.ts +70 -0
- package/src/monorepo/__tests__/ci-workflow.test.ts +27 -2
- package/src/monorepo/__tests__/publish-workflow.test.ts +85 -13
- package/src/monorepo/ci-workflow.ts +17 -6
- package/src/monorepo/managed-files.test.ts +26 -0
- package/src/monorepo/managed-files.ts +36 -2
- package/src/monorepo/publish-workflow.ts +135 -21
- package/src/monorepo/secret-references.test.ts +1 -1
- package/src/release/__tests__/private-npm-status.test.ts +37 -0
- package/src/release/__tests__/private-npm-workflow.test.ts +20 -0
- package/src/release/index.ts +27 -2
- package/src/release/private-npm.ts +30 -5
- package/src/secrets/commands.ts +210 -0
- package/src/secrets/index.test.ts +92 -0
- package/src/secrets/index.ts +183 -0
- package/src/wrangler/deploy-stage.test.ts +190 -12
- package/src/wrangler/deploy-stage.ts +72 -45
- package/src/wrangler/deployed-version.test.ts +150 -0
- package/src/wrangler/deployed-version.ts +130 -0
- package/src/wrangler/live-version.ts +363 -0
|
@@ -170,6 +170,8 @@ export interface ManagedFileContext {
|
|
|
170
170
|
nodeModulesCacheKey: string;
|
|
171
171
|
repoName: string;
|
|
172
172
|
platformTargetGlobs: string[];
|
|
173
|
+
/** Platform families the release workflow produces; excluded families are absent. */
|
|
174
|
+
releasePlatformTargetGlobs: string[];
|
|
173
175
|
macosPlatformArchitectures: string[];
|
|
174
176
|
/** Declared private-npm opt-in from the root smoo config; absent means fully public. */
|
|
175
177
|
privateNpm?: PackagePrivateNpmConfig;
|
|
@@ -221,6 +223,12 @@ const managedFiles: ManagedFile[] = [
|
|
|
221
223
|
source: 'tooling/direnv/secret-references.ts',
|
|
222
224
|
target: 'tooling/direnv/secret-references.ts',
|
|
223
225
|
},
|
|
226
|
+
{
|
|
227
|
+
kind: 'raw',
|
|
228
|
+
source: 'tooling/direnv/toolchain-stamp.ts',
|
|
229
|
+
target: 'tooling/direnv/toolchain-stamp.ts',
|
|
230
|
+
executable: true,
|
|
231
|
+
},
|
|
224
232
|
{
|
|
225
233
|
kind: 'raw',
|
|
226
234
|
source: 'tooling/direnv/devenv.smoo.nix',
|
|
@@ -409,7 +417,7 @@ function getManagedContent(file: ManagedFile, context: ManagedFileContext): stri
|
|
|
409
417
|
release: context.hasReleasePackages,
|
|
410
418
|
deployProvider: context.productionDeployProvider,
|
|
411
419
|
repoName: context.repoName,
|
|
412
|
-
platformTargetGlobs: context.
|
|
420
|
+
platformTargetGlobs: context.releasePlatformTargetGlobs,
|
|
413
421
|
macosPlatformArchitectures: context.macosPlatformArchitectures,
|
|
414
422
|
runsOn: context.ciRunsOn,
|
|
415
423
|
macosRunsOn: context.macosRunsOn,
|
|
@@ -451,6 +459,14 @@ async function getManagedFileContext(root: string): Promise<ManagedFileContext>
|
|
|
451
459
|
const productionDeploy = deployTargetInfoFromProjects(nxProjects, 'production');
|
|
452
460
|
const targetNames = targetNamesFromProjects(nxProjects);
|
|
453
461
|
const platformTargetGlobs = platformTargetGlobsForTest(targetNames);
|
|
462
|
+
// The release workflow produces every platform family the graph carries
|
|
463
|
+
// EXCEPT the ones the repository excludes. An excluded family renders no job
|
|
464
|
+
// at all rather than a job whose failure blocks every release; the workflow
|
|
465
|
+
// that does own it says so itself.
|
|
466
|
+
const releasePlatformTargetGlobs = releasePlatformTargetGlobsFor(
|
|
467
|
+
platformTargetGlobs,
|
|
468
|
+
github?.releasePlatformFamiliesExcluded,
|
|
469
|
+
);
|
|
454
470
|
const privateNpm = resolvePrivateNpmWorkflowConfig(root);
|
|
455
471
|
// Cache registry identity plus the lockfile, never token values: a scope
|
|
456
472
|
// URL change in the committed .npmrc must invalidate the dependency cache,
|
|
@@ -479,10 +495,13 @@ async function getManagedFileContext(root: string): Promise<ManagedFileContext>
|
|
|
479
495
|
nodeModulesCacheKey,
|
|
480
496
|
repoName,
|
|
481
497
|
platformTargetGlobs,
|
|
498
|
+
releasePlatformTargetGlobs,
|
|
482
499
|
sourceCheckouts,
|
|
483
500
|
cargoCredentials,
|
|
484
501
|
remoteCache: manifest?.smoo?.remoteCache,
|
|
485
|
-
macosPlatformArchitectures:
|
|
502
|
+
macosPlatformArchitectures: MACOS_PLATFORM_TARGET_GLOBS.some((glob) => releasePlatformTargetGlobs.includes(glob))
|
|
503
|
+
? macosPlatformArchitecturesForTest(targetNames)
|
|
504
|
+
: [],
|
|
486
505
|
privateNpm,
|
|
487
506
|
};
|
|
488
507
|
}
|
|
@@ -491,6 +510,21 @@ export function hasExactTargetForTest(targetNames: Iterable<string>, target: str
|
|
|
491
510
|
return [...targetNames].includes(target);
|
|
492
511
|
}
|
|
493
512
|
|
|
513
|
+
/**
|
|
514
|
+
* The platform families the release workflow produces: the graph's families
|
|
515
|
+
* minus the ones the repository excludes. Excluding a family the graph does not
|
|
516
|
+
* have is not an error — a repository states what it does not release, and the
|
|
517
|
+
* statement stays true when the target is added later.
|
|
518
|
+
*/
|
|
519
|
+
export function releasePlatformTargetGlobsFor(
|
|
520
|
+
platformTargetGlobs: readonly string[],
|
|
521
|
+
excluded: readonly string[] | undefined,
|
|
522
|
+
): string[] {
|
|
523
|
+
if (!excluded || excluded.length === 0) return [...platformTargetGlobs];
|
|
524
|
+
const drop = new Set(excluded);
|
|
525
|
+
return platformTargetGlobs.filter((glob) => !drop.has(glob));
|
|
526
|
+
}
|
|
527
|
+
|
|
494
528
|
export function platformTargetGlobsForTest(targetNames: Iterable<string>): string[] {
|
|
495
529
|
const names = [...targetNames];
|
|
496
530
|
return PLATFORM_TARGET_GLOBS.filter((glob) => {
|
|
@@ -49,6 +49,7 @@ const synchronizedPrettier = makeModuleSynchronized<typeof PrettierModule>(impor
|
|
|
49
49
|
|
|
50
50
|
export type PublishWorkflowBump = 'auto' | 'patch' | 'minor' | 'major' | 'prerelease';
|
|
51
51
|
export type PublishWorkflowCondition =
|
|
52
|
+
| 'plan-mode-not-none'
|
|
52
53
|
| 'version-mode-not-none'
|
|
53
54
|
| 'deploy-production'
|
|
54
55
|
| 'deploy-production-standalone'
|
|
@@ -65,6 +66,7 @@ export enum PublishWorkflowStepKind {
|
|
|
65
66
|
ConfigureReleaseAuthor = 'configure-release-author',
|
|
66
67
|
BuildNxVersionActions = 'build-nx-version-actions',
|
|
67
68
|
RepairPendingReleases = 'repair-pending-releases',
|
|
69
|
+
PlanRelease = 'plan-release',
|
|
68
70
|
VersionRelease = 'version-release',
|
|
69
71
|
CheckManagedMonorepoFiles = 'check-managed-monorepo-files',
|
|
70
72
|
Build = 'build',
|
|
@@ -159,6 +161,12 @@ export interface PublishWorkflowCallbacks {
|
|
|
159
161
|
buildNxVersionActions(): Promise<void>;
|
|
160
162
|
repairPendingReleases(input: { dryRun: boolean }): Promise<void>;
|
|
161
163
|
versionRelease(input: { bump: PublishWorkflowBump; dryRun: boolean }): Promise<PublishWorkflowVersionOutputs>;
|
|
164
|
+
/**
|
|
165
|
+
* The selection the bump WILL make, computed without writing anything, so the
|
|
166
|
+
* gates that run before the version commit know what to run over. Defaults to
|
|
167
|
+
* a dry-run `versionRelease` when a caller supplies none.
|
|
168
|
+
*/
|
|
169
|
+
planRelease?(input: { bump: PublishWorkflowBump }): Promise<PublishWorkflowVersionOutputs>;
|
|
162
170
|
checkManagedMonorepoFiles(): Promise<void>;
|
|
163
171
|
nxRunMany(input: { target: PublishWorkflowNxTarget; projects: string[] }): Promise<void>;
|
|
164
172
|
uploadTraceDbs(): Promise<void>;
|
|
@@ -183,6 +191,9 @@ type PublishWorkflowStepInput = Omit<PublishWorkflowStep, 'number'>;
|
|
|
183
191
|
|
|
184
192
|
export function definePublishWorkflow(options: PublishWorkflowDefinitionOptions = {}): PublishWorkflowDefinition {
|
|
185
193
|
const versionMode = githubExpression('steps.version.outputs.mode');
|
|
194
|
+
// Steps that run before the bump must label themselves from the plan: the
|
|
195
|
+
// version step's outputs do not exist yet, so the interpolation renders empty.
|
|
196
|
+
const planMode = githubExpression('steps.plan.outputs.mode');
|
|
186
197
|
if (options.release === false) {
|
|
187
198
|
return { steps: defineDeployOnlyWorkflowSteps(options) };
|
|
188
199
|
}
|
|
@@ -224,30 +235,37 @@ export function definePublishWorkflow(options: PublishWorkflowDefinitionOptions
|
|
|
224
235
|
kind: PublishWorkflowStepKind.RepairPendingReleases,
|
|
225
236
|
name: '🧯 Repair pending releases',
|
|
226
237
|
},
|
|
227
|
-
|
|
238
|
+
// Plan first, gate second, bump third. Lint and test hash the source, so
|
|
239
|
+
// running them BEFORE the version commit means their task hashes match
|
|
240
|
+
// the ones ci already computed for the same source — the publish reuses
|
|
241
|
+
// that cache instead of re-running 189 tasks. Build stays after the bump
|
|
242
|
+
// on purpose: an artifact built from pre-bump sources would ship the
|
|
243
|
+
// previous version string.
|
|
244
|
+
{ kind: PublishWorkflowStepKind.PlanRelease, name: '🧭 Plan release', id: 'plan' },
|
|
228
245
|
{
|
|
229
246
|
kind: PublishWorkflowStepKind.CheckManagedMonorepoFiles,
|
|
230
|
-
name: `✅ Check managed monorepo files (${
|
|
231
|
-
condition: '
|
|
232
|
-
},
|
|
233
|
-
{
|
|
234
|
-
kind: PublishWorkflowStepKind.Build,
|
|
235
|
-
name: `🔨 Build (${versionMode})`,
|
|
236
|
-
condition: 'version-mode-not-none',
|
|
237
|
-
nxTarget: 'build',
|
|
247
|
+
name: `✅ Check managed monorepo files (${planMode})`,
|
|
248
|
+
condition: 'plan-mode-not-none',
|
|
238
249
|
},
|
|
239
250
|
{
|
|
240
251
|
kind: PublishWorkflowStepKind.Lint,
|
|
241
|
-
name: `🔍 Lint (${
|
|
242
|
-
condition: '
|
|
252
|
+
name: `🔍 Lint (${planMode})`,
|
|
253
|
+
condition: 'plan-mode-not-none',
|
|
243
254
|
nxTarget: 'lint',
|
|
244
255
|
},
|
|
245
256
|
{
|
|
246
257
|
kind: PublishWorkflowStepKind.UnitTests,
|
|
247
|
-
name: `🧪 Unit Tests (${
|
|
248
|
-
condition: '
|
|
258
|
+
name: `🧪 Unit Tests (${planMode})`,
|
|
259
|
+
condition: 'plan-mode-not-none',
|
|
249
260
|
nxTarget: 'test',
|
|
250
261
|
},
|
|
262
|
+
{ kind: PublishWorkflowStepKind.VersionRelease, name: '🔢 Version release', id: 'version' },
|
|
263
|
+
{
|
|
264
|
+
kind: PublishWorkflowStepKind.Build,
|
|
265
|
+
name: `🔨 Build (${versionMode})`,
|
|
266
|
+
condition: 'version-mode-not-none',
|
|
267
|
+
nxTarget: 'build',
|
|
268
|
+
},
|
|
251
269
|
{ kind: PublishWorkflowStepKind.UploadTraceDbs, name: '📎 Upload trace DBs', condition: 'failure' },
|
|
252
270
|
{
|
|
253
271
|
kind: PublishWorkflowStepKind.ValidateMonorepoConfig,
|
|
@@ -302,10 +320,11 @@ export async function runPublishWorkflow(
|
|
|
302
320
|
): Promise<PublishWorkflowRunResult> {
|
|
303
321
|
let setupOutputs: PublishWorkflowSetupOutputs = { nixCacheHit: '', devenvCacheHit: '' };
|
|
304
322
|
let version: PublishWorkflowVersionOutputs = { mode: 'none', projects: [] };
|
|
323
|
+
let plan: PublishWorkflowVersionOutputs = { mode: 'none', projects: [] };
|
|
305
324
|
let failed = false;
|
|
306
325
|
let failure: unknown;
|
|
307
326
|
for (const step of workflow.steps) {
|
|
308
|
-
if (!shouldRunStep(step, version, failed, context.inputs)) {
|
|
327
|
+
if (!shouldRunStep(step, version, failed, context.inputs, plan)) {
|
|
309
328
|
continue;
|
|
310
329
|
}
|
|
311
330
|
try {
|
|
@@ -331,6 +350,11 @@ export async function runPublishWorkflow(
|
|
|
331
350
|
dryRun: context.inputs.dryRun,
|
|
332
351
|
});
|
|
333
352
|
break;
|
|
353
|
+
case PublishWorkflowStepKind.PlanRelease:
|
|
354
|
+
plan = context.callbacks.planRelease
|
|
355
|
+
? await context.callbacks.planRelease({ bump: context.inputs.bump })
|
|
356
|
+
: await context.callbacks.versionRelease({ bump: context.inputs.bump, dryRun: true });
|
|
357
|
+
break;
|
|
334
358
|
case PublishWorkflowStepKind.VersionRelease:
|
|
335
359
|
version = await context.callbacks.versionRelease(context.inputs);
|
|
336
360
|
break;
|
|
@@ -343,7 +367,12 @@ export async function runPublishWorkflow(
|
|
|
343
367
|
if (!step.nxTarget) {
|
|
344
368
|
throw new Error(`Workflow step ${step.kind} is missing an Nx target.`);
|
|
345
369
|
}
|
|
346
|
-
|
|
370
|
+
// Lint and test run before the bump exists, so they select from the
|
|
371
|
+
// plan; build runs after it and selects from the written version.
|
|
372
|
+
await context.callbacks.nxRunMany({
|
|
373
|
+
target: step.nxTarget,
|
|
374
|
+
projects: step.kind === PublishWorkflowStepKind.Build ? version.projects : plan.projects,
|
|
375
|
+
});
|
|
347
376
|
break;
|
|
348
377
|
case PublishWorkflowStepKind.UploadTraceDbs:
|
|
349
378
|
await context.callbacks.uploadTraceDbs();
|
|
@@ -383,7 +412,11 @@ function shouldRunStep(
|
|
|
383
412
|
version: PublishWorkflowVersionOutputs,
|
|
384
413
|
failed: boolean,
|
|
385
414
|
inputs: PublishWorkflowInputs,
|
|
415
|
+
plan: PublishWorkflowVersionOutputs = version,
|
|
386
416
|
): boolean {
|
|
417
|
+
if (step.condition === 'plan-mode-not-none') {
|
|
418
|
+
return plan.mode !== 'none';
|
|
419
|
+
}
|
|
387
420
|
if (step.condition === 'version-mode-not-none') {
|
|
388
421
|
return version.mode !== 'none';
|
|
389
422
|
}
|
|
@@ -404,8 +437,17 @@ export function renderPublishWorkflowYaml(options: PublishWorkflowDefinitionOpti
|
|
|
404
437
|
if (options.release === false) {
|
|
405
438
|
const steps = definePublishWorkflow(options).steps;
|
|
406
439
|
workflow = `${renderPublishWorkflowHeader(options)}${renderPublishWorkflowSteps(steps, options)}`;
|
|
407
|
-
} else if (hasMacosPlatformTargets(options)) {
|
|
440
|
+
} else if (hasMacosPlatformTargets(options) && !skipsPublishHandoff(options)) {
|
|
408
441
|
workflow = renderPlatformPublishWorkflowYaml(options);
|
|
442
|
+
} else if (hasMacosPlatformTargets(options)) {
|
|
443
|
+
// A linux-cross producer runs the Apple targets on the publisher's own
|
|
444
|
+
// runner. Splitting jobs then buys nothing and costs a real transfer: the
|
|
445
|
+
// outputs get tarred, uploaded, downloaded into a second checkout, and two
|
|
446
|
+
// more devenv setups pay for themselves twice. One job, no artifacts.
|
|
447
|
+
workflow = `${renderPublishWorkflowHeader(options)}${renderSingleJobPublishWorkflowSteps(
|
|
448
|
+
definePublishWorkflow(options).steps,
|
|
449
|
+
options,
|
|
450
|
+
)}`;
|
|
409
451
|
} else if (hasLinuxPlatformTargets(options)) {
|
|
410
452
|
workflow = `${renderPublishWorkflowHeader(options)}${renderSingleJobPublishWorkflowSteps(
|
|
411
453
|
definePublishWorkflow(options).steps,
|
|
@@ -475,7 +517,7 @@ jobs:
|
|
|
475
517
|
publish:
|
|
476
518
|
${options.release === false ? renderRunsOnLine(options.runsOn) : publishJobRunsOnLine(options)}
|
|
477
519
|
env:
|
|
478
|
-
GH_TOKEN: ${githubExpression('github.token')}${remoteCacheJobEnv(options)}${cargoCredentialsJobEnv(options)}${privateNpmInstallJobEnv(options)}
|
|
520
|
+
GH_TOKEN: ${githubExpression('github.token')}${remoteCacheJobEnv(options)}${cargoCredentialsJobEnv(options)}${privateNpmInstallJobEnv(options)}${platformProducerJobEnv(options)}
|
|
479
521
|
steps:
|
|
480
522
|
`;
|
|
481
523
|
}
|
|
@@ -572,6 +614,16 @@ function yamlLinesForStep(step: PublishWorkflowStep, options: PublishWorkflowDef
|
|
|
572
614
|
...privateNpmPublisherStepEnv(options),
|
|
573
615
|
` run: smoo release repair-pending --dry-run "${githubExpression('inputs.dry_run')}"`,
|
|
574
616
|
];
|
|
617
|
+
case PublishWorkflowStepKind.PlanRelease:
|
|
618
|
+
// The same selection the bump will make, computed without writing: the
|
|
619
|
+
// gates need `mode` and `projects` before any version commit exists.
|
|
620
|
+
return [
|
|
621
|
+
` - name: ${step.name}`,
|
|
622
|
+
' id: plan',
|
|
623
|
+
' run:',
|
|
624
|
+
` smoo release version --bump "${githubExpression('inputs.bump')}" --projects "${githubExpression('inputs.projects')}" --dry-run "true" --github-output`,
|
|
625
|
+
' "$GITHUB_OUTPUT"',
|
|
626
|
+
];
|
|
575
627
|
case PublishWorkflowStepKind.VersionRelease:
|
|
576
628
|
return [
|
|
577
629
|
` - name: ${step.name}`,
|
|
@@ -592,12 +644,12 @@ function yamlLinesForStep(step: PublishWorkflowStep, options: PublishWorkflowDef
|
|
|
592
644
|
case PublishWorkflowStepKind.Lint:
|
|
593
645
|
return conditionalRunStep(
|
|
594
646
|
step,
|
|
595
|
-
`smoo github-ci nx-run-many --targets lint --projects "${githubExpression('steps.
|
|
647
|
+
`smoo github-ci nx-run-many --targets lint --projects "${githubExpression('steps.plan.outputs.projects')}"`,
|
|
596
648
|
);
|
|
597
649
|
case PublishWorkflowStepKind.UnitTests:
|
|
598
650
|
return conditionalRunStep(
|
|
599
651
|
step,
|
|
600
|
-
`smoo github-ci nx-run-many --targets test --projects "${githubExpression('steps.
|
|
652
|
+
`smoo github-ci nx-run-many --targets test --projects "${githubExpression('steps.plan.outputs.projects')}"`,
|
|
601
653
|
);
|
|
602
654
|
case PublishWorkflowStepKind.UploadTraceDbs:
|
|
603
655
|
return artifactStepLines(
|
|
@@ -686,8 +738,11 @@ function deployProductionStep(step: PublishWorkflowStep, options: PublishWorkflo
|
|
|
686
738
|
}
|
|
687
739
|
|
|
688
740
|
function conditionalRunStep(step: PublishWorkflowStep, run: string): string[] {
|
|
689
|
-
|
|
690
|
-
|
|
741
|
+
// A step gated on the plan must read the PLAN's mode: it runs before the
|
|
742
|
+
// version step exists, and `steps.version.outputs.mode` is empty there, which
|
|
743
|
+
// silently skips the gate rather than running it.
|
|
744
|
+
const source = step.condition === 'plan-mode-not-none' ? 'plan' : 'version';
|
|
745
|
+
return [` - name: ${step.name}`, ` if: steps.${source}.outputs.mode != 'none'`, ` run: ${run}`];
|
|
691
746
|
}
|
|
692
747
|
|
|
693
748
|
function siblingSourceCheckoutStepLines(options: PublishWorkflowDefinitionOptions): string[] {
|
|
@@ -749,11 +804,25 @@ function renderSingleJobPublishWorkflowSteps(
|
|
|
749
804
|
options: PublishWorkflowDefinitionOptions,
|
|
750
805
|
): string {
|
|
751
806
|
const lines: string[] = [];
|
|
807
|
+
const producer = options.platformProducer;
|
|
808
|
+
const crossOnThisRunner = skipsPublishHandoff(options) && hasMacosPlatformTargets(options);
|
|
752
809
|
for (const step of steps) {
|
|
753
810
|
lines.push(...sectionLinesBefore(step));
|
|
754
811
|
lines.push(...commentLinesForStep(step));
|
|
755
812
|
lines.push(...yamlLinesForStep(step, options));
|
|
756
813
|
lines.push('');
|
|
814
|
+
if (crossOnThisRunner && producer && step.kind === PublishWorkflowStepKind.Checkout) {
|
|
815
|
+
// Before any toolchain setup or build, exactly as the dedicated producer
|
|
816
|
+
// job ordered it: the SDK has to be on disk before devenv resolves it.
|
|
817
|
+
lines.push(
|
|
818
|
+
' - name: Check cross-platform toolchain prerequisites',
|
|
819
|
+
' working-directory: .',
|
|
820
|
+
' run: |',
|
|
821
|
+
' set -euo pipefail',
|
|
822
|
+
...producer.preflight.split('\n').map((line) => ` ${line}`),
|
|
823
|
+
'',
|
|
824
|
+
);
|
|
825
|
+
}
|
|
757
826
|
if (step.kind === PublishWorkflowStepKind.Build) {
|
|
758
827
|
lines.push(
|
|
759
828
|
' - name: 🐧 Build supplemental Linux targets',
|
|
@@ -763,6 +832,16 @@ function renderSingleJobPublishWorkflowSteps(
|
|
|
763
832
|
)}"`,
|
|
764
833
|
'',
|
|
765
834
|
);
|
|
835
|
+
if (crossOnThisRunner) {
|
|
836
|
+
lines.push(
|
|
837
|
+
' - name: 🍎 Build selected macOS and iOS release outputs',
|
|
838
|
+
" if: steps.version.outputs.mode != 'none'",
|
|
839
|
+
` run: smoo github-ci nx-run-many --targets "${macosPlatformFamilies(options).join(',')}" --projects "${githubExpression(
|
|
840
|
+
'steps.version.outputs.projects',
|
|
841
|
+
)}"`,
|
|
842
|
+
'',
|
|
843
|
+
);
|
|
844
|
+
}
|
|
766
845
|
}
|
|
767
846
|
}
|
|
768
847
|
return `${lines.join('\n').trimEnd()}\n`;
|
|
@@ -1423,6 +1502,41 @@ function githubExpression(expression: string): string {
|
|
|
1423
1502
|
return ['$', '{{ ', expression, ' }}'].join('');
|
|
1424
1503
|
}
|
|
1425
1504
|
|
|
1505
|
+
/**
|
|
1506
|
+
* Whether the separate publishing job can be dropped, so the release runs as
|
|
1507
|
+
* one job that builds every artifact and publishes them.
|
|
1508
|
+
*
|
|
1509
|
+
* The hand-off exists for ONE reason: npmjs mints provenance from the
|
|
1510
|
+
* publishing job's OIDC token and refuses a self-hosted runner for it, so a
|
|
1511
|
+
* release with public packages must publish from the GitHub-hosted runner and
|
|
1512
|
+
* therefore has to receive its self-hosted artifacts through an artifact.
|
|
1513
|
+
* A release that publishes only to the declared private registry has no such
|
|
1514
|
+
* constraint; when its Apple targets are cross-built on the same runner (or it
|
|
1515
|
+
* has none), one job holds everything.
|
|
1516
|
+
*/
|
|
1517
|
+
function skipsPublishHandoff(options: PublishWorkflowDefinitionOptions): boolean {
|
|
1518
|
+
if (options.actionsProvider !== 'forgejo' || options.privateNpm === undefined) {
|
|
1519
|
+
return false;
|
|
1520
|
+
}
|
|
1521
|
+
return !hasMacosPlatformTargets(options) || options.platformProducer?.kind === 'linux-cross';
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
/**
|
|
1525
|
+
* Toolchain environment for a foreign-platform producer, rendered onto whichever
|
|
1526
|
+
* job runs its builds. In the flattened single-job shape that is the publish job
|
|
1527
|
+
* itself, so the SDK path and cross flags have to reach it the same way the
|
|
1528
|
+
* dedicated producer job received them.
|
|
1529
|
+
*/
|
|
1530
|
+
function platformProducerJobEnv(options: PublishWorkflowDefinitionOptions): string {
|
|
1531
|
+
const producer = options.platformProducer;
|
|
1532
|
+
if (!producer || !skipsPublishHandoff(options)) {
|
|
1533
|
+
return '';
|
|
1534
|
+
}
|
|
1535
|
+
return Object.entries(producer.env ?? {})
|
|
1536
|
+
.map(([name, value]) => `\n ${name}: ${JSON.stringify(value)}`)
|
|
1537
|
+
.join('');
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1426
1540
|
function privateNpmInstallJobEnv(options: PublishWorkflowDefinitionOptions): string {
|
|
1427
1541
|
const tokenEnv = options.privateNpm?.readTokenEnv;
|
|
1428
1542
|
if (!tokenEnv) {
|
|
@@ -206,7 +206,7 @@ describe('registryAuthEnvNames', () => {
|
|
|
206
206
|
|
|
207
207
|
it('collects ${VAR} references from .npmrc text', async () => {
|
|
208
208
|
expect(
|
|
209
|
-
await names('@
|
|
209
|
+
await names('@acme:registry=https://npm.example.net\n//npm.example.net/:_authToken=${SMOO_READ_TOKEN}\n'),
|
|
210
210
|
).toEqual(['SMOO_READ_TOKEN']);
|
|
211
211
|
});
|
|
212
212
|
|
|
@@ -433,6 +433,43 @@ describe('private npm published-version status', () => {
|
|
|
433
433
|
});
|
|
434
434
|
});
|
|
435
435
|
|
|
436
|
+
it('reads through a repository .npmrc whose own auth line names an unset env', async () => {
|
|
437
|
+
// The shape that cost a day: a repository commits
|
|
438
|
+
// `//host/path:_authToken=${NPM_PUBLISH_TOKEN}` at the workspace root.
|
|
439
|
+
// npm ranks that project file ABOVE the userconfig this CLI writes, so with
|
|
440
|
+
// the publish env unset it sent the unexpanded value and the registry
|
|
441
|
+
// answered 401 — with a perfectly good read credential in hand.
|
|
442
|
+
await withStatusFixture(async ({ fixture, root, userconfig }) => {
|
|
443
|
+
await fixture.privateRegistry.publishPackage({ name: PRIVATE_PACKAGE, version: PRIVATE_VERSION });
|
|
444
|
+
const resolved = loopbackRegistry(fixture.privateRegistry);
|
|
445
|
+
await writeFile(
|
|
446
|
+
join(root, '.npmrc'),
|
|
447
|
+
`${resolved.scope}:registry=${resolved.registry}\n${resolved.authKey}=\${NOT_SET_ANYWHERE}\n`,
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
// Control: with the userconfig alone the project file wins, so the token
|
|
451
|
+
// npm sends is the unexpanded reference — the registry sees no usable
|
|
452
|
+
// credential. Asserted on the wire, because a fixture that answers
|
|
453
|
+
// anonymous reads would let the server's verdict hide it.
|
|
454
|
+
await npmPublishedVersionExists(root, PRIVATE_PACKAGE, PRIVATE_VERSION, {
|
|
455
|
+
registry: fixture.privateRegistry.registry,
|
|
456
|
+
userconfig,
|
|
457
|
+
});
|
|
458
|
+
const overridden = fixture.privateRegistry.requestsFor('probe').at(-1);
|
|
459
|
+
expect(overridden?.authorization ?? '').not.toContain(FIXTURE_READ_TOKEN);
|
|
460
|
+
|
|
461
|
+
await expect(
|
|
462
|
+
npmPublishedVersionExists(root, PRIVATE_PACKAGE, PRIVATE_VERSION, {
|
|
463
|
+
registry: fixture.privateRegistry.registry,
|
|
464
|
+
userconfig,
|
|
465
|
+
credential: { authKey: resolved.authKey, tokenEnv: FIXTURE_READ_TOKEN_ENV },
|
|
466
|
+
}),
|
|
467
|
+
).resolves.toBe(true);
|
|
468
|
+
const authenticated = fixture.privateRegistry.requestsFor('probe').at(-1);
|
|
469
|
+
expect(authenticated?.authorization ?? '').toContain(FIXTURE_READ_TOKEN);
|
|
470
|
+
});
|
|
471
|
+
});
|
|
472
|
+
|
|
436
473
|
it('reports a genuine not-found as absent', async () => {
|
|
437
474
|
await withStatusFixture(async ({ fixture, root, userconfig }) => {
|
|
438
475
|
fixture.privateRegistry.failWith(404);
|
|
@@ -86,17 +86,37 @@ describe('private npm workflow token selection', () => {
|
|
|
86
86
|
});
|
|
87
87
|
|
|
88
88
|
it('ignores workspace and link specs so a producer install does not look like a registry consume', async () => {
|
|
89
|
+
// Without a declared read token, the .npmrc credential is a publish
|
|
90
|
+
// credential and must never be promoted into the read role by inference.
|
|
89
91
|
await withRepo(
|
|
90
92
|
{
|
|
91
93
|
privatePackage: `${SCOPE}/sdk`,
|
|
92
94
|
rootDeps: { [`${SCOPE}/sdk`]: 'workspace:*' },
|
|
93
95
|
extraDeps: { [`${SCOPE}/other`]: 'link:@priv.test/other' },
|
|
94
96
|
npmrcAuthEnv: PUBLISH_ENV,
|
|
97
|
+
declared: { scope: SCOPE, publishTokenEnv: PUBLISH_ENV },
|
|
98
|
+
},
|
|
99
|
+
(root) => {
|
|
100
|
+
expect(resolvePrivateNpmWorkflowConfig(root)).toEqual({
|
|
101
|
+
scope: SCOPE,
|
|
102
|
+
publishTokenEnv: PUBLISH_ENV,
|
|
103
|
+
});
|
|
104
|
+
},
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('renders a declared read token for a producer, whose later releases read the previous tag state', async () => {
|
|
109
|
+
await withRepo(
|
|
110
|
+
{
|
|
111
|
+
privatePackage: `${SCOPE}/sdk`,
|
|
112
|
+
rootDeps: { [`${SCOPE}/sdk`]: 'workspace:*' },
|
|
113
|
+
npmrcAuthEnv: PUBLISH_ENV,
|
|
95
114
|
declared: { scope: SCOPE, readTokenEnv: READ_ENV, publishTokenEnv: PUBLISH_ENV },
|
|
96
115
|
},
|
|
97
116
|
(root) => {
|
|
98
117
|
expect(resolvePrivateNpmWorkflowConfig(root)).toEqual({
|
|
99
118
|
scope: SCOPE,
|
|
119
|
+
readTokenEnv: READ_ENV,
|
|
100
120
|
publishTokenEnv: PUBLISH_ENV,
|
|
101
121
|
});
|
|
102
122
|
},
|
package/src/release/index.ts
CHANGED
|
@@ -48,6 +48,7 @@ export {
|
|
|
48
48
|
type PrivateNpmRegistryConfigErrorKind,
|
|
49
49
|
type PublishDestination,
|
|
50
50
|
privateNpmPublishArgs,
|
|
51
|
+
privateNpmTokenEnvForMode,
|
|
51
52
|
privateNpmUserconfigContent,
|
|
52
53
|
publishPrivateWithDiagnostics,
|
|
53
54
|
type Result as PrivateNpmResult,
|
|
@@ -58,6 +59,7 @@ export {
|
|
|
58
59
|
withPrivateNpmUserconfig,
|
|
59
60
|
} from './private-npm.js';
|
|
60
61
|
|
|
62
|
+
import { PLATFORM_TARGET_GLOBS } from '@smoothbricks/nx-plugin/workspace-config-policy';
|
|
61
63
|
import { ciApiContext } from '../github-ci/api.js';
|
|
62
64
|
import { readProjectTargets } from '../nx/index.js';
|
|
63
65
|
import {
|
|
@@ -114,6 +116,7 @@ import {
|
|
|
114
116
|
npmPublishedVersionExists as npmPublishedVersionExistsOnRegistry,
|
|
115
117
|
type PrivateNpmRegistry,
|
|
116
118
|
privateNpmPublishArgs,
|
|
119
|
+
privateNpmTokenEnvForMode,
|
|
117
120
|
publishPrivateWithDiagnostics,
|
|
118
121
|
selectPublishDestination,
|
|
119
122
|
selectRegistryForPackage,
|
|
@@ -754,7 +757,13 @@ async function publishPrivatePackedPackage(
|
|
|
754
757
|
await publishPrivateWithDiagnostics(
|
|
755
758
|
pkg,
|
|
756
759
|
{
|
|
757
|
-
publish: () =>
|
|
760
|
+
publish: () =>
|
|
761
|
+
runNpm(root, args, {
|
|
762
|
+
NPM_CONFIG_USERCONFIG: userconfig,
|
|
763
|
+
// Env config outranks a committed project `.npmrc` auth line; see
|
|
764
|
+
// npmStatusEnv in private-npm.ts for why that matters here.
|
|
765
|
+
[`npm_config_${registry.authKey}`]: process.env[privateNpmTokenEnvForMode(registry, 'publish')] ?? '',
|
|
766
|
+
}),
|
|
758
767
|
// The open publish userconfig already authenticates this registry,
|
|
759
768
|
// so the failure probe reuses it. Opening a second read-mode
|
|
760
769
|
// userconfig here demanded a read credential the publishing job need
|
|
@@ -764,6 +773,7 @@ async function publishPrivatePackedPackage(
|
|
|
764
773
|
npmPublishedVersionExistsOnRegistry(root, pkg.name, pkg.version, {
|
|
765
774
|
registry: registry.registry,
|
|
766
775
|
userconfig,
|
|
776
|
+
credential: { authKey: registry.authKey, tokenEnv: privateNpmTokenEnvForMode(registry, 'publish') },
|
|
767
777
|
}),
|
|
768
778
|
log: (message) => console.log(message),
|
|
769
779
|
error: (message) => console.error(message),
|
|
@@ -1197,6 +1207,17 @@ function releaseRepairShell(root: string, platformOutputs: readonly string[]): R
|
|
|
1197
1207
|
const sourceSha = await gitHead(root);
|
|
1198
1208
|
const outputs = platformOutputs.map((base) => join(base, sourceSha));
|
|
1199
1209
|
if (outputs.length === 0) {
|
|
1210
|
+
// No producer job handed outputs over, so this runner is the producer:
|
|
1211
|
+
// build the platform legs at the checked-out ref. `build` alone gives a
|
|
1212
|
+
// package only the legs its own graph depends on — the host one and
|
|
1213
|
+
// whatever it declares — and the release gate demands the whole declared
|
|
1214
|
+
// native closure, so a foreign leg it never asked for was simply absent.
|
|
1215
|
+
console.log('Repair pending releases: building cross-platform outputs on this runner.');
|
|
1216
|
+
await githubCiNxRunMany(root, {
|
|
1217
|
+
targets: PLATFORM_TARGET_GLOBS.join(','),
|
|
1218
|
+
projects: releasePackageProjects(packages),
|
|
1219
|
+
allowEmptyProjects: true,
|
|
1220
|
+
});
|
|
1200
1221
|
return;
|
|
1201
1222
|
}
|
|
1202
1223
|
console.log(`Repair pending releases: applying cross-platform outputs from ${outputs.join(', ')}.`);
|
|
@@ -1865,7 +1886,11 @@ async function npmVersionExists(root: string, name: string, version: string): Pr
|
|
|
1865
1886
|
return npmPublishedVersionExistsOnRegistry(root, name, version);
|
|
1866
1887
|
}
|
|
1867
1888
|
return withPrivateNpmUserconfig(registry, 'read', (userconfig) =>
|
|
1868
|
-
npmPublishedVersionExistsOnRegistry(root, name, version, {
|
|
1889
|
+
npmPublishedVersionExistsOnRegistry(root, name, version, {
|
|
1890
|
+
registry: registry.registry,
|
|
1891
|
+
userconfig,
|
|
1892
|
+
credential: { authKey: registry.authKey, tokenEnv: privateNpmTokenEnvForMode(registry, 'read') },
|
|
1893
|
+
}),
|
|
1869
1894
|
);
|
|
1870
1895
|
}
|
|
1871
1896
|
|
|
@@ -247,7 +247,12 @@ export function resolvePrivateNpmWorkflowConfig(root: string): PackagePrivateNpm
|
|
|
247
247
|
return undefined;
|
|
248
248
|
}
|
|
249
249
|
const npmrcEnv = npmrcAuthTokenEnv(root, declared.scope);
|
|
250
|
-
|
|
250
|
+
// A publishing workspace reads the registry too: every release after the
|
|
251
|
+
// first checks the previous tag's durable state, and that check runs in the
|
|
252
|
+
// producer jobs that never see the publish credential. A declared read token
|
|
253
|
+
// therefore always renders; only the .npmrc fallback stays consumer-only, so
|
|
254
|
+
// a publish token is never promoted into a read role by inference.
|
|
255
|
+
const readTokenEnv = declared.readTokenEnv ?? (consumes ? npmrcEnv : undefined);
|
|
251
256
|
const publishTokenEnv = publishes ? (declared.publishTokenEnv ?? npmrcEnv) : undefined;
|
|
252
257
|
if (!readTokenEnv && !publishTokenEnv) {
|
|
253
258
|
return undefined;
|
|
@@ -412,10 +417,30 @@ export async function withPrivateNpmUserconfig<T>(
|
|
|
412
417
|
export interface NpmStatusOptions {
|
|
413
418
|
registry?: string;
|
|
414
419
|
userconfig?: string;
|
|
420
|
+
/** Path-scoped auth key and token env, so the credential can ride env config. */
|
|
421
|
+
credential?: { authKey: string; tokenEnv: string };
|
|
415
422
|
}
|
|
416
423
|
|
|
417
|
-
|
|
418
|
-
|
|
424
|
+
/**
|
|
425
|
+
* npm config precedence is cli > env > project `.npmrc` > userconfig. A
|
|
426
|
+
* repository that commits its own `//host/path:_authToken=${SOME_ENV}` line
|
|
427
|
+
* therefore OVERRIDES the userconfig this module writes, and when that env is
|
|
428
|
+
* unset npm sends the unexpanded value: the registry answers 401 on reads with
|
|
429
|
+
* a perfectly good read credential, naming nothing. So the credential also
|
|
430
|
+
* rides as env config, which the project file cannot outrank, and the token
|
|
431
|
+
* value stays out of every file on disk.
|
|
432
|
+
*/
|
|
433
|
+
function npmStatusEnv(
|
|
434
|
+
userconfig: string | undefined,
|
|
435
|
+
credential?: { authKey: string; tokenEnv: string },
|
|
436
|
+
): Record<string, string> | undefined {
|
|
437
|
+
if (!userconfig) return undefined;
|
|
438
|
+
const env: Record<string, string> = { NPM_CONFIG_USERCONFIG: userconfig };
|
|
439
|
+
const token = credential ? process.env[credential.tokenEnv] : undefined;
|
|
440
|
+
if (credential && token) {
|
|
441
|
+
env[`npm_config_${credential.authKey}`] = token;
|
|
442
|
+
}
|
|
443
|
+
return env;
|
|
419
444
|
}
|
|
420
445
|
|
|
421
446
|
function npmCommandFailedMessage(npmArgs: string[], exitCode: number, stdout = '', stderr = ''): string {
|
|
@@ -439,7 +464,7 @@ export async function npmPublishedVersionExists(
|
|
|
439
464
|
if (options.registry) {
|
|
440
465
|
args.push('--registry', options.registry);
|
|
441
466
|
}
|
|
442
|
-
const result = await runResult('npm', args, root, npmStatusEnv(options.userconfig));
|
|
467
|
+
const result = await runResult('npm', args, root, npmStatusEnv(options.userconfig, options.credential));
|
|
443
468
|
if (result.exitCode === 0) {
|
|
444
469
|
return true;
|
|
445
470
|
}
|
|
@@ -455,7 +480,7 @@ export async function npmPackageExists(root: string, name: string, options: NpmS
|
|
|
455
480
|
if (options.registry) {
|
|
456
481
|
args.push('--registry', options.registry);
|
|
457
482
|
}
|
|
458
|
-
const result = await runResult('npm', args, root, npmStatusEnv(options.userconfig));
|
|
483
|
+
const result = await runResult('npm', args, root, npmStatusEnv(options.userconfig, options.credential));
|
|
459
484
|
if (result.exitCode === 0) {
|
|
460
485
|
return true;
|
|
461
486
|
}
|