@smoothbricks/cli 0.11.10 → 0.11.11

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.11",
4
4
  "type": "module",
5
5
  "description": "SmoothBricks monorepo automation CLI",
6
6
  "bin": {
@@ -140,7 +140,7 @@ interface InstallRun {
140
140
  nixCalls: string;
141
141
  }
142
142
 
143
- function runInstallDevenv(lock: string, options: { devenvOnPath?: boolean } = {}): InstallRun {
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.devenvOnPath) {
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, '#!/usr/bin/env bash\necho 9.9.9-from-path\n');
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 select the
165
- // already-installed branch and the pin would never be exercised.
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: 'f'.repeat(40), type: 'github' },
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/${'f'.repeat(40)}`);
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/${'f'.repeat(40)}`);
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 an already-installed devenv and never evaluates the flake', () => {
220
- const run = runInstallDevenv(LOCK_WITH_REV, { devenvOnPath: true });
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 existing devenv');
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
  });