@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.
- package/.claude-plugin/plugin.json +1 -1
- package/door/cli.mjs +6 -1
- package/door/lib/fails-before.mjs +4 -2
- package/door/lib/gate-check.mjs +2 -2
- package/door/lib/gate.mjs +41 -12
- package/door/lib/owner-only.mjs +33 -1
- package/package.json +1 -1
- package/skills/lead/SKILL.md +5 -0
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
|
-
|
|
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 };
|
package/door/lib/gate-check.mjs
CHANGED
|
@@ -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(
|
|
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
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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,
|
|
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
|
-
|
|
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}.`);
|
package/door/lib/owner-only.mjs
CHANGED
|
@@ -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
|
|
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
package/skills/lead/SKILL.md
CHANGED
|
@@ -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
|