@plainconceptsplatform/workflows 1.14.1 → 1.16.1

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.
Files changed (41) hide show
  1. package/dist/workflow-catalog.d.ts +1 -1
  2. package/dist/workflow-catalog.js +0 -2
  3. package/loops/actions/add-issue-labels/action.yml +50 -50
  4. package/loops/actions/agent-output.cjs +17 -17
  5. package/loops/actions/apply-agent-bundle/action.yml +24 -24
  6. package/loops/actions/apply-agent-comments/action.yml +42 -42
  7. package/loops/actions/apply-agent-labels/action.yml +55 -55
  8. package/loops/actions/assess-blast-radius/assess-blast-radius.sh +0 -0
  9. package/loops/actions/assess-blast-radius/migration-is-additive.sh +0 -0
  10. package/loops/actions/cleanup-artifacts/action.yml +91 -91
  11. package/loops/actions/close-agent-issues/action.yml +43 -43
  12. package/loops/actions/create-issue-comment/action.yml +29 -29
  13. package/loops/actions/decide-agent-retry/classify-failure.sh +0 -0
  14. package/loops/actions/download-agent-output/action.yml +53 -53
  15. package/loops/actions/link-pr-to-issue/action.yml +40 -40
  16. package/loops/actions/list-open-issues/action.yml +33 -33
  17. package/loops/actions/load-issue-context/action.yml +45 -45
  18. package/loops/actions/merge-agent-pr/action.yml +49 -49
  19. package/loops/actions/merge-train/merge-train.sh +0 -0
  20. package/loops/actions/rebase-stale-patch/rebase-stale-patch.sh +0 -0
  21. package/loops/actions/remove-issue-labels/action.yml +37 -37
  22. package/loops/actions/update-agent-issues/action.yml +58 -58
  23. package/loops/actions/validate-merge-gate-output/validate-merge-gate-output.sh +0 -0
  24. package/loops/actions/validate-refine-output/validate-refine-output.sh +13 -1
  25. package/loops/actions/validate-review-output/action.yml +35 -35
  26. package/loops/actions/validate-triage-output/action.yml +36 -36
  27. package/loops/actions/verify-composite-actions/action.yml +9 -9
  28. package/loops/actions/verify-refine-output/action.yml +9 -9
  29. package/loops/actions/verify-refine-output/verify-refine-output.sh +4 -0
  30. package/loops/actions/verify-route-matrix/action.yml +9 -9
  31. package/loops/actions/verify-route-matrix/verify-route-matrix.sh +32 -46
  32. package/loops/templates/agentics/agentics-maintenance.yml +121 -121
  33. package/loops/templates/issues/bug_report.yml +109 -109
  34. package/loops/templates/issues/feature_request.yml +75 -75
  35. package/loops/templates/quality-gates/scripts/dast-wait-stable.sh +0 -0
  36. package/loops/workflows/agent-refine.md +29 -118
  37. package/loops/workflows/shared/platform-defaults.md +53 -53
  38. package/package.json +1 -1
  39. package/loops/actions/report-workflow-errors/action.yml +0 -538
  40. package/loops/actions/report-workflow-errors/report-policy.cjs +0 -194
  41. package/loops/templates/agentics/agentics-error-report.yml +0 -101
