@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.
@@ -31,20 +31,16 @@ add_repo_paths() {
31
31
  # `--accept-flake-config` makes it worse than untidy: it pre-trusts the
32
32
  # substituters declared by a commit nobody looked at.
33
33
  #
34
- # Resolved lazily, on the install path only: a runner that already has devenv
35
- # never needs the rev, and that is the one branch which pays for a `nix eval`.
36
- devenv_flake() {
37
- if [ -n "${DEVENV_FLAKE:-}" ]; then
38
- printf '%s' "$DEVENV_FLAKE"
39
- return 0
40
- fi
34
+ # Resolved lazily, on the paths that need it, because a host runner never does
35
+ # and that is the one branch which must not pay for a `nix eval`.
36
+ devenv_locked_rev() {
41
37
  local lock="$repo_root/tooling/direnv/devenv.lock" rev
42
38
  # nix, not jq: jq arrives with the shell this script is about to build, while
43
39
  # nix is guaranteed by the install step before it. fromJSON also beats
44
40
  # hand-rolled parsing of a file whose key order is not ours to assume.
45
41
  # Absolute-path readFile needs --impure; nix's own error is left on stderr
46
- # rather than swallowed, because an unresolvable pin is the failure this
47
- # function exists to prevent.
42
+ # rather than swallowed, because an unresolvable pin is the failure these
43
+ # functions exist to prevent.
48
44
  rev="$(nix eval --raw --impure --expr \
49
45
  "(builtins.fromJSON (builtins.readFile \"$lock\")).nodes.devenv.locked.rev")" || rev=""
50
46
  if [ -z "$rev" ]; then
@@ -52,26 +48,89 @@ devenv_flake() {
52
48
  echo " set DEVENV_FLAKE to install a devenv explicitly" >&2
53
49
  return 1
54
50
  fi
55
- printf 'github:cachix/devenv/%s' "$rev"
51
+ printf '%s' "$rev"
52
+ }
53
+
54
+ # Whether a devenv binary is the commit the lock names. `devenv version` prints
55
+ # "devenv <semver>+<short rev> (<system>)", so the abbreviated commit is right
56
+ # there and a prefix test against the full rev is the entire comparison — no
57
+ # assumption about how many characters devenv chooses to abbreviate to. A build
58
+ # with no "+<rev>" at all cannot prefix a 40-char hex rev, so it reads as a
59
+ # mismatch, which is the safe direction.
60
+ devenv_matches_lock() {
61
+ local bin="$1" rev="$2" printed
62
+ printed="$("$bin" version 2>/dev/null)" || return 1
63
+ printed="${printed#*+}"
64
+ printed="${printed%% *}"
65
+ [ -n "$printed" ] || return 1
66
+ case "$rev" in
67
+ "$printed"*) return 0 ;;
68
+ esac
69
+ return 1
56
70
  }
57
71
 
58
72
  install_devenv() {
59
- # Shared host /nix/store is the package cache. Image may already provide
60
- # devenv; otherwise profile-add the flake rev devenv.lock names (links store
61
- # paths; re-fetch only when missing).
62
- # The restored profile is not on PATH yet; look where the store cache put
63
- # it before evaluating the devenv flake again.
64
- local restored="$HOME/.nix-profile/bin/devenv"
73
+ local rev flake found=""
74
+ # Host runners own their Nix profile, and that profile roots a /nix/store the
75
+ # whole fleet shares, so an image-provided devenv is authoritative here
76
+ # whatever commit it is and a wrong one is never REPLACED, because
77
+ # `nix profile remove --all` on a host would take the fleet's other roots with
78
+ # it. SMOO_HOST_RUNNER comes from setup-devenv's runner-kind detection.
79
+ #
80
+ # With none present these runners install from the FLOATING branch, not the
81
+ # locked rev, which is the one place this script deliberately accepts drift.
82
+ # The pin exists to make the ephemeral runners' cached devenv reproducible;
83
+ # a host installs into a long-lived shared profile whose blast radius is the
84
+ # whole fleet, and pinning it down to an older CLI than the fleet had been
85
+ # running broke it: run 34374650577 segfaulted the Rust linker
86
+ # (devenv-rust-linker, clang exit 139) building cowshed-cli tests, where run
87
+ # 34371205556 on the same commit range was green with floating 2a399e9.
88
+ # 190959a is TWO COMMITS OLDER than what these hosts had been getting, so the
89
+ # pin was moving them backwards. A host's toolchain moves when its image or
90
+ # the lock moves, not when a repository decides to hold it back.
91
+ if [ "${SMOO_HOST_RUNNER:-false}" = true ]; then
92
+ if command -v devenv >/dev/null 2>&1; then
93
+ echo "using host devenv: $(command -v devenv) ($(devenv version))"
94
+ else
95
+ flake="${DEVENV_FLAKE:-github:cachix/devenv}"
96
+ echo "host runner has no devenv; nix profile add ${flake}"
97
+ nix profile add --accept-flake-config "$flake"
98
+ fi
99
+ devenv_path_and_caches
100
+ return 0
101
+ fi
102
+
103
+ rev="$(devenv_locked_rev)"
104
+ # The store cache carries content only, never the Nix profiles, so the normal
105
+ # ephemeral case is that nothing is installed yet and this profile-adds the
106
+ # locked rev against an already-warm /nix — an evaluation and a link, not a
107
+ # download. Anything that does turn up on PATH is still checked rather than
108
+ # trusted, because "a devenv exists" was never the question.
65
109
  if command -v devenv >/dev/null 2>&1; then
66
- echo "using existing devenv: $(command -v devenv) ($(devenv version))"
67
- elif [ -x "$restored" ]; then
68
- echo "using restored devenv: $restored ($("$restored" version))"
110
+ found="$(command -v devenv)"
111
+ elif [ -x "$HOME/.nix-profile/bin/devenv" ]; then
112
+ found="$HOME/.nix-profile/bin/devenv"
113
+ fi
114
+
115
+ if [ -n "$found" ] && devenv_matches_lock "$found" "$rev"; then
116
+ echo "using locked devenv: $found ($("$found" version))"
69
117
  else
70
- local flake
71
- flake="$(devenv_flake)"
118
+ flake="${DEVENV_FLAKE:-github:cachix/devenv/$rev}"
119
+ if [ -n "$found" ]; then
120
+ # Replacing needs the old entry gone first: `nix profile add` would
121
+ # otherwise refuse on a bin/devenv collision at equal priority. --all is
122
+ # exact rather than blunt, because devenv is the only thing this script
123
+ # ever profile-adds, so a wrong devenv means a wrong profile.
124
+ echo "replacing devenv $("$found" version 2>/dev/null) — lock names ${rev:0:7}"
125
+ nix profile remove --all
126
+ fi
72
127
  echo "nix profile add ${flake}"
73
128
  nix profile add --accept-flake-config "$flake"
74
129
  fi
130
+ devenv_path_and_caches
131
+ }
132
+
133
+ devenv_path_and_caches() {
75
134
  if [ -d "$HOME/.nix-profile/bin" ]; then
76
135
  echo "$HOME/.nix-profile/bin" >> "${GITHUB_PATH:-/dev/null}"
77
136
  fi
@@ -119,9 +119,29 @@ runs:
119
119
  keep-env-derivations = true
120
120
 
121
121
  # The store itself, restored as files: /nix/store plus the Nix database,
122
- # merged into the fresh install. Profiles live outside /nix/var in
123
- # single-user mode and ride along. The post phase collects garbage down
124
- # to the live closure and saves when the primary key missed.
122
+ # merged into the fresh install. The post phase collects garbage down to the
123
+ # live closure and saves when the primary key missed.
124
+ #
125
+ # CONTENT ONLY — never the Nix profiles. `~/.nix-profile` and
126
+ # ~/.local/state/nix/profiles are mutable places whose whole job is to be
127
+ # gcroots, and restoring a previous run's copies over a freshly installed
128
+ # single-user Nix de-roots the Nix binary that is currently running. The post
129
+ # phase then garbage-collects it and cache-nix-action's own `nix --version`
130
+ # probe fails, at which point it skips the upload and still reports the step
131
+ # as a success — so the store cache silently stops being written and every
132
+ # later run pays a cold shell. Measured exactly that way: run 34371205556
133
+ # logged `nix (Nix) 2.34.7`, freed 2.7 GiB, then `nix: command not found`,
134
+ # and no `Cache saved` line for this key ever appeared.
135
+ #
136
+ # It only surfaced now because it needs a restore-prefix fallback rather than
137
+ # a cold store or an exact hit: a matching key restores profiles consistent
138
+ # with the store, while the diet rotated the key and pulled the previous
139
+ # store's profiles onto a different Nix.
140
+ #
141
+ # Dropping them costs nothing and buys determinism. devenv's closure still
142
+ # arrives inside /nix, so install-devenv's `nix profile add` of the locked
143
+ # rev is a link rather than a download, and the devenv in play is the one
144
+ # devenv.lock names instead of whichever run last populated the prefix.
125
145
  - name: 🧊 Cache Nix store
126
146
  id: nix-cache
127
147
  if: steps.runner-kind.outputs.host != 'true'
@@ -129,12 +149,10 @@ runs:
129
149
  with:
130
150
  paths: |
131
151
  /nix
132
- ~/.nix-profile
133
- ~/.local/state/nix/profiles
134
152
  ~/.cache/nix
135
153
  # prettier-ignore
136
- primary-key: ${{ runner.os }}-${{ runner.arch }}-nix-v4-${{ hashFiles('tooling/direnv/devenv.yaml', 'tooling/direnv/devenv.nix', 'tooling/direnv/devenv.lock') }}
137
- restore-prefixes-first-match: ${{ runner.os }}-${{ runner.arch }}-nix-v4-
154
+ primary-key: ${{ runner.os }}-${{ runner.arch }}-nix-v5-${{ hashFiles('tooling/direnv/devenv.yaml', 'tooling/direnv/devenv.nix', 'tooling/direnv/devenv.lock') }}
155
+ restore-prefixes-first-match: ${{ runner.os }}-${{ runner.arch }}-nix-v5-
138
156
  gc-max-store-size: 1G
139
157
 
140
158
  - name: ⚡ Enable Cachix
@@ -143,9 +161,17 @@ runs:
143
161
  with:
144
162
  name: devenv
145
163
 
164
+ # A host runner's devenv belongs to its image, so install-devenv accepts
165
+ # whatever is on PATH there. On an ephemeral runner the devenv came out of a
166
+ # cache this workflow wrote, so it is held to devenv.lock's rev: the store
167
+ # cache restores ~/.nix-profile wholesale and a prefix match hands the job
168
+ # the CLI of whichever run last populated the key, which outlived a lock bump
169
+ # until the check existed.
146
170
  - name: 🧰 Install devenv
147
171
  shell: bash
148
172
  working-directory: tooling/direnv
173
+ env:
174
+ SMOO_HOST_RUNNER: ${{ steps.runner-kind.outputs.host }}
149
175
  run: ./github-actions-bootstrap.sh install-devenv
150
176
 
151
177
  # node_modules + ttsc plugin binaries: GH Actions cache on ephemeral runners.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smoothbricks/cli",
3
- "version": "0.11.10",
3
+ "version": "0.11.12",
4
4
  "type": "module",
5
5
  "description": "SmoothBricks monorepo automation CLI",
6
6
  "bin": {
@@ -64,7 +64,7 @@
64
64
  ],
65
65
  "dependencies": {
66
66
  "@arethetypeswrong/core": "^0.18.2",
67
- "@smoothbricks/nx-plugin": "0.4.5",
67
+ "@smoothbricks/nx-plugin": "0.4.6",
68
68
  "@smoothbricks/validation": "0.1.8",
69
69
  "commander": "^14.0.3",
70
70
  "make-synchronized": "^0.8.0",
@@ -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(files: Record<string, string>): Promise<{ failures: number; messages: string[] }> {
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
+ });