@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
|
@@ -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);
|
|
@@ -57,6 +57,35 @@ describe('publish workflow definition', () => {
|
|
|
57
57
|
).resolves.toBe(rendered);
|
|
58
58
|
});
|
|
59
59
|
|
|
60
|
+
it('gives every publish job the declared remote cache, in bytes Prettier keeps', async () => {
|
|
61
|
+
const remoteCache = {
|
|
62
|
+
server: 'https://nx-cache.example.net',
|
|
63
|
+
internalServer: 'http://10.89.0.1:8765',
|
|
64
|
+
tokenSecret: 'NX_REMOTE_CACHE_TOKEN',
|
|
65
|
+
};
|
|
66
|
+
const cacheEnv = {
|
|
67
|
+
NX_SELF_HOSTED_REMOTE_CACHE_SERVER: 'http://10.89.0.1:8765',
|
|
68
|
+
NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN: '${{ secrets.NX_REMOTE_CACHE_TOKEN }}',
|
|
69
|
+
};
|
|
70
|
+
const platform = renderPublishWorkflowYaml({ ...codebaseWorkflowOptions, remoteCache });
|
|
71
|
+
const single = renderPublishWorkflowYaml({ repoName: '@smoothbricks/codebase', remoteCache });
|
|
72
|
+
|
|
73
|
+
expect(Bun.YAML.parse(platform)).toMatchObject({
|
|
74
|
+
jobs: {
|
|
75
|
+
'linux-release-candidate': { env: cacheEnv },
|
|
76
|
+
'macos-platform': { env: cacheEnv },
|
|
77
|
+
'publish-on-linux': { env: cacheEnv },
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
expect(Bun.YAML.parse(single)).toMatchObject({ jobs: { publish: { env: cacheEnv } } });
|
|
81
|
+
expect(renderPublishWorkflowYaml(codebaseWorkflowOptions)).not.toContain('NX_SELF_HOSTED');
|
|
82
|
+
// A workflow the repository Prettier config would rewrite drifts on the
|
|
83
|
+
// first commit hook, so the cache lines must already be its output.
|
|
84
|
+
await expect(
|
|
85
|
+
format(platform, { parser: 'yaml', printWidth: 120, proseWrap: 'always', singleQuote: true }),
|
|
86
|
+
).resolves.toBe(platform);
|
|
87
|
+
});
|
|
88
|
+
|
|
60
89
|
it('passes the projects selector to the version and platform-output steps', async () => {
|
|
61
90
|
const rendered = renderPublishWorkflowYaml(codebaseWorkflowOptions);
|
|
62
91
|
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { describe, expect, it, spyOn } from 'bun:test';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
2
3
|
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
3
4
|
import { tmpdir } from 'node:os';
|
|
4
5
|
import { dirname, join } from 'node:path';
|
|
5
|
-
import { validateCargoCachePolicy } from './cargo-policy.js';
|
|
6
|
+
import { applyCargoFeatureUnification, type CargoHakariShell, validateCargoCachePolicy } from './cargo-policy.js';
|
|
6
7
|
|
|
7
8
|
async function withFixture<T>(files: Record<string, string>, callback: (root: string) => T): Promise<T> {
|
|
8
9
|
const root = await mkdtemp(join(tmpdir(), 'smoo-cargo-policy-'));
|
|
@@ -18,17 +19,46 @@ async function withFixture<T>(files: Record<string, string>, callback: (root: st
|
|
|
18
19
|
}
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
async function check(
|
|
22
|
+
async function check(
|
|
23
|
+
files: Record<string, string>,
|
|
24
|
+
shell?: CargoHakariShell,
|
|
25
|
+
): Promise<{ failures: number; messages: string[] }> {
|
|
22
26
|
return withFixture(files, (root) => {
|
|
23
27
|
const captured = captureErrors();
|
|
24
28
|
try {
|
|
25
|
-
return { failures: validateCargoCachePolicy(root), messages: captured.messages };
|
|
29
|
+
return { failures: validateCargoCachePolicy(root, { shell }), messages: captured.messages };
|
|
26
30
|
} finally {
|
|
27
31
|
captured.restore();
|
|
28
32
|
}
|
|
29
33
|
});
|
|
30
34
|
}
|
|
31
35
|
|
|
36
|
+
/** Records what update would run, and answers `verify` however the test needs. */
|
|
37
|
+
function recordingHakari(
|
|
38
|
+
verify: { code: number; output?: string; missing?: boolean } = { code: 0 },
|
|
39
|
+
): CargoHakariShell & {
|
|
40
|
+
calls: string[][];
|
|
41
|
+
} {
|
|
42
|
+
const calls: string[][] = [];
|
|
43
|
+
return {
|
|
44
|
+
calls,
|
|
45
|
+
run(_directory, args) {
|
|
46
|
+
calls.push([...args]);
|
|
47
|
+
return args[0] === 'verify'
|
|
48
|
+
? { code: verify.code, output: verify.output ?? '', missing: verify.missing ?? false }
|
|
49
|
+
: { code: 0, output: '', missing: false };
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const NIGHTLY_DEVENV = 'languages.rust = {\n channel = "nightly";\n};\n';
|
|
55
|
+
const UNIFIED_CONFIG = '[unstable]\nfeature-unification = true\n\n[resolver]\nfeature-unification = "workspace"\n';
|
|
56
|
+
const TWO_CRATE_WORKSPACE = {
|
|
57
|
+
'Cargo.toml': '[workspace]\nmembers = ["crates/*"]\n\n[profile.test]\nincremental = false\ndebug = 0\n',
|
|
58
|
+
'crates/alpha/Cargo.toml': '[package]\nname = "alpha"\n',
|
|
59
|
+
'crates/beta/Cargo.toml': '[package]\nname = "beta"\n',
|
|
60
|
+
};
|
|
61
|
+
|
|
32
62
|
function captureErrors(): { messages: string[]; restore: () => void } {
|
|
33
63
|
const messages: string[] = [];
|
|
34
64
|
const error = spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
|
|
@@ -186,3 +216,176 @@ describe('Cargo cache policy', () => {
|
|
|
186
216
|
expect(result.messages).toEqual([]);
|
|
187
217
|
});
|
|
188
218
|
});
|
|
219
|
+
|
|
220
|
+
describe('Cargo workspace feature unification', () => {
|
|
221
|
+
it('leaves a single-crate workspace alone', async () => {
|
|
222
|
+
// Nothing to unify: `feature-unification = "workspace"` and a workspace-hack
|
|
223
|
+
// both exist to stop ONE dependency being built twice with different
|
|
224
|
+
// features for two members, which needs two members.
|
|
225
|
+
const result = await check({
|
|
226
|
+
'Cargo.toml': '[workspace]\nmembers = ["crates/only"]\n\n[profile.test]\nincremental = false\ndebug = 0\n',
|
|
227
|
+
'crates/only/Cargo.toml': '[package]\nname = "only"\n',
|
|
228
|
+
'tooling/direnv/devenv.smoo.nix': NIGHTLY_DEVENV,
|
|
229
|
+
});
|
|
230
|
+
expect(result.failures).toBe(0);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('requires a mechanism once a workspace holds more than one crate', async () => {
|
|
234
|
+
const result = await check({ ...TWO_CRATE_WORKSPACE, 'tooling/direnv/devenv.smoo.nix': NIGHTLY_DEVENV });
|
|
235
|
+
expect(result.failures).toBe(1);
|
|
236
|
+
expect(result.messages[0]).toContain('feature-unification');
|
|
237
|
+
expect(result.messages[0]).toContain('smoo monorepo update');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('accepts the nightly resolver configuration and rejects it without the unstable flag', async () => {
|
|
241
|
+
const configured = await check({
|
|
242
|
+
...TWO_CRATE_WORKSPACE,
|
|
243
|
+
'tooling/direnv/devenv.smoo.nix': NIGHTLY_DEVENV,
|
|
244
|
+
'.cargo/config.toml': UNIFIED_CONFIG,
|
|
245
|
+
});
|
|
246
|
+
expect(configured.failures).toBe(0);
|
|
247
|
+
|
|
248
|
+
// Cargo silently ignores [resolver] feature-unification without the
|
|
249
|
+
// unstable opt-in, so the half-configured repository believes it is unified
|
|
250
|
+
// while every crate still re-resolves features.
|
|
251
|
+
const inert = await check({
|
|
252
|
+
...TWO_CRATE_WORKSPACE,
|
|
253
|
+
'tooling/direnv/devenv.smoo.nix': NIGHTLY_DEVENV,
|
|
254
|
+
'.cargo/config.toml': '[resolver]\nfeature-unification = "workspace"\n',
|
|
255
|
+
});
|
|
256
|
+
expect(inert.failures).toBe(1);
|
|
257
|
+
expect(inert.messages[0]).toContain('[unstable] feature-unification = true');
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('reads the channel from the managed devenv module, not from a decorative rust-toolchain file', async () => {
|
|
261
|
+
// devenv resolves the toolchain through rust-overlay and ignores
|
|
262
|
+
// rust-toolchain.toml unless languages.rust.toolchainFile names it, so the
|
|
263
|
+
// module's channel is the compiler that actually runs these commands.
|
|
264
|
+
const result = await check({
|
|
265
|
+
...TWO_CRATE_WORKSPACE,
|
|
266
|
+
'tooling/direnv/devenv.smoo.nix': NIGHTLY_DEVENV,
|
|
267
|
+
'rust-toolchain.toml': '[toolchain]\nchannel = "stable"\n',
|
|
268
|
+
'.cargo/config.toml': UNIFIED_CONFIG,
|
|
269
|
+
});
|
|
270
|
+
expect(result.failures).toBe(0);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('refuses the nightly-only resolver key on a stable toolchain', async () => {
|
|
274
|
+
const result = await check({
|
|
275
|
+
...TWO_CRATE_WORKSPACE,
|
|
276
|
+
'rust-toolchain.toml': '[toolchain]\nchannel = "stable"\n',
|
|
277
|
+
'.cargo/config.toml': UNIFIED_CONFIG,
|
|
278
|
+
});
|
|
279
|
+
expect(result.failures).toBe(1);
|
|
280
|
+
expect(result.messages[0]).toContain('cargo-hakari');
|
|
281
|
+
expect(result.messages[0]).toContain('stable');
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('accepts a hakari-managed workspace-hack on a stable toolchain', async () => {
|
|
285
|
+
const hakari = recordingHakari();
|
|
286
|
+
const result = await check(
|
|
287
|
+
{
|
|
288
|
+
'Cargo.toml':
|
|
289
|
+
'[workspace]\nmembers = ["crates/*", "workspace-hack"]\n\n[profile.test]\nincremental = false\ndebug = 0\n',
|
|
290
|
+
'crates/alpha/Cargo.toml':
|
|
291
|
+
'[package]\nname = "alpha"\n\n[dependencies]\nworkspace-hack = { path = "../../workspace-hack" }\n',
|
|
292
|
+
'crates/beta/Cargo.toml':
|
|
293
|
+
'[package]\nname = "beta"\n\n[dependencies]\nworkspace-hack = { path = "../../workspace-hack" }\n',
|
|
294
|
+
'workspace-hack/Cargo.toml': '[package]\nname = "workspace-hack"\n',
|
|
295
|
+
'.config/hakari.toml': 'hakari-package = "workspace-hack"\nresolver = "2"\n',
|
|
296
|
+
'rust-toolchain.toml': '[toolchain]\nchannel = "stable"\n',
|
|
297
|
+
},
|
|
298
|
+
hakari,
|
|
299
|
+
);
|
|
300
|
+
expect(result.failures).toBe(0);
|
|
301
|
+
expect(hakari.calls).toEqual([['verify']]);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('flags a crate that does not depend on the workspace-hack', async () => {
|
|
305
|
+
const result = await check(
|
|
306
|
+
{
|
|
307
|
+
'Cargo.toml':
|
|
308
|
+
'[workspace]\nmembers = ["crates/*", "workspace-hack"]\n\n[profile.test]\nincremental = false\ndebug = 0\n',
|
|
309
|
+
'crates/alpha/Cargo.toml':
|
|
310
|
+
'[package]\nname = "alpha"\n\n[dependencies]\nworkspace-hack = { path = "../../workspace-hack" }\n',
|
|
311
|
+
'crates/beta/Cargo.toml': '[package]\nname = "beta"\n',
|
|
312
|
+
'workspace-hack/Cargo.toml': '[package]\nname = "workspace-hack"\n',
|
|
313
|
+
'.config/hakari.toml': 'hakari-package = "workspace-hack"\n',
|
|
314
|
+
'rust-toolchain.toml': '[toolchain]\nchannel = "stable"\n',
|
|
315
|
+
},
|
|
316
|
+
recordingHakari(),
|
|
317
|
+
);
|
|
318
|
+
expect(result.failures).toBe(1);
|
|
319
|
+
expect(result.messages[0]).toContain('beta');
|
|
320
|
+
expect(result.messages[0]).toContain('smoo monorepo update');
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('surfaces a stale workspace-hack that cargo hakari verify rejects', async () => {
|
|
324
|
+
const result = await check(
|
|
325
|
+
{
|
|
326
|
+
'Cargo.toml':
|
|
327
|
+
'[workspace]\nmembers = ["crates/*", "workspace-hack"]\n\n[profile.test]\nincremental = false\ndebug = 0\n',
|
|
328
|
+
'crates/alpha/Cargo.toml':
|
|
329
|
+
'[package]\nname = "alpha"\n\n[dependencies]\nworkspace-hack = { path = "../../workspace-hack" }\n',
|
|
330
|
+
'crates/beta/Cargo.toml':
|
|
331
|
+
'[package]\nname = "beta"\n\n[dependencies]\nworkspace-hack = { path = "../../workspace-hack" }\n',
|
|
332
|
+
'workspace-hack/Cargo.toml': '[package]\nname = "workspace-hack"\n',
|
|
333
|
+
'.config/hakari.toml': 'hakari-package = "workspace-hack"\n',
|
|
334
|
+
'rust-toolchain.toml': '[toolchain]\nchannel = "stable"\n',
|
|
335
|
+
},
|
|
336
|
+
recordingHakari({ code: 1, output: 'workspace-hack is not up-to-date' }),
|
|
337
|
+
);
|
|
338
|
+
expect(result.failures).toBe(1);
|
|
339
|
+
expect(result.messages[0]).toContain('cargo hakari verify');
|
|
340
|
+
expect(result.messages[0]).toContain('workspace-hack is not up-to-date');
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
describe('Cargo feature unification update', () => {
|
|
345
|
+
it('writes the nightly resolver configuration once', async () => {
|
|
346
|
+
await withFixture({ ...TWO_CRATE_WORKSPACE, 'tooling/direnv/devenv.smoo.nix': NIGHTLY_DEVENV }, (root) => {
|
|
347
|
+
const configPath = join(root, '.cargo/config.toml');
|
|
348
|
+
const hakari = recordingHakari();
|
|
349
|
+
applyCargoFeatureUnification(root, { shell: hakari });
|
|
350
|
+
const written = readFileSync(configPath, 'utf8');
|
|
351
|
+
expect(written).toContain('[unstable]\nfeature-unification = true');
|
|
352
|
+
expect(written).toContain('[resolver]\nfeature-unification = "workspace"');
|
|
353
|
+
expect(hakari.calls).toEqual([]);
|
|
354
|
+
|
|
355
|
+
applyCargoFeatureUnification(root, { shell: hakari });
|
|
356
|
+
expect(readFileSync(configPath, 'utf8')).toBe(written);
|
|
357
|
+
const captured = captureErrors();
|
|
358
|
+
try {
|
|
359
|
+
expect(validateCargoCachePolicy(root, { shell: hakari })).toBe(0);
|
|
360
|
+
} finally {
|
|
361
|
+
captured.restore();
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('generates and wires a workspace-hack on a stable toolchain', async () => {
|
|
367
|
+
await withFixture(
|
|
368
|
+
{ ...TWO_CRATE_WORKSPACE, 'rust-toolchain.toml': '[toolchain]\nchannel = "1.89.0"\n' },
|
|
369
|
+
(root) => {
|
|
370
|
+
const hakari = recordingHakari();
|
|
371
|
+
applyCargoFeatureUnification(root, { shell: hakari });
|
|
372
|
+
expect(hakari.calls).toEqual([['init', 'workspace-hack'], ['generate'], ['manage-deps', '--yes']]);
|
|
373
|
+
},
|
|
374
|
+
);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('skips hakari init when the workspace already declares one', async () => {
|
|
378
|
+
await withFixture(
|
|
379
|
+
{
|
|
380
|
+
...TWO_CRATE_WORKSPACE,
|
|
381
|
+
'rust-toolchain.toml': '[toolchain]\nchannel = "1.89.0"\n',
|
|
382
|
+
'.config/hakari.toml': 'hakari-package = "workspace-hack"\n',
|
|
383
|
+
},
|
|
384
|
+
(root) => {
|
|
385
|
+
const hakari = recordingHakari();
|
|
386
|
+
applyCargoFeatureUnification(root, { shell: hakari });
|
|
387
|
+
expect(hakari.calls).toEqual([['generate'], ['manage-deps', '--yes']]);
|
|
388
|
+
},
|
|
389
|
+
);
|
|
390
|
+
});
|
|
391
|
+
});
|