@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.
- package/.claude-plugin/plugin.json +1 -1
- package/door/cli.mjs +4 -3
- package/door/jobs/gate-audit.yaml +16 -0
- package/door/jobs/gate-errors.yaml +11 -0
- package/door/jobs/gate-files.yaml +13 -0
- package/door/lib/gate-audit.mjs +303 -0
- package/door/lib/gate-check.mjs +4 -0
- package/door/lib/gate.mjs +92 -23
- package/door/lib/jobs.mjs +25 -4
- package/door/templates/atlas-jobs.yml +5 -1
- package/door/workflows/atlas-gate-audit.yml +128 -0
- package/door/workflows/atlas-gate.yml +142 -0
- package/package.json +1 -1
- package/work/lib/roundup.mjs +29 -3
- package/work/mcp.mjs +2 -0
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,
|
|
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
|
+
}
|
package/door/lib/gate-check.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
424
|
-
// that
|
|
425
|
-
|
|
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
|
|
430
|
-
const 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
package/work/lib/roundup.mjs
CHANGED
|
@@ -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) => {
|