@arjunkhera/atlas 0.4.21 → 0.4.23

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/door/lib/jobs.mjs CHANGED
@@ -25,6 +25,8 @@ import { dirname, join, resolve } from 'node:path';
25
25
  import { fileURLToPath } from 'node:url';
26
26
  import { parseYaml } from '../../shape/check.mjs';
27
27
  import { api, apiBase, repoOk, openIssues, TOKEN_SHAPE, DEFAULT_BOT, DEFAULT_MAX } from './issues.mjs';
28
+ import { checkGateFiles, checkGateAudit, checkGateErrors } from './gate-audit.mjs';
29
+ import { commitAudit, ghApi } from './gate.mjs';
28
30
 
29
31
  export const JOBS_LABEL = 'atlas-job';
30
32
  export const HANDED_OVER = 'handed-over';
@@ -107,9 +109,16 @@ export async function checkRedMain({ repo, branch, env }) {
107
109
 
108
110
  // The check of each Atlas job, by its `when`. A job whose check is not built yet reads `none` and
109
111
  // says so in the notes; it never reads `found` from nothing.
110
- const CHECKS = { 'red-main': checkRedMain };
112
+ // The gate jobs (roadmap R14) read GitHub through `gh`, which the Actions
113
+ // runner holds; their token is the run's own GITHUB_TOKEN.
114
+ const CHECKS = {
115
+ 'red-main': checkRedMain,
116
+ 'gate-files': ({ root, env, fetchImpl }) => checkGateFiles({ root, env, fetchImpl }),
117
+ 'gate-audit': ({ repo, branch, gh }) => checkGateAudit({ repo, branch, gh, audit: commitAudit }),
118
+ 'gate-errors': ({ repo, gh }) => checkGateErrors({ repo, gh }),
119
+ };
111
120
 
112
- export async function jobsCheck({ root, repo, branch = null, env = process.env, jobs = atlasJobs() }) {
121
+ export async function jobsCheck({ root, repo, branch = null, env = process.env, jobs = atlasJobs(), gh = ghApi, fetchImpl = fetch }) {
113
122
  const named = namedJobs(root);
114
123
  const notes = [];
115
124
  const ran = [];
@@ -123,7 +132,10 @@ export async function jobsCheck({ root, repo, branch = null, env = process.env,
123
132
  if (!check) { notes.push(`${id}: its check is not built yet`); ran.push({ job: id, state: 'none' }); continue; }
124
133
  try {
125
134
  if (!mainBranch) mainBranch = await defaultBranch({ repo, env });
126
- const found = (await check({ repo, branch: mainBranch, env })).map((f) => ({ ...f, job: id }));
135
+ // A check gives its findings, or { findings, notes }.
136
+ const got = await check({ repo, branch: mainBranch, env, root, gh, fetchImpl });
137
+ const found = (Array.isArray(got) ? got : got.findings).map((f) => ({ ...f, job: id }));
138
+ for (const note of Array.isArray(got) ? [] : got.notes ?? []) notes.push(`${id}: ${String(note).slice(0, 300)}`);
127
139
  findings.push(...found);
128
140
  ran.push({ job: id, state: found.length ? 'found' : 'none' });
129
141
  } catch (error) {
@@ -194,13 +206,22 @@ export function jobIssue(finding) {
194
206
  export const handedOverStops = (now = Date.now()) => (issue) => (issue.labels ?? []).some((l) => (typeof l === 'string' ? l : l?.name) === HANDED_OVER)
195
207
  && Number.isFinite(Date.parse(issue.closed_at)) && now - Date.parse(issue.closed_at) <= HANDED_OVER_DAYS * DAY;
196
208
 
209
+ // A job with `handed_over: ignored` never stops on a closed issue: anyone with
210
+ // the shared login can close an issue, so a gate finding comes back while its
211
+ // commit is in the window (roadmap R14).
212
+ export const closedStopsFor = (jobs, now = Date.now()) => (issue) => {
213
+ const job = (issue.labels ?? []).map((l) => (typeof l === 'string' ? l : l?.name)).find((name) => String(name).startsWith('job:'));
214
+ if (job && jobs.get(job.slice(4))?.handed_over === 'ignored') return false;
215
+ return handedOverStops(now)(issue);
216
+ };
217
+
197
218
  export async function fileJobs({ envelope, repo, dryRun = false, max = DEFAULT_MAX, bot = DEFAULT_BOT, env = process.env, line, now = Date.now(), jobs = atlasJobs() }) {
198
219
  const { findings, dropped } = typedFindings(envelope, jobs);
199
220
  if (dropped) line(`dropped ${dropped} finding(s) that are not typed, or that name no Atlas job`);
200
221
  if (envelope.state === 'broken') line('the check reads broken, so a check found nothing it can trust. The run ends red.');
201
222
  const list = findings.map((f) => ({ key: `${f.job}:${f.key}`, issue: jobIssue(f) }));
202
223
  const result = list.length
203
- ? await openIssues({ findings: list, repo, dryRun, max, bot, env, line, label: JOBS_LABEL, closedStops: handedOverStops(now) })
224
+ ? await openIssues({ findings: list, repo, dryRun, max, bot, env, line, label: JOBS_LABEL, closedStops: closedStopsFor(jobs, now) })
204
225
  : (line('no finding: nothing to file.'), { opened: [], skipped: [], deferred: [], failed: [] });
205
226
  return { ...result, broken: envelope.state === 'broken' };
206
227
  }
@@ -0,0 +1,122 @@
1
+ // CI runs the procedures (roadmap R40, item 38b2a0fe). Target design 4.6: CI
2
+ // runs each procedure step that a clean machine can run.
3
+ //
4
+ // A procedure marks such a step with a fenced block whose info string holds
5
+ // the tag `atlas-run`:
6
+ //
7
+ // ```sh atlas-run
8
+ // npm test
9
+ // ```
10
+ //
11
+ // A step that needs what a clean machine does not have names it after the
12
+ // tag: `atlas-run needs=secret/NPM_TOKEN` or `atlas-run needs=device/macos`.
13
+ // Such a step is listed as "not run here", never as passed.
14
+ //
15
+ // `atlas upkeep procedures` runs the marked steps of each procedure that
16
+ // atlas.yaml `procedures:` names. With --base, it runs only the procedures a
17
+ // pull request changes: its own folder, or a path its `sources:` names. Each
18
+ // block runs in bash, from the repo root, with `set -euo pipefail`. It reads
19
+ // no repo kind, so a repo with no deploy target uses it unchanged.
20
+ import { readFileSync } from 'node:fs';
21
+ import { join } from 'node:path';
22
+ import { spawnSync } from 'node:child_process';
23
+ import { fenceOpens, fenceCloses } from './markdown.mjs';
24
+ import { docUnits, covers, changedPaths } from './doc-sources.mjs';
25
+
26
+ export const TAG = 'atlas-run';
27
+ const SHELLS = new Set(['sh', 'bash', 'shell']);
28
+ const NEEDS = new Set(['secret', 'device', 'network']);
29
+ // The bound on one block. A step that runs longer is a failure. It sits
30
+ // below the 10 minutes of the CI job, so the step fails with a reason.
31
+ export const STEP_TIMEOUT_MS = 8 * 60 * 1000;
32
+
33
+ // Line 1: the marked steps of one procedure text. Each step is one fenced
34
+ // block. `problems` holds a tag that cannot run as written.
35
+ export function markedSteps(text) {
36
+ const steps = [];
37
+ const problems = [];
38
+ const lines = String(text ?? '').split('\n');
39
+ for (let i = 0; i < lines.length; i += 1) {
40
+ const mark = fenceOpens(lines[i]);
41
+ if (!mark) continue;
42
+ const info = lines[i].trim().slice(mark.length).trim().split(/\s+/).filter(Boolean);
43
+ const start = i;
44
+ const body = [];
45
+ for (i += 1; i < lines.length && !fenceCloses(lines[i], mark); i += 1) body.push(lines[i]);
46
+ if (!info.includes(TAG)) continue;
47
+ const line = start + 1;
48
+ const lang = info[0] === TAG ? null : info[0];
49
+ if (!SHELLS.has(lang)) { problems.push({ line, why: `the block is tagged ${TAG} but its language is ${lang ?? 'missing'}; use sh or bash` }); continue; }
50
+ if (i >= lines.length) { problems.push({ line, why: 'the block never closes' }); continue; }
51
+ const needs = [];
52
+ let bad = null;
53
+ for (const word of info.slice(info.indexOf(TAG) + 1)) {
54
+ const m = /^needs=(.+)$/.exec(word);
55
+ if (!m) { bad = `"${word}" after ${TAG} is not needs=<kind>/<name>`; break; }
56
+ for (const one of m[1].split(',')) {
57
+ const [kind, name] = one.split('/');
58
+ if (!NEEDS.has(kind) || !name) { bad = `"${one}" is not one of ${[...NEEDS].map((k) => `${k}/<name>`).join(', ')}`; break; }
59
+ needs.push({ kind, name });
60
+ }
61
+ if (bad) break;
62
+ }
63
+ if (bad) { problems.push({ line, why: bad }); continue; }
64
+ const script = body.join('\n').trim();
65
+ if (!script) { problems.push({ line, why: 'the block is empty' }); continue; }
66
+ steps.push({ line, script, needs });
67
+ }
68
+ return { steps, problems };
69
+ }
70
+
71
+ // The procedures to run. With `changed`, only those a pull request changes:
72
+ // a path in the procedure's folder, or a path its `sources:` names.
73
+ export function proceduresToRun(root, changed = null) {
74
+ const units = docUnits(root).filter((one) => one.kind === 'procedure');
75
+ if (changed === null) return units;
76
+ return units.filter((one) => changed.some((path) => path.startsWith(`${one.unit}/`)
77
+ || (one.named?.sources ?? []).some((source) => covers(root, source, path))));
78
+ }
79
+
80
+ const needsText = (needs) => needs.map((one) => `${one.kind} ${one.name}`).join(', ');
81
+
82
+ // Line 2 and line 3: run each marked step of each chosen procedure. A step
83
+ // that needs a secret, a device or the network is "not run here".
84
+ export function runProcedures(root, { base = null, run = runStep } = {}) {
85
+ const changed = base === null ? null : changedPaths(root, `${base}...HEAD`);
86
+ const chosen = proceduresToRun(root, changed);
87
+ const rows = [];
88
+ for (const unit of chosen) {
89
+ const { steps, problems } = markedSteps(readFileSync(join(root, unit.where), 'utf8'));
90
+ for (const one of problems) rows.push({ procedure: unit.unit, where: `${unit.where}:${one.line}`, state: 'bad tag', why: one.why });
91
+ if (!steps.length && !problems.length) rows.push({ procedure: unit.unit, where: unit.where, state: 'no step', why: `it marks no step with ${TAG}` });
92
+ for (const step of steps) {
93
+ const where = `${unit.where}:${step.line}`;
94
+ if (step.needs.length) { rows.push({ procedure: unit.unit, where, state: 'not run here', why: `needs ${needsText(step.needs)}` }); continue; }
95
+ const result = run(root, step.script);
96
+ rows.push({ procedure: unit.unit, where, state: result.ok ? 'passed' : 'failed', why: result.why, tail: result.tail });
97
+ }
98
+ }
99
+ const red = rows.filter((row) => row.state === 'failed' || row.state === 'bad tag');
100
+ return { procedures: chosen.map((one) => one.unit), all: changed === null, rows, green: red.length === 0 };
101
+ }
102
+
103
+ export function runStep(root, script) {
104
+ const out = spawnSync('bash', ['-c', `set -euo pipefail\n${script}`], { cwd: root, encoding: 'utf8', timeout: STEP_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024 });
105
+ const tail = `${out.stdout ?? ''}${out.stderr ?? ''}`.trimEnd().split('\n').slice(-20).join('\n');
106
+ if (out.error?.code === 'ETIMEDOUT') return { ok: false, why: `it ran past ${STEP_TIMEOUT_MS / 60000} minutes`, tail };
107
+ if (out.error) return { ok: false, why: out.error.message, tail };
108
+ return out.status === 0 ? { ok: true, why: 'exit 0', tail } : { ok: false, why: `exit ${out.status ?? out.signal}`, tail };
109
+ }
110
+
111
+ // `atlas upkeep procedures`.
112
+ export function proceduresCommand({ base = null, json = false }, root, line) {
113
+ const out = runProcedures(root, { base });
114
+ if (json) { process.stdout.write(`${JSON.stringify(out, null, 2)}\n`); return out.green ? 0 : 1; }
115
+ line(`procedures: ${out.procedures.length} to run${out.all ? '' : ` (the ones this change touches, from ${base})`}${out.procedures.length ? `: ${out.procedures.join(', ')}` : ''}`);
116
+ for (const row of out.rows) {
117
+ line(` ${row.state.padEnd(12)} ${row.where} ${row.why}`);
118
+ if (row.state === 'failed' && row.tail) for (const one of row.tail.split('\n')) line(` | ${one}`);
119
+ }
120
+ line(`procedure steps: ${out.green ? 'GREEN' : 'RED'}`);
121
+ return out.green ? 0 : 1;
122
+ }
@@ -23,6 +23,7 @@ import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
23
23
  import { join, dirname, posix, resolve } from 'node:path';
24
24
  import { listChanged } from './changed-tests.mjs';
25
25
  import { docSourceFindings, replay } from './doc-sources.mjs';
26
+ import { proceduresCommand } from './procedure-steps.mjs';
26
27
 
27
28
  // R39 line 4 gates only after the replay of line 3. The replay of the last 30
28
29
  // merges (atlas#225): rule "file" pulled in 81 docs over 24 merges, 58 not
@@ -246,9 +247,10 @@ export function upkeepCheck(root, { event = null } = {}) {
246
247
  return { green: findings.length === 0, findings, notes: sources.notes, index, dead, item, text, sources };
247
248
  }
248
249
 
249
- export function upkeepCommand({ what, event: eventFile, json = false, last }, root, line) {
250
+ export function upkeepCommand({ what, event: eventFile, json = false, last, base }, root, line) {
250
251
  if (what === 'replay') return replayCommand({ last, json }, root, line);
251
- if (what !== 'check') throw new Error(`there is no upkeep verb "${what ?? ''}". Use atlas upkeep check, or atlas upkeep replay.`);
252
+ if (what === 'procedures') return proceduresCommand({ base: base ?? null, json }, root, line);
253
+ if (what !== 'check') throw new Error(`there is no upkeep verb "${what ?? ''}". Use atlas upkeep check, atlas upkeep replay or atlas upkeep procedures.`);
252
254
  let event = null;
253
255
  if (eventFile !== undefined) {
254
256
  try { event = JSON.parse(readFileSync(resolve(eventFile), 'utf8')); } catch (error) { throw new Error(`cannot read the event file ${eventFile}: ${error.message}`); }
@@ -5,7 +5,9 @@ name: atlas-jobs
5
5
  # It runs once a day from the default branch, and never on a pull request, so
6
6
  # a pull request cannot change what it runs. It has two jobs:
7
7
  # check reads the repo and the CI runs of the default branch. It holds only
8
- # read permissions and the run's own token, and no repo secret.
8
+ # read permissions and the run's own token, and no repo secret. The
9
+ # gate jobs (gate-audit, gate-errors) also read pull requests and
10
+ # check runs, so the job reads those two as well.
9
11
  # file holds only issues write. It runs Atlas code on the JSON of the
10
12
  # check job, and files one `atlas-job` issue for each new finding.
11
13
  # A job does nothing until atlas.yaml names it under `jobs: { atlas: [...] }`.
@@ -28,6 +30,8 @@ jobs:
28
30
  permissions:
29
31
  contents: read
30
32
  actions: read
33
+ pull-requests: read
34
+ checks: read
31
35
  outputs:
32
36
  findings: ${{ steps.check.outputs.findings }}
33
37
  steps:
@@ -0,0 +1,128 @@
1
+ name: atlas-gate-audit
2
+ on:
3
+ push:
4
+ branches: [master]
5
+
6
+ # The push audit of the advisory gate (roadmap R14, design
7
+ # docs/design-docs/audit-job/). This plan gives the repo no rulesets, so
8
+ # GitHub merges and takes pushes with no check. This workflow looks at each
9
+ # commit that a push put on the main branch, along the first parent:
10
+ # 1. A commit that no pull request merged is a push past the gate.
11
+ # 2. A force push.
12
+ # 3. A merge made before the required checks of its pull request were
13
+ # green. A check counts only from a run of the base file that defines it.
14
+ # 4. A gate run that is not from the base copy of atlas-gate.yml.
15
+ # Each finding opens one `atlas-job` issue, and the run ends red. It does not
16
+ # undo the push.
17
+ #
18
+ # It runs the code of the pushed commit, so a push can edit it and hide. The
19
+ # watcher (atlas-jobs.yml) runs the npm copy of Atlas once a day. It audits
20
+ # the commits of the last two days again, and compares these files with the
21
+ # latest release. A test keeps this file byte identical to
22
+ # packages/atlas/door/workflows/atlas-gate-audit.yml.
23
+ #
24
+ # Two jobs, each with only what it needs:
25
+ # audit read rights only. It writes the findings as job output.
26
+ # file contents: read for the checkout, and issues: write. That is the
27
+ # only write right of this workflow.
28
+ permissions: {}
29
+
30
+ concurrency:
31
+ group: atlas-gate-audit-${{ github.sha }}
32
+ cancel-in-progress: false
33
+
34
+ jobs:
35
+ audit:
36
+ name: gate · push audit
37
+ if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch)
38
+ runs-on: ubuntu-latest
39
+ timeout-minutes: 10
40
+ permissions:
41
+ contents: read
42
+ pull-requests: read
43
+ checks: read
44
+ actions: read
45
+ outputs:
46
+ findings: ${{ steps.audit.outputs.findings }}
47
+ steps:
48
+ # actions/checkout v7.0.1
49
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
50
+ with:
51
+ fetch-depth: 0
52
+ persist-credentials: false
53
+ # actions/setup-node v7.0.0
54
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
55
+ with:
56
+ node-version: "22.22.0"
57
+
58
+ - name: Audit each commit of this push
59
+ id: audit
60
+ env:
61
+ GH_TOKEN: ${{ github.token }}
62
+ BEFORE: ${{ github.event.before }}
63
+ AFTER: ${{ github.event.after }}
64
+ FORCED: ${{ github.event.forced }}
65
+ REPO: ${{ github.repository }}
66
+ run: |
67
+ set -uo pipefail
68
+ out="$RUNNER_TEMP/findings.json"
69
+ # keep FILE [FROM]: add the findings of FROM to FILE, through Atlas code.
70
+ keep() { node --input-type=module -e 'import { keepFindings } from "./packages/atlas/door/lib/gate-audit.mjs"; import { readFileSync, existsSync } from "node:fs"; const [to, from] = process.argv.slice(1); const have = from && existsSync(from) ? JSON.parse(readFileSync(from, "utf8")) : { findings: [] }; keepFindings(to, have.findings ?? [], { broken: have.state === "broken" });' "$@"; }
71
+ keep "$out"
72
+ forced=()
73
+ if [ "$FORCED" = "true" ]; then forced=(--forced); fi
74
+ if [ -z "${BEFORE//0/}" ] || [ "$FORCED" = "true" ] || ! git cat-file -e "$BEFORE^{commit}" 2>/dev/null; then
75
+ commits="$AFTER"
76
+ else
77
+ commits=$(git rev-list --first-parent --reverse "$BEFORE..$AFTER")
78
+ fi
79
+ for sha in $commits; do
80
+ flag=()
81
+ if [ "$sha" = "$AFTER" ]; then flag=("${forced[@]}"); fi
82
+ # GitHub can take a moment to link a fresh merge to its pull
83
+ # request. So a "no pull request" answer gets one more read, with
84
+ # a fresh findings file.
85
+ if ! text=$(node packages/atlas/bin/atlas gate audit --repo "$REPO" --commit "$sha" "${flag[@]}" --findings "$out.$sha"); then
86
+ if grep -q '^RED PUSH' <<<"$text"; then
87
+ sleep 30
88
+ rm -f "$out.$sha"
89
+ text=$(node packages/atlas/bin/atlas gate audit --repo "$REPO" --commit "$sha" "${flag[@]}" --findings "$out.$sha")
90
+ fi
91
+ fi
92
+ printf '%s\n' "$text" | tee -a "$GITHUB_STEP_SUMMARY"
93
+ # A run that wrote no file stopped before it could: mark it broken.
94
+ if [ -s "$out.$sha" ]; then keep "$out" "$out.$sha"; else echo '{"jobs":1,"state":"broken","findings":[]}' > "$out.$sha"; keep "$out" "$out.$sha"; fi
95
+ done
96
+ echo "findings=$(base64 -w0 < "$out")" >> "$GITHUB_OUTPUT"
97
+
98
+ file:
99
+ name: gate · push audit issues
100
+ needs: audit
101
+ if: always() && needs.audit.outputs.findings != ''
102
+ runs-on: ubuntu-latest
103
+ timeout-minutes: 5
104
+ permissions:
105
+ contents: read
106
+ issues: write
107
+ steps:
108
+ # actions/checkout v7.0.1
109
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
110
+ with:
111
+ persist-credentials: false
112
+ # actions/setup-node v7.0.0
113
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
114
+ with:
115
+ node-version: "22.22.0"
116
+ - name: File one issue for each new finding
117
+ env:
118
+ GITHUB_TOKEN: ${{ github.token }}
119
+ FINDINGS: ${{ needs.audit.outputs.findings }}
120
+ run: |
121
+ set -euo pipefail
122
+ printf '%s' "$FINDINGS" | base64 -d > "$RUNNER_TEMP/findings.json"
123
+ node packages/atlas/bin/atlas jobs file --findings "$RUNNER_TEMP/findings.json" --repo "$GITHUB_REPOSITORY" | tee -a "$GITHUB_STEP_SUMMARY"
124
+ state=$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).state' "$RUNNER_TEMP/findings.json")
125
+ if [ "$state" != "none" ]; then
126
+ echo "::error::the push audit found a break of the gate (state $state). Read the issues it opened."
127
+ exit 1
128
+ fi
@@ -0,0 +1,142 @@
1
+ name: atlas-gate
2
+ run-name: "gate #${{ github.event.pull_request.number }} at ${{ github.event.pull_request.head.sha }}"
3
+ on:
4
+ pull_request_target:
5
+ types: [opened, synchronize, reopened, edited, ready_for_review]
6
+
7
+ # The gate (roadmap R6, design docs/design-docs/gate-check/). One job, named
8
+ # `gate`, ends as success, or as failure with "owner: class" or "red: reason"
9
+ # in its summary. It judges the pull request by the static rules: the
10
+ # owner-only list, secrets of a known shape, head jobs named like a check, and
11
+ # a test change for product code.
12
+ #
13
+ # It runs on pull_request_target, so GitHub runs this file as the base branch
14
+ # holds it, with the base code. The head is fetched as git objects only and is
15
+ # never checked out, built or run. atlas.yaml comes from the base side only.
16
+ # The title of the run names the pull request and the head; `atlas gate ready`
17
+ # reads it to pin the run (done line 8).
18
+ #
19
+ # The token can only read. No checkout keeps a credential: the one fetch of
20
+ # the head takes the token for that command alone. The job writes two output
21
+ # keys, `result` and `class`, each one word of a fixed set.
22
+ #
23
+ # The signed act (roadmap R72): the gate reads the comments of the pull
24
+ # request, and a valid signature of the owner for this head turns "owner" into
25
+ # success. The key comes from atlas/owner-signers on the base. A new comment
26
+ # with a signed act reruns this run (atlas-gate-signed.yml).
27
+ #
28
+ # Report mode: the gate is not in required_checks yet. It joins the list after
29
+ # the A1 exit test of the overhaul roadmap.
30
+ #
31
+ # The second job, `shape`, is the repo shape check from the base (roadmap R7,
32
+ # design docs/design-docs/shape-check-from-base/). It is the job that
33
+ # `atlas tooling` writes into every repo, word for word, and `atlas status`
34
+ # compares it with the package.
35
+ permissions:
36
+ contents: read
37
+ pull-requests: read
38
+ issues: read
39
+ checks: read
40
+ actions: read
41
+
42
+ concurrency:
43
+ group: atlas-gate-${{ github.event.pull_request.number }}
44
+ cancel-in-progress: true
45
+
46
+ jobs:
47
+ gate:
48
+ name: gate
49
+ runs-on: ubuntu-latest
50
+ timeout-minutes: 10
51
+ outputs:
52
+ result: ${{ steps.check.outputs.result }}
53
+ class: ${{ steps.check.outputs.class }}
54
+ steps:
55
+ # actions/checkout v7.0.1
56
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
57
+ with:
58
+ ref: ${{ github.sha }}
59
+ fetch-depth: 0
60
+ persist-credentials: false
61
+ # actions/setup-node v7.0.0
62
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
63
+ with:
64
+ node-version: "22.22.0"
65
+
66
+ - name: Fetch the head as objects only
67
+ env:
68
+ PR: ${{ github.event.pull_request.number }}
69
+ HEAD_SHA: ${{ github.event.pull_request.head.sha }}
70
+ TOKEN: ${{ github.token }}
71
+ run: |
72
+ set -euo pipefail
73
+ header="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$TOKEN" | base64 -w0)"
74
+ git -c "http.https://github.com/.extraheader=$header" fetch --no-tags --no-recurse-submodules origin "+refs/pull/$PR/head:refs/atlas-gate/head"
75
+ got="$(git rev-parse --verify refs/atlas-gate/head^{commit})"
76
+ if [ "$got" != "$HEAD_SHA" ]; then
77
+ echo "::error::the fetched head is $got, not the head of the event $HEAD_SHA. Push again."
78
+ exit 1
79
+ fi
80
+
81
+ - name: Read the pull request text from the event
82
+ run: node -e 'const fs=require("fs");const e=JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH,"utf8"));fs.writeFileSync(process.env.RUNNER_TEMP+"/body.md",String(e.pull_request.body??""))'
83
+
84
+ - name: Gate check
85
+ id: check
86
+ env:
87
+ PR: ${{ github.event.pull_request.number }}
88
+ BASE_REF: ${{ github.event.pull_request.base.ref }}
89
+ HEAD_SHA: ${{ github.event.pull_request.head.sha }}
90
+ GH_TOKEN: ${{ github.token }}
91
+ run: |
92
+ set -euo pipefail
93
+ node packages/atlas/bin/atlas gate check --root . --repo "$GITHUB_REPOSITORY" --base "$GITHUB_SHA" --head "$HEAD_SHA" --pr "$PR" --base-ref "$BASE_REF" --body "$RUNNER_TEMP/body.md" --summary "$GITHUB_STEP_SUMMARY" --output "$GITHUB_OUTPUT"
94
+
95
+ shape:
96
+ name: gate · shape
97
+ runs-on: ubuntu-latest
98
+ # The check reads two trees and opens no network. It finishes in seconds
99
+ # on a small repo. The bound stops a runner being billed until someone
100
+ # kills it; a run that hits it is red.
101
+ timeout-minutes: 10
102
+ outputs:
103
+ result: ${{ steps.shape.outputs.result }}
104
+ class: ${{ steps.shape.outputs.class }}
105
+ steps:
106
+ # actions/checkout v7.0.1
107
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
108
+ with:
109
+ ref: ${{ github.sha }}
110
+ fetch-depth: 0
111
+ persist-credentials: false
112
+ # actions/setup-node v7.0.0
113
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
114
+ with:
115
+ node-version: "22.22.0"
116
+ - name: Fetch the head as objects only
117
+ env:
118
+ PR: ${{ github.event.pull_request.number }}
119
+ HEAD_SHA: ${{ github.event.pull_request.head.sha }}
120
+ TOKEN: ${{ github.token }}
121
+ run: |
122
+ set -euo pipefail
123
+ header="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$TOKEN" | base64 -w0)"
124
+ git -c "http.https://github.com/.extraheader=$header" fetch --no-tags --no-recurse-submodules origin "+refs/pull/$PR/head:refs/atlas-gate/head"
125
+ got="$(git rev-parse --verify refs/atlas-gate/head^{commit})"
126
+ if [ "$got" != "$HEAD_SHA" ]; then
127
+ echo "::error::the fetched head is $got, not the head of the event $HEAD_SHA. Push again."
128
+ exit 1
129
+ fi
130
+ - name: Shape check from the base
131
+ id: shape
132
+ env:
133
+ HEAD_SHA: ${{ github.event.pull_request.head.sha }}
134
+ run: |
135
+ set -euo pipefail
136
+ if [ ! -f .github/atlas/check.mjs ]; then
137
+ echo "OWNER: first-run: the base has no .github/atlas/check.mjs, so the owner reads the first shape"
138
+ echo "result=owner" >> "$GITHUB_OUTPUT"
139
+ echo "class=first-run" >> "$GITHUB_OUTPUT"
140
+ exit 1
141
+ fi
142
+ node .github/atlas/check.mjs --gate --repo "$GITHUB_REPOSITORY" --base-commit "$GITHUB_SHA" --head-commit "$HEAD_SHA"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.4.21",
3
+ "version": "0.4.23",
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",
@@ -110,7 +110,7 @@ set. A test keeps the two lists equal.
110
110
  | `atlas design` | `folder` to learn where designs live; `check` before you show a design; `build` to make its page |
111
111
  | `atlas view` | `build --product <id>` makes the project view page of one product, when the owner asks for it or after an Atlas release changes it. Publish it with only the grant the command prints |
112
112
  | `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` |
113
- | `atlas upkeep` | `check` before you ask for a merge. It reads the docs index, dead doc links, the code each doc names, and the tracker item of a pull request. The daily upkeep run calls it on the main branch. `replay` before the doc-sources rule changes how it gates |
113
+ | `atlas upkeep` | `check` before you ask for a merge. It reads the docs index, dead doc links, the code each doc names, and the tracker item of a pull request. 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 |
114
114
  | `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 |
115
115
  | `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 |
116
116
  | `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 |
@@ -27,19 +27,24 @@ the form the repo already uses for that kind of thing.
27
27
  deploy. A rule says how to judge a change. They have different approval
28
28
  rules, so split a skill that holds both.
29
29
  4. **Write the header Atlas reads,** for a skill file. It names the type,
30
- the commands and files, and the code it covers. It says how CI runs
31
- it, if CI can. Today the Atlas check reads only `name` and
32
- `description`, and finds the skill when `procedures` in `atlas.yaml`
33
- names it. Later upkeep checks read the other keys, so keep each true.
34
- 5. **Keep it to this repo.** A step that holds how to work in general
30
+ the commands and files, and the code it covers in `sources:`. The
31
+ Atlas check finds the skill when `procedures` in `atlas.yaml` names
32
+ it. `atlas upkeep check` reads `sources:`.
33
+ 5. **Tag each step a clean machine can run.** Put its commands in a
34
+ fenced block. Open it with ```` ```sh atlas-run ````. CI runs each
35
+ such block when a pull request changes the skill or its sources. A
36
+ step that needs a secret, a device or the network gets
37
+ `needs=secret/<name>`, `needs=device/<name>` or `needs=network/<name>`
38
+ after the tag. CI lists it as "not run here".
39
+ 6. **Keep it to this repo.** A step that holds how to work in general
35
40
  belongs in Atlas. Do not put it here. Propose it to Atlas with the
36
41
  `atlas:feedback` skill.
37
- 6. **Follow the repo's own rules.** Read its entry file first. Some repos
42
+ 7. **Follow the repo's own rules.** Read its entry file first. Some repos
38
43
  ask for one area folder for each change, or a status file updated
39
44
  when the state changes. Keep to them.
40
- 7. **Prove the steps once.** Ask the `atlas:verifier` crew to run them in
45
+ 8. **Prove the steps once.** Ask the `atlas:verifier` crew to run them in
41
46
  a fresh worktree, before you ask for the merge.
42
- 8. **Write the pull request text.** Say what you found, what you kept,
47
+ 9. **Write the pull request text.** Say what you found, what you kept,
43
48
  and the gap you closed.
44
49
 
45
50
  ## The header of a skill file
@@ -51,9 +56,8 @@ description: <when to use it, in one or two sentences>
51
56
  type: task # or rule
52
57
  names: # each command and file the steps name
53
58
  - <command or path>
54
- covers: # the code this skill describes
59
+ sources: # the code this skill describes
55
60
  - <path>
56
- ci: <how CI runs it, or "cannot run in CI">
57
61
  ---
58
62
  ```
59
63
 
@@ -25,6 +25,29 @@ export const ROUNDUP_READ_VERBS = Object.freeze(['roundup']);
25
25
  // stage-2 is the stage 2 item or one of its parts.
26
26
  export const STAGE_TAG = /^stage-(\d{1,2})$/;
27
27
  export const STUCK_DAYS = 14;
28
+ // Roadmap R14 done line 6: the daily audit runs in the watcher workflow. A
29
+ // repo whose watcher has not run for this long is flagged in part 4.
30
+ export const AUDIT_WORKFLOW = 'atlas-jobs.yml';
31
+ export const AUDIT_STALE_HOURS = 26;
32
+ const HOUR = 3_600_000;
33
+
34
+ // The newest run of the daily audit in each repo. A repo with no such
35
+ // workflow (GitHub answers 404) has no audit, so it is left out. Any other
36
+ // failure is listed as unread, never as fresh.
37
+ export async function readAudits(repoIds, readGitHub, readAt, staleHours = AUDIT_STALE_HOURS) {
38
+ const stale = [], unread = [];
39
+ for (const repo of repoIds) {
40
+ let runs;
41
+ try { runs = (await readGitHub(`repos/${repo}/actions/workflows/${AUDIT_WORKFLOW}/runs?per_page=1`))?.workflow_runs; } catch (error) {
42
+ if (!/\b404\b|Not Found/i.test(String(error?.message ?? error))) unread.push({ repo, reason: oneLine(`the audit runs could not be read: ${error?.message ?? String(error)}`, 200) });
43
+ continue;
44
+ }
45
+ const last = Array.isArray(runs) && runs.length ? runs[0].created_at ?? null : null;
46
+ const hours = last ? Math.floor((Date.parse(readAt) - Date.parse(last)) / HOUR) : null;
47
+ if (hours === null || hours >= staleHours) stale.push({ repo, workflow: `.github/workflows/${AUDIT_WORKFLOW}`, last_run_at: last, hours });
48
+ }
49
+ return { stale, unread };
50
+ }
28
51
  export const TREND_WEEKS = 4;
29
52
  const DEFAULT_WINDOW = 7;
30
53
  const MAX_WINDOW = 90;
@@ -141,6 +164,7 @@ export function createRoundupVerbs(core) {
141
164
  const notMarked = (flight.merged_not_marked ?? []).map((row) => ({ item: row.item, pr: row.pr, title: row.title, days: row.days }));
142
165
  const openNoItem = pulls.filter((pull) => pull.state === 'open' && !owner.has(pull.url)).map((pull) => ({ pr: pull.url, title: pull.title, updated_at: pull.updated_at }));
143
166
  const longWaits = needRows.filter((row) => row.days_waiting !== null && row.days_waiting >= STUCK_DAYS);
167
+ const audits = await readAudits(repoIds, readGitHub, readAt);
144
168
 
145
169
  // 5. Trend, per week; week 1 is the last seven days.
146
170
  const weeks = Array.from({ length: TREND_WEEKS }, (_, index) => ({ week: index + 1, from: dayOf(minus(readAt, (index + 1) * 7)), to: dayOf(minus(readAt, index * 7)), merged: 0, delivered: 0, new_items: 0 }));
@@ -150,13 +174,13 @@ export function createRoundupVerbs(core) {
150
174
  for (const row of items) bump(row.created_at, 'new_items');
151
175
  const oldest = needRows.reduce((max, row) => Math.max(max, row.days_waiting ?? 0), 0);
152
176
 
153
- const text = render({ product, readAt, windowDays, needRows, ladder, lifecycles, active, merged, deliveries, created, stale, notMarked, openNoItem, longWaits, weeks, oldest, repoErrors });
177
+ const text = render({ product, readAt, windowDays, needRows, ladder, lifecycles, active, merged, deliveries, created, stale, notMarked, openNoItem, longWaits, audits, weeks, oldest, repoErrors });
154
178
  return {
155
179
  verb: 'roundup', read_at: readAt, product, window_days: windowDays, since,
156
180
  needs_you: needRows,
157
181
  where_we_stand: { ladder, lifecycles, active },
158
182
  what_moved: { merged, delivered: deliveries, new_items: created },
159
- stuck_or_drifting: { stale, merged_not_marked: notMarked, open_prs_with_no_item: openNoItem, long_waits: longWaits, stuck_days: STUCK_DAYS },
183
+ stuck_or_drifting: { stale, merged_not_marked: notMarked, open_prs_with_no_item: openNoItem, long_waits: longWaits, stuck_days: STUCK_DAYS, audits_not_run: audits.stale, audits_unread: audits.unread, audit_stale_hours: AUDIT_STALE_HOURS },
160
184
  trend: { weeks, open_questions: needRows.length, oldest_question_days: oldest },
161
185
  repos_read: repoIds, repo_errors: repoErrors,
162
186
  citation: { product, read_at: readAt, items: items.length, repos: repoIds },
@@ -167,7 +191,7 @@ export function createRoundupVerbs(core) {
167
191
  }
168
192
 
169
193
  function render(view) {
170
- const { product, readAt, windowDays, needRows, ladder, lifecycles, active, merged, deliveries, created, stale, notMarked, openNoItem, longWaits, weeks, oldest, repoErrors } = view;
194
+ const { product, readAt, windowDays, needRows, ladder, lifecycles, active, merged, deliveries, created, stale, notMarked, openNoItem, longWaits, audits = { stale: [], unread: [] }, weeks, oldest, repoErrors } = view;
171
195
  const more = (list) => (list.length > SHOWN ? [` and ${list.length - SHOWN} more`] : []);
172
196
  const out = [`${product} — roundup, ${dayOf(readAt)}`];
173
197
 
@@ -195,6 +219,8 @@ function render(view) {
195
219
  stuck.push(` merged, not marked (${notMarked.length})`, ...notMarked.slice(0, SHOWN).map((row) => listLine(row.title, `${row.pr} · ${row.item}`, ' ')));
196
220
  stuck.push(` open pull requests with no item (${openNoItem.length})`, ...openNoItem.slice(0, SHOWN).map((row) => listLine(row.title, row.pr, ' ')));
197
221
  stuck.push(` questions waiting ${STUCK_DAYS} days or more (${longWaits.length})`, ...longWaits.slice(0, SHOWN).map((row) => listLine(row.text, `${row.question} · ${row.days_waiting} days`, ' ')));
222
+ stuck.push(` audits with no run in ${AUDIT_STALE_HOURS} hours (${audits.stale.length})`, ...audits.stale.slice(0, SHOWN).map((row) => listLine(row.repo, row.last_run_at ? `last run ${row.last_run_at}, ${row.hours} hours` : 'no run yet', ' ')));
223
+ for (const row of audits.unread.slice(0, SHOWN)) stuck.push(listLine(row.repo, row.reason, ' '));
198
224
  out.push(stuck.join('\n'));
199
225
 
200
226
  out.push([`5. Trend, per week (week 1 is the last seven days)`,
package/work/mcp.mjs CHANGED
@@ -206,6 +206,8 @@ export const GITHUB_READS = Object.freeze([
206
206
  new RegExp(`^repos/${REPO}/contents/atlas\\.yaml\\?ref=(?:${SHA}|${BRANCH})$`),
207
207
  new RegExp(`^repos/${REPO}/releases\\?per_page=\\d{1,3}$`),
208
208
  new RegExp(`^repos/${REPO}/compare/${SHA}\\.\\.\\.${TAG}$`),
209
+ // Roadmap R14 line 6: the newest run of the daily audit, for the roundup.
210
+ new RegExp(`^repos/${REPO}/actions/workflows/atlas-jobs\\.yml/runs\\?per_page=\\d{1,3}$`),
209
211
  ]);
210
212
  export function githubReader() {
211
213
  return async (path) => {