@codyswann/lisa 4.60.3 → 4.60.5
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/{scripts/check-workflow-load-failures.ts → all/copy-overwrite/scripts/check-workflow-load-failures.mjs} +36 -76
- package/all/copy-overwrite/scripts/lib/reusable-workflow-load-adapter.d.mts +21 -0
- package/{dist/core/reusable-workflow-load-adapter.js → all/copy-overwrite/scripts/lib/reusable-workflow-load-adapter.mjs} +50 -41
- package/all/copy-overwrite/scripts/lib/reusable-workflow-load-failure.d.mts +29 -0
- package/all/copy-overwrite/scripts/lib/reusable-workflow-load-failure.mjs +124 -0
- package/all/copy-overwrite/scripts/lib/reusable-workflow-load-scan.d.mts +36 -0
- package/all/copy-overwrite/scripts/lib/reusable-workflow-load-scan.mjs +102 -0
- package/all/copy-overwrite/scripts/lisa-hooks/block-instruction-file-edits.sh +11 -1
- package/all/copy-overwrite/scripts/lisa-hooks/parity-safety-net-heredoc.py +43 -0
- package/all/copy-overwrite/scripts/lisa-hooks/parity-safety-net.sh +16 -1
- package/all/create-only/.github/workflows/workflow-load-failure-sweep.yml +42 -0
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +72 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +28 -13
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/dist/opencode/plugin-templates/parity-safety-net-heredoc.py +43 -0
- package/dist/opencode/plugin-templates/parity-safety-net.sh +16 -1
- package/package.json +5 -5
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/hooks/block-instruction-file-edits.sh +11 -1
- package/plugins/lisa/hooks/parity-safety-net-heredoc.py +43 -0
- package/plugins/lisa/hooks/parity-safety-net.sh +16 -1
- package/plugins/lisa-agy/hooks/block-instruction-file-edits.sh +11 -1
- package/plugins/lisa-agy/hooks/parity-safety-net-heredoc.py +43 -0
- package/plugins/lisa-agy/hooks/parity-safety-net.sh +16 -1
- package/plugins/lisa-agy/plugin.json +1 -1
- 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/hooks/block-instruction-file-edits.sh +11 -1
- package/plugins/lisa-copilot/hooks/parity-safety-net-heredoc.py +43 -0
- package/plugins/lisa-copilot/hooks/parity-safety-net.sh +16 -1
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/block-instruction-file-edits.sh +11 -1
- package/plugins/lisa-cursor/hooks/parity-safety-net-heredoc.py +43 -0
- package/plugins/lisa-cursor/hooks/parity-safety-net.sh +16 -1
- 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/hooks/block-instruction-file-edits.sh +11 -1
- package/plugins/src/base/hooks/parity-safety-net-heredoc.py +43 -0
- package/plugins/src/base/hooks/parity-safety-net.sh +16 -1
- package/scripts/check-workflow-load-failures.mjs +4 -0
- package/scripts/two-channel-couplings.json +1 -1
- package/typescript/copy-overwrite/.prettierignore +1 -0
- package/dist/core/reusable-workflow-load-adapter.d.ts +0 -78
- package/dist/core/reusable-workflow-load-adapter.d.ts.map +0 -1
- package/dist/core/reusable-workflow-load-adapter.js.map +0 -1
- package/dist/core/reusable-workflow-load-failure.d.ts +0 -144
- package/dist/core/reusable-workflow-load-failure.d.ts.map +0 -1
- package/dist/core/reusable-workflow-load-failure.js +0 -184
- package/dist/core/reusable-workflow-load-failure.js.map +0 -1
- package/dist/core/reusable-workflow-load-scan.d.ts +0 -92
- package/dist/core/reusable-workflow-load-scan.d.ts.map +0 -1
- package/dist/core/reusable-workflow-load-scan.js +0 -108
- package/dist/core/reusable-workflow-load-scan.js.map +0 -1
|
@@ -1,55 +1,20 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
* ## Why this runs on a schedule and not at a gate
|
|
6
|
-
*
|
|
7
|
-
* The failure is invisible at the moments a gate covers. On a pull request it
|
|
8
|
-
* is already caught — `check-skipped-required-checks --pr=<n>` raises
|
|
9
|
-
* `absent_required_check`, and a ruleset-required context that never reports
|
|
10
|
-
* blocks the merge anyway. The incident this closes happened somewhere no gate
|
|
11
|
-
* looks: a `push` handler failed five times in a row, ~40 minutes after an
|
|
12
|
-
* upstream commit landed, with nothing changed on the consumer side and no red
|
|
13
|
-
* job to open. A control that only runs where the failure cannot happen is not
|
|
14
|
-
* a control, so the moment is the design decision here, not the mechanism.
|
|
15
|
-
*
|
|
16
|
-
* The sibling precedent is `lifecycle-drift-sweep.yml`, which exists because a
|
|
17
|
-
* report nobody scheduled and nobody read is indistinguishable from no report.
|
|
18
|
-
* This follows it: scheduled, and failing the job on drift so the finding lands
|
|
19
|
-
* in the existing failure-to-issue path rather than a job summary nobody opens.
|
|
20
|
-
*
|
|
21
|
-
* ## Two ways to exit non-zero, and they mean different things
|
|
22
|
-
*
|
|
23
|
-
* Findings are one. **A window this scan could not cover is the other**, and it
|
|
24
|
-
* is the more important of the two: the implementation this replaces read one
|
|
25
|
-
* page, filtered by timestamp, and reported that a hundred runs were inspected
|
|
26
|
-
* across a window that contained four known load failures. "Found nothing" and
|
|
27
|
-
* "could not look" must never render as the same green.
|
|
28
|
-
* @module scripts/check-workflow-load-failures
|
|
29
|
-
*/
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// This file is managed by Lisa and IS replaced on each `lisa` run.
|
|
3
|
+
// Do not edit directly — durable changes belong upstream in Lisa.
|
|
4
|
+
|
|
30
5
|
import { execFile } from "node:child_process";
|
|
31
6
|
import { readdir, readFile } from "node:fs/promises";
|
|
32
7
|
import path from "node:path";
|
|
8
|
+
import { pathToFileURL } from "node:url";
|
|
33
9
|
import { promisify } from "node:util";
|
|
34
|
-
|
|
35
|
-
import {
|
|
36
|
-
import {
|
|
37
|
-
createRunPageFetcher,
|
|
38
|
-
type GithubRequest,
|
|
39
|
-
} from "../src/core/reusable-workflow-load-adapter.js";
|
|
40
|
-
import {
|
|
41
|
-
scanForLoadFailures,
|
|
42
|
-
type ScanResult,
|
|
43
|
-
} from "../src/core/reusable-workflow-load-scan.js";
|
|
44
|
-
|
|
10
|
+
import { callerDeclaresReusableWorkflow } from "./lib/reusable-workflow-load-failure.mjs";
|
|
11
|
+
import { createRunPageFetcher } from "./lib/reusable-workflow-load-adapter.mjs";
|
|
12
|
+
import { scanForLoadFailures } from "./lib/reusable-workflow-load-scan.mjs";
|
|
45
13
|
const run = promisify(execFile);
|
|
46
|
-
|
|
47
14
|
/** Where caller workflows live, relative to the repository root. */
|
|
48
15
|
const WORKFLOW_DIR = ".github/workflows";
|
|
49
|
-
|
|
50
16
|
/** How far back to scan, in hours. */
|
|
51
17
|
const WINDOW_HOURS = 26;
|
|
52
|
-
|
|
53
18
|
/**
|
|
54
19
|
* Page cap.
|
|
55
20
|
*
|
|
@@ -58,7 +23,6 @@ const WINDOW_HOURS = 26;
|
|
|
58
23
|
* evidence about the runs beyond it.
|
|
59
24
|
*/
|
|
60
25
|
const MAX_PAGES = 20;
|
|
61
|
-
|
|
62
26
|
/**
|
|
63
27
|
* Build the population test by reading the callers' own source.
|
|
64
28
|
*
|
|
@@ -68,37 +32,32 @@ const MAX_PAGES = 20;
|
|
|
68
32
|
* @param root - Repository root
|
|
69
33
|
* @returns A predicate over a run's caller `path`
|
|
70
34
|
*/
|
|
71
|
-
export async function buildPopulationTest(
|
|
72
|
-
root: string
|
|
73
|
-
): Promise<(runPath: string) => boolean> {
|
|
35
|
+
export async function buildPopulationTest(root) {
|
|
74
36
|
const dir = path.join(root, WORKFLOW_DIR);
|
|
75
|
-
const names = await readdir(dir)
|
|
37
|
+
const names = await readdir(dir);
|
|
76
38
|
const declaring = await Promise.all(
|
|
77
39
|
names
|
|
78
40
|
.filter(name => name.endsWith(".yml") || name.endsWith(".yaml"))
|
|
79
41
|
.map(async name => {
|
|
80
|
-
const source = await readFile(path.join(dir, name), "utf8")
|
|
81
|
-
() => ""
|
|
82
|
-
);
|
|
42
|
+
const source = await readFile(path.join(dir, name), "utf8");
|
|
83
43
|
return callerDeclaresReusableWorkflow(source) ? name : "";
|
|
84
44
|
})
|
|
85
45
|
);
|
|
86
46
|
const declared = new Set(declaring.filter(name => name !== ""));
|
|
87
|
-
return
|
|
47
|
+
return runPath => declared.has(path.basename(runPath));
|
|
88
48
|
}
|
|
89
|
-
|
|
90
49
|
/**
|
|
91
50
|
* A request function backed by the `gh` CLI.
|
|
92
51
|
* @param repo - Repository in `owner/name` form, used only for error text
|
|
93
52
|
* @returns A request function returning decoded JSON
|
|
94
53
|
*/
|
|
95
|
-
export function ghRequest(repo
|
|
96
|
-
return async
|
|
54
|
+
export function ghRequest(repo) {
|
|
55
|
+
return async apiPath => {
|
|
97
56
|
const { stdout } = await run("gh", ["api", apiPath], {
|
|
98
57
|
maxBuffer: 32 * 1024 * 1024,
|
|
99
58
|
});
|
|
100
59
|
try {
|
|
101
|
-
return JSON.parse(stdout)
|
|
60
|
+
return JSON.parse(stdout);
|
|
102
61
|
} catch {
|
|
103
62
|
throw new Error(
|
|
104
63
|
`check-workflow-load-failures: \`gh api ${apiPath}\` on ${repo} returned a body that is not JSON. Treating that as "nothing found" would be a pass produced by a broken read, so this is an error.`
|
|
@@ -106,18 +65,17 @@ export function ghRequest(repo: string): GithubRequest {
|
|
|
106
65
|
}
|
|
107
66
|
};
|
|
108
67
|
}
|
|
109
|
-
|
|
110
68
|
/**
|
|
111
69
|
* Render the scan's answer for an operator.
|
|
112
70
|
* @param result - What the scan found, and whether it could see
|
|
113
71
|
* @returns Operator-readable lines
|
|
114
72
|
*/
|
|
115
|
-
export function describe(result
|
|
73
|
+
export function describe(result) {
|
|
116
74
|
if (!result.covered) {
|
|
117
75
|
return `check-workflow-load-failures: INCOMPLETE. The scan ${result.reason} It inspected ${result.inspected} run(s), but that is not the same as having covered the window, so this is an error rather than a pass.`;
|
|
118
76
|
}
|
|
119
77
|
if (result.loadFailures.length === 0) {
|
|
120
|
-
return `check-workflow-load-failures: OK. ${result.inspected} run(s) inspected across the last ${WINDOW_HOURS}h;
|
|
78
|
+
return `check-workflow-load-failures: OK. ${result.inspected} run(s) inspected across the last ${WINDOW_HOURS}h; no reusable workflow load failures found.`;
|
|
121
79
|
}
|
|
122
80
|
const lines = result.loadFailures.map(
|
|
123
81
|
finding =>
|
|
@@ -130,42 +88,44 @@ export function describe(result: ScanResult): string {
|
|
|
130
88
|
"A load failure creates NO jobs, so there is no red job to open and no annotation naming the line. Check the upstream workflow's most recent commit for a syntax or schema error.",
|
|
131
89
|
].join("\n");
|
|
132
90
|
}
|
|
133
|
-
|
|
134
91
|
/**
|
|
135
92
|
* Scan, report, and choose an exit code.
|
|
136
93
|
* @param repo - Repository in `owner/name` form
|
|
137
94
|
* @param root - Repository root
|
|
138
95
|
* @returns 0 when the window was covered and clean, 1 otherwise
|
|
139
96
|
*/
|
|
140
|
-
export async function main(repo
|
|
97
|
+
export async function main(repo, root) {
|
|
141
98
|
const windowStart = new Date(
|
|
142
99
|
Date.now() - WINDOW_HOURS * 60 * 60 * 1000
|
|
143
100
|
).toISOString();
|
|
144
|
-
|
|
145
101
|
const result = await scanForLoadFailures({
|
|
146
102
|
fetchPage: createRunPageFetcher({ repo, request: ghRequest(repo) }),
|
|
147
103
|
windowStart,
|
|
148
104
|
inPopulation: await buildPopulationTest(root),
|
|
149
105
|
maxPages: MAX_PAGES,
|
|
150
106
|
});
|
|
151
|
-
|
|
152
107
|
console.log(describe(result));
|
|
153
108
|
return result.covered && result.loadFailures.length === 0 ? 0 : 1;
|
|
154
109
|
}
|
|
110
|
+
/** Run against the repository explicitly supplied by the workflow. */
|
|
111
|
+
export async function runCli() {
|
|
112
|
+
const repo = process.env.GITHUB_REPOSITORY;
|
|
113
|
+
if (!repo || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) {
|
|
114
|
+
console.error("Set GITHUB_REPOSITORY to the owner/repository to scan.");
|
|
115
|
+
process.exitCode = 1;
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
process.exitCode = await main(repo, process.cwd());
|
|
120
|
+
} catch (error) {
|
|
121
|
+
console.error(String(error));
|
|
122
|
+
process.exitCode = 1;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
155
125
|
|
|
156
|
-
/* c8 ignore start -- entry point, exercised by the workflow rather than a test */
|
|
157
126
|
if (
|
|
158
|
-
process.argv[1]
|
|
159
|
-
import.meta.url
|
|
127
|
+
process.argv[1] &&
|
|
128
|
+
import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href
|
|
160
129
|
) {
|
|
161
|
-
|
|
162
|
-
main(repo, process.cwd())
|
|
163
|
-
.then(code => {
|
|
164
|
-
process.exitCode = code;
|
|
165
|
-
})
|
|
166
|
-
.catch((error: unknown) => {
|
|
167
|
-
console.error(String(error));
|
|
168
|
-
process.exitCode = 1;
|
|
169
|
-
});
|
|
130
|
+
runCli();
|
|
170
131
|
}
|
|
171
|
-
/* c8 ignore stop */
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// This file is managed by Lisa and IS replaced on each `lisa` run.
|
|
2
|
+
// Do not edit directly — durable changes belong upstream in Lisa.
|
|
3
|
+
|
|
4
|
+
import type { RunPage } from "./reusable-workflow-load-scan.mjs";
|
|
5
|
+
export declare const RUNS_PER_PAGE = 100;
|
|
6
|
+
export type GithubRequest = (path: string) => Promise<unknown>;
|
|
7
|
+
export interface AdapterOptions {
|
|
8
|
+
readonly repo: string;
|
|
9
|
+
readonly request: GithubRequest;
|
|
10
|
+
readonly perPage?: number;
|
|
11
|
+
}
|
|
12
|
+
export declare function runsPath(
|
|
13
|
+
repo: string,
|
|
14
|
+
page: number,
|
|
15
|
+
perPage: number
|
|
16
|
+
): string;
|
|
17
|
+
export declare function runPath(repo: string, id: number): string;
|
|
18
|
+
export declare function jobsPath(repo: string, id: number): string;
|
|
19
|
+
export declare function createRunPageFetcher(
|
|
20
|
+
options: AdapterOptions
|
|
21
|
+
): (page: number) => Promise<RunPage>;
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// This file is managed by Lisa and IS replaced on each `lisa` run.
|
|
2
|
+
// Do not edit directly — durable changes belong upstream in Lisa.
|
|
3
|
+
|
|
1
4
|
/**
|
|
2
5
|
* Conclusions worth spending two extra requests on.
|
|
3
6
|
*
|
|
@@ -17,7 +20,7 @@ export const RUNS_PER_PAGE = 100;
|
|
|
17
20
|
* @returns The API path to request
|
|
18
21
|
*/
|
|
19
22
|
export function runsPath(repo, page, perPage) {
|
|
20
|
-
|
|
23
|
+
return `repos/${repo}/actions/runs?per_page=${perPage}&page=${page}`;
|
|
21
24
|
}
|
|
22
25
|
/**
|
|
23
26
|
* Path for one run's detail, which is where `referenced_workflows` lives.
|
|
@@ -26,7 +29,7 @@ export function runsPath(repo, page, perPage) {
|
|
|
26
29
|
* @returns The API path to request
|
|
27
30
|
*/
|
|
28
31
|
export function runPath(repo, id) {
|
|
29
|
-
|
|
32
|
+
return `repos/${repo}/actions/runs/${id}`;
|
|
30
33
|
}
|
|
31
34
|
/**
|
|
32
35
|
* Path for one run's jobs, read only for its `total_count`.
|
|
@@ -35,7 +38,7 @@ export function runPath(repo, id) {
|
|
|
35
38
|
* @returns The API path to request
|
|
36
39
|
*/
|
|
37
40
|
export function jobsPath(repo, id) {
|
|
38
|
-
|
|
41
|
+
return `repos/${repo}/actions/runs/${id}/jobs?per_page=1`;
|
|
39
42
|
}
|
|
40
43
|
/**
|
|
41
44
|
* Read a property off an unknown decoded body without asserting a shape.
|
|
@@ -44,9 +47,8 @@ export function jobsPath(repo, id) {
|
|
|
44
47
|
* @returns The value, or undefined when the body is not an object
|
|
45
48
|
*/
|
|
46
49
|
function field(body, key) {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
return body[key];
|
|
50
|
+
if (typeof body !== "object" || body === null) return undefined;
|
|
51
|
+
return body[key];
|
|
50
52
|
}
|
|
51
53
|
/**
|
|
52
54
|
* Coerce an unknown to a number, defaulting rather than throwing.
|
|
@@ -54,7 +56,7 @@ function field(body, key) {
|
|
|
54
56
|
* @returns The number, or 0 when it is not a finite one
|
|
55
57
|
*/
|
|
56
58
|
function count(value) {
|
|
57
|
-
|
|
59
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
58
60
|
}
|
|
59
61
|
/**
|
|
60
62
|
* Coerce an unknown to a string, or empty.
|
|
@@ -62,7 +64,7 @@ function count(value) {
|
|
|
62
64
|
* @returns The string, or empty when it is not one
|
|
63
65
|
*/
|
|
64
66
|
function text(value) {
|
|
65
|
-
|
|
67
|
+
return typeof value === "string" ? value : "";
|
|
66
68
|
}
|
|
67
69
|
/**
|
|
68
70
|
* Read the list endpoint's runs array into the fields the scan needs.
|
|
@@ -70,17 +72,21 @@ function text(value) {
|
|
|
70
72
|
* @returns One entry per run, in the order GitHub returned them
|
|
71
73
|
*/
|
|
72
74
|
function listedRuns(body) {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
75
|
+
const runs = field(body, "workflow_runs");
|
|
76
|
+
if (!Array.isArray(runs)) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
"Workflow run list is unreadable: expected workflow_runs array."
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
return runs.map(run => ({
|
|
82
|
+
id: count(field(run, "id")),
|
|
83
|
+
path: text(field(run, "path")),
|
|
84
|
+
createdAt: text(field(run, "created_at")),
|
|
85
|
+
conclusion:
|
|
86
|
+
typeof field(run, "conclusion") === "string"
|
|
87
|
+
? text(field(run, "conclusion"))
|
|
88
|
+
: null,
|
|
89
|
+
}));
|
|
84
90
|
}
|
|
85
91
|
/**
|
|
86
92
|
* Gather the two extra facts a candidate run's verdict turns on.
|
|
@@ -89,14 +95,14 @@ function listedRuns(body) {
|
|
|
89
95
|
* @returns The run with its referenced-workflow and job counts filled in
|
|
90
96
|
*/
|
|
91
97
|
async function withDetail(listed, options) {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
98
|
+
const detail = await options.request(runPath(options.repo, listed.id));
|
|
99
|
+
const jobs = await options.request(jobsPath(options.repo, listed.id));
|
|
100
|
+
const referenced = field(detail, "referenced_workflows");
|
|
101
|
+
return {
|
|
102
|
+
...listed,
|
|
103
|
+
referencedCount: Array.isArray(referenced) ? referenced.length : 0,
|
|
104
|
+
jobCount: count(field(jobs, "total_count")),
|
|
105
|
+
};
|
|
100
106
|
}
|
|
101
107
|
/**
|
|
102
108
|
* Build the page fetcher `scanForLoadFailures` consumes.
|
|
@@ -108,18 +114,21 @@ async function withDetail(listed, options) {
|
|
|
108
114
|
* @returns A fetcher for one 1-based page of run history
|
|
109
115
|
*/
|
|
110
116
|
export function createRunPageFetcher(options) {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
117
|
+
const perPage = options.perPage ?? RUNS_PER_PAGE;
|
|
118
|
+
return async page => {
|
|
119
|
+
const body = await options.request(runsPath(options.repo, page, perPage));
|
|
120
|
+
const listed = listedRuns(body);
|
|
121
|
+
const runs = await Promise.all(
|
|
122
|
+
listed.map(async run => {
|
|
123
|
+
if (
|
|
124
|
+
run.conclusion === null ||
|
|
125
|
+
!DETAIL_WORTH_FETCHING.includes(run.conclusion)
|
|
126
|
+
) {
|
|
127
|
+
return { ...run, referencedCount: 0, jobCount: 0 };
|
|
128
|
+
}
|
|
129
|
+
return withDetail(run, options);
|
|
130
|
+
})
|
|
131
|
+
);
|
|
132
|
+
return { runs, hasMore: listed.length === perPage };
|
|
133
|
+
};
|
|
124
134
|
}
|
|
125
|
-
//# sourceMappingURL=reusable-workflow-load-adapter.js.map
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// This file is managed by Lisa and IS replaced on each `lisa` run.
|
|
2
|
+
// Do not edit directly — durable changes belong upstream in Lisa.
|
|
3
|
+
|
|
4
|
+
export type RunLoadClass =
|
|
5
|
+
| "load-failure"
|
|
6
|
+
| "dead-runner"
|
|
7
|
+
| "resolved"
|
|
8
|
+
| "out-of-population"
|
|
9
|
+
| "skipped"
|
|
10
|
+
| "inconclusive"
|
|
11
|
+
| "in-flight";
|
|
12
|
+
export interface RunLoadFacts {
|
|
13
|
+
readonly inPopulation: boolean;
|
|
14
|
+
readonly referencedCount: number;
|
|
15
|
+
readonly jobCount: number;
|
|
16
|
+
readonly conclusion: string | null;
|
|
17
|
+
}
|
|
18
|
+
export declare function callerDeclaresReusableWorkflow(source: string): boolean;
|
|
19
|
+
export declare function classifyRunLoad(facts: RunLoadFacts): RunLoadClass;
|
|
20
|
+
export interface WindowScan {
|
|
21
|
+
readonly oldestSeen: string | null;
|
|
22
|
+
readonly windowStart: string;
|
|
23
|
+
readonly exhausted: boolean;
|
|
24
|
+
}
|
|
25
|
+
export interface WindowVerdict {
|
|
26
|
+
readonly covered: boolean;
|
|
27
|
+
readonly reason: string;
|
|
28
|
+
}
|
|
29
|
+
export declare function windowCoverage(scan: WindowScan): WindowVerdict;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// This file is managed by Lisa and IS replaced on each `lisa` run.
|
|
2
|
+
// Do not edit directly — durable changes belong upstream in Lisa.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A job-level `uses:` naming a reusable workflow in another repository.
|
|
6
|
+
*
|
|
7
|
+
* The path shape is what discriminates, not a roster of known callers: a
|
|
8
|
+
* reusable workflow is always `<owner>/<repo>/.github/workflows/<file>@<ref>`.
|
|
9
|
+
* A step action is `<owner>/<repo>@<ref>` with no path, so `actions/checkout@v6`
|
|
10
|
+
* cannot match — which matters, because reading step actions as declarations
|
|
11
|
+
* is exactly what produced 14 false positives out of 19.
|
|
12
|
+
*/
|
|
13
|
+
const CROSS_REPO_USES =
|
|
14
|
+
/^[ \t]*uses:[ \t]*["']?[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+\/\.github\/workflows\/[A-Za-z0-9._-]+\.ya?ml@[^\s"'#]+/u;
|
|
15
|
+
/**
|
|
16
|
+
* A job-level `uses:` naming a reusable workflow in the caller's own
|
|
17
|
+
* repository.
|
|
18
|
+
*
|
|
19
|
+
* Unlike the cross-repo arm this one is NOT backed by a measured run — the
|
|
20
|
+
* ticket's evidence is all cross-repo. It is included because the path shape
|
|
21
|
+
* is unambiguous and excluding it would leave local reusable workflows
|
|
22
|
+
* undetectable, but if this detector ever reports a false positive, this is
|
|
23
|
+
* the first line to suspect.
|
|
24
|
+
*/
|
|
25
|
+
const LOCAL_USES =
|
|
26
|
+
/^[ \t]*uses:[ \t]*["']?\.\/\.github\/workflows\/[A-Za-z0-9._-]+\.ya?ml/u;
|
|
27
|
+
/** Whether a line is entirely a YAML comment. */
|
|
28
|
+
const COMMENT_LINE = /^\s*#/u;
|
|
29
|
+
/**
|
|
30
|
+
* Conclusions that can be reached before a single job is created.
|
|
31
|
+
*
|
|
32
|
+
* `startup_failure` is GitHub's own name for this class and is the conclusion
|
|
33
|
+
* a load failure most often carries; `failure` is what the runs measured for
|
|
34
|
+
* this ticket carried. Anything else — `success`, `cancelled`, `neutral` — is
|
|
35
|
+
* never a load failure, and treating it as one is how a detector starts
|
|
36
|
+
* inventing reds.
|
|
37
|
+
*/
|
|
38
|
+
const PRE_JOB_FAILURES = ["failure", "startup_failure"];
|
|
39
|
+
/**
|
|
40
|
+
* Whether a caller workflow declares any reusable workflow.
|
|
41
|
+
*
|
|
42
|
+
* This is the population gate, and it is DERIVED from the caller's own source
|
|
43
|
+
* rather than read from a roster. There is deliberately no list of known
|
|
44
|
+
* callers or known upstream repositories anywhere in this module: a roster
|
|
45
|
+
* would silently exclude every caller nobody remembered to add, which is the
|
|
46
|
+
* failure mode this codebase already has a ticket for one level up.
|
|
47
|
+
* @param source - Full text of a caller's workflow file
|
|
48
|
+
* @returns True when at least one non-comment line calls a reusable workflow
|
|
49
|
+
*/
|
|
50
|
+
export function callerDeclaresReusableWorkflow(source) {
|
|
51
|
+
return source
|
|
52
|
+
.split("\n")
|
|
53
|
+
.some(
|
|
54
|
+
line =>
|
|
55
|
+
!COMMENT_LINE.test(line) &&
|
|
56
|
+
(CROSS_REPO_USES.test(line) || LOCAL_USES.test(line))
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Classify what a finished run says about reusable-workflow loading.
|
|
61
|
+
*
|
|
62
|
+
* The order of these tests is the whole design, so it is worth stating: a run
|
|
63
|
+
* that RESOLVED something is settled before the population gate is consulted,
|
|
64
|
+
* because resolution is direct evidence that loading worked and the gate is
|
|
65
|
+
* only a heuristic about whether the field can speak. That way an
|
|
66
|
+
* under-detecting population gate can never manufacture a load-failure
|
|
67
|
+
* verdict — it can only cause one to be missed, which is the direction a
|
|
68
|
+
* detector should fail in.
|
|
69
|
+
* @param facts - The four run facts
|
|
70
|
+
* @returns The single class this run belongs to
|
|
71
|
+
*/
|
|
72
|
+
export function classifyRunLoad(facts) {
|
|
73
|
+
const { inPopulation, referencedCount, jobCount, conclusion } = facts;
|
|
74
|
+
if (conclusion === null) return "in-flight";
|
|
75
|
+
if (conclusion === "skipped") return "skipped";
|
|
76
|
+
// Resolution outranks the population gate: see the note above.
|
|
77
|
+
if (referencedCount > 0) return "resolved";
|
|
78
|
+
if (!inPopulation) return "out-of-population";
|
|
79
|
+
// Jobs that exist and are empty are a dead runner, not a parse failure.
|
|
80
|
+
if (jobCount > 0) return "dead-runner";
|
|
81
|
+
if (!PRE_JOB_FAILURES.includes(conclusion)) return "inconclusive";
|
|
82
|
+
return "load-failure";
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Whether a page set actually covered the window it claims to report on.
|
|
86
|
+
*
|
|
87
|
+
* A first implementation of this detector read one page of runs and filtered
|
|
88
|
+
* by timestamp. On a busy repository the window's start fell off page one, so
|
|
89
|
+
* it reported "OK, 100 runs inspected" across a period that contained four
|
|
90
|
+
* known load failures. It was clean because it could not see. A scan that
|
|
91
|
+
* cannot cover its window must report an ERROR, never a pass — a detector
|
|
92
|
+
* that reports success for the range it failed to read is worse than no
|
|
93
|
+
* detector, because it also retires the suspicion.
|
|
94
|
+
* @param scan - How far back the page set reached
|
|
95
|
+
* @returns Covered, or not covered with the reason
|
|
96
|
+
*/
|
|
97
|
+
export function windowCoverage(scan) {
|
|
98
|
+
const { oldestSeen, windowStart, exhausted } = scan;
|
|
99
|
+
// An exhausted history is covered even when it stops short: a repository
|
|
100
|
+
// younger than the window has no earlier runs to read, and refusing it
|
|
101
|
+
// would be a red nobody can ever clear.
|
|
102
|
+
if (exhausted) return { covered: true, reason: "" };
|
|
103
|
+
if (oldestSeen === null) {
|
|
104
|
+
return {
|
|
105
|
+
covered: false,
|
|
106
|
+
reason: `read no runs at all, so it did not reach ${windowStart}. An empty read is not an empty window.`,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
const oldest = Date.parse(oldestSeen);
|
|
110
|
+
const start = Date.parse(windowStart);
|
|
111
|
+
if (Number.isNaN(oldest) || Number.isNaN(start)) {
|
|
112
|
+
return {
|
|
113
|
+
covered: false,
|
|
114
|
+
reason: `could not read \`${oldestSeen}\` or \`${windowStart}\` as a timestamp, so it did not reach the start of the window. An unreadable bound is not a satisfied one.`,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
if (oldest > start) {
|
|
118
|
+
return {
|
|
119
|
+
covered: false,
|
|
120
|
+
reason: `paging stopped at ${oldestSeen} and did not reach ${windowStart}, so any load failure older than that was never read. Page further, or narrow the window.`,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
return { covered: true, reason: "" };
|
|
124
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// This file is managed by Lisa and IS replaced on each `lisa` run.
|
|
2
|
+
// Do not edit directly — durable changes belong upstream in Lisa.
|
|
3
|
+
|
|
4
|
+
import { type RunLoadClass } from "./reusable-workflow-load-failure.mjs";
|
|
5
|
+
export interface ScannedRun {
|
|
6
|
+
readonly id: number;
|
|
7
|
+
readonly path: string;
|
|
8
|
+
readonly createdAt: string;
|
|
9
|
+
readonly referencedCount: number;
|
|
10
|
+
readonly jobCount: number;
|
|
11
|
+
readonly conclusion: string | null;
|
|
12
|
+
}
|
|
13
|
+
export interface RunPage {
|
|
14
|
+
readonly runs: readonly ScannedRun[];
|
|
15
|
+
readonly hasMore: boolean;
|
|
16
|
+
}
|
|
17
|
+
export interface ScanRequest {
|
|
18
|
+
readonly fetchPage: (page: number) => Promise<RunPage>;
|
|
19
|
+
readonly windowStart: string;
|
|
20
|
+
readonly inPopulation: (path: string) => boolean;
|
|
21
|
+
readonly maxPages: number;
|
|
22
|
+
}
|
|
23
|
+
export interface ScanFinding {
|
|
24
|
+
readonly id: number;
|
|
25
|
+
readonly path: string;
|
|
26
|
+
readonly verdict: RunLoadClass;
|
|
27
|
+
}
|
|
28
|
+
export interface ScanResult {
|
|
29
|
+
readonly loadFailures: readonly ScanFinding[];
|
|
30
|
+
readonly inspected: number;
|
|
31
|
+
readonly covered: boolean;
|
|
32
|
+
readonly reason: string;
|
|
33
|
+
}
|
|
34
|
+
export declare function scanForLoadFailures(
|
|
35
|
+
request: ScanRequest
|
|
36
|
+
): Promise<ScanResult>;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// This file is managed by Lisa and IS replaced on each `lisa` run.
|
|
2
|
+
// Do not edit directly — durable changes belong upstream in Lisa.
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
classifyRunLoad,
|
|
6
|
+
windowCoverage,
|
|
7
|
+
} from "./reusable-workflow-load-failure.mjs";
|
|
8
|
+
/**
|
|
9
|
+
* Whether a run predates the window, so everything after it can be ignored.
|
|
10
|
+
* @param run - The run being considered
|
|
11
|
+
* @param windowStart - ISO timestamp the scan intends to cover back to
|
|
12
|
+
* @returns True only when this run is at or before the start of the window
|
|
13
|
+
*/
|
|
14
|
+
function reachedWindow(run, windowStart) {
|
|
15
|
+
const created = Date.parse(run.createdAt);
|
|
16
|
+
const start = Date.parse(windowStart);
|
|
17
|
+
// An unparseable timestamp must not read as "we are past the window" —
|
|
18
|
+
// that would end the scan early and call the short read complete.
|
|
19
|
+
if (Number.isNaN(created) || Number.isNaN(start)) return false;
|
|
20
|
+
return created <= start;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Classify one run through the shared decision.
|
|
24
|
+
* @param run - The run being classified
|
|
25
|
+
* @param inPopulation - Whether this run's caller declares a reusable workflow
|
|
26
|
+
* @returns The run's id, caller path, and verdict
|
|
27
|
+
*/
|
|
28
|
+
function findingFor(run, inPopulation) {
|
|
29
|
+
return {
|
|
30
|
+
id: run.id,
|
|
31
|
+
path: run.path,
|
|
32
|
+
verdict: classifyRunLoad({
|
|
33
|
+
inPopulation,
|
|
34
|
+
referencedCount: run.referencedCount,
|
|
35
|
+
jobCount: run.jobCount,
|
|
36
|
+
conclusion: run.conclusion,
|
|
37
|
+
}),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Fold one page into the running scan state.
|
|
42
|
+
* @param state - Everything the scan has accumulated so far
|
|
43
|
+
* @param page - The page just fetched
|
|
44
|
+
* @param request - The scan request, for its window and population test
|
|
45
|
+
* @returns The scan state after absorbing this page
|
|
46
|
+
*/
|
|
47
|
+
function absorb(state, page, request) {
|
|
48
|
+
const findings = page.runs
|
|
49
|
+
.filter(run => !reachedWindow(run, request.windowStart))
|
|
50
|
+
.map(run => findingFor(run, request.inPopulation(run.path)));
|
|
51
|
+
const oldest = page.runs.at(-1)?.createdAt ?? state.oldestSeen;
|
|
52
|
+
const reached = page.runs.some(run =>
|
|
53
|
+
reachedWindow(run, request.windowStart)
|
|
54
|
+
);
|
|
55
|
+
return {
|
|
56
|
+
findings: [...state.findings, ...findings],
|
|
57
|
+
inspected: state.inspected + page.runs.length,
|
|
58
|
+
oldestSeen: oldest,
|
|
59
|
+
exhausted: !page.hasMore,
|
|
60
|
+
done: reached || !page.hasMore,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Read run history until the window is covered, then report load failures.
|
|
65
|
+
*
|
|
66
|
+
* Paging stops on the first of three conditions: a run older than the window
|
|
67
|
+
* was seen, the history ran out, or `maxPages` was reached. Only the first
|
|
68
|
+
* two are coverage; hitting the cap leaves `covered` false, because a cap is
|
|
69
|
+
* a budget the operator set and not evidence about the runs beyond it.
|
|
70
|
+
* @param request - Fetcher, window, population test, and page cap
|
|
71
|
+
* @returns Findings plus whether the window was actually covered
|
|
72
|
+
*/
|
|
73
|
+
export async function scanForLoadFailures(request) {
|
|
74
|
+
const initial = {
|
|
75
|
+
findings: [],
|
|
76
|
+
inspected: 0,
|
|
77
|
+
oldestSeen: null,
|
|
78
|
+
exhausted: false,
|
|
79
|
+
done: false,
|
|
80
|
+
};
|
|
81
|
+
const final = await Array.from(
|
|
82
|
+
{ length: request.maxPages },
|
|
83
|
+
(_unused, index) => index + 1
|
|
84
|
+
).reduce(async (carried, pageNumber) => {
|
|
85
|
+
const state = await carried;
|
|
86
|
+
if (state.done) return state;
|
|
87
|
+
return absorb(state, await request.fetchPage(pageNumber), request);
|
|
88
|
+
}, Promise.resolve(initial));
|
|
89
|
+
const coverage = windowCoverage({
|
|
90
|
+
oldestSeen: final.oldestSeen,
|
|
91
|
+
windowStart: request.windowStart,
|
|
92
|
+
exhausted: final.exhausted,
|
|
93
|
+
});
|
|
94
|
+
return {
|
|
95
|
+
loadFailures: final.findings.filter(
|
|
96
|
+
finding => finding.verdict === "load-failure"
|
|
97
|
+
),
|
|
98
|
+
inspected: final.inspected,
|
|
99
|
+
covered: coverage.covered,
|
|
100
|
+
reason: coverage.reason,
|
|
101
|
+
};
|
|
102
|
+
}
|