@arjunkhera/atlas 0.4.24 → 0.4.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atlas",
3
- "version": "0.4.24",
3
+ "version": "0.4.26",
4
4
  "description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, for any repository.",
5
5
  "author": {
6
6
  "name": "Arjun Khera"
@@ -4,7 +4,9 @@
4
4
  # the same findings, with the same keys, so one break opens one issue.
5
5
  #
6
6
  # kind is one of: push (no pull request merged it), force-push,
7
- # merge-before-green, gate-copy (a gate run not from the base workflow file).
7
+ # merge-before-green, gate-copy (a gate run not from the base workflow file),
8
+ # owner-unsigned (the gate sends the merge to the owner, and no signed act
9
+ # names its head; the push audit only, because it needs the clone).
8
10
  # A closed issue does not stop a new one while its commit is in the window:
9
11
  # anyone with the shared login can close or hand over an issue.
10
12
  title: Each commit on the main branch came through the gate
@@ -0,0 +1,24 @@
1
+ # An Atlas job (the routines design, records part). A job does nothing until a
2
+ # repo names it in its atlas.yaml, with the npm packages it publishes:
3
+ #
4
+ # jobs:
5
+ # atlas: [npm-release]
6
+ # npm: ['@scope/name']
7
+ #
8
+ # It fits a repo that publishes to npm through a release.yml on master that
9
+ # keeps the Atlas release record, as Atlas does. A service, a schema repo or a
10
+ # repo that publishes nothing does not name it.
11
+ #
12
+ # The check is Atlas code (door/lib/jobs.mjs). Each version that npm got in the
13
+ # last 2 days must have the proof that `atlas upgrade` asks for: a successful
14
+ # push run of release.yml on a master commit that recorded that version with
15
+ # the sha256 of its tarball. A branch can publish before any merge (the gate
16
+ # probes, P7), so a version with no such run is a finding. `problem` is a
17
+ # code: no-tag, not-on-master, never-tip or no-record. Only a person can act
18
+ # on it (deprecate the version on npm), so the job may only report.
19
+ title: Each new npm version was published by a release run on master
20
+ when: daily
21
+ needs: []
22
+ act: atlas:npm-release
23
+ may: report
24
+ fields: [package, version, problem, commit]
@@ -10,8 +10,10 @@
10
10
  //
11
11
  // The answer is success, owner or red:
12
12
  //
13
- // red a secret in an added line; product code that changed with no test
14
- // case added or changed; a head that git cannot read
13
+ // red a secret in an added line; a private term in an added line or a
14
+ // path that ships, or no terms when atlas.yaml names shipped
15
+ // paths; product code that changed with no test case added or
16
+ // changed; a head that git cannot read
15
17
  // owner a path or a sign of the owner-only list; a head job named like a
16
18
  // check; a refactor claim (until the review job, roadmap R28); a
17
19
  // file too large to scan; the brake (roadmap R13, brake.mjs)
@@ -31,7 +33,8 @@
31
33
  import { spawnSync } from 'node:child_process';
32
34
  import { readFileSync, appendFileSync } from 'node:fs';
33
35
  import { resolve } from 'node:path';
34
- import { classify, changedPaths, compile, matcher, fakeSecret } from './owner-only.mjs';
36
+ import { classify, changedPaths, compile, matcher, fakeSecret, repoFacts } from './owner-only.mjs';
37
+ import { MISSING, HIT, fileHits, hidePath, shippedPaths, ships, termsFromEnv } from './private-terms.mjs';
35
38
  import { listChanged } from './changed-tests.mjs';
36
39
  import { parseYaml, SECRET_PATTERNS } from '../../shape/check.mjs';
37
40
  import { parseSigners, verifyTexts, findAct, SIGNERS_FILE, BRANCH_FORM } from './signed-act.mjs';
@@ -115,6 +118,39 @@ export function secretRows(root, base, head, changes) {
115
118
  return rows;
116
119
  }
117
120
 
121
+ // ------------------------------------------------------------------ private terms
122
+
123
+ // The private-terms rule (roadmap R18, design docs/design-docs/private-terms/).
124
+ // It holds when the base atlas.yaml names `private_terms: { paths }`, the
125
+ // paths that ship in public. `terms` comes from the repo secret. It reads the
126
+ // whole head text of each changed file that ships, binary or not, so a term
127
+ // split over two lines, or already in an old line, is found too. A row names
128
+ // the path, with [hidden] for a term in it, and the line, never the term.
129
+ export function termRows(root, base, head, changes, terms) {
130
+ const paths = shippedPaths(repoFacts(root, base).facts);
131
+ if (paths === null) return [];
132
+ if (!paths.length) return [{ kind: 'red', class: 'private-term', cause: 'atlas.yaml private_terms names no paths' }];
133
+ if (!terms.length) return [{ kind: 'red', class: 'private-term', cause: MISSING }];
134
+ const rows = [];
135
+ for (const change of changes) {
136
+ if (change.status === 'D' || change.newMode === '160000' || !ships(paths, change.path)) continue;
137
+ const size = Number((git(root, ['cat-file', '-s', `${head}:${change.path}`], { allowFail: true }) ?? '0').trim());
138
+ if (size > SCAN_LIMIT) { rows.push({ kind: 'red', class: 'private-term', path: change.path, cause: `${size} bytes: too large to check for private terms` }); continue; }
139
+ for (const hit of fileHits(change.path, textAt(root, head, change.path) ?? '', terms)) {
140
+ rows.push({ kind: 'red', class: 'private-term', path: hit.path, ...(hit.line ? { line: hit.line } : {}), cause: hit.line ? `the line ${HIT}` : `the path ${HIT}` });
141
+ }
142
+ }
143
+ rows.push({ kind: 'note', class: 'private-term', cause: `private terms: ${terms.length} term(s), ${rows.length} hit(s)` });
144
+ return rows;
145
+ }
146
+
147
+ // The answer with each term hidden in every path and cause, so no row of any
148
+ // rule shows a term in the summary, the log or the JSON.
149
+ export function hideTerms(answer, terms) {
150
+ if (!terms?.length) return answer;
151
+ return { ...answer, rows: answer.rows.map((row) => ({ ...row, ...(row.path ? { path: hidePath(row.path, terms) } : {}), cause: hidePath(String(row.cause ?? ''), terms) })) };
152
+ }
153
+
118
154
  // ------------------------------------------------------------------ jobs named like a check
119
155
 
120
156
  const WORKFLOW = /^\.github\/workflows\/[^/]+\.ya?ml$/;
@@ -230,7 +266,9 @@ function testRows({ root, mergeBase, head, body, redMain, rules, undo = null })
230
266
  // the replay does, because the brake depends on the day of the run. `signed`
231
267
  // is { repo, pr, baseRef, texts }: the texts are the comments of the pull
232
268
  // request, read for a signed act, and baseRef is the name of its base branch.
233
- export function judge({ root, base, head, body = '', redMain = [], rules = null, brake = {}, now = new Date(), signed = null }) {
269
+ // `terms` is the list of private terms, or undefined to skip that rule (the
270
+ // replay, which has no secret).
271
+ export function judge({ root, base, head, body = '', redMain = [], rules = null, brake = {}, now = new Date(), signed = null, terms = undefined }) {
234
272
  const top = resolve(root);
235
273
  const baseSha = commitOf(top, base);
236
274
  if (!baseSha) throw new GateCheckError(`the base "${base}" is not a commit in this repo.`);
@@ -257,6 +295,7 @@ export function judge({ root, base, head, body = '', redMain = [], rules = null,
257
295
  for (const row of classify({ root: top, base: mergeBase, head: headSha, rules }).rows) answer.rows.push({ kind: 'owner', class: row.class ?? 'unlisted', path: row.path, cause: row.cause });
258
296
  answer.rows.push(...jobRows(top, rules ?? mergeBase, headSha, changes));
259
297
  answer.rows.push(...secretRows(top, mergeBase, headSha, changes));
298
+ if (terms !== undefined) answer.rows.push(...termRows(top, baseSha, headSha, changes, terms));
260
299
  answer.rows.push(...testRows({ root: top, mergeBase, head: headSha, body, redMain, rules, undo }));
261
300
  if (signed) signedRows(top, baseSha, headSha, signed, answer);
262
301
  return finish(answer);
@@ -353,7 +392,8 @@ export function checkCommand({ root, base, head, body: bodyFile, json = false, s
353
392
  try { texts = comments(); } catch { texts = null; }
354
393
  signed = { repo, pr: Number(pr), baseRef: baseRef && baseRef !== true ? String(baseRef) : null, texts };
355
394
  }
356
- const answer = judge({ root, base, head, body, redMain, brake: Array.isArray(got) ? {} : got.brake ?? {}, signed });
395
+ const { terms } = termsFromEnv();
396
+ const answer = hideTerms(judge({ root, base, head, body, redMain, brake: Array.isArray(got) ? {} : got.brake ?? {}, signed, terms }), terms);
357
397
  const text = renderText(answer);
358
398
  if (json) process.stdout.write(`${JSON.stringify(answer, null, 2)}\n`);
359
399
  else for (const each of text) line(each);
@@ -362,7 +402,7 @@ export function checkCommand({ root, base, head, body: bodyFile, json = false, s
362
402
  if (output && output !== true) appendFileSync(output, `result=${answer.result}\nclass=${/^[a-z-]+$/.test(answer.class ?? '') ? answer.class : 'none'}\n`);
363
403
  return answer.result === 'success' ? 0 : 1;
364
404
  } catch (error) {
365
- line(`gate check: ${error.message}`);
405
+ line(`gate check: ${hidePath(error.message, termsFromEnv().terms)}`);
366
406
  // In the gate workflow (it passes --output), an error leaves a note with a
367
407
  // fixed title. The audit reads it to tell an error from a result (roadmap
368
408
  // R14 done line 5).
package/door/lib/gate.mjs CHANGED
@@ -20,7 +20,7 @@ import { homedir } from 'node:os';
20
20
  import { dirname, join } from 'node:path';
21
21
  import { childEnvironment } from '../../work/lib/child-env.mjs';
22
22
  import { parseYaml } from '../../shape/check.mjs';
23
- import { checkCommand, replayCommand, jobNames, GATE_CHECK, GATE_WORKFLOW } from './gate-check.mjs';
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
25
  import { MARKER as SIGNED_MARKER } from './signed-act.mjs';
26
26
  import { brakeFacts, windowCommits, ciTrips, BrakeError } from './brake.mjs';
@@ -422,7 +422,7 @@ export function keepReady({ repo, pr, sha, code, home = homedir(), now = new Dat
422
422
 
423
423
  // `atlas gate audit --since <date>`: each pull request merged since the date
424
424
  // whose required checks were not all green at the moment of the merge.
425
- export function auditCommand({ repo, since, commit, errors = false, forced = false, findings }, line, gh = ghApi) {
425
+ export function auditCommand({ repo, root, since, commit, errors = false, forced = false, findings }, line, gh = ghApi) {
426
426
  // R14: with --findings, each finding also goes into the file, and `atlas
427
427
  // jobs file` opens one issue for each. A call that stops with an error
428
428
  // marks the file broken, so the file job ends red.
@@ -431,7 +431,7 @@ export function auditCommand({ repo, since, commit, errors = false, forced = fal
431
431
  try {
432
432
  let code;
433
433
  if (errors) code = gateErrors({ repo, found }, line, gh);
434
- else if (commit !== undefined) code = commitAudit({ repo, commit, forced, found }, line, gh);
434
+ else if (commit !== undefined) code = commitAudit({ repo, root, commit, forced, found }, line, gh);
435
435
  else code = sinceAudit({ repo, since }, line, gh);
436
436
  keep();
437
437
  return code;
@@ -472,7 +472,22 @@ function sinceAudit({ repo, since }, line, gh) {
472
472
  // gate run of that pull request came from the base copy of the gate
473
473
  // workflow. A commit that no pull request merged was pushed past the gate, so
474
474
  // it is red. With --forced, the push that brought it was a force push.
475
- export function commitAudit({ repo, commit, forced = false, found = [] }, line, gh) {
475
+ // R14 done line 3: a merge that the gate sent to the owner needs the owner's
476
+ // signed act for its head (R72). The audit judges the merged head again with
477
+ // the base commit's rules, at the time of the merge, and reads the signed acts
478
+ // of the pull request. It needs both commits in the clone. Returns 'owner'
479
+ // when no signed act clears it, 'unread' when it cannot judge, else null.
480
+ export function ownerUnsigned({ root, repo, pull, parent }, gh) {
481
+ const head = pull.head?.sha;
482
+ const has = (sha) => { try { execFileSync('git', ['-C', root, 'cat-file', '-e', `${sha}^{commit}`], { stdio: 'ignore' }); return true; } catch { return false; } };
483
+ if (!/^[0-9a-f]{40}$/.test(String(head)) || !/^[0-9a-f]{40}$/.test(String(parent)) || !has(head) || !has(parent)) return 'unread';
484
+ let texts = null;
485
+ try { texts = prComments(gh, repo, pull.number); } catch { texts = null; }
486
+ const answer = judge({ root, base: parent, head, body: String(pull.body ?? ''), now: new Date(pull.merged_at), signed: { repo, pr: Number(pull.number), baseRef: pull.base?.ref ?? null, texts } });
487
+ return answer.result === 'owner' ? 'owner' : null;
488
+ }
489
+
490
+ export function commitAudit({ repo, root = null, commit, forced = false, found = [] }, line, gh) {
476
491
  if (!commit || commit === true || !/^[0-9a-f]{7,40}$/i.test(String(commit))) throw new GateError('--commit needs a commit SHA.');
477
492
  // GitHub expands a short SHA, and names the first parent: the main branch
478
493
  // just before this commit, whose atlas.yaml held the gate at that moment.
@@ -498,6 +513,16 @@ export function commitAudit({ repo, commit, forced = false, found = [] }, line,
498
513
  found.push({ job: AUDIT_JOB, key: `gate-copy:${sha}`, kind: 'gate-copy', sha, pr: Number(pull.number), ...(one.run ? { run_id: Number(one.run) } : {}), pin: pinCode(one.why) });
499
514
  }
500
515
  const parent = fetched.parents?.[0]?.sha ?? pull.base.sha;
516
+ if (root) {
517
+ const owner = ownerUnsigned({ root, repo, pull, parent }, gh);
518
+ if (owner) {
519
+ red = true;
520
+ line(owner === 'owner'
521
+ ? `- ✗ #${pull.number} merged while the gate sent it to the owner, and no signed act names its head ${String(pull.head?.sha).slice(0, 7)}.`
522
+ : `- ✗ #${pull.number}: the head or the base commit is not in this clone, so the audit cannot judge it.`);
523
+ found.push({ job: AUDIT_JOB, key: `owner-unsigned:${sha}`, kind: 'owner-unsigned', sha, pr: Number(pull.number), ...(owner === 'unread' ? { pin: 'unread' } : {}) });
524
+ }
525
+ } else line(`- atlas gate audit: no clone (--root), so the owner check of #${pull.number} did not run.`);
501
526
  let checks;
502
527
  try { checks = checksAt(gh, repo, parent); } catch (error) {
503
528
  if (!(error instanceof GateError)) throw error;
@@ -629,7 +654,7 @@ export function gateCommand({ what, repo, root, dryRun = false, off = false, on
629
654
  }
630
655
  return code;
631
656
  }
632
- if (what === 'audit') return auditCommand({ repo, since, commit, errors, forced, findings }, line, gh);
657
+ if (what === 'audit') return auditCommand({ repo, root, since, commit, errors, forced, findings }, line, gh);
633
658
  if (off && on) throw new GateError('pick --off or --on, not both.');
634
659
  const mode = off ? 'off' : on ? 'on' : 'apply';
635
660
  const facts = mode === 'apply' ? gateFacts(root) : null;
@@ -218,12 +218,13 @@ export const recordName = (version, sha256) => `atlas-release-${version}-${sha25
218
218
  // commit must once have been the tip of master. A tag that a person made or
219
219
  // moved finds no record, so the upgrade refuses. With no sha256 (a loaded
220
220
  // copy has no tarball), any record of this version counts, and the byte
221
- // compare of every file does the rest.
221
+ // compare of every file does the rest. A refusal carries a `code` too, for
222
+ // the npm-release job of the watcher (jobs.mjs).
222
223
  export function findRelease({ gh, repo, version, sha256 }) {
223
224
  const commit = tagCommit(gh, repo, version);
224
- if (!commit) return { problem: `no tag v${version} names a commit` };
225
- if (!onMaster(gh, repo, commit)) return { problem: `the tag v${version} names commit ${commit.slice(0, 12)}, which is not on master` };
226
- if (!wasTip(gh, repo, commit)) return { problem: `the tag v${version} names commit ${commit.slice(0, 12)}, which was never the tip of master through a merge or a push` };
225
+ if (!commit) return { code: 'no-tag', problem: `no tag v${version} names a commit` };
226
+ if (!onMaster(gh, repo, commit)) return { code: 'not-on-master', commit, problem: `the tag v${version} names commit ${commit.slice(0, 12)}, which is not on master` };
227
+ if (!wasTip(gh, repo, commit)) return { code: 'never-tip', commit, problem: `the tag v${version} names commit ${commit.slice(0, 12)}, which was never the tip of master through a merge or a push` };
227
228
  const runs = gh(['api', '--method', 'GET', `repos/${repo}/actions/workflows/release.yml/runs?event=push&status=success&head_sha=${commit}&per_page=100`]).workflow_runs ?? [];
228
229
  const prefix = recordName(version, '');
229
230
  const named = (name) => (sha256 ? name === recordName(version, sha256) : name.startsWith(prefix) && /^[0-9a-f]{64}$/.test(name.slice(prefix.length)));
@@ -234,7 +235,7 @@ export function findRelease({ gh, repo, version, sha256 }) {
234
235
  const artifacts = gh(['api', '--method', 'GET', `repos/${repo}/actions/runs/${run.id}/artifacts?per_page=100`]).artifacts ?? [];
235
236
  if (artifacts.some((artifact) => named(artifact.name ?? ''))) return { run, commit };
236
237
  }
237
- return { problem: `no successful release run on master commit ${commit.slice(0, 12)} recorded ${version}${sha256 ? ' with this tarball' : ''}` };
238
+ return { code: 'no-record', commit, problem: `no successful release run on master commit ${commit.slice(0, 12)} recorded ${version}${sha256 ? ' with this tarball' : ''}` };
238
239
  }
239
240
 
240
241
  // The blob ids that the version files of the tarball must have: the files of
package/door/lib/jobs.mjs CHANGED
@@ -24,7 +24,9 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from
24
24
  import { dirname, join, resolve } from 'node:path';
25
25
  import { fileURLToPath } from 'node:url';
26
26
  import { parseYaml } from '../../shape/check.mjs';
27
+ import { createHash } from 'node:crypto';
27
28
  import { api, apiBase, repoOk, openIssues, TOKEN_SHAPE, DEFAULT_BOT, DEFAULT_MAX } from './issues.mjs';
29
+ import { findRelease, sha512Integrity } from './install.mjs';
28
30
  import { checkGateFiles, checkGateAudit, checkGateErrors } from './gate-audit.mjs';
29
31
  import { commitAudit, ghApi } from './gate.mjs';
30
32
 
@@ -107,7 +109,100 @@ export async function checkRedMain({ repo, branch, env }) {
107
109
  return findings;
108
110
  }
109
111
 
110
- // The check of each Atlas job, by its `when`. A job whose check is not built yet reads `none` and
112
+ // ---------------------------------------------------------------- the check of npm-release
113
+
114
+ // A branch of the repo can run its own release.yml and publish to npm before any merge (the gate
115
+ // probes, P7). So each version that npm got in the last NPM_DAYS days must have the proof that
116
+ // `atlas upgrade` asks for: a successful push run of release.yml on a master commit that recorded
117
+ // this version with the sha256 of this exact tarball (findRelease, install.mjs). The watcher runs
118
+ // once a day, so two days cover one missed run. A longer window reaches versions from before the
119
+ // release record existed (Atlas 0.4.0), and the tip proof reads only the newest 100 changes of master.
120
+ export const NPM_DAYS = 2;
121
+ export const NPM_MAX = 50;
122
+ const PACKAGE = /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
123
+
124
+ // The npm packages that atlas.yaml names under `jobs: { npm: [...] }`.
125
+ export function npmPackages(root) {
126
+ const facts = parseYaml(readFileSync(join(root, 'atlas.yaml'), 'utf8')) ?? {};
127
+ const named = facts.jobs?.npm;
128
+ if (!Array.isArray(named) || !named.length) throw new Error('atlas.yaml names no package under jobs.npm');
129
+ const bad = named.map(String).find((name) => !PACKAGE.test(name));
130
+ if (bad !== undefined) throw new Error('jobs.npm in atlas.yaml holds a name that is not an npm package name');
131
+ return named.map(String);
132
+ }
133
+
134
+ // https, or http for this machine only (a test uses a fake registry on 127.0.0.1).
135
+ function registryBase(env) {
136
+ const text = String(env.NPM_REGISTRY_URL || 'https://registry.npmjs.org');
137
+ let url;
138
+ try { url = new URL(text); } catch { throw usage('NPM_REGISTRY_URL is not a URL'); }
139
+ const local = url.hostname === '127.0.0.1' || url.hostname === 'localhost';
140
+ if (!(url.protocol === 'https:' || (url.protocol === 'http:' && local))) throw usage('NPM_REGISTRY_URL must be https (http is allowed only for 127.0.0.1 or localhost)');
141
+ return url;
142
+ }
143
+
144
+ // findRelease reads GitHub through a sync `gh`. Here each read is a fetch, so the proof runs again
145
+ // after each read it asked for, until it needs none. Every read is a GET, and the same path gives the
146
+ // same answer, so a replay changes nothing.
147
+ export async function replayRelease({ base, key, repo, version, sha256 }) {
148
+ const answers = new Map();
149
+ for (let round = 0; round < 50; round += 1) {
150
+ try {
151
+ return findRelease({
152
+ repo, version, sha256,
153
+ gh: (args) => {
154
+ const path = `/${args.at(-1)}`;
155
+ if (!answers.has(path)) throw Object.assign(new Error('a read is still to come'), { need: path });
156
+ const answer = answers.get(path);
157
+ if (answer instanceof Error) throw answer;
158
+ return answer;
159
+ },
160
+ });
161
+ } catch (error) {
162
+ if (!error.need) throw error;
163
+ const r = await api(base, key, 'GET', error.need);
164
+ if (r.status === 404) answers.set(error.need, Object.assign(new Error('Not Found'), { stderr: 'HTTP 404' }));
165
+ else if (!r.ok) throw new Error(`GitHub answered ${r.status} when the release of ${version} was read`);
166
+ else answers.set(error.need, r.data);
167
+ }
168
+ }
169
+ throw new Error(`the release of ${version} needed more than 50 reads`);
170
+ }
171
+
172
+ export async function checkNpmRelease({ repo, env, root, now = Date.now(), fetchImpl = fetch }) {
173
+ const base = apiBase(env);
174
+ const key = env.GITHUB_TOKEN || '';
175
+ if (!key) throw usage('there is no token: set GITHUB_TOKEN in the environment');
176
+ const registry = registryBase(env);
177
+ const findings = [];
178
+ for (const name of npmPackages(root)) {
179
+ const response = await fetchImpl(new URL(name.replace('/', '%2F'), `${registry.href.replace(/\/?$/, '/')}`));
180
+ if (!response.ok) throw new Error(`npm answered ${response.status} when ${name} was read`);
181
+ const doc = await response.json();
182
+ // A broken answer must read broken, never none: each version needs a publish time.
183
+ const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
184
+ if (!object(doc?.versions) || !object(doc?.time)) throw new Error(`npm sent no versions and times for ${name}`);
185
+ if (Object.keys(doc.versions).some((version) => !Number.isFinite(Date.parse(doc.time[version])))) throw new Error(`npm sent a version of ${name} with no publish time`);
186
+ const recent = Object.keys(doc.versions).filter((version) => now - Date.parse(doc.time[version]) <= NPM_DAYS * DAY);
187
+ if (recent.length > NPM_MAX) throw new Error(`npm holds ${recent.length} versions of ${name} from the last ${NPM_DAYS} days, more than ${NPM_MAX}`);
188
+ for (const version of recent.sort()) {
189
+ if (!VALUE.test(version)) throw new Error(`npm holds a version of ${name} that is not a plain version`);
190
+ const dist = doc.versions[version].dist ?? {};
191
+ const tarball = new URL(String(dist.tarball ?? ''), registry);
192
+ if (tarball.origin !== registry.origin) throw new Error(`the tarball of ${name}@${version} is not on the registry`);
193
+ const got = await fetchImpl(tarball);
194
+ if (!got.ok) throw new Error(`npm answered ${got.status} when the tarball of ${name}@${version} was read`);
195
+ const bytes = Buffer.from(await got.arrayBuffer());
196
+ if (sha512Integrity(bytes) !== dist.integrity) throw new Error(`the tarball of ${name}@${version} does not match the integrity npm records`);
197
+ const release = await replayRelease({ base, key, repo, version, sha256: createHash('sha256').update(bytes).digest('hex') });
198
+ if (!release.problem) continue;
199
+ findings.push({ job: 'npm-release', key: `${name}@${version}`, package: name, version, problem: release.code, ...(release.commit ? { commit: release.commit } : {}) });
200
+ }
201
+ }
202
+ return findings;
203
+ }
204
+
205
+ // The check of each Atlas job, by its id, else by its `when`. A job whose check is not built yet reads `none` and
111
206
  // says so in the notes; it never reads `found` from nothing.
112
207
  // The gate jobs (roadmap R14) read GitHub through `gh`, which the Actions
113
208
  // runner holds; their token is the run's own GITHUB_TOKEN.
@@ -116,6 +211,7 @@ const CHECKS = {
116
211
  'gate-files': ({ root, env, fetchImpl }) => checkGateFiles({ root, env, fetchImpl }),
117
212
  'gate-audit': ({ repo, branch, gh }) => checkGateAudit({ repo, branch, gh, audit: commitAudit }),
118
213
  'gate-errors': ({ repo, gh }) => checkGateErrors({ repo, gh }),
214
+ 'npm-release': checkNpmRelease,
119
215
  };
120
216
 
121
217
  export async function jobsCheck({ root, repo, branch = null, env = process.env, jobs = atlasJobs(), gh = ghApi, fetchImpl = fetch }) {
@@ -128,7 +224,7 @@ export async function jobsCheck({ root, repo, branch = null, env = process.env,
128
224
  for (const id of named) {
129
225
  const job = jobs.get(id);
130
226
  if (!job) { notes.push(`${JOB_ID.test(id) ? id : '(a bad id)'}: no Atlas job has this id`); continue; }
131
- const check = CHECKS[job.when];
227
+ const check = CHECKS[job.id] ?? CHECKS[job.when];
132
228
  if (!check) { notes.push(`${id}: its check is not built yet`); ran.push({ job: id, state: 'none' }); continue; }
133
229
  try {
134
230
  if (!mainBranch) mainBranch = await defaultBranch({ repo, env });
@@ -36,15 +36,37 @@ export function loadPrivateTerms({ path = null, home = homedir() } = {}) {
36
36
  if (path) throw new Error(`the private terms file ${path} does not exist.`);
37
37
  return [];
38
38
  }
39
- return readFileSync(file, 'utf8').split('\n').map((line) => line.trim()).filter((line) => line && !line.startsWith('#'));
39
+ return parseTerms(readFileSync(file, 'utf8'));
40
+ }
41
+
42
+ // The terms of one terms file's text: one term on each line, and a line that
43
+ // starts with # is a note. The Mac file and the repo secret share this form.
44
+ export function parseTerms(text) {
45
+ return String(text ?? '').split('\n').map((line) => line.trim()).filter((line) => line && !line.startsWith('#'));
46
+ }
47
+
48
+ // Text as the term rules read it: NFKC form, with the marks that do not
49
+ // show (a soft hyphen, the zero-width marks, a byte-order mark) taken out.
50
+ // It keeps every line break, so a line number stays true.
51
+ const INVISIBLE = /[\u00AD\u200B-\u200D\u2060\uFEFF]/g;
52
+ export const termText = (text) => String(text ?? '').normalize('NFKC').replace(INVISIBLE, '');
53
+
54
+ // One rule for each private term. A term matches only as a whole word, in any
55
+ // case. The words of a term may be split by any run of white space, also a
56
+ // line break, so a wrapped paragraph still matches when the caller reads the
57
+ // whole text.
58
+ export function termRules(terms = []) {
59
+ return terms.map((term) => {
60
+ const words = termText(term).split(/\s+/).filter(Boolean).map(escape);
61
+ return { id: 'private-term', holds: 'One of the owner\'s private terms.', pattern: new RegExp(`(?<![\\p{L}\\p{N}])${words.join('\\s+')}(?![\\p{L}\\p{N}])`, 'iu') };
62
+ });
40
63
  }
41
64
 
42
65
  // Every rule this filter applies: the secret patterns, the generic patterns,
43
66
  // then one rule for each private term.
44
67
  export function privacyRules({ terms = [] } = {}) {
45
68
  const secrets = secretPatterns().map((pattern, index) => ({ id: `secret-${SECRET_PATTERNS[index].id}`, holds: SECRET_PATTERNS[index].holds, pattern }));
46
- const own = terms.map((term) => ({ id: 'private-term', holds: 'One of the owner\'s private terms.', pattern: new RegExp(`(?<![A-Za-z0-9])${escape(term)}(?![A-Za-z0-9])`, 'i') }));
47
- return [...secrets, ...PRIVACY_PATTERNS, ...own];
69
+ return [...secrets, ...PRIVACY_PATTERNS, ...termRules(terms)];
48
70
  }
49
71
 
50
72
  // Each finding names its line and its rule, never the text it matched, so a
@@ -0,0 +1,65 @@
1
+ // The private-terms check of a public package (roadmap R18, tracker item
2
+ // 437b99c1; design docs/design-docs/private-terms/). The release build runs
3
+ // it on each file of the tarball, and the gate runs it on each added line
4
+ // under the shipped paths of a pull request.
5
+ //
6
+ // The terms come from the repo secret ATLAS_PRIVATE_TERMS, in the form of the
7
+ // owner's terms file. This plan gives a private repo no environments, so any
8
+ // workflow run of this repo can read that secret, on any branch. A fork
9
+ // cannot. The design accepts that: who can push here can already publish.
10
+ //
11
+ // A hit names the path and the line, never the term. A path that holds a
12
+ // term shows [hidden] in its place. The text is read in NFKC form with the
13
+ // marks that do not show taken out, and the words of a term may be split by
14
+ // any white space.
15
+ import { parseTerms, redact, termRules, termText } from './privacy.mjs';
16
+
17
+ export const TERMS_ENV = 'ATLAS_PRIVATE_TERMS';
18
+ export const MISSING = `the private terms are missing: the repo secret ${TERMS_ENV} is empty`;
19
+ export const HIT = 'holds a private term';
20
+
21
+ // The terms from the environment. `set` is false when the variable is absent
22
+ // or holds notes only.
23
+ export function termsFromEnv(env = process.env) {
24
+ const terms = parseTerms(env[TERMS_ENV]);
25
+ return { set: terms.length > 0, terms };
26
+ }
27
+
28
+ // The text with each term replaced by [hidden]. Text with no term comes back
29
+ // as it was.
30
+ export function hidePath(text, terms) {
31
+ if (!terms?.length) return text;
32
+ const clean = termText(text);
33
+ const hidden = redact(clean, { rules: termRules(terms) }).replace(/\[hidden: private-term\]/g, '[hidden]');
34
+ return hidden === clean ? text : hidden;
35
+ }
36
+
37
+ // The hits of one file: { path, line } for the first line of each match, and
38
+ // { path, line: null } when the path holds a term. `path` is already hidden.
39
+ // The whole text is read at once, so a term split over two lines still
40
+ // matches.
41
+ export function fileHits(path, text, terms) {
42
+ const rules = termRules(terms);
43
+ const shown = hidePath(path, terms);
44
+ const hits = [];
45
+ if (rules.some((rule) => rule.pattern.test(termText(path)))) hits.push({ path: shown, line: null });
46
+ const body = termText(text);
47
+ const lines = new Set();
48
+ for (const rule of rules) {
49
+ const all = new RegExp(rule.pattern.source, `${rule.pattern.flags}g`);
50
+ for (const match of body.matchAll(all)) lines.add(body.slice(0, match.index).split('\n').length);
51
+ }
52
+ for (const line of [...lines].sort((a, b) => a - b)) hits.push({ path: shown, line });
53
+ return hits;
54
+ }
55
+
56
+ // The shipped paths that atlas.yaml names under private_terms.paths, or null
57
+ // when the key is not there.
58
+ export function shippedPaths(facts) {
59
+ const entry = facts?.private_terms;
60
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null;
61
+ const paths = Array.isArray(entry.paths) ? entry.paths.filter((path) => typeof path === 'string' && path) : [];
62
+ return paths;
63
+ }
64
+
65
+ export const ships = (paths, path) => paths.some((prefix) => (prefix.endsWith('/') ? path.startsWith(prefix) : path === prefix || path.startsWith(`${prefix}/`)));
@@ -12,6 +12,8 @@ on:
12
12
  # 3. A merge made before the required checks of its pull request were
13
13
  # green. A check counts only from a run of the base file that defines it.
14
14
  # 4. A gate run that is not from the base copy of atlas-gate.yml.
15
+ # 5. A merge that the gate sends to the owner, with no signed act of the
16
+ # owner for its head (R72). The audit judges the head again from git.
15
17
  # Each finding opens one `atlas-job` issue, and the run ends red. It does not
16
18
  # undo the push.
17
19
  #
@@ -81,6 +81,8 @@ jobs:
81
81
  - name: Read the pull request text from the event
82
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
83
 
84
+ # Only this step gets the private terms (roadmap R18). It runs the base
85
+ # code, and reads the head as git data.
84
86
  - name: Gate check
85
87
  id: check
86
88
  env:
@@ -88,6 +90,7 @@ jobs:
88
90
  BASE_REF: ${{ github.event.pull_request.base.ref }}
89
91
  HEAD_SHA: ${{ github.event.pull_request.head.sha }}
90
92
  GH_TOKEN: ${{ github.token }}
93
+ ATLAS_PRIVATE_TERMS: ${{ secrets.ATLAS_PRIVATE_TERMS }}
91
94
  run: |
92
95
  set -euo pipefail
93
96
  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"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.4.24",
3
+ "version": "0.4.26",
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",
package/shape/check.mjs CHANGED
@@ -38,7 +38,7 @@ export const sha256 = (text) => createHash('sha256').update(String(text ?? ''),
38
38
  // Whether a copy is current or behind is not decided here. CI has no package
39
39
  // and no network. The session verb decides it, from the release list that
40
40
  // ships beside this file in the package, shape/releases.json.
41
- export const CHECK_VERSION = 7;
41
+ export const CHECK_VERSION = 8;
42
42
 
43
43
  // ---------------------------------------------------------------------------
44
44
  // The spec. One object per version. The check reads a version it has here and
@@ -1232,6 +1232,22 @@ function workflowStructure(document) {
1232
1232
  return out;
1233
1233
  }
1234
1234
 
1235
+ // The shape check takes no secret. The rule reads the job that runs the check
1236
+ // and the workflow-wide env, which every job gets. Another job of the same file
1237
+ // may give one step a secret: the gate step gets the private terms (roadmap
1238
+ // R18), and it runs the base code too. When the file cannot be read, or no job
1239
+ // runs the check, the rule reads the whole text.
1240
+ function readsSecret(text) {
1241
+ const READS = /\bsecrets\./;
1242
+ let document = null;
1243
+ try { document = parseYaml(text); } catch { return READS.test(text); }
1244
+ const jobs = document?.jobs && typeof document.jobs === 'object' ? Object.values(document.jobs) : [];
1245
+ const job = jobs.find((candidate) => (Array.isArray(candidate?.steps) ? candidate.steps : [])
1246
+ .some((step) => typeof step?.run === 'string' && step.run.includes(GATE_RUNNER.runs)));
1247
+ if (!job) return READS.test(text);
1248
+ return READS.test(JSON.stringify(job)) || READS.test(JSON.stringify(document.env ?? null));
1249
+ }
1250
+
1235
1251
  function checkWorkflow(context, part) {
1236
1252
  const text = readText(context.root, part.path);
1237
1253
  if (text === null || !text.trim()) return result(part, 'empty', [`${part.path} is missing or holds nothing`]);
@@ -1254,7 +1270,7 @@ function checkWorkflow(context, part) {
1254
1270
  // a block map and a flow map for both permissions and steps, so read both
1255
1271
  // spellings or a workflow can widen itself past the check (review R-3).
1256
1272
  for (const grant of writeGrants(text)) findings.push(`the workflow grants ${grant}; this check needs read access only`);
1257
- if (/\bsecrets\./.test(text)) findings.push('the workflow reads a repository secret; this check takes none');
1273
+ if (readsSecret(text)) findings.push('the workflow reads a repository secret; this check takes none');
1258
1274
  const unpinned = [...text.matchAll(/uses[ \t]*:[ \t]*["']?([^\s,}\]"']+)/g)]
1259
1275
  .map(([, value]) => value)
1260
1276
  .filter((value) => !value.startsWith('./'))
@@ -40,6 +40,12 @@
40
40
  {
41
41
  "check_version": 7,
42
42
  "sha256": "b29fdb036170419b9f0642ef0ca7da45a80ff53492421eb57beaf157df13d057",
43
+ "first_package": "0.4.17",
44
+ "workflow_sha256": "fcddebef030e6564f97e1a5fb81c8ad1b3084ebef9622167c6565698312dcdd7"
45
+ },
46
+ {
47
+ "check_version": 8,
48
+ "sha256": "8cf521f6e8f89ae65a257c521c583281e0c3c27ef968b4bd96eb336a8d095795",
43
49
  "workflow_sha256": "fcddebef030e6564f97e1a5fb81c8ad1b3084ebef9622167c6565698312dcdd7"
44
50
  }
45
51
  ]