@@ -1,538 +0,0 @@
1
- # Managed by @plainconceptsplatform/workflows. Source: loops/actions/report-workflow-errors/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
- name: Report workflow errors upstream
3
- description: >-
4
- Summarise the last day of failures in the workflows this package ships and file the summary
5
- upstream so the package can be improved. Deterministic: no model runs, and nothing that could
6
- identify this repository or its contents leaves it.
7
-
8
- inputs:
9
- token:
10
- description: Token for THIS repository. Read-only; used to list runs and jobs.
11
- required: true
12
- upstream-token:
13
- description: >-
14
- Token that can open an issue in the upstream repository. Empty means compute the report,
15
- attach it to the run, and file nothing.
16
- required: false
17
- default: ''
18
- upstream-repo:
19
- description: The repository the report is filed in, as owner/name.
20
- required: false
21
- default: PlainConceptsPlatform/Agentic-Workflows
22
- lookback-hours:
23
- description: How far back to look for failures.
24
- required: false
25
- default: '24'
26
- min-occurrences:
27
- description: >-
28
- A signature seen fewer times than this inside the window does not open a new issue. One
29
- occurrence is a blip; an issue that is already open is always appended to.
30
- required: false
31
- default: '2'
32
- min-occurrences-unknown:
33
- description: >-
34
- The same, for a failure the catalogue could not classify. With no diagnosis behind it, a
35
- cause has to repeat more before it is worth a person's time.
36
- required: false
37
- default: '3'
38
- max-findings:
39
- description: The most issues one run opens, the most frequent first. The rest stay in the job summary.
40
- required: false
41
- default: '5'
42
- quiet-days:
43
- description: A signature whose issue was closed within this many days stays quiet, whatever the reason it was closed.
44
- required: false
45
- default: '30'
46
- dry-run:
47
- description: Compute and print the report, file nothing.
48
- required: false
49
- default: 'false'
50
-
51
- outputs:
52
- filed:
53
- description: The upstream issue number, or empty if nothing was filed.
54
- value: ${{ steps.report.outputs.filed }}
55
-
56
- runs:
57
- using: composite
58
- steps:
59
- - name: Collect and file
60
- id: report
61
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
62
- env:
63
- UPSTREAM_TOKEN: ${{ inputs.upstream-token }}
64
- UPSTREAM_REPO: ${{ inputs.upstream-repo }}
65
- LOOKBACK_HOURS: ${{ inputs.lookback-hours }}
66
- MIN_OCCURRENCES: ${{ inputs.min-occurrences }}
67
- MIN_UNKNOWN: ${{ inputs.min-occurrences-unknown }}
68
- MAX_FINDINGS: ${{ inputs.max-findings }}
69
- QUIET_DAYS: ${{ inputs.quiet-days }}
70
- POLICY_PATH: ${{ github.action_path }}/report-policy.cjs
71
- DRY_RUN: ${{ inputs.dry-run }}
72
- with:
73
- github-token: ${{ inputs.token }}
74
- script: |
75
- // Every consumer of this package is a private repository, and this is the one job that
76
- // sends anything out of one. The rule it is built around: no free text ever leaves
77
- // here. Not a log line, not a branch name, not an issue title, not a file path. What
78
- // leaves is a fixed set of enumerable facts about workflows this package itself
79
- // ships -- their names, their job and step names, conclusions, timings and counts --
80
- // plus the NAME of a matched error pattern from the catalogue below. The final body
81
- // is checked against a leak scanner and the job fails rather than files if the
82
- // scanner trips.
83
-
84
- // What to file and what to leave as a number lives in report-policy.cjs, apart from
85
- // what this script reads and writes, so it can be run on its own.
86
- const policy = require(process.env.POLICY_PATH);
87
-
88
- const dryRun = process.env.DRY_RUN === 'true';
89
- const upstreamToken = process.env.UPSTREAM_TOKEN;
90
- const [upstreamOwner, upstreamName] = process.env.UPSTREAM_REPO.split('/');
91
- const since = new Date(Date.now() - Number(process.env.LOOKBACK_HOURS) * 3600 * 1000);
92
-
93
- // Only workflows this package ships. A consumer's own workflow name is theirs and can
94
- // describe a product, a customer or an environment -- one consumer really does have a
95
- // job named "Deploy app to" followed by an interpolated environment input -- so an
96
- // allowlist by filename is the boundary, and it is the only thing keeping those names
97
- // out. Anything not matching is counted and otherwise ignored.
98
- //
99
- // That example is described rather than quoted on purpose. An Actions expression is
100
- // interpolated into this script by the runner BEFORE the JavaScript is parsed, comments
101
- // included, so quoting one here made the comment a live substitution -- flagged by
102
- // semgrep as github-script-injection, and invisible to a `node --check` pass because
103
- // the checker has to mask the expression to parse the file at all. Everything this
104
- // script needs arrives through `env:` above; nothing is interpolated into it.
105
- //
106
- // Exactly the filenames the package installs, and no pattern that admits a name it does
107
- // not. The previous version was written as a shape (`\.(yml|lock\.yml)$` over a group of
108
- // stems) and so admitted 22 names of which 11 are never shipped: the seven workers exist
109
- // as `.md` sources compiled to `.lock.yml`, never as `agent-refine.yml`, and there is no
110
- // `work-router.lock.yml`. A consumer writing their own `agent-release.yml` would have had
111
- // its job and step names read and reported.
112
- const OWNED = new Set([
113
- 'work-router.yml',
114
- 'authorize-bot-work.yml',
115
- 'release-tag.yml',
116
- 'agentics-checks.yml',
117
- 'agentics-maintenance.yml',
118
- ...['refine', 'implement', 'triage', 'apply-review', 'merge-gate', 'audit', 'docs-audit', 'release']
119
- .map((route) => `agent-${route}.lock.yml`),
120
- ]);
121
- const ownedName = (path) => (path ?? '').split('/').pop() ?? '';
122
-
123
- // The catalogue. Each entry is a failure shape worth fixing in the package. Only the
124
- // `id` is ever reported -- never the text that matched it, because the surrounding log
125
- // line carries repository paths, branch names and issue titles.
126
- const CATALOGUE = [
127
- { id: 'runner-never-assigned', test: /waiting for a runner|no runner matching|requested labels/i, note: 'A job waited for a self-hosted runner that never arrived.' },
128
- { id: 'runner-lost-mid-job', test: /lost communication with the server|runner has received a shutdown signal|The self-hosted runner:.*lost|exit code 143/i, note: 'The runner disappeared while a job was running.' },
129
- { id: 'job-cancelled-by-timeout', test: /The job running on runner .* has exceeded the maximum execution time|timeout-minutes/i, note: 'A job hit its timeout instead of finishing.' },
130
- { id: 'out-of-disk', test: /no space left on device|You are running out of disk space/i, note: 'The runner ran out of disk.' },
131
- { id: 'out-of-memory', test: /JavaScript heap out of memory|Killed\s+process|OOMKilled/i, note: 'A step was killed for memory.' },
132
- { id: 'jq-error', test: /^jq: error|jq: error \(at/im, note: 'A jq filter in the router or an action died on its input.' },
133
- { id: 'gh-api-403', test: /HTTP 403|Resource not accessible by integration/i, note: 'A step called an endpoint its token has no scope for.' },
134
- { id: 'gh-api-404', test: /HTTP 404/i, note: 'A step called an endpoint or resource that does not exist.' },
135
- { id: 'gh-api-rate-limit', test: /API rate limit exceeded|secondary rate limit/i, note: 'A step was rate limited.' },
136
- { id: 'gh-api-409-conflict', test: /HTTP 409|Update is not a fast forward/i, note: 'A push or merge raced another writer.' },
137
- { id: 'model-quota', test: /quota|insufficient_quota|429 Too Many Requests/i, note: 'The model gateway refused the request for quota reasons.' },
138
- { id: 'model-gateway-unreachable', test: /ECONNREFUSED|ETIMEDOUT|getaddrinfo|502 Bad Gateway|503 Service Unavailable/i, note: 'The model gateway or another network dependency was unreachable.' },
139
- { id: 'agent-produced-no-output', test: /no agent output|output file (is )?(missing|empty)|safe.outputs.*empty/i, note: 'The agent finished without emitting any safe output.' },
140
- { id: 'lock-out-of-date', test: /lock file is out of date|recompile|\.lock\.yml.*differs/i, note: 'A compiled lock file did not match its source.' },
141
- { id: 'action-not-found', test: /Can't find '.*' in|Unable to resolve action/i, note: 'A workflow referenced an action or path that is not present.' },
142
- { id: 'yaml-or-expression-invalid', test: /Invalid workflow file|Unrecognized named-value|Unexpected symbol/i, note: 'A workflow failed to parse or an expression did not evaluate.' },
143
- ];
144
-
145
- // The leak scanner. Runs on the finished body, and its failure is fatal: a report that
146
- // might carry private information is not filed at all. Deliberately paranoid, because
147
- // the cost of a false positive is one missing daily report and the cost of a false
148
- // negative is a private repository described in a public-facing issue.
149
- const owner = context.repo.owner;
150
- const repo = context.repo.repo;
151
- const leakChecks = [
152
- { what: 'the repository name', test: new RegExp(`\\b${repo.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i') },
153
- { what: 'the owner name', test: new RegExp(`\\b${owner.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i') },
154
- { what: 'a github.com URL', test: /https?:\/\/[^\s)]*github\.com\/[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+/ },
155
- { what: 'an email address', test: /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/ },
156
- { what: 'an absolute path', test: /(?:^|\s)(?:\/(?:home|Users|github|runner|mnt)\/|[A-Za-z]:\\)/m },
157
- { what: 'a long hex or base64 blob that could be a token or a commit', test: /\b(?:gh[pousr]_[A-Za-z0-9]{16,}|[A-Za-z0-9+/]{40,}={0,2}|[0-9a-f]{32,})\b/ },
158
- { what: 'an issue or pull request reference', test: /(?:^|[^A-Za-z0-9_/])#\d+/ },
159
- { what: 'a branch or ref path', test: /\brefs\/heads\/|\b(?:feature|fix|chore|bot)\/[A-Za-z0-9._-]+/ },
160
- ];
161
-
162
- // Which version of the package is reporting. Without this a bug fixed weeks ago keeps
163
- // arriving upstream with no way to tell it is stale: the first report filed by this
164
- // job was 19 occurrences of a jq bug that a release had already fixed, and nothing in
165
- // it said so. The version is the package's own name for itself, read from the
166
- // ownership header the installer stamps, so it carries nothing of the repository.
167
- let packageVersion = 'unknown';
168
- try {
169
- const header = require('node:fs')
170
- .readFileSync('.github/workflows/work-router.yml', 'utf8')
171
- .split('\n')[0];
172
- packageVersion = policy.parseVersion(header);
173
- } catch {
174
- core.info('could not read the installed package version from the router header');
175
- }
176
-
177
- const runs = await github.paginate(github.rest.actions.listWorkflowRunsForRepo, {
178
- ...context.repo, created: `>=${since.toISOString()}`, per_page: 100,
179
- });
180
-
181
- // signature -> what we know about it. The signature is the dedup key both here and
182
- // upstream, and every part of it is package-owned or an enumerated value.
183
- //
184
- // THE FINDING SHAPE IS THE PRIVACY BOUNDARY. bodyFor() renders a finding, so a field
185
- // added here is a field sent to another repository. These ten are all
186
- // package-owned names or enumerated values; nothing derived from a log, a branch, a
187
- // path or an issue belongs on this object. The route matrix asserts this exact list,
188
- // so adding a ninth field fails the build rather than leaking on the next cron.
189
- const FINDING_FIELDS = ['workflow', 'job', 'step', 'conclusion', 'patternId', 'count', 'runnerLabels', 'attempts', 'severity', 'variants'];
190
- const findings = new Map();
191
- let ownedRuns = 0;
192
- let ownedFailures = 0;
193
- let skippedForeign = 0;
194
- // Things that happened and are worth a number in the job summary, but that nobody can
195
- // fix by editing a workflow, so they never open an issue: a cancelled job with no step
196
- // that failed, a log that could not be read, and the fleet's own transient causes.
197
- const quiet = { cancelledNoStep: 0, unreadable: 0, retried: 0, reviewChecks: 0, infra: new Map() };
198
- let queueWaitTotal = 0;
199
- let queueWaitCount = 0;
200
- let queueWaitWorst = 0;
201
-
202
- // A run that never gets a runner never fails: it sits queued until GitHub expires it
203
- // a day later. On a two-VM fleet that is the most common way work disappears, and it
204
- // is invisible in every failure count, so it is detected by state and age instead.
205
- const STUCK_AFTER_MS = 45 * 60 * 1000;
206
- let stuckInQueue = 0;
207
-
208
- for (const run of runs) {
209
- const file = ownedName(run.path);
210
- if (!OWNED.has(file)) { skippedForeign += 1; continue; }
211
- ownedRuns += 1;
212
-
213
- if (['queued', 'waiting', 'pending'].includes(run.status) &&
214
- Date.now() - new Date(run.created_at).getTime() > STUCK_AFTER_MS) {
215
- stuckInQueue += 1;
216
- quiet.infra.set('runner-never-assigned', (quiet.infra.get('runner-never-assigned') ?? 0) + 1);
217
- continue;
218
- }
219
-
220
- if (!['failure', 'cancelled', 'timed_out', 'startup_failure'].includes(run.conclusion)) continue;
221
- ownedFailures += 1;
222
-
223
- // A lint check failing on a pull request has done its job on somebody's change.
224
- if (policy.isReviewCheck({ workflow: file, event: run.event })) { quiet.reviewChecks += 1; continue; }
225
-
226
- let jobs = [];
227
- try {
228
- jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRunAttempt, {
229
- ...context.repo, run_id: run.id, attempt_number: run.run_attempt ?? 1, per_page: 100,
230
- });
231
- } catch { continue; }
232
-
233
- // The worker already caught this one and sent the work back through the router; the
234
- // attempt that parks does not retry, so a failure no retry got past is still read.
235
- if (policy.wasRetried(jobs)) { quiet.retried += 1; continue; }
236
-
237
- for (const job of jobs) {
238
- if (job.started_at && job.created_at) {
239
- const wait = (new Date(job.started_at) - new Date(job.created_at)) / 1000;
240
- if (wait >= 0) {
241
- queueWaitTotal += wait; queueWaitCount += 1;
242
- queueWaitWorst = Math.max(queueWaitWorst, Math.round(wait));
243
- }
244
- }
245
- if (!['failure', 'cancelled', 'timed_out'].includes(job.conclusion)) continue;
246
-
247
- const step = (job.steps ?? []).find((s) => ['failure', 'timed_out'].includes(s.conclusion));
248
- // Job and step names come from files this package ships, so they are the
249
- // package's own words. A `run-name` is not: it interpolates issue titles.
250
- const stepName = step?.name ?? '(no failing step recorded)';
251
-
252
- // A cancelled job that never reached a failing step is somebody or something
253
- // stopping work, not a defect: a superseded run, a job cancelled behind a failed
254
- // one. It was 189 of 320 reports and every one said nothing.
255
- if (job.conclusion === 'cancelled' && !step) { quiet.cancelledNoStep += 1; continue; }
256
-
257
- // GitHub writes why a runner went away into the job's annotations ("The runner has
258
- // received a shutdown signal", exit code 143), not into the log, so they are read
259
- // with it. Only the id of a catalogue entry that matches ever leaves.
260
- let annotationText = '';
261
- try {
262
- const notes = await github.paginate(github.rest.checks.listAnnotations, {
263
- ...context.repo, check_run_id: job.id, per_page: 50,
264
- });
265
- annotationText = notes.map((note) => note.message ?? '').join('\n');
266
- } catch { /* annotations are an extra; the log can still classify */ }
267
-
268
- // A step whose name already says what failed is not read further: a missing secret
269
- // is the repository's to set, whatever the log says around it.
270
- let patternId = policy.causeFromStep(step?.name) ?? 'unrecognised';
271
- let logRead = true;
272
- if (patternId === 'unrecognised') {
273
- try {
274
- const { data: log } = await github.rest.actions.downloadJobLogsForWorkflowRun({
275
- ...context.repo, job_id: job.id,
276
- });
277
- const text = typeof log === 'string' ? log : Buffer.from(log).toString('utf8');
278
- // The tail is where the failure is, and reading less of the log is less to get
279
- // wrong. Only the matched entry's id crosses into the report.
280
- const tail = `${annotationText}\n${text.slice(-20000)}`;
281
- patternId = CATALOGUE.find((entry) => entry.test.test(tail))?.id ?? 'unrecognised';
282
- } catch {
283
- logRead = false;
284
- patternId = CATALOGUE.find((entry) => entry.test.test(annotationText))?.id ?? 'log-unavailable';
285
- }
286
- }
287
-
288
- // A log nobody could read says nothing, so it is counted and not filed.
289
- if (patternId === 'log-unavailable') { quiet.unreadable += 1; continue; }
290
- if (!logRead && policy.severityOf(patternId) === 'infra') {
291
- quiet.infra.set(patternId, (quiet.infra.get(patternId) ?? 0) + 1);
292
- continue;
293
- }
294
-
295
- // One cause is one finding, whichever job tripped on it.
296
- const signature = policy.signatureOf({
297
- workflow: file, step: stepName, conclusion: job.conclusion, patternId,
298
- });
299
- const seen = findings.get(signature) ?? {
300
- workflow: file, job: job.name, step: stepName,
301
- conclusion: job.conclusion, patternId: patternId, count: 0,
302
- runnerLabels: new Set(), attempts: new Set(),
303
- severity: policy.severityOf(patternId), variants: new Set(),
304
- };
305
- seen.count += 1;
306
- seen.variants.add(`${job.name}|${stepName}`);
307
- seen.attempts.add(run.run_attempt ?? 1);
308
- // A pool label is the one value on a finding that the CONSUMER writes, and the
309
- // installer deliberately preserves theirs across updates (preserveRunnerPool in
310
- // cli/src/worker-env.ts). A pool named for a customer, a tenant or an environment --
311
- // `contoso-prod-arc`, `retail-eu-runners` -- would have been filed upstream verbatim,
312
- // and no leak check would have caught it: the scanner looks for this repository's
313
- // name, its owner, URLs, emails, paths and token shapes, and a pool name is none of
314
- // those. Bucket it. The catalogue asks whether the self-hosted fleet was involved,
315
- // never what it is called, so the bucket loses no diagnostic value.
316
- for (const label of job.labels ?? []) {
317
- seen.runnerLabels.add(label === 'ubuntu-latest' ? 'github-hosted' : 'self-hosted');
318
- }
319
- findings.set(signature, seen);
320
- }
321
- }
322
-
323
- const catalogueNote = (id) => CATALOGUE.find((entry) => entry.id === id)?.note
324
- ?? (id === 'missing-secret' ? 'A workflow stopped at a check for a secret this repository has not set.'
325
- : id === 'log-unavailable' ? 'The job log could not be read, so the failure could not be classified.'
326
- : 'The log matched no known failure shape. Worth adding one to the catalogue.');
327
-
328
- const ranked = [...findings.values()].sort((a, b) => b.count - a.count);
329
- const hours = process.env.LOOKBACK_HOURS;
330
-
331
- // What becomes an issue. Infrastructure causes are a rate, a blip is not worth a
332
- // person's time, and a bad night opens a handful of issues rather than forty.
333
- const selection = policy.select(ranked, {
334
- minOccurrences: Number(process.env.MIN_OCCURRENCES),
335
- minUnknown: Number(process.env.MIN_UNKNOWN),
336
- maxFindings: Number(process.env.MAX_FINDINGS),
337
- });
338
-
339
- // Enforce the boundary at run time as well as in the build. A field that reached a
340
- // finding by some path the static check did not see stops the job here rather than
341
- // being rendered into an issue in another repository.
342
- for (const finding of ranked) {
343
- const unexpected = Object.keys(finding).filter((key) => !FINDING_FIELDS.includes(key));
344
- if (unexpected.length > 0) {
345
- core.setFailed(`a finding carries ${unexpected.join(', ')}, which is not in the reportable field list. Nothing was filed.`);
346
- return;
347
- }
348
- }
349
-
350
- const bodyFor = (finding) => [
351
- `<!-- pcp-error-report: ${policy.markerKey(finding)} -->`,
352
- '',
353
- 'Filed automatically by the `report-workflow-errors` job in a repository that installs',
354
- 'this package. It carries no information about that repository: only the names this',
355
- 'package gives its own workflows, jobs and steps, and a classification of the failure.',
356
- '',
357
- '| | |',
358
- '|---|---|',
359
- `| Workflow | \`${finding.workflow}\` |`,
360
- `| Job | \`${finding.job}\` |`,
361
- `| Failing step | \`${finding.step}\` |`,
362
- `| Conclusion | \`${finding.conclusion}\` |`,
363
- `| Classification | \`${finding.patternId}\` |`,
364
- `| Severity | \`${finding.severity}\` |`,
365
- `| Job and step combinations with this cause | ${finding.variants.size} |`,
366
- `| Runner labels | ${[...finding.runnerLabels].map((l) => `\`${l}\``).join(', ') || 'not reported'} |`,
367
- `| Occurrences in the last ${hours}h | ${finding.count} |`,
368
- `| Re-run attempts involved | ${[...finding.attempts].sort().join(', ')} |`,
369
- `| Package version reporting | \`${packageVersion}\` |`,
370
- '',
371
- `**What this classification means.** ${catalogueNote(finding.patternId)}`,
372
- '',
373
- '### Occurrences',
374
- '',
375
- `- ${new Date().toISOString().slice(0, 10)}: ${finding.count} in ${hours}h, on \`${packageVersion}\``,
376
- ].join('\n');
377
-
378
- const titleFor = (finding) => (finding.patternId === 'unrecognised'
379
- ? `[${finding.severity}] ${finding.workflow}: ${finding.patternId} in "${finding.step}"`
380
- : `[${finding.severity}] ${finding.workflow}: ${finding.patternId}`);
381
-
382
- // The scanner. A body that trips it is never filed, and the job goes red so the
383
- // pattern that produced it gets fixed rather than quietly dropped every night.
384
- const scan = (text, label) => {
385
- const tripped = leakChecks.filter((check) => check.test.test(text));
386
- if (tripped.length === 0) return true;
387
- core.error(`${label} was withheld: the leak scanner matched ${tripped.map((t) => t.what).join('; ')}`);
388
- return false;
389
- };
390
-
391
- const summaryRows = ranked.map((f) => [
392
- `\`${f.workflow}\``, `\`${f.job}\``, `\`${f.patternId}\``, String(f.count),
393
- ]);
394
- await core.summary
395
- .addHeading('Workflow error report', 2)
396
- .addList([
397
- `Runs from workflows this package ships: ${ownedRuns}`,
398
- `Of those, failed: ${ownedFailures}`,
399
- `Runs from the repository's own workflows, not inspected: ${skippedForeign}`,
400
- // This job's own workflow is not on the allowlist, which is right -- it would be
401
- // reporting on itself -- but it means a failure here reaches nobody upstream. Say so
402
- // where someone reading a run will see it, rather than leaving it to be found out.
403
- 'This report does not cover itself: a failure in this job shows up in this repository only.',
404
- `Runs still queued after 45 minutes, so no runner ever took them: ${stuckInQueue}`,
405
- `Distinct failure signatures: ${ranked.length}`,
406
- `Filed or appended: ${selection.file.length}; held back: ${selection.held.length}`,
407
- `Cancelled jobs with no failing step, not reported: ${quiet.cancelledNoStep}`,
408
- `Runs whose failure the worker retried through the router, not reported: ${quiet.retried}`,
409
- `Checks that failed on a pull request, not reported: ${quiet.reviewChecks}`,
410
- `Jobs whose log could not be read, not reported: ${quiet.unreadable}`,
411
- `Infrastructure causes, counted and not reported: ${[...quiet.infra].map(([id, n]) => `${id} ${n}`).join(', ') || 'none'}`,
412
- queueWaitCount > 0
413
- ? `Runner queue wait: ${Math.round(queueWaitTotal / queueWaitCount)}s mean, ${queueWaitWorst}s worst`
414
- : 'Runner queue wait: no jobs started in the window',
415
- ])
416
- .addTable([
417
- [{ data: 'Workflow', header: true }, { data: 'Job', header: true }, { data: 'Classification', header: true }, { data: 'Count', header: true }],
418
- ...summaryRows,
419
- ])
420
- .write();
421
-
422
- // The repository's own configuration is said here, to the people who can change it, and
423
- // nowhere else. The step name is the package's (or gh-aw's) own words.
424
- for (const { finding } of selection.held.filter(({ finding }) => finding.severity === 'consumer')) {
425
- core.warning(`${finding.count} job(s) of ${finding.workflow} stopped at "${finding.step}": set the secret it checks for, or the work it guards never runs.`);
426
- }
427
-
428
- if (ranked.length === 0) {
429
- core.info('No failures in the window for any workflow this package ships.');
430
- core.setOutput('filed', '');
431
- return;
432
- }
433
-
434
- // Build every report first and scan them all, so a leak is reported once with the
435
- // full picture rather than one night at a time.
436
- const reports = [];
437
- let leaked = 0;
438
- for (const finding of selection.file) {
439
- const title = titleFor(finding);
440
- const body = bodyFor(finding);
441
- if (!scan(`${title}\n${body}`, `The report for ${finding.patternId}`)) { leaked += 1; continue; }
442
- reports.push({ finding, title, body });
443
- core.info(`report ready: ${title} (${finding.count})`);
444
- }
445
-
446
- if (!upstreamToken) {
447
- // Not silent and not red: the report exists in the job summary, and the run says
448
- // plainly why it went no further.
449
- core.warning(`${reports.length} report(s) computed but not filed: no upstream token is configured. The full report is in this job summary.`);
450
- core.setOutput('filed', '');
451
- if (leaked > 0) core.setFailed(`${leaked} report(s) were withheld by the leak scanner; fix the field that carried private text.`);
452
- return;
453
- }
454
-
455
- const upstream = getOctokit(upstreamToken);
456
- const filed = [];
457
-
458
- // The latest release, to tell a failure a release may already have fixed from one that
459
- // is still live. Best effort: a token that cannot read tags files as before.
460
- let latestRelease = '';
461
- try {
462
- const { data: tags } = await upstream.rest.repos.listTags({
463
- owner: upstreamOwner, repo: upstreamName, per_page: 100,
464
- });
465
- latestRelease = policy.latestVersion(tags.map((tag) => tag.name));
466
- } catch (error) {
467
- core.info(`could not read the upstream tags: HTTP ${error.status ?? '?'}`);
468
- }
469
-
470
- for (const { finding, title, body } of reports) {
471
- const marker = body.split('\n')[0];
472
- // One issue per signature, updated rather than repeated. Searching by the marker
473
- // keeps that true across every consumer: two repositories hitting the same package
474
- // bug land on the same issue and its count is the number that matters.
475
- let existing;
476
- try {
477
- const { data } = await upstream.rest.search.issuesAndPullRequests({
478
- q: `repo:${upstreamOwner}/${upstreamName} is:issue in:body "${marker}"`,
479
- per_page: 10,
480
- });
481
- const matches = data.items.filter((item) => (item.body ?? '').includes(marker));
482
- existing = matches.find((item) => item.state === 'open');
483
- // Closing an issue is the answer to it. Without this every bulk close reopened the
484
- // whole set the next morning: 150 of 320 reports re-filed a signature seen before.
485
- if (!existing && matches.some((item) => policy.isQuiet(item.closed_at, Date.now(), Number(process.env.QUIET_DAYS)))) {
486
- core.info(`left alone, an issue for it was closed within ${process.env.QUIET_DAYS} days: ${title}`);
487
- continue;
488
- }
489
- } catch (error) {
490
- core.warning(`could not search upstream for an existing report: HTTP ${error.status ?? '?'}`);
491
- }
492
-
493
- // An install a minor or more behind does not open a new issue: update it first. It
494
- // still appends to one that is open, because then the bug is live on newer versions too.
495
- if (!existing && policy.isOutdated(packageVersion, latestRelease)) {
496
- core.info(`left alone, ${packageVersion} is behind ${latestRelease}; update first: ${title}`);
497
- continue;
498
- }
499
-
500
- if (dryRun) {
501
- core.info(`[dry-run] would ${existing ? `append to #${existing.number}` : 'open an issue'}: ${title}`);
502
- continue;
503
- }
504
-
505
- try {
506
- if (existing) {
507
- // Append one dated line rather than rewriting the body, so the history of how
508
- // often this fires stays readable.
509
- const line = `- ${new Date().toISOString().slice(0, 10)}: ${finding.count} in ${hours}h, on \`${packageVersion}\``;
510
- const updated = (existing.body ?? '').includes(line)
511
- ? existing.body
512
- : `${existing.body}\n${line}`;
513
- if (!scan(updated, `The appended report for ${finding.patternId}`)) { leaked += 1; continue; }
514
- await upstream.rest.issues.update({
515
- owner: upstreamOwner, repo: upstreamName,
516
- issue_number: existing.number, body: updated,
517
- });
518
- filed.push(existing.number);
519
- core.info(`appended to ${upstreamOwner}/${upstreamName}#${existing.number}`);
520
- } else {
521
- const { data: created } = await upstream.rest.issues.create({
522
- owner: upstreamOwner, repo: upstreamName, title, body,
523
- labels: ['fleet-error-report'],
524
- });
525
- filed.push(created.number);
526
- core.info(`opened ${upstreamOwner}/${upstreamName}#${created.number}`);
527
- }
528
- } catch (error) {
529
- // A missing label, a token without issues:write, a repository that moved. Say so;
530
- // do not fail the whole report for one signature.
531
- core.warning(`could not file the report for ${finding.patternId}: HTTP ${error.status ?? '?'} ${error.message ?? ''}`);
532
- }
533
- }
534
-
535
- core.setOutput('filed', filed.join(','));
536
- if (leaked > 0) {
537
- core.setFailed(`${leaked} report(s) were withheld by the leak scanner. Nothing was filed for them; fix the field that carried private text.`);
538
- }