@arjunkhera/atlas 0.4.54 → 0.4.55
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 +65 -57
- package/door/lib/approve.mjs +115 -5
- package/door/lib/gate-audit.mjs +1 -0
- package/door/lib/gate-check.mjs +42 -2
- package/door/lib/gate.mjs +30 -2
- package/door/lib/signed-act.mjs +9 -1
- package/door/lib/window.mjs +271 -0
- package/owner-only.yaml +1 -0
- package/package.json +1 -1
- package/skills/lead/SKILL.md +1 -1
package/door/cli.mjs
CHANGED
|
@@ -15,35 +15,17 @@ import { resolve, join, dirname, basename } from 'node:path';
|
|
|
15
15
|
import { fileURLToPath } from 'node:url';
|
|
16
16
|
import { readFileSync, existsSync, realpathSync } from 'node:fs';
|
|
17
17
|
import { spawnSync } from 'node:child_process';
|
|
18
|
-
import { open } from './lib/pull-request.mjs';
|
|
19
|
-
import { write as writeTooling, changes as toolingChanges, ToolingRefusal, CHECK_PATH, WORKFLOW_PATH } from './lib/tooling.mjs';
|
|
20
|
-
import { statusMany, copies } from './lib/status.mjs';
|
|
21
18
|
import { scan, toText, verdict, CHECK_VERSION, SPECS } from '../shape/check.mjs';
|
|
22
19
|
import { packagePackageVersion } from './lib/releases.mjs';
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
import
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
import { proofCommand as testsProof } from './lib/proof.mjs';
|
|
33
|
-
import { verdictCommand as testsVerdict, namedCommand as testsNamed, rerunCommand as testsRerun } from '../tests/verdict.mjs';
|
|
34
|
-
import { runKept } from '../tests/kept.mjs';
|
|
35
|
-
import { sweepCommand as testsSweep, coversCommand as testsCovers } from '../tests/sweep.mjs';
|
|
36
|
-
import { issuesCommand as testsIssues } from './lib/issues.mjs';
|
|
37
|
-
import { jobsCommand } from './lib/jobs.mjs';
|
|
38
|
-
import { runnerCommand } from './lib/runner.mjs';
|
|
39
|
-
import { changedCommand as testsChanged } from './lib/changed-tests.mjs';
|
|
40
|
-
import { beforeCommand as testsBefore } from './lib/fails-before.mjs';
|
|
41
|
-
import { afterCommand as testsAfter } from './lib/passes-after.mjs';
|
|
42
|
-
import { upkeepCommand } from './lib/upkeep.mjs';
|
|
43
|
-
import { gateCommand, mergeList } from './lib/gate.mjs';
|
|
44
|
-
import { judgeCommand } from './lib/judge.mjs';
|
|
45
|
-
import { approveCommand } from './lib/approve.mjs';
|
|
46
|
-
|
|
20
|
+
// Each command loads its own code when it runs, with a plain `import()`. So
|
|
21
|
+
// `atlas gate check` loads only the gate's code, and a file that no gate
|
|
22
|
+
// command imports cannot change the gate (the signing window, item 4af66381,
|
|
23
|
+
// rule 1: the gate's import closure is its own code).
|
|
24
|
+
|
|
25
|
+
// The commands of the Mac setup, loaded when one runs.
|
|
26
|
+
const install = async (...args) => (await import('./lib/install.mjs')).install(...args);
|
|
27
|
+
const upgrade = async (...args) => (await import('./lib/install.mjs')).upgrade(...args);
|
|
28
|
+
const doctor = async (...args) => (await import('./lib/install.mjs')).doctor(...args);
|
|
47
29
|
export const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
48
30
|
const ATLAS_VERSION = packagePackageVersion(PACKAGE_ROOT);
|
|
49
31
|
|
|
@@ -182,6 +164,9 @@ const HELP = `atlas — the door into a repo's Atlas files
|
|
|
182
164
|
--body <file> the pull request text, for the "behaviour change: no" of a refactor
|
|
183
165
|
--json print the answer as JSON
|
|
184
166
|
--summary <file> --output <file> append the text to a job summary, and result= and class= to an output file
|
|
167
|
+
atlas gate hold --repo <owner/name> --commit <sha> the release job's owner hold: exit 3 when a merge since the owner's last seen commit has an owner change that no merge act signed
|
|
168
|
+
--branch <name> the branch; default GitHub's default branch
|
|
169
|
+
--root <repo> the clone with the history (default: this folder)
|
|
185
170
|
atlas gate replay [--days 30] the static rules of the gate over the first-parent commits of the main branch; the owner share. Writes nothing
|
|
186
171
|
--root <repo> the clone to read (default: this folder)
|
|
187
172
|
--branch <name> the branch; default origin/HEAD
|
|
@@ -195,6 +180,9 @@ const HELP = `atlas — the door into a repo's Atlas files
|
|
|
195
180
|
atlas approve act <kind> sign one other act and print its block: brake-clear --repo --commit, ready --repo --item, lock --item --card <file>, decision --item --words <file>, waiver --repo --pr --head --decision --check
|
|
196
181
|
--out <file> write the block here; default the terminal
|
|
197
182
|
atlas approve audit [<pr>...] the owner's acceptance of the night's audit reds, on his Mac: list each open audit issue of an owner merge that no touch signed, then one touch signs an accept act for each, the block goes on each pull request, each audit issue closes, and it prints the one message for the lead
|
|
183
|
+
atlas approve window the signing window, on his Mac in a clone: list each merge since the last seen commit, then one touch marks the tip seen and opens a window of up to 12 hours; the block goes on the atlas-window issue
|
|
184
|
+
--hours <n> the length of the window, 0 to 12; default 12. With 0 the touch only marks the list seen
|
|
185
|
+
--list show the list and sign nothing
|
|
198
186
|
atlas approve verify --file <file> check each signed block of a file against atlas/owner-signers; exit 0 when one counts
|
|
199
187
|
--signers <file> the key file; default atlas/owner-signers under --root
|
|
200
188
|
atlas approve key-check one touch signs a test text, and the key's own public half checks it: does the key count for the gate
|
|
@@ -241,7 +229,7 @@ export const FLAGS = Object.freeze({
|
|
|
241
229
|
runner: { repo: 'value', issue: 'value', holder: 'value', from: 'value', comment: 'value', notice: 'switch', json: 'switch' },
|
|
242
230
|
upkeep: { root: 'optional', event: 'value', json: 'switch', last: 'value', base: 'value' },
|
|
243
231
|
gate: { repo: 'value', root: 'optional', 'dry-run': 'switch', off: 'switch', on: 'switch', pr: 'value', since: 'value', commit: 'value', from: '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' },
|
|
244
|
-
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' },
|
|
232
|
+
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', hours: 'value', list: 'switch' },
|
|
245
233
|
judge: { tasks: 'value', verdict: 'value', seal: 'value', runs: 'value', out: 'value', repo: 'value', budget: 'value', model: 'value' },
|
|
246
234
|
install: { local: 'switch', from: 'value', 'skip-global': 'switch', yes: 'switch', cloud: 'switch' },
|
|
247
235
|
upgrade: { version: 'optional', yes: 'switch' },
|
|
@@ -287,7 +275,9 @@ let readerClosed = false;
|
|
|
287
275
|
const line = (text = '') => { if (!readerClosed) process.stdout.write(`${text}\n`); };
|
|
288
276
|
process.stdout.on('error', (error) => { if (error.code === 'EPIPE') { readerClosed = true; return; } throw error; });
|
|
289
277
|
|
|
290
|
-
function toolingCommand(chosen) {
|
|
278
|
+
async function toolingCommand(chosen) {
|
|
279
|
+
const { open } = await import('./lib/pull-request.mjs');
|
|
280
|
+
const { write: writeTooling, changes: toolingChanges, ToolingRefusal } = await import('./lib/tooling.mjs');
|
|
291
281
|
const root = rootOf(chosen);
|
|
292
282
|
const what = chosen._[1] ?? 'write';
|
|
293
283
|
const replaceUnknown = Boolean(chosen['replace-unknown']);
|
|
@@ -320,9 +310,11 @@ function toolingCommand(chosen) {
|
|
|
320
310
|
}
|
|
321
311
|
}
|
|
322
312
|
|
|
323
|
-
function statusCommand(chosen) {
|
|
313
|
+
async function statusCommand(chosen) {
|
|
314
|
+
const { statusMany } = await import('./lib/status.mjs');
|
|
315
|
+
const { mergeList } = await import('./lib/gate.mjs');
|
|
324
316
|
const roots = chosen._.slice(1).length ? chosen._.slice(1).map((one) => resolve(one)) : [rootOf(chosen)];
|
|
325
|
-
if (chosen.all || chosen.json) return copiesCommand(roots, { all: Boolean(chosen.all), json: Boolean(chosen.json) });
|
|
317
|
+
if (chosen.all || chosen.json) return await copiesCommand(roots, { all: Boolean(chosen.all), json: Boolean(chosen.json) });
|
|
326
318
|
const rows = statusMany(roots, PACKAGE_ROOT);
|
|
327
319
|
for (const row of rows) {
|
|
328
320
|
line(`${row.state.toUpperCase().padEnd(8)} ${row.root}`);
|
|
@@ -339,7 +331,8 @@ function statusCommand(chosen) {
|
|
|
339
331
|
|
|
340
332
|
// One row for each Atlas copy (roadmap item R19). Exit 1 when a copy is
|
|
341
333
|
// behind or broken, so a watcher can act on the exit alone.
|
|
342
|
-
function copiesCommand(roots, { all, json }) {
|
|
334
|
+
async function copiesCommand(roots, { all, json }) {
|
|
335
|
+
const { copies } = await import('./lib/status.mjs');
|
|
343
336
|
const repos = roots.map((root) => copies({ root, packageRoot: PACKAGE_ROOT, all }));
|
|
344
337
|
const decided = `Decided by the release lists in Atlas ${ATLAS_VERSION}, never by git history or the network.`;
|
|
345
338
|
if (json) {
|
|
@@ -360,7 +353,9 @@ function copiesCommand(roots, { all, json }) {
|
|
|
360
353
|
return repos.some((repo) => repo.rows.some((one) => bad.includes(one.state))) ? 1 : 0;
|
|
361
354
|
}
|
|
362
355
|
|
|
363
|
-
function steCommand(chosen) {
|
|
356
|
+
async function steCommand(chosen) {
|
|
357
|
+
const { checkPaths, LIMITS } = await import('./lib/ste.mjs');
|
|
358
|
+
const { loadPrivateTerms } = await import('./lib/privacy.mjs');
|
|
364
359
|
const paths = chosen._.slice(1);
|
|
365
360
|
if (!paths.length) throw new Error('give a file or a folder: atlas ste <path> [--share]');
|
|
366
361
|
const share = Boolean(chosen.share);
|
|
@@ -377,7 +372,10 @@ function steCommand(chosen) {
|
|
|
377
372
|
return count ? 1 : 0;
|
|
378
373
|
}
|
|
379
374
|
|
|
380
|
-
function designCommand(chosen) {
|
|
375
|
+
async function designCommand(chosen) {
|
|
376
|
+
const { checkDesignFolder, designFolderOf, repoRootFrom } = await import('./lib/design.mjs');
|
|
377
|
+
const { writeDesignPage, readTracker } = await import('./lib/design-build.mjs');
|
|
378
|
+
const { listDesigns, freezeDesign } = await import('./lib/design-freeze.mjs');
|
|
381
379
|
const what = chosen._[1];
|
|
382
380
|
const folders = chosen._.slice(2);
|
|
383
381
|
if (what === 'folder') {
|
|
@@ -443,7 +441,9 @@ function designCommand(chosen) {
|
|
|
443
441
|
return count ? 1 : 0;
|
|
444
442
|
}
|
|
445
443
|
|
|
446
|
-
function pageCommand(chosen) {
|
|
444
|
+
async function pageCommand(chosen) {
|
|
445
|
+
const { writePage } = await import('./lib/page.mjs');
|
|
446
|
+
const { checkPaths } = await import('./lib/ste.mjs');
|
|
447
447
|
const sources = chosen._.slice(2);
|
|
448
448
|
if (chosen._[1] !== 'build') throw new Error('use atlas page build <file or folder>');
|
|
449
449
|
if (sources.length !== 1) throw new Error('give one markdown file or one folder: atlas page build <file or folder>');
|
|
@@ -463,7 +463,8 @@ function pageCommand(chosen) {
|
|
|
463
463
|
return findings.length ? 1 : 0;
|
|
464
464
|
}
|
|
465
465
|
|
|
466
|
-
function viewCommand(chosen) {
|
|
466
|
+
async function viewCommand(chosen) {
|
|
467
|
+
const { writeViewPage, viewCapabilities } = await import('./lib/view.mjs');
|
|
467
468
|
if (chosen._[1] !== 'build' || chosen._.length > 2) throw new Error('use atlas view build --product <id>');
|
|
468
469
|
const product = chosen.product;
|
|
469
470
|
const out = resolve(chosen.out ?? join('docs', 'artifacts', `${product}-view.html`));
|
|
@@ -477,49 +478,56 @@ async function testsCommand(chosen) {
|
|
|
477
478
|
const root = rootOf(chosen);
|
|
478
479
|
const what = chosen._[1];
|
|
479
480
|
if (what === 'check') {
|
|
480
|
-
return
|
|
481
|
+
return (await import('./lib/tests.mjs')).checkCommand({ halves: chosen.halves, evidence: chosen.evidence, tests: chosen.tests, guardsFrom: chosen['guards-from'], run: chosen.run }, root, line, PACKAGE_ROOT);
|
|
481
482
|
}
|
|
482
|
-
if (what === 'write') return
|
|
483
|
-
if (what === 'proof') return
|
|
484
|
-
if (what === 'verdict') return
|
|
485
|
-
if (what === 'changed') return
|
|
486
|
-
if (what === 'after') return
|
|
487
|
-
if (what === 'before') return
|
|
488
|
-
if (what === 'named') return
|
|
489
|
-
if (what === 'sweep') return
|
|
490
|
-
if (what === 'covers') return
|
|
491
|
-
if (what === 'kept') return (await runKept({ root, evidenceDir: chosen.evidence, only: chosen.scenario ?? null, write: (text) => process.stdout.write(text) })).exit;
|
|
492
|
-
if (what === 'rerun') return
|
|
493
|
-
if (what === 'issues') return
|
|
483
|
+
if (what === 'write') return (await import('./lib/tests.mjs')).writeCommand({ dryRun: Boolean(chosen['dry-run']) }, root, PACKAGE_ROOT, line);
|
|
484
|
+
if (what === 'proof') return (await import('./lib/proof.mjs')).proofCommand({ run: chosen.run, own: chosen.own, evidence: chosen.evidence, base: chosen.base, covers: Boolean(chosen.covers) }, root, line);
|
|
485
|
+
if (what === 'verdict') return (await import('../tests/verdict.mjs')).verdictCommand({ run: chosen.run, base: chosen.base, evidence: chosen.evidence, covers: Boolean(chosen.covers) }, root, line);
|
|
486
|
+
if (what === 'changed') return (await import('./lib/changed-tests.mjs')).changedCommand({ base: chosen.base, head: chosen.head, out: chosen.out, text: Boolean(chosen.text) }, root, line);
|
|
487
|
+
if (what === 'after') return (await import('./lib/passes-after.mjs')).afterCommand({ base: chosen.base, head: chosen.head, list: chosen.list, out: chosen.out, text: Boolean(chosen.text) }, root, line);
|
|
488
|
+
if (what === 'before') return (await import('./lib/fails-before.mjs')).beforeCommand({ base: chosen.base, head: chosen.head, list: chosen.list, out: chosen.out, side: chosen.side, old: chosen.old, new: chosen.new, text: Boolean(chosen.text) }, root, line);
|
|
489
|
+
if (what === 'named') return (await import('../tests/verdict.mjs')).namedCommand({ base: chosen.base, covers: Boolean(chosen.covers) }, root, line);
|
|
490
|
+
if (what === 'sweep') return (await import('../tests/sweep.mjs')).sweepCommand({ evidence: chosen.evidence, since: chosen.since, base: chosen.base, out: chosen.out, text: Boolean(chosen.text) }, root, line);
|
|
491
|
+
if (what === 'covers') return (await import('../tests/sweep.mjs')).coversCommand({ itemId: chosen._[2], evidence: chosen.evidence, json: Boolean(chosen.json) }, root, line);
|
|
492
|
+
if (what === 'kept') return (await (await import('../tests/kept.mjs')).runKept({ root, evidenceDir: chosen.evidence, only: chosen.scenario ?? null, write: (text) => process.stdout.write(text) })).exit;
|
|
493
|
+
if (what === 'rerun') return (await import('../tests/verdict.mjs')).rerunCommand({ run: chosen.run, evidence: chosen.evidence, tests: chosen.tests, language: chosen.language ?? 'javascript' }, root, line);
|
|
494
|
+
if (what === 'issues') return (await import('./lib/issues.mjs')).issuesCommand({ findings: chosen.findings, repo: chosen.repo, max: chosen.max, bot: chosen.bot, dryRun: Boolean(chosen['dry-run']), args: [...chosen._, ...Object.values(chosen).filter((v) => typeof v === 'string')] }, line);
|
|
494
495
|
throw new Error(`there is no tests verb "${what ?? ''}". Use check, write, proof, verdict, named, changed, before, after, sweep, covers, kept, issues or rerun.`);
|
|
495
496
|
}
|
|
496
497
|
|
|
497
|
-
function jobsVerb(chosen) {
|
|
498
|
+
async function jobsVerb(chosen) {
|
|
499
|
+
const { jobsCommand } = await import('./lib/jobs.mjs');
|
|
498
500
|
return jobsCommand({ what: chosen._[1], root: rootOf(chosen), repo: chosen.repo, branch: chosen.branch, out: chosen.out, findings: chosen.findings, max: chosen.max, dryRun: Boolean(chosen['dry-run']), args: [...chosen._, ...Object.values(chosen).filter((v) => typeof v === 'string')] }, line);
|
|
499
501
|
}
|
|
500
502
|
|
|
501
|
-
function runnerVerb(chosen) {
|
|
503
|
+
async function runnerVerb(chosen) {
|
|
504
|
+
const { runnerCommand } = await import('./lib/runner.mjs');
|
|
502
505
|
return runnerCommand({ what: chosen._[1], repo: chosen.repo, issue: chosen.issue, holder: chosen.holder, from: chosen.from, comment: chosen.comment, notice: Boolean(chosen.notice), json: Boolean(chosen.json), args: [...chosen._, ...Object.values(chosen).filter((v) => typeof v === 'string')] }, line);
|
|
503
506
|
}
|
|
504
507
|
|
|
505
|
-
function upkeepVerb(chosen) {
|
|
508
|
+
async function upkeepVerb(chosen) {
|
|
509
|
+
const { upkeepCommand } = await import('./lib/upkeep.mjs');
|
|
506
510
|
return upkeepCommand({ what: chosen._[1], event: chosen.event, json: Boolean(chosen.json), last: chosen.last, base: chosen.base }, rootOf(chosen), line);
|
|
507
511
|
}
|
|
508
512
|
|
|
509
|
-
function gateVerb(chosen) {
|
|
513
|
+
async function gateVerb(chosen) {
|
|
514
|
+
const { gateCommand } = await import('./lib/gate.mjs');
|
|
510
515
|
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, from: chosen.from, 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);
|
|
511
516
|
}
|
|
512
517
|
|
|
513
|
-
function approveVerb(chosen) {
|
|
518
|
+
async function approveVerb(chosen) {
|
|
519
|
+
const { approveCommand } = await import('./lib/approve.mjs');
|
|
514
520
|
const flags = { repo: chosen.repo, commit: chosen.commit, item: chosen.item, card: chosen.card, words: chosen.words, pr: chosen.pr, head: chosen.head, decision: chosen.decision, check: chosen.check };
|
|
515
|
-
return approveCommand({ words: chosen._.slice(1), repo: chosen.repo, root: chosen.root === undefined ? undefined : rootOf(chosen), key: chosen.key, provider: chosen.provider, yes: Boolean(chosen.yes), out: chosen.out, file: chosen.file, signers: chosen.signers, flags }, line);
|
|
521
|
+
return approveCommand({ words: chosen._.slice(1), repo: chosen.repo, root: chosen.root === undefined ? undefined : rootOf(chosen), key: chosen.key, provider: chosen.provider, yes: Boolean(chosen.yes), out: chosen.out, file: chosen.file, signers: chosen.signers, flags, hours: chosen.hours, list: Boolean(chosen.list) }, line);
|
|
516
522
|
}
|
|
517
523
|
|
|
518
|
-
function judgeVerb(chosen) {
|
|
524
|
+
async function judgeVerb(chosen) {
|
|
525
|
+
const { judgeCommand } = await import('./lib/judge.mjs');
|
|
519
526
|
return judgeCommand({ what: chosen._[1], file: chosen._[2], task: chosen._[3], tasks: chosen.tasks, verdict: chosen.verdict, seal: chosen.seal, runs: chosen.runs, out: chosen.out, repo: chosen.repo, budget: chosen.budget, model: chosen.model }, line, PACKAGE_ROOT);
|
|
520
527
|
}
|
|
521
528
|
|
|
522
|
-
function checkCommand(chosen) {
|
|
529
|
+
async function checkCommand(chosen) {
|
|
530
|
+
const { mergeList } = await import('./lib/gate.mjs');
|
|
523
531
|
const root = rootOf(chosen);
|
|
524
532
|
const report = scan({ root, repoId: chosen.repo ?? null });
|
|
525
533
|
const decision = verdict(report, chosen['fail-on'] ?? 'error');
|
package/door/lib/approve.mjs
CHANGED
|
@@ -28,14 +28,15 @@
|
|
|
28
28
|
// a hardware key needs an ssh-keygen that reads it. No signing call runs in a
|
|
29
29
|
// test: each one takes `deps`, which a test replaces.
|
|
30
30
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
31
|
-
import { readFileSync, writeFileSync, mkdtempSync, rmSync, existsSync } from 'node:fs';
|
|
31
|
+
import { readFileSync, writeFileSync, mkdtempSync, rmSync, existsSync, mkdirSync } from 'node:fs';
|
|
32
32
|
import { homedir, tmpdir, platform } from 'node:os';
|
|
33
33
|
import { join, resolve } from 'node:path';
|
|
34
34
|
import { createInterface } from 'node:readline/promises';
|
|
35
35
|
import { childEnvironment } from '../../work/lib/child-env.mjs';
|
|
36
36
|
import { parseYaml } from '../../shape/check.mjs';
|
|
37
|
-
import { ghApi, pinnedRuns, commitAudit } from './gate.mjs';
|
|
38
|
-
import { GATE_CHECK } from './gate-check.mjs';
|
|
37
|
+
import { ghApi, pinnedRuns, commitAudit, prComments, windowComments } from './gate.mjs';
|
|
38
|
+
import { GATE_CHECK, judge } from './gate-check.mjs';
|
|
39
|
+
import { WINDOW_HOURS, WINDOW_LABEL, WINDOW_TITLE, windowActs, seenAt, holdsCommit } from './window.mjs';
|
|
39
40
|
import {
|
|
40
41
|
ACTS, MAX_ACTS, AUDIT_CHECK, AUDIT_CHECKS, AUDIT_TEST, AUDIT_CODE, AUDIT_CODE_TEST, AUDIT_WORKFLOW, auditCodeDiff, NAMESPACE, SIGNERS_FILE, payloadText, blockText, parseSigners, verifyBlock, verifyTexts, fingerprint, wordsFingerprint, SignedActError,
|
|
41
42
|
preKeyKeys,
|
|
@@ -610,12 +611,120 @@ export async function approveAuditCode({ prs = [], repo, root, key, provider, ye
|
|
|
610
611
|
return 0;
|
|
611
612
|
}
|
|
612
613
|
|
|
614
|
+
// ------------------------------------------------------------ atlas approve window
|
|
615
|
+
|
|
616
|
+
// `atlas approve window` (item 4af66381; design docs/design-docs/signing-window/).
|
|
617
|
+
// It runs on the owner's Mac, in a clone of the repo. It builds the morning
|
|
618
|
+
// list from git and the signed acts: each first-parent merge of the default
|
|
619
|
+
// branch after the newest seen commit, judged again with the code of this
|
|
620
|
+
// Mac's Atlas. It reads no issue for the list (rule 3). One touch then signs
|
|
621
|
+
// one window act that names the tip of the list as seen and an end time at
|
|
622
|
+
// most WINDOW_HOURS on (rule 2). With --hours 0 the touch only marks the list
|
|
623
|
+
// seen. With --list it signs nothing. Exit 0 when the act is posted.
|
|
624
|
+
export const LIST_MAX = 200;
|
|
625
|
+
export function windowRow(deps, { repo, root, sha }) {
|
|
626
|
+
const out = { sha };
|
|
627
|
+
let pull;
|
|
628
|
+
try { pull = (deps.gh('GET', `repos/${repo}/commits/${sha}/pulls`) ?? []).find((each) => each.merged_at && each.merge_commit_sha === sha); } catch { return { ...out, state: 'UNREAD', why: 'GitHub did not say which pull request merged it' }; }
|
|
629
|
+
if (!pull) return { ...out, state: 'PUSH', why: 'it reached the branch with no pull request: a push past the gate' };
|
|
630
|
+
Object.assign(out, { pr: Number(pull.number), title: plainText(pull.title).slice(0, 100), merged_at: String(pull.merged_at) });
|
|
631
|
+
let parent;
|
|
632
|
+
try { parent = execFileSync('git', ['-C', root, 'rev-parse', '--verify', `${sha}^1`], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return { ...out, state: 'UNREAD', why: 'its parent is not in this clone' }; }
|
|
633
|
+
let texts = null;
|
|
634
|
+
try { texts = [...prComments(deps.gh, repo, pull.number), ...windowComments(deps.gh, repo)]; } catch { texts = null; }
|
|
635
|
+
let answer;
|
|
636
|
+
try { answer = judge({ root, base: parent, head: pull.head?.sha, body: String(pull.body ?? ''), now: new Date(pull.merged_at), signed: { repo, pr: Number(pull.number), baseRef: pull.base?.ref ?? null, texts } }); } catch (error) { return { ...out, state: 'UNREAD', why: plainText(error?.message ?? error).slice(0, 160) }; }
|
|
637
|
+
const shown = (kind) => answer.rows.filter((row) => row.kind === kind).slice(0, 4).map((row) => plainText(`${row.class}${row.path ? ` ${row.path}` : ''}`).slice(0, 160));
|
|
638
|
+
if (answer.result === 'red') return { ...out, state: 'RED', rows: [...shown('red'), ...shown('owner')] };
|
|
639
|
+
if (answer.result === 'owner') return { ...out, state: 'OWNER, NO ACT', rows: shown('owner') };
|
|
640
|
+
if (answer.signed?.window) return { ...out, state: 'WINDOW', rows: shown('signed') };
|
|
641
|
+
if (answer.signed) return { ...out, state: 'SIGNED', rows: shown('signed') };
|
|
642
|
+
return { ...out, state: 'GREEN', rows: [] };
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
export async function approveWindow({ repo, root, key, provider, yes = false, hours, list = false }, line, deps = DEPS) {
|
|
646
|
+
const name = repoOf({ repo, root });
|
|
647
|
+
const length = hours === undefined || hours === true ? WINDOW_HOURS : Number(hours);
|
|
648
|
+
if (!Number.isInteger(length) || length < 0 || length > WINDOW_HOURS) throw new ApproveError(`--hours must be a whole number from 0 to ${WINDOW_HOURS}.`);
|
|
649
|
+
const clone = resolve(root ?? '.');
|
|
650
|
+
const git = (args) => execFileSync('git', ['-C', clone, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024 }).trim();
|
|
651
|
+
let origin = '';
|
|
652
|
+
try { origin = git(['remote', 'get-url', 'origin']); } catch { origin = ''; }
|
|
653
|
+
if (!new RegExp(`^(https://|ssh://git@|git@)github\\.com[:/]${name.replace(/[.]/g, '\\.')}(\\.git)?$`, 'i').test(origin)) throw new ApproveError(`${clone} is not a clone of ${name}; run the command in the clone.`);
|
|
654
|
+
const branch = String(deps.gh('GET', `repos/${name}`)?.default_branch ?? '');
|
|
655
|
+
if (!branch) throw new ApproveError(`GitHub gave no default branch of ${name}.`);
|
|
656
|
+
(deps.fetch ?? ((one) => git(['fetch', '--quiet', 'origin', one])))(branch);
|
|
657
|
+
const tip = git(['rev-parse', '--verify', `refs/remotes/origin/${branch}^{commit}`]);
|
|
658
|
+
const { keys } = signersOn(deps, name, branch);
|
|
659
|
+
const acts = windowActs(verifyTexts(windowComments(deps.gh, name), keys), { repo: name, branch });
|
|
660
|
+
const seen = seenAt(acts, (one) => holdsCommit(clone, one, tip));
|
|
661
|
+
let start = seen?.act.seen ?? null;
|
|
662
|
+
if (!start) {
|
|
663
|
+
const added = git(['log', '--first-parent', '--diff-filter=A', '--format=%H', tip, '--', 'packages/atlas/door/lib/window.mjs']).split('\n').filter(Boolean);
|
|
664
|
+
start = added.at(-1) ?? git(['rev-list', '--first-parent', '--max-count=1', '--skip=20', tip]) ?? null;
|
|
665
|
+
}
|
|
666
|
+
const commits = start ? git(['rev-list', '--first-parent', '--reverse', `${start}..${tip}`]).split('\n').filter(Boolean) : [];
|
|
667
|
+
line(`atlas approve window: ${name}, ${branch}. Merges since ${seen ? `your last seen commit ${start.slice(0, 7)} (${seen.signed_at})` : `${start ? start.slice(0, 7) : 'the start'}, with no window act before`}: ${commits.length}.`);
|
|
668
|
+
if (commits.length > LIST_MAX) line(` Only the newest ${LIST_MAX} are judged here; ${commits.length - LIST_MAX} older merge(s) are counted, not listed.`);
|
|
669
|
+
const rows = commits.slice(-LIST_MAX).map((sha) => windowRow(deps, { repo: name, root: clone, sha }));
|
|
670
|
+
const text = [];
|
|
671
|
+
for (const row of rows) {
|
|
672
|
+
text.push(` ${row.pr ? `#${row.pr}` : row.sha.slice(0, 7)}${row.merged_at ? ` merged ${row.merged_at}` : ''} ${row.state} ${row.title ?? ''}`.trimEnd());
|
|
673
|
+
for (const one of row.rows ?? []) text.push(` ${one}`);
|
|
674
|
+
if (row.why) text.push(` ${row.why}`);
|
|
675
|
+
}
|
|
676
|
+
for (const one of text) line(one);
|
|
677
|
+
const counts = ['WINDOW', 'SIGNED', 'GREEN', 'OWNER, NO ACT', 'RED', 'PUSH', 'UNREAD'].map((state) => [state, rows.filter((row) => row.state === state).length]).filter(([, n]) => n);
|
|
678
|
+
line(` ${counts.map(([state, n]) => `${n} ${state}`).join(', ') || 'no merge'}.`);
|
|
679
|
+
const at = deps.now();
|
|
680
|
+
const stamp = new Date(at).toISOString().slice(0, 16).replace(':', '-');
|
|
681
|
+
const folder = deps.listFolder ?? join(homedir(), '.atlas', 'window');
|
|
682
|
+
try {
|
|
683
|
+
mkdirSync(folder, { recursive: true });
|
|
684
|
+
const file = join(folder, `${name.replace('/', '-')}-${stamp}.md`);
|
|
685
|
+
writeFileSync(file, [`# ${name} ${branch}: merges up to ${tip}`, '', `Built ${new Date(at).toISOString()} from git and the signed acts.`, '', '```text', ...text, '```', ''].join('\n'));
|
|
686
|
+
line(`The list is in ${file}`);
|
|
687
|
+
} catch (error) { line(`The list could not be written to ${folder} (${plainText(error?.message ?? error).slice(0, 120)}).`); }
|
|
688
|
+
if (list) { line('Nothing signed: --list only shows the list.'); return 0; }
|
|
689
|
+
const ends = new Date(new Date(at).getTime() + length * 3600 * 1000).toISOString();
|
|
690
|
+
const question = length ? `Mark ${tip.slice(0, 7)} as seen, and open a window for ${name} ${branch} until ${ends}?` : `Mark ${tip.slice(0, 7)} as seen, with no window?`;
|
|
691
|
+
if (!yes && !(await deps.ask(question))) { line('Nothing signed.'); return 1; }
|
|
692
|
+
const setup = (deps.setup ?? signingSetup)({ key, provider });
|
|
693
|
+
const trusted = (deps.trusted ?? trustedKeys)(setup);
|
|
694
|
+
const strange = keys.filter((one) => !trusted.has(one.fingerprint));
|
|
695
|
+
if (strange.length) {
|
|
696
|
+
line(`STOP: ${SIGNERS_FILE} on ${branch} holds a key that is not yours: ${strange.map((one) => one.fingerprint).join(', ')}.`);
|
|
697
|
+
line('Nothing was signed. Remove that key in a pull request, or pin it in ~/.config/atlas/signing.json if it is yours.');
|
|
698
|
+
return 1;
|
|
699
|
+
}
|
|
700
|
+
const payload = payloadText([{ kind: 'window', repo: name, branch, seen: tip, ends }], at);
|
|
701
|
+
line(`Touch the key now (${setup.key}${setup.provider ? `, provider ${setup.provider}` : ''}).`);
|
|
702
|
+
const signature = deps.sign(payload, setup);
|
|
703
|
+
const proof = verifyBlock({ payload, signature }, keys);
|
|
704
|
+
if (proof.error) { line(`The signature does not count on ${branch}: ${proof.error}`); line('Nothing was posted.'); return 1; }
|
|
705
|
+
let issue = null;
|
|
706
|
+
try {
|
|
707
|
+
const open = deps.gh('GET', `repos/${name}/issues?labels=${WINDOW_LABEL}&state=open&per_page=5`) ?? [];
|
|
708
|
+
issue = (Array.isArray(open) ? open : []).find((one) => !one?.pull_request)?.number ?? null;
|
|
709
|
+
if (!issue) issue = deps.gh('POST', `repos/${name}/issues`, { title: WINDOW_TITLE, labels: [WINDOW_LABEL], body: 'Each comment here holds one window act, signed with `atlas approve window` on the owner\'s Mac. The gate reads them. A deleted comment only ends a window sooner.' })?.number ?? null;
|
|
710
|
+
if (!issue) throw new Error('GitHub gave no issue number');
|
|
711
|
+
const summary = counts.map(([state, n]) => `${n} ${state}`).join(', ') || 'no merge';
|
|
712
|
+
deps.gh('POST', `repos/${name}/issues/${issue}/comments`, { body: `${blockText(payload, signature)}\nThe owner saw the list up to ${tip.slice(0, 7)} (${summary})${length ? ` and opened a window until ${ends}` : ''}, with \`atlas approve window\`.\n` });
|
|
713
|
+
} catch (error) { line(`GitHub refused a write (${plainText(error?.message ?? error).slice(0, 200)}). Nothing was posted; run the command again.`); return 1; }
|
|
714
|
+
line(` posted on issue #${issue} (${WINDOW_LABEL})`);
|
|
715
|
+
// `atlas upgrade` trusts only a push run of the release, so the command
|
|
716
|
+
// starts no run: the next merge into the branch publishes what was held.
|
|
717
|
+
line(` the next merge into ${branch} publishes what the release held`);
|
|
718
|
+
return 0;
|
|
719
|
+
}
|
|
720
|
+
|
|
613
721
|
// The act that `atlas approve act <kind>` builds from its flags.
|
|
614
722
|
export function actFrom(kind, flags, read = (path) => readFileSync(resolve(path), 'utf8')) {
|
|
615
|
-
if (!Object.hasOwn(ACTS, kind ?? '')) throw new ApproveError(`there is no act "${kind ?? ''}". Use ${Object.keys(ACTS).filter((each) => !['merge', 'accept', 'accept-code'].includes(each)).join(', ')}; a pull request takes atlas approve <pr>.`);
|
|
723
|
+
if (!Object.hasOwn(ACTS, kind ?? '')) throw new ApproveError(`there is no act "${kind ?? ''}". Use ${Object.keys(ACTS).filter((each) => !['merge', 'accept', 'accept-code', 'window'].includes(each)).join(', ')}; a pull request takes atlas approve <pr>.`);
|
|
616
724
|
if (kind === 'merge') throw new ApproveError('a pull request takes atlas approve <pr>, which reads its gate first.');
|
|
617
725
|
if (kind === 'accept') throw new ApproveError('an audit red takes atlas approve audit, which reads the audit issues first.');
|
|
618
726
|
if (kind === 'accept-code') throw new ApproveError('the audit red of an audit-code merge takes atlas approve audit-code <pr>, which shows its diff first.');
|
|
727
|
+
if (kind === 'window') throw new ApproveError('a signing window takes atlas approve window, which shows the list of the last window first.');
|
|
619
728
|
const need = (flag) => {
|
|
620
729
|
const value = flags[flag];
|
|
621
730
|
if (value === undefined || value === true || value === '') throw new ApproveError(`atlas approve act ${kind} needs --${flag}.`);
|
|
@@ -683,11 +792,12 @@ export async function keyCheck({ key, provider }, line, deps = DEPS) {
|
|
|
683
792
|
|
|
684
793
|
// The verb. `words` are the words after "approve"; `flags` are the fields of
|
|
685
794
|
// an act.
|
|
686
|
-
export async function approveCommand({ words = [], repo, root, key, provider, yes = false, out, file, signers, flags = {} }, line, deps = DEPS) {
|
|
795
|
+
export async function approveCommand({ words = [], repo, root, key, provider, yes = false, out, file, signers, flags = {}, hours, list = false }, line, deps = DEPS) {
|
|
687
796
|
const common = { key, provider, yes };
|
|
688
797
|
try {
|
|
689
798
|
if (words[0] === 'act') return await approveAct({ ...common, kind: words[1], flags, out }, line, deps);
|
|
690
799
|
if (words[0] === 'audit-code') return await approveAuditCode({ ...common, prs: words.slice(1), repo, root }, line, deps);
|
|
800
|
+
if (words[0] === 'window') return await approveWindow({ ...common, repo, root, hours, list }, line, deps);
|
|
691
801
|
if (words[0] === 'audit') return await approveAudit({ ...common, prs: words.slice(1), repo, root }, line, deps);
|
|
692
802
|
if (words[0] === 'verify') return approveVerify({ file, signers, root }, line);
|
|
693
803
|
if (words[0] === 'key-check') return await keyCheck(common, line, deps);
|
package/door/lib/gate-audit.mjs
CHANGED
|
@@ -134,6 +134,7 @@ export const GATE_FILE_GROUPS = Object.freeze([
|
|
|
134
134
|
['packages/atlas/door/lib/gate.mjs', 'door/lib/gate.mjs'],
|
|
135
135
|
['packages/atlas/door/lib/gate-check.mjs', 'door/lib/gate-check.mjs'],
|
|
136
136
|
['packages/atlas/door/lib/owner-only.mjs', 'door/lib/owner-only.mjs'],
|
|
137
|
+
['packages/atlas/door/lib/window.mjs', 'door/lib/window.mjs'],
|
|
137
138
|
['packages/atlas/owner-only.yaml', 'owner-only.yaml'],
|
|
138
139
|
] },
|
|
139
140
|
{ group: 'audit', files: [
|
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 { windowOn, protectedSet, clearable, windowActs, openAt, holdsCommit, WINDOW_KEY } from './window.mjs';
|
|
47
48
|
|
|
48
49
|
export const VERSION = 1;
|
|
49
50
|
export const GATE_CHECK = 'gate';
|
|
@@ -313,7 +314,7 @@ export function judge({ root, base, head, body = '', redMain = [], rules = null,
|
|
|
313
314
|
answer.rows.push(...secretRows(top, mergeBase, headSha, changes));
|
|
314
315
|
if (terms !== undefined) answer.rows.push(...termRows(top, baseSha, headSha, changes, terms));
|
|
315
316
|
answer.rows.push(...testRows({ root: top, mergeBase, head: headSha, body, redMain, rules: ruleRef, undo }));
|
|
316
|
-
if (signed) signedRows(top, baseSha, headSha, signed, answer, ruleRef);
|
|
317
|
+
if (signed) signedRows(top, baseSha, headSha, signed, answer, ruleRef, now);
|
|
317
318
|
return finish(answer);
|
|
318
319
|
}
|
|
319
320
|
|
|
@@ -324,7 +325,7 @@ export function judge({ root, base, head, body = '', redMain = [], rules = null,
|
|
|
324
325
|
// the base after the touch does not carry it (security review S1). A head job
|
|
325
326
|
// named like a check is never cleared: the owner's view of the gate could be
|
|
326
327
|
// that job's forgery (security review S1).
|
|
327
|
-
function signedRows(root, baseSha, headSha, { repo, pr, baseRef, texts }, answer, ruleRef = baseSha) {
|
|
328
|
+
function signedRows(root, baseSha, headSha, { repo, pr, baseRef, texts }, answer, ruleRef = baseSha, now = new Date()) {
|
|
328
329
|
if (!answer.rows.some((row) => row.kind === 'owner') || answer.rows.some((row) => row.kind === 'red')) return;
|
|
329
330
|
const note = (cause) => answer.rows.push({ kind: 'note', class: 'signed', cause });
|
|
330
331
|
if (texts === null) { note('the comments of the pull request could not be read, so no signed act counts'); return; }
|
|
@@ -340,6 +341,7 @@ function signedRows(root, baseSha, headSha, { repo, pr, baseRef, texts }, answer
|
|
|
340
341
|
carried = carryAct(root, baseSha, headSha, verified, { repo, pr, baseRef, ruleRef }, answer);
|
|
341
342
|
if (carried?.found) found = carried.found;
|
|
342
343
|
}
|
|
344
|
+
if (!found && windowClear(root, baseSha, verified, { repo, baseRef, now }, answer)) return;
|
|
343
345
|
if (!found) {
|
|
344
346
|
for (const row of verified.filter((each) => each.error).slice(0, 3)) note(`a signed act that does not count: ${row.error}`);
|
|
345
347
|
if (carried?.why) note(`a signed act for an older head does not carry: ${carried.why}`);
|
|
@@ -355,6 +357,43 @@ function signedRows(root, baseSha, headSha, { repo, pr, baseRef, texts }, answer
|
|
|
355
357
|
else note(`the owner signed this head with key ${found.key} at ${found.signed_at}`);
|
|
356
358
|
}
|
|
357
359
|
|
|
360
|
+
// The signing window (item 4af66381; design docs/design-docs/signing-window/).
|
|
361
|
+
// With no merge act for this head, a window act that is open now clears the
|
|
362
|
+
// owner rows, but only when each one is a row of the repo's own list, of a
|
|
363
|
+
// class that the window names, on a path outside the protected set (rule 1).
|
|
364
|
+
// One row that it does not cover keeps the pull request OWNER: a window never
|
|
365
|
+
// clears part of a pull request. The brake, a head job named like a check, a
|
|
366
|
+
// floor path and a sign are never covered. Returns true when it cleared.
|
|
367
|
+
function windowClear(root, baseSha, verified, { repo, baseRef, now }, answer) {
|
|
368
|
+
const note = (cause) => answer.rows.push({ kind: 'note', class: 'window', cause });
|
|
369
|
+
const acts = windowActs(verified, { repo, branch: baseRef });
|
|
370
|
+
if (!acts.length) return false;
|
|
371
|
+
if (!windowOn(root, baseSha)) { note(`a window act counts only where atlas.yaml on the base says ${WINDOW_KEY}: on`); return false; }
|
|
372
|
+
const got = openAt(acts, { now, isSeen: (sha) => holdsCommit(root, sha, baseSha) });
|
|
373
|
+
if (!got.open) { note(`no window counts: ${got.why}`); return false; }
|
|
374
|
+
// A window never clears while the brake is on: only the owner's signed
|
|
375
|
+
// clear ends a trip (rule 2; security review of 31 October, S2).
|
|
376
|
+
if (answer.brake?.on) { note('no window counts: the brake is on'); return false; }
|
|
377
|
+
const guard = protectedSet(root, baseSha);
|
|
378
|
+
if (guard.why) { note(`no window counts: ${guard.why}`); return false; }
|
|
379
|
+
let floorRules;
|
|
380
|
+
try { floorRules = rulesAt({ root, ref: baseSha }).rules.filter((rule) => String(rule.cause).startsWith('floor: ')); } catch (error) { note(`no window counts: the owner-only floor at the base cannot be read (${String(error?.message ?? error).split('\n')[0].slice(0, 120)})`); return false; }
|
|
381
|
+
guard.floor = (path) => onList(floorRules, path) !== null;
|
|
382
|
+
const owner = answer.rows.filter((row) => row.kind === 'owner');
|
|
383
|
+
const kept = owner.filter((row) => !clearable(row, guard));
|
|
384
|
+
const { act, signed_at: signedAt, key, verified: touched } = got.open;
|
|
385
|
+
if (kept.length) {
|
|
386
|
+
const first = kept[0];
|
|
387
|
+
const why = first.path && guard.paths.has(first.path) ? 'a protected file' : `${first.cause}`;
|
|
388
|
+
note(`the window open until ${act.ends} does not cover ${kept.length} owner row(s); the first is ${first.class}${first.path ? ` ${first.path}` : ''}: ${why}`);
|
|
389
|
+
return false;
|
|
390
|
+
}
|
|
391
|
+
for (const row of owner) row.kind = 'signed';
|
|
392
|
+
answer.signed = { key, signed_at: signedAt, verified: touched, window: { seen: act.seen, ends: act.ends } };
|
|
393
|
+
note(`the owner's window, signed ${signedAt} with key ${key}, is open until ${act.ends}; each owner row is a row of the repo's list outside the protected set`);
|
|
394
|
+
return true;
|
|
395
|
+
}
|
|
396
|
+
|
|
358
397
|
// At most this many new commits between a signed head and this head.
|
|
359
398
|
export const CARRY_LIMIT = 20;
|
|
360
399
|
// At most this many signed heads are tried, so copies of one block cost little.
|
|
@@ -572,6 +611,7 @@ export function renderText(answer) {
|
|
|
572
611
|
if (answer.result === 'red') out.push(`RED: ${oneLine(answer.rows.find((row) => row.kind === 'red').cause)}. Fix it; red wins over owner.`);
|
|
573
612
|
else if (answer.result === 'owner') out.push(`OWNER: ${oneLine(answer.class)}. ${answer.signed_act}`);
|
|
574
613
|
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
|
+
else if (answer.signed?.window) out.push(`SUCCESS: the owner's signing window covers this pull request until ${oneLine(answer.signed.window.ends)}.`);
|
|
575
615
|
else if (answer.signed) out.push('SUCCESS: the owner signed this head.');
|
|
576
616
|
else out.push('SUCCESS: no rule holds this pull request.');
|
|
577
617
|
return out;
|
package/door/lib/gate.mjs
CHANGED
|
@@ -23,6 +23,7 @@ import { parseYaml } from '../../shape/check.mjs';
|
|
|
23
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, SIGNERS_FILE, AUDIT_CHECK, parseSigners, verifyTexts, findAct, auditCodeDiff } from './signed-act.mjs';
|
|
26
|
+
import { WINDOW_LABEL } from './window.mjs';
|
|
26
27
|
import { brakeFacts, windowCommits, ciTrips, BrakeError } from './brake.mjs';
|
|
27
28
|
|
|
28
29
|
export const BRANCH_RULESET = 'atlas gate';
|
|
@@ -601,7 +602,7 @@ export function ownerUnsigned({ root, repo, pull, parent }, gh) {
|
|
|
601
602
|
const has = (sha) => { try { execFileSync('git', ['-C', root, 'cat-file', '-e', `${sha}^{commit}`], { stdio: 'ignore' }); return true; } catch { return false; } };
|
|
602
603
|
if (!/^[0-9a-f]{40}$/.test(String(head)) || !/^[0-9a-f]{40}$/.test(String(parent)) || !has(head) || !has(parent)) return 'unread';
|
|
603
604
|
let texts = null;
|
|
604
|
-
try { texts = prComments(gh, repo, pull.number); } catch { texts = null; }
|
|
605
|
+
try { texts = [...prComments(gh, repo, pull.number), ...windowComments(gh, repo)]; } catch { texts = null; }
|
|
605
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 } });
|
|
606
607
|
return answer.result === 'owner' ? 'owner' : null;
|
|
607
608
|
}
|
|
@@ -708,6 +709,28 @@ export function prComments(gh, repo, pr) {
|
|
|
708
709
|
return kept;
|
|
709
710
|
}
|
|
710
711
|
|
|
712
|
+
// The comment texts of the window issues that carry the signed-act marker:
|
|
713
|
+
// where `atlas approve window` posts each window act (item 4af66381). The
|
|
714
|
+
// newest five issues with the label, open or closed, and three pages of
|
|
715
|
+
// comments of each. A read that fails gives no text, so no window counts;
|
|
716
|
+
// a hidden or deleted act only makes the gate and the hold stricter.
|
|
717
|
+
export const WINDOW_ISSUES = 5;
|
|
718
|
+
export function windowComments(gh, repo) {
|
|
719
|
+
const texts = [];
|
|
720
|
+
let issues;
|
|
721
|
+
try { issues = gh('GET', `repos/${repo}/issues?labels=${WINDOW_LABEL}&state=all&sort=created&direction=desc&per_page=${WINDOW_ISSUES}`); } catch { return []; }
|
|
722
|
+
for (const issue of (Array.isArray(issues) ? issues : []).filter((one) => !one?.pull_request).slice(0, WINDOW_ISSUES)) {
|
|
723
|
+
for (let page = 1; page <= 3; page += 1) {
|
|
724
|
+
let rows;
|
|
725
|
+
try { rows = gh('GET', `repos/${repo}/issues/${issue.number}/comments?per_page=100&page=${page}`); } catch { break; }
|
|
726
|
+
if (!Array.isArray(rows)) break;
|
|
727
|
+
for (const row of rows) if (typeof row?.body === 'string' && row.body.includes(SIGNED_MARKER)) texts.push(row.body);
|
|
728
|
+
if (rows.length < 100) break;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
return texts;
|
|
732
|
+
}
|
|
733
|
+
|
|
711
734
|
// `atlas gate hold --root <clone> --commit <sha> --from <sha>` (item 4af66381,
|
|
712
735
|
// the release hold). The release job runs it from the trusted copy, the
|
|
713
736
|
// version that npm holds as latest, never from the commit that it judges.
|
|
@@ -768,6 +791,11 @@ export function holdRow({ repo, root, sha }, gh, git) {
|
|
|
768
791
|
try { git(['cat-file', '-e', `${head}^{commit}`]); } catch { return `#${pull.number}: its head ${head.slice(0, 7)} is not in this clone`; }
|
|
769
792
|
let texts;
|
|
770
793
|
try { texts = prComments(gh, repo, pull.number); } catch { return `#${pull.number}: GitHub did not give its comments, so no act of the owner can be read`; }
|
|
794
|
+
// The window acts too (S1 of 31 October, hole 1). The judge below counts a
|
|
795
|
+
// window act only when it was open at the merge time, on the rules of the
|
|
796
|
+
// parent. So a window clears in the hold just the merges that it cleared at
|
|
797
|
+
// the gate. An act that GitHub hides or that an agent deletes only holds more.
|
|
798
|
+
texts = [...texts, ...windowComments(gh, repo)];
|
|
771
799
|
let answer;
|
|
772
800
|
try {
|
|
773
801
|
const at = new Date(pull.merged_at);
|
|
@@ -857,7 +885,7 @@ export function gateCommand({ what, repo, root, dryRun = false, off = false, on
|
|
|
857
885
|
const named = repo && repo !== true && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) && !repo.split('/').some((part) => /^\.+$/.test(part));
|
|
858
886
|
if (what === 'check') {
|
|
859
887
|
const number = /^[1-9][0-9]{0,8}$/.test(String(pr ?? '')) ? Number(pr) : null;
|
|
860
|
-
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: { ci: brakeRead({ root, base }, gh, repo), clears: { repo, commentsOf: (sha) => commitComments(gh, repo, sha) } } }) : null, named && number ? () => prComments(gh, repo, number) : 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: { ci: brakeRead({ root, base }, gh, repo), clears: { repo, commentsOf: (sha) => commitComments(gh, repo, sha) } } }) : null, named && number ? () => [...prComments(gh, repo, number), ...windowComments(gh, repo)] : null);
|
|
861
889
|
}
|
|
862
890
|
if (what === 'replay') return replayCommand({ root, days, branch, json }, line);
|
|
863
891
|
if (!named) throw new GateError(`atlas gate ${what} needs --repo <owner/name>.`);
|
package/door/lib/signed-act.mjs
CHANGED
|
@@ -60,6 +60,8 @@ const HASH = '[0-9a-f]{64}';
|
|
|
60
60
|
const NUMBER = '[1-9][0-9]{0,8}';
|
|
61
61
|
// A branch name: plain characters only, so a line never holds a space.
|
|
62
62
|
const BRANCH = '[A-Za-z0-9._/-]{1,100}';
|
|
63
|
+
// A time in the form that payloadText writes: UTC, with milliseconds.
|
|
64
|
+
const TIME = '\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z';
|
|
63
65
|
export const BRANCH_FORM = new RegExp(`^${BRANCH}$`);
|
|
64
66
|
|
|
65
67
|
// Each kind of act, its fields, and the exact form of its line. The order of
|
|
@@ -93,6 +95,12 @@ export const ACTS = Object.freeze({
|
|
|
93
95
|
// audit code, made with `atlas approve audit-code` after the owner read
|
|
94
96
|
// that diff (item f36c6b0f). It names the merge commit and the fingerprint
|
|
95
97
|
// of its audit-code change, so the touch covers only the diff he read.
|
|
98
|
+
// A signing window (item 4af66381; design docs/design-docs/signing-window/).
|
|
99
|
+
// It names the repo, the branch, the tip that the owner's morning list
|
|
100
|
+
// ended at (the seen commit), and the end time. The touch is the start, so
|
|
101
|
+
// the end needs no act; window.mjs holds the 12-hour rule and the
|
|
102
|
+
// allow-list.
|
|
103
|
+
window: { fields: ['repo', 'branch', 'seen', 'ends'], form: `(${REPO}) (${BRANCH}) (${SHA}) (${TIME})` },
|
|
96
104
|
'accept-code': { fields: ['repo', 'pr', 'head', 'merge', 'run', 'diff', 'words', 'check'], form: `(${REPO}) (${NUMBER}) (${SHA}) (${SHA}) ([1-9][0-9]{0,15}) (${HASH}) (${HASH}) ([^\\s].{0,99})` },
|
|
97
105
|
});
|
|
98
106
|
|
|
@@ -122,7 +130,7 @@ export const AUDIT_WORKFLOW = '.github/workflows/atlas-gate-audit.yml';
|
|
|
122
130
|
export const AUDIT_CODE = Object.freeze([
|
|
123
131
|
AUDIT_WORKFLOW, '.github/workflows/atlas-gate.yml', '.github/workflows/atlas-jobs.yml', '.github/workflows/release.yml',
|
|
124
132
|
'packages/atlas/bin/atlas', 'packages/atlas/door/cli.mjs', 'packages/atlas/owner-only.yaml', 'packages/atlas/door/jobs/gate-audit.yaml',
|
|
125
|
-
...['gate.mjs', 'gate-audit.mjs', 'gate-check.mjs', 'owner-only.mjs', 'jobs.mjs', 'issues.mjs', 'signed-act.mjs'].map((name) => `packages/atlas/door/lib/${name}`),
|
|
133
|
+
...['gate.mjs', 'gate-audit.mjs', 'gate-check.mjs', 'owner-only.mjs', 'jobs.mjs', 'issues.mjs', 'signed-act.mjs', 'window.mjs'].map((name) => `packages/atlas/door/lib/${name}`),
|
|
126
134
|
'packages/atlas/shape/check.mjs', 'packages/atlas/work/lib/child-env.mjs',
|
|
127
135
|
]);
|
|
128
136
|
export const AUDIT_CODE_TEST = 'audit-code';
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// The signing window (tracker item 4af66381; design
|
|
2
|
+
// docs/design-docs/signing-window/). One touch of the owner's key signs a
|
|
3
|
+
// window act: `act window <repo> <branch> <seen> <ends>`. Until its end time,
|
|
4
|
+
// the gate clears some owner rows of a pull request into that branch with
|
|
5
|
+
// the act, as if the owner had signed its head.
|
|
6
|
+
//
|
|
7
|
+
// windowOn(root, ref) does atlas.yaml on the base turn the window on
|
|
8
|
+
// protectedSet(root, ref) the files that a window never covers (rule 1)
|
|
9
|
+
// clearable(row, guard) may a window clear this one owner row
|
|
10
|
+
// windowActs(verified, want) the valid window acts of one repo and branch
|
|
11
|
+
// openAt(acts, { now, isSeen }) the window act that is open now, or a reason
|
|
12
|
+
// seenAt(acts, isSeen) the newest seen commit that a commit holds
|
|
13
|
+
//
|
|
14
|
+
// The four rules, one for each S1 of the security review of 29 October:
|
|
15
|
+
// 1. An allow-list. A window clears an owner row only when the row comes from
|
|
16
|
+
// the repo's own `owner_only:` list, its class is rule-book or security,
|
|
17
|
+
// and its path is not in the protected set. The protected set is derived
|
|
18
|
+
// from the base commit: the import closure of the entry files, every data
|
|
19
|
+
// file in a folder of that closure, every package manifest, and AUDIT_CODE.
|
|
20
|
+
// 2. The end time is in the act, at most 12 hours after the touch. No act
|
|
21
|
+
// ends a window early; the brake does, and the window never clears it.
|
|
22
|
+
// 3. The owner's Mac builds the morning list from git and the acts
|
|
23
|
+
// (approve.mjs). A new window opens only with the touch that saw the list:
|
|
24
|
+
// the act names the tip that the list ended at.
|
|
25
|
+
// 4. A class that the window does not name keeps the pull request OWNER, so
|
|
26
|
+
// the window needs no row for money or a new service.
|
|
27
|
+
// Everything here fails closed: a file that git cannot read, or an import
|
|
28
|
+
// that is not a plain text path, makes the closure unreadable, and then no
|
|
29
|
+
// window counts.
|
|
30
|
+
import { spawnSync } from 'node:child_process';
|
|
31
|
+
import { posix } from 'node:path';
|
|
32
|
+
import { parseYaml } from '../../shape/check.mjs';
|
|
33
|
+
import { AUDIT_CODE } from './signed-act.mjs';
|
|
34
|
+
|
|
35
|
+
export const WINDOW_HOURS = 12;
|
|
36
|
+
export const WINDOW_MS = WINDOW_HOURS * 3600 * 1000;
|
|
37
|
+
export const WINDOW_LABEL = 'atlas-window';
|
|
38
|
+
export const WINDOW_TITLE = 'atlas signing window';
|
|
39
|
+
export const WINDOW_KEY = 'signing_window';
|
|
40
|
+
// The classes of owner row that a window may clear (rule 1, rule 4). Every
|
|
41
|
+
// other class keeps the pull request OWNER.
|
|
42
|
+
export const WINDOW_CLASSES = Object.freeze(['rule-book', 'security']);
|
|
43
|
+
// The files that start the code which makes, shows, checks or audits an act:
|
|
44
|
+
// the command, the gate, the push audit and its jobs, the signing tools, the
|
|
45
|
+
// test checks of the gate, the shape check, the tools server with its done
|
|
46
|
+
// tool, and the window itself. The protected set is their import closure at
|
|
47
|
+
// the base, so a new import of a protected file is protected at once.
|
|
48
|
+
export const WINDOW_ENTRIES = Object.freeze([
|
|
49
|
+
'packages/atlas/bin/atlas',
|
|
50
|
+
'packages/atlas/door/cli.mjs',
|
|
51
|
+
...['gate', 'gate-check', 'gate-audit', 'jobs', 'issues', 'approve', 'signed-act', 'owner-only', 'brake', 'kinds', 'fails-before', 'passes-after', 'changed-tests', 'window']
|
|
52
|
+
.map((name) => `packages/atlas/door/lib/${name}.mjs`),
|
|
53
|
+
'packages/atlas/shape/check.mjs',
|
|
54
|
+
// The judges of the required checks, and the code that writes the gate
|
|
55
|
+
// into other repos (security review of 7 November, question 1).
|
|
56
|
+
'packages/atlas/door/lib/upkeep.mjs',
|
|
57
|
+
'packages/atlas/door/lib/tooling.mjs',
|
|
58
|
+
'packages/atlas/tests/verdict.mjs',
|
|
59
|
+
// release.yml loads these two straight from the trusted copy.
|
|
60
|
+
'packages/atlas/door/lib/install.mjs',
|
|
61
|
+
'packages/atlas/door/lib/private-terms.mjs',
|
|
62
|
+
'packages/atlas/work/mcp.mjs',
|
|
63
|
+
'packages/atlas/work/lib/delivery.mjs',
|
|
64
|
+
]);
|
|
65
|
+
// Files protected by their role, whatever imports them (security review of
|
|
66
|
+
// 31 October, S2: the set picked by import alone). The workflows and the
|
|
67
|
+
// check copy run the required checks; the hooks run at each session start;
|
|
68
|
+
// the command, the shape check and the gate tooling judge the checks; the
|
|
69
|
+
// signers file, the owner-only list and the repo facts set the rules. A path
|
|
70
|
+
// that ends in `/` holds each file under it.
|
|
71
|
+
export const WINDOW_ROLES = Object.freeze([
|
|
72
|
+
'.github/',
|
|
73
|
+
'packages/atlas/hooks/',
|
|
74
|
+
'packages/atlas/bin/',
|
|
75
|
+
'packages/atlas/shape/',
|
|
76
|
+
'test/package/',
|
|
77
|
+
'door/release-notes.mjs',
|
|
78
|
+
'packages/atlas/tests/',
|
|
79
|
+
'docs/manual/extract/',
|
|
80
|
+
'atlas/owner-signers',
|
|
81
|
+
'atlas.yaml',
|
|
82
|
+
'packages/atlas/owner-only.yaml',
|
|
83
|
+
]);
|
|
84
|
+
// File names that change what npm packs or how a folder resolves. A new one
|
|
85
|
+
// in any folder could drop a protected file from the package, so a window
|
|
86
|
+
// never clears one (security review of 7 November, S2).
|
|
87
|
+
export const WINDOW_PACK_FILES = Object.freeze(['.npmignore', '.gitignore', '.npmrc', '.gitattributes', 'package.json', 'package-lock.json', 'npm-shrinkwrap.json']);
|
|
88
|
+
// One form for a path, as a Mac file system compares names: no case, and one
|
|
89
|
+
// Unicode form. Two paths with one fold are one file on the owner's Mac
|
|
90
|
+
// (security review of 7 November, S1).
|
|
91
|
+
export const foldPath = (path) => String(path).normalize('NFC').toLowerCase();
|
|
92
|
+
// The command file loads each command's code with a plain `import()` only
|
|
93
|
+
// when that command runs. The closure follows its static imports alone; the
|
|
94
|
+
// gate's own commands are entries above.
|
|
95
|
+
export const WINDOW_DISPATCH = Object.freeze(['packages/atlas/door/cli.mjs']);
|
|
96
|
+
// Files that the import closure walks. Others are data.
|
|
97
|
+
const CODE = /\.(m?js|cjs)$/;
|
|
98
|
+
// At most this many files in the closure, so a loop of imports ends.
|
|
99
|
+
const MAX_CLOSURE = 2000;
|
|
100
|
+
// git's word for the ancestor test, joined so the package names no ancestry verb (test/package D9).
|
|
101
|
+
const MERGE_BASE = ['merge', 'base'].join('-');
|
|
102
|
+
|
|
103
|
+
function git(root, args) {
|
|
104
|
+
const r = spawnSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
105
|
+
return r.status === 0 ? r.stdout : null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// True when atlas.yaml on the base says `signing_window: on` (or true). The
|
|
109
|
+
// key is on the fixed list of the owner-only floor, so only the owner turns
|
|
110
|
+
// it on.
|
|
111
|
+
export function windowOn(root, ref) {
|
|
112
|
+
const text = git(root, ['show', `${ref}:atlas.yaml`]);
|
|
113
|
+
if (text === null) return false;
|
|
114
|
+
let facts;
|
|
115
|
+
try { facts = parseYaml(text) ?? {}; } catch { return false; }
|
|
116
|
+
const value = facts[WINDOW_KEY];
|
|
117
|
+
return value === true || value === 'on';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Each module path that one code text imports, or null when an import is
|
|
121
|
+
// not a plain text path: an import whose name is built at run time, or a
|
|
122
|
+
// call of the CommonJS loader.
|
|
123
|
+
// With `dynamic: false`, a plain `import()` is left out: the command file
|
|
124
|
+
// loads each command's code only when that command runs. Only relative
|
|
125
|
+
// paths name files of the repo. A `node:` name is built in, and a bare name
|
|
126
|
+
// is a package outside the repo; a pull request changes either one only
|
|
127
|
+
// through a package manifest, which is protected. Whole comment lines go
|
|
128
|
+
// first, so a comment that names an import form is not read as code. A
|
|
129
|
+
// comment at the end of a code line stays, which only makes it stricter.
|
|
130
|
+
const COMMENT_LINE = /^\s*(\/\/|\/\*|\*)/;
|
|
131
|
+
const LOADERS = new RegExp(`\\b${['re', 'quire'].join('')}\\s*\\(|\\b${['create', 'Require'].join('')}\\b|\\bimport\\.meta\\.${['re', 'solve'].join('')}\\b`);
|
|
132
|
+
export function importsOf(text, { dynamic = true } = {}) {
|
|
133
|
+
const source = String(text).split('\n').filter((line) => !COMMENT_LINE.test(line)).join('\n');
|
|
134
|
+
const found = [];
|
|
135
|
+
const forms = [
|
|
136
|
+
/\b(?:import|export)\s[^'"`;]*?\bfrom\s*(['"])([^'"\n]+)\1/g,
|
|
137
|
+
/\bimport\s*(['"])([^'"\n]+)\1/g,
|
|
138
|
+
];
|
|
139
|
+
for (const form of forms) for (const hit of source.matchAll(form)) found.push(hit[2]);
|
|
140
|
+
const loaded = [...source.matchAll(/\bimport\s*\(\s*(['"])([^'"\n]+)\1\s*\)/g)].map((hit) => hit[2]);
|
|
141
|
+
if (dynamic) found.push(...loaded);
|
|
142
|
+
// Every dynamic import must be one that the forms above read.
|
|
143
|
+
if ([...source.matchAll(/\bimport\s*\(/g)].length !== loaded.length) return null;
|
|
144
|
+
if (LOADERS.test(source)) return null;
|
|
145
|
+
const out = [];
|
|
146
|
+
for (const name of found) {
|
|
147
|
+
if (name.startsWith('./') || name.startsWith('../')) out.push(name);
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// The protected set at one commit: { paths: Set, why: null } or { why }.
|
|
153
|
+
// 1. The import closure of WINDOW_ENTRIES, read from git objects of `ref`.
|
|
154
|
+
// An entry that the commit does not hold is left out, so a repo with no
|
|
155
|
+
// Atlas package has an empty closure.
|
|
156
|
+
// 2. Every file that is not code, in each folder of the closure and below it:
|
|
157
|
+
// the YAML, JSON, templates and workflows that protected code reads.
|
|
158
|
+
// 3. Every package manifest and lock file, which name the commands.
|
|
159
|
+
// 4. AUDIT_CODE of signed-act.mjs.
|
|
160
|
+
export function protectedSet(root, ref) {
|
|
161
|
+
const tree = git(root, ['ls-tree', '-r', '--name-only', '-z', ref]);
|
|
162
|
+
if (tree === null) return { why: `git cannot list the files of ${String(ref).slice(0, 12)}` };
|
|
163
|
+
const files = tree.split('\0').filter(Boolean);
|
|
164
|
+
const held = new Set(files);
|
|
165
|
+
const paths = new Set();
|
|
166
|
+
const queue = WINDOW_ENTRIES.filter((path) => held.has(path));
|
|
167
|
+
while (queue.length) {
|
|
168
|
+
const path = queue.pop();
|
|
169
|
+
if (paths.has(path)) continue;
|
|
170
|
+
paths.add(path);
|
|
171
|
+
if (paths.size > MAX_CLOSURE) return { why: `the import closure holds more than ${MAX_CLOSURE} files` };
|
|
172
|
+
// A path that the commit does not hold is still protected: a pull request
|
|
173
|
+
// that adds it adds code that a protected file imports.
|
|
174
|
+
if (!held.has(path)) continue;
|
|
175
|
+
if (!CODE.test(path) && !path.endsWith('/bin/atlas')) continue;
|
|
176
|
+
const text = git(root, ['show', `${ref}:${path}`]);
|
|
177
|
+
if (text === null) return { why: `git cannot read ${path} at ${String(ref).slice(0, 12)}` };
|
|
178
|
+
const names = importsOf(text, { dynamic: !WINDOW_DISPATCH.includes(path) });
|
|
179
|
+
if (names === null) return { why: `${path} has an import that is not a plain text path` };
|
|
180
|
+
for (const name of names) {
|
|
181
|
+
const next = posix.normalize(posix.join(posix.dirname(path), name));
|
|
182
|
+
if (next.startsWith('../') || next === '..') return { why: `${path} imports a file outside the repo` };
|
|
183
|
+
queue.push(next);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const folders = new Set([...paths].map((path) => posix.dirname(path)));
|
|
187
|
+
for (const file of files) {
|
|
188
|
+
if (CODE.test(file)) continue;
|
|
189
|
+
if ([...folders].some((folder) => folder !== '.' && file.startsWith(`${folder}/`))) paths.add(file);
|
|
190
|
+
}
|
|
191
|
+
for (const file of files) if (/(^|\/)(package\.json|package-lock\.json|pnpm-lock\.yaml|npm-shrinkwrap\.json)$/.test(file)) paths.add(file);
|
|
192
|
+
for (const path of AUDIT_CODE) paths.add(path);
|
|
193
|
+
for (const file of files) if (WINDOW_ROLES.some((role) => (role.endsWith('/') ? file.startsWith(role) : file === role))) paths.add(file);
|
|
194
|
+
for (const role of WINDOW_ROLES) if (!role.endsWith('/')) paths.add(role);
|
|
195
|
+
return { paths, folded: new Set([...paths].map(foldPath)), roles: WINDOW_ROLES, why: null };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// May a window clear this owner row? Rule 1: a row of the repo's own list, of
|
|
199
|
+
// a class that the window names, on a path outside the protected set.
|
|
200
|
+
export function clearable(row, guard) {
|
|
201
|
+
if (row?.kind !== 'owner') return false;
|
|
202
|
+
if (!WINDOW_CLASSES.includes(row.class)) return false;
|
|
203
|
+
if (typeof row.cause !== 'string' || !row.cause.startsWith('list: ')) return false;
|
|
204
|
+
if (typeof row.path !== 'string' || !row.path) return false;
|
|
205
|
+
if (!guard?.paths || guard.why) return false;
|
|
206
|
+
// Each test compares folded paths, so `Approve.mjs` is `approve.mjs`.
|
|
207
|
+
// A name with a letter outside plain ASCII is never cleared: a Mac may take
|
|
208
|
+
// it as a protected file by a case rule that no fold here matches (second
|
|
209
|
+
// review of 7 November).
|
|
210
|
+
if (!/^[\x20-\x7e]+$/.test(row.path)) return false;
|
|
211
|
+
const path = foldPath(row.path);
|
|
212
|
+
if (WINDOW_PACK_FILES.includes(posix.basename(path))) return false;
|
|
213
|
+
// A new file under a role folder is protected too, though the base lacks it.
|
|
214
|
+
if ((guard.roles ?? []).some((role) => (role.endsWith('/') ? path.startsWith(foldPath(role)) : path === foldPath(role)))) return false;
|
|
215
|
+
if ((guard.folded ?? new Set([...guard.paths].map(foldPath))).has(path)) return false;
|
|
216
|
+
// A path on the owner-only floor is never cleared, even when the repo's
|
|
217
|
+
// list names it too (security review of 31 October, S2).
|
|
218
|
+
if (guard.floor && guard.floor(row.path)) return false;
|
|
219
|
+
return !guard.paths.has(row.path);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const same = (a, b) => String(a).toLowerCase() === String(b).toLowerCase();
|
|
223
|
+
|
|
224
|
+
// The window acts of the verified blocks that name this repo and branch, each
|
|
225
|
+
// with the time of its block, newest first.
|
|
226
|
+
export function windowActs(verified, { repo, branch }) {
|
|
227
|
+
const out = [];
|
|
228
|
+
for (const row of verified) {
|
|
229
|
+
if (row.error) continue;
|
|
230
|
+
for (const act of row.acts) {
|
|
231
|
+
if (act.kind !== 'window' || !same(act.repo, repo) || act.branch !== branch) continue;
|
|
232
|
+
out.push({ act, signed_at: row.signed_at, key: row.key, verified: row.verified });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return out.sort((a, b) => b.signed_at.localeCompare(a.signed_at));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// The length rule of rule 2: the end is at or after the touch, and at most
|
|
239
|
+
// WINDOW_HOURS after it.
|
|
240
|
+
export function lengthOk(one) {
|
|
241
|
+
const start = Date.parse(one.signed_at);
|
|
242
|
+
const end = Date.parse(one.act.ends);
|
|
243
|
+
return Number.isFinite(start) && Number.isFinite(end) && end >= start && end - start <= WINDOW_MS;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// The window act that is open at `now`: its touch is at or before now, now is
|
|
247
|
+
// before its end, and the base holds its seen commit. `isSeen(sha)` says
|
|
248
|
+
// whether the base holds that commit. Returns { open } or { why }.
|
|
249
|
+
export function openAt(acts, { now, isSeen }) {
|
|
250
|
+
const at = new Date(now).getTime();
|
|
251
|
+
let why = acts.length ? null : 'no window act names this repo and branch';
|
|
252
|
+
for (const one of acts) {
|
|
253
|
+
if (!lengthOk(one)) { why ??= `the window act of ${one.signed_at} is longer than ${WINDOW_HOURS} hours, or ends before its touch`; continue; }
|
|
254
|
+
if (Date.parse(one.signed_at) > at) { why ??= `the window act of ${one.signed_at} is from after this run`; continue; }
|
|
255
|
+
if (Date.parse(one.act.ends) <= at) { why ??= `the newest window ended at ${one.act.ends}`; continue; }
|
|
256
|
+
if (!isSeen(one.act.seen)) { why ??= `the seen commit ${one.act.seen.slice(0, 7)} of the window is not in the history of the base`; continue; }
|
|
257
|
+
return { open: one };
|
|
258
|
+
}
|
|
259
|
+
return { why };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// The newest seen commit of the acts that `isSeen` holds, or null. Every act
|
|
263
|
+
// counts here, also one of zero length: a touch that saw the list.
|
|
264
|
+
export function seenAt(acts, isSeen) {
|
|
265
|
+
for (const one of acts) if (lengthOk(one) && isSeen(one.act.seen)) return one;
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Does commit `newer` hold commit `older`?
|
|
270
|
+
export const holdsCommit = (root, older, newer) => /^[0-9a-f]{40}$/.test(String(older))
|
|
271
|
+
&& spawnSync('git', [MERGE_BASE, '--is-ancestor', '--end-of-options', older, newer], { cwd: root }).status === 0;
|
package/owner-only.yaml
CHANGED
package/package.json
CHANGED
package/skills/lead/SKILL.md
CHANGED
|
@@ -128,7 +128,7 @@ set. A test keeps the two lists equal.
|
|
|
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
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` |
|
|
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 |
|
|
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
134
|
A person merges every file that `atlas tooling` writes.
|