@smoothbricks/cli 0.11.17 → 0.11.18

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 (52) hide show
  1. package/README.md +22 -4
  2. package/dist/cli.js +10 -7
  3. package/dist/monorepo/ci-workflow.d.ts +56 -0
  4. package/dist/monorepo/ci-workflow.d.ts.map +1 -1
  5. package/dist/monorepo/ci-workflow.js +199 -2
  6. package/dist/monorepo/managed-files.d.ts +23 -0
  7. package/dist/monorepo/managed-files.d.ts.map +1 -1
  8. package/dist/monorepo/managed-files.js +39 -1
  9. package/dist/release/github-release.d.ts +10 -0
  10. package/dist/release/github-release.d.ts.map +1 -1
  11. package/dist/release/github-release.js +11 -5
  12. package/dist/release/index.d.ts.map +1 -1
  13. package/dist/release/index.js +4 -5
  14. package/dist/secrets/commands.d.ts +52 -13
  15. package/dist/secrets/commands.d.ts.map +1 -1
  16. package/dist/secrets/commands.js +220 -34
  17. package/dist/secrets/index.d.ts +21 -1
  18. package/dist/secrets/index.d.ts.map +1 -1
  19. package/dist/secrets/index.js +110 -4
  20. package/dist/secrets/repository.d.ts +60 -0
  21. package/dist/secrets/repository.d.ts.map +1 -0
  22. package/dist/secrets/repository.js +145 -0
  23. package/dist/wrangler/cloudflare.d.ts +7 -0
  24. package/dist/wrangler/cloudflare.d.ts.map +1 -1
  25. package/dist/wrangler/cloudflare.js +10 -0
  26. package/dist/wrangler/deploy-stage.d.ts.map +1 -1
  27. package/dist/wrangler/deploy-stage.js +50 -24
  28. package/dist/wrangler/stage-secrets.d.ts +57 -0
  29. package/dist/wrangler/stage-secrets.d.ts.map +1 -0
  30. package/dist/wrangler/stage-secrets.js +178 -0
  31. package/managed/raw/tooling/direnv/devenv.smoo.nix +9 -1
  32. package/managed/raw/tooling/git-hooks/pre-push.sh +30 -29
  33. package/package.json +2 -2
  34. package/src/cli.ts +33 -10
  35. package/src/monorepo/__tests__/ci-workflow.test.ts +161 -0
  36. package/src/monorepo/ci-workflow.ts +277 -2
  37. package/src/monorepo/managed-files.test.ts +27 -0
  38. package/src/monorepo/managed-files.ts +56 -2
  39. package/src/monorepo/package-policy.test.ts +1 -1
  40. package/src/release/__tests__/github-release.test.ts +8 -4
  41. package/src/release/github-release.ts +13 -5
  42. package/src/release/index.ts +4 -4
  43. package/src/secrets/commands.test.ts +28 -0
  44. package/src/secrets/commands.ts +237 -35
  45. package/src/secrets/index.test.ts +118 -1
  46. package/src/secrets/index.ts +108 -4
  47. package/src/secrets/repository.test.ts +98 -0
  48. package/src/secrets/repository.ts +164 -0
  49. package/src/wrangler/cloudflare.ts +18 -0
  50. package/src/wrangler/deploy-stage.test.ts +258 -11
  51. package/src/wrangler/deploy-stage.ts +70 -38
  52. package/src/wrangler/stage-secrets.ts +146 -0
@@ -1,5 +1,10 @@
1
1
  /* biome-ignore-all lint/suspicious/noTemplateCurlyInString: GitHub Actions expressions are emitted literally. */
2
2
 
3
+ import {
4
+ cargoCrossTestArchiveFile,
5
+ cargoCrossTestArchiveTargetName,
6
+ cargoCrossTestTargetName,
7
+ } from '@smoothbricks/nx-plugin/cross-check-policy';
3
8
  import { PRODUCTION_PUSH_DEPLOY_TAG } from '../lib/deploy-tags.js';
