@arjunkhera/atlas 0.4.17 → 0.4.19

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.17",
3
+ "version": "0.4.19",
4
4
  "description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, for any repository.",
5
5
  "author": {
6
6
  "name": "Arjun Khera"
package/door/cli.mjs CHANGED
@@ -38,7 +38,7 @@ import { changedCommand as testsChanged } from './lib/changed-tests.mjs';
38
38
  import { beforeCommand as testsBefore } from './lib/fails-before.mjs';
39
39
  import { afterCommand as testsAfter } from './lib/passes-after.mjs';
40
40
  import { upkeepCommand } from './lib/upkeep.mjs';
41
- import { gateCommand } from './lib/gate.mjs';
41
+ import { gateCommand, mergeList } from './lib/gate.mjs';
42
42
  import { judgeCommand } from './lib/judge.mjs';
43
43
 
44
44
  export const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
@@ -290,6 +290,8 @@ function statusCommand(chosen) {
290
290
  line(` ${row.why}`);
291
291
  if (row.copy) line(` copy sha256 ${row.copy.sha256.slice(0, 12)}… check version ${row.copy.check_version ?? '?'}`);
292
292
  if (row.packaged) line(` package sha256 ${row.packaged.sha256.slice(0, 12)}… check version ${row.packaged.check_version}${row.packaged.released ? '' : ' (not released)'}`);
293
+ const list = mergeList(row.root);
294
+ if (list) line(` ${list.line}`);
293
295
  line('');
294
296
  }
295
297
  line(`Decided by the release list in Atlas ${ATLAS_VERSION}, never by git history.`);
@@ -461,6 +463,9 @@ function checkCommand(chosen) {
461
463
  const report = scan({ root, repoId: chosen.repo ?? null });
462
464
  const decision = verdict(report, chosen['fail-on'] ?? 'error');
463
465
  process.stdout.write(toText(report));
466
+ // A report, never a reason for red: the list is an owner-only key.
467
+ const list = mergeList(root);
468
+ if (list && list.state !== 'listed') line(list.line);
464
469
  line(`${decision.green ? 'GREEN' : 'RED'}: ${decision.why}`);
465
470
  return decision.green ? 0 : 1;
466
471
  }
@@ -292,7 +292,9 @@ export function freshTree(top, tree, sha) {
292
292
  // commit has a fixed author, date and message, so it holds no history and names neither real commit.
293
293
  export function gitTree(tree) {
294
294
  const env = { PATH: process.env.PATH ?? '/usr/bin:/bin', HOME: tree, GIT_CONFIG_NOSYSTEM: '1', GIT_AUTHOR_NAME: 'atlas', GIT_AUTHOR_EMAIL: 'atlas@invalid', GIT_COMMITTER_NAME: 'atlas', GIT_COMMITTER_EMAIL: 'atlas@invalid', GIT_AUTHOR_DATE: '2000-01-01T00:00:00Z', GIT_COMMITTER_DATE: '2000-01-01T00:00:00Z' };
295
- for (const args of [['init', '-q', '-b', 'main'], ['add', '-A'], ['commit', '-q', '--no-verify', '--no-gpg-sign', '-m', 'tree']]) {
295
+ // No auto gc or background maintenance: it can still write in .git while
296
+ // the run removes the tree (ENOTEMPTY on atlas#223).
297
+ for (const args of [['init', '-q', '-b', 'main'], ['config', 'gc.auto', '0'], ['config', 'maintenance.auto', 'false'], ['add', '-A'], ['commit', '-q', '--no-verify', '--no-gpg-sign', '-m', 'tree']]) {
296
298
  const r = spawnSync('git', args, { cwd: tree, env, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
297
299
  if (r.status !== 0) throw new Error(`git ${args[0]} in the run tree failed: ${String(r.stderr).trim().split('\n')[0]}`);
298
300
  }
@@ -322,7 +324,7 @@ export function runSide({ top, changed, plans, side, work = join(tmpdir(), 'atla
322
324
  }
323
325
  }
324
326
  gitTree(tree);
325
- for (const dir of ['home', 'tmp', 'report']) { rmSync(join(work, dir), { recursive: true, force: true }); mkdirSync(join(work, dir), { recursive: true }); }
327
+ for (const dir of ['home', 'tmp', 'report']) { rmSync(join(work, dir), { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); mkdirSync(join(work, dir), { recursive: true }); }
326
328
  const command = commandOf(p.runner, p.path, report);
327
329
  if (python && p.runner === 'pytest' && spawnSync('python3', ['-B', '-c', 'import pytest'], { cwd: tree, env }).status !== 0) {
328
330
  out.files[p.path] = { command, cases: null, why: 'pytest is not installed', none: true };
@@ -26,7 +26,7 @@
26
26
  import { spawnSync } from 'node:child_process';
27
27
  import { readFileSync, appendFileSync } from 'node:fs';
28
28
  import { resolve } from 'node:path';
29
- import { classify, changedPaths, compile, matcher } from './owner-only.mjs';
29
+ import { classify, changedPaths, compile, matcher, fakeSecret } from './owner-only.mjs';
30
30
  import { listChanged } from './changed-tests.mjs';
31
31
  import { parseYaml, SECRET_PATTERNS } from '../../shape/check.mjs';
32
32
  import { brakeState, undoOf, tripText } from './brake.mjs';
@@ -101,7 +101,7 @@ export function secretRows(root, base, head, changes) {
101
101
  for (const added of addedLines(root, base, head, change.path)) {
102
102
  const found = patterns.find((pattern) => {
103
103
  const hit = pattern.regex.exec(added.text);
104
- return hit && !(pattern.id === 'url-credential' && EXAMPLE_HOST.test(hit[0]));
104
+ return hit && !fakeSecret(pattern.id, hit[0]) && !(pattern.id === 'url-credential' && EXAMPLE_HOST.test(hit[0]));
105
105
  });
106
106
  if (found) rows.push({ kind: 'red', class: 'secret', path: change.path, line: added.line, cause: `secret pattern ${found.id}` });
107
107
  }
package/door/lib/gate.mjs CHANGED
@@ -15,7 +15,7 @@
15
15
  // The plan is built by pure functions, so a test reads it without GitHub. The
16
16
  // gh calls sit in one place, behind `gh`, which a test replaces.
17
17
  import { execFileSync } from 'node:child_process';
18
- import { readFileSync, existsSync, mkdirSync, writeFileSync, rmSync, renameSync } from 'node:fs';
18
+ import { readFileSync, existsSync, mkdirSync, writeFileSync, rmSync, renameSync, readdirSync } from 'node:fs';
19
19
  import { homedir } from 'node:os';
20
20
  import { dirname, join } from 'node:path';
21
21
  import { childEnvironment } from '../../work/lib/child-env.mjs';
@@ -38,16 +38,40 @@ export function gateFacts(root) {
38
38
  return factsOf(readFileSync(path, 'utf8'));
39
39
  }
40
40
 
41
+ // A repo with no list cannot merge through the door: ready reads the list,
42
+ // and the door never makes one up (item 3d675dc7).
43
+ export const NO_LIST = 'atlas.yaml lists no required_checks.';
44
+ export const LIST_FIX = 'Fix: add required_checks to atlas.yaml, with the names of the CI jobs that run on every pull request. The key is owner-only, so the owner merges that pull request by hand.';
45
+
41
46
  function factsOf(text) {
42
47
  const facts = parseYaml(text) ?? {};
43
48
  const checks = facts.required_checks;
44
49
  if (!Array.isArray(checks) || checks.length === 0 || checks.some((name) => typeof name !== 'string' || !name.trim())) {
45
- throw new GateError('atlas.yaml lists no required_checks. Nothing was changed.');
50
+ throw Object.assign(new GateError(`${NO_LIST} Nothing was changed.`), { noList: true });
46
51
  }
47
52
  if (facts.up_to_date !== undefined && typeof facts.up_to_date !== 'boolean') throw new GateError('atlas.yaml up_to_date must be true or false. Nothing was changed.');
48
53
  return { checks: [...checks], upToDate: facts.up_to_date === true };
49
54
  }
50
55
 
56
+ // Can this repo merge through the door? The answer does not read the repo
57
+ // kind: each kind merges on the same list. `atlas status` and `atlas check`
58
+ // print it.
59
+ // listed atlas.yaml names the checks
60
+ // missing CI runs, but atlas.yaml names no checks
61
+ // no-ci no workflow runs, so no list can exist yet
62
+ export function mergeList(root) {
63
+ const path = join(root, 'atlas.yaml');
64
+ if (!existsSync(path)) return null;
65
+ let checks = null;
66
+ try { checks = factsOf(readFileSync(path, 'utf8')).checks; } catch (error) { if (!error.noList) return { state: 'missing', checks: [], line: `merge door: atlas.yaml cannot be read (${error.message}). ${LIST_FIX}` }; }
67
+ if (checks) return { state: 'listed', checks, line: `merge door: ${checks.length} required check(s) in atlas.yaml.` };
68
+ const flows = join(root, '.github', 'workflows');
69
+ const ci = existsSync(flows) && readdirSync(flows).some((name) => /\.ya?ml$/.test(name));
70
+ return ci
71
+ ? { state: 'missing', checks: [], line: `merge door: ${NO_LIST} ${LIST_FIX}` }
72
+ : { state: 'no-ci', checks: [], line: 'merge door: no list, because no CI workflow runs here. Merges stay with the owner.' };
73
+ }
74
+
51
75
  // The two ruleset bodies, as the GitHub rulesets API takes them.
52
76
  export function rulesets({ checks, upToDate }, enforcement = 'active') {
53
77
  return [
@@ -237,14 +261,12 @@ export function refusal(run, { name, pr, sha, baseRef, homes, sides = () => [] }
237
261
  // the side branch's file, so it does not count.
238
262
  if (pulls.some((each) => each.base?.ref !== baseRef)) return `also of a pull request into ${pulls.find((each) => each.base?.ref !== baseRef).base?.ref ?? 'another branch'}`;
239
263
  // GitHub builds that list when it is read, from the open pull requests of
240
- // the head branch. Once a side pull request closes, its gate run names only
241
- // this one, and its title can be set to match. So the gate also refuses a
242
- // head branch that ever had a pull request into another branch.
243
- if (name === GATE_CHECK) {
244
- const side = sides();
245
- if (side === null) return 'no head branch to check for side pull requests';
246
- if (side.length) return `the head branch also had #${side[0].number} into ${side[0].base?.ref ?? 'another branch'}`;
247
- }
264
+ // the head branch. Once a side pull request closes, its run names only this
265
+ // one. So no run counts when the head branch ever had a pull request into
266
+ // another branch (atlas#212 for the gate; item 043f8c00 for every check).
267
+ const side = sides();
268
+ if (side === null) return 'no head branch to check for side pull requests';
269
+ if (side.length) return `the head branch also had #${side[0].number} into ${side[0].base?.ref ?? 'another branch'}`;
248
270
  return null;
249
271
  }
250
272
 
@@ -268,7 +290,7 @@ export function jobRefusal(job, check, run) {
268
290
  function pinnedRuns(gh, repo, { pr, sha, baseRef, head, checks }) {
269
291
  const homes = homesAt(gh, repo, baseRef, checks);
270
292
  // Every pull request, open or closed, from the head branch into another
271
- // branch. Read once, and only when a gate run needs it.
293
+ // branch. Read once, when the first run needs it.
272
294
  let sideList;
273
295
  const sides = () => {
274
296
  if (sideList !== undefined) return sideList;
@@ -319,7 +341,14 @@ export function readyCommand({ repo, pr, answer }, line, gh = ghApi) {
319
341
  if (answer && typeof pull?.head?.sha === 'string') answer.sha = pull.head.sha;
320
342
  if (pull?.state !== 'open') { line(`atlas gate ready: pull request #${pr} is ${pull?.merged ? 'merged' : pull?.state ?? 'not found'}. NOT READY.`); return 1; }
321
343
  const sha = pull.head.sha;
322
- const checks = checksAt(gh, repo, pull.base.ref);
344
+ let checks;
345
+ try { checks = checksAt(gh, repo, pull.base.ref); } catch (error) {
346
+ if (!error.noList) throw error;
347
+ line(`atlas gate ready: #${pr} at ${sha.slice(0, 7)}: atlas.yaml lists no required_checks on ${pull.base.ref}.`);
348
+ line(` ${LIST_FIX}`);
349
+ line('NOT READY: do not merge. Until the list exists, merges in this repo stay with the owner.');
350
+ return 1;
351
+ }
323
352
  const { kept, refused } = pinnedRuns(gh, repo, { pr, sha, baseRef: pull.base.ref, head: pull.head, checks });
324
353
  const rows = checkStates(checks, kept);
325
354
  line(`atlas gate ready: #${pr} at ${sha.slice(0, 7)}, ${checks.length} required check(s) from atlas.yaml on ${pull.base.ref}.`);
@@ -498,6 +498,38 @@ const newest = (paths) => paths.reduce((best, path) => (best === null || newer(p
498
498
 
499
499
  // `rules` names the commit whose list holds; the default is the base. The
500
500
  // gate replay passes today's main branch, to ask what today's list would do.
501
+ // Item b6e264a9, from the 30-day replay: the loose pattern "named-value"
502
+ // (token = ..., secret: ...) matched test code in most of its hits, such as
503
+ // a fake environment value. It does not count in a test file or under
504
+ // archive/. The patterns of a known shape still count there.
505
+ export const LOOSE_PATTERN = 'named-value';
506
+ // A spec/ folder is not on the list: a schema repo keeps product text there.
507
+ export const TEST_PATH = /(^|\/)(test|tests|__tests__)\/|\.(test|spec)\.[^/]+$|(^|\/)test_[^/]*\.py$|_test\.(py|go)$|^archive\//;
508
+ // A value that says it is fake, such as "sk-fake-model-key-...", is no key.
509
+ export const FAKE_VALUE = /fake|dummy|placeholder|example/i;
510
+ // The part of a hit that holds the value: the user and password of a URL
511
+ // credential, the text after ":" or "=" of a named value, else the whole
512
+ // token. The word test reads only that part, so a host such as
513
+ // db.examplecorp.io or a word after the value does not hide a real secret
514
+ // (verifier, atlas#221).
515
+ export function secretValue(id, text) {
516
+ if (id === 'url-credential') return text.slice(text.indexOf('://') + 3, text.lastIndexOf('@'));
517
+ if (id === LOOSE_PATTERN) return (/[:=]\s*(.*)$/s.exec(text)?.[1] ?? text).replace(/["',;})\]].*$/s, '');
518
+ return text;
519
+ }
520
+ export const fakeSecret = (id, text) => FAKE_VALUE.test(secretValue(id, text));
521
+ // The first secret pattern that one added line of this path shows, or null.
522
+ export function secretIn(patterns, path, lines) {
523
+ for (const pattern of patterns) {
524
+ if (pattern.id === LOOSE_PATTERN && TEST_PATH.test(path)) continue;
525
+ for (const line of lines) {
526
+ const hit = pattern.regex.exec(line);
527
+ if (hit && !fakeSecret(pattern.id, hit[0])) return { pattern, hit };
528
+ }
529
+ }
530
+ return null;
531
+ }
532
+
501
533
  export function classify({ root, base, head, rules: rulesRef = null, classes = loadClasses() }) {
502
534
  const { rules, product, tree } = rulesAt({ root, ref: rulesRef ?? base, classes });
503
535
  const baseTree = rulesRef ? treeAt(root, base) : tree;
@@ -544,7 +576,7 @@ export function classify({ root, base, head, rules: rulesRef = null, classes = l
544
576
  if (kitRelease && sign.version_key) continue;
545
577
  if (sign.type === 'path' || sign.type === 'any_change') hits.push([sign.class, cause]);
546
578
  else if (sign.type === 'secret') {
547
- const found = secrets.find((pattern) => changesOf().added.some((line) => pattern.regex.test(line)));
579
+ const found = secretIn(secrets, path, changesOf().added)?.pattern;
548
580
  if (found) hits.push([sign.class, `${cause} (${found.id})`]);
549
581
  } else if (sign.type === 'added_line') {
550
582
  const regex = compile(sign.pattern);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.4.17",
3
+ "version": "0.4.19",
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",
@@ -117,6 +117,11 @@ set. A test keeps the two lists equal.
117
117
  A person merges every file that `atlas tooling` writes.
118
118
  A person merges every file that `atlas tests write` writes.
119
119
 
120
+ When a repo joins Atlas, its first pull request lists `required_checks` in
121
+ `atlas.yaml`: the CI jobs that run on every pull request. The owner merges
122
+ that pull request by hand. With no list, the merge check says NOT READY,
123
+ and merges stay with the owner.
124
+
120
125
  ## 7. The first reply of a cloud session
121
126
 
122
127
  A cloud session can start from an old setup and run an old Atlas. So in a