@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.
@@ -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
- const actionLockLine = "[ ! -f .github/actions/actions-lock.json ] || git add -- .github/actions/actions-lock.json";
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
- if (content.includes("compile-agent-workflows")) {
298
- const legacyLines = `${compileLine}\n${stageLine}\n${actionLockLine}\n`;
299
- if (content.includes(managedLines))
300
- return { target, content };
301
- if (content.includes(legacyLines))
302
- return { target, content: content.replace(legacyLines, managedLines) };
303
- const suffix = content.endsWith("\n") || content === "" ? "" : "\n";
304
- return { target, content: `${content}${suffix}${managedLines}` };
305
- }
306
- return { target, content: content.endsWith("\n") || content === ""
307
- ? `${content}${managedLines}`
308
- : `${content}\n${managedLines}` };
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, ".github", "actions", "actions-lock.json");
379
+ const actionsLock = join(repositoryPath, ...ACTIONS_LOCK.split("/"));
367
380
  if (await exists(actionsLock)) {
368
- updates.push({ target: ".github/actions/actions-lock.json", content: await readFile(actionsLock, "utf8") });
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
  }
@@ -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
- // Which self-hosted pool a worker's agent jobs run on. gh-aw cannot share runs-on through an
124
- // import, so every worker names it. ubuntu-latest is excluded, being GitHub's own runner, used
125
- // by the deterministic jobs everywhere and chosen by nobody.
126
- function runnerPools(content) {
127
- const found = new Set();
128
- for (const match of content.matchAll(/^\s*runs-on(?:-slim)?: (\S+)\s*$/gm)) {
129
- if (match[1] !== "ubuntu-latest")
130
- found.add(match[1]);
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: preserveRunnerPool(result, consumerContent), report };
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) continue;
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 (needsHuman.length === 0) {
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}: ${needsHuman.length} item(s)`, () =>
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 (needsHuman.length > 0) {
238
- await act(`opening the digest: ${needsHuman.length} item(s)`, () =>
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
- linked-issue:
16
- description: Issue the pull request closes, when the router already resolved it.
17
- required: false
18
- default: ''
19
- require-label:
20
- description: Label the closing issue must carry. Empty disables the check.
21
- required: false
22
- default: ''
23
- outputs:
24
- found:
25
- description: Whether a valid gate subject was found.
26
- value: ${{ steps.identify.outputs.found }}
27
- pr:
28
- description: Pull request number.
29
- value: ${{ steps.identify.outputs.pr }}
30
- issue:
31
- description: Issue number the pull request closes.
32
- value: ${{ steps.identify.outputs.issue }}
33
- conclusion:
34
- description: CI conclusion, passed through unchanged or fetched from latest CI run.
35
- value: ${{ steps.identify.outputs.conclusion }}
36
- run-id:
37
- description: CI run ID when fetched.
38
- value: ${{ steps.identify.outputs.run-id }}
39
- runs:
40
- using: composite
41
- steps:
42
- - name: Identify pull request and closing issue
43
- id: identify
44
- shell: bash
45
- env:
46
- GH_TOKEN: ${{ inputs.token }}
47
- REPO: ${{ github.repository }}
48
- PR_NUMBER: ${{ inputs.pr-number }}
49
- CONCLUSION: ${{ inputs.ci-conclusion }}
50
- LINKED_ISSUE: ${{ inputs.linked-issue }}
51
- REQUIRE_LABEL: ${{ inputs.require-label }}
52
- run: |
53
- set -euo pipefail
54
-
55
- none() {
56
- echo "found=false" >> "$GITHUB_OUTPUT"
57
- echo "$1"
58
- exit 0
59
- }
60
-
61
- pr="$(gh pr view "$PR_NUMBER" --repo "$REPO" \
62
- --json state,author,closingIssuesReferences,body)"
63
-
64
- [ "$(jq -r '.state' <<<"$pr")" = "OPEN" ] || none "PR #$PR_NUMBER is not open"
65
-
66
- # Use GitHub's bot flag because App login formats differ across APIs.
67
- if [ "$(jq -r '.author.is_bot' <<<"$pr")" != "true" ]; then
68
- none "PR #$PR_NUMBER was authored by $(jq -r '.author.login' <<<"$pr"), a human."
69
- fi
70
-
71
- issue="$LINKED_ISSUE"
72
-
73
- if [ -z "$issue" ]; then
74
- issue="$(jq -r '.closingIssuesReferences[0].number // empty' <<<"$pr")"
75
- fi
76
-
77
- if [ -z "$issue" ]; then
78
- issue="$(jq -r '.body // ""' <<<"$pr" |
79
- grep -oiE '(close[sd]?|fixe?[sd]?|resolve[sd]?) +#[0-9]+' |
80
- grep -oE '[0-9]+' | head -n 1 || true)"
81
- fi
82
-
83
- [ -n "$issue" ] || none "PR #$PR_NUMBER does not close a numbered issue"
84
-
85
- if [ -n "$REQUIRE_LABEL" ]; then
86
- labels="$(gh issue view "$issue" --repo "$REPO" --json labels \
87
- --jq '[.labels[].name]')"
88
-
89
- if ! jq -e --arg name "$REQUIRE_LABEL" 'index($name)' >/dev/null <<<"$labels"; then
90
- none "issue #$issue does not carry $REQUIRE_LABEL (has: $(jq -r 'join(", ")' <<<"$labels"))"
91
- fi
92
- fi
93
-
94
- # Fetch CI conclusion if not provided (label-triggered runs)
95
- ci_conclusion="$CONCLUSION"
96
- ci_run_id=""
97
- if [ -z "$ci_conclusion" ]; then
98
- head_sha="$(gh pr view "$PR_NUMBER" --repo "$REPO" --json headRefOid --jq '.headRefOid')"
99
- # A run keeps the name it had when it ran, so a repository whose CI was renamed still
100
- # carries runs under the old casing. Fold the name before comparing.
101
- ci_run="$(gh api "repos/$REPO/actions/runs?head_sha=$head_sha&per_page=100" \
102
- --jq '[.workflow_runs[] | select((.name | ascii_downcase) == "app: ci" or (.name | ascii_downcase) == "ci")] | sort_by(.created_at) | last')"
103
-
104
- if [ -n "$ci_run" ] && [ "$ci_run" != "null" ]; then
105
- ci_conclusion="$(jq -r '.conclusion // "pending"' <<<"$ci_run")"
106
- ci_run_id="$(jq -r '.id' <<<"$ci_run")"
107
- echo "::notice::Fetched CI status: $ci_conclusion (run $ci_run_id)"
108
- else
109
- ci_conclusion="unknown"
110
- echo "::warning::No CI run found for PR #$PR_NUMBER"
111
- fi
112
- fi
113
-
114
- {
115
- echo "found=true"
116
- echo "pr=$PR_NUMBER"
117
- echo "issue=$issue"
118
- echo "conclusion=$ci_conclusion"
119
- echo "run-id=$ci_run_id"
120
- } >> "$GITHUB_OUTPUT"
121
-
122
- echo "PR #$PR_NUMBER closes #$issue; CI concluded $ci_conclusion"
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