@arjunkhera/atlas 0.4.45 → 0.4.47
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 +9 -41
- package/door/lib/approve.mjs +193 -35
- package/door/lib/owner-only.mjs +1 -1
- package/door/lib/signed-act.mjs +39 -0
- package/door/lib/tooling.mjs +32 -1
- package/door/lib/upkeep.mjs +14 -82
- package/owner-only.yaml +17 -9
- package/package.json +1 -1
- package/shape/check.mjs +402 -487
- package/shape/releases.json +5 -0
- package/skills/lead/SKILL.md +8 -6
- package/work/lib/delivery.mjs +22 -6
- package/door/lib/kind-drift.mjs +0 -86
package/door/cli.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// The atlas command: install, upgrade, doctor, check, status, the tooling
|
|
3
|
-
// writer, the
|
|
3
|
+
// writer, the code-read resolver, the STE check, the
|
|
4
4
|
// design check and page builder, the tests verbs (the link check and the
|
|
5
5
|
// test kit writer), the upkeep check, and the judge of the fresh-agent test.
|
|
6
6
|
//
|
|
@@ -18,7 +18,6 @@ import { spawnSync } from 'node:child_process';
|
|
|
18
18
|
import { open } from './lib/pull-request.mjs';
|
|
19
19
|
import { write as writeTooling, changes as toolingChanges, ToolingRefusal, CHECK_PATH, WORKFLOW_PATH } from './lib/tooling.mjs';
|
|
20
20
|
import { statusMany, copies } from './lib/status.mjs';
|
|
21
|
-
import { drift, kindsFromRegistry, fileKind } from './lib/kind-drift.mjs';
|
|
22
21
|
import { scan, toText, verdict, CHECK_VERSION, SPECS } from '../shape/check.mjs';
|
|
23
22
|
import { packagePackageVersion } from './lib/releases.mjs';
|
|
24
23
|
import { install, upgrade, doctor } from './lib/install.mjs';
|
|
@@ -54,9 +53,8 @@ const HELP = `atlas — the door into a repo's Atlas files
|
|
|
54
53
|
atlas status --root <repo> is the repo's check current, behind, unknown or missing
|
|
55
54
|
--all every Atlas copy in the repo: the check, its workflow, each test kit, each pinned Atlas version
|
|
56
55
|
--json print the rows as JSON: type, path, state, have, want, why
|
|
57
|
-
atlas kind-drift --root <repo> --record-kind <kinds>
|
|
58
|
-
does atlas.yaml agree with the repo record
|
|
59
56
|
atlas check --root <repo> run the repo shape check here
|
|
57
|
+
--json print the report as JSON, with the counts of parts, procedures and jobs
|
|
60
58
|
|
|
61
59
|
atlas code-read resolve … resolve the citations of a code digest
|
|
62
60
|
|
|
@@ -210,8 +208,7 @@ A person merges every file the tooling writer writes.
|
|
|
210
208
|
export const FLAGS = Object.freeze({
|
|
211
209
|
tooling: { root: 'optional', branch: 'optional', 'replace-unknown': 'switch', workflow: 'switch', 'dry-run': 'switch' },
|
|
212
210
|
status: { root: 'optional', all: 'switch', json: 'switch' },
|
|
213
|
-
|
|
214
|
-
check: { root: 'optional', repo: 'value', 'fail-on': 'value' },
|
|
211
|
+
check: { root: 'optional', repo: 'value', 'fail-on': 'value', json: 'switch' },
|
|
215
212
|
ste: { share: 'switch', terms: 'value' },
|
|
216
213
|
design: { tracker: 'value', out: 'value', draft: 'switch', root: 'optional' },
|
|
217
214
|
view: { product: 'value', name: 'value', out: 'value' },
|
|
@@ -338,40 +335,6 @@ function copiesCommand(roots, { all, json }) {
|
|
|
338
335
|
return repos.some((repo) => repo.rows.some((one) => bad.includes(one.state))) ? 1 : 0;
|
|
339
336
|
}
|
|
340
337
|
|
|
341
|
-
function kindDriftCommand(chosen) {
|
|
342
|
-
const root = rootOf(chosen);
|
|
343
|
-
let recordKind = chosen['record-kind'] ?? null;
|
|
344
|
-
if (!recordKind && chosen.registry) {
|
|
345
|
-
const kinds = kindsFromRegistry(JSON.parse(readFileSync(resolve(chosen.registry), 'utf8')));
|
|
346
|
-
const repo = chosen.repo ?? null;
|
|
347
|
-
recordKind = repo ? kinds[repo] ?? null : null;
|
|
348
|
-
if (!repo) {
|
|
349
|
-
// One checkout, so only the registry row that names THIS repo can be
|
|
350
|
-
// compared. Comparing every row against one root invented drift that was
|
|
351
|
-
// not there (acceptance review of batch 16, finding S2-6).
|
|
352
|
-
const here = fileKind(root);
|
|
353
|
-
if (!here.repo) { line(`${root} has no atlas.yaml, or its atlas.yaml names no repo, so no registry row can be matched to it.`); return 1; }
|
|
354
|
-
recordKind = kinds[here.repo] ?? null;
|
|
355
|
-
const others = Object.keys(kinds).filter((id) => id !== here.repo);
|
|
356
|
-
const report = drift([{ root, recordKind, recordRepo: here.repo }]);
|
|
357
|
-
for (const row of report.rows) line(`${row.state.toUpperCase().padEnd(10)} ${row.repo} file=${row.file_kind ?? '-'} record=${row.record_kind ?? '-'} ${row.why}`);
|
|
358
|
-
if (!(here.repo in kinds)) line(`The registry holds no row for ${here.repo}. Register it with the repo_add verb.`);
|
|
359
|
-
if (others.length) line(`Not this checkout, so not read: ${others.join(', ')}. Point --root at each one.`);
|
|
360
|
-
return report.drifting ? 1 : 0;
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
const report = drift([{ root, recordKind }]);
|
|
364
|
-
for (const row of report.rows) {
|
|
365
|
-
line(`${row.state.toUpperCase()} ${row.repo ?? row.root}`);
|
|
366
|
-
line(` atlas.yaml kind ${row.file_kind ?? '(none)'} spec ${row.spec ?? '(none)'}`);
|
|
367
|
-
line(` repo record kind ${row.record_kind ?? '(not given)'}`);
|
|
368
|
-
line(` ${row.why}`);
|
|
369
|
-
}
|
|
370
|
-
line('');
|
|
371
|
-
line('Which of the two is the source is open question O-9. This report picks no winner.');
|
|
372
|
-
return report.drifting ? 1 : 0;
|
|
373
|
-
}
|
|
374
|
-
|
|
375
338
|
function steCommand(chosen) {
|
|
376
339
|
const paths = chosen._.slice(1);
|
|
377
340
|
if (!paths.length) throw new Error('give a file or a folder: atlas ste <path> [--share]');
|
|
@@ -484,6 +447,12 @@ function checkCommand(chosen) {
|
|
|
484
447
|
const root = rootOf(chosen);
|
|
485
448
|
const report = scan({ root, repoId: chosen.repo ?? null });
|
|
486
449
|
const decision = verdict(report, chosen['fail-on'] ?? 'error');
|
|
450
|
+
// The O7 count is one command (roadmap R62 line 6): the report, its counts
|
|
451
|
+
// of listed parts, procedures and jobs, and the verdict.
|
|
452
|
+
if (chosen.json) {
|
|
453
|
+
process.stdout.write(`${JSON.stringify({ ...report, fail_on: chosen['fail-on'] ?? 'error', green: decision.green, why: decision.why }, null, 2)}\n`);
|
|
454
|
+
return decision.green ? 0 : 1;
|
|
455
|
+
}
|
|
487
456
|
process.stdout.write(toText(report));
|
|
488
457
|
// A report, never a reason for red: the list is an owner-only key.
|
|
489
458
|
const list = mergeList(root);
|
|
@@ -509,7 +478,6 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
509
478
|
case 'doctor': return doctor({ packageRoot: PACKAGE_ROOT, options: chosen, line });
|
|
510
479
|
case 'tooling': return toolingCommand(chosen);
|
|
511
480
|
case 'status': return statusCommand(chosen);
|
|
512
|
-
case 'kind-drift': return kindDriftCommand(chosen);
|
|
513
481
|
case 'check': return checkCommand(chosen);
|
|
514
482
|
case 'ste': return steCommand(chosen);
|
|
515
483
|
case 'design': return designCommand(chosen);
|
package/door/lib/approve.mjs
CHANGED
|
@@ -14,6 +14,10 @@
|
|
|
14
14
|
// for each with one touch, post the block on
|
|
15
15
|
// each pull request, close each audit issue,
|
|
16
16
|
// and print the one message for the lead
|
|
17
|
+
// atlas approve audit-code <pr> show the audit-code diff of one merged
|
|
18
|
+
// pull request and its red audit check,
|
|
19
|
+
// sign one accept-code act with one touch,
|
|
20
|
+
// post the block, and print the message
|
|
17
21
|
// atlas approve verify --file f verify the blocks of a file against the
|
|
18
22
|
// key file of a repo
|
|
19
23
|
// atlas approve key-check sign a test text and verify it with the
|
|
@@ -30,10 +34,10 @@ import { join, resolve } from 'node:path';
|
|
|
30
34
|
import { createInterface } from 'node:readline/promises';
|
|
31
35
|
import { childEnvironment } from '../../work/lib/child-env.mjs';
|
|
32
36
|
import { parseYaml } from '../../shape/check.mjs';
|
|
33
|
-
import { ghApi, pinnedRuns } from './gate.mjs';
|
|
37
|
+
import { ghApi, pinnedRuns, commitAudit } from './gate.mjs';
|
|
34
38
|
import { GATE_CHECK } from './gate-check.mjs';
|
|
35
39
|
import {
|
|
36
|
-
ACTS, MAX_ACTS, AUDIT_CHECK, AUDIT_TEST, NAMESPACE, SIGNERS_FILE, payloadText, blockText, parseSigners, verifyBlock, verifyTexts, fingerprint, wordsFingerprint, SignedActError,
|
|
40
|
+
ACTS, MAX_ACTS, AUDIT_CHECK, AUDIT_CHECKS, AUDIT_TEST, AUDIT_CODE, AUDIT_CODE_TEST, AUDIT_WORKFLOW, auditCodeDiff, NAMESPACE, SIGNERS_FILE, payloadText, blockText, parseSigners, verifyBlock, verifyTexts, fingerprint, wordsFingerprint, SignedActError,
|
|
37
41
|
preKeyKeys,
|
|
38
42
|
} from './signed-act.mjs';
|
|
39
43
|
|
|
@@ -241,9 +245,8 @@ export async function approvePulls({ prs, repo, root, key, provider, yes = false
|
|
|
241
245
|
// (R14 line 3), and its filing job then ends red on the merge commit. That red
|
|
242
246
|
// job is the check the owner accepts; the test that his words quote is the
|
|
243
247
|
// kind of the finding (item 0ca9fced, design docs/design-docs/audit-red-sign-off/).
|
|
244
|
-
export { AUDIT_CHECK, AUDIT_TEST };
|
|
248
|
+
export { AUDIT_CHECK, AUDIT_TEST, AUDIT_CODE, AUDIT_WORKFLOW };
|
|
245
249
|
export const AUDIT_ISSUE = /^atlas-job: gate-audit owner-unsigned:([0-9a-f]{40})$/;
|
|
246
|
-
export const AUDIT_WORKFLOW = '.github/workflows/atlas-gate-audit.yml';
|
|
247
250
|
// The label on an audit issue whose red the owner accepted. The lead closes
|
|
248
251
|
// the issue after the done tool records the acceptance (security review S3).
|
|
249
252
|
export const ACCEPTED_LABEL = 'atlas-accepted';
|
|
@@ -257,10 +260,6 @@ export function auditWords(prs, at) {
|
|
|
257
260
|
return `I accept the ${AUDIT_TEST} audit reds of ${prs.map((pr) => `#${pr}`).join(', ')}, listed at ${new Date(at).toISOString().slice(0, 16)}Z. These pull requests merged without my touch, under my standing approval.`;
|
|
258
261
|
}
|
|
259
262
|
|
|
260
|
-
// The files whose change lets a merge write its own audit: the audit
|
|
261
|
-
// workflow, the audit and the filer. A merge that changed one is never
|
|
262
|
-
// signed (security review 2, S3).
|
|
263
|
-
export const AUDIT_CODE = Object.freeze([AUDIT_WORKFLOW, 'packages/atlas/door/lib/gate-audit.mjs', 'packages/atlas/door/lib/gate.mjs', 'packages/atlas/door/lib/jobs.mjs', 'packages/atlas/bin/atlas']);
|
|
264
263
|
const AUDIT_ANY = /^atlas-job: gate-audit ([a-z-]+):([0-9a-f]{40})$/;
|
|
265
264
|
|
|
266
265
|
// The owner keys that the done tool reads for one merge: the keys on its
|
|
@@ -274,6 +273,46 @@ function mergeKeys(deps, repo, parent, head) {
|
|
|
274
273
|
return atParent.filter((key) => atFork.has(key.fingerprint));
|
|
275
274
|
}
|
|
276
275
|
|
|
276
|
+
// The owner keys for the act of one merge: the keys of mergeKeys, or, for a
|
|
277
|
+
// merge made before the first owner key, the keys at the tip of the default
|
|
278
|
+
// branch within the limits of preKeyKeys (item 65c6c907).
|
|
279
|
+
async function ownerKeys(deps, repo, { parent, head, main }) {
|
|
280
|
+
const keys = mergeKeys(deps, repo, parent, head);
|
|
281
|
+
if (keys.length) return { keys };
|
|
282
|
+
const read = (path) => { try { return deps.gh('GET', path); } catch (error) { if (error?.status === 404 || /HTTP 404|Not Found/i.test(String(error?.message ?? ''))) return null; throw error; } };
|
|
283
|
+
let first;
|
|
284
|
+
try { first = await preKeyKeys(read, repo, { parent, branch: main }); } catch (error) { first = { why: `GitHub refused a read of the key file history (${plainText(error?.message ?? error).slice(0, 120)})` }; }
|
|
285
|
+
return first.keys ? { keys: first.keys, first_key: first.from } : { keys: [], why: first.why };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Every audit issue of each merge, open or closed, by the kinds of its
|
|
289
|
+
// findings.
|
|
290
|
+
function auditKinds(deps, repo) {
|
|
291
|
+
const kinds = new Map();
|
|
292
|
+
for (let page = 1; page <= 10; page += 1) {
|
|
293
|
+
const rows = deps.gh('GET', `repos/${repo}/issues?state=all&labels=job:gate-audit&per_page=100&page=${page}`) ?? [];
|
|
294
|
+
for (const one of Array.isArray(rows) ? rows : []) {
|
|
295
|
+
const hit = AUDIT_ANY.exec(String(one?.title ?? ''));
|
|
296
|
+
if (hit) kinds.set(hit[2], new Set([...(kinds.get(hit[2]) ?? []), hit[1]]));
|
|
297
|
+
}
|
|
298
|
+
if (!Array.isArray(rows) || rows.length < 100) break;
|
|
299
|
+
}
|
|
300
|
+
return kinds;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Why the audit run of a check is not a push run of the audit workflow on
|
|
304
|
+
// the default branch at this merge commit, or null when it is (S3). It
|
|
305
|
+
// answers the job too.
|
|
306
|
+
function auditRunCause(deps, repo, { run, sha, main }) {
|
|
307
|
+
const job = deps.gh('GET', `repos/${repo}/actions/jobs/${run.id}`);
|
|
308
|
+
const workflow = job?.run_id ? deps.gh('GET', `repos/${repo}/actions/runs/${job.run_id}`) : null;
|
|
309
|
+
if (!workflow) return { cause: 'GitHub gave no workflow run for the audit check' };
|
|
310
|
+
if (workflow.path !== AUDIT_WORKFLOW || workflow.event !== 'push' || workflow.head_branch !== main || workflow.head_sha !== sha) {
|
|
311
|
+
return { cause: `the audit check is not from a push run of ${AUDIT_WORKFLOW} on ${main} at this merge` };
|
|
312
|
+
}
|
|
313
|
+
return { cause: null, job };
|
|
314
|
+
}
|
|
315
|
+
|
|
277
316
|
// Why the red audit run of one merge is not only "owner-unsigned" for this
|
|
278
317
|
// pull request, or null when it is (security review S1). The run must be a
|
|
279
318
|
// push run of the audit workflow on the default branch at this merge commit
|
|
@@ -281,12 +320,8 @@ function mergeKeys(deps, repo, parent, head) {
|
|
|
281
320
|
// FINDINGS line: the state must be "found", not "broken", and each finding
|
|
282
321
|
// must be owner-unsigned for this merge and this pull request.
|
|
283
322
|
export function auditCause(deps, repo, { run, sha, pr, main }) {
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
if (!workflow) return 'GitHub gave no workflow run for the audit check';
|
|
287
|
-
if (workflow.path !== AUDIT_WORKFLOW || workflow.event !== 'push' || workflow.head_branch !== main || workflow.head_sha !== sha) {
|
|
288
|
-
return `the audit check is not from a push run of ${AUDIT_WORKFLOW} on ${main} at this merge`;
|
|
289
|
-
}
|
|
323
|
+
const { cause } = auditRunCause(deps, repo, { run, sha, main });
|
|
324
|
+
if (cause) return cause;
|
|
290
325
|
let log = '';
|
|
291
326
|
try { log = deps.text(`repos/${repo}/actions/jobs/${run.id}/logs`); } catch { return 'the log of the audit check cannot be read'; }
|
|
292
327
|
const lines = String(log ?? '').split('\n').map((one) => one.replace(/^\d{4}-\d{2}-\d{2}T[\d:.]+Z ?/, '').replace(/\r$/, ''));
|
|
@@ -324,21 +359,16 @@ export async function auditRow(deps, repo, issue, main, kinds = new Map()) {
|
|
|
324
359
|
const commit = deps.gh('GET', `repos/${repo}/commits/${sha}`);
|
|
325
360
|
const files = Array.isArray(commit?.files) ? commit.files.map((one) => String(one?.filename ?? '')) : null;
|
|
326
361
|
if (!files) return { ...row, skip: 'GitHub gave no file list of the merge commit' };
|
|
327
|
-
const
|
|
328
|
-
if (
|
|
362
|
+
const code = auditCodeDiff(commit.files);
|
|
363
|
+
if (code.unknown) return { ...row, skip: 'GitHub gave no full file list of the merge commit' };
|
|
364
|
+
if (code.files.length) return { ...row, skip: `the merge changed ${code.files[0]}, so its audit ran its own code: read that diff and sign it with atlas approve audit-code ${row.pr}` };
|
|
329
365
|
const parent = String(commit?.parents?.[0]?.sha ?? '');
|
|
330
366
|
if (!/^[0-9a-f]{40}$/.test(parent)) return { ...row, skip: 'GitHub gave no parent of the merge commit' };
|
|
331
|
-
let keys = mergeKeys(deps, repo, parent, head);
|
|
332
367
|
// Item 65c6c907: a merge made before the first owner key takes the keys
|
|
333
368
|
// at the tip of the default branch, within the limits of preKeyKeys.
|
|
334
|
-
|
|
335
|
-
if (!keys.length) {
|
|
336
|
-
|
|
337
|
-
try { first = await preKeyKeys(read, repo, { parent, branch: main }); } catch (error) { first = { why: `GitHub refused a read of the key file history (${plainText(error?.message ?? error).slice(0, 120)})` }; }
|
|
338
|
-
if (first.keys) keys = first.keys;
|
|
339
|
-
}
|
|
340
|
-
if (!keys.length) return { ...row, skip: `${SIGNERS_FILE} on the base of this merge holds no owner key, so no act can count: close the issue by hand. No first key counts: ${first?.why ?? 'no reason'}` };
|
|
341
|
-
return { ...row, run: String(run.id), keys, ...(first?.from ? { first_key: first.from } : {}) };
|
|
369
|
+
const owner = await ownerKeys(deps, repo, { parent, head, main });
|
|
370
|
+
if (!owner.keys.length) return { ...row, skip: `${SIGNERS_FILE} on the base of this merge holds no owner key, so no act can count: close the issue by hand. No first key counts: ${owner.why ?? 'no reason'}` };
|
|
371
|
+
return { ...row, run: String(run.id), keys: owner.keys, ...(owner.first_key ? { first_key: owner.first_key } : {}) };
|
|
342
372
|
}
|
|
343
373
|
|
|
344
374
|
// `atlas approve audit [<pr>...]`. Exit 0 when each listed red got its act.
|
|
@@ -366,15 +396,7 @@ export async function approveAudit({ prs = [], repo, root, key, provider, yes =
|
|
|
366
396
|
issues.sort((a, b) => Number(a.number) - Number(b.number));
|
|
367
397
|
// Every audit issue of each merge, open or closed: an issue for another
|
|
368
398
|
// finding of the same merge stops its row.
|
|
369
|
-
const kinds =
|
|
370
|
-
for (let page = 1; page <= 10; page += 1) {
|
|
371
|
-
const rows = deps.gh('GET', `repos/${name}/issues?state=all&labels=job:gate-audit&per_page=100&page=${page}`) ?? [];
|
|
372
|
-
for (const one of Array.isArray(rows) ? rows : []) {
|
|
373
|
-
const hit = AUDIT_ANY.exec(String(one?.title ?? ''));
|
|
374
|
-
if (hit) kinds.set(hit[2], new Set([...(kinds.get(hit[2]) ?? []), hit[1]]));
|
|
375
|
-
}
|
|
376
|
-
if (!Array.isArray(rows) || rows.length < 100) break;
|
|
377
|
-
}
|
|
399
|
+
const kinds = auditKinds(deps, name);
|
|
378
400
|
line(` ${issues.length} open audit issue(s) of an owner merge with no touch. Each one can take about 5 seconds.`);
|
|
379
401
|
const rows = [];
|
|
380
402
|
for (const issue of issues) {
|
|
@@ -437,11 +459,146 @@ export async function approveAudit({ prs = [], repo, root, key, provider, yes =
|
|
|
437
459
|
return failed ? 1 : 0;
|
|
438
460
|
}
|
|
439
461
|
|
|
462
|
+
// ------------------------------------------------------- atlas approve audit-code
|
|
463
|
+
|
|
464
|
+
// The owner's words for the audit-code red of one merge. The lead records
|
|
465
|
+
// them, word for word, as one decision. They name the pull request as #N,
|
|
466
|
+
// quote the test, and hold the time, as the words of approveAudit do.
|
|
467
|
+
export function auditCodeWords({ pr, sha, files, check }, at) {
|
|
468
|
+
return `I read the ${AUDIT_CODE_TEST} diff of #${pr} (merge ${sha.slice(0, 7)}): ${files.join(', ')}. I accept its ${AUDIT_CODE_TEST} red of "${check}", listed at ${new Date(at).toISOString().slice(0, 16)}Z.`;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const PASSING = ['success', 'skipped', 'neutral'];
|
|
472
|
+
const RED = ['failure', 'timed_out', 'cancelled', 'action_required', 'startup_failure'];
|
|
473
|
+
|
|
474
|
+
// The audit of one merge commit with the code of this Mac's Atlas, not the
|
|
475
|
+
// code of the merge. It needs the merge, its head and its parent in the clone
|
|
476
|
+
// at `root`. Returns the findings.
|
|
477
|
+
export function macAudit({ repo, root, sha }) {
|
|
478
|
+
// The clone must be a clone of this repo, or every commit reads as unread
|
|
479
|
+
// (security review 2, S2).
|
|
480
|
+
let origin = '';
|
|
481
|
+
try { origin = execFileSync('git', ['-C', root, 'remote', 'get-url', 'origin'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { origin = ''; }
|
|
482
|
+
if (!new RegExp(`^(https://|ssh://git@|git@)github\\.com[:/]${repo.replace(/[.]/g, '\\.')}(\\.git)?$`, 'i').test(origin)) throw new ApproveError(`${root} is not a clone of ${repo}; run the command in the clone`);
|
|
483
|
+
const found = [];
|
|
484
|
+
commitAudit({ repo, root, commit: sha, found }, () => {}, ghApi);
|
|
485
|
+
return found;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// `atlas approve audit-code <pr>` (item f36c6b0f). A merge that changed the
|
|
489
|
+
// audit code ran its own audit, so the batch never signs it. The owner reads
|
|
490
|
+
// the audit-code diff of that one merge here, and one touch signs one
|
|
491
|
+
// accept-code act for its one red audit check. Exit 0 when the act is posted.
|
|
492
|
+
export async function approveAuditCode({ prs = [], repo, root, key, provider, yes = false }, line, deps = DEPS) {
|
|
493
|
+
if (prs.length !== 1 || !/^[1-9][0-9]{0,8}$/.test(String(prs[0]))) throw new ApproveError('atlas approve audit-code takes one pull request number.');
|
|
494
|
+
const pr = Number(prs[0]);
|
|
495
|
+
const name = repoOf({ repo, root });
|
|
496
|
+
const main = String(deps.gh('GET', `repos/${name}`)?.default_branch ?? '');
|
|
497
|
+
if (!main) throw new ApproveError(`GitHub gave no default branch of ${name}.`);
|
|
498
|
+
const pull = deps.gh('GET', `repos/${name}/pulls/${pr}`);
|
|
499
|
+
const sha = String(pull?.merge_commit_sha ?? '');
|
|
500
|
+
const head = String(pull?.head?.sha ?? '');
|
|
501
|
+
if (!pull?.merged_at || !/^[0-9a-f]{40}$/.test(sha) || !/^[0-9a-f]{40}$/.test(head)) throw new ApproveError(`#${pr} is not a merged pull request with a merge commit.`);
|
|
502
|
+
if (pull.base?.ref !== main) throw new ApproveError(`#${pr} did not merge into ${main}; the push audit runs only there.`);
|
|
503
|
+
const commit = deps.gh('GET', `repos/${name}/commits/${sha}`);
|
|
504
|
+
const parent = String(commit?.parents?.[0]?.sha ?? '');
|
|
505
|
+
if (!/^[0-9a-f]{40}$/.test(parent)) throw new ApproveError('GitHub gave no parent of the merge commit.');
|
|
506
|
+
// Only a merge commit with two parents lists the whole change of the pull
|
|
507
|
+
// request against the main branch. A rebase merge lists only its last
|
|
508
|
+
// commit, so the diff could hide audit code (security review 2, S1).
|
|
509
|
+
if (!Array.isArray(commit.parents) || commit.parents.length !== 2) throw new ApproveError(`#${pr} did not merge with a merge commit, so its whole diff cannot be shown. Nothing was signed.`);
|
|
510
|
+
const code = auditCodeDiff(commit?.files);
|
|
511
|
+
if (code.unknown) throw new ApproveError('GitHub gave no full file list of the merge commit.');
|
|
512
|
+
if (!code.files.length) throw new ApproveError(`#${pr} changed no audit code. Use atlas approve audit.`);
|
|
513
|
+
const changed = commit.files.filter((one) => AUDIT_CODE.includes(String(one?.filename ?? '')) || AUDIT_CODE.includes(String(one?.previous_filename ?? '')));
|
|
514
|
+
const cut = changed.find((one) => typeof one.patch !== 'string' && one.status !== 'removed');
|
|
515
|
+
if (cut) throw new ApproveError(`GitHub gave no diff of ${plainText(cut.filename)}, so you cannot read it here. Nothing was signed.`);
|
|
516
|
+
// An audit issue for another finding of this merge stops the act: the
|
|
517
|
+
// touch accepts the audit-code red only (security review S3 of 0ca9fced).
|
|
518
|
+
const more = [...(auditKinds(deps, name).get(sha) ?? [])].filter((kind) => kind !== AUDIT_TEST);
|
|
519
|
+
if (more.length) throw new ApproveError(`another audit issue names this merge: ${more.join(', ')}. Nothing was signed.`);
|
|
520
|
+
// Every run of each audit check, not only the newest attempt.
|
|
521
|
+
const all = (deps.gh('GET', `repos/${name}/commits/${sha}/check-runs?filter=all&per_page=100`)?.check_runs ?? []).filter((run) => AUDIT_CHECKS.includes(run?.name));
|
|
522
|
+
const newest = AUDIT_CHECKS.map((check) => all.filter((run) => run.name === check).sort((a, b) => String(b.started_at ?? '').localeCompare(String(a.started_at ?? '')))[0]).filter(Boolean);
|
|
523
|
+
const open = newest.find((run) => run.status !== 'completed');
|
|
524
|
+
if (open) throw new ApproveError(`"${plainText(open.name)}" has not completed on the merge commit. Nothing was signed.`);
|
|
525
|
+
const red = newest.filter((run) => RED.includes(run.conclusion));
|
|
526
|
+
const odd = newest.find((run) => !RED.includes(run.conclusion) && !PASSING.includes(run.conclusion));
|
|
527
|
+
if (odd) throw new ApproveError(`"${plainText(odd.name)}" ended ${plainText(odd.conclusion)}. Nothing was signed.`);
|
|
528
|
+
if (red.length !== 1) throw new ApproveError(`the merge commit has ${red.length} red audit checks; this command accepts exactly one. Nothing was signed.`);
|
|
529
|
+
const run = red[0];
|
|
530
|
+
if (run.app?.id !== ACTIONS_APP_ID || !/^[1-9][0-9]{0,15}$/.test(String(run.id))) throw new ApproveError(`the red "${plainText(run.name)}" is not a GitHub Actions run.`);
|
|
531
|
+
const pinned = auditRunCause(deps, name, { run, sha, main });
|
|
532
|
+
if (pinned.cause) throw new ApproveError(`${pinned.cause}. Nothing was signed.`);
|
|
533
|
+
// The first parent was a main tip of its own push: its push audit ran at
|
|
534
|
+
// it. So the push of this merge brought this merge commit only, and no
|
|
535
|
+
// commit past the gate hides under it (security review 2, S1).
|
|
536
|
+
const before = (deps.gh('GET', `repos/${name}/commits/${parent}/check-runs?filter=all&per_page=100`)?.check_runs ?? [])
|
|
537
|
+
.filter((one) => AUDIT_CHECKS.includes(one?.name) && one.app?.id === ACTIONS_APP_ID && /^[1-9][0-9]{0,15}$/.test(String(one.id)));
|
|
538
|
+
if (!before.some((one) => !auditRunCause(deps, name, { run: one, sha: parent, main }).cause)) throw new ApproveError(`the parent ${parent.slice(0, 7)} has no push audit run of its own, so the push of #${pr} may hold more than its merge. Nothing was signed.`);
|
|
539
|
+
// A force push can put this merge on a parent that drops a later commit
|
|
540
|
+
// of the main branch. The repo may enforce no branch rule, so the command
|
|
541
|
+
// reads the force pushes of the branch (security review 3, S2).
|
|
542
|
+
const forced = deps.gh('GET', `repos/${name}/activity?ref=${encodeURIComponent(`refs/heads/${main}`)}&activity_type=force_push&per_page=100`);
|
|
543
|
+
if (!Array.isArray(forced)) throw new ApproveError(`GitHub gave no list of the force pushes to ${main}. Nothing was signed.`);
|
|
544
|
+
if (forced.some((one) => String(one?.after ?? '').toLowerCase() === sha)) throw new ApproveError(`a force push put the merge of #${pr} on ${main}. Nothing was signed.`);
|
|
545
|
+
// The audit of that run ran the code of this merge, or never ran. So the
|
|
546
|
+
// Atlas on this Mac audits the merge again with its own code (security
|
|
547
|
+
// review S2): it must find this owner merge and nothing else.
|
|
548
|
+
let found;
|
|
549
|
+
try { found = await (deps.audit ?? macAudit)({ repo: name, root: resolve(root ?? '.'), sha }); }
|
|
550
|
+
catch (error) { throw new ApproveError(`the audit on this Mac stopped: ${plainText(error?.message ?? error).slice(0, 200)}. Nothing was signed.`); }
|
|
551
|
+
if (Array.isArray(found) && found.some((one) => one?.pin === 'unread')) throw new ApproveError(`the audit on this Mac could not read the head or the base of #${pr} in this clone. Run git fetch origin pull/${pr}/head, then the command again. Nothing was signed.`);
|
|
552
|
+
const only = Array.isArray(found) && found.length === 1 && found[0]?.kind === AUDIT_TEST && found[0].sha === sha && Number(found[0].pr) === pr && found[0].key === `${AUDIT_TEST}:${sha}` && found[0].pin === undefined;
|
|
553
|
+
if (!only) throw new ApproveError(`the audit on this Mac found ${Array.isArray(found) && found.length ? found.map((one) => plainText(one?.key ?? one?.kind)).join(', ') : 'nothing to accept'}, not only ${AUDIT_TEST} for #${pr}. Nothing was signed.`);
|
|
554
|
+
const owner = await ownerKeys(deps, name, { parent, head, main });
|
|
555
|
+
if (!owner.keys.length) throw new ApproveError(`${SIGNERS_FILE} holds no owner key that counts for this merge: ${owner.why ?? 'no reason'}.`);
|
|
556
|
+
line(`atlas approve audit-code: ${name} #${pr} ${plainText(pull.title).slice(0, 120)}`);
|
|
557
|
+
line(` merged ${plainText(pull.merged_at)} as ${sha}, head ${head.slice(0, 7)}`);
|
|
558
|
+
line(` red: "${plainText(run.name)}" ${plainText(run.conclusion)}, run ${run.id}.`);
|
|
559
|
+
line(` The audit on this Mac, with its own code, found only ${AUDIT_TEST} for #${pr}.`);
|
|
560
|
+
line(` The merge changed ${commit.files.length} file(s): ${commit.files.map((one) => plainText(one?.filename)).join(', ')}`);
|
|
561
|
+
if (owner.first_key) line(` merged before the first owner key: the act counts against the keys at the tip of ${main}, ${owner.first_key.slice(0, 7)}`);
|
|
562
|
+
line(`Read the audit-code diff of this merge before you touch (${changed.length} file(s)):`);
|
|
563
|
+
for (const one of changed) {
|
|
564
|
+
line(`--- ${plainText(one.previous_filename ? `${one.previous_filename} -> ` : '')}${plainText(one.filename)} (${plainText(one.status)})`);
|
|
565
|
+
for (const each of String(one.patch ?? '').split('\n')) line(` ${plainText(each)}`);
|
|
566
|
+
}
|
|
567
|
+
const words = auditCodeWords({ pr, sha, files: code.files, check: run.name }, deps.now());
|
|
568
|
+
line('Your words, which the lead records as one decision:');
|
|
569
|
+
line(` ${words}`);
|
|
570
|
+
if (!yes && !(await deps.ask(`You read this diff. Accept the ${AUDIT_CODE_TEST} red of #${pr} with one touch?`))) { line('Nothing signed.'); return 1; }
|
|
571
|
+
const setup = (deps.setup ?? signingSetup)({ key, provider });
|
|
572
|
+
const trusted = (deps.trusted ?? trustedKeys)(setup);
|
|
573
|
+
const strange = owner.keys.filter((one) => !trusted.has(one.fingerprint)).map((one) => one.fingerprint);
|
|
574
|
+
if (strange.length) {
|
|
575
|
+
line(`STOP: ${SIGNERS_FILE} holds a key that is not yours: ${strange.join(', ')}.`);
|
|
576
|
+
line('Nothing was signed. Remove that key in a pull request, or pin it in ~/.config/atlas/signing.json if it is yours.');
|
|
577
|
+
return 1;
|
|
578
|
+
}
|
|
579
|
+
const act = { kind: 'accept-code', repo: name, pr, head, merge: sha, run: String(run.id), diff: code.print, words: wordsFingerprint(words), check: run.name };
|
|
580
|
+
const payload = payloadText([act], deps.now());
|
|
581
|
+
line(`Touch the key now (${setup.key}${setup.provider ? `, provider ${setup.provider}` : ''}).`);
|
|
582
|
+
const signature = deps.sign(payload, setup);
|
|
583
|
+
const proof = verifyBlock({ payload, signature }, owner.keys);
|
|
584
|
+
if (proof.error) { line(`The signature does not count for #${pr}: ${proof.error}`); line('Nothing was posted.'); return 1; }
|
|
585
|
+
const text = `${blockText(payload, signature)}\nThe owner read the ${AUDIT_CODE_TEST} diff of #${pr} and accepts its red with \`atlas approve audit-code\`. His words:\n\n> ${words}\n`;
|
|
586
|
+
try { deps.gh('POST', `repos/${name}/issues/${pr}/comments`, { body: text }); }
|
|
587
|
+
catch (error) { line(`#${pr}: GitHub refused the write (${plainText(error?.message ?? error).slice(0, 200)}). Run the command again.`); return 1; }
|
|
588
|
+
line(` posted on #${pr}`);
|
|
589
|
+
line('Now send this one message to the lead, word for word:');
|
|
590
|
+
line('');
|
|
591
|
+
line(words);
|
|
592
|
+
line('');
|
|
593
|
+
return 0;
|
|
594
|
+
}
|
|
595
|
+
|
|
440
596
|
// The act that `atlas approve act <kind>` builds from its flags.
|
|
441
597
|
export function actFrom(kind, flags, read = (path) => readFileSync(resolve(path), 'utf8')) {
|
|
442
|
-
if (!Object.hasOwn(ACTS, kind ?? '')) throw new ApproveError(`there is no act "${kind ?? ''}". Use ${Object.keys(ACTS).filter((each) =>
|
|
598
|
+
if (!Object.hasOwn(ACTS, kind ?? '')) throw new ApproveError(`there is no act "${kind ?? ''}". Use ${Object.keys(ACTS).filter((each) => !['merge', 'accept', 'accept-code'].includes(each)).join(', ')}; a pull request takes atlas approve <pr>.`);
|
|
443
599
|
if (kind === 'merge') throw new ApproveError('a pull request takes atlas approve <pr>, which reads its gate first.');
|
|
444
600
|
if (kind === 'accept') throw new ApproveError('an audit red takes atlas approve audit, which reads the audit issues first.');
|
|
601
|
+
if (kind === 'accept-code') throw new ApproveError('the audit red of an audit-code merge takes atlas approve audit-code <pr>, which shows its diff first.');
|
|
445
602
|
const need = (flag) => {
|
|
446
603
|
const value = flags[flag];
|
|
447
604
|
if (value === undefined || value === true || value === '') throw new ApproveError(`atlas approve act ${kind} needs --${flag}.`);
|
|
@@ -513,6 +670,7 @@ export async function approveCommand({ words = [], repo, root, key, provider, ye
|
|
|
513
670
|
const common = { key, provider, yes };
|
|
514
671
|
try {
|
|
515
672
|
if (words[0] === 'act') return await approveAct({ ...common, kind: words[1], flags, out }, line, deps);
|
|
673
|
+
if (words[0] === 'audit-code') return await approveAuditCode({ ...common, prs: words.slice(1), repo, root }, line, deps);
|
|
516
674
|
if (words[0] === 'audit') return await approveAudit({ ...common, prs: words.slice(1), repo, root }, line, deps);
|
|
517
675
|
if (words[0] === 'verify') return approveVerify({ file, signers, root }, line);
|
|
518
676
|
if (words[0] === 'key-check') return await keyCheck(common, line, deps);
|
package/door/lib/owner-only.mjs
CHANGED
|
@@ -215,7 +215,7 @@ export function atlasYamlCause(baseText, headText, rules) {
|
|
|
215
215
|
if (!changed.length) return 'atlas.yaml text changed outside its keys';
|
|
216
216
|
for (const key of changed) {
|
|
217
217
|
if (rules.fixed.includes(key)) return `atlas.yaml key ${key} changed`;
|
|
218
|
-
if (!rules.add_only.includes(key)) return `atlas.yaml key ${key} changed, and
|
|
218
|
+
if (!rules.add_only.includes(key)) return `atlas.yaml key ${key} changed, and the key list in owner-only.yaml names no rule for it`;
|
|
219
219
|
if (!onlyAdds(a[key], b[key])) return `atlas.yaml key ${key}: an entry was removed, moved or changed`;
|
|
220
220
|
}
|
|
221
221
|
return null;
|
package/door/lib/signed-act.mjs
CHANGED
|
@@ -89,6 +89,11 @@ export const ACTS = Object.freeze({
|
|
|
89
89
|
// S1). It names the fingerprint of the owner's words, not a decision id, so
|
|
90
90
|
// one touch comes before the one message that the lead records.
|
|
91
91
|
accept: { fields: ['repo', 'pr', 'head', 'run', 'words', 'check'], form: `(${REPO}) (${NUMBER}) (${SHA}) ([1-9][0-9]{0,15}) (${HASH}) ([^\\s].{0,99})` },
|
|
92
|
+
// The acceptance of the red audit check of one merge that changed the
|
|
93
|
+
// audit code, made with `atlas approve audit-code` after the owner read
|
|
94
|
+
// that diff (item f36c6b0f). It names the merge commit and the fingerprint
|
|
95
|
+
// of its audit-code change, so the touch covers only the diff he read.
|
|
96
|
+
'accept-code': { fields: ['repo', 'pr', 'head', 'merge', 'run', 'diff', 'words', 'check'], form: `(${REPO}) (${NUMBER}) (${SHA}) (${SHA}) ([1-9][0-9]{0,15}) (${HASH}) (${HASH}) ([^\\s].{0,99})` },
|
|
92
97
|
});
|
|
93
98
|
|
|
94
99
|
// The fingerprint that a lock or a decision act names: sha256 of the text,
|
|
@@ -105,6 +110,40 @@ export const AUDIT_TEST = 'owner-unsigned';
|
|
|
105
110
|
// tests, so a release never turns it into a warning (item 9348568f).
|
|
106
111
|
export const AUDIT_CHECKS = Object.freeze(['gate · push audit', AUDIT_CHECK]);
|
|
107
112
|
|
|
113
|
+
// The files whose change lets a merge write its own audit: every repo file
|
|
114
|
+
// of the gate and audit groups of GATE_FILE_GROUPS in gate-audit.mjs (a test
|
|
115
|
+
// keeps this list a superset), the signed act that the gate reads, and the
|
|
116
|
+
// job file of the audit. `atlas approve audit` never signs a merge that
|
|
117
|
+
// changed one (security review 2 of item 0ca9fced, S3). Such a merge takes
|
|
118
|
+
// an accept-code act instead, and its words quote the test AUDIT_CODE_TEST
|
|
119
|
+
// (item f36c6b0f).
|
|
120
|
+
export const AUDIT_WORKFLOW = '.github/workflows/atlas-gate-audit.yml';
|
|
121
|
+
export const AUDIT_CODE = Object.freeze([
|
|
122
|
+
AUDIT_WORKFLOW, '.github/workflows/atlas-gate.yml', '.github/workflows/atlas-jobs.yml',
|
|
123
|
+
'packages/atlas/bin/atlas', 'packages/atlas/door/cli.mjs', 'packages/atlas/owner-only.yaml', 'packages/atlas/door/jobs/gate-audit.yaml',
|
|
124
|
+
...['gate.mjs', 'gate-audit.mjs', 'gate-check.mjs', 'owner-only.mjs', 'jobs.mjs', 'issues.mjs', 'signed-act.mjs'].map((name) => `packages/atlas/door/lib/${name}`),
|
|
125
|
+
'packages/atlas/shape/check.mjs', 'packages/atlas/work/lib/child-env.mjs',
|
|
126
|
+
]);
|
|
127
|
+
export const AUDIT_CODE_TEST = 'audit-code';
|
|
128
|
+
// GitHub lists at most 300 files of one commit.
|
|
129
|
+
const COMMIT_FILES = 300;
|
|
130
|
+
|
|
131
|
+
// The audit-code change of one merge commit, from GitHub's file list of that
|
|
132
|
+
// commit: { files, print }, where print is the fingerprint of each changed
|
|
133
|
+
// audit-code file with its state and its blob, or { files: [] } for none. A
|
|
134
|
+
// rename counts by its old name too. A list that is missing or may be cut
|
|
135
|
+
// gives { unknown: true }.
|
|
136
|
+
export function auditCodeDiff(files) {
|
|
137
|
+
if (!Array.isArray(files) || files.length >= COMMIT_FILES) return { unknown: true };
|
|
138
|
+
const hits = files.filter((one) => AUDIT_CODE.includes(String(one?.filename ?? '')) || AUDIT_CODE.includes(String(one?.previous_filename ?? '')));
|
|
139
|
+
if (!hits.length) return { files: [] };
|
|
140
|
+
const rows = hits.map((one) => `${one.previous_filename ? `${one.previous_filename}>` : ''}${one.filename} ${one.status} ${one.sha ?? '-'}`).sort();
|
|
141
|
+
// Each file by its name in AUDIT_CODE, never by a new name that a rename
|
|
142
|
+
// chose, so the words and the refusals hold only names of the list.
|
|
143
|
+
const named = hits.map((one) => (AUDIT_CODE.includes(String(one.filename)) ? String(one.filename) : String(one.previous_filename)));
|
|
144
|
+
return { files: [...new Set(named)].sort(), print: fingerprint(rows.join('\n')) };
|
|
145
|
+
}
|
|
146
|
+
|
|
108
147
|
// The fingerprint that an accept act names: the owner's words with each run
|
|
109
148
|
// of white space made one space, and no space at the ends. A chat can wrap a
|
|
110
149
|
// line or drop a last line end; that does not change the words.
|
package/door/lib/tooling.mjs
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
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, OLD_WORKFLOW_PATH, WORKFLOW_TEMPLATE, SHAPE_JOB_TEXT, shapeJobOf, 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, SPECS, SPEC_3, 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';
|
|
@@ -122,12 +122,43 @@ export function isPackageRepo(root, packageRoot) {
|
|
|
122
122
|
// refusal that says what to do instead.
|
|
123
123
|
const readWorkflow = (root) => { const full = join(root, WORKFLOW_PATH); return existsSync(full) ? readFileSync(full, 'utf8') : null; };
|
|
124
124
|
|
|
125
|
+
// The spec 3 lines for a repo whose atlas.yaml is older (roadmap R62). Check
|
|
126
|
+
// version 10 reads spec 3 only, so a copy written into a spec 2 repo would turn
|
|
127
|
+
// its own check red. The lines name the parts this repo already carries: the
|
|
128
|
+
// two entry maps, the index and the two tooling files, plus the rules and a
|
|
129
|
+
// linter config where the repo has them.
|
|
130
|
+
export function specThreeLines(root) {
|
|
131
|
+
const parts = ['claude-md', 'agents-md'];
|
|
132
|
+
if (existsSync(join(root, '.claude', 'rules'))) parts.push('rules');
|
|
133
|
+
parts.push('docs-index', 'check-file', 'gate-workflow');
|
|
134
|
+
const linters = SPEC_3.files.flatMap((file) => file.parts).find((part) => part.id === 'linters').candidates;
|
|
135
|
+
if (linters.some((path) => existsSync(join(root, path)))) parts.push('linters');
|
|
136
|
+
return [`parts: [${parts.join(', ')}]`];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// The spec version atlas.yaml declares, or null when there is no file or it
|
|
140
|
+
// does not read. The check reports those cases itself.
|
|
141
|
+
function declaredSpec(root) {
|
|
142
|
+
const file = join(root, 'atlas.yaml');
|
|
143
|
+
if (!existsSync(file)) return null;
|
|
144
|
+
try { return parseYaml(readFileSync(file, 'utf8'))?.spec ?? null; } catch { return null; }
|
|
145
|
+
}
|
|
146
|
+
|
|
125
147
|
export function mayWrite(root, packageRoot, { replaceUnknown = false, workflowOnly = false } = {}) {
|
|
126
148
|
const pkg = packagedCheck(packageRoot);
|
|
127
149
|
const own = isPackageRepo(root, packageRoot);
|
|
128
150
|
if (!pkg.latest && !own) {
|
|
129
151
|
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.`);
|
|
130
152
|
}
|
|
153
|
+
const spec = declaredSpec(root);
|
|
154
|
+
if (spec !== null && !workflowOnly && !SPECS[spec]) {
|
|
155
|
+
throw new ToolingRefusal([
|
|
156
|
+
`atlas.yaml is spec ${spec}. Check version ${CHECK_VERSION} reads spec ${Object.keys(SPECS).join(', ')} only.`,
|
|
157
|
+
'Set spec: 3, remove the kind: line, and add:',
|
|
158
|
+
...specThreeLines(root),
|
|
159
|
+
'Then run atlas tooling again. Nothing was written.',
|
|
160
|
+
].join('\n'));
|
|
161
|
+
}
|
|
131
162
|
const now = status({ root, packageRoot });
|
|
132
163
|
if (now.state === 'current' && !workflowOnly && !templateMissing(root, packageRoot) && !pinnedNeedWrite(root, packageRoot) && !oldWorkflow(root)) {
|
|
133
164
|
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 });
|