@smoothbricks/cli 0.11.10 → 0.11.12
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/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/index.d.ts.map +1 -1
- package/dist/monorepo/index.js +5 -1
- package/dist/monorepo/packs/index.d.ts.map +1 -1
- package/dist/monorepo/packs/index.js +13 -0
- package/managed/raw/tooling/direnv/devenv.smoo.nix +6 -0
- package/managed/raw/tooling/direnv/github-actions-bootstrap.sh +80 -21
- package/managed/templates/github/actions/setup-devenv/action.yml +33 -7
- package/package.json +2 -2
- package/src/monorepo/cargo-policy.test.ts +206 -3
- package/src/monorepo/cargo-policy.ts +341 -2
- package/src/monorepo/github-actions-bootstrap.test.ts +65 -11
- package/src/monorepo/index.ts +5 -1
- package/src/monorepo/packs/index.test.ts +32 -0
- package/src/monorepo/packs/index.ts +13 -0
|
@@ -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
|
+
}
|
|
@@ -140,7 +140,7 @@ interface InstallRun {
|
|
|
140
140
|
nixCalls: string;
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
-
function runInstallDevenv(lock: string, options: {
|
|
143
|
+
function runInstallDevenv(lock: string, options: { devenvVersion?: string; hostRunner?: boolean } = {}): InstallRun {
|
|
144
144
|
const dir = mkdtempSync(join(tmpdir(), 'gab-install-'));
|
|
145
145
|
try {
|
|
146
146
|
const direnv = join(dir, 'root', 'tooling', 'direnv');
|
|
@@ -154,20 +154,22 @@ function runInstallDevenv(lock: string, options: { devenvOnPath?: boolean } = {}
|
|
|
154
154
|
const stub = join(bin, 'nix');
|
|
155
155
|
writeFileSync(stub, NIX_STUB);
|
|
156
156
|
chmodSync(stub, 0o755);
|
|
157
|
-
if (options.
|
|
157
|
+
if (options.devenvVersion !== undefined) {
|
|
158
|
+
// Shaped like the real thing: `devenv <semver>+<short rev> (<system>)`.
|
|
158
159
|
const devenv = join(bin, 'devenv');
|
|
159
|
-
writeFileSync(devenv,
|
|
160
|
+
writeFileSync(devenv, `#!/usr/bin/env bash\necho "${options.devenvVersion}"\n`);
|
|
160
161
|
chmodSync(devenv, 0o755);
|
|
161
162
|
}
|
|
162
163
|
const r = spawnSync('bash', [join(direnv, 'github-actions-bootstrap.sh'), 'install-devenv'], {
|
|
163
164
|
encoding: 'utf8',
|
|
164
|
-
// A bare PATH on purpose: an ambient devenv would
|
|
165
|
-
//
|
|
165
|
+
// A bare PATH on purpose: an ambient devenv would decide these cases
|
|
166
|
+
// instead of the stub.
|
|
166
167
|
env: {
|
|
167
168
|
PATH: `${bin}:${dirname(REAL_NIX)}:/usr/bin:/bin`,
|
|
168
169
|
HOME: join(dir, 'home'),
|
|
169
170
|
NIX_CALLS: nixCalls,
|
|
170
171
|
REAL_NIX,
|
|
172
|
+
...(options.hostRunner ? { SMOO_HOST_RUNNER: 'true' } : {}),
|
|
171
173
|
},
|
|
172
174
|
});
|
|
173
175
|
return {
|
|
@@ -181,10 +183,12 @@ function runInstallDevenv(lock: string, options: { devenvOnPath?: boolean } = {}
|
|
|
181
183
|
}
|
|
182
184
|
}
|
|
183
185
|
|
|
186
|
+
const REV = 'f'.repeat(40);
|
|
187
|
+
|
|
184
188
|
const LOCK_WITH_REV = JSON.stringify({
|
|
185
189
|
nodes: {
|
|
186
190
|
devenv: {
|
|
187
|
-
locked: { dir: 'src/modules', owner: 'cachix', repo: 'devenv', rev:
|
|
191
|
+
locked: { dir: 'src/modules', owner: 'cachix', repo: 'devenv', rev: REV, type: 'github' },
|
|
188
192
|
original: { dir: 'src/modules', owner: 'cachix', repo: 'devenv', type: 'github' },
|
|
189
193
|
},
|
|
190
194
|
},
|
|
@@ -202,10 +206,10 @@ describe('github-actions-bootstrap install-devenv', () => {
|
|
|
202
206
|
// The rev comes out of the lock, so the CLI is the commit whose modules the
|
|
203
207
|
// shell is locked to. An unpinned `github:cachix/devenv` would install
|
|
204
208
|
// whatever HEAD is on the day a cold runner misses the store cache.
|
|
205
|
-
expect(run.nixCalls).toContain(`nix profile add --accept-flake-config github:cachix/devenv/${
|
|
209
|
+
expect(run.nixCalls).toContain(`nix profile add --accept-flake-config github:cachix/devenv/${REV}`);
|
|
206
210
|
expect(run.nixCalls).not.toContain('github:cachix/devenv\n');
|
|
207
211
|
// Announced, so a run's log names the version it installed.
|
|
208
|
-
expect(run.stdout).toContain(`github:cachix/devenv/${
|
|
212
|
+
expect(run.stdout).toContain(`github:cachix/devenv/${REV}`);
|
|
209
213
|
});
|
|
210
214
|
|
|
211
215
|
it('refuses to install anything when the lock names no devenv rev', () => {
|
|
@@ -216,10 +220,60 @@ describe('github-actions-bootstrap install-devenv', () => {
|
|
|
216
220
|
expect(run.nixCalls).not.toContain('profile add');
|
|
217
221
|
});
|
|
218
222
|
|
|
219
|
-
it('keeps
|
|
220
|
-
const run = runInstallDevenv(LOCK_WITH_REV, {
|
|
223
|
+
it('keeps a restored devenv built from the locked commit', () => {
|
|
224
|
+
const run = runInstallDevenv(LOCK_WITH_REV, { devenvVersion: 'devenv 2.3.1+fffffff (aarch64-darwin)' });
|
|
225
|
+
expect(run.status).toBe(0);
|
|
226
|
+
expect(run.stdout).toContain('using locked devenv');
|
|
227
|
+
// The warm path must cost nothing: no eval of the flake, no profile writes.
|
|
228
|
+
expect(run.nixCalls).toBe('');
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('replaces a restored devenv that is a different commit than the lock', () => {
|
|
232
|
+
// The real drift: the store cache restores ~/.nix-profile wholesale, so a
|
|
233
|
+
// rotated key hands the job the CLI of whichever run last populated the
|
|
234
|
+
// prefix. Observed in run 34369909403 as devenv 2.3.1+2a399e9 against a
|
|
235
|
+
// lock naming 190959a.
|
|
236
|
+
const run = runInstallDevenv(LOCK_WITH_REV, { devenvVersion: 'devenv 2.3.1+2a399e9 (aarch64-darwin)' });
|
|
237
|
+
if (run.status !== 0) {
|
|
238
|
+
printCommandOutput(run.stdout, run.stderr);
|
|
239
|
+
}
|
|
240
|
+
expect(run.status).toBe(0);
|
|
241
|
+
// Removal first: `nix profile add` collides on bin/devenv at equal priority.
|
|
242
|
+
const removeAt = run.nixCalls.indexOf('profile remove --all');
|
|
243
|
+
const addAt = run.nixCalls.indexOf(`profile add --accept-flake-config github:cachix/devenv/${REV}`);
|
|
244
|
+
expect(removeAt).toBeGreaterThanOrEqual(0);
|
|
245
|
+
expect(addAt).toBeGreaterThan(removeAt);
|
|
246
|
+
// Said out loud, with both commits, so a log explains the replacement.
|
|
247
|
+
expect(run.stdout).toContain('replacing devenv');
|
|
248
|
+
expect(run.stdout).toContain('2a399e9');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('leaves a host runner its own devenv, whatever commit it is', () => {
|
|
252
|
+
// Host runners own their Nix profile; a repository rewriting it would take
|
|
253
|
+
// the whole fleet's shared store with it.
|
|
254
|
+
const run = runInstallDevenv(LOCK_WITH_REV, {
|
|
255
|
+
devenvVersion: 'devenv 2.3.1+2a399e9 (x86_64-linux)',
|
|
256
|
+
hostRunner: true,
|
|
257
|
+
});
|
|
221
258
|
expect(run.status).toBe(0);
|
|
222
|
-
expect(run.stdout).toContain('using
|
|
259
|
+
expect(run.stdout).toContain('using host devenv');
|
|
223
260
|
expect(run.nixCalls).toBe('');
|
|
224
261
|
});
|
|
262
|
+
|
|
263
|
+
it('installs from the floating branch on a host runner with no devenv', () => {
|
|
264
|
+
// The one place drift is accepted deliberately. A host installs into a
|
|
265
|
+
// long-lived profile that roots the fleet's shared store, and holding it
|
|
266
|
+
// back to an older CLI than the fleet was running segfaulted the Rust
|
|
267
|
+
// linker in run 34374650577. Refusing to install at all was also wrong —
|
|
268
|
+
// that failed linux-release-candidate in run 34373420924.
|
|
269
|
+
const run = runInstallDevenv(LOCK_WITH_REV, { hostRunner: true });
|
|
270
|
+
if (run.status !== 0) {
|
|
271
|
+
printCommandOutput(run.stdout, run.stderr);
|
|
272
|
+
}
|
|
273
|
+
expect(run.status).toBe(0);
|
|
274
|
+
expect(run.nixCalls).toContain('nix profile add --accept-flake-config github:cachix/devenv\n');
|
|
275
|
+
expect(run.nixCalls).not.toContain(REV);
|
|
276
|
+
// Never on a host: the profile roots a store the whole fleet shares.
|
|
277
|
+
expect(run.nixCalls).not.toContain('profile remove');
|
|
278
|
+
});
|
|
225
279
|
});
|
package/src/monorepo/index.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { appendFileSync, readFileSync, writeFileSync } from 'node:fs';
|
|
|
2
2
|
import { printCommandOutput, run, runResult } from '../lib/run.js';
|
|
3
3
|
import { escapeRegex, getWorkspacePackages, getWorkspacePatterns, listReleasePackages } from '../lib/workspace.js';
|
|
4
4
|
import { readProjectTargets } from '../nx/index.js';
|
|
5
|
-
import { validateCargoCachePolicy } from './cargo-policy.js';
|
|
5
|
+
import { applyCargoFeatureUnification, validateCargoCachePolicy } from './cargo-policy.js';
|
|
6
6
|
import {
|
|
7
7
|
formatCommitMessage,
|
|
8
8
|
stagedDeletedPublicPackages,
|
|
@@ -112,6 +112,10 @@ export async function updateManagedFiles(root: string): Promise<void> {
|
|
|
112
112
|
// Tool dependency policy (typescript API 6, @typescript/native for ttsc, nx, …)
|
|
113
113
|
// lives next to managed templates — update must install them, not only rewrite files.
|
|
114
114
|
await applyToolConfigDefaults(root);
|
|
115
|
+
// Rust's half of the same job: a multi-crate Cargo workspace must resolve
|
|
116
|
+
// features once for every member, or each per-crate cargo invocation
|
|
117
|
+
// recompiles the shared graph for its own selection.
|
|
118
|
+
applyCargoFeatureUnification(root);
|
|
115
119
|
syncBunLockfileVersions(root, { mode: 'install' });
|
|
116
120
|
console.log('installing workspace dependencies (bun install)');
|
|
117
121
|
await run('bun', ['install', '--no-summary'], root);
|
|
@@ -221,6 +221,38 @@ describe('monorepo validation pack phases', () => {
|
|
|
221
221
|
}
|
|
222
222
|
});
|
|
223
223
|
|
|
224
|
+
it('validates and fixes cargo workspace feature unification through its own pack', async () => {
|
|
225
|
+
const root = await mkdtemp(join(tmpdir(), 'smoo-validate-cargo-'));
|
|
226
|
+
try {
|
|
227
|
+
await mkdir(join(root, 'crates/alpha'), { recursive: true });
|
|
228
|
+
await mkdir(join(root, 'crates/beta'), { recursive: true });
|
|
229
|
+
await mkdir(join(root, 'tooling/direnv'), { recursive: true });
|
|
230
|
+
await writeFile(
|
|
231
|
+
join(root, 'Cargo.toml'),
|
|
232
|
+
'[workspace]\nmembers = ["crates/*"]\n\n[profile.test]\nincremental = false\ndebug = 0\n',
|
|
233
|
+
);
|
|
234
|
+
await writeFile(join(root, 'crates/alpha/Cargo.toml'), '[package]\nname = "alpha"\n');
|
|
235
|
+
await writeFile(join(root, 'crates/beta/Cargo.toml'), '[package]\nname = "beta"\n');
|
|
236
|
+
await writeFile(join(root, 'tooling/direnv/devenv.smoo.nix'), 'languages.rust = {\n channel = "nightly";\n};\n');
|
|
237
|
+
const cargoPack = packsForTest.find((pack) => pack.name === 'cargo');
|
|
238
|
+
if (!cargoPack) {
|
|
239
|
+
throw new Error('cargo validation pack not found');
|
|
240
|
+
}
|
|
241
|
+
const runBuild = () => 0;
|
|
242
|
+
|
|
243
|
+
// A policy nothing calls is not a policy: validate must reach it.
|
|
244
|
+
expect(
|
|
245
|
+
await runValidatePacks({ root, syncRuntime: false }, { failFast: true }, { packs: [cargoPack], runBuild }),
|
|
246
|
+
).toEqual({ failures: 1, failedChecks: 1 });
|
|
247
|
+
|
|
248
|
+
expect(
|
|
249
|
+
await runValidatePacks({ root, syncRuntime: false }, { fix: true }, { packs: [cargoPack], runBuild }),
|
|
250
|
+
).toEqual({ failures: 0, failedChecks: 0 });
|
|
251
|
+
} finally {
|
|
252
|
+
await rm(root, { recursive: true, force: true });
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
|
|
224
256
|
it('propagates parsed target dependencies through the production adapter', () => {
|
|
225
257
|
const targetDependencies = new Map([
|
|
226
258
|
['build', ['compile-linux']],
|
|
@@ -2,6 +2,7 @@ import { chmodSync, existsSync, statSync } from 'node:fs';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { printCommandOutput, runResult, runStatus } from '../../lib/run.js';
|
|
4
4
|
import { type ProjectTargets, readProjectTargets } from '../../nx/index.js';
|
|
5
|
+
import { applyCargoFeatureUnification, validateCargoCachePolicy } from '../cargo-policy.js';
|
|
5
6
|
import { validateGoToolchainAgreement } from '../go-toolchain.js';
|
|
6
7
|
import { syncBunLockfileVersions, validateBunLockfileVersions } from '../lockfile.js';
|
|
7
8
|
import { validateDevenvModuleImport, warnOnManagedFileDrift } from '../managed-files.js';
|
|
@@ -120,6 +121,18 @@ const packs: MonorepoPack[] = [
|
|
|
120
121
|
return validateDevenvModuleImport(ctx.root);
|
|
121
122
|
},
|
|
122
123
|
},
|
|
124
|
+
{
|
|
125
|
+
// Cargo's build-cache and feature-unification conventions. `--fix` writes
|
|
126
|
+
// the workspace feature unification a multi-crate workspace needs; the rest
|
|
127
|
+
// of the policy is a verdict a human has to act on.
|
|
128
|
+
name: 'cargo',
|
|
129
|
+
fixPreBuild(ctx) {
|
|
130
|
+
applyCargoFeatureUnification(ctx.root);
|
|
131
|
+
},
|
|
132
|
+
validatePreBuild(ctx) {
|
|
133
|
+
return validateCargoCachePolicy(ctx.root);
|
|
134
|
+
},
|
|
135
|
+
},
|
|
123
136
|
{
|
|
124
137
|
name: 'publishing',
|
|
125
138
|
init(ctx) {
|