@feigi/fleet-ctl 3.21.4 → 3.21.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feigi/fleet-ctl",
3
- "version": "3.21.4",
3
+ "version": "3.21.5",
4
4
  "description": "Agent fleet: run-team controller, merge bot, PR reviewer, and the ticket pipeline they share",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -38,16 +38,18 @@
38
38
  // — <workspace> being the directory holding the repository's common git dir,
39
39
  // the place derive-testcmd.sh reads it from — and then read back through
40
40
  // derive-testcmd.sh for both fields. A cache the reader refuses is rolled
41
- // back to whatever stood before, so the reader stays the one authority on
42
- // what a usable cache is. A failed proof never touches an existing cache.
41
+ // back to whatever stood before, as is one whose read-back is no verdict, so
42
+ // the reader stays the one authority on what a usable cache is. A failed
43
+ // proof never touches an existing cache.
43
44
  //
44
45
  // Exit 0: proven, cache written (its path and contents on stdout).
45
46
  // Exit 1: NOT PROVEN — no cache written; the reason on stderr.
46
47
  // Exit 2: no verdict on the proof — it could not be attempted (usage, not a
47
48
  // repository, origin/main missing, the worktree could not be made), or
48
49
  // a git or filesystem fault stopped it before the cache was settled
49
- // (a git command could not be started; the cache or its temp file
50
- // could not be written).
50
+ // (a git command could not be started, the reader's own git
51
+ // included, or the reader's `sh`; the cache or its temp file could
52
+ // not be written).
51
53
 
52
54
  import { spawnSync } from "node:child_process";
