@arjunkhera/atlas 0.4.22 → 0.4.24

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.24",
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;