@arjunkhera/atlas 0.4.51 → 0.4.53

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.53",
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
@@ -155,6 +155,8 @@ const HELP = `atlas — the door into a repo's Atlas files
155
155
  --json print the whole answer as JSON
156
156
  atlas runner claim --repo <owner/name> --issue <n> --holder <run id> claim one atlas-job issue; the oldest live claim holds it
157
157
  atlas runner line --from <file> print the last line of a run, from a JSON file of ids and counts
158
+ --notice print the comment that hands the run facts to the notice workflow
159
+ atlas runner notice --repo <owner/name> --issue <n> --comment <id> in the notice workflow only: post the run line of one run comment as the bot
158
160
  Until the A2 exit test, the runner works on the scratch repo only
159
161
 
160
162
  atlas upkeep check --root <repo> the upkeep check: the docs index, dead doc links, the code each doc names, stale pages, and in a pull request its tracker item, its changed designs and STE on its changed lines
@@ -173,6 +175,7 @@ const HELP = `atlas — the door into a repo's Atlas files
173
175
  atlas gate audit --repo <owner/name> [--since <date>] each merge since the date (default: one day) that came before its checks were green
174
176
  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
177
  atlas gate audit --repo <owner/name> --errors [--findings <file>] red when the gate errored on 3 pull requests in a row
178
+ 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
179
  atlas gate check --base <ref> --head <sha> the gate: judge a pull request with this copy of Atlas; success, owner or red
177
180
  --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
181
  --root <repo> the clone that holds both commits (default: this folder); the head is read as git data only
@@ -235,9 +238,9 @@ export const FLAGS = Object.freeze({
235
238
  view: { product: 'value', name: 'value', out: 'value' },
236
239
  tests: { root: 'optional', halves: 'value', evidence: 'value', 'guards-from': 'value', tests: 'value', 'dry-run': 'switch', run: 'value', own: 'value', base: 'value', head: 'value', list: 'value', side: 'value', old: 'value', new: 'value', covers: 'switch', findings: 'value', repo: 'value', max: 'value', bot: 'value', since: 'value', out: 'value', text: 'switch', json: 'switch', scenario: 'value', language: 'value' },
237
240
  jobs: { root: 'optional', repo: 'value', branch: 'value', out: 'value', findings: 'value', max: 'value', 'dry-run': 'switch' },
238
- runner: { repo: 'value', issue: 'value', holder: 'value', from: 'value', json: 'switch' },
241
+ runner: { repo: 'value', issue: 'value', holder: 'value', from: 'value', comment: 'value', notice: 'switch', json: 'switch' },
239
242
  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' },
243
+ 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
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' },
242
245
  judge: { tasks: 'value', verdict: 'value', seal: 'value', runs: 'value', out: 'value', repo: 'value', budget: 'value', model: 'value' },
243
246
  install: { local: 'switch', from: 'value', 'skip-global': 'switch', yes: 'switch', cloud: 'switch' },
@@ -496,7 +499,7 @@ function jobsVerb(chosen) {
496
499
  }
497
500
 
498
501
  function runnerVerb(chosen) {
499
- return runnerCommand({ what: chosen._[1], repo: chosen.repo, issue: chosen.issue, holder: chosen.holder, from: chosen.from, json: Boolean(chosen.json), args: [...chosen._, ...Object.values(chosen).filter((v) => typeof v === 'string')] }, line);
502
+ 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);
500
503
  }
501
504
 
502
505
  function upkeepVerb(chosen) {
@@ -504,7 +507,7 @@ function upkeepVerb(chosen) {
504
507
  }
505
508
 
506
509
  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);
510
+ 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
511
  }
509
512
 
510
513
  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';
@@ -3,7 +3,8 @@
3
3
  //
4
4
  // atlas runner start --repo <owner/name> [--json]
5
5
  // atlas runner claim --repo <owner/name> --issue <n> --holder <run id>
6
- // atlas runner line --from <file>
6
+ // atlas runner line --from <file> [--notice]
7
+ // atlas runner notice --repo <owner/name> --issue <n> --comment <id>
7
8
  //
