@arjunkhera/atlas 0.4.22 → 0.4.23

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.22",
3
+ "version": "0.4.23",
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
@@ -153,7 +153,8 @@ const HELP = `atlas — the door into a repo's Atlas files
153
153
  On a GitHub plan with no rulesets, apply changes nothing and reports advisory mode.
154
154
  atlas gate ready --repo <owner/name> --pr <n> may this pull request merge: every required check green on its head
155
155
  atlas gate audit --repo <owner/name> [--since <date>] each merge since the date (default: one day) that came before its checks were green
156
- atlas gate audit --repo <owner/name> --commit <sha> one commit on the main branch: red when no pull request merged it, or one merged it before its checks were green
156
+ atlas gate audit --repo <owner/name> --commit <sha> [--forced] [--findings <file>] one commit on the main branch: red when no pull request merged it, one merged it before its checks were green, or a gate run is not from the base workflow
157
+ atlas gate audit --repo <owner/name> --errors [--findings <file>] red when the gate errored on 3 pull requests in a row
157
158
  atlas gate check --base <ref> --head <sha> the gate: judge a pull request with this copy of Atlas; success, owner or red
158
159
  --pr <n> --base-ref <branch> with --repo: read the comments of the pull request, and let the owner's signed act for this base and head clear owner
159
160
  --root <repo> the clone that holds both commits (default: this folder); the head is read as git data only
@@ -216,7 +217,7 @@ export const FLAGS = Object.freeze({
216
217
  tests: { root: 'optional', halves: 'value', evidence: 'value', 'guards-from': 'value', tests: 'value', 'dry-run': 'switch', run: 'value', own: 'value', base: 'value', head: 'value', list: 'value', side: 'value', old: 'value', new: 'value', covers: 'switch', findings: 'value', repo: 'value', max: 'value', bot: 'value', since: 'value', out: 'value', text: 'switch', json: 'switch', scenario: 'value', language: 'value' },
217
218
  jobs: { root: 'optional', repo: 'value', branch: 'value', out: 'value', findings: 'value', max: 'value', 'dry-run': 'switch' },
218
219
  upkeep: { root: 'optional', event: 'value', json: 'switch', last: 'value', base: 'value' },
219
- gate: { repo: 'value', root: 'optional', 'dry-run': 'switch', off: 'switch', on: 'switch', pr: 'value', since: 'value', commit: 'value', base: 'value', 'base-ref': 'value', head: 'value', body: 'value', json: 'switch', summary: 'value', output: 'value', days: 'value', branch: 'value' },
220
+ gate: { repo: 'value', root: 'optional', 'dry-run': 'switch', off: 'switch', on: 'switch', pr: 'value', since: 'value', commit: 'value', base: 'value', 'base-ref': 'value', head: 'value', body: 'value', json: 'switch', summary: 'value', output: 'value', days: 'value', branch: 'value', errors: 'switch', forced: 'switch', findings: 'value' },
220
221
  approve: { repo: 'value', root: 'optional', key: 'value', provider: 'value', yes: 'switch', out: 'value', file: 'value', signers: 'value', commit: 'value', item: 'value', card: 'value', words: 'value', pr: 'value', head: 'value', decision: 'value', check: 'value' },
221
222
  judge: { tasks: 'value', verdict: 'value', seal: 'value', runs: 'value', out: 'value', repo: 'value', budget: 'value', model: 'value' },
222
223
  install: { local: 'switch', from: 'value', 'skip-global': 'switch', yes: 'switch', cloud: 'switch' },
@@ -466,7 +467,7 @@ function upkeepVerb(chosen) {
466
467
  }
467
468
 
468
469
  function gateVerb(chosen) {
469
- return gateCommand({ what: chosen._[1], repo: chosen.repo, root: rootOf(chosen), dryRun: Boolean(chosen['dry-run']), off: Boolean(chosen.off), on: Boolean(chosen.on), pr: chosen.pr, since: chosen.since, commit: chosen.commit, base: chosen.base, baseRef: chosen['base-ref'], head: chosen.head, body: chosen.body, json: Boolean(chosen.json), summary: chosen.summary, output: chosen.output, days: chosen.days, branch: chosen.branch }, line);
470
+ return gateCommand({ what: chosen._[1], repo: chosen.repo, root: rootOf(chosen), dryRun: Boolean(chosen['dry-run']), off: Boolean(chosen.off), on: Boolean(chosen.on), pr: chosen.pr, since: chosen.since, commit: chosen.commit, base: chosen.base, baseRef: chosen['base-ref'], head: chosen.head, body: chosen.body, json: Boolean(chosen.json), summary: chosen.summary, output: chosen.output, days: chosen.days, branch: chosen.branch, errors: Boolean(chosen.errors), forced: Boolean(chosen.forced), findings: chosen.findings }, line);
470
471
  }
471
472
 
472
473
  function approveVerb(chosen) {
@@ -0,0 +1,16 @@
1
+ # An Atlas job of the audit (roadmap R14). The watcher runs it from the npm
2
+ # copy of Atlas, over the first-parent commits of the main branch in the last
3
+ # two days. The push audit (`.github/workflows/atlas-gate-audit.yml`) files
4
+ # the same findings, with the same keys, so one break opens one issue.
5
+ #
6
+ # kind is one of: push (no pull request merged it), force-push,
7
+ # merge-before-green, gate-copy (a gate run not from the base workflow file).
8
+ # A closed issue does not stop a new one while its commit is in the window:
9
+ # anyone with the shared login can close or hand over an issue.
10
+ title: Each commit on the main branch came through the gate
11
+ when: gate-audit
12
+ needs: [gate]
13
+ act: atlas:gate-audit
14
+ may: owner
15
+ handed_over: ignored
16
+ fields: [kind, sha, pr, run_id, pin, checks]
@@ -0,0 +1,11 @@
1
+ # An Atlas job of the audit (roadmap R14). The watcher reads the newest gate
2
+ # run of each pull request. When the gate ended with an error, not a result,
3
+ # on three pull requests in a row, that is one finding. Its key is the oldest
4
+ # run of the streak, so a fourth error opens no second issue.
5
+ title: The gate gives a result
6
+ when: gate-errors
7
+ needs: [gate]
8
+ act: atlas:gate-errors
9
+ may: owner
10
+ handed_over: ignored
11
+ fields: [count, runs, prs]
@@ -0,0 +1,13 @@
1
+ # An Atlas job of the audit (roadmap R14). The watcher compares the gate
2
+ # files, the audit files and the merge-door hook of the main branch with the
3
+ # latest npm release. On a push, GitHub runs the pushed copy of a workflow,
4
+ # so a push that edits the audit hides from its own run. This job runs from
5
+ # the npm copy of Atlas, and finds it. It runs in the Atlas source repo only,
6
+ # until `atlas tooling` writes the gate workflows into other repos.
7
+ title: The gate files on the main branch are the released files
8
+ when: gate-files
9
+ needs: [gate]
10
+ act: atlas:gate-files
11
+ may: owner
12
+ handed_over: ignored
13
+ fields: [group, paths, count, release]
@@ -0,0 +1,303 @@
1
+ // The audit job (roadmap R14, design docs/design-docs/audit-job/). With no
2
+ // GitHub Pro, GitHub stops no bad merge (decision dd90d8e8). So the audit
3
+ // finds each break of the gate after it lands, and each finding becomes one
4
+ // `atlas-job` issue through `atlas jobs file`.
5
+ //
6
+ // 1. The commit audit (gate.mjs) records a push past the gate, a force push,
7
+ // a merge before green, and a gate run that is not from the base copy of
8
+ // its workflow (done lines 1 and 2). This file holds the gate run check.
9
+ // 2. `gateErrors`: three pull requests in a row whose newest gate run ended
10
+ // with no result (done line 5).
11
+ // 3. `checkGateFiles`: the watcher job `gate-files`. It compares the gate
12
+ // files, the audit files and the merge-door hook with the latest npm
13
+ // release (done line 4). It runs from the npm copy of Atlas, so an edit of
14
+ // the audit workflow cannot hide from it.
15
+ //
16
+ // A finding holds typed fields only: SHAs, numbers, run ids, paths and short
17
+ // codes. No text of a pull request or of a check run reaches an issue.
18
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
19
+ import { createHash } from 'node:crypto';
20
+ import { gunzipSync } from 'node:zlib';
21
+ import { dirname, join, resolve } from 'node:path';
22
+
23
+ export const AUDIT_JOB = 'gate-audit';
24
+ export const ERRORS_JOB = 'gate-errors';
25
+ export const FILES_JOB = 'gate-files';
26
+ export const GATE_STEP = 'Gate check';
27
+ export const GATE_ERROR_TITLE = 'atlas gate error';
28
+ export const ERROR_STREAK = 3;
29
+ export const PACKAGE_NAME = '@arjunkhera/atlas';
30
+
31
+ // A check name becomes a short code: letters, digits and hyphens only.
32
+ export const slug = (name) => String(name).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'check';
33
+
34
+ // The pin that a refused gate run broke, as a short code. The words come from
35
+ // `refusal` in gate.mjs.
36
+ export function pinCode(why) {
37
+ const text = String(why ?? '');
38
+ if (/^no workflow run/.test(text)) return 'no-run';
39
+ if (/not a base file/.test(text)) return 'file';
40
+ if (/^from the event/.test(text)) return 'event';
41
+ if (/^of another head commit/.test(text)) return 'head';
42
+ if (/^of another pull request or head/.test(text)) return 'title';
43
+ if (/^not of #/.test(text)) return 'pull';
44
+ if (/^also of a pull request|head branch also had/.test(text)) return 'side';
45
+ if (/no head branch/.test(text)) return 'no-branch';
46
+ return 'other';
47
+ }
48
+
49
+ // ------------------------------------------------------------ the findings file
50
+
51
+ // The findings file is the envelope of `atlas jobs check`, so `atlas jobs file`
52
+ // reads it as it reads the watcher's. Each audit call adds its findings. A call
53
+ // that stopped with an error marks it broken, and the file job ends red.
54
+ export function keepFindings(file, found = [], { broken = false } = {}) {
55
+ const path = resolve(file);
56
+ let envelope = { jobs: 1, state: 'none', ran: [], notes: [], findings: [] };
57
+ if (existsSync(path)) {
58
+ try {
59
+ const have = JSON.parse(readFileSync(path, 'utf8'));
60
+ if (have?.jobs === 1 && Array.isArray(have.findings)) envelope = { ...envelope, ...have };
61
+ } catch { envelope.state = 'broken'; }
62
+ }
63
+ const keys = new Set(envelope.findings.map((f) => `${f.job}:${f.key}`));
64
+ for (const f of found) if (!keys.has(`${f.job}:${f.key}`)) { keys.add(`${f.job}:${f.key}`); envelope.findings.push(f); }
65
+ if (broken || envelope.state === 'broken') envelope.state = 'broken';
66
+ else envelope.state = envelope.findings.length ? 'found' : 'none';
67
+ mkdirSync(dirname(path), { recursive: true });
68
+ writeFileSync(path, `${JSON.stringify(envelope, null, 2)}\n`);
69
+ return envelope;
70
+ }
71
+
72
+ // ------------------------------------------------------------ line 5: gate errors
73
+
74
+ const TITLE = /^gate #(\d{1,9}) at ([0-9a-f]{40})$/;
75
+ const SKIPPED = new Set(['cancelled', 'skipped', 'action_required', 'stale']);
76
+
77
+ // Did one finished gate run end with a result (success, owner or red), or
78
+ // with an error? An error is a run that timed out or never started, a failed
79
+ // step before the check, or a check that stopped and wrote the error note.
80
+ // A failed check with no note is a result: runs from before the note have
81
+ // none, so they never read as errors.
82
+ export function gateRunState(gh, repo, run) {
83
+ if (run.conclusion === 'success') return 'result';
84
+ if (run.conclusion === 'timed_out' || run.conclusion === 'startup_failure') return 'error';
85
+ if (run.conclusion !== 'failure') return 'result';
86
+ const job = (gh('GET', `repos/${repo}/actions/runs/${run.id}/jobs`)?.jobs ?? []).find((each) => each.name === 'gate');
87
+ if (!job) return 'error';
88
+ const failed = (job.steps ?? []).find((step) => step.conclusion === 'failure');
89
+ if (failed && failed.name !== GATE_STEP) return 'error';
90
+ const notes = gh('GET', `repos/${repo}/check-runs/${job.id}/annotations`) ?? [];
91
+ return (Array.isArray(notes) ? notes : []).some((note) => note?.title === GATE_ERROR_TITLE) ? 'error' : 'result';
92
+ }
93
+
94
+ // `atlas gate audit --errors`: the newest gate run of each pull request, newest
95
+ // first. When the first `streak` of them all ended with an error, that is one
96
+ // finding. Its key is the oldest run of the streak, so a fourth error keeps
97
+ // the key and opens no second issue.
98
+ export function gateErrors({ repo, found = [] }, line, gh, streak = ERROR_STREAK) {
99
+ const runs = gh('GET', `repos/${repo}/actions/workflows/atlas-gate.yml/runs?event=pull_request_target&status=completed&per_page=100`)?.workflow_runs ?? [];
100
+ const newest = new Map();
101
+ for (const run of runs) {
102
+ const title = TITLE.exec(String(run?.display_title ?? ''));
103
+ if (!title || SKIPPED.has(run.conclusion) || run.status !== 'completed') continue;
104
+ const have = newest.get(title[1]);
105
+ if (!have || Date.parse(run.created_at) > Date.parse(have.created_at)) newest.set(title[1], { ...run, pr: Number(title[1]) });
106
+ }
107
+ const order = [...newest.values()].sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at));
108
+ const errors = [];
109
+ for (const run of order) {
110
+ if (gateRunState(gh, repo, run) !== 'error') break;
111
+ errors.push(run);
112
+ }
113
+ if (errors.length < streak) {
114
+ line(`- atlas gate audit: the newest gate run of ${order.length ? `#${order[0].pr}` : 'no pull request'} ${errors.length ? `and ${errors.length - 1} more ended with an error; fewer than ${streak} in a row` : 'ended with a result'}.`);
115
+ return 0;
116
+ }
117
+ const shown = errors.slice(0, 10);
118
+ line(`- ✗ the gate errored on ${errors.length} pull request(s) in a row: ${shown.map((run) => `#${run.pr} run ${run.id}`).join(', ')}`);
119
+ line('GATE ERRORS: the gate gives no result. Look at its runs now.');
120
+ const oldest = errors.at(-1);
121
+ found.push({ job: ERRORS_JOB, key: `streak-${oldest.id}`, count: errors.length, runs: shown.map((run) => run.id).join('+'), prs: shown.map((run) => run.pr).join('+') });
122
+ return 1;
123
+ }
124
+
125
+ // ------------------------------------------------------------ line 4: the files
126
+
127
+ // Each group: the path in the repo, and the path in the npm package. The
128
+ // workflows ship as copies in door/workflows/, which a test keeps byte
129
+ // identical to the repo copies. So a change to a gate workflow is a package
130
+ // change, and it releases.
131
+ export const GATE_FILE_GROUPS = Object.freeze([
132
+ { group: 'gate', files: [
133
+ ['.github/workflows/atlas-gate.yml', 'door/workflows/atlas-gate.yml'],
134
+ ['packages/atlas/door/lib/gate.mjs', 'door/lib/gate.mjs'],
135
+ ['packages/atlas/door/lib/gate-check.mjs', 'door/lib/gate-check.mjs'],
136
+ ['packages/atlas/door/lib/owner-only.mjs', 'door/lib/owner-only.mjs'],
137
+ ['packages/atlas/owner-only.yaml', 'owner-only.yaml'],
138
+ ] },
139
+ { group: 'audit', files: [
140
+ ['.github/workflows/atlas-gate-audit.yml', 'door/workflows/atlas-gate-audit.yml'],
141
+ ['packages/atlas/door/lib/gate-audit.mjs', 'door/lib/gate-audit.mjs'],
142
+ ['packages/atlas/door/lib/jobs.mjs', 'door/lib/jobs.mjs'],
143
+ ['packages/atlas/door/lib/issues.mjs', 'door/lib/issues.mjs'],
144
+ ['packages/atlas/door/cli.mjs', 'door/cli.mjs'],
145
+ ['packages/atlas/bin/atlas', 'bin/atlas'],
146
+ ['packages/atlas/shape/check.mjs', 'shape/check.mjs'],
147
+ ['packages/atlas/work/lib/child-env.mjs', 'work/lib/child-env.mjs'],
148
+ ] },
149
+ { group: 'door-hook', files: [
150
+ ['packages/atlas/hooks/door.mjs', 'hooks/door.mjs'],
151
+ ['packages/atlas/hooks/hooks.json', 'hooks/hooks.json'],
152
+ ] },
153
+ ]);
154
+
155
+ // The files of an npm tarball (gzip of a ustar archive), by their path under
156
+ // `package/`. It reads plain files only, and the long names of pax and GNU.
157
+ export const MAX_TARBALL = 50 * 1024 * 1024;
158
+ export function untar(gz, wanted = null) {
159
+ const bytes = gunzipSync(gz, { maxOutputLength: MAX_TARBALL });
160
+ const files = new Map();
161
+ const text = (block, start, length) => block.subarray(start, start + length).toString('utf8').replace(/\0[\s\S]*$/, '');
162
+ let at = 0;
163
+ let long = null;
164
+ while (at + 512 <= bytes.length) {
165
+ const head = bytes.subarray(at, at + 512);
166
+ if (head.every((byte) => byte === 0)) break;
167
+ const size = parseInt(text(head, 124, 12).trim() || '0', 8);
168
+ if (!Number.isFinite(size) || size < 0) throw new Error('the tarball has a bad header');
169
+ const type = head[156] === 0 ? '0' : String.fromCharCode(head[156]);
170
+ const prefix = text(head, 345, 155);
171
+ let name = prefix ? `${prefix}/${text(head, 0, 100)}` : text(head, 0, 100);
172
+ const body = bytes.subarray(at + 512, at + 512 + size);
173
+ at += 512 + Math.ceil(size / 512) * 512;
174
+ if (type === 'x') { const path = /(?:^|\n)\d+ path=([^\n]*)\n/.exec(body.toString('utf8')); long = path ? path[1] : null; continue; }
175
+ if (type === 'L') { long = body.toString('utf8').replace(/\0[\s\S]*$/, ''); continue; }
176
+ if (long) { name = long; long = null; }
177
+ // Plain files only: no link, no path that climbs out, and only the named paths when a list is given.
178
+ const path = name.replace(/^\.\//, '').replace(/^package\//, '');
179
+ if ((type === '0' || type === '7') && !path.split('/').includes('..') && (!wanted || wanted.has(path))) files.set(path, Buffer.from(body));
180
+ }
181
+ return files;
182
+ }
183
+
184
+ export const integrityOf = (bytes) => `sha512-${createHash('sha512').update(bytes).digest('base64')}`;
185
+
186
+ function registryBase(env) {
187
+ const text = String(env.ATLAS_NPM_REGISTRY || 'https://registry.npmjs.org');
188
+ let url;
189
+ try { url = new URL(text); } catch { throw Object.assign(new Error('ATLAS_NPM_REGISTRY is not a URL'), { usage: true }); }
190
+ const local = url.hostname === '127.0.0.1' || url.hostname === 'localhost';
191
+ if (!(url.protocol === 'https:' || (url.protocol === 'http:' && local))) throw Object.assign(new Error('ATLAS_NPM_REGISTRY must be https'), { usage: true });
192
+ return url;
193
+ }
194
+
195
+ // The latest release on npm: its version and its files. The tarball must come
196
+ // from the registry's own origin and match the sha512 that npm records.
197
+ export async function latestRelease({ env = process.env, fetchImpl = fetch, wanted = null } = {}) {
198
+ const base = registryBase(env);
199
+ const meta = await fetchImpl(new URL(`${base.pathname.replace(/\/$/, '')}/${PACKAGE_NAME.replace('/', '%2f')}/latest`, base), { redirect: 'error' });
200
+ if (!meta.ok) throw new Error(`npm answered ${meta.status} when the latest release was read`);
201
+ const doc = await meta.json();
202
+ const version = String(doc?.version ?? '');
203
+ const tarball = String(doc?.dist?.tarball ?? '');
204
+ const integrity = String(doc?.dist?.integrity ?? '');
205
+ if (!/^\d+\.\d+\.\d+$/.test(version) || !/^sha512-/.test(integrity)) throw new Error('npm gave no version or integrity for the latest release');
206
+ let url;
207
+ try { url = new URL(tarball); } catch { throw new Error('npm gave no tarball URL'); }
208
+ if (url.origin !== base.origin) throw new Error('the tarball is not on the registry origin');
209
+ const got = await fetchImpl(url, { redirect: 'error' });
210
+ if (!got.ok) throw new Error(`npm answered ${got.status} when the tarball was read`);
211
+ const bytes = Buffer.from(await got.arrayBuffer());
212
+ if (bytes.length > MAX_TARBALL) throw new Error('the tarball is too large');
213
+ if (integrityOf(bytes) !== integrity) throw new Error(`the tarball of ${version} does not match the integrity npm records`);
214
+ return { version, files: untar(bytes, wanted) };
215
+ }
216
+
217
+ // The Atlas source repo: its package folder holds the package that ships.
218
+ export function isAtlasSource(root) {
219
+ try { return JSON.parse(readFileSync(join(root, 'packages/atlas/package.json'), 'utf8'))?.name === PACKAGE_NAME; } catch { return false; }
220
+ }
221
+
222
+ // The paths of each group that differ from the release. A file that is
223
+ // missing on either side differs.
224
+ export function compareGroups(root, files, groups = GATE_FILE_GROUPS) {
225
+ const out = [];
226
+ for (const { group, files: pairs } of groups) {
227
+ const differ = [];
228
+ for (const [repoPath, packagePath] of pairs) {
229
+ const mine = join(root, repoPath);
230
+ const theirs = files.get(packagePath);
231
+ if (!existsSync(mine) || !theirs || !readFileSync(mine).equals(theirs)) differ.push(repoPath);
232
+ }
233
+ if (differ.length) out.push({ group, paths: differ });
234
+ }
235
+ return out;
236
+ }
237
+
238
+ // The watcher check of the job `gate-files`. A finding names the files by
239
+ // their last part, so the field stays short; the run log names the full paths.
240
+ export async function checkGateFiles({ root, env = process.env, fetchImpl = fetch }) {
241
+ if (!root || !isAtlasSource(root)) {
242
+ return { findings: [], notes: ['this repo is not the Atlas source; its gate files come with `atlas tooling` later (roadmap R7 line 5)'] };
243
+ }
244
+ const wanted = new Set(GATE_FILE_GROUPS.flatMap(({ files }) => files.map(([, path]) => path)));
245
+ const { version, files } = await latestRelease({ env, fetchImpl, wanted });
246
+ const groups = compareGroups(root, files);
247
+ // The key holds a hash of the repo bytes that differ, so a second edit of
248
+ // the same group opens its own issue.
249
+ const hashOf = (paths) => createHash('sha256').update(paths.map((path) => (existsSync(join(root, path)) ? readFileSync(join(root, path)) : Buffer.from(`missing:${path}`))).reduce((all, one) => Buffer.concat([all, one]), Buffer.alloc(0))).digest('hex').slice(0, 12);
250
+ const findings = groups.map(({ group, paths }) => ({ job: FILES_JOB, key: `${group}-${hashOf(paths)}`, group, paths: paths.map((path) => path.split('/').at(-1)).join('+'), count: paths.length, release: version }));
251
+ return { findings, notes: [`compared with ${PACKAGE_NAME}@${version}`, ...groups.map(({ group, paths }) => `${group} differs: ${paths.join(', ')}`)] };
252
+ }
253
+
254
+ // ------------------------------------------------------------ the daily audit
255
+
256
+ export const AUDIT_DAYS = 2;
257
+ export const MAX_COMMITS = 100;
258
+
259
+ // The first-parent commits of the branch in the last `days` days, newest
260
+ // first, read from GitHub. The watcher runs from the npm copy of Atlas, so a
261
+ // push that edits the repo's own audit cannot change this walk.
262
+ export function windowCommits(gh, repo, branch, since, max = MAX_COMMITS) {
263
+ const out = [];
264
+ let commit = gh('GET', `repos/${repo}/commits/${encodeURIComponent(branch)}`);
265
+ while (commit?.sha && out.length < max) {
266
+ const at = commit.commit?.committer?.date ?? commit.commit?.author?.date;
267
+ if (!at || at < since) break;
268
+ out.push(commit.sha);
269
+ const parent = commit.parents?.[0]?.sha;
270
+ commit = parent ? gh('GET', `repos/${repo}/commits/${parent}`) : null;
271
+ }
272
+ return out;
273
+ }
274
+
275
+ // The force pushes to the branch in the window, from the activity of its ref.
276
+ export function forcePushes(gh, repo, branch, since) {
277
+ const rows = gh('GET', `repos/${repo}/activity?ref=${encodeURIComponent(`refs/heads/${branch}`)}&activity_type=force_push&per_page=100`) ?? [];
278
+ return (Array.isArray(rows) ? rows : []).filter((row) => /^[0-9a-f]{40}$/.test(String(row?.after ?? '')) && String(row?.timestamp ?? '') >= since);
279
+ }
280
+
281
+ // The watcher check of the job `gate-audit`: the commit audit of this Atlas
282
+ // copy over the window, and each force push. `audit` is commitAudit, passed
283
+ // in so this file needs no import of gate.mjs.
284
+ export function checkGateAudit({ repo, branch, gh, audit, now = new Date(), days = AUDIT_DAYS }) {
285
+ const since = new Date(now.getTime() - days * 864e5).toISOString();
286
+ const found = [];
287
+ const notes = [];
288
+ const forced = new Set(forcePushes(gh, repo, branch, since).map((row) => row.after));
289
+ const commits = windowCommits(gh, repo, branch, since);
290
+ for (const sha of commits) audit({ repo, commit: sha, forced: forced.has(sha), found }, (text) => { if (/^- ✗/.test(text)) notes.push(text.slice(2, 300)); }, gh);
291
+ // A force push whose new tip is no longer on the branch still counts.
292
+ for (const sha of forced) if (!commits.includes(sha)) found.push({ job: AUDIT_JOB, key: `force-push:${sha}`, kind: 'force-push', sha });
293
+ notes.unshift(`audited ${commits.length} commit(s) of ${branch} since ${since.slice(0, 16)}Z, and ${forced.size} force push(es)`);
294
+ return { findings: found, notes };
295
+ }
296
+
297
+ // The watcher check of the job `gate-errors`.
298
+ export function checkGateErrors({ repo, gh }) {
299
+ const found = [];
300
+ const notes = [];
301
+ gateErrors({ repo, found }, (text) => notes.push(text.replace(/^- /, '').slice(0, 300)), gh);
302
+ return { findings: found, notes };
303
+ }
@@ -363,6 +363,10 @@ export function checkCommand({ root, base, head, body: bodyFile, json = false, s
363
363
  return answer.result === 'success' ? 0 : 1;
364
364
  } catch (error) {
365
365
  line(`gate check: ${error.message}`);
366
+ // In the gate workflow (it passes --output), an error leaves a note with a
367
+ // fixed title. The audit reads it to tell an error from a result (roadmap
368
+ // R14 done line 5).
369
+ if (output && output !== true) line('::error title=atlas gate error::the gate check stopped before a result. Read the log of this step.');
366
370
  return error.usage ? 2 : 1;
367
371
  }
368
372
  }
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 { keepFindings, gateErrors, pinCode, slug, AUDIT_JOB } from './gate-audit.mjs';
24
25
  import { MARKER as SIGNED_MARKER } from './signed-act.mjs';
25
26
  import { brakeFacts, windowCommits, ciTrips, BrakeError } from './brake.mjs';
26
27
 
@@ -248,7 +249,10 @@ export const gateRunTitle = (pr, sha) => `gate #${pr} at ${sha}`;
248
249
  // name this pull request and this head. The probe of 26 October (done line
