@edgehero/pi-dispatch-receiver 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edgehero/pi-dispatch-receiver",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "type": "module",
5
5
  "description": "Webhook receiver for pi-dispatch: the always-on edge that verifies GitHub, GitLab, Forgejo and Azure DevOps deliveries and enqueues (at most) one job per event for the worker.",
6
6
  "keywords": [
@@ -45,7 +45,7 @@
45
45
  },
46
46
  "dependencies": {
47
47
  "@octokit/webhooks": "14.2.0",
48
- "@edgehero/pi-dispatch": "^1.0.0",
48
+ "@edgehero/pi-dispatch": "^1.4.0",
49
49
  "bullmq": "5.80.4",
50
50
  "ioredis": "5.11.1"
51
51
  }
@@ -88,7 +88,10 @@ export function makeResolveAzureAuthority({ orgUrl, token, fetchFn = fetch }) {
88
88
  try {
89
89
  return { body: await res.json(), continuation: continuation || null };
90
90
  } catch (err) {
91
- return { indeterminate: `azure lookup returned unparseable JSON: ${err?.message ?? "unknown"}` };
91
+ // A FIXED reason, never err.message: V8's JSON.parse errors quote the offending input,
92
+ // so a failed res.json() here would carry response-body bytes into a log line -- the
93
+ // no-pii-in-logs rule the github resolver states, applied to its elders (issue #231).
94
+ return { indeterminate: "azure lookup returned unparseable JSON" };
92
95
  }
93
96
  }
94
97
 
