@arjunkhera/atlas 0.4.22 → 0.4.24

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
  }
@@ -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/hooks/step-in.mjs CHANGED
@@ -5,7 +5,8 @@
5
5
  // every test that reads the count.
6
6
  //
7
7
  // Its limits are the reason the package may ship it:
8
- // 1. A row holds the time, the session and the repos. It never holds the
8
+ // 1. A row holds the time, the session and the repos, and `from: platform`
9
+ // when a platform sent the message (item f7391622). It never holds the
9
10
  // text of the message.
10
11
  // 2. It makes no network call, reads no key and starts no program. It reads
11
12
  // the hook input, atlas.yaml in the session folder or one level down, and
@@ -24,6 +25,11 @@ import { fileURLToPath } from 'node:url';
24
25
  const MAX_REPOS = 16;
25
26
  const REPO = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}\/[A-Za-z0-9_.-]{1,100}$/;
26
27
  const SESSION = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,199}$/;
28
+ // Item f7391622: in a cloud project the platform also sends messages to the
29
+ // session, such as a reminder, a relay or a pull request event. A message
30
+ // that starts with one of these tags is the platform's.
31
+ const PLATFORM_TAGS = ['wake', 'session-context', 'relay', 'coordinator-relay', 'system-reminder', 'task-notification'];
32
+ const READ_BYTES = 65536;
27
33
 
28
34
  // The folder that the hook writes and the Atlas tools read. The tools keep
29
35
  // their own copy of the path, because these bytes are pinned.
@@ -52,13 +58,32 @@ export function reposOf(cwd) {
52
58
  return [...new Set(found.map((name) => repoOf(join(cwd, name, 'atlas.yaml'))).filter(Boolean))].slice(0, MAX_REPOS);
53
59
  }
54
60
 
61
+ // Who sent one message, from its first bytes: 'owner' or 'platform'. The
62
+ // text is read here and never kept.
63
+ // 1. A <message> tag with trigger="true" and from="human" is the owner's. A
64
+ // cloud project wraps an owner message this way.
65
+ // 2. A message that starts with a platform tag is the platform's.
66
+ // 3. Any other message is the owner's, so an unknown shape counts high.
67
+ export function senderOf(prompt) {
68
+ if (typeof prompt !== 'string') return 'owner';
69
+ const head = prompt.slice(0, READ_BYTES);
70
+ for (const match of head.matchAll(/<message\b([^>]*)>/g)) {
71
+ const attrs = match[1];
72
+ if (/\btrigger="true"/.test(attrs) && /\bfrom="human"/.test(attrs)) return 'owner';
73
+ }
74
+ const tag = /^\s*<([a-z][a-z-]*)[\s>]/.exec(head);
75
+ return tag && PLATFORM_TAGS.includes(tag[1]) ? 'platform' : 'owner';
76
+ }
77
+
55
78
  // One message in, one row out, or null when no row is due.
56
79
  export function rowOf(input, { now = new Date(), id = randomUUID() } = {}) {
57
80
  const session = typeof input?.session_id === 'string' && SESSION.test(input.session_id) ? input.session_id : null;
58
81
  if (!session) return null;
59
82
  const repos = reposOf(input?.cwd);
60
83
  if (repos === null) return null;
61
- return { v: 1, id, at: now.toISOString(), session, repos };
84
+ // A row with no sender is the owner's, as in the hook of 0.4.8, so only a
85
+ // platform message carries the key.
86
+ return { v: 1, id, at: now.toISOString(), session, repos, ...(senderOf(input?.prompt) === 'platform' ? { from: 'platform' } : {}) };
62
87
  }
63
88
 
64
89
  // The row goes to a file of its own, written under a temporary name and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.4.22",
3
+ "version": "0.4.24",
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",