249
250
  // 15, atlas#211) showed that its run and its check run sit on the head commit,
250
251
  // like the others.
251
- export function refusal(run, { name, pr, sha, baseRef, homes, sides = () => [] }) {
252
+ // The audit reads a merged pull request. GitHub then lists no pull request
253
+ // on its runs, so with `audit` the two pull request pins are left out; the
254
+ // file, the event, the head and the title still hold.
255
+ export function refusal(run, { name, pr, sha, baseRef, homes, sides = () => [], audit = false }) {
252
256
  if (!run) return 'no workflow run';
253
257
  if (!homes.get(name)?.has(run.path)) return `from ${run.path ?? 'no file'}, not a base file that defines the check`;
254
258
  const event = name === GATE_CHECK ? 'pull_request_target' : 'pull_request';
@@ -256,11 +260,11 @@ export function refusal(run, { name, pr, sha, baseRef, homes, sides = () => [] }
256
260
  if (run.head_sha !== sha) return 'of another head commit';
257
261
  if (name === GATE_CHECK && run.display_title !== gateRunTitle(pr, sha)) return 'of another pull request or head';
258
262
  const pulls = run.pull_requests ?? [];
259
- if (!pulls.some((each) => Number(each.number) === Number(pr) && each.base?.ref === baseRef)) return `not of #${pr} into ${baseRef}`;
263
+ if (!audit && !pulls.some((each) => Number(each.number) === Number(pr) && each.base?.ref === baseRef)) return `not of #${pr} into ${baseRef}`;
260
264
  // GitHub lists every open pull request with this head, so a run made for a
261
265
  // pull request into a side branch can also name this one. Such a run used
262
266
  // the side branch's file, so it does not count.
263
- if (pulls.some((each) => each.base?.ref !== baseRef)) return `also of a pull request into ${pulls.find((each) => each.base?.ref !== baseRef).base?.ref ?? 'another branch'}`;
267
+ if (!audit && pulls.some((each) => each.base?.ref !== baseRef)) return `also of a pull request into ${pulls.find((each) => each.base?.ref !== baseRef).base?.ref ?? 'another branch'}`;
264
268
  // GitHub builds that list when it is read, from the open pull requests of
265
269
  // the head branch. Once a side pull request closes, its run names only this
266
270
  // one. So no run counts when the head branch ever had a pull request into
@@ -287,30 +291,59 @@ export function jobRefusal(job, check, run) {
287
291
  return null;
288
292
  }
289
293
 
290
- // The check runs that may count for this pull request, and the ones refused.
291
- export function pinnedRuns(gh, repo, { pr, sha, baseRef, head, checks }) {
292
- const homes = homesAt(gh, repo, baseRef, checks);
293
- // Every pull request, open or closed, from the head branch into another
294
- // branch. Read once, when the first run needs it.
294
+ // Every pull request, open or closed, from the head branch into another
295
+ // branch. Read once, and only when a gate run needs it.
296
+ function sidesOf(gh, repo, head, baseRef) {
295
297
  let sideList;
296
- const sides = () => {
298
+ return () => {
297
299
  if (sideList !== undefined) return sideList;
298
300
  const owner = head?.repo?.owner?.login ?? head?.user?.login;
299
301
  if (!head?.ref || !owner) return (sideList = null);
300
302
  const all = gh('GET', `repos/${repo}/pulls?head=${encodeURIComponent(`${owner}:${head.ref}`)}&state=all&per_page=100`) ?? [];
301
303
  return (sideList = all.filter((each) => each.base?.ref !== baseRef));
302
304
  };
305
+ }
306
+
307
+ // The workflow run of each check suite, read once.
308
+ function suiteRuns(gh, repo) {
303
309
  const suites = new Map();
304
- const runOf = (id) => {
310
+ return (id) => {
305
311
  if (!suites.has(id)) suites.set(id, id ? (gh('GET', `repos/${repo}/actions/runs?check_suite_id=${id}`)?.workflow_runs ?? [])[0] ?? null : null);
306
312
  return suites.get(id);
307
313
  };
314
+ }
315
+
316
+ // R14 done line 2: each gate run on the head of a merged pull request must
317
+ // come from the base copy of the gate workflow, on pull_request_target, with
318
+ // the title that names this pull request and head. Returns the refused runs.
319
+ export function gateCopyRefusals(gh, repo, pull) {
320
+ const sha = pull.head?.sha;
321
+ const baseRef = pull.base?.ref;
322
+ const homes = new Map([[GATE_CHECK, new Set([GATE_WORKFLOW])]]);
323
+ const sides = sidesOf(gh, repo, pull.head, baseRef);
324
+ const runOf = suiteRuns(gh, repo);
325
+ const refused = [];
326
+ for (const check of checkRuns(gh, repo, sha)) {
327
+ if (check.name !== GATE_CHECK || check.app?.id !== ACTIONS_APP_ID) continue;
328
+ const run = runOf(check.check_suite?.id);
329
+ const why = refusal(run, { name: GATE_CHECK, pr: pull.number, sha, baseRef, homes, sides, audit: true })
330
+ ?? jobRefusal(check.id === undefined ? null : gh('GET', `repos/${repo}/actions/jobs/${check.id}`), check, run);
331
+ if (why) refused.push({ run: run?.id ?? null, why });
332
+ }
333
+ return refused;
334
+ }
335
+
336
+ // The check runs that may count for this pull request, and the ones refused.
337
+ export function pinnedRuns(gh, repo, { pr, sha, baseRef, head, checks, homesRef = baseRef, audit = false }) {
338
+ const homes = homesAt(gh, repo, homesRef, checks);
339
+ const sides = sidesOf(gh, repo, head, baseRef);
340
+ const runOf = suiteRuns(gh, repo);
308
341
  const kept = [];
309
342
  const refused = [];
310
343
  for (const check of checkRuns(gh, repo, sha)) {
311
344
  if (!checks.includes(check.name) || check.app?.id !== ACTIONS_APP_ID) continue;
312
345
  const run = runOf(check.check_suite?.id);
313
- const why = refusal(run, { name: check.name, pr, sha, baseRef, homes, sides })
346
+ const why = refusal(run, { name: check.name, pr, sha, baseRef, homes, sides, audit })
314
347
  ?? jobRefusal(check.id === undefined ? null : gh('GET', `repos/${repo}/actions/jobs/${check.id}`), check, run);
315
348
  if (why) refused.push({ name: check.name, why }); else kept.push(check);
316
349
  }
@@ -389,8 +422,23 @@ export function keepReady({ repo, pr, sha, code, home = homedir(), now = new Dat
389
422
 
390
423
  // `atlas gate audit --since <date>`: each pull request merged since the date
391
424
  // whose required checks were not all green at the moment of the merge.
392
- export function auditCommand({ repo, since, commit }, line, gh = ghApi) {
393
- if (commit !== undefined) return commitAudit({ repo, commit }, line, gh);
425
+ export function auditCommand({ repo, since, commit, errors = false, forced = false, findings }, line, gh = ghApi) {
426
+ // R14: with --findings, each finding also goes into the file, and `atlas
427
+ // jobs file` opens one issue for each. A call that stops with an error
428
+ // marks the file broken, so the file job ends red.
429
+ const found = [];
430
+ const keep = (broken = false) => { if (findings && findings !== true) keepFindings(findings, found, { broken }); };
431
+ try {
432
+ let code;
433
+ if (errors) code = gateErrors({ repo, found }, line, gh);
434
+ else if (commit !== undefined) code = commitAudit({ repo, commit, forced, found }, line, gh);
435
+ else code = sinceAudit({ repo, since }, line, gh);
436
+ keep();
437
+ return code;
438
+ } catch (error) { keep(true); throw error; }
439
+ }
440
+
441
+ function sinceAudit({ repo, since }, line, gh) {
394
442
  const from = since && since !== true ? since : new Date(Date.now() - 864e5).toISOString().slice(0, 10);
395
443
  if (Number.isNaN(Date.parse(from))) throw new GateError(`--since must be a date, not "${from}".`);
396
444
  const closed = gh('GET', `repos/${repo}/pulls?state=closed&sort=updated&direction=desc&per_page=100`) ?? [];
@@ -420,34 +468,55 @@ export function auditCommand({ repo, since, commit }, line, gh = ghApi) {
420
468
 
421
469
  // `atlas gate audit --commit <sha>`: one commit that reached the main branch.
422
470
  // The push job runs it on each push. A commit is good when a pull request
423
- // merged it after that pull request's required checks were green. A commit
424
- // that no pull request merged was pushed past the gate, so it is red.
425
- function commitAudit({ repo, commit }, line, gh) {
471
+ // merged it after that pull request's required checks were green, and each
472
+ // gate run of that pull request came from the base copy of the gate
473
+ // workflow. A commit that no pull request merged was pushed past the gate, so
474
+ // it is red. With --forced, the push that brought it was a force push.
475
+ export function commitAudit({ repo, commit, forced = false, found = [] }, line, gh) {
426
476
  if (!commit || commit === true || !/^[0-9a-f]{7,40}$/i.test(String(commit))) throw new GateError('--commit needs a commit SHA.');
427
477
  // GitHub expands a short SHA, and names the first parent: the main branch
428
478
  // just before this commit, whose atlas.yaml held the gate at that moment.
429
- const found = gh('GET', `repos/${repo}/commits/${commit}`);
430
- const sha = found?.sha;
479
+ const fetched = gh('GET', `repos/${repo}/commits/${commit}`);
480
+ const sha = fetched?.sha;
431
481
  if (!sha) throw new GateError(`there is no commit ${commit} in ${repo}.`);
482
+ let red = false;
483
+ if (forced) {
484
+ red = true;
485
+ line(`- ✗ ${sha.slice(0, 7)} came to the main branch with a force push.`);
486
+ found.push({ job: AUDIT_JOB, key: `force-push:${sha}`, kind: 'force-push', sha });
487
+ }
432
488
  const pull = (gh('GET', `repos/${repo}/commits/${sha}/pulls`) ?? []).find((each) => each.merged_at && each.merge_commit_sha === sha);
433
489
  if (!pull) {
434
490
  line(`- ✗ ${sha.slice(0, 7)} reached the main branch with no pull request: a push past the gate.`);
435
491
  line('RED PUSH: look at it now; the main branch may hold an untested change.');
492
+ found.push({ job: AUDIT_JOB, key: `push:${sha}`, kind: 'push', sha });
436
493
  return 1;
437
494
  }
438
- const parent = found.parents?.[0]?.sha ?? pull.base.sha;
495
+ for (const one of gateCopyRefusals(gh, repo, pull)) {
496
+ red = true;
497
+ line(`- ✗ #${pull.number}: a gate run is not from the base copy of ${GATE_WORKFLOW}: run ${one.run ?? 'none'} ${one.why}`);
498
+ found.push({ job: AUDIT_JOB, key: `gate-copy:${sha}`, kind: 'gate-copy', sha, pr: Number(pull.number), ...(one.run ? { run_id: Number(one.run) } : {}), pin: pinCode(one.why) });
499
+ }
500
+ const parent = fetched.parents?.[0]?.sha ?? pull.base.sha;
439
501
  let checks;
440
502
  try { checks = checksAt(gh, repo, parent); } catch (error) {
441
503
  if (!(error instanceof GateError)) throw error;
442
504
  line(`- atlas gate audit: ${sha.slice(0, 7)} is the merge of #${pull.number}; atlas.yaml before it listed no required_checks, so nothing to audit.`);
443
- return 0;
505
+ return red ? 1 : 0;
444
506
  }
445
- const bad = checkStates(checks, checkRuns(gh, repo, pull.head.sha), pull.merged_at).filter((row) => row.state !== 'green');
507
+ // A required check counts only from a run of a base file that defines it,
508
+ // as `atlas gate ready` reads it. So a head workflow with a job named like
509
+ // a required check does not make a merge look green.
510
+ const { kept, refused } = pinnedRuns(gh, repo, { pr: pull.number, sha: pull.head.sha, baseRef: pull.base?.ref, head: pull.head, checks, homesRef: parent, audit: true });
511
+ const bad = checkStates(checks, kept, pull.merged_at).filter((row) => row.state !== 'green');
446
512
  if (bad.length) {
447
513
  line(`- ✗ #${pull.number} merged ${pull.merged_at} before its checks were green: ${bad.map((row) => `${row.name} ${row.state}`).join(', ')}`);
514
+ for (const one of refused) line(` - ignored a run of ${one.name}: ${one.why}`);
448
515
  line('RED MERGES: 1. Look at it now; the main branch may hold an untested change.');
516
+ found.push({ job: AUDIT_JOB, key: `merge-before-green:${sha}`, kind: 'merge-before-green', sha, pr: Number(pull.number), checks: bad.map((row) => slug(row.name)).join('+').slice(0, 200) });
449
517
  return 1;
450
518
  }
519
+ if (red) return 1;
451
520
  line(`- atlas gate audit: ${sha.slice(0, 7)} is the merge of #${pull.number}, after its ${checks.length} required check(s) were green.`);
452
521
  return 0;
453
522
  }
@@ -537,7 +606,7 @@ export function brakeRead({ root, base, now = new Date() }, gh, repo, pages = 3)
537
606
  }
538
607
 
539
608
  // `atlas gate apply|ready|audit|check|replay`. Returns the exit code.
540
- export function gateCommand({ what, repo, root, dryRun = false, off = false, on = false, pr, since, commit, base, baseRef, head, body, json = false, summary, output, days, branch, home = homedir() }, line, gh = ghApi) {
609
+ export function gateCommand({ what, repo, root, dryRun = false, off = false, on = false, pr, since, commit, base, baseRef, head, body, json = false, summary, output, days, branch, errors = false, forced = false, findings, home = homedir() }, line, gh = ghApi) {
541
610
  if (!['apply', 'ready', 'audit', 'check', 'replay'].includes(what)) throw new GateError(`there is no gate verb "${what ?? ''}". Use apply, ready, audit, check or replay.`);
542
611
  // With --repo, check reads the red main checks from GitHub; without, it judges git data alone.
543
612
  const named = repo && repo !== true && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) && !repo.split('/').some((part) => /^\.+$/.test(part));
@@ -560,7 +629,7 @@ export function gateCommand({ what, repo, root, dryRun = false, off = false, on
560
629
  }
561
630
  return code;
562
631
  }
563
- if (what === 'audit') return auditCommand({ repo, since, commit }, line, gh);
632
+ if (what === 'audit') return auditCommand({ repo, since, commit, errors, forced, findings }, line, gh);
564
633
  if (off && on) throw new GateError('pick --off or --on, not both.');
565
634
  const mode = off ? 'off' : on ? 'on' : 'apply';
566
635
  const facts = mode === 'apply' ? gateFacts(root) : null;
package/door/lib/jobs.mjs CHANGED
@@ -25,6 +25,8 @@ import { dirname, join, resolve } from 'node:path';
25
25
  import { fileURLToPath } from 'node:url';
26
26
  import { parseYaml } from '../../shape/check.mjs';
27
27
  import { api, apiBase, repoOk, openIssues, TOKEN_SHAPE, DEFAULT_BOT, DEFAULT_MAX } from './issues.mjs';
28
+ import { checkGateFiles, checkGateAudit, checkGateErrors } from './gate-audit.mjs';
29
+ import { commitAudit, ghApi } from './gate.mjs';
28
30
 
29
31
  export const JOBS_LABEL = 'atlas-job';
30
32
  export const HANDED_OVER = 'handed-over';
@@ -107,9 +109,16 @@ export async function checkRedMain({ repo, branch, env }) {
107
109
 
108
110
  // The check of each Atlas job, by its `when`. A job whose check is not built yet reads `none` and
109
111
  // says so in the notes; it never reads `found` from nothing.
110
- const CHECKS = { 'red-main': checkRedMain };
112
+ // The gate jobs (roadmap R14) read GitHub through `gh`, which the Actions
113
+ // runner holds; their token is the run's own GITHUB_TOKEN.
114
+ const CHECKS = {
115
+ 'red-main': checkRedMain,
116
+ 'gate-files': ({ root, env, fetchImpl }) => checkGateFiles({ root, env, fetchImpl }),
117
+ 'gate-audit': ({ repo, branch, gh }) => checkGateAudit({ repo, branch, gh, audit: commitAudit }),
118
+ 'gate-errors': ({ repo, gh }) => checkGateErrors({ repo, gh }),
119
+ };
111
120
 
112
- export async function jobsCheck({ root, repo, branch = null, env = process.env, jobs = atlasJobs() }) {
121
+ export async function jobsCheck({ root, repo, branch = null, env = process.env, jobs = atlasJobs(), gh = ghApi, fetchImpl = fetch }) {
113
122
  const named = namedJobs(root);
114
123
  const notes = [];
115
124
  const ran = [];
@@ -123,7 +132,10 @@ export async function jobsCheck({ root, repo, branch = null, env = process.env,
123
132
  if (!check) { notes.push(`${id}: its check is not built yet`); ran.push({ job: id, state: 'none' }); continue; }
124
133
  try {
125
134
  if (!mainBranch) mainBranch = await defaultBranch({ repo, env });
126
- const found = (await check({ repo, branch: mainBranch, env })).map((f) => ({ ...f, job: id }));
135
+ // A check gives its findings, or { findings, notes }.
136
+ const got = await check({ repo, branch: mainBranch, env, root, gh, fetchImpl });
137
+ const found = (Array.isArray(got) ? got : got.findings).map((f) => ({ ...f, job: id }));
138
+ for (const note of Array.isArray(got) ? [] : got.notes ?? []) notes.push(`${id}: ${String(note).slice(0, 300)}`);
127
139
  findings.push(...found);
128
140
  ran.push({ job: id, state: found.length ? 'found' : 'none' });
129
141
  } catch (error) {
@@ -194,13 +206,22 @@ export function jobIssue(finding) {
194
206
  export const handedOverStops = (now = Date.now()) => (issue) => (issue.labels ?? []).some((l) => (typeof l === 'string' ? l : l?.name) === HANDED_OVER)
195
207
  && Number.isFinite(Date.parse(issue.closed_at)) && now - Date.parse(issue.closed_at) <= HANDED_OVER_DAYS * DAY;
196
208
 
209
+ // A job with `handed_over: ignored` never stops on a closed issue: anyone with
210
+ // the shared login can close an issue, so a gate finding comes back while its
211
+ // commit is in the window (roadmap R14).
212
+ export const closedStopsFor = (jobs, now = Date.now()) => (issue) => {
213
+ const job = (issue.labels ?? []).map((l) => (typeof l === 'string' ? l : l?.name)).find((name) => String(name).startsWith('job:'));
214
+ if (job && jobs.get(job.slice(4))?.handed_over === 'ignored') return false;
215
+ return handedOverStops(now)(issue);
216
+ };
217
+
197
218
  export async function fileJobs({ envelope, repo, dryRun = false, max = DEFAULT_MAX, bot = DEFAULT_BOT, env = process.env, line, now = Date.now(), jobs = atlasJobs() }) {
198
219
  const { findings, dropped } = typedFindings(envelope, jobs);
199
220
  if (dropped) line(`dropped ${dropped} finding(s) that are not typed, or that name no Atlas job`);
200
221
  if (envelope.state === 'broken') line('the check reads broken, so a check found nothing it can trust. The run ends red.');
201
222
  const list = findings.map((f) => ({ key: `${f.job}:${f.key}`, issue: jobIssue(f) }));
202
223
  const result = list.length
203
- ? await openIssues({ findings: list, repo, dryRun, max, bot, env, line, label: JOBS_LABEL, closedStops: handedOverStops(now) })
224
+ ? await openIssues({ findings: list, repo, dryRun, max, bot, env, line, label: JOBS_LABEL, closedStops: closedStopsFor(jobs, now) })
204
225
  : (line('no finding: nothing to file.'), { opened: [], skipped: [], deferred: [], failed: [] });
205
226
  return { ...result, broken: envelope.state === 'broken' };
206
227
  }
@@ -5,7 +5,9 @@ name: atlas-jobs
5
5
  # It runs once a day from the default branch, and never on a pull request, so
6
6
  # a pull request cannot change what it runs. It has two jobs:
7
7
  # check reads the repo and the CI runs of the default branch. It holds only
8
- # read permissions and the run's own token, and no repo secret.
8
+ # read permissions and the run's own token, and no repo secret. The
9
+ # gate jobs (gate-audit, gate-errors) also read pull requests and
10
+ # check runs, so the job reads those two as well.
9
11
  # file holds only issues write. It runs Atlas code on the JSON of the
10
12
  # check job, and files one `atlas-job` issue for each new finding.
11
13
  # A job does nothing until atlas.yaml names it under `jobs: { atlas: [...] }`.
@@ -28,6 +30,8 @@ jobs:
28
30
  permissions:
29
31
  contents: read
30
32
  actions: read
33
+ pull-requests: read
34
+ checks: read
31
35
  outputs:
32
36
  findings: ${{ steps.check.outputs.findings }}
33
37
  steps:
@@ -0,0 +1,128 @@
1
+ name: atlas-gate-audit
2
+ on:
3
+ push:
4
+ branches: [master]
5
+
6
+ # The push audit of the advisory gate (roadmap R14, design
7
+ # docs/design-docs/audit-job/). This plan gives the repo no rulesets, so
8
+ # GitHub merges and takes pushes with no check. This workflow looks at each
9
+ # commit that a push put on the main branch, along the first parent:
10
+ # 1. A commit that no pull request merged is a push past the gate.
11
+ # 2. A force push.
12
+ # 3. A merge made before the required checks of its pull request were
13
+ # green. A check counts only from a run of the base file that defines it.
14
+ # 4. A gate run that is not from the base copy of atlas-gate.yml.
15
+ # Each finding opens one `atlas-job` issue, and the run ends red. It does not
16
+ # undo the push.
17
+ #
18
+ # It runs the code of the pushed commit, so a push can edit it and hide. The
19
+ # watcher (atlas-jobs.yml) runs the npm copy of Atlas once a day. It audits
20
+ # the commits of the last two days again, and compares these files with the
21
+ # latest release. A test keeps this file byte identical to
22
+ # packages/atlas/door/workflows/atlas-gate-audit.yml.
23
+ #
24
+ # Two jobs, each with only what it needs:
25
+ # audit read rights only. It writes the findings as job output.
26
+ # file contents: read for the checkout, and issues: write. That is the
27
+ # only write right of this workflow.
28
+ permissions: {}
29
+
30
+ concurrency:
31
+ group: atlas-gate-audit-${{ github.sha }}
32
+ cancel-in-progress: false
33
+
34
+ jobs:
35
+ audit:
36
+ name: gate · push audit
37
+ if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch)
38
+ runs-on: ubuntu-latest
39
+ timeout-minutes: 10
40
+ permissions:
41
+ contents: read
42
+ pull-requests: read
43
+ checks: read
44
+ actions: read
45
+ outputs:
46
+ findings: ${{ steps.audit.outputs.findings }}
47
+ steps:
48
+ # actions/checkout v7.0.1
49
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
50
+ with:
51
+ fetch-depth: 0
52
+ persist-credentials: false
53
+ # actions/setup-node v7.0.0
54
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
55
+ with:
56
+ node-version: "22.22.0"
57
+
58
+ - name: Audit each commit of this push
59
+ id: audit
60
+ env:
61
+ GH_TOKEN: ${{ github.token }}
62
+ BEFORE: ${{ github.event.before }}
63
+ AFTER: ${{ github.event.after }}
64
+ FORCED: ${{ github.event.forced }}
65
+ REPO: ${{ github.repository }}
66
+ run: |
67
+ set -uo pipefail
68
+ out="$RUNNER_TEMP/findings.json"
69
+ # keep FILE [FROM]: add the findings of FROM to FILE, through Atlas code.
70
+ keep() { node --input-type=module -e 'import { keepFindings } from "./packages/atlas/door/lib/gate-audit.mjs"; import { readFileSync, existsSync } from "node:fs"; const [to, from] = process.argv.slice(1); const have = from && existsSync(from) ? JSON.parse(readFileSync(from, "utf8")) : { findings: [] }; keepFindings(to, have.findings ?? [], { broken: have.state === "broken" });' "$@"; }
71
+ keep "$out"
72
+ forced=()
73
+ if [ "$FORCED" = "true" ]; then forced=(--forced); fi
74
+ if [ -z "${BEFORE//0/}" ] || [ "$FORCED" = "true" ] || ! git cat-file -e "$BEFORE^{commit}" 2>/dev/null; then
75
+ commits="$AFTER"
76
+ else
77
+ commits=$(git rev-list --first-parent --reverse "$BEFORE..$AFTER")
78
+ fi
79
+ for sha in $commits; do
80
+ flag=()
81
+ if [ "$sha" = "$AFTER" ]; then flag=("${forced[@]}"); fi
82
+ # GitHub can take a moment to link a fresh merge to its pull
83
+ # request. So a "no pull request" answer gets one more read, with
84
+ # a fresh findings file.
85
+ if ! text=$(node packages/atlas/bin/atlas gate audit --repo "$REPO" --commit "$sha" "${flag[@]}" --findings "$out.$sha"); then
86
+ if grep -q '^RED PUSH' <<<"$text"; then
87
+ sleep 30
88
+ rm -f "$out.$sha"
89
+ text=$(node packages/atlas/bin/atlas gate audit --repo "$REPO" --commit "$sha" "${flag[@]}" --findings "$out.$sha")
90
+ fi
91
+ fi
92
+ printf '%s\n' "$text" | tee -a "$GITHUB_STEP_SUMMARY"
93
+ # A run that wrote no file stopped before it could: mark it broken.
94
+ if [ -s "$out.$sha" ]; then keep "$out" "$out.$sha"; else echo '{"jobs":1,"state":"broken","findings":[]}' > "$out.$sha"; keep "$out" "$out.$sha"; fi
95
+ done
96
+ echo "findings=$(base64 -w0 < "$out")" >> "$GITHUB_OUTPUT"
97
+
98
+ file:
99
+ name: gate · push audit issues
100
+ needs: audit
101
+ if: always() && needs.audit.outputs.findings != ''
102
+ runs-on: ubuntu-latest
103
+ timeout-minutes: 5
104
+ permissions:
105
+ contents: read
106
+ issues: write
107
+ steps:
108
+ # actions/checkout v7.0.1
109
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
110
+ with:
111
+ persist-credentials: false
112
+ # actions/setup-node v7.0.0
113
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
114
+ with:
115
+ node-version: "22.22.0"
116
+ - name: File one issue for each new finding
117
+ env:
118
+ GITHUB_TOKEN: ${{ github.token }}
119
+ FINDINGS: ${{ needs.audit.outputs.findings }}
120
+ run: |
121
+ set -euo pipefail
122
+ printf '%s' "$FINDINGS" | base64 -d > "$RUNNER_TEMP/findings.json"
123
+ node packages/atlas/bin/atlas jobs file --findings "$RUNNER_TEMP/findings.json" --repo "$GITHUB_REPOSITORY" | tee -a "$GITHUB_STEP_SUMMARY"
124
+ state=$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).state' "$RUNNER_TEMP/findings.json")
125
+ if [ "$state" != "none" ]; then
126
+ echo "::error::the push audit found a break of the gate (state $state). Read the issues it opened."
127
+ exit 1
128
+ fi
@@ -0,0 +1,142 @@
1
+ name: atlas-gate
2
+ run-name: "gate #${{ github.event.pull_request.number }} at ${{ github.event.pull_request.head.sha }}"
3
+ on:
4
+ pull_request_target:
5
+ types: [opened, synchronize, reopened, edited, ready_for_review]
6
+
7
+ # The gate (roadmap R6, design docs/design-docs/gate-check/). One job, named
8
+ # `gate`, ends as success, or as failure with "owner: class" or "red: reason"
9
+ # in its summary. It judges the pull request by the static rules: the
10
+ # owner-only list, secrets of a known shape, head jobs named like a check, and
11
+ # a test change for product code.
12
+ #
13
+ # It runs on pull_request_target, so GitHub runs this file as the base branch
14
+ # holds it, with the base code. The head is fetched as git objects only and is
15
+ # never checked out, built or run. atlas.yaml comes from the base side only.
16
+ # The title of the run names the pull request and the head; `atlas gate ready`
17
+ # reads it to pin the run (done line 8).
18
+ #
19
+ # The token can only read. No checkout keeps a credential: the one fetch of
20
+ # the head takes the token for that command alone. The job writes two output
21
+ # keys, `result` and `class`, each one word of a fixed set.
22
+ #
23
+ # The signed act (roadmap R72): the gate reads the comments of the pull
24
+ # request, and a valid signature of the owner for this head turns "owner" into
25
+ # success. The key comes from atlas/owner-signers on the base. A new comment
26
+ # with a signed act reruns this run (atlas-gate-signed.yml).
27
+ #
28
+ # Report mode: the gate is not in required_checks yet. It joins the list after
29
+ # the A1 exit test of the overhaul roadmap.
30
+ #
31
+ # The second job, `shape`, is the repo shape check from the base (roadmap R7,
32
+ # design docs/design-docs/shape-check-from-base/). It is the job that
33
+ # `atlas tooling` writes into every repo, word for word, and `atlas status`
34
+ # compares it with the package.
35
+ permissions:
36
+ contents: read
37
+ pull-requests: read
38
+ issues: read
39
+ checks: read
40
+ actions: read
41
+
42
+ concurrency:
43
+ group: atlas-gate-${{ github.event.pull_request.number }}
44
+ cancel-in-progress: true
45
+
46
+ jobs:
47
+ gate:
48
+ name: gate
49
+ runs-on: ubuntu-latest
50
+ timeout-minutes: 10
51
+ outputs:
52
+ result: ${{ steps.check.outputs.result }}
53
+ class: ${{ steps.check.outputs.class }}
54
+ steps:
55
+ # actions/checkout v7.0.1
56
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
57
+ with:
58
+ ref: ${{ github.sha }}
59
+ fetch-depth: 0
60
+ persist-credentials: false
61
+ # actions/setup-node v7.0.0
62
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
63
+ with:
64
+ node-version: "22.22.0"
65
+
66
+ - name: Fetch the head as objects only
67
+ env:
68
+ PR: ${{ github.event.pull_request.number }}
69
+ HEAD_SHA: ${{ github.event.pull_request.head.sha }}
70
+ TOKEN: ${{ github.token }}
71
+ run: |
72
+ set -euo pipefail
73
+ header="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$TOKEN" | base64 -w0)"
74
+ git -c "http.https://github.com/.extraheader=$header" fetch --no-tags --no-recurse-submodules origin "+refs/pull/$PR/head:refs/atlas-gate/head"
75
+ got="$(git rev-parse --verify refs/atlas-gate/head^{commit})"
76
+ if [ "$got" != "$HEAD_SHA" ]; then
77
+ echo "::error::the fetched head is $got, not the head of the event $HEAD_SHA. Push again."
78
+ exit 1
79
+ fi
80
+
81
+ - name: Read the pull request text from the event
82
+ run: node -e 'const fs=require("fs");const e=JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH,"utf8"));fs.writeFileSync(process.env.RUNNER_TEMP+"/body.md",String(e.pull_request.body??""))'
83
+
84
+ - name: Gate check
85
+ id: check
86
+ env:
87
+ PR: ${{ github.event.pull_request.number }}
88
+ BASE_REF: ${{ github.event.pull_request.base.ref }}
89
+ HEAD_SHA: ${{ github.event.pull_request.head.sha }}
90
+ GH_TOKEN: ${{ github.token }}
91
+ run: |
92
+ set -euo pipefail
93
+ node packages/atlas/bin/atlas gate check --root . --repo "$GITHUB_REPOSITORY" --base "$GITHUB_SHA" --head "$HEAD_SHA" --pr "$PR" --base-ref "$BASE_REF" --body "$RUNNER_TEMP/body.md" --summary "$GITHUB_STEP_SUMMARY" --output "$GITHUB_OUTPUT"
94
+
95
+ shape:
96
+ name: gate · shape
97
+ runs-on: ubuntu-latest
98
+ # The check reads two trees and opens no network. It finishes in seconds
99
+ # on a small repo. The bound stops a runner being billed until someone
100
+ # kills it; a run that hits it is red.
101
+ timeout-minutes: 10
102
+ outputs:
103
+ result: ${{ steps.shape.outputs.result }}
104
+ class: ${{ steps.shape.outputs.class }}
105
+ steps:
106
+ # actions/checkout v7.0.1
107
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
108
+ with:
109
+ ref: ${{ github.sha }}
110
+ fetch-depth: 0
111
+ persist-credentials: false
112
+ # actions/setup-node v7.0.0
113
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
114
+ with:
115
+ node-version: "22.22.0"
116
+ - name: Fetch the head as objects only
117
+ env:
118
+ PR: ${{ github.event.pull_request.number }}
119
+ HEAD_SHA: ${{ github.event.pull_request.head.sha }}
120
+ TOKEN: ${{ github.token }}
121
+ run: |
122
+ set -euo pipefail
123
+ header="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$TOKEN" | base64 -w0)"
124
+ git -c "http.https://github.com/.extraheader=$header" fetch --no-tags --no-recurse-submodules origin "+refs/pull/$PR/head:refs/atlas-gate/head"
125
+ got="$(git rev-parse --verify refs/atlas-gate/head^{commit})"
126
+ if [ "$got" != "$HEAD_SHA" ]; then
127
+ echo "::error::the fetched head is $got, not the head of the event $HEAD_SHA. Push again."
128
+ exit 1
129
+ fi
130
+ - name: Shape check from the base
131
+ id: shape
132
+ env:
133
+ HEAD_SHA: ${{ github.event.pull_request.head.sha }}
134
+ run: |
135
+ set -euo pipefail
136
+ if [ ! -f .github/atlas/check.mjs ]; then
137
+ echo "OWNER: first-run: the base has no .github/atlas/check.mjs, so the owner reads the first shape"
138
+ echo "result=owner" >> "$GITHUB_OUTPUT"
139
+ echo "class=first-run" >> "$GITHUB_OUTPUT"
140
+ exit 1
141
+ fi
142
+ node .github/atlas/check.mjs --gate --repo "$GITHUB_REPOSITORY" --base-commit "$GITHUB_SHA" --head-commit "$HEAD_SHA"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.4.22",
3
+ "version": "0.4.23",
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",
@@ -25,6 +25,29 @@ export const ROUNDUP_READ_VERBS = Object.freeze(['roundup']);
25
25
  // stage-2 is the stage 2 item or one of its parts.
26
26
  export const STAGE_TAG = /^stage-(\d{1,2})$/;
27
27
  export const STUCK_DAYS = 14;
28
+ // Roadmap R14 done line 6: the daily audit runs in the watcher workflow. A
29
+ // repo whose watcher has not run for this long is flagged in part 4.
30
+ export const AUDIT_WORKFLOW = 'atlas-jobs.yml';
31
+ export const AUDIT_STALE_HOURS = 26;
32
+ const HOUR = 3_600_000;
33
+
34
+ // The newest run of the daily audit in each repo. A repo with no such
35
+ // workflow (GitHub answers 404) has no audit, so it is left out. Any other
36
+ // failure is listed as unread, never as fresh.
37
+ export async function readAudits(repoIds, readGitHub, readAt, staleHours = AUDIT_STALE_HOURS) {
38
+ const stale = [], unread = [];
39
+ for (const repo of repoIds) {
40
+ let runs;
41
+ try { runs = (await readGitHub(`repos/${repo}/actions/workflows/${AUDIT_WORKFLOW}/runs?per_page=1`))?.workflow_runs; } catch (error) {
42
+ if (!/\b404\b|Not Found/i.test(String(error?.message ?? error))) unread.push({ repo, reason: oneLine(`the audit runs could not be read: ${error?.message ?? String(error)}`, 200) });
43
+ continue;
44
+ }
45
+ const last = Array.isArray(runs) && runs.length ? runs[0].created_at ?? null : null;
46
+ const hours = last ? Math.floor((Date.parse(readAt) - Date.parse(last)) / HOUR) : null;
47
+ if (hours === null || hours >= staleHours) stale.push({ repo, workflow: `.github/workflows/${AUDIT_WORKFLOW}`, last_run_at: last, hours });
48
+ }
49
+ return { stale, unread };
50
+ }
28
51
  export const TREND_WEEKS = 4;
29
52
  const DEFAULT_WINDOW = 7;
30
53
  const MAX_WINDOW = 90;
@@ -141,6 +164,7 @@ export function createRoundupVerbs(core) {
141
164
  const notMarked = (flight.merged_not_marked ?? []).map((row) => ({ item: row.item, pr: row.pr, title: row.title, days: row.days }));
142
165
  const openNoItem = pulls.filter((pull) => pull.state === 'open' && !owner.has(pull.url)).map((pull) => ({ pr: pull.url, title: pull.title, updated_at: pull.updated_at }));
143
166
  const longWaits = needRows.filter((row) => row.days_waiting !== null && row.days_waiting >= STUCK_DAYS);
167
+ const audits = await readAudits(repoIds, readGitHub, readAt);
144
168
 
145
169
  // 5. Trend, per week; week 1 is the last seven days.
146
170
  const weeks = Array.from({ length: TREND_WEEKS }, (_, index) => ({ week: index + 1, from: dayOf(minus(readAt, (index + 1) * 7)), to: dayOf(minus(readAt, index * 7)), merged: 0, delivered: 0, new_items: 0 }));
@@ -150,13 +174,13 @@ export function createRoundupVerbs(core) {
150
174
  for (const row of items) bump(row.created_at, 'new_items');
151
175
  const oldest = needRows.reduce((max, row) => Math.max(max, row.days_waiting ?? 0), 0);
152
176
 
153
- const text = render({ product, readAt, windowDays, needRows, ladder, lifecycles, active, merged, deliveries, created, stale, notMarked, openNoItem, longWaits, weeks, oldest, repoErrors });
177
+ const text = render({ product, readAt, windowDays, needRows, ladder, lifecycles, active, merged, deliveries, created, stale, notMarked, openNoItem, longWaits, audits, weeks, oldest, repoErrors });
154
178
  return {
155
179
  verb: 'roundup', read_at: readAt, product, window_days: windowDays, since,
156
180
  needs_you: needRows,
157
181
  where_we_stand: { ladder, lifecycles, active },
158
182
  what_moved: { merged, delivered: deliveries, new_items: created },
159
- stuck_or_drifting: { stale, merged_not_marked: notMarked, open_prs_with_no_item: openNoItem, long_waits: longWaits, stuck_days: STUCK_DAYS },
183
+ stuck_or_drifting: { stale, merged_not_marked: notMarked, open_prs_with_no_item: openNoItem, long_waits: longWaits, stuck_days: STUCK_DAYS, audits_not_run: audits.stale, audits_unread: audits.unread, audit_stale_hours: AUDIT_STALE_HOURS },
160
184
  trend: { weeks, open_questions: needRows.length, oldest_question_days: oldest },
161
185
  repos_read: repoIds, repo_errors: repoErrors,
162
186
  citation: { product, read_at: readAt, items: items.length, repos: repoIds },
@@ -167,7 +191,7 @@ export function createRoundupVerbs(core) {
167
191
  }
168
192
 
169
193
  function render(view) {
170
- const { product, readAt, windowDays, needRows, ladder, lifecycles, active, merged, deliveries, created, stale, notMarked, openNoItem, longWaits, weeks, oldest, repoErrors } = view;
194
+ const { product, readAt, windowDays, needRows, ladder, lifecycles, active, merged, deliveries, created, stale, notMarked, openNoItem, longWaits, audits = { stale: [], unread: [] }, weeks, oldest, repoErrors } = view;
171
195
  const more = (list) => (list.length > SHOWN ? [` and ${list.length - SHOWN} more`] : []);
172
196
  const out = [`${product} — roundup, ${dayOf(readAt)}`];
173
197
 
@@ -195,6 +219,8 @@ function render(view) {
195
219
  stuck.push(` merged, not marked (${notMarked.length})`, ...notMarked.slice(0, SHOWN).map((row) => listLine(row.title, `${row.pr} · ${row.item}`, ' ')));
196
220
  stuck.push(` open pull requests with no item (${openNoItem.length})`, ...openNoItem.slice(0, SHOWN).map((row) => listLine(row.title, row.pr, ' ')));
197
221
  stuck.push(` questions waiting ${STUCK_DAYS} days or more (${longWaits.length})`, ...longWaits.slice(0, SHOWN).map((row) => listLine(row.text, `${row.question} · ${row.days_waiting} days`, ' ')));
222
+ stuck.push(` audits with no run in ${AUDIT_STALE_HOURS} hours (${audits.stale.length})`, ...audits.stale.slice(0, SHOWN).map((row) => listLine(row.repo, row.last_run_at ? `last run ${row.last_run_at}, ${row.hours} hours` : 'no run yet', ' ')));
223
+ for (const row of audits.unread.slice(0, SHOWN)) stuck.push(listLine(row.repo, row.reason, ' '));
198
224
  out.push(stuck.join('\n'));
199
225
 
200
226
  out.push([`5. Trend, per week (week 1 is the last seven days)`,
package/work/mcp.mjs CHANGED
@@ -206,6 +206,8 @@ export const GITHUB_READS = Object.freeze([
206
206
  new RegExp(`^repos/${REPO}/contents/atlas\\.yaml\\?ref=(?:${SHA}|${BRANCH})$`),
207
207
  new RegExp(`^repos/${REPO}/releases\\?per_page=\\d{1,3}$`),
208
208
  new RegExp(`^repos/${REPO}/compare/${SHA}\\.\\.\\.${TAG}$`),
209
+ // Roadmap R14 line 6: the newest run of the daily audit, for the roundup.
210
+ new RegExp(`^repos/${REPO}/actions/workflows/atlas-jobs\\.yml/runs\\?per_page=\\d{1,3}$`),
209
211
  ]);
210
212
  export function githubReader() {
211
213
  return async (path) => {