@arjunkhera/atlas 0.4.51 → 0.4.52

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.51",
3
+ "version": "0.4.52",
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
@@ -173,6 +173,7 @@ const HELP = `atlas — the door into a repo's Atlas files
173
173
  atlas gate audit --repo <owner/name> [--since <date>] each merge since the date (default: one day) that came before its checks were green
174
174
  atlas gate audit --repo <owner/name> --commit <sha> [--forced] [--findings <file>] one commit on the main branch: red when no pull request merged it, one merged it before its checks were green, or a gate run is not from the base workflow
175
175
  atlas gate audit --repo <owner/name> --errors [--findings <file>] red when the gate errored on 3 pull requests in a row
176
+ atlas gate hold --repo <owner/name> --root <clone> --commit <sha> --from <sha> the release job only, from the copy on npm: exit 3 holds the release while a merge since --from went to the owner with no act of his
176
177
  atlas gate check --base <ref> --head <sha> the gate: judge a pull request with this copy of Atlas; success, owner or red
177
178
  --pr <n> --base-ref <branch> with --repo: read the comments of the pull request, and let the owner's signed act for this base and head clear owner
178
179
  --root <repo> the clone that holds both commits (default: this folder); the head is read as git data only
@@ -237,7 +238,7 @@ export const FLAGS = Object.freeze({
237
238
  jobs: { root: 'optional', repo: 'value', branch: 'value', out: 'value', findings: 'value', max: 'value', 'dry-run': 'switch' },
238
239
  runner: { repo: 'value', issue: 'value', holder: 'value', from: 'value', json: 'switch' },
239
240
  upkeep: { root: 'optional', event: 'value', json: 'switch', last: 'value', base: 'value' },
240
- gate: { repo: 'value', root: 'optional', 'dry-run': 'switch', off: 'switch', on: 'switch', pr: 'value', since: 'value', commit: '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' },
241
+ 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' },
241
242
  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' },
242
243
  judge: { tasks: 'value', verdict: 'value', seal: 'value', runs: 'value', out: 'value', repo: 'value', budget: 'value', model: 'value' },
243
244
  install: { local: 'switch', from: 'value', 'skip-global': 'switch', yes: 'switch', cloud: 'switch' },
@@ -504,7 +505,7 @@ function upkeepVerb(chosen) {
504
505
  }
505
506
 
506
507
  function gateVerb(chosen) {
507
- 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, 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);
508
+ 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);
508
509
  }
509
510
 
510
511
  function approveVerb(chosen) {
package/door/lib/gate.mjs CHANGED
@@ -22,7 +22,7 @@ import { childEnvironment } from '../../work/lib/child-env.mjs';
22
22
  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
- import { MARKER as SIGNED_MARKER } from './signed-act.mjs';
25
+ import { MARKER as SIGNED_MARKER, SIGNERS_FILE, AUDIT_CHECK, parseSigners, verifyTexts, findAct, auditCodeDiff } from './signed-act.mjs';
26
26
  import { brakeFacts, windowCommits, ciTrips, BrakeError } from './brake.mjs';
27
27
 
28
28
  export const BRANCH_RULESET = 'atlas gate';
@@ -708,6 +708,93 @@ export function prComments(gh, repo, pr) {
708
708
  return kept;
709
709
  }
710
710
 
711
+ // `atlas gate hold --root <clone> --commit <sha> --from <sha>` (item 4af66381,
712
+ // the release hold). The release job runs it from the trusted copy, the
713
+ // version that npm holds as latest, never from the commit that it judges.
714
+ // `--from` is the commit of that version, from its release record, as
715
+ // `atlas upgrade` finds it. The hold judges
716
+ // each first-parent merge from there to the release commit again, with the
717
+ // rules and the brake of its parent, at the time of its merge. Only a merge
718
+ // that the gate judges success passes: an owner or a red answer holds the
719
+ // release (security review of #290, S1), unless the owner signed one of
720
+ // three acts on the pull request:
721
+ // 1. a merge act for its head (the gate counts it already, so the answer
722
+ // is success);
723
+ // 2. an accept act for its head, from `atlas approve audit`, for a merge
724
+ // that changed no audit code;
725
+ // 3. an accept-code act for its merge commit and its audit-code diff, from
726
+ // `atlas approve audit-code`.
727
+ // It fails closed: a push past the gate, a merge that it cannot judge, a
728
+ // start that the release commit does not hold, or more than HOLD_MAX merges
729
+ // also hold. Exit 0: publish. Exit 3: hold. Any other exit is an error.
730
+ export const HOLD_MAX = 200;
731
+ // git's word for the ancestor test, joined so the package names no ancestry verb (test/package D9).
732
+ const MERGE_BASE = ['merge', 'base'].join('-');
733
+ export const HOLD_EXIT = 3;
734
+ export function holdCommand({ repo, root, commit, from }, line, gh) {
735
+ const full = (value) => typeof value === 'string' && /^[0-9a-f]{40}$/.test(value);
736
+ if (!full(commit)) throw new GateError('atlas gate hold needs --commit <full sha>.');
737
+ if (!full(from)) throw new GateError('atlas gate hold needs --from <full sha>: the commit of the version that npm holds as latest.');
738
+ if (!root || root === true) throw new GateError('atlas gate hold needs --root <clone>.');
739
+ const git = (args) => execFileSync('git', ['-C', root, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024 }).trim();
740
+ try { git(['cat-file', '-e', `${commit}^{commit}`]); } catch { throw new GateError(`${commit} is not a commit in ${root}.`); }
741
+ try { git([MERGE_BASE, '--is-ancestor', '--end-of-options', from, commit]); } catch {
742
+ line(`HOLD: the published commit ${from.slice(0, 7)} is not in the history of ${commit.slice(0, 7)}, so the hold cannot tell which merges npm does not hold yet.`);
743
+ return HOLD_EXIT;
744
+ }
745
+ const commits = git(['rev-list', '--first-parent', '--reverse', `--max-count=${HOLD_MAX + 1}`, `${from}..${commit}`]).split('\n').filter(Boolean);
746
+ if (commits.length > HOLD_MAX) { line(`HOLD: more than ${HOLD_MAX} merges since the published commit ${from.slice(0, 7)}.`); return HOLD_EXIT; }
747
+ const held = [];
748
+ for (const sha of commits) {
749
+ const why = holdRow({ repo, root, sha }, gh, git);
750
+ if (why) { held.push(why); line(` ✗ ${why}`); }
751
+ }
752
+ if (held.length) {
753
+ line(`HOLD: ${held.length} of ${commits.length} merge(s) since the published commit ${from.slice(0, 7)} went to the owner, and no act of his clears them. The owner signs them with atlas approve audit (atlas approve audit-code <pr> for a merge that changed the audit code). The next release run after his touch publishes.`);
754
+ return HOLD_EXIT;
755
+ }
756
+ line(`atlas gate hold: ${commits.length} merge(s) since the published commit ${from.slice(0, 7)}, each green or signed by the owner. Publish.`);
757
+ return 0;
758
+ }
759
+
760
+ // Why one first-parent commit holds the release, or null.
761
+ export function holdRow({ repo, root, sha }, gh, git) {
762
+ let pull;
763
+ try { pull = (gh('GET', `repos/${repo}/commits/${sha}/pulls`) ?? []).find((each) => each.merged_at && each.merge_commit_sha === sha); } catch { return `${sha.slice(0, 7)}: GitHub did not say which pull request merged it`; }
764
+ if (!pull) return `${sha.slice(0, 7)} reached the branch with no pull request: a push past the gate`;
765
+ const head = String(pull.head?.sha ?? '');
766
+ let parent;
767
+ try { parent = git(['rev-parse', '--verify', '--end-of-options', `${sha}^1`]); } catch { return `#${pull.number}: the parent of ${sha.slice(0, 7)} is not in this clone`; }
768
+ try { git(['cat-file', '-e', `${head}^{commit}`]); } catch { return `#${pull.number}: its head ${head.slice(0, 7)} is not in this clone`; }
769
+ let texts;
770
+ 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`; }
771
+ let answer;
772
+ try {
773
+ const at = new Date(pull.merged_at);
774
+ const brake = { ci: brakeRead({ root, base: parent, now: at }, gh, repo), clears: { repo, commentsOf: (one) => commitComments(gh, repo, one) } };
775
+ // The red main checks of its parent, as the gate read them, so a fix of a red main check is judged as the gate judged it.
776
+ answer = judge({ root, base: parent, head, body: String(pull.body ?? ''), now: at, brake, redMain: redMain({ root, base: parent }, gh, repo), signed: { repo, pr: Number(pull.number), baseRef: pull.base?.ref ?? null, texts } });
777
+ } catch (error) { return `#${pull.number}: it cannot be judged (${String(error?.message ?? error).split('\n')[0].slice(0, 160)})`; }
778
+ if (answer.result === 'success') return null;
779
+ // The gate did not say success. An accept act, or an accept-code act for a
780
+ // merge that changed audit code, checked with the keys of its parent,
781
+ // clears it now. The audit-code test is the one that `atlas approve audit`
782
+ // keeps, so the hold does not rest on the Mac tool alone.
783
+ let keys = [];
784
+ try { keys = parseSigners(git(['show', `${parent}:${SIGNERS_FILE}`])).keys; } catch { keys = []; }
785
+ const verified = verifyTexts(texts, keys);
786
+ let code;
787
+ try { code = auditCodeDiff(gh('GET', `repos/${repo}/commits/${sha}`)?.files); } catch { code = { unknown: true }; }
788
+ const accepted = code.unknown ? null
789
+ : code.files.length ? findAct(verified, { kind: 'accept-code', repo, pr: String(pull.number), head, merge: sha, diff: code.print })
790
+ : findAct(verified, { kind: 'accept', repo, pr: String(pull.number), head, check: AUDIT_CHECK });
791
+ if (accepted) return null;
792
+ const row = (answer.rows ?? []).find((each) => each.kind === answer.result) ?? (answer.rows ?? []).find((each) => each.kind === 'owner');
793
+ const what = answer.result === 'red' ? 'the gate said red' : 'went to the owner';
794
+ if (code.unknown) return `#${pull.number} (${sha.slice(0, 7)}) ${what}, and GitHub gave no full file list of its merge, so no accept act can be checked`;
795
+ return `#${pull.number} (${sha.slice(0, 7)}) ${what}${row ? `: ${row.class ?? answer.result}${row.path ? ` ${row.path}` : ''}` : ''}, and no ${code.files.length ? 'merge or accept-code' : 'merge or accept'} act names it`;
796
+ }
797
+
711
798
  // The comment texts of one commit on main that carry the signed-act marker:
