@feigi/fleet-ctl 3.23.9 → 3.23.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.
|
@@ -212,7 +212,7 @@ For each labeled PR clearing the hold rule, lowest first:
|
|
|
212
212
|
|
|
213
213
|
`--pre` is the head you recorded at **The labelled head**; `--post` is the head step 1's rebase produced — omit it when no rebase ran, which is the same as passing `pre` twice. `pr<N>` is this PR and `merge-bot-<n>` is your own name, so the reading lands under a directory only you write, never in the scratch root by itself: that root is injected into every dispatched member and shared with every sibling. The gate is read-only — it runs the instrument re-check, `gh pr view <pr> --json labels,reviewDecision,headRefOid` and `ci-state.mjs --pr <pr>`, answers their conjunction, and never merges, labels, rebases or waits. Stdout is exactly one JSON line, `{pr, verdict, reason, head, pre, post, behind, instruments, ci}`; stderr is its children's diagnostics, so never fold `2>&1` into the line you read. Read `rc` straight off the call, as above — never through a pipe.
|
|
214
214
|
|
|
215
|
-
1. **Run it.** Exit 0 → go to 2. Exit 1 with a `ci:…` `reason`, `ci.runId` set and `ci.status` not `completed` → the run has started but not finished: wait on `ci.runId` (step 2), then run it again. Exit 1 with `ci.runId` **null** (`reason` starts `no CI run whose headSha equals`) → GitHub has not yet registered a run for this head at all — right after `gh pr update-branch --rebase`, the id step 2's wait blocks on does not exist yet, so never route this into step 2's `gh run
|
|
215
|
+
1. **Run it.** Exit 0 → go to 2. Exit 1 with a `ci:…` `reason`, `ci.runId` set and `ci.status` not `completed` → the run has started but not finished: wait on `ci.runId` (step 2), then run it again. Exit 1 with `ci.runId` **null** (`reason` starts `no CI run whose headSha equals`) → GitHub has not yet registered a run for this head at all — right after `gh pr update-branch --rebase`, the id step 2's wait blocks on does not exist yet, so never route this into step 2's `gh run view <run-id>` poll with a null id. Wait 15s and run the gate again instead, capped at 20 attempts (~5 minutes); no run within that cap → skip the PR and report `<reason>-#<pr>`, the same as any other exit 1. Any other exit 1 → skip the PR, report `<reason>-#<pr>` (`head-moved-after-label-#<pr>`, `label-pulled-#<pr>`, `changes-requested-#<pr>` …), leave the label where it is, and move on. Exit 2 → stop on that PR and report `<reason>-#<pr>`: the gate could not evaluate — a finding about the tree, the tooling or the API, never about the PR. Re-run once only for `rate-limited`, a quota refusal that clears by itself.
|
|
216
216
|
2. **Run it again immediately before `gh pr merge`, and merge only on that second exit 0.** Anything else takes the same arms as 1, and nothing merges.
|
|
217
217
|
|
|
218
218
|
Each `reason` is a fact the gate now enforces, not a step you run:
|
package/package.json
CHANGED
package/scripts/recipe-prove.mjs
CHANGED
|
@@ -148,11 +148,25 @@ function parseArgs(argv) {
|
|
|
148
148
|
// proof that did not hold. Only a spawn that produced no process counts:
|
|
149
149
|
// spawnSync also sets `error` for a git that ran and was killed — ENOBUFS,
|
|
150
150
|
// when its output outgrows the default maxBuffer — and that is a failed git
|
|
151
|
-
// command
|
|
151
|
+
// command, never a git that could not be started.
|
|
152
|
+
//
|
|
153
|
+
// A git killed by a signal (that buffer kill, an OOM kill, a timeout wrapper)
|
|
154
|
+
// is still a failed command, never a throw: each caller maps a failed git to
|
|
155
|
+
// its own refusal. But it has no exit status and mostly no stderr, so `err`
|
|
156
|
+
// names the kill, after whatever git did write — a caller quoting `err` would
|
|
157
|
+
// otherwise quote an empty reason.
|
|
152
158
|
function git(args, cwd) {
|
|
153
159
|
const r = spawnSync("git", args, { cwd, env: ENV, encoding: "utf8" });
|
|
154
160
|
if (r.error && !r.pid) throw cannot(`could not start git: ${r.error.message}`);
|
|
155
|
-
|
|
161
|
+
const err = (r.stderr ?? "").trim();
|
|
162
|
+
const killed = r.signal && `git was killed by ${r.signal}${killCause(r.error)}`;
|
|
163
|
+
return { ok: r.status === 0, out: (r.stdout ?? "").replace(/\n$/, ""), err: [err, killed].filter(Boolean).join("; ") };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function killCause(error) {
|
|
167
|
+
if (!error) return "";
|
|
168
|
+
if (error.code === "ENOBUFS") return " (ENOBUFS: its output outgrew the spawn buffer)";
|
|
169
|
+
return ` (${error.code ?? error.message})`;
|
|
156
170
|
}
|
|
157
171
|
|
|
158
172
|
// Run a Recipe command through `sh -c` from the worktree root, its output to
|
|
@@ -338,12 +352,13 @@ function removeLogs(logs) {
|
|
|
338
352
|
|
|
339
353
|
function main(argv) {
|
|
340
354
|
const o = parseArgs(argv);
|
|
341
|
-
|
|
355
|
+
const gitDir = git(["rev-parse", "--git-dir"], o.repo);
|
|
356
|
+
if (!gitDir.ok) throw cannot(`${o.repo} is not a git repository: ${gitDir.err}`);
|
|
342
357
|
const common = git(["rev-parse", "--path-format=absolute", "--git-common-dir"], o.repo);
|
|
343
358
|
const workspace = common.ok ? workspaceDirFromGitCommonDir(common.out, o.repo) : null;
|
|
344
359
|
if (!workspace) throw cannot(`cannot resolve the common git dir of ${o.repo}: ${common.err}`);
|
|
345
360
|
const sha = git(["rev-parse", "--verify", "origin/main^{commit}"], o.repo);
|
|
346
|
-
if (!sha.ok) throw cannot(
|
|
361
|
+
if (!sha.ok) throw cannot(`origin/main does not resolve to a commit (${sha.err}) — fetch it; the proof runs against it`);
|
|
347
362
|
|
|
348
363
|
const logs = mkdtempSync(join(tmpdir(), `${NAME}-`));
|
|
349
364
|
const cache = join(workspace, ".fleet", "recipe.json");
|
|
@@ -506,6 +506,112 @@ test("a git whose output outgrows the spawn buffer is a failed command, not a gi
|
|
|
506
506
|
assert.doesNotMatch(r.err, /could not start git/);
|
|
507
507
|
});
|
|
508
508
|
|
|
509
|
+
// A `git` first on PATH that runs `act` when its arguments match the sh `case`
|
|
510
|
+
// pattern `when`, and otherwise execs the real one.
|
|
511
|
+
function fakeGit(when, act) {
|
|
512
|
+
const realGit = execFileSync("sh", ["-c", "command -v git"], { encoding: "utf8" }).trim();
|
|
513
|
+
const bin = tempDir("recipe-prove-path-");
|
|
514
|
+
writeExecStub(join(bin, "git"), `#!/bin/sh\ncase "$*" in ${when}) ${act} ;; esac\nexec '${realGit}' "$@"\n`);
|
|
515
|
+
return `${bin}:${BIN}:${process.env.PATH}`;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const COUNT_PROOF = ["--install", "true", "--test", "echo 'tests 1'", "--count-line", "tests 1", "--test-count", "1"];
|
|
519
|
+
|
|
520
|
+
// A git that ran and was killed has no exit status and, mostly, no stderr: its
|
|
521
|
+
// failure must name the kill, or every caller quoting git's reason quotes an
|
|
522
|
+
// empty one. Each caller keeps its own refusal class — NOT PROVEN (1) inside
|
|
523
|
+
// the proof, no verdict (2) for the probes that come before it.
|
|
524
|
+
const KILLED_GIT = [
|
|
525
|
+
{
|
|
526
|
+
name: "a git killed by SIGKILL reading the tree state",
|
|
527
|
+
when: "status*", act: "kill -9 $$",
|
|
528
|
+
status: 1, reason: /NOT PROVEN — could not read the tree state in the throwaway worktree: git was killed by SIGKILL; install output: /,
|
|
529
|
+
},
|
|
530
|
+
{
|
|
531
|
+
name: "a git killed by SIGTERM reading the tree state",
|
|
532
|
+
when: "status*", act: "kill -TERM $$",
|
|
533
|
+
status: 1, reason: /NOT PROVEN — could not read the tree state in the throwaway worktree: git was killed by SIGTERM; install output: /,
|
|
534
|
+
},
|
|
535
|
+
{
|
|
536
|
+
name: "a git killed for outgrowing the spawn buffer reading the tree state",
|
|
537
|
+
when: "status*", act: "yes | head -c 2000000; exit 0",
|
|
538
|
+
status: 1, reason: /NOT PROVEN — could not read the tree state in the throwaway worktree: git was killed by SIGTERM \(ENOBUFS: its output outgrew the spawn buffer\); install output: /,
|
|
539
|
+
},
|
|
540
|
+
{
|
|
541
|
+
name: "a git that wrote to stderr and was then killed",
|
|
542
|
+
when: "status*", act: "echo boom >&2; kill -9 $$",
|
|
543
|
+
status: 1, reason: /NOT PROVEN — could not read the tree state in the throwaway worktree: boom; git was killed by SIGKILL; install output: /,
|
|
544
|
+
},
|
|
545
|
+
{
|
|
546
|
+
name: "a git killed restoring the tree before the mutation",
|
|
547
|
+
when: "reset*", act: "kill -9 $$", files: GO_FILES, args: GO_PROOF,
|
|
548
|
+
status: 1, reason: /NOT PROVEN — could not restore the tree before the mutation: git was killed by SIGKILL; test output: /,
|
|
549
|
+
},
|
|
550
|
+
{
|
|
551
|
+
name: "a git killed reading what the mutation changed",
|
|
552
|
+
when: "diff*", act: "kill -9 $$", files: GO_FILES, args: GO_PROOF,
|
|
553
|
+
status: 1, reason: /NOT PROVEN — could not read what the mutation changed: git was killed by SIGKILL; its output: /,
|
|
554
|
+
},
|
|
555
|
+
{
|
|
556
|
+
name: "a git killed probing for the repository",
|
|
557
|
+
when: '"rev-parse --git-dir"', act: "kill -9 $$",
|
|
558
|
+
status: 2, reason: /^recipe-prove: .* is not a git repository: git was killed by SIGKILL$/m,
|
|
559
|
+
},
|
|
560
|
+
{
|
|
561
|
+
name: "a git killed resolving the common git dir",
|
|
562
|
+
when: '"rev-parse --path-format=absolute --git-common-dir"', act: "kill -9 $$",
|
|
563
|
+
status: 2, reason: /^recipe-prove: cannot resolve the common git dir of .*: git was killed by SIGKILL$/m,
|
|
564
|
+
},
|
|
565
|
+
{
|
|
566
|
+
name: "a git killed resolving origin/main",
|
|
567
|
+
when: '"rev-parse --verify"*', act: "kill -9 $$",
|
|
568
|
+
status: 2, reason: /^recipe-prove: origin\/main does not resolve to a commit \(git was killed by SIGKILL\) — fetch it/m,
|
|
569
|
+
},
|
|
570
|
+
{
|
|
571
|
+
name: "a git killed creating the throwaway worktree",
|
|
572
|
+
when: '"worktree add"*', act: "kill -9 $$",
|
|
573
|
+
status: 2, reason: /^recipe-prove: could not create the throwaway worktree: git was killed by SIGKILL$/m,
|
|
574
|
+
},
|
|
575
|
+
];
|
|
576
|
+
|
|
577
|
+
for (const c of KILLED_GIT) {
|
|
578
|
+
test(`${c.name} names the signal in its refusal`, () => {
|
|
579
|
+
const { dir } = repo(c.files ?? MAVEN_FILES);
|
|
580
|
+
const r = prove(dir, c.args ?? COUNT_PROOF, { env: { PATH: fakeGit(c.when, c.act) } });
|
|
581
|
+
assert.equal(r.status, c.status, r.err);
|
|
582
|
+
assert.match(r.err, c.reason);
|
|
583
|
+
assert.doesNotMatch(r.err, /could not start git/);
|
|
584
|
+
if (c.status === 2) assert.doesNotMatch(r.err, /NOT PROVEN/);
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// The control: a git that ran and exited non-zero is quoted as it wrote, with
|
|
589
|
+
// no kill named.
|
|
590
|
+
test("a git that exits non-zero is refused with exactly its stderr, naming no signal", () => {
|
|
591
|
+
const { dir } = repo(MAVEN_FILES);
|
|
592
|
+
const r = prove(dir, COUNT_PROOF, { env: { PATH: fakeGit("status*", "echo boom >&2; exit 3") } });
|
|
593
|
+
assert.equal(r.status, 1, r.err);
|
|
594
|
+
assert.match(r.err, /NOT PROVEN — could not read the tree state in the throwaway worktree: boom; install output: /);
|
|
595
|
+
assert.doesNotMatch(r.err, /killed/);
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
// A killed `worktree remove` is a failed one: cleanup falls back to the plain
|
|
599
|
+
// delete, and the proof's own NOT PROVEN — which keeps its log directory,
|
|
600
|
+
// the throwaway worktree's parent — stands.
|
|
601
|
+
test("a git killed removing the throwaway worktree still leaves it deleted", () => {
|
|
602
|
+
const { dir } = repo(MAVEN_FILES);
|
|
603
|
+
const r = prove(dir, ["--install", "false", "--test", "true", "--count-line", "x 1", "--test-count", "1"], {
|
|
604
|
+
env: { PATH: fakeGit('"worktree remove"*', "kill -9 $$") },
|
|
605
|
+
});
|
|
606
|
+
assert.equal(r.status, 1, r.err);
|
|
607
|
+
assert.match(r.err, /NOT PROVEN — the Install step 'false' failed/);
|
|
608
|
+
assert.doesNotMatch(r.err, /skipped git/);
|
|
609
|
+
const kept = logDirs(r.tmp);
|
|
610
|
+
assert.equal(kept.length, 1, `log directories in ${r.tmp}: ${kept}`);
|
|
611
|
+
assert.equal(existsSync(join(r.tmp, kept[0], "wt")), false, "the throwaway worktree is gone");
|
|
612
|
+
assert.equal(git(dir, "worktree", "list", "--porcelain").split("\n").filter((l) => l.startsWith("worktree ")).length, 1, "only the main worktree stays registered");
|
|
613
|
+
});
|
|
614
|
+
|
|
509
615
|
// A git that starts for the worktree removal and not for the prune after it:
|
|
510
616
|
// the stub deletes itself once `worktree remove` has run. The proof's own
|
|
511
617
|
// outcome is NOT PROVEN (the Install step fails), and the prune that cannot
|
|
@@ -1273,9 +1273,8 @@ test("step 3 waits on an unfinished run instead of skipping the PR, and only the
|
|
|
1273
1273
|
// registered a run for the rebased head: `ci.runId` and `ci.status` both read
|
|
1274
1274
|
// `null`, which the arm above's "`ci.status` not `completed`" condition also
|
|
1275
1275
|
// matches — collapsed together, a bot reading this prose is sent to wait on
|
|
1276
|
-
// `ci.runId` (step 2) with a `null` id, i.e. `gh run
|
|
1277
|
-
//
|
|
1278
|
-
// step 2's blocking wait.
|
|
1276
|
+
// `ci.runId` (step 2) with a `null` id, i.e. `gh run view null`. This pins
|
|
1277
|
+
// the no-run case as its own arm, never routed through step 2's blocking wait.
|
|
1279
1278
|
test("step 3 waits and re-gates on a null ci.runId instead of routing it into step 2's wait", () => {
|
|
1280
1279
|
const s = step3();
|
|
1281
1280
|
assert.match(
|
|
@@ -1285,7 +1284,7 @@ test("step 3 waits and re-gates on a null ci.runId instead of routing it into st
|
|
|
1285
1284
|
);
|
|
1286
1285
|
assert.match(
|
|
1287
1286
|
s,
|
|
1288
|
-
phrase("never route this into step 2's `gh run
|
|
1287
|
+
phrase("never route this into step 2's `gh run view <run-id>` poll with a null id"),
|
|
1289
1288
|
"step 3 no longer warns against waiting on step 2's blocking call with a null run id",
|
|
1290
1289
|
);
|
|
1291
1290
|
assert.match(
|