8
9
  // The runner is one scheduled Claude session for each repo. It follows the
9
10
  // atlas:runner skill, and that skill calls these commands. The commands read
@@ -15,6 +16,12 @@
15
16
  // claim posts one claim on an issue, then reads the claims back. The
16
17
  // oldest live claim holds the issue, so two runs build it once.
17
18
  // line prints the last line of a run, built by code from ids and counts.
19
+ // With --notice it prints the comment that hands the run facts to
20
+ // the notice workflow.
21
+ // notice runs in the notice workflow, as github-actions[bot]. It reads one
22
+ // run comment of the owner's login, builds the run line again from
23
+ // its facts, and posts the line, so GitHub tells the owner's phone
24
+ // (R25 line 10). It never copies the comment's text.
18
25
  //
19
26
  // Rules that hold everywhere here:
20
27
  // 1. Until the owner records the A2 exit test as passed, the runner works on
@@ -28,7 +35,7 @@
28
35
  // 4. No call here merges, pushes or changes a branch.
29
36
  import { readFileSync } from 'node:fs';
30
37
  import { jobMarker, typedFindings, JOBS_LABEL, atlasJobs } from './jobs.mjs';
31
- import { repoOk, TOKEN_SHAPE, DEFAULT_BOT } from './issues.mjs';
38
+ import { repoOk, TOKEN_SHAPE, DEFAULT_BOT, api, apiBase } from './issues.mjs';
32
39
  import { ghApi } from './gate.mjs';
33
40
  import { PACKAGE_NAME } from './gate-audit.mjs';
34
41
 
