@smoothbricks/cli 0.11.12 → 0.11.14

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 (39) hide show
  1. package/README.md +104 -20
  2. package/dist/cli.js +6 -1
  3. package/dist/lib/json.d.ts +51 -0
  4. package/dist/lib/json.d.ts.map +1 -1
  5. package/dist/lib/json.js +111 -67
  6. package/dist/monorepo/ci-workflow.d.ts +27 -2
  7. package/dist/monorepo/ci-workflow.d.ts.map +1 -1
  8. package/dist/monorepo/ci-workflow.js +151 -11
  9. package/dist/monorepo/index.d.ts +2 -0
  10. package/dist/monorepo/index.d.ts.map +1 -1
  11. package/dist/monorepo/index.js +1 -1
  12. package/dist/monorepo/managed-files.d.ts +3 -1
  13. package/dist/monorepo/managed-files.d.ts.map +1 -1
  14. package/dist/monorepo/managed-files.js +3 -0
  15. package/dist/monorepo/packed-package.d.ts +3 -3
  16. package/dist/monorepo/packed-package.d.ts.map +1 -1
  17. package/dist/monorepo/packed-package.js +11 -6
  18. package/dist/monorepo/packs/index.d.ts +8 -0
  19. package/dist/monorepo/packs/index.d.ts.map +1 -1
  20. package/dist/monorepo/packs/index.js +6 -5
  21. package/dist/monorepo/publish-workflow.d.ts +8 -1
  22. package/dist/monorepo/publish-workflow.d.ts.map +1 -1
  23. package/dist/monorepo/publish-workflow.js +20 -6
  24. package/managed/raw/tooling/direnv/devenv.smoo.nix +28 -4
  25. package/managed/raw/tooling/direnv/secret-references.ts +138 -53
  26. package/managed/templates/github/actions/save-nix-devenv/action.yml +2 -2
  27. package/managed/templates/github/actions/setup-devenv/action.yml +54 -5
  28. package/package.json +2 -2
  29. package/src/cli.ts +10 -1
  30. package/src/lib/json.ts +52 -0
  31. package/src/monorepo/__tests__/ci-workflow.test.ts +183 -0
  32. package/src/monorepo/__tests__/publish-workflow.test.ts +32 -0
  33. package/src/monorepo/ci-workflow.ts +180 -11
  34. package/src/monorepo/index.ts +6 -1
  35. package/src/monorepo/managed-files.ts +6 -0
  36. package/src/monorepo/packed-package.ts +21 -6
  37. package/src/monorepo/packs/index.ts +14 -5
  38. package/src/monorepo/publish-workflow.ts +32 -5
  39. package/src/monorepo/secret-references.test.ts +28 -2
@@ -6,6 +6,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
6
6
  import { readFile } from 'node:fs/promises';
7
7
  import { tmpdir } from 'node:os';
8
8
  import { join } from 'node:path';
9
+ import type { PackageCargoGitOrigin } from '../../lib/json.js';
9
10
  import {
10
11
  type CiWorkflowDefinitionOptions,
11
12
  CiWorkflowStepKind,
@@ -317,6 +318,115 @@ describe('CI workflow definition', () => {
317
318
  }
318
319
  });
319
320
 
