@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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atlas",
3
- "version": "0.4.54",
3
+ "version": "0.4.55",
4
4
  "description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, for any repository.",
5
5
  "author": {
6
6
  "name": "Arjun Khera"
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
- import { install, upgrade, doctor } from './lib/install.mjs';
24
- import { checkPaths, LIMITS } from './lib/ste.mjs';
25
- import { checkDesignFolder, designFolderOf, repoRootFrom } from './lib/design.mjs';
26
- import { writeDesignPage, readTracker } from './lib/design-build.mjs';
27
- import { listDesigns, freezeDesign } from './lib/design-freeze.mjs';
28
- import { writePage } from './lib/page.mjs';
29
- import { writeViewPage, viewCapabilities } from './lib/view.mjs';
30
- import { loadPrivateTerms } from './lib/privacy.mjs';
31
- import { checkCommand as testsCheck, writeCommand as testsWrite } from './lib/tests.mjs';
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 testsCheck({ halves: chosen.halves, evidence: chosen.evidence, tests: chosen.tests, guardsFrom: chosen['guards-from'], run: chosen.run }, root, line, PACKAGE_ROOT);
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 testsWrite({ dryRun: Boolean(chosen['dry-run']) }, root, PACKAGE_ROOT, line);
483
- if (what === 'proof') return testsProof({ run: chosen.run, own: chosen.own, evidence: chosen.evidence, base: chosen.base, covers: Boolean(chosen.covers) }, root, line);
484
- if (what === 'verdict') return testsVerdict({ run: chosen.run, base: chosen.base, evidence: chosen.evidence, covers: Boolean(chosen.covers) }, root, line);
485
- if (what === 'changed') return testsChanged({ base: chosen.base, head: chosen.head, out: chosen.out, text: Boolean(chosen.text) }, root, line);
486
- if (what === 'after') return testsAfter({ base: chosen.base, head: chosen.head, list: chosen.list, out: chosen.out, text: Boolean(chosen.text) }, root, line);
487
- if (what === 'before') return testsBefore({ 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);
488
- if (what === 'named') return testsNamed({ base: chosen.base, covers: Boolean(chosen.covers) }, root, line);
489
- if (what === 'sweep') return testsSweep({ evidence: chosen.evidence, since: chosen.since, base: chosen.base, out: chosen.out, text: Boolean(chosen.text) }, root, line);
490
- if (what === 'covers') return testsCovers({ itemId: chosen._[2], evidence: chosen.evidence, json: Boolean(chosen.json) }, root, line);
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 testsRerun({ run: chosen.run, evidence: chosen.evidence, tests: chosen.tests, language: chosen.language ?? 'javascript' }, root, line);
493
- if (what === 'issues') return testsIssues({ 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);
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');
@@ -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);
@@ -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: [
@@ -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>.`);
@@ -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
@@ -245,3 +245,4 @@ atlas_yaml:
245
245
  - generated
246
246
  - verbatim
247
247
  - private_terms
248
+ - signing_window
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.4.54",
3
+ "version": "0.4.55",
4
4
  "description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, as a Claude Code plugin for any repository.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -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` | Never from a session. Only the owner runs it, 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 |
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.