@arjunkhera/atlas 0.4.25 → 0.4.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atlas",
3
- "version": "0.4.25",
3
+ "version": "0.4.26",
4
4
  "description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, for any repository.",
5
5
  "author": {
6
6
  "name": "Arjun Khera"
@@ -4,7 +4,9 @@
4
4
  # the same findings, with the same keys, so one break opens one issue.
5
5
  #
6
6
  # kind is one of: push (no pull request merged it), force-push,
7
- # merge-before-green, gate-copy (a gate run not from the base workflow file).
7
+ # merge-before-green, gate-copy (a gate run not from the base workflow file),
8
+ # owner-unsigned (the gate sends the merge to the owner, and no signed act
9
+ # names its head; the push audit only, because it needs the clone).
8
10
  # A closed issue does not stop a new one while its commit is in the window:
9
11
  # anyone with the shared login can close or hand over an issue.
10
12
  title: Each commit on the main branch came through the gate
@@ -10,8 +10,10 @@
10
10
  //
11
11
  // The answer is success, owner or red:
12
12
  //
13
- // red a secret in an added line; product code that changed with no test
14
- // case added or changed; a head that git cannot read
13
+ // red a secret in an added line; a private term in an added line or a
14
+ // path that ships, or no terms when atlas.yaml names shipped
15
+ // paths; product code that changed with no test case added or
16
+ // changed; a head that git cannot read
15
17
  // owner a path or a sign of the owner-only list; a head job named like a
16
18
  // check; a refactor claim (until the review job, roadmap R28); a
17
19
  // file too large to scan; the brake (roadmap R13, brake.mjs)
@@ -31,7 +33,8 @@
31
33
  import { spawnSync } from 'node:child_process';
32
34
  import { readFileSync, appendFileSync } from 'node:fs';
33
35
  import { resolve } from 'node:path';
34
- import { classify, changedPaths, compile, matcher, fakeSecret } from './owner-only.mjs';
36
+ import { classify, changedPaths, compile, matcher, fakeSecret, repoFacts } from './owner-only.mjs';
37
+ import { MISSING, HIT, fileHits, hidePath, shippedPaths, ships, termsFromEnv } from './private-terms.mjs';
35
38
  import { listChanged } from './changed-tests.mjs';
36
39
  import { parseYaml, SECRET_PATTERNS } from '../../shape/check.mjs';
37
40
  import { parseSigners, verifyTexts, findAct, SIGNERS_FILE, BRANCH_FORM } from './signed-act.mjs';
@@ -115,6 +118,39 @@ export function secretRows(root, base, head, changes) {
115
118
  return rows;
116
119
  }
117
120
 
121
+ // ------------------------------------------------------------------ private terms
122
+
123
+ // The private-terms rule (roadmap R18, design docs/design-docs/private-terms/).
124
+ // It holds when the base atlas.yaml names `private_terms: { paths }`, the
125
+ // paths that ship in public. `terms` comes from the repo secret. It reads the
126
+ // whole head text of each changed file that ships, binary or not, so a term
127
+ // split over two lines, or already in an old line, is found too. A row names
128
+ // the path, with [hidden] for a term in it, and the line, never the term.
129
+ export function termRows(root, base, head, changes, terms) {
130
+ const paths = shippedPaths(repoFacts(root, base).facts);
131
+ if (paths === null) return [];
132
+ if (!paths.length) return [{ kind: 'red', class: 'private-term', cause: 'atlas.yaml private_terms names no paths' }];
133
+ if (!terms.length) return [{ kind: 'red', class: 'private-term', cause: MISSING }];
134
+ const rows = [];
135
+ for (const change of changes) {
136
+ if (change.status === 'D' || change.newMode === '160000' || !ships(paths, change.path)) continue;
137
+ const size = Number((git(root, ['cat-file', '-s', `${head}:${change.path}`], { allowFail: true }) ?? '0').trim());
138
+ if (size > SCAN_LIMIT) { rows.push({ kind: 'red', class: 'private-term', path: change.path, cause: `${size} bytes: too large to check for private terms` }); continue; }
139
+ for (const hit of fileHits(change.path, textAt(root, head, change.path) ?? '', terms)) {
140
+ rows.push({ kind: 'red', class: 'private-term', path: hit.path, ...(hit.line ? { line: hit.line } : {}), cause: hit.line ? `the line ${HIT}` : `the path ${HIT}` });
141
+ }
142
+ }
143
+ rows.push({ kind: 'note', class: 'private-term', cause: `private terms: ${terms.length} term(s), ${rows.length} hit(s)` });
144
+ return rows;
145
+ }
146
+
147
+ // The answer with each term hidden in every path and cause, so no row of any
148
+ // rule shows a term in the summary, the log or the JSON.
149
+ export function hideTerms(answer, terms) {
150
+ if (!terms?.length) return answer;
151
+ return { ...answer, rows: answer.rows.map((row) => ({ ...row, ...(row.path ? { path: hidePath(row.path, terms) } : {}), cause: hidePath(String(row.cause ?? ''), terms) })) };
152
+ }
153
+
118
154
  // ------------------------------------------------------------------ jobs named like a check
119
155
 
120
156
  const WORKFLOW = /^\.github\/workflows\/[^/]+\.ya?ml$/;
@@ -230,7 +266,9 @@ function testRows({ root, mergeBase, head, body, redMain, rules, undo = null })
230
266
  // the replay does, because the brake depends on the day of the run. `signed`
231
267
  // is { repo, pr, baseRef, texts }: the texts are the comments of the pull
232
268
  // request, read for a signed act, and baseRef is the name of its base branch.
233
- export function judge({ root, base, head, body = '', redMain = [], rules = null, brake = {}, now = new Date(), signed = null }) {
269
+ // `terms` is the list of private terms, or undefined to skip that rule (the
270
+ // replay, which has no secret).
271
+ export function judge({ root, base, head, body = '', redMain = [], rules = null, brake = {}, now = new Date(), signed = null, terms = undefined }) {
234
272
  const top = resolve(root);
235
273
  const baseSha = commitOf(top, base);
236
274
  if (!baseSha) throw new GateCheckError(`the base "${base}" is not a commit in this repo.`);
@@ -257,6 +295,7 @@ export function judge({ root, base, head, body = '', redMain = [], rules = null,
257
295
  for (const row of classify({ root: top, base: mergeBase, head: headSha, rules }).rows) answer.rows.push({ kind: 'owner', class: row.class ?? 'unlisted', path: row.path, cause: row.cause });
258
296
  answer.rows.push(...jobRows(top, rules ?? mergeBase, headSha, changes));
259
297
  answer.rows.push(...secretRows(top, mergeBase, headSha, changes));
298
+ if (terms !== undefined) answer.rows.push(...termRows(top, baseSha, headSha, changes, terms));
260
299
  answer.rows.push(...testRows({ root: top, mergeBase, head: headSha, body, redMain, rules, undo }));
261
300
  if (signed) signedRows(top, baseSha, headSha, signed, answer);
262
301
  return finish(answer);
@@ -353,7 +392,8 @@ export function checkCommand({ root, base, head, body: bodyFile, json = false, s
353
392
  try { texts = comments(); } catch { texts = null; }
354
393
  signed = { repo, pr: Number(pr), baseRef: baseRef && baseRef !== true ? String(baseRef) : null, texts };
355
394
  }
356
- const answer = judge({ root, base, head, body, redMain, brake: Array.isArray(got) ? {} : got.brake ?? {}, signed });
395
+ const { terms } = termsFromEnv();
396
+ const answer = hideTerms(judge({ root, base, head, body, redMain, brake: Array.isArray(got) ? {} : got.brake ?? {}, signed, terms }), terms);
357
397
  const text = renderText(answer);
358
398
  if (json) process.stdout.write(`${JSON.stringify(answer, null, 2)}\n`);
359
399
  else for (const each of text) line(each);
@@ -362,7 +402,7 @@ export function checkCommand({ root, base, head, body: bodyFile, json = false, s
362
402
  if (output && output !== true) appendFileSync(output, `result=${answer.result}\nclass=${/^[a-z-]+$/.test(answer.class ?? '') ? answer.class : 'none'}\n`);
363
403
  return answer.result === 'success' ? 0 : 1;
364
404
  } catch (error) {
365
- line(`gate check: ${error.message}`);
405
+ line(`gate check: ${hidePath(error.message, termsFromEnv().terms)}`);
366
406
  // In the gate workflow (it passes --output), an error leaves a note with a
367
407
  // fixed title. The audit reads it to tell an error from a result (roadmap
368
408
  // R14 done line 5).
package/door/lib/gate.mjs CHANGED
@@ -20,7 +20,7 @@ import { homedir } from 'node:os';
20
20
  import { dirname, join } from 'node:path';
21
21
  import { childEnvironment } from '../../work/lib/child-env.mjs';
22
22
  import { parseYaml } from '../../shape/check.mjs';
23
- import { checkCommand, replayCommand, jobNames, GATE_CHECK, GATE_WORKFLOW } from './gate-check.mjs';
23
+ import { checkCommand, replayCommand, judge, jobNames, GATE_CHECK, GATE_WORKFLOW } from './gate-check.mjs';
24
24
  import { keepFindings, gateErrors, pinCode, slug, AUDIT_JOB } from './gate-audit.mjs';
25
25
  import { MARKER as SIGNED_MARKER } from './signed-act.mjs';
26
26
  import { brakeFacts, windowCommits, ciTrips, BrakeError } from './brake.mjs';
@@ -422,7 +422,7 @@ export function keepReady({ repo, pr, sha, code, home = homedir(), now = new Dat
422
422
 
423
423
  // `atlas gate audit --since <date>`: each pull request merged since the date
424
424
  // whose required checks were not all green at the moment of the merge.
425
- export function auditCommand({ repo, since, commit, errors = false, forced = false, findings }, line, gh = ghApi) {
425
+ export function auditCommand({ repo, root, since, commit, errors = false, forced = false, findings }, line, gh = ghApi) {
426
426
  // R14: with --findings, each finding also goes into the file, and `atlas
427
427
  // jobs file` opens one issue for each. A call that stops with an error
428
428
  // marks the file broken, so the file job ends red.
@@ -431,7 +431,7 @@ export function auditCommand({ repo, since, commit, errors = false, forced = fal
431
431
  try {
432
432
  let code;
433
433
  if (errors) code = gateErrors({ repo, found }, line, gh);
434
- else if (commit !== undefined) code = commitAudit({ repo, commit, forced, found }, line, gh);
434
+ else if (commit !== undefined) code = commitAudit({ repo, root, commit, forced, found }, line, gh);
435
435
  else code = sinceAudit({ repo, since }, line, gh);
436
436
  keep();
437
437
  return code;
@@ -472,7 +472,22 @@ function sinceAudit({ repo, since }, line, gh) {
472
472
  // gate run of that pull request came from the base copy of the gate
473
473
  // workflow. A commit that no pull request merged was pushed past the gate, so
474
474
  // it is red. With --forced, the push that brought it was a force push.
475
- export function commitAudit({ repo, commit, forced = false, found = [] }, line, gh) {
475
+ // R14 done line 3: a merge that the gate sent to the owner needs the owner's
476
+ // signed act for its head (R72). The audit judges the merged head again with
477
+ // the base commit's rules, at the time of the merge, and reads the signed acts
478
+ // of the pull request. It needs both commits in the clone. Returns 'owner'
479
+ // when no signed act clears it, 'unread' when it cannot judge, else null.
480
+ export function ownerUnsigned({ root, repo, pull, parent }, gh) {
481
+ const head = pull.head?.sha;
482
+ const has = (sha) => { try { execFileSync('git', ['-C', root, 'cat-file', '-e', `${sha}^{commit}`], { stdio: 'ignore' }); return true; } catch { return false; } };
483
+ if (!/^[0-9a-f]{40}$/.test(String(head)) || !/^[0-9a-f]{40}$/.test(String(parent)) || !has(head) || !has(parent)) return 'unread';
484
+ let texts = null;
485
+ try { texts = prComments(gh, repo, pull.number); } catch { texts = null; }
486
+ const answer = judge({ root, base: parent, head, body: String(pull.body ?? ''), now: new Date(pull.merged_at), signed: { repo, pr: Number(pull.number), baseRef: pull.base?.ref ?? null, texts } });
487
+ return answer.result === 'owner' ? 'owner' : null;
488
+ }
489
+
490
+ export function commitAudit({ repo, root = null, commit, forced = false, found = [] }, line, gh) {
476
491
  if (!commit || commit === true || !/^[0-9a-f]{7,40}$/i.test(String(commit))) throw new GateError('--commit needs a commit SHA.');
477
492
  // GitHub expands a short SHA, and names the first parent: the main branch
478
493
  // just before this commit, whose atlas.yaml held the gate at that moment.
@@ -498,6 +513,16 @@ export function commitAudit({ repo, commit, forced = false, found = [] }, line,
498
513
  found.push({ job: AUDIT_JOB, key: `gate-copy:${sha}`, kind: 'gate-copy', sha, pr: Number(pull.number), ...(one.run ? { run_id: Number(one.run) } : {}), pin: pinCode(one.why) });
499
514
  }
500
515
  const parent = fetched.parents?.[0]?.sha ?? pull.base.sha;
516
+ if (root) {
517
+ const owner = ownerUnsigned({ root, repo, pull, parent }, gh);
518
+ if (owner) {
519
+ red = true;
520
+ line(owner === 'owner'
521
+ ? `- ✗ #${pull.number} merged while the gate sent it to the owner, and no signed act names its head ${String(pull.head?.sha).slice(0, 7)}.`
522
+ : `- ✗ #${pull.number}: the head or the base commit is not in this clone, so the audit cannot judge it.`);
523
+ found.push({ job: AUDIT_JOB, key: `owner-unsigned:${sha}`, kind: 'owner-unsigned', sha, pr: Number(pull.number), ...(owner === 'unread' ? { pin: 'unread' } : {}) });
524
+ }
525
+ } else line(`- atlas gate audit: no clone (--root), so the owner check of #${pull.number} did not run.`);
501
526
  let checks;
502
527
  try { checks = checksAt(gh, repo, parent); } catch (error) {
503
528
  if (!(error instanceof GateError)) throw error;
@@ -629,7 +654,7 @@ export function gateCommand({ what, repo, root, dryRun = false, off = false, on
629
654
  }
630
655
  return code;
631
656
  }
632
- if (what === 'audit') return auditCommand({ repo, since, commit, errors, forced, findings }, line, gh);
657
+ if (what === 'audit') return auditCommand({ repo, root, since, commit, errors, forced, findings }, line, gh);
633
658
  if (off && on) throw new GateError('pick --off or --on, not both.');
634
659
  const mode = off ? 'off' : on ? 'on' : 'apply';
635
660
  const facts = mode === 'apply' ? gateFacts(root) : null;
@@ -36,15 +36,37 @@ export function loadPrivateTerms({ path = null, home = homedir() } = {}) {
36
36
  if (path) throw new Error(`the private terms file ${path} does not exist.`);
37
37
  return [];
38
38
  }
39
- return readFileSync(file, 'utf8').split('\n').map((line) => line.trim()).filter((line) => line && !line.startsWith('#'));
39
+ return parseTerms(readFileSync(file, 'utf8'));
40
+ }
41
+
42
+ // The terms of one terms file's text: one term on each line, and a line that
43
+ // starts with # is a note. The Mac file and the repo secret share this form.
44
+ export function parseTerms(text) {
45
+ return String(text ?? '').split('\n').map((line) => line.trim()).filter((line) => line && !line.startsWith('#'));
46
+ }
47
+
48
+ // Text as the term rules read it: NFKC form, with the marks that do not
49
+ // show (a soft hyphen, the zero-width marks, a byte-order mark) taken out.
50
+ // It keeps every line break, so a line number stays true.
51
+ const INVISIBLE = /[\u00AD\u200B-\u200D\u2060\uFEFF]/g;
52
+ export const termText = (text) => String(text ?? '').normalize('NFKC').replace(INVISIBLE, '');
53
+
54
+ // One rule for each private term. A term matches only as a whole word, in any
55
+ // case. The words of a term may be split by any run of white space, also a
56
+ // line break, so a wrapped paragraph still matches when the caller reads the
57
+ // whole text.
58
+ export function termRules(terms = []) {
59
+ return terms.map((term) => {
60
+ const words = termText(term).split(/\s+/).filter(Boolean).map(escape);
61
+ return { id: 'private-term', holds: 'One of the owner\'s private terms.', pattern: new RegExp(`(?<![\\p{L}\\p{N}])${words.join('\\s+')}(?![\\p{L}\\p{N}])`, 'iu') };
62
+ });
40
63
  }
41
64
 
42
65
  // Every rule this filter applies: the secret patterns, the generic patterns,
43
66
  // then one rule for each private term.
44
67
  export function privacyRules({ terms = [] } = {}) {
45
68
  const secrets = secretPatterns().map((pattern, index) => ({ id: `secret-${SECRET_PATTERNS[index].id}`, holds: SECRET_PATTERNS[index].holds, pattern }));
46
- const own = terms.map((term) => ({ id: 'private-term', holds: 'One of the owner\'s private terms.', pattern: new RegExp(`(?<![A-Za-z0-9])${escape(term)}(?![A-Za-z0-9])`, 'i') }));
47
- return [...secrets, ...PRIVACY_PATTERNS, ...own];
69
+ return [...secrets, ...PRIVACY_PATTERNS, ...termRules(terms)];
48
70
  }
49
71
 
50
72
  // Each finding names its line and its rule, never the text it matched, so a
@@ -0,0 +1,65 @@
1
+ // The private-terms check of a public package (roadmap R18, tracker item
2
+ // 437b99c1; design docs/design-docs/private-terms/). The release build runs
3
+ // it on each file of the tarball, and the gate runs it on each added line
4
+ // under the shipped paths of a pull request.
5
+ //
6
+ // The terms come from the repo secret ATLAS_PRIVATE_TERMS, in the form of the
7
+ // owner's terms file. This plan gives a private repo no environments, so any
8
+ // workflow run of this repo can read that secret, on any branch. A fork
9
+ // cannot. The design accepts that: who can push here can already publish.
10
+ //
11
+ // A hit names the path and the line, never the term. A path that holds a
12
+ // term shows [hidden] in its place. The text is read in NFKC form with the
13
+ // marks that do not show taken out, and the words of a term may be split by
14
+ // any white space.
15
+ import { parseTerms, redact, termRules, termText } from './privacy.mjs';
16
+
17
+ export const TERMS_ENV = 'ATLAS_PRIVATE_TERMS';
18
+ export const MISSING = `the private terms are missing: the repo secret ${TERMS_ENV} is empty`;
19
+ export const HIT = 'holds a private term';
20
+
21
+ // The terms from the environment. `set` is false when the variable is absent
22
+ // or holds notes only.
23
+ export function termsFromEnv(env = process.env) {
24
+ const terms = parseTerms(env[TERMS_ENV]);
25
+ return { set: terms.length > 0, terms };
26
+ }
27
+
28
+ // The text with each term replaced by [hidden]. Text with no term comes back
29
+ // as it was.
30
+ export function hidePath(text, terms) {
31
+ if (!terms?.length) return text;
32
+ const clean = termText(text);
33
+ const hidden = redact(clean, { rules: termRules(terms) }).replace(/\[hidden: private-term\]/g, '[hidden]');
34
+ return hidden === clean ? text : hidden;
35
+ }
36
+
37
+ // The hits of one file: { path, line } for the first line of each match, and
38
+ // { path, line: null } when the path holds a term. `path` is already hidden.
39
+ // The whole text is read at once, so a term split over two lines still
40
+ // matches.
41
+ export function fileHits(path, text, terms) {
42
+ const rules = termRules(terms);
43
+ const shown = hidePath(path, terms);
44
+ const hits = [];
45
+ if (rules.some((rule) => rule.pattern.test(termText(path)))) hits.push({ path: shown, line: null });
46
+ const body = termText(text);
47
+ const lines = new Set();
48
+ for (const rule of rules) {
49
+ const all = new RegExp(rule.pattern.source, `${rule.pattern.flags}g`);
50
+ for (const match of body.matchAll(all)) lines.add(body.slice(0, match.index).split('\n').length);
51
+ }
52
+ for (const line of [...lines].sort((a, b) => a - b)) hits.push({ path: shown, line });
53
+ return hits;
54
+ }
55
+
56
+ // The shipped paths that atlas.yaml names under private_terms.paths, or null
57
+ // when the key is not there.
58
+ export function shippedPaths(facts) {
59
+ const entry = facts?.private_terms;
60
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null;
61
+ const paths = Array.isArray(entry.paths) ? entry.paths.filter((path) => typeof path === 'string' && path) : [];
62
+ return paths;
63
+ }
64
+
65
+ export const ships = (paths, path) => paths.some((prefix) => (prefix.endsWith('/') ? path.startsWith(prefix) : path === prefix || path.startsWith(`${prefix}/`)));
@@ -12,6 +12,8 @@ on:
12
12
  # 3. A merge made before the required checks of its pull request were
13
13
  # green. A check counts only from a run of the base file that defines it.
14
14
  # 4. A gate run that is not from the base copy of atlas-gate.yml.
15
+ # 5. A merge that the gate sends to the owner, with no signed act of the
16
+ # owner for its head (R72). The audit judges the head again from git.
15
17
  # Each finding opens one `atlas-job` issue, and the run ends red. It does not
16
18
  # undo the push.
17
19
  #
@@ -81,6 +81,8 @@ jobs:
81
81
  - name: Read the pull request text from the event
82
82
  run: node -e 'const fs=require("fs");const e=JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH,"utf8"));fs.writeFileSync(process.env.RUNNER_TEMP+"/body.md",String(e.pull_request.body??""))'
83
83
 
84
+ # Only this step gets the private terms (roadmap R18). It runs the base
85
+ # code, and reads the head as git data.
84
86
  - name: Gate check
85
87
  id: check
86
88
  env:
@@ -88,6 +90,7 @@ jobs:
88
90
  BASE_REF: ${{ github.event.pull_request.base.ref }}
89
91
  HEAD_SHA: ${{ github.event.pull_request.head.sha }}
90
92
  GH_TOKEN: ${{ github.token }}
93
+ ATLAS_PRIVATE_TERMS: ${{ secrets.ATLAS_PRIVATE_TERMS }}
91
94
  run: |
92
95
  set -euo pipefail
93
96
  node packages/atlas/bin/atlas gate check --root . --repo "$GITHUB_REPOSITORY" --base "$GITHUB_SHA" --head "$HEAD_SHA" --pr "$PR" --base-ref "$BASE_REF" --body "$RUNNER_TEMP/body.md" --summary "$GITHUB_STEP_SUMMARY" --output "$GITHUB_OUTPUT"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.4.25",
3
+ "version": "0.4.26",
4
4
  "description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, as a Claude Code plugin for any repository.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
package/shape/check.mjs CHANGED
@@ -38,7 +38,7 @@ export const sha256 = (text) => createHash('sha256').update(String(text ?? ''),
38
38
  // Whether a copy is current or behind is not decided here. CI has no package
39
39
  // and no network. The session verb decides it, from the release list that
40
40
  // ships beside this file in the package, shape/releases.json.
41
- export const CHECK_VERSION = 7;
41
+ export const CHECK_VERSION = 8;
42
42
 
43
43
  // ---------------------------------------------------------------------------
44
44
  // The spec. One object per version. The check reads a version it has here and
@@ -1232,6 +1232,22 @@ function workflowStructure(document) {
1232
1232
  return out;
1233
1233
  }
1234
1234
 
1235
+ // The shape check takes no secret. The rule reads the job that runs the check
1236
+ // and the workflow-wide env, which every job gets. Another job of the same file
1237
+ // may give one step a secret: the gate step gets the private terms (roadmap
1238
+ // R18), and it runs the base code too. When the file cannot be read, or no job
1239
+ // runs the check, the rule reads the whole text.
1240
+ function readsSecret(text) {
1241
+ const READS = /\bsecrets\./;
1242
+ let document = null;
1243
+ try { document = parseYaml(text); } catch { return READS.test(text); }
1244
+ const jobs = document?.jobs && typeof document.jobs === 'object' ? Object.values(document.jobs) : [];
1245
+ const job = jobs.find((candidate) => (Array.isArray(candidate?.steps) ? candidate.steps : [])
1246
+ .some((step) => typeof step?.run === 'string' && step.run.includes(GATE_RUNNER.runs)));
1247
+ if (!job) return READS.test(text);
1248
+ return READS.test(JSON.stringify(job)) || READS.test(JSON.stringify(document.env ?? null));
1249
+ }
1250
+
1235
1251
  function checkWorkflow(context, part) {
1236
1252
  const text = readText(context.root, part.path);
1237
1253
  if (text === null || !text.trim()) return result(part, 'empty', [`${part.path} is missing or holds nothing`]);
@@ -1254,7 +1270,7 @@ function checkWorkflow(context, part) {
1254
1270
  // a block map and a flow map for both permissions and steps, so read both
1255
1271
  // spellings or a workflow can widen itself past the check (review R-3).
1256
1272
  for (const grant of writeGrants(text)) findings.push(`the workflow grants ${grant}; this check needs read access only`);
1257
- if (/\bsecrets\./.test(text)) findings.push('the workflow reads a repository secret; this check takes none');
1273
+ if (readsSecret(text)) findings.push('the workflow reads a repository secret; this check takes none');
1258
1274
  const unpinned = [...text.matchAll(/uses[ \t]*:[ \t]*["']?([^\s,}\]"']+)/g)]
1259
1275
  .map(([, value]) => value)
1260
1276
  .filter((value) => !value.startsWith('./'))
@@ -40,6 +40,12 @@
40
40
  {
41
41
  "check_version": 7,
42
42
  "sha256": "b29fdb036170419b9f0642ef0ca7da45a80ff53492421eb57beaf157df13d057",
43
+ "first_package": "0.4.17",
44
+ "workflow_sha256": "fcddebef030e6564f97e1a5fb81c8ad1b3084ebef9622167c6565698312dcdd7"
45
+ },
46
+ {
47
+ "check_version": 8,
48
+ "sha256": "8cf521f6e8f89ae65a257c521c583281e0c3c27ef968b4bd96eb336a8d095795",
43
49
  "workflow_sha256": "fcddebef030e6564f97e1a5fb81c8ad1b3084ebef9622167c6565698312dcdd7"
44
50
  }
45
51
  ]