@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.
- package/.claude-plugin/plugin.json +1 -1
- package/door/cli.mjs +7 -4
- package/door/lib/gate.mjs +92 -4
- package/door/lib/runner.mjs +61 -5
- package/door/lib/signed-act.mjs +3 -2
- package/door/lib/tooling.mjs +5 -0
- package/door/templates/atlas-runner-notice.yml +41 -0
- package/package.json +1 -1
- package/skills/lead/SKILL.md +1 -1
- package/skills/runner/SKILL.md +6 -3
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
|
|
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';
|
package/door/lib/runner.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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;
|
package/door/lib/signed-act.mjs
CHANGED
|
@@ -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
|
|
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/door/lib/tooling.mjs
CHANGED
|
@@ -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
package/skills/lead/SKILL.md
CHANGED
|
@@ -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
|
|
package/skills/runner/SKILL.md
CHANGED
|
@@ -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
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|