@smoothbricks/cli 0.11.17 → 0.11.19

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.
Files changed (60) hide show
  1. package/README.md +45 -10
  2. package/dist/cli.js +10 -7
  3. package/dist/github-ci/index.d.ts.map +1 -1
  4. package/dist/github-ci/index.js +6 -20
  5. package/dist/lib/deploy-tags.d.ts +17 -3
  6. package/dist/lib/deploy-tags.d.ts.map +1 -1
  7. package/dist/lib/deploy-tags.js +23 -4
  8. package/dist/monorepo/ci-workflow.d.ts +56 -0
  9. package/dist/monorepo/ci-workflow.d.ts.map +1 -1
  10. package/dist/monorepo/ci-workflow.js +199 -2
  11. package/dist/monorepo/managed-files.d.ts +28 -2
  12. package/dist/monorepo/managed-files.d.ts.map +1 -1
  13. package/dist/monorepo/managed-files.js +74 -22
  14. package/dist/release/github-release.d.ts +10 -0
  15. package/dist/release/github-release.d.ts.map +1 -1
  16. package/dist/release/github-release.js +11 -5
  17. package/dist/release/index.d.ts.map +1 -1
  18. package/dist/release/index.js +4 -5
  19. package/dist/secrets/commands.d.ts +52 -13
  20. package/dist/secrets/commands.d.ts.map +1 -1
  21. package/dist/secrets/commands.js +220 -34
  22. package/dist/secrets/index.d.ts +21 -1
  23. package/dist/secrets/index.d.ts.map +1 -1
  24. package/dist/secrets/index.js +110 -4
  25. package/dist/secrets/repository.d.ts +60 -0
  26. package/dist/secrets/repository.d.ts.map +1 -0
  27. package/dist/secrets/repository.js +145 -0
  28. package/dist/wrangler/cloudflare.d.ts +7 -0
  29. package/dist/wrangler/cloudflare.d.ts.map +1 -1
  30. package/dist/wrangler/cloudflare.js +10 -0
  31. package/dist/wrangler/deploy-stage.d.ts.map +1 -1
  32. package/dist/wrangler/deploy-stage.js +50 -24
  33. package/dist/wrangler/stage-secrets.d.ts +57 -0
  34. package/dist/wrangler/stage-secrets.d.ts.map +1 -0
  35. package/dist/wrangler/stage-secrets.js +178 -0
  36. package/managed/raw/tooling/direnv/devenv.smoo.nix +9 -1
  37. package/managed/raw/tooling/git-hooks/pre-push.sh +30 -29
  38. package/package.json +2 -2
  39. package/src/cli.ts +33 -10
  40. package/src/github-ci/index.test.ts +6 -7
  41. package/src/github-ci/index.ts +11 -19
  42. package/src/lib/deploy-tags.ts +22 -4
  43. package/src/monorepo/__tests__/ci-workflow.test.ts +161 -0
  44. package/src/monorepo/ci-workflow.ts +277 -2
  45. package/src/monorepo/managed-files.test.ts +96 -37
  46. package/src/monorepo/managed-files.ts +94 -23
  47. package/src/monorepo/package-policy.test.ts +1 -1
  48. package/src/release/__tests__/github-release.test.ts +8 -4
  49. package/src/release/github-release.ts +13 -5
  50. package/src/release/index.ts +4 -4
  51. package/src/secrets/commands.test.ts +28 -0
  52. package/src/secrets/commands.ts +237 -35
  53. package/src/secrets/index.test.ts +118 -1
  54. package/src/secrets/index.ts +108 -4
  55. package/src/secrets/repository.test.ts +98 -0
  56. package/src/secrets/repository.ts +164 -0
  57. package/src/wrangler/cloudflare.ts +18 -0
  58. package/src/wrangler/deploy-stage.test.ts +258 -11
  59. package/src/wrangler/deploy-stage.ts +70 -38
  60. package/src/wrangler/stage-secrets.ts +146 -0
@@ -1,8 +1,12 @@
1
1
  import { appendFileSync, existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, join, resolve } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
+ import {
5
+ cargoCrossTestArchiveFile,
6
+ cargoCrossTestTripleFromArchiveTarget,
7
+ } from '@smoothbricks/nx-plugin/cross-check-policy';
4
8
  import { MACOS_PLATFORM_TARGET_GLOBS, PLATFORM_TARGET_GLOBS } from '@smoothbricks/nx-plugin/workspace-config-policy';