53
55
  import { closeSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
@@ -212,7 +214,11 @@ function prove(o, wt, logs) {
212
214
  }
213
215
 
214
216
  // Write, then read back through the one reader. A cache it refuses is undone:
215
- // the prior bytes restored, or the file removed when none stood before.
217
+ // the prior bytes restored, or the file removed when none stood before. The
218
+ // cache is undone just the same when the read-back is no verdict: an `sh` that
219
+ // could not be started read nothing, and the reader runs git itself and reads
220
+ // a git that will not start as no repository at all, so its refusal is a
221
+ // verdict on what was proven only while git still starts.
216
222
  function writeCache(cache, recipe, repo) {
217
223
  mkdirSync(dirname(cache), { recursive: true });
218
224
  const prior = existsSync(cache) ? readFileSync(cache) : null;
@@ -226,11 +232,17 @@ function writeCache(cache, recipe, repo) {
226
232
  }
227
233
  for (const field of ["install", "test"]) {
228
234
  const r = spawnSync("sh", [READER, repo, field], { env: ENV, encoding: "utf8" });
229
- if (r.status !== 0 || r.stdout.replace(/\n$/, "") !== recipe[field]) {
230
- if (prior === null) unlinkSync(cache);
231
- else writeFileSync(cache, prior);
232
- throw notProven(`the Recipe cache reader refuses what was proven: ${(r.stderr || r.stdout).trim()}`);
235
+ if (r.status === 0 && r.stdout.replace(/\n$/, "") === recipe[field]) continue;
236
+ if (prior === null) unlinkSync(cache);
237
+ else writeFileSync(cache, prior);
238
+ if (r.error && !r.pid) throw cannot(`could not start sh to read the Recipe cache back: ${r.error.message}`);
239
+ try {
240
+ git(["--version"]);
241
+ } catch (e) {
242
+ if (!(e instanceof Refusal)) throw e;
243
+ throw cannot(`${e.message} — the Recipe cache reader runs git, so its refusal is no verdict on what was proven`);
233
244
  }
245
+ throw notProven(`the Recipe cache reader refuses what was proven: ${(r.stderr || r.stdout).trim()}`);
234
246
  }
235
247
  }
236
248
 
@@ -442,6 +442,54 @@ test("a git that stops starting during cleanup does not replace the proof's own
442
442
  assert.match(r.err, /skipped git worktree prune: could not start git/);
443
443
  });
444
444
 
445
+ // A PATH holding exactly what the proof and the Recipe cache reader run — `sh`,
446
+ // a `git` that execs the real one, `node`, and the reader's `mktemp`, `cat`
447
+ // and `rm` — so the Test entrypoint can take git away by deleting it. With the
448
+ // count proof no proof step runs git after the Test entrypoint, so the first
449
+ // thing to meet the missing git is the reader, during the read-back.
450
+ function readerPath() {
451
+ const realGit = execFileSync("sh", ["-c", "command -v git"], { encoding: "utf8" }).trim();
452
+ const bin = tempDir("recipe-prove-path-");
453
+ writeExecStub(join(bin, "git"), `#!/bin/sh\nexec '${realGit}' "$@"\n`);
454
+ symlinkSync("/bin/sh", join(bin, "sh"));
455
+ symlinkSync(process.execPath, join(bin, "node"));
456
+ for (const tool of ["mktemp", "cat", "rm"]) {
457
+ symlinkSync(execFileSync("sh", ["-c", `command -v ${tool}`], { encoding: "utf8" }).trim(), join(bin, tool));
458
+ }
459
+ return bin;
460
+ }
461
+
462
+ test("a git that stops starting before the cache is read back is no verdict, never NOT PROVEN — and the prior cache is restored", () => {
463
+ // The control: the same PATH with git left in place is a proof that holds.
464
+ const kept = repo(MAVEN_FILES);
465
+ const ok = prove(kept.dir, ["--install", "true", "--test", "echo 'tests 1'", "--count-line", "tests 1", "--test-count", "1"], { env: { PATH: readerPath() } });
466
+ assert.equal(ok.status, 0, ok.err);
467
+ assert.match(ok.out, /^recipe-prove: PROVEN/);
468
+
469
+ const { dir } = repo(MAVEN_FILES);
470
+ mkdirSync(join(dir, ".fleet"));
471
+ writeFileSync(cachePath(dir), "prior bytes");
472
+ const bin = readerPath();
473
+ const r = prove(dir, ["--install", "true", "--test", `rm '${join(bin, "git")}'; echo 'tests 1'`, "--count-line", "tests 1", "--test-count", "1"], { env: { PATH: bin } });
474
+ assert.equal(r.status, 2, r.err);
475
+ assert.match(r.err, /^recipe-prove: could not start git: .*ENOENT — the Recipe cache reader runs git, so its refusal is no verdict/m);
476
+ assert.doesNotMatch(r.err, /NOT PROVEN/);
477
+ assert.doesNotMatch(r.err, /not a git repository/);
478
+ assert.equal(readFileSync(cachePath(dir), "utf8"), "prior bytes", "a cache the reader never settled is rolled back");
479
+ });
480
+
481
+ // The reader's own `sh`, taken away the same way: a read-back that never ran
482
+ // read nothing, so it is no verdict either.
483
+ test("an sh that stops starting before the cache is read back is no verdict, never NOT PROVEN — and no cache is left", () => {
484
+ const { dir } = repo(MAVEN_FILES);
485
+ const bin = readerPath();
486
+ const r = prove(dir, ["--install", "true", "--test", `rm '${join(bin, "sh")}'; echo 'tests 1'`, "--count-line", "tests 1", "--test-count", "1"], { env: { PATH: bin } });
487
+ assert.equal(r.status, 2, r.err);
488
+ assert.match(r.err, /^recipe-prove: could not start sh to read the Recipe cache back: .*ENOENT/m);
489
+ assert.doesNotMatch(r.err, /NOT PROVEN/);
490
+ assert.equal(existsSync(cachePath(dir)), false, "a cache the reader never read is rolled back");
491
+ });
492
+
445
493
  // Whether the process ignores file modes: root writes into a 0555 directory.
446
494
  const IGNORES_MODES = process.getuid?.() === 0 && "root ignores file modes";
447
495