@arjunkhera/atlas 0.4.60 → 0.4.62
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/lib/brake.mjs +28 -3
- package/door/lib/gate-check.mjs +87 -16
- package/door/lib/gate.mjs +77 -6
- package/door/lib/runner.mjs +21 -4
- package/door/lib/strict.mjs +67 -0
- package/door/workflows/atlas-gate.yml +4 -2
- package/owner-only.yaml +1 -0
- package/package.json +1 -1
- package/skills/lead/SKILL.md +11 -2
- package/skills/sdlc-task/SKILL.md +6 -0
package/door/lib/brake.mjs
CHANGED
|
@@ -168,7 +168,14 @@ export function clearOf(root, base, trip, clears) {
|
|
|
168
168
|
// and 3, which the gate reads from GitHub; with no GitHub read it is empty.
|
|
169
169
|
// `clears` reads the owner's signed clears (line 6); a cleared trip holds
|
|
170
170
|
// nothing, and the answer lists it under `cleared`.
|
|
171
|
-
|
|
171
|
+
//
|
|
172
|
+
// Normal mode (`strict_mode` off, item bca1e8fb): a trip also ends at the first
|
|
173
|
+
// first-parent commit of the base, at or after the commit that set the trip,
|
|
174
|
+
// that has its check green. `green` is read from GitHub (see greenRead); with none read, no trip ends this way. A
|
|
175
|
+
// live trip ends only on a later commit, because its own commit ran the check
|
|
176
|
+
// that failed. The answer lists each ended trip under `ended`. Strict mode
|
|
177
|
+
// (the default here) keeps every trip for its days.
|
|
178
|
+
export function brakeState({ root, base, now = new Date(), ci = [], clears = null, strict = true, green = null }) {
|
|
172
179
|
const sha = fullSha(root, base);
|
|
173
180
|
if (!sha) throw new BrakeError(`the base "${base}" is not a commit in this repo.`);
|
|
174
181
|
const text = git(root, ['show', `${sha}:atlas.yaml`], { allowFail: true });
|
|
@@ -187,9 +194,27 @@ export function brakeState({ root, base, now = new Date(), ci = [], clears = nul
|
|
|
187
194
|
const bySha = new Map();
|
|
188
195
|
for (const trip of all) if (!bySha.has(trip.sha)) bySha.set(trip.sha, clearOf(root, sha, trip, clears));
|
|
189
196
|
for (const found of bySha.values()) if (found) cleared.push(found);
|
|
190
|
-
|
|
197
|
+
let trips = all.filter((trip) => !bySha.get(trip.sha));
|
|
198
|
+
const ended = [];
|
|
199
|
+
if (!strict && green && trips.length) {
|
|
200
|
+
// `green` maps a commit of main to { ok, checks }: ok when at least one
|
|
201
|
+
// required check ran there and each one that ran is green, and checks as
|
|
202
|
+
// the names of the green checks. A plain set of commits means ok only.
|
|
203
|
+
const entry = (sha) => (green instanceof Map ? green.get(sha) : green.has?.(sha) ? { ok: true, checks: new Set() } : null);
|
|
204
|
+
trips = trips.filter((trip) => {
|
|
205
|
+
const at = commits.findIndex((commit) => commit.sha === trip.sha);
|
|
206
|
+
if (at < 0) return true;
|
|
207
|
+
// A trip set by a red check ends only where that check ran and is green.
|
|
208
|
+
// A live trip needs a later commit, because its own commit ran the check that failed.
|
|
209
|
+
const own = trip.check !== undefined && trip.check !== null;
|
|
210
|
+
const found = commits.slice(0, trip.cause === 'live' ? at : at + 1).find((commit) => { const one = entry(commit.sha); return own ? Boolean(one?.checks?.has(trip.check)) : Boolean(one?.ok); });
|
|
211
|
+
if (!found) return true;
|
|
212
|
+
ended.push({ sha: trip.sha, green: found.sha });
|
|
213
|
+
return false;
|
|
214
|
+
});
|
|
215
|
+
}
|
|
191
216
|
const until = trips.length ? new Date(Date.parse(trips[0].time) + facts.days * 864e5).toISOString() : null;
|
|
192
|
-
return { on: trips.length > 0, days: facts.days, since: since.toISOString(), until, trips, cleared };
|
|
217
|
+
return { on: trips.length > 0, days: facts.days, since: since.toISOString(), until, trips, cleared, ended };
|
|
193
218
|
}
|
|
194
219
|
|
|
195
220
|
// The patch id of a diff, or null for an empty diff. --verbatim keeps every
|
package/door/lib/gate-check.mjs
CHANGED
|
@@ -44,6 +44,7 @@ import { parseYaml, SECRET_PATTERNS } from '../../shape/check.mjs';
|
|
|
44
44
|
import { parseSigners, verifyTexts, findAct, SIGNERS_FILE, BRANCH_FORM } from './signed-act.mjs';
|
|
45
45
|
import { brakeState, undoOf, tripText } from './brake.mjs';
|
|
46
46
|
import { kindsAt, nameKinds, kindRows } from './kinds.mjs';
|
|
47
|
+
import { strictAt, reviewHeads, insider } from './strict.mjs';
|
|
47
48
|
import { windowOn, protectedSet, clearable, windowActs, openAt, holdsCommit, WINDOW_KEY } from './window.mjs';
|
|
48
49
|
|
|
49
50
|
export const VERSION = 1;
|
|
@@ -51,7 +52,7 @@ export const GATE_CHECK = 'gate';
|
|
|
51
52
|
export const GATE_WORKFLOW = '.github/workflows/atlas-gate.yml';
|
|
52
53
|
export const TEST_CHECKS = Object.freeze(['fails-before', 'passes-after']);
|
|
53
54
|
export const RESULTS = Object.freeze(['success', 'owner', 'red']);
|
|
54
|
-
export const ANSWER_KEYS = Object.freeze(['gate', 'base', 'merge_base', 'head', 'result', 'class', 'rows', 'signed_act', 'brake', 'signed', 'kinds']);
|
|
55
|
+
export const ANSWER_KEYS = Object.freeze(['gate', 'base', 'merge_base', 'head', 'result', 'class', 'rows', 'signed_act', 'brake', 'signed', 'kinds', 'mode', 'review', 'author']);
|
|
55
56
|
export const SIGNED_ACT = "Clears with the owner's signed act: atlas approve <pr> (roadmap R72).";
|
|
56
57
|
// A file over this size is not scanned for secrets, and comes to the owner.
|
|
57
58
|
export const SCAN_LIMIT = 1024 * 1024;
|
|
@@ -274,12 +275,12 @@ function testRows({ root, mergeBase, head, body, redMain, rules, undo = null })
|
|
|
274
275
|
// request, read for a signed act, and baseRef is the name of its base branch.
|
|
275
276
|
// `terms` is the list of private terms, or undefined to skip that rule (the
|
|
276
277
|
// replay, which has no secret).
|
|
277
|
-
export function judge({ root, base, head, body = '', redMain = [], rules = null, brake = {}, now = new Date(), signed = null, terms = undefined }) {
|
|
278
|
+
export function judge({ root, base, head, body = '', redMain = [], rules = null, brake = {}, now = new Date(), signed = null, terms = undefined, marks = null, author = null, fixes = true }) {
|
|
278
279
|
const top = resolve(root);
|
|
279
280
|
const baseSha = commitOf(top, base);
|
|
280
281
|
if (!baseSha) throw new GateCheckError(`the base "${base}" is not a commit in this repo.`);
|
|
281
282
|
const headSha = commitOf(top, head);
|
|
282
|
-
const answer = { gate: VERSION, base: baseSha, merge_base: null, head: headSha || String(head), result: 'red', class: null, rows: [], signed_act: null, brake: null, signed: null, kinds: null };
|
|
283
|
+
const answer = { gate: VERSION, base: baseSha, merge_base: null, head: headSha || String(head), result: 'red', class: null, rows: [], signed_act: null, brake: null, signed: null, kinds: null, mode: 'normal', review: null, author: 'outsider' };
|
|
283
284
|
if (!headSha) {
|
|
284
285
|
answer.rows.push({ kind: 'red', class: 'gate', cause: 'the head commit is not readable in this clone' });
|
|
285
286
|
return finish(answer);
|
|
@@ -295,29 +296,89 @@ export function judge({ root, base, head, body = '', redMain = [], rules = null,
|
|
|
295
296
|
const list = kindsAt(top, baseSha);
|
|
296
297
|
const named = nameKinds(list, changes.map((change) => change.path));
|
|
297
298
|
answer.kinds = { listed: list.listed, names: named.names, unlisted: list.listed ? named.unlisted : [] };
|
|
298
|
-
let undo = null;
|
|
299
|
-
if (brake !== false) {
|
|
300
|
-
const state = brakeState({ root: top, base: baseSha, now, ci: brake?.ci ?? [], clears: brake?.clears ?? null });
|
|
301
|
-
undo = undoOf(top, mergeBase, headSha, state.trips);
|
|
302
|
-
answer.brake = { on: state.on, until: state.until, days: state.days, trips: state.trips, undo: undo?.culprit ?? null, cleared: state.cleared };
|
|
303
|
-
for (const one of state.cleared) answer.rows.push({ kind: 'note', class: 'brake', cause: `the owner's signed clear ended the trip set by ${one.sha.slice(0, 7)} (key ${one.key}, ${one.signed_at})` });
|
|
304
|
-
answer.rows.push(...brakeRows(state, undo, list.listed ? named : null));
|
|
305
|
-
}
|
|
306
299
|
// The rules come from the base commit, never from the merge base. An agent
|
|
307
300
|
// picks the merge base when it picks where its branch starts, also a middle
|
|
308
301
|
// commit of a merged pull request that weakened the list and then took the
|
|
309
302
|
// change back (item d56a9c03). The diff stays merge base to head.
|
|
310
303
|
const ruleRef = rules ?? baseSha;
|
|
304
|
+
// The mode comes from the same ref as the rules: the base, never the head.
|
|
305
|
+
const strict = strictAt(top, ruleRef);
|
|
306
|
+
answer.mode = strict ? 'strict' : 'normal';
|
|
307
|
+
// Normal mode is lenient (review mark, atlas-fix) only for a pull request
|
|
308
|
+
// that an insider opened: author association OWNER, MEMBER or COLLABORATOR.
|
|
309
|
+
// With no author read, the author is an outsider, and the controls of strict
|
|
310
|
+
// mode hold: owner rows stay owner, and the signed act path still works.
|
|
311
|
+
const lenient = !strict && insider(author);
|
|
312
|
+
answer.author = insider(author) ? 'insider' : 'outsider';
|
|
313
|
+
let undo = null;
|
|
314
|
+
if (brake !== false) {
|
|
315
|
+
const state = brakeState({ root: top, base: baseSha, now, ci: brake?.ci ?? [], clears: brake?.clears ?? null, strict, green: brake?.green ?? null });
|
|
316
|
+
undo = undoOf(top, mergeBase, headSha, state.trips);
|
|
317
|
+
const fixed = lenient && fixes ? fixOf(body, state.trips) : [];
|
|
318
|
+
answer.brake = { on: state.on, until: state.until, days: state.days, trips: state.trips, undo: undo?.culprit ?? null, cleared: state.cleared, ended: state.ended ?? [], fix: fixed[0]?.culprit ?? null };
|
|
319
|
+
for (const one of state.cleared) answer.rows.push({ kind: 'note', class: 'brake', cause: `the owner's signed clear ended the trip set by ${one.sha.slice(0, 7)} (key ${one.key}, ${one.signed_at})` });
|
|
320
|
+
for (const one of state.ended ?? []) answer.rows.push({ kind: 'note', class: 'brake', cause: `the trip set by ${one.sha.slice(0, 7)} ended: ${one.green.slice(0, 7)} on main has every required check green` });
|
|
321
|
+
answer.rows.push(...brakeRows(state, undo, list.listed ? named : null, { lenient, fixed }));
|
|
322
|
+
}
|
|
311
323
|
for (const row of classify({ root: top, base: mergeBase, head: headSha, rules: ruleRef }).rows) answer.rows.push({ kind: 'owner', class: row.class ?? 'unlisted', path: row.path, cause: row.cause });
|
|
312
324
|
answer.rows.push(...kindRows(list, named));
|
|
313
325
|
answer.rows.push(...jobRows(top, ruleRef, headSha, changes));
|
|
314
326
|
answer.rows.push(...secretRows(top, mergeBase, headSha, changes));
|
|
315
327
|
if (terms !== undefined) answer.rows.push(...termRows(top, baseSha, headSha, changes, terms));
|
|
316
328
|
answer.rows.push(...testRows({ root: top, mergeBase, head: headSha, body, redMain, rules: ruleRef, undo }));
|
|
317
|
-
if (
|
|
329
|
+
if (lenient) reviewRows(top, baseSha, headSha, marks, answer);
|
|
330
|
+
else if (signed) signedRows(top, baseSha, headSha, signed, answer, ruleRef, now);
|
|
318
331
|
return finish(answer);
|
|
319
332
|
}
|
|
320
333
|
|
|
334
|
+
// Normal mode (item bca1e8fb): no owner row stays. A head job named like a
|
|
335
|
+
// check and a brake hold are red. Each other owner row is red with class
|
|
336
|
+
// `review` until a second agent posts a review mark for this head. The mark
|
|
337
|
+
// counts from an author with the association OWNER, MEMBER or COLLABORATOR.
|
|
338
|
+
// It carries over a clean merge of the base, by the rule of the signed act. The
|
|
339
|
+
// mark proves that a review ran. It does not stop a session that forges it.
|
|
340
|
+
function reviewRows(root, baseSha, headSha, marks, answer) {
|
|
341
|
+
for (const row of answer.rows) if (row.kind === 'owner' && (row.class === 'gate-checks' || row.class === 'brake' || row.class === 'too-large')) row.kind = 'red';
|
|
342
|
+
const owner = answer.rows.filter((row) => row.kind === 'owner');
|
|
343
|
+
if (!owner.length) return;
|
|
344
|
+
const note = (cause) => answer.rows.push({ kind: 'note', class: 'review', cause });
|
|
345
|
+
const turn = (why) => { for (const row of owner) { row.cause = `${why}: ${row.class}: ${row.cause}`; row.class = 'review'; row.kind = 'red'; } };
|
|
346
|
+
let list = marks;
|
|
347
|
+
if (typeof marks === 'function') { try { list = marks(); } catch { list = null; } }
|
|
348
|
+
const heads = reviewHeads(list);
|
|
349
|
+
if (heads.includes(headSha)) {
|
|
350
|
+
for (const row of owner) row.kind = 'signed';
|
|
351
|
+
answer.review = { head: headSha };
|
|
352
|
+
note(`a review mark names this head ${headSha.slice(0, 7)}`);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
for (const old of heads.slice(0, CARRY_TRIES)) {
|
|
356
|
+
if (commitOf(root, old) !== old || !contains(root, old, headSha)) continue;
|
|
357
|
+
const base = (git(root, [MERGE_BASE, '--end-of-options', baseSha, old], { allowFail: true }) ?? '').trim();
|
|
358
|
+
if (!base) continue;
|
|
359
|
+
const result = carryOne(root, baseSha, headSha, answer.merge_base, { head: old, merge_base: base }, null);
|
|
360
|
+
if (!result.ok) continue;
|
|
361
|
+
for (const row of owner) row.kind = 'signed';
|
|
362
|
+
answer.review = { head: headSha, carried_from: old };
|
|
363
|
+
note(`a review mark names head ${old.slice(0, 7)}, and this head only adds ${result.merges} clean merge(s) of the base`);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
turn(heads.length ? 'the review mark names another head, so a new review is needed' : 'no review mark for this head, so a second agent must review it');
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Normal mode: a pull request whose text says `atlas-fix: <sha>` names the
|
|
370
|
+
// trips of the brake at that commit: at least 7 hex, a prefix of the sha or
|
|
371
|
+
// the culprit of the trip. The brake lets the pull request pass for those
|
|
372
|
+
// trips only. Other trips still hold. Returns the trips that it names.
|
|
373
|
+
export function fixOf(body, trips) {
|
|
374
|
+
const text = String(body ?? '').replace(/<!--[\s\S]*?-->/g, '');
|
|
375
|
+
const named = new Set();
|
|
376
|
+
for (const hit of text.matchAll(/^atlas-fix:[ \t]*([0-9a-f]{7,40})[ \t]*$/gim)) {
|
|
377
|
+
for (const trip of trips) if ([trip.sha, trip.culprit].some((sha) => sha && sha.startsWith(hit[1].toLowerCase()))) named.add(trip);
|
|
378
|
+
}
|
|
379
|
+
return [...named];
|
|
380
|
+
}
|
|
381
|
+
|
|
321
382
|
// Done line 4 of R72: a valid signed act for this head clears each owner row.
|
|
322
383
|
// It never clears red: the pull request must be fixed first. The key file is
|
|
323
384
|
// read from the base commit, so a key that the pull request adds does not count.
|
|
@@ -568,9 +629,11 @@ function offPages(root, a, b, built, pages, checks) {
|
|
|
568
629
|
// holds as before.
|
|
569
630
|
const holds = (trip, named) => !named || !trip.kinds || named.unlisted.length > 0 || trip.kinds.some((kind) => named.names.includes(kind));
|
|
570
631
|
|
|
571
|
-
function brakeRows(state, undo, named = null) {
|
|
632
|
+
function brakeRows(state, undo, named = null, { lenient = false, fixed = [] } = {}) {
|
|
572
633
|
if (!state.on) return [];
|
|
573
|
-
const holding = state.trips.filter((trip) => holds(trip, named));
|
|
634
|
+
const holding = state.trips.filter((trip) => holds(trip, named) && !fixed.includes(trip));
|
|
635
|
+
const fixNote = fixed.length ? [{ kind: 'note', class: 'brake', cause: `the fix of the trip set by ${fixed[0].sha.slice(0, 7)} (atlas-fix in the text): ${tripText(fixed[0])}` }] : [];
|
|
636
|
+
if (lenient && !undo && fixed.length && !holding.length) return fixNote;
|
|
574
637
|
if (!undo && !holding.length) {
|
|
575
638
|
const kinds = [...new Set(state.trips.flatMap((trip) => trip.kinds))].join(', ');
|
|
576
639
|
return [{ kind: 'note', class: 'brake', cause: `the brake is on until ${state.until.slice(0, 16).replace('T', ' ')} UTC for ${kinds}; this pull request is ${named.names.join(', ') || 'no kind'}` }];
|
|
@@ -579,6 +642,7 @@ function brakeRows(state, undo, named = null) {
|
|
|
579
642
|
const more = holding.length > 1 ? `; ${holding.length} trips in ${state.days} days` : '';
|
|
580
643
|
if (undo) return [{ kind: 'note', class: 'brake', cause: `the exact undo of ${undo.culprit.slice(0, 7)}, which tripped the brake: ${tripText(undo)}` }];
|
|
581
644
|
const until = new Date(Date.parse(newest.time) + state.days * 864e5).toISOString();
|
|
645
|
+
if (lenient) return [...fixNote, { kind: 'red', class: 'brake', cause: `the brake is on: ${tripText(newest)}${more}. It ends at the first commit on main after it where its check is green. The exact undo, or a pull request whose text says atlas-fix: ${newest.sha.slice(0, 7)}, may merge` }];
|
|
582
646
|
const owner = { kind: 'owner', class: 'brake', cause: `the brake is on until ${until.slice(0, 16).replace('T', ' ')} UTC: ${tripText(newest)}${more}` };
|
|
583
647
|
// Done line 6: one signed clear for each trip, as a comment on its commit.
|
|
584
648
|
const clear = (holding.length ? holding : [newest]).map((trip) => ({ kind: 'note', class: 'brake', cause: `to end the trip now: atlas approve act brake-clear --repo <owner/repo> --commit ${trip.sha}, then post the block as a comment on commit ${trip.sha.slice(0, 7)} and run the atlas-brake-clear workflow` }));
|
|
@@ -610,6 +674,8 @@ export function renderText(answer) {
|
|
|
610
674
|
}
|
|
611
675
|
if (answer.result === 'red') out.push(`RED: ${oneLine(answer.rows.find((row) => row.kind === 'red').cause)}. Fix it; red wins over owner.`);
|
|
612
676
|
else if (answer.result === 'owner') out.push(`OWNER: ${oneLine(answer.class)}. ${answer.signed_act}`);
|
|
677
|
+
else if (answer.review?.carried_from) out.push(`SUCCESS: a review mark names head ${oneLine(answer.review.carried_from).slice(0, 7)}, and this head only merges in the base.`);
|
|
678
|
+
else if (answer.review) out.push('SUCCESS: a second agent reviewed this head.');
|
|
613
679
|
else if (answer.signed?.carried_from) out.push(`SUCCESS: the owner signed head ${oneLine(answer.signed.carried_from).slice(0, 7)}, and this head only merges in the base.`);
|
|
614
680
|
else if (answer.signed?.window) out.push(`SUCCESS: the owner's signing window covers this pull request until ${oneLine(answer.signed.window.ends)}.`);
|
|
615
681
|
else if (answer.signed) out.push('SUCCESS: the owner signed this head.');
|
|
@@ -623,7 +689,7 @@ export function renderText(answer) {
|
|
|
623
689
|
// job runs with --repo. `comments` gives the comment texts of the pull request
|
|
624
690
|
// when it also runs with --pr, so a signed act can clear owner. Exit 0 on
|
|
625
691
|
// success, 1 on owner or red, 2 on a usage error.
|
|
626
|
-
export function checkCommand({ root, base, head, body: bodyFile, json = false, summary, output, repo = null, pr = null, baseRef = null }, line, read = null, comments = null) {
|
|
692
|
+
export function checkCommand({ root, base, head, body: bodyFile, json = false, summary, output, repo = null, pr = null, baseRef = null }, line, read = null, comments = null, reviews = null, authorOf = null) {
|
|
627
693
|
try {
|
|
628
694
|
if (!base || base === true) throw Object.assign(new GateCheckError('atlas gate check needs --base <ref>.'), { usage: true });
|
|
629
695
|
if (!head || head === true) throw Object.assign(new GateCheckError('atlas gate check needs --head <sha>.'), { usage: true });
|
|
@@ -638,8 +704,13 @@ export function checkCommand({ root, base, head, body: bodyFile, json = false, s
|
|
|
638
704
|
try { texts = comments(); } catch { texts = null; }
|
|
639
705
|
signed = { repo, pr: Number(pr), baseRef: baseRef && baseRef !== true ? String(baseRef) : null, texts };
|
|
640
706
|
}
|
|
707
|
+
|
|
708
|
+
// A function, so the gate reads them only in normal mode when a row needs them.
|
|
709
|
+
const marks = reviews && repo && pr ? reviews : null;
|
|
641
710
|
const { terms } = termsFromEnv();
|
|
642
|
-
|
|
711
|
+
let author = null;
|
|
712
|
+
if (authorOf && repo && pr) { try { author = authorOf(); } catch { author = null; } }
|
|
713
|
+
const answer = hideTerms(judge({ root, base, head, body, redMain, brake: Array.isArray(got) ? {} : got.brake ?? {}, signed, terms, marks, author }), terms);
|
|
643
714
|
const text = renderText(answer);
|
|
644
715
|
if (json) process.stdout.write(`${JSON.stringify(answer, null, 2)}\n`);
|
|
645
716
|
else for (const each of text) line(each);
|
package/door/lib/gate.mjs
CHANGED
|
@@ -25,6 +25,7 @@ import { keepFindings, gateErrors, pinCode, slug, AUDIT_JOB } from './gate-audit
|
|
|
25
25
|
import { MARKER as SIGNED_MARKER, SIGNERS_FILE, AUDIT_CHECK, parseSigners, verifyTexts, findAct, auditCodeDiff } from './signed-act.mjs';
|
|
26
26
|
import { WINDOW_LABEL } from './window.mjs';
|
|
27
27
|
import { brakeFacts, windowCommits, ciTrips, BrakeError } from './brake.mjs';
|
|
28
|
+
import { strictAt, insider, REVIEW_MARKER } from './strict.mjs';
|
|
28
29
|
|
|
29
30
|
export const BRANCH_RULESET = 'atlas gate';
|
|
30
31
|
export const TAG_RULESET = 'atlas release tags';
|
|
@@ -256,10 +257,15 @@ export const gateRunTitle = (pr, sha) => `gate #${pr} at ${sha}`;
|
|
|
256
257
|
export function refusal(run, { name, pr, sha, baseRef, homes, sides = () => [], audit = false, baseChanged = () => '' }) {
|
|
257
258
|
if (!run) return 'no workflow run';
|
|
258
259
|
if (!homes.get(name)?.has(run.path)) return `from ${run.path ?? 'no file'}, not a base file that defines the check`;
|
|
259
|
-
|
|
260
|
+
// Each job of the gate file, `gate` and `gate · shape`, runs on
|
|
261
|
+
// pull_request_target, so GitHub runs the base copy, and its title names the
|
|
262
|
+
// pull request and the head. A run of that file from another event runs a
|
|
263
|
+
// head copy, so it never counts.
|
|
264
|
+
const gateFile = name === GATE_CHECK || run.path === GATE_WORKFLOW;
|
|
265
|
+
const event = gateFile ? 'pull_request_target' : 'pull_request';
|
|
260
266
|
if (run.event !== event) return `from the event ${run.event}`;
|
|
261
267
|
if (run.head_sha !== sha) return 'of another head commit';
|
|
262
|
-
if (
|
|
268
|
+
if (gateFile && run.display_title !== gateRunTitle(pr, sha)) return 'of another pull request or head';
|
|
263
269
|
const pulls = run.pull_requests ?? [];
|
|
264
270
|
if (!audit && !pulls.some((each) => Number(each.number) === Number(pr) && each.base?.ref === baseRef)) return `not of #${pr} into ${baseRef}`;
|
|
265
271
|
// GitHub lists every open pull request with this head, so a run made for a
|
|
@@ -603,7 +609,7 @@ export function ownerUnsigned({ root, repo, pull, parent }, gh) {
|
|
|
603
609
|
if (!/^[0-9a-f]{40}$/.test(String(head)) || !/^[0-9a-f]{40}$/.test(String(parent)) || !has(head) || !has(parent)) return 'unread';
|
|
604
610
|
let texts = null;
|
|
605
611
|
try { texts = [...prComments(gh, repo, pull.number), ...windowComments(gh, repo)]; } catch { texts = null; }
|
|
606
|
-
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 } });
|
|
612
|
+
const answer = judge({ root, base: parent, head, body: String(pull.body ?? ''), now: new Date(pull.merged_at), author: pull.author_association ?? null, fixes: false, signed: { repo, pr: Number(pull.number), baseRef: pull.base?.ref ?? null, texts } });
|
|
607
613
|
return answer.result === 'owner' ? 'owner' : null;
|
|
608
614
|
}
|
|
609
615
|
|
|
@@ -709,6 +715,30 @@ export function prComments(gh, repo, pr) {
|
|
|
709
715
|
return kept;
|
|
710
716
|
}
|
|
711
717
|
|
|
718
|
+
// The review marks of one pull request (item bca1e8fb): each comment that
|
|
719
|
+
// holds the review marker, as { body, association }. GitHub states the
|
|
720
|
+
// association of the author itself. At most ten pages of a hundred, newest
|
|
721
|
+
// first, and at most 2 MB of text in all. The gate reads them as data only.
|
|
722
|
+
// Only a comment of an insider (OWNER, MEMBER or COLLABORATOR) is kept, and it
|
|
723
|
+
// is kept before the size cap, so an outsider cannot push a mark out. Each
|
|
724
|
+
// mark keeps its created_at, so the release hold can drop a late mark.
|
|
725
|
+
export function reviewComments(gh, repo, pr) {
|
|
726
|
+
const marks = [];
|
|
727
|
+
for (let page = 1; page <= 10; page += 1) {
|
|
728
|
+
const rows = gh('GET', `repos/${repo}/issues/${pr}/comments?per_page=100&page=${page}`) ?? [];
|
|
729
|
+
for (const row of Array.isArray(rows) ? rows : []) if (typeof row?.body === 'string' && row.body.includes(REVIEW_MARKER) && insider(row.author_association)) marks.push({ body: row.body, association: String(row.author_association), created_at: String(row.created_at ?? '') });
|
|
730
|
+
if (!Array.isArray(rows) || rows.length < 100) break;
|
|
731
|
+
}
|
|
732
|
+
const kept = [];
|
|
733
|
+
let bytes = 0;
|
|
734
|
+
for (const mark of marks.reverse()) {
|
|
735
|
+
bytes += mark.body.length;
|
|
736
|
+
if (bytes > 2 * 1024 * 1024) break;
|
|
737
|
+
kept.push(mark);
|
|
738
|
+
}
|
|
739
|
+
return kept;
|
|
740
|
+
}
|
|
741
|
+
|
|
712
742
|
// The comment texts of the window issues that carry the signed-act marker:
|
|
713
743
|
// where `atlas approve window` posts each window act (item 4af66381). The
|
|
714
744
|
// newest five issues with the label, open or closed, and three pages of
|
|
@@ -799,9 +829,13 @@ export function holdRow({ repo, root, sha }, gh, git) {
|
|
|
799
829
|
let answer;
|
|
800
830
|
try {
|
|
801
831
|
const at = new Date(pull.merged_at);
|
|
802
|
-
const brake =
|
|
832
|
+
const brake = brakeInput({ root, base: parent, now: at }, gh, repo);
|
|
833
|
+
// In normal mode the hold reads the review marks too: only a red row holds the release.
|
|
834
|
+
// A mark counts only when it came before the merge. The hold reads no atlas-fix line.
|
|
835
|
+
let marks = null;
|
|
836
|
+
if (!strictAt(root, parent)) { try { marks = reviewComments(gh, repo, pull.number).filter((mark) => mark.created_at && Date.parse(mark.created_at) <= at.getTime()); } catch { marks = null; } }
|
|
803
837
|
// The red main checks of its parent, as the gate read them, so a fix of a red main check is judged as the gate judged it.
|
|
804
|
-
answer = judge({ root, base: parent, head, body: String(pull.body ?? ''), now: at, brake, redMain: redMain({ root, base: parent }, gh, repo), signed: { repo, pr: Number(pull.number), baseRef: pull.base?.ref ?? null, texts } });
|
|
838
|
+
answer = judge({ root, base: parent, head, body: String(pull.body ?? ''), now: at, brake, redMain: redMain({ root, base: parent }, gh, repo), marks, author: pull.author_association ?? null, fixes: false, signed: { repo, pr: Number(pull.number), baseRef: pull.base?.ref ?? null, texts } });
|
|
805
839
|
} catch (error) { return `#${pull.number}: it cannot be judged (${String(error?.message ?? error).split('\n')[0].slice(0, 160)})`; }
|
|
806
840
|
if (answer.result === 'success') return null;
|
|
807
841
|
// The gate did not say success. An accept act, or an accept-code act for a
|
|
@@ -878,6 +912,43 @@ export function brakeRead({ root, base, now = new Date() }, gh, repo, pages = 3)
|
|
|
878
912
|
return ciTrips({ commits, runs, homes, checks, live: facts.live, liveChecks }, jobsOf);
|
|
879
913
|
}
|
|
880
914
|
|
|
915
|
+
// Normal mode (item bca1e8fb): the first-parent commits of the base, in the
|
|
916
|
+
// brake window, that have every required check green. The brake ends a trip
|
|
917
|
+
// at the first of them, at or after the trip. A commit whose checks cannot be
|
|
918
|
+
// read counts as not green. The `gate` check is left out, as in brakeRead.
|
|
919
|
+
// Some required checks run only on a pull request (fails-before, the upkeep
|
|
920
|
+
// check), so a commit of main never has them. The answer maps each commit to
|
|
921
|
+
// { ok, checks }: ok when at least one required check ran on it and each one
|
|
922
|
+
// that ran is green, and checks as the names of the green checks. The live
|
|
923
|
+
// checks of the brake (`live: deploy`) are read too, for a live trip.
|
|
924
|
+
export function greenRead({ root, base, now = new Date() }, gh, repo) {
|
|
925
|
+
const git = (args) => execFileSync('git', ['-C', root, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
926
|
+
const sha = git(['rev-parse', '--verify', '--end-of-options', `${base}^{commit}`]);
|
|
927
|
+
let facts;
|
|
928
|
+
try { facts = brakeFacts(git(['show', `${sha}:atlas.yaml`])); } catch { return new Map(); }
|
|
929
|
+
const checks = facts.checks.filter((name) => name !== GATE_CHECK);
|
|
930
|
+
if (!checks.length) return new Map();
|
|
931
|
+
const live = facts.live === 'deploy' ? facts.liveChecks.filter((name) => !checks.includes(name)) : [];
|
|
932
|
+
const green = new Map();
|
|
933
|
+
for (const commit of windowCommits(root, sha, new Date(now.getTime() - facts.days * 864e5))) {
|
|
934
|
+
try {
|
|
935
|
+
const runs = checkRuns(gh, repo, commit.sha);
|
|
936
|
+
const ran = checkStates(checks, runs).filter((row) => row.state !== 'missing');
|
|
937
|
+
const states = checkStates([...checks, ...live], runs);
|
|
938
|
+
green.set(commit.sha, { ok: ran.length > 0 && ran.every((row) => row.state === 'green'), checks: new Set(states.filter((row) => row.state === 'green').map((row) => row.name)) });
|
|
939
|
+
} catch { /* not green */ }
|
|
940
|
+
}
|
|
941
|
+
return green;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// What the brake reads from GitHub for one judgment: the trips, the signed
|
|
945
|
+
// clears, and in normal mode the green commits of main.
|
|
946
|
+
export function brakeInput({ root, base, now }, gh, repo) {
|
|
947
|
+
const input = { ci: brakeRead({ root, base, now }, gh, repo), clears: { repo, commentsOf: (sha) => commitComments(gh, repo, sha) } };
|
|
948
|
+
if (!strictAt(root, base)) input.green = greenRead({ root, base, now }, gh, repo);
|
|
949
|
+
return input;
|
|
950
|
+
}
|
|
951
|
+
|
|
881
952
|
// `atlas gate apply|ready|audit|check|replay|hold`. Returns the exit code.
|
|
882
953
|
export function gateCommand({ what, repo, root, dryRun = false, off = false, on = false, pr, since, commit, from, base, baseRef, head, body, json = false, summary, output, days, branch, errors = false, forced = false, findings, home = homedir() }, line, gh = ghApi) {
|
|
883
954
|
if (!['apply', 'ready', 'audit', 'check', 'replay', 'hold'].includes(what)) throw new GateError(`there is no gate verb "${what ?? ''}". Use apply, ready, audit, check, replay or hold.`);
|
|
@@ -885,7 +956,7 @@ export function gateCommand({ what, repo, root, dryRun = false, off = false, on
|
|
|
885
956
|
const named = repo && repo !== true && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) && !repo.split('/').some((part) => /^\.+$/.test(part));
|
|
886
957
|
if (what === 'check') {
|
|
887
958
|
const number = /^[1-9][0-9]{0,8}$/.test(String(pr ?? '')) ? Number(pr) : null;
|
|
888
|
-
return checkCommand({ root, base, head, body, json, summary, output, repo: named ? repo : null, pr: pr === undefined ? null : pr, baseRef: baseRef ?? null }, line, named ? () => ({ redMain: redMain({ root, base }, gh, repo), brake:
|
|
959
|
+
return checkCommand({ root, base, head, body, json, summary, output, repo: named ? repo : null, pr: pr === undefined ? null : pr, baseRef: baseRef ?? null }, line, named ? () => ({ redMain: redMain({ root, base }, gh, repo), brake: brakeInput({ root, base }, gh, repo) }) : null, named && number ? () => [...prComments(gh, repo, number), ...windowComments(gh, repo)] : null, named && number ? () => reviewComments(gh, repo, number) : null, named && number ? () => gh('GET', `repos/${repo}/pulls/${number}`)?.author_association ?? null : null);
|
|
889
960
|
}
|
|
890
961
|
if (what === 'replay') return replayCommand({ root, days, branch, json }, line);
|
|
891
962
|
if (!named) throw new GateError(`atlas gate ${what} needs --repo <owner/name>.`);
|
package/door/lib/runner.mjs
CHANGED
|
@@ -38,6 +38,7 @@ import { jobMarker, typedFindings, JOBS_LABEL, atlasJobs } from './jobs.mjs';
|
|
|
38
38
|
import { repoOk, TOKEN_SHAPE, DEFAULT_BOT, api, apiBase } from './issues.mjs';
|
|
39
39
|
import { ghApi } from './gate.mjs';
|
|
40
40
|
import { PACKAGE_NAME } from './gate-audit.mjs';
|
|
41
|
+
import { strictMode } from './strict.mjs';
|
|
41
42
|
|
|
42
43
|
const usage = (message) => Object.assign(new Error(message), { usage: true });
|
|
43
44
|
const DAY = 24 * 60 * 60 * 1000;
|
|
@@ -47,6 +48,9 @@ const HOUR = 60 * 60 * 1000;
|
|
|
47
48
|
// request that the gate sends to him.
|
|
48
49
|
export const RUNNER_REPOS = Object.freeze(['Arjunkhera/atlas-scratch']);
|
|
49
50
|
export const RELEASE_DAYS = 2;
|
|
51
|
+
// Normal mode (item bca1e8fb): the repo's atlas.yaml has no `strict_mode: on`,
|
|
52
|
+
// so the runner waits no days for a release.
|
|
53
|
+
export const NORMAL_RELEASE_DAYS = 0;
|
|
50
54
|
export const MAX_OPEN = 5;
|
|
51
55
|
export const MAX_WAITING = 3;
|
|
52
56
|
export const CLAIM_HOURS = 6;
|
|
@@ -65,13 +69,13 @@ export function repoAllowed(repo, allowed = RUNNER_REPOS) {
|
|
|
65
69
|
|
|
66
70
|
// The newest version that is RELEASE_DAYS old, from the npm document of the
|
|
67
71
|
// package. `newest` is npm latest; `use` is what the runner may load.
|
|
68
|
-
export function releaseToUse(doc, now = Date.now()) {
|
|
72
|
+
export function releaseToUse(doc, now = Date.now(), days = RELEASE_DAYS) {
|
|
69
73
|
const times = doc?.time ?? {};
|
|
70
74
|
const newest = doc?.['dist-tags']?.latest ?? null;
|
|
71
75
|
const versions = Object.keys(doc?.versions ?? {})
|
|
72
76
|
.filter((version) => Number.isFinite(Date.parse(times[version])))
|
|
73
77
|
.sort((a, b) => Date.parse(times[b]) - Date.parse(times[a]));
|
|
74
|
-
const use = versions.find((version) => now - Date.parse(times[version]) >=
|
|
78
|
+
const use = versions.find((version) => now - Date.parse(times[version]) >= days * DAY) ?? null;
|
|
75
79
|
return { newest, use, newest_age_days: newest && times[newest] ? Math.floor((now - Date.parse(times[newest])) / DAY) : null };
|
|
76
80
|
}
|
|
77
81
|
|
|
@@ -190,6 +194,16 @@ export function capsOf({ gh, repo }) {
|
|
|
190
194
|
|
|
191
195
|
// ------------------------------------------------------------ start
|
|
192
196
|
|
|
197
|
+
// True unless atlas.yaml on the branch is readable and does not say
|
|
198
|
+
// `strict_mode: on`.
|
|
199
|
+
export function strictRepo({ gh, repo, branch }) {
|
|
200
|
+
try {
|
|
201
|
+
const file = gh('GET', `repos/${repo}/contents/atlas.yaml?ref=${encodeURIComponent(branch)}`);
|
|
202
|
+
if (typeof file?.content !== 'string') return true;
|
|
203
|
+
return strictMode(Buffer.from(file.content, 'base64').toString('utf8'));
|
|
204
|
+
} catch { return true; }
|
|
205
|
+
}
|
|
206
|
+
|
|
193
207
|
export async function runnerStart({ repo, gh = ghApi, env = process.env, now = Date.now(), fetchImpl = fetch, allowed = RUNNER_REPOS, jobs = atlasJobs() }) {
|
|
194
208
|
const stop = (why, extra = {}) => ({ repo, go: false, stop: why, ...extra });
|
|
195
209
|
if (!repoAllowed(repo, allowed)) return stop(`${repo} is not a runner repo. Until the owner records the A2 exit test, the runner works on ${allowed.join(', ')} only (R25 line 13)`);
|
|
@@ -203,8 +217,11 @@ export async function runnerStart({ repo, gh = ghApi, env = process.env, now = D
|
|
|
203
217
|
const audit = auditOnTip({ gh, repo, branch: main });
|
|
204
218
|
if (!audit.ok) return stop(`${audit.why} (R25 line 11)`, { audit });
|
|
205
219
|
let release;
|
|
206
|
-
|
|
207
|
-
|
|
220
|
+
// The wait comes from the target repo's atlas.yaml on its main branch. A
|
|
221
|
+
// file that GitHub cannot give counts as strict: the longer wait.
|
|
222
|
+
const days = strictRepo({ gh, repo, branch: main }) ? RELEASE_DAYS : NORMAL_RELEASE_DAYS;
|
|
223
|
+
try { release = releaseToUse(await npmDoc(fetchImpl), now, days); } catch (error) { return stop(`npm could not be read: ${error.message}`); }
|
|
224
|
+
if (!release.use) return stop(`no release of ${PACKAGE_NAME} is ${days} days old yet`, { release });
|
|
208
225
|
const { inputs, outside, held } = readInputs({ gh, repo, branch: main, now, jobs });
|
|
209
226
|
const caps = capsOf({ gh, repo });
|
|
210
227
|
return { repo, go: true, stop: null, branch: main, release, audit, caps, inputs, outside, held };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Strict mode (item bca1e8fb; design docs/design-docs/pipeline/controls-off.md).
|
|
2
|
+
// The owner controls are off by default. A repo turns them on with one key in
|
|
3
|
+
// atlas.yaml: `strict_mode: on`. With no key, or with `off` or `false`, the
|
|
4
|
+
// repo is in normal mode. Any other value, or a file that does not read, is
|
|
5
|
+
// strict mode. The gate reads the key from the base commit, never from the
|
|
6
|
+
// head. The key is on the fixed list of the owner-only floor, so only the
|
|
7
|
+
// owner turns the mode off in a strict repo.
|
|
8
|
+
//
|
|
9
|
+
// strictMode(text) true unless the key is missing, off or false
|
|
10
|
+
// strictAt(root, ref) the same, from the file at one commit
|
|
11
|
+
// insider(association) true for OWNER, MEMBER or COLLABORATOR
|
|
12
|
+
// reviewHeads(marks) the heads that a valid review mark names
|
|
13
|
+
//
|
|
14
|
+
// Do not confuse this key with the `strict` option of parseYaml in
|
|
15
|
+
// shape/check.mjs. That option makes the reader refuse odd YAML.
|
|
16
|
+
import { spawnSync } from 'node:child_process';
|
|
17
|
+
import { parseYaml } from '../../shape/check.mjs';
|
|
18
|
+
|
|
19
|
+
export const STRICT_KEY = 'strict_mode';
|
|
20
|
+
// The marker line of a review mark, and the line that names the reviewed head.
|
|
21
|
+
export const REVIEW_MARKER = '<!-- atlas:review -->';
|
|
22
|
+
// Only these authors count. GitHub states the association itself; a session
|
|
23
|
+
// cannot set it on a comment.
|
|
24
|
+
export const REVIEW_AUTHORS = Object.freeze(['OWNER', 'MEMBER', 'COLLABORATOR']);
|
|
25
|
+
|
|
26
|
+
// Fail closed. A missing key, `off` or `false` gives normal mode. Any other
|
|
27
|
+
// value (on, ON, yes, true, odd text) gives strict mode. A text that does not
|
|
28
|
+
// read gives strict mode.
|
|
29
|
+
export function strictMode(text) {
|
|
30
|
+
if (typeof text !== 'string') return true;
|
|
31
|
+
let facts;
|
|
32
|
+
try { facts = parseYaml(text) ?? {}; } catch { return true; }
|
|
33
|
+
if (typeof facts !== 'object' || Array.isArray(facts)) return true;
|
|
34
|
+
if (!(STRICT_KEY in facts)) return false;
|
|
35
|
+
const value = facts[STRICT_KEY];
|
|
36
|
+
return !(value === false || value === 'off');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The mode at one commit. A commit with no atlas.yaml is normal mode. A
|
|
40
|
+
// commit that git cannot read is strict mode.
|
|
41
|
+
export function strictAt(root, ref) {
|
|
42
|
+
const r = spawnSync('git', ['show', `${ref}:atlas.yaml`], { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
43
|
+
if (r.status === 0) return strictMode(r.stdout);
|
|
44
|
+
const has = spawnSync('git', ['cat-file', '-e', `${ref}^{commit}`], { cwd: root });
|
|
45
|
+
return has.status !== 0 || r.error !== undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// True for an author association that counts as an insider.
|
|
49
|
+
export const insider = (association) => REVIEW_AUTHORS.includes(String(association));
|
|
50
|
+
|
|
51
|
+
// The marks that count: each is { body, association }. A mark needs the marker
|
|
52
|
+
// line, and the next non-empty line is the pass line with the 40 hex of a head.
|
|
53
|
+
// A comment with more than one pass line counts for nothing. The author needs
|
|
54
|
+
// the association OWNER, MEMBER or COLLABORATOR. Newest first when the input is.
|
|
55
|
+
export function reviewHeads(marks) {
|
|
56
|
+
const heads = [];
|
|
57
|
+
for (const mark of Array.isArray(marks) ? marks : []) {
|
|
58
|
+
if (!mark || typeof mark.body !== 'string' || !insider(mark.association)) continue;
|
|
59
|
+
const lines = mark.body.split('\n').map((line) => line.trim());
|
|
60
|
+
const at = lines.indexOf(REVIEW_MARKER);
|
|
61
|
+
if (at < 0 || lines.filter((line) => /^atlas-review:\s*pass\b/.test(line)).length !== 1) continue;
|
|
62
|
+
const next = lines.slice(at + 1).find(Boolean) ?? '';
|
|
63
|
+
const hit = /^atlas-review: pass ([0-9a-f]{40})$/.exec(next);
|
|
64
|
+
if (hit && !heads.includes(hit[1])) heads.push(hit[1]);
|
|
65
|
+
}
|
|
66
|
+
return heads;
|
|
67
|
+
}
|
|
@@ -25,8 +25,10 @@ on:
|
|
|
25
25
|
# success. The key comes from atlas/owner-signers on the base. A new comment
|
|
26
26
|
# with a signed act reruns this run (atlas-gate-signed.yml).
|
|
27
27
|
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
28
|
+
# `gate` is in required_checks (roadmap A2, item 71b09109). `shape` is not
|
|
29
|
+
# yet: its owner answer reads no signed act. `atlas gate ready` counts a job
|
|
30
|
+
# of this file only from a pull_request_target run whose title names the pull
|
|
31
|
+
# request and the head.
|
|
30
32
|
#
|
|
31
33
|
# The second job, `shape`, is the repo shape check from the base (roadmap R7,
|
|
32
34
|
# design docs/design-docs/shape-check-from-base/). It is the job that
|
package/owner-only.yaml
CHANGED
package/package.json
CHANGED
package/skills/lead/SKILL.md
CHANGED
|
@@ -127,10 +127,19 @@ set. A test keeps the two lists equal.
|
|
|
127
127
|
| `atlas jobs` | The `atlas-jobs` workflow calls `check`, then `file`, once a day. A session calls it only to see what the watcher would file: `check`, then `file --dry-run` |
|
|
128
128
|
| `atlas runner` | Only in a runner run, as the `atlas:runner` skill says: `start` first, `claim` before each build, `line` last. A lead session never runs the runner |
|
|
129
129
|
| `atlas upkeep` | `check` before you ask for a merge. It reads the docs index, dead doc links, the code each doc names and stale pages. In a pull request it also reads the tracker item, the changed designs, and STE on the changed lines. The daily upkeep run calls it on the main branch. `replay` before the doc-sources rule changes how it gates. `procedures` runs the procedure steps tagged `atlas-run`; CI runs it with `--base` on each pull request |
|
|
130
|
-
| `atlas gate` | `apply` once per repo, by the owner on his Mac, after the repo's `required_checks` exist. `--dry-run` first. `--off` and `--on` are the break glass. On a plan with no rulesets it reports advisory mode. `ready` before each merge, `check` in the `atlas-gate` workflow, `replay` before a change to the owner-only list, `hold` only in the release job. `audit` in the daily upkeep run, and with `--commit` in the push job of a repo that has one |
|
|
131
|
-
| `atlas approve` | Only the owner runs it, never a session, on his Mac with his key's touch. When the gate says "owner" for a pull request, ask him once for the signed act: name each pull request, its class and its cause. Put the owner pull requests of one day in one ask, so one touch signs them. `act`, `verify` and `key-check` work the same way. Each morning with open audit issues, ask him for `audit`, and `audit-code <pr>` per audit-code merge; record its words as one decision, then close each issue. Where `signing_window: on`, ask him each evening for `window`: one touch sees the list of merges and opens a window of up to 12 hours |
|
|
130
|
+
| `atlas gate` | `apply` once per repo, only where `strict_mode: on`, by the owner on his Mac, after the repo's `required_checks` exist. `--dry-run` first. `--off` and `--on` are the break glass. On a plan with no rulesets it reports advisory mode. `ready` before each merge, `check` in the `atlas-gate` workflow, `replay` before a change to the owner-only list, `hold` only in the release job. `audit` in the daily upkeep run, and with `--commit` in the push job of a repo that has one |
|
|
131
|
+
| `atlas approve` | Only the owner runs it, never a session, and only where `strict_mode: on`, on his Mac with his key's touch. When the gate says "owner" for a pull request, ask him once for the signed act: name each pull request, its class and its cause. Put the owner pull requests of one day in one ask, so one touch signs them. `act`, `verify` and `key-check` work the same way. Each morning with open audit issues, ask him for `audit`, and `audit-code <pr>` per audit-code merge; record its words as one decision, then close each issue. Where `signing_window: on`, ask him each evening for `window`: one touch sees the list of merges and opens a window of up to 12 hours |
|
|
132
132
|
| `atlas judge` | In the fresh-agent test only, as the `atlas:fresh-agent-test` skill says. `seal` after the pick, `brief` to read a sealed ask, `run` for each sealed task, `check` after the grade |
|
|
133
133
|
|
|
134
|
+
**Strict mode** (item bca1e8fb, 14 November 2026): `atlas.yaml` holds
|
|
135
|
+
`strict_mode: on` or `off`, and no key means off. The gate reads the key from
|
|
136
|
+
the base commit. In normal mode a pull request merges on green. A change to
|
|
137
|
+
the rules of Atlas needs a review mark: a second agent posts a comment with
|
|
138
|
+
`<!-- atlas:review -->` and `atlas-review: pass <head sha>`. The signed act,
|
|
139
|
+
the window, `atlas approve`, the gate `apply` verb, the audit accept and the
|
|
140
|
+
runner wait hold only where the mode is on. Design:
|
|
141
|
+
`docs/design-docs/pipeline/controls-off.md`.
|
|
142
|
+
|
|
134
143
|
A person merges every file that `atlas tooling` writes.
|
|
135
144
|
A person merges every file that `atlas tests write` writes.
|
|
136
145
|
|
|
@@ -163,6 +163,12 @@ A lock is the owner's approval of a short design.
|
|
|
163
163
|
It refuses a push to the main branch and a force push. It refuses the
|
|
164
164
|
auto-merge tool; `gh pr merge --auto --match-head-commit` names the
|
|
165
165
|
head. When it refuses, do what its reason says. Never work around it.
|
|
166
|
+
6. In normal mode (`strict_mode` off or missing), a pull request merges
|
|
167
|
+
on green. A change to the rules of Atlas needs a review mark. Send a
|
|
168
|
+
reviewer crew with a fresh context. When it finds no S1, post a comment
|
|
169
|
+
with `<!-- atlas:review -->` and `atlas-review: pass <head sha>`. Never
|
|
170
|
+
ask for a signed act. The signed act, the window, `atlas approve` and
|
|
171
|
+
the gate `apply` verb hold only where `strict_mode: on`.
|
|
166
172
|
|
|
167
173
|
## pause
|
|
168
174
|
|