712
799
  // where the owner posts a brake clear (roadmap R13 line 6). At most three
713
800
  // pages of a hundred. A read that fails gives null, so no clear counts.
@@ -763,9 +850,9 @@ export function brakeRead({ root, base, now = new Date() }, gh, repo, pages = 3)
763
850
  return ciTrips({ commits, runs, homes, checks, live: facts.live, liveChecks }, jobsOf);
764
851
  }
765
852
 
766
- // `atlas gate apply|ready|audit|check|replay`. Returns the exit code.
767
- export function gateCommand({ what, repo, root, dryRun = false, off = false, on = false, pr, since, commit, base, baseRef, head, body, json = false, summary, output, days, branch, errors = false, forced = false, findings, home = homedir() }, line, gh = ghApi) {
768
- if (!['apply', 'ready', 'audit', 'check', 'replay'].includes(what)) throw new GateError(`there is no gate verb "${what ?? ''}". Use apply, ready, audit, check or replay.`);
853
+ // `atlas gate apply|ready|audit|check|replay|hold`. Returns the exit code.
854
+ export function gateCommand({ what, repo, root, dryRun = false, off = false, on = false, pr, since, commit, from, base, baseRef, head, body, json = false, summary, output, days, branch, errors = false, forced = false, findings, home = homedir() }, line, gh = ghApi) {
855
+ if (!['apply', 'ready', 'audit', 'check', 'replay', 'hold'].includes(what)) throw new GateError(`there is no gate verb "${what ?? ''}". Use apply, ready, audit, check, replay or hold.`);
769
856
  // With --repo, check reads the red main checks from GitHub; without, it judges git data alone.
770
857
  const named = repo && repo !== true && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) && !repo.split('/').some((part) => /^\.+$/.test(part));
771
858
  if (what === 'check') {
@@ -787,6 +874,7 @@ export function gateCommand({ what, repo, root, dryRun = false, off = false, on
787
874
  }
788
875
  return code;
789
876
  }
877
+ if (what === 'hold') return holdCommand({ repo, root, commit, from }, line, gh);
790
878
  if (what === 'audit') return auditCommand({ repo, root, since, commit, errors, forced, findings }, line, gh);
791
879
  if (off && on) throw new GateError('pick --off or --on, not both.');
792
880
  const mode = off ? 'off' : on ? 'on' : 'apply';
@@ -113,13 +113,14 @@ export const AUDIT_CHECKS = Object.freeze(['gate · push audit', AUDIT_CHECK]);
113
113
  // The files whose change lets a merge write its own audit: every repo file
114
114
  // of the gate and audit groups of GATE_FILE_GROUPS in gate-audit.mjs (a test
115
115
  // keeps this list a superset), the signed act that the gate reads, and the
116
- // job file of the audit. `atlas approve audit` never signs a merge that
116
+ // job file of the audit, and the release job, which runs the release hold
117
+ // (item 4af66381). `atlas approve audit` never signs a merge that
117
118
  // changed one (security review 2 of item 0ca9fced, S3). Such a merge takes
118
119
  // an accept-code act instead, and its words quote the test AUDIT_CODE_TEST
119
120
  // (item f36c6b0f).
120
121
  export const AUDIT_WORKFLOW = '.github/workflows/atlas-gate-audit.yml';
121
122
  export const AUDIT_CODE = Object.freeze([
122
- AUDIT_WORKFLOW, '.github/workflows/atlas-gate.yml', '.github/workflows/atlas-jobs.yml',
123
+ AUDIT_WORKFLOW, '.github/workflows/atlas-gate.yml', '.github/workflows/atlas-jobs.yml', '.github/workflows/release.yml',
123
124
  'packages/atlas/bin/atlas', 'packages/atlas/door/cli.mjs', 'packages/atlas/owner-only.yaml', 'packages/atlas/door/jobs/gate-audit.yaml',
124
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}`),
125
126
  'packages/atlas/shape/check.mjs', 'packages/atlas/work/lib/child-env.mjs',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.4.51",
3
+ "version": "0.4.52",
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",
@@ -127,7 +127,7 @@ set. A test keeps the two lists equal.
127
127
  | `atlas jobs` | The `atlas-jobs` workflow calls `check`, then `file`, once a day. A session calls it only to see what the watcher would file: `check`, then `file --dry-run` |
128
128
  | `atlas runner` | Only in a runner run, as the `atlas:runner` skill says: `start` first, `claim` before each build, `line` last. A lead session never runs the runner |
129
129
  | `atlas upkeep` | `check` before you ask for a merge. It reads the docs index, dead doc links, the code each doc names and stale pages. In a pull request it also reads the tracker item, the changed designs, and STE on the changed lines. The daily upkeep run calls it on the main branch. `replay` before the doc-sources rule changes how it gates. `procedures` runs the procedure steps tagged `atlas-run`; CI runs it with `--base` on each pull request |
130
- | `atlas gate` | `apply` once per repo, by the owner on his Mac, after the repo's `required_checks` exist. `--dry-run` first. `--off` and `--on` are the break glass. On a plan with no rulesets it reports advisory mode. `ready` before each merge, `check` in the `atlas-gate` workflow, `replay` before a change to the owner-only list. `audit` in the daily upkeep run, and with `--commit` in the push job of a repo that has one |
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
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 |
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