@biffo/cli 0.235.0 → 0.237.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.
- package/_skeletons/plugin-template/AGENTS.md +29 -6
- package/_skeletons/plugin-template/scripts/biffo.sh +16 -0
- package/_skeletons/sibling-template/AGENTS.md +27 -4
- package/_skeletons/sibling-template/scripts/biffo.sh +16 -0
- package/dist/index.js +46 -16
- package/package.json +4 -2
- package/_skeletons/plugin-template/scripts/wait-for-checks.sh +0 -238
- package/_skeletons/sibling-template/scripts/branch-health.sh +0 -306
- package/_skeletons/sibling-template/scripts/claim.sh +0 -208
- package/_skeletons/sibling-template/scripts/wait-for-checks.sh +0 -238
- /package/{_skeletons/plugin-template/scripts → scripts}/branch-health.sh +0 -0
- /package/{_skeletons/plugin-template/scripts → scripts}/claim.sh +0 -0
|
@@ -48,7 +48,7 @@ This file is distributed by the template and kept in step by
|
|
|
48
48
|
**Before starting work on an issue, run:**
|
|
49
49
|
|
|
50
50
|
```bash
|
|
51
|
-
sh scripts/
|
|
51
|
+
sh scripts/biffo.sh claim <issue-number> [-R owner/repo] # 0 free · 1 taken · 2 cannot tell
|
|
52
52
|
```
|
|
53
53
|
|
|
54
54
|
Several agent sessions run against this estate at once. The script asks four
|
|
@@ -96,12 +96,17 @@ it deliberately and say so in a comment; never steal a fresh one.
|
|
|
96
96
|
|
|
97
97
|
- Get CI green and confirm it: `gh pr checks <N>`. A green local run is not
|
|
98
98
|
sufficient — verify the actual PR checks.
|
|
99
|
-
- **Wait with `
|
|
99
|
+
- **Wait with the CLI's `wait-for-checks`, not a hand-rolled loop:**
|
|
100
100
|
|
|
101
101
|
```bash
|
|
102
|
-
sh scripts/wait-for-checks
|
|
102
|
+
sh scripts/biffo.sh wait-for-checks <N> # 0 green · 1 failed · 2 cannot tell
|
|
103
103
|
```
|
|
104
104
|
|
|
105
|
+
This repo no longer carries its own copy of the script. `scripts/biffo.sh`
|
|
106
|
+
resolves the version-pinned Biffo CLI from `.biffo-shared-version` and runs
|
|
107
|
+
the canonical copy that ships inside the package, so there is one script
|
|
108
|
+
rather than one per repo (#1109).
|
|
109
|
+
|
|
105
110
|
Do not write your own `until … grep -c pending … done`. That polls for the
|
|
106
111
|
**absence** of pending checks, so the empty window right after
|
|
107
112
|
`gh pr update-branch` — superseded runs dropped, new ones not yet registered —
|
|
@@ -122,7 +127,25 @@ it deliberately and say so in a comment; never steal a fresh one.
|
|
|
122
127
|
before merge: `git log origin/<branch> -1`. A green PR page is not proof your
|
|
123
128
|
latest local commit reached it.
|
|
124
129
|
|
|
125
|
-
## 7.
|
|
130
|
+
## 7. Verify post-merge
|
|
131
|
+
|
|
132
|
+
- A merge is not done when the PR closes. **Check the whole branch:**
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
sh scripts/biffo.sh branch-health [-R owner/repo] # 0 green · 1 red · 2 cannot tell
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
It reports the latest run of **every** workflow on the integration branch, so
|
|
139
|
+
the deploy cannot fall off the bottom of a short `gh run list` — and when
|
|
140
|
+
something is red it names the **first** failing commit, not the newest.
|
|
141
|
+
|
|
142
|
+
- **Check whether the branch was already failing before diagnosing your own
|
|
143
|
+
change.** A red deploy has no audience: the author who broke it has moved on,
|
|
144
|
+
and every later merge fails on damage it did not cause. On 2026-08-02 that
|
|
145
|
+
cost 2h25m and four people each diagnosed their own innocent change.
|
|
146
|
+
- Exit 2 is "cannot tell" and is never a pass.
|
|
147
|
+
|
|
148
|
+
## 8. Creating this repo correctly (read once, at birth)
|
|
126
149
|
|
|
127
150
|
**No `biffo` command creates a standalone plugin repo.** `biffo plugin create`
|
|
128
151
|
scaffolds a plugin _into an existing checkout_; a repo like this one is made by
|
|
@@ -154,7 +177,7 @@ biffo check branch-protection --repo <org>/<repo>
|
|
|
154
177
|
> that means merging with checks still running. Steps 1 and 2 together are what
|
|
155
178
|
> make the documented flow behave as documented; either alone is not enough.
|
|
156
179
|
|
|
157
|
-
##
|
|
180
|
+
## 9. CI runners — two steps this repo cannot do for itself
|
|
158
181
|
|
|
159
182
|
The workflows use `runs-on: ${{ vars.RUNNER_LABEL || 'ubuntu-latest' }}`, so they
|
|
160
183
|
work anywhere by default and route to a self-hosted fleet when one exists. Two
|
|
@@ -171,7 +194,7 @@ fleet:
|
|
|
171
194
|
If a job is queued and nothing is happening, check the grant before anything
|
|
172
195
|
else. It is the failure that looks exactly like patience.
|
|
173
196
|
|
|
174
|
-
##
|
|
197
|
+
## 10. Security
|
|
175
198
|
|
|
176
199
|
- **Never commit secrets** (keys, tokens, credentials, `.env` values).
|
|
177
200
|
- **Never silently disable a security gate.** If one must be loosened, do it in
|
|
@@ -77,6 +77,22 @@ if [ -f biffo.core.json ]; then
|
|
|
77
77
|
exec npx --yes "@biffo/cli@$version" "$@"
|
|
78
78
|
fi
|
|
79
79
|
|
|
80
|
+
# Nothing above matched, so this must be the template — the only repo that
|
|
81
|
+
# carries `cli/`. A satellite that reaches here has no pin, which since #1109
|
|
82
|
+
# means it cannot run guards at all; before this branch it fell through and
|
|
83
|
+
# exec'd a `tsx` that does not exist, exiting 127 with no explanation.
|
|
84
|
+
#
|
|
85
|
+
# `biffo sibling create` now stamps the pin at birth, so this is reachable
|
|
86
|
+
# mainly by a repo scaffolded before that, or by a plugin repo (`biffo plugin
|
|
87
|
+
# create` scaffolds INTO an existing repo, so it has no standalone repo root to
|
|
88
|
+
# stamp). `shared-sync.sh` writes the pin on its first run, which is the fix.
|
|
89
|
+
if [ ! -d cli ]; then
|
|
90
|
+
echo "biffo.sh: no biffo.core.json, no .biffo-shared-version, and no cli/ here." >&2
|
|
91
|
+
echo " If this is a satellite, run shared-sync from the template to stamp it:" >&2
|
|
92
|
+
echo " sh scripts/shared-sync.sh --estate <path-to-your-repos>" >&2
|
|
93
|
+
exit 2
|
|
94
|
+
fi
|
|
95
|
+
|
|
80
96
|
# NOT `pnpm --filter @biffo/cli exec tsx ...`: `pnpm exec` normalises every
|
|
81
97
|
# non-zero exit to 1. Verified — the CLI exits 2 and pnpm reports 1.
|
|
82
98
|
#
|
|
@@ -48,7 +48,7 @@ This file is distributed by the template and kept in step by
|
|
|
48
48
|
**Before starting work on an issue, run:**
|
|
49
49
|
|
|
50
50
|
```bash
|
|
51
|
-
sh scripts/
|
|
51
|
+
sh scripts/biffo.sh claim <issue-number> [-R owner/repo] # 0 free · 1 taken · 2 cannot tell
|
|
52
52
|
```
|
|
53
53
|
|
|
54
54
|
Several agent sessions run against this estate at once. The script asks four
|
|
@@ -96,12 +96,17 @@ it deliberately and say so in a comment; never steal a fresh one.
|
|
|
96
96
|
|
|
97
97
|
- Get CI green and confirm it: `gh pr checks <N>`. A green local run is not
|
|
98
98
|
sufficient — verify the actual PR checks.
|
|
99
|
-
- **Wait with `
|
|
99
|
+
- **Wait with the CLI's `wait-for-checks`, not a hand-rolled loop:**
|
|
100
100
|
|
|
101
101
|
```bash
|
|
102
|
-
sh scripts/wait-for-checks
|
|
102
|
+
sh scripts/biffo.sh wait-for-checks <N> # 0 green · 1 failed · 2 cannot tell
|
|
103
103
|
```
|
|
104
104
|
|
|
105
|
+
This repo no longer carries its own copy of the script. `scripts/biffo.sh`
|
|
106
|
+
resolves the version-pinned Biffo CLI from `.biffo-shared-version` and runs
|
|
107
|
+
the canonical copy that ships inside the package, so there is one script
|
|
108
|
+
rather than one per repo (#1109).
|
|
109
|
+
|
|
105
110
|
Do not write your own `until … grep -c pending … done`. That polls for the
|
|
106
111
|
**absence** of pending checks, so the empty window right after
|
|
107
112
|
`gh pr update-branch` — superseded runs dropped, new ones not yet registered —
|
|
@@ -122,7 +127,25 @@ it deliberately and say so in a comment; never steal a fresh one.
|
|
|
122
127
|
before merge: `git log origin/<branch> -1`. A green PR page is not proof your
|
|
123
128
|
latest local commit reached it.
|
|
124
129
|
|
|
125
|
-
## 7.
|
|
130
|
+
## 7. Verify post-merge
|
|
131
|
+
|
|
132
|
+
- A merge is not done when the PR closes. **Check the whole branch:**
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
sh scripts/biffo.sh branch-health [-R owner/repo] # 0 green · 1 red · 2 cannot tell
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
It reports the latest run of **every** workflow on the integration branch, so
|
|
139
|
+
the deploy cannot fall off the bottom of a short `gh run list` — and when
|
|
140
|
+
something is red it names the **first** failing commit, not the newest.
|
|
141
|
+
|
|
142
|
+
- **Check whether the branch was already failing before diagnosing your own
|
|
143
|
+
change.** A red deploy has no audience: the author who broke it has moved on,
|
|
144
|
+
and every later merge fails on damage it did not cause. On 2026-08-02 that
|
|
145
|
+
cost 2h25m and four people each diagnosed their own innocent change.
|
|
146
|
+
- Exit 2 is "cannot tell" and is never a pass.
|
|
147
|
+
|
|
148
|
+
## 8. Security
|
|
126
149
|
|
|
127
150
|
- **Never commit secrets** (keys, tokens, credentials, `.env` values).
|
|
128
151
|
- **Never silently disable a security gate.** If one must be loosened, do it in
|
|
@@ -77,6 +77,22 @@ if [ -f biffo.core.json ]; then
|
|
|
77
77
|
exec npx --yes "@biffo/cli@$version" "$@"
|
|
78
78
|
fi
|
|
79
79
|
|
|
80
|
+
# Nothing above matched, so this must be the template — the only repo that
|
|
81
|
+
# carries `cli/`. A satellite that reaches here has no pin, which since #1109
|
|
82
|
+
# means it cannot run guards at all; before this branch it fell through and
|
|
83
|
+
# exec'd a `tsx` that does not exist, exiting 127 with no explanation.
|
|
84
|
+
#
|
|
85
|
+
# `biffo sibling create` now stamps the pin at birth, so this is reachable
|
|
86
|
+
# mainly by a repo scaffolded before that, or by a plugin repo (`biffo plugin
|
|
87
|
+
# create` scaffolds INTO an existing repo, so it has no standalone repo root to
|
|
88
|
+
# stamp). `shared-sync.sh` writes the pin on its first run, which is the fix.
|
|
89
|
+
if [ ! -d cli ]; then
|
|
90
|
+
echo "biffo.sh: no biffo.core.json, no .biffo-shared-version, and no cli/ here." >&2
|
|
91
|
+
echo " If this is a satellite, run shared-sync from the template to stamp it:" >&2
|
|
92
|
+
echo " sh scripts/shared-sync.sh --estate <path-to-your-repos>" >&2
|
|
93
|
+
exit 2
|
|
94
|
+
fi
|
|
95
|
+
|
|
80
96
|
# NOT `pnpm --filter @biffo/cli exec tsx ...`: `pnpm exec` normalises every
|
|
81
97
|
# non-zero exit to 1. Verified — the CLI exits 2 and pnpm reports 1.
|
|
82
98
|
#
|
package/dist/index.js
CHANGED
|
@@ -6589,6 +6589,11 @@ function writeSiblingTemplate(templateRoot, targetDir, config, context) {
|
|
|
6589
6589
|
2
|
|
6590
6590
|
) + "\n"
|
|
6591
6591
|
);
|
|
6592
|
+
writeFileSync7(
|
|
6593
|
+
join21(targetDir, ".biffo-shared-version"),
|
|
6594
|
+
`core-v${context.templateVersion.replace(/^core-v/, "")}
|
|
6595
|
+
`
|
|
6596
|
+
);
|
|
6592
6597
|
const envPath = join21(targetDir, "apps", "frontend", ".env.example");
|
|
6593
6598
|
try {
|
|
6594
6599
|
const path = basePathFor(context.pathPrefix);
|
|
@@ -10765,7 +10770,7 @@ function resolveGithubToken4() {
|
|
|
10765
10770
|
);
|
|
10766
10771
|
}
|
|
10767
10772
|
|
|
10768
|
-
// src/
|
|
10773
|
+
// src/lib/packaged-script-command.ts
|
|
10769
10774
|
import { spawnSync } from "child_process";
|
|
10770
10775
|
import { dirname as dirname11 } from "path";
|
|
10771
10776
|
import { fileURLToPath as fileURLToPath6 } from "url";
|
|
@@ -10789,22 +10794,45 @@ function packagedScriptMissing(relativePath) {
|
|
|
10789
10794
|
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
10795
|
}
|
|
10791
10796
|
|
|
10792
|
-
// src/
|
|
10793
|
-
|
|
10794
|
-
|
|
10795
|
-
|
|
10796
|
-
|
|
10797
|
-
|
|
10798
|
-
|
|
10799
|
-
|
|
10800
|
-
|
|
10797
|
+
// src/lib/packaged-script-command.ts
|
|
10798
|
+
function packagedScriptCommand(spec) {
|
|
10799
|
+
const command = new Command26(spec.name).description(spec.description).allowExcessArguments(true).allowUnknownOption(true);
|
|
10800
|
+
if (spec.argument) command.argument(`<${spec.argument.name}>`, spec.argument.description);
|
|
10801
|
+
return command.action(() => {
|
|
10802
|
+
const here = dirname11(fileURLToPath6(import.meta.url));
|
|
10803
|
+
const script = findPackagedScript(here, spec.script);
|
|
10804
|
+
if (!script) {
|
|
10805
|
+
process.stderr.write(`${packagedScriptMissing(spec.script)}
|
|
10801
10806
|
`);
|
|
10802
|
-
|
|
10803
|
-
|
|
10804
|
-
|
|
10805
|
-
|
|
10806
|
-
|
|
10807
|
-
|
|
10807
|
+
process.exit(2);
|
|
10808
|
+
}
|
|
10809
|
+
const at = process.argv.indexOf(spec.name);
|
|
10810
|
+
const args = at === -1 ? [] : process.argv.slice(at + 1);
|
|
10811
|
+
const result = spawnSync("sh", [script, ...args], { stdio: "inherit" });
|
|
10812
|
+
process.exit(result.status === null ? 2 : result.status);
|
|
10813
|
+
});
|
|
10814
|
+
}
|
|
10815
|
+
|
|
10816
|
+
// src/commands/branch-health.ts
|
|
10817
|
+
var branchHealthCommand = packagedScriptCommand({
|
|
10818
|
+
name: "branch-health",
|
|
10819
|
+
script: "scripts/branch-health.sh",
|
|
10820
|
+
description: "Report every workflow on the integration branch (0 green, 1 red, 2 cannot tell)"
|
|
10821
|
+
});
|
|
10822
|
+
|
|
10823
|
+
// src/commands/claim.ts
|
|
10824
|
+
var claimCommand = packagedScriptCommand({
|
|
10825
|
+
name: "claim",
|
|
10826
|
+
script: "scripts/claim.sh",
|
|
10827
|
+
description: "Check and claim an issue before starting (0 free, 1 taken, 2 cannot tell)"
|
|
10828
|
+
});
|
|
10829
|
+
|
|
10830
|
+
// src/commands/wait-for-checks.ts
|
|
10831
|
+
var waitForChecksCommand = packagedScriptCommand({
|
|
10832
|
+
name: "wait-for-checks",
|
|
10833
|
+
script: "scripts/wait-for-checks.sh",
|
|
10834
|
+
description: "Wait for a PR\u2019s required checks on a positive signal (0 green, 1 failed, 2 cannot tell)",
|
|
10835
|
+
argument: { name: "pr", description: "Pull request number" }
|
|
10808
10836
|
});
|
|
10809
10837
|
|
|
10810
10838
|
// src/index.ts
|
|
@@ -10822,6 +10850,8 @@ program.addCommand(deployCommand);
|
|
|
10822
10850
|
program.addCommand(destroyCommand);
|
|
10823
10851
|
program.addCommand(teardownCommand);
|
|
10824
10852
|
program.addCommand(waitForChecksCommand);
|
|
10853
|
+
program.addCommand(branchHealthCommand);
|
|
10854
|
+
program.addCommand(claimCommand);
|
|
10825
10855
|
program.addCommand(pluginCommand);
|
|
10826
10856
|
program.addCommand(dataCommand);
|
|
10827
10857
|
program.addCommand(coreCommand);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@biffo/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.237.0",
|
|
4
4
|
"description": "Biffo project scaffolding CLI",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -24,7 +24,9 @@
|
|
|
24
24
|
"dist",
|
|
25
25
|
"schemas",
|
|
26
26
|
"_skeletons",
|
|
27
|
-
"scripts/wait-for-checks.sh"
|
|
27
|
+
"scripts/wait-for-checks.sh",
|
|
28
|
+
"scripts/branch-health.sh",
|
|
29
|
+
"scripts/claim.sh"
|
|
28
30
|
],
|
|
29
31
|
"scripts": {
|
|
30
32
|
"build": "tsup src/index.ts --format esm --dts --clean",
|
|
@@ -1,238 +0,0 @@
|
|
|
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
|
|
@@ -1,306 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
#
|
|
3
|
-
# Is the integration branch actually healthy — including the deploy, and
|
|
4
|
-
# including who broke it?
|
|
5
|
-
#
|
|
6
|
-
# ## Why this exists
|
|
7
|
-
#
|
|
8
|
-
# On 2026-08-02 an instance's `dev` deploy went red at 10:43 and nobody noticed
|
|
9
|
-
# until 12:36. **1h53m, four further merges**, each of which also failed, and
|
|
10
|
-
# development was effectively blocked for 2h25m. Three separate things had to be
|
|
11
|
-
# true at once for that to happen, and this script answers all three (#1133).
|
|
12
|
-
#
|
|
13
|
-
# **1. The obvious command hides the deploy.** An instance runs five workflows on
|
|
14
|
-
# a merge to `dev` — `CI`, `CodeQL`, `Core Version Tag`, `Deploy Application`,
|
|
15
|
-
# `RLS Tests` — and the reflexive check is:
|
|
16
|
-
#
|
|
17
|
-
# gh run list --branch dev --limit 3
|
|
18
|
-
#
|
|
19
|
-
# which returns three that are *not* the deploy. "dev CI green" was reported
|
|
20
|
-
# truthfully and repeatedly by an agent following AGENTS.md, while the deploy was
|
|
21
|
-
# red the whole time. A truncated list is not a status; it is a sample. This
|
|
22
|
-
# script enumerates **every** workflow that ran on the branch and reports the
|
|
23
|
-
# latest conclusion of each, so nothing can fall off the bottom.
|
|
24
|
-
#
|
|
25
|
-
# **2. A red post-merge deploy has no audience.** A failing PR check is noticed
|
|
26
|
-
# because someone is watching their PR. A failing *post-merge* deploy is watched
|
|
27
|
-
# by nobody: the author has moved on, and the next person only discovers it by
|
|
28
|
-
# merging into it. So this notifies, reusing the desktop-alert channel
|
|
29
|
-
# `practices-daily.sh` established (`_notify`) — same opt-OUT posture, because an
|
|
30
|
-
# opt-in alert is how that notification once spent months existing and never
|
|
31
|
-
# firing.
|
|
32
|
-
#
|
|
33
|
-
# **3. A poisoned branch blames the wrong person.** Once `dev` is broken every
|
|
34
|
-
# subsequent merge fails too, so four people each saw *their* change fail. The
|
|
35
|
-
# expensive part was not the breakage, it was four independent diagnoses of an
|
|
36
|
-
# innocent change. On failure this walks the run history back to the **first
|
|
37
|
-
# failing run** and names its commit, author and time — so the fifth person in
|
|
38
|
-
# starts at the real cause instead of their own diff.
|
|
39
|
-
#
|
|
40
|
-
# ## Exit codes
|
|
41
|
-
#
|
|
42
|
-
# 0 every workflow observed on the branch concluded, none failed
|
|
43
|
-
# 1 something failed — the workflow names and the first bad commit are printed
|
|
44
|
-
# 2 cannot determine: no runs found, or the repo/branch is unreadable
|
|
45
|
-
#
|
|
46
|
-
# 2 is deliberately not 0, matching `wait-for-checks.sh` and `ci-wiring-audit.sh`.
|
|
47
|
-
# A check that cannot see its input must say so rather than passing: this
|
|
48
|
-
# estate's most repeated defect is a zero that means "could not look", and
|
|
49
|
-
# `protection-audit.sh` was reporting `27 branches, all protected` while silently
|
|
50
|
-
# dropping the four repos least likely to be protected (#1145).
|
|
51
|
-
#
|
|
52
|
-
# `cancelled` is reported but does not fail the branch. On self-hosted runners it
|
|
53
|
-
# is usually spot reclamation or a `cancel-in-progress` concurrency group — two
|
|
54
|
-
# merges landing seconds apart cancel the first run by design. It is called out
|
|
55
|
-
# by name so nobody debugs a phantom.
|
|
56
|
-
#
|
|
57
|
-
# ## Usage
|
|
58
|
-
#
|
|
59
|
-
# sh scripts/branch-health.sh [-R owner/repo] [--branch dev] [--quiet]
|
|
60
|
-
#
|
|
61
|
-
# Requires `gh`, authenticated. Uses gh's embedded jq, so no jq binary is needed.
|
|
62
|
-
|
|
63
|
-
set -uo pipefail
|
|
64
|
-
|
|
65
|
-
REPO=""
|
|
66
|
-
BRANCH=""
|
|
67
|
-
QUIET=""
|
|
68
|
-
|
|
69
|
-
usage() {
|
|
70
|
-
sed -n '2,60p' "$0" | sed 's/^# \{0,1\}//'
|
|
71
|
-
exit 2
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
while [ $# -gt 0 ]; do
|
|
75
|
-
case "$1" in
|
|
76
|
-
-R | --repo)
|
|
77
|
-
REPO="${2:-}"
|
|
78
|
-
shift 2
|
|
79
|
-
;;
|
|
80
|
-
--branch)
|
|
81
|
-
BRANCH="${2:-}"
|
|
82
|
-
shift 2
|
|
83
|
-
;;
|
|
84
|
-
--quiet)
|
|
85
|
-
QUIET=1
|
|
86
|
-
shift
|
|
87
|
-
;;
|
|
88
|
-
-h | --help) usage ;;
|
|
89
|
-
*)
|
|
90
|
-
echo "branch-health: unexpected argument '$1'" >&2
|
|
91
|
-
usage
|
|
92
|
-
;;
|
|
93
|
-
esac
|
|
94
|
-
done
|
|
95
|
-
|
|
96
|
-
RED=$(printf '\033[31m')
|
|
97
|
-
GREEN=$(printf '\033[32m')
|
|
98
|
-
YELLOW=$(printf '\033[33m')
|
|
99
|
-
DIM=$(printf '\033[90m')
|
|
100
|
-
OFF=$(printf '\033[0m')
|
|
101
|
-
|
|
102
|
-
# A real tab, built the same way the colours above are, and used as `IFS="$TAB"`.
|
|
103
|
-
#
|
|
104
|
-
# AGENTS.md invokes these scripts as `sh scripts/...`, and /bin/sh is dash here.
|
|
105
|
-
# The bash spelling `IFS=$'\t'` is NOT a syntax error under dash — it is read as
|
|
106
|
-
# the four literal characters `$ ' \ t`, so the field split then happens on any
|
|
107
|
-
# of them. The first draft of this script did exactly that and reported a
|
|
108
|
-
# workflow called "Deploy Applica", having split "Deploy Application" at its
|
|
109
|
-
# 't'. It printed one row instead of four and still exited 0.
|
|
110
|
-
#
|
|
111
|
-
# Same reason the colours use `$(printf '\033[31m')` rather than `$'\e[31m'`.
|
|
112
|
-
TAB=$(printf '\t')
|
|
113
|
-
|
|
114
|
-
gh_run() {
|
|
115
|
-
if [ -n "$REPO" ]; then gh run "$@" --repo "$REPO"; else gh run "$@"; fi
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
# The integration branch is `dev` in every Biffo repo (AGENTS.md §2). Resolved
|
|
119
|
-
# from the repo rather than hardcoded so this still works in the rare repo whose
|
|
120
|
-
# default has not been migrated — and so the failure is "cannot read repo"
|
|
121
|
-
# rather than a confident answer about a branch that does not exist.
|
|
122
|
-
if [ -z "$BRANCH" ]; then
|
|
123
|
-
if [ -n "$REPO" ]; then
|
|
124
|
-
BRANCH=$(gh repo view "$REPO" --json defaultBranchRef --jq .defaultBranchRef.name 2>/dev/null)
|
|
125
|
-
else
|
|
126
|
-
BRANCH=$(gh repo view --json defaultBranchRef --jq .defaultBranchRef.name 2>/dev/null)
|
|
127
|
-
fi
|
|
128
|
-
fi
|
|
129
|
-
|
|
130
|
-
if [ -z "$BRANCH" ]; then
|
|
131
|
-
echo "${RED}branch-health: cannot determine the integration branch.${OFF}" >&2
|
|
132
|
-
exit 2
|
|
133
|
-
fi
|
|
134
|
-
|
|
135
|
-
label=${REPO:-$(basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)")}
|
|
136
|
-
|
|
137
|
-
# --- Every workflow that ran on the branch, not the first three --------------
|
|
138
|
-
#
|
|
139
|
-
# 200 runs is deep enough to reach every workflow's latest run even when a busy
|
|
140
|
-
# one (CI) dominates the head of the list. `--json` on `gh run list` returns them
|
|
141
|
-
# newest-first, so the first row per workflow name IS its latest run.
|
|
142
|
-
|
|
143
|
-
# `group_by | max_by(.createdAt)` rather than "first occurrence in a newest-first
|
|
144
|
-
# list": the ordering is gh's to change, and a status tool that quietly reports
|
|
145
|
-
# an older run because an API changed its sort is the same class of defect as the
|
|
146
|
-
# truncated list this replaces. Ask for the newest explicitly.
|
|
147
|
-
summary=$(gh_run list --branch "$BRANCH" --limit 200 \
|
|
148
|
-
--json workflowName,status,conclusion,headSha,createdAt,url \
|
|
149
|
-
--jq 'group_by(.workflowName)
|
|
150
|
-
| map(max_by(.createdAt))
|
|
151
|
-
| .[]
|
|
152
|
-
| [ (if .status == "completed" then (.conclusion // "unknown") else .status end),
|
|
153
|
-
.workflowName, .headSha[0:8], .createdAt[0:16], .url ]
|
|
154
|
-
| @tsv' 2>/dev/null)
|
|
155
|
-
|
|
156
|
-
if [ -z "$summary" ]; then
|
|
157
|
-
echo "${RED}branch-health: no workflow runs readable on '$BRANCH' in $label.${OFF}" >&2
|
|
158
|
-
echo "${DIM} That is 'cannot tell', not 'healthy' — exiting 2.${OFF}" >&2
|
|
159
|
-
exit 2
|
|
160
|
-
fi
|
|
161
|
-
|
|
162
|
-
failed=""
|
|
163
|
-
pending=""
|
|
164
|
-
cancelled=""
|
|
165
|
-
skipped=""
|
|
166
|
-
ok=""
|
|
167
|
-
|
|
168
|
-
while IFS="$TAB" read -r state name sha when url; do
|
|
169
|
-
[ -n "$name" ] || continue
|
|
170
|
-
case "$state" in
|
|
171
|
-
success) ok="${ok}${name}\n" ;;
|
|
172
|
-
failure | timed_out | startup_failure)
|
|
173
|
-
failed="${failed}${state}\t${name}\t${sha}\t${when}\t${url}\n"
|
|
174
|
-
;;
|
|
175
|
-
cancelled) cancelled="${cancelled}${name}\n" ;;
|
|
176
|
-
skipped) skipped="${skipped}${name}\n" ;;
|
|
177
|
-
*) pending="${pending}${state}\t${name}\n" ;;
|
|
178
|
-
esac
|
|
179
|
-
done <<EOF
|
|
180
|
-
$summary
|
|
181
|
-
EOF
|
|
182
|
-
|
|
183
|
-
# --- Report -------------------------------------------------------------------
|
|
184
|
-
|
|
185
|
-
echo "${DIM}$label — branch '$BRANCH', latest run per workflow${OFF}"
|
|
186
|
-
echo
|
|
187
|
-
|
|
188
|
-
# `printf '%b'` FIRST, then sed. These lists are accumulated as strings holding
|
|
189
|
-
# literal `\n` two-character sequences (POSIX sh has no clean way to append a
|
|
190
|
-
# real newline to a variable), so piping them straight to sed hands it a single
|
|
191
|
-
# line and only the first entry gets its prefix — which read as a workflow with
|
|
192
|
-
# no status at all. Expand the escapes, then prefix each real line.
|
|
193
|
-
[ -n "$ok" ] && printf '%b' "$ok" | sed "s/^/ ${GREEN}ok${OFF} /"
|
|
194
|
-
[ -n "$skipped" ] && printf '%b' "$skipped" | sed "s/^/ ${DIM}skipped${OFF} /"
|
|
195
|
-
[ -n "$cancelled" ] && printf '%b' "$cancelled" | sed "s/^/ ${YELLOW}cancelled${OFF} /"
|
|
196
|
-
|
|
197
|
-
if [ -n "$pending" ]; then
|
|
198
|
-
printf '%b' "$pending" | awk -F'\t' -v d="$YELLOW" -v o="$OFF" 'NF{printf " %srunning%s %s (%s)\n", d, o, $2, $1}'
|
|
199
|
-
fi
|
|
200
|
-
|
|
201
|
-
if [ -z "$failed" ]; then
|
|
202
|
-
if [ -n "$cancelled" ]; then
|
|
203
|
-
echo
|
|
204
|
-
echo "${DIM}A cancelled run is usually spot reclamation or a superseded concurrency${OFF}"
|
|
205
|
-
echo "${DIM}group, not the code. Re-run it rather than debugging it.${OFF}"
|
|
206
|
-
fi
|
|
207
|
-
echo
|
|
208
|
-
echo "${GREEN}Nothing on '$BRANCH' is failing.${OFF}"
|
|
209
|
-
exit 0
|
|
210
|
-
fi
|
|
211
|
-
|
|
212
|
-
echo
|
|
213
|
-
printf '%b' "$failed" | awk -F'\t' -v r="$RED" -v o="$OFF" 'NF{printf " %s%s%s %s at %s %s\n", r, $1, o, $2, $3, $5}'
|
|
214
|
-
|
|
215
|
-
# --- Who actually broke it ----------------------------------------------------
|
|
216
|
-
#
|
|
217
|
-
# The whole point of #1133's third defect. Walk this workflow's runs on this
|
|
218
|
-
# branch backwards from the newest failure through consecutive failures, and
|
|
219
|
-
# report the OLDEST one in that unbroken streak. That run's commit is where the
|
|
220
|
-
# breakage started, which is very often not the person now reading this.
|
|
221
|
-
|
|
222
|
-
echo
|
|
223
|
-
printf '%b' "$failed" | while IFS="$TAB" read -r state name sha when url; do
|
|
224
|
-
[ -n "$name" ] || continue
|
|
225
|
-
|
|
226
|
-
# Sort newest-first ourselves, cut the list at the most recent SUCCESS, and
|
|
227
|
-
# take the oldest failure still inside that streak. Anything before a green run
|
|
228
|
-
# is a different, already-fixed breakage and must not be blamed for this one.
|
|
229
|
-
first=$(gh_run list --branch "$BRANCH" --workflow "$name" --limit 60 \
|
|
230
|
-
--json conclusion,headSha,createdAt,displayTitle,url \
|
|
231
|
-
--jq 'sort_by(.createdAt) | reverse
|
|
232
|
-
| (map(.conclusion == "success") | index(true)) as $green
|
|
233
|
-
| .[0: (if $green == null then length else $green end)]
|
|
234
|
-
| map(select(.conclusion == "failure"
|
|
235
|
-
or .conclusion == "timed_out"
|
|
236
|
-
or .conclusion == "startup_failure"))
|
|
237
|
-
| last
|
|
238
|
-
| select(. != null)
|
|
239
|
-
| [ .headSha[0:8], .createdAt[0:16], (.displayTitle // "")[0:72], .url ]
|
|
240
|
-
| @tsv' 2>/dev/null)
|
|
241
|
-
|
|
242
|
-
if [ -n "$first" ]; then
|
|
243
|
-
f_sha=$(printf '%s' "$first" | cut -f1)
|
|
244
|
-
f_when=$(printf '%s' "$first" | cut -f2)
|
|
245
|
-
f_title=$(printf '%s' "$first" | cut -f3)
|
|
246
|
-
f_url=$(printf '%s' "$first" | cut -f4)
|
|
247
|
-
echo " ${RED}$name${OFF} has been failing since ${YELLOW}$f_sha${OFF} ($f_when)"
|
|
248
|
-
echo " $f_title"
|
|
249
|
-
echo " ${DIM}$f_url${OFF}"
|
|
250
|
-
if [ "$f_sha" != "$sha" ]; then
|
|
251
|
-
echo " ${DIM}The newest failure is at $sha — but it is NOT where this started.${OFF}"
|
|
252
|
-
echo " ${DIM}Diagnose $f_sha, not your own merge.${OFF}"
|
|
253
|
-
fi
|
|
254
|
-
else
|
|
255
|
-
echo " ${RED}$name${OFF} is failing at $sha ${DIM}(could not establish when it started)${OFF}"
|
|
256
|
-
fi
|
|
257
|
-
done
|
|
258
|
-
|
|
259
|
-
# --- Tell somebody ------------------------------------------------------------
|
|
260
|
-
#
|
|
261
|
-
# Copied in posture, deliberately, from practices-daily.sh's `_notify`: opt-OUT
|
|
262
|
-
# via an env var rather than opt-in, because an opt-in alert is one that never
|
|
263
|
-
# fires. Replaces its own previous card rather than stacking, so a branch red for
|
|
264
|
-
# three days is one notification and not three.
|
|
265
|
-
|
|
266
|
-
_notify() {
|
|
267
|
-
[ -z "$QUIET" ] || return 0
|
|
268
|
-
command -v notify-send >/dev/null 2>&1 || return 0
|
|
269
|
-
[ -z "${BRANCH_HEALTH_NO_DESKTOP_ALERT:-}" ] || return 0
|
|
270
|
-
|
|
271
|
-
if [ -z "${DBUS_SESSION_BUS_ADDRESS:-}" ]; then
|
|
272
|
-
_bus="/run/user/$(id -u)/bus"
|
|
273
|
-
[ -S "$_bus" ] || return 0
|
|
274
|
-
DBUS_SESSION_BUS_ADDRESS="unix:path=$_bus"
|
|
275
|
-
export DBUS_SESSION_BUS_ADDRESS
|
|
276
|
-
fi
|
|
277
|
-
|
|
278
|
-
_slug=$(printf '%s' "$label-$BRANCH" | tr -c 'a-zA-Z0-9' '-')
|
|
279
|
-
_idfile="${XDG_RUNTIME_DIR:-/tmp}/biffo-branch-health-${_slug}.id"
|
|
280
|
-
_prev=""
|
|
281
|
-
[ -f "$_idfile" ] && _prev=$(cat "$_idfile" 2>/dev/null)
|
|
282
|
-
|
|
283
|
-
# Built as a plain variable rather than `${_prev:+--replace-id="$_prev"}`.
|
|
284
|
-
# That form nests double quotes inside a parameter expansion, which dash
|
|
285
|
-
# refuses to parse — and because the script is run as `sh`, the failure lands
|
|
286
|
-
# at RUNTIME, after all the useful output has already printed, turning a
|
|
287
|
-
# correct exit 1 into a confusing exit 2.
|
|
288
|
-
_replace=""
|
|
289
|
-
[ -n "$_prev" ] && _replace="--replace-id=$_prev"
|
|
290
|
-
|
|
291
|
-
_names=$(printf '%b' "$failed" | awk -F'\t' 'NF{printf "%s ", $2}')
|
|
292
|
-
# Deliberately unquoted: empty must expand to no argument at all.
|
|
293
|
-
# shellcheck disable=SC2086
|
|
294
|
-
_new=$(notify-send --print-id $_replace \
|
|
295
|
-
-u critical -a "biffo" \
|
|
296
|
-
"$label: $BRANCH is red" \
|
|
297
|
-
"$_names— nobody is watching a post-merge failure. sh scripts/branch-health.sh" 2>/dev/null)
|
|
298
|
-
[ -n "$_new" ] && printf '%s' "$_new" > "$_idfile" 2>/dev/null
|
|
299
|
-
return 0
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
_notify
|
|
303
|
-
|
|
304
|
-
echo
|
|
305
|
-
echo "${RED}'$BRANCH' is red. It blocks everyone — fixing it is the next task (AGENTS.md §6).${OFF}"
|
|
306
|
-
exit 1
|
|
@@ -1,208 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env sh
|
|
2
|
-
#
|
|
3
|
-
# Is anyone already working this issue? Ask git, not a label.
|
|
4
|
-
#
|
|
5
|
-
# ## Why this exists
|
|
6
|
-
#
|
|
7
|
-
# Several agent sessions run against this estate at once, and on 2026-08-03
|
|
8
|
-
# **four** of them collided in one morning:
|
|
9
|
-
#
|
|
10
|
-
# - #1165 — another session built AND MERGED a PR for it while this one was
|
|
11
|
-
# claiming it. Three minutes, start to merge.
|
|
12
|
-
# - #1174 — a live worktree on `fix/1174-…` existed; the issue was unlabelled.
|
|
13
|
-
# - #621, #956 — live worktrees, no labels. Labelled on their behalf.
|
|
14
|
-
# - #1188 — the reverse: a label with no work, while another session built it
|
|
15
|
-
# and opened a PR.
|
|
16
|
-
#
|
|
17
|
-
# Three of the four were "work exists, label does not". That is the shape this
|
|
18
|
-
# script is for.
|
|
19
|
-
#
|
|
20
|
-
# ## The rule it encodes
|
|
21
|
-
#
|
|
22
|
-
# **The `in-progress` label is a hand-maintained second copy of something git
|
|
23
|
-
# already knows.** A branch exists. A PR exists. Those are automatic — you
|
|
24
|
-
# cannot do the work without creating them — whereas the label is a separate
|
|
25
|
-
# action a human or agent has to remember, in a workflow that may never have
|
|
26
|
-
# been told to. Second copies of a decision drift; this estate says so about
|
|
27
|
-
# `_extract_detail`, about AGENTS.md, and about the commit-msg type list.
|
|
28
|
-
#
|
|
29
|
-
# So this checks FOUR signals and reports all of them, rather than trusting the
|
|
30
|
-
# one that is easiest to forget.
|
|
31
|
-
#
|
|
32
|
-
# ## What it cannot do
|
|
33
|
-
#
|
|
34
|
-
# Prevent a race. GitHub has no locking, two sessions can start in the same
|
|
35
|
-
# second, and #1165 went from branch to merged in three minutes — no protocol
|
|
36
|
-
# would have caught that. The goal is early, cheap detection, not exclusion.
|
|
37
|
-
# Every collision that morning was caught before duplicate work merged; the
|
|
38
|
-
# cost was minutes, not shipped rework.
|
|
39
|
-
#
|
|
40
|
-
# ## Usage
|
|
41
|
-
#
|
|
42
|
-
# sh scripts/claim.sh 1234 # check, and claim if free
|
|
43
|
-
# sh scripts/claim.sh 1234 --check # report only, change nothing
|
|
44
|
-
# sh scripts/claim.sh 1234 -R owner/repo
|
|
45
|
-
#
|
|
46
|
-
# 0 free — and claimed, unless --check
|
|
47
|
-
# 1 taken, or already closed — the reason is printed
|
|
48
|
-
# 2 cannot tell — issue unreadable, gh unauthenticated
|
|
49
|
-
#
|
|
50
|
-
# 2 is deliberately not 0, matching `wait-for-checks.sh` and `branch-health.sh`.
|
|
51
|
-
# A check that cannot see its input must not report "free".
|
|
52
|
-
#
|
|
53
|
-
# Requires `gh`, authenticated. Uses gh's embedded jq, so no jq binary needed.
|
|
54
|
-
|
|
55
|
-
set -u
|
|
56
|
-
|
|
57
|
-
ISSUE=""
|
|
58
|
-
REPO=""
|
|
59
|
-
CHECK_ONLY=""
|
|
60
|
-
|
|
61
|
-
usage() {
|
|
62
|
-
sed -n '2,58p' "$0" | sed 's/^# \{0,1\}//'
|
|
63
|
-
exit 2
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
while [ $# -gt 0 ]; do
|
|
67
|
-
case "$1" in
|
|
68
|
-
-R | --repo)
|
|
69
|
-
REPO="${2:-}"
|
|
70
|
-
shift 2
|
|
71
|
-
;;
|
|
72
|
-
--check) CHECK_ONLY=1; shift ;;
|
|
73
|
-
-h | --help) usage ;;
|
|
74
|
-
*)
|
|
75
|
-
ISSUE="$1"
|
|
76
|
-
shift
|
|
77
|
-
;;
|
|
78
|
-
esac
|
|
79
|
-
done
|
|
80
|
-
|
|
81
|
-
case "$ISSUE" in
|
|
82
|
-
'' | *[!0-9]*)
|
|
83
|
-
echo "claim: give an issue number, e.g. sh scripts/claim.sh 1234" >&2
|
|
84
|
-
exit 2
|
|
85
|
-
;;
|
|
86
|
-
esac
|
|
87
|
-
|
|
88
|
-
RED=$(printf '\033[31m')
|
|
89
|
-
GREEN=$(printf '\033[32m')
|
|
90
|
-
YELLOW=$(printf '\033[33m')
|
|
91
|
-
DIM=$(printf '\033[90m')
|
|
92
|
-
OFF=$(printf '\033[0m')
|
|
93
|
-
|
|
94
|
-
gh_issue() { if [ -n "$REPO" ]; then gh issue "$@" --repo "$REPO"; else gh issue "$@"; fi; }
|
|
95
|
-
gh_pr() { if [ -n "$REPO" ]; then gh pr "$@" --repo "$REPO"; else gh pr "$@"; fi; }
|
|
96
|
-
|
|
97
|
-
LABEL=in-progress
|
|
98
|
-
TAKEN=0
|
|
99
|
-
REASONS=""
|
|
100
|
-
|
|
101
|
-
note() { REASONS="${REASONS} $1\n"; TAKEN=1; }
|
|
102
|
-
|
|
103
|
-
# --- 0. Does the issue exist, and is it still open? --------------------------
|
|
104
|
-
|
|
105
|
-
meta=$(gh_issue view "$ISSUE" --json state,title,labels \
|
|
106
|
-
--jq '"\(.state)\t\(.title)\t\((.labels|map(.name)|join(",")))"' 2>/dev/null) || {
|
|
107
|
-
echo "${RED}claim: cannot read issue #$ISSUE${OFF} — wrong repo, or gh not authenticated." >&2
|
|
108
|
-
echo "${DIM} That is 'cannot tell', not 'free'.${OFF}" >&2
|
|
109
|
-
exit 2
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
state=$(printf '%s' "$meta" | cut -f1)
|
|
113
|
-
title=$(printf '%s' "$meta" | cut -f2)
|
|
114
|
-
labels=$(printf '%s' "$meta" | cut -f3)
|
|
115
|
-
|
|
116
|
-
echo "${DIM}#$ISSUE — $title${OFF}"
|
|
117
|
-
echo
|
|
118
|
-
|
|
119
|
-
if [ "$state" != "OPEN" ]; then
|
|
120
|
-
echo "${RED}Already $state.${OFF} Nothing to claim."
|
|
121
|
-
exit 1
|
|
122
|
-
fi
|
|
123
|
-
|
|
124
|
-
# --- 1. The label. Easiest to check, easiest to forget. ----------------------
|
|
125
|
-
|
|
126
|
-
case ",$labels," in
|
|
127
|
-
*",$LABEL,"*)
|
|
128
|
-
updated=$(gh_issue view "$ISSUE" --json updatedAt --jq .updatedAt 2>/dev/null)
|
|
129
|
-
note "${YELLOW}label${OFF} carries '$LABEL' (issue last updated $updated)"
|
|
130
|
-
;;
|
|
131
|
-
esac
|
|
132
|
-
|
|
133
|
-
# --- 2. An open PR that references it ----------------------------------------
|
|
134
|
-
#
|
|
135
|
-
# The strongest signal, because a PR cannot be opened without the work existing.
|
|
136
|
-
# Matches the issue number in the title or body as a whole number, so #118 does
|
|
137
|
-
# not match #1188.
|
|
138
|
-
|
|
139
|
-
open_prs=$(gh_pr list --state open --limit 100 --json number,title,body,headRefName \
|
|
140
|
-
--jq "[.[] | select(((.title + \" \" + .body) | test(\"(^|[^0-9])#$ISSUE([^0-9]|\$)\")) or (.headRefName | test(\"(^|[^0-9])$ISSUE([^0-9]|\$)\")))] | .[] | \"#\(.number) \(.headRefName)\"" 2>/dev/null)
|
|
141
|
-
|
|
142
|
-
if [ -n "$open_prs" ]; then
|
|
143
|
-
printf '%s\n' "$open_prs" | while IFS= read -r pr; do
|
|
144
|
-
[ -n "$pr" ] && echo " ${RED}open PR${OFF} $pr"
|
|
145
|
-
done
|
|
146
|
-
note "${RED}open PR${OFF} see above — someone has working code"
|
|
147
|
-
fi
|
|
148
|
-
|
|
149
|
-
# --- 3. A remote branch naming it --------------------------------------------
|
|
150
|
-
#
|
|
151
|
-
# Catches work that has been pushed but has no PR yet. Whole-number match again.
|
|
152
|
-
|
|
153
|
-
branches=$(git ls-remote --heads "${REPO:+https://github.com/$REPO.git}" 2>/dev/null |
|
|
154
|
-
sed 's|.*refs/heads/||' |
|
|
155
|
-
grep -E "(^|[^0-9])$ISSUE([^0-9]|$)" 2>/dev/null)
|
|
156
|
-
|
|
157
|
-
if [ -n "$branches" ]; then
|
|
158
|
-
printf '%s\n' "$branches" | while IFS= read -r b; do
|
|
159
|
-
[ -n "$b" ] && echo " ${RED}branch${OFF} $b"
|
|
160
|
-
done
|
|
161
|
-
note "${RED}branch${OFF} a remote branch names this issue"
|
|
162
|
-
fi
|
|
163
|
-
|
|
164
|
-
# --- 4. A recently merged PR that already closed it --------------------------
|
|
165
|
-
#
|
|
166
|
-
# Not "taken" — "possibly already done". #1165 was built and merged in three
|
|
167
|
-
# minutes; the only trace afterwards is a merged PR.
|
|
168
|
-
|
|
169
|
-
merged=$(gh_pr list --state merged --limit 30 --json number,title,body,mergedAt \
|
|
170
|
-
--jq "[.[] | select((.title + \" \" + .body) | test(\"(^|[^0-9])#$ISSUE([^0-9]|\$)\"))] | .[0] | select(. != null) | \"#\(.number) merged \(.mergedAt[0:16])\"" 2>/dev/null)
|
|
171
|
-
|
|
172
|
-
if [ -n "$merged" ]; then
|
|
173
|
-
echo " ${YELLOW}merged${OFF} $merged"
|
|
174
|
-
echo " ${DIM} the issue is still open, but work referencing it has landed —${OFF}"
|
|
175
|
-
echo " ${DIM} read it before rebuilding.${OFF}"
|
|
176
|
-
fi
|
|
177
|
-
|
|
178
|
-
# --- Verdict ------------------------------------------------------------------
|
|
179
|
-
|
|
180
|
-
echo
|
|
181
|
-
if [ "$TAKEN" -eq 1 ]; then
|
|
182
|
-
printf '%b' "${RED}Taken.${OFF} Signals:\n$REASONS"
|
|
183
|
-
echo
|
|
184
|
-
echo "${DIM}If you believe it is abandoned, check how old the work is and say so in a${OFF}"
|
|
185
|
-
echo "${DIM}comment before taking it. Never steal a fresh claim.${OFF}"
|
|
186
|
-
exit 1
|
|
187
|
-
fi
|
|
188
|
-
|
|
189
|
-
if [ -n "$CHECK_ONLY" ]; then
|
|
190
|
-
echo "${GREEN}Free.${OFF} ${DIM}(--check: nothing changed)${OFF}"
|
|
191
|
-
exit 0
|
|
192
|
-
fi
|
|
193
|
-
|
|
194
|
-
# Claim it. Label AND comment together: the label is what other sessions filter
|
|
195
|
-
# on, the comment is what dates it so a stale claim can be recognised later.
|
|
196
|
-
gh_issue edit "$ISSUE" --add-label "$LABEL" >/dev/null 2>&1 || {
|
|
197
|
-
echo "${RED}claim: could not apply the '$LABEL' label.${OFF}" >&2
|
|
198
|
-
echo "${DIM} Not claimed. Do not start work on the assumption that it worked.${OFF}" >&2
|
|
199
|
-
exit 2
|
|
200
|
-
}
|
|
201
|
-
gh_issue comment "$ISSUE" \
|
|
202
|
-
--body "Claimed at $(date -u +%FT%TZ) by \`$(git config user.name 2>/dev/null || echo agent)\`. Release it — remove the label — on merge, or if you stop." \
|
|
203
|
-
>/dev/null 2>&1
|
|
204
|
-
|
|
205
|
-
echo "${GREEN}Claimed.${OFF}"
|
|
206
|
-
echo "${DIM}Push your branch as soon as it exists: a claim is a reservation, the branch${OFF}"
|
|
207
|
-
echo "${DIM}is the evidence, and the window between them is where collisions happen.${OFF}"
|
|
208
|
-
exit 0
|
|
@@ -1,238 +0,0 @@
|
|
|
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
|
|
File without changes
|
|
File without changes
|