4
9
  import type {
5
10
  NonEmptyArray,
@@ -24,6 +29,11 @@ export enum CiWorkflowStepKind {
24
29
  BrowserTests = 'browser-tests',
25
30
  Lint = 'lint',
26
31
  UnitTests = 'unit-tests',
32
+ CrossToolchainPreflight = 'cross-toolchain-preflight',
33
+ CrossTestArchives = 'cross-test-archives',
34
+ UploadCrossTestArchives = 'upload-cross-test-archives',
35
+ DownloadCrossTestArchives = 'download-cross-test-archives',
36
+ CrossTests = 'cross-tests',
27
37
  ManagedFilesCheck = 'managed-files-check',
28
38
  ManagedFilesDispatch = 'managed-files-dispatch',
29
39
  Deploy = 'deploy',
@@ -51,6 +61,23 @@ export interface DeployStepSecretConfig {
51
61
  deploySecrets?: Record<string, string>;
52
62
  }
53
63
 
64
+ /**
65
+ * One foreign target triple's test archive: what the Linux job cross-builds and
66
+ * a native runner executes.
67
+ *
68
+ * Both fields are DERIVED, never hand-written: the triple comes from the cargo
69
+ * workspace's `[workspace.metadata.smoothbricks.test] cross-targets`, and the
70
+ * path is where the inferred `cargo-cross-test-archive-<triple>` target
71
+ * declares its output. That is why this is not a `smoo.github` key — a triple
72
+ * repeated in CI config could name an archive the graph cannot build, and the
73
+ * workflow would only find out on the runner.
74
+ */
75
+ export interface CiCrossTestArchive {
76
+ triple: string;
77
+ /** Repository-relative path of the archive file, from the target's output. */
78
+ path: string;
79
+ }
80
+
54
81
  export interface CiWorkflowDefinitionOptions extends DeployStepSecretConfig {
55
82
  actionsProvider?: PackageSmooGithub['actionsProvider'];
56
83
  deploy: boolean;
@@ -92,6 +119,41 @@ export interface CiWorkflowDefinitionOptions extends DeployStepSecretConfig {
92
119
  e2eSecrets?: Record<string, string>;
93
120
  /** Emit the production-on-push job (some project carries PRODUCTION_PUSH_DEPLOY_TAG). */
94
121
  productionOnPush?: boolean;
122
+ /**
123
+ * Cross-built test archives, one per foreign target triple the cargo
124
+ * workspace declares. The Linux job builds and uploads each one; a second job
125
+ * downloads the `*-apple-darwin` ones and EXECUTES them on `macosRunsOn`,
126
+ * which is how a cross compile gets proved by running rather than by linking.
127
+ *
128
+ * Empty or absent renders exactly what a repository without cross archives
129
+ * renders — no step, no job, no numbering shift.
130
+ */
131
+ crossTestArchives?: readonly CiCrossTestArchive[];
132
+ /**
133
+ * macOS runner labels for the darwin execution job
134
+ * (`smoo.github.macosRunsOn`, the same labels the publish workflow's macOS
135
+ * legs use). Default macos-latest.
136
+ *
137
+ * A self-hosted label here is one specific machine. That machine being asleep
138
+ * does not skip the job: it queues, and the run FAILS on the job's
139
+ * `timeout-minutes`. That is the trade this opt-in buys — a red run when the
140
+ * Mac is unreachable, in exchange for the darwin binaries being tested by
141
+ * execution at all. `continue-on-error` would buy the green back by making
142
+ * the job unable to report anything, which is worse than not having it.
143
+ */
144
+ macosRunsOn?: string | string[];
145
+ /**
146
+ * Declared Linux cross-platform producer (`smoo.github.platformProducer`):
147
+ * the toolchain preflight and the environment a foreign-target build needs on
148
+ * a Linux runner. The publish workflow already renders this declaration for
149
+ * its cross artifact job, so the cross test archive reuses it rather than
150
+ * carrying a second copy of one repository's SDK and linker setup.
151
+ *
152
+ * Rendered STEP-scoped here, never as job env: this environment turns the
153
+ * repository's native producers into cross producers, and Validate's other
154
+ * steps are host builds that must stay host builds.
155
+ */
156
+ platformProducer?: PackageSmooGithub['platformProducer'];
95
157
  }
96
158
 
97
159
  type CiWorkflowStepInput = Omit<CiWorkflowStep, 'number'>;
@@ -117,6 +179,18 @@ export function defineCiWorkflow(options: CiWorkflowDefinitionOptions): CiWorkfl
117
179
  { kind: CiWorkflowStepKind.Lint, name: '🔍 Lint' },
118
180
  { kind: CiWorkflowStepKind.UnitTests, name: '🧪 Unit Tests' },
119
181
  );
182
+ if (options.crossTestArchives?.length) {
183
+ if (options.platformProducer !== undefined) {
184
+ steps.push({
185
+ kind: CiWorkflowStepKind.CrossToolchainPreflight,
186
+ name: 'Check cross-platform toolchain prerequisites',
187
+ });
188
+ }
189
+ steps.push(
190
+ { kind: CiWorkflowStepKind.CrossTestArchives, name: '🎯 Build cross-target test archives' },
191
+ { kind: CiWorkflowStepKind.UploadCrossTestArchives, name: '📤 Upload cross-target test archives' },
192
+ );
193
+ }
120
194
  if (options.browserTests) {
121
195
  steps.push({ kind: CiWorkflowStepKind.BrowserTests, name: '🌐 Browser Tests' });
122
196
  }
@@ -135,6 +209,7 @@ export function renderCiWorkflowYaml(options: CiWorkflowDefinitionOptions): stri
135
209
  return [
136
210
  renderCiWorkflowHeader(options),
137
211
  renderCiWorkflowSteps(steps, options),
212
+ renderCrossTestExecutionJob(options),
138
213
  renderE2eDeploymentJob(options),
139
214
  renderProductionDeployJob(options),
140
215
  ].join('');
@@ -321,6 +396,61 @@ function yamlLinesForStep(step: CiWorkflowStep, options: CiWorkflowDefinitionOpt
321
396
  return nxSmartStep(step, 'lint', 'Lint');
322
397
  case CiWorkflowStepKind.UnitTests:
323
398
  return nxSmartStep(step, 'test', 'Unit Tests');
399
+ case CiWorkflowStepKind.CrossToolchainPreflight:
400
+ // The repository's own preflight, the same one the publish workflow's
401
+ // cross artifact job runs — an SDK to provision, a linker to check.
402
+ // `working-directory: .` because this workflow's steps default to the
403
+ // direnv shell directory while a preflight is repository-relative.
404
+ return [
405
+ ` - name: ${step.name}`,
406
+ ' working-directory: .',
407
+ ...platformProducerStepEnvLines(options),
408
+ ' run: |',
409
+ ' set -euo pipefail',
410
+ ...crossToolchainPreflight(options).map((line) => ` ${line}`),
411
+ ];
412
+ case CiWorkflowStepKind.CrossTestArchives:
413
+ // Cross-COMPILES the workspace's test binaries for each declared foreign
414
+ // triple and packs them; runs nothing. `nx-run-many` rather than
415
+ // `nx-smart`, because an affected-graph selection would skip the archive
416
+ // on a commit that changed nothing Rust — and the sibling job that
417
+ // executes it has no way to build one.
418
+ return [
419
+ ` - name: ${step.name}`,
420
+ ...platformProducerStepEnvLines(options),
421
+ ` run: smoo github-ci nx-run-many --targets "${crossArchiveTargetNames(options).join(',')}"`,
422
+ ];
423
+ case CiWorkflowStepKind.UploadCrossTestArchives:
424
+ // retention-days: 1 — the only consumer is the sibling job in this same
425
+ // run, and these archives are the whole workspace's test binaries.
426
+ return artifactStepLines(options.actionsProvider, step.name, 'upload', [
427
+ `name: ${CROSS_TEST_ARCHIVE_ARTIFACT}`,
428
+ 'path: |',
429
+ ...crossTestArchives(options).map((archive) => ` ${archive.path}`),
430
+ // error, not ignore: a missing archive means the cross build silently
431
+ // produced nothing, and the execute job would then have nothing to run
432
+ // and no reason to say so.
433
+ 'if-no-files-found: error',
434
+ 'retention-days: 1',
435
+ ]);
436
+ case CiWorkflowStepKind.DownloadCrossTestArchives:
437
+ // Restores each archive at the path its own Nx target declares as output,
438
+ // which is where the runner target reads it from. upload-artifact roots
439
+ // the artifact at the least common ancestor of what it uploaded, so the
440
+ // download path is that shared directory.
441
+ return artifactStepLines(options.actionsProvider, step.name, 'download', [
442
+ `name: ${CROSS_TEST_ARCHIVE_ARTIFACT}`,
443
+ `path: ${crossTestArchiveDirectory(options)}`,
444
+ ]);
445
+ case CiWorkflowStepKind.CrossTests:
446
+ // EXECUTES, and that is all it can do: every one of these targets is
447
+ // `nextest run --archive-file`, which extracts prebuilt binaries into its
448
+ // own temporary directory. nextest never invokes cargo on this path
449
+ // (measured), so nothing here can compile even if a toolchain is present.
450
+ return [
451
+ ` - name: ${step.name}`,
452
+ ` run: smoo github-ci nx-run-many --targets "${darwinCrossTestTargetNames(options).join(',')}"`,
453
+ ];
324
454
  case CiWorkflowStepKind.ManagedFilesCheck:
325
455
  // The authoritative drift check is nearly free here (devenv is already
326
456
  // up). --warn never fails the job and publishes the step output
@@ -953,6 +1083,10 @@ function renderYamlList(values: string[], spaces: number): string {
953
1083
  * is GitHub's automatic "Set up job"); every preflight the main job runs also
954
1084
  * runs here before SetupDevenv, so the middle and cleanup anchors shift with
955
1085
  * the configuration instead of baking a fixed step number.
1086
+ *
1087
+ * `middle` is the FIRST of the job's own steps. A job with more than one — the
1088
+ * cross-test job downloads before it runs — passes how many it has, so the
1089
+ * cleanup anchor stays the number of the step GitHub will actually report.
956
1090
  */
957
1091
  interface FollowUpStepNumbers {
958
1092
  cargo: number | undefined;
@@ -962,12 +1096,13 @@ interface FollowUpStepNumbers {
962
1096
  cleanup: number;
963
1097
  }
964
1098
 
965
- function followUpStepNumbers(options: CiWorkflowDefinitionOptions): FollowUpStepNumbers {
1099
+ function followUpStepNumbers(options: CiWorkflowDefinitionOptions, middleSteps = 1): FollowUpStepNumbers {
966
1100
  let number = 3;
967
1101
  const cargo = options.cargoCredentials !== undefined ? number++ : undefined;
968
1102
  const sources = options.sourceCheckouts?.length ? number++ : undefined;
969
1103
  const setup = number++;
970
- const middle = number++;
1104
+ const middle = number;
1105
+ number += middleSteps;
971
1106
  const cleanup = number++;
972
1107
  return { cargo, sources, setup, middle, cleanup };
973
1108
  }
@@ -994,6 +1129,146 @@ function followUpCleanupStep(numbers: FollowUpStepNumbers): CiWorkflowStep[] {
994
1129
  return [{ kind: CiWorkflowStepKind.SaveNixDevenv, name: '🧹 Cleanup and cache Nix/devenv', number: numbers.cleanup }];
995
1130
  }
996
1131
 
1132
+ /**
1133
+ * How long the darwin execution job may take, download included. Generous
1134
+ * because the archive is the whole workspace's test binaries, and bounded
1135
+ * because the runner behind `macosRunsOn` may be one person's laptop: an
1136
+ * unreachable runner queues, and this number is when the run says so.
1137
+ */
1138
+ const CROSS_TEST_JOB_TIMEOUT_MINUTES = 30;
1139
+
1140
+ /** Rust names every Apple-desktop triple this way; iOS and simulator do not. */
1141
+ const DARWIN_TRIPLE_SUFFIX = '-apple-darwin';
1142
+
1143
+ /** One artifact carrying every triple's archive, scoped to the run that built it. */
1144
+ const CROSS_TEST_ARCHIVE_ARTIFACT = `cross-test-archives-${githubExpression('github.run_id')}`;
1145
+
1146
+ /**
1147
+ * Executes the cross-built test binaries on a machine where they are native.
1148
+ *
1149
+ * This job COMPILES NOTHING, and cannot: its only Rust input is the archive the
1150
+ * Validate job uploaded, and `nextest run --archive-file` extracts prebuilt
1151
+ * binaries rather than invoking cargo (measured: zero cargo invocations). It
1152
+ * needs the checkout for the workspace `--workspace-remap .` points at and for
1153
+ * Nx to resolve the target, and setup-devenv for `nx` and `cargo-nextest`
1154
+ * themselves — the repository's ordinary shell, not a cross toolchain, no SDK,
1155
+ * no `rustup target add`.
1156
+ *
1157
+ * `needs: main` and a success gate rather than `!cancelled()`: an archive that
1158
+ * was never uploaded cannot be executed, and a job that starts anyway would
1159
+ * fail on a missing artifact and report it as a test failure.
1160
+ *
1161
+ * Only `*-apple-darwin` archives run here, because macOS runner labels are the
1162
+ * only native runner this declaration names. A repository declaring some other
1163
+ * foreign triple still gets its archive BUILT — which proves that triple
1164
+ * compiles — and no execution job, which is the honest rendering of having
1165
+ * nowhere to run it.
1166
+ */
1167
+ function renderCrossTestExecutionJob(options: CiWorkflowDefinitionOptions): string {
1168
+ const archives = darwinCrossTestArchives(options);
1169
+ if (archives.length === 0) return '';
1170
+ const numbers = followUpStepNumbers(options, 2);
1171
+ const middleSteps: CiWorkflowStep[] = [
1172
+ {
1173
+ kind: CiWorkflowStepKind.DownloadCrossTestArchives,
1174
+ name: '📥 Download cross-target test archives',
1175
+ number: numbers.middle,
1176
+ },
1177
+ { kind: CiWorkflowStepKind.CrossTests, name: '🧪 Cross-Target Unit Tests', number: numbers.middle + 1 },
1178
+ ];
1179
+ return `
1180
+ macos-cross-tests:
1181
+ name: Unit Tests (${archives.map((archive) => archive.triple).join(', ')})
1182
+ needs: main
1183
+ ${options.macosRunsOn === undefined ? ' runs-on: macos-latest' : renderRunsOnLine(options.macosRunsOn)}
1184
+ timeout-minutes: ${CROSS_TEST_JOB_TIMEOUT_MINUTES}
1185
+ if: \${{ needs.main.result == 'success' }}
1186
+ env:
1187
+ GH_TOKEN: \${{ github.token }}
1188
+ ${remoteCacheJobEnvLines(options.remoteCache)}${cargoCredentialJobEnvLines(options.cargoCredentials)}${privateNpmReadTokenJobEnv(options)} steps:
1189
+ ${renderCiWorkflowSteps(followUpSetupSteps(options, numbers), options)}
1190
+ ${renderCiWorkflowSteps(middleSteps, options)}
1191
+ ${renderCiWorkflowSteps(followUpCleanupStep(numbers), options)}`;
1192
+ }
1193
+
1194
+ /**
1195
+ * The declared archives, checked against the naming the Nx graph would have
1196
+ * produced. A path that is not `cargoCrossTestArchiveFile(triple)` under some
1197
+ * project root cannot have come from that target, so it is refused here rather
1198
+ * than uploaded as an artifact nothing on the other side can find.
1199
+ */
1200
+ function crossTestArchives(options: CiWorkflowDefinitionOptions): CiCrossTestArchive[] {
1201
+ return (options.crossTestArchives ?? []).map((archive) => {
1202
+ const suffix = cargoCrossTestArchiveFile(archive.triple);
1203
+ const path = archive.path.split('\\').join('/');
1204
+ if (!/^[a-z0-9][a-z0-9._-]*$/.test(archive.triple)) {
1205
+ throw new Error(`smoo cross test archive triple must be a target triple, got ${JSON.stringify(archive.triple)}`);
1206
+ }
1207
+ if (path !== suffix && !path.endsWith(`/${suffix}`)) {
1208
+ throw new Error(
1209
+ `smoo cross test archive for ${archive.triple} must be a repository-relative ${suffix}, got ${archive.path}`,
1210
+ );
1211
+ }
1212
+ if (path.startsWith('/') || path.split('/').includes('..')) {
1213
+ throw new Error(`smoo cross test archive path must stay inside the repository, got ${archive.path}`);
1214
+ }
1215
+ return { triple: archive.triple, path };
1216
+ });
1217
+ }
1218
+
1219
+ function crossArchiveTargetNames(options: CiWorkflowDefinitionOptions): string[] {
1220
+ return crossTestArchives(options).map((archive) => cargoCrossTestArchiveTargetName(archive.triple));
1221
+ }
1222
+
1223
+ /**
1224
+ * The declared preflight, as lines. A declaration with nothing in it is a
1225
+ * mechanism error, and it surfaces here at render time rather than as a step
1226
+ * whose `run:` block is empty.
1227
+ */
1228
+ function crossToolchainPreflight(options: CiWorkflowDefinitionOptions): string[] {
1229
+ const preflight = options.platformProducer?.preflight ?? '';
1230
+ if (preflight.trim().length === 0) {
1231
+ throw new Error('smoo.github.platformProducer requires a nonempty toolchain preflight command.');
1232
+ }
1233
+ return preflight.split('\n');
1234
+ }
1235
+
1236
+ /** The producer environment, step-scoped, quoted exactly as the publish workflow quotes it. */
1237
+ function platformProducerStepEnvLines(options: CiWorkflowDefinitionOptions): string[] {
1238
+ const entries = Object.entries(options.platformProducer?.env ?? {});
1239
+ if (entries.length === 0) return [];
1240
+ return [' env:', ...entries.map(([name, value]) => ` ${name}: ${JSON.stringify(value)}`)];
1241
+ }
1242
+
1243
+ function darwinCrossTestArchives(options: CiWorkflowDefinitionOptions): CiCrossTestArchive[] {
1244
+ return crossTestArchives(options).filter((archive) => archive.triple.endsWith(DARWIN_TRIPLE_SUFFIX));
1245
+ }
1246
+
1247
+ function darwinCrossTestTargetNames(options: CiWorkflowDefinitionOptions): string[] {
1248
+ return darwinCrossTestArchives(options).map((archive) => cargoCrossTestTargetName(archive.triple));
1249
+ }
1250
+
1251
+ /**
1252
+ * Where the download lands. upload-artifact roots an artifact at the least
1253
+ * common ancestor of the files it uploaded, so every archive in one artifact
1254
+ * has to share a parent directory for the restored layout to be the one the
1255
+ * runner targets read — true for one cargo workspace, false the moment a
1256
+ * second one also declares cross targets, which is refused here instead of
1257
+ * silently restoring the archives one directory too high.
1258
+ */
1259
+ function crossTestArchiveDirectory(options: CiWorkflowDefinitionOptions): string {
1260
+ const directories = new Set(
1261
+ darwinCrossTestArchives(options).map((archive) => archive.path.slice(0, archive.path.lastIndexOf('/') + 1) || './'),
1262
+ );
1263
+ if (directories.size > 1) {
1264
+ throw new Error(
1265
+ `smoo cross test archives must share one directory to travel as one artifact, got ${[...directories].join(', ')}`,
1266
+ );
1267
+ }
1268
+ const [directory] = [...directories];
1269
+ return (directory ?? './').replace(/\/$/, '');
1270
+ }
1271
+
997
1272
  function renderE2eDeploymentJob(options: CiWorkflowDefinitionOptions): string {
998
1273
  if (!options.e2eDeployment) return '';
999
1274
  const numbers = followUpStepNumbers(options);
@@ -8,6 +8,7 @@ import fc from 'fast-check';
8
8
  import { type NxProjects, targetNamesFromProjects } from '../nx/index.js';
9
9
  import {
10
10
  anyProjectHasTagForTest,
11
+ crossTestArchivesForTest,
11
12
  DEVENV_MODULE_IMPORT,
12
13
  deployTargetInfoFromProjects,
13
14
  extractInlineLocalBlocksForTest,
@@ -205,6 +206,31 @@ describe('managed publish platform discovery', () => {
205
206
  expect(macosPlatformArchitecturesForTest(['simulator-arm64-ios', 'cli-arm64-macos'])).toEqual(['arm64']);
206
207
  expect(macosPlatformArchitecturesForTest(['build', 'cli-x64-linux', 'test'])).toEqual([]);
207
208
  });
209
+
210
+ it('reads cross-built archives out of the graph, at each project root', () => {
211
+ expect(
212
+ crossTestArchivesForTest({
213
+ '@acme/codebase': {
214
+ root: '.',
215
+ targets: { 'cargo-cross-test-archive-aarch64-apple-darwin': {}, 'cargo-test-archive': {}, test: {} },
216
+ },
217
+ 'acme-embedded': {
218
+ root: 'packages/embedded',
219
+ targets: { 'cargo-cross-test-archive-thumbv7em-none-eabihf': {} },
220
+ },
221
+ 'acme-web': { root: 'packages/web', targets: { build: {}, 'cargo-cross-test-aarch64-apple-darwin': {} } },
222
+ }),
223
+ ).toEqual([
224
+ { triple: 'aarch64-apple-darwin', path: 'target/nextest/archive-aarch64-apple-darwin.tar.zst' },
225
+ {
226
+ triple: 'thumbv7em-none-eabihf',
227
+ path: 'packages/embedded/target/nextest/archive-thumbv7em-none-eabihf.tar.zst',
228
+ },
229
+ ]);
230
+ // A repository with no cross declaration has no such target, so the CI
231
+ // renderer gets an empty list and renders the workflow it renders today.
232
+ expect(crossTestArchivesForTest({ '@acme/codebase': { root: '.', targets: { test: {} } } })).toEqual([]);
233
+ });
208
234
  });
209
235
 
210
236
  describe('managed devenv module import', () => {
@@ -461,6 +487,7 @@ const context = (overrides: Partial<ManagedFileContext>): ManagedFileContext =>
461
487
  platformTargetGlobs: [],
462
488
  releasePlatformTargetGlobs: [],
463
489
  macosPlatformArchitectures: [],
490
+ crossTestArchives: [],
464
491
  ...overrides,
465
492
  });
466
493
 
@@ -1,6 +1,10 @@
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
9
  import { isStageDerivedDeploy, PRODUCTION_PUSH_DEPLOY_TAG } from '../lib/deploy-tags.js';
6
10
  import {
@@ -17,9 +21,15 @@ 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 CiCrossTestArchive, privateNpmReadTokenJobEnv, renderCiWorkflowYaml } from './ci-workflow.js';
23
33
  import { renderRunsOnLine } from './github-runs-on.js';
24
34
  import { renderPrPreviewCleanupWorkflowYaml } from './pr-preview-cleanup-workflow.js';
25
35
  import { renderPublishWorkflowYaml } from './publish-workflow.js';
@@ -181,6 +191,12 @@ export interface ManagedFileContext {
181
191
  cargoCredentials?: PackageCargoCredentialsConfig;
182
192
  /** Declared self-hosted Nx remote cache from the root smoo config; absent means local caching only. */
183
193
  remoteCache?: PackageRemoteCacheConfig;
194
+ /**
195
+ * Cross-built test archives the graph can produce, one per foreign target
196
+ * triple the cargo workspace declares. Empty for a repository that declares
197
+ * none, which renders a CI workflow with no cross steps and no macOS job.
198
+ */
199
+ crossTestArchives: CiCrossTestArchive[];
184
200
  }
185
201
 
186
202
  interface DeployTargetInfo {
@@ -407,6 +423,9 @@ function getManagedContent(file: ManagedFile, context: ManagedFileContext): stri
407
423
  deploySecrets: context.ciDeploySecrets,
408
424
  e2eSecrets: context.ciE2eSecrets,
409
425
  productionOnPush: context.hasProductionPushDeployTargets,
426
+ crossTestArchives: context.crossTestArchives,
427
+ macosRunsOn: context.macosRunsOn,
428
+ platformProducer: context.platformProducer,
410
429
  });
411
430
  }
412
431
  if (file.source === 'publish-workflow') {
@@ -503,6 +522,7 @@ async function getManagedFileContext(root: string): Promise<ManagedFileContext>
503
522
  ? macosPlatformArchitecturesForTest(targetNames)
504
523
  : [],
505
524
  privateNpm,
525
+ crossTestArchives: crossTestArchivesForTest(nxProjects),
506
526
  };
507
527
  }
508
528
 
@@ -510,6 +530,40 @@ export function hasExactTargetForTest(targetNames: Iterable<string>, target: str
510
530
  return [...targetNames].includes(target);
511
531
  }
512
532
 
533
+ /**
534
+ * Test seam: the cross-built test archives this graph can produce, read back
535
+ * out of the inferred `cargo-cross-test-archive-<triple>` targets.
536
+ *
537
+ * Derived, not configured. The triple is declared exactly once — in the cargo
538
+ * workspace's own `[workspace.metadata.smoothbricks.test] cross-targets`, where
539
+ * a Rust target triple belongs — and both halves of the workflow are rendered
540
+ * from the targets that declaration produced. A second copy in `smoo.github`
541
+ * could name a triple the graph cannot build, and CI would discover that only
542
+ * on the runner.
543
+ *
544
+ * The path is the target's own output location, so the artifact the Linux job
545
+ * uploads and the file the macOS job's runner opens are the same string
546
+ * computed once.
547
+ */
548
+ export function crossTestArchivesForTest(projects: NxProjects): CiCrossTestArchive[] {
549
+ const archives: CiCrossTestArchive[] = [];
550
+ for (const project of Object.values(projects)) {
551
+ const projectRoot = projectRootFromNxProjectJson(project);
552
+ for (const targetName of targetNamesFromNxProjectJson(project)) {
553
+ const triple = cargoCrossTestTripleFromArchiveTarget(targetName);
554
+ if (triple === null) {
555
+ continue;
556
+ }
557
+ const file = cargoCrossTestArchiveFile(triple);
558
+ archives.push({
559
+ triple,
560
+ path: projectRoot === undefined || projectRoot === '.' ? file : `${projectRoot}/${file}`,
561
+ });
562
+ }
563
+ }
564
+ return archives.sort((left, right) => left.triple.localeCompare(right.triple));
565
+ }
566
+
513
567
  /**
514
568
  * The platform families the release workflow produces: the graph's families
515
569
  * minus the ones the repository excludes. Excluding a family the graph does not
@@ -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
+ });