5
- import { isStageDerivedDeploy, PRODUCTION_PUSH_DEPLOY_TAG } from '../lib/deploy-tags.js';
9
+ import { PRODUCTION_PUSH_DEPLOY_TAG, stageDeploysProject } from '../lib/deploy-tags.js';
6
10
  import {
7
11
  ciPushBranches,
8
12
  type NonEmptyArray,
@@ -17,9 +21,16 @@ import {
17
21
  readValidatedPackageJson,
18
22
  } from '../lib/json.js';
19
23
  import { listReleasePackages, packageInfo } from '../lib/workspace.js';
20
- import { loadNxProjects, type NxProjects, targetNamesFromProjects } from '../nx/index.js';
24
+ import {
25
+ loadNxProjects,
26
+ type NxProjects,
27
+ projectRootFromNxProjectJson,
28
+ targetNamesFromNxProjectJson,
29
+ targetNamesFromProjects,
30
+ } from '../nx/index.js';
21
31
  import { resolvePrivateNpmWorkflowConfig } from '../release/private-npm.js';
22
- import { privateNpmReadTokenJobEnv, renderCiWorkflowYaml } from './ci-workflow.js';
32
+ import type { DeploymentStage } from '../wrangler/stage.js';
33
+ import { type CiCrossTestArchive, privateNpmReadTokenJobEnv, renderCiWorkflowYaml } from './ci-workflow.js';
23
34
  import { renderRunsOnLine } from './github-runs-on.js';
24
35
  import { renderPrPreviewCleanupWorkflowYaml } from './pr-preview-cleanup-workflow.js';
25
36
  import { renderPublishWorkflowYaml } from './publish-workflow.js';
@@ -181,6 +192,12 @@ export interface ManagedFileContext {
181
192
  cargoCredentials?: PackageCargoCredentialsConfig;
182
193
  /** Declared self-hosted Nx remote cache from the root smoo config; absent means local caching only. */
183
194
  remoteCache?: PackageRemoteCacheConfig;
195
+ /**
196
+ * Cross-built test archives the graph can produce, one per foreign target
197
+ * triple the cargo workspace declares. Empty for a repository that declares
198
+ * none, which renders a CI workflow with no cross steps and no macOS job.
199
+ */
200
+ crossTestArchives: CiCrossTestArchive[];
184
201
  }
185
202
 
186
203
  interface DeployTargetInfo {
@@ -326,6 +343,23 @@ export const managedFileTargetsForTest = managedFiles.map(({ target, executable
326
343
 
327
344
  const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
328
345
 
346
+ /**
347
+ * Whether a repository of this shape has the file at all. A repository that owns
348
+ * no packages has nothing to publish, and one the stage flow does not deploy to
349
+ * Cloudflare has no preview stage to clean up.
350
+ */
351
+ function managedFileApplies(file: ManagedFile, context: ManagedFileContext): boolean {
352
+ if (file.releasePackagesOnly === true && !context.hasReleasePackages && !context.hasProductionDeployTargets) {
353
+ return false;
354
+ }
355
+ return file.cloudflareDeployOnly !== true || context.stagingDeployProvider === 'cloudflare';
356
+ }
357
+
358
+ /** Test seam: the managed targets a repository of this shape gets, in file order. */
359
+ export function managedFileTargetsForContext(context: ManagedFileContext): string[] {
360
+ return managedFiles.filter((file) => managedFileApplies(file, context)).map((file) => file.target);
361
+ }
362
+
329
363
  export async function applyManagedFiles(root: string, mode: 'update' | 'check' | 'diff'): Promise<FileResult[]> {
330
364
  const context = await getManagedFileContext(root);
331
365
  return managedFiles.map((file) => applyManagedFile(root, file, mode, context));
@@ -337,10 +371,7 @@ function applyManagedFile(
337
371
  mode: 'update' | 'check' | 'diff',
338
372
  context: ManagedFileContext,
339
373
  ): FileResult {
340
- if (file.releasePackagesOnly === true && !context.hasReleasePackages && !context.hasProductionDeployTargets) {
341
- return { target: file.target, action: 'skipped' };
342
- }
343
- if (file.cloudflareDeployOnly === true && context.stagingDeployProvider !== 'cloudflare') {
374
+ if (!managedFileApplies(file, context)) {
344
375
  return { target: file.target, action: 'skipped' };
345
376
  }
346
377
  const target = resolve(root, file.target);
@@ -407,6 +438,9 @@ function getManagedContent(file: ManagedFile, context: ManagedFileContext): stri
407
438
  deploySecrets: context.ciDeploySecrets,
408
439
  e2eSecrets: context.ciE2eSecrets,
409
440
  productionOnPush: context.hasProductionPushDeployTargets,
441
+ crossTestArchives: context.crossTestArchives,
442
+ macosRunsOn: context.macosRunsOn,
443
+ platformProducer: context.platformProducer,
410
444
  });
411
445
  }
412
446
  if (file.source === 'publish-workflow') {
@@ -503,6 +537,7 @@ async function getManagedFileContext(root: string): Promise<ManagedFileContext>
503
537
  ? macosPlatformArchitecturesForTest(targetNames)
504
538
  : [],
505
539
  privateNpm,
540
+ crossTestArchives: crossTestArchivesForTest(nxProjects),
506
541
  };
507
542
  }
508
543
 
@@ -510,6 +545,40 @@ export function hasExactTargetForTest(targetNames: Iterable<string>, target: str
510
545
  return [...targetNames].includes(target);
511
546
  }
512
547
 
548
+ /**
549
+ * Test seam: the cross-built test archives this graph can produce, read back
550
+ * out of the inferred `cargo-cross-test-archive-<triple>` targets.
551
+ *
552
+ * Derived, not configured. The triple is declared exactly once — in the cargo
553
+ * workspace's own `[workspace.metadata.smoothbricks.test] cross-targets`, where
554
+ * a Rust target triple belongs — and both halves of the workflow are rendered
555
+ * from the targets that declaration produced. A second copy in `smoo.github`
556
+ * could name a triple the graph cannot build, and CI would discover that only
557
+ * on the runner.
558
+ *
559
+ * The path is the target's own output location, so the artifact the Linux job
560
+ * uploads and the file the macOS job's runner opens are the same string
561
+ * computed once.
562
+ */
563
+ export function crossTestArchivesForTest(projects: NxProjects): CiCrossTestArchive[] {
564
+ const archives: CiCrossTestArchive[] = [];
565
+ for (const project of Object.values(projects)) {
566
+ const projectRoot = projectRootFromNxProjectJson(project);
567
+ for (const targetName of targetNamesFromNxProjectJson(project)) {
568
+ const triple = cargoCrossTestTripleFromArchiveTarget(targetName);
569
+ if (triple === null) {
570
+ continue;
571
+ }
572
+ const file = cargoCrossTestArchiveFile(triple);
573
+ archives.push({
574
+ triple,
575
+ path: projectRoot === undefined || projectRoot === '.' ? file : `${projectRoot}/${file}`,
576
+ });
577
+ }
578
+ }
579
+ return archives.sort((left, right) => left.triple.localeCompare(right.triple));
580
+ }
581
+
513
582
  /**
514
583
  * The platform families the release workflow produces: the graph's families
515
584
  * minus the ones the repository excludes. Excluding a family the graph does not
@@ -558,12 +627,12 @@ export function macosPlatformArchitecturesForTest(targetNames: Iterable<string>)
558
627
  return [...architectures].sort((left, right) => left.localeCompare(right));
559
628
  }
560
629
 
561
- /** Test seam: deploy configuration presence/provider from graph nodes. */
562
- export function deployTargetInfoFromProjects(projects: NxProjects, configuration: string): DeployTargetInfo {
630
+ /** Test seam: what the stage flow deploys, and with which provider, from graph nodes. */
631
+ export function deployTargetInfoFromProjects(projects: NxProjects, stage: DeploymentStage): DeployTargetInfo {
563
632
  let exists = false;
564
633
  let provider: DeployTargetInfo['provider'];
565
634
  for (const project of Object.values(projects)) {
566
- const info = deployTargetInfoFromProject(project, configuration);
635
+ const info = deployTargetInfoFromProject(project, stage);
567
636
  if (!info.exists) {
568
637
  continue;
569
638
  }
@@ -578,22 +647,24 @@ export function anyProjectHasTagForTest(projects: NxProjects, tag: string): bool
578
647
  return Object.values(projects).some((project) => project.tags?.includes(tag));
579
648
  }
580
649
 
581
- function deployTargetInfoFromProject(project: NxProjectJson, configuration: string): DeployTargetInfo {
650
+ /**
651
+ * A generated deploy job exists for the projects CI would actually deploy, which
652
+ * is `stageDeploysProject` and nothing else — the same rule `smoo github-ci
653
+ * nx-deploy` selects by, so the job and its selection cannot disagree. Reading
654
+ * the target's presence instead would render a deploy job, with cloud
655
+ * credentials, into a repository whose only wrangler manifests document a
656
+ * Durable Object binding for a published library's consumers.
657
+ *
658
+ * The command is read for the PROVIDER only: which cloud CLI runs says which
659
+ * credentials the job needs, and says nothing about whether CI runs it.
660
+ */
661
+ function deployTargetInfoFromProject(project: NxProjectJson, stage: DeploymentStage): DeployTargetInfo {
582
662
  const deploy = project.targets?.deploy;
583
- if (!deploy) {
584
- return { exists: false };
585
- }
586
- const baseCommandValue = deploy.options?.command ?? deploy.command;
587
- const baseCommand = typeof baseCommandValue === 'string' ? baseCommandValue : '';
588
- if (isStageDerivedDeploy(project.tags, baseCommand)) {
589
- return { exists: true, provider: deployProvider(baseCommand) };
590
- }
591
- const config = deploy.configurations?.[configuration];
592
- if (!config) {
663
+ if (!deploy || !stageDeploysProject(project.tags, stage)) {
593
664
  return { exists: false };
594
665
  }
595
- const commandValue = config.command ?? config.options?.command ?? baseCommand;
596
- return { exists: true, provider: deployProvider(typeof commandValue === 'string' ? commandValue : '') };
666
+ const command = deploy.options?.command ?? deploy.command;
667
+ return { exists: true, provider: deployProvider(typeof command === 'string' ? command : '') };
597
668
  }
598
669
 
599
670
  function deployProvider(command: string): DeployTargetInfo['provider'] {
@@ -63,7 +63,7 @@ describe('root smoo monorepo policy', () => {
63
63
  const rootPackage = await readJson(join(root, 'package.json'));
64
64
  const nxJson = await readJson(join(root, 'nx.json'));
65
65
  expect(rootPackage.scripts).toEqual({
66
- 'check:linux': 'tooling/devenv --quiet -P linux-cross shell -- nx run-many -t cargo-lint-cross',
66
+ 'check:linux': 'tooling/devenv -P linux-cross shell -- nx run-many -t cargo-lint-cross',
67
67
  clean: 'nx run-many -t clean; nx reset',
68
68
  'clean:node_modules':
69
69
  'rm -rf node_modules && find e* t* p* -type d -name node_modules -print0 | xargs -0 rm -rvf',
@@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises';
3
3
  import type { ReleasePackageInfo } from '../core.js';
4
4
  import {
5
5
  createOrUpdateGithubRelease,
6
+ DEPENDENCY_ONLY_RELEASE_NOTES,
6
7
  type GithubReleaseWriteShell,
7
8
  githubReleaseLookupExists,
8
9
  nxProjectChangelogArgs,
@@ -63,10 +64,13 @@ describe('GitHub release helpers', () => {
63
64
  ).toBe('generated release notes');
64
65
  });
65
66
 
66
- it('fails when Nx omits the requested project changelog', () => {
67
- expect(() => projectChangelogContents({ projectChangelogs: {} }, 'pkg')).toThrow(
68
- 'Nx did not generate a project changelog for pkg.',
69
- );
67
+ it('renders a dependency-only body when Nx generates no project changelog', () => {
68
+ // Nx bumps a project "because a dependency was bumped", and such a version
69
+ // carries no commits of its own — so no changelog is the correct output
70
+ // rather than a failure. Refusing here aborted a publish AFTER the new
71
+ // versions had been written to every manifest, leaving the release half
72
+ // done: one dependency-only package took down the whole run.
73
+ expect(projectChangelogContents({ projectChangelogs: {} }, 'pkg')).toBe(DEPENDENCY_ONLY_RELEASE_NOTES);
70
74
  });
71
75
 
72
76
  it('distinguishes missing releases from transient GitHub lookup failures', () => {
@@ -80,12 +80,20 @@ export function nxProjectChangelogArgs(pkg: ReleasePackageInfo, previousTag: str
80
80
  return previousTag ? { ...base, from: previousTag } : { ...base, firstRelease: true };
81
81
  }
82
82
 
83
+ /**
84
+ * The release body for a version that carries no commits of its own.
85
+ *
86
+ * Nx bumps a project when a dependency it depends on was bumped, and reports
87
+ * that reason itself ("because a dependency was bumped"). Such a version has no
88
+ * commits touching the project, so Nx generates no changelog for it — which is
89
+ * correct, not a failure. Treating the absence as fatal made one dependency-only
90
+ * package abort a whole publish after the versions had already been written.
91
+ */
92
+ export const DEPENDENCY_ONLY_RELEASE_NOTES =
93
+ '_No changelog entries for this package: this version was released because one of its dependencies was bumped._';
94
+
83
95
  export function projectChangelogContents(result: ProjectChangelogLookupResult, projectName: string): string {
84
- const changelog = result.projectChangelogs?.[projectName];
85
- if (!changelog) {
86
- throw new Error(`Nx did not generate a project changelog for ${projectName}.`);
87
- }
88
- return changelog.contents;
96
+ return result.projectChangelogs?.[projectName]?.contents ?? DEPENDENCY_ONLY_RELEASE_NOTES;
89
97
  }
90
98
 
91
99
  export function githubReleaseLookupExists(tag: string, exitCode: number, stdout: string, stderr: string): boolean {
@@ -89,6 +89,7 @@ import {
89
89
  } from './core.js';
90
90
  import {
91
91
  createOrUpdateGithubRelease,
92
+ DEPENDENCY_ONLY_RELEASE_NOTES,
92
93
  githubReleaseLookupExists,
93
94
  importWorkspaceNx,
94
95
  renderNxProjectChangelogContents,
@@ -1596,10 +1597,9 @@ async function writeReleasePreviewSummary(
1596
1597
  lines.push(` - \`${pkg.name}\`: \`${currentByName.get(pkg.name)?.version ?? 'unknown'}\` -> \`${pkg.version}\``);
1597
1598
  }
1598
1599
  for (const pkg of previewPackages) {
1599
- const contents = releasePreviewChangelogs.get(pkg.projectName);
1600
- if (!contents) {
1601
- throw new Error(`Nx did not generate a project changelog for ${pkg.projectName}.`);
1602
- }
1600
+ // A dependency-only bump has no commits of its own, so Nx generates no
1601
+ // changelog for it. The preview says so rather than refusing to render.
1602
+ const contents = releasePreviewChangelogs.get(pkg.projectName) ?? DEPENDENCY_ONLY_RELEASE_NOTES;
1603
1603
  lines.push('', `### ${pkg.name} ${pkg.version}`, '', contents.trim());
1604
1604
  }
1605
1605
  }
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { secretsMissingInEnvironment } from './commands.js';
3
+ import { reconcileSecrets } from './index.js';
4
+
5
+ const sources = {
6
+ workerSecrets: { 'targets/billing': ['PAYMENTS_PUBLISHABLE_KEY', 'PAYMENTS_SECRET_KEY'] },
7
+ workflowSecrets: ['PAYMENTS_PUBLISHABLE_KEY', 'PAYMENTS_SECRET_KEY'],
8
+ localCommands: [],
9
+ secretNames: {
10
+ PAYMENTS_PUBLISHABLE_KEY: 'PAYMENTS_PUBLISHABLE_KEY',
11
+ PAYMENTS_SECRET_KEY: 'PAYMENTS_SECRET_KEY',
12
+ },
13
+ repositorySecrets: ['PAYMENTS_PUBLISHABLE_KEY', 'PAYMENTS_SECRET_KEY', 'RETIRED_TOKEN'],
14
+ environmentSecrets: { production: ['PAYMENTS_PUBLISHABLE_KEY'] },
15
+ };
16
+
17
+ describe('what `smoo secrets set --env` walks', () => {
18
+ it('offers a name the repository holds and the environment does not, and no undeclared one', () => {
19
+ // Overriding one stage's credentials is the reason to write at environment
20
+ // scope: skipping names the repository already holds would leave a live
21
+ // value unsettable. RETIRED_TOKEN is absent from the environment too, but
22
+ // nothing declares it - prompting for it would ask an operator to copy a
23
+ // stale value forward.
24
+ const walked = secretsMissingInEnvironment(reconcileSecrets(sources), 'production');
25
+
26
+ expect(walked.map((row) => row.name)).toEqual(['PAYMENTS_SECRET_KEY']);
27
+ });
28
+ });