@arjunkhera/atlas 0.4.21 → 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 +7 -5
- 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/lib/procedure-steps.mjs +122 -0
- package/door/lib/upkeep.mjs +4 -2
- 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/skills/lead/SKILL.md +1 -1
- package/skills/repo-skills/SKILL.md +14 -10
- package/work/lib/roundup.mjs +29 -3
- package/work/mcp.mjs +2 -0
package/door/cli.mjs
CHANGED
|
@@ -141,6 +141,7 @@ const HELP = `atlas — the door into a repo's Atlas files
|
|
|
141
141
|
|
|
142
142
|
atlas upkeep check --root <repo> the upkeep check: the docs index, dead doc links, the code each doc names, and in a pull request its tracker item
|
|
143
143
|
atlas upkeep replay --root <repo> what the doc-sources rule would have asked of the last merges; writes nothing
|
|
144
|
+
atlas upkeep procedures --root <repo> run the procedure steps marked atlas-run; --base <ref> runs only the procedures a change touches
|
|
144
145
|
--last <n> how many merges on the first-parent line; default 30
|
|
145
146
|
--event <file> the GitHub pull request event; adds the item check
|
|
146
147
|
--json print JSON
|
|
@@ -152,7 +153,8 @@ const HELP = `atlas — the door into a repo's Atlas files
|
|
|
152
153
|
On a GitHub plan with no rulesets, apply changes nothing and reports advisory mode.
|
|
153
154
|
atlas gate ready --repo <owner/name> --pr <n> may this pull request merge: every required check green on its head
|
|
154
155
|
atlas gate audit --repo <owner/name> [--since <date>] each merge since the date (default: one day) that came before its checks were green
|
|
155
|
-
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
|
|
156
158
|
atlas gate check --base <ref> --head <sha> the gate: judge a pull request with this copy of Atlas; success, owner or red
|
|
157
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
|
|
158
160
|
--root <repo> the clone that holds both commits (default: this folder); the head is read as git data only
|
|
@@ -214,8 +216,8 @@ export const FLAGS = Object.freeze({
|
|
|
214
216
|
view: { product: 'value', name: 'value', out: 'value' },
|
|
215
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' },
|
|
216
218
|
jobs: { root: 'optional', repo: 'value', branch: 'value', out: 'value', findings: 'value', max: 'value', 'dry-run': 'switch' },
|
|
217
|
-
upkeep: { root: 'optional', event: 'value', json: 'switch', last: 'value' },
|
|
218
|
-
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' },
|
|
219
|
+
upkeep: { root: 'optional', event: 'value', json: 'switch', last: 'value', base: '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' },
|
|
219
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' },
|
|
220
222
|
judge: { tasks: 'value', verdict: 'value', seal: 'value', runs: 'value', out: 'value', repo: 'value', budget: 'value', model: 'value' },
|
|
221
223
|
install: { local: 'switch', from: 'value', 'skip-global': 'switch', yes: 'switch', cloud: 'switch' },
|
|
@@ -461,11 +463,11 @@ function jobsVerb(chosen) {
|
|
|
461
463
|
}
|
|
462
464
|
|
|
463
465
|
function upkeepVerb(chosen) {
|
|
464
|
-
return upkeepCommand({ what: chosen._[1], event: chosen.event, json: Boolean(chosen.json), last: chosen.last }, rootOf(chosen), line);
|
|
466
|
+
return upkeepCommand({ what: chosen._[1], event: chosen.event, json: Boolean(chosen.json), last: chosen.last, base: chosen.base }, rootOf(chosen), line);
|
|
465
467
|
}
|
|
466
468
|
|
|
467
469
|
function gateVerb(chosen) {
|
|
468
|
-
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);
|
|
469
471
|
}
|
|
470
472
|
|
|
471
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;
|