@intentius/chant-lexicon-github 0.57.0 → 0.59.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.
@@ -4,33 +4,59 @@
4
4
  * The Op counterpart to `./generate-pipeline.ts` (#891): that module
5
5
  * synthesizes a `workflow_dispatch`-triggered pipeline from a deploy-time
6
6
  * component graph, this one synthesizes a cron-triggered workflow per
7
- * stateless Op the CI-native alternative to a Temporal `TemporalSchedule`
8
- * for downstream projects that don't run Temporal (`WorkflowAuditOp`,
9
- * `PipelineAuditOp`, `ReconcileOp`, all accept an optional `schedule`
10
- * precisely for this).
7
+ * stateless Op. An Op's cadence is an `OpSchedule` on the Op itself
8
+ * (`packages/core/src/op/types.ts`), runtime-neutral data each reader
9
+ * interprets; this module is the reader that turns it into a cron a GitHub
10
+ * runner fires (`WorkflowAuditOp`, `PipelineAuditOp`, `ReconcileOp`, … all
11
+ * accept an optional `schedule` precisely for this).
11
12
  *
12
13
  * GitHub Actions' `on.schedule` is workflow-scoped, not job-scoped, so unlike
13
14
  * the component generator (one combined pipeline for the whole graph) this
14
15
  * emits one workflow file per `ScheduledOpSpec`. Each workflow:
