@arjunkhera/atlas 0.4.15 → 0.4.16
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 +2 -1
- package/door/lib/pull-request.mjs +18 -7
- package/door/lib/status.mjs +24 -9
- package/door/lib/tooling.mjs +28 -6
- package/package.json +1 -1
- package/shape/check.mjs +419 -135
- package/shape/releases.json +5 -0
package/door/cli.mjs
CHANGED
|
@@ -267,7 +267,8 @@ function toolingCommand(chosen) {
|
|
|
267
267
|
if (what !== 'write') throw new Error(`there is no tooling verb "${what}". Use write or pr.`);
|
|
268
268
|
const result = writeTooling(root, PACKAGE_ROOT, { dryRun: Boolean(chosen['dry-run']), replaceUnknown, workflowOnly });
|
|
269
269
|
line(`Atlas ${result.version}, check version ${result.check_version}${result.status ? `; the copy was ${result.status}` : ''}`);
|
|
270
|
-
|
|
270
|
+
// A removed file has no bytes, so its row has no sha256 (roadmap R7).
|
|
271
|
+
for (const row of result.rows) line(` ${row.state.padEnd(10)} ${row.path}${row.sha256 ? ` sha256 ${row.sha256.slice(0, 12)}…` : ''}`);
|
|
271
272
|
line('');
|
|
272
273
|
line(result.changed
|
|
273
274
|
? 'The files come from the package. No hand step, and no edit by hand afterwards.'
|
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
// git and GitHub calls sit below it, in one place.
|
|
12
12
|
import { execFileSync } from 'node:child_process';
|
|
13
13
|
import { childEnvironment } from '../../work/lib/child-env.mjs';
|
|
14
|
-
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
15
15
|
import { dirname, join, posix } from 'node:path';
|
|
16
|
-
import { CHECK_PATH, WORKFLOW_PATH } from '../../shape/check.mjs';
|
|
16
|
+
import { CHECK_PATH, WORKFLOW_PATH, OLD_WORKFLOW_PATH } from '../../shape/check.mjs';
|
|
17
17
|
|
|
18
18
|
// Every change names where it came from, and the set is closed.
|
|
19
19
|
export const PROVENANCE_SOURCES = Object.freeze(['owner words', 'repo read', 'task input']);
|
|
@@ -24,6 +24,9 @@ export const TOOLING_LABEL = 'atlas-helper';
|
|
|
24
24
|
// The only files the tooling writer may write: the check, its workflow, and
|
|
25
25
|
// the pull request template when the repo has none.
|
|
26
26
|
export const TOOLING_PATHS = Object.freeze([CHECK_PATH, WORKFLOW_PATH, '.github/workflows/atlas-jobs.yml', '.github/pull_request_template.md']);
|
|
27
|
+
// The only files the tooling writer may remove: the workflow of check versions
|
|
28
|
+
// 1 to 5, which the gate workflow replaces (roadmap R7).
|
|
29
|
+
export const TOOLING_REMOVALS = Object.freeze([OLD_WORKFLOW_PATH]);
|
|
27
30
|
|
|
28
31
|
export class ChangeError extends Error {}
|
|
29
32
|
|
|
@@ -38,8 +41,12 @@ export function checkChanges(changes) {
|
|
|
38
41
|
for (const change of changes) {
|
|
39
42
|
const path = posix.normalize(String(change?.path ?? '')).replace(/^\.\//, '');
|
|
40
43
|
if (!path || path === '.') throw new ChangeError('a change has no path');
|
|
41
|
-
if (
|
|
42
|
-
|
|
44
|
+
if (change.remove === true) {
|
|
45
|
+
if (!TOOLING_REMOVALS.includes(path)) throw new ChangeError(`the tooling writer removes only ${TOOLING_REMOVALS.join(' and ')}; it refuses to remove "${path}"`);
|
|
46
|
+
} else {
|
|
47
|
+
if (!TOOLING_PATHS.includes(path)) throw new ChangeError(`the tooling writer writes only ${TOOLING_PATHS.join(' and ')}; it refuses "${path}"`);
|
|
48
|
+
if (typeof change.contents !== 'string') throw new ChangeError(`the change to "${path}" carries no contents`);
|
|
49
|
+
}
|
|
43
50
|
const source = change?.provenance?.source;
|
|
44
51
|
if (!PROVENANCE_SOURCES.includes(source)) throw new ChangeError(`the change to "${path}" names no source; every change names one of ${PROVENANCE_SOURCES.join(', ')}`);
|
|
45
52
|
if (!String(change?.provenance?.detail ?? '').trim()) throw new ChangeError(`the change to "${path}" names the source "${source}" and says nothing about it`);
|
|
@@ -82,8 +89,11 @@ export function applyChanges(root, changes) {
|
|
|
82
89
|
const written = [];
|
|
83
90
|
for (const change of changes) {
|
|
84
91
|
const path = join(root, change.path);
|
|
85
|
-
|
|
86
|
-
|
|
92
|
+
if (change.remove === true) rmSync(path, { force: true });
|
|
93
|
+
else {
|
|
94
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
95
|
+
writeFileSync(path, change.contents);
|
|
96
|
+
}
|
|
87
97
|
written.push(change.path);
|
|
88
98
|
}
|
|
89
99
|
return { written };
|
|
@@ -105,7 +115,8 @@ export function open({ root, changes, why, branch, title, base = null, label = T
|
|
|
105
115
|
if (dryRun) return { repo, branch, title, body, base: onto, written, opened: false };
|
|
106
116
|
|
|
107
117
|
exec('git', ['checkout', '-b', branch], root);
|
|
108
|
-
|
|
118
|
+
// `git add -A` stages a removed file as well as a written one.
|
|
119
|
+
exec('git', ['add', '-A', '--', ...written], root);
|
|
109
120
|
exec('git', ['commit', '-m', title, '-m', body], root);
|
|
110
121
|
exec('git', ['push', '-u', 'origin', branch], root);
|
|
111
122
|
// A label that does not exist yet makes `gh pr create` fail, so it is made
|
package/door/lib/status.mjs
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// package's list. This runs in a session, with the package on disk.
|
|
16
16
|
import { readFileSync, existsSync, readdirSync, lstatSync } from 'node:fs';
|
|
17
17
|
import { join, relative, sep } from 'node:path';
|
|
18
|
-
import { CHECK_PATH, WORKFLOW_PATH, WORKFLOW_TEMPLATE, checkStamps, sha256 } from '../../shape/check.mjs';
|
|
18
|
+
import { CHECK_PATH, WORKFLOW_PATH, OLD_WORKFLOW_PATH, WORKFLOW_TEMPLATE, SHAPE_JOB_TEXT, shapeJobOf, checkStamps, sha256 } from '../../shape/check.mjs';
|
|
19
19
|
import { packagedCheck, placeOf, packagePackageVersion } from './releases.mjs';
|
|
20
20
|
import { currentKitHash, KIT_RELEASES_FILE, KIT_TARGET, MANIFEST } from './tests.mjs';
|
|
21
21
|
import { kitHashOf } from '../../tests/link-check.mjs';
|
|
@@ -57,8 +57,9 @@ export function statusMany(roots, packageRoot) {
|
|
|
57
57
|
// R19, slice 0 of the routines design). The check is one copy of four types:
|
|
58
58
|
//
|
|
59
59
|
// check .github/atlas/check.mjs, by the release list of the check
|
|
60
|
-
// workflow .github/workflows/atlas-
|
|
61
|
-
// in each row of that list
|
|
60
|
+
// workflow .github/workflows/atlas-gate.yml, by the workflow fingerprint
|
|
61
|
+
// in each row of that list, or by its shape job; before check
|
|
62
|
+
// version 6, .github/workflows/atlas-check.yml
|
|
62
63
|
// kit each <area>/test/atlas/, by its manifest and the kit list
|
|
63
64
|
// pin an Atlas version written in a workflow, a script or package.json
|
|
64
65
|
//
|
|
@@ -92,23 +93,37 @@ function checkRow(root, packageRoot) {
|
|
|
92
93
|
|
|
93
94
|
// The workflow is written with the check, from the WORKFLOW_TEMPLATE of that
|
|
94
95
|
// check. Each row of the release list keeps the fingerprint of its template.
|
|
96
|
+
// From check version 6 the workflow is the gate workflow, and a repo may add
|
|
97
|
+
// its own jobs to it, as the Atlas repo does: then its shape job must be the
|
|
98
|
+
// one the package writes. A repo that still has only the old atlas-check.yml
|
|
99
|
+
// is behind (roadmap R7 line 5).
|
|
95
100
|
function workflowRow(root, packageRoot) {
|
|
96
101
|
const pkg = packagedCheck(packageRoot);
|
|
97
102
|
const version = checkStamps(pkg.text).check_version;
|
|
98
103
|
const want = `workflow of check version ${version}`;
|
|
99
104
|
const full = join(root, WORKFLOW_PATH);
|
|
100
105
|
if (linkAt(full)) return linked('workflow', WORKFLOW_PATH, want);
|
|
101
|
-
|
|
106
|
+
const known = (text) => pkg.releases.filter((one) => one.workflow_sha256 === sha256(text));
|
|
107
|
+
if (!existsSync(full)) {
|
|
108
|
+
const old = join(root, OLD_WORKFLOW_PATH);
|
|
109
|
+
if (linkAt(old)) return linked('workflow', OLD_WORKFLOW_PATH, want);
|
|
110
|
+
if (!existsSync(old)) return row('workflow', WORKFLOW_PATH, 'missing', null, want, `${WORKFLOW_PATH} does not exist in this repo, so its check never runs in CI. "atlas tooling pr" adds it.`);
|
|
111
|
+
let text;
|
|
112
|
+
try { text = readFileSync(old, 'utf8'); } catch (error) { return broken('workflow', OLD_WORKFLOW_PATH, want, error); }
|
|
113
|
+
const rows = known(text);
|
|
114
|
+
if (rows.length) return row('workflow', OLD_WORKFLOW_PATH, 'behind', `workflow of check version ${rows.at(-1).check_version}`, want, `${OLD_WORKFLOW_PATH} runs the head's copy of the check, so a pull request can change its own judge. "atlas tooling pr" writes ${WORKFLOW_PATH} and removes it.`);
|
|
115
|
+
return row('workflow', OLD_WORKFLOW_PATH, 'unknown', `sha256 ${sha256(text).slice(0, 12)}…`, want, `${OLD_WORKFLOW_PATH} is in no release, and ${WORKFLOW_PATH} does not exist. "atlas tooling pr" shows the difference.`);
|
|
116
|
+
}
|
|
102
117
|
let text;
|
|
103
118
|
try { text = readFileSync(full, 'utf8'); } catch (error) { return broken('workflow', WORKFLOW_PATH, want, error); }
|
|
104
|
-
|
|
105
|
-
if (
|
|
106
|
-
const rows =
|
|
119
|
+
if (text === WORKFLOW_TEMPLATE) return row('workflow', WORKFLOW_PATH, 'current', want, want, 'the workflow is the one this package writes.');
|
|
120
|
+
if (shapeJobOf(text) === SHAPE_JOB_TEXT) return row('workflow', WORKFLOW_PATH, 'current', want, want, 'its shape job is the one this package writes; the file holds other jobs of this repo too.');
|
|
121
|
+
const rows = known(text);
|
|
107
122
|
if (rows.length) {
|
|
108
123
|
const newest = rows.at(-1);
|
|
109
|
-
return row('workflow', WORKFLOW_PATH, 'behind', `workflow of check version ${newest.check_version}`, want, `the workflow was first written by Atlas ${rows[0].first_package}. "atlas tooling pr --workflow" refreshes it.`);
|
|
124
|
+
return row('workflow', WORKFLOW_PATH, 'behind', `workflow of check version ${newest.check_version}`, want, `the workflow was first written by Atlas ${rows[0].first_package ?? `check version ${rows[0].check_version}`}. "atlas tooling pr --workflow" refreshes it.`);
|
|
110
125
|
}
|
|
111
|
-
return row('workflow', WORKFLOW_PATH, 'unknown', `sha256 ${
|
|
126
|
+
return row('workflow', WORKFLOW_PATH, 'unknown', `sha256 ${sha256(text).slice(0, 12)}…`, want, `the workflow is in no release, and its shape job is not the one this package writes. Someone edited it, or it came from an unreleased package. "atlas tooling pr" shows the difference.`);
|
|
112
127
|
}
|
|
113
128
|
|
|
114
129
|
// A walk never follows a link and never enters node_modules or .git. The
|
package/door/lib/tooling.mjs
CHANGED
|
@@ -14,15 +14,27 @@
|
|
|
14
14
|
// is missing or behind, or unknown with --replace-unknown, and only a check
|
|
15
15
|
// that is the newest row of the release list. The Atlas repo itself may take
|
|
16
16
|
// its own unreleased check, because that is how a new check is made.
|
|
17
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
17
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, rmSync } from 'node:fs';
|
|
18
18
|
import { execFileSync } from 'node:child_process';
|
|
19
19
|
import { dirname, join } from 'node:path';
|
|
20
|
-
import { CHECK_PATH, WORKFLOW_PATH, WORKFLOW_TEMPLATE, CHECK_VERSION, sha256, parseYaml } from '../../shape/check.mjs';
|
|
20
|
+
import { CHECK_PATH, WORKFLOW_PATH, OLD_WORKFLOW_PATH, WORKFLOW_TEMPLATE, SHAPE_JOB_TEXT, shapeJobOf, CHECK_VERSION, sha256, parseYaml } from '../../shape/check.mjs';
|
|
21
21
|
import { packagedCheck, packagePackageVersion, PACKAGE_CHECK_SOURCE } from './releases.mjs';
|
|
22
22
|
import { status } from './status.mjs';
|
|
23
23
|
import { PR_TEMPLATE_PATH } from './upkeep.mjs';
|
|
24
24
|
|
|
25
|
-
export { CHECK_PATH, WORKFLOW_PATH, PACKAGE_CHECK_SOURCE, PR_TEMPLATE_PATH };
|
|
25
|
+
export { CHECK_PATH, WORKFLOW_PATH, OLD_WORKFLOW_PATH, PACKAGE_CHECK_SOURCE, PR_TEMPLATE_PATH };
|
|
26
|
+
|
|
27
|
+
// The gate workflow (roadmap R7 line 5). A repo may keep its own jobs in it, as
|
|
28
|
+
// the Atlas repo does. When its shape job is the one the package writes, the
|
|
29
|
+
// writer keeps the file. The workflow of check versions 1 to 5,
|
|
30
|
+
// atlas-check.yml, ran the head's copy of the check; the writer removes it.
|
|
31
|
+
const workflowKept = (root) => {
|
|
32
|
+
const full = join(root, WORKFLOW_PATH);
|
|
33
|
+
if (!existsSync(full)) return false;
|
|
34
|
+
const text = readFileSync(full, 'utf8');
|
|
35
|
+
return text !== WORKFLOW_TEMPLATE && shapeJobOf(text) === SHAPE_JOB_TEXT;
|
|
36
|
+
};
|
|
37
|
+
const oldWorkflow = (root) => existsSync(join(root, OLD_WORKFLOW_PATH));
|
|
26
38
|
|
|
27
39
|
// The watcher of the routines design (roadmap R24). The writer puts it into each
|
|
28
40
|
// repo, with the version of this package as its pin. It refreshes a copy whose pin
|
|
@@ -101,8 +113,8 @@ export function mayWrite(root, packageRoot, { replaceUnknown = false, workflowOn
|
|
|
101
113
|
throw new ToolingRefusal(`this package's ${PACKAGE_CHECK_SOURCE} is ${pkg.row ? 'not the newest release' : 'in no release'} of the check, so it is not written into another repo. Use a released Atlas.`);
|
|
102
114
|
}
|
|
103
115
|
const now = status({ root, packageRoot });
|
|
104
|
-
if (now.state === 'current' && !workflowOnly && !templateMissing(root, packageRoot) && !jobsNeedWrite(root, packageRoot)) {
|
|
105
|
-
throw new ToolingRefusal(`this repo's check is already current. ${readWorkflow(root) === WORKFLOW_TEMPLATE ? 'Both files match the package, so there is nothing to write.' : 'Its workflow file differs from the package; pass --workflow to rewrite only that file.'}`, { current: true });
|
|
116
|
+
if (now.state === 'current' && !workflowOnly && !templateMissing(root, packageRoot) && !jobsNeedWrite(root, packageRoot) && !oldWorkflow(root)) {
|
|
117
|
+
throw new ToolingRefusal(`this repo's check is already current. ${readWorkflow(root) === WORKFLOW_TEMPLATE || workflowKept(root) ? 'Both files match the package, so there is nothing to write.' : 'Its workflow file differs from the package; pass --workflow to rewrite only that file.'}`, { current: true });
|
|
106
118
|
}
|
|
107
119
|
if (now.state === 'unknown' && !replaceUnknown && !own) {
|
|
108
120
|
throw new ToolingRefusal(`${now.why}`);
|
|
@@ -120,6 +132,7 @@ export function write(root, packageRoot, { dryRun = false, replaceUnknown = fals
|
|
|
120
132
|
for (const [path, contents] of Object.entries(wanted)) {
|
|
121
133
|
const full = join(root, path);
|
|
122
134
|
const was = existsSync(full) ? readFileSync(full, 'utf8') : null;
|
|
135
|
+
if (path === WORKFLOW_PATH && workflowKept(root)) { rows.push({ path, state: 'kept', sha256: sha256(was), was: sha256(was) }); continue; }
|
|
123
136
|
const state = was === null ? 'written' : was === contents ? 'unchanged' : 'refreshed';
|
|
124
137
|
if (state !== 'unchanged' && !dryRun) {
|
|
125
138
|
mkdirSync(dirname(full), { recursive: true });
|
|
@@ -127,6 +140,11 @@ export function write(root, packageRoot, { dryRun = false, replaceUnknown = fals
|
|
|
127
140
|
}
|
|
128
141
|
rows.push({ path, state, sha256: sha256(contents), was: was === null ? null : sha256(was) });
|
|
129
142
|
}
|
|
143
|
+
if (oldWorkflow(root)) {
|
|
144
|
+
const was = readFileSync(join(root, OLD_WORKFLOW_PATH), 'utf8');
|
|
145
|
+
if (!dryRun) rmSync(join(root, OLD_WORKFLOW_PATH));
|
|
146
|
+
rows.push({ path: OLD_WORKFLOW_PATH, state: 'removed', sha256: null, was: sha256(was) });
|
|
147
|
+
}
|
|
130
148
|
const jobs = workflowOnly ? null : jobsState(root, packageRoot);
|
|
131
149
|
if (jobs !== null) {
|
|
132
150
|
const contents = packagedJobsWorkflow(packageRoot);
|
|
@@ -165,7 +183,11 @@ export function changes(root, packageRoot, { replaceUnknown = false, workflowOnl
|
|
|
165
183
|
const wanted = { ...packagedFiles(packageRoot), [JOBS_PATH]: packagedJobsWorkflow(packageRoot), [PR_TEMPLATE_PATH]: packagedTemplate(packageRoot) };
|
|
166
184
|
return rows
|
|
167
185
|
.filter((row) => row.state !== 'unchanged' && row.state !== 'kept')
|
|
168
|
-
.map((row) => ({
|
|
186
|
+
.map((row) => (row.state === 'removed' ? {
|
|
187
|
+
path: row.path,
|
|
188
|
+
remove: true,
|
|
189
|
+
provenance: { source: 'repo read', detail: `the workflow of check versions 1 to 5, which runs the head's copy of the check; ${WORKFLOW_PATH} replaces it from check version ${CHECK_VERSION}` },
|
|
190
|
+
} : {
|
|
169
191
|
path: row.path,
|
|
170
192
|
contents: wanted[row.path],
|
|
171
193
|
provenance: {
|
package/package.json
CHANGED
package/shape/check.mjs
CHANGED
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
// reader, the secret patterns and the report. It imports Node builtins only.
|
|
8
8
|
//
|
|
9
9
|
// What it reads: the checkout it is pointed at, and nothing else. It opens no
|
|
10
|
-
// network. It holds no token. It writes nothing back into the repo.
|
|
10
|
+
// network. It holds no token. It writes nothing back into the repo. In gate
|
|
11
|
+
// mode only, it reads git objects with the git binary, and writes two trees
|
|
12
|
+
// into a temp folder outside the checkout.
|
|
11
13
|
//
|
|
12
14
|
// What it reports: one state per part of the seven files. Five states: filled,
|
|
13
15
|
// empty, stale, not applicable, no extractor. It leads with the count of
|
|
@@ -18,10 +20,12 @@
|
|
|
18
20
|
//
|
|
19
21
|
// Authority: the Atlas M2 study, design page D1 with its
|
|
20
22
|
// D7 amendment; the Atlas architecture review, build order B1.
|
|
21
|
-
import { readFileSync, readdirSync, statSync, existsSync, writeFileSync, appendFileSync } from 'node:fs';
|
|
22
|
-
import { join, resolve, relative, posix } from 'node:path';
|
|
23
|
+
import { readFileSync, readdirSync, statSync, existsSync, writeFileSync, appendFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
|
24
|
+
import { join, resolve, relative, posix, dirname } from 'node:path';
|
|
23
25
|
import { fileURLToPath } from 'node:url';
|
|
24
26
|
import { createHash } from 'node:crypto';
|
|
27
|
+
import { spawnSync } from 'node:child_process';
|
|
28
|
+
import { tmpdir } from 'node:os';
|
|
25
29
|
|
|
26
30
|
// node:crypto is a builtin, so the check still imports nothing but builtins.
|
|
27
31
|
export const sha256 = (text) => createHash('sha256').update(String(text ?? ''), 'utf8').digest('hex');
|
|
@@ -34,7 +38,7 @@ export const sha256 = (text) => createHash('sha256').update(String(text ?? ''),
|
|
|
34
38
|
// Whether a copy is current or behind is not decided here. CI has no package
|
|
35
39
|
// and no network. The session verb decides it, from the release list that
|
|
36
40
|
// ships beside this file in the package, shape/releases.json.
|
|
37
|
-
export const CHECK_VERSION =
|
|
41
|
+
export const CHECK_VERSION = 6;
|
|
38
42
|
|
|
39
43
|
// ---------------------------------------------------------------------------
|
|
40
44
|
// The spec. One object per version. The check reads a version it has here and
|
|
@@ -417,8 +421,8 @@ export const SPEC_1 = {
|
|
|
417
421
|
"parts": [
|
|
418
422
|
{
|
|
419
423
|
"id": "atlas-check",
|
|
420
|
-
"path": ".github/workflows/atlas-
|
|
421
|
-
"holds": "The
|
|
424
|
+
"path": ".github/workflows/atlas-gate.yml",
|
|
425
|
+
"holds": "The gate workflow. Its shape job runs the base copy of .github/atlas/check.mjs over the merge base and the head of each pull request.",
|
|
422
426
|
"kind": {
|
|
423
427
|
"service": "required",
|
|
424
428
|
"library": "required",
|
|
@@ -640,64 +644,135 @@ export function canaryMisses() {
|
|
|
640
644
|
}
|
|
641
645
|
|
|
642
646
|
// ---------------------------------------------------------------------------
|
|
643
|
-
// The workflow that runs this file
|
|
644
|
-
//
|
|
645
|
-
//
|
|
646
|
-
//
|
|
647
|
+
// The workflow that runs this file: the gate (roadmap R7, design
|
|
648
|
+
// docs/design-docs/shape-check-from-base/ in the Atlas repo). The tooling
|
|
649
|
+
// writer puts it into each repo from here. It runs on pull_request_target, so
|
|
650
|
+
// GitHub takes it from the base branch, and the check that judges a pull
|
|
651
|
+
// request is the base copy. It reads the head as git objects only, it holds a
|
|
652
|
+
// read token, and it pins its one action by commit SHA (design review finding
|
|
653
|
+
// R11).
|
|
654
|
+
//
|
|
655
|
+
// Check version 6 moved the check here from the old atlas-check.yml, which ran
|
|
656
|
+
// on pull_request with the head's copy of this file. A pull request could edit
|
|
657
|
+
// the check to print GREEN, and the check only warned that a person merges it.
|
|
647
658
|
// ---------------------------------------------------------------------------
|
|
648
659
|
|
|
649
|
-
export const WORKFLOW_PATH = '.github/workflows/atlas-
|
|
660
|
+
export const WORKFLOW_PATH = '.github/workflows/atlas-gate.yml';
|
|
661
|
+
// The workflow of check versions 1 to 5. The tooling writer removes it.
|
|
662
|
+
export const OLD_WORKFLOW_PATH = '.github/workflows/atlas-check.yml';
|
|
650
663
|
export const CHECK_PATH = '.github/atlas/check.mjs';
|
|
651
664
|
|
|
652
|
-
// Where
|
|
653
|
-
// whose name starts with a dot, and every other
|
|
654
|
-
// scanner ever reaches into it.
|
|
665
|
+
// Where a run by hand, or an old workflow, puts the base tree. `walk` and
|
|
666
|
+
// `symlinks` skip every entry whose name starts with a dot, and every other
|
|
667
|
+
// read names an exact path, so no scanner ever reaches into it.
|
|
655
668
|
export const BASE_DIR = '.atlas-base';
|
|
656
669
|
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
670
|
+
// The two trees of the gate, under the runner's temp folder, outside the
|
|
671
|
+
// checkout. Each is written from git objects, and loses every link.
|
|
672
|
+
export const GATE_TREES = Object.freeze({ base: 'atlas-base', head: 'atlas-head' });
|
|
673
|
+
|
|
674
|
+
// The job and the check name. The name says the job runs the shape rule
|
|
675
|
+
// only, so no reader takes it for the full gate, which keeps the name `gate`
|
|
676
|
+
// (roadmap R76). A repo may add jobs to this file; the shape job must stay as
|
|
677
|
+
// Atlas writes it, and `atlas status` compares that job.
|
|
678
|
+
export const SHAPE_JOB = 'shape';
|
|
679
|
+
export const SHAPE_CHECK = 'gate · shape';
|
|
680
|
+
|
|
681
|
+
export const WORKFLOW_HEADER = `# The Atlas gate workflow, in this repo's own CI. Its shape job is the repo
|
|
682
|
+
# shape check, judged from the base.
|
|
663
683
|
#
|
|
664
|
-
#
|
|
665
|
-
#
|
|
684
|
+
# It runs on pull_request_target, so GitHub runs this file as the base branch
|
|
685
|
+
# holds it. The job checks out the base and runs the base copy of
|
|
686
|
+
# ${CHECK_PATH}. It fetches the head as git objects only, and runs none of it.
|
|
666
687
|
#
|
|
667
|
-
#
|
|
688
|
+
# The check writes two trees from git objects, the merge base and the head, in
|
|
689
|
+
# the runner's temp folder, and scores them side by side. No attribute of a
|
|
690
|
+
# tree applies, and no link is written. The no-loss rule says red when a part
|
|
691
|
+
# that the base had is taken away. It says owner when only the owner may
|
|
692
|
+
# approve the change, such as a change to the check or to this file.
|
|
668
693
|
#
|
|
669
|
-
# The
|
|
670
|
-
#
|
|
671
|
-
#
|
|
672
|
-
# starts with a dot, so no scanner walks into it. If that checkout fails there
|
|
673
|
-
# is nothing to compare, so the step may fail and the check says so and does not
|
|
674
|
-
# gate.
|
|
694
|
+
# The token can only read. No checkout keeps a credential: the one fetch of
|
|
695
|
+
# the head takes the token for that command alone. The check step holds no
|
|
696
|
+
# token. The job writes two output keys, result and class.
|
|
675
697
|
#
|
|
676
|
-
#
|
|
677
|
-
|
|
698
|
+
# Atlas writes this file and ${CHECK_PATH}; do not edit the shape job by hand.
|
|
699
|
+
# Design: docs/design-docs/shape-check-from-base/ in the Atlas repo.
|
|
700
|
+
name: atlas-gate
|
|
701
|
+
run-name: "gate #\${{ github.event.pull_request.number }} at \${{ github.event.pull_request.head.sha }}"
|
|
678
702
|
on:
|
|
679
|
-
|
|
703
|
+
pull_request_target:
|
|
704
|
+
types: [opened, synchronize, reopened, edited, ready_for_review]
|
|
680
705
|
permissions:
|
|
681
706
|
contents: read
|
|
707
|
+
concurrency:
|
|
708
|
+
group: atlas-gate-\${{ github.event.pull_request.number }}
|
|
709
|
+
cancel-in-progress: true
|
|
682
710
|
jobs:
|
|
683
|
-
|
|
684
|
-
|
|
711
|
+
`;
|
|
712
|
+
|
|
713
|
+
export const SHAPE_JOB_TEXT = ` ${SHAPE_JOB}:
|
|
714
|
+
name: ${SHAPE_CHECK}
|
|
685
715
|
runs-on: ubuntu-latest
|
|
686
|
-
# The check reads two
|
|
687
|
-
#
|
|
688
|
-
|
|
716
|
+
# The check reads two trees and opens no network. It finishes in seconds
|
|
717
|
+
# on a small repo. The bound stops a runner being billed until someone
|
|
718
|
+
# kills it; a run that hits it is red.
|
|
719
|
+
timeout-minutes: 10
|
|
720
|
+
outputs:
|
|
721
|
+
result: \${{ steps.shape.outputs.result }}
|
|
722
|
+
class: \${{ steps.shape.outputs.class }}
|
|
689
723
|
steps:
|
|
690
|
-
# actions/checkout
|
|
691
|
-
- uses: actions/checkout@
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
724
|
+
# actions/checkout v7.0.1
|
|
725
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
|
726
|
+
with:
|
|
727
|
+
ref: \${{ github.sha }}
|
|
728
|
+
fetch-depth: 0
|
|
729
|
+
persist-credentials: false
|
|
730
|
+
# actions/setup-node v7.0.0
|
|
731
|
+
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
|
695
732
|
with:
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
733
|
+
node-version: "22.22.0"
|
|
734
|
+
- name: Fetch the head as objects only
|
|
735
|
+
env:
|
|
736
|
+
PR: \${{ github.event.pull_request.number }}
|
|
737
|
+
HEAD_SHA: \${{ github.event.pull_request.head.sha }}
|
|
738
|
+
TOKEN: \${{ github.token }}
|
|
739
|
+
run: |
|
|
740
|
+
set -euo pipefail
|
|
741
|
+
header="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$TOKEN" | base64 -w0)"
|
|
742
|
+
git -c "http.https://github.com/.extraheader=$header" fetch --no-tags --no-recurse-submodules origin "+refs/pull/$PR/head:refs/atlas-gate/head"
|
|
743
|
+
got="$(git rev-parse --verify refs/atlas-gate/head^{commit})"
|
|
744
|
+
if [ "$got" != "$HEAD_SHA" ]; then
|
|
745
|
+
echo "::error::the fetched head is $got, not the head of the event $HEAD_SHA. Push again."
|
|
746
|
+
exit 1
|
|
747
|
+
fi
|
|
748
|
+
- name: Shape check from the base
|
|
749
|
+
id: shape
|
|
750
|
+
env:
|
|
751
|
+
HEAD_SHA: \${{ github.event.pull_request.head.sha }}
|
|
752
|
+
run: |
|
|
753
|
+
set -euo pipefail
|
|
754
|
+
if [ ! -f ${CHECK_PATH} ]; then
|
|
755
|
+
echo "OWNER: first-run: the base has no ${CHECK_PATH}, so the owner reads the first shape"
|
|
756
|
+
echo "result=owner" >> "$GITHUB_OUTPUT"
|
|
757
|
+
echo "class=first-run" >> "$GITHUB_OUTPUT"
|
|
758
|
+
exit 1
|
|
759
|
+
fi
|
|
760
|
+
node ${CHECK_PATH} --gate --repo "$GITHUB_REPOSITORY" --base-commit "$GITHUB_SHA" --head-commit "$HEAD_SHA"
|
|
699
761
|
`;
|
|
700
762
|
|
|
763
|
+
export const WORKFLOW_TEMPLATE = `${WORKFLOW_HEADER}${SHAPE_JOB_TEXT}`;
|
|
764
|
+
|
|
765
|
+
// The shape job of a workflow text: from its key line to the next job key or
|
|
766
|
+
// the end. `atlas status` compares it with SHAPE_JOB_TEXT.
|
|
767
|
+
export function shapeJobOf(text) {
|
|
768
|
+
const lines = String(text ?? '').replace(/\r\n?/g, '\n').split('\n');
|
|
769
|
+
const start = lines.indexOf(` ${SHAPE_JOB}:`);
|
|
770
|
+
if (start < 0) return null;
|
|
771
|
+
let end = start + 1;
|
|
772
|
+
while (end < lines.length && !/^ {2}\S/.test(lines[end]) && !/^\S/.test(lines[end])) end += 1;
|
|
773
|
+
return `${lines.slice(start, end).join('\n').replace(/\n+$/, '')}\n`;
|
|
774
|
+
}
|
|
775
|
+
|
|
701
776
|
// ---------------------------------------------------------------------------
|
|
702
777
|
// The YAML reader. Atlas ships no dependency, so the check reads the subset of
|
|
703
778
|
// YAML that atlas.yaml, the section files and a SKILL.md front matter block
|
|
@@ -1013,21 +1088,29 @@ function checkPathRules(context, part) {
|
|
|
1013
1088
|
if (!list.length) { bad.push(`${file} has no paths: key`); continue; }
|
|
1014
1089
|
for (const pattern of list) {
|
|
1015
1090
|
const base = String(pattern).split(/[*?[]/)[0].replace(/\/+$/, '');
|
|
1091
|
+
if (base && !insideRepo(context.root, base)) { bad.push(`${file} names a path outside the repo`); continue; }
|
|
1016
1092
|
if (base && !existsSync(join(context.root, base))) bad.push(`${file} names ${pattern}, which does not exist`);
|
|
1017
1093
|
}
|
|
1018
1094
|
}
|
|
1019
1095
|
return bad.length ? result(part, 'stale', bad.slice(0, MAX_LISTED)) : result(part, 'filled', [`${files.length} rule file(s)`]);
|
|
1020
1096
|
}
|
|
1021
1097
|
|
|
1098
|
+
const SKILL_NAME = /^(?!\.\.?$)[A-Za-z0-9._-]{1,100}$/;
|
|
1099
|
+
|
|
1022
1100
|
function checkProcedure(context, part) {
|
|
1023
1101
|
const named = context.facts?.procedures?.[part.procedure];
|
|
1024
1102
|
if (named === undefined) return result(part, 'empty', [`atlas.yaml has no procedures entry for ${part.procedure}`]);
|
|
1025
1103
|
if (named === null || named === '') return result(part, 'empty', [`atlas.yaml sets procedures.${part.procedure} to null: the repo has no skill for it yet`]);
|
|
1104
|
+
// A skill name is one folder name. A slash, a dot pair or a control
|
|
1105
|
+
// character would let atlas.yaml point the check outside the skills folder,
|
|
1106
|
+
// or outside the tree it scores (security review of roadmap R7).
|
|
1107
|
+
if (!SKILL_NAME.test(String(named))) return result(part, 'stale', [`atlas.yaml names a skill for ${part.procedure} that is not one folder name`]);
|
|
1026
1108
|
const skill = posix.join('.claude/skills', String(named), 'SKILL.md');
|
|
1027
|
-
|
|
1109
|
+
const at = { ...part, path: skill };
|
|
1110
|
+
if (!isFile(context.root, skill)) return result(at, 'stale', [`atlas.yaml names skill "${named}", and ${skill} does not exist`]);
|
|
1028
1111
|
const front = frontMatter(readText(context.root, skill) ?? '');
|
|
1029
|
-
if (!front?.name || !front?.description) return result(
|
|
1030
|
-
return result(
|
|
1112
|
+
if (!front?.name || !front?.description) return result(at, 'stale', [`${skill} has no name or no description in its front matter`]);
|
|
1113
|
+
return result(at, 'filled', [skill]);
|
|
1031
1114
|
}
|
|
1032
1115
|
|
|
1033
1116
|
// `scan` reports a missing or unreadable atlas.yaml as an error and returns
|
|
@@ -1221,6 +1304,9 @@ function writeGrants(text) {
|
|
|
1221
1304
|
// A workflow this function cannot read is a finding, not a pass. That is the
|
|
1222
1305
|
// safe direction: the check says it cannot confirm the job runs, instead of
|
|
1223
1306
|
// saying the job is fine.
|
|
1307
|
+
// The step that runs the shape check, and the flags it must pass.
|
|
1308
|
+
const GATE_RUNNER = Object.freeze({ runs: `node ${CHECK_PATH} `, flags: ['--gate', '--base-commit', '--head-commit'] });
|
|
1309
|
+
|
|
1224
1310
|
function workflowStructure(document) {
|
|
1225
1311
|
const out = [];
|
|
1226
1312
|
const jobs = document?.jobs;
|
|
@@ -1230,9 +1316,9 @@ function workflowStructure(document) {
|
|
|
1230
1316
|
// the pull requests that change the files it guards, and the pull request
|
|
1231
1317
|
// still reads green, because a job that never ran reports nothing (P13).
|
|
1232
1318
|
const on = document.on;
|
|
1233
|
-
const trigger = on && typeof on === 'object' && !Array.isArray(on) ? on.
|
|
1319
|
+
const trigger = on && typeof on === 'object' && !Array.isArray(on) ? on.pull_request_target : null;
|
|
1234
1320
|
if (trigger && typeof trigger === 'object' && !Array.isArray(trigger)) {
|
|
1235
|
-
for (const key of ['paths', 'paths-ignore']) {
|
|
1321
|
+
for (const key of ['paths', 'paths-ignore', 'branches-ignore']) {
|
|
1236
1322
|
if (trigger[key] !== undefined && trigger[key] !== null) {
|
|
1237
1323
|
out.push(`the workflow narrows its trigger with ${key}; this check must run on every pull request`);
|
|
1238
1324
|
}
|
|
@@ -1245,41 +1331,39 @@ function workflowStructure(document) {
|
|
|
1245
1331
|
for (const [jobName, job] of Object.entries(jobs)) {
|
|
1246
1332
|
for (const step of Array.isArray(job?.steps) ? job.steps : []) {
|
|
1247
1333
|
const runs = typeof step?.run === 'string' ? step.run : '';
|
|
1248
|
-
if (runs.includes(
|
|
1334
|
+
if (runs.includes(GATE_RUNNER.runs)) found = { jobName, job, step, runs, runner: GATE_RUNNER };
|
|
1249
1335
|
}
|
|
1250
1336
|
}
|
|
1251
1337
|
if (!found) {
|
|
1252
|
-
out.push(`no step in this workflow runs "node ${CHECK_PATH}", so the job reports nothing`);
|
|
1338
|
+
out.push(`no step in this workflow runs "node ${CHECK_PATH} --gate", so the job reports nothing`);
|
|
1253
1339
|
return out;
|
|
1254
1340
|
}
|
|
1255
1341
|
|
|
1256
|
-
// The
|
|
1257
|
-
//
|
|
1258
|
-
//
|
|
1259
|
-
//
|
|
1260
|
-
|
|
1261
|
-
// silences the job while the part still reads "filled". Deleting two flags
|
|
1262
|
-
// is the cheapest version of it, so the scanner reads the flags, not just
|
|
1263
|
-
// the path.
|
|
1264
|
-
for (const flag of ['--base', '--base-sha']) {
|
|
1342
|
+
// The flags. Without them the check still runs and still goes green, and the
|
|
1343
|
+
// no-loss rule never runs at all, or it scores the wrong tree. Deleting a
|
|
1344
|
+
// flag is the cheapest way to silence the job, so the scanner reads the flags,
|
|
1345
|
+
// not just the path (finding S2-1).
|
|
1346
|
+
for (const flag of found.runner.flags) {
|
|
1265
1347
|
if (!new RegExp(`${flag}(?:[ \t=]|$)`, 'm').test(found.runs)) {
|
|
1266
|
-
out.push(`the check step does not pass ${flag}, so the no-loss rule
|
|
1348
|
+
out.push(`the check step does not pass ${flag}, so the no-loss rule cannot judge the pull request from the base`);
|
|
1267
1349
|
}
|
|
1268
1350
|
}
|
|
1269
|
-
//
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1351
|
+
// Every checkout is of the base commit and keeps no credential. A checkout
|
|
1352
|
+
// of the head would run the head's copy of the check: the hole this
|
|
1353
|
+
// workflow exists to close.
|
|
1354
|
+
const checkouts = (Array.isArray(found.job?.steps) ? found.job.steps : [])
|
|
1355
|
+
.filter((step) => typeof step?.uses === 'string' && step.uses.startsWith('actions/checkout'));
|
|
1356
|
+
if (!checkouts.length) out.push('no step checks the base commit out, so there is no base copy of the check to run');
|
|
1357
|
+
for (const step of checkouts) {
|
|
1358
|
+
if (String(step?.with?.ref ?? '').replace(/\s+/g, '') !== '${{github.sha}}') out.push('a checkout step does not check out github.sha, the base commit; the check must run from the base');
|
|
1359
|
+
if (step?.with?.['persist-credentials'] !== false) out.push('a checkout step keeps its credential; set persist-credentials: false');
|
|
1274
1360
|
}
|
|
1275
1361
|
|
|
1276
1362
|
// A condition switches the step, or its whole job, off. Same effect as a
|
|
1277
1363
|
// narrowed trigger, by another route.
|
|
1278
1364
|
if (found.step.if !== undefined) out.push('the workflow puts an if: on the check step; the step must always run');
|
|
1279
1365
|
if (found.job.if !== undefined) out.push("the workflow puts an if: on the check's job; the job must always run");
|
|
1280
|
-
// Two more ways to make a red check look green
|
|
1281
|
-
// list. Both defeat the same purpose, and stage two's no-loss rule needs a red job
|
|
1282
|
-
// to mean something, so the check names them (reviewer of batch 16, S2-1).
|
|
1366
|
+
// Two more ways to make a red check look green (reviewer of batch 16, S2-1).
|
|
1283
1367
|
if (found.step['continue-on-error'] === true || found.job['continue-on-error'] === true) {
|
|
1284
1368
|
out.push('the workflow sets continue-on-error on the check; a failed check would not turn the job red');
|
|
1285
1369
|
}
|
|
@@ -1293,8 +1377,11 @@ function checkWorkflow(context, part) {
|
|
|
1293
1377
|
const text = readText(context.root, part.path);
|
|
1294
1378
|
if (text === null || !text.trim()) return result(part, 'empty', [`${part.path} is missing or holds nothing`]);
|
|
1295
1379
|
const findings = [];
|
|
1296
|
-
|
|
1297
|
-
|
|
1380
|
+
// pull_request_target takes this file from the base branch. On pull_request,
|
|
1381
|
+
// GitHub runs the copy the pull request carries, and the pull request can
|
|
1382
|
+
// change its own judge (roadmap R7).
|
|
1383
|
+
if (!/^\s*pull_request_target\s*:/m.test(text)) findings.push('the workflow does not run on pull_request_target; it must take its file and the check from the base');
|
|
1384
|
+
if (/^\s*pull_request\s*:/m.test(text)) findings.push('the workflow also runs on pull_request, which runs the head copy of this file');
|
|
1298
1385
|
// The structure, from the parsed document. A workflow this check cannot read
|
|
1299
1386
|
// is a finding: it can no longer say the job runs at all.
|
|
1300
1387
|
try {
|
|
@@ -1511,7 +1598,12 @@ export function scan({ root, repoId = null, extractors = [], secretPatterns: pat
|
|
|
1511
1598
|
if (spec && !spec.kinds[kind]) { errors.push(`atlas.yaml names kind "${kind}"; spec ${spec.spec} knows ${Object.keys(spec.kinds).join(', ')}`); spec = null; }
|
|
1512
1599
|
if (!spec) return { ok: false, check_version: CHECK_VERSION, repo: repoId, kind, spec: wantedSpec ?? null, scored_as: scoreAs, product: facts?.product ?? null, files: [], by_reference: [], counts: counts([]), errors };
|
|
1513
1600
|
|
|
1514
|
-
|
|
1601
|
+
// The no-loss rule can score a tree at another tree's pointers: the paths
|
|
1602
|
+
// that atlas.yaml names for the procedures and the facts files. So a part
|
|
1603
|
+
// that a pull request moves is also scored where the base had it (roadmap R7
|
|
1604
|
+
// line 3).
|
|
1605
|
+
const pointed = scoreAs?.pointers ? { ...facts, files: scoreAs.pointers.files, procedures: scoreAs.pointers.procedures } : facts;
|
|
1606
|
+
const context = { root, facts: pointed, factsError, repoId, extractors, secretPatterns: usable, errors };
|
|
1515
1607
|
// atlas.yaml's own values reach the report: the kind, the repo id, a
|
|
1516
1608
|
// procedure's skill name, every pointer path. Scan it before any of them is
|
|
1517
1609
|
// quoted (acceptance review of batch 16, note S3-2).
|
|
@@ -1568,21 +1660,30 @@ export function counts(files) {
|
|
|
1568
1660
|
// four required parts into "not applicable" and the count falls. Scoring per
|
|
1569
1661
|
// part, with the base tree's `kind` and `spec`, closes both (findings P2, P20).
|
|
1570
1662
|
//
|
|
1571
|
-
// Which copy scores. The
|
|
1572
|
-
//
|
|
1573
|
-
//
|
|
1574
|
-
//
|
|
1575
|
-
//
|
|
1576
|
-
//
|
|
1663
|
+
// Which copy scores. The base's, from check version 6 (roadmap R7). The gate
|
|
1664
|
+
// job runs on pull_request_target, so it runs this file as the base holds it,
|
|
1665
|
+
// over two trees written from git objects. Before that, the head's copy judged
|
|
1666
|
+
// its own diff, and a pull request could edit the check to print GREEN. The
|
|
1667
|
+
// two report-only cases for the check file and its workflow covered that hole
|
|
1668
|
+
// with a warning that a person merges the file. Under merge on green, no
|
|
1669
|
+
// person reads that warning. Now a change to either gives "owner" and the rule
|
|
1670
|
+
// still gates.
|
|
1671
|
+
//
|
|
1672
|
+
// A run by hand, or an old workflow, still runs the head's copy. Its answer
|
|
1673
|
+
// is a report. The gate's answer is the one a merge waits on.
|
|
1577
1674
|
//
|
|
1578
1675
|
// Where it lives. Here. The repository still carries two files and only two.
|
|
1579
1676
|
// ---------------------------------------------------------------------------
|
|
1580
1677
|
|
|
1581
|
-
// The
|
|
1582
|
-
// the
|
|
1583
|
-
|
|
1678
|
+
// The cases that give "owner". Only the owner may approve such a change, and
|
|
1679
|
+
// the rule still gates: it still finds any part that went backwards. Before
|
|
1680
|
+
// check version 6 five cases switched the rule off and printed a warning that
|
|
1681
|
+
// a person merges the file. Nothing switches it off now. The base copy scores,
|
|
1682
|
+
// and the base kind and spec score both trees, so no case needs to.
|
|
1683
|
+
export const OWNER_CASES = Object.freeze(['check-file', 'workflow', 'kind', 'spec', 'moved', 'first-run']);
|
|
1584
1684
|
|
|
1585
1685
|
const fileHash = (root, path) => { const text = readText(root, path); return text === null ? null : sha256(text); };
|
|
1686
|
+
const isEmptyDir = (root) => { try { return !readdirSync(root).length; } catch { return true; } };
|
|
1586
1687
|
|
|
1587
1688
|
// The commit a checkout sits on, read from `.git/HEAD` as text. No git binary,
|
|
1588
1689
|
// no network. `actions/checkout` leaves a detached HEAD, so the first line is
|
|
@@ -1610,45 +1711,48 @@ const partIndex = (report) => {
|
|
|
1610
1711
|
return map;
|
|
1611
1712
|
};
|
|
1612
1713
|
|
|
1613
|
-
|
|
1714
|
+
// `baseCommit` is the commit the caller wrote the base tree from, when it
|
|
1715
|
+
// wrote the tree itself from git objects, as the gate does. Such a tree has
|
|
1716
|
+
// no `.git`, and the caller is the base code, so its word is the tree's name.
|
|
1717
|
+
export function noLoss({ headRoot, baseRoot, baseSha = null, baseCommit = null, repoId = null, secretPatterns: patterns = secretPatterns() }) {
|
|
1614
1718
|
const out = {
|
|
1615
1719
|
ran: true,
|
|
1616
|
-
gates: true,
|
|
1617
1720
|
green: true,
|
|
1721
|
+
broken: null,
|
|
1618
1722
|
base_dir: baseRoot,
|
|
1619
1723
|
base_sha: baseSha,
|
|
1620
1724
|
base_head: null,
|
|
1621
1725
|
base_facts: null,
|
|
1622
1726
|
head_facts: null,
|
|
1623
1727
|
parts_compared: 0,
|
|
1624
|
-
|
|
1728
|
+
owner: [],
|
|
1625
1729
|
regressions: [],
|
|
1626
1730
|
};
|
|
1627
|
-
const
|
|
1731
|
+
const owner = (name, why, extra = {}) => out.owner.push({ case: name, why, ...extra });
|
|
1732
|
+
// A missing or wrong base tree is red, never green: there is nothing to
|
|
1733
|
+
// compare, and a rule that cannot compare cannot say nothing was lost
|
|
1734
|
+
// (roadmap R7 line 4).
|
|
1735
|
+
const broken = (why) => { out.broken = why; out.green = false; return out; };
|
|
1628
1736
|
|
|
1629
1737
|
// 1. Is there a base tree at all, and is it the pull request's base?
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
excuse('base-tree', `no base tree at ${baseRoot}: the checkout of the base commit did not land, so there is nothing to compare`);
|
|
1633
|
-
return out;
|
|
1634
|
-
}
|
|
1635
|
-
if (baseRead.error || !baseRead.facts) {
|
|
1636
|
-
excuse('base-tree', `the base tree at ${baseRoot} has no readable atlas.yaml${baseRead.error ? `: ${baseRead.error}` : ''}, so its kind and spec cannot be read`);
|
|
1637
|
-
return out;
|
|
1638
|
-
}
|
|
1639
|
-
out.base_head = checkoutHead(baseRoot);
|
|
1738
|
+
if (!existsSync(baseRoot) || isEmptyDir(baseRoot)) return broken(`no base tree at ${baseRoot}: the base commit did not land, so there is nothing to compare`);
|
|
1739
|
+
out.base_head = baseCommit && PINNED.test(baseCommit) ? baseCommit : checkoutHead(baseRoot);
|
|
1640
1740
|
// The pull request promised a base commit. If the tree cannot say which
|
|
1641
1741
|
// commit it is, the promise is unchecked, and an unchecked promise is not a
|
|
1642
1742
|
// base tree. Reporting a sha this check was handed but never read would be a
|
|
1643
|
-
// claim it cannot support
|
|
1644
|
-
if (baseSha && !out.base_head) {
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1743
|
+
// claim it cannot support.
|
|
1744
|
+
if (baseSha && !out.base_head) return broken(`the base tree at ${baseRoot} does not say which commit it is, so this check cannot confirm it is ${baseSha.slice(0, 12)}, the base this pull request names`);
|
|
1745
|
+
if (baseSha && out.base_head !== baseSha) return broken(`the base tree at ${baseRoot} is at ${out.base_head.slice(0, 12)}, and this pull request's base is ${baseSha.slice(0, 12)}; the comparison would be against the wrong commit`);
|
|
1746
|
+
// A base with no atlas.yaml is the first run in a repo: the pull request that
|
|
1747
|
+
// adds Atlas. There is nothing to lose, and the owner reads the first shape.
|
|
1748
|
+
const baseRead = readFacts(baseRoot);
|
|
1749
|
+
if (!baseRead.facts && !baseRead.error) {
|
|
1750
|
+
owner('first-run', 'the base has no atlas.yaml, so there is nothing to lose; the owner reads the first shape');
|
|
1650
1751
|
return out;
|
|
1651
1752
|
}
|
|
1753
|
+
// A base atlas.yaml that does not read cannot name a kind or a spec, so
|
|
1754
|
+
// there is no yardstick. That is red. The owner merges the fix by hand.
|
|
1755
|
+
if (baseRead.error) return broken(`the base atlas.yaml does not read, so its kind and spec are unknown: ${baseRead.error}`);
|
|
1652
1756
|
|
|
1653
1757
|
// 2. The base tree's kind and spec score both trees.
|
|
1654
1758
|
const scoreAs = { kind: baseRead.facts.kind ?? null, spec: baseRead.facts.spec ?? null };
|
|
@@ -1657,28 +1761,30 @@ export function noLoss({ headRoot, baseRoot, baseSha = null, repoId = null, secr
|
|
|
1657
1761
|
out.head_facts = { kind: headRead.facts?.kind ?? null, spec: headRead.facts?.spec ?? null };
|
|
1658
1762
|
|
|
1659
1763
|
if (String(out.head_facts.kind) !== String(scoreAs.kind)) {
|
|
1660
|
-
|
|
1764
|
+
owner('kind', `atlas.yaml names kind "${scoreAs.kind}" at the base and "${out.head_facts.kind}" at the head; this pull request changes which parts the repo needs`);
|
|
1661
1765
|
}
|
|
1662
1766
|
if (String(out.head_facts.spec) !== String(scoreAs.spec)) {
|
|
1663
|
-
|
|
1767
|
+
owner('spec', `atlas.yaml names spec "${scoreAs.spec}" at the base and "${out.head_facts.spec}" at the head; this pull request changes what the spec version means`);
|
|
1664
1768
|
}
|
|
1665
1769
|
|
|
1666
|
-
// 3. Does the diff change the gate itself?
|
|
1667
|
-
|
|
1770
|
+
// 3. Does the diff change the gate itself? The base copy judges, so the
|
|
1771
|
+
// change cannot score itself, but only the owner may approve it.
|
|
1772
|
+
for (const [name, path] of [['check-file', CHECK_PATH], ['workflow', WORKFLOW_PATH], ['workflow', OLD_WORKFLOW_PATH]]) {
|
|
1668
1773
|
if (fileHash(baseRoot, path) !== fileHash(headRoot, path)) {
|
|
1669
|
-
|
|
1774
|
+
owner(name, `this pull request changes ${path}, which is the gate itself; it is instruction class, so only the owner approves it`, { path });
|
|
1670
1775
|
}
|
|
1671
1776
|
}
|
|
1672
1777
|
|
|
1673
|
-
// 4. Score both trees, by the same yardstick, and compare part by part.
|
|
1778
|
+
// 4. Score both trees, by the same yardstick, and compare part by part. The
|
|
1779
|
+
// head is scored twice: at its own pointers, and at the base's.
|
|
1674
1780
|
const base = scan({ root: baseRoot, repoId, secretPatterns: patterns, scoreAs });
|
|
1675
1781
|
const head = scan({ root: headRoot, repoId, secretPatterns: patterns, scoreAs });
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
}
|
|
1782
|
+
const pointers = { files: baseRead.facts.files, procedures: baseRead.facts.procedures };
|
|
1783
|
+
const headAtBase = scan({ root: headRoot, repoId, secretPatterns: patterns, scoreAs: { ...scoreAs, pointers } });
|
|
1784
|
+
if (!base.files.length) return broken(`the base tree at ${baseRoot} could not be scored: ${base.errors.join('; ') || 'no parts came back'}`);
|
|
1680
1785
|
|
|
1681
1786
|
const headParts = partIndex(head);
|
|
1787
|
+
const atBaseParts = partIndex(headAtBase);
|
|
1682
1788
|
for (const [key, { file, part }] of partIndex(base)) {
|
|
1683
1789
|
if (file.class !== 'instruction' || part.state !== 'filled') continue;
|
|
1684
1790
|
out.parts_compared += 1;
|
|
@@ -1689,12 +1795,51 @@ export function noLoss({ headRoot, baseRoot, baseSha = null, repoId = null, secr
|
|
|
1689
1795
|
}
|
|
1690
1796
|
if (now.part.state !== 'filled') {
|
|
1691
1797
|
out.regressions.push({ part: key, label: label(now.part), was: 'filled', now: now.part.state, findings: now.part.findings, why: `${label(now.part)} was filled at the base and is ${now.part.state} at the head` });
|
|
1798
|
+
continue;
|
|
1692
1799
|
}
|
|
1800
|
+
// A moved part: the head points it somewhere else. The head is filled
|
|
1801
|
+
// at its new pointer, or the part would be a regression above. The owner
|
|
1802
|
+
// reads every move, and the answer says whether the base path still holds
|
|
1803
|
+
// the part in the head (roadmap R7 line 3).
|
|
1804
|
+
const from = part.path;
|
|
1805
|
+
const to = now.part.path;
|
|
1806
|
+
if (!from || !to || from === to) continue;
|
|
1807
|
+
const kept = atBaseParts.get(key)?.part.state === 'filled' ? `; ${from} still holds it` : `; ${from} no longer holds it`;
|
|
1808
|
+
owner('moved', `${label(part)} moved from ${from} to ${to}${kept}`, { path: to });
|
|
1693
1809
|
}
|
|
1694
1810
|
out.green = !out.regressions.length;
|
|
1695
1811
|
return out;
|
|
1696
1812
|
}
|
|
1697
1813
|
|
|
1814
|
+
// The answer of the shape rule for the gate: success, owner or red, with one
|
|
1815
|
+
// row for each finding. Red wins over owner. The gate job and `atlas gate
|
|
1816
|
+
// check` both take it from here, so the two cannot disagree.
|
|
1817
|
+
export const GATE_RESULTS = Object.freeze(['success', 'owner', 'red']);
|
|
1818
|
+
export function gateAnswer(report, state) {
|
|
1819
|
+
const rows = [];
|
|
1820
|
+
for (const error of report.errors) rows.push({ kind: 'red', class: 'shape', cause: `the check hit an error at the head: ${error}` });
|
|
1821
|
+
if (state?.broken) rows.push({ kind: 'red', class: 'shape', cause: state.broken });
|
|
1822
|
+
for (const row of state?.regressions ?? []) rows.push({ kind: 'red', class: 'shape', cause: row.why });
|
|
1823
|
+
for (const row of state?.owner ?? []) rows.push({ kind: 'owner', class: 'shape', cause: `${row.case}: ${row.why}`, ...(row.path ? { path: row.path } : {}) });
|
|
1824
|
+
if (!state) rows.push({ kind: 'red', class: 'shape', cause: 'the no-loss rule did not run: no base tree was named' });
|
|
1825
|
+
const result = rows.some((row) => row.kind === 'red') ? 'red' : rows.some((row) => row.kind === 'owner') ? 'owner' : 'success';
|
|
1826
|
+
return { result, class: result === 'success' ? null : 'shape', rows };
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
// One line for each row, with a fixed prefix, and no control character, so a
|
|
1830
|
+
// path cannot start a workflow command.
|
|
1831
|
+
const oneLine = (text) => String(text ?? '').replace(/[\u0000-\u001f\u007f]/g, ' ');
|
|
1832
|
+
export function gateLines(answer, state) {
|
|
1833
|
+
const base = state?.base_head ? state.base_head.slice(0, 12) : 'none';
|
|
1834
|
+
const yard = state?.base_facts ? `kind ${oneLine(state.base_facts.kind)}, spec ${oneLine(state.base_facts.spec)}` : 'no yardstick';
|
|
1835
|
+
const out = [`shape: base ${base}, ${yard}, ${state?.parts_compared ?? 0} instruction-class part(s) filled at the base`];
|
|
1836
|
+
for (const row of answer.rows) out.push(` ${row.kind.padEnd(6)} ${row.class.padEnd(6)} ${oneLine(row.cause)}`);
|
|
1837
|
+
if (answer.result === 'red') out.push(`RED: ${oneLine(answer.rows.find((row) => row.kind === 'red').cause)}. Fix it; red wins over owner.`);
|
|
1838
|
+
else if (answer.result === 'owner') out.push("OWNER: shape. Clears with the owner's signed act: atlas approve <pr> (roadmap R72).");
|
|
1839
|
+
else out.push('SUCCESS: no instruction-class part went backwards.');
|
|
1840
|
+
return out;
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1698
1843
|
// ---------------------------------------------------------------------------
|
|
1699
1844
|
// The report. Two shapes: plain text for the CI log, and GitHub markdown for
|
|
1700
1845
|
// the job summary. Both list every part, its state and its findings.
|
|
@@ -1748,23 +1893,13 @@ export function noLossLines(state) {
|
|
|
1748
1893
|
out.push(` base ${read} in ${state.base_dir}${promised}`);
|
|
1749
1894
|
if (state.base_facts) out.push(` scored both trees as kind ${state.base_facts.kind}, spec ${state.base_facts.spec}, from the base tree`);
|
|
1750
1895
|
if (state.parts_compared) out.push(` ${state.parts_compared} instruction-class part(s) were filled at the base`);
|
|
1751
|
-
|
|
1896
|
+
if (state.broken) out.push(` NO BASE TREE: ${state.broken}`);
|
|
1897
|
+
for (const row of state.owner ?? []) out.push(` OWNER (${row.case}): ${row.why}`);
|
|
1752
1898
|
for (const row of state.regressions) {
|
|
1753
1899
|
out.push(` BACKWARDS ${row.label}: ${row.was} at the base, ${row.now} at the head`);
|
|
1754
1900
|
for (const finding of row.findings ?? []) out.push(` - ${finding}`);
|
|
1755
1901
|
}
|
|
1756
1902
|
if (!state.regressions.length && state.base_facts) out.push(' no instruction-class part went backwards');
|
|
1757
|
-
if (!state.gates) {
|
|
1758
|
-
out.push(' this no-loss rule reports and does not gate on this pull request');
|
|
1759
|
-
// The loudest line in the report, and the one a reader must not miss. The
|
|
1760
|
-
// frozen text excuses these five cases because a person merges every file
|
|
1761
|
-
// that causes one. That is a compensating control, not an absence of
|
|
1762
|
-
// findings, so the findings are said out loud beside the green.
|
|
1763
|
-
if (state.regressions.length) {
|
|
1764
|
-
out.push(` WARNING: ${state.regressions.length} part(s) above went backwards and this job is NOT red for them.`);
|
|
1765
|
-
out.push(' A person merges this pull request. Read the BACKWARDS lines before you do.');
|
|
1766
|
-
}
|
|
1767
|
-
}
|
|
1768
1903
|
return out;
|
|
1769
1904
|
}
|
|
1770
1905
|
|
|
@@ -1808,15 +1943,14 @@ export function toMarkdown(report) {
|
|
|
1808
1943
|
export function verdict(report, failOn) {
|
|
1809
1944
|
if (report.errors.length) return { green: false, why: `the check hit ${report.errors.length} error(s)` };
|
|
1810
1945
|
const state = report.noLoss;
|
|
1811
|
-
if (state?.
|
|
1946
|
+
if (state?.broken) return { green: false, why: `the no-loss rule has no base tree to compare: ${state.broken}` };
|
|
1947
|
+
if (state?.regressions.length) {
|
|
1812
1948
|
return { green: false, why: `${state.regressions.length} instruction-class part(s) went backwards against the base: ${state.regressions.map((row) => row.label).join(', ')}` };
|
|
1813
1949
|
}
|
|
1814
1950
|
if (failOn === 'required' && report.counts.required_not_filled) return { green: false, why: `${report.counts.required_not_filled} required file(s) are not filled` };
|
|
1815
1951
|
if (failOn === 'any' && report.counts.not_filled) return { green: false, why: `${report.counts.not_filled} file(s) are not filled` };
|
|
1816
|
-
const
|
|
1817
|
-
|
|
1818
|
-
: '';
|
|
1819
|
-
const noLossWhy = state ? (state.gates ? '; no instruction-class part went backwards' : `; the no-loss rule reported and did not gate (${state.report_only.map((row) => row.case).join(', ')})${excused}`) : '';
|
|
1952
|
+
const owned = state?.owner?.length ? `; only the owner approves this change (${[...new Set(state.owner.map((row) => row.case))].join(', ')}), so the gate says "owner"` : '';
|
|
1953
|
+
const noLossWhy = state ? `; no instruction-class part went backwards${owned}` : '';
|
|
1820
1954
|
return { green: true, why: `${report.counts.required_not_filled} required file(s) are not filled; fail-on is "${failOn}"${noLossWhy}` };
|
|
1821
1955
|
}
|
|
1822
1956
|
|
|
@@ -1828,7 +1962,7 @@ export const FAIL_ON = Object.freeze(['error', 'required', 'any']);
|
|
|
1828
1962
|
export const EXTRACTORS = Object.freeze([]); // Slice L3 adds routes, migrations and mcp.
|
|
1829
1963
|
|
|
1830
1964
|
export function parseArguments(argv) {
|
|
1831
|
-
const out = { root: process.cwd(), failOn: 'error', json: null, summary: null, quiet: false, base: null, baseSha: null };
|
|
1965
|
+
const out = { root: process.cwd(), failOn: 'error', json: null, summary: null, quiet: false, base: null, baseSha: null, baseCommit: null, headCommit: null, git: process.cwd(), gate: false };
|
|
1832
1966
|
for (let index = 0; index < argv.length; index += 1) {
|
|
1833
1967
|
const flag = argv[index];
|
|
1834
1968
|
const next = () => { const value = argv[index + 1]; if (value === undefined) throw new Error(`${flag} needs a value`); index += 1; return value; };
|
|
@@ -1842,16 +1976,27 @@ export function parseArguments(argv) {
|
|
|
1842
1976
|
// the check can say the tree it reads is the tree it was promised.
|
|
1843
1977
|
else if (flag === '--base') out.base = resolve(next());
|
|
1844
1978
|
else if (flag === '--base-sha') out.baseSha = next().trim() || null;
|
|
1979
|
+
// Gate mode (roadmap R7). The gate job wrote the base tree from git
|
|
1980
|
+
// objects, so it names the commit; the answer is success, owner or red.
|
|
1981
|
+
else if (flag === '--base-commit') out.baseCommit = next().trim() || null;
|
|
1982
|
+
else if (flag === '--head-commit') out.headCommit = next().trim() || null;
|
|
1983
|
+
else if (flag === '--git') out.git = resolve(next());
|
|
1984
|
+
else if (flag === '--gate') out.gate = true;
|
|
1845
1985
|
else if (flag === '--repo') out.repo = next();
|
|
1846
1986
|
else if (flag === '--version') out.version = true;
|
|
1847
1987
|
else throw new Error(`unknown argument ${flag}`);
|
|
1848
1988
|
}
|
|
1849
1989
|
if (!FAIL_ON.includes(out.failOn)) throw new Error(`--fail-on must be one of ${FAIL_ON.join(', ')}`);
|
|
1990
|
+
for (const [flag, value] of [['--base-commit', out.baseCommit], ['--head-commit', out.headCommit]]) {
|
|
1991
|
+
if (value && !PINNED.test(value)) throw new Error(`${flag} must be a full 40-character commit SHA`);
|
|
1992
|
+
}
|
|
1993
|
+
if (out.gate && (!out.baseCommit || !out.headCommit)) throw new Error('--gate needs --base-commit and --head-commit');
|
|
1850
1994
|
return out;
|
|
1851
1995
|
}
|
|
1852
1996
|
|
|
1853
1997
|
export function run(argv, env = process.env) {
|
|
1854
1998
|
const options = parseArguments(argv);
|
|
1999
|
+
if (options.gate) return runGate({ options, env });
|
|
1855
2000
|
const repoId = options.repo ?? env.GITHUB_REPOSITORY ?? null;
|
|
1856
2001
|
const patterns = secretPatterns();
|
|
1857
2002
|
const report = scan({ root: options.root, repoId, extractors: EXTRACTORS, secretPatterns: patterns });
|
|
@@ -1859,7 +2004,7 @@ export function run(argv, env = process.env) {
|
|
|
1859
2004
|
// hand. The no-loss rule then does not run at all, and the report says nothing
|
|
1860
2005
|
// about it rather than claiming a pass.
|
|
1861
2006
|
report.noLoss = options.base
|
|
1862
|
-
? noLoss({ headRoot: options.root, baseRoot: options.base, baseSha: options.baseSha, repoId, secretPatterns: patterns })
|
|
2007
|
+
? noLoss({ headRoot: options.root, baseRoot: options.base, baseSha: options.baseSha, baseCommit: options.baseCommit, repoId, secretPatterns: patterns })
|
|
1863
2008
|
: null;
|
|
1864
2009
|
const decision = verdict(report, options.failOn);
|
|
1865
2010
|
const text = toText(report);
|
|
@@ -1870,6 +2015,144 @@ export function run(argv, env = process.env) {
|
|
|
1870
2015
|
return { report, decision, text, options };
|
|
1871
2016
|
}
|
|
1872
2017
|
|
|
2018
|
+
// ---------------------------------------------------------------------------
|
|
2019
|
+
// The two trees of the gate (roadmap R7). Each one is written from git objects:
|
|
2020
|
+
// `git ls-tree` lists the entries and `git cat-file` gives each blob, so no
|
|
2021
|
+
// attribute of the tree applies. `git archive` would apply the head's own
|
|
2022
|
+
// .gitattributes (export-subst, export-ignore, a filter), and the check would
|
|
2023
|
+
// score bytes that do not merge (security review of R7, finding S1).
|
|
2024
|
+
//
|
|
2025
|
+
// Each blob is checked against its id. A path must be plain: no empty, "." or
|
|
2026
|
+
// ".." part, no ".git" part, no leading slash and no control character. A link
|
|
2027
|
+
// or a submodule is listed and never written, so the check never follows one.
|
|
2028
|
+
// ---------------------------------------------------------------------------
|
|
2029
|
+
|
|
2030
|
+
export class TreeError extends Error {}
|
|
2031
|
+
|
|
2032
|
+
export const TREE_LIMITS = Object.freeze({ files: 200_000, bytes: 1024 * 1024 * 1024 });
|
|
2033
|
+
|
|
2034
|
+
function gitRun(git, args, input = null) {
|
|
2035
|
+
const r = spawnSync('git', ['-C', git, ...args], { input, maxBuffer: TREE_LIMITS.bytes + 64 * 1024 * 1024 });
|
|
2036
|
+
if (r.error) throw new TreeError(`git cannot run: ${r.error.message}`);
|
|
2037
|
+
if (r.status !== 0) throw new TreeError(`git ${args[0]} failed: ${String(r.stderr ?? '').trim().split('\n')[0]}`);
|
|
2038
|
+
return r.stdout;
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
const PLAIN_PART = /^(?!\.\.?$)(?!\.git$)[^\u0000-\u001f\u007f/\\]+$/;
|
|
2042
|
+
export const plainPath = (path) => typeof path === 'string' && path.length > 0 && path.split('/').every((part) => PLAIN_PART.test(part));
|
|
2043
|
+
|
|
2044
|
+
// The entries of one commit's tree: blobs, links and submodules.
|
|
2045
|
+
export function treeEntries(git, commit) {
|
|
2046
|
+
const out = { blobs: [], links: [], modules: [] };
|
|
2047
|
+
const seen = new Set();
|
|
2048
|
+
for (const row of gitRun(git, ['ls-tree', '-r', '-z', '--full-tree', commit]).toString('utf8').split('\0')) {
|
|
2049
|
+
if (!row) continue;
|
|
2050
|
+
const match = /^(\d{6}) (\w+) ([0-9a-f]{40,64})\t([\s\S]+)$/.exec(row);
|
|
2051
|
+
if (!match) throw new TreeError('git ls-tree printed a row this check cannot read');
|
|
2052
|
+
const [, mode, type, id, path] = match;
|
|
2053
|
+
if (!plainPath(path)) throw new TreeError(`the tree of ${commit.slice(0, 12)} holds a path that is not plain`);
|
|
2054
|
+
if (seen.has(path)) throw new TreeError(`the tree of ${commit.slice(0, 12)} holds one path twice`);
|
|
2055
|
+
seen.add(path);
|
|
2056
|
+
if (mode === '120000') out.links.push({ path, id });
|
|
2057
|
+
else if (type === 'commit') out.modules.push({ path, id });
|
|
2058
|
+
else if (type === 'blob') out.blobs.push({ path, id });
|
|
2059
|
+
else throw new TreeError(`the tree of ${commit.slice(0, 12)} holds an entry of type ${type}`);
|
|
2060
|
+
}
|
|
2061
|
+
return out;
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
const blobId = (bytes, length) => createHash(length === 64 ? 'sha256' : 'sha1').update(`blob ${bytes.length}\0`).update(bytes).digest('hex');
|
|
2065
|
+
|
|
2066
|
+
// Write the blobs of one commit under `dest`. Returns the entries.
|
|
2067
|
+
export function writeTree({ git, commit, dest }) {
|
|
2068
|
+
const entries = treeEntries(git, commit);
|
|
2069
|
+
if (entries.blobs.length > TREE_LIMITS.files) throw new TreeError(`the tree of ${commit.slice(0, 12)} holds more than ${TREE_LIMITS.files} files`);
|
|
2070
|
+
const output = entries.blobs.length ? gitRun(git, ['cat-file', '--batch'], `${entries.blobs.map((blob) => blob.id).join('\n')}\n`) : Buffer.alloc(0);
|
|
2071
|
+
let at = 0;
|
|
2072
|
+
let total = 0;
|
|
2073
|
+
for (const blob of entries.blobs) {
|
|
2074
|
+
const end = output.indexOf(0x0a, at);
|
|
2075
|
+
const header = output.subarray(at, end).toString('utf8');
|
|
2076
|
+
const match = /^([0-9a-f]{40,64}) blob (\d+)$/.exec(header);
|
|
2077
|
+
if (!match || match[1] !== blob.id) throw new TreeError(`git cat-file did not give the blob of ${blob.path}`);
|
|
2078
|
+
const size = Number(match[2]);
|
|
2079
|
+
total += size;
|
|
2080
|
+
if (total > TREE_LIMITS.bytes) throw new TreeError(`the tree of ${commit.slice(0, 12)} is over ${TREE_LIMITS.bytes} bytes`);
|
|
2081
|
+
const bytes = output.subarray(end + 1, end + 1 + size);
|
|
2082
|
+
if (blobId(bytes, blob.id.length) !== blob.id) throw new TreeError(`the bytes of ${blob.path} are not its blob`);
|
|
2083
|
+
const full = join(dest, ...blob.path.split('/'));
|
|
2084
|
+
mkdirSync(dirname(full), { recursive: true });
|
|
2085
|
+
writeFileSync(full, bytes, { flag: 'wx' });
|
|
2086
|
+
at = end + 1 + size + 1;
|
|
2087
|
+
}
|
|
2088
|
+
return entries;
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
const commitOf = (git, ref) => {
|
|
2092
|
+
const r = spawnSync('git', ['-C', git, 'rev-parse', '--verify', '--quiet', '--end-of-options', `${ref}^{commit}`], { encoding: 'utf8' });
|
|
2093
|
+
return r.status === 0 ? r.stdout.trim() : null;
|
|
2094
|
+
};
|
|
2095
|
+
|
|
2096
|
+
// The shape rule over git data: the base commit is the tip of the base branch,
|
|
2097
|
+
// the head commit is the pull request's head. Both trees come from the merge
|
|
2098
|
+
// base and the head. Any step that fails is red.
|
|
2099
|
+
export function gateFromGit({ git, baseCommit, headCommit, repoId = null, trees = null, secretPatterns: patterns = secretPatterns() }) {
|
|
2100
|
+
const red = (why) => ({ report: { errors: [], files: [], counts: counts([]) }, state: { broken: why, owner: [], regressions: [], parts_compared: 0, base_head: null, base_facts: null } });
|
|
2101
|
+
const base = commitOf(git, baseCommit);
|
|
2102
|
+
const head = commitOf(git, headCommit);
|
|
2103
|
+
if (!base) return red(`the base commit ${String(baseCommit).slice(0, 12)} is not in this clone`);
|
|
2104
|
+
if (!head) return red(`the head commit ${String(headCommit).slice(0, 12)} is not in this clone`);
|
|
2105
|
+
let mergeBase;
|
|
2106
|
+
try { mergeBase = gitRun(git, ['merge-base', '--end-of-options', base, head]).toString('utf8').trim(); } catch { return red('the base and the head share no commit in this clone'); }
|
|
2107
|
+
const top = trees ?? mkdtempSync(join(tmpdir(), 'atlas-gate-'));
|
|
2108
|
+
const baseRoot = join(top, GATE_TREES.base);
|
|
2109
|
+
const headRoot = join(top, GATE_TREES.head);
|
|
2110
|
+
try {
|
|
2111
|
+
let baseEntries;
|
|
2112
|
+
let headEntries;
|
|
2113
|
+
try {
|
|
2114
|
+
baseEntries = writeTree({ git, commit: mergeBase, dest: baseRoot });
|
|
2115
|
+
headEntries = writeTree({ git, commit: head, dest: headRoot });
|
|
2116
|
+
} catch (error) {
|
|
2117
|
+
if (error instanceof TreeError) return red(`a tree could not be written: ${error.message}`);
|
|
2118
|
+
throw error;
|
|
2119
|
+
}
|
|
2120
|
+
mkdirSync(baseRoot, { recursive: true });
|
|
2121
|
+
mkdirSync(headRoot, { recursive: true });
|
|
2122
|
+
const report = scan({ root: headRoot, repoId, secretPatterns: patterns });
|
|
2123
|
+
const state = noLoss({ headRoot, baseRoot, baseCommit: mergeBase, repoId, secretPatterns: patterns });
|
|
2124
|
+
// A link or a submodule that the head adds or changes is never read, so the
|
|
2125
|
+
// check cannot judge it. The owner reads it.
|
|
2126
|
+
const had = new Map([...baseEntries.links, ...baseEntries.modules].map((entry) => [entry.path, entry.id]));
|
|
2127
|
+
for (const entry of [...headEntries.links, ...headEntries.modules]) {
|
|
2128
|
+
if (had.get(entry.path) !== entry.id) state.owner.push({ case: 'link', why: `${entry.path} is a link or a submodule that this pull request adds or changes; the check never follows one`, path: entry.path });
|
|
2129
|
+
}
|
|
2130
|
+
return { report, state, merge_base: mergeBase, head };
|
|
2131
|
+
} finally {
|
|
2132
|
+
if (!trees) rmSync(top, { recursive: true, force: true });
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
// Gate mode. The job summary holds the full report and the answer. The output
|
|
2137
|
+
// file gets two keys only, each one word of a fixed set, so no path can add a
|
|
2138
|
+
// key. Exit 0 on success, 1 on owner or red.
|
|
2139
|
+
function runGate({ options, env }) {
|
|
2140
|
+
const { report, state } = gateFromGit({ git: options.git, baseCommit: options.baseCommit, headCommit: options.headCommit, repoId: options.repo ?? env.GITHUB_REPOSITORY ?? null });
|
|
2141
|
+
const answer = gateAnswer(report, state);
|
|
2142
|
+
// Only the answer lines leave this process. Each one has a fixed prefix and
|
|
2143
|
+
// no control character or backtick, so a head string cannot start a
|
|
2144
|
+
// workflow command or close the fence of the summary (security review of
|
|
2145
|
+
// R7). The full report stays in the JSON file, for a run by hand.
|
|
2146
|
+
const lines = gateLines(answer, state).map((line) => line.replaceAll('`', "'"));
|
|
2147
|
+
if (options.json) writeFileSync(options.json, `${JSON.stringify({ ...report, noLoss: state, gate: answer }, null, 2)}\n`);
|
|
2148
|
+
const summary = `## Atlas repo shape, from the base\n\n${['```text', ...lines, '```'].join('\n')}\n`;
|
|
2149
|
+
if (options.summary) writeFileSync(options.summary, summary);
|
|
2150
|
+
if (env.GITHUB_STEP_SUMMARY) appendFileSync(env.GITHUB_STEP_SUMMARY, summary);
|
|
2151
|
+
if (env.GITHUB_OUTPUT) appendFileSync(env.GITHUB_OUTPUT, `result=${answer.result}\nclass=${answer.class ?? 'none'}\n`);
|
|
2152
|
+
const decision = { green: answer.result === 'success', why: lines.at(-1) };
|
|
2153
|
+
return { report, decision, text: `${lines.join('\n')}\n`, lines, options, answer, gate: true };
|
|
2154
|
+
}
|
|
2155
|
+
|
|
1873
2156
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
1874
2157
|
try {
|
|
1875
2158
|
// --version is parsed like every other flag, so an unknown flag beside it
|
|
@@ -1878,9 +2161,10 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
|
|
|
1878
2161
|
if (asked.version) {
|
|
1879
2162
|
process.stdout.write(`atlas repo-shape check, check version ${CHECK_VERSION}; it reads spec ${Object.keys(SPECS).join(', ')}\n`);
|
|
1880
2163
|
} else {
|
|
1881
|
-
const { decision, text, options } = run(process.argv.slice(2));
|
|
1882
|
-
if (!options.quiet) process.stdout.write(text);
|
|
1883
|
-
|
|
2164
|
+
const { decision, text, options, gate } = run(process.argv.slice(2));
|
|
2165
|
+
if (!options.quiet || gate) process.stdout.write(text);
|
|
2166
|
+
// In gate mode the last line of the text already says SUCCESS, OWNER or RED.
|
|
2167
|
+
if (!gate) process.stdout.write(`${decision.green ? 'GREEN' : 'RED'}: ${decision.why}\n`);
|
|
1884
2168
|
process.exitCode = decision.green ? 0 : 1;
|
|
1885
2169
|
}
|
|
1886
2170
|
} catch (error) {
|
package/shape/releases.json
CHANGED
|
@@ -30,6 +30,11 @@
|
|
|
30
30
|
"sha256": "8f36633fe693ebf7a7cc0f7237d49eb4720f67d371d4758d556bab51965ec334",
|
|
31
31
|
"first_package": "0.3.48",
|
|
32
32
|
"workflow_sha256": "b69efaac2a3e52368ae5e7d7a33e1c88c6c7a7efa6fc1846844f79f9432ecd7d"
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"check_version": 6,
|
|
36
|
+
"sha256": "bad1d05853e173c034971282c5762a1628dfd9f198c8276178bd17280afdf6e7",
|
|
37
|
+
"workflow_sha256": "fcddebef030e6564f97e1a5fb81c8ad1b3084ebef9622167c6565698312dcdd7"
|
|
33
38
|
}
|
|
34
39
|
]
|
|
35
40
|
}
|