@codyswann/lisa 3.4.1 → 3.5.0
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/README.md +22 -0
- package/all/copy-contents/gitignore +7 -0
- package/all/copy-overwrite/scripts/lisa-command-envelope.mjs +44 -4
- package/all/copy-overwrite/scripts/lisa-schema-validate.mjs +125 -47
- package/cdk/merge/.oxlintrc.json +1 -1
- package/dist/cli/apply.d.ts.map +1 -1
- package/dist/cli/apply.js +48 -13
- package/dist/cli/apply.js.map +1 -1
- package/dist/cli/doctor-apply-freshness.d.ts +29 -0
- package/dist/cli/doctor-apply-freshness.d.ts.map +1 -0
- package/dist/cli/doctor-apply-freshness.js +194 -0
- package/dist/cli/doctor-apply-freshness.js.map +1 -0
- package/dist/cli/doctor-learnings-merge-driver.d.ts +23 -0
- package/dist/cli/doctor-learnings-merge-driver.d.ts.map +1 -0
- package/dist/cli/doctor-learnings-merge-driver.js +73 -0
- package/dist/cli/doctor-learnings-merge-driver.js.map +1 -0
- package/dist/cli/doctor-readiness-workflows.js +2 -2
- package/dist/cli/doctor-readiness-workflows.js.map +1 -1
- package/dist/cli/doctor.d.ts.map +1 -1
- package/dist/cli/doctor.js +13 -0
- package/dist/cli/doctor.js.map +1 -1
- package/dist/cli/ui-ci-quality-jobs-parse.js +2 -2
- package/dist/cli/ui-ci-quality-jobs-parse.js.map +1 -1
- package/dist/cli/ui-deploy-pipeline-model.d.ts +4 -0
- package/dist/cli/ui-deploy-pipeline-model.d.ts.map +1 -1
- package/dist/cli/ui-deploy-pipeline-model.js +2 -2
- package/dist/cli/ui-deploy-pipeline-model.js.map +1 -1
- package/dist/codex/agent-transformer.js +2 -2
- package/dist/codex/agent-transformer.js.map +1 -1
- package/dist/codex/command-skill-transformer.js +3 -3
- package/dist/codex/command-skill-transformer.js.map +1 -1
- package/dist/core/apply-receipt.d.ts +68 -0
- package/dist/core/apply-receipt.d.ts.map +1 -0
- package/dist/core/apply-receipt.js +107 -0
- package/dist/core/apply-receipt.js.map +1 -0
- package/dist/core/learnings-merge-driver-install.d.ts +34 -0
- package/dist/core/learnings-merge-driver-install.d.ts.map +1 -1
- package/dist/core/learnings-merge-driver-install.js +36 -0
- package/dist/core/learnings-merge-driver-install.js.map +1 -1
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +53 -31
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/dist/migrations/ensure-lisa-postinstall.d.ts +33 -0
- package/dist/migrations/ensure-lisa-postinstall.d.ts.map +1 -1
- package/dist/migrations/ensure-lisa-postinstall.js +47 -5
- package/dist/migrations/ensure-lisa-postinstall.js.map +1 -1
- package/dist/migrations/ensure-oxlint-base-configs.d.ts +42 -0
- package/dist/migrations/ensure-oxlint-base-configs.d.ts.map +1 -0
- package/dist/migrations/ensure-oxlint-base-configs.js +212 -0
- package/dist/migrations/ensure-oxlint-base-configs.js.map +1 -0
- package/dist/migrations/index.d.ts +1 -0
- package/dist/migrations/index.d.ts.map +1 -1
- package/dist/migrations/index.js +3 -0
- package/dist/migrations/index.js.map +1 -1
- package/dist/opencode/command-transformer.js +3 -3
- package/dist/opencode/command-transformer.js.map +1 -1
- package/dist/utils/yaml.d.ts +54 -0
- package/dist/utils/yaml.d.ts.map +1 -0
- package/dist/utils/yaml.js +124 -0
- package/dist/utils/yaml.js.map +1 -0
- package/expo/copy-overwrite/scripts/bdd/baseline.mjs +104 -19
- package/expo/copy-overwrite/scripts/bdd/contract.mjs +20 -1
- package/expo/copy-overwrite/scripts/bdd/discover.mjs +53 -10
- package/expo/copy-overwrite/scripts/bdd/parse.mjs +25 -3
- package/expo/copy-overwrite/scripts/bdd/render.mjs +2 -1
- package/expo/copy-overwrite/scripts/bdd/report.mjs +26 -12
- package/expo/copy-overwrite/scripts/bdd/validate.mjs +2 -2
- package/expo/copy-overwrite/scripts/bdd/waivers.mjs +19 -4
- package/expo/copy-overwrite/scripts/bdd-matrix.mjs +2 -2
- package/expo/copy-overwrite/scripts/check-bdd-coverage.mjs +58 -16
- package/expo/create-only/.github/required-checks.json +10 -4
- package/expo/merge/.oxlintrc.json +1 -1
- package/harper-fabric/merge/.oxlintrc.json +1 -1
- package/nestjs/create-only/.github/required-checks.json +57 -0
- package/nestjs/merge/.oxlintrc.json +1 -1
- package/package.json +1 -1
- package/phaser/merge/.oxlintrc.json +1 -1
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-atlassian-access/SKILL.md +7 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-jam-access/SKILL.md +37 -2
- package/plugins/lisa/.codex-plugin/skills/lisa-notion-access/SKILL.md +7 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-posthog-access/SKILL.md +40 -3
- package/plugins/lisa/.codex-plugin/skills/lisa-sentry-access/SKILL.md +44 -3
- package/plugins/lisa/.codex-plugin/skills/lisa-sonarcloud-access/SKILL.md +56 -0
- package/plugins/lisa/rules/eager/credential-substrate-precedence.md +3 -1
- package/plugins/lisa/rules/reference/credential-substrate-precedence.md +20 -0
- package/plugins/lisa/skills/lisa-atlassian-access/SKILL.md +7 -0
- package/plugins/lisa/skills/lisa-jam-access/SKILL.md +37 -2
- package/plugins/lisa/skills/lisa-notion-access/SKILL.md +7 -0
- package/plugins/lisa/skills/lisa-posthog-access/SKILL.md +40 -3
- package/plugins/lisa/skills/lisa-sentry-access/SKILL.md +44 -3
- package/plugins/lisa/skills/lisa-sonarcloud-access/SKILL.md +56 -0
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/skills/lisa-atlassian-access/SKILL.md +7 -0
- package/plugins/lisa-agy/skills/lisa-jam-access/SKILL.md +37 -2
- package/plugins/lisa-agy/skills/lisa-notion-access/SKILL.md +7 -0
- package/plugins/lisa-agy/skills/lisa-posthog-access/SKILL.md +40 -3
- package/plugins/lisa-agy/skills/lisa-sentry-access/SKILL.md +44 -3
- package/plugins/lisa-agy/skills/lisa-sonarcloud-access/SKILL.md +56 -0
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/rules/eager/credential-substrate-precedence.md +3 -1
- package/plugins/lisa-copilot/rules/reference/credential-substrate-precedence.md +20 -0
- package/plugins/lisa-copilot/skills/lisa-atlassian-access/SKILL.md +7 -0
- package/plugins/lisa-copilot/skills/lisa-jam-access/SKILL.md +37 -2
- package/plugins/lisa-copilot/skills/lisa-notion-access/SKILL.md +7 -0
- package/plugins/lisa-copilot/skills/lisa-posthog-access/SKILL.md +40 -3
- package/plugins/lisa-copilot/skills/lisa-sentry-access/SKILL.md +44 -3
- package/plugins/lisa-copilot/skills/lisa-sonarcloud-access/SKILL.md +56 -0
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/rules/credential-substrate-precedence-reference.mdc +20 -0
- package/plugins/lisa-cursor/rules/credential-substrate-precedence.mdc +3 -1
- package/plugins/lisa-cursor/skills/lisa-atlassian-access/SKILL.md +7 -0
- package/plugins/lisa-cursor/skills/lisa-jam-access/SKILL.md +37 -2
- package/plugins/lisa-cursor/skills/lisa-notion-access/SKILL.md +7 -0
- package/plugins/lisa-cursor/skills/lisa-posthog-access/SKILL.md +40 -3
- package/plugins/lisa-cursor/skills/lisa-sentry-access/SKILL.md +44 -3
- package/plugins/lisa-cursor/skills/lisa-sonarcloud-access/SKILL.md +56 -0
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/rules/eager/credential-substrate-precedence.md +3 -1
- package/plugins/src/base/rules/reference/credential-substrate-precedence.md +20 -0
- package/plugins/src/base/skills/lisa-atlassian-access/SKILL.md +7 -0
- package/plugins/src/base/skills/lisa-jam-access/SKILL.md +37 -2
- package/plugins/src/base/skills/lisa-notion-access/SKILL.md +7 -0
- package/plugins/src/base/skills/lisa-posthog-access/SKILL.md +40 -3
- package/plugins/src/base/skills/lisa-sentry-access/SKILL.md +44 -3
- package/plugins/src/base/skills/lisa-sonarcloud-access/SKILL.md +56 -0
- package/scripts/install-claude-plugins.sh +5 -1
- package/typescript/copy-overwrite/scripts/check-skipped-required-checks.mjs +298 -26
- package/typescript/create-only/.github/required-checks.json +11 -6
- package/typescript/create-only/.github/workflows/required-checks-drift.yml +84 -0
- package/typescript/merge/.oxlintrc.json +1 -1
|
@@ -11,6 +11,53 @@
|
|
|
11
11
|
* Usage:
|
|
12
12
|
* node scripts/check-skipped-required-checks.mjs [rootDir] [--remote] [--json]
|
|
13
13
|
*
|
|
14
|
+
* ## Where this runs
|
|
15
|
+
*
|
|
16
|
+
* Lisa's `quality.yml` runs the OFFLINE arm on every pull request, and the
|
|
17
|
+
* shipped `required-checks-drift.yml` runs `--remote` on a weekly schedule.
|
|
18
|
+
* That split is deliberate and is argued under "Why this is a DECLARATION
|
|
19
|
+
* guard" below: the enforced PR path may not depend on network or `gh` auth,
|
|
20
|
+
* and the snapshot it enforces may not be allowed to rot unwatched.
|
|
21
|
+
*
|
|
22
|
+
* ## `enforcement`
|
|
23
|
+
*
|
|
24
|
+
* A declaration may set `"enforcement": "warn"` to report violations without
|
|
25
|
+
* failing the build. The seeds Lisa ships start there ON PURPOSE: a seed cannot
|
|
26
|
+
* know which contexts a given repository's ruleset requires, so a seeded repo
|
|
27
|
+
* that has never been reviewed would otherwise go red on its first Lisa update
|
|
28
|
+
* for skips that may be entirely legitimate — and a gate that reddens a
|
|
29
|
+
* repository the day it arrives gets deleted, not fixed. Absent the key the
|
|
30
|
+
* guard ENFORCES: a declaration somebody hand-wrote is an opt-in.
|
|
31
|
+
*
|
|
32
|
+
* `warn` is a ramp, not a licence: `skipped_required_check` — the repository's
|
|
33
|
+
* own declaration stating that a token it really skips silences a context its
|
|
34
|
+
* ruleset really requires — blocks in every mode.
|
|
35
|
+
*
|
|
36
|
+
* ## `required_contexts` is a CACHE, and an unstamped cache is NOT AN ANSWER
|
|
37
|
+
*
|
|
38
|
+
* The single worst thing this guard can do is render a confident verdict from a
|
|
39
|
+
* list nobody ever compared against a real ruleset. Measured (#2476): the seed
|
|
40
|
+
* Lisa shipped claimed `🔗 Work-Item Traceability` was required — no ruleset
|
|
41
|
+
* required it — and OMITTED SIX contexts that genuinely were. A guard reading
|
|
42
|
+
* that would clear a genuinely-skipped required check and flag a non-required
|
|
43
|
+
* one. That is worse than a guard nobody runs, because it teaches people to
|
|
44
|
+
* trust it.
|
|
45
|
+
*
|
|
46
|
+
* So `required_contexts` is treated as a cache of a live fetch, not as
|
|
47
|
+
* testimony. It is trusted only while `ruleset.baseline_fetched_at` carries a
|
|
48
|
+
* parseable timestamp no older than SNAPSHOT_MAX_AGE_DAYS. Untranscribed or
|
|
49
|
+
* expired, the guard REFUSES to answer: every rule that depends on
|
|
50
|
+
* `required_contexts` is skipped, no ✅ is printed, and the report says NOT
|
|
51
|
+
* CHECKED and why. The rules that do NOT read `required_contexts` — an
|
|
52
|
+
* undeclared token, a token written with whitespace — still run, because they
|
|
53
|
+
* are true regardless of what the ruleset requires.
|
|
54
|
+
*
|
|
55
|
+
* Refusal is exit 1, so an explicit `npm run check:skipped-required-checks`
|
|
56
|
+
* cannot be mistaken for a pass. Under `"enforcement": "warn"` — which is what
|
|
57
|
+
* Lisa's seeds ship, and only its seeds — refusal is loud and exit 0, because
|
|
58
|
+
* reddening every repository in a fleet the day an untranscribed seed arrives
|
|
59
|
+
* is how a gate gets deleted rather than transcribed.
|
|
60
|
+
*
|
|
14
61
|
* ## Why this exists
|
|
15
62
|
*
|
|
16
63
|
* **GitHub counts a `skipped` required status check as SATISFIED.** A job named
|
|
@@ -69,6 +116,18 @@ import { pathToFileURL } from "node:url";
|
|
|
69
116
|
/** Repo-relative path of the per-repo declaration file. */
|
|
70
117
|
export const DECLARATION_PATH = ".github/required-checks.json";
|
|
71
118
|
|
|
119
|
+
/**
|
|
120
|
+
* How long a transcribed `required_contexts` snapshot stays trustworthy.
|
|
121
|
+
*
|
|
122
|
+
* A SOURCE CONSTANT, not an input: a ceiling somebody can widen from a config
|
|
123
|
+
* file fails open on exactly the runs nobody tests. Rulesets are edited in an
|
|
124
|
+
* admin console with no signal in the repository, so an old transcription is
|
|
125
|
+
* not evidence — it is a memory of evidence. Ninety days is long enough that
|
|
126
|
+
* the weekly `--remote` run refreshes it many times over, and short enough that
|
|
127
|
+
* an abandoned repository stops being told its skips are fine.
|
|
128
|
+
*/
|
|
129
|
+
export const SNAPSHOT_MAX_AGE_DAYS = 90;
|
|
130
|
+
|
|
72
131
|
/**
|
|
73
132
|
* Matches a `skip_jobs:` key and captures everything after the colon.
|
|
74
133
|
*
|
|
@@ -110,8 +169,32 @@ export const VIOLATIONS = Object.freeze({
|
|
|
110
169
|
orphaned: "orphaned_exemption",
|
|
111
170
|
badExemption: "exemption_without_valid_ticket",
|
|
112
171
|
remoteDrift: "ruleset_snapshot_drift",
|
|
172
|
+
whitespace: "whitespace_in_skip_token",
|
|
113
173
|
});
|
|
114
174
|
|
|
175
|
+
/** Enforcement modes a declaration may select. */
|
|
176
|
+
const ENFORCEMENT_MODES = Object.freeze(["error", "warn"]);
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Violation kinds that block even under `"enforcement": "warn"`.
|
|
180
|
+
*
|
|
181
|
+
* `warn` is an ADOPTION RAMP for hygiene findings — an undeclared token, a
|
|
182
|
+
* snapshot that has drifted from the code — in a repository nobody has reviewed
|
|
183
|
+
* yet. It is not a licence for a PROVEN false green. Reaching
|
|
184
|
+
* `skipped_required_check` requires the repository's own declaration to say
|
|
185
|
+
* both that a context is ruleset-required AND that a token it actually skips
|
|
186
|
+
* silences it; that is a reviewed state, and shipping past it is the exact
|
|
187
|
+
* defect this file exists to refuse.
|
|
188
|
+
*
|
|
189
|
+
* `ruleset_snapshot_drift` blocks for the same reason: it only fires under an
|
|
190
|
+
* explicit `--remote` on a repository that filled in its ruleset ids, and it is
|
|
191
|
+
* measured against live truth rather than inferred from a seed.
|
|
192
|
+
*/
|
|
193
|
+
const ALWAYS_BLOCKING = Object.freeze([
|
|
194
|
+
VIOLATIONS.suppressesRequired,
|
|
195
|
+
VIOLATIONS.remoteDrift,
|
|
196
|
+
]);
|
|
197
|
+
|
|
115
198
|
/**
|
|
116
199
|
* True when a line is a whole-line YAML comment.
|
|
117
200
|
*
|
|
@@ -170,13 +253,25 @@ export function unquoteScalar(rawValue) {
|
|
|
170
253
|
/**
|
|
171
254
|
* Splits a comma list into unique, non-empty tokens.
|
|
172
255
|
*
|
|
256
|
+
* `preserveWhitespace` keeps each token EXACTLY as written, which is the only
|
|
257
|
+
* way to see the defect in `skip_jobs: 'test, test:e2e'`. GitHub Actions has no
|
|
258
|
+
* string-replace in expression syntax, so the sentinel-comma idiom
|
|
259
|
+
* (`contains(format(',{0},', inputs.skip_jobs), ',test:e2e,')`) compares raw
|
|
260
|
+
* bytes: the token there is `" test:e2e"`, which never matches `,test:e2e,` and
|
|
261
|
+
* the job runs. That fails CLOSED, so it is a papercut rather than a hole — but
|
|
262
|
+
* it silently does not do what the operator asked, and nothing else in the
|
|
263
|
+
* pipeline can see it, because by the time the guard has trimmed it looks
|
|
264
|
+
* identical to a correctly written skip.
|
|
265
|
+
*
|
|
173
266
|
* @param {string} commaList - Tokens joined by commas
|
|
267
|
+
* @param {boolean} [preserveWhitespace] - Return tokens exactly as written
|
|
174
268
|
* @returns {string[]} Unique tokens in first-seen order
|
|
175
269
|
*/
|
|
176
|
-
function tokenize(commaList) {
|
|
270
|
+
function tokenize(commaList, preserveWhitespace = false) {
|
|
177
271
|
const out = [];
|
|
178
|
-
for (const
|
|
179
|
-
|
|
272
|
+
for (const part of commaList.split(",")) {
|
|
273
|
+
const token = preserveWhitespace ? part : part.trim();
|
|
274
|
+
if (token.trim() !== "" && !out.includes(token)) out.push(token);
|
|
180
275
|
}
|
|
181
276
|
return out;
|
|
182
277
|
}
|
|
@@ -195,10 +290,12 @@ function tokenize(commaList) {
|
|
|
195
290
|
* @param {string} contents - Full text of a workflow file
|
|
196
291
|
* @param {string} sourcePath - Repo-relative path, used only to name the file in
|
|
197
292
|
* a throw. Never an absolute path.
|
|
293
|
+
* @param {{preserveWhitespace?: boolean}} [options] - `preserveWhitespace`
|
|
294
|
+
* returns each token exactly as written instead of trimmed
|
|
198
295
|
* @returns {string[]} Every declared skip token, de-duplicated
|
|
199
296
|
* @throws {Error} When a `skip_jobs` key is not an inline scalar
|
|
200
297
|
*/
|
|
201
|
-
export function readSkipJobs(contents, sourcePath) {
|
|
298
|
+
export function readSkipJobs(contents, sourcePath, options = {}) {
|
|
202
299
|
const all = [];
|
|
203
300
|
contents.split("\n").forEach((line, index) => {
|
|
204
301
|
if (isCommentLine(line)) return;
|
|
@@ -224,7 +321,7 @@ export function readSkipJobs(contents, sourcePath) {
|
|
|
224
321
|
.map(found => found[1])
|
|
225
322
|
.join(",");
|
|
226
323
|
|
|
227
|
-
for (const token of tokenize(source)) {
|
|
324
|
+
for (const token of tokenize(source, options.preserveWhitespace === true)) {
|
|
228
325
|
if (!all.includes(token)) all.push(token);
|
|
229
326
|
}
|
|
230
327
|
});
|
|
@@ -270,6 +367,14 @@ export function loadDeclaration(rootDir) {
|
|
|
270
367
|
`check-skipped-required-checks: \`workflows\` must list at least one workflow file whose \`skip_jobs\` this guard reads.`
|
|
271
368
|
);
|
|
272
369
|
}
|
|
370
|
+
if (
|
|
371
|
+
declaration.enforcement !== undefined &&
|
|
372
|
+
!ENFORCEMENT_MODES.includes(declaration.enforcement)
|
|
373
|
+
) {
|
|
374
|
+
throw new Error(
|
|
375
|
+
`check-skipped-required-checks: \`enforcement\` must be one of ${ENFORCEMENT_MODES.map(mode => `"${mode}"`).join(", ")}, not ${JSON.stringify(declaration.enforcement)}. Omit it to enforce — a typo silently downgrading this guard to advice is the fail-open shape it exists to refuse.`
|
|
376
|
+
);
|
|
377
|
+
}
|
|
273
378
|
|
|
274
379
|
// Validate the SHAPE of every declaration, not just its presence. A truthy
|
|
275
380
|
// non-object entry (`"test:e2e": true`, or a string) reads as "declared" at
|
|
@@ -308,14 +413,21 @@ export function loadDeclaration(rootDir) {
|
|
|
308
413
|
* guard that silently reads nothing reports a clean bill of health for a
|
|
309
414
|
* repository it never looked at.
|
|
310
415
|
*
|
|
416
|
+
* A token written with surrounding whitespace is reported HERE rather than
|
|
417
|
+
* downstream, because trimming has already erased the evidence by the time the
|
|
418
|
+
* coherence rules run. The trimmed form is still carried into the evaluation:
|
|
419
|
+
* over-reporting a skip that does not actually happen is the safe direction,
|
|
420
|
+
* and the whitespace violation beside it explains why the token appeared.
|
|
421
|
+
*
|
|
311
422
|
* @param {string} rootDir - Repository root
|
|
312
423
|
* @param {ReadonlyArray<string>} workflows - Repo-relative workflow paths
|
|
313
|
-
* @returns {{tokens: string[], sources: Record<string, string[]
|
|
424
|
+
* @returns {{tokens: string[], sources: Record<string, string[]>, violations: object[]}} Tokens, where each came from, and how each was written
|
|
314
425
|
*/
|
|
315
426
|
export function collectSkipJobTokens(rootDir, workflows) {
|
|
316
427
|
const tokens = [];
|
|
317
428
|
/** @type {Record<string, string[]>} */
|
|
318
429
|
const sources = {};
|
|
430
|
+
const violations = [];
|
|
319
431
|
for (const relative of workflows) {
|
|
320
432
|
const path = resolve(rootDir, relative);
|
|
321
433
|
if (!existsSync(path)) {
|
|
@@ -323,12 +435,75 @@ export function collectSkipJobTokens(rootDir, workflows) {
|
|
|
323
435
|
`check-skipped-required-checks: \`workflows\` names ${relative}, which does not exist. A guard that reads nothing reports a clean bill of health for a repository it never looked at.`
|
|
324
436
|
);
|
|
325
437
|
}
|
|
326
|
-
|
|
438
|
+
const contents = readFileSync(path, "utf8");
|
|
439
|
+
for (const token of readSkipJobs(contents, relative)) {
|
|
327
440
|
if (!tokens.includes(token)) tokens.push(token);
|
|
328
441
|
sources[token] = [...(sources[token] ?? []), relative];
|
|
329
442
|
}
|
|
443
|
+
for (const raw of readSkipJobs(contents, relative, {
|
|
444
|
+
preserveWhitespace: true,
|
|
445
|
+
})) {
|
|
446
|
+
if (raw === raw.trim()) continue;
|
|
447
|
+
violations.push({
|
|
448
|
+
kind: VIOLATIONS.whitespace,
|
|
449
|
+
token: raw.trim(),
|
|
450
|
+
message: `\`skip_jobs\` in ${relative} lists ${JSON.stringify(raw)} — the token carries whitespace. GitHub Actions expression syntax has no string-replace, so \`skip_jobs\` is matched as an exact comma-delimited token and the surrounding space makes it match NOTHING: the job runs as if you had never listed it. That fails closed, so nothing unverified ships — but the skip you asked for silently did not happen. Write the list with no spaces: \`skip_jobs: 'a,b'\`, never \`skip_jobs: 'a, b'\`.`,
|
|
451
|
+
});
|
|
452
|
+
}
|
|
330
453
|
}
|
|
331
|
-
return { tokens, sources };
|
|
454
|
+
return { tokens, sources, violations };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Decides whether `required_contexts` may be believed at all.
|
|
459
|
+
*
|
|
460
|
+
* The stamp is `ruleset.baseline_fetched_at`, and it means one specific thing:
|
|
461
|
+
* somebody read this list off a live ruleset on that date. An empty stamp is
|
|
462
|
+
* the state Lisa's seeds ship in, and the seeds are a GUESS — a guess that was
|
|
463
|
+
* measured wrong (#2476). No stamp, no answer.
|
|
464
|
+
*
|
|
465
|
+
* @param {object} declaration - The per-repo declaration
|
|
466
|
+
* @param {number} [now] - Current epoch milliseconds, injectable for tests
|
|
467
|
+
* @returns {{trusted: boolean, reason: string}} Whether to believe the snapshot, and why not
|
|
468
|
+
*/
|
|
469
|
+
export function snapshotTrust(declaration, now = Date.now()) {
|
|
470
|
+
const stamp = declaration.ruleset?.baseline_fetched_at;
|
|
471
|
+
if (typeof stamp !== "string" || stamp.trim() === "") {
|
|
472
|
+
return {
|
|
473
|
+
trusted: false,
|
|
474
|
+
reason: `\`ruleset.baseline_fetched_at\` is empty, so \`required_contexts\` has never been transcribed from a live ruleset. Lisa's seed ships a GUESS, and the guess was measured WRONG in this fleet: it claimed "🔍 Quality Checks / 🔗 Work-Item Traceability" was required when no ruleset required it, and omitted six contexts that were. Transcribe the real list, stamp the date, and this guard starts answering.`,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
const fetchedAt = Date.parse(stamp);
|
|
478
|
+
if (Number.isNaN(fetchedAt)) {
|
|
479
|
+
return {
|
|
480
|
+
trusted: false,
|
|
481
|
+
reason: `\`ruleset.baseline_fetched_at\` is ${JSON.stringify(stamp)}, which is not a date this can read. Use an ISO-8601 timestamp, e.g. "2026-08-13".`,
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
const ageDays = (now - fetchedAt) / 86_400_000;
|
|
485
|
+
if (ageDays > SNAPSHOT_MAX_AGE_DAYS) {
|
|
486
|
+
return {
|
|
487
|
+
trusted: false,
|
|
488
|
+
reason: `\`required_contexts\` was last transcribed ${Math.floor(ageDays)} days ago, past the ${SNAPSHOT_MAX_AGE_DAYS}-day ceiling. Rulesets are edited in an admin console with no signal in this repository, so a stale transcription is a memory of evidence rather than evidence.`,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
return { trusted: true, reason: "" };
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* The transcription instructions, printed wherever the snapshot is refused.
|
|
496
|
+
*
|
|
497
|
+
* @param {object} declaration - The per-repo declaration
|
|
498
|
+
* @returns {string} A copy-pasteable recipe
|
|
499
|
+
*/
|
|
500
|
+
export function transcriptionRecipe(declaration) {
|
|
501
|
+
const repo = declaration.ruleset?.repo || "OWNER/NAME";
|
|
502
|
+
return [
|
|
503
|
+
` gh api repos/${repo}/rulesets --jq '.[] | "\\(.id) \\(.name)"'`,
|
|
504
|
+
` gh api repos/${repo}/rulesets/RULESET_ID --jq '.rules[] | select(.type=="required_status_checks") | .parameters.required_status_checks[].context'`,
|
|
505
|
+
`Paste the output into \`required_contexts\` byte for byte, record the ids in \`ruleset.ids\`, and set \`ruleset.baseline_fetched_at\` to today.`,
|
|
506
|
+
].join("\n");
|
|
332
507
|
}
|
|
333
508
|
|
|
334
509
|
/**
|
|
@@ -352,11 +527,25 @@ export function collectSkipJobTokens(rootDir, workflows) {
|
|
|
352
527
|
* 5. An exemption for a token nobody skips any more is ORPHANED. Deleting it is
|
|
353
528
|
* one line, and leaving it teaches readers the exemption list is fiction.
|
|
354
529
|
*
|
|
530
|
+
* Rules 2, 3 and 4 all read `required_contexts`, so all three are SUPPRESSED
|
|
531
|
+
* when `trustRequiredContexts` is false. Rules 1 and 5 do not read it and keep
|
|
532
|
+
* running: whether a token was reviewed, and whether an exemption still refers
|
|
533
|
+
* to a real skip, are true regardless of what any ruleset requires. Suppressing
|
|
534
|
+
* a rule is not the same as passing it — the caller reports NOT CHECKED.
|
|
535
|
+
*
|
|
355
536
|
* @param {object} declaration - The per-repo declaration
|
|
356
537
|
* @param {ReadonlyArray<string>} skipped - Tokens the workflows actually skip
|
|
538
|
+
* @param {{trustRequiredContexts?: boolean}} [options] - Set
|
|
539
|
+
* `trustRequiredContexts: false` to suppress every rule that reads the
|
|
540
|
+
* `required_contexts` snapshot
|
|
357
541
|
* @returns {{violations: object[], checked: number}} Violations and how many tokens were examined
|
|
358
542
|
*/
|
|
359
|
-
export function evaluateSkippedRequiredChecks(
|
|
543
|
+
export function evaluateSkippedRequiredChecks(
|
|
544
|
+
declaration,
|
|
545
|
+
skipped,
|
|
546
|
+
options = {}
|
|
547
|
+
) {
|
|
548
|
+
const trustRequired = options.trustRequiredContexts !== false;
|
|
360
549
|
const required = new Set(declaration.required_contexts);
|
|
361
550
|
const declarations = declaration.skip_job_declarations ?? {};
|
|
362
551
|
const ticketPattern = new RegExp(
|
|
@@ -374,6 +563,7 @@ export function evaluateSkippedRequiredChecks(declaration, skipped) {
|
|
|
374
563
|
});
|
|
375
564
|
continue;
|
|
376
565
|
}
|
|
566
|
+
if (!trustRequired) continue;
|
|
377
567
|
const suppressed = entry.suppressed_contexts ?? [];
|
|
378
568
|
const hits = suppressed.filter(context => required.has(context));
|
|
379
569
|
|
|
@@ -490,25 +680,48 @@ export function compareRulesetBaseline(snapshot, live) {
|
|
|
490
680
|
/**
|
|
491
681
|
* Runs the guard.
|
|
492
682
|
*
|
|
683
|
+
* `--remote` reads the ruleset live, so it does not need the cache to be
|
|
684
|
+
* trustworthy — it is the thing that MAKES it trustworthy. Under `--remote` the
|
|
685
|
+
* required-context rules therefore run regardless of the stamp, and any
|
|
686
|
+
* disagreement surfaces as `ruleset_snapshot_drift` rather than as a verdict
|
|
687
|
+
* about skips.
|
|
688
|
+
*
|
|
493
689
|
* @param {ReadonlyArray<string>} argv - CLI arguments
|
|
494
|
-
* @returns {{violations: object[], checked: number, tokens: string[]}} The result
|
|
690
|
+
* @returns {{violations: object[], checked: number, tokens: string[], enforcement: string, trust: {trusted: boolean, reason: string}, recipe: string}} The result
|
|
495
691
|
*/
|
|
496
692
|
export function runGuard(argv) {
|
|
497
693
|
const positional = argv.filter(arg => !arg.startsWith("--"));
|
|
498
694
|
const rootDir = positional[0] ?? process.cwd();
|
|
499
695
|
const declaration = loadDeclaration(rootDir);
|
|
500
|
-
const
|
|
501
|
-
const
|
|
502
|
-
const
|
|
503
|
-
|
|
696
|
+
const collected = collectSkipJobTokens(rootDir, declaration.workflows);
|
|
697
|
+
const remote = argv.includes("--remote");
|
|
698
|
+
const live = remote
|
|
699
|
+
? fetchLiveRequiredContexts(declaration.ruleset)
|
|
700
|
+
: undefined;
|
|
701
|
+
const trust = remote
|
|
702
|
+
? { trusted: true, reason: "" }
|
|
703
|
+
: snapshotTrust(declaration);
|
|
704
|
+
const result = evaluateSkippedRequiredChecks(
|
|
705
|
+
live === undefined
|
|
706
|
+
? declaration
|
|
707
|
+
: { ...declaration, required_contexts: live },
|
|
708
|
+
collected.tokens,
|
|
709
|
+
{ trustRequiredContexts: trust.trusted }
|
|
710
|
+
);
|
|
711
|
+
const violations = [...collected.violations, ...result.violations];
|
|
712
|
+
if (live !== undefined) {
|
|
504
713
|
violations.push(
|
|
505
|
-
...compareRulesetBaseline(
|
|
506
|
-
declaration.required_contexts,
|
|
507
|
-
fetchLiveRequiredContexts(declaration.ruleset)
|
|
508
|
-
)
|
|
714
|
+
...compareRulesetBaseline(declaration.required_contexts, live)
|
|
509
715
|
);
|
|
510
716
|
}
|
|
511
|
-
return {
|
|
717
|
+
return {
|
|
718
|
+
violations,
|
|
719
|
+
checked: result.checked,
|
|
720
|
+
tokens: collected.tokens,
|
|
721
|
+
enforcement: declaration.enforcement ?? "error",
|
|
722
|
+
trust,
|
|
723
|
+
recipe: transcriptionRecipe(declaration),
|
|
724
|
+
};
|
|
512
725
|
}
|
|
513
726
|
|
|
514
727
|
/**
|
|
@@ -518,7 +731,7 @@ export function runGuard(argv) {
|
|
|
518
731
|
* @returns {void}
|
|
519
732
|
*/
|
|
520
733
|
function main(argv) {
|
|
521
|
-
/** @type {{violations: object[], checked: number, tokens: string[]}} */
|
|
734
|
+
/** @type {{violations: object[], checked: number, tokens: string[], enforcement: string, trust: {trusted: boolean, reason: string}, recipe: string}} */
|
|
522
735
|
let result;
|
|
523
736
|
try {
|
|
524
737
|
result = runGuard(argv);
|
|
@@ -540,25 +753,78 @@ function main(argv) {
|
|
|
540
753
|
|
|
541
754
|
if (argv.includes("--json")) {
|
|
542
755
|
process.stdout.write(
|
|
543
|
-
`${JSON.stringify(
|
|
756
|
+
`${JSON.stringify(
|
|
757
|
+
{
|
|
758
|
+
ok: result.violations.length === 0 && result.trust.trusted,
|
|
759
|
+
answered: result.trust.trusted,
|
|
760
|
+
...result,
|
|
761
|
+
},
|
|
762
|
+
null,
|
|
763
|
+
2
|
|
764
|
+
)}\n`
|
|
544
765
|
);
|
|
545
766
|
return;
|
|
546
767
|
}
|
|
547
768
|
|
|
769
|
+
const warnOnly = result.enforcement === "warn";
|
|
770
|
+
/**
|
|
771
|
+
* True when a violation still fails the build under the active mode.
|
|
772
|
+
*
|
|
773
|
+
* @param {{kind: string}} violation - One violation
|
|
774
|
+
* @returns {boolean} True when it blocks
|
|
775
|
+
*/
|
|
776
|
+
const blocks = violation =>
|
|
777
|
+
!warnOnly || ALWAYS_BLOCKING.includes(violation.kind);
|
|
778
|
+
const blocking = result.violations.filter(blocks);
|
|
548
779
|
const lines = ["## 🔒 Skipped required checks", ""];
|
|
549
|
-
|
|
780
|
+
|
|
781
|
+
// The refusal comes FIRST and replaces the verdict. Printing "✅ none
|
|
782
|
+
// silences a required check" from a snapshot nobody transcribed is the one
|
|
783
|
+
// outcome that is worse than never running: it is a confident wrong answer,
|
|
784
|
+
// and it teaches people to trust it (#2476).
|
|
785
|
+
if (!result.trust.trusted) {
|
|
550
786
|
lines.push(
|
|
551
|
-
|
|
787
|
+
`⛔ **NOT CHECKED** — this guard cannot say whether any skip silences a required status check, and will not pretend to.`,
|
|
788
|
+
"",
|
|
789
|
+
result.trust.reason,
|
|
790
|
+
"",
|
|
791
|
+
"```",
|
|
792
|
+
result.recipe,
|
|
793
|
+
"```",
|
|
794
|
+
"",
|
|
795
|
+
`Meanwhile \`--remote\` answers WITHOUT the cache, because it reads the ruleset live: \`npm run check:skipped-required-checks:remote\`.`,
|
|
796
|
+
""
|
|
797
|
+
);
|
|
798
|
+
process.stderr.write(
|
|
799
|
+
`::${warnOnly ? "warning" : "error"} title=Skipped-required checks NOT CHECKED::${result.trust.reason.split("\n")[0]}\n`
|
|
552
800
|
);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
if (result.violations.length === 0) {
|
|
804
|
+
if (result.trust.trusted) {
|
|
805
|
+
lines.push(
|
|
806
|
+
`✅ ${result.checked} \`skip_jobs\` token(s) examined; none silences a ruleset-required status check.`
|
|
807
|
+
);
|
|
808
|
+
} else {
|
|
809
|
+
lines.push(
|
|
810
|
+
`The rules that do NOT read \`required_contexts\` were still applied to ${result.checked} token(s) and found nothing.`
|
|
811
|
+
);
|
|
812
|
+
}
|
|
553
813
|
} else {
|
|
554
814
|
lines.push(
|
|
555
|
-
|
|
815
|
+
`${blocking.length > 0 ? "❌" : "⚠️"} ${result.violations.length} violation(s) across ${result.checked} \`skip_jobs\` token(s):`,
|
|
556
816
|
""
|
|
557
817
|
);
|
|
558
818
|
for (const violation of result.violations) {
|
|
559
819
|
lines.push(`- **${violation.kind}** — ${violation.message}`);
|
|
560
820
|
process.stderr.write(
|
|
561
|
-
|
|
821
|
+
`::${blocks(violation) ? "error" : "warning"} title=${violation.kind}::${violation.message.split("\n")[0]}\n`
|
|
822
|
+
);
|
|
823
|
+
}
|
|
824
|
+
if (warnOnly) {
|
|
825
|
+
lines.push(
|
|
826
|
+
"",
|
|
827
|
+
`This declaration sets \`"enforcement": "warn"\`, so everything above except a proven false green (\`${VIOLATIONS.suppressesRequired}\`) is reported without failing the build. Review each finding, fix or declare it, then delete the \`enforcement\` key so this guard can block.`
|
|
562
828
|
);
|
|
563
829
|
}
|
|
564
830
|
}
|
|
@@ -569,7 +835,13 @@ function main(argv) {
|
|
|
569
835
|
appendFileSync(process.env.GITHUB_STEP_SUMMARY, report);
|
|
570
836
|
});
|
|
571
837
|
}
|
|
572
|
-
|
|
838
|
+
// Refusal is a failure, not a pass: an explicit run must never be mistaken
|
|
839
|
+
// for a clean bill of health. `warn` — which only Lisa's untranscribed seeds
|
|
840
|
+
// ship — downgrades it, because reddening a whole fleet the day a seed
|
|
841
|
+
// arrives is how a gate gets deleted instead of transcribed.
|
|
842
|
+
if (blocking.length > 0 || (!result.trust.trusted && !warnOnly)) {
|
|
843
|
+
process.exitCode = 1;
|
|
844
|
+
}
|
|
573
845
|
}
|
|
574
846
|
|
|
575
847
|
if (
|
|
@@ -3,10 +3,15 @@
|
|
|
3
3
|
"Two REVIEWED SNAPSHOTS that scripts/check-skipped-required-checks.mjs makes police each other.",
|
|
4
4
|
"Neither is derivable from this repository: `required_contexts` lives in a GitHub ruleset that humans edit in an admin console, and the skip_jobs token -> job name map lives in Lisa's quality.yml, which is not vendored here.",
|
|
5
5
|
"Why it matters: GitHub counts a SKIPPED required status check as SATISFIED. A job named in `skip_jobs` still reports green against its required context having run zero steps, so the merge gate can never be red and therefore can never block anything.",
|
|
6
|
-
"
|
|
7
|
-
"
|
|
8
|
-
"
|
|
6
|
+
"⚠️ `required_contexts` SHIPS EMPTY AND UNSTAMPED ON PURPOSE. Lisa cannot know what YOUR ruleset requires, and an earlier version of this seed shipped a guess that was measured WRONG (#2476): it claimed `🔗 Work-Item Traceability` was required when no ruleset required it, and omitted six contexts that were. Until `ruleset.baseline_fetched_at` carries the date you transcribed the real list, the guard reports NOT CHECKED rather than answering from fiction.",
|
|
7
|
+
"To arm it: gh api repos/OWNER/NAME/rulesets --jq '.[] | \"\\(.id) \\(.name)\"' to find the ids, then gh api repos/OWNER/NAME/rulesets/ID --jq '.rules[] | select(.type==\"required_status_checks\") | .parameters.required_status_checks[].context' for the contexts. Paste them into `required_contexts` BYTE FOR BYTE (emoji and the ' / ' separator included), fill in `ruleset.repo` / `ruleset.ids`, and set `baseline_fetched_at` to today.",
|
|
8
|
+
"The guard compares with exact string equality on purpose: a repo routinely carries confusable pairs (an external app's required `SonarCloud Code Analysis` beside a skippable, not-required `SonarCloud SAST`; `Run Tests` beside `Run Unit Tests`), and a fuzzy match would raise a false alarm whose obvious fix is to delete the guard.",
|
|
9
|
+
"A transcription expires after 90 days, because a ruleset can be edited with no signal in this repository. The shipped `.github/workflows/required-checks-drift.yml` runs `--remote` weekly to catch that; `--remote` reads the ruleset live and so answers even when the cache is untrusted.",
|
|
10
|
+
"Lisa's quality.yml runs the offline arm on every pull request. This seed ships `\"enforcement\": \"warn\"`, which downgrades findings AND the NOT-CHECKED refusal to reports so a fresh install does not go red on arrival. Delete the key once you have transcribed the list — then it blocks.",
|
|
11
|
+
"`skip_jobs` is matched as an exact comma-delimited token and GitHub Actions expression syntax has no string-replace, so write the list with no spaces: `skip_jobs: 'a,b'`, never `skip_jobs: 'a, b'`. A spaced token matches nothing and the job runs — reported as `whitespace_in_skip_token`.",
|
|
12
|
+
"`_example_required_contexts` below is a STARTING POINT FOR TYPING, never read by the guard. Verify every line against your own ruleset before promoting any of it."
|
|
9
13
|
],
|
|
14
|
+
"enforcement": "warn",
|
|
10
15
|
"ruleset": {
|
|
11
16
|
"repo": "",
|
|
12
17
|
"ids": [],
|
|
@@ -14,15 +19,15 @@
|
|
|
14
19
|
},
|
|
15
20
|
"workflows": [".github/workflows/ci.yml"],
|
|
16
21
|
"exemption_ticket_pattern": "^[A-Z][A-Z0-9]+-\\d+$",
|
|
17
|
-
"required_contexts": [
|
|
22
|
+
"required_contexts": [],
|
|
23
|
+
"_example_required_contexts": [
|
|
18
24
|
"🔍 Quality Checks / 🧹 Lint",
|
|
19
25
|
"🔍 Quality Checks / 🔍 Type Check",
|
|
20
26
|
"🔍 Quality Checks / 🏗️ Build",
|
|
21
27
|
"🔍 Quality Checks / 📐 Check Formatting",
|
|
22
28
|
"🔍 Quality Checks / 🔒 Security Scan",
|
|
23
29
|
"🔍 Quality Checks / 🧪 Run Unit Tests",
|
|
24
|
-
"🔍 Quality Checks / 🧪 Run Integration Tests"
|
|
25
|
-
"🔍 Quality Checks / 🔗 Work-Item Traceability"
|
|
30
|
+
"🔍 Quality Checks / 🧪 Run Integration Tests"
|
|
26
31
|
],
|
|
27
32
|
"skip_job_declarations": {}
|
|
28
33
|
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# This file was created by Lisa and is yours to edit.
|
|
2
|
+
# -----------------------------------------------------------------------------
|
|
3
|
+
# Required-checks snapshot drift — the REMOTE arm (#2426)
|
|
4
|
+
# -----------------------------------------------------------------------------
|
|
5
|
+
# `.github/required-checks.json` holds two reviewed snapshots that police each
|
|
6
|
+
# other: the contexts your ruleset requires, and what each `skip_jobs` token
|
|
7
|
+
# silences. Two snapshots inside one repository can only catch each other
|
|
8
|
+
# drifting from the CODE. Neither can see the RULESET change, because rulesets
|
|
9
|
+
# are edited in an admin console — which is exactly how one repo's list went
|
|
10
|
+
# from ten required contexts to eleven with every test still green for a day.
|
|
11
|
+
#
|
|
12
|
+
# So this runs the `--remote` arm on a schedule, and the pull-request path stays
|
|
13
|
+
# offline. That split is the whole design: an enforced gate that needed network
|
|
14
|
+
# and `gh` auth on every run would flake, and a flaky gate gets skipped — which
|
|
15
|
+
# reintroduces the false-green class the guard exists to refuse. This workflow
|
|
16
|
+
# never runs on `pull_request`, so it cannot wedge a merge.
|
|
17
|
+
#
|
|
18
|
+
# `--remote` is also the arm that STRUCTURALLY CANNOT LIE (#2476). It evaluates
|
|
19
|
+
# the skip rules against the contexts it just read from the live ruleset, not
|
|
20
|
+
# against the committed cache, so it answers correctly even in a repository
|
|
21
|
+
# whose `required_contexts` has never been transcribed — which is exactly the
|
|
22
|
+
# state the offline arm refuses to answer in. Arming this workflow is therefore
|
|
23
|
+
# the fastest way to get a real answer.
|
|
24
|
+
#
|
|
25
|
+
# Reading a repository ruleset needs a token with administration:read. The
|
|
26
|
+
# default GITHUB_TOKEN generally does not have it, so provide a PAT as the
|
|
27
|
+
# repository secret RULESET_READ_TOKEN. Without it this job goes red rather
|
|
28
|
+
# than reporting a clean bill of health it never checked.
|
|
29
|
+
#
|
|
30
|
+
# Until `ruleset.repo` / `ruleset.ids` are filled in, this reports UNCONFIGURED
|
|
31
|
+
# and passes. Find the ids with:
|
|
32
|
+
# gh api repos/OWNER/NAME/rulesets --jq '.[] | "\(.id) \(.name)"'
|
|
33
|
+
|
|
34
|
+
name: 🔒 Required-Checks Drift
|
|
35
|
+
|
|
36
|
+
on:
|
|
37
|
+
schedule:
|
|
38
|
+
# Weekly. A ruleset can be edited in the admin console on a day nobody
|
|
39
|
+
# touched the repository, so change-triggered checking alone would miss it.
|
|
40
|
+
- cron: '0 8 * * 1'
|
|
41
|
+
workflow_dispatch:
|
|
42
|
+
|
|
43
|
+
permissions:
|
|
44
|
+
contents: read
|
|
45
|
+
|
|
46
|
+
jobs:
|
|
47
|
+
drift:
|
|
48
|
+
name: 🔒 Ruleset snapshot vs live ruleset
|
|
49
|
+
runs-on: ubuntu-latest
|
|
50
|
+
timeout-minutes: 10
|
|
51
|
+
|
|
52
|
+
steps:
|
|
53
|
+
- name: 📥 Checkout repository
|
|
54
|
+
uses: actions/checkout@v6
|
|
55
|
+
with:
|
|
56
|
+
persist-credentials: false
|
|
57
|
+
|
|
58
|
+
- name: 🔧 Setup Node.js
|
|
59
|
+
uses: actions/setup-node@v6
|
|
60
|
+
with:
|
|
61
|
+
package-manager-cache: false
|
|
62
|
+
node-version: '22.21.1'
|
|
63
|
+
|
|
64
|
+
- name: 🔒 Compare the committed snapshot against the live ruleset
|
|
65
|
+
env:
|
|
66
|
+
GH_TOKEN: ${{ secrets.RULESET_READ_TOKEN || github.token }}
|
|
67
|
+
run: |
|
|
68
|
+
set -euo pipefail
|
|
69
|
+
if [ ! -f scripts/check-skipped-required-checks.mjs ]; then
|
|
70
|
+
echo "scripts/check-skipped-required-checks.mjs not present — project not yet on this template; skipping."
|
|
71
|
+
exit 0
|
|
72
|
+
fi
|
|
73
|
+
if [ ! -f .github/required-checks.json ]; then
|
|
74
|
+
echo "No .github/required-checks.json — nothing to compare. Lisa ships a seed; run \`lisa apply\` to get it."
|
|
75
|
+
exit 0
|
|
76
|
+
fi
|
|
77
|
+
configured="$(node -e "const d=require('./.github/required-checks.json');process.stdout.write(String(Boolean(d.ruleset&&d.ruleset.repo&&Array.isArray(d.ruleset.ids)&&d.ruleset.ids.length)))")"
|
|
78
|
+
if [ "$configured" != "true" ]; then
|
|
79
|
+
echo "UNCONFIGURED: .github/required-checks.json has no \`ruleset.repo\` / \`ruleset.ids\`, so drift cannot be checked."
|
|
80
|
+
echo "Fill them in to arm this: gh api repos/OWNER/NAME/rulesets --jq '.[] | \"\\(.id) \\(.name)\"'"
|
|
81
|
+
echo "::warning title=Required-checks drift UNCHECKED::Fill in ruleset.repo and ruleset.ids in .github/required-checks.json to arm the remote drift check."
|
|
82
|
+
exit 0
|
|
83
|
+
fi
|
|
84
|
+
node scripts/check-skipped-required-checks.mjs --remote
|