@@ -253,14 +260,56 @@ export function runLine({ repo, at, you = [], landed = [], opened = [], open_job
253
260
  return parts.join(' · ');
254
261
  }
255
262
 
263
+ // ------------------------------------------------------------ the notice
264
+
265
+ // R25 line 10. The runner session acts as the owner's login, and GitHub does
266
+ // not tell a person about his own comment. So the session posts its run facts
267
+ // in one comment on the notice issue, and the notice workflow posts the run
268
+ // line as the bot. The workflow trusts no text: it reads the facts as JSON,
269
+ // and runLine refuses any field that is not an id, a count or a known form.
270
+ export const RUN_MARKER = '<!-- atlas-runner:run -->';
271
+ export const NOTICE_LABEL = 'atlas-notice';
272
+ export const noticeBody = (facts) => [RUN_MARKER, '```json', JSON.stringify(facts), '```'].join('\n');
273
+
274
+ export function factsOf(body) {
275
+ const text = String(body ?? '');
276
+ if (!text.startsWith(`${RUN_MARKER}\n`)) return null;
277
+ const blocks = [...text.matchAll(/```json\n([\s\S]*?)\n```/g)];
278
+ if (blocks.length !== 1) return null;
279
+ try { return JSON.parse(blocks[0][1]); } catch { return null; }
280
+ }
281
+
282
+ export async function runnerNotice({ repo, issue, comment, env = process.env, call = null, allowed = RUNNER_REPOS }) {
283
+ if (!repoAllowed(repo, allowed)) throw usage(`${repo} is not a runner repo`);
284
+ const gh = call ?? ((method, path, body) => api(apiBase(env), env.GITHUB_TOKEN, method, path, body));
285
+ if (!call && !env.GITHUB_TOKEN) throw usage('the notice needs GITHUB_TOKEN, the token of the workflow run');
286
+ const got = await gh('GET', `/repos/${repo}/issues/comments/${comment}`);
287
+ if (!got.ok) return { posted: false, why: `GitHub answered ${got.status} for comment ${comment}` };
288
+ const note = got.data;
289
+ if (!String(note?.issue_url ?? '').endsWith(`/repos/${repo}/issues/${issue}`)) return { posted: false, why: `comment ${comment} is not on issue #${issue}` };
290
+ if (note?.author_association !== 'OWNER') return { posted: false, why: `comment ${comment} is not by the owner's login, so no runner wrote it` };
291
+ const thread = await gh('GET', `/repos/${repo}/issues/${issue}`);
292
+ const labels = (thread.data?.labels ?? []).map((one) => (typeof one === 'string' ? one : one?.name));
293
+ if (!thread.ok || !labels.includes(NOTICE_LABEL)) return { posted: false, why: `issue #${issue} is not the notice issue (label ${NOTICE_LABEL})` };
294
+ const facts = factsOf(note.body);
295
+ if (!facts) return { posted: false, why: `comment ${comment} holds no run facts` };
296
+ if (String(facts.repo ?? '').toLowerCase() !== repo.toLowerCase()) return { posted: false, why: `the run facts name ${facts.repo}, not ${repo}` };
297
+ const runLineText = runLine(facts);
298
+ const posted = await gh('POST', `/repos/${repo}/issues/${issue}/comments`, { body: runLineText });
299
+ if (!posted.ok) return { posted: false, why: `GitHub answered ${posted.status} to the notice` };
300
+ return { posted: true, line: runLineText };
301
+ }
302
+
256
303
  // ------------------------------------------------------------ the command
257
304
 
258
- export async function runnerCommand({ what, repo, issue, holder, from, json = false, args = [] }, line, env = process.env) {
305
+ export async function runnerCommand({ what, repo, issue, holder, from, comment, notice = false, json = false, args = [] }, line, env = process.env) {
259
306
  try {
260
307
  if ([...args, repo, holder, from].some((a) => typeof a === 'string' && TOKEN_SHAPE.test(a))) throw usage('a token must never be an argument');
261
308
  if (what === 'line') {
262
309
  if (!from) throw usage('give the run as --from <file>, a JSON object of ids and counts');
263
- line(runLine(JSON.parse(readFileSync(from, 'utf8'))));
310
+ const facts = JSON.parse(readFileSync(from, 'utf8'));
311
+ const text = runLine(facts);
312
+ line(notice ? noticeBody(facts) : text);
264
313
  return 0;
265
314
  }
266
315
  if (!repoOk(repo)) throw usage('give the repo as --repo <owner/name>');
@@ -278,7 +327,14 @@ export async function runnerCommand({ what, repo, issue, holder, from, json = fa
278
327
  line(result.held ? `HELD: issue #${issue} is this run's` : `TAKEN: issue #${issue} is held by ${result.holder}; leave it`);
279
328
  return result.held ? 0 : 1;
280
329
  }
281
- throw usage('use atlas runner start, atlas runner claim or atlas runner line');
330
+ if (what === 'notice') {
331
+ if (!/^[1-9][0-9]{0,8}$/.test(String(issue ?? ''))) throw usage('give the notice issue as --issue <number>');
332
+ if (!/^[1-9][0-9]{0,11}$/.test(String(comment ?? ''))) throw usage('give the run comment as --comment <id>');
333
+ const result = await runnerNotice({ repo, issue: Number(issue), comment: Number(comment), env });
334
+ line(result.posted ? `POSTED: ${result.line}` : `NOT POSTED: ${result.why}`);
335
+ return result.posted ? 0 : 1;
336
+ }
337
+ throw usage('use atlas runner start, claim, line or notice');
282
338
  } catch (error) {
283
339
  line(`atlas runner: ${error.message}`);
284
340
  return error.usage ? 2 : 1;
@@ -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',
@@ -63,6 +63,11 @@ function packagedPinned(packageRoot, source) {
63
63
  export const packagedJobsWorkflow = (packageRoot) => packagedPinned(packageRoot, PACKAGE_JOBS_SOURCE);
64
64
  export const packagedUpkeepWorkflow = (packageRoot) => packagedPinned(packageRoot, PACKAGE_UPKEEP_SOURCE);
65
65
  export const packagedProceduresWorkflow = (packageRoot) => packagedPinned(packageRoot, PACKAGE_PROCEDURES_SOURCE);
66
+ // The notice workflow of the runner (R25 line 10). It is not in PINNED: until
67
+ // the owner records the A2 exit test, only the scratch repo gets it, by hand.
68
+ export const RUNNER_NOTICE_PATH = '.github/workflows/atlas-runner-notice.yml';
69
+ export const PACKAGE_RUNNER_NOTICE_SOURCE = 'door/templates/atlas-runner-notice.yml';
70
+ export const packagedRunnerNotice = (packageRoot) => packagedPinned(packageRoot, PACKAGE_RUNNER_NOTICE_SOURCE);
66
71
  const pinOf = (text) => /@arjunkhera\/atlas@(\d+)\.(\d+)\.(\d+)\b/.exec(text)?.slice(1).map(Number) ?? null;
67
72
  const older = (a, b) => { for (let i = 0; i < 3; i += 1) if (a[i] !== b[i]) return a[i] < b[i]; return false; };
68
73
  // written, refreshed, unchanged or kept: what the writer does with one pinned workflow.
@@ -0,0 +1,41 @@
1
+ name: atlas-runner-notice
2
+ # The notice of the Atlas runner (roadmap R25 line 10). Atlas wrote this file
3
+ # from the Atlas package; do not edit it by hand.
4
+ #
5
+ # A runner session acts as the owner's login, and GitHub does not tell a
6
+ # person about his own comment. So the session posts its run facts in one
7
+ # comment on the notice issue (label atlas-notice), and this job posts the run
8
+ # line as github-actions[bot]. GitHub then tells the owner's phone.
9
+ #
10
+ # It holds no secret, only issues write. It never copies the comment's text:
11
+ # Atlas code reads the facts as JSON and builds the line from ids and counts.
12
+ # Until the owner records the A2 exit test, only the scratch repo has it.
13
+ on:
14
+ issue_comment:
15
+ types: [created]
16
+
17
+ permissions: {}
18
+
19
+ jobs:
20
+ notice:
21
+ if: >-
22
+ github.event.comment.author_association == 'OWNER' &&
23
+ startsWith(github.event.comment.body, '<!-- atlas-runner:run -->') &&
24
+ contains(github.event.issue.labels.*.name, 'atlas-notice')
25
+ runs-on: ubuntu-latest
26
+ timeout-minutes: 5
27
+ permissions:
28
+ issues: write
29
+ steps:
30
+ # actions/setup-node v7.0.0
31
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
32
+ with:
33
+ node-version: "22.22.0"
34
+ - name: Post the run line as the bot
35
+ env:
36
+ GITHUB_TOKEN: ${{ github.token }}
37
+ ISSUE: ${{ github.event.issue.number }}
38
+ COMMENT: ${{ github.event.comment.id }}
39
+ run: |
40
+ set -euo pipefail
41
+ npx --yes --ignore-scripts @arjunkhera/atlas@__ATLAS_VERSION__ runner notice --repo "$GITHUB_REPOSITORY" --issue "$ISSUE" --comment "$COMMENT" | tee -a "$GITHUB_STEP_SUMMARY"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.4.51",
3
+ "version": "0.4.53",
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
 
@@ -72,9 +72,12 @@ its environment come from `trigger.yaml` beside this file.
72
72
  `the merge door is off in the runner`, `start stopped`. A landed pull
73
73
  request carries its `number`, its `release`, and the id of its proof
74
74
  comment as `proof`. The command refuses any other text.
75
- 8. Run `atlas runner line --from <file>`. Its output is the last message of
76
- the run, word for word. The notice workflow posts it on the repo's notice
77
- issue.
75
+ 8. Run `atlas runner line --from <file> --notice`. Post its output, word for
76
+ word, as one comment on the repo's notice issue (label `atlas-notice`).
77
+ The notice workflow reads the facts and posts the run line as the bot,
78
+ so GitHub tells the owner.
79
+ 9. Run `atlas runner line --from <file>`. Its output is the last message of
80
+ the run, word for word.
78
81
 
79
82
  ## Until the exit test
80
83