@plainconceptsplatform/workflows 0.16.4 → 0.19.2
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/dist/catalog-installation.js +28 -15
- package/dist/worker-env.js +10 -21
- package/loops/actions/housekeeping/action.yml +41 -5
- package/loops/actions/identify-gate-subject/action.yml +134 -122
- package/loops/actions/require-open-issue/action.yml +59 -0
- package/loops/actions/verify-route-matrix/verify-route-matrix.sh +322 -0
- package/loops/workflows/agent-apply-review.md +33 -3
- package/loops/workflows/agent-audit.md +36 -0
- package/loops/workflows/agent-implement.md +77 -5
- package/loops/workflows/agent-merge-gate.md +1 -0
- package/loops/workflows/agent-refine.md +33 -1
- package/loops/workflows/agent-release.md +4 -4
- package/loops/workflows/agent-triage.md +33 -1
- package/loops/workflows/work-router.yml +25 -11
- package/package.json +2 -2
|
@@ -26,6 +26,12 @@ export function mandatoryFileSpecs(sourcePath) {
|
|
|
26
26
|
export function catalogSourcePath(modulePath = fileURLToPath(import.meta.url)) {
|
|
27
27
|
return resolve(dirname(modulePath), "..", "loops");
|
|
28
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Where `gh aw` writes the pinned-action lock, and the only place it should be named.
|
|
31
|
+
* `generatedConsumerTargets` in the catalog declares the same path; two literals is how the
|
|
32
|
+
* pre-commit hook ended up guarding one that exists nowhere.
|
|
33
|
+
*/
|
|
34
|
+
const ACTIONS_LOCK = ".github/aw/actions-lock.json";
|
|
29
35
|
const isWorker = (target) => target.startsWith(".github/workflows/agent-") && target.endsWith(".md");
|
|
30
36
|
const isRouter = (target) => target === ".github/workflows/work-router.yml";
|
|
31
37
|
const carriesHeader = (target) => /\.(ya?ml|md|sh|mjs|cjs)$/.test(target);
|
|
@@ -287,25 +293,32 @@ async function preCommitHookUpdate(repositoryPath) {
|
|
|
287
293
|
const hookPath = join(repositoryPath, target);
|
|
288
294
|
const compileLine = "node scripts/compile-agent-workflows.mjs";
|
|
289
295
|
const stageLine = "git add -- .github/workflows/*.lock.yml";
|
|
290
|
-
|
|
296
|
+
// gh aw writes this at .github/aw/, which is what every consumer tracks and what
|
|
297
|
+
// `generatedConsumerTargets` in the catalog declares. The line used to name
|
|
298
|
+
// `.github/actions/actions-lock.json`, a path that exists nowhere, so `[ ! -f ... ]` was
|
|
299
|
+
// always true, the `||` short-circuited, and the real lock was never staged: a compile that
|
|
300
|
+
// bumped an action pin left the lock out of the commit and the tree dirty behind it. Nothing
|
|
301
|
+
// failed, which is why it survived. Found by the first audit run in the dogfood repository.
|
|
302
|
+
const actionLockLine = `[ ! -f ${ACTIONS_LOCK} ] || git add -- ${ACTIONS_LOCK}`;
|
|
291
303
|
const managedLines = `if git diff --cached --name-only -- .github | grep -q .; then\n ${compileLine}\n ${stageLine}\n ${actionLockLine}\nfi\n`;
|
|
292
304
|
if (!await exists(hookPath)) {
|
|
293
305
|
return { target, content: managedLines };
|
|
294
306
|
}
|
|
295
307
|
const content = (await readFile(hookPath, "utf8"))
|
|
296
308
|
.replace("pnpm exec if git diff --cached --name-only -- .github | grep -q .; then", "if git diff --cached --name-only -- .github | grep -q .; then");
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
+
// Replace whatever managed block is there, never append beside it. The previous version
|
|
310
|
+
// recognised only two exact shapes -- the current block and the pre-`if` legacy lines -- and
|
|
311
|
+
// appended when it matched neither. A consumer holding an *older* wrapped block therefore
|
|
312
|
+
// gained a second one, so the compiler ran twice on every commit and one of the two staged a
|
|
313
|
+
// lock path that exists nowhere. All five consumers were carrying two blocks by the time this
|
|
314
|
+
// was noticed, and every future edit to `managedLines` would have added another. Nothing
|
|
315
|
+
// failed: running the compiler twice is only wasteful, so it never surfaced.
|
|
316
|
+
const managedBlock = /^[ \t]*if git diff --cached --name-only -- \.github \| grep -q \.; then\n(?:.*\n)*?[ \t]*fi\n?/gm;
|
|
317
|
+
const legacyLines = `${compileLine}\n${stageLine}\n${actionLockLine}\n`;
|
|
318
|
+
let stripped = content.replace(managedBlock, block => block.includes("compile-agent-workflows") ? "" : block);
|
|
319
|
+
stripped = stripped.split(legacyLines).join("");
|
|
320
|
+
const body = stripped.replace(/\n{3,}/g, "\n\n").replace(/\s+$/, "");
|
|
321
|
+
return { target, content: body === "" ? managedLines : `${body}\n${managedLines}` };
|
|
309
322
|
}
|
|
310
323
|
export async function runCompileIfAvailable(repositoryPath) {
|
|
311
324
|
const script = join(repositoryPath, "scripts", "compile-agent-workflows.mjs");
|
|
@@ -363,9 +376,9 @@ async function generatedFiles(repositoryPath) {
|
|
|
363
376
|
updates.push({ target: `.github/workflows/${file.replaceAll("\\", "/")}`, content: await readFile(join(workflowPath, file), "utf8") });
|
|
364
377
|
}
|
|
365
378
|
}
|
|
366
|
-
const actionsLock = join(repositoryPath,
|
|
379
|
+
const actionsLock = join(repositoryPath, ...ACTIONS_LOCK.split("/"));
|
|
367
380
|
if (await exists(actionsLock)) {
|
|
368
|
-
updates.push({ target:
|
|
381
|
+
updates.push({ target: ACTIONS_LOCK, content: await readFile(actionsLock, "utf8") });
|
|
369
382
|
}
|
|
370
383
|
return updates.sort((left, right) => left.target.localeCompare(right.target));
|
|
371
384
|
}
|
package/dist/worker-env.js
CHANGED
|
@@ -120,26 +120,15 @@ export function mergeWorkerEnv(packageContent, consumerContent, baselineContent,
|
|
|
120
120
|
function engineEndpoint(content) {
|
|
121
121
|
return /^ OPENAI_BASE_URL: (.+)$/m.exec(content)?.[1];
|
|
122
122
|
}
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
return [...found];
|
|
133
|
-
}
|
|
134
|
-
function preserveRunnerPool(packageContent, consumerContent) {
|
|
135
|
-
const mine = runnerPools(consumerContent);
|
|
136
|
-
const theirs = runnerPools(packageContent);
|
|
137
|
-
// Only an unambiguous swap. A consumer naming several pools has drifted rather than
|
|
138
|
-
// decided, and guessing which it meant is worse than leaving the package's.
|
|
139
|
-
if (mine.length !== 1 || theirs.length !== 1 || mine[0] === theirs[0])
|
|
140
|
-
return packageContent;
|
|
141
|
-
return packageContent.replace(/^(\s*runs-on(?:-slim)?: )(\S+)(\s*)$/gm, (line, prefix, pool, tail) => pool === "ubuntu-latest" ? line : `${prefix}${mine[0]}${tail}`);
|
|
142
|
-
}
|
|
123
|
+
// The runner pool used to be preserved from the consumer here: a repository naming a single
|
|
124
|
+
// non-ubuntu pool had every worker rewritten to it. That is how Pliny-Bot ended up running
|
|
125
|
+
// triage, refine, implement, apply-review and audit on RunnerLandingZone while its merge-gate
|
|
126
|
+
// and release stayed on agents-arc -- the pool was applied to the workers installed at the time
|
|
127
|
+
// and never to the ones added later, and nothing reported the split. The pool is package-owned
|
|
128
|
+
// now: the agent jobs belong on the Azure fleet (agents-arc, runner group `agentic`), and the
|
|
129
|
+
// router and release worker name RunnerLandingZone. The route matrix asserts both, so a repo
|
|
130
|
+
// cannot drift back without a red run. Per the standing rule for this package, anything a
|
|
131
|
+
// consumer really must vary belongs in `env:` at the top of the file, not in `runs-on`.
|
|
143
132
|
// GitHub evaluates no expression in a `workflow_run.workflows:` list or in a `cron:`, so the two
|
|
144
133
|
// router values that are also needed there cannot be read from `env:` at those two lines. The
|
|
145
134
|
// installer copies them in instead, which is what keeps the pair in step across an update; the
|
|
@@ -175,5 +164,5 @@ export function mergeWorker(packageContent, consumerContent, baselineContent) {
|
|
|
175
164
|
if (endpoint !== undefined) {
|
|
176
165
|
result = result.replace(/^ OPENAI_BASE_URL: .+$/m, ` OPENAI_BASE_URL: ${endpoint}`);
|
|
177
166
|
}
|
|
178
|
-
return { content:
|
|
167
|
+
return { content: result, report };
|
|
179
168
|
}
|
|
@@ -75,6 +75,13 @@ runs:
|
|
|
75
75
|
const names = (issue) => issue.labels.map((l) => (typeof l === 'string' ? l : l.name));
|
|
76
76
|
|
|
77
77
|
const needsHuman = [];
|
|
78
|
+
// Stalled work still inside its retry window. Separate from needsHuman because nobody
|
|
79
|
+
// needs to act on it yet, and reported anyway because the alternative is silence: the
|
|
80
|
+
// window is 6 hours and the budget is 3 attempts, so an issue that keeps stalling was
|
|
81
|
+
// invisible for eighteen hours before it surfaced as retries-exhausted. Watched that
|
|
82
|
+
// happen to an audit report in the dogfood repository -- parked, and the digest said
|
|
83
|
+
// one item while three issues were sitting there.
|
|
84
|
+
const retrying = [];
|
|
78
85
|
let retried = 0;
|
|
79
86
|
|
|
80
87
|
// 1. Retry what the machine stalled on. `stalled` marks a park caused by a crash, a
|
|
@@ -96,7 +103,10 @@ runs:
|
|
|
96
103
|
needsHuman.push({ kind: 'decided', issue, labels, waited });
|
|
97
104
|
continue;
|
|
98
105
|
}
|
|
99
|
-
if (waited < retryAfterMs)
|
|
106
|
+
if (waited < retryAfterMs) {
|
|
107
|
+
retrying.push({ issue, waited, attempts, work, due: retryAfterMs - waited });
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
100
110
|
if (attempts >= maxRetries || !work) {
|
|
101
111
|
needsHuman.push({ kind: attempts >= maxRetries ? 'retries-exhausted' : 'no-work-label', issue, labels, waited, attempts });
|
|
102
112
|
continue;
|
|
@@ -203,39 +213,64 @@ runs:
|
|
|
203
213
|
|
|
204
214
|
// 6. One digest. Everything above either fixed itself or is listed here, so there is a
|
|
205
215
|
// single place to look instead of four repositories and three label queries.
|
|
216
|
+
// Audit reports nobody actioned. audit-close labels these `stale-audit` once they are
|
|
217
|
+
// past their budget, which frees the audit's backpressure slot. Freeing the slot
|
|
218
|
+
// without listing the report anywhere would only move the silence, so they land here.
|
|
219
|
+
for (const issue of issues) {
|
|
220
|
+
if (issue.pull_request) continue;
|
|
221
|
+
if (!names(issue).includes('stale-audit')) continue;
|
|
222
|
+
needsHuman.push({ kind: 'stale-audit', issue, waited: age(issue.created_at) });
|
|
223
|
+
}
|
|
224
|
+
|
|
206
225
|
const line = (entry) => {
|
|
207
226
|
if (entry.kind === 'pr-parked') return `- PR #${entry.pull.number} · parked ${days(entry.waited)}d · gate verdict \`${entry.verdict}\` · ${entry.pull.title}`;
|
|
208
227
|
if (entry.kind === 'pr-stale') return `- PR #${entry.pull.number} · ${days(entry.waited)}d old · no gate verdict yet · ${entry.pull.title}`;
|
|
209
228
|
if (entry.kind === 'retries-exhausted') return `- #${entry.issue.number} · stalled ${days(entry.waited)}d · retried ${entry.attempts}× and still stalling · ${entry.issue.title}`;
|
|
210
229
|
if (entry.kind === 'no-work-label') return `- #${entry.issue.number} · stalled ${days(entry.waited)}d · no work label to retry with · ${entry.issue.title}`;
|
|
230
|
+
if (entry.kind === 'stale-audit') return `- #${entry.issue.number} · open ${days(entry.waited)}d · audit report nobody actioned · ${entry.issue.title}`;
|
|
211
231
|
return `- #${entry.issue.number} · waiting ${days(entry.waited)}d · ${entry.labels.filter((l) => l !== 'review').join(', ') || 'no other labels'} · ${entry.issue.title}`;
|
|
212
232
|
};
|
|
213
233
|
const section = (heading, kinds) => {
|
|
214
234
|
const rows = needsHuman.filter((e) => kinds.includes(e.kind));
|
|
215
235
|
return rows.length === 0 ? '' : `\n### ${heading} (${rows.length})\n\n${rows.map(line).join('\n')}\n`;
|
|
216
236
|
};
|
|
237
|
+
// Listed, but not as something to act on: the machine is going to try again by itself.
|
|
238
|
+
// The heading says so, because a reader who acts on these duplicates the retry.
|
|
239
|
+
const retryingSection = () => {
|
|
240
|
+
if (retrying.length === 0) return '';
|
|
241
|
+
const hours = (ms) => Math.max(1, Math.round(ms / 3600000));
|
|
242
|
+
const rows = retrying.map((entry) =>
|
|
243
|
+
`- #${entry.issue.number} · stalled ${hours(entry.waited)}h ago · attempt ${entry.attempts + 1} of ${maxRetries} due in ~${hours(entry.due)}h · ${entry.issue.title}`);
|
|
244
|
+
return `\n### Retrying by itself, no action needed (${retrying.length})\n\n${rows.join('\n')}\n`;
|
|
245
|
+
};
|
|
217
246
|
const body = [
|
|
218
247
|
DIGEST_MARKER,
|
|
219
248
|
`_Updated ${new Date().toISOString().slice(0, 16).replace('T', ' ')} UTC. This issue is rewritten by the housekeeping job; edits to it are lost._`,
|
|
220
249
|
section('Waiting on a decision', ['decided']),
|
|
221
250
|
section('Pull requests parked by the gate', ['pr-parked', 'pr-stale']),
|
|
222
251
|
section('Stalled beyond the retry budget', ['retries-exhausted', 'no-work-label']),
|
|
252
|
+
section('Audit reports nobody actioned', ['stale-audit']),
|
|
253
|
+
retryingSection(),
|
|
223
254
|
needsHuman.length === 0 ? '\nNothing is waiting on a person right now.\n' : '',
|
|
224
255
|
`\n---\n\nThis run retried ${retried} stalled issue(s) and made ${planned.length} change(s) in total.`,
|
|
225
256
|
].filter(Boolean).join('\n');
|
|
226
257
|
|
|
227
258
|
if (digestTitle) {
|
|
259
|
+
// The digest exists whenever there is anything to show, including work the machine is
|
|
260
|
+
// about to retry. Gating it on needsHuman alone is what left three parked issues
|
|
261
|
+
// with a digest that named one.
|
|
262
|
+
const anythingToShow = needsHuman.length + retrying.length;
|
|
228
263
|
const existing = issues.find((i) => !i.pull_request && i.title === digestTitle && (i.body ?? '').includes(DIGEST_MARKER));
|
|
229
264
|
if (existing) {
|
|
230
|
-
if (
|
|
265
|
+
if (anythingToShow === 0) {
|
|
231
266
|
await act(`closing the digest #${existing.number}: nothing is waiting`, () =>
|
|
232
267
|
github.rest.issues.update({ ...context.repo, issue_number: existing.number, body, state: 'closed', state_reason: 'completed' }));
|
|
233
268
|
} else {
|
|
234
|
-
await act(`updating the digest #${existing.number}: ${
|
|
269
|
+
await act(`updating the digest #${existing.number}: ${anythingToShow} item(s)`, () =>
|
|
235
270
|
github.rest.issues.update({ ...context.repo, issue_number: existing.number, body }));
|
|
236
271
|
}
|
|
237
|
-
} else if (
|
|
238
|
-
await act(`opening the digest: ${
|
|
272
|
+
} else if (anythingToShow > 0) {
|
|
273
|
+
await act(`opening the digest: ${anythingToShow} item(s)`, () =>
|
|
239
274
|
github.rest.issues.create({ ...context.repo, title: digestTitle, body }));
|
|
240
275
|
}
|
|
241
276
|
}
|
|
@@ -245,6 +280,7 @@ runs:
|
|
|
245
280
|
.addList([
|
|
246
281
|
`Retried: ${retried}`,
|
|
247
282
|
`Waiting on a person: ${needsHuman.length}`,
|
|
283
|
+
`Stalled, retrying by itself: ${retrying.length}`,
|
|
248
284
|
`Changes ${dryRun ? 'planned' : 'made'}: ${planned.length}`,
|
|
249
285
|
])
|
|
250
286
|
.addRaw(planned.length ? `\n\n${planned.map((p) => `- ${p}`).join('\n')}` : '')
|
|
@@ -1,122 +1,134 @@
|
|
|
1
|
-
# Managed by @plainconceptsplatform/workflows. Source: loops/actions/identify-gate-subject/action.yml. Update with workflows update --force; consumer edits may be overwritten.
|
|
2
|
-
name: Identify gate subject
|
|
3
|
-
description: Confirm the pull request is one we authored, resolve the issue it closes, and check that issue carries the required label.
|
|
4
|
-
inputs:
|
|
5
|
-
token:
|
|
6
|
-
description: GitHub token with issues and pull-requests read access.
|
|
7
|
-
required: true
|
|
8
|
-
pr-number:
|
|
9
|
-
description: Pull request number to gate.
|
|
10
|
-
required: true
|
|
11
|
-
ci-conclusion:
|
|
12
|
-
description: Conclusion reported by the CI run that triggered this gate. If empty, fetches from the PR's latest CI run.
|
|
13
|
-
required: false
|
|
14
|
-
default: ''
|
|
15
|
-
|
|
16
|
-
description:
|
|
17
|
-
required: false
|
|
18
|
-
default: ''
|
|
19
|
-
|
|
20
|
-
description:
|
|
21
|
-
required: false
|
|
22
|
-
default: ''
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
1
|
+
# Managed by @plainconceptsplatform/workflows. Source: loops/actions/identify-gate-subject/action.yml. Update with workflows update --force; consumer edits may be overwritten.
|
|
2
|
+
name: Identify gate subject
|
|
3
|
+
description: Confirm the pull request is one we authored, resolve the issue it closes, and check that issue carries the required label.
|
|
4
|
+
inputs:
|
|
5
|
+
token:
|
|
6
|
+
description: GitHub token with issues and pull-requests read access.
|
|
7
|
+
required: true
|
|
8
|
+
pr-number:
|
|
9
|
+
description: Pull request number to gate.
|
|
10
|
+
required: true
|
|
11
|
+
ci-conclusion:
|
|
12
|
+
description: Conclusion reported by the CI run that triggered this gate. If empty, fetches from the PR's latest CI run.
|
|
13
|
+
required: false
|
|
14
|
+
default: ''
|
|
15
|
+
ci-run-id:
|
|
16
|
+
description: CI run ID reported by the run that triggered this gate. If empty, resolved from the PR's latest CI run.
|
|
17
|
+
required: false
|
|
18
|
+
default: ''
|
|
19
|
+
linked-issue:
|
|
20
|
+
description: Issue the pull request closes, when the router already resolved it.
|
|
21
|
+
required: false
|
|
22
|
+
default: ''
|
|
23
|
+
require-label:
|
|
24
|
+
description: Label the closing issue must carry. Empty disables the check.
|
|
25
|
+
required: false
|
|
26
|
+
default: ''
|
|
27
|
+
outputs:
|
|
28
|
+
found:
|
|
29
|
+
description: Whether a valid gate subject was found.
|
|
30
|
+
value: ${{ steps.identify.outputs.found }}
|
|
31
|
+
pr:
|
|
32
|
+
description: Pull request number.
|
|
33
|
+
value: ${{ steps.identify.outputs.pr }}
|
|
34
|
+
issue:
|
|
35
|
+
description: Issue number the pull request closes.
|
|
36
|
+
value: ${{ steps.identify.outputs.issue }}
|
|
37
|
+
conclusion:
|
|
38
|
+
description: CI conclusion, passed through unchanged or fetched from latest CI run.
|
|
39
|
+
value: ${{ steps.identify.outputs.conclusion }}
|
|
40
|
+
run-id:
|
|
41
|
+
description: CI run ID when fetched.
|
|
42
|
+
value: ${{ steps.identify.outputs.run-id }}
|
|
43
|
+
runs:
|
|
44
|
+
using: composite
|
|
45
|
+
steps:
|
|
46
|
+
- name: Identify pull request and closing issue
|
|
47
|
+
id: identify
|
|
48
|
+
shell: bash
|
|
49
|
+
env:
|
|
50
|
+
GH_TOKEN: ${{ inputs.token }}
|
|
51
|
+
REPO: ${{ github.repository }}
|
|
52
|
+
PR_NUMBER: ${{ inputs.pr-number }}
|
|
53
|
+
CONCLUSION: ${{ inputs.ci-conclusion }}
|
|
54
|
+
CI_RUN_ID: ${{ inputs.ci-run-id }}
|
|
55
|
+
LINKED_ISSUE: ${{ inputs.linked-issue }}
|
|
56
|
+
REQUIRE_LABEL: ${{ inputs.require-label }}
|
|
57
|
+
run: |
|
|
58
|
+
set -euo pipefail
|
|
59
|
+
|
|
60
|
+
none() {
|
|
61
|
+
echo "found=false" >> "$GITHUB_OUTPUT"
|
|
62
|
+
echo "$1"
|
|
63
|
+
exit 0
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
pr="$(gh pr view "$PR_NUMBER" --repo "$REPO" \
|
|
67
|
+
--json state,author,closingIssuesReferences,body)"
|
|
68
|
+
|
|
69
|
+
[ "$(jq -r '.state' <<<"$pr")" = "OPEN" ] || none "PR #$PR_NUMBER is not open"
|
|
70
|
+
|
|
71
|
+
# Use GitHub's bot flag because App login formats differ across APIs.
|
|
72
|
+
if [ "$(jq -r '.author.is_bot' <<<"$pr")" != "true" ]; then
|
|
73
|
+
none "PR #$PR_NUMBER was authored by $(jq -r '.author.login' <<<"$pr"), a human."
|
|
74
|
+
fi
|
|
75
|
+
|
|
76
|
+
issue="$LINKED_ISSUE"
|
|
77
|
+
|
|
78
|
+
if [ -z "$issue" ]; then
|
|
79
|
+
issue="$(jq -r '.closingIssuesReferences[0].number // empty' <<<"$pr")"
|
|
80
|
+
fi
|
|
81
|
+
|
|
82
|
+
if [ -z "$issue" ]; then
|
|
83
|
+
issue="$(jq -r '.body // ""' <<<"$pr" |
|
|
84
|
+
grep -oiE '(close[sd]?|fixe?[sd]?|resolve[sd]?) +#[0-9]+' |
|
|
85
|
+
grep -oE '[0-9]+' | head -n 1 || true)"
|
|
86
|
+
fi
|
|
87
|
+
|
|
88
|
+
[ -n "$issue" ] || none "PR #$PR_NUMBER does not close a numbered issue"
|
|
89
|
+
|
|
90
|
+
if [ -n "$REQUIRE_LABEL" ]; then
|
|
91
|
+
labels="$(gh issue view "$issue" --repo "$REPO" --json labels \
|
|
92
|
+
--jq '[.labels[].name]')"
|
|
93
|
+
|
|
94
|
+
if ! jq -e --arg name "$REQUIRE_LABEL" 'index($name)' >/dev/null <<<"$labels"; then
|
|
95
|
+
none "issue #$issue does not carry $REQUIRE_LABEL (has: $(jq -r 'join(", ")' <<<"$labels"))"
|
|
96
|
+
fi
|
|
97
|
+
fi
|
|
98
|
+
|
|
99
|
+
# The run ID is what lets the gate read the failing job and its logs, so it has to be
|
|
100
|
+
# resolved on every path -- not only the label-triggered one. It used to be set inside
|
|
101
|
+
# `if [ -z "$ci_conclusion" ]`, which meant the caller that *knew* the ID got an empty
|
|
102
|
+
# one: the router passes ci-conclusion and ci-run-id together, so the branch was skipped
|
|
103
|
+
# and the value discarded. The gate then wrote `failed-jobs.json` as `[]`, and the agent,
|
|
104
|
+
# with no failing job name and no logs, escalated to `review`. Pliny-Bot #129 and #131
|
|
105
|
+
# both stalled on a human that way while CI was legitimately red and remediable.
|
|
106
|
+
ci_conclusion="$CONCLUSION"
|
|
107
|
+
ci_run_id="$CI_RUN_ID"
|
|
108
|
+
if [ -z "$ci_conclusion" ] || [ -z "$ci_run_id" ]; then
|
|
109
|
+
head_sha="$(gh pr view "$PR_NUMBER" --repo "$REPO" --json headRefOid --jq '.headRefOid')"
|
|
110
|
+
# A run keeps the name it had when it ran, so a repository whose CI was renamed still
|
|
111
|
+
# carries runs under the old casing. Fold the name before comparing.
|
|
112
|
+
ci_run="$(gh api "repos/$REPO/actions/runs?head_sha=$head_sha&per_page=100" --jq '[.workflow_runs[] | select((.name | ascii_downcase) == "app: ci" or (.name | ascii_downcase) == "ci")] | sort_by(.created_at) | last')"
|
|
113
|
+
|
|
114
|
+
if [ -n "$ci_run" ] && [ "$ci_run" != "null" ]; then
|
|
115
|
+
[ -n "$ci_conclusion" ] || ci_conclusion="$(jq -r '.conclusion // "pending"' <<<"$ci_run")"
|
|
116
|
+
[ -n "$ci_run_id" ] || ci_run_id="$(jq -r '.id' <<<"$ci_run")"
|
|
117
|
+
echo "::notice::Resolved CI status: $ci_conclusion (run $ci_run_id)"
|
|
118
|
+
elif [ -z "$ci_conclusion" ]; then
|
|
119
|
+
ci_conclusion="unknown"
|
|
120
|
+
echo "::warning::No CI run found for PR #$PR_NUMBER"
|
|
121
|
+
else
|
|
122
|
+
echo "::warning::CI concluded $ci_conclusion but no run was found for PR #$PR_NUMBER; the gate has no failure evidence"
|
|
123
|
+
fi
|
|
124
|
+
fi
|
|
125
|
+
|
|
126
|
+
{
|
|
127
|
+
echo "found=true"
|
|
128
|
+
echo "pr=$PR_NUMBER"
|
|
129
|
+
echo "issue=$issue"
|
|
130
|
+
echo "conclusion=$ci_conclusion"
|
|
131
|
+
echo "run-id=$ci_run_id"
|
|
132
|
+
} >> "$GITHUB_OUTPUT"
|
|
133
|
+
|
|
134
|
+
echo "PR #$PR_NUMBER closes #$issue; CI concluded $ci_conclusion"
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Managed by @plainconceptsplatform/workflows. Source: loops/actions/require-open-issue/action.yml. Update with `workflows update --force`; consumer edits may be overwritten.
|
|
2
|
+
name: Require an open issue
|
|
3
|
+
description: Report whether the issue is still open, read now rather than from the event that started the run.
|
|
4
|
+
inputs:
|
|
5
|
+
token:
|
|
6
|
+
description: GitHub token with issues read access.
|
|
7
|
+
required: true
|
|
8
|
+
issue-number:
|
|
9
|
+
description: Issue to check.
|
|
10
|
+
required: true
|
|
11
|
+
outputs:
|
|
12
|
+
open:
|
|
13
|
+
description: '"true" when the issue is still open, "false" when it is closed or unreadable.'
|
|
14
|
+
value: ${{ steps.check.outputs.open }}
|
|
15
|
+
runs:
|
|
16
|
+
using: composite
|
|
17
|
+
steps:
|
|
18
|
+
# The classifier already refuses a closed issue, but it can only read
|
|
19
|
+
# `github.event.issue.state`, which is the state at the moment the event fired and is absent
|
|
20
|
+
# altogether on a workflow_dispatch. This fleet queues for a runner for ten minutes and more,
|
|
21
|
+
# so an issue can be closed between the event and the pickup. Numa #659: a comment at
|
|
22
|
+
# 09:02:22 dispatched refine, the issue was closed a second later, the run reached this point
|
|
23
|
+
# at 09:15:53 and refined a closed issue for thirty-eight minutes, ending by labelling it
|
|
24
|
+
# `refined`, `implement` and `sp-5`. Every job reported success.
|
|
25
|
+
- name: Read the issue state now
|
|
26
|
+
id: check
|
|
27
|
+
shell: bash
|
|
28
|
+
env:
|
|
29
|
+
GH_TOKEN: ${{ inputs.token }}
|
|
30
|
+
REPO: ${{ github.repository }}
|
|
31
|
+
ISSUE: ${{ inputs.issue-number }}
|
|
32
|
+
run: |
|
|
33
|
+
set -uo pipefail
|
|
34
|
+
if [ -z "${ISSUE:-}" ]; then
|
|
35
|
+
echo "open=true" >> "$GITHUB_OUTPUT"
|
|
36
|
+
echo "No issue number given; nothing to check."
|
|
37
|
+
exit 0
|
|
38
|
+
fi
|
|
39
|
+
# Three tries: a transient API failure must not park work a person then has to restart.
|
|
40
|
+
# Defaulting to open on an unreadable issue keeps the worker's existing behaviour, which
|
|
41
|
+
# is the safer side of this check -- it costs a wasted run, never a lost one.
|
|
42
|
+
state=""
|
|
43
|
+
for _ in 1 2 3; do
|
|
44
|
+
if state=$(gh issue view "$ISSUE" --repo "$REPO" --json state --jq '.state'); then break; fi
|
|
45
|
+
sleep 5
|
|
46
|
+
done
|
|
47
|
+
case "${state:-}" in
|
|
48
|
+
CLOSED|closed)
|
|
49
|
+
echo "open=false" >> "$GITHUB_OUTPUT"
|
|
50
|
+
echo "::notice::Issue #${ISSUE} was closed after this run was dispatched; stopping before any work is done."
|
|
51
|
+
;;
|
|
52
|
+
"")
|
|
53
|
+
echo "open=true" >> "$GITHUB_OUTPUT"
|
|
54
|
+
echo "::warning::Could not read the state of issue #${ISSUE} after three tries; continuing."
|
|
55
|
+
;;
|
|
56
|
+
*)
|
|
57
|
+
echo "open=true" >> "$GITHUB_OUTPUT"
|
|
58
|
+
;;
|
|
59
|
+
esac
|