@arjunkhera/atlas 0.4.42 → 0.4.44

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.42",
3
+ "version": "0.4.44",
4
4
  "description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, for any repository.",
5
5
  "author": {
6
6
  "name": "Arjun Khera"
@@ -34,6 +34,7 @@ import { ghApi, pinnedRuns } from './gate.mjs';
34
34
  import { GATE_CHECK } from './gate-check.mjs';
35
35
  import {
36
36
  ACTS, MAX_ACTS, AUDIT_CHECK, AUDIT_TEST, NAMESPACE, SIGNERS_FILE, payloadText, blockText, parseSigners, verifyBlock, verifyTexts, fingerprint, wordsFingerprint, SignedActError,
37
+ preKeyKeys,
37
38
  } from './signed-act.mjs';
38
39
 
39
40
  export class ApproveError extends Error {}
@@ -303,7 +304,7 @@ export function auditCause(deps, repo, { run, sha, pr, main }) {
303
304
 
304
305
  // One open audit issue, read from GitHub: the pull request that made the
305
306
  // merge, its head, and the red audit run. `skip` says why it is not signed.
306
- export function auditRow(deps, repo, issue, main, kinds = new Map()) {
307
+ export async function auditRow(deps, repo, issue, main, kinds = new Map()) {
307
308
  const sha = AUDIT_ISSUE.exec(String(issue.title ?? ''))[1];
308
309
  const row = { issue: Number(issue.number), sha };
309
310
  const pull = (deps.gh('GET', `repos/${repo}/commits/${sha}/pulls`) ?? []).find((each) => each?.merged_at && each.merge_commit_sha === sha);
@@ -327,9 +328,17 @@ export function auditRow(deps, repo, issue, main, kinds = new Map()) {
327
328
  if (own) return { ...row, skip: `the merge changed ${own}, so its audit ran its own code: sign it by hand` };
328
329
  const parent = String(commit?.parents?.[0]?.sha ?? '');
329
330
  if (!/^[0-9a-f]{40}$/.test(parent)) return { ...row, skip: 'GitHub gave no parent of the merge commit' };
330
- const keys = mergeKeys(deps, repo, parent, head);
331
- if (!keys.length) return { ...row, skip: `${SIGNERS_FILE} on the base of this merge holds no owner key, so no act can count: close the issue by hand` };
332
- return { ...row, run: String(run.id), keys };
331
+ let keys = mergeKeys(deps, repo, parent, head);
332
+ // Item 65c6c907: a merge made before the first owner key takes the keys
333
+ // at the tip of the default branch, within the limits of preKeyKeys.
334
+ let first = null;
335
+ if (!keys.length) {
336
+ const read = (path) => { try { return deps.gh('GET', path); } catch (error) { if (error?.status === 404 || /HTTP 404|Not Found/i.test(String(error?.message ?? ''))) return null; throw error; } };
337
+ try { first = await preKeyKeys(read, repo, { parent, branch: main }); } catch (error) { first = { why: `GitHub refused a read of the key file history (${plainText(error?.message ?? error).slice(0, 120)})` }; }
338
+ if (first.keys) keys = first.keys;
339
+ }
340
+ if (!keys.length) return { ...row, skip: `${SIGNERS_FILE} on the base of this merge holds no owner key, so no act can count: close the issue by hand. No first key counts: ${first?.why ?? 'no reason'}` };
341
+ return { ...row, run: String(run.id), keys, ...(first?.from ? { first_key: first.from } : {}) };
333
342
  }
334
343
 
335
344
  // `atlas approve audit [<pr>...]`. Exit 0 when each listed red got its act.
@@ -369,7 +378,7 @@ export async function approveAudit({ prs = [], repo, root, key, provider, yes =
369
378
  line(` ${issues.length} open audit issue(s) of an owner merge with no touch. Each one can take about 5 seconds.`);
370
379
  const rows = [];
371
380
  for (const issue of issues) {
372
- const row = auditRow(deps, name, issue, main, kinds);
381
+ const row = await auditRow(deps, name, issue, main, kinds);
373
382
  if (only.size && !only.has(row.pr)) continue;
374
383
  rows.push(row);
375
384
  }
@@ -378,6 +387,7 @@ export async function approveAudit({ prs = [], repo, root, key, provider, yes =
378
387
  for (const row of rows) {
379
388
  line(` issue #${row.issue} ${row.pr ? `#${row.pr}` : row.sha.slice(0, 7)}${row.head ? ` at ${row.head.slice(0, 7)}` : ''}${row.merged_at ? ` merged ${row.merged_at}` : ''} ${row.skip ? 'NOT SIGNED' : 'SIGN'} ${row.title ?? ''}`.trimEnd());
380
389
  if (row.skip) line(` ${row.skip}`);
390
+ else if (row.first_key) line(` merged before the first owner key: the act counts against the keys at the tip of ${main}, ${row.first_key.slice(0, 7)}`);
381
391
  }
382
392
  if (only.size) for (const pr of only) if (!rows.some((row) => row.pr === pr)) line(` #${pr} has no open audit issue: nothing to accept.`);
383
393
  if (!sign.length) { line('Nothing to sign: no open audit red can take an act.'); return 1; }
@@ -24,7 +24,8 @@
24
24
  // the owner's signed act for this repo, this pull request and this head
25
25
  // (roadmap R72, signed-act.mjs). The key comes from atlas/owner-signers on the
26
26
  // base, never from the head. An act for an older head carries to this head
27
- // when the new commits only merge the base in with no conflict (item 34b554e7).
27
+ // when the new commits only merge the base in with no conflict (item 34b554e7),
28
+ // or only rebuild the pages that atlas.yaml names under `generated` (item 4ee7cab3).
28
29
  //
29
30
  // Red wins over owner: the pull request must be fixed before the owner signs.
30
31
  // Rules that hold everywhere here:
@@ -36,7 +37,7 @@
36
37
  import { spawnSync } from 'node:child_process';
37
38
  import { readFileSync, appendFileSync } from 'node:fs';
38
39
  import { resolve } from 'node:path';
39
- import { classify, changedPaths, compile, matcher, fakeSecret, repoFacts } from './owner-only.mjs';
40
+ import { classify, changedPaths, compile, matcher, fakeSecret, repoFacts, rulesAt, onList } from './owner-only.mjs';
40
41
  import { MISSING, HIT, fileHits, hidePath, shippedPaths, ships, termsFromEnv } from './private-terms.mjs';
41
42
  import { listChanged } from './changed-tests.mjs';
42
43
  import { parseYaml, SECRET_PATTERNS } from '../../shape/check.mjs';
@@ -58,8 +59,8 @@ const CASE_GROUPS = new Set(['unit-test', 'check-block', 'scenario-words']);
58
59
 
59
60
  export class GateCheckError extends Error {}
60
61
 
61
- function git(root, args, { allowFail = false } = {}) {
62
- const r = spawnSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
62
+ function git(root, args, { allowFail = false, env = process.env } = {}) {
63
+ const r = spawnSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024, env });
63
64
  if (r.error) throw new GateCheckError(`git cannot run: ${r.error.message}`);
64
65
  if (r.status !== 0) {
65
66
  if (allowFail) return null;
@@ -312,7 +313,7 @@ export function judge({ root, base, head, body = '', redMain = [], rules = null,
312
313
  answer.rows.push(...secretRows(top, mergeBase, headSha, changes));
313
314
  if (terms !== undefined) answer.rows.push(...termRows(top, baseSha, headSha, changes, terms));
314
315
  answer.rows.push(...testRows({ root: top, mergeBase, head: headSha, body, redMain, rules: ruleRef, undo }));
315
- if (signed) signedRows(top, baseSha, headSha, signed, answer);
316
+ if (signed) signedRows(top, baseSha, headSha, signed, answer, ruleRef);
316
317
  return finish(answer);
317
318
  }
318
319
 
@@ -323,7 +324,7 @@ export function judge({ root, base, head, body = '', redMain = [], rules = null,
323
324
  // the base after the touch does not carry it (security review S1). A head job
324
325
  // named like a check is never cleared: the owner's view of the gate could be
325
326
  // that job's forgery (security review S1).
326
- function signedRows(root, baseSha, headSha, { repo, pr, baseRef, texts }, answer) {
327
+ function signedRows(root, baseSha, headSha, { repo, pr, baseRef, texts }, answer, ruleRef = baseSha) {
327
328
  if (!answer.rows.some((row) => row.kind === 'owner') || answer.rows.some((row) => row.kind === 'red')) return;
328
329
  const note = (cause) => answer.rows.push({ kind: 'note', class: 'signed', cause });
329
330
  if (texts === null) { note('the comments of the pull request could not be read, so no signed act counts'); return; }
@@ -336,7 +337,7 @@ function signedRows(root, baseSha, headSha, { repo, pr, baseRef, texts }, answer
336
337
  let found = findAct(verified, { kind: 'merge', repo, pr: Number(pr), base: baseRef, merge_base: answer.merge_base, head: headSha });
337
338
  let carried = null;
338
339
  if (!found) {
339
- carried = carryAct(root, baseSha, headSha, verified, { repo, pr, baseRef }, answer);
340
+ carried = carryAct(root, baseSha, headSha, verified, { repo, pr, baseRef, ruleRef }, answer);
340
341
  if (carried?.found) found = carried.found;
341
342
  }
342
343
  if (!found) {
@@ -348,8 +349,9 @@ function signedRows(root, baseSha, headSha, { repo, pr, baseRef, texts }, answer
348
349
  const locked = answer.rows.filter((row) => row.kind === 'owner' && row.class === 'gate-checks');
349
350
  if (locked.length) { note('a signed act never clears a head job named like a check: remove that job'); return; }
350
351
  for (const row of answer.rows) if (row.kind === 'owner') row.kind = 'signed';
351
- answer.signed = { key: found.key, signed_at: found.signed_at, verified: found.verified, ...(carried?.found ? { carried_from: found.act.head } : {}) };
352
- if (carried?.found) note(`the owner signed head ${found.act.head.slice(0, 7)} with key ${found.key} at ${found.signed_at}; this head only adds ${carried.merges} clean merge(s) of the base`);
352
+ answer.signed = { key: found.key, signed_at: found.signed_at, verified: found.verified, ...(carried?.found ? { carried_from: found.act.head } : {}), ...(carried?.pages?.length ? { pages: carried.pages, pages_checks: carried.checks } : {}) };
353
+ if (carried?.found && carried.pages?.length) note(`the owner signed head ${found.act.head.slice(0, 7)} with key ${found.key} at ${found.signed_at}; this head only adds ${carried.merges} merge(s) of the base and rebuilds ${carried.pages.length} generated page(s), which the required check(s) ${carried.checks.join(', ')} prove`);
354
+ else if (carried?.found) note(`the owner signed head ${found.act.head.slice(0, 7)} with key ${found.key} at ${found.signed_at}; this head only adds ${carried.merges} clean merge(s) of the base`);
353
355
  else note(`the owner signed this head with key ${found.key} at ${found.signed_at}`);
354
356
  }
355
357
 
@@ -377,7 +379,7 @@ const contains = (root, older, newer) => spawnSync('git', [MERGE_BASE, '--is-anc
377
379
  // 5. Only the newest touch on this pull request carries, so a rollback to a
378
380
  // head that the owner moved past does not clear.
379
381
  // It fails closed: a commit or a merge that git cannot read stops the carry.
380
- function carryAct(root, baseSha, headSha, verified, { repo, pr, baseRef }, answer) {
382
+ function carryAct(root, baseSha, headSha, verified, { repo, pr, baseRef, ruleRef = baseSha }, answer) {
381
383
  const same = (a, b) => String(a).toLowerCase() === String(b).toLowerCase();
382
384
  // Every valid merge act of this pull request, on any base branch and head.
383
385
  const mine = [];
@@ -398,10 +400,13 @@ function carryAct(root, baseSha, headSha, verified, { repo, pr, baseRef }, answe
398
400
  }).slice(0, CARRY_TRIES);
399
401
  if (!candidates.length) return { why: 'the newest signed act of this pull request names another base branch or head' };
400
402
  if (answer.rows.some((row) => row.kind === 'owner' && row.class === 'brake')) return { why: 'the brake is on, so the owner signs this head' };
403
+ const pages = generatedAt(root, baseSha);
404
+ for (const cause of pages.notes) answer.rows.push({ kind: 'note', class: 'signed', cause });
405
+ const built = pages.entries.length ? { entries: pages.entries, rules: rulesAt({ root, ref: ruleRef }).rules } : null;
401
406
  let why = null;
402
407
  for (const one of candidates) {
403
- const result = carryOne(root, baseSha, headSha, answer.merge_base, one.act);
404
- if (result.merges) return { found: one, merges: result.merges };
408
+ const result = carryOne(root, baseSha, headSha, answer.merge_base, one.act, built);
409
+ if (result.ok) return { found: one, merges: result.merges, pages: result.pages, checks: result.checks };
405
410
  why ??= result.why;
406
411
  }
407
412
  return { why: `${why} (${candidates.length} signed head(s) tried)` };
@@ -411,7 +416,7 @@ function carryAct(root, baseSha, headSha, verified, { repo, pr, baseRef }, answe
411
416
  // gate makes depends on the commits alone.
412
417
  const PINNED = { ...process.env, GIT_NO_REPLACE_OBJECTS: '1', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null' };
413
418
 
414
- function carryOne(root, baseSha, headSha, mergeBase, act) {
419
+ function carryOne(root, baseSha, headSha, mergeBase, act, built = null) {
415
420
  const old = commitOf(root, act.head);
416
421
  if (old !== act.head) return { why: `the signed head ${act.head.slice(0, 7)} is not a commit in this clone` };
417
422
  if (commitOf(root, act.merge_base) !== act.merge_base) return { why: `the signed merge base ${act.merge_base.slice(0, 7)} is not a commit in this clone` };
@@ -424,15 +429,97 @@ function carryOne(root, baseSha, headSha, mergeBase, act) {
424
429
  // commits after the signed merge base. A merge may bring in only one of
425
430
  // them, never a middle commit of a merged pull request (finding 3).
426
431
  const tips = new Set((git(root, ['rev-list', '--first-parent', baseSha, `^${act.merge_base}`], { allowFail: true }) ?? '').split('\n').filter(Boolean));
432
+ const pages = new Set();
433
+ const checks = new Set();
434
+ let merges = 0;
427
435
  for (const sha of shas) {
428
- const [tree, ...parents] = (git(root, ['show', '-s', '--format=%T %P', sha], { allowFail: true }) ?? '').trim().split(' ');
436
+ const [tree, ...parents] = (git(root, ['show', '-s', '--format=%T %P', sha], { allowFail: true, env: PINNED }) ?? '').trim().split(' ');
437
+ // Item 4ee7cab3: a commit on the branch that only rebuilds generated pages.
438
+ // No check names its parent. H0 comes into this head only down the first
439
+ // parents, because each second parent is a tip of the base, so the parent
440
+ // is H0 or a new commit (security review, finding 5).
441
+ if (parents.length === 1 && built) {
442
+ const off = offPages(root, `${parents[0]}^{tree}`, tree, built, pages, checks);
443
+ if (off) return { why: `commit ${sha.slice(0, 7)} is not a merge of two parents, and ${off}` };
444
+ continue;
445
+ }
429
446
  if (parents.length !== 2) return { why: `commit ${sha.slice(0, 7)} is not a merge of two parents` };
430
447
  if (!tips.has(parents[1])) return { why: `merge ${sha.slice(0, 7)} brings in ${parents[1].slice(0, 7)}, which was never a tip of the base after the touch` };
431
- const made = spawnSync('git', ['-c', 'merge.renames=true', '-c', 'merge.conflictStyle=merge', 'merge-tree', '--write-tree', '--no-messages', '--end-of-options', parents[0], parents[1]], { cwd: root, encoding: 'utf8', env: PINNED, timeout: 30_000 });
432
- if (made.status !== 0) return { why: `merge ${sha.slice(0, 7)} has a conflict, so its fix needs a new touch` };
433
- if (made.stdout.split('\n')[0].trim() !== tree) return { why: `merge ${sha.slice(0, 7)} changes more than a clean merge of its parents` };
448
+ merges += 1;
449
+ const made = spawnSync('git', ['-c', 'merge.renames=true', '-c', 'merge.conflictStyle=merge', 'merge-tree', '--write-tree', '-z', '--name-only', '--no-messages', '--end-of-options', parents[0], parents[1]], { cwd: root, encoding: 'utf8', env: PINNED, timeout: 30_000 });
450
+ // 0 is a clean merge, 1 a merge with a conflict; anything else fails closed.
451
+ if (made.status !== 0 && made.status !== 1) return { why: `git cannot merge the parents of ${sha.slice(0, 7)}` };
452
+ const [madeTree, ...conflicted] = made.stdout.split('\0').filter(Boolean);
453
+ if (made.status === 0 && madeTree === tree) continue;
454
+ if (!built) return { why: made.status === 1 ? `merge ${sha.slice(0, 7)} has a conflict, so its fix needs a new touch` : `merge ${sha.slice(0, 7)} changes more than a clean merge of its parents` };
455
+ // A conflict counts only in a generated page, and the merge may differ
456
+ // from the tree that git makes only in generated pages.
457
+ const outside = conflicted.find((path) => !pageOf(built, path));
458
+ if (outside) return { why: `merge ${sha.slice(0, 7)} has a conflict in ${outside}, which no generated entry names, so its fix needs a new touch` };
459
+ const off = offPages(root, madeTree, tree, built, pages, checks);
460
+ if (off) return { why: `merge ${sha.slice(0, 7)} changes more than a clean merge of its parents: ${off}` };
461
+ }
462
+ return { ok: true, merges, pages: [...pages].sort(), checks: [...checks].sort() };
463
+ }
464
+
465
+ // ------------------------------------------------------------------ generated pages
466
+
467
+ // Tracker item 4ee7cab3. A repo can name pages that a script builds from its
468
+ // code, and the required check that proves each page equals the build:
469
+ //
470
+ // generated:
471
+ // - path: docs/manual/reference/
472
+ // check: tests · unit
473
+ // why: node docs/manual/extract/run.mjs --check
474
+ //
475
+ // The list comes from the base, never from the head. An entry counts only
476
+ // when its check is a required check of the base and its path is a plain
477
+ // path that is not the repo root, .github/ or atlas.yaml. The gate never
478
+ // builds a page: nothing of the head runs. It only proves that a new commit
479
+ // changes a generated page and nothing else, and it names the check that
480
+ // proves the page. A merge needs every required check green, so a page that
481
+ // differs from the build stops the merge in that check.
482
+ const PAGE_PATH = /^(?!\.github(?:\/|$))(?!atlas\.yaml$)(?!atlas\/)[A-Za-z0-9_][A-Za-z0-9_.-]*(?:\/[A-Za-z0-9_][A-Za-z0-9_.-]*)*\/?$/;
483
+ export function generatedAt(root, ref) {
484
+ let facts;
485
+ try { facts = parseYaml(textAt(root, ref, 'atlas.yaml') ?? '') ?? {}; } catch { return { entries: [], notes: ['atlas.yaml on the base cannot be read, so no generated page counts'] }; }
486
+ if (facts.generated === undefined || facts.generated === null) return { entries: [], notes: [] };
487
+ if (!Array.isArray(facts.generated)) return { entries: [], notes: ['atlas.yaml generated is not a list, so no generated page counts'] };
488
+ const required = new Set(Array.isArray(facts.required_checks) ? facts.required_checks.map(String) : []);
489
+ const entries = [];
490
+ const notes = [];
491
+ facts.generated.forEach((entry, index) => {
492
+ const path = entry && typeof entry === 'object' ? entry.path : null;
493
+ const check = entry && typeof entry === 'object' ? entry.check : null;
494
+ if (typeof path !== 'string' || !PAGE_PATH.test(path) || path.split('/').some((part) => part === '..' || part === '.')) notes.push(`atlas.yaml generated entry ${index + 1} does not count: its path is not a plain path`);
495
+ else if (typeof check !== 'string' || !required.has(check)) notes.push(`atlas.yaml generated entry ${index + 1} does not count: its check is not a required check of the base`);
496
+ else entries.push({ path, check });
497
+ });
498
+ return { entries, notes };
499
+ }
500
+
501
+ const pageOf = (built, path) => built.entries.find((entry) => (entry.path.endsWith('/') ? path.startsWith(entry.path) : path === entry.path)) ?? null;
502
+
503
+ // Null when tree b differs from tree a only in generated pages that both trees
504
+ // hold as plain files, which the owner-only list does not name. A page that
505
+ // is added, removed or made a link counts as a change the owner did not see.
506
+ // Otherwise the first reason.
507
+ function offPages(root, a, b, built, pages, checks) {
508
+ const out = git(root, ['diff-tree', '-r', '-z', '--no-renames', '--no-textconv', '--no-ext-diff', a, b], { allowFail: true, env: PINNED });
509
+ if (out === null) return 'git cannot compare its trees';
510
+ const parts = out.split('\0');
511
+ const found = [];
512
+ for (let i = 0; i + 1 < parts.length; i += 2) {
513
+ const [oldMode, newMode, , , status] = parts[i].replace(/^:/, '').split(' ');
514
+ const path = parts[i + 1];
515
+ const entry = pageOf(built, path);
516
+ if (!entry) return `it changes ${path}, which no generated entry names`;
517
+ if (status !== 'M' || oldMode !== '100644' || newMode !== '100644') return `it adds, removes or retypes the generated page ${path}`;
518
+ if (onList(built.rules, path)) return `the generated page ${path} is on the owner-only list`;
519
+ found.push({ path, check: entry.check });
434
520
  }
435
- return { merges: shas.length };
521
+ for (const one of found) { pages.add(one.path); checks.add(one.check); }
522
+ return null;
436
523
  }
437
524
 
438
525
  // Line 1: while the brake is on, every pull request of the repo comes to the
package/door/lib/gate.mjs CHANGED
@@ -426,7 +426,10 @@ export function gateCopyRefusals(gh, repo, pull) {
426
426
  export function pinnedRuns(gh, repo, { pr, sha, baseRef, head, checks, homesRef = baseRef, audit = false }) {
427
427
  const homes = homesAt(gh, repo, homesRef, checks);
428
428
  const sides = sidesOf(gh, repo, head, baseRef);
429
- const baseChanged = audit ? () => '' : baseChangedOf(gh, repo, pr);
429
+ // The audit keeps the base-change pin: a merged pull request still lists
430
+ // its events, and a run made while it pointed at a side branch used that
431
+ // branch's file (security review of item 4ee7cab3, finding 1).
432
+ const baseChanged = baseChangedOf(gh, repo, pr);
430
433
  const runOf = suiteRuns(gh, repo);
431
434
  const kept = [];
432
435
  const refused = [];
@@ -351,3 +351,67 @@ export function findAct(verified, want) {
351
351
  }
352
352
  return null;
353
353
  }
354
+
355
+ // Item 65c6c907: the owner keys for a merge made before the first owner key.
356
+ // Such a merge can take no act against the keys of its base, so its audit
357
+ // red would block its item for good. It may take an accept act against the
358
+ // keys of the default branch at its tip now, within four limits:
359
+ // 1. No version of the key file in the history of the base held a key, so
360
+ // a key that was removed later never opens this path. A version that
361
+ // cannot be read counts as one that held a key.
362
+ // 2. The tip is the branch ref, resolved to a commit (refs/heads only, so a
363
+ // tag with the same name changes nothing). The caller passes the default
364
+ // branch of the repo.
365
+ // 3. Only a tip key that every keyed version of the file in the history of
366
+ // the tip holds counts. A key pushed after the first owner key, by anyone
367
+ // who can push to the branch, never counts here (security round 2, S2).
368
+ // 4. A history that cannot be read in full counts as no history: no keys.
369
+ // GitHub's history of a path leaves out a commit whose change did not reach
370
+ // the branch line (history simplification); limits 1 and 3 hold for the rest.
371
+ // `read(path)` answers GitHub's JSON for a GET, or null for a 404; it may
372
+ // answer a promise. Returns { keys, from } or { why }.
373
+ export const HISTORY_PAGES = 3;
374
+ export const signersHistoryPath = (repo, sha, page) => `repos/${repo}/commits?path=${encodeURIComponent(SIGNERS_FILE)}&sha=${sha}&per_page=100&page=${page}`;
375
+ export async function preKeyKeys(read, repo, { parent, branch }) {
376
+ const fileAt = async (sha) => {
377
+ const file = await read(`repos/${repo}/contents/${SIGNERS_FILE}?ref=${sha}`);
378
+ if (!file || typeof file.content !== 'string' || file.encoding !== 'base64') return null;
379
+ return parseSigners(Buffer.from(file.content, 'base64').toString('utf8')).keys;
380
+ };
381
+ // Each commit that changed the key file, from `sha` back; null when the
382
+ // history cannot be read in full.
383
+ const history = async (sha) => {
384
+ const shas = [];
385
+ for (let page = 1; page <= HISTORY_PAGES; page += 1) {
386
+ const rows = await read(signersHistoryPath(repo, sha, page));
387
+ if (!Array.isArray(rows) || rows.some((row) => !/^[0-9a-f]{40}$/.test(String(row?.sha ?? '')))) return null;
388
+ shas.push(...rows.map((row) => row.sha));
389
+ if (rows.length < 100) return shas;
390
+ }
391
+ return null;
392
+ };
393
+ if (!/^[0-9a-f]{40}$/.test(String(parent ?? ''))) return { why: 'no base commit is named' };
394
+ if (!/^[A-Za-z0-9._-]{1,100}$/.test(String(branch ?? '')) || String(branch).includes('..')) return { why: 'no branch is named' };
395
+ const before = await history(parent);
396
+ if (!before) return { why: `the history of ${SIGNERS_FILE} before the merge could not be read in full` };
397
+ for (const sha of before) {
398
+ const keys = await fileAt(sha);
399
+ if (keys === null) return { why: `${SIGNERS_FILE} at ${sha.slice(0, 7)}, before the merge, could not be read` };
400
+ if (keys.length) return { why: `${SIGNERS_FILE} held a key at ${sha.slice(0, 7)}, before the merge` };
401
+ }
402
+ const ref = await read(`repos/${repo}/git/ref/heads/${branch}`);
403
+ const tip = String(ref?.object?.sha ?? '');
404
+ if (ref?.ref !== `refs/heads/${branch}` || !/^[0-9a-f]{40}$/.test(tip)) return { why: `the tip of ${branch} could not be read` };
405
+ const atTip = await fileAt(tip);
406
+ if (!atTip?.length) return { why: `${SIGNERS_FILE} at the tip of ${branch} holds no owner key` };
407
+ const since = await history(tip);
408
+ if (!since) return { why: `the history of ${SIGNERS_FILE} at the tip of ${branch} could not be read in full` };
409
+ let keys = atTip;
410
+ for (const sha of since) {
411
+ const held = await fileAt(sha);
412
+ if (held === null) return { why: `${SIGNERS_FILE} at ${sha.slice(0, 7)}, before the tip of ${branch}, could not be read` };
413
+ if (held.length) keys = keys.filter((key) => held.some((one) => one.fingerprint === key.fingerprint));
414
+ }
415
+ if (!keys.length) return { why: `no key at the tip of ${branch} is in every keyed version of ${SIGNERS_FILE}` };
416
+ return { keys, from: tip };
417
+ }
package/owner-only.yaml CHANGED
@@ -235,4 +235,5 @@ atlas_yaml:
235
235
  - kinds
236
236
  - up_to_date
237
237
  - brake
238
+ - generated
238
239
  - spec
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.4.42",
3
+ "version": "0.4.44",
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",
@@ -16,7 +16,7 @@ import { refuse, text, isoDate, url, stringList, sha256, repoKinds, LIVE_KINDS }
16
16
  import { oneLine, ROW_LINE_MAX, priorityOrderOf, byPriority, orderNote, waitsOnLine, blockersOf, blocksOf, blockedPhrase, waitsShown } from './questions.mjs';
17
17
  import { parseYaml } from '../../shape/check.mjs';
18
18
  import { ACTIONS_APP_ID } from '../../door/lib/gate.mjs';
19
- import { MARKER as SIGNED_MARKER, SIGNERS_FILE, parseSigners, verifyTexts, findAct, wordsFingerprint, AUDIT_CHECK, AUDIT_CHECKS, AUDIT_TEST } from '../../door/lib/signed-act.mjs';
19
+ import { MARKER as SIGNED_MARKER, SIGNERS_FILE, parseSigners, verifyTexts, findAct, wordsFingerprint, AUDIT_CHECK, AUDIT_CHECKS, AUDIT_TEST, preKeyKeys } from '../../door/lib/signed-act.mjs';
20
20
 
21
21
  export const DELIVERY_WRITE_VERBS = Object.freeze(['item_done', 'item_reopen']);
22
22
  export const DELIVERY_READ_VERBS = Object.freeze(['in_flight']);
@@ -462,8 +462,20 @@ export function createDeliveryVerbs(core) {
462
462
  return parseSigners(file && typeof file.content === 'string' ? Buffer.from(file.content, 'base64').toString('utf8') : '').keys;
463
463
  };
464
464
  const atFork = forkPoint.toLowerCase() === parent.toLowerCase() ? null : new Set((await keysAt(forkPoint)).map((key) => key.fingerprint));
465
- const keys = (await keysAt(parent)).filter((key) => !atFork || atFork.has(key.fingerprint));
466
- if (!keys.length) refuse(`accepted_red needs the owner's signed waiver, and ${SIGNERS_FILE} on the base of ${pull.url} holds no owner key`);
465
+ let keys = (await keysAt(parent)).filter((key) => !atFork || atFork.has(key.fingerprint));
466
+ // Item 65c6c907: a merge made before the first owner key. Only an accept
467
+ // act for the push audit red may count, against the keys at the tip of
468
+ // the default branch (preKeyKeys in signed-act.mjs holds the limits). The
469
+ // audit runs only on the default branch, so the merge must be into it.
470
+ let prekey = null;
471
+ if (!keys.length && check === AUDIT_CHECK && testName === AUDIT_TEST) {
472
+ const main = (await github(`repos/${pull.repo}`))?.default_branch;
473
+ prekey = typeof main === 'string' && main === pull.base
474
+ ? await preKeyKeys(githubOrNull, pull.repo, { parent, branch: main })
475
+ : { why: `${pull.url} did not merge into the default branch` };
476
+ if (prekey.keys) keys = prekey.keys;
477
+ }
478
+ if (!keys.length) refuse(`accepted_red needs the owner's signed waiver, and ${SIGNERS_FILE} on the base of ${pull.url} holds no owner key${prekey?.why ? `; no first key counts either: ${prekey.why}` : ''}`);
467
479
  const texts = [];
468
480
  for (let page = 1; page <= 10; page += 1) {
469
481
  const rows = await github(`repos/${pull.repo}/issues/${pull.number}/comments?per_page=100&page=${page}`);
@@ -472,7 +484,7 @@ export function createDeliveryVerbs(core) {
472
484
  }
473
485
  const verified = verifyTexts(texts, keys);
474
486
  const words = typeof decision.fields?.text === 'string' ? decision.fields.text : '';
475
- let found = findAct(verified, { kind: 'waiver', repo: pull.repo, pr: pull.number, head, decision: decisionId, check });
487
+ let found = prekey ? null : findAct(verified, { kind: 'waiver', repo: pull.repo, pr: pull.number, head, decision: decisionId, check });
476
488
  // An accept act takes only the push audit's red of an owner merge
477
489
  // (security review S3). Its words name no decision, so exactly one
478
490
  // standing decision may hold them: a second decision with the same words
@@ -491,7 +503,7 @@ export function createDeliveryVerbs(core) {
491
503
  const why = verified.find((row) => row.error)?.error;
492
504
  refuse(`accepted_red needs the owner's signed waiver on ${pull.url} for check ${check}, head ${head.slice(0, 12)} and decision ${decisionId}; none counts${why ? ` (a block was refused: ${why})` : ''}. The owner runs: atlas approve act waiver, and posts the block on the pull request; or, for an audit red, atlas approve audit, and the decision holds the words it printed`);
493
505
  }
494
- return { key: found.key, signed_at: found.signed_at, act: found.act.kind, ...(found.act.kind === 'accept' ? { run: String(found.act.run) } : {}) };
506
+ return { key: found.key, signed_at: found.signed_at, act: found.act.kind, ...(found.act.kind === 'accept' ? { run: String(found.act.run) } : {}), ...(prekey?.from ? { first_key: prekey.from } : {}) };
495
507
  }
496
508
 
497
509
  async function acceptedRed(value, pulls, item) {
package/work/mcp.mjs CHANGED
@@ -221,6 +221,10 @@ export const GITHUB_READS = Object.freeze([
221
221
  // owner's key file on the base of its merge.
222
222
  new RegExp(`^repos/${REPO}/issues/\\d{1,9}/comments\\?per_page=100&page=\\d{1,2}$`),
223
223
  new RegExp(`^repos/${REPO}/contents/atlas/owner-signers\\?ref=[0-9a-fA-F]{40}$`),
224
+ // Item 65c6c907: for a merge made before the first owner key, the history
225
+ // of the key file on a commit, and the branch ref (heads only) of the tip.
226
+ new RegExp(`^repos/${REPO}/commits\\?path=atlas%2Fowner-signers&sha=[0-9a-f]{40}&per_page=100&page=[1-3]$`),
227
+ new RegExp(`^repos/${REPO}/git/ref/heads/${BRANCH}$`),
224
228
  // Roadmap R16: the numbers page past 100 pull requests, and read the
225
229
  // files of each merge to find a changed test.
226
230
  new RegExp(`^repos/${REPO}/pulls\\?state=all&sort=updated&direction=desc&per_page=\\d{1,3}&page=\\d{1,3}$`),