@smoothbricks/cli 0.11.11 → 0.11.13
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 +104 -20
- package/dist/lib/json.d.ts +51 -0
- package/dist/lib/json.d.ts.map +1 -1
- package/dist/lib/json.js +111 -67
- package/dist/monorepo/cargo-policy.d.ts +25 -1
- package/dist/monorepo/cargo-policy.d.ts.map +1 -1
- package/dist/monorepo/cargo-policy.js +396 -4
- package/dist/monorepo/ci-workflow.d.ts +27 -2
- package/dist/monorepo/ci-workflow.d.ts.map +1 -1
- package/dist/monorepo/ci-workflow.js +151 -11
- package/dist/monorepo/index.d.ts.map +1 -1
- package/dist/monorepo/index.js +5 -1
- package/dist/monorepo/managed-files.d.ts +3 -1
- package/dist/monorepo/managed-files.d.ts.map +1 -1
- package/dist/monorepo/managed-files.js +3 -0
- package/dist/monorepo/packs/index.d.ts.map +1 -1
- package/dist/monorepo/packs/index.js +13 -0
- package/dist/monorepo/publish-workflow.d.ts +8 -1
- package/dist/monorepo/publish-workflow.d.ts.map +1 -1
- package/dist/monorepo/publish-workflow.js +15 -5
- package/managed/raw/tooling/direnv/devenv.smoo.nix +28 -2
- package/managed/raw/tooling/direnv/secret-references.ts +248 -51
- package/managed/templates/github/actions/save-nix-devenv/action.yml +2 -2
- package/managed/templates/github/actions/setup-devenv/action.yml +43 -5
- package/package.json +2 -2
- package/src/lib/json.ts +52 -0
- package/src/monorepo/__tests__/ci-workflow.test.ts +183 -0
- package/src/monorepo/__tests__/publish-workflow.test.ts +29 -0
- package/src/monorepo/cargo-policy.test.ts +206 -3
- package/src/monorepo/cargo-policy.ts +341 -2
- package/src/monorepo/ci-workflow.ts +180 -11
- package/src/monorepo/index.ts +5 -1
- package/src/monorepo/managed-files.ts +6 -0
- package/src/monorepo/packs/index.test.ts +32 -0
- package/src/monorepo/packs/index.ts +13 -0
- package/src/monorepo/publish-workflow.ts +24 -4
- package/src/monorepo/secret-references.test.ts +175 -2
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { type Dirent, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
3
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
3
4
|
import typia from 'typia';
|
|
4
5
|
import { parsePackageJsonText } from '../lib/json.js';
|
|
@@ -21,6 +22,11 @@ interface CargoManifest {
|
|
|
21
22
|
workspace?: string | boolean;
|
|
22
23
|
};
|
|
23
24
|
profile?: Record<string, CargoProfile>;
|
|
25
|
+
// Only the KEYS matter here: a workspace-hack is wired in by name, and every
|
|
26
|
+
// dependency spelling — string, table, inherited — carries the same key.
|
|
27
|
+
dependencies?: Record<string, unknown>;
|
|
28
|
+
'dev-dependencies'?: Record<string, unknown>;
|
|
29
|
+
'build-dependencies'?: Record<string, unknown>;
|
|
24
30
|
}
|
|
25
31
|
|
|
26
32
|
interface CargoConfigTarget {
|
|
@@ -42,6 +48,22 @@ interface CargoConfig {
|
|
|
42
48
|
};
|
|
43
49
|
target?: Record<string, CargoConfigTarget>;
|
|
44
50
|
env?: Record<string, string | CargoConfigEnvObject>;
|
|
51
|
+
resolver?: {
|
|
52
|
+
'feature-unification'?: string;
|
|
53
|
+
};
|
|
54
|
+
unstable?: {
|
|
55
|
+
'feature-unification'?: boolean;
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface RustToolchainFile {
|
|
60
|
+
toolchain?: {
|
|
61
|
+
channel?: string;
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface HakariConfig {
|
|
66
|
+
'hakari-package'?: string;
|
|
45
67
|
}
|
|
46
68
|
|
|
47
69
|
interface LoadedManifest {
|
|
@@ -62,6 +84,45 @@ interface EffectiveProfile {
|
|
|
62
84
|
|
|
63
85
|
const validateCargoManifest = typia.createValidate<CargoManifest>();
|
|
64
86
|
const validateCargoConfig = typia.createValidate<CargoConfig>();
|
|
87
|
+
const validateRustToolchainFile = typia.createValidate<RustToolchainFile>();
|
|
88
|
+
const validateHakariConfig = typia.createValidate<HakariConfig>();
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The one command this policy shells out to, behind a seam. `cargo hakari
|
|
92
|
+
* verify` is the only authority on whether a generated workspace-hack still
|
|
93
|
+
* unifies what the workspace resolves today; nothing in these files can answer
|
|
94
|
+
* that. Tests supply their own so the policy stays runnable without the binary.
|
|
95
|
+
*/
|
|
96
|
+
export interface CargoHakariShell {
|
|
97
|
+
run(directory: string, args: readonly string[]): { code: number; output: string; missing: boolean };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const defaultHakariShell: CargoHakariShell = {
|
|
101
|
+
run(directory, args) {
|
|
102
|
+
const result = spawnSync('cargo', ['hakari', ...args], { cwd: directory, encoding: 'utf8' });
|
|
103
|
+
const output = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim();
|
|
104
|
+
// cargo itself exists; a missing subcommand is cargo's own error, not ENOENT.
|
|
105
|
+
const missing =
|
|
106
|
+
(result.error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT' || output.includes('no such command');
|
|
107
|
+
return { code: result.status ?? 1, output, missing };
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/** Cargo's nightly-only workspace feature unification, and the config that turns it on. */
|
|
112
|
+
const RESOLVER_UNIFICATION_BLOCK = [
|
|
113
|
+
'# One feature resolution for the whole workspace. Without it every per-crate',
|
|
114
|
+
'# cargo invocation resolves features for its own selection, so one shared',
|
|
115
|
+
'# dependency is compiled once per selection and no warm target directory is',
|
|
116
|
+
'# reused across crates. Requires the nightly channel the managed devenv',
|
|
117
|
+
'# module pins; on a stable toolchain a cargo-hakari workspace-hack does the',
|
|
118
|
+
'# same job. Both keys are needed: cargo ignores [resolver] without the',
|
|
119
|
+
'# [unstable] opt-in.',
|
|
120
|
+
'[unstable]',
|
|
121
|
+
'feature-unification = true',
|
|
122
|
+
'',
|
|
123
|
+
'[resolver]',
|
|
124
|
+
'feature-unification = "workspace"',
|
|
125
|
+
].join('\n');
|
|
65
126
|
|
|
66
127
|
const SKIPPED_DIRECTORY_NAMES = new Set([
|
|
67
128
|
'node_modules',
|
|
@@ -256,6 +317,188 @@ function hasAncestorWorkspace(manifest: LoadedManifest, workspaceRoots: LoadedMa
|
|
|
256
317
|
);
|
|
257
318
|
}
|
|
258
319
|
|
|
320
|
+
/**
|
|
321
|
+
* The channel that will actually compile this repository.
|
|
322
|
+
*
|
|
323
|
+
* The managed devenv module wins when it exists: devenv resolves the toolchain
|
|
324
|
+
* through rust-overlay and ignores `rust-toolchain.toml` unless
|
|
325
|
+
* `languages.rust.toolchainFile` names it, so a rust-toolchain file beside that
|
|
326
|
+
* module is decoration. Without the module, rustup's file is the answer. With
|
|
327
|
+
* neither, stable is the safe verdict: it is the channel on which the nightly
|
|
328
|
+
* resolver keys silently do nothing.
|
|
329
|
+
*/
|
|
330
|
+
function isNightlyToolchain(repositoryRoot: string, workspaceDirectory: string): boolean {
|
|
331
|
+
const devenvModule = join(repositoryRoot, 'tooling/direnv/devenv.smoo.nix');
|
|
332
|
+
if (existsSync(devenvModule)) {
|
|
333
|
+
const text = readFileSync(devenvModule, 'utf8');
|
|
334
|
+
const rustIndex = text.indexOf('languages.rust');
|
|
335
|
+
const channel = rustIndex === -1 ? null : /channel\s*=\s*"([^"]+)"/.exec(text.slice(rustIndex))?.[1];
|
|
336
|
+
return channel?.startsWith('nightly') === true;
|
|
337
|
+
}
|
|
338
|
+
for (const directory of [workspaceDirectory, repositoryRoot]) {
|
|
339
|
+
const path = join(directory, 'rust-toolchain.toml');
|
|
340
|
+
if (!existsSync(path)) {
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
try {
|
|
344
|
+
const validation = validateRustToolchainFile(Bun.TOML.parse(readFileSync(path, 'utf8')));
|
|
345
|
+
return validation.success && validation.data.toolchain?.channel?.startsWith('nightly') === true;
|
|
346
|
+
} catch {
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return false;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Cargo merges `.cargo/config.toml` from the invocation directory upward, with
|
|
355
|
+
* the deepest file winning. Only the configs at or above the workspace root can
|
|
356
|
+
* govern a command run there, so the effective value is the deepest of those.
|
|
357
|
+
*/
|
|
358
|
+
function effectiveConfigValue<T>(
|
|
359
|
+
configs: LoadedConfig[],
|
|
360
|
+
workspaceDirectory: string,
|
|
361
|
+
read: (config: CargoConfig) => T | undefined,
|
|
362
|
+
): T | undefined {
|
|
363
|
+
let deepest: { directory: string; value: T } | null = null;
|
|
364
|
+
for (const loaded of configs) {
|
|
365
|
+
const directory = dirname(dirname(loaded.path));
|
|
366
|
+
if (!pathWithin(directory, workspaceDirectory)) {
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
const value = read(loaded.config);
|
|
370
|
+
if (value === undefined) {
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
if (deepest === null || directory.length > deepest.directory.length) {
|
|
374
|
+
deepest = { directory, value };
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return deepest?.value;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function workspaceCrates(root: LoadedManifest, manifests: LoadedManifest[]): LoadedManifest[] {
|
|
381
|
+
const members = manifests.filter((manifest) => workspaceContains(root, manifest));
|
|
382
|
+
return root.manifest.package === undefined ? members : [root, ...members];
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const FEATURE_UNIFICATION_FIX = 'Run: smoo monorepo update';
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* A workspace of two or more crates must resolve features ONCE for all of them.
|
|
389
|
+
*
|
|
390
|
+
* Without that, every per-crate cargo invocation resolves features for its own
|
|
391
|
+
* selection, and one shared dependency is compiled once per selection: the same
|
|
392
|
+
* `nx run cargo-test-<crate>` that should reuse a warm target directory rebuilds
|
|
393
|
+
* its half of the graph instead. Cargo's own mechanism is nightly-only, so a
|
|
394
|
+
* stable toolchain reaches the identical result through a cargo-hakari
|
|
395
|
+
* workspace-hack — a generated crate that depends on the union of features, which
|
|
396
|
+
* every member then depends on.
|
|
397
|
+
*
|
|
398
|
+
* One crate needs neither: there is only one selection to unify.
|
|
399
|
+
*/
|
|
400
|
+
function featureUnificationPolicy(
|
|
401
|
+
repositoryRoot: string,
|
|
402
|
+
workspaceRoots: LoadedManifest[],
|
|
403
|
+
manifests: LoadedManifest[],
|
|
404
|
+
configs: LoadedConfig[],
|
|
405
|
+
shell: CargoHakariShell,
|
|
406
|
+
): number {
|
|
407
|
+
let failures = 0;
|
|
408
|
+
for (const root of workspaceRoots) {
|
|
409
|
+
const crates = workspaceCrates(root, manifests);
|
|
410
|
+
if (crates.length < 2) {
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
const nightly = isNightlyToolchain(repositoryRoot, root.directory);
|
|
414
|
+
const resolver = effectiveConfigValue(
|
|
415
|
+
configs,
|
|
416
|
+
root.directory,
|
|
417
|
+
(config) => config.resolver?.['feature-unification'],
|
|
418
|
+
);
|
|
419
|
+
const unstable = effectiveConfigValue(
|
|
420
|
+
configs,
|
|
421
|
+
root.directory,
|
|
422
|
+
(config) => config.unstable?.['feature-unification'],
|
|
423
|
+
);
|
|
424
|
+
if (nightly && resolver === 'workspace') {
|
|
425
|
+
if (unstable !== true) {
|
|
426
|
+
failures += report(
|
|
427
|
+
join(root.directory, '.cargo/config.toml'),
|
|
428
|
+
'sets [resolver] feature-unification = "workspace" without [unstable] feature-unification = true, ' +
|
|
429
|
+
`so cargo ignores it and every crate still resolves features on its own. ${FEATURE_UNIFICATION_FIX}`,
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
if (existsSync(join(root.directory, '.config/hakari.toml'))) {
|
|
435
|
+
failures += hakariWorkspaceHackPolicy(root, crates, shell);
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
failures += report(
|
|
439
|
+
root.path,
|
|
440
|
+
`workspace of ${crates.length} crates has no workspace-wide feature unification: ` +
|
|
441
|
+
(nightly
|
|
442
|
+
? 'add [unstable] feature-unification = true and [resolver] feature-unification = "workspace" to .cargo/config.toml'
|
|
443
|
+
: 'the [resolver] feature-unification key needs the nightly channel, so this stable toolchain needs a ' +
|
|
444
|
+
'cargo-hakari workspace-hack (.config/hakari.toml)') +
|
|
445
|
+
`. Every per-crate cargo invocation otherwise recompiles the shared graph for its own feature selection. ${FEATURE_UNIFICATION_FIX}`,
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
return failures;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function hakariWorkspaceHackPolicy(root: LoadedManifest, crates: LoadedManifest[], shell: CargoHakariShell): number {
|
|
452
|
+
const configPath = join(root.directory, '.config/hakari.toml');
|
|
453
|
+
let hackName: string | undefined;
|
|
454
|
+
try {
|
|
455
|
+
const validation = validateHakariConfig(Bun.TOML.parse(readFileSync(configPath, 'utf8')));
|
|
456
|
+
hackName = validation.success ? validation.data['hakari-package'] : undefined;
|
|
457
|
+
} catch {
|
|
458
|
+
hackName = undefined;
|
|
459
|
+
}
|
|
460
|
+
if (hackName === undefined || hackName.length === 0) {
|
|
461
|
+
return report(configPath, `must declare hakari-package = "<crate>". ${FEATURE_UNIFICATION_FIX}`);
|
|
462
|
+
}
|
|
463
|
+
const hack = crates.find((crate) => crate.manifest.package?.name === hackName);
|
|
464
|
+
if (hack === undefined) {
|
|
465
|
+
return report(
|
|
466
|
+
configPath,
|
|
467
|
+
`names hakari-package "${hackName}", which is not a member of this workspace. ${FEATURE_UNIFICATION_FIX}`,
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
const unwired = crates
|
|
471
|
+
.filter((crate) => crate !== hack)
|
|
472
|
+
.filter(
|
|
473
|
+
(crate) =>
|
|
474
|
+
![crate.manifest.dependencies, crate.manifest['dev-dependencies'], crate.manifest['build-dependencies']].some(
|
|
475
|
+
(table) => table !== undefined && hackName in table,
|
|
476
|
+
),
|
|
477
|
+
)
|
|
478
|
+
.map((crate) => crate.manifest.package?.name ?? crate.path);
|
|
479
|
+
if (unwired.length > 0) {
|
|
480
|
+
return report(
|
|
481
|
+
root.path,
|
|
482
|
+
`these crates do not depend on "${hackName}", so cargo-hakari cannot unify their features: ` +
|
|
483
|
+
`${unwired.join(', ')}. ${FEATURE_UNIFICATION_FIX}`,
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
const verify = shell.run(root.directory, ['verify']);
|
|
487
|
+
if (verify.missing) {
|
|
488
|
+
return report(
|
|
489
|
+
configPath,
|
|
490
|
+
'needs cargo-hakari, which is not installed; the managed devenv module provides it — reload the shell (direnv reload).',
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
if (verify.code !== 0) {
|
|
494
|
+
return report(
|
|
495
|
+
root.path,
|
|
496
|
+
`cargo hakari verify rejected the workspace-hack: ${verify.output || 'no output'}. ${FEATURE_UNIFICATION_FIX}`,
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
return 0;
|
|
500
|
+
}
|
|
501
|
+
|
|
259
502
|
function isWorkspaceMember(manifest: LoadedManifest, workspaceRoots: LoadedManifest[]): boolean {
|
|
260
503
|
if (manifest.manifest.package?.workspace !== undefined) {
|
|
261
504
|
return true;
|
|
@@ -563,7 +806,12 @@ function reportManifestDirectoryAdvisories(repositoryRoot: string, ignoredDirect
|
|
|
563
806
|
}
|
|
564
807
|
}
|
|
565
808
|
|
|
566
|
-
export
|
|
809
|
+
export interface CargoPolicyOptions {
|
|
810
|
+
/** Injected in tests; production shells out to the real `cargo hakari`. */
|
|
811
|
+
shell?: CargoHakariShell;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
export function validateCargoCachePolicy(root: string, options: CargoPolicyOptions = {}): number {
|
|
567
815
|
const repositoryRoot = resolve(root);
|
|
568
816
|
const discoveredManifestPaths = discoverFiles(repositoryRoot, (name) => name === 'Cargo.toml');
|
|
569
817
|
const ignoredDirectories = discoverIgnoredSubtrees(discoveredManifestPaths);
|
|
@@ -606,6 +854,13 @@ export function validateCargoCachePolicy(root: string): number {
|
|
|
606
854
|
|
|
607
855
|
const workspaceRoots = manifests.filter((loaded) => loaded.manifest.workspace !== undefined);
|
|
608
856
|
failures += reportManifestPolicy(manifests, workspaceRoots);
|
|
857
|
+
failures += featureUnificationPolicy(
|
|
858
|
+
repositoryRoot,
|
|
859
|
+
workspaceRoots,
|
|
860
|
+
manifests,
|
|
861
|
+
configs,
|
|
862
|
+
options.shell ?? defaultHakariShell,
|
|
863
|
+
);
|
|
609
864
|
failures += cargoIncrementalPolicy(repositoryRoot, configs, packageJsonPaths, justfilePaths, ignoredDirectories);
|
|
610
865
|
for (const config of configs) {
|
|
611
866
|
failures += configPolicy(config, repositoryRoot);
|
|
@@ -613,3 +868,87 @@ export function validateCargoCachePolicy(root: string): number {
|
|
|
613
868
|
reportManifestDirectoryAdvisories(repositoryRoot, ignoredDirectories);
|
|
614
869
|
return failures;
|
|
615
870
|
}
|
|
871
|
+
|
|
872
|
+
/**
|
|
873
|
+
* The fix `smoo monorepo update` applies for the policy above: write the
|
|
874
|
+
* nightly resolver configuration, or generate and wire the workspace-hack a
|
|
875
|
+
* stable toolchain needs. Both are idempotent — the second run of either does
|
|
876
|
+
* nothing — because update runs on every repository, not only broken ones.
|
|
877
|
+
*/
|
|
878
|
+
export function applyCargoFeatureUnification(root: string, options: CargoPolicyOptions = {}): void {
|
|
879
|
+
const repositoryRoot = resolve(root);
|
|
880
|
+
const shell = options.shell ?? defaultHakariShell;
|
|
881
|
+
const discoveredManifestPaths = discoverFiles(repositoryRoot, (name) => name === 'Cargo.toml');
|
|
882
|
+
const manifestPaths = filterIgnoredPaths(discoveredManifestPaths, discoverIgnoredSubtrees(discoveredManifestPaths));
|
|
883
|
+
const manifests: LoadedManifest[] = [];
|
|
884
|
+
for (const path of manifestPaths) {
|
|
885
|
+
const manifest = loadManifest(path);
|
|
886
|
+
if (manifest !== null) {
|
|
887
|
+
manifests.push({ path, directory: dirname(path), manifest });
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
for (const root of manifests.filter((loaded) => loaded.manifest.workspace !== undefined)) {
|
|
891
|
+
if (workspaceCrates(root, manifests).length < 2) {
|
|
892
|
+
continue;
|
|
893
|
+
}
|
|
894
|
+
if (isNightlyToolchain(repositoryRoot, root.directory)) {
|
|
895
|
+
writeResolverUnification(root.directory);
|
|
896
|
+
continue;
|
|
897
|
+
}
|
|
898
|
+
const hakariConfig = join(root.directory, '.config/hakari.toml');
|
|
899
|
+
const relativeRoot = relative(repositoryRoot, root.directory) || '.';
|
|
900
|
+
if (!existsSync(hakariConfig)) {
|
|
901
|
+
console.log(`generating cargo workspace-hack in ${relativeRoot} (cargo hakari init workspace-hack)`);
|
|
902
|
+
runHakari(shell, root.directory, ['init', 'workspace-hack']);
|
|
903
|
+
}
|
|
904
|
+
console.log(`unifying cargo features in ${relativeRoot} (cargo hakari generate, manage-deps)`);
|
|
905
|
+
runHakari(shell, root.directory, ['generate']);
|
|
906
|
+
runHakari(shell, root.directory, ['manage-deps', '--yes']);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
function runHakari(shell: CargoHakariShell, directory: string, args: readonly string[]): void {
|
|
911
|
+
const result = shell.run(directory, args);
|
|
912
|
+
if (result.missing) {
|
|
913
|
+
console.error(
|
|
914
|
+
`cargo hakari is not installed, so the workspace-hack in ${directory} was not updated; the managed devenv module provides it — reload the shell (direnv reload).`,
|
|
915
|
+
);
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
if (result.code !== 0) {
|
|
919
|
+
console.error(`cargo hakari ${args.join(' ')} failed in ${directory}: ${result.output || 'no output'}`);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* Append the two keys, never rewrite the file. A `.cargo/config.toml` carries
|
|
925
|
+
* linkers, env and target settings whose ORDER and comments are load-bearing;
|
|
926
|
+
* re-emitting parsed TOML would lose both. If either table already exists with
|
|
927
|
+
* some other content, appending a second one is invalid TOML — so that case is
|
|
928
|
+
* reported for a human instead of being guessed at.
|
|
929
|
+
*/
|
|
930
|
+
function writeResolverUnification(workspaceDirectory: string): void {
|
|
931
|
+
const path = join(workspaceDirectory, '.cargo/config.toml');
|
|
932
|
+
const existing = existsSync(path) ? readFileSync(path, 'utf8') : null;
|
|
933
|
+
if (existing === null) {
|
|
934
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
935
|
+
writeFileSync(path, `${RESOLVER_UNIFICATION_BLOCK}\n`);
|
|
936
|
+
console.log(`writing ${path}`);
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
const parsed = loadConfig(path);
|
|
940
|
+
if (parsed === null) {
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
if (parsed.resolver?.['feature-unification'] === 'workspace' && parsed.unstable?.['feature-unification'] === true) {
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
if (parsed.resolver !== undefined || parsed.unstable !== undefined) {
|
|
947
|
+
console.error(
|
|
948
|
+
`${path}: already declares [resolver] or [unstable]; add feature-unification to the existing tables by hand:\n${RESOLVER_UNIFICATION_BLOCK}`,
|
|
949
|
+
);
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
writeFileSync(path, `${existing.replace(/\n*$/, '\n')}\n${RESOLVER_UNIFICATION_BLOCK}\n`);
|
|
953
|
+
console.log(`updating ${path}`);
|
|
954
|
+
}
|
|
@@ -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
|
|
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
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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
|