@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/.claude-plugin/plugin.json +1 -1
- package/door/cli.mjs +4 -3
- package/door/jobs/gate-audit.yaml +16 -0
- package/door/jobs/gate-errors.yaml +11 -0
- package/door/jobs/gate-files.yaml +13 -0
- package/door/lib/gate-audit.mjs +303 -0
- package/door/lib/gate-check.mjs +4 -0
- package/door/lib/gate.mjs +92 -23
- package/door/lib/jobs.mjs +25 -4
- package/door/templates/atlas-jobs.yml +5 -1
- package/door/workflows/atlas-gate-audit.yml +128 -0
- package/door/workflows/atlas-gate.yml +142 -0
- package/hooks/step-in.mjs +27 -2
- package/package.json +1 -1
- package/work/lib/numbers.mjs +317 -0
- package/work/lib/roundup.mjs +73 -3
- package/work/lib/step-ins.mjs +35 -15
- package/work/mcp.mjs +17 -6
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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