@edgehero/pi-dispatch 1.2.0 → 1.4.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/.env.example +11 -0
- package/deploy/docker-compose.yml +5 -1
- package/package.json +2 -1
- package/src/config.mjs +17 -0
- package/src/doctor.mjs +111 -4
- package/src/env-allowlist.mjs +31 -1
- package/src/forges.mjs +14 -0
- package/src/get-token.mjs +6 -1
- package/src/index.mjs +16 -0
- package/src/outbox.mjs +8 -0
- package/src/processor.mjs +141 -2
- package/src/queue.mjs +35 -3
- package/src/reserved-env.mjs +40 -0
- package/src/run-container.mjs +6 -2
- package/src/run-history.mjs +50 -31
- package/src/runtime-settings.mjs +40 -0
- package/src/schedules.mjs +1 -1
- package/src/secret-profiles.mjs +119 -0
- package/src/secrets.mjs +319 -0
- package/src/start.mjs +41 -3
- package/src/triggers-file.mjs +403 -0
- package/src/triggers.mjs +478 -21
package/src/triggers.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared trigger-file schema + validator (issue #20). One `triggers.json` of `{ on, run }` entries is
|
|
3
3
|
* the single reviewed source of standing triggers for BOTH services: the worker owns `on.type:"cron"`
|
|
4
|
-
* (local jobs), the receiver owns the webhook types (`label|comment|pull_request` -> a forge job). Each
|
|
4
|
+
* (local jobs), the receiver owns the webhook types (`label|comment|pull_request|issue` -> a forge job). Each
|
|
5
5
|
* service validates the WHOLE file, then selects the `on.type` it owns, so a malformed file fails both
|
|
6
6
|
* identically and the two cannot drift.
|
|
7
7
|
*
|
|
@@ -23,10 +23,26 @@
|
|
|
23
23
|
* Custom: triggers validated inline per config.mjs/schedules.mjs precedent; zod not in deps
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
-
import { configError } from "./config.mjs";
|
|
27
|
-
|
|
26
|
+
import { EGRESS_ENV_VARS, WORKER_ONLY_SECRET_VARS, configError } from "./config.mjs";
|
|
27
|
+
// SKILL_NAME_RE is the single-sourced skill charset (flow-gate exports it for exactly this reason:
|
|
28
|
+
// materialize.mjs and the admin already import it, and a keep-in-sync copy would drift where a
|
|
29
|
+
// traversal guard cannot). flow-gate's module body is import-inert, so this keeps parseTriggers pure.
|
|
30
|
+
import { SKILL_NAME_RE } from "./flow-gate.mjs";
|
|
31
|
+
import { FORGE_HOST_VARS, FORGE_KINDS, MINTED_TOKEN_VARS, RUN_KINDS, forgeSpec, isForgeKind } from "./forges.mjs";
|
|
32
|
+
import { CONTAINER_ENV_NAMES } from "./reserved-env.mjs";
|
|
28
33
|
|
|
29
|
-
const ON_TYPES = new Set(["cron", "label", "comment", "pull_request"]);
|
|
34
|
+
const ON_TYPES = new Set(["cron", "label", "comment", "pull_request", "issue"]);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The `on.type` a DISARMED one-shot normalizes to (issue #231). Producible only by this validator:
|
|
38
|
+
* `ON_TYPES` excludes it, so an authored `on.type: "disarmed"` refuses like any unknown type. The
|
|
39
|
+
* sentinel keeps the entry's raw array position (deleting it would shift `triggerIndex` attribution
|
|
40
|
+
* for every later entry) while making it unmatchable BY CONSTRUCTION -- it carries no selectors, no
|
|
41
|
+
* actions, no run.kind and no flow, so no receiver group, no schedule and no flow allowlist can ever
|
|
42
|
+
* pick it up. A marker flag on the original type was rejected: one consumer forgetting to check it
|
|
43
|
+
* is a spent one-shot firing again, and this shape makes that bug unwritable.
|
|
44
|
+
*/
|
|
45
|
+
export const DISARMED_TYPE = "disarmed";
|
|
30
46
|
|
|
31
47
|
// `RUN_KINDS` and `FORGE_KINDS` come from the forge table (forges.mjs) rather than being written out
|
|
32
48
|
// here. They differ by exactly `local`, and that difference IS the on x run matrix below: a webhook
|
|
@@ -40,8 +56,17 @@ export { FORGE_KINDS };
|
|
|
40
56
|
* what their forge's documentation says and can grep for it there.
|
|
41
57
|
*
|
|
42
58
|
* GitLab has no `labeled`: adding a label to a merge request arrives as `update` carrying a
|
|
43
|
-
* `changes.labels` diff, and `open`/`reopen` are its spellings of `opened`/`reopened`.
|
|
44
|
-
*
|
|
59
|
+
* `changes.labels` diff, and `open`/`reopen` are its spellings of `opened`/`reopened`.
|
|
60
|
+
*
|
|
61
|
+
* The close words (issue #231) carry a distinction the old "nothing left to act on" wording folded
|
|
62
|
+
* flat. A job ABOUT the closed thing still has nothing left to act on, and `run.replicas`' neighbour
|
|
63
|
+
* refusal still stands; what a close CAN do is release separately-armed work -- an operator wrote
|
|
64
|
+
* "when this closes, run that" into this file, and the close is the starting gun, not the subject.
|
|
65
|
+
* So `closed`/`close` are close-ONLY action lists (mixing them with other actions is refused below:
|
|
66
|
+
* the two families gate on different actors), while `merge` stays omitted everywhere -- GitHub and
|
|
67
|
+
* Forgejo emit `closed` for a merged PR so close rules cover merges there, and GitLab's `merge` is
|
|
68
|
+
* its own action no rule takes (an explicit close is what fires a GitLab close rule; the spec names
|
|
69
|
+
* the gap).
|
|
45
70
|
*
|
|
46
71
|
* `review_submitted` (issue #66) is github's fifth and the one compound word here. It names the
|
|
47
72
|
* `pull_request_review` event's `submitted` action, so both halves are greppable in GitHub's own docs, the
|
|
@@ -52,23 +77,61 @@ export { FORGE_KINDS };
|
|
|
52
77
|
* `author_association`, never the PR author's -- see filter.mjs and CONST-TRIGGER-AUTHOR-GATE.
|
|
53
78
|
*/
|
|
54
79
|
const PR_ACTIONS = {
|
|
55
|
-
github: new Set(["labeled", "opened", "synchronize", "reopened", "review_submitted"]),
|
|
80
|
+
github: new Set(["labeled", "opened", "synchronize", "reopened", "review_submitted", "closed"]),
|
|
56
81
|
// GitLab's `approved` is its review gate (a member approved the MR). It is NOT github's
|
|
57
82
|
// `review_submitted` renamed: `approved` is one verdict, `review_submitted` is every verdict, which is
|
|
58
|
-
// what `on.reviewState` below exists to narrow.
|
|
59
|
-
gitlab: new Set(["open", "update", "reopen", "approved"]),
|
|
83
|
+
// what `on.reviewState` below exists to narrow. `close` is GitLab's own spelling of the close action.
|
|
84
|
+
gitlab: new Set(["open", "update", "reopen", "approved", "close"]),
|
|
60
85
|
// Forgejo's own spellings. `label_updated` is its `labeled` and `synchronized` its `synchronize` -- a
|
|
61
86
|
// one-letter difference that an operator would otherwise discover as a trigger that loads clean and
|
|
62
87
|
// never fires. `label_cleared` is deliberately ABSENT and always will be: REMOVING a label must never
|
|
63
88
|
// start a paid run, and it has no GitHub counterpart to inherit that rule from.
|
|
64
|
-
forgejo: new Set(["label_updated", "opened", "synchronized", "reopened"]),
|
|
89
|
+
forgejo: new Set(["label_updated", "opened", "synchronized", "reopened", "closed"]),
|
|
65
90
|
// Azure's Service Hook events reduced to the two that leave something to act on. `git.pullrequest.merged`
|
|
66
|
-
// is omitted for the
|
|
67
|
-
//
|
|
68
|
-
//
|
|
91
|
+
// is omitted for the reason the close words' own comment above records, and there is no close word here at
|
|
92
|
+
// all: an abandon arrives as `git.pullrequest.updated` with nothing in the projected subset to tell it from
|
|
93
|
+
// any other update, so an azure close rule could only ever fire on the wrong events or never. Widening
|
|
94
|
+
// INT-AZURE-PAYLOAD-SUBSET (a PR status field) is the gap to close before this set can grow. There is no
|
|
95
|
+
// label action either -- Azure attaches tags to WORK ITEMS, never to pull requests -- which is why azure's
|
|
96
|
+
// `prLabelAction` is null and a predicated PR rule is refused below.
|
|
69
97
|
azure: new Set(["created", "updated"]),
|
|
70
98
|
};
|
|
71
99
|
|
|
100
|
+
/**
|
|
101
|
+
* The one action per forge that closes a pull request, in that forge's own words (issue #231). Spelled
|
|
102
|
+
* once because three places turn on it: the close-only refusal in `normalizePullRequest` (a close rule
|
|
103
|
+
* gates on the CLOSER's write access, every other PR rule gates on the author's association or a
|
|
104
|
+
* collaborator's label, and one rule cannot gate on two different actors), the `capable` switch that
|
|
105
|
+
* admits `on.number`/`on.once` there, and the queue's semantic-key discriminant. Azure is absent for
|
|
106
|
+
* the subset reason the table above records, so `PR_ACTIONS.azure` simply never grows the word and the
|
|
107
|
+
* existing vocabulary refusal names azure on its own.
|
|
108
|
+
*
|
|
109
|
+
* EXPORTED for the two consumers that must never re-derive it: the receiver's grouping (a close-only
|
|
110
|
+
* rule routes through the close gate, every other PR rule through the author gate, and the split must
|
|
111
|
+
* be THIS table's) and the queue's semantic-key discriminant (a matched close action word is what
|
|
112
|
+
* marks a close job).
|
|
113
|
+
*/
|
|
114
|
+
export const PR_CLOSE_ACTIONS = { github: "closed", gitlab: "close", forgejo: "closed" };
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The `issue` action vocabulary, per forge, in each forge's own words (issue #231). One word each so
|
|
118
|
+
* far: the type exists for "when issue #40 closes, run deploy", and every other issue event already
|
|
119
|
+
* has a home (`label` for label predicates, `comment` for phrases). GitLab's word is `close` (its
|
|
120
|
+
* `object_attributes.action`), GitHub's and Forgejo's is `closed`.
|
|
121
|
+
*
|
|
122
|
+
* Azure is REFUSED rather than absent-and-unhandled, with its own message: a work item's close is a
|
|
123
|
+
* `System.State` transition whose terminal names vary by process template (Agile "Closed", Scrum
|
|
124
|
+
* "Done", plus "Resolved"), and the projected subset carries only `System.Tags` -- so matching a
|
|
125
|
+
* close needs both an INT-AZURE-PAYLOAD-SUBSET widening and a state vocabulary this version does not
|
|
126
|
+
* guess at. Not yet covered, not impossible -- validateResumeFlag's distinction, kept for the same
|
|
127
|
+
* reason.
|
|
128
|
+
*/
|
|
129
|
+
const ISSUE_ACTIONS = {
|
|
130
|
+
github: new Set(["closed"]),
|
|
131
|
+
gitlab: new Set(["close"]),
|
|
132
|
+
forgejo: new Set(["closed"]),
|
|
133
|
+
};
|
|
134
|
+
|
|
72
135
|
/**
|
|
73
136
|
* The verdicts a submitted GitHub review can carry, in the webhook's own (lower-case) spelling, and the
|
|
74
137
|
* vocabulary of the optional `on.reviewState` narrowing (issue #66).
|
|
@@ -100,6 +163,41 @@ const ID_CHARSET = /^[A-Za-z0-9._-]+$/;
|
|
|
100
163
|
*/
|
|
101
164
|
const REPLICAS_MAX = 3;
|
|
102
165
|
|
|
166
|
+
/**
|
|
167
|
+
* The ceiling on how many references one trigger may name (issue #225). Not tidiness: the worker resolves
|
|
168
|
+
* them SEQUENTIALLY, before the container, each with its own timeout, so N references hold a
|
|
169
|
+
* `PI_CONCURRENCY` slot for up to N x PI_SECRET_RESOLVE_TIMEOUT_MS inside the job's own 30-minute kill
|
|
170
|
+
* timer. A cap at load turns an unbounded slot occupancy into a bounded one for free, before anything
|
|
171
|
+
* runs. It bounds the host argv too: every resolved value is pushed as `-e NAME=VALUE` (docker-run.mjs).
|
|
172
|
+
*
|
|
173
|
+
* Sixteen rather than three: unlike `REPLICAS_MAX` this multiplies no spend, and a deploy job legitimately
|
|
174
|
+
* wants a handful of credentials. A literal for REPLICAS_MAX's reason -- this validator is pure and fs-free.
|
|
175
|
+
*/
|
|
176
|
+
const SECRETS_MAX = 16;
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* A POSIX environment variable name. Nothing in this repo validated one before `run.secrets` (checked), so
|
|
180
|
+
* this is the definition rather than a copy of one. Deliberately stricter than what `execve` would accept:
|
|
181
|
+
* a name is `-e NAME=VALUE` in the docker argv, so `=` would split in the wrong place, and the leading
|
|
182
|
+
* digit is excluded because a shell cannot expand `$1FOO` as that variable.
|
|
183
|
+
*/
|
|
184
|
+
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The env variable names a trigger may NOT bind, and why each set is here.
|
|
188
|
+
*
|
|
189
|
+
* These are the STATICALLY KNOWABLE half. `parseTriggers` is pure, fs-free and env-free, so it cannot see
|
|
190
|
+
* the resolved provider's credential variable names (they come from `findEnvKeys(provider, hostEnv)`) or
|
|
191
|
+
* the deployment's `PI_FORWARD_ENV` list. Those two are refused PRE-SPEND in the processor, where both are
|
|
192
|
+
* in hand -- the same load-time / deployment-state split `run.resume` already makes against
|
|
193
|
+
* `PI_SESSIONS_DIR`. Refusing here what can be answered here keeps the file's own mistakes in the file's
|
|
194
|
+
* own error.
|
|
195
|
+
*
|
|
196
|
+
* Every set is IMPORTED, never retyped, which is the rule `sandbox.test.mjs` keeps for the same reason: a
|
|
197
|
+
* forge added to the table later must not need a second edit here to stay covered.
|
|
198
|
+
*/
|
|
199
|
+
const RESERVED_ENV_NAMES = new Set([...MINTED_TOKEN_VARS, ...FORGE_HOST_VARS, ...WORKER_ONLY_SECRET_VARS, ...EGRESS_ENV_VARS, ...CONTAINER_ENV_NAMES]);
|
|
200
|
+
|
|
103
201
|
function isNonEmptyString(value) {
|
|
104
202
|
return typeof value === "string" && value.trim() !== "";
|
|
105
203
|
}
|
|
@@ -139,8 +237,12 @@ function normalizeTrigger(entry, index, path, state) {
|
|
|
139
237
|
if (run === null || typeof run !== "object") {
|
|
140
238
|
throw configError(`${at}: "run" must be an object: ${path}`);
|
|
141
239
|
}
|
|
240
|
+
// Joined from the set for the joined-vocabulary rule stated over the run.kind check below -- a type
|
|
241
|
+
// added to the vocabulary can never be
|
|
242
|
+
// refused by a message that does not mention it. This is also what keeps `DISARMED_TYPE` refused
|
|
243
|
+
// when authored: it is deliberately not in ON_TYPES, so it reads here as any other unknown type.
|
|
142
244
|
if (!ON_TYPES.has(on.type)) {
|
|
143
|
-
throw configError(`${at}: on.type must be one of
|
|
245
|
+
throw configError(`${at}: on.type must be one of ${[...ON_TYPES].join("|")} (got ${JSON.stringify(on.type)}): ${path}`);
|
|
144
246
|
}
|
|
145
247
|
// The legal-values half of every message below is JOINED from the table rather than typed out, so a
|
|
146
248
|
// forge added to the table can never be refused by a message that does not mention it -- which reads
|
|
@@ -159,9 +261,18 @@ function normalizeTrigger(entry, index, path, state) {
|
|
|
159
261
|
if (!isForgeKind(run.kind)) {
|
|
160
262
|
throw configError(`${at}: a ${on.type} trigger is webhook-driven and produces a forge job; run.kind must be one of ${FORGE_KINDS.join("|")} (got ${JSON.stringify(run.kind)}): ${path}`);
|
|
161
263
|
}
|
|
162
|
-
|
|
163
|
-
if (on.type === "
|
|
164
|
-
|
|
264
|
+
let normalized;
|
|
265
|
+
if (on.type === "label") normalized = normalizeLabel(on, run, index, path);
|
|
266
|
+
else if (on.type === "comment") normalized = normalizeComment(on, run, index, path, state);
|
|
267
|
+
else if (on.type === "issue") normalized = normalizeIssue(on, run, index, path);
|
|
268
|
+
else normalized = normalizePullRequest(on, run, index, path);
|
|
269
|
+
|
|
270
|
+
// A disarmed one-shot has validated IN FULL above (a disarmed entry with a malformed run still
|
|
271
|
+
// refuses the file -- writeTriggers' fail-closed contract needs the whole file valid, and the
|
|
272
|
+
// worker's disarm only ever ADDS one key to an entry that already passed). Only then does it
|
|
273
|
+
// normalize to the sentinel, so its raw index survives and nothing downstream can match it.
|
|
274
|
+
if (on.disarmed !== undefined) return { on: { type: DISARMED_TYPE }, run: {} };
|
|
275
|
+
return normalized;
|
|
165
276
|
}
|
|
166
277
|
|
|
167
278
|
function normalizeCron(on, run, index, path, state) {
|
|
@@ -228,6 +339,12 @@ function normalizeCron(on, run, index, path, state) {
|
|
|
228
339
|
// Called and DISCARDED: on a cron trigger this can only refuse, and the refusal is the point. The
|
|
229
340
|
// returned `run` below deliberately grows no `replicas` key -- a cron entry can never carry one.
|
|
230
341
|
validateReplicas(run, `cron trigger "${id}"`, path);
|
|
342
|
+
// Same posture for the close-trigger fields (issue #231): none has a legal value on cron.
|
|
343
|
+
validateNumber(on, `cron trigger "${id}"`, path, { onType: "cron" });
|
|
344
|
+
validateOnce(on, run, `cron trigger "${id}"`, path, { onType: "cron" });
|
|
345
|
+
validateDisarmed(on, `cron trigger "${id}"`, path, { onType: "cron" });
|
|
346
|
+
const secrets = validateSecrets(run, `cron trigger "${id}"`, path);
|
|
347
|
+
const secretsProfile = validateSecretsProfile(run, `cron trigger "${id}"`, path);
|
|
231
348
|
|
|
232
349
|
// provider/model/maxTurns stay absent when omitted so the value resolves at job start against the
|
|
233
350
|
// settings overlay/env, not a default frozen here (INT-CONFIG-OVERLAY-CONTRACT). github/packages/image stay
|
|
@@ -236,7 +353,7 @@ function normalizeCron(on, run, index, path, state) {
|
|
|
236
353
|
// freeze today's default into every stored repeatable.
|
|
237
354
|
return {
|
|
238
355
|
on: { type: "cron", id, pattern },
|
|
239
|
-
run: { kind: "local", folder: run.folder, flow: run.flow, task: run.task, provider: run.provider, model: run.model, maxTurns: run.maxTurns, github: run.github, packages, image, resume, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }) },
|
|
356
|
+
run: { kind: "local", folder: run.folder, flow: run.flow, task: run.task, provider: run.provider, model: run.model, maxTurns: run.maxTurns, github: run.github, packages, image, resume, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }) },
|
|
240
357
|
};
|
|
241
358
|
}
|
|
242
359
|
|
|
@@ -562,6 +679,122 @@ function validatePredicate(on, index, path, requirePositive) {
|
|
|
562
679
|
return { any: on.any, all: on.all, none: on.none };
|
|
563
680
|
}
|
|
564
681
|
|
|
682
|
+
/**
|
|
683
|
+
* `on.number` -- narrow a close-capable trigger to ONE item, by the number the forge itself assigns
|
|
684
|
+
* (issue #231). Legal with or without `on.once`: a standing "every close of #40" rule is coherent
|
|
685
|
+
* narrowing, exactly as `on.reviewState` narrows without changing what the rule is. Called from ALL
|
|
686
|
+
* normalizers, `validateReplicas`' posture -- where it cannot apply it refuses, never ignores.
|
|
687
|
+
* `capable` is passed only by the close-capable paths (the `issue` normalizer, and a `pull_request`
|
|
688
|
+
* rule whose only action is the forge's close word).
|
|
689
|
+
*/
|
|
690
|
+
function validateNumber(on, at, path, { capable = false, onType } = {}) {
|
|
691
|
+
const number = on.number;
|
|
692
|
+
if (number === undefined) return undefined;
|
|
693
|
+
if (!capable) {
|
|
694
|
+
if (onType === "cron") {
|
|
695
|
+
throw configError(`${at}: on.number is not available on a cron trigger -- a schedule fires on time, not on an item, so there is no delivery for a number to narrow: ${path}`);
|
|
696
|
+
}
|
|
697
|
+
throw configError(`${at}: on.number is not yet covered for ${onType} triggers here -- only the close routes read it, so on a rule they never serve it would sit in the file looking configured; narrowing labels, comments or non-close pull_request rules is a gap to close, not a limit: ${path}`);
|
|
698
|
+
}
|
|
699
|
+
if (!Number.isInteger(number) || number < 1) {
|
|
700
|
+
throw configError(`${at}: on.number must be an integer >= 1 when present -- the item number the forge itself assigns (on GitLab, the iid) (got ${JSON.stringify(number)}): ${path}`);
|
|
701
|
+
}
|
|
702
|
+
return number;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* `on.once` -- a one-shot: the trigger fires, produces a run record, and the worker disarms it by
|
|
707
|
+
* adding `on.disarmed` to this entry (issue #231, DES-ONE-SHOT-DISARM-IN-THE-FILE). Strictly boolean
|
|
708
|
+
* and fail-loud, the house rule; `false` is legal and carried, validateResumeFlag's argument -- an
|
|
709
|
+
* operator who wrote down today's default must not be refused for stating present behaviour.
|
|
710
|
+
*
|
|
711
|
+
* `once: true` REQUIRES `on.number`, and the requirement is a race analysis, not taste: a numberless
|
|
712
|
+
* one-shot matched by two different items' closes inside one dedup window enqueues both before either
|
|
713
|
+
* disarm lands, and which item "spent" the trigger is a coin flip. With a number, concurrent
|
|
714
|
+
* duplicates are duplicates of the SAME item, which is what the delivery GUID and the semantic window
|
|
715
|
+
* actually bound -- and the number is the identity the disarm re-checks before it writes, so a file
|
|
716
|
+
* edited between enqueue and disarm refuses loudly instead of disarming a stranger.
|
|
717
|
+
*
|
|
718
|
+
* `once: true` is refused beside `run.replicas` (`false`, the written-down default, is not): "exactly
|
|
719
|
+
* one run" and "N sandboxes race" contradict on their face,
|
|
720
|
+
* and with N run records the disarm no longer says which one spent the trigger.
|
|
721
|
+
*/
|
|
722
|
+
function validateOnce(on, run, at, path, { capable = false, onType } = {}) {
|
|
723
|
+
const once = on.once;
|
|
724
|
+
if (once === undefined) return undefined;
|
|
725
|
+
if (!capable) {
|
|
726
|
+
if (onType === "cron") {
|
|
727
|
+
throw configError(`${at}: on.once is not available on a cron trigger -- a one-shot that can re-arm is a schedule, and a cron job carries no matched delivery for the disarm to name; delete the entry when the work is done: ${path}`);
|
|
728
|
+
}
|
|
729
|
+
// The close-word hint is JOINED from the table (the same rule the vocabulary messages follow), so a
|
|
730
|
+
// forge gaining a close word later can never be pointed away from it by a stale sentence here.
|
|
731
|
+
const closeWords = Object.entries(PR_CLOSE_ACTIONS).map(([k, w]) => `${k} has ${JSON.stringify(w)}`).join(", ");
|
|
732
|
+
throw configError(`${at}: on.once is not yet covered here -- the disarm writes back to the one entry a delivery matched, and only a close delivery names the single item that spends it; use on.type "issue", or a pull_request rule whose only action is the close word (${closeWords}; azure has no close trigger yet): ${path}`);
|
|
733
|
+
}
|
|
734
|
+
if (typeof once !== "boolean") {
|
|
735
|
+
throw configError(`${at}: on.once must be true or false when present -- a truthy string arming a one-shot is exactly the drift this validator exists to refuse (got ${JSON.stringify(once)}): ${path}`);
|
|
736
|
+
}
|
|
737
|
+
if (once === true && on.number === undefined) {
|
|
738
|
+
throw configError(`${at}: on.once requires on.number -- a one-shot without a named item is spent by whichever close arrives first, and two different items closing inside one dedup window would race for it; the number is also the identity the disarm re-checks before it writes: ${path}`);
|
|
739
|
+
}
|
|
740
|
+
if (once === true && run.replicas !== undefined) {
|
|
741
|
+
throw configError(`${at}: on.once and run.replicas cannot be combined -- "exactly one run" and "N sandboxes race" contradict, and with N run records the disarm no longer says which one spent the trigger: ${path}`);
|
|
742
|
+
}
|
|
743
|
+
return once;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* `on.disarmed` -- the mark the worker writes when a one-shot fires: `{ at, jobId? }`, provenance an
|
|
748
|
+
* operator can read a year later when the run record is long reaped (issue #231). Hand-writable too
|
|
749
|
+
* (jobId optional), which is how an operator disarms deliberately; deleting the key is how they
|
|
750
|
+
* re-arm. The entry it sits on normalizes to the DISARMED_TYPE sentinel -- see normalizeTrigger --
|
|
751
|
+
* but only AFTER this shape check and the full entry validation pass, so a corrupted disarm mark is
|
|
752
|
+
* a load refusal, never a silently-still-armed rule.
|
|
753
|
+
*/
|
|
754
|
+
function validateDisarmed(on, at, path, { capable = false, onType } = {}) {
|
|
755
|
+
const disarmed = on.disarmed;
|
|
756
|
+
if (disarmed === undefined) return undefined;
|
|
757
|
+
if (!capable) {
|
|
758
|
+
throw configError(`${at}: on.disarmed marks a spent one-shot, and on.once is not ${onType === "cron" ? "available on a cron trigger" : "yet covered here"}, so there is nothing this entry could have spent: ${path}`);
|
|
759
|
+
}
|
|
760
|
+
if (disarmed === null || typeof disarmed !== "object" || Array.isArray(disarmed)) {
|
|
761
|
+
throw configError(`${at}: on.disarmed must be an object with a non-empty string "at" (and optionally a non-empty string "jobId") -- the worker writes it when the one-shot fires, and a hand-written one disarms the entry deliberately (got ${JSON.stringify(disarmed)}): ${path}`);
|
|
762
|
+
}
|
|
763
|
+
for (const key of Object.keys(disarmed)) {
|
|
764
|
+
if (key !== "at" && key !== "jobId") {
|
|
765
|
+
throw configError(`${at}: on.disarmed has an unsupported key ${JSON.stringify(key)} (expected at|jobId): ${path}`);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
if (!isNonEmptyString(disarmed.at)) {
|
|
769
|
+
throw configError(`${at}: on.disarmed.at must be a non-empty string (the time the one-shot fired): ${path}`);
|
|
770
|
+
}
|
|
771
|
+
if (disarmed.jobId !== undefined && !isNonEmptyString(disarmed.jobId)) {
|
|
772
|
+
throw configError(`${at}: on.disarmed.jobId must be a non-empty string when present (the run record it joins to): ${path}`);
|
|
773
|
+
}
|
|
774
|
+
if (on.once !== true) {
|
|
775
|
+
throw configError(`${at}: on.disarmed is only meaningful beside on.once: true -- an entry that was never a one-shot has nothing to spend: ${path}`);
|
|
776
|
+
}
|
|
777
|
+
return disarmed;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* `run.flow` charset for the WEBHOOK kinds (issue #231). materialize.mjs refuses a name outside
|
|
782
|
+
* SKILL_NAME_RE at job start, AFTER the budget slot is reserved -- so until now a charset-invalid
|
|
783
|
+
* forge flow loaded clean and could only ever fail in-container (graph-model already renders it as
|
|
784
|
+
* the `charset-invalid` defect). Refusing at load turns that paid failure into a free one, and it is
|
|
785
|
+
* also what keeps `:` out of the flow slot of the queue's semantic dedup key, where the command jobs'
|
|
786
|
+
* `cmd:` prefix lives and the close jobs' discriminant sits beside it once close routing lands.
|
|
787
|
+
* Deliberately NOT called on cron:
|
|
788
|
+
* a local flow resolves inside the operator's own folder by pi itself, and the semantic key does not
|
|
789
|
+
* apply to repeat jobs -- narrowing there would refuse deployments this hazard cannot reach.
|
|
790
|
+
*/
|
|
791
|
+
function validateFlowName(run, at, path) {
|
|
792
|
+
if (run.flow === undefined) return;
|
|
793
|
+
if (!SKILL_NAME_RE.test(run.flow)) {
|
|
794
|
+
throw configError(`${at}: run.flow ${JSON.stringify(run.flow)} fails the skill-name charset (lowercase letters, digits, dash and underscore, 1-64 chars, starting and ending alphanumeric) -- a name outside it can never materialise, so this trigger could only ever fail after the budget slot was reserved: ${path}`);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
565
798
|
|
|
566
799
|
/**
|
|
567
800
|
* `run.repository` -- WHICH repository a job clones, for a forge whose trigger subject does not name one.
|
|
@@ -635,14 +868,134 @@ function validateReplicas(run, at, path) {
|
|
|
635
868
|
return replicas;
|
|
636
869
|
}
|
|
637
870
|
|
|
871
|
+
/**
|
|
872
|
+
* `run.secrets` -- the env variables this trigger's job receives, and the opaque references an operator's
|
|
873
|
+
* own resolver turns into values (REQ-TRIGGER-SECRETS, issue #225).
|
|
874
|
+
*
|
|
875
|
+
* THE GRAMMAR OF THE VALUES IS NOT OURS. `op://vault/item/field`, `secret/data/ci#stripe` and a bare name
|
|
876
|
+
* are all valid here, because what parses them is a script the operator wrote and named in
|
|
877
|
+
* `PI_SECRET_PROFILES`. This file validates the SHAPE of the map and never the meaning of a reference --
|
|
878
|
+
* the posture #206 and #209 already set ("the seam is a command"), and the same one
|
|
879
|
+
* `DES-SERVICE-ENV-SETUP-SEAM` implements one layer up. A regex over `op://` here would bless a vendor.
|
|
880
|
+
*
|
|
881
|
+
* WHY EACH REFUSAL:
|
|
882
|
+
* - not a plain object, or a value that is not a non-empty string. An array or a nested object is an
|
|
883
|
+
* operator writing a different feature than the one that exists.
|
|
884
|
+
* - more than SECRETS_MAX entries -- see that constant: it bounds a worker slot, not a preference.
|
|
885
|
+
* - a key that is not an environment variable name. There is no downstream check: `-e NAME=VALUE` goes
|
|
886
|
+
* into the docker argv as written, so a key with an `=` in it silently binds a different variable.
|
|
887
|
+
* - a key in RESERVED_ENV_NAMES. The mint, the egress policy and the closed map all write AFTER this
|
|
888
|
+
* feature does, so such a key would be accepted, overwritten, and the job would run without the value
|
|
889
|
+
* it named on a clean exit 0. Ordering is the backstop; this is the refusal, which is the same
|
|
890
|
+
* division of labour `PI_FORWARD_ENV` already keeps (config.mjs refuses the names, env-allowlist.mjs
|
|
891
|
+
* orders the assignments so a slip cannot matter).
|
|
892
|
+
* - a reference starting with `-`. It is passed as `argv[1]` of the resolver, where a leading dash
|
|
893
|
+
* parses as a flag -- exactly `validateImageRef`'s reason for the same refusal on `run.image`. We do
|
|
894
|
+
* NOT pass `--` before it instead: that would be a claim about the resolver's option parser, and the
|
|
895
|
+
* option parser is the operator's.
|
|
896
|
+
* - `run.resume: true`. `assertResumeAllowedOnGhSource` (get-token.mjs) already refuses the `gh` token
|
|
897
|
+
* source for a resumed job, and states the argument this inherits whole: every property
|
|
898
|
+
* CONST-TOKEN-SCOPED-PER-JOB relies on assumes the credential is an ENV VALUE that dies with the
|
|
899
|
+
* container, while a persisted transcript is a FILE on host disk, replayed into the next job on that
|
|
900
|
+
* key. A resolved vault value is an env value the agent can echo, and nothing here redacts a
|
|
901
|
+
* transcript. Refused with NO escape hatch, unlike that one's PI_SESSIONS_ALLOW_GH_SOURCE: a pure
|
|
902
|
+
* validator cannot read an env var to find one, and the reason is stronger anyway, since that refusal
|
|
903
|
+
* complains a `gh` login is "full-scope and NON-EXPIRING" and a vault password does not expire either.
|
|
904
|
+
*
|
|
905
|
+
* Called from ALL FOUR normalizers, cron included. Unlike `run.replicas` this is NOT refused on a local
|
|
906
|
+
* job: nothing about resolving a secret is forge-specific, and a nightly deploy is the obvious user. The
|
|
907
|
+
* replica refusal turns on a local `/workspace` being the operator's own folder with no clone, which is a
|
|
908
|
+
* fact about two agents sharing a working tree, not about a credential. That folder does bring its own
|
|
909
|
+
* hazard (an agent that writes a credential into `.env` writes it into the operator's real repository),
|
|
910
|
+
* and `doctor` warns about exactly that rather than this validator refusing the use case outright.
|
|
911
|
+
*
|
|
912
|
+
* Returns the map, undefined when absent, so an unflagged trigger normalizes byte-identically.
|
|
913
|
+
*/
|
|
914
|
+
function validateSecrets(run, at, path) {
|
|
915
|
+
const secrets = run.secrets;
|
|
916
|
+
if (secrets === undefined) return undefined;
|
|
917
|
+
if (secrets === null || typeof secrets !== "object" || Array.isArray(secrets)) {
|
|
918
|
+
throw configError(`${at}: run.secrets must be an object mapping environment variable names to references when present (got ${JSON.stringify(secrets)}): ${path}`);
|
|
919
|
+
}
|
|
920
|
+
const names = Object.keys(secrets);
|
|
921
|
+
if (names.length === 0) {
|
|
922
|
+
throw configError(`${at}: run.secrets is empty -- an empty map is a field that does nothing, and this trigger reads as though it binds a secret: ${path}`);
|
|
923
|
+
}
|
|
924
|
+
if (names.length > SECRETS_MAX) {
|
|
925
|
+
throw configError(`${at}: run.secrets names ${names.length} variables, over the ${SECRETS_MAX} cap -- each one is resolved before the container starts, holding a concurrency slot while it runs: ${path}`);
|
|
926
|
+
}
|
|
927
|
+
for (const name of names) {
|
|
928
|
+
if (!ENV_NAME.test(name)) {
|
|
929
|
+
throw configError(`${at}: run.secrets key ${JSON.stringify(name)} is not an environment variable name (letters, digits and underscore, not starting with a digit): ${path}`);
|
|
930
|
+
}
|
|
931
|
+
if (RESERVED_ENV_NAMES.has(name)) {
|
|
932
|
+
throw configError(`${at}: run.secrets key ${JSON.stringify(name)} is a variable the worker sets itself -- the job container would receive the worker's value, not this trigger's, and the trigger would look like it worked: ${path}`);
|
|
933
|
+
}
|
|
934
|
+
const reference = secrets[name];
|
|
935
|
+
if (!isNonEmptyString(reference)) {
|
|
936
|
+
throw configError(`${at}: run.secrets.${name} must be a non-empty string reference for your resolver to read (got ${JSON.stringify(reference)}): ${path}`);
|
|
937
|
+
}
|
|
938
|
+
if (reference !== reference.trim()) {
|
|
939
|
+
throw configError(`${at}: run.secrets.${name} must not have leading or trailing whitespace -- the file is the reviewed artifact and silently trimming it would make the file disagree with what the resolver is asked for: ${path}`);
|
|
940
|
+
}
|
|
941
|
+
if (reference.startsWith("-")) {
|
|
942
|
+
throw configError(`${at}: run.secrets.${name} must not start with "-" -- it is passed as the resolver's first argument, where a leading dash parses as a flag: ${path}`);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
if (run.resume === true) {
|
|
946
|
+
throw configError(`${at}: run.secrets and run.resume cannot be combined -- a resumed job replays a transcript kept on host disk, and any command the agent ran that echoed a resolved value wrote it into that transcript, which is then prefilled into every later job on the same key: ${path}`);
|
|
947
|
+
}
|
|
948
|
+
return { ...secrets };
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/**
|
|
952
|
+
* `run.secretsProfile` -- WHICH of the operator's declared resolvers reads this trigger's references.
|
|
953
|
+
*
|
|
954
|
+
* A NAME, never a path, and that distinction is the whole reason this field is allowed to exist.
|
|
955
|
+
* `DES-SERVICE-ENV-SETUP-SEAM` rejected "making this reachable from configuration, which would turn a
|
|
956
|
+
* boot-time root-adjacent exec into something a trigger file could name". A profile name selects among
|
|
957
|
+
* execs the operator already declared in `PI_SECRET_PROFILES`; it cannot introduce one, cannot name a
|
|
958
|
+
* path, and cannot reach a script nobody wired. The rejected thing is a trigger file NAMING an exec.
|
|
959
|
+
*
|
|
960
|
+
* The charset is `ID_CHARSET`, shared with cron ids, for two reasons that both bite: the name is echoed in
|
|
961
|
+
* a refusal that `comment` posts PUBLICLY on the issue, and `PI_SECRET_PROFILES` is a `,`-separated list
|
|
962
|
+
* of `name:path` pairs, so a name containing either separator could not round-trip through the very
|
|
963
|
+
* variable that declares it.
|
|
964
|
+
*
|
|
965
|
+
* WHETHER the named profile exists is NOT checked here and cannot be: which profiles a deployment declares
|
|
966
|
+
* is env and overlay state, and this validator is pure and fs-free. That is refused pre-spend, per
|
|
967
|
+
* delivery, as `secret-profile-unknown` -- the same split `run.resume` makes against `PI_SESSIONS_DIR`,
|
|
968
|
+
* where the file answers what the file knows and `doctor` plus a per-delivery refusal carry the rest.
|
|
969
|
+
*
|
|
970
|
+
* Absent selects the profile named `default`, so a single-manager deployment never writes the field.
|
|
971
|
+
*/
|
|
972
|
+
function validateSecretsProfile(run, at, path) {
|
|
973
|
+
const profile = run.secretsProfile;
|
|
974
|
+
if (profile === undefined) return undefined;
|
|
975
|
+
if (!isNonEmptyString(profile)) {
|
|
976
|
+
throw configError(`${at}: run.secretsProfile must be a non-empty string naming one of the resolver profiles this deployment declares (got ${JSON.stringify(profile)}): ${path}`);
|
|
977
|
+
}
|
|
978
|
+
if (!ID_CHARSET.test(profile)) {
|
|
979
|
+
throw configError(`${at}: run.secretsProfile ${JSON.stringify(profile)} may use letters, digits, dot, dash and underscore only -- PI_SECRET_PROFILES is a comma-separated list of name:path pairs, so a name carrying either separator cannot be declared: ${path}`);
|
|
980
|
+
}
|
|
981
|
+
if (run.secrets === undefined) {
|
|
982
|
+
throw configError(`${at}: run.secretsProfile is set but run.secrets names nothing -- a profile that resolves no references is a field that does nothing: ${path}`);
|
|
983
|
+
}
|
|
984
|
+
return profile;
|
|
985
|
+
}
|
|
986
|
+
|
|
638
987
|
function normalizeLabel(on, run, index, path) {
|
|
639
988
|
const at = `trigger at index ${index}`;
|
|
640
989
|
const predicate = validatePredicate(on, index, path, true);
|
|
990
|
+
validateNumber(on, at, path, { onType: "label" });
|
|
991
|
+
validateOnce(on, run, at, path, { onType: "label" });
|
|
992
|
+
validateDisarmed(on, at, path, { onType: "label" });
|
|
641
993
|
// First among the run checks, before the flow-required check -- validateCommand says why.
|
|
642
994
|
const command = validateCommand(run, at, path, { onType: "label" });
|
|
643
995
|
if (command === undefined && !isNonEmptyString(run.flow)) {
|
|
644
996
|
throw configError(`${at}: label trigger run.flow must be a non-empty string (or use run.command): ${path}`);
|
|
645
997
|
}
|
|
998
|
+
validateFlowName(run, at, path);
|
|
646
999
|
const packages = validatePackagesFlag(run, at, path);
|
|
647
1000
|
const image = validateImageRef(run, at, path);
|
|
648
1001
|
const skillsDir = validateSkillsDir(run, at, path);
|
|
@@ -650,9 +1003,11 @@ function normalizeLabel(on, run, index, path) {
|
|
|
650
1003
|
const resume = validateResumeFlag(run, at, path);
|
|
651
1004
|
const repository = validateRepository(run, "label", at, path);
|
|
652
1005
|
const replicas = validateReplicas(run, at, path);
|
|
1006
|
+
const secrets = validateSecrets(run, at, path);
|
|
1007
|
+
const secretsProfile = validateSecretsProfile(run, at, path);
|
|
653
1008
|
return {
|
|
654
1009
|
on: { type: "label", any: predicate.any, all: predicate.all, none: predicate.none },
|
|
655
|
-
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }) },
|
|
1010
|
+
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }) },
|
|
656
1011
|
};
|
|
657
1012
|
}
|
|
658
1013
|
|
|
@@ -661,6 +1016,9 @@ function normalizeComment(on, run, index, path, state) {
|
|
|
661
1016
|
if (!isNonEmptyString(on.phrase)) {
|
|
662
1017
|
throw configError(`${at}: comment trigger on.phrase must be a non-empty string: ${path}`);
|
|
663
1018
|
}
|
|
1019
|
+
validateNumber(on, at, path, { onType: "comment" });
|
|
1020
|
+
validateOnce(on, run, at, path, { onType: "comment" });
|
|
1021
|
+
validateDisarmed(on, at, path, { onType: "comment" });
|
|
664
1022
|
// First among the run checks, before the flow-required check -- validateCommand says why. A command
|
|
665
1023
|
// trigger has NO default flow for the `<phrase> <flow>` comment override to replace; making that
|
|
666
1024
|
// token inert is the receiver filter's job, not a shape this validator can see.
|
|
@@ -668,6 +1026,7 @@ function normalizeComment(on, run, index, path, state) {
|
|
|
668
1026
|
if (command === undefined && !isNonEmptyString(run.flow)) {
|
|
669
1027
|
throw configError(`${at}: comment trigger run.flow (the default flow) must be a non-empty string (or use run.command): ${path}`);
|
|
670
1028
|
}
|
|
1029
|
+
validateFlowName(run, at, path);
|
|
671
1030
|
// At most one comment trigger PER FORGE. The cap exists because the receiver holds one comment rule
|
|
672
1031
|
// per forge and a second would be silently unreachable -- so it is a cap on ambiguity, not on count,
|
|
673
1032
|
// and a deployment serving GitHub and GitLab is entitled to the same `@pi` phrase on each.
|
|
@@ -682,9 +1041,83 @@ function normalizeComment(on, run, index, path, state) {
|
|
|
682
1041
|
const resume = validateResumeFlag(run, at, path);
|
|
683
1042
|
const repository = validateRepository(run, "comment", at, path);
|
|
684
1043
|
const replicas = validateReplicas(run, at, path);
|
|
1044
|
+
const secrets = validateSecrets(run, at, path);
|
|
1045
|
+
const secretsProfile = validateSecretsProfile(run, at, path);
|
|
685
1046
|
return {
|
|
686
1047
|
on: { type: "comment", phrase: on.phrase },
|
|
687
|
-
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }) },
|
|
1048
|
+
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }) },
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
/**
|
|
1053
|
+
* `on.type: "issue"` (issue #231): fire when an ISSUE's lifecycle action happens -- one word so far,
|
|
1054
|
+
* the close. The type exists because every other issue event already has a home (`label` for label
|
|
1055
|
+
* predicates, `comment` for phrases) and neither of those shapes fits a close: there is no label diff
|
|
1056
|
+
* to match and no phrase to read, only an action, an item number, and the actor who performed it.
|
|
1057
|
+
* A PULL REQUEST's close is deliberately NOT this type -- it rides `on.type: "pull_request"` as an
|
|
1058
|
+
* action, the #66 rule (one forge's PR lifecycle must not be a type while another's is an action),
|
|
1059
|
+
* and because GitLab and Azure number issues and merge/pull requests from SEPARATE sequences, a
|
|
1060
|
+
* both-kinds type narrowed by `on.number` would be ambiguous exactly where a one-shot spending
|
|
1061
|
+
* itself on the wrong #5 hurts most. Under the split, the type IS the discriminator.
|
|
1062
|
+
*/
|
|
1063
|
+
function normalizeIssue(on, run, index, path) {
|
|
1064
|
+
const at = `trigger at index ${index}`;
|
|
1065
|
+
|
|
1066
|
+
// Kind first: on azure the whole TYPE is unsupported, and hearing that beats hearing that one
|
|
1067
|
+
// action word is. See ISSUE_ACTIONS for why, and validateResumeFlag for the "not yet covered"
|
|
1068
|
+
// vocabulary this reuses.
|
|
1069
|
+
if (run.kind === "azure") {
|
|
1070
|
+
throw configError(`${at}: an issue trigger is not yet covered for azure -- a work item's close is a System.State transition whose terminal names vary by process template (Agile "Closed", Scrum "Done"), and the projected subset carries only System.Tags, so nothing in the delivery says the item closed; widening the payload subset is the gap to close, not a limit: ${path}`);
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
const actions = on.action;
|
|
1074
|
+
if (!Array.isArray(actions) || actions.length === 0) {
|
|
1075
|
+
throw configError(`${at}: issue on.action must be a non-empty array: ${path}`);
|
|
1076
|
+
}
|
|
1077
|
+
// Validated against THIS entry's forge, in that forge's own words -- normalizePullRequest's rule.
|
|
1078
|
+
const allowed = ISSUE_ACTIONS[run.kind];
|
|
1079
|
+
const expected = [...allowed].join("|");
|
|
1080
|
+
for (const a of actions) {
|
|
1081
|
+
if (!allowed.has(a)) {
|
|
1082
|
+
throw configError(`${at}: issue on.action has an unsupported ${run.kind} action ${JSON.stringify(a)} (expected ${expected}): ${path}`);
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// The close route matches action and number alone, so a predicate here would be accepted-and-
|
|
1087
|
+
// ignored -- the exact thing validateRepository's posture forbids. Refused, not dropped.
|
|
1088
|
+
if (on.any !== undefined || on.all !== undefined || on.none !== undefined) {
|
|
1089
|
+
throw configError(`${at}: an issue trigger cannot carry a label predicate -- it fires on a lifecycle action, not a label diff, so any/all/none could never match; a label-predicated rule is on.type "label": ${path}`);
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
const number = validateNumber(on, at, path, { capable: true, onType: "issue" });
|
|
1093
|
+
const once = validateOnce(on, run, at, path, { capable: true, onType: "issue" });
|
|
1094
|
+
validateDisarmed(on, at, path, { capable: true, onType: "issue" });
|
|
1095
|
+
|
|
1096
|
+
// First among the run checks, before the flow-required check -- validateCommand says why.
|
|
1097
|
+
const command = validateCommand(run, at, path, { onType: "issue" });
|
|
1098
|
+
if (command === undefined && !isNonEmptyString(run.flow)) {
|
|
1099
|
+
throw configError(`${at}: issue trigger run.flow must be a non-empty string (or use run.command): ${path}`);
|
|
1100
|
+
}
|
|
1101
|
+
validateFlowName(run, at, path);
|
|
1102
|
+
const packages = validatePackagesFlag(run, at, path);
|
|
1103
|
+
const image = validateImageRef(run, at, path);
|
|
1104
|
+
const skillsDir = validateSkillsDir(run, at, path);
|
|
1105
|
+
const instructions = validateInstructions(run, at, path);
|
|
1106
|
+
const resume = validateResumeFlag(run, at, path);
|
|
1107
|
+
validateRepository(run, "issue", at, path);
|
|
1108
|
+
const replicas = validateReplicas(run, at, path);
|
|
1109
|
+
const secrets = validateSecrets(run, at, path);
|
|
1110
|
+
const secretsProfile = validateSecretsProfile(run, at, path);
|
|
1111
|
+
return {
|
|
1112
|
+
on: {
|
|
1113
|
+
type: "issue",
|
|
1114
|
+
action: [...actions],
|
|
1115
|
+
// Absent rather than present-and-undefined, reviewState's rule below: an unnarrowed rule's
|
|
1116
|
+
// normalized shape must not grow keys.
|
|
1117
|
+
...(number !== undefined && { number }),
|
|
1118
|
+
...(once !== undefined && { once }),
|
|
1119
|
+
},
|
|
1120
|
+
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }) },
|
|
688
1121
|
};
|
|
689
1122
|
}
|
|
690
1123
|
|
|
@@ -705,6 +1138,16 @@ function normalizePullRequest(on, run, index, path) {
|
|
|
705
1138
|
}
|
|
706
1139
|
}
|
|
707
1140
|
|
|
1141
|
+
// The close-only split (issue #231). A close rule gates on the CLOSER's write access; every other
|
|
1142
|
+
// PR action gates on the author's association or a collaborator-applied label. One rule cannot
|
|
1143
|
+
// gate on two different actors, so a list mixing the close word with anything else is refused --
|
|
1144
|
+
// which is also what lets the receiver group close rules separately without re-deriving this.
|
|
1145
|
+
const closeWord = PR_CLOSE_ACTIONS[run.kind];
|
|
1146
|
+
const isClose = closeWord !== undefined && actions.includes(closeWord);
|
|
1147
|
+
if (isClose && actions.some((a) => a !== closeWord)) {
|
|
1148
|
+
throw configError(`${at}: pull_request on.action cannot mix ${JSON.stringify(closeWord)} with other actions -- a close rule is gated on the closer's write access and every other action on the author's, and one rule cannot gate on two different actors; split it into two entries: ${path}`);
|
|
1149
|
+
}
|
|
1150
|
+
|
|
708
1151
|
const reviewState = validateReviewState(on, actions, run, at, path);
|
|
709
1152
|
|
|
710
1153
|
// A `labeled` PR trigger is gated by its label predicate (the collaborator-applied label is the
|
|
@@ -724,12 +1167,21 @@ function normalizePullRequest(on, run, index, path) {
|
|
|
724
1167
|
if (run.kind === "azure" && (on.any !== undefined || on.all !== undefined || on.none !== undefined)) {
|
|
725
1168
|
throw configError(`${at}: an azure pull_request trigger cannot carry a label predicate -- Azure DevOps attaches tags to work items, never to pull requests, so any/all/none could never match: ${path}`);
|
|
726
1169
|
}
|
|
1170
|
+
// A close-only rule reads no label diff either -- its route matches action and number alone -- so
|
|
1171
|
+
// a predicate on it is normalizeIssue's refusal arriving on the PR side.
|
|
1172
|
+
if (isClose && (on.any !== undefined || on.all !== undefined || on.none !== undefined)) {
|
|
1173
|
+
throw configError(`${at}: a close pull_request rule cannot carry a label predicate -- the close route matches the action and on.number alone, so any/all/none would sit in the file looking configured and never match anything: ${path}`);
|
|
1174
|
+
}
|
|
727
1175
|
const predicate = validatePredicate(on, index, path, requirePositive);
|
|
1176
|
+
const number = validateNumber(on, at, path, { capable: isClose, onType: "pull_request" });
|
|
1177
|
+
const once = validateOnce(on, run, at, path, { capable: isClose, onType: "pull_request" });
|
|
1178
|
+
validateDisarmed(on, at, path, { capable: isClose, onType: "pull_request" });
|
|
728
1179
|
// First among the run checks, before the flow-required check -- validateCommand says why.
|
|
729
1180
|
const command = validateCommand(run, at, path, { onType: "pull_request" });
|
|
730
1181
|
if (command === undefined && !isNonEmptyString(run.flow)) {
|
|
731
1182
|
throw configError(`${at}: pull_request trigger run.flow must be a non-empty string (or use run.command): ${path}`);
|
|
732
1183
|
}
|
|
1184
|
+
validateFlowName(run, at, path);
|
|
733
1185
|
const packages = validatePackagesFlag(run, at, path);
|
|
734
1186
|
const image = validateImageRef(run, at, path);
|
|
735
1187
|
const skillsDir = validateSkillsDir(run, at, path);
|
|
@@ -737,6 +1189,8 @@ function normalizePullRequest(on, run, index, path) {
|
|
|
737
1189
|
const resume = validateResumeFlag(run, at, path);
|
|
738
1190
|
validateRepository(run, "pull_request", at, path);
|
|
739
1191
|
const replicas = validateReplicas(run, at, path);
|
|
1192
|
+
const secrets = validateSecrets(run, at, path);
|
|
1193
|
+
const secretsProfile = validateSecretsProfile(run, at, path);
|
|
740
1194
|
return {
|
|
741
1195
|
on: {
|
|
742
1196
|
type: "pull_request",
|
|
@@ -747,8 +1201,11 @@ function normalizePullRequest(on, run, index, path) {
|
|
|
747
1201
|
any: predicate.any,
|
|
748
1202
|
all: predicate.all,
|
|
749
1203
|
none: predicate.none,
|
|
1204
|
+
// Same rule for the close-narrowing pair (issue #231): only a close-only rule can carry them.
|
|
1205
|
+
...(number !== undefined && { number }),
|
|
1206
|
+
...(once !== undefined && { once }),
|
|
750
1207
|
},
|
|
751
|
-
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }) },
|
|
1208
|
+
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }) },
|
|
752
1209
|
};
|
|
753
1210
|
}
|
|
754
1211
|
|