@arjunkhera/atlas 0.4.25 → 0.4.27
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/jobs/gate-audit.yaml +3 -1
- package/door/lib/gate-check.mjs +46 -6
- package/door/lib/gate.mjs +30 -5
- package/door/lib/privacy.mjs +25 -3
- package/door/lib/private-terms.mjs +65 -0
- package/door/lib/pull-request.mjs +1 -1
- package/door/lib/tooling.mjs +30 -20
- package/door/templates/atlas-upkeep.yml +45 -0
- package/door/workflows/atlas-gate-audit.yml +2 -0
- package/door/workflows/atlas-gate.yml +3 -0
- package/package.json +1 -1
- package/shape/check.mjs +18 -2
- package/shape/releases.json +6 -0
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
# the same findings, with the same keys, so one break opens one issue.
|
|
5
5
|
#
|
|
6
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)
|
|
7
|
+
# merge-before-green, gate-copy (a gate run not from the base workflow file),
|
|
8
|
+
# owner-unsigned (the gate sends the merge to the owner, and no signed act
|
|
9
|
+
# names its head; the push audit only, because it needs the clone).
|
|
8
10
|
# A closed issue does not stop a new one while its commit is in the window:
|
|
9
11
|
# anyone with the shared login can close or hand over an issue.
|
|
10
12
|
title: Each commit on the main branch came through the gate
|
package/door/lib/gate-check.mjs
CHANGED
|
@@ -10,8 +10,10 @@
|
|
|
10
10
|
//
|
|
11
11
|
// The answer is success, owner or red:
|
|
12
12
|
//
|
|
13
|
-
// red a secret in an added line;
|
|
14
|
-
//
|
|
13
|
+
// red a secret in an added line; a private term in an added line or a
|
|
14
|
+
// path that ships, or no terms when atlas.yaml names shipped
|
|
15
|
+
// paths; product code that changed with no test case added or
|
|
16
|
+
// changed; a head that git cannot read
|
|
15
17
|
// owner a path or a sign of the owner-only list; a head job named like a
|
|
16
18
|
// check; a refactor claim (until the review job, roadmap R28); a
|
|
17
19
|
// file too large to scan; the brake (roadmap R13, brake.mjs)
|
|
@@ -31,7 +33,8 @@
|
|
|
31
33
|
import { spawnSync } from 'node:child_process';
|
|
32
34
|
import { readFileSync, appendFileSync } from 'node:fs';
|
|
33
35
|
import { resolve } from 'node:path';
|
|
34
|
-
import { classify, changedPaths, compile, matcher, fakeSecret } from './owner-only.mjs';
|
|
36
|
+
import { classify, changedPaths, compile, matcher, fakeSecret, repoFacts } from './owner-only.mjs';
|
|
37
|
+
import { MISSING, HIT, fileHits, hidePath, shippedPaths, ships, termsFromEnv } from './private-terms.mjs';
|
|
35
38
|
import { listChanged } from './changed-tests.mjs';
|
|
36
39
|
import { parseYaml, SECRET_PATTERNS } from '../../shape/check.mjs';
|
|
37
40
|
import { parseSigners, verifyTexts, findAct, SIGNERS_FILE, BRANCH_FORM } from './signed-act.mjs';
|
|
@@ -115,6 +118,39 @@ export function secretRows(root, base, head, changes) {
|
|
|
115
118
|
return rows;
|
|
116
119
|
}
|
|
117
120
|
|
|
121
|
+
// ------------------------------------------------------------------ private terms
|
|
122
|
+
|
|
123
|
+
// The private-terms rule (roadmap R18, design docs/design-docs/private-terms/).
|
|
124
|
+
// It holds when the base atlas.yaml names `private_terms: { paths }`, the
|
|
125
|
+
// paths that ship in public. `terms` comes from the repo secret. It reads the
|
|
126
|
+
// whole head text of each changed file that ships, binary or not, so a term
|
|
127
|
+
// split over two lines, or already in an old line, is found too. A row names
|
|
128
|
+
// the path, with [hidden] for a term in it, and the line, never the term.
|
|
129
|
+
export function termRows(root, base, head, changes, terms) {
|
|
130
|
+
const paths = shippedPaths(repoFacts(root, base).facts);
|
|
131
|
+
if (paths === null) return [];
|
|
132
|
+
if (!paths.length) return [{ kind: 'red', class: 'private-term', cause: 'atlas.yaml private_terms names no paths' }];
|
|
133
|
+
if (!terms.length) return [{ kind: 'red', class: 'private-term', cause: MISSING }];
|
|
134
|
+
const rows = [];
|
|
135
|
+
for (const change of changes) {
|
|
136
|
+
if (change.status === 'D' || change.newMode === '160000' || !ships(paths, change.path)) continue;
|
|
137
|
+
const size = Number((git(root, ['cat-file', '-s', `${head}:${change.path}`], { allowFail: true }) ?? '0').trim());
|
|
138
|
+
if (size > SCAN_LIMIT) { rows.push({ kind: 'red', class: 'private-term', path: change.path, cause: `${size} bytes: too large to check for private terms` }); continue; }
|
|
139
|
+
for (const hit of fileHits(change.path, textAt(root, head, change.path) ?? '', terms)) {
|
|
140
|
+
rows.push({ kind: 'red', class: 'private-term', path: hit.path, ...(hit.line ? { line: hit.line } : {}), cause: hit.line ? `the line ${HIT}` : `the path ${HIT}` });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
rows.push({ kind: 'note', class: 'private-term', cause: `private terms: ${terms.length} term(s), ${rows.length} hit(s)` });
|
|
144
|
+
return rows;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// The answer with each term hidden in every path and cause, so no row of any
|
|
148
|
+
// rule shows a term in the summary, the log or the JSON.
|
|
149
|
+
export function hideTerms(answer, terms) {
|
|
150
|
+
if (!terms?.length) return answer;
|
|
151
|
+
return { ...answer, rows: answer.rows.map((row) => ({ ...row, ...(row.path ? { path: hidePath(row.path, terms) } : {}), cause: hidePath(String(row.cause ?? ''), terms) })) };
|
|
152
|
+
}
|
|
153
|
+
|
|
118
154
|
// ------------------------------------------------------------------ jobs named like a check
|
|
119
155
|
|
|
120
156
|
const WORKFLOW = /^\.github\/workflows\/[^/]+\.ya?ml$/;
|
|
@@ -230,7 +266,9 @@ function testRows({ root, mergeBase, head, body, redMain, rules, undo = null })
|
|
|
230
266
|
// the replay does, because the brake depends on the day of the run. `signed`
|
|
231
267
|
// is { repo, pr, baseRef, texts }: the texts are the comments of the pull
|
|
232
268
|
// request, read for a signed act, and baseRef is the name of its base branch.
|
|
233
|
-
|
|
269
|
+
// `terms` is the list of private terms, or undefined to skip that rule (the
|
|
270
|
+
// replay, which has no secret).
|
|
271
|
+
export function judge({ root, base, head, body = '', redMain = [], rules = null, brake = {}, now = new Date(), signed = null, terms = undefined }) {
|
|
234
272
|
const top = resolve(root);
|
|
235
273
|
const baseSha = commitOf(top, base);
|
|
236
274
|
if (!baseSha) throw new GateCheckError(`the base "${base}" is not a commit in this repo.`);
|
|
@@ -257,6 +295,7 @@ export function judge({ root, base, head, body = '', redMain = [], rules = null,
|
|
|
257
295
|
for (const row of classify({ root: top, base: mergeBase, head: headSha, rules }).rows) answer.rows.push({ kind: 'owner', class: row.class ?? 'unlisted', path: row.path, cause: row.cause });
|
|
258
296
|
answer.rows.push(...jobRows(top, rules ?? mergeBase, headSha, changes));
|
|
259
297
|
answer.rows.push(...secretRows(top, mergeBase, headSha, changes));
|
|
298
|
+
if (terms !== undefined) answer.rows.push(...termRows(top, baseSha, headSha, changes, terms));
|
|
260
299
|
answer.rows.push(...testRows({ root: top, mergeBase, head: headSha, body, redMain, rules, undo }));
|
|
261
300
|
if (signed) signedRows(top, baseSha, headSha, signed, answer);
|
|
262
301
|
return finish(answer);
|
|
@@ -353,7 +392,8 @@ export function checkCommand({ root, base, head, body: bodyFile, json = false, s
|
|
|
353
392
|
try { texts = comments(); } catch { texts = null; }
|
|
354
393
|
signed = { repo, pr: Number(pr), baseRef: baseRef && baseRef !== true ? String(baseRef) : null, texts };
|
|
355
394
|
}
|
|
356
|
-
const
|
|
395
|
+
const { terms } = termsFromEnv();
|
|
396
|
+
const answer = hideTerms(judge({ root, base, head, body, redMain, brake: Array.isArray(got) ? {} : got.brake ?? {}, signed, terms }), terms);
|
|
357
397
|
const text = renderText(answer);
|
|
358
398
|
if (json) process.stdout.write(`${JSON.stringify(answer, null, 2)}\n`);
|
|
359
399
|
else for (const each of text) line(each);
|
|
@@ -362,7 +402,7 @@ export function checkCommand({ root, base, head, body: bodyFile, json = false, s
|
|
|
362
402
|
if (output && output !== true) appendFileSync(output, `result=${answer.result}\nclass=${/^[a-z-]+$/.test(answer.class ?? '') ? answer.class : 'none'}\n`);
|
|
363
403
|
return answer.result === 'success' ? 0 : 1;
|
|
364
404
|
} catch (error) {
|
|
365
|
-
line(`gate check: ${error.message}`);
|
|
405
|
+
line(`gate check: ${hidePath(error.message, termsFromEnv().terms)}`);
|
|
366
406
|
// In the gate workflow (it passes --output), an error leaves a note with a
|
|
367
407
|
// fixed title. The audit reads it to tell an error from a result (roadmap
|
|
368
408
|
// R14 done line 5).
|
package/door/lib/gate.mjs
CHANGED
|
@@ -20,7 +20,7 @@ import { homedir } from 'node:os';
|
|
|
20
20
|
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
|
-
import { checkCommand, replayCommand, jobNames, GATE_CHECK, GATE_WORKFLOW } from './gate-check.mjs';
|
|
23
|
+
import { checkCommand, replayCommand, judge, jobNames, GATE_CHECK, GATE_WORKFLOW } from './gate-check.mjs';
|
|
24
24
|
import { keepFindings, gateErrors, pinCode, slug, AUDIT_JOB } from './gate-audit.mjs';
|
|
25
25
|
import { MARKER as SIGNED_MARKER } from './signed-act.mjs';
|
|
26
26
|
import { brakeFacts, windowCommits, ciTrips, BrakeError } from './brake.mjs';
|
|
@@ -422,7 +422,7 @@ export function keepReady({ repo, pr, sha, code, home = homedir(), now = new Dat
|
|
|
422
422
|
|
|
423
423
|
// `atlas gate audit --since <date>`: each pull request merged since the date
|
|
424
424
|
// whose required checks were not all green at the moment of the merge.
|
|
425
|
-
export function auditCommand({ repo, since, commit, errors = false, forced = false, findings }, line, gh = ghApi) {
|
|
425
|
+
export function auditCommand({ repo, root, since, commit, errors = false, forced = false, findings }, line, gh = ghApi) {
|
|
426
426
|
// R14: with --findings, each finding also goes into the file, and `atlas
|
|
427
427
|
// jobs file` opens one issue for each. A call that stops with an error
|
|
428
428
|
// marks the file broken, so the file job ends red.
|
|
@@ -431,7 +431,7 @@ export function auditCommand({ repo, since, commit, errors = false, forced = fal
|
|
|
431
431
|
try {
|
|
432
432
|
let code;
|
|
433
433
|
if (errors) code = gateErrors({ repo, found }, line, gh);
|
|
434
|
-
else if (commit !== undefined) code = commitAudit({ repo, commit, forced, found }, line, gh);
|
|
434
|
+
else if (commit !== undefined) code = commitAudit({ repo, root, commit, forced, found }, line, gh);
|
|
435
435
|
else code = sinceAudit({ repo, since }, line, gh);
|
|
436
436
|
keep();
|
|
437
437
|
return code;
|
|
@@ -472,7 +472,22 @@ function sinceAudit({ repo, since }, line, gh) {
|
|
|
472
472
|
// gate run of that pull request came from the base copy of the gate
|
|
473
473
|
// workflow. A commit that no pull request merged was pushed past the gate, so
|
|
474
474
|
// it is red. With --forced, the push that brought it was a force push.
|
|
475
|
-
|
|
475
|
+
// R14 done line 3: a merge that the gate sent to the owner needs the owner's
|
|
476
|
+
// signed act for its head (R72). The audit judges the merged head again with
|
|
477
|
+
// the base commit's rules, at the time of the merge, and reads the signed acts
|
|
478
|
+
// of the pull request. It needs both commits in the clone. Returns 'owner'
|
|
479
|
+
// when no signed act clears it, 'unread' when it cannot judge, else null.
|
|
480
|
+
export function ownerUnsigned({ root, repo, pull, parent }, gh) {
|
|
481
|
+
const head = pull.head?.sha;
|
|
482
|
+
const has = (sha) => { try { execFileSync('git', ['-C', root, 'cat-file', '-e', `${sha}^{commit}`], { stdio: 'ignore' }); return true; } catch { return false; } };
|
|
483
|
+
if (!/^[0-9a-f]{40}$/.test(String(head)) || !/^[0-9a-f]{40}$/.test(String(parent)) || !has(head) || !has(parent)) return 'unread';
|
|
484
|
+
let texts = null;
|
|
485
|
+
try { texts = prComments(gh, repo, pull.number); } catch { texts = null; }
|
|
486
|
+
const answer = judge({ root, base: parent, head, body: String(pull.body ?? ''), now: new Date(pull.merged_at), signed: { repo, pr: Number(pull.number), baseRef: pull.base?.ref ?? null, texts } });
|
|
487
|
+
return answer.result === 'owner' ? 'owner' : null;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
export function commitAudit({ repo, root = null, commit, forced = false, found = [] }, line, gh) {
|
|
476
491
|
if (!commit || commit === true || !/^[0-9a-f]{7,40}$/i.test(String(commit))) throw new GateError('--commit needs a commit SHA.');
|
|
477
492
|
// GitHub expands a short SHA, and names the first parent: the main branch
|
|
478
493
|
// just before this commit, whose atlas.yaml held the gate at that moment.
|
|
@@ -498,6 +513,16 @@ export function commitAudit({ repo, commit, forced = false, found = [] }, line,
|
|
|
498
513
|
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
514
|
}
|
|
500
515
|
const parent = fetched.parents?.[0]?.sha ?? pull.base.sha;
|
|
516
|
+
if (root) {
|
|
517
|
+
const owner = ownerUnsigned({ root, repo, pull, parent }, gh);
|
|
518
|
+
if (owner) {
|
|
519
|
+
red = true;
|
|
520
|
+
line(owner === 'owner'
|
|
521
|
+
? `- ✗ #${pull.number} merged while the gate sent it to the owner, and no signed act names its head ${String(pull.head?.sha).slice(0, 7)}.`
|
|
522
|
+
: `- ✗ #${pull.number}: the head or the base commit is not in this clone, so the audit cannot judge it.`);
|
|
523
|
+
found.push({ job: AUDIT_JOB, key: `owner-unsigned:${sha}`, kind: 'owner-unsigned', sha, pr: Number(pull.number), ...(owner === 'unread' ? { pin: 'unread' } : {}) });
|
|
524
|
+
}
|
|
525
|
+
} else line(`- atlas gate audit: no clone (--root), so the owner check of #${pull.number} did not run.`);
|
|
501
526
|
let checks;
|
|
502
527
|
try { checks = checksAt(gh, repo, parent); } catch (error) {
|
|
503
528
|
if (!(error instanceof GateError)) throw error;
|
|
@@ -629,7 +654,7 @@ export function gateCommand({ what, repo, root, dryRun = false, off = false, on
|
|
|
629
654
|
}
|
|
630
655
|
return code;
|
|
631
656
|
}
|
|
632
|
-
if (what === 'audit') return auditCommand({ repo, since, commit, errors, forced, findings }, line, gh);
|
|
657
|
+
if (what === 'audit') return auditCommand({ repo, root, since, commit, errors, forced, findings }, line, gh);
|
|
633
658
|
if (off && on) throw new GateError('pick --off or --on, not both.');
|
|
634
659
|
const mode = off ? 'off' : on ? 'on' : 'apply';
|
|
635
660
|
const facts = mode === 'apply' ? gateFacts(root) : null;
|
package/door/lib/privacy.mjs
CHANGED
|
@@ -36,15 +36,37 @@ export function loadPrivateTerms({ path = null, home = homedir() } = {}) {
|
|
|
36
36
|
if (path) throw new Error(`the private terms file ${path} does not exist.`);
|
|
37
37
|
return [];
|
|
38
38
|
}
|
|
39
|
-
return readFileSync(file, 'utf8')
|
|
39
|
+
return parseTerms(readFileSync(file, 'utf8'));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The terms of one terms file's text: one term on each line, and a line that
|
|
43
|
+
// starts with # is a note. The Mac file and the repo secret share this form.
|
|
44
|
+
export function parseTerms(text) {
|
|
45
|
+
return String(text ?? '').split('\n').map((line) => line.trim()).filter((line) => line && !line.startsWith('#'));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Text as the term rules read it: NFKC form, with the marks that do not
|
|
49
|
+
// show (a soft hyphen, the zero-width marks, a byte-order mark) taken out.
|
|
50
|
+
// It keeps every line break, so a line number stays true.
|
|
51
|
+
const INVISIBLE = /[\u00AD\u200B-\u200D\u2060\uFEFF]/g;
|
|
52
|
+
export const termText = (text) => String(text ?? '').normalize('NFKC').replace(INVISIBLE, '');
|
|
53
|
+
|
|
54
|
+
// One rule for each private term. A term matches only as a whole word, in any
|
|
55
|
+
// case. The words of a term may be split by any run of white space, also a
|
|
56
|
+
// line break, so a wrapped paragraph still matches when the caller reads the
|
|
57
|
+
// whole text.
|
|
58
|
+
export function termRules(terms = []) {
|
|
59
|
+
return terms.map((term) => {
|
|
60
|
+
const words = termText(term).split(/\s+/).filter(Boolean).map(escape);
|
|
61
|
+
return { id: 'private-term', holds: 'One of the owner\'s private terms.', pattern: new RegExp(`(?<![\\p{L}\\p{N}])${words.join('\\s+')}(?![\\p{L}\\p{N}])`, 'iu') };
|
|
62
|
+
});
|
|
40
63
|
}
|
|
41
64
|
|
|
42
65
|
// Every rule this filter applies: the secret patterns, the generic patterns,
|
|
43
66
|
// then one rule for each private term.
|
|
44
67
|
export function privacyRules({ terms = [] } = {}) {
|
|
45
68
|
const secrets = secretPatterns().map((pattern, index) => ({ id: `secret-${SECRET_PATTERNS[index].id}`, holds: SECRET_PATTERNS[index].holds, pattern }));
|
|
46
|
-
|
|
47
|
-
return [...secrets, ...PRIVACY_PATTERNS, ...own];
|
|
69
|
+
return [...secrets, ...PRIVACY_PATTERNS, ...termRules(terms)];
|
|
48
70
|
}
|
|
49
71
|
|
|
50
72
|
// Each finding names its line and its rule, never the text it matched, so a
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// The private-terms check of a public package (roadmap R18, tracker item
|
|
2
|
+
// 437b99c1; design docs/design-docs/private-terms/). The release build runs
|
|
3
|
+
// it on each file of the tarball, and the gate runs it on each added line
|
|
4
|
+
// under the shipped paths of a pull request.
|
|
5
|
+
//
|
|
6
|
+
// The terms come from the repo secret ATLAS_PRIVATE_TERMS, in the form of the
|
|
7
|
+
// owner's terms file. This plan gives a private repo no environments, so any
|
|
8
|
+
// workflow run of this repo can read that secret, on any branch. A fork
|
|
9
|
+
// cannot. The design accepts that: who can push here can already publish.
|
|
10
|
+
//
|
|
11
|
+
// A hit names the path and the line, never the term. A path that holds a
|
|
12
|
+
// term shows [hidden] in its place. The text is read in NFKC form with the
|
|
13
|
+
// marks that do not show taken out, and the words of a term may be split by
|
|
14
|
+
// any white space.
|
|
15
|
+
import { parseTerms, redact, termRules, termText } from './privacy.mjs';
|
|
16
|
+
|
|
17
|
+
export const TERMS_ENV = 'ATLAS_PRIVATE_TERMS';
|
|
18
|
+
export const MISSING = `the private terms are missing: the repo secret ${TERMS_ENV} is empty`;
|
|
19
|
+
export const HIT = 'holds a private term';
|
|
20
|
+
|
|
21
|
+
// The terms from the environment. `set` is false when the variable is absent
|
|
22
|
+
// or holds notes only.
|
|
23
|
+
export function termsFromEnv(env = process.env) {
|
|
24
|
+
const terms = parseTerms(env[TERMS_ENV]);
|
|
25
|
+
return { set: terms.length > 0, terms };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// The text with each term replaced by [hidden]. Text with no term comes back
|
|
29
|
+
// as it was.
|
|
30
|
+
export function hidePath(text, terms) {
|
|
31
|
+
if (!terms?.length) return text;
|
|
32
|
+
const clean = termText(text);
|
|
33
|
+
const hidden = redact(clean, { rules: termRules(terms) }).replace(/\[hidden: private-term\]/g, '[hidden]');
|
|
34
|
+
return hidden === clean ? text : hidden;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The hits of one file: { path, line } for the first line of each match, and
|
|
38
|
+
// { path, line: null } when the path holds a term. `path` is already hidden.
|
|
39
|
+
// The whole text is read at once, so a term split over two lines still
|
|
40
|
+
// matches.
|
|
41
|
+
export function fileHits(path, text, terms) {
|
|
42
|
+
const rules = termRules(terms);
|
|
43
|
+
const shown = hidePath(path, terms);
|
|
44
|
+
const hits = [];
|
|
45
|
+
if (rules.some((rule) => rule.pattern.test(termText(path)))) hits.push({ path: shown, line: null });
|
|
46
|
+
const body = termText(text);
|
|
47
|
+
const lines = new Set();
|
|
48
|
+
for (const rule of rules) {
|
|
49
|
+
const all = new RegExp(rule.pattern.source, `${rule.pattern.flags}g`);
|
|
50
|
+
for (const match of body.matchAll(all)) lines.add(body.slice(0, match.index).split('\n').length);
|
|
51
|
+
}
|
|
52
|
+
for (const line of [...lines].sort((a, b) => a - b)) hits.push({ path: shown, line });
|
|
53
|
+
return hits;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The shipped paths that atlas.yaml names under private_terms.paths, or null
|
|
57
|
+
// when the key is not there.
|
|
58
|
+
export function shippedPaths(facts) {
|
|
59
|
+
const entry = facts?.private_terms;
|
|
60
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null;
|
|
61
|
+
const paths = Array.isArray(entry.paths) ? entry.paths.filter((path) => typeof path === 'string' && path) : [];
|
|
62
|
+
return paths;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const ships = (paths, path) => paths.some((prefix) => (prefix.endsWith('/') ? path.startsWith(prefix) : path === prefix || path.startsWith(`${prefix}/`)));
|
|
@@ -23,7 +23,7 @@ export const TOOLING_LABEL = 'atlas-helper';
|
|
|
23
23
|
|
|
24
24
|
// The only files the tooling writer may write: the check, its workflow, and
|
|
25
25
|
// the pull request template when the repo has none.
|
|
26
|
-
export const TOOLING_PATHS = Object.freeze([CHECK_PATH, WORKFLOW_PATH, '.github/workflows/atlas-jobs.yml', '.github/pull_request_template.md']);
|
|
26
|
+
export const TOOLING_PATHS = Object.freeze([CHECK_PATH, WORKFLOW_PATH, '.github/workflows/atlas-jobs.yml', '.github/workflows/atlas-upkeep.yml', '.github/pull_request_template.md']);
|
|
27
27
|
// The only files the tooling writer may remove: the workflow of check versions
|
|
28
28
|
// 1 to 5, which the gate workflow replaces (roadmap R7).
|
|
29
29
|
export const TOOLING_REMOVALS = Object.freeze([OLD_WORKFLOW_PATH]);
|
package/door/lib/tooling.mjs
CHANGED
|
@@ -42,25 +42,34 @@ const oldWorkflow = (root) => existsSync(join(root, OLD_WORKFLOW_PATH));
|
|
|
42
42
|
// repo backwards, and never replaces a hand edit at the same version.
|
|
43
43
|
export const JOBS_PATH = '.github/workflows/atlas-jobs.yml';
|
|
44
44
|
export const PACKAGE_JOBS_SOURCE = 'door/templates/atlas-jobs.yml';
|
|
45
|
+
// The upkeep check on each pull request (roadmap R41). The same pin rule holds.
|
|
46
|
+
// The Atlas repo keeps its own atlas-upkeep.yml, which runs the package of
|
|
47
|
+
// master and has no pin, so the writer keeps it and Atlas runs one upkeep job.
|
|
48
|
+
export const UPKEEP_PATH = '.github/workflows/atlas-upkeep.yml';
|
|
49
|
+
export const PACKAGE_UPKEEP_SOURCE = 'door/templates/atlas-upkeep.yml';
|
|
50
|
+
// The workflows the writer pins, in the order of its rows.
|
|
51
|
+
const PINNED = Object.freeze([[JOBS_PATH, PACKAGE_JOBS_SOURCE], [UPKEEP_PATH, PACKAGE_UPKEEP_SOURCE]]);
|
|
45
52
|
const VERSION_SLOT = '__ATLAS_VERSION__';
|
|
46
|
-
|
|
47
|
-
const full = join(packageRoot,
|
|
53
|
+
function packagedPinned(packageRoot, source) {
|
|
54
|
+
const full = join(packageRoot, source);
|
|
48
55
|
return existsSync(full) ? readFileSync(full, 'utf8').split(VERSION_SLOT).join(packagePackageVersion(packageRoot)) : null;
|
|
49
56
|
}
|
|
50
|
-
const
|
|
57
|
+
export const packagedJobsWorkflow = (packageRoot) => packagedPinned(packageRoot, PACKAGE_JOBS_SOURCE);
|
|
58
|
+
export const packagedUpkeepWorkflow = (packageRoot) => packagedPinned(packageRoot, PACKAGE_UPKEEP_SOURCE);
|
|
59
|
+
const pinOf = (text) => /@arjunkhera\/atlas@(\d+)\.(\d+)\.(\d+)\b/.exec(text)?.slice(1).map(Number) ?? null;
|
|
51
60
|
const older = (a, b) => { for (let i = 0; i < 3; i += 1) if (a[i] !== b[i]) return a[i] < b[i]; return false; };
|
|
52
|
-
// written, refreshed, unchanged or kept: what the writer does with
|
|
53
|
-
function
|
|
54
|
-
const wanted =
|
|
61
|
+
// written, refreshed, unchanged or kept: what the writer does with one pinned workflow.
|
|
62
|
+
function pinnedState(root, packageRoot, path, source) {
|
|
63
|
+
const wanted = packagedPinned(packageRoot, source);
|
|
55
64
|
if (wanted === null) return null;
|
|
56
|
-
const full = join(root,
|
|
65
|
+
const full = join(root, path);
|
|
57
66
|
if (!existsSync(full)) return 'written';
|
|
58
67
|
const was = readFileSync(full, 'utf8');
|
|
59
68
|
if (was === wanted) return 'unchanged';
|
|
60
|
-
const have =
|
|
61
|
-
return have && older(have,
|
|
69
|
+
const have = pinOf(was);
|
|
70
|
+
return have && older(have, pinOf(wanted)) ? 'refreshed' : 'kept';
|
|
62
71
|
}
|
|
63
|
-
const
|
|
72
|
+
const pinnedNeedWrite = (root, packageRoot) => PINNED.some(([path, source]) => ['written', 'refreshed'].includes(pinnedState(root, packageRoot, path, source)));
|
|
64
73
|
|
|
65
74
|
// The pull request template (roadmap R8). The writer puts it into a repo that
|
|
66
75
|
// has none, and never replaces a template the repo keeps.
|
|
@@ -113,7 +122,7 @@ export function mayWrite(root, packageRoot, { replaceUnknown = false, workflowOn
|
|
|
113
122
|
throw new ToolingRefusal(`this package's ${PACKAGE_CHECK_SOURCE} is ${pkg.row ? 'not the newest release' : 'in no release'} of the check, so it is not written into another repo. Use a released Atlas.`);
|
|
114
123
|
}
|
|
115
124
|
const now = status({ root, packageRoot });
|
|
116
|
-
if (now.state === 'current' && !workflowOnly && !templateMissing(root, packageRoot) && !
|
|
125
|
+
if (now.state === 'current' && !workflowOnly && !templateMissing(root, packageRoot) && !pinnedNeedWrite(root, packageRoot) && !oldWorkflow(root)) {
|
|
117
126
|
throw new ToolingRefusal(`this repo's check is already current. ${readWorkflow(root) === WORKFLOW_TEMPLATE || workflowKept(root) ? 'Both files match the package, so there is nothing to write.' : 'Its workflow file differs from the package; pass --workflow to rewrite only that file.'}`, { current: true });
|
|
118
127
|
}
|
|
119
128
|
if (now.state === 'unknown' && !replaceUnknown && !own) {
|
|
@@ -145,16 +154,17 @@ export function write(root, packageRoot, { dryRun = false, replaceUnknown = fals
|
|
|
145
154
|
if (!dryRun) rmSync(join(root, OLD_WORKFLOW_PATH));
|
|
146
155
|
rows.push({ path: OLD_WORKFLOW_PATH, state: 'removed', sha256: null, was: sha256(was) });
|
|
147
156
|
}
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
const
|
|
157
|
+
for (const [path, source] of workflowOnly ? [] : PINNED) {
|
|
158
|
+
const state = pinnedState(root, packageRoot, path, source);
|
|
159
|
+
if (state === null) continue;
|
|
160
|
+
const contents = packagedPinned(packageRoot, source);
|
|
161
|
+
const full = join(root, path);
|
|
152
162
|
const was = existsSync(full) ? readFileSync(full, 'utf8') : null;
|
|
153
|
-
if ((
|
|
163
|
+
if ((state === 'written' || state === 'refreshed') && !dryRun) {
|
|
154
164
|
mkdirSync(dirname(full), { recursive: true });
|
|
155
165
|
writeFileSync(full, contents);
|
|
156
166
|
}
|
|
157
|
-
rows.push({ path
|
|
167
|
+
rows.push({ path, state, sha256: sha256(state === 'kept' ? was : contents), was: was === null ? null : sha256(was) });
|
|
158
168
|
}
|
|
159
169
|
const template = workflowOnly ? null : packagedTemplate(packageRoot);
|
|
160
170
|
if (template !== null) {
|
|
@@ -180,7 +190,7 @@ export function changes(root, packageRoot, { replaceUnknown = false, workflowOnl
|
|
|
180
190
|
if (rows.every((row) => row.state === 'unchanged' || row.state === 'kept')) {
|
|
181
191
|
throw new ToolingRefusal('this repo is already current: its Atlas files match the package, so there is nothing to open.', { current: true });
|
|
182
192
|
}
|
|
183
|
-
const wanted = { ...packagedFiles(packageRoot), [JOBS_PATH]: packagedJobsWorkflow(packageRoot), [PR_TEMPLATE_PATH]: packagedTemplate(packageRoot) };
|
|
193
|
+
const wanted = { ...packagedFiles(packageRoot), [JOBS_PATH]: packagedJobsWorkflow(packageRoot), [UPKEEP_PATH]: packagedUpkeepWorkflow(packageRoot), [PR_TEMPLATE_PATH]: packagedTemplate(packageRoot) };
|
|
184
194
|
return rows
|
|
185
195
|
.filter((row) => row.state !== 'unchanged' && row.state !== 'kept')
|
|
186
196
|
.map((row) => (row.state === 'removed' ? {
|
|
@@ -196,8 +206,8 @@ export function changes(root, packageRoot, { replaceUnknown = false, workflowOnl
|
|
|
196
206
|
? `copied byte for byte from ${PACKAGE_CHECK_SOURCE}, check version ${CHECK_VERSION}, in Atlas ${version}`
|
|
197
207
|
: row.path === PR_TEMPLATE_PATH
|
|
198
208
|
? `copied from ${PACKAGE_TEMPLATE_SOURCE}, Atlas ${version}; the repo had no pull request template`
|
|
199
|
-
: row.path === JOBS_PATH
|
|
200
|
-
? `written from ${PACKAGE_JOBS_SOURCE}, with its pin set to Atlas ${version}`
|
|
209
|
+
: row.path === JOBS_PATH || row.path === UPKEEP_PATH
|
|
210
|
+
? `written from ${row.path === JOBS_PATH ? PACKAGE_JOBS_SOURCE : PACKAGE_UPKEEP_SOURCE}, with its pin set to Atlas ${version}`
|
|
201
211
|
: `written from the WORKFLOW_TEMPLATE constant in ${PACKAGE_CHECK_SOURCE}, Atlas ${version}`,
|
|
202
212
|
},
|
|
203
213
|
}));
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
name: atlas-upkeep
|
|
2
|
+
# The Atlas upkeep check on each pull request: plain code, no model and no
|
|
3
|
+
# secret. `atlas tooling` wrote this file from the Atlas package; do not edit
|
|
4
|
+
# it by hand. A newer Atlas moves the pin below when `atlas tooling pr` runs.
|
|
5
|
+
#
|
|
6
|
+
# It stops drift at its source: the docs index lists each doc, every doc link
|
|
7
|
+
# resolves, each doc names its code, and the pull request names its tracker
|
|
8
|
+
# item, or says "no item:" and a reason. A repo with no docs/ folder passes
|
|
9
|
+
# the index part with one line.
|
|
10
|
+
#
|
|
11
|
+
# It runs a pinned Atlas release from npm, never code of the pull request. It
|
|
12
|
+
# runs on pull_request, not pull_request_target, so it holds only a read token
|
|
13
|
+
# and no secret, also for a pull request from a fork. npx runs outside the
|
|
14
|
+
# checkout, so an .npmrc in the pull request cannot pick the registry.
|
|
15
|
+
on:
|
|
16
|
+
pull_request:
|
|
17
|
+
types: [opened, edited, synchronize, reopened]
|
|
18
|
+
|
|
19
|
+
permissions:
|
|
20
|
+
contents: read
|
|
21
|
+
|
|
22
|
+
concurrency:
|
|
23
|
+
group: atlas-upkeep-${{ github.event.pull_request.number }}
|
|
24
|
+
cancel-in-progress: true
|
|
25
|
+
|
|
26
|
+
jobs:
|
|
27
|
+
upkeep:
|
|
28
|
+
name: docs · upkeep check
|
|
29
|
+
runs-on: ubuntu-latest
|
|
30
|
+
timeout-minutes: 5
|
|
31
|
+
steps:
|
|
32
|
+
# actions/checkout v7.0.1
|
|
33
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
|
34
|
+
with:
|
|
35
|
+
fetch-depth: 0
|
|
36
|
+
persist-credentials: false
|
|
37
|
+
# actions/setup-node v7.0.0
|
|
38
|
+
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
|
39
|
+
with:
|
|
40
|
+
node-version: "22.22.0"
|
|
41
|
+
- name: Upkeep check
|
|
42
|
+
run: |
|
|
43
|
+
set -euo pipefail
|
|
44
|
+
cd "$RUNNER_TEMP"
|
|
45
|
+
npx --yes --ignore-scripts @arjunkhera/atlas@__ATLAS_VERSION__ upkeep check --root "$GITHUB_WORKSPACE" --event "$GITHUB_EVENT_PATH" | tee -a "$GITHUB_STEP_SUMMARY"
|
|
@@ -12,6 +12,8 @@ on:
|
|
|
12
12
|
# 3. A merge made before the required checks of its pull request were
|
|
13
13
|
# green. A check counts only from a run of the base file that defines it.
|
|
14
14
|
# 4. A gate run that is not from the base copy of atlas-gate.yml.
|
|
15
|
+
# 5. A merge that the gate sends to the owner, with no signed act of the
|
|
16
|
+
# owner for its head (R72). The audit judges the head again from git.
|
|
15
17
|
# Each finding opens one `atlas-job` issue, and the run ends red. It does not
|
|
16
18
|
# undo the push.
|
|
17
19
|
#
|
|
@@ -81,6 +81,8 @@ jobs:
|
|
|
81
81
|
- name: Read the pull request text from the event
|
|
82
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
83
|
|
|
84
|
+
# Only this step gets the private terms (roadmap R18). It runs the base
|
|
85
|
+
# code, and reads the head as git data.
|
|
84
86
|
- name: Gate check
|
|
85
87
|
id: check
|
|
86
88
|
env:
|
|
@@ -88,6 +90,7 @@ jobs:
|
|
|
88
90
|
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
|
89
91
|
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
|
90
92
|
GH_TOKEN: ${{ github.token }}
|
|
93
|
+
ATLAS_PRIVATE_TERMS: ${{ secrets.ATLAS_PRIVATE_TERMS }}
|
|
91
94
|
run: |
|
|
92
95
|
set -euo pipefail
|
|
93
96
|
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"
|
package/package.json
CHANGED
package/shape/check.mjs
CHANGED
|
@@ -38,7 +38,7 @@ export const sha256 = (text) => createHash('sha256').update(String(text ?? ''),
|
|
|
38
38
|
// Whether a copy is current or behind is not decided here. CI has no package
|
|
39
39
|
// and no network. The session verb decides it, from the release list that
|
|
40
40
|
// ships beside this file in the package, shape/releases.json.
|
|
41
|
-
export const CHECK_VERSION =
|
|
41
|
+
export const CHECK_VERSION = 8;
|
|
42
42
|
|
|
43
43
|
// ---------------------------------------------------------------------------
|
|
44
44
|
// The spec. One object per version. The check reads a version it has here and
|
|
@@ -1232,6 +1232,22 @@ function workflowStructure(document) {
|
|
|
1232
1232
|
return out;
|
|
1233
1233
|
}
|
|
1234
1234
|
|
|
1235
|
+
// The shape check takes no secret. The rule reads the job that runs the check
|
|
1236
|
+
// and the workflow-wide env, which every job gets. Another job of the same file
|
|
1237
|
+
// may give one step a secret: the gate step gets the private terms (roadmap
|
|
1238
|
+
// R18), and it runs the base code too. When the file cannot be read, or no job
|
|
1239
|
+
// runs the check, the rule reads the whole text.
|
|
1240
|
+
function readsSecret(text) {
|
|
1241
|
+
const READS = /\bsecrets\./;
|
|
1242
|
+
let document = null;
|
|
1243
|
+
try { document = parseYaml(text); } catch { return READS.test(text); }
|
|
1244
|
+
const jobs = document?.jobs && typeof document.jobs === 'object' ? Object.values(document.jobs) : [];
|
|
1245
|
+
const job = jobs.find((candidate) => (Array.isArray(candidate?.steps) ? candidate.steps : [])
|
|
1246
|
+
.some((step) => typeof step?.run === 'string' && step.run.includes(GATE_RUNNER.runs)));
|
|
1247
|
+
if (!job) return READS.test(text);
|
|
1248
|
+
return READS.test(JSON.stringify(job)) || READS.test(JSON.stringify(document.env ?? null));
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1235
1251
|
function checkWorkflow(context, part) {
|
|
1236
1252
|
const text = readText(context.root, part.path);
|
|
1237
1253
|
if (text === null || !text.trim()) return result(part, 'empty', [`${part.path} is missing or holds nothing`]);
|
|
@@ -1254,7 +1270,7 @@ function checkWorkflow(context, part) {
|
|
|
1254
1270
|
// a block map and a flow map for both permissions and steps, so read both
|
|
1255
1271
|
// spellings or a workflow can widen itself past the check (review R-3).
|
|
1256
1272
|
for (const grant of writeGrants(text)) findings.push(`the workflow grants ${grant}; this check needs read access only`);
|
|
1257
|
-
if (
|
|
1273
|
+
if (readsSecret(text)) findings.push('the workflow reads a repository secret; this check takes none');
|
|
1258
1274
|
const unpinned = [...text.matchAll(/uses[ \t]*:[ \t]*["']?([^\s,}\]"']+)/g)]
|
|
1259
1275
|
.map(([, value]) => value)
|
|
1260
1276
|
.filter((value) => !value.startsWith('./'))
|
package/shape/releases.json
CHANGED
|
@@ -40,6 +40,12 @@
|
|
|
40
40
|
{
|
|
41
41
|
"check_version": 7,
|
|
42
42
|
"sha256": "b29fdb036170419b9f0642ef0ca7da45a80ff53492421eb57beaf157df13d057",
|
|
43
|
+
"first_package": "0.4.17",
|
|
44
|
+
"workflow_sha256": "fcddebef030e6564f97e1a5fb81c8ad1b3084ebef9622167c6565698312dcdd7"
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"check_version": 8,
|
|
48
|
+
"sha256": "8cf521f6e8f89ae65a257c521c583281e0c3c27ef968b4bd96eb336a8d095795",
|
|
43
49
|
"workflow_sha256": "fcddebef030e6564f97e1a5fb81c8ad1b3084ebef9622167c6565698312dcdd7"
|
|
44
50
|
}
|
|
45
51
|
]
|