15
- * - triggers on `schedule` (the Op's cron) and `workflow_dispatch` (manual
16
- * runs stay available for testing/dry-runs);
17
- * - declares only the `permissions:` its `findingMode` needs `report`
18
- * stays read-only, `issue`/`pull-request` add the write scope the Op's own
19
- * activity uses (`gh issue create` / `gh pr create`, see
20
- * `@intentius/chant-lexicon-temporal`'s `reconcilePr` activity) — never a
21
- * blanket `write-all`;
16
+ * - triggers on its `ScheduledOpSpec`'s trigger (#2084): `cron` (the Op's
17
+ * schedule, plus `workflow_dispatch` so manual runs stay available for
18
+ * testing/dry-runs), `pull_request` (optionally filtered to `branches`,
19
+ * no `workflow_dispatch` a PR event needs no manual escape hatch), or
20
+ * `push` (filtered to `branches`, defaulting to the repository's default
21
+ * branch);
22
+ * - declares only the `permissions:` its `findingMode` (and, for a
23
+ * `pull_request` trigger, whether that mode posts a comment) needs —
24
+ * `report` stays read-only, `issue`/`comment`/`pull-request` add the write
25
+ * scope the Op's own activity uses (`gh issue create` / a comment on the
26
+ * triggering PR / `gh pr create`, see `@intentius/chant/op`'s
27
+ * `reconcilePr` activity) — never a blanket `write-all`. `comment` is the
28
+ * one mode that constrains the trigger rather than only the scope: it
29
+ * needs a pull request to post onto, so this generator refuses it by name
30
+ * on any other trigger (#2231);
22
31
  * - runs exactly one invocation, `chant run <name>` by default — never
23
32
  * inlined audit/reconcile logic. The finding-mode itself is already baked
24
33
  * into the Op's own activity args at build time by the composite that
25
- * created it; this workflow only supplies the token the mode needs to act.
34
+ * created it; this workflow only supplies the token the mode needs to act;
35
+ * - on a `push` trigger only, runs that invocation with `--gated-exit 0`
36
+ * and adds a follow-up job that says where the approval is pending
37
+ * (#2243). See {@link GATED_EXIT_FLAG} and {@link gateNoticeJob}.
38
+ *
39
+ * Two per-Op options widen that shape without loosening it (#2242). A spec's
40
+ * `setup` list emits steps between the checkout and the `beforeScript` lines,
41
+ * `uses:` steps included, which is the only way a generated job can reach an
42
+ * action like `aws-actions/configure-aws-credentials`; {@link
43
+ * assertSetupSteps} refuses an unpinned or default-branch ref at build time.
44
+ * A spec's `permissions` map is merged over {@link permissionsFor}, adding
45
+ * scopes the finding-mode never grants (`id-token: write` is the whole
46
+ * reason) and never touching one it does; {@link mergePermissions} refuses a
47
+ * blanket grant, an overlap with the mode's own set, an unknown scope name,
48
+ * and pull-request write on a trigger that has no pull request.
26
49
  */
27
50
 
28
51
  import { emitYAML } from "@intentius/chant/yaml";
52
+ import { resolveOpTrigger } from "@intentius/chant/lexicon";
29
53
  import type {
30
54
  ComponentPipelineOptions as GenerateGithubOpOptions,
31
55
  OpFindingMode,
32
56
  OpPipelineJob,
33
57
  OpPipelineResult as GenerateGithubOpResult,
58
+ OpSetupStep,
59
+ OpTrigger,
34
60
  ScheduledOpSpec,
35
61
  } from "@intentius/chant/lexicon";
36
62
 
@@ -44,7 +70,11 @@ export type { GenerateGithubOpOptions, GenerateGithubOpResult };
44
70
  * `./generate-pipeline.ts`'s `GithubPipelineDoc` split.
45
71
  */
46
72
  export interface GithubOpPipelineDoc {
47
- /** The `on:` trigger mapping (`schedule` + `workflow_dispatch`). */
73
+ /**
74
+ * The `on:` trigger mapping, per {@link ScheduledOpSpec}'s trigger kind
75
+ * (#2084): `{ schedule, workflow_dispatch }` for cron, `{ pull_request }`
76
+ * for `pull_request`, `{ push }` for `push`.
77
+ */
48
78
  on: Record<string, unknown>;
49
79
  /** The `env:` mapping, when `options.variables` is set. */
50
80
  env?: Record<string, unknown>;
@@ -58,6 +88,15 @@ export interface GithubOpPipelineDoc {
58
88
  permissions: Record<string, unknown>;
59
89
  /** The `jobs:` mapping — one entry, this Op's trigger job. */
60
90
  jobsDoc: Record<string, unknown>;
91
+ /**
92
+ * The gated-apply notice job (#2243), when this Op's trigger is `push`.
93
+ * Kept out of {@link jobsDoc} so a dialect that cannot run it drops it by
94
+ * simply not copying it: the job shells to `gh` against the GitHub API and
95
+ * needs `gh` on the runner, which is the same reason the `comment` finding
96
+ * mode is refused on forgejo and gitlab (#2231). {@link emitOpPipelineYAML}
97
+ * merges it into `jobs:` for the forges that can.
98
+ */
99
+ gatedNoticeDoc?: Record<string, unknown>;
61
100
  }
62
101
 
63
102
  /** One generated file: a suggested name plus its pipeline document, pre-emission. */
@@ -75,16 +114,48 @@ function toJobName(opName: string): string {
75
114
  const DEFAULT_IMAGE = "node:22-slim";
76
115
 
77
116
  /**
78
- * Least-privilege `permissions:` for a scheduled Op's finding-mode. `report`
79
- * needs no write access; `issue` needs only `issues: write`; `pull-request`
80
- * (and `merge-request`, generated the same way when a GitLab-authored spec is
81
- * targeted at github) needs `contents: write` to push the reconcile branch
82
- * plus `pull-requests: write` to open the PR.
117
+ * Default branch assumed for a `push` trigger with no `branches` override
118
+ * (#2084). There is no generator constant for a downstream project's actual
119
+ * default branch this operates on a `ScheduledOpSpec`, not a git checkout
120
+ * so "main" is the documented default; set `trigger.branches` explicitly
121
+ * for a project whose default branch is something else (e.g. "master").
83
122
  */
84
- function permissionsFor(mode: OpFindingMode): Record<string, "read" | "write"> {
123
+ const DEFAULT_PUSH_BRANCH = "main";
124
+
125
+ /** This trigger's `on:` mapping (#2084): cron unchanged, `pull_request`/`push` new. */
126
+ function onFor(trigger: OpTrigger): Record<string, unknown> {
127
+ switch (trigger.kind) {
128
+ case "cron":
129
+ return { schedule: [{ cron: trigger.schedule }], workflow_dispatch: {} };
130
+ case "pull_request":
131
+ // No `workflow_dispatch`: a PR trigger needs no manual-dispatch escape
132
+ // hatch (revisit in review if that's wrong).
133
+ return { pull_request: trigger.branches ? { branches: trigger.branches } : {} };
134
+ case "push":
135
+ return { push: { branches: trigger.branches ?? [DEFAULT_PUSH_BRANCH] } };
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Least-privilege `permissions:` for a scheduled Op's finding-mode and
141
+ * trigger. `report` needs no write access; `issue` needs only `issues:
142
+ * write`; `pull-request` (and `merge-request`, generated the same way when a
143
+ * GitLab-authored spec is targeted at github) needs `contents: write` to
144
+ * push the reconcile branch plus `pull-requests: write` to open the PR. A
145
+ * `pull_request` trigger reports its finding as a comment on the triggering
146
+ * PR itself (#2084): any mode but `report` posts something to act on a
147
+ * finding, so on that trigger every such mode also gets `pull-requests:
148
+ * write` for the comment, whether or not its own scope already included it.
149
+ * `comment` is the mode that actually spends that grant (#2231), and it
150
+ * changes nothing in the repository, so its whole scope is `{ contents: read,
151
+ * pull-requests: write }`.
152
+ */
153
+ function permissionsForMode(mode: OpFindingMode): Record<string, "read" | "write"> {
85
154
  switch (mode) {
86
155
  case "issue":
87
156
  return { contents: "read", issues: "write" };
157
+ case "comment":
158
+ return { contents: "read", "pull-requests": "write" };
88
159
  case "pull-request":
89
160
  case "merge-request":
90
161
  return { contents: "write", "pull-requests": "write" };
@@ -93,11 +164,340 @@ function permissionsFor(mode: OpFindingMode): Record<string, "read" | "write"> {
93
164
  }
94
165
  }
95
166
 
167
+ function permissionsFor(mode: OpFindingMode, trigger: OpTrigger): Record<string, "read" | "write"> {
168
+ const base = permissionsForMode(mode);
169
+ if (trigger.kind === "pull_request" && mode !== "report") {
170
+ return { ...base, "pull-requests": "write" };
171
+ }
172
+ return base;
173
+ }
174
+
175
+ // ── The gated apply (#2243) ─────────────────────────────────────────────────
176
+
177
+ /**
178
+ * `chant run` returns 3 when a run stops at an unapproved gate. GitHub Actions
179
+ * has no neutral conclusion for a `run:` step, so a push-to-main apply that
180
+ * gates paints the branch red on every merge until someone approves. This maps
181
+ * that one outcome to success, in chant rather than in a shell wrapper
182
+ * (#2243); a failed run still returns 1 and is still red.
183
+ *
184
+ * `push` only. A cron watch and a `pull_request` plan are never gated in a way
185
+ * that should be hidden: nobody is waiting on a merge for either, and a gated
186
+ * one there is a signal, not noise.
187
+ */
188
+ const GATED_EXIT_FLAG = ["--gated-exit", "0"];
189
+
190
+ /** The id of the `chant run` step on a `push` job, so the job can publish its outputs. */
191
+ const RUN_STEP_ID = "chant-run";
192
+
193
+ /**
194
+ * Turn the run's `--json` record into step outputs, so the notice job below
195
+ * has a condition to test and a gate to name. Runs in node, which is already
196
+ * on any machine `chant` runs on — unlike `jq`, which the Op's own container
197
+ * image need not carry.
198
+ *
199
+ * Nothing is written for a run that completed, so `gated` is either the string
200
+ * `true` or absent, and the notice job's `if:` is a plain equality.
201
+ */
202
+ const GATE_OUTPUT_SCRIPT =
203
+ 'const fs=require("fs");' +
204
+ 'const r=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));' +
205
+ 'if(r.status!=="gated"||!process.env.GITHUB_OUTPUT)process.exit(0);' +
206
+ "fs.appendFileSync(process.env.GITHUB_OUTPUT," +
207
+ '`gated=true\\nop=${r.op}\\ngate=${(r.gate&&r.gate.name)||""}\\napprove=${r.approve||""}\\n`)';
208
+
209
+ /**
210
+ * The `push` job's run step: the invocation with {@link GATED_EXIT_FLAG} and
211
+ * `--json`, tee'd so the record is both in the log and on disk, then read for
212
+ * the job's outputs.
213
+ *
214
+ * `set -o pipefail` is not decoration. GitHub's default shell is `bash -e`,
215
+ * which does not set it, so a failing `chant run` piped into `tee` would come
216
+ * back as `tee`'s zero and turn a broken apply green — the exact thing this
217
+ * whole change must not do.
218
+ */
219
+ function gatedRunScript(op: string, invocation: string): string {
220
+ return [
221
+ "set -o pipefail",
222
+ 'json="${RUNNER_TEMP:-/tmp}/chant-run-' + op + '.json"',
223
+ `${invocation} | tee "$json"`,
224
+ `node -e '${GATE_OUTPUT_SCRIPT}' "$json"`,
225
+ ].join("\n");
226
+ }
227
+
228
+ /**
229
+ * The notice body's `printf` format. Kept out of {@link gateNoticeScript} so
230
+ * the shell quoting stays readable: it is single-quoted in the emitted script
231
+ * because it carries markdown backticks, which a double-quoted shell string
232
+ * would run as command substitution.
233
+ */
234
+ const NOTICE_BODY_FORMAT =
235
+ "%s\\n\\nThe `%s` apply for %s stopped at gate `%s` and is waiting for an approval. Nothing was applied." +
236
+ "\\n\\n```\\n%s --approver <you>\\n```\\n\\nThe pending fact is on `_gates/%s.jsonl` on the " +
237
+ "`chant/lifecycle` branch. Approving is a commit: push it and this workflow runs again and applies.\\n";
238
+
239
+ /**
240
+ * What the notice job posts. The sticky-comment recipe `reconcilePr`'s
241
+ * `comment` mode already uses (#2231), spelled in shell because this job runs
242
+ * no Op: a hidden marker as the body's first line, found again with
243
+ * `startswith` on the next run, PATCHed when it is there and POSTed when it is
244
+ * not. So a branch that merges three times before anyone approves carries one
245
+ * comment saying what is pending, not three.
246
+ *
247
+ * A GitHub `push` event carries no pull request, so the target is looked up:
248
+ * `repos/{repo}/commits/{sha}/pulls` is the commit's own associated-pull-request
249
+ * endpoint, exact rather than a search index, and on a merge commit it answers
250
+ * with the pull request that just merged. When it answers with nothing — a
251
+ * direct push to the branch, a merge whose commit the API does not associate —
252
+ * the notice becomes an issue instead, which is the `issue` finding mode's own
253
+ * recipe and the reason this job carries `issues: write`.
254
+ */
255
+ function gateNoticeScript(): string {
256
+ return [
257
+ 'marker="<!-- chant-gate:$CHANT_OP -->"',
258
+ "body=$(printf '" + NOTICE_BODY_FORMAT + "' " +
259
+ '"$marker" "$CHANT_OP" "$GITHUB_SHA" "$CHANT_GATE" "$CHANT_APPROVE" "$CHANT_OP")',
260
+ 'pr=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls" --jq ".[0].number // empty")',
261
+ 'if [ -z "$pr" ]; then',
262
+ ' gh issue create --title "$CHANT_OP is waiting on gate $CHANT_GATE" --body "$body"',
263
+ " exit 0",
264
+ "fi",
265
+ 'id=$(gh api "repos/$GITHUB_REPOSITORY/issues/$pr/comments" --paginate ' +
266
+ '--jq "map(select(.body | startswith(\\"$marker\\"))) | .[0].id // empty" ' +
267
+ '| grep -m1 -E "^[0-9]+$" || true)',
268
+ 'if [ -n "$id" ]; then',
269
+ ' gh api --method PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$id" -f "body=$body" --jq .html_url',
270
+ "else",
271
+ ' gh api --method POST "repos/$GITHUB_REPOSITORY/issues/$pr/comments" -f "body=$body" --jq .html_url',
272
+ "fi",
273
+ ].join("\n");
274
+ }
275
+
276
+ /**
277
+ * The follow-up job: `needs:` the apply, runs only when the apply reported
278
+ * gated, and puts the pending state somewhere other than the Actions log.
279
+ *
280
+ * No `container:`. It needs `gh`, which GitHub-hosted runner images carry and
281
+ * an Op's own image (`node:22-slim` by default) does not; it reads nothing out
282
+ * of the repository, so it also needs no checkout.
283
+ *
284
+ * Its `permissions:` are its own, replacing the workflow-level set for this
285
+ * job alone: `contents: read` for the commit-to-pull-request lookup,
286
+ * `pull-requests: write` for the sticky comment, `issues: write` for the
287
+ * fallback when the push has no pull request. Nothing wider — it opens no
288
+ * branch and merges nothing.
289
+ */
290
+ function gateNoticeJob(applyJobName: string): Record<string, unknown> {
291
+ const output = (name: string) => "${{ needs." + applyJobName + ".outputs." + name + ' }}';
292
+ return {
293
+ needs: applyJobName,
294
+ if: `needs.${applyJobName}.outputs.gated == 'true'`,
295
+ "runs-on": "ubuntu-latest",
296
+ permissions: { contents: "read", issues: "write", "pull-requests": "write" },
297
+ steps: [
298
+ {
299
+ name: "Report the pending gate",
300
+ env: {
301
+ GH_TOKEN: "${{ github.token }}",
302
+ GH_REPO: "${{ github.repository }}",
303
+ CHANT_OP: output("op"),
304
+ CHANT_GATE: output("gate"),
305
+ CHANT_APPROVE: output("approve"),
306
+ },
307
+ run: gateNoticeScript(),
308
+ },
309
+ ],
310
+ };
311
+ }
312
+
313
+ /**
314
+ * Refuse `findingMode: "comment"` on a trigger that has no pull request
315
+ * (#2231). The mode's activity reads the triggering PR out of the event
316
+ * payload at run time, so a cron- or push-triggered job carrying it would
317
+ * generate fine and then fail on every run. Refusing here names the Op, the
318
+ * mode and the trigger at build time instead.
319
+ */
320
+ function assertTriggerSupportsMode(name: string, mode: OpFindingMode, trigger: OpTrigger): void {
321
+ if (mode !== "comment" || trigger.kind === "pull_request") return;
322
+ throw new Error(
323
+ `Scheduled Op "${name}" has findingMode "comment", which posts its finding on the pull request that ` +
324
+ `triggered the run, but its trigger is "${trigger.kind}". A ${trigger.kind} run has no pull request ` +
325
+ `to comment on. Give it a { kind: "pull_request" } trigger, or use findingMode "issue".`,
326
+ );
327
+ }
328
+
329
+ /**
330
+ * Every scope `GITHUB_TOKEN` accepts in a workflow's `permissions:` mapping,
331
+ * kebab-cased as GitHub spells them. An additive scope outside this set is
332
+ * refused by name rather than emitted: GitHub ignores an unknown key, so
333
+ * `id_token` or `idToken` would generate a workflow that looks like it grants
334
+ * OIDC and hands the run no token at all.
335
+ */
336
+ const GITHUB_TOKEN_SCOPES = new Set([
337
+ "actions",
338
+ "attestations",
339
+ "checks",
340
+ "contents",
341
+ "deployments",
342
+ "discussions",
343
+ "id-token",
344
+ "issues",
345
+ "models",
346
+ "packages",
347
+ "pages",
348
+ "pull-requests",
349
+ "repository-projects",
350
+ "security-events",
351
+ "statuses",
352
+ ]);
353
+
354
+ /**
355
+ * Refs that name an action repository's own default branch. A generated
356
+ * workflow is committed once and then re-run unattended, often over a cloud
357
+ * role, so "whatever was pushed to that repo last" is not a version — the
358
+ * code that assumes the role can change between the run somebody reviewed and
359
+ * the next one. A release channel the action's author cuts deliberately (`v6`,
360
+ * `v6.2.4`, `stable`) or a commit sha is a version, and both pass: this repo's
361
+ * own workflows pin `actions/checkout@v6` and `dtolnay/rust-toolchain@stable`
362
+ * and name no default branch anywhere.
363
+ */
364
+ const DEFAULT_BRANCH_REFS = new Set(["main", "master", "head", "default"]);
365
+
366
+ /** `owner/repo` or `owner/repo/subpath`, then `@ref`. */
367
+ const USES_PATTERN = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)((?:\/[A-Za-z0-9_.-]+)*)@([^\s@]+)$/;
368
+
369
+ /**
370
+ * Validate a spec's `setup` list (#2242). A `run` entry needs a non-empty
371
+ * line and nothing else. A `uses` entry has to be a pinned
372
+ * `owner/repo[/subpath]@ref`: no bare `owner/repo`, since an unpinned action
373
+ * resolves to its default branch, and no ref in {@link DEFAULT_BRANCH_REFS}
374
+ * for the same reason spelled out loud. Local (`./path`) and container
375
+ * (`docker://`) refs are refused too — they are legal GitHub Actions, but the
376
+ * generator emits a workflow into a repository it has never seen, so it
377
+ * cannot know a local path resolves there.
378
+ */
379
+ export function assertSetupSteps(name: string, setup: OpSetupStep[]): void {
380
+ setup.forEach((step, index) => {
381
+ const where = `Scheduled Op "${name}" setup step ${index + 1}`;
382
+ if ("uses" in step) {
383
+ const ref = step.uses.trim();
384
+ const match = USES_PATTERN.exec(ref);
385
+ if (!match) {
386
+ throw new Error(
387
+ `${where} has \`uses: "${step.uses}"\`, which is not a pinned action reference. ` +
388
+ `Write it as owner/repo@ref (optionally owner/repo/subpath@ref), e.g. ` +
389
+ `"aws-actions/configure-aws-credentials@v6". A local "./path" or "docker://" ref is not ` +
390
+ `accepted here: this generator emits a workflow into a repository it cannot inspect, so it ` +
391
+ `has no way to tell whether such a ref resolves there.`,
392
+ );
393
+ }
394
+ const gitRef = match[4];
395
+ if (DEFAULT_BRANCH_REFS.has(gitRef.toLowerCase())) {
396
+ throw new Error(
397
+ `${where} pins \`uses: "${step.uses}"\` to "${gitRef}", the action repository's own default ` +
398
+ `branch, which names whatever was pushed there last rather than a version. A generated ` +
399
+ `workflow is committed once and re-run unattended, often over a cloud role, so pin a release ` +
400
+ `tag or a commit sha instead (e.g. "${match[1]}/${match[2]}@v1" or "@<40-char sha>").`,
401
+ );
402
+ }
403
+ return;
404
+ }
405
+ if (step.run.trim() === "") {
406
+ throw new Error(`${where} has an empty \`run\` line. Give it a command, or drop the entry.`);
407
+ }
408
+ });
409
+ }
410
+
411
+ /**
412
+ * Merge a spec's additive `permissions` over the finding-mode's own set
413
+ * (#2242), refusing by name anything that is not strictly additive:
414
+ *
415
+ * - a blanket `write-all`/`read-all`, in either the key or the value
416
+ * position, which is the exact thing {@link permissionsForMode} exists to
417
+ * avoid;
418
+ * - a scope GitHub does not define ({@link GITHUB_TOKEN_SCOPES}), because
419
+ * GitHub ignores the key and the run silently gets nothing;
420
+ * - a scope the mode already grants, at any value — additive means additive,
421
+ * so this can neither downgrade `contents: write` to read nor restate it.
422
+ * A mode whose set is wrong is fixed by changing the mode, where the
423
+ * scope and the behavior that spends it stay together;
424
+ * - `pull-requests: write` on a trigger with no pull request. Pull-request
425
+ * access is what the finding-modes own: `pull-request` grants it together
426
+ * with the `contents: write` needed to push the branch first, and
427
+ * `comment` grants it on the one trigger that carries a pull request to
428
+ * comment on. Adding it beside a mode that posts nothing, on a cron or
429
+ * push run, grants write access no step in the generated job can spend.
430
+ */
431
+ export function mergePermissions(
432
+ name: string,
433
+ base: Record<string, "read" | "write">,
434
+ additive: Record<string, "read" | "write">,
435
+ trigger: OpTrigger,
436
+ ): Record<string, "read" | "write"> {
437
+ const merged: Record<string, "read" | "write"> = { ...base };
438
+ for (const [rawScope, value] of Object.entries(additive)) {
439
+ const scope = rawScope.trim();
440
+ const where = `Scheduled Op "${name}" adds permission "${scope}: ${value}"`;
441
+ if (scope === "write-all" || scope === "read-all" || String(value).endsWith("-all")) {
442
+ throw new Error(
443
+ `${where}, a blanket grant. \`permissions\` on a scheduled Op is additive over the ` +
444
+ `least-privilege set its finding-mode needs, one named scope at a time. Name the scopes the ` +
445
+ `job actually spends (e.g. { "id-token": "write" }).`,
446
+ );
447
+ }
448
+ if (!GITHUB_TOKEN_SCOPES.has(scope)) {
449
+ throw new Error(
450
+ `${where}, which is not a GITHUB_TOKEN permission scope. GitHub ignores an unrecognized key, so ` +
451
+ `this would emit a workflow that reads as granted and hands the run nothing. Known scopes: ` +
452
+ `${[...GITHUB_TOKEN_SCOPES].sort().join(", ")}.`,
453
+ );
454
+ }
455
+ if (scope in base) {
456
+ throw new Error(
457
+ `${where}, but its finding-mode already grants "${scope}: ${base[scope]}". These permissions are ` +
458
+ `additive only — they never replace, widen or downgrade a scope the mode computed. Change the ` +
459
+ `Op's findingMode if that set is wrong, and add only scopes no mode grants (e.g. "id-token").`,
460
+ );
461
+ }
462
+ if (scope === "pull-requests" && trigger.kind !== "pull_request") {
463
+ throw new Error(
464
+ `${where}, but this Op's trigger is "${trigger.kind}", which carries no pull request. Pull-request ` +
465
+ `write access belongs to a finding-mode: "pull-request" grants it with the contents: write its ` +
466
+ `branch push needs, and "comment" grants it on the pull_request trigger. Set findingMode instead ` +
467
+ `of adding the scope here.`,
468
+ );
469
+ }
470
+ merged[scope] = value;
471
+ }
472
+ return merged;
473
+ }
474
+
475
+ /** Emit one setup entry as a GitHub Actions step. */
476
+ function setupStepDoc(step: OpSetupStep): Record<string, unknown> {
477
+ if ("uses" in step) {
478
+ return {
479
+ uses: step.uses,
480
+ ...(step.with && Object.keys(step.with).length > 0 ? { with: step.with } : {}),
481
+ ...(step.env && Object.keys(step.env).length > 0 ? { env: step.env } : {}),
482
+ };
483
+ }
484
+ return {
485
+ run: step.run,
486
+ ...(step.env && Object.keys(step.env).length > 0 ? { env: step.env } : {}),
487
+ };
488
+ }
489
+
96
490
  /**
97
- * Build one `GithubOpPipelineDoc` per scheduled Op: cron trigger,
98
- * least-privilege `permissions:` for its finding-mode, one job that runs
99
- * `chant run <name>`. Throws nothing every `ScheduledOpSpec` is independent,
100
- * unlike the component generator there is no shared graph to resolve.
491
+ * Build one `GithubOpPipelineDoc` per scheduled Op: its trigger, its `setup`
492
+ * steps, least-privilege `permissions:` for its finding-mode plus whatever
493
+ * the spec adds, one job that runs `chant run <name>`. Every
494
+ * `ScheduledOpSpec` is independent — unlike the component generator there is
495
+ * no shared graph to resolve — so the only thing this refuses is a spec that
496
+ * contradicts itself: no trigger at all (`resolveOpTrigger`), `findingMode:
497
+ * "comment"` on a trigger that has no pull request ({@link
498
+ * assertTriggerSupportsMode}), an unpinned `setup` action ({@link
499
+ * assertSetupSteps}), or a `permissions` entry that is not additive ({@link
500
+ * mergePermissions}).
101
501
  */
102
502
  export function buildGithubOpPipelineDocs(
103
503
  ops: ScheduledOpSpec[],
@@ -113,8 +513,10 @@ export function buildGithubOpPipelineDocs(
113
513
 
114
514
  for (const spec of ops) {
115
515
  const findingMode = spec.findingMode ?? "report";
516
+ const trigger = resolveOpTrigger(spec);
517
+ assertTriggerSupportsMode(spec.name, findingMode, trigger);
116
518
  const jobName = toJobName(spec.name);
117
- jobs.push({ jobName, op: spec.name, schedule: spec.schedule, findingMode });
519
+ jobs.push({ jobName, op: spec.name, trigger, findingMode });
118
520
 
119
521
  const runParts = runCommand.map((part) => part.replace("{name}", spec.name));
120
522
 
@@ -123,25 +525,58 @@ export function buildGithubOpPipelineDocs(
123
525
  const stepEnv: Record<string, string> = { GITHUB_TOKEN: "${{ github.token }}" };
124
526
  if (findingMode !== "report") stepEnv.GH_TOKEN = "${{ github.token }}";
125
527
 
528
+ const setup = spec.setup ?? [];
529
+ assertSetupSteps(spec.name, setup);
530
+
531
+ // A `push` job is the one that has to survive a gate (#2243): the apply
532
+ // runs with `--gated-exit 0` so a pending approval is a green run, and
533
+ // publishes what it stopped on as job outputs for the notice job below.
534
+ // Every other trigger keeps the plain one-line invocation it always had.
535
+ const gated = trigger.kind === "push";
536
+ const invocation = gated
537
+ ? [...runParts, ...GATED_EXIT_FLAG, "--json"].join(" ")
538
+ : runParts.join(" ");
539
+
126
540
  const steps: Array<Record<string, unknown>> = [{ uses: "actions/checkout@v4" }];
541
+ for (const step of setup) steps.push(setupStepDoc(step));
127
542
  for (const line of beforeScript) steps.push({ run: line });
128
- steps.push({ run: runParts.join(" "), env: stepEnv });
543
+ steps.push(
544
+ gated
545
+ ? { id: RUN_STEP_ID, run: gatedRunScript(spec.name, invocation), env: stepEnv }
546
+ : { run: invocation, env: stepEnv },
547
+ );
129
548
  for (const line of extraScript) steps.push({ run: line });
130
549
 
131
550
  const doc: GithubOpPipelineDoc = {
132
- on: { schedule: [{ cron: spec.schedule }], workflow_dispatch: {} },
551
+ on: onFor(trigger),
133
552
  ...(options.variables && Object.keys(options.variables).length > 0 ? { env: options.variables } : {}),
134
553
  // One run at a time per Op — a slow audit must not overlap its own next
135
554
  // scheduled trigger.
136
555
  concurrency: { group: jobName, "cancel-in-progress": false },
137
- permissions: permissionsFor(findingMode),
556
+ permissions: mergePermissions(
557
+ spec.name,
558
+ permissionsFor(findingMode, trigger),
559
+ spec.permissions ?? {},
560
+ trigger,
561
+ ),
138
562
  jobsDoc: {
139
563
  [jobName]: {
140
564
  "runs-on": "ubuntu-latest",
141
565
  container: image,
566
+ ...(gated
567
+ ? {
568
+ outputs: Object.fromEntries(
569
+ ["gated", "op", "gate", "approve"].map((name) => [
570
+ name,
571
+ `\${{ steps.${RUN_STEP_ID}.outputs.${name} }}`,
572
+ ]),
573
+ ),
574
+ }
575
+ : {}),
142
576
  steps,
143
577
  },
144
578
  },
579
+ ...(gated ? { gatedNoticeDoc: { [`${jobName}-gate-notice`]: gateNoticeJob(jobName) } } : {}),
145
580
  };
146
581
 
147
582
  files.push({ name: `${spec.name}.yml`, doc });
@@ -161,7 +596,10 @@ export function emitOpPipelineYAML(doc: GithubOpPipelineDoc): string {
161
596
  if (doc.env && Object.keys(doc.env).length > 0) sections.push("env:" + emitYAML(doc.env, 1));
162
597
  sections.push("concurrency:" + emitYAML(doc.concurrency, 1));
163
598
  if (Object.keys(doc.permissions).length > 0) sections.push("permissions:" + emitYAML(doc.permissions, 1));
164
- sections.push("jobs:" + emitYAML(doc.jobsDoc, 1));
599
+ // The gated-apply notice job rides in `jobs:` beside the Op's own job, but
600
+ // is carried separately on the doc so a dialect that cannot run it (forgejo,
601
+ // whose runner has no `gh` pointed at its own instance) drops it by omission.
602
+ sections.push("jobs:" + emitYAML({ ...doc.jobsDoc, ...(doc.gatedNoticeDoc ?? {}) }, 1));
165
603
  return sections.join("\n\n") + "\n";
166
604
  }
167
605
 
@@ -222,7 +222,7 @@ describe("generateGithubPipeline: a cross-cutting change is one generator edit,
222
222
  test("changing the trigger command (runCommand) updates every job's script uniformly", () => {
223
223
  const components = pilotComponents();
224
224
  const result = generateGithubPipeline(components, {
225
- runCommand: ["chant", "run", "--components", "{name}", "--env", "staging", "--temporal"],
225
+ runCommand: ["chant", "run", "--components", "{name}", "--env", "staging", "--verbose"],
226
226
  });
227
227
  const jobs = parsedJobs(result.yaml);
228
228
 
@@ -230,7 +230,7 @@ describe("generateGithubPipeline: a cross-cutting change is one generator edit,
230
230
  const lines = runLines(jobs[job.jobName]);
231
231
  // The runCommand prefix reflects in every job; output-threading flags
232
232
  // (--seed-outputs/--dump-outputs) may be appended per the dependency graph.
233
- expect(lines[0].startsWith(`chant run --components ${job.component} --env staging --temporal`)).toBe(true);
233
+ expect(lines[0].startsWith(`chant run --components ${job.component} --env staging --verbose`)).toBe(true);
234
234
  }
235
235
  });
236
236
 
@@ -79,6 +79,28 @@ describe("PrPlanReport composite (#1983)", () => {
79
79
  expect(Math.max(...credIndexes)).toBeLessThan(planIndex);
80
80
  });
81
81
 
82
+ // #2236 — `gh api`'s `-F/--field` is the flag that expands a leading `@`
83
+ // into the file's contents; `-f/--raw-field` adds the parameter as a literal
84
+ // string, so the `-f` form this composite shipped with posted a comment
85
+ // whose body was the eight characters `@plan.md`. Nothing asserted the flag,
86
+ // which is how it survived, so both the emitted script and the serialized
87
+ // workflow are pinned here.
88
+ test("the sticky-comment script reads the body with -F, never -f (#2236)", () => {
89
+ const { job } = PrPlanReport({ environment: "prod" });
90
+ const postStep = steps(job).find((s) => s.props.name === "Post or update PR comment")!;
91
+ const run = postStep.props.run!;
92
+ expect(run).toContain('gh api -X PATCH "repos/$REPO/issues/comments/$comment_id" -F body=@plan.md');
93
+ expect(run).toContain('gh api -X POST "repos/$REPO/issues/$PR_NUMBER/comments" -F body=@plan.md');
94
+ expect(run).not.toContain("-f body=@");
95
+ // The plan step writes the marker as plan.md's first line, so the body the
96
+ // -F read now starts with `$MARKER` and the jq `startswith` search finds
97
+ // the comment on the next push. Under `-f` it never could: the body was
98
+ // `@plan.md`, so every run posted a new comment instead of patching.
99
+ const planStep = steps(job).find((s) => s.props.name === "Plan prod")!;
100
+ expect(planStep.props.run).toContain('{ printf \'%s\\n\\n\' "$MARKER";');
101
+ expect(planStep.props.run).toContain("> plan.md");
102
+ });
103
+
82
104
  test("the emitted workflow passes the github lexicon's own lint — no errors, pinned actions included", () => {
83
105
  const { job } = PrPlanReport({ environment: "prod", before: ["aws sts get-caller-identity"] });
84
106
  const workflow = new Workflow({
@@ -90,6 +112,10 @@ describe("PrPlanReport composite (#1983)", () => {
90
112
  ) as SerializerResult;
91
113
  const yaml = typeof result === "string" ? result : result.primary!;
92
114
  expect(yaml).toContain("Post or update PR comment");
115
+ // The flag survives serialization too, not just the composite's script
116
+ // string (#2236).
117
+ expect(yaml).toContain("-F body=@plan.md");
118
+ expect(yaml).not.toContain("-f body=@");
93
119
 
94
120
  const ctx: PostSynthContext = {
95
121
  outputs: new Map([["github", yaml]]),