@arjunkhera/atlas 0.4.14 → 0.4.16

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atlas",
3
- "version": "0.4.14",
3
+ "version": "0.4.16",
4
4
  "description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, for any repository.",
5
5
  "author": {
6
6
  "name": "Arjun Khera"
package/door/cli.mjs CHANGED
@@ -267,7 +267,8 @@ function toolingCommand(chosen) {
267
267
  if (what !== 'write') throw new Error(`there is no tooling verb "${what}". Use write or pr.`);
268
268
  const result = writeTooling(root, PACKAGE_ROOT, { dryRun: Boolean(chosen['dry-run']), replaceUnknown, workflowOnly });
269
269
  line(`Atlas ${result.version}, check version ${result.check_version}${result.status ? `; the copy was ${result.status}` : ''}`);
270
- for (const row of result.rows) line(` ${row.state.padEnd(10)} ${row.path} sha256 ${row.sha256.slice(0, 12)}…`);
270
+ // A removed file has no bytes, so its row has no sha256 (roadmap R7).
271
+ for (const row of result.rows) line(` ${row.state.padEnd(10)} ${row.path}${row.sha256 ? ` sha256 ${row.sha256.slice(0, 12)}…` : ''}`);
271
272
  line('');
272
273
  line(result.changed
273
274
  ? 'The files come from the package. No hand step, and no edit by hand afterwards.'
@@ -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
+
@@ -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
- export function judge({ root, base, head, body = '', redMain = [], rules = null }) {
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
- const redMain = read ? read() : [];
282
- const answer = judge({ root, base, head, body, redMain });
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') {
@@ -11,9 +11,9 @@
11
11
  // git and GitHub calls sit below it, in one place.
12
12
  import { execFileSync } from 'node:child_process';
13
13
  import { childEnvironment } from '../../work/lib/child-env.mjs';
14
- import { mkdirSync, writeFileSync } from 'node:fs';
14
+ import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
15
15
  import { dirname, join, posix } from 'node:path';
16
- import { CHECK_PATH, WORKFLOW_PATH } from '../../shape/check.mjs';
16
+ import { CHECK_PATH, WORKFLOW_PATH, OLD_WORKFLOW_PATH } from '../../shape/check.mjs';
17
17
 
18
18
  // Every change names where it came from, and the set is closed.
19
19
  export const PROVENANCE_SOURCES = Object.freeze(['owner words', 'repo read', 'task input']);
@@ -24,6 +24,9 @@ export const TOOLING_LABEL = 'atlas-helper';
24
24
  // The only files the tooling writer may write: the check, its workflow, and
25
25
  // the pull request template when the repo has none.
26
26
  export const TOOLING_PATHS = Object.freeze([CHECK_PATH, WORKFLOW_PATH, '.github/workflows/atlas-jobs.yml', '.github/pull_request_template.md']);
27
+ // The only files the tooling writer may remove: the workflow of check versions
28
+ // 1 to 5, which the gate workflow replaces (roadmap R7).
29
+ export const TOOLING_REMOVALS = Object.freeze([OLD_WORKFLOW_PATH]);
27
30
 
28
31
  export class ChangeError extends Error {}
29
32
 
@@ -38,8 +41,12 @@ export function checkChanges(changes) {
38
41
  for (const change of changes) {
39
42
  const path = posix.normalize(String(change?.path ?? '')).replace(/^\.\//, '');
40
43
  if (!path || path === '.') throw new ChangeError('a change has no path');
41
- if (!TOOLING_PATHS.includes(path)) throw new ChangeError(`the tooling writer writes only ${TOOLING_PATHS.join(' and ')}; it refuses "${path}"`);
42
- if (typeof change.contents !== 'string') throw new ChangeError(`the change to "${path}" carries no contents`);
44
+ if (change.remove === true) {
45
+ if (!TOOLING_REMOVALS.includes(path)) throw new ChangeError(`the tooling writer removes only ${TOOLING_REMOVALS.join(' and ')}; it refuses to remove "${path}"`);
46
+ } else {
47
+ if (!TOOLING_PATHS.includes(path)) throw new ChangeError(`the tooling writer writes only ${TOOLING_PATHS.join(' and ')}; it refuses "${path}"`);
48
+ if (typeof change.contents !== 'string') throw new ChangeError(`the change to "${path}" carries no contents`);
49
+ }
43
50
  const source = change?.provenance?.source;
44
51
  if (!PROVENANCE_SOURCES.includes(source)) throw new ChangeError(`the change to "${path}" names no source; every change names one of ${PROVENANCE_SOURCES.join(', ')}`);
45
52
  if (!String(change?.provenance?.detail ?? '').trim()) throw new ChangeError(`the change to "${path}" names the source "${source}" and says nothing about it`);
@@ -82,8 +89,11 @@ export function applyChanges(root, changes) {
82
89
  const written = [];
83
90
  for (const change of changes) {
84
91
  const path = join(root, change.path);
85
- mkdirSync(dirname(path), { recursive: true });
86
- writeFileSync(path, change.contents);
92
+ if (change.remove === true) rmSync(path, { force: true });
93
+ else {
94
+ mkdirSync(dirname(path), { recursive: true });
95
+ writeFileSync(path, change.contents);
96
+ }
87
97
  written.push(change.path);
88
98
  }
89
99
  return { written };
@@ -105,7 +115,8 @@ export function open({ root, changes, why, branch, title, base = null, label = T
105
115
  if (dryRun) return { repo, branch, title, body, base: onto, written, opened: false };
106
116
 
107
117
  exec('git', ['checkout', '-b', branch], root);
108
- exec('git', ['add', '--', ...written], root);
118
+ // `git add -A` stages a removed file as well as a written one.
119
+ exec('git', ['add', '-A', '--', ...written], root);
109
120
  exec('git', ['commit', '-m', title, '-m', body], root);
110
121
  exec('git', ['push', '-u', 'origin', branch], root);
111
122
  // A label that does not exist yet makes `gh pr create` fail, so it is made
@@ -15,7 +15,7 @@
15
15
  // package's list. This runs in a session, with the package on disk.
16
16
  import { readFileSync, existsSync, readdirSync, lstatSync } from 'node:fs';
17
17
  import { join, relative, sep } from 'node:path';
18
- import { CHECK_PATH, WORKFLOW_PATH, WORKFLOW_TEMPLATE, checkStamps, sha256 } from '../../shape/check.mjs';
18
+ import { CHECK_PATH, WORKFLOW_PATH, OLD_WORKFLOW_PATH, WORKFLOW_TEMPLATE, SHAPE_JOB_TEXT, shapeJobOf, checkStamps, sha256 } from '../../shape/check.mjs';
19
19
  import { packagedCheck, placeOf, packagePackageVersion } from './releases.mjs';
20
20
  import { currentKitHash, KIT_RELEASES_FILE, KIT_TARGET, MANIFEST } from './tests.mjs';
21
21
  import { kitHashOf } from '../../tests/link-check.mjs';
@@ -57,8 +57,9 @@ export function statusMany(roots, packageRoot) {
57
57
  // R19, slice 0 of the routines design). The check is one copy of four types:
58
58
  //
59
59
  // check .github/atlas/check.mjs, by the release list of the check
60
- // workflow .github/workflows/atlas-check.yml, by the workflow fingerprint
61
- // in each row of that list
60
+ // workflow .github/workflows/atlas-gate.yml, by the workflow fingerprint
61
+ // in each row of that list, or by its shape job; before check
62
+ // version 6, .github/workflows/atlas-check.yml
62
63
  // kit each <area>/test/atlas/, by its manifest and the kit list
63
64
  // pin an Atlas version written in a workflow, a script or package.json
64
65
  //
@@ -92,23 +93,37 @@ function checkRow(root, packageRoot) {
92
93
 
93
94
  // The workflow is written with the check, from the WORKFLOW_TEMPLATE of that
94
95
  // check. Each row of the release list keeps the fingerprint of its template.
96
+ // From check version 6 the workflow is the gate workflow, and a repo may add
97
+ // its own jobs to it, as the Atlas repo does: then its shape job must be the
98
+ // one the package writes. A repo that still has only the old atlas-check.yml
99
+ // is behind (roadmap R7 line 5).
95
100
  function workflowRow(root, packageRoot) {
96
101
  const pkg = packagedCheck(packageRoot);
97
102
  const version = checkStamps(pkg.text).check_version;
98
103
  const want = `workflow of check version ${version}`;
99
104
  const full = join(root, WORKFLOW_PATH);
100
105
  if (linkAt(full)) return linked('workflow', WORKFLOW_PATH, want);
101
- if (!existsSync(full)) return row('workflow', WORKFLOW_PATH, 'missing', null, want, `${WORKFLOW_PATH} does not exist in this repo, so its check never runs in CI. "atlas tooling pr" adds it.`);
106
+ const known = (text) => pkg.releases.filter((one) => one.workflow_sha256 === sha256(text));
107
+ if (!existsSync(full)) {
108
+ const old = join(root, OLD_WORKFLOW_PATH);
109
+ if (linkAt(old)) return linked('workflow', OLD_WORKFLOW_PATH, want);
110
+ if (!existsSync(old)) return row('workflow', WORKFLOW_PATH, 'missing', null, want, `${WORKFLOW_PATH} does not exist in this repo, so its check never runs in CI. "atlas tooling pr" adds it.`);
111
+ let text;
112
+ try { text = readFileSync(old, 'utf8'); } catch (error) { return broken('workflow', OLD_WORKFLOW_PATH, want, error); }
113
+ const rows = known(text);
114
+ if (rows.length) return row('workflow', OLD_WORKFLOW_PATH, 'behind', `workflow of check version ${rows.at(-1).check_version}`, want, `${OLD_WORKFLOW_PATH} runs the head's copy of the check, so a pull request can change its own judge. "atlas tooling pr" writes ${WORKFLOW_PATH} and removes it.`);
115
+ return row('workflow', OLD_WORKFLOW_PATH, 'unknown', `sha256 ${sha256(text).slice(0, 12)}…`, want, `${OLD_WORKFLOW_PATH} is in no release, and ${WORKFLOW_PATH} does not exist. "atlas tooling pr" shows the difference.`);
116
+ }
102
117
  let text;
103
118
  try { text = readFileSync(full, 'utf8'); } catch (error) { return broken('workflow', WORKFLOW_PATH, want, error); }
104
- const fingerprint = sha256(text);
105
- if (fingerprint === sha256(WORKFLOW_TEMPLATE)) return row('workflow', WORKFLOW_PATH, 'current', want, want, 'the workflow is the one this package writes.');
106
- const rows = pkg.releases.filter((one) => one.workflow_sha256 === fingerprint);
119
+ if (text === WORKFLOW_TEMPLATE) return row('workflow', WORKFLOW_PATH, 'current', want, want, 'the workflow is the one this package writes.');
120
+ if (shapeJobOf(text) === SHAPE_JOB_TEXT) return row('workflow', WORKFLOW_PATH, 'current', want, want, 'its shape job is the one this package writes; the file holds other jobs of this repo too.');
121
+ const rows = known(text);
107
122
  if (rows.length) {
108
123
  const newest = rows.at(-1);
109
- return row('workflow', WORKFLOW_PATH, 'behind', `workflow of check version ${newest.check_version}`, want, `the workflow was first written by Atlas ${rows[0].first_package}. "atlas tooling pr --workflow" refreshes it.`);
124
+ return row('workflow', WORKFLOW_PATH, 'behind', `workflow of check version ${newest.check_version}`, want, `the workflow was first written by Atlas ${rows[0].first_package ?? `check version ${rows[0].check_version}`}. "atlas tooling pr --workflow" refreshes it.`);
110
125
  }
111
- return row('workflow', WORKFLOW_PATH, 'unknown', `sha256 ${fingerprint.slice(0, 12)}…`, want, `the workflow is in no release. Someone edited it, or it came from an unreleased package. "atlas tooling pr" shows the difference.`);
126
+ return row('workflow', WORKFLOW_PATH, 'unknown', `sha256 ${sha256(text).slice(0, 12)}…`, want, `the workflow is in no release, and its shape job is not the one this package writes. Someone edited it, or it came from an unreleased package. "atlas tooling pr" shows the difference.`);
112
127
  }
113
128
 
114
129
  // A walk never follows a link and never enters node_modules or .git. The
@@ -14,15 +14,27 @@
14
14
  // is missing or behind, or unknown with --replace-unknown, and only a check
15
15
  // that is the newest row of the release list. The Atlas repo itself may take
16
16
  // its own unreleased check, because that is how a new check is made.
17
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
17
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, rmSync } from 'node:fs';
18
18
  import { execFileSync } from 'node:child_process';
19
19
  import { dirname, join } from 'node:path';
20
- import { CHECK_PATH, WORKFLOW_PATH, WORKFLOW_TEMPLATE, CHECK_VERSION, sha256, parseYaml } from '../../shape/check.mjs';
20
+ import { CHECK_PATH, WORKFLOW_PATH, OLD_WORKFLOW_PATH, WORKFLOW_TEMPLATE, SHAPE_JOB_TEXT, shapeJobOf, CHECK_VERSION, sha256, parseYaml } from '../../shape/check.mjs';
21
21
  import { packagedCheck, packagePackageVersion, PACKAGE_CHECK_SOURCE } from './releases.mjs';
22
22
  import { status } from './status.mjs';
23
23
  import { PR_TEMPLATE_PATH } from './upkeep.mjs';
24
24
 
25
- export { CHECK_PATH, WORKFLOW_PATH, PACKAGE_CHECK_SOURCE, PR_TEMPLATE_PATH };
25
+ export { CHECK_PATH, WORKFLOW_PATH, OLD_WORKFLOW_PATH, PACKAGE_CHECK_SOURCE, PR_TEMPLATE_PATH };
26
+
27
+ // The gate workflow (roadmap R7 line 5). A repo may keep its own jobs in it, as
28
+ // the Atlas repo does. When its shape job is the one the package writes, the
29
+ // writer keeps the file. The workflow of check versions 1 to 5,
30
+ // atlas-check.yml, ran the head's copy of the check; the writer removes it.
31
+ const workflowKept = (root) => {
32
+ const full = join(root, WORKFLOW_PATH);
33
+ if (!existsSync(full)) return false;
34
+ const text = readFileSync(full, 'utf8');
35
+ return text !== WORKFLOW_TEMPLATE && shapeJobOf(text) === SHAPE_JOB_TEXT;
36
+ };
37
+ const oldWorkflow = (root) => existsSync(join(root, OLD_WORKFLOW_PATH));
26
38
 
27
39
  // The watcher of the routines design (roadmap R24). The writer puts it into each
28
40
  // repo, with the version of this package as its pin. It refreshes a copy whose pin
@@ -101,8 +113,8 @@ export function mayWrite(root, packageRoot, { replaceUnknown = false, workflowOn
101
113
  throw new ToolingRefusal(`this package's ${PACKAGE_CHECK_SOURCE} is ${pkg.row ? 'not the newest release' : 'in no release'} of the check, so it is not written into another repo. Use a released Atlas.`);
102
114
  }
103
115
  const now = status({ root, packageRoot });
104
- if (now.state === 'current' && !workflowOnly && !templateMissing(root, packageRoot) && !jobsNeedWrite(root, packageRoot)) {
105
- throw new ToolingRefusal(`this repo's check is already current. ${readWorkflow(root) === WORKFLOW_TEMPLATE ? 'Both files match the package, so there is nothing to write.' : 'Its workflow file differs from the package; pass --workflow to rewrite only that file.'}`, { current: true });
116
+ if (now.state === 'current' && !workflowOnly && !templateMissing(root, packageRoot) && !jobsNeedWrite(root, packageRoot) && !oldWorkflow(root)) {
117
+ throw new ToolingRefusal(`this repo's check is already current. ${readWorkflow(root) === WORKFLOW_TEMPLATE || workflowKept(root) ? 'Both files match the package, so there is nothing to write.' : 'Its workflow file differs from the package; pass --workflow to rewrite only that file.'}`, { current: true });
106
118
  }
107
119
  if (now.state === 'unknown' && !replaceUnknown && !own) {
108
120
  throw new ToolingRefusal(`${now.why}`);
@@ -120,6 +132,7 @@ export function write(root, packageRoot, { dryRun = false, replaceUnknown = fals
120
132
  for (const [path, contents] of Object.entries(wanted)) {
121
133
  const full = join(root, path);
122
134
  const was = existsSync(full) ? readFileSync(full, 'utf8') : null;
135
+ if (path === WORKFLOW_PATH && workflowKept(root)) { rows.push({ path, state: 'kept', sha256: sha256(was), was: sha256(was) }); continue; }
123
136
  const state = was === null ? 'written' : was === contents ? 'unchanged' : 'refreshed';
124
137
  if (state !== 'unchanged' && !dryRun) {
125
138
  mkdirSync(dirname(full), { recursive: true });
@@ -127,6 +140,11 @@ export function write(root, packageRoot, { dryRun = false, replaceUnknown = fals
127
140
  }
128
141
  rows.push({ path, state, sha256: sha256(contents), was: was === null ? null : sha256(was) });
129
142
  }
143
+ if (oldWorkflow(root)) {
144
+ const was = readFileSync(join(root, OLD_WORKFLOW_PATH), 'utf8');
145
+ if (!dryRun) rmSync(join(root, OLD_WORKFLOW_PATH));
146
+ rows.push({ path: OLD_WORKFLOW_PATH, state: 'removed', sha256: null, was: sha256(was) });
147
+ }
130
148
  const jobs = workflowOnly ? null : jobsState(root, packageRoot);
131
149
  if (jobs !== null) {
132
150
  const contents = packagedJobsWorkflow(packageRoot);
@@ -165,7 +183,11 @@ export function changes(root, packageRoot, { replaceUnknown = false, workflowOnl
165
183
  const wanted = { ...packagedFiles(packageRoot), [JOBS_PATH]: packagedJobsWorkflow(packageRoot), [PR_TEMPLATE_PATH]: packagedTemplate(packageRoot) };
166
184
  return rows
167
185
  .filter((row) => row.state !== 'unchanged' && row.state !== 'kept')
168
- .map((row) => ({
186
+ .map((row) => (row.state === 'removed' ? {
187
+ path: row.path,
188
+ remove: true,
189
+ provenance: { source: 'repo read', detail: `the workflow of check versions 1 to 5, which runs the head's copy of the check; ${WORKFLOW_PATH} replaces it from check version ${CHECK_VERSION}` },
190
+ } : {
169
191
  path: row.path,
170
192
  contents: wanted[row.path],
171
193
  provenance: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.4.14",
3
+ "version": "0.4.16",
4
4
  "description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, as a Claude Code plugin for any repository.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",