@biffo/cli 0.234.1 → 0.235.0
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.
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
#
|
|
3
|
+
# Run the Biffo CLI, from whichever copy is correct for this repo.
|
|
4
|
+
#
|
|
5
|
+
# - **Template** (no `biffo.core.json`): the local `cli/` workspace, through
|
|
6
|
+
# tsx. The template develops the CLI, so its own CI has to exercise the code
|
|
7
|
+
# in the working tree — running the published package here would test the
|
|
8
|
+
# last release instead of the change under review, and a broken guard would
|
|
9
|
+
# go green and only fail after publish.
|
|
10
|
+
#
|
|
11
|
+
# - **Instance** (`biffo.core.json` present): the published package, pinned to
|
|
12
|
+
# the core version this instance is actually on. Instances no longer carry
|
|
13
|
+
# `cli/` at all: it is 31k lines of a scaffolding tool they never develop
|
|
14
|
+
# and never deploy, and shipping it meant the template's own test suite ran
|
|
15
|
+
# in every tenant's CI — where a failure could not be fixed by the repo it
|
|
16
|
+
# failed in.
|
|
17
|
+
#
|
|
18
|
+
# Pinning to `biffo.core.json` rather than `@latest` keeps the guards in step
|
|
19
|
+
# with the core the instance runs. An instance mid-upgrade is checked by the
|
|
20
|
+
# version it is upgrading from, which is the one whose rules its tree follows.
|
|
21
|
+
#
|
|
22
|
+
# That last sentence is why the version is read from `HEAD` and not the working
|
|
23
|
+
# tree (#667). `biffo core upgrade --apply` rewrites `biffo.core.json` to the
|
|
24
|
+
# TARGET version before committing, so by the time the `commit-msg` hook runs
|
|
25
|
+
# the working tree already names a version that, by definition, may not be
|
|
26
|
+
# published yet:
|
|
27
|
+
#
|
|
28
|
+
# npm error notarget No matching version found for @biffo/cli@0.133.1
|
|
29
|
+
# husky - commit-msg script failed (code 1)
|
|
30
|
+
#
|
|
31
|
+
# The upgrade then cannot be committed at all, and `--no-verify` does not help
|
|
32
|
+
# because CI runs the same check. Reading HEAD judges the commit by the rules
|
|
33
|
+
# the tree currently follows, and decouples committing an upgrade from whether
|
|
34
|
+
# the target has reached npm yet.
|
|
35
|
+
set -eu
|
|
36
|
+
|
|
37
|
+
root=$(git rev-parse --show-toplevel)
|
|
38
|
+
cd "$root"
|
|
39
|
+
|
|
40
|
+
# Satellites (sibling apps, plugin repos, runner fleets) carry neither
|
|
41
|
+
# `biffo.core.json` nor `cli/`. They DO carry `.biffo-shared-version`, written
|
|
42
|
+
# by `scripts/shared-sync.sh` and naming the template core version their shared
|
|
43
|
+
# files came from — the same pin, under a different name.
|
|
44
|
+
#
|
|
45
|
+
# Without this branch a satellite fell through to the template's `pnpm --filter`
|
|
46
|
+
# line and failed, so every guard a satellite ran had to be a COPIED shell
|
|
47
|
+
# script kept in step by hand. That is the drift surface `shared-files.json`
|
|
48
|
+
# exists to police: 16 files across 15 repos, roughly 240 copies, and about ten
|
|
49
|
+
# of the estate's guards written to police them. Giving satellites the same
|
|
50
|
+
# versioned CLI instances already use is what makes those copies unnecessary
|
|
51
|
+
# (#1109).
|
|
52
|
+
#
|
|
53
|
+
# The tag form is `core-v0.231.2`; npm wants `0.231.2`.
|
|
54
|
+
if [ ! -f biffo.core.json ] && [ -f .biffo-shared-version ]; then
|
|
55
|
+
shared=$(tr -d ' \t\n\r' < .biffo-shared-version)
|
|
56
|
+
version=${shared#core-v}
|
|
57
|
+
if [ -z "$version" ]; then
|
|
58
|
+
echo "biffo.sh: .biffo-shared-version is present but carries no readable version." >&2
|
|
59
|
+
exit 2
|
|
60
|
+
fi
|
|
61
|
+
exec npx --yes "@biffo/cli@$version" "$@"
|
|
62
|
+
fi
|
|
63
|
+
|
|
64
|
+
if [ -f biffo.core.json ]; then
|
|
65
|
+
# The committed record first. Falls through to the working tree when there is
|
|
66
|
+
# no HEAD yet (a fresh `biffo init`, before the first commit) or when the file
|
|
67
|
+
# is newly added and not yet in any commit.
|
|
68
|
+
version=$(git show HEAD:biffo.core.json 2>/dev/null |
|
|
69
|
+
node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).version" 2>/dev/null || echo '')
|
|
70
|
+
if [ -z "$version" ] || [ "$version" = 'undefined' ]; then
|
|
71
|
+
version=$(node -p "require('./biffo.core.json').version" 2>/dev/null || echo '')
|
|
72
|
+
fi
|
|
73
|
+
if [ -z "$version" ] || [ "$version" = 'undefined' ]; then
|
|
74
|
+
echo "biffo.sh: biffo.core.json is present but carries no readable version." >&2
|
|
75
|
+
exit 2
|
|
76
|
+
fi
|
|
77
|
+
exec npx --yes "@biffo/cli@$version" "$@"
|
|
78
|
+
fi
|
|
79
|
+
|
|
80
|
+
# NOT `pnpm --filter @biffo/cli exec tsx ...`: `pnpm exec` normalises every
|
|
81
|
+
# non-zero exit to 1. Verified — the CLI exits 2 and pnpm reports 1.
|
|
82
|
+
#
|
|
83
|
+
# That silently flattened the exit code of every guard this bridge runs in the
|
|
84
|
+
# template, and it matters now because the estate's conventions are built on a
|
|
85
|
+
# THREE-valued contract: 0 green, 1 failed, **2 cannot tell**, where 2 is never
|
|
86
|
+
# a pass (`wait-for-checks.sh`, `branch-health.sh`, `claim.sh`). Collapsing 2
|
|
87
|
+
# into 1 turns "I could not tell" into "it failed", which is the safe direction
|
|
88
|
+
# by luck rather than design — and the reverse collapse would be a fail-open.
|
|
89
|
+
#
|
|
90
|
+
# `exec` on the binary directly keeps the child's status as this script's own.
|
|
91
|
+
exec "$root/cli/node_modules/.bin/tsx" "$root/cli/src/index.ts" "$@"
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
#
|
|
3
|
+
# Run the Biffo CLI, from whichever copy is correct for this repo.
|
|
4
|
+
#
|
|
5
|
+
# - **Template** (no `biffo.core.json`): the local `cli/` workspace, through
|
|
6
|
+
# tsx. The template develops the CLI, so its own CI has to exercise the code
|
|
7
|
+
# in the working tree — running the published package here would test the
|
|
8
|
+
# last release instead of the change under review, and a broken guard would
|
|
9
|
+
# go green and only fail after publish.
|
|
10
|
+
#
|
|
11
|
+
# - **Instance** (`biffo.core.json` present): the published package, pinned to
|
|
12
|
+
# the core version this instance is actually on. Instances no longer carry
|
|
13
|
+
# `cli/` at all: it is 31k lines of a scaffolding tool they never develop
|
|
14
|
+
# and never deploy, and shipping it meant the template's own test suite ran
|
|
15
|
+
# in every tenant's CI — where a failure could not be fixed by the repo it
|
|
16
|
+
# failed in.
|
|
17
|
+
#
|
|
18
|
+
# Pinning to `biffo.core.json` rather than `@latest` keeps the guards in step
|
|
19
|
+
# with the core the instance runs. An instance mid-upgrade is checked by the
|
|
20
|
+
# version it is upgrading from, which is the one whose rules its tree follows.
|
|
21
|
+
#
|
|
22
|
+
# That last sentence is why the version is read from `HEAD` and not the working
|
|
23
|
+
# tree (#667). `biffo core upgrade --apply` rewrites `biffo.core.json` to the
|
|
24
|
+
# TARGET version before committing, so by the time the `commit-msg` hook runs
|
|
25
|
+
# the working tree already names a version that, by definition, may not be
|
|
26
|
+
# published yet:
|
|
27
|
+
#
|
|
28
|
+
# npm error notarget No matching version found for @biffo/cli@0.133.1
|
|
29
|
+
# husky - commit-msg script failed (code 1)
|
|
30
|
+
#
|
|
31
|
+
# The upgrade then cannot be committed at all, and `--no-verify` does not help
|
|
32
|
+
# because CI runs the same check. Reading HEAD judges the commit by the rules
|
|
33
|
+
# the tree currently follows, and decouples committing an upgrade from whether
|
|
34
|
+
# the target has reached npm yet.
|
|
35
|
+
set -eu
|
|
36
|
+
|
|
37
|
+
root=$(git rev-parse --show-toplevel)
|
|
38
|
+
cd "$root"
|
|
39
|
+
|
|
40
|
+
# Satellites (sibling apps, plugin repos, runner fleets) carry neither
|
|
41
|
+
# `biffo.core.json` nor `cli/`. They DO carry `.biffo-shared-version`, written
|
|
42
|
+
# by `scripts/shared-sync.sh` and naming the template core version their shared
|
|
43
|
+
# files came from — the same pin, under a different name.
|
|
44
|
+
#
|
|
45
|
+
# Without this branch a satellite fell through to the template's `pnpm --filter`
|
|
46
|
+
# line and failed, so every guard a satellite ran had to be a COPIED shell
|
|
47
|
+
# script kept in step by hand. That is the drift surface `shared-files.json`
|
|
48
|
+
# exists to police: 16 files across 15 repos, roughly 240 copies, and about ten
|
|
49
|
+
# of the estate's guards written to police them. Giving satellites the same
|
|
50
|
+
# versioned CLI instances already use is what makes those copies unnecessary
|
|
51
|
+
# (#1109).
|
|
52
|
+
#
|
|
53
|
+
# The tag form is `core-v0.231.2`; npm wants `0.231.2`.
|
|
54
|
+
if [ ! -f biffo.core.json ] && [ -f .biffo-shared-version ]; then
|
|
55
|
+
shared=$(tr -d ' \t\n\r' < .biffo-shared-version)
|
|
56
|
+
version=${shared#core-v}
|
|
57
|
+
if [ -z "$version" ]; then
|
|
58
|
+
echo "biffo.sh: .biffo-shared-version is present but carries no readable version." >&2
|
|
59
|
+
exit 2
|
|
60
|
+
fi
|
|
61
|
+
exec npx --yes "@biffo/cli@$version" "$@"
|
|
62
|
+
fi
|
|
63
|
+
|
|
64
|
+
if [ -f biffo.core.json ]; then
|
|
65
|
+
# The committed record first. Falls through to the working tree when there is
|
|
66
|
+
# no HEAD yet (a fresh `biffo init`, before the first commit) or when the file
|
|
67
|
+
# is newly added and not yet in any commit.
|
|
68
|
+
version=$(git show HEAD:biffo.core.json 2>/dev/null |
|
|
69
|
+
node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).version" 2>/dev/null || echo '')
|
|
70
|
+
if [ -z "$version" ] || [ "$version" = 'undefined' ]; then
|
|
71
|
+
version=$(node -p "require('./biffo.core.json').version" 2>/dev/null || echo '')
|
|
72
|
+
fi
|
|
73
|
+
if [ -z "$version" ] || [ "$version" = 'undefined' ]; then
|
|
74
|
+
echo "biffo.sh: biffo.core.json is present but carries no readable version." >&2
|
|
75
|
+
exit 2
|
|
76
|
+
fi
|
|
77
|
+
exec npx --yes "@biffo/cli@$version" "$@"
|
|
78
|
+
fi
|
|
79
|
+
|
|
80
|
+
# NOT `pnpm --filter @biffo/cli exec tsx ...`: `pnpm exec` normalises every
|
|
81
|
+
# non-zero exit to 1. Verified — the CLI exits 2 and pnpm reports 1.
|
|
82
|
+
#
|
|
83
|
+
# That silently flattened the exit code of every guard this bridge runs in the
|
|
84
|
+
# template, and it matters now because the estate's conventions are built on a
|
|
85
|
+
# THREE-valued contract: 0 green, 1 failed, **2 cannot tell**, where 2 is never
|
|
86
|
+
# a pass (`wait-for-checks.sh`, `branch-health.sh`, `claim.sh`). Collapsing 2
|
|
87
|
+
# into 1 turns "I could not tell" into "it failed", which is the safe direction
|
|
88
|
+
# by luck rather than design — and the reverse collapse would be a fail-open.
|
|
89
|
+
#
|
|
90
|
+
# `exec` on the binary directly keeps the child's status as this script's own.
|
|
91
|
+
exec "$root/cli/node_modules/.bin/tsx" "$root/cli/src/index.ts" "$@"
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import { Command as
|
|
4
|
+
import { Command as Command27 } from "commander";
|
|
5
5
|
|
|
6
6
|
// src/commands/core.ts
|
|
7
7
|
import { Command as Command4 } from "commander";
|
|
@@ -9684,8 +9684,8 @@ async function runOwnershipCheck(argv) {
|
|
|
9684
9684
|
const { stdout } = await execa7("git", ["diff", "--cached", "--name-status"], { cwd: root });
|
|
9685
9685
|
({ changed: changedFiles, deleted: deletedFiles } = parseNameStatus(stdout));
|
|
9686
9686
|
if (messageFile) {
|
|
9687
|
-
const { readFileSync: readFileSync28, existsSync:
|
|
9688
|
-
if (
|
|
9687
|
+
const { readFileSync: readFileSync28, existsSync: existsSync39 } = await import("fs");
|
|
9688
|
+
if (existsSync39(messageFile)) commitMessage = readFileSync28(messageFile, "utf8");
|
|
9689
9689
|
}
|
|
9690
9690
|
} else {
|
|
9691
9691
|
const base = process.env["GITHUB_BASE_REF"] ?? args[0];
|
|
@@ -10765,8 +10765,50 @@ function resolveGithubToken4() {
|
|
|
10765
10765
|
);
|
|
10766
10766
|
}
|
|
10767
10767
|
|
|
10768
|
+
// src/commands/wait-for-checks.ts
|
|
10769
|
+
import { spawnSync } from "child_process";
|
|
10770
|
+
import { dirname as dirname11 } from "path";
|
|
10771
|
+
import { fileURLToPath as fileURLToPath6 } from "url";
|
|
10772
|
+
import { Command as Command26 } from "commander";
|
|
10773
|
+
|
|
10774
|
+
// src/lib/packaged-scripts.ts
|
|
10775
|
+
import { existsSync as existsSync38 } from "fs";
|
|
10776
|
+
import { dirname as dirname10, join as join38 } from "path";
|
|
10777
|
+
function findPackagedScript(startDir, relativePath) {
|
|
10778
|
+
let dir = startDir;
|
|
10779
|
+
for (; ; ) {
|
|
10780
|
+
const candidate = join38(dir, relativePath);
|
|
10781
|
+
if (existsSync38(candidate)) return candidate;
|
|
10782
|
+
const parent = dirname10(dir);
|
|
10783
|
+
if (parent === dir) return null;
|
|
10784
|
+
dir = parent;
|
|
10785
|
+
}
|
|
10786
|
+
}
|
|
10787
|
+
function packagedScriptMissing(relativePath) {
|
|
10788
|
+
return `biffo: cannot find ${relativePath}.
|
|
10789
|
+
It ships with this package via cli/scripts/packaged-root-assets.mjs; if you are running from a checkout, run from inside the template repo. If you are running the published package, this is a packaging bug \u2014 the asset was not copied at prepack.`;
|
|
10790
|
+
}
|
|
10791
|
+
|
|
10792
|
+
// src/commands/wait-for-checks.ts
|
|
10793
|
+
var SCRIPT = "scripts/wait-for-checks.sh";
|
|
10794
|
+
var waitForChecksCommand = new Command26("wait-for-checks").description(
|
|
10795
|
+
"Wait for a PR\u2019s required checks on a positive signal (0 green, 1 failed, 2 cannot tell)"
|
|
10796
|
+
).argument("<pr>", "Pull request number").allowExcessArguments(true).allowUnknownOption(true).action(() => {
|
|
10797
|
+
const here = dirname11(fileURLToPath6(import.meta.url));
|
|
10798
|
+
const script = findPackagedScript(here, SCRIPT);
|
|
10799
|
+
if (!script) {
|
|
10800
|
+
process.stderr.write(`${packagedScriptMissing(SCRIPT)}
|
|
10801
|
+
`);
|
|
10802
|
+
process.exit(2);
|
|
10803
|
+
}
|
|
10804
|
+
const at = process.argv.indexOf("wait-for-checks");
|
|
10805
|
+
const args = at === -1 ? [] : process.argv.slice(at + 1);
|
|
10806
|
+
const result = spawnSync("sh", [script, ...args], { stdio: "inherit" });
|
|
10807
|
+
process.exit(result.status === null ? 2 : result.status);
|
|
10808
|
+
});
|
|
10809
|
+
|
|
10768
10810
|
// src/index.ts
|
|
10769
|
-
var program = new
|
|
10811
|
+
var program = new Command27();
|
|
10770
10812
|
function cliVersion() {
|
|
10771
10813
|
try {
|
|
10772
10814
|
return getLatestCoreVersion();
|
|
@@ -10779,6 +10821,7 @@ program.addCommand(initCommand);
|
|
|
10779
10821
|
program.addCommand(deployCommand);
|
|
10780
10822
|
program.addCommand(destroyCommand);
|
|
10781
10823
|
program.addCommand(teardownCommand);
|
|
10824
|
+
program.addCommand(waitForChecksCommand);
|
|
10782
10825
|
program.addCommand(pluginCommand);
|
|
10783
10826
|
program.addCommand(dataCommand);
|
|
10784
10827
|
program.addCommand(coreCommand);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@biffo/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.235.0",
|
|
4
4
|
"description": "Biffo project scaffolding CLI",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
"files": [
|
|
24
24
|
"dist",
|
|
25
25
|
"schemas",
|
|
26
|
-
"_skeletons"
|
|
26
|
+
"_skeletons",
|
|
27
|
+
"scripts/wait-for-checks.sh"
|
|
27
28
|
],
|
|
28
29
|
"scripts": {
|
|
29
30
|
"build": "tsup src/index.ts --format esm --dts --clean",
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# Wait for a pull request's checks to finish, without mistaking "not started"
|
|
4
|
+
# for "all green".
|
|
5
|
+
#
|
|
6
|
+
# ## Why this exists
|
|
7
|
+
#
|
|
8
|
+
# Every session hand-rolls this loop, and the natural formulation is wrong in
|
|
9
|
+
# the dangerous direction:
|
|
10
|
+
#
|
|
11
|
+
# until [ "$(gh pr checks "$N" | grep -c pending)" = 0 ]; do sleep 30; done
|
|
12
|
+
#
|
|
13
|
+
# That polls for the **absence** of pending work, so a transient empty set reads
|
|
14
|
+
# as completion. Immediately after `gh pr update-branch` GitHub drops the
|
|
15
|
+
# superseded check runs before registering the new ones — for a few seconds
|
|
16
|
+
# there are **zero** checks, `pending` is 0, and the loop exits on a PR whose CI
|
|
17
|
+
# has not started. The caller then merges. Observed on 2026-08-02 while clearing
|
|
18
|
+
# a 13-repo queue; the same session also wrote an `until` whose `|| &&`
|
|
19
|
+
# precedence never terminated and burned a full 10-minute timeout.
|
|
20
|
+
#
|
|
21
|
+
# That is the estate's dominant failure shape — a gate passing because it cannot
|
|
22
|
+
# run — reproduced inside the agent's own tooling, where no CI guard can see it.
|
|
23
|
+
#
|
|
24
|
+
# ## The rule this encodes
|
|
25
|
+
#
|
|
26
|
+
# **Wait on a positive signal, never on the absence of a negative.** Two ways to
|
|
27
|
+
# get one, strongest first:
|
|
28
|
+
#
|
|
29
|
+
# 1. **Branch protection's required contexts.** If the base branch is protected,
|
|
30
|
+
# those names are exactly the checks that MUST report, so "every required
|
|
31
|
+
# context has concluded" is a direct answer rather than an inference. This is
|
|
32
|
+
# the only condition that cannot be satisfied by an empty or half-registered
|
|
33
|
+
# set.
|
|
34
|
+
# 2. **Stability, when protection is unreadable.** Some repos are unprotected
|
|
35
|
+
# (both plugin repos were until 2026-07-27) and a token may lack the scope to
|
|
36
|
+
# read protection. Then: at least one check present, all concluded, and the
|
|
37
|
+
# same count seen on two consecutive polls — so a fast check concluding while
|
|
38
|
+
# slower ones are still registering does not end the wait early.
|
|
39
|
+
#
|
|
40
|
+
# ## Exit codes, and why 2 exists
|
|
41
|
+
#
|
|
42
|
+
# 0 every required/observed check concluded, none failed
|
|
43
|
+
# 1 a check failed — the names are printed
|
|
44
|
+
# 2 cannot determine: timed out, no checks ever appeared, PR unreadable
|
|
45
|
+
#
|
|
46
|
+
# 2 is distinct from 1 on purpose, and neither is 0. A timeout is not a pass,
|
|
47
|
+
# and a caller that treats "cannot tell" as "green" has rebuilt the defect this
|
|
48
|
+
# script exists to prevent. `ci-wiring-audit.sh` uses the same 2-means-cannot-run
|
|
49
|
+
# convention.
|
|
50
|
+
#
|
|
51
|
+
# `cancelled` is reported separately rather than as a failure: on this estate's
|
|
52
|
+
# self-hosted runners a cancelled job is usually spot reclamation or a
|
|
53
|
+
# `cancel-in-progress` concurrency group, not the code. It still exits 1 —
|
|
54
|
+
# something must be re-run — but the message says which, so nobody debugs a
|
|
55
|
+
# phantom.
|
|
56
|
+
#
|
|
57
|
+
# ## Usage
|
|
58
|
+
#
|
|
59
|
+
# sh scripts/wait-for-checks.sh <pr-number> [-R owner/repo]
|
|
60
|
+
# [--timeout SECONDS] [--interval SECONDS]
|
|
61
|
+
#
|
|
62
|
+
# Requires `gh`, authenticated. Uses gh's embedded jq, so no jq binary is needed.
|
|
63
|
+
|
|
64
|
+
set -uo pipefail
|
|
65
|
+
|
|
66
|
+
PR=""
|
|
67
|
+
REPO=""
|
|
68
|
+
TIMEOUT="${WAIT_FOR_CHECKS_TIMEOUT:-1800}"
|
|
69
|
+
INTERVAL="${WAIT_FOR_CHECKS_INTERVAL:-30}"
|
|
70
|
+
|
|
71
|
+
usage() {
|
|
72
|
+
sed -n '2,60p' "$0" | sed 's/^# \{0,1\}//'
|
|
73
|
+
exit 2
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
while [ $# -gt 0 ]; do
|
|
77
|
+
case "$1" in
|
|
78
|
+
-R | --repo)
|
|
79
|
+
REPO="${2:-}"
|
|
80
|
+
shift 2
|
|
81
|
+
;;
|
|
82
|
+
--timeout)
|
|
83
|
+
TIMEOUT="${2:-}"
|
|
84
|
+
shift 2
|
|
85
|
+
;;
|
|
86
|
+
--interval)
|
|
87
|
+
INTERVAL="${2:-}"
|
|
88
|
+
shift 2
|
|
89
|
+
;;
|
|
90
|
+
-h | --help) usage ;;
|
|
91
|
+
*)
|
|
92
|
+
PR="$1"
|
|
93
|
+
shift
|
|
94
|
+
;;
|
|
95
|
+
esac
|
|
96
|
+
done
|
|
97
|
+
|
|
98
|
+
[ -n "$PR" ] || {
|
|
99
|
+
echo "wait-for-checks: no PR number given" >&2
|
|
100
|
+
usage
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
RED=$(printf '\033[31m')
|
|
104
|
+
GREEN=$(printf '\033[32m')
|
|
105
|
+
DIM=$(printf '\033[90m')
|
|
106
|
+
OFF=$(printf '\033[0m')
|
|
107
|
+
|
|
108
|
+
gh_pr() {
|
|
109
|
+
if [ -n "$REPO" ]; then gh pr "$@" --repo "$REPO"; else gh pr "$@"; fi
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
gh_api() {
|
|
113
|
+
gh api "$@" 2>/dev/null
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
# --- What is this PR, and is there anything to wait for? ----------------------
|
|
117
|
+
|
|
118
|
+
meta=$(gh_pr view "$PR" --json state,baseRefName --jq '"\(.state)\t\(.baseRefName)"') || {
|
|
119
|
+
echo "${RED}wait-for-checks: cannot read PR $PR${OFF}" >&2
|
|
120
|
+
exit 2
|
|
121
|
+
}
|
|
122
|
+
state=${meta%% *}
|
|
123
|
+
base=${meta##* }
|
|
124
|
+
|
|
125
|
+
case "$state" in
|
|
126
|
+
MERGED | CLOSED)
|
|
127
|
+
echo "${DIM}PR $PR is $state — nothing to wait for.${OFF}"
|
|
128
|
+
exit 0
|
|
129
|
+
;;
|
|
130
|
+
esac
|
|
131
|
+
|
|
132
|
+
# --- Signal 1: the checks branch protection says MUST report ------------------
|
|
133
|
+
|
|
134
|
+
owner_repo="$REPO"
|
|
135
|
+
[ -n "$owner_repo" ] || owner_repo=$(gh repo view --json nameWithOwner --jq .nameWithOwner 2>/dev/null)
|
|
136
|
+
|
|
137
|
+
required=""
|
|
138
|
+
if [ -n "$owner_repo" ]; then
|
|
139
|
+
required=$(gh_api "repos/$owner_repo/branches/$base/protection" \
|
|
140
|
+
--jq '.required_status_checks.contexts[]?' | sort -u)
|
|
141
|
+
fi
|
|
142
|
+
|
|
143
|
+
if [ -n "$required" ]; then
|
|
144
|
+
echo "${DIM}Waiting on $(echo "$required" | wc -l | tr -d ' ') required check(s) on $base.${OFF}"
|
|
145
|
+
else
|
|
146
|
+
# Not an error. An unprotected branch is a real configuration, and a token
|
|
147
|
+
# without the scope to read protection is common. Say which mode is in use, so
|
|
148
|
+
# a weaker guarantee is never mistaken for the strong one.
|
|
149
|
+
echo "${DIM}No readable branch protection on $base — falling back to stability.${OFF}"
|
|
150
|
+
fi
|
|
151
|
+
|
|
152
|
+
# --- Poll ---------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
deadline=$(($(date +%s) + TIMEOUT))
|
|
155
|
+
prev_count=-1
|
|
156
|
+
rollup=""
|
|
157
|
+
|
|
158
|
+
while :; do
|
|
159
|
+
rollup=$(gh_pr view "$PR" --json statusCheckRollup --jq '
|
|
160
|
+
[ .statusCheckRollup[]?
|
|
161
|
+
| { name: (.name // .context),
|
|
162
|
+
state: (.conclusion // .state // (if .status == "COMPLETED" then "" else null end))
|
|
163
|
+
}
|
|
164
|
+
] | .[] | "\(.name)\t\(.state // "")"') || rollup=""
|
|
165
|
+
|
|
166
|
+
count=0
|
|
167
|
+
[ -n "$rollup" ] && count=$(printf '%s\n' "$rollup" | grep -c .)
|
|
168
|
+
|
|
169
|
+
# Every check that has reported a terminal state.
|
|
170
|
+
concluded=$(printf '%s\n' "$rollup" | awk -F'\t' 'NF && $2 != "" && $2 != "PENDING" && $2 != "IN_PROGRESS" && $2 != "QUEUED" && $2 != "WAITING" { print $1 }')
|
|
171
|
+
|
|
172
|
+
done_waiting=0
|
|
173
|
+
|
|
174
|
+
if [ -n "$required" ]; then
|
|
175
|
+
# Strong condition: every required context is present AND concluded.
|
|
176
|
+
missing=""
|
|
177
|
+
while IFS= read -r ctx; do
|
|
178
|
+
[ -n "$ctx" ] || continue
|
|
179
|
+
printf '%s\n' "$concluded" | grep -Fxq "$ctx" || missing="$missing $ctx"
|
|
180
|
+
done <<EOF
|
|
181
|
+
$required
|
|
182
|
+
EOF
|
|
183
|
+
[ -z "$missing" ] && done_waiting=1
|
|
184
|
+
else
|
|
185
|
+
# Fallback: at least one check, all concluded, and the set has stopped
|
|
186
|
+
# growing. The count check is what stops a fast Secret Scan concluding alone
|
|
187
|
+
# while five slower jobs are still being registered.
|
|
188
|
+
if [ "$count" -gt 0 ]; then
|
|
189
|
+
n_concluded=$(printf '%s\n' "$concluded" | grep -c .)
|
|
190
|
+
if [ "$n_concluded" = "$count" ] && [ "$count" = "$prev_count" ]; then
|
|
191
|
+
done_waiting=1
|
|
192
|
+
fi
|
|
193
|
+
fi
|
|
194
|
+
fi
|
|
195
|
+
|
|
196
|
+
[ "$done_waiting" = "1" ] && break
|
|
197
|
+
|
|
198
|
+
prev_count=$count
|
|
199
|
+
|
|
200
|
+
now=$(date +%s)
|
|
201
|
+
if [ "$now" -ge "$deadline" ]; then
|
|
202
|
+
echo "${RED}wait-for-checks: timed out after ${TIMEOUT}s.${OFF}" >&2
|
|
203
|
+
if [ "$count" = "0" ]; then
|
|
204
|
+
# The exact case the naive loop gets wrong, so name it explicitly.
|
|
205
|
+
echo "No checks ever appeared on PR $PR. That is 'cannot tell', not 'green'." >&2
|
|
206
|
+
else
|
|
207
|
+
echo "Still unfinished:" >&2
|
|
208
|
+
printf '%s\n' "$rollup" | awk -F'\t' 'NF && ($2 == "" || $2 == "PENDING" || $2 == "IN_PROGRESS" || $2 == "QUEUED" || $2 == "WAITING") { print " " $1 }' >&2
|
|
209
|
+
fi
|
|
210
|
+
exit 2
|
|
211
|
+
fi
|
|
212
|
+
|
|
213
|
+
sleep "$INTERVAL"
|
|
214
|
+
done
|
|
215
|
+
|
|
216
|
+
# --- Report -------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
failed=$(printf '%s\n' "$rollup" | awk -F'\t' 'NF && ($2 == "FAILURE" || $2 == "TIMED_OUT" || $2 == "ACTION_REQUIRED" || $2 == "STARTUP_FAILURE" || $2 == "ERROR") { print " " $1 " (" $2 ")" }')
|
|
219
|
+
cancelled=$(printf '%s\n' "$rollup" | awk -F'\t' 'NF && $2 == "CANCELLED" { print " " $1 }')
|
|
220
|
+
|
|
221
|
+
if [ -n "$cancelled" ]; then
|
|
222
|
+
echo "${RED}Cancelled:${OFF}"
|
|
223
|
+
printf '%s\n' "$cancelled"
|
|
224
|
+
echo "${DIM}A cancelled check is usually infrastructure (spot reclamation, or a" >&2
|
|
225
|
+
echo "cancel-in-progress concurrency group), not your code. Re-run it rather" >&2
|
|
226
|
+
echo "than debugging a phantom.${OFF}" >&2
|
|
227
|
+
fi
|
|
228
|
+
|
|
229
|
+
if [ -n "$failed" ]; then
|
|
230
|
+
echo "${RED}Failed:${OFF}"
|
|
231
|
+
printf '%s\n' "$failed"
|
|
232
|
+
exit 1
|
|
233
|
+
fi
|
|
234
|
+
|
|
235
|
+
[ -n "$cancelled" ] && exit 1
|
|
236
|
+
|
|
237
|
+
echo "${GREEN}All checks concluded, none failed.${OFF}"
|
|
238
|
+
exit 0
|