321
+ it('rewrites every declared SSH spelling of a mirrored forge onto the same mirror', () => {
322
+ const directory = mkdtempSync(join(tmpdir(), 'cargo-ssh-'));
323
+ try {
324
+ const lines = cargoCredentialStepLines(
325
+ { kind: CiWorkflowStepKind.CargoCredentials, name: 'Credentials', number: 3 },
326
+ {
327
+ gitOrigins: [
328
+ {
329
+ origin: 'https://git.example.net',
330
+ tokenEnv: 'SOURCE_READ_TOKEN',
331
+ internalMirror: 'http://10.89.0.1:3000',
332
+ // Both spellings a lockfile can carry: git matches insteadOf
333
+ // prefixes textually and infers neither from the other.
334
+ sshOrigins: ['ssh://forgejo@forge.example.net:2223/', 'ssh://forge.example.net:2223'],
335
+ },
336
+ ],
337
+ },
338
+ );
339
+ const script = lines
340
+ .slice(lines.indexOf(' run: |') + 1)
341
+ .map((line) => line.slice(10))
342
+ .join('\n');
343
+ const githubEnv = join(directory, 'env');
344
+ writeFileSync(githubEnv, '');
345
+ const environment = {
346
+ PATH: process.env.PATH ?? '/usr/bin:/bin',
347
+ RUNNER_TEMP: directory,
348
+ GITHUB_ENV: githubEnv,
349
+ SOURCE_READ_TOKEN: 'fixture-secret',
350
+ };
351
+ const prepared = spawnSync('sh', ['-eu', '-c', script], { env: environment, encoding: 'utf8' });
352
+ expect(prepared.status).toBe(0);
353
+ const written = readFileSync(githubEnv, 'utf8');
354
+ // One insteadOf pair per spelling, all pointing at the one mirror, and a
355
+ // missing trailing slash is normalized: `ssh://host:2223` would rewrite
356
+ // `ssh://host:2223-other/` too.
357
+ expect(written).toContain('GIT_CONFIG_KEY_2=url.http://10.89.0.1:3000/.insteadOf');
358
+ expect(written).toContain('GIT_CONFIG_VALUE_2=https://git.example.net/');
359
+ expect(written).toContain('GIT_CONFIG_KEY_3=url.http://10.89.0.1:3000/.insteadOf');
360
+ expect(written).toContain('GIT_CONFIG_VALUE_3=ssh://forgejo@forge.example.net:2223/');
361
+ expect(written).toContain('GIT_CONFIG_KEY_4=url.http://10.89.0.1:3000/.insteadOf');
362
+ expect(written).toContain('GIT_CONFIG_VALUE_4=ssh://forge.example.net:2223/');
363
+ expect(written).toContain('GIT_CONFIG_COUNT=5');
364
+ const helper = join(directory, 'cargo-git-credential.sh');
365
+ const ask = (protocol: string, host: string): string => {
366
+ const result = spawnSync('sh', [helper, 'get'], {
367
+ env: environment,
368
+ encoding: 'utf8',
369
+ input: `protocol=${protocol}\nhost=${host}\n\n`,
370
+ });
371
+ expect(result.status).toBe(0);
372
+ return result.stdout;
373
+ };
374
+ // The rewrite happens before transport, so git only ever asks for the
375
+ // mirror. The SSH spelling stays credential-free: an SSH origin the
376
+ // runner cannot reach must fail loudly, not collect a token.
377
+ expect(ask('http', '10.89.0.1:3000')).toBe('username=x-access-token\npassword=fixture-secret\n');
378
+ expect(ask('ssh', 'forge.example.net:2223')).toBe('');
379
+ expect(ask('ssh', 'forgejo@forge.example.net:2223')).toBe('');
380
+ } finally {
381
+ rmSync(directory, { recursive: true, force: true });
382
+ }
383
+ });
384
+
385
+ it('refuses SSH spellings no mirror rewrites, and spellings git cannot match as a prefix', () => {
386
+ const origin = { origin: 'https://git.example.net', tokenEnv: 'SOURCE_READ_TOKEN' };
387
+ const render = (entry: PackageCargoGitOrigin): string[] =>
388
+ cargoCredentialStepLines(
389
+ { kind: CiWorkflowStepKind.CargoCredentials, name: 'Credentials', number: 3 },
390
+ { gitOrigins: [entry] },
391
+ );
392
+
393
+ // An sshOrigins entry only means anything as a rewrite target.
394
+ expect(() => render({ ...origin, sshOrigins: ['ssh://forge.example.net:2223/'] })).toThrow('internalMirror');
395
+ const mirrored = { ...origin, internalMirror: 'http://10.89.0.1:3000' };
396
+ for (const sshOrigins of [
397
+ [],
398
+ // scp syntax is not a URL prefix git can rewrite from a Cargo pin.
399
+ ['forgejo@forge.example.net:axe/minigraf.git'],
400
+ // A path would rewrite one repository, not the forge.
401
+ ['ssh://forge.example.net:2223/axe/minigraf.git'],
402
+ // A password in a declared origin is a secret in package.json.
403
+ ['ssh://forgejo:hunter2@forge.example.net:2223/'],
404
+ ['https://git.example.net/'],
405
+ // Two identical spellings make the same key ambiguous.
406
+ ['ssh://forge.example.net:2223/', 'ssh://forge.example.net:2223'],
407
+ ]) {
408
+ expect(() => render({ ...mirrored, sshOrigins })).toThrow('sshOrigins');
409
+ }
410
+ // One spelling, two mirrors: git keeps whichever identical key it read
411
+ // last, so the declaration is refused instead of resolved.
412
+ expect(() =>
413
+ cargoCredentialStepLines(
414
+ { kind: CiWorkflowStepKind.CargoCredentials, name: 'Credentials', number: 3 },
415
+ {
416
+ gitOrigins: [
417
+ { ...mirrored, sshOrigins: ['ssh://forge.example.net:2223/'] },
418
+ {
419
+ origin: 'https://git.other.net',
420
+ tokenEnv: 'OTHER_READ_TOKEN',
421
+ internalMirror: 'http://10.89.0.2:3000',
422
+ sshOrigins: ['ssh://forge.example.net:2223/'],
423
+ },
424
+ ],
425
+ },
426
+ ),
427
+ ).toThrow('sshOrigins');
428
+ });
429
+
320
430
  it('refuses malformed internal mirrors at render time', () => {
321
431
  for (const internalMirror of [
322
432
  'http://git.example.net/private/repo.git',
@@ -333,6 +443,79 @@ describe('CI workflow definition', () => {
333
443
  }
334
444
  });
335
445
 
446
+ it('gives every Nx job the declared remote cache at the address its runners reach', () => {
447
+ const definition = options({
448
+ deploy: true,
449
+ e2eDeployment: true,
450
+ productionOnPush: true,
451
+ remoteCache: {
452
+ server: 'https://nx-cache.example.net',
453
+ internalServer: 'http://10.89.0.1:8765',
454
+ tokenSecret: 'NX_REMOTE_CACHE_TOKEN',
455
+ },
456
+ });
457
+ const rendered = renderCiWorkflowYaml(definition);
458
+ const cacheEnv = {
459
+ NX_SELF_HOSTED_REMOTE_CACHE_SERVER: 'http://10.89.0.1:8765',
460
+ NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN: '${{ secrets.NX_REMOTE_CACHE_TOKEN }}',
461
+ };
462
+
463
+ expect(Bun.YAML.parse(rendered)).toMatchObject({
464
+ jobs: {
465
+ main: {
466
+ env: cacheEnv,
467
+ // A fork PR receives no secrets, and Nx fails every task a cache
468
+ // server refuses, so the job cannot run there at all.
469
+ if: "${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}",
470
+ },
471
+ 'e2e-deployment': { env: cacheEnv },
472
+ 'deploy-production': { env: cacheEnv },
473
+ },
474
+ });
475
+ // The public origin belongs to shells outside the runner network; a job
476
+ // that took it would leave the internal address unused.
477
+ expect(rendered).not.toContain('https://nx-cache.example.net');
478
+ });
479
+
480
+ it('takes the public server without an internal runner, and emits nothing without the declaration', () => {
481
+ const publicOnly = renderCiWorkflowYaml(
482
+ options({ remoteCache: { server: 'https://nx-cache.example.net', tokenSecret: 'NX_REMOTE_CACHE_TOKEN' } }),
483
+ );
484
+
485
+ expect(Bun.YAML.parse(publicOnly)).toMatchObject({
486
+ jobs: { main: { env: { NX_SELF_HOSTED_REMOTE_CACHE_SERVER: 'https://nx-cache.example.net' } } },
487
+ });
488
+ expect(renderCiWorkflowYaml(options())).not.toContain('NX_SELF_HOSTED');
489
+ // Without a cache token the job needs no secrets, so fork PRs still run.
490
+ expect(renderCiWorkflowYaml(options())).not.toContain('head.repo.full_name');
491
+ });
492
+
493
+ it('refuses a remote cache declaration Nx could not use, at render time', () => {
494
+ for (const server of [
495
+ 'https://nx-cache.example.net/',
496
+ 'https://nx-cache.example.net/cache',
497
+ 'https://token@nx-cache.example.net',
498
+ 'ftp://nx-cache.example.net',
499
+ 'nx-cache.example.net:8765',
500
+ ]) {
501
+ expect(() =>
502
+ renderCiWorkflowYaml(options({ remoteCache: { server, tokenSecret: 'NX_REMOTE_CACHE_TOKEN' } })),
503
+ ).toThrow('smoo.remoteCache server');
504
+ }
505
+ const server = 'https://nx-cache.example.net';
506
+ expect(() =>
507
+ renderCiWorkflowYaml(
508
+ options({ remoteCache: { server, internalServer: 'http://10.89.0.1:8765/', tokenSecret: 'TOKEN' } }),
509
+ ),
510
+ ).toThrow('smoo.remoteCache internalServer');
511
+ expect(() =>
512
+ renderCiWorkflowYaml(options({ remoteCache: { server, internalServer: server, tokenSecret: 'TOKEN' } })),
513
+ ).toThrow('repeats server');
514
+ expect(() =>
515
+ renderCiWorkflowYaml(options({ remoteCache: { server, tokenSecret: 'nx_remote_cache_token' } })),
516
+ ).toThrow('tokenSecret');
517
+ });
518
+
336
519
  it('refuses missing registry secrets before setup and skips private Cargo jobs for fork PRs', () => {
337
520
  const definition = options({ cargoCredentials: { registryTokenEnvs: ['CARGO_REGISTRIES_EXAMPLE_TOKEN'] } });
338
521
  const steps = defineCiWorkflow(definition);
@@ -38,6 +38,9 @@ const codebaseWorkflowOptions: PublishWorkflowDefinitionOptions = {
38
38
  describe('publish workflow definition', () => {
39
39
  it('renders the checked-in local publish workflow copy', async () => {
40
40
  const rendered = renderPublishWorkflowYaml(codebaseWorkflowOptions);
41
+ // validate builds and pack-checks the release's candidates, not every project:
42
+ // unscoped it compiled binaries the release did not contain.
43
+ expect(rendered).toContain('smoo monorepo validate --projects "${{ steps.version.outputs.projects }}"');
41
44
  const packageRoot = join(import.meta.dir, '..', '..', '..');
42
45
  await expect(readFile(join(packageRoot, '..', '..', '.github/workflows/publish.yml'), 'utf8')).resolves.toBe(
43
46
  rendered,
@@ -57,6 +60,35 @@ describe('publish workflow definition', () => {
57
60
  ).resolves.toBe(rendered);
58
61
  });
59
62
 
63
+ it('gives every publish job the declared remote cache, in bytes Prettier keeps', async () => {
64
+ const remoteCache = {
65
+ server: 'https://nx-cache.example.net',
66
+ internalServer: 'http://10.89.0.1:8765',
67
+ tokenSecret: 'NX_REMOTE_CACHE_TOKEN',
68
+ };
69
+ const cacheEnv = {
70
+ NX_SELF_HOSTED_REMOTE_CACHE_SERVER: 'http://10.89.0.1:8765',
71
+ NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN: '${{ secrets.NX_REMOTE_CACHE_TOKEN }}',
72
+ };
73
+ const platform = renderPublishWorkflowYaml({ ...codebaseWorkflowOptions, remoteCache });
74
+ const single = renderPublishWorkflowYaml({ repoName: '@smoothbricks/codebase', remoteCache });
75
+
76
+ expect(Bun.YAML.parse(platform)).toMatchObject({
77
+ jobs: {
78
+ 'linux-release-candidate': { env: cacheEnv },
79
+ 'macos-platform': { env: cacheEnv },
80
+ 'publish-on-linux': { env: cacheEnv },
81
+ },
82
+ });
83
+ expect(Bun.YAML.parse(single)).toMatchObject({ jobs: { publish: { env: cacheEnv } } });
84
+ expect(renderPublishWorkflowYaml(codebaseWorkflowOptions)).not.toContain('NX_SELF_HOSTED');
85
+ // A workflow the repository Prettier config would rewrite drifts on the
86
+ // first commit hook, so the cache lines must already be its output.
87
+ await expect(
88
+ format(platform, { parser: 'yaml', printWidth: 120, proseWrap: 'always', singleQuote: true }),
89
+ ).resolves.toBe(platform);
90
+ });
91
+
60
92
  it('passes the projects selector to the version and platform-output steps', async () => {
61
93
  const rendered = renderPublishWorkflowYaml(codebaseWorkflowOptions);
62
94
 
@@ -6,6 +6,7 @@ import type {
6
6
  PackageCargoCredentialsConfig,
7
7
  PackageCargoGitOrigin,
8
8
  PackagePrivateNpmConfig,
9
+ PackageRemoteCacheConfig,
9
10
  PackageSmooGithub,
10
11
  PackageSmooGithubEnvironments,
11
12
  PackageSourceCheckoutConfig,
@@ -79,6 +80,12 @@ export interface CiWorkflowDefinitionOptions extends DeployStepSecretConfig {
79
80
  * before SetupDevenv. Absent means no private Cargo fetch.
80
81
  */
81
82
  cargoCredentials?: PackageCargoCredentialsConfig;
83
+ /**
84
+ * Declared self-hosted Nx remote cache (package.json `smoo.remoteCache`).
85
+ * Every job that runs Nx carries the server and the access token, so one
86
+ * cache serves the whole fleet. Absent means each runner caches locally.
87
+ */
88
+ remoteCache?: PackageRemoteCacheConfig;
82
89
  /** GitHub Environments: staging for the validate/e2e jobs, production for the production-on-push job. */
83
90
  environments?: PackageSmooGithubEnvironments;
84
91
  /** Secrets for the e2e-deployment step, env var name → repository secret name. */
@@ -166,8 +173,9 @@ jobs:
166
173
  ${renderRunsOnLine(options.runsOn)}
167
174
  timeout-minutes: 45
168
175
  ${
169
- options.privateNpm?.readTokenEnv || options.cargoCredentials !== undefined
170
- ? ` # Fork PRs receive no secrets; private dependency installs cannot run there.
176
+ options.privateNpm?.readTokenEnv || options.cargoCredentials !== undefined || options.remoteCache !== undefined
177
+ ? ` # Fork PRs receive no secrets, so neither a private dependency install nor an
178
+ # authenticated remote cache read can run there.
171
179
  if: \${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
172
180
  `
173
181
  : ''
@@ -179,7 +187,7 @@ ${
179
187
  : ''
180
188
  } env:
181
189
  GH_TOKEN: ${githubExpression('github.token')}
182
- ${cargoCredentialJobEnvLines(options.cargoCredentials)}${privateNpmReadTokenJobEnv(options)} steps:
190
+ ${remoteCacheJobEnvLines(options.remoteCache)}${cargoCredentialJobEnvLines(options.cargoCredentials)}${privateNpmReadTokenJobEnv(options)} steps:
183
191
  `;
184
192
  }
185
193
 
@@ -459,6 +467,78 @@ export function normalizeSourceCheckout(config: PackageSourceCheckoutConfig): Pa
459
467
  return config;
460
468
  }
461
469
 
470
+ /**
471
+ * Job env lines carrying the declared Nx remote cache. The pair rides the job
472
+ * env rather than a step because every Nx invocation in the job — build, lint,
473
+ * tests, deploy — reads it, and Nx enables the cache on a nonempty server
474
+ * alone: an empty or absent token would make it authenticate with nothing and
475
+ * fail every task on 401, which is why the job carries the same fork-PR gate
476
+ * as a private dependency install (see `renderCiWorkflowHeader`).
477
+ *
478
+ * A declared `internalServer` is the address managed runners use, exactly as a
479
+ * git origin's `internalMirror` is: both say this repository's runners sit
480
+ * inside that network, and shells outside it keep the public origin.
481
+ * Mechanism errors surface at managed-file render time, never inside CI.
482
+ */
483
+ export function remoteCacheJobEnvLines(config: PackageRemoteCacheConfig | undefined): string {
484
+ if (config === undefined) {
485
+ return '';
486
+ }
487
+ const normalized = normalizeRemoteCache(config);
488
+ return [
489
+ ` NX_SELF_HOSTED_REMOTE_CACHE_SERVER: ${normalized.internalServer ?? normalized.server}\n`,
490
+ ` NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN: ${githubExpression(`secrets.${normalized.tokenSecret}`)}\n`,
491
+ ].join('');
492
+ }
493
+
494
+ /** Mechanism errors surface at managed-file render time, never inside CI. */
495
+ export function normalizeRemoteCache(config: PackageRemoteCacheConfig): PackageRemoteCacheConfig {
496
+ assertCacheOrigin('server', config.server);
497
+ if (config.internalServer !== undefined) {
498
+ assertCacheOrigin('internalServer', config.internalServer);
499
+ if (config.internalServer === config.server) {
500
+ throw new Error(
501
+ `smoo.remoteCache internalServer repeats server ${config.server}; drop internalServer or point it at the address internal runners reach`,
502
+ );
503
+ }
504
+ }
505
+ if (!/^[A-Z_][A-Z0-9_]*$/.test(config.tokenSecret)) {
506
+ throw new Error(
507
+ `smoo.remoteCache needs an upper-case secret name for tokenSecret, got ${JSON.stringify(config.tokenSecret)}`,
508
+ );
509
+ }
510
+ return config;
511
+ }
512
+
513
+ /**
514
+ * A cache origin is exactly `scheme://host[:port]`. Nx appends
515
+ * `/v1/cache/<hash>` to it, so a trailing slash silently requests a doubled
516
+ * slash the server routes nowhere, and a path or credential would be dropped
517
+ * or leaked rather than honored.
518
+ */
519
+ function assertCacheOrigin(field: 'server' | 'internalServer', value: string): void {
520
+ let url: URL | null = null;
521
+ try {
522
+ url = new URL(value);
523
+ } catch {
524
+ url = null;
525
+ }
526
+ if (
527
+ url === null ||
528
+ (url.protocol !== 'https:' && url.protocol !== 'http:') ||
529
+ url.pathname !== '/' ||
530
+ value.endsWith('/') ||
531
+ url.username !== '' ||
532
+ url.password !== '' ||
533
+ url.search !== '' ||
534
+ url.hash !== ''
535
+ ) {
536
+ throw new Error(
537
+ `smoo.remoteCache ${field} needs a credential-free http(s) origin with no path and no trailing slash, got ${JSON.stringify(value)}`,
538
+ );
539
+ }
540
+ }
541
+
462
542
  /**
463
543
  * Job env lines carrying the declared Cargo credential secrets. Both registry
464
544
  * tokens and git-origin tokens ride the job env so the credential helper is
@@ -488,7 +568,10 @@ export function cargoCredentialJobEnvLines(config: PackageCargoCredentialsConfig
488
568
  * `internalMirror` are rewritten to the mirror with `url.<mirror>.insteadOf`
489
569
  * and the helper answers the same credential for the mirror's host, since git
490
570
  * passes helpers the rewritten URL. The helper reads the environment at call
491
- * time and answers only for declared origins.
571
+ * time and answers only for declared origins. Declared `sshOrigins` rewrite
572
+ * onto the same mirror and stay credential-free: the rewrite happens before
573
+ * transport, so git only ever asks for the mirror, and an SSH pin the runner
574
+ * has no key for fails loudly instead of collecting a token.
492
575
  * Mechanism errors surface at managed-file render time, never inside CI.
493
576
  */
494
577
  export function cargoCredentialStepLines(step: CiWorkflowStep, config: PackageCargoCredentialsConfig): string[] {
@@ -576,23 +659,52 @@ export function cargoCredentialStepLines(step: CiWorkflowStep, config: PackageCa
576
659
  lines.push(' fi');
577
660
  lines.push(' # Internal mirrors rewrite the declared origin prefix so runners');
578
661
  lines.push(' # that cannot reach the public URL fetch over guest networking.');
662
+ if (origins.some((origin) => origin.sshOrigins !== undefined)) {
663
+ lines.push(' # The same forge over SSH, as Cargo and uv pin it: git matches');
664
+ lines.push(' # insteadOf values as literal URL prefixes, so every declared');
665
+ lines.push(' # spelling rewrites onto the mirror on its own line.');
666
+ }
579
667
  for (const origin of origins) {
580
668
  if (origin.internalMirror === undefined) {
581
669
  continue;
582
670
  }
583
- const originUrl = new URL(origin.origin);
584
671
  const mirrorUrl = new URL(origin.internalMirror);
585
- const originBase = `${originUrl.protocol}//${originUrl.host}/`.replaceAll("'", "'\\''");
586
672
  const mirrorBase = `${mirrorUrl.protocol}//${mirrorUrl.host}/`.replaceAll("'", "'\\''");
587
- lines.push(' echo "GIT_CONFIG_KEY_${idx}=url.' + mirrorBase + '.insteadOf"');
588
- lines.push(' echo "GIT_CONFIG_VALUE_${idx}=' + originBase + '"');
589
- lines.push(' idx=$((idx + 1))');
673
+ for (const prefix of mirrorRewritePrefixes(origin)) {
674
+ lines.push(' echo "GIT_CONFIG_KEY_${idx}=url.' + mirrorBase + '.insteadOf"');
675
+ lines.push(' echo "GIT_CONFIG_VALUE_${idx}=' + prefix.replaceAll("'", "'\\''") + '"');
676
+ lines.push(' idx=$((idx + 1))');
677
+ }
590
678
  }
591
679
  lines.push(' echo "GIT_CONFIG_COUNT=${idx}"');
592
680
  lines.push(' } >> "$GITHUB_ENV"');
593
681
  return lines;
594
682
  }
595
683
 
684
+ /**
685
+ * Every URL prefix a mirrored origin rewrites from: the declared https origin
686
+ * first, then each declared SSH spelling of the same forge. One `insteadOf`
687
+ * value per prefix, because git matches them as literal prefixes and derives
688
+ * no spelling from another. Rendering runs after `normalizeCargoCredentials`,
689
+ * which parsed and refused every malformed entry.
690
+ */
691
+ function mirrorRewritePrefixes(origin: PackageCargoGitOrigin): string[] {
692
+ const originUrl = new URL(origin.origin);
693
+ return [
694
+ `${originUrl.protocol}//${originUrl.host}/`,
695
+ ...(origin.sshOrigins ?? []).map((spelling) => sshRewritePrefix(new URL(spelling))),
696
+ ];
697
+ }
698
+
699
+ /**
700
+ * The SSH spelling as a rewritable prefix: userinfo is part of the spelling
701
+ * git matches, and the trailing slash keeps `ssh://host:2223` from also
702
+ * rewriting `ssh://host:22230/`.
703
+ */
704
+ function sshRewritePrefix(spelling: URL): string {
705
+ return `ssh://${spelling.username === '' ? '' : `${spelling.username}@`}${spelling.host}/`;
706
+ }
707
+
596
708
  function distinctCargoTokenEnvs(config: PackageCargoCredentialsConfig): string[] {
597
709
  const seen = new Set<string>();
598
710
  for (const env of config.registryTokenEnvs ?? []) {
@@ -614,12 +726,24 @@ export function normalizeCargoCredentials(config: PackageCargoCredentialsConfig)
614
726
  }
615
727
  }
616
728
  const hosts = new Set<string>();
729
+ const sshPrefixes = new Set<string>();
617
730
  for (const origin of config.gitOrigins ?? []) {
618
731
  const host = new URL(normalizeCargoGitOrigin(origin).origin).host;
619
732
  if (hosts.has(host)) {
620
733
  throw new Error(`smoo.github.cargoCredentials repeats git origin host ${host}; declare one token per origin`);
621
734
  }
622
735
  hosts.add(host);
736
+ for (const spelling of origin.sshOrigins ?? []) {
737
+ // One spelling, one rewrite, whole config: a repeat renders two
738
+ // identical insteadOf keys and git silently keeps the last one read.
739
+ const prefix = sshRewritePrefix(assertSshOrigin(spelling));
740
+ if (sshPrefixes.has(prefix)) {
741
+ throw new Error(
742
+ `smoo.github.cargoCredentials repeats the sshOrigins spelling ${prefix}; declare each spelling once, on the origin whose mirror rewrites it`,
743
+ );
744
+ }
745
+ sshPrefixes.add(prefix);
746
+ }
623
747
  }
624
748
  if ((config.registryTokenEnvs?.length ?? 0) === 0 && (config.gitOrigins?.length ?? 0) === 0) {
625
749
  throw new Error(
@@ -680,9 +804,54 @@ function normalizeCargoGitOrigin(origin: PackageCargoGitOrigin): PackageCargoGit
680
804
  );
681
805
  }
682
806
  }
807
+ if (origin.sshOrigins !== undefined) {
808
+ if (origin.internalMirror === undefined) {
809
+ throw new Error(
810
+ `smoo.github.cargoCredentials gitOrigins entry declares sshOrigins for ${origin.origin} without an internalMirror to rewrite them onto; an SSH spelling is a rewrite source and nothing else`,
811
+ );
812
+ }
813
+ if (origin.sshOrigins.length === 0) {
814
+ throw new Error(
815
+ `smoo.github.cargoCredentials gitOrigins entry for ${origin.origin} declares an empty sshOrigins list; name every SSH spelling a lockfile can carry, or omit the field`,
816
+ );
817
+ }
818
+ for (const spelling of origin.sshOrigins) {
819
+ assertSshOrigin(spelling);
820
+ }
821
+ }
683
822
  return origin;
684
823
  }
685
824
 
825
+ /**
826
+ * One declared SSH spelling, parsed. Credential-free and path-free: a
827
+ * password here would be a secret committed in package.json, and a path
828
+ * would rewrite a single repository instead of the forge. scp syntax
829
+ * (`git@host:org/repo.git`) is no URL, so git cannot rewrite it from a Cargo
830
+ * or uv pin at all; the `ssh://` spelling is the one to declare.
831
+ */
832
+ function assertSshOrigin(spelling: string): URL {
833
+ let ssh: URL | null = null;
834
+ try {
835
+ ssh = new URL(spelling);
836
+ } catch {
837
+ ssh = null;
838
+ }
839
+ if (
840
+ ssh === null ||
841
+ ssh.protocol !== 'ssh:' ||
842
+ ssh.host === '' ||
843
+ (ssh.pathname !== '' && ssh.pathname !== '/') ||
844
+ ssh.password !== '' ||
845
+ ssh.search !== '' ||
846
+ ssh.hash !== ''
847
+ ) {
848
+ throw new Error(
849
+ `smoo.github.cargoCredentials gitOrigins entry needs credential-free ssh:// sshOrigins without a path, got ${JSON.stringify(spelling)}`,
850
+ );
851
+ }
852
+ return ssh;
853
+ }
854
+
686
855
  /**
687
856
  * The same-repo gate as prettier folds it: the raw single line exceeds the
688
857
  * print width, so the generator emits the folded form itself to keep the
@@ -828,7 +997,7 @@ ${renderRunsOnLine(options.runsOn)}
828
997
  ${environmentLine(options.environments?.staging)} if: \${{ needs.main.result == 'success' && needs.main.outputs.deployment-stage != '' }}
829
998
  env:
830
999
  GH_TOKEN: \${{ github.token }}
831
- ${cargoCredentialJobEnvLines(options.cargoCredentials)}${privateNpmReadTokenJobEnv(options)} steps:
1000
+ ${remoteCacheJobEnvLines(options.remoteCache)}${cargoCredentialJobEnvLines(options.cargoCredentials)}${privateNpmReadTokenJobEnv(options)} steps:
832
1001
  ${renderCiWorkflowSteps(followUpSetupSteps(options, numbers), options)}
833
1002
  # Step ${numbers.middle}
834
1003
  - name: E2E Tests (Deployed Stage)
@@ -859,7 +1028,7 @@ ${renderRunsOnLine(options.runsOn)}
859
1028
  if: \${{ !cancelled() && github.event_name == 'push' && github.ref == ${stagingRefLiteral(options)} && needs.main.result == 'success'${e2eGate} }}
860
1029
  ${environmentLine(options.environments?.production)} env:
861
1030
  GH_TOKEN: \${{ github.token }}
862
- ${cargoCredentialJobEnvLines(options.cargoCredentials)}${privateNpmReadTokenJobEnv(options)} steps:
1031
+ ${remoteCacheJobEnvLines(options.remoteCache)}${cargoCredentialJobEnvLines(options.cargoCredentials)}${privateNpmReadTokenJobEnv(options)} steps:
863
1032
  ${renderCiWorkflowSteps(followUpSetupSteps(options, numbers), options)}
864
1033
  # Step ${numbers.middle}
865
1034
  - name: 🚀 Deploy Production
@@ -47,6 +47,8 @@ export interface ValidateOptions {
47
47
  onlyIfNewWorkspacePackage?: boolean;
48
48
  fix?: boolean;
49
49
  verbose?: boolean;
50
+ /** Nx project names to build and pack-validate; see MonorepoContext.projects. */
51
+ projects?: readonly string[];
50
52
  }
51
53
 
52
54
  export interface ValidateCommitMessageOptions {
@@ -85,7 +87,10 @@ export async function validateMonorepo(root: string, options: ValidateOptions =
85
87
  if (options.onlyIfNewWorkspacePackage && !(await hasNewWorkspacePackage(root))) {
86
88
  return;
87
89
  }
88
- const result = await runValidatePacks({ root, syncRuntime: false, verbose: options.verbose === true }, options);
90
+ const result = await runValidatePacks(
91
+ { root, syncRuntime: false, verbose: options.verbose === true, projects: options.projects },
92
+ options,
93
+ );
89
94
  if (result.failures > 0) {
90
95
  const checkNoun = result.failedChecks === 1 ? 'check' : 'checks';
91
96
  const problemNoun = result.failures === 1 ? 'problem' : 'problems';
@@ -10,6 +10,7 @@ import {
10
10
  type PackageCargoCredentialsConfig,
11
11
  type PackageJson,
12
12
  type PackagePrivateNpmConfig,
13
+ type PackageRemoteCacheConfig,
13
14
  type PackageSmooGithub,
14
15
  type PackageSmooGithubEnvironments,
15
16
  type PackageSourceCheckoutConfig,
@@ -176,6 +177,8 @@ export interface ManagedFileContext {
176
177
  sourceCheckouts?: PackageSourceCheckoutConfig[];
177
178
  /** Declared Cargo private-dependency credentials from the root smoo config; absent means none. */
178
179
  cargoCredentials?: PackageCargoCredentialsConfig;
180
+ /** Declared self-hosted Nx remote cache from the root smoo config; absent means local caching only. */
181
+ remoteCache?: PackageRemoteCacheConfig;
179
182
  }
180
183
 
181
184
  interface DeployTargetInfo {
@@ -391,6 +394,7 @@ function getManagedContent(file: ManagedFile, context: ManagedFileContext): stri
391
394
  privateNpm: context.privateNpm,
392
395
  sourceCheckouts: context.sourceCheckouts,
393
396
  cargoCredentials: context.cargoCredentials,
397
+ remoteCache: context.remoteCache,
394
398
  environments: context.ciEnvironments,
395
399
  deploySecrets: context.ciDeploySecrets,
396
400
  e2eSecrets: context.ciE2eSecrets,
@@ -414,6 +418,7 @@ function getManagedContent(file: ManagedFile, context: ManagedFileContext): stri
414
418
  privateNpm: context.privateNpm,
415
419
  sourceCheckouts: context.sourceCheckouts,
416
420
  cargoCredentials: context.cargoCredentials,
421
+ remoteCache: context.remoteCache,
417
422
  deploySecrets: context.ciDeploySecrets,
418
423
  });
419
424
  }
@@ -476,6 +481,7 @@ async function getManagedFileContext(root: string): Promise<ManagedFileContext>
476
481
  platformTargetGlobs,
477
482
  sourceCheckouts,
478
483
  cargoCredentials,
484
+ remoteCache: manifest?.smoo?.remoteCache,
479
485
  macosPlatformArchitectures: macosPlatformArchitecturesForTest(targetNames),
480
486
  privateNpm,
481
487
  };
@@ -21,25 +21,40 @@ export async function validatePackedPublishablePackages(root: string): Promise<n
21
21
  );
22
22
  }
23
23
 
24
- export async function validatePackedPublishablePackagePublint(root: string): Promise<number> {
24
+ /** The publishable packages, narrowed to `projects` (Nx names) when given. */
25
+ function selectedPublishablePackages(root: string, projects?: readonly string[]): PackageInfo[] {
26
+ const all = listPublishablePackages(root);
27
+ return projects?.length ? all.filter((pkg) => projects.includes(pkg.projectName)) : all;
28
+ }
29
+
30
+ export async function validatePackedPublishablePackagePublint(
31
+ root: string,
32
+ projects?: readonly string[],
33
+ ): Promise<number> {
25
34
  let failures = 0;
26
- for (const pkg of listPublishablePackages(root)) {
35
+ for (const pkg of selectedPublishablePackages(root, projects)) {
27
36
  failures += await validatePackedPublishablePackageTool(root, pkg, validatePublint);
28
37
  }
29
38
  return failures;
30
39
  }
31
40
 
32
- export async function validatePackedPublishablePackageManifest(root: string): Promise<number> {
41
+ export async function validatePackedPublishablePackageManifest(
42
+ root: string,
43
+ projects?: readonly string[],
44
+ ): Promise<number> {
33
45
  let failures = 0;
34
- for (const pkg of listPublishablePackages(root)) {
46
+ for (const pkg of selectedPublishablePackages(root, projects)) {
35
47
  failures += await validatePackedPublishablePackageTool(root, pkg, validatePackedManifest);
36
48
  }
37
49
  return failures;
38
50
  }
39
51
 
40
- export async function validatePackedPublishablePackageTypes(root: string): Promise<number> {
52
+ export async function validatePackedPublishablePackageTypes(
53
+ root: string,
54
+ projects?: readonly string[],
55
+ ): Promise<number> {
41
56
  let failures = 0;
42
- for (const pkg of listPublishablePackages(root)) {
57
+ for (const pkg of selectedPublishablePackages(root, projects)) {
43
58
  failures += await validatePackedPublishablePackageTool(root, pkg, validateAttw);
44
59
  }
45
60
  return failures;