package/src/close.mjs ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The ONE derivation of "which close rule matches this delivery" (issue #231), shared by every
3
+ * forge's close route and by the GitHub arm's wants-authority predicate. Two copies of a gate
4
+ * decision is the shape filter.mjs's prAuthorOk comment warns against, and here the two callers are
5
+ * a NETWORK CALL apart: the receiver asks "does any rule want this close" before spending a
6
+ * permission lookup, and the filter asks it again to route -- if those two answers could drift, a
7
+ * delivery could cost a lookup it then drops, or worse, route a close the lookup never gated.
8
+ *
9
+ * Total and throw-free like predicate.mjs: rules are loader-validated shapes, the inputs are
10
+ * adversarial payload fields, and the receiver's gates must never throw on data.
11
+ */
12
+
13
+ /**
14
+ * Find the first rule (file order, firstMatchingRule's convention) whose action set has this close
15
+ * word and whose `number` narrowing (when present) names this item. Returns:
16
+ * { rule } -- matched; the caller gates and enqueues
17
+ * { reason: "close-number-not-matched" } -- a close rule is armed but names a different item
18
+ * { reason: "no-matching-close-trigger" } -- nothing armed wants this close
19
+ * The two refusal tokens are distinct because they call for different operator responses: "your
20
+ * one-shot exists and this was a different item closing" and "nothing is armed" read nothing alike
21
+ * from a panel.
22
+ */
23
+ export function findCloseRule(rules, closeWord, number) {
24
+ let numberSkipped = false;
25
+ for (const rule of rules ?? []) {
26
+ if (!rule.actions?.has(closeWord)) continue;
27
+ if (rule.number !== undefined && rule.number !== number) {
28
+ numberSkipped = true;
29
+ continue;
30
+ }
31
+ return { rule };
32
+ }
33
+ return { reason: numberSkipped ? "close-number-not-matched" : "no-matching-close-trigger" };
34
+ }
package/src/config.mjs CHANGED
@@ -32,7 +32,7 @@
32
32
 
33
33
  import { existsSync, readFileSync } from "node:fs";
34
34
  import { configError, loadGitHubAuth, positiveInt } from "@edgehero/pi-dispatch/config";
35
- import { FORGE_KINDS, parseTriggers } from "@edgehero/pi-dispatch/triggers";
35
+ import { FORGE_KINDS, PR_CLOSE_ACTIONS, parseTriggers } from "@edgehero/pi-dispatch/triggers";
36
36
 
37
37
  // Cwd-relative, matching what `pi-dispatch init` scaffolds (and the admin's default): the receiver's
38
38
  // default must be the file init just told the operator it created, not a demo buried in the repo.
@@ -102,7 +102,13 @@ export function loadReceiverConfig(env = process.env, { readFile = readFileSync,
102
102
  * credential lying around is not a statement that this receiver terminates GitHub webhooks.
103
103
  */
104
104
  function decideServesGithub(env, githubGroup) {
105
- const hasGithubTriggers = githubGroup.label.length > 0 || githubGroup.comment !== null || githubGroup.pullRequest.length > 0;
105
+ // The close groups count too (issue #231): a deployment whose ONLY github rule is a close trigger
106
+ // must still mount `/`, require WEBHOOK_SECRET and build auth, or the one-shot the operator armed
107
+ // can never fire -- and the receiver would report nothing, the silent no-op this file's header
108
+ // calls the worst outcome. Conversely a deployment whose last github one-shot is SPENT stops
109
+ // counting here at its next boot (the sentinel groups nowhere), which is deliberate: nothing is
110
+ // left to serve, and INT-TRIGGERS-FILE-CONTRACT states it.
111
+ const hasGithubTriggers = githubGroup.label.length > 0 || githubGroup.comment !== null || githubGroup.pullRequest.length > 0 || githubGroup.issue.length > 0 || githubGroup.prClose.length > 0;
106
112
  // `loadGitHubAuth` has already refused a garbled value by the time we get here, so anything non-empty
107
113
  // here is one of pat|gh|app -- a real choice, not a typo we would be reading as consent.
108
114
  const explicitAuthSource = env.GITHUB_AUTH_SOURCE !== undefined && env.GITHUB_AUTH_SOURCE !== "";
@@ -228,7 +234,11 @@ function loadAzureConfig(env) {
228
234
  * `{ github: <group>, gitlab: <group>, knownFlows }` where each group is:
229
235
  * - `label`: ordered `{ index, predicate, flow, command, packages, image }` rules (first match wins in the filter).
230
236
  * - `comment`: the single `{ index, phrase, defaultFlow, command, packages, image }` (or null when no comment trigger is configured).
231
- * - `pullRequest`: ordered `{ index, actions:Set, predicate, flow, command, packages, image }` rules.
237
+ * - `pullRequest`: ordered `{ index, actions:Set, predicate, flow, command, packages, image }` rules (never a close-only rule).
238
+ * - `issue`: ordered `{ index, actions:Set, number?, once?, ... }` close rules (issue #231).
239
+ * - `prClose`: ordered close-only pull_request rules, same shape as `issue` -- split from
240
+ * `pullRequest` so the closer-gated route and the author-gated route can never read
241
+ * each other's rules (the loader's no-mixing refusal makes the split total).
232
242
  * and `knownFlows` is every webhook `run.flow`, so a comment's `<phrase> <flow>` override cannot summon an
233
243
  * unlisted flow. A rule carries EITHER `flow` or `command` (issue #189; the shared parser enforces the
234
244
  * exclusivity): a command rule's match dispatches a registered pi extension command in the container, so
@@ -283,13 +293,28 @@ function loadTriggers(env, readFile, fileExists) {
283
293
  }
284
294
  const group = groups[run.kind];
285
295
  if (on.type === "label") {
286
- group.label.push({ index, predicate: { any: on.any, all: on.all, none: on.none }, flow: run.flow, command: run.command, packages: run.packages, image: run.image, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, replicas: run.replicas, repository: run.repository });
296
+ group.label.push({ index, predicate: { any: on.any, all: on.all, none: on.none }, flow: run.flow, command: run.command, packages: run.packages, image: run.image, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, secrets: run.secrets, secretsProfile: run.secretsProfile, replicas: run.replicas, repository: run.repository });
287
297
  } else if (on.type === "comment") {
288
- group.comment = { index, phrase: on.phrase, defaultFlow: run.flow, command: run.command, packages: run.packages, image: run.image, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, replicas: run.replicas, repository: run.repository }; // parseTriggers guarantees at most one per forge
298
+ group.comment = { index, phrase: on.phrase, defaultFlow: run.flow, command: run.command, packages: run.packages, image: run.image, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, secrets: run.secrets, secretsProfile: run.secretsProfile, replicas: run.replicas, repository: run.repository }; // parseTriggers guarantees at most one per forge
289
299
  } else if (on.type === "pull_request") {
290
- // `reviewStates` is null rather than an empty Set when unnarrowed: the filter tests it for
291
- // presence, and an empty Set would read as "no verdict matches" and silently refuse everything.
292
- group.pullRequest.push({ index, actions: new Set(on.action), reviewStates: on.reviewState ? new Set(on.reviewState) : null, predicate: { any: on.any, all: on.all, none: on.none }, flow: run.flow, command: run.command, packages: run.packages, image: run.image, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, replicas: run.replicas });
300
+ // The close-only split (issue #231): a close rule routes through the close gate (the CLOSER's
301
+ // resolved authority) and every other PR rule through the author gate, so they live in separate
302
+ // groups and neither route ever re-derives the distinction -- the loader already refused any
303
+ // list mixing the close word with another action, which is what makes this split total. The
304
+ // split reads the SHARED close-word table, never a re-typed word.
305
+ const closeWord = PR_CLOSE_ACTIONS[run.kind];
306
+ if (closeWord !== undefined && on.action.includes(closeWord)) {
307
+ group.prClose.push({ index, actions: new Set(on.action), number: on.number, once: on.once, flow: run.flow, command: run.command, packages: run.packages, image: run.image, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, secrets: run.secrets, secretsProfile: run.secretsProfile, replicas: run.replicas });
308
+ } else {
309
+ // `reviewStates` is null rather than an empty Set when unnarrowed: the filter tests it for
310
+ // presence, and an empty Set would read as "no verdict matches" and silently refuse everything.
311
+ group.pullRequest.push({ index, actions: new Set(on.action), reviewStates: on.reviewState ? new Set(on.reviewState) : null, predicate: { any: on.any, all: on.all, none: on.none }, flow: run.flow, command: run.command, packages: run.packages, image: run.image, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, secrets: run.secrets, secretsProfile: run.secretsProfile, replicas: run.replicas });
312
+ }
313
+ } else if (on.type === "issue") {
314
+ // The close-trigger kind (issue #231). No predicate and no reviewStates by construction (the
315
+ // loader refused both), and `number`/`once` ride conditionally-present exactly as the loader
316
+ // normalized them -- the filter's close route reads them, nothing else does.
317
+ group.issue.push({ index, actions: new Set(on.action), number: on.number, once: on.once, flow: run.flow, command: run.command, packages: run.packages, image: run.image, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, secrets: run.secrets, secretsProfile: run.secretsProfile, replicas: run.replicas });
293
318
  }
294
319
  }
295
320
 
@@ -297,7 +322,7 @@ function loadTriggers(env, readFile, fileExists) {
297
322
  }
298
323
 
299
324
  function emptyGroup() {
300
- return { label: [], comment: null, pullRequest: [] };
325
+ return { label: [], comment: null, pullRequest: [], issue: [], prClose: [] };
301
326
  }
302
327
 
303
328
  /** The triggers file path the receiver reads (env override or the cwd default matching what `pi-dispatch init` scaffolds). */
@@ -114,6 +114,11 @@ export function filterAzure(subset, triggers, knownFlows, selfId, authorized, de
114
114
  // /job/event.json, and a worker-host path in an agent-readable file is the leak prepare-local's
115
115
  // basename(folder) restraint already exists to prevent.
116
116
  ...(resolved.skillsDir !== undefined ? { skillsDir: resolved.skillsDir } : {}),
117
+ // REQ-TRIGGER-SECRETS. Conditional exactly like skillsDir above, so a trigger that binds no
118
+ // secret enqueues the job data it always has. The map holds REFERENCES, never values: the
119
+ // receiver has no resolver and reaches no vault -- the worker resolves them pre-spend.
120
+ ...(resolved.secrets !== undefined ? { secrets: resolved.secrets } : {}),
121
+ ...(resolved.secretsProfile !== undefined ? { secretsProfile: resolved.secretsProfile } : {}),
117
122
  ...(resolved.instructions !== undefined ? { instructions: resolved.instructions } : {}),
118
123
  ...(resolved.resume !== undefined ? { resume: resolved.resume } : {}),
119
124
  // How many independent sandboxes race this flow (REQ-REPLICA-RUNS). At JOB level and conditional for the
@@ -181,6 +186,8 @@ function matchLabelRules(subset, triggers, labels, action) {
181
186
  packages: rule.packages,
182
187
  image: rule.image,
183
188
  skillsDir: rule.skillsDir,
189
+ secrets: rule.secrets,
190
+ secretsProfile: rule.secretsProfile,
184
191
  instructions: rule.instructions,
185
192
  resume: rule.resume,
186
193
  replicas: rule.replicas,
@@ -222,6 +229,8 @@ function routeComment(subset, triggers, knownFlows, targetType) {
222
229
  packages: triggers.comment.packages,
223
230
  image: triggers.comment.image,
224
231
  skillsDir: triggers.comment.skillsDir,
232
+ secrets: triggers.comment.secrets,
233
+ secretsProfile: triggers.comment.secretsProfile,
225
234
  instructions: triggers.comment.instructions,
226
235
  resume: triggers.comment.resume,
227
236
  replicas: triggers.comment.replicas,
@@ -252,6 +261,8 @@ function routePullRequest(subset, triggers, action) {
252
261
  packages: rule.packages,
253
262
  image: rule.image,
254
263
  skillsDir: rule.skillsDir,
264
+ secrets: rule.secrets,
265
+ secretsProfile: rule.secretsProfile,
255
266
  instructions: rule.instructions,
256
267
  resume: rule.resume,
257
268
  replicas: rule.replicas,
@@ -34,6 +34,7 @@
34
34
  */
35
35
 
36
36
  import { escapeRegExp, firstMatchingRule, labelSet, matchedLabel, matchesRule } from "./predicate.mjs";
37
+ import { findCloseRule } from "./close.mjs";
37
38
  import { isRecognizedAction, mapAction } from "./forgejo-subset.mjs";
38
39
 
39
40
  const LABEL_ACTIONS = new Set(["opened", "labeled", "reopened"]);
@@ -84,6 +85,17 @@ export function filterForgejo(eventName, subset, triggers, knownFlows, selfId, a
84
85
  if (action === null) return unactionable();
85
86
  if (eventName === "issues" && LABEL_ACTIONS.has(action)) {
86
87
  resolved = routeIssueLabel(subset, group);
88
+ } else if (eventName === "issues" && action === "closed") {
89
+ // The close trigger (issue #231). `group.issue` holds ONLY close rules (the loader admits no
90
+ // other issue kind), and this arm can never contend with the label one: `closed` is not in
91
+ // LABEL_ACTIONS and must not become so -- a close is a statement about an item, not a label move.
92
+ resolved = routeIssueClose(subset, group, authorized);
93
+ } else if (eventName === "pull_request" && raw === "closed") {
94
+ // The RAW word, for routePullRequest's reason below: the close vocabulary is the forge's own
95
+ // (PR_CLOSE_ACTIONS, which the loader validated the rule against). Checked BEFORE the PR_ACTIONS
96
+ // arm, though today they cannot overlap; `group.prClose` was split from `group.pullRequest` at
97
+ // load, so a close rule is unreachable from routePullRequest and vice versa.
98
+ resolved = routePullRequestClose(subset, group, authorized);
87
99
  } else if (eventName === "pull_request" && PR_ACTIONS.has(action)) {
88
100
  // The RAW word, not the mapped one. A trigger file names actions in the forge's own vocabulary
89
101
  // (`label_updated`, `synchronized`) because that is what an operator reads in Forgejo's docs and
@@ -116,6 +128,11 @@ export function filterForgejo(eventName, subset, triggers, knownFlows, selfId, a
116
128
  // /job/event.json, and a worker-host path in an agent-readable file is the leak prepare-local's
117
129
  // basename(folder) restraint already exists to prevent.
118
130
  ...(resolved.skillsDir !== undefined ? { skillsDir: resolved.skillsDir } : {}),
131
+ // REQ-TRIGGER-SECRETS. Conditional exactly like skillsDir above, so a trigger that binds no
132
+ // secret enqueues the job data it always has. The map holds REFERENCES, never values: the
133
+ // receiver has no resolver and reaches no vault -- the worker resolves them pre-spend.
134
+ ...(resolved.secrets !== undefined ? { secrets: resolved.secrets } : {}),
135
+ ...(resolved.secretsProfile !== undefined ? { secretsProfile: resolved.secretsProfile } : {}),
119
136
  ...(resolved.instructions !== undefined ? { instructions: resolved.instructions } : {}),
120
137
  ...(resolved.resume !== undefined ? { resume: resolved.resume } : {}),
121
138
  // How many independent sandboxes race this flow (REQ-REPLICA-RUNS). At JOB level and conditional for the
@@ -149,6 +166,8 @@ function routeIssueLabel(subset, triggers) {
149
166
  packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
150
167
  image: rule.image,
151
168
  skillsDir: rule.skillsDir,
169
+ secrets: rule.secrets,
170
+ secretsProfile: rule.secretsProfile,
152
171
  instructions: rule.instructions,
153
172
  resume: rule.resume,
154
173
  replicas: rule.replicas,
@@ -195,6 +214,8 @@ function routeComment(subset, triggers, knownFlows) {
195
214
  packages: triggers.comment.packages,
196
215
  image: triggers.comment.image,
197
216
  skillsDir: triggers.comment.skillsDir,
217
+ secrets: triggers.comment.secrets,
218
+ secretsProfile: triggers.comment.secretsProfile,
198
219
  instructions: triggers.comment.instructions,
199
220
  resume: triggers.comment.resume,
200
221
  replicas: triggers.comment.replicas,
@@ -223,6 +244,8 @@ function routePullRequest(subset, triggers, action) {
223
244
  packages: rule.packages,
224
245
  image: rule.image,
225
246
  skillsDir: rule.skillsDir,
247
+ secrets: rule.secrets,
248
+ secretsProfile: rule.secretsProfile,
226
249
  instructions: rule.instructions,
227
250
  resume: rule.resume,
228
251
  replicas: rule.replicas,
@@ -239,3 +262,92 @@ function routePullRequest(subset, triggers, action) {
239
262
  }
240
263
  return { enqueue: false, reason: "no-matching-pr-trigger" };
241
264
  }
265
+
266
+ /**
267
+ * Issue close path (issue #231). `findCloseRule` is the ONE derivation of "which close rule wants
268
+ * this delivery" (close.mjs), shared with the receiver's pre-lookup check -- two hand-rolled copies
269
+ * a network call apart is the drift that module's header warns against.
270
+ *
271
+ * The `closer-not-allowed` gate below is belt-and-braces and UNREACHABLE today: gate (2) in
272
+ * `filterForgejo` already refused every non-true verdict before routing, and on a webhook close
273
+ * delivery the SENDER is the closer, so the verdict that gate consumed is the closer's. It stays
274
+ * anyway, because this route's own contract is the closer's authority: if the global gate ever moves,
275
+ * or a resolver starts returning a shape it never has, a close must fail closed under its own token
276
+ * rather than route on a stale assumption about the caller.
277
+ */
278
+ function routeIssueClose(subset, triggers, authorized) {
279
+ const number = subset.issue?.number;
280
+ // Integer or refuse, the github arms' rule: an UNNARROWED rule matches an undefined number, so a
281
+ // degenerate shape would otherwise enqueue a numberless target.
282
+ if (!Number.isInteger(number)) {
283
+ return { enqueue: false, reason: "missing-issue-number" };
284
+ }
285
+ // "closed" is PR_CLOSE_ACTIONS.forgejo -- the forge's own word, the one the loader validated the
286
+ // rule's action list against, exactly as routePullRequest matches raw vocabulary.
287
+ const found = findCloseRule(triggers.issue, "closed", number);
288
+ if (found.rule === undefined) {
289
+ return { enqueue: false, reason: found.reason };
290
+ }
291
+ if (authorized !== true) {
292
+ return { enqueue: false, reason: "closer-not-allowed" };
293
+ }
294
+ const rule = found.rule;
295
+ return {
296
+ enqueue: true,
297
+ ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
298
+ packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
299
+ image: rule.image,
300
+ skillsDir: rule.skillsDir,
301
+ secrets: rule.secrets,
302
+ secretsProfile: rule.secretsProfile,
303
+ instructions: rule.instructions,
304
+ resume: rule.resume,
305
+ replicas: rule.replicas,
306
+ // `once: true` rides `matched` ONLY when armed (spreading false is a no-op), so the enqueue path
307
+ // can see a disarm is owed without re-reading the rule table -- and a plain close rule's matched
308
+ // record stays byte-identical to the other routes' shape.
309
+ matched: { index: rule.index, type: "issue", action: "closed", number, ...(rule.once === true && { once: true }) },
310
+ target: { type: "issue", number, title: subset.issue?.title, body: subset.issue?.body },
311
+ };
312
+ }
313
+
314
+ /** PR close path (issue #231). Same shape and same belt-and-braces closer gate as routeIssueClose. */
315
+ function routePullRequestClose(subset, triggers, authorized) {
316
+ const pr = subset.pull_request;
317
+ // Object AND integer number, the github arm's rule: a shape without a usable number must refuse
318
+ // before any rule is consulted.
319
+ if (!pr || !Number.isInteger(pr.number)) {
320
+ return { enqueue: false, reason: "missing-pull-request" };
321
+ }
322
+ const found = findCloseRule(triggers.prClose, "closed", pr.number);
323
+ if (found.rule === undefined) {
324
+ return { enqueue: false, reason: found.reason };
325
+ }
326
+ if (authorized !== true) {
327
+ return { enqueue: false, reason: "closer-not-allowed" }; // unreachable behind gate (2) -- see routeIssueClose
328
+ }
329
+ const rule = found.rule;
330
+ return {
331
+ enqueue: true,
332
+ ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
333
+ packages: rule.packages,
334
+ image: rule.image,
335
+ skillsDir: rule.skillsDir,
336
+ secrets: rule.secrets,
337
+ secretsProfile: rule.secretsProfile,
338
+ instructions: rule.instructions,
339
+ resume: rule.resume,
340
+ replicas: rule.replicas,
341
+ // No `number` on a PR close's matched record -- parallel to routePullRequest's, where the item
342
+ // number is the target's business; the issue route carries it because the github design does.
343
+ matched: { index: rule.index, type: "pull_request", action: "closed", ...(rule.once === true && { once: true }) },
344
+ target: {
345
+ type: "pull_request",
346
+ number: pr.number,
347
+ title: pr.title,
348
+ body: pr.body,
349
+ head: pr.head,
350
+ base: pr.base,
351
+ },
352
+ };
353
+ }
@@ -41,6 +41,7 @@
41
41
  */
42
42
 
43
43
  import { firstMatchingRule, labelSet, matchedLabel } from "./predicate.mjs";
44
+ import { findCloseRule } from "./close.mjs";
44
45
 
45
46
  /**
46
47
  /** The merge-request actions a trigger may name, mirrored from the loader's gitlab vocabulary. */
@@ -75,9 +76,32 @@ export function filterGitLab(subset, triggers, knownFlows, selfId, authorized, d
75
76
  const kind = subset.objectKind;
76
77
  let resolved;
77
78
  if (kind === "issue") {
78
- resolved = routeLabel(subset, triggers, "issue");
79
+ if (subset.action === "close") {
80
+ // The close trigger (issue #231), tried FIRST -- and the label walk only as a FALLBACK -- because
81
+ // a real GitLab delivery can close and label in one call (a quick action comment, or the sidebar
82
+ // edited during close). One delivery, one job: when an armed close rule wants this close it fires
83
+ // and the label rule does NOT, so the two rule kinds cannot double-bill a single click.
84
+ resolved = routeIssueClose(subset, triggers, authorized);
85
+ if (!resolved.enqueue && resolved.reason !== "closer-not-allowed" && subset.labelChanges) {
86
+ // No close rule took it, and this close also MOVED labels (`changes.labels`, the exact field
87
+ // `addedLabels` reads for every non-open action). Label rules fired on such closes before close
88
+ // routing existed and must keep doing so byte-identically -- including on ANY close refusal, so
89
+ // a one-shot armed for a DIFFERENT item cannot eat a label trigger it was never about, and
90
+ // including the label walk's own refusal reasons when nothing matches. The `closer-not-allowed`
91
+ // exclusion is belt-and-braces with the route's own unreachable gate: a close a drifted
92
+ // resolver refused must stay refused, never demoted into a label job.
93
+ resolved = routeLabel(subset, triggers, "issue");
94
+ }
95
+ } else {
96
+ resolved = routeLabel(subset, triggers, "issue");
97
+ }
79
98
  } else if (kind === "merge_request") {
80
- resolved = routeMergeRequest(subset, triggers);
99
+ // An MR close routes over `group.prClose` ALONE (issue #231), with no label fallback: unlike the
100
+ // issue arm above, MR_ACTIONS never contained "close", so no MR rule ever fired on a close and
101
+ // there is nothing for a fallback to keep working. MR_ACTIONS stays untouched for the same reason
102
+ // in the other direction -- close rules can never reach routeMergeRequest, because the loader split
103
+ // them into `prClose` at group time, so the two rule kinds are mutually unreachable by construction.
104
+ resolved = subset.action === "close" ? routeMergeRequestClose(subset, triggers, authorized) : routeMergeRequest(subset, triggers);
81
105
  } else if (kind === "note") {
82
106
  resolved = routeNote(subset, triggers, knownFlows);
83
107
  } else {
@@ -107,6 +131,11 @@ export function filterGitLab(subset, triggers, knownFlows, selfId, authorized, d
107
131
  // /job/event.json, and a worker-host path in an agent-readable file is the leak prepare-local's
108
132
  // basename(folder) restraint already exists to prevent.
109
133
  ...(resolved.skillsDir !== undefined ? { skillsDir: resolved.skillsDir } : {}),
134
+ // REQ-TRIGGER-SECRETS. Conditional exactly like skillsDir above, so a trigger that binds no
135
+ // secret enqueues the job data it always has. The map holds REFERENCES, never values: the
136
+ // receiver has no resolver and reaches no vault -- the worker resolves them pre-spend.
137
+ ...(resolved.secrets !== undefined ? { secrets: resolved.secrets } : {}),
138
+ ...(resolved.secretsProfile !== undefined ? { secretsProfile: resolved.secretsProfile } : {}),
110
139
  ...(resolved.instructions !== undefined ? { instructions: resolved.instructions } : {}),
111
140
  // Conditional like packages/image, and for the same reason: an unflagged job's data must stay
112
141
  // byte-identical to today's, so the key is absent rather than present-and-undefined.
@@ -147,6 +176,8 @@ function routeLabel(subset, triggers, targetType) {
147
176
  packages: rule.packages,
148
177
  image: rule.image,
149
178
  skillsDir: rule.skillsDir,
179
+ secrets: rule.secrets,
180
+ secretsProfile: rule.secretsProfile,
150
181
  instructions: rule.instructions,
151
182
  resume: rule.resume,
152
183
  replicas: rule.replicas,
@@ -208,6 +239,8 @@ function mrResult(subset, rule, matched) {
208
239
  packages: rule.packages,
209
240
  image: rule.image,
210
241
  skillsDir: rule.skillsDir,
242
+ secrets: rule.secrets,
243
+ secretsProfile: rule.secretsProfile,
211
244
  instructions: rule.instructions,
212
245
  resume: rule.resume,
213
246
  replicas: rule.replicas,
@@ -216,6 +249,86 @@ function mrResult(subset, rule, matched) {
216
249
  };
217
250
  }
218
251
 
252
+ /**
253
+ * Issue close path (issue #231). `findCloseRule` is the ONE derivation of "which close rule wants
254
+ * this delivery" (close.mjs), shared with the receiver's pre-lookup check -- two hand-rolled copies
255
+ * a network call apart is the drift that module's header warns against.
256
+ *
257
+ * The `closer-not-allowed` gate below is belt-and-braces and UNREACHABLE today: gate (2) in
258
+ * `filterGitLab` already refused every non-true verdict before routing, and on a webhook close
259
+ * delivery the ACTOR is the closer, so the verdict that gate consumed is the closer's. It stays
260
+ * anyway, because this route's own contract is the closer's authority: if the global gate ever
261
+ * moves, or a resolver starts returning a shape it never has, a close must fail closed under its own
262
+ * token rather than route on a stale assumption about the caller.
263
+ */
264
+ function routeIssueClose(subset, triggers, authorized) {
265
+ const number = subset.target?.iid;
266
+ // Integer or refuse, the github arms' rule: an UNNARROWED rule matches an undefined number, so a
267
+ // degenerate shape would otherwise enqueue a numberless target. GitLab sends integer iids.
268
+ if (!Number.isInteger(number)) {
269
+ return { enqueue: false, reason: "missing-issue-number" };
270
+ }
271
+ // "close" is PR_CLOSE_ACTIONS.gitlab -- the forge's own word, the one the loader validated the
272
+ // rule's action list against. GitLab really does say "close", not "closed".
273
+ const found = findCloseRule(triggers?.issue, "close", number);
274
+ if (found.rule === undefined) {
275
+ return { enqueue: false, reason: found.reason };
276
+ }
277
+ if (authorized !== true) {
278
+ return { enqueue: false, reason: "closer-not-allowed" };
279
+ }
280
+ const rule = found.rule;
281
+ return {
282
+ enqueue: true,
283
+ ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
284
+ packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
285
+ image: rule.image,
286
+ skillsDir: rule.skillsDir,
287
+ secrets: rule.secrets,
288
+ secretsProfile: rule.secretsProfile,
289
+ instructions: rule.instructions,
290
+ resume: rule.resume,
291
+ replicas: rule.replicas,
292
+ // `once: true` rides `matched` ONLY when armed (spreading false is a no-op), so the enqueue path
293
+ // can see a disarm is owed without re-reading the rule table -- and a plain close rule's matched
294
+ // record stays byte-identical to the other routes' shape.
295
+ matched: { index: rule.index, type: "issue", action: "close", number, ...(rule.once === true && { once: true }) },
296
+ target: buildTarget(subset, "issue"),
297
+ };
298
+ }
299
+
300
+ /** MR close path (issue #231). Same shape and same belt-and-braces closer gate as routeIssueClose. */
301
+ function routeMergeRequestClose(subset, triggers, authorized) {
302
+ // The issue arm's integer guard, same reason.
303
+ if (!Number.isInteger(subset.target?.iid)) {
304
+ return { enqueue: false, reason: "missing-pull-request" };
305
+ }
306
+ const found = findCloseRule(triggers?.prClose, "close", subset.target?.iid);
307
+ if (found.rule === undefined) {
308
+ return { enqueue: false, reason: found.reason };
309
+ }
310
+ if (authorized !== true) {
311
+ return { enqueue: false, reason: "closer-not-allowed" }; // unreachable behind gate (2) -- see routeIssueClose
312
+ }
313
+ const rule = found.rule;
314
+ return {
315
+ enqueue: true,
316
+ ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
317
+ packages: rule.packages,
318
+ image: rule.image,
319
+ skillsDir: rule.skillsDir,
320
+ secrets: rule.secrets,
321
+ secretsProfile: rule.secretsProfile,
322
+ instructions: rule.instructions,
323
+ resume: rule.resume,
324
+ replicas: rule.replicas,
325
+ // No `number` on an MR close's matched record -- parallel to routeMergeRequest's, where the item
326
+ // number is the target's business; the issue route carries it because the github design does.
327
+ matched: { index: rule.index, type: "pull_request", action: "close", ...(rule.once === true && { once: true }) },
328
+ target: buildTarget(subset, "pull_request"),
329
+ };
330
+ }
331
+
219
332
  /**
220
333
  * Note (comment) path. `noteable_type` states whether the comment is on an issue or a merge request --
221
334
  * GitHub infers the same thing from the presence of `issue.pull_request`. Routing it wrong would mint an
@@ -255,6 +368,8 @@ function routeNote(subset, triggers, knownFlows) {
255
368
  packages: triggers.comment.packages,
256
369
  image: triggers.comment.image,
257
370
  skillsDir: triggers.comment.skillsDir,
371
+ secrets: triggers.comment.secrets,
372
+ secretsProfile: triggers.comment.secretsProfile,
258
373
  instructions: triggers.comment.instructions,
259
374
  resume: triggers.comment.resume,
260
375
  replicas: triggers.comment.replicas,