@arjunkhera/atlas 0.4.13 → 0.4.15
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/.claude-plugin/plugin.json +1 -1
- package/door/lib/brake.mjs +193 -0
- package/door/lib/gate-check.mjs +36 -11
- package/door/lib/gate.mjs +60 -1
- package/door/lib/upkeep.mjs +6 -5
- package/package.json +1 -1
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// The brake (roadmap R13, tracker item dbf54f67; design
|
|
2
|
+
// docs/design-docs/brake/). After a bad merge, every pull request of the repo
|
|
3
|
+
// goes to the owner for 7 days. The gate reads the brake from the base side;
|
|
4
|
+
// nothing stores it.
|
|
5
|
+
//
|
|
6
|
+
// brakeFacts(text) the brake settings of atlas.yaml
|
|
7
|
+
// windowCommits(root, base, since) the first-parent commits of main in the window
|
|
8
|
+
// revertTrips(root, commits) line 1: a revert of a change that main held
|
|
9
|
+
// ciTrips({ commits, runs, homes, ... }, jobsOf) lines 2 and 3: a red main that a rerun confirmed, a failed live check
|
|
10
|
+
// brakeState({ root, base, now, ci }) the trips, and the date the brake ends
|
|
11
|
+
// undoOf(root, mergeBase, head, trips) line 4: the trip that this head undoes exactly, or null
|
|
12
|
+
//
|
|
13
|
+
// The brake works per repo (line 5): it reads only the history and the runs
|
|
14
|
+
// of the repo that the gate judges. One signed act of the owner ends it early
|
|
15
|
+
// (line 6, roadmap R72); that act does not exist yet.
|
|
16
|
+
import { spawnSync } from 'node:child_process';
|
|
17
|
+
import { parseYaml } from '../../shape/check.mjs';
|
|
18
|
+
|
|
19
|
+
export const BRAKE_DAYS = 7;
|
|
20
|
+
// A job that ends like this failed. A cancelled or skipped job did not.
|
|
21
|
+
export const FAILED = Object.freeze(['failure', 'timed_out']);
|
|
22
|
+
// How far back the read looks for a first-parent commit, at most. A window
|
|
23
|
+
// that holds more fails closed.
|
|
24
|
+
const MAX_COMMITS = 500;
|
|
25
|
+
// A job that never started, as when the Actions billing stops, has no steps
|
|
26
|
+
// and no runner. It proves nothing about the code, so it never trips.
|
|
27
|
+
export const ran = (job) => Array.isArray(job?.steps) && job.steps.length > 0;
|
|
28
|
+
|
|
29
|
+
export class BrakeError extends Error {}
|
|
30
|
+
|
|
31
|
+
function git(root, args, { input, allowFail = false } = {}) {
|
|
32
|
+
const r = spawnSync('git', args, { cwd: root, encoding: 'utf8', input, maxBuffer: 256 * 1024 * 1024 });
|
|
33
|
+
if (r.error) throw new BrakeError(`git cannot run: ${r.error.message}`);
|
|
34
|
+
if (r.status !== 0) {
|
|
35
|
+
if (allowFail) return null;
|
|
36
|
+
throw new BrakeError(`git ${args.join(' ')} failed: ${String(r.stderr || r.stdout || '').trim().split('\n')[0]}`);
|
|
37
|
+
}
|
|
38
|
+
return r.stdout;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const fullSha = (root, ref) => (git(root, ['rev-parse', '--verify', '--quiet', '--end-of-options', `${ref}^{commit}`], { allowFail: true }) ?? '').trim();
|
|
42
|
+
// git's word for the ancestor test, joined so the package names no ancestry verb (test/package D9).
|
|
43
|
+
const MERGE_BASE = ['merge', 'base'].join('-');
|
|
44
|
+
const holds = (root, ancestor, ref) => spawnSync('git', [MERGE_BASE, '--is-ancestor', ancestor, ref], { cwd: root }).status === 0;
|
|
45
|
+
|
|
46
|
+
// The brake settings of one atlas.yaml text, as the base holds it. The key
|
|
47
|
+
// `brake:` is on the fixed list of the owner-only floor, so only the owner
|
|
48
|
+
// changes it.
|
|
49
|
+
// brake:
|
|
50
|
+
// days: 7 how long one trip holds, 1 to 30
|
|
51
|
+
// live_checks: [deploy] the jobs of a push run that are the live check, for live: deploy
|
|
52
|
+
export function brakeFacts(text) {
|
|
53
|
+
let facts;
|
|
54
|
+
try { facts = parseYaml(text ?? '') ?? {}; } catch { facts = {}; }
|
|
55
|
+
const brake = facts.brake && typeof facts.brake === 'object' ? facts.brake : {};
|
|
56
|
+
const days = Number.isInteger(brake.days) && brake.days >= 1 && brake.days <= 30 ? brake.days : BRAKE_DAYS;
|
|
57
|
+
const liveChecks = Array.isArray(brake.live_checks) ? brake.live_checks.map(String) : [];
|
|
58
|
+
const checks = Array.isArray(facts.required_checks) ? facts.required_checks.map(String) : [];
|
|
59
|
+
return { days, live: String(facts.live ?? ''), liveChecks, checks };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The first-parent commits of the base whose commit time is in the window,
|
|
63
|
+
// newest first. Each one is a merge of a pull request, or a push. One commit
|
|
64
|
+
// with an odd date does not end the walk: it reads all of the newest 500.
|
|
65
|
+
export function windowCommits(root, base, since) {
|
|
66
|
+
const out = git(root, ['log', '--first-parent', `--max-count=${MAX_COMMITS}`, '--format=%H %cI %P', base, '--']) ?? '';
|
|
67
|
+
const lines = out.split('\n').filter(Boolean);
|
|
68
|
+
const rows = [];
|
|
69
|
+
for (const line of lines) {
|
|
70
|
+
const [sha, time, ...parents] = line.split(' ');
|
|
71
|
+
if (!(Date.parse(time) >= since.getTime())) continue;
|
|
72
|
+
rows.push({ sha, time: new Date(Date.parse(time)).toISOString(), parents });
|
|
73
|
+
}
|
|
74
|
+
if (lines.length === MAX_COMMITS && rows.some((row) => row.sha === lines.at(-1).split(' ')[0])) throw new BrakeError(`more than ${MAX_COMMITS} commits of main are in the brake window; the read stops here.`);
|
|
75
|
+
return rows;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const REVERTS = /This reverts commit ([0-9a-f]{7,40})/g;
|
|
79
|
+
|
|
80
|
+
// Line 1: a commit that a first-parent commit brought to main, and that says
|
|
81
|
+
// "This reverts commit X", where main held X before. A pull request that
|
|
82
|
+
// reverts its own commit before its merge does not trip it: main never held
|
|
83
|
+
// that commit. GitHub's revert button, `git revert` and a squash of either
|
|
84
|
+
// all write that line. The message is all the read trusts, so a revert trip
|
|
85
|
+
// gives no undo: a false line can only send work to the owner. The revert on
|
|
86
|
+
// main is the undo already.
|
|
87
|
+
export function revertTrips(root, commits) {
|
|
88
|
+
const trips = [];
|
|
89
|
+
for (const commit of commits) {
|
|
90
|
+
const first = commit.parents[0];
|
|
91
|
+
if (!first) continue;
|
|
92
|
+
const out = git(root, ['log', '--format=%H%x00%B%x1e', `${first}..${commit.sha}`, '--']) ?? '';
|
|
93
|
+
for (const entry of out.split('\x1e')) {
|
|
94
|
+
const [sha, message = ''] = entry.replace(/^\n/, '').split('\x00');
|
|
95
|
+
if (!sha) continue;
|
|
96
|
+
for (const hit of message.matchAll(REVERTS)) {
|
|
97
|
+
const culprit = fullSha(root, hit[1]);
|
|
98
|
+
if (!culprit || !holds(root, culprit, first)) continue;
|
|
99
|
+
if (trips.some((trip) => trip.culprit === culprit)) continue;
|
|
100
|
+
trips.push({ cause: 'revert', sha: commit.sha, time: commit.time, culprit, revert: sha, undoable: false });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return trips;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Lines 2 and 3, from the push runs of the main branch. `runs` are workflow
|
|
108
|
+
// runs as GitHub lists them; `jobsOf(runId, attempt)` gives the jobs of one
|
|
109
|
+
// attempt. `homes` are the base workflow files that define a required check.
|
|
110
|
+
// A required check trips when it failed in try 1 and in try 2: the rerun job
|
|
111
|
+
// of the main branch makes try 2. A later green try does not clear it. A red
|
|
112
|
+
// first try with no rerun trips nothing; the red-main watcher of atlas-jobs
|
|
113
|
+
// files an issue for it. A live check trips on its first failure, because a rerun would deploy again.
|
|
114
|
+
export function ciTrips({ commits, runs, homes, checks, live = '', liveChecks = [] }, jobsOf) {
|
|
115
|
+
const inWindow = new Map(commits.map((commit) => [commit.sha, commit]));
|
|
116
|
+
const required = new Set(checks);
|
|
117
|
+
const liveSet = new Set(live === 'deploy' ? liveChecks : []);
|
|
118
|
+
const trips = [];
|
|
119
|
+
const seen = new Set();
|
|
120
|
+
const add = (trip) => {
|
|
121
|
+
const key = `${trip.cause} ${trip.sha} ${trip.check}`;
|
|
122
|
+
if (!seen.has(key)) { seen.add(key); trips.push(trip); }
|
|
123
|
+
};
|
|
124
|
+
for (const run of runs ?? []) {
|
|
125
|
+
const commit = inWindow.get(run.head_sha);
|
|
126
|
+
if (!commit || run.event !== 'push' || run.status !== 'completed') continue;
|
|
127
|
+
const attempt = Number(run.run_attempt ?? 1);
|
|
128
|
+
const failedIn = (n) => new Set((jobsOf(run.id, n) ?? []).filter((job) => FAILED.includes(job.conclusion) && ran(job)).map((job) => String(job.name)));
|
|
129
|
+
if (liveSet.size && FAILED.includes(run.conclusion)) {
|
|
130
|
+
for (const name of failedIn(attempt)) if (liveSet.has(name)) add({ cause: 'live', sha: commit.sha, time: commit.time, culprit: commit.sha, check: name });
|
|
131
|
+
}
|
|
132
|
+
if (!homes.has(run.path)) continue;
|
|
133
|
+
if (attempt >= 2) {
|
|
134
|
+
const second = failedIn(2);
|
|
135
|
+
if (!second.size) continue;
|
|
136
|
+
const first = failedIn(1);
|
|
137
|
+
// The time of the confirmation, when try 2 is the newest try.
|
|
138
|
+
const seen = attempt === 2 && run.updated_at ? new Date(Date.parse(run.updated_at)).toISOString() : commit.time;
|
|
139
|
+
for (const name of second) if (required.has(name) && first.has(name)) add({ cause: 'red-main', sha: commit.sha, time: seen > commit.time ? seen : commit.time, culprit: commit.sha, check: name });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return trips;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// The brake of the repo at the base commit. `ci` holds the trips of lines 2
|
|
146
|
+
// and 3, which the gate reads from GitHub; with no GitHub read it is empty.
|
|
147
|
+
export function brakeState({ root, base, now = new Date(), ci = [] }) {
|
|
148
|
+
const sha = fullSha(root, base);
|
|
149
|
+
if (!sha) throw new BrakeError(`the base "${base}" is not a commit in this repo.`);
|
|
150
|
+
const facts = brakeFacts(git(root, ['show', `${sha}:atlas.yaml`], { allowFail: true }));
|
|
151
|
+
const since = new Date(now.getTime() - facts.days * 864e5);
|
|
152
|
+
const commits = windowCommits(root, sha, since);
|
|
153
|
+
// A time after now, from an odd commit date, counts as now.
|
|
154
|
+
const stamp = now.toISOString();
|
|
155
|
+
const trips = [...revertTrips(root, commits), ...ci].map((trip) => (trip.time > stamp ? { ...trip, time: stamp } : trip)).sort((a, b) => b.time.localeCompare(a.time));
|
|
156
|
+
const until = trips.length ? new Date(Date.parse(trips[0].time) + facts.days * 864e5).toISOString() : null;
|
|
157
|
+
return { on: trips.length > 0, days: facts.days, since: since.toISOString(), until, trips };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// The patch id of a diff, or null for an empty diff. --verbatim keeps every
|
|
161
|
+
// space, so a change of indent is a different patch.
|
|
162
|
+
function patchId(root, from, to) {
|
|
163
|
+
const diff = git(root, ['diff', '--no-color', '--no-ext-diff', '--no-textconv', '--full-index', '--binary', from, to, '--']) ?? '';
|
|
164
|
+
if (!diff.trim()) return null;
|
|
165
|
+
const out = (git(root, ['patch-id', '--verbatim'], { input: diff }) ?? '').trim();
|
|
166
|
+
return out.split(/\s+/)[0] || null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Line 4: the trip whose change this head takes back exactly, or null. The
|
|
170
|
+
// change of the head, from the merge base, must be the reverse of the
|
|
171
|
+
// culprit's change against its first parent. Any other line makes it a new
|
|
172
|
+
// change, which the brake holds.
|
|
173
|
+
export function undoOf(root, mergeBase, head, trips) {
|
|
174
|
+
if (!trips.length) return null;
|
|
175
|
+
const mine = patchId(root, mergeBase, head);
|
|
176
|
+
if (!mine) return null;
|
|
177
|
+
for (const trip of trips) {
|
|
178
|
+
if (trip.undoable === false) continue;
|
|
179
|
+
const parent = fullSha(root, `${trip.culprit}^1`);
|
|
180
|
+
if (parent && patchId(root, trip.culprit, parent) === mine) return trip;
|
|
181
|
+
}
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const short = (sha) => String(sha ?? '').slice(0, 7);
|
|
186
|
+
|
|
187
|
+
// One line for a trip, for the gate summary.
|
|
188
|
+
export function tripText(trip) {
|
|
189
|
+
if (trip.cause === 'revert') return `revert of ${short(trip.culprit)} by ${short(trip.revert)}, on main at ${short(trip.sha)} (${trip.time.slice(0, 10)})`;
|
|
190
|
+
if (trip.cause === 'red-main') return `"${trip.check}" red on ${short(trip.sha)} after one rerun (${trip.time.slice(0, 10)})`;
|
|
191
|
+
return `the live check "${trip.check}" failed after ${short(trip.sha)} (${trip.time.slice(0, 10)})`;
|
|
192
|
+
}
|
|
193
|
+
|
package/door/lib/gate-check.mjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
//
|
|
7
7
|
// judge({ root, base, head, body, redMain }) the answer, from git data
|
|
8
8
|
// renderText(answer) the lines of the job summary
|
|
9
|
-
// checkCommand(options, line, read) the command; `read` gives the red main checks
|
|
9
|
+
// checkCommand(options, line, read) the command; `read` gives the red main checks and the brake trips
|
|
10
10
|
//
|
|
11
11
|
// The answer is success, owner or red:
|
|
12
12
|
//
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// case added or changed; a head that git cannot read
|
|
15
15
|
// owner a path or a sign of the owner-only list; a head job named like a
|
|
16
16
|
// check; a refactor claim (until the review job, roadmap R28); a
|
|
17
|
-
// file too large to scan
|
|
17
|
+
// file too large to scan; the brake (roadmap R13, brake.mjs)
|
|
18
18
|
// success no rule holds the pull request
|
|
19
19
|
//
|
|
20
20
|
// Red wins over owner: the pull request must be fixed before the owner signs.
|
|
@@ -29,13 +29,14 @@ import { resolve } from 'node:path';
|
|
|
29
29
|
import { classify, changedPaths, compile, matcher } from './owner-only.mjs';
|
|
30
30
|
import { listChanged } from './changed-tests.mjs';
|
|
31
31
|
import { parseYaml, SECRET_PATTERNS } from '../../shape/check.mjs';
|
|
32
|
+
import { brakeState, undoOf, tripText } from './brake.mjs';
|
|
32
33
|
|
|
33
34
|
export const VERSION = 1;
|
|
34
35
|
export const GATE_CHECK = 'gate';
|
|
35
36
|
export const GATE_WORKFLOW = '.github/workflows/atlas-gate.yml';
|
|
36
37
|
export const TEST_CHECKS = Object.freeze(['fails-before', 'passes-after']);
|
|
37
38
|
export const RESULTS = Object.freeze(['success', 'owner', 'red']);
|
|
38
|
-
export const ANSWER_KEYS = Object.freeze(['gate', 'base', 'merge_base', 'head', 'result', 'class', 'rows', 'signed_act']);
|
|
39
|
+
export const ANSWER_KEYS = Object.freeze(['gate', 'base', 'merge_base', 'head', 'result', 'class', 'rows', 'signed_act', 'brake']);
|
|
39
40
|
export const SIGNED_ACT = "Clears with the owner's signed act: atlas approve <pr> (roadmap R72).";
|
|
40
41
|
// A file over this size is not scanned for secrets, and comes to the owner.
|
|
41
42
|
export const SCAN_LIMIT = 1024 * 1024;
|
|
@@ -185,10 +186,13 @@ export function coveredFiles(root, green, mergeBase) {
|
|
|
185
186
|
// Line 6: new product code needs a new or changed test case, and fails-before
|
|
186
187
|
// proves it. Line 7: a fix of a red main check that changes no test is exempt
|
|
187
188
|
// for the files that main changed since that check was last green.
|
|
188
|
-
function testRows({ root, mergeBase, head, body, redMain, rules }) {
|
|
189
|
+
function testRows({ root, mergeBase, head, body, redMain, rules, undo = null }) {
|
|
189
190
|
const list = listChanged({ root, base: mergeBase, head });
|
|
190
191
|
const product = list.other_paths.filter(productTest(root, rules ?? mergeBase));
|
|
191
192
|
if (!product.length) return [];
|
|
193
|
+
// The exact undo of a change that tripped the brake puts main back as it
|
|
194
|
+
// was before that change, so it needs no new test (roadmap R13 line 4).
|
|
195
|
+
if (undo) return [{ kind: 'note', class: 'tests', cause: `the exact undo of ${undo.culprit.slice(0, 7)} needs no new test case` }];
|
|
192
196
|
const cases = list.rows.filter((row) => CASE_GROUPS.has(row.group) && row.status !== 'removed');
|
|
193
197
|
if (cases.length) return [{ kind: 'note', class: 'tests', cause: `${new Set(cases.map((row) => row.path)).size} test file(s) added or changed; fails-before proves them` }];
|
|
194
198
|
const anyTest = list.rows.length > 0;
|
|
@@ -215,13 +219,15 @@ function testRows({ root, mergeBase, head, body, redMain, rules }) {
|
|
|
215
219
|
|
|
216
220
|
// The answer for one pull request. `redMain` lists the required checks that
|
|
217
221
|
// are red on the merge base: [{ check, green }], where `green` is the newest
|
|
218
|
-
// commit of main on which the check was green.
|
|
219
|
-
|
|
222
|
+
// commit of main on which the check was green. `brake` holds the trips that
|
|
223
|
+
// GitHub gives ({ ci: [...] }, see brake.mjs); false leaves the brake out, as
|
|
224
|
+
// the replay does, because the brake depends on the day of the run.
|
|
225
|
+
export function judge({ root, base, head, body = '', redMain = [], rules = null, brake = {}, now = new Date() }) {
|
|
220
226
|
const top = resolve(root);
|
|
221
227
|
const baseSha = commitOf(top, base);
|
|
222
228
|
if (!baseSha) throw new GateCheckError(`the base "${base}" is not a commit in this repo.`);
|
|
223
229
|
const headSha = commitOf(top, head);
|
|
224
|
-
const answer = { gate: VERSION, base: baseSha, merge_base: null, head: headSha || String(head), result: 'red', class: null, rows: [], signed_act: null };
|
|
230
|
+
const answer = { gate: VERSION, base: baseSha, merge_base: null, head: headSha || String(head), result: 'red', class: null, rows: [], signed_act: null, brake: null };
|
|
225
231
|
if (!headSha) {
|
|
226
232
|
answer.rows.push({ kind: 'red', class: 'gate', cause: 'the head commit is not readable in this clone' });
|
|
227
233
|
return finish(answer);
|
|
@@ -233,13 +239,30 @@ export function judge({ root, base, head, body = '', redMain = [], rules = null
|
|
|
233
239
|
}
|
|
234
240
|
answer.merge_base = mergeBase;
|
|
235
241
|
const changes = changedPaths(top, mergeBase, headSha);
|
|
242
|
+
let undo = null;
|
|
243
|
+
if (brake !== false) {
|
|
244
|
+
const state = brakeState({ root: top, base: baseSha, now, ci: brake?.ci ?? [] });
|
|
245
|
+
undo = undoOf(top, mergeBase, headSha, state.trips);
|
|
246
|
+
answer.brake = { on: state.on, until: state.until, days: state.days, trips: state.trips, undo: undo?.culprit ?? null };
|
|
247
|
+
answer.rows.push(...brakeRows(state, undo));
|
|
248
|
+
}
|
|
236
249
|
for (const row of classify({ root: top, base: mergeBase, head: headSha, rules }).rows) answer.rows.push({ kind: 'owner', class: row.class ?? 'unlisted', path: row.path, cause: row.cause });
|
|
237
250
|
answer.rows.push(...jobRows(top, rules ?? mergeBase, headSha, changes));
|
|
238
251
|
answer.rows.push(...secretRows(top, mergeBase, headSha, changes));
|
|
239
|
-
answer.rows.push(...testRows({ root: top, mergeBase, head: headSha, body, redMain, rules }));
|
|
252
|
+
answer.rows.push(...testRows({ root: top, mergeBase, head: headSha, body, redMain, rules, undo }));
|
|
240
253
|
return finish(answer);
|
|
241
254
|
}
|
|
242
255
|
|
|
256
|
+
// Line 1: while the brake is on, every pull request of the repo comes to the
|
|
257
|
+
// owner, except the exact undo of a trip (line 4).
|
|
258
|
+
function brakeRows(state, undo) {
|
|
259
|
+
if (!state.on) return [];
|
|
260
|
+
const newest = state.trips[0];
|
|
261
|
+
const more = state.trips.length > 1 ? `; ${state.trips.length} trips in ${state.days} days` : '';
|
|
262
|
+
if (undo) return [{ kind: 'note', class: 'brake', cause: `the exact undo of ${undo.culprit.slice(0, 7)}, which tripped the brake: ${tripText(undo)}` }];
|
|
263
|
+
return [{ kind: 'owner', class: 'brake', cause: `the brake is on until ${state.until.slice(0, 16).replace('T', ' ')} UTC: ${tripText(newest)}${more}` }];
|
|
264
|
+
}
|
|
265
|
+
|
|
243
266
|
function finish(answer) {
|
|
244
267
|
const red = answer.rows.filter((row) => row.kind === 'red');
|
|
245
268
|
const owner = answer.rows.filter((row) => row.kind === 'owner');
|
|
@@ -278,8 +301,10 @@ export function checkCommand({ root, base, head, body: bodyFile, json = false, s
|
|
|
278
301
|
if (!base || base === true) throw Object.assign(new GateCheckError('atlas gate check needs --base <ref>.'), { usage: true });
|
|
279
302
|
if (!head || head === true) throw Object.assign(new GateCheckError('atlas gate check needs --head <sha>.'), { usage: true });
|
|
280
303
|
const body = bodyFile && bodyFile !== true ? readFileSync(resolve(bodyFile), 'utf8') : '';
|
|
281
|
-
|
|
282
|
-
const
|
|
304
|
+
// A reader gives the red main checks, or { redMain, brake } with the brake trips too.
|
|
305
|
+
const got = read ? read() : [];
|
|
306
|
+
const redMain = Array.isArray(got) ? got : got.redMain ?? [];
|
|
307
|
+
const answer = judge({ root, base, head, body, redMain, brake: Array.isArray(got) ? {} : got.brake ?? {} });
|
|
283
308
|
const text = renderText(answer);
|
|
284
309
|
if (json) process.stdout.write(`${JSON.stringify(answer, null, 2)}\n`);
|
|
285
310
|
else for (const each of text) line(each);
|
|
@@ -311,7 +336,7 @@ export function replay({ root, days = 30, branch = null, now = new Date() }) {
|
|
|
311
336
|
for (const entry of shas) {
|
|
312
337
|
const [sha, parent] = entry.split(' ');
|
|
313
338
|
if (!parent) continue;
|
|
314
|
-
const answer = judge({ root: top, base: parent, head: sha, rules: tip });
|
|
339
|
+
const answer = judge({ root: top, base: parent, head: sha, rules: tip, brake: false });
|
|
315
340
|
const list = listChanged({ root: top, base: parent, head: sha });
|
|
316
341
|
const oldTest = list.rows.some((row) => CASE_GROUPS.has(row.group) && row.status !== 'added');
|
|
317
342
|
rows.push({
|
package/door/lib/gate.mjs
CHANGED
|
@@ -21,6 +21,7 @@ import { dirname, join } from 'node:path';
|
|
|
21
21
|
import { childEnvironment } from '../../work/lib/child-env.mjs';
|
|
22
22
|
import { parseYaml } from '../../shape/check.mjs';
|
|
23
23
|
import { checkCommand, replayCommand, jobNames, GATE_CHECK, GATE_WORKFLOW } from './gate-check.mjs';
|
|
24
|
+
import { brakeFacts, windowCommits, ciTrips, BrakeError } from './brake.mjs';
|
|
24
25
|
|
|
25
26
|
export const BRANCH_RULESET = 'atlas gate';
|
|
26
27
|
export const TAG_RULESET = 'atlas release tags';
|
|
@@ -293,6 +294,23 @@ function pinnedRuns(gh, repo, { pr, sha, baseRef, head, checks }) {
|
|
|
293
294
|
return { kept, refused };
|
|
294
295
|
}
|
|
295
296
|
|
|
297
|
+
// The push runs of the base files that define a required or a live check,
|
|
298
|
+
// since `since`. Three full pages fail closed.
|
|
299
|
+
function pushRuns(gh, repo, { files, since, pages = 3 }) {
|
|
300
|
+
const created = encodeURIComponent(`>=${since.toISOString().slice(0, 10)}`);
|
|
301
|
+
const runs = [];
|
|
302
|
+
for (const path of files) {
|
|
303
|
+
const file = path.split('/').pop();
|
|
304
|
+
for (let page = 1; ; page += 1) {
|
|
305
|
+
if (page > pages) throw new BrakeError(`more than ${pages * 100} push runs of ${path} in the brake window; the read stops here.`);
|
|
306
|
+
const got = gh('GET', `repos/${repo}/actions/workflows/${encodeURIComponent(file)}/runs?event=push&status=completed&created=${created}&per_page=100&page=${page}`)?.workflow_runs ?? [];
|
|
307
|
+
runs.push(...got.filter((run) => run.path === path || String(run.path ?? '').startsWith(`${path}@`)).map((run) => ({ ...run, path })));
|
|
308
|
+
if (got.length < 100) break;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return runs;
|
|
312
|
+
}
|
|
313
|
+
|
|
296
314
|
// `atlas gate ready --pr <n>`: may this pull request merge now? Exit 0 when
|
|
297
315
|
// every required check is green on its head commit, 1 when not.
|
|
298
316
|
export function readyCommand({ repo, pr, answer }, line, gh = ghApi) {
|
|
@@ -427,12 +445,53 @@ export function redMain({ root, base }, gh, repo, depth = 30) {
|
|
|
427
445
|
return found;
|
|
428
446
|
}
|
|
429
447
|
|
|
448
|
+
// The base workflow files that define a job of one of `names`, read from git.
|
|
449
|
+
function definedAt(git, sha, names) {
|
|
450
|
+
const files = new Set();
|
|
451
|
+
if (!names.length) return files;
|
|
452
|
+
const listing = (() => { try { return git(['ls-tree', '-r', '--name-only', sha, '--', '.github/workflows/']); } catch { return ''; } })();
|
|
453
|
+
for (const path of listing.split('\n').filter((each) => /^\.github\/workflows\/[^/]+\.ya?ml$/.test(each))) {
|
|
454
|
+
let text = '';
|
|
455
|
+
try { text = git(['show', `${sha}:${path}`]); } catch { continue; }
|
|
456
|
+
if ((jobNames(text) ?? []).some((name) => names.includes(name))) files.add(path);
|
|
457
|
+
}
|
|
458
|
+
return files;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// The brake trips that only GitHub knows (roadmap R13 lines 2 and 3): a
|
|
462
|
+
// required check that failed twice on a commit of main, and a failed live
|
|
463
|
+
// check. The gate job reads them with its read-only token. It reads the push
|
|
464
|
+
// runs of the base files that define those jobs, in the brake window. A base
|
|
465
|
+
// with no such file makes no call.
|
|
466
|
+
export function brakeRead({ root, base, now = new Date() }, gh, repo, pages = 3) {
|
|
467
|
+
const git = (args) => execFileSync('git', ['-C', root, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
468
|
+
const sha = git(['rev-parse', '--verify', '--end-of-options', `${base}^{commit}`]);
|
|
469
|
+
let text = '';
|
|
470
|
+
try { text = git(['show', `${sha}:atlas.yaml`]); } catch { return []; }
|
|
471
|
+
const facts = brakeFacts(text);
|
|
472
|
+
const checks = facts.checks.filter((name) => name !== GATE_CHECK);
|
|
473
|
+
const liveChecks = facts.live === 'deploy' ? facts.liveChecks : [];
|
|
474
|
+
const homes = definedAt(git, sha, checks);
|
|
475
|
+
const files = new Set([...homes, ...definedAt(git, sha, liveChecks)]);
|
|
476
|
+
if (!files.size) return [];
|
|
477
|
+
const since = new Date(now.getTime() - facts.days * 864e5);
|
|
478
|
+
const commits = windowCommits(root, sha, since);
|
|
479
|
+
if (!commits.length) return [];
|
|
480
|
+
const runs = pushRuns(gh, repo, { files, since, pages });
|
|
481
|
+
const jobsOf = (id, attempt) => {
|
|
482
|
+
const jobs = gh('GET', `repos/${repo}/actions/runs/${id}/attempts/${attempt}/jobs?per_page=100`)?.jobs ?? [];
|
|
483
|
+
if (jobs.length >= 100) throw new BrakeError(`run ${id} has 100 jobs or more; the read stops here.`);
|
|
484
|
+
return jobs;
|
|
485
|
+
};
|
|
486
|
+
return ciTrips({ commits, runs, homes, checks, live: facts.live, liveChecks }, jobsOf);
|
|
487
|
+
}
|
|
488
|
+
|
|
430
489
|
// `atlas gate apply|ready|audit|check|replay`. Returns the exit code.
|
|
431
490
|
export function gateCommand({ what, repo, root, dryRun = false, off = false, on = false, pr, since, commit, base, head, body, json = false, summary, output, days, branch, home = homedir() }, line, gh = ghApi) {
|
|
432
491
|
if (!['apply', 'ready', 'audit', 'check', 'replay'].includes(what)) throw new GateError(`there is no gate verb "${what ?? ''}". Use apply, ready, audit, check or replay.`);
|
|
433
492
|
// With --repo, check reads the red main checks from GitHub; without, it judges git data alone.
|
|
434
493
|
const named = repo && repo !== true && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) && !repo.split('/').some((part) => /^\.+$/.test(part));
|
|
435
|
-
if (what === 'check') return checkCommand({ root, base, head, body, json, summary, output }, line, named ? () => redMain({ root, base }, gh, repo) : null);
|
|
494
|
+
if (what === 'check') return checkCommand({ root, base, head, body, json, summary, output }, line, named ? () => ({ redMain: redMain({ root, base }, gh, repo), brake: { ci: brakeRead({ root, base }, gh, repo) } }) : null);
|
|
436
495
|
if (what === 'replay') return replayCommand({ root, days, branch, json }, line);
|
|
437
496
|
if (!named) throw new GateError(`atlas gate ${what} needs --repo <owner/name>.`);
|
|
438
497
|
if (what === 'ready') {
|
package/door/lib/upkeep.mjs
CHANGED
|
@@ -16,8 +16,7 @@
|
|
|
16
16
|
// 4. Doc sources (roadmap R39, lib/doc-sources.mjs). Each doc and each
|
|
17
17
|
// procedure names its code, or why it names none, and each named path
|
|
18
18
|
// exists. With --event, each doc whose code the pull request changes is
|
|
19
|
-
// updated in it, or marked "still true" in its text.
|
|
20
|
-
// R39 line 3 is read, that last rule only reports (TOUCHED_DOCS_GATE).
|
|
19
|
+
// updated in it, or marked "still true" in its text (TOUCHED_DOCS_GATE).
|
|
21
20
|
//
|
|
22
21
|
// It reads the tree and the event file, and writes nothing.
|
|
23
22
|
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
|
|
@@ -25,9 +24,11 @@ import { join, dirname, posix, resolve } from 'node:path';
|
|
|
25
24
|
import { listChanged } from './changed-tests.mjs';
|
|
26
25
|
import { docSourceFindings, replay } from './doc-sources.mjs';
|
|
27
26
|
|
|
28
|
-
// R39 line 4 gates only after the replay of line 3.
|
|
29
|
-
//
|
|
30
|
-
|
|
27
|
+
// R39 line 4 gates only after the replay of line 3. The replay of the last 30
|
|
28
|
+
// merges (atlas#225): rule "file" pulled in 81 docs over 24 merges, 58 not
|
|
29
|
+
// updated; rule "line" pulled in none. So rule "file" gates. False makes a
|
|
30
|
+
// touched doc with no update and no mark a note, not a finding.
|
|
31
|
+
export const TOUCHED_DOCS_GATE = true;
|
|
31
32
|
|
|
32
33
|
const INDEX = 'docs/index.md';
|
|
33
34
|
const SKIP_DIRS = new Set(['node_modules', 'archive', 'fixtures']);
|
package/package.json
CHANGED