@intentius/chant-lexicon-github 0.58.0 → 0.60.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.
@@ -21,16 +21,44 @@
21
21
  * branch);
22
22
  * - declares only the `permissions:` its `findingMode` (and, for a
23
23
  * `pull_request` trigger, whether that mode posts a comment) needs —
24
- * `report` stays read-only, `issue`/`pull-request` add the write scope
25
- * the Op's own activity uses (`gh issue create` / `gh pr create`, see
26
- * `@intentius/chant/op`'s `reconcilePr` activity) — never a blanket
27
- * `write-all`;
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);
28
31
  * - runs exactly one invocation, `chant run <name>` by default — never
29
32
  * inlined audit/reconcile logic. The finding-mode itself is already baked
30
33
  * into the Op's own activity args at build time by the composite that
31
- * 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.
49
+ *
50
+ * A third widens it the other way (#2257): a spec's `environment` emits
51
+ * `environment:` on the Op's own job, which is how a GitHub environment's
52
+ * protection rules — required reviewers above all — come to hold a generated
53
+ * apply. That is a second gate beside chant's own, not a replacement for it:
54
+ * the reviewer stops the job before any step runs, chant's gate ledger
55
+ * (#2119) stops the apply inside a run that already started, and the two
56
+ * compose in either combination. It costs {@link permissionsFor} nothing —
57
+ * environment protection is repository configuration, not a token scope — and
58
+ * {@link assertEnvironment} refuses only what would emit as configured and
59
+ * bind nothing.
32
60
  */
33
- import type { ComponentPipelineOptions as GenerateGithubOpOptions, OpPipelineJob, OpPipelineResult as GenerateGithubOpResult, ScheduledOpSpec } from "@intentius/chant/lexicon";
61
+ import type { ComponentPipelineOptions as GenerateGithubOpOptions, OpEnvironment, OpPipelineJob, OpPipelineResult as GenerateGithubOpResult, OpSetupStep, OpTrigger, ScheduledOpSpec } from "@intentius/chant/lexicon";
34
62
  export type { GenerateGithubOpOptions, GenerateGithubOpResult };
35
63
  /**
36
64
  * The structured pipeline document behind one generated file, before YAML
@@ -40,6 +68,15 @@ export type { GenerateGithubOpOptions, GenerateGithubOpResult };
40
68
  * `./generate-pipeline.ts`'s `GithubPipelineDoc` split.
41
69
  */
42
70
  export interface GithubOpPipelineDoc {
71
+ /**
72
+ * Comment lines emitted above the document, `#` prefix included, when a
73
+ * dialect has something to say about what it could not carry across
74
+ * (#2257). Empty on github, which drops nothing; the forgejo dialect uses
75
+ * it to name the `environment:` its runner has no concept of, so the fact
76
+ * that a reviewer gate did not survive is readable in the generated file
77
+ * rather than only in a build warning.
78
+ */
79
+ header?: string[];
43
80
  /**
44
81
  * The `on:` trigger mapping, per {@link ScheduledOpSpec}'s trigger kind
45
82
  * (#2084): `{ schedule, workflow_dispatch }` for cron, `{ pull_request }`
@@ -58,6 +95,17 @@ export interface GithubOpPipelineDoc {
58
95
  permissions: Record<string, unknown>;
59
96
  /** The `jobs:` mapping — one entry, this Op's trigger job. */
60
97
  jobsDoc: Record<string, unknown>;
98
+ /**
99
+ * The gated-apply notice job (#2243), when this Op's trigger is `push`.
100
+ * Kept out of {@link jobsDoc} so a dialect that cannot run it drops it by
101
+ * simply not copying it: the job shells to `gh` against the GitHub API and
102
+ * needs `gh` on the runner, which is the same reason the `comment` finding
103
+ * mode is refused on forgejo (#2231). {@link emitOpPipelineYAML} merges it
104
+ * into `jobs:` for the forges that can. GitLab reaches the same outcome
105
+ * without this job at all: its push job writes the pending block to an
106
+ * artifact instead (#2256).
107
+ */
108
+ gatedNoticeDoc?: Record<string, unknown>;
61
109
  }
62
110
  /** One generated file: a suggested name plus its pipeline document, pre-emission. */
63
111
  export interface GithubOpPipelineFile {
@@ -66,10 +114,60 @@ export interface GithubOpPipelineFile {
66
114
  doc: GithubOpPipelineDoc;
67
115
  }
68
116
  /**
69
- * Build one `GithubOpPipelineDoc` per scheduled Op: cron trigger,
70
- * least-privilege `permissions:` for its finding-mode, one job that runs
71
- * `chant run <name>`. Throws nothing every `ScheduledOpSpec` is independent,
72
- * unlike the component generator there is no shared graph to resolve.
117
+ * Validate a spec's `setup` list (#2242). A `run` entry needs a non-empty
118
+ * line and nothing else. A `uses` entry has to be a pinned
119
+ * `owner/repo[/subpath]@ref`: no bare `owner/repo`, since an unpinned action
120
+ * resolves to its default branch, and no ref in {@link DEFAULT_BRANCH_REFS}
121
+ * for the same reason spelled out loud. Local (`./path`) and container
122
+ * (`docker://`) refs are refused too — they are legal GitHub Actions, but the
123
+ * generator emits a workflow into a repository it has never seen, so it
124
+ * cannot know a local path resolves there.
125
+ */
126
+ export declare function assertSetupSteps(name: string, setup: OpSetupStep[]): void;
127
+ /**
128
+ * Validate a spec's `environment` (#2257). GitHub creates an environment it
129
+ * has never seen on first use rather than failing the run, and an environment
130
+ * created that way carries no protection rules at all — so a job can name one
131
+ * and read as gated while being gated by nothing. Neither this generator nor
132
+ * GitHub can tell those apart at build time (the environment and its
133
+ * reviewers are repository configuration, not workflow content), which is why
134
+ * what is refused here is only the shape that could never bind: a name that
135
+ * is blank, and a `url` that is neither absolute nor an expression the forge
136
+ * resolves. The rest is the README's job to say out loud.
137
+ */
138
+ export declare function assertEnvironment(name: string, environment: OpEnvironment): void;
139
+ /**
140
+ * Merge a spec's additive `permissions` over the finding-mode's own set
141
+ * (#2242), refusing by name anything that is not strictly additive:
142
+ *
143
+ * - a blanket `write-all`/`read-all`, in either the key or the value
144
+ * position, which is the exact thing {@link permissionsForMode} exists to
145
+ * avoid;
146
+ * - a scope GitHub does not define ({@link GITHUB_TOKEN_SCOPES}), because
147
+ * GitHub ignores the key and the run silently gets nothing;
148
+ * - a scope the mode already grants, at any value — additive means additive,
149
+ * so this can neither downgrade `contents: write` to read nor restate it.
150
+ * A mode whose set is wrong is fixed by changing the mode, where the
151
+ * scope and the behavior that spends it stay together;
152
+ * - `pull-requests: write` on a trigger with no pull request. Pull-request
153
+ * access is what the finding-modes own: `pull-request` grants it together
154
+ * with the `contents: write` needed to push the branch first, and
155
+ * `comment` grants it on the one trigger that carries a pull request to
156
+ * comment on. Adding it beside a mode that posts nothing, on a cron or
157
+ * push run, grants write access no step in the generated job can spend.
158
+ */
159
+ export declare function mergePermissions(name: string, base: Record<string, "read" | "write">, additive: Record<string, "read" | "write">, trigger: OpTrigger): Record<string, "read" | "write">;
160
+ /**
161
+ * Build one `GithubOpPipelineDoc` per scheduled Op: its trigger, its `setup`
162
+ * steps, least-privilege `permissions:` for its finding-mode plus whatever
163
+ * the spec adds, one job that runs `chant run <name>`. Every
164
+ * `ScheduledOpSpec` is independent — unlike the component generator there is
165
+ * no shared graph to resolve — so the only thing this refuses is a spec that
166
+ * contradicts itself: no trigger at all (`resolveOpTrigger`), `findingMode:
167
+ * "comment"` on a trigger that has no pull request ({@link
168
+ * assertTriggerSupportsMode}), an unpinned `setup` action ({@link
169
+ * assertSetupSteps}), or a `permissions` entry that is not additive ({@link
170
+ * mergePermissions}).
73
171
  */
74
172
  export declare function buildGithubOpPipelineDocs(ops: ScheduledOpSpec[], options?: GenerateGithubOpOptions): {
75
173
  files: GithubOpPipelineFile[];
@@ -1 +1 @@
1
- {"version":3,"file":"generate-op-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAIH,OAAO,KAAK,EACV,wBAAwB,IAAI,uBAAuB,EAEnD,aAAa,EACb,gBAAgB,IAAI,sBAAsB,EAE1C,eAAe,EAChB,MAAM,0BAA0B,CAAC;AAElC,YAAY,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,CAAC;AAEhE;;;;;;GAMG;AACH,MAAM,WAAW,mBAAmB;IAClC;;;;OAIG;IACH,EAAE,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC5B,2DAA2D;IAC3D,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9B,6DAA6D;IAC7D,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC;;;;OAIG;IACH,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC,8DAA8D;IAC9D,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAClC;AAED,qFAAqF;AACrF,MAAM,WAAW,oBAAoB;IACnC,4FAA4F;IAC5F,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,mBAAmB,CAAC;CAC1B;AA+DD;;;;;GAKG;AACH,wBAAgB,yBAAyB,CACvC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,GACpC;IAAE,KAAK,EAAE,oBAAoB,EAAE,CAAC;IAAC,IAAI,EAAE,aAAa,EAAE,CAAA;CAAE,CA+C1D;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,mBAAmB,GAAG,MAAM,CAQnE;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CACtC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,GACpC,sBAAsB,CAMxB"}
1
+ {"version":3,"file":"generate-op-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2DG;AAIH,OAAO,KAAK,EACV,wBAAwB,IAAI,uBAAuB,EACnD,aAAa,EAEb,aAAa,EACb,gBAAgB,IAAI,sBAAsB,EAC1C,WAAW,EACX,SAAS,EACT,eAAe,EAChB,MAAM,0BAA0B,CAAC;AAElC,YAAY,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,CAAC;AAEhE;;;;;;GAMG;AACH,MAAM,WAAW,mBAAmB;IAClC;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB;;;;OAIG;IACH,EAAE,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC5B,2DAA2D;IAC3D,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9B,6DAA6D;IAC7D,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC;;;;OAIG;IACH,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC,8DAA8D;IAC9D,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC;;;;;;;;;OASG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC1C;AAED,qFAAqF;AACrF,MAAM,WAAW,oBAAoB;IACnC,4FAA4F;IAC5F,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,mBAAmB,CAAC;CAC1B;AAsQD;;;;;;;;;GASG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,EAAE,GAAG,IAAI,CA8BzE;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,aAAa,GAAG,IAAI,CAwBhF;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,EACtC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,EAC1C,OAAO,EAAE,SAAS,GACjB,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,CAqClC;AA6BD;;;;;;;;;;;GAWG;AACH,wBAAgB,yBAAyB,CACvC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,GACpC;IAAE,KAAK,EAAE,oBAAoB,EAAE,CAAC;IAAC,IAAI,EAAE,aAAa,EAAE,CAAA;CAAE,CAuF1D;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,mBAAmB,GAAG,MAAM,CAenE;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CACtC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,GACpC,sBAAsB,CAMxB"}
@@ -1 +1 @@
1
- {"version":3,"file":"pr-plan-report.d.ts","sourceRoot":"","sources":["../../src/composites/pr-plan-report.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,GAAG,EAAqB,MAAM,oBAAoB,CAAC;AAI5D,MAAM,WAAW,iBAAiB;IAChC;;;OAGG;IACH,WAAW,EAAE,MAAM,CAAC;IACpB,4EAA4E;IAC5E,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,kEAAkE;IAClE,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,+CAA+C;IAC/C,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,iEAAiE;IACjE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,+DAA+D;IAC/D,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB;;;;;OAKG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,6DAA6D;IAC7D,QAAQ,CAAC,EAAE;QACT,GAAG,CAAC,EAAE,OAAO,CAAC,qBAAqB,CAAC,OAAO,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;KACrD,CAAC;CACH;AAoBD;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,YAAY;;EAkEP,CAAC"}
1
+ {"version":3,"file":"pr-plan-report.d.ts","sourceRoot":"","sources":["../../src/composites/pr-plan-report.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,GAAG,EAAqB,MAAM,oBAAoB,CAAC;AAI5D,MAAM,WAAW,iBAAiB;IAChC;;;OAGG;IACH,WAAW,EAAE,MAAM,CAAC;IACpB,4EAA4E;IAC5E,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,kEAAkE;IAClE,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,+CAA+C;IAC/C,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,iEAAiE;IACjE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,+DAA+D;IAC/D,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB;;;;;OAKG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,6DAA6D;IAC7D,QAAQ,CAAC,EAAE;QACT,GAAG,CAAC,EAAE,OAAO,CAAC,qBAAqB,CAAC,OAAO,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;KACrD,CAAC;CACH;AAyBD;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,YAAY;;EAkEP,CAAC"}
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "dd75f9469a0b8058a7620ebef4b937fd9a6a60e0912e3afd1a0e2a3631685773",
4
+ "manifest.json": "bc5ba7395f22f836b8889f84a6b6b3ec39c4158d6dc8967f5d1315a578d99f32",
5
5
  "meta.json": "dc3977afc2b6ddc4904de906d1b1e448b786b2aced9d82f8eade2ff915203344",
6
6
  "types/index.d.ts": "f207946d7ab52f712d0c09995366440a6bba8b39358057b0da38ff9c0acca429",
7
7
  "rules/deprecated-action-version.ts": "d41e6e532ab7f623af1bee4ac5279fcb2baada7defa1c5d022a5bc71983e8797",
@@ -77,5 +77,5 @@
77
77
  "skills/chant-github-patterns.md": "bb3abef289a8fdfcf07d6bb2d7289dcb2f38bc0cb0321ea320b78b45a6f548c0",
78
78
  "skills/chant-github-security.md": "aab111cb0871cad30281ce48d7da23663689619351029219e2be019a1a61e394"
79
79
  },
80
- "composite": "f6bd3cec37abf68646e70479861823f42af9002b807edda16bf4c873c0e3273a"
80
+ "composite": "b137c6aa19b27d166416427a565ce61b4af0e16fbeaa27253f83470af045a7b6"
81
81
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github",
3
- "version": "0.58.0",
3
+ "version": "0.60.0",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "GitHub",
6
6
  "intrinsics": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-github",
3
- "version": "0.58.0",
3
+ "version": "0.60.0",
4
4
  "description": "GitHub Actions lexicon for chant — declarative IaC in TypeScript",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -61,7 +61,7 @@
61
61
  "typescript": "^5.9.3"
62
62
  },
63
63
  "peerDependencies": {
64
- "@intentius/chant": "^0.58.0",
64
+ "@intentius/chant": "^0.60.0",
65
65
  "typescript": "^5.9.3"
66
66
  }
67
67
  }
@@ -6,7 +6,7 @@
6
6
  * (parses back via `../yaml.ts`'s `parseYAML`) with a `schedule` +
7
7
  * `workflow_dispatch` trigger and one job.
8
8
  * 2. `permissions:` is least-privilege per finding-mode — read-only for
9
- * `report`, scoped write for `issue`/`pull-request`.
9
+ * `report`, scoped write for `issue`/`comment`/`pull-request`.
10
10
  * 3. A cross-cutting generator change (extraScript/beforeScript/runCommand)
11
11
  * is a single edit reflected in every generated file.
12
12
  */
@@ -18,6 +18,7 @@ import type { ScheduledOpSpec } from "@intentius/chant/lexicon";
18
18
 
19
19
  interface ParsedStep {
20
20
  name?: string;
21
+ id?: string;
21
22
  uses?: string;
22
23
  run?: string;
23
24
  env?: Record<string, string>;
@@ -26,6 +27,11 @@ interface ParsedStep {
26
27
  interface ParsedJob {
27
28
  "runs-on"?: string;
28
29
  container?: string;
30
+ environment?: Record<string, string>;
31
+ needs?: string;
32
+ if?: string;
33
+ permissions?: Record<string, string>;
34
+ outputs?: Record<string, string>;
29
35
  steps: ParsedStep[];
30
36
  }
31
37
 
@@ -100,6 +106,23 @@ describe("generateGithubOpPipeline: least-privilege permissions per finding-mode
100
106
  expect(doc.permissions).toEqual({ contents: "write", "pull-requests": "write" });
101
107
  });
102
108
 
109
+ test("comment mode is exactly contents: read + pull-requests: write (#2231)", () => {
110
+ // The least-privilege set a plan-on-PR job wants, and the one no mode
111
+ // could produce before this one existed: `issue` adds `issues: write`,
112
+ // `pull-request` widens `contents` to write, `report` gets no forge write
113
+ // scope at all. `toEqual` is what makes this an exact set rather than a
114
+ // containment check.
115
+ const result = generateGithubOpPipeline([
116
+ { name: "app-plan", trigger: { kind: "pull_request", branches: ["main"] }, findingMode: "comment" },
117
+ ]);
118
+ const doc = parseFile(result.files[0].yaml);
119
+ expect(doc.permissions).toEqual({ contents: "read", "pull-requests": "write" });
120
+
121
+ // The activity shells to `gh`, so the CLI's own token variable rides too.
122
+ const runStep = doc.jobs!["app-plan"].steps.find((s) => typeof s.run === "string")!;
123
+ expect(runStep.env).toEqual({ GITHUB_TOKEN: "${{ github.token }}", GH_TOKEN: "${{ github.token }}" });
124
+ });
125
+
103
126
  test("defaults to report (read-only) when findingMode is omitted", () => {
104
127
  const result = generateGithubOpPipeline([{ name: "actions-audit", schedule: "0 6 * * *" }]);
105
128
  expect(result.jobs[0].findingMode).toBe("report");
@@ -152,6 +175,24 @@ describe("generateGithubOpPipeline: trigger kinds (#2084)", () => {
152
175
  expect(doc.permissions).toEqual({ contents: "read" });
153
176
  });
154
177
 
178
+ test("comment mode is refused by name on a cron trigger (#2231)", () => {
179
+ const specs: ScheduledOpSpec[] = [
180
+ { name: "app-plan", schedule: "0 6 * * *", findingMode: "comment" },
181
+ ];
182
+ expect(() => generateGithubOpPipeline(specs)).toThrow(
183
+ /findingMode "comment".*trigger is "cron".*no pull request/s,
184
+ );
185
+ });
186
+
187
+ test("comment mode is refused by name on a push trigger (#2231)", () => {
188
+ const specs: ScheduledOpSpec[] = [
189
+ { name: "app-apply", trigger: { kind: "push", branches: ["main"] }, findingMode: "comment" },
190
+ ];
191
+ expect(() => generateGithubOpPipeline(specs)).toThrow(
192
+ /Scheduled Op "app-apply".*findingMode "comment".*trigger is "push"/s,
193
+ );
194
+ });
195
+
155
196
  test("push trigger: filters to branches", () => {
156
197
  const specs: ScheduledOpSpec[] = [
157
198
  { name: "tf-apply", trigger: { kind: "push", branches: ["release"] } },
@@ -257,3 +298,347 @@ describe("generateGithubOpPipeline: the Op's own schedule (#2120)", () => {
257
298
  );
258
299
  });
259
300
  });
301
+
302
+ /**
303
+ * The two per-Op options from #2242: `setup` steps between the checkout and
304
+ * the `beforeScript` lines, and `permissions` merged additively over the
305
+ * finding-mode's own set. Together they are what makes an OIDC job
306
+ * expressible — `aws-actions/configure-aws-credentials` is a `uses:` step,
307
+ * and no finding-mode grants `id-token: write`.
308
+ */
309
+ describe("generateGithubOpPipeline: setup steps and additive permissions (#2242)", () => {
310
+ const OIDC_SPEC: ScheduledOpSpec = {
311
+ name: "app-apply",
312
+ trigger: { kind: "push", branches: ["main"] },
313
+ setup: [
314
+ {
315
+ uses: "aws-actions/configure-aws-credentials@v6",
316
+ with: { "role-to-assume": "${{ vars.AWS_ROLE_ARN }}", "aws-region": "eu-west-1" },
317
+ },
318
+ ],
319
+ permissions: { "id-token": "write" },
320
+ };
321
+
322
+ test("emits the action between the checkout and the beforeScript install", () => {
323
+ const result = generateGithubOpPipeline([OIDC_SPEC], { beforeScript: ["install terraform"] });
324
+ const doc = parseFile(result.files[0].yaml);
325
+ const steps = doc.jobs!["app-apply"].steps;
326
+
327
+ expect(steps.slice(0, 3).map((s) => s.uses ?? s.run)).toEqual([
328
+ "actions/checkout@v4",
329
+ "aws-actions/configure-aws-credentials@v6",
330
+ "install terraform",
331
+ ]);
332
+ // The last step is the invocation. This spec's trigger is `push`, so it
333
+ // is the gated-apply script rather than a bare line (#2243); what this
334
+ // test owns is that the setup action lands between the checkout and the
335
+ // `beforeScript` install, whatever shape the invocation takes.
336
+ expect(steps).toHaveLength(4);
337
+ expect(steps[3].run).toContain("chant run app-apply");
338
+ expect((steps[1] as { with?: Record<string, string> }).with).toEqual({
339
+ "role-to-assume": "${{ vars.AWS_ROLE_ARN }}",
340
+ "aws-region": "eu-west-1",
341
+ });
342
+ });
343
+
344
+ test("adds id-token: write to the mode's own set without replacing it", () => {
345
+ const doc = parseFile(generateGithubOpPipeline([OIDC_SPEC]).files[0].yaml);
346
+ expect(doc.permissions).toEqual({ contents: "read", "id-token": "write" });
347
+ });
348
+
349
+ test("carries a setup step's own `env` and emits a `run` entry as a plain step", () => {
350
+ const specs: ScheduledOpSpec[] = [
351
+ {
352
+ name: "app-apply",
353
+ schedule: "0 6 * * *",
354
+ setup: [{ run: "aws sts get-caller-identity", env: { AWS_REGION: "eu-west-1" } }],
355
+ },
356
+ ];
357
+ const steps = parseFile(generateGithubOpPipeline(specs).files[0].yaml).jobs!["app-apply"].steps;
358
+ expect(steps[1]).toEqual({ run: "aws sts get-caller-identity", env: { AWS_REGION: "eu-west-1" } });
359
+ });
360
+
361
+ test("refuses an action pinned to its own default branch", () => {
362
+ expect(() =>
363
+ generateGithubOpPipeline([{ ...OIDC_SPEC, setup: [{ uses: "aws-actions/configure-aws-credentials@main" }] }]),
364
+ ).toThrow(/setup step 1 pins .* to "main", the action repository's own default branch/s);
365
+ });
366
+
367
+ test("refuses an action with no ref at all", () => {
368
+ expect(() =>
369
+ generateGithubOpPipeline([{ ...OIDC_SPEC, setup: [{ uses: "aws-actions/configure-aws-credentials" }] }]),
370
+ ).toThrow(/is not a pinned action reference/);
371
+ });
372
+
373
+ test("accepts a subpath ref and a commit sha", () => {
374
+ const specs: ScheduledOpSpec[] = [
375
+ {
376
+ name: "app-apply",
377
+ schedule: "0 6 * * *",
378
+ setup: [
379
+ { uses: "github/codeql-action/upload-sarif@v4" },
380
+ { uses: "aws-actions/configure-aws-credentials@0e613a0980cbf65ed5b322eb7a1e075d28913a83" },
381
+ ],
382
+ },
383
+ ];
384
+ const steps = parseFile(generateGithubOpPipeline(specs).files[0].yaml).jobs!["app-apply"].steps;
385
+ expect(steps.map((s) => s.uses).filter(Boolean)).toEqual([
386
+ "actions/checkout@v4",
387
+ "github/codeql-action/upload-sarif@v4",
388
+ "aws-actions/configure-aws-credentials@0e613a0980cbf65ed5b322eb7a1e075d28913a83",
389
+ ]);
390
+ });
391
+
392
+ test("refuses a blanket write-all", () => {
393
+ expect(() =>
394
+ generateGithubOpPipeline([{ ...OIDC_SPEC, permissions: { "write-all": "write" } }]),
395
+ ).toThrow(/a blanket grant/);
396
+ });
397
+
398
+ test("refuses widening a scope the finding-mode already grants", () => {
399
+ expect(() =>
400
+ generateGithubOpPipeline([
401
+ { name: "prod-reconcile", schedule: "0 * * * *", findingMode: "issue", permissions: { issues: "write" } },
402
+ ]),
403
+ ).toThrow(/its finding-mode already grants "issues: write"/);
404
+ });
405
+
406
+ test("refuses downgrading a scope the finding-mode already grants", () => {
407
+ expect(() =>
408
+ generateGithubOpPipeline([
409
+ {
410
+ name: "prod-reconcile",
411
+ schedule: "0 * * * *",
412
+ findingMode: "pull-request",
413
+ permissions: { contents: "read" },
414
+ },
415
+ ]),
416
+ ).toThrow(/its finding-mode already grants "contents: write"/);
417
+ });
418
+
419
+ test("refuses a scope name GitHub does not define, which it would silently ignore", () => {
420
+ expect(() => generateGithubOpPipeline([{ ...OIDC_SPEC, permissions: { id_token: "write" } }])).toThrow(
421
+ /not a GITHUB_TOKEN permission scope/,
422
+ );
423
+ });
424
+
425
+ test("refuses pull-requests: write on a trigger that has no pull request", () => {
426
+ expect(() =>
427
+ generateGithubOpPipeline([{ ...OIDC_SPEC, permissions: { "pull-requests": "write" } }]),
428
+ ).toThrow(/trigger is "push", which carries no pull request/);
429
+ });
430
+
431
+ test("allows id-token: write beside the comment mode's own pull-request scope", () => {
432
+ const doc = parseFile(
433
+ generateGithubOpPipeline([
434
+ {
435
+ name: "app-plan",
436
+ trigger: { kind: "pull_request", branches: ["main"] },
437
+ findingMode: "comment",
438
+ permissions: { "id-token": "write" },
439
+ },
440
+ ]).files[0].yaml,
441
+ );
442
+ expect(doc.permissions).toEqual({
443
+ contents: "read",
444
+ "pull-requests": "write",
445
+ "id-token": "write",
446
+ });
447
+ });
448
+ });
449
+
450
+ /**
451
+ * chant #2243 — a `push` job whose Op gates would otherwise be a red workflow
452
+ * run on every merge until someone approves. The mapping is `chant run`'s own
453
+ * (`--gated-exit 0`); what the generator adds is asking for it on the one
454
+ * trigger that needs it, and a job that says where the approval is pending.
455
+ */
456
+ describe("generateGithubOpPipeline: the gated apply on push (#2243)", () => {
457
+ const pushSpec: ScheduledOpSpec = { name: "app-apply", trigger: { kind: "push", branches: ["main"] } };
458
+
459
+ function pushDoc(): ParsedDoc {
460
+ return parseFile(generateGithubOpPipeline([pushSpec]).files[0].yaml);
461
+ }
462
+
463
+ test("a push job runs with --gated-exit 0 and publishes what it stopped on", () => {
464
+ const doc = pushDoc();
465
+ const job = doc.jobs!["app-apply"];
466
+ const step = job.steps.find((s) => s.id === "chant-run");
467
+ expect(step?.run).toContain("chant run app-apply --gated-exit 0 --json");
468
+ expect(job.outputs).toEqual({
469
+ gated: "${{ steps.chant-run.outputs.gated }}",
470
+ op: "${{ steps.chant-run.outputs.op }}",
471
+ gate: "${{ steps.chant-run.outputs.gate }}",
472
+ approve: "${{ steps.chant-run.outputs.approve }}",
473
+ });
474
+ });
475
+
476
+ test("a cron watch and a pull_request plan keep the plain one-line invocation", () => {
477
+ for (const spec of [
478
+ { name: "app-watch", schedule: "0 6 * * *" },
479
+ { name: "app-plan", trigger: { kind: "pull_request" as const } },
480
+ ] satisfies ScheduledOpSpec[]) {
481
+ const doc = parseFile(generateGithubOpPipeline([spec]).files[0].yaml);
482
+ const job = doc.jobs![spec.name];
483
+ expect(job.steps.some((s) => s.run?.includes("--gated-exit"))).toBe(false);
484
+ expect(job.outputs).toBeUndefined();
485
+ expect(doc.jobs![`${spec.name}-gate-notice`]).toBeUndefined();
486
+ }
487
+ });
488
+
489
+ test("the notice job needs the apply, runs only on gated, and posts outside the log", () => {
490
+ const notice = pushDoc().jobs!["app-apply-gate-notice"];
491
+ expect(notice.needs).toBe("app-apply");
492
+ expect(notice.if).toBe("needs.app-apply.outputs.gated == 'true'");
493
+ // It shells to `gh`, which a hosted runner carries and the Op's own
494
+ // container image does not.
495
+ expect(notice.container).toBeUndefined();
496
+ const script = notice.steps[0].run ?? "";
497
+ expect(script).toContain('gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls"');
498
+ expect(script).toContain('marker="<!-- chant-gate:$CHANT_OP -->"');
499
+ expect(script).toContain("gh issue create");
500
+ });
501
+
502
+ test("the notice job's permissions are its two posting paths and the lookup", () => {
503
+ const notice = pushDoc().jobs!["app-apply-gate-notice"];
504
+ expect(notice.permissions).toEqual({
505
+ contents: "read",
506
+ issues: "write",
507
+ "pull-requests": "write",
508
+ });
509
+ // Job-level, so the apply beside it keeps the workflow's own read-only set.
510
+ expect(pushDoc().permissions).toEqual({ contents: "read" });
511
+ expect(pushDoc().jobs!["app-apply"].permissions).toBeUndefined();
512
+ });
513
+
514
+ test("a failing run stays a failing job: the pipe cannot swallow its exit code", () => {
515
+ const step = pushDoc().jobs!["app-apply"].steps.find((s) => s.id === "chant-run");
516
+ expect(step?.run).toContain("set -o pipefail");
517
+ });
518
+ });
519
+
520
+ /**
521
+ * chant #2257 — the second gate. A GitHub environment carries its own
522
+ * protection rules (required reviewers above all), and until this existed no
523
+ * generated job named one, so a `production` environment declared in a
524
+ * repository bound nothing chant generated. What is asserted here is the key
525
+ * on the right job, the key's absence everywhere else, and that adding it
526
+ * changes nothing else in the document.
527
+ */
528
+ describe("generateGithubOpPipeline: a deployment environment on the Op's job (#2257)", () => {
529
+ const APPLY: ScheduledOpSpec = { name: "app-apply", trigger: { kind: "push", branches: ["main"] } };
530
+ const GATED: ScheduledOpSpec = { ...APPLY, environment: { name: "production" } };
531
+
532
+ /**
533
+ * The exact document a spec with no `environment` emitted before the option
534
+ * existed, produced by the generator at the commit this change branched
535
+ * from. The claim the option makes is that it is additive; this is what
536
+ * makes that claim falsifiable rather than a sentence in a PR body.
537
+ */
538
+ const AUDIT_YAML_BEFORE_2257 =
539
+ [
540
+ "on:",
541
+ " schedule:",
542
+ " - cron: '0 6 * * *'",
543
+ " workflow_dispatch: {}",
544
+ "",
545
+ "concurrency:",
546
+ " group: actions-audit",
547
+ " cancel-in-progress: false",
548
+ "",
549
+ "permissions:",
550
+ " contents: read",
551
+ " issues: write",
552
+ "",
553
+ "jobs:",
554
+ " actions-audit:",
555
+ " runs-on: ubuntu-latest",
556
+ " container: node:22-slim",
557
+ " steps:",
558
+ " - uses: actions/checkout@v4",
559
+ " - run: chant run actions-audit",
560
+ " env:",
561
+ " GITHUB_TOKEN: '${{ github.token }}'",
562
+ " GH_TOKEN: '${{ github.token }}'",
563
+ ].join("\n") + "\n";
564
+
565
+ test("a spec with no environment emits the bytes it emitted before the option existed", () => {
566
+ const yaml = generateGithubOpPipeline([
567
+ { name: "actions-audit", schedule: "0 6 * * *", findingMode: "issue" },
568
+ ]).files[0].yaml;
569
+ expect(yaml).toBe(AUDIT_YAML_BEFORE_2257);
570
+ });
571
+
572
+ test("emits environment: on the Op's job, as a mapping rather than the string shorthand", () => {
573
+ const job = parseFile(generateGithubOpPipeline([GATED]).files[0].yaml).jobs!["app-apply"];
574
+ expect(job.environment).toEqual({ name: "production" });
575
+ });
576
+
577
+ test("carries the url when the spec sets one", () => {
578
+ const spec: ScheduledOpSpec = {
579
+ ...APPLY,
580
+ environment: { name: "production", url: "https://app.example.com" },
581
+ };
582
+ const job = parseFile(generateGithubOpPipeline([spec]).files[0].yaml).jobs!["app-apply"];
583
+ expect(job.environment).toEqual({
584
+ name: "production",
585
+ url: "https://app.example.com",
586
+ });
587
+ });
588
+
589
+ test("adding the environment changes exactly the environment block and nothing else", () => {
590
+ const before = generateGithubOpPipeline([APPLY]).files[0].yaml;
591
+ const after = generateGithubOpPipeline([GATED]).files[0].yaml;
592
+ expect(after).toContain(" environment:\n name: production\n");
593
+ expect(after.replace(" environment:\n name: production\n", "")).toBe(before);
594
+ });
595
+
596
+ test("the gate-notice job is not held behind the same reviewer", () => {
597
+ // It exists to say a chant gate is pending. Behind the environment it
598
+ // would only be readable after somebody had already released the job it
599
+ // is reporting on, which is after the message stops being useful.
600
+ const notice = parseFile(generateGithubOpPipeline([GATED]).files[0].yaml).jobs![
601
+ "app-apply-gate-notice"
602
+ ];
603
+ expect(notice.environment).toBeUndefined();
604
+ });
605
+
606
+ test("costs no token scope: environment protection is repository configuration", () => {
607
+ // `permissionsFor` gains nothing from the option — the reviewer lives on
608
+ // the environment object, not on GITHUB_TOKEN — so the workflow-level set
609
+ // is identical with and without it.
610
+ const withEnv = parseFile(generateGithubOpPipeline([GATED]).files[0].yaml);
611
+ const withoutEnv = parseFile(generateGithubOpPipeline([APPLY]).files[0].yaml);
612
+ expect(withEnv.permissions).toEqual(withoutEnv.permissions);
613
+ expect(withEnv.permissions).toEqual({ contents: "read" });
614
+ });
615
+
616
+ test("applies to any trigger, so a pull_request plan can name a review environment too", () => {
617
+ const spec: ScheduledOpSpec = {
618
+ name: "app-plan",
619
+ trigger: { kind: "pull_request", branches: ["main"] },
620
+ findingMode: "comment",
621
+ environment: { name: "review", url: "${{ steps.deploy.outputs.url }}" },
622
+ };
623
+ const job = parseFile(generateGithubOpPipeline([spec]).files[0].yaml).jobs!["app-plan"];
624
+ expect(job.environment?.name).toBe("review");
625
+ });
626
+
627
+ test("refuses a blank environment name, which resolves to nothing", () => {
628
+ expect(() =>
629
+ generateGithubOpPipeline([{ ...APPLY, environment: { name: " " } }]),
630
+ ).toThrow(/environment has an empty `name`/);
631
+ });
632
+
633
+ test("refuses a url that is neither absolute nor an expression, which renders as a dead link", () => {
634
+ expect(() =>
635
+ generateGithubOpPipeline([{ ...APPLY, environment: { name: "production", url: "/deploys" } }]),
636
+ ).toThrow(/neither an absolute http\(s\) URL nor a/);
637
+ });
638
+
639
+ test("refuses an empty url rather than emitting one", () => {
640
+ expect(() =>
641
+ generateGithubOpPipeline([{ ...APPLY, environment: { name: "production", url: "" } }]),
642
+ ).toThrow(/has an empty `url`/);
643
+ });
644
+ });
@@ -21,23 +21,53 @@
21
21
  * branch);
22
22
  * - declares only the `permissions:` its `findingMode` (and, for a
23
23
  * `pull_request` trigger, whether that mode posts a comment) needs —
24
- * `report` stays read-only, `issue`/`pull-request` add the write scope
25
- * the Op's own activity uses (`gh issue create` / `gh pr create`, see
26
- * `@intentius/chant/op`'s `reconcilePr` activity) — never a blanket
27
- * `write-all`;
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);
28
31
  * - runs exactly one invocation, `chant run <name>` by default — never
29
32
  * inlined audit/reconcile logic. The finding-mode itself is already baked
30
33
  * into the Op's own activity args at build time by the composite that
31
- * 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.
49
+ *
50
+ * A third widens it the other way (#2257): a spec's `environment` emits
51
+ * `environment:` on the Op's own job, which is how a GitHub environment's
52
+ * protection rules — required reviewers above all — come to hold a generated
53
+ * apply. That is a second gate beside chant's own, not a replacement for it:
54
+ * the reviewer stops the job before any step runs, chant's gate ledger
55
+ * (#2119) stops the apply inside a run that already started, and the two
56
+ * compose in either combination. It costs {@link permissionsFor} nothing —
57
+ * environment protection is repository configuration, not a token scope — and
58
+ * {@link assertEnvironment} refuses only what would emit as configured and
59
+ * bind nothing.
32
60
  */
33
61
 
34
62
  import { emitYAML } from "@intentius/chant/yaml";
35
63
  import { resolveOpTrigger } from "@intentius/chant/lexicon";
36
64
  import type {
37
65
  ComponentPipelineOptions as GenerateGithubOpOptions,
66
+ OpEnvironment,
38
67
  OpFindingMode,
39
68
  OpPipelineJob,
40
69
  OpPipelineResult as GenerateGithubOpResult,
70
+ OpSetupStep,
41
71
  OpTrigger,
42
72
  ScheduledOpSpec,
43
73
  } from "@intentius/chant/lexicon";
@@ -52,6 +82,15 @@ export type { GenerateGithubOpOptions, GenerateGithubOpResult };
52
82
  * `./generate-pipeline.ts`'s `GithubPipelineDoc` split.
53
83
  */
54
84
  export interface GithubOpPipelineDoc {
85
+ /**
86
+ * Comment lines emitted above the document, `#` prefix included, when a
87
+ * dialect has something to say about what it could not carry across
88
+ * (#2257). Empty on github, which drops nothing; the forgejo dialect uses
89
+ * it to name the `environment:` its runner has no concept of, so the fact
90
+ * that a reviewer gate did not survive is readable in the generated file
91
+ * rather than only in a build warning.
92
+ */
93
+ header?: string[];
55
94
  /**
56
95
  * The `on:` trigger mapping, per {@link ScheduledOpSpec}'s trigger kind
57
96
  * (#2084): `{ schedule, workflow_dispatch }` for cron, `{ pull_request }`
@@ -70,6 +109,17 @@ export interface GithubOpPipelineDoc {
70
109
  permissions: Record<string, unknown>;
71
110
  /** The `jobs:` mapping — one entry, this Op's trigger job. */
72
111
  jobsDoc: Record<string, unknown>;
112
+ /**
113
+ * The gated-apply notice job (#2243), when this Op's trigger is `push`.
114
+ * Kept out of {@link jobsDoc} so a dialect that cannot run it drops it by
115
+ * simply not copying it: the job shells to `gh` against the GitHub API and
116
+ * needs `gh` on the runner, which is the same reason the `comment` finding
117
+ * mode is refused on forgejo (#2231). {@link emitOpPipelineYAML} merges it
118
+ * into `jobs:` for the forges that can. GitLab reaches the same outcome
119
+ * without this job at all: its push job writes the pending block to an
120
+ * artifact instead (#2256).
121
+ */
122
+ gatedNoticeDoc?: Record<string, unknown>;
73
123
  }
74
124
 
75
125
  /** One generated file: a suggested name plus its pipeline document, pre-emission. */
@@ -119,11 +169,16 @@ function onFor(trigger: OpTrigger): Record<string, unknown> {
119
169
  * PR itself (#2084): any mode but `report` posts something to act on a
120
170
  * finding, so on that trigger every such mode also gets `pull-requests:
121
171
  * write` for the comment, whether or not its own scope already included it.
172
+ * `comment` is the mode that actually spends that grant (#2231), and it
173
+ * changes nothing in the repository, so its whole scope is `{ contents: read,
174
+ * pull-requests: write }`.
122
175
  */
123
176
  function permissionsForMode(mode: OpFindingMode): Record<string, "read" | "write"> {
124
177
  switch (mode) {
125
178
  case "issue":
126
179
  return { contents: "read", issues: "write" };
180
+ case "comment":
181
+ return { contents: "read", "pull-requests": "write" };
127
182
  case "pull-request":
128
183
  case "merge-request":
129
184
  return { contents: "write", "pull-requests": "write" };
@@ -140,11 +195,381 @@ function permissionsFor(mode: OpFindingMode, trigger: OpTrigger): Record<string,
140
195
  return base;
141
196
  }
142
197
 
198
+ // ── The gated apply (#2243) ─────────────────────────────────────────────────
199
+
200
+ /**
201
+ * `chant run` returns 3 when a run stops at an unapproved gate. GitHub Actions
202
+ * has no neutral conclusion for a `run:` step, so a push-to-main apply that
203
+ * gates paints the branch red on every merge until someone approves. This maps
204
+ * that one outcome to success, in chant rather than in a shell wrapper
205
+ * (#2243); a failed run still returns 1 and is still red.
206
+ *
207
+ * `push` only. A cron watch and a `pull_request` plan are never gated in a way
208
+ * that should be hidden: nobody is waiting on a merge for either, and a gated
209
+ * one there is a signal, not noise.
210
+ */
211
+ const GATED_EXIT_FLAG = ["--gated-exit", "0"];
212
+
213
+ /** The id of the `chant run` step on a `push` job, so the job can publish its outputs. */
214
+ const RUN_STEP_ID = "chant-run";
215
+
216
+ /**
217
+ * Turn the run's `--json` record into step outputs, so the notice job below
218
+ * has a condition to test and a gate to name. Runs in node, which is already
219
+ * on any machine `chant` runs on — unlike `jq`, which the Op's own container
220
+ * image need not carry.
221
+ *
222
+ * Nothing is written for a run that completed, so `gated` is either the string
223
+ * `true` or absent, and the notice job's `if:` is a plain equality.
224
+ */
225
+ const GATE_OUTPUT_SCRIPT =
226
+ 'const fs=require("fs");' +
227
+ 'const r=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));' +
228
+ 'if(r.status!=="gated"||!process.env.GITHUB_OUTPUT)process.exit(0);' +
229
+ "fs.appendFileSync(process.env.GITHUB_OUTPUT," +
230
+ '`gated=true\\nop=${r.op}\\ngate=${(r.gate&&r.gate.name)||""}\\napprove=${r.approve||""}\\n`)';
231
+
232
+ /**
233
+ * The `push` job's run step: the invocation with {@link GATED_EXIT_FLAG} and
234
+ * `--json`, tee'd so the record is both in the log and on disk, then read for
235
+ * the job's outputs.
236
+ *
237
+ * `set -o pipefail` is not decoration. GitHub's default shell is `bash -e`,
238
+ * which does not set it, so a failing `chant run` piped into `tee` would come
239
+ * back as `tee`'s zero and turn a broken apply green — the exact thing this
240
+ * whole change must not do.
241
+ */
242
+ function gatedRunScript(op: string, invocation: string): string {
243
+ return [
244
+ "set -o pipefail",
245
+ 'json="${RUNNER_TEMP:-/tmp}/chant-run-' + op + '.json"',
246
+ `${invocation} | tee "$json"`,
247
+ `node -e '${GATE_OUTPUT_SCRIPT}' "$json"`,
248
+ ].join("\n");
249
+ }
250
+
143
251
  /**
144
- * Build one `GithubOpPipelineDoc` per scheduled Op: cron trigger,
145
- * least-privilege `permissions:` for its finding-mode, one job that runs
146
- * `chant run <name>`. Throws nothing every `ScheduledOpSpec` is independent,
147
- * unlike the component generator there is no shared graph to resolve.
252
+ * The notice body's `printf` format. Kept out of {@link gateNoticeScript} so
253
+ * the shell quoting stays readable: it is single-quoted in the emitted script
254
+ * because it carries markdown backticks, which a double-quoted shell string
255
+ * would run as command substitution.
256
+ */
257
+ const NOTICE_BODY_FORMAT =
258
+ "%s\\n\\nThe `%s` apply for %s stopped at gate `%s` and is waiting for an approval. Nothing was applied." +
259
+ "\\n\\n```\\n%s --approver <you>\\n```\\n\\nThe pending fact is on `_gates/%s.jsonl` on the " +
260
+ "`chant/lifecycle` branch. Approving is a commit: push it and this workflow runs again and applies.\\n";
261
+
262
+ /**
263
+ * What the notice job posts. The sticky-comment recipe `reconcilePr`'s
264
+ * `comment` mode already uses (#2231), spelled in shell because this job runs
265
+ * no Op: a hidden marker as the body's first line, found again with
266
+ * `startswith` on the next run, PATCHed when it is there and POSTed when it is
267
+ * not. So a branch that merges three times before anyone approves carries one
268
+ * comment saying what is pending, not three.
269
+ *
270
+ * A GitHub `push` event carries no pull request, so the target is looked up:
271
+ * `repos/{repo}/commits/{sha}/pulls` is the commit's own associated-pull-request
272
+ * endpoint, exact rather than a search index, and on a merge commit it answers
273
+ * with the pull request that just merged. When it answers with nothing — a
274
+ * direct push to the branch, a merge whose commit the API does not associate —
275
+ * the notice becomes an issue instead, which is the `issue` finding mode's own
276
+ * recipe and the reason this job carries `issues: write`.
277
+ */
278
+ function gateNoticeScript(): string {
279
+ return [
280
+ 'marker="<!-- chant-gate:$CHANT_OP -->"',
281
+ "body=$(printf '" + NOTICE_BODY_FORMAT + "' " +
282
+ '"$marker" "$CHANT_OP" "$GITHUB_SHA" "$CHANT_GATE" "$CHANT_APPROVE" "$CHANT_OP")',
283
+ 'pr=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls" --jq ".[0].number // empty")',
284
+ 'if [ -z "$pr" ]; then',
285
+ ' gh issue create --title "$CHANT_OP is waiting on gate $CHANT_GATE" --body "$body"',
286
+ " exit 0",
287
+ "fi",
288
+ 'id=$(gh api "repos/$GITHUB_REPOSITORY/issues/$pr/comments" --paginate ' +
289
+ '--jq "map(select(.body | startswith(\\"$marker\\"))) | .[0].id // empty" ' +
290
+ '| grep -m1 -E "^[0-9]+$" || true)',
291
+ 'if [ -n "$id" ]; then',
292
+ ' gh api --method PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$id" -f "body=$body" --jq .html_url',
293
+ "else",
294
+ ' gh api --method POST "repos/$GITHUB_REPOSITORY/issues/$pr/comments" -f "body=$body" --jq .html_url',
295
+ "fi",
296
+ ].join("\n");
297
+ }
298
+
299
+ /**
300
+ * The follow-up job: `needs:` the apply, runs only when the apply reported
301
+ * gated, and puts the pending state somewhere other than the Actions log.
302
+ *
303
+ * No `container:`. It needs `gh`, which GitHub-hosted runner images carry and
304
+ * an Op's own image (`node:22-slim` by default) does not; it reads nothing out
305
+ * of the repository, so it also needs no checkout.
306
+ *
307
+ * Its `permissions:` are its own, replacing the workflow-level set for this
308
+ * job alone: `contents: read` for the commit-to-pull-request lookup,
309
+ * `pull-requests: write` for the sticky comment, `issues: write` for the
310
+ * fallback when the push has no pull request. Nothing wider — it opens no
311
+ * branch and merges nothing.
312
+ */
313
+ function gateNoticeJob(applyJobName: string): Record<string, unknown> {
314
+ const output = (name: string) => "${{ needs." + applyJobName + ".outputs." + name + ' }}';
315
+ return {
316
+ needs: applyJobName,
317
+ if: `needs.${applyJobName}.outputs.gated == 'true'`,
318
+ "runs-on": "ubuntu-latest",
319
+ permissions: { contents: "read", issues: "write", "pull-requests": "write" },
320
+ steps: [
321
+ {
322
+ name: "Report the pending gate",
323
+ env: {
324
+ GH_TOKEN: "${{ github.token }}",
325
+ GH_REPO: "${{ github.repository }}",
326
+ CHANT_OP: output("op"),
327
+ CHANT_GATE: output("gate"),
328
+ CHANT_APPROVE: output("approve"),
329
+ },
330
+ run: gateNoticeScript(),
331
+ },
332
+ ],
333
+ };
334
+ }
335
+
336
+ /**
337
+ * Refuse `findingMode: "comment"` on a trigger that has no pull request
338
+ * (#2231). The mode's activity reads the triggering PR out of the event
339
+ * payload at run time, so a cron- or push-triggered job carrying it would
340
+ * generate fine and then fail on every run. Refusing here names the Op, the
341
+ * mode and the trigger at build time instead.
342
+ */
343
+ function assertTriggerSupportsMode(name: string, mode: OpFindingMode, trigger: OpTrigger): void {
344
+ if (mode !== "comment" || trigger.kind === "pull_request") return;
345
+ throw new Error(
346
+ `Scheduled Op "${name}" has findingMode "comment", which posts its finding on the pull request that ` +
347
+ `triggered the run, but its trigger is "${trigger.kind}". A ${trigger.kind} run has no pull request ` +
348
+ `to comment on. Give it a { kind: "pull_request" } trigger, or use findingMode "issue".`,
349
+ );
350
+ }
351
+
352
+ /**
353
+ * Every scope `GITHUB_TOKEN` accepts in a workflow's `permissions:` mapping,
354
+ * kebab-cased as GitHub spells them. An additive scope outside this set is
355
+ * refused by name rather than emitted: GitHub ignores an unknown key, so
356
+ * `id_token` or `idToken` would generate a workflow that looks like it grants
357
+ * OIDC and hands the run no token at all.
358
+ */
359
+ const GITHUB_TOKEN_SCOPES = new Set([
360
+ "actions",
361
+ "attestations",
362
+ "checks",
363
+ "contents",
364
+ "deployments",
365
+ "discussions",
366
+ "id-token",
367
+ "issues",
368
+ "models",
369
+ "packages",
370
+ "pages",
371
+ "pull-requests",
372
+ "repository-projects",
373
+ "security-events",
374
+ "statuses",
375
+ ]);
376
+
377
+ /**
378
+ * Refs that name an action repository's own default branch. A generated
379
+ * workflow is committed once and then re-run unattended, often over a cloud
380
+ * role, so "whatever was pushed to that repo last" is not a version — the
381
+ * code that assumes the role can change between the run somebody reviewed and
382
+ * the next one. A release channel the action's author cuts deliberately (`v6`,
383
+ * `v6.2.4`, `stable`) or a commit sha is a version, and both pass: this repo's
384
+ * own workflows pin `actions/checkout@v6` and `dtolnay/rust-toolchain@stable`
385
+ * and name no default branch anywhere.
386
+ */
387
+ const DEFAULT_BRANCH_REFS = new Set(["main", "master", "head", "default"]);
388
+
389
+ /** `owner/repo` or `owner/repo/subpath`, then `@ref`. */
390
+ const USES_PATTERN = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)((?:\/[A-Za-z0-9_.-]+)*)@([^\s@]+)$/;
391
+
392
+ /**
393
+ * Validate a spec's `setup` list (#2242). A `run` entry needs a non-empty
394
+ * line and nothing else. A `uses` entry has to be a pinned
395
+ * `owner/repo[/subpath]@ref`: no bare `owner/repo`, since an unpinned action
396
+ * resolves to its default branch, and no ref in {@link DEFAULT_BRANCH_REFS}
397
+ * for the same reason spelled out loud. Local (`./path`) and container
398
+ * (`docker://`) refs are refused too — they are legal GitHub Actions, but the
399
+ * generator emits a workflow into a repository it has never seen, so it
400
+ * cannot know a local path resolves there.
401
+ */
402
+ export function assertSetupSteps(name: string, setup: OpSetupStep[]): void {
403
+ setup.forEach((step, index) => {
404
+ const where = `Scheduled Op "${name}" setup step ${index + 1}`;
405
+ if ("uses" in step) {
406
+ const ref = step.uses.trim();
407
+ const match = USES_PATTERN.exec(ref);
408
+ if (!match) {
409
+ throw new Error(
410
+ `${where} has \`uses: "${step.uses}"\`, which is not a pinned action reference. ` +
411
+ `Write it as owner/repo@ref (optionally owner/repo/subpath@ref), e.g. ` +
412
+ `"aws-actions/configure-aws-credentials@v6". A local "./path" or "docker://" ref is not ` +
413
+ `accepted here: this generator emits a workflow into a repository it cannot inspect, so it ` +
414
+ `has no way to tell whether such a ref resolves there.`,
415
+ );
416
+ }
417
+ const gitRef = match[4];
418
+ if (DEFAULT_BRANCH_REFS.has(gitRef.toLowerCase())) {
419
+ throw new Error(
420
+ `${where} pins \`uses: "${step.uses}"\` to "${gitRef}", the action repository's own default ` +
421
+ `branch, which names whatever was pushed there last rather than a version. A generated ` +
422
+ `workflow is committed once and re-run unattended, often over a cloud role, so pin a release ` +
423
+ `tag or a commit sha instead (e.g. "${match[1]}/${match[2]}@v1" or "@<40-char sha>").`,
424
+ );
425
+ }
426
+ return;
427
+ }
428
+ if (step.run.trim() === "") {
429
+ throw new Error(`${where} has an empty \`run\` line. Give it a command, or drop the entry.`);
430
+ }
431
+ });
432
+ }
433
+
434
+ /**
435
+ * Validate a spec's `environment` (#2257). GitHub creates an environment it
436
+ * has never seen on first use rather than failing the run, and an environment
437
+ * created that way carries no protection rules at all — so a job can name one
438
+ * and read as gated while being gated by nothing. Neither this generator nor
439
+ * GitHub can tell those apart at build time (the environment and its
440
+ * reviewers are repository configuration, not workflow content), which is why
441
+ * what is refused here is only the shape that could never bind: a name that
442
+ * is blank, and a `url` that is neither absolute nor an expression the forge
443
+ * resolves. The rest is the README's job to say out loud.
444
+ */
445
+ export function assertEnvironment(name: string, environment: OpEnvironment): void {
446
+ const where = `Scheduled Op "${name}" environment`;
447
+ if (environment.name.trim() === "") {
448
+ throw new Error(
449
+ `${where} has an empty \`name\`. An environment is named repository configuration — the ` +
450
+ `protection rules and reviewers live on the environment, not in this workflow — so there is ` +
451
+ `nothing for a blank name to resolve to. Give it the environment's name, or drop the option.`,
452
+ );
453
+ }
454
+ if (environment.url === undefined) return;
455
+ const url = environment.url.trim();
456
+ if (url === "") {
457
+ throw new Error(
458
+ `${where} "${environment.name}" has an empty \`url\`. Omit the field rather than setting it to "".`,
459
+ );
460
+ }
461
+ if (!/^https?:\/\//.test(url) && !url.includes("${{")) {
462
+ throw new Error(
463
+ `${where} "${environment.name}" has \`url: "${environment.url}"\`, which is neither an absolute ` +
464
+ `http(s) URL nor a \${{ }} expression. GitHub renders this value as the deployment's own link, ` +
465
+ `so a relative path becomes a dead link on the environment page rather than an error anywhere. ` +
466
+ `Write the full URL, or an expression the run resolves to one.`,
467
+ );
468
+ }
469
+ }
470
+
471
+ /**
472
+ * Merge a spec's additive `permissions` over the finding-mode's own set
473
+ * (#2242), refusing by name anything that is not strictly additive:
474
+ *
475
+ * - a blanket `write-all`/`read-all`, in either the key or the value
476
+ * position, which is the exact thing {@link permissionsForMode} exists to
477
+ * avoid;
478
+ * - a scope GitHub does not define ({@link GITHUB_TOKEN_SCOPES}), because
479
+ * GitHub ignores the key and the run silently gets nothing;
480
+ * - a scope the mode already grants, at any value — additive means additive,
481
+ * so this can neither downgrade `contents: write` to read nor restate it.
482
+ * A mode whose set is wrong is fixed by changing the mode, where the
483
+ * scope and the behavior that spends it stay together;
484
+ * - `pull-requests: write` on a trigger with no pull request. Pull-request
485
+ * access is what the finding-modes own: `pull-request` grants it together
486
+ * with the `contents: write` needed to push the branch first, and
487
+ * `comment` grants it on the one trigger that carries a pull request to
488
+ * comment on. Adding it beside a mode that posts nothing, on a cron or
489
+ * push run, grants write access no step in the generated job can spend.
490
+ */
491
+ export function mergePermissions(
492
+ name: string,
493
+ base: Record<string, "read" | "write">,
494
+ additive: Record<string, "read" | "write">,
495
+ trigger: OpTrigger,
496
+ ): Record<string, "read" | "write"> {
497
+ const merged: Record<string, "read" | "write"> = { ...base };
498
+ for (const [rawScope, value] of Object.entries(additive)) {
499
+ const scope = rawScope.trim();
500
+ const where = `Scheduled Op "${name}" adds permission "${scope}: ${value}"`;
501
+ if (scope === "write-all" || scope === "read-all" || String(value).endsWith("-all")) {
502
+ throw new Error(
503
+ `${where}, a blanket grant. \`permissions\` on a scheduled Op is additive over the ` +
504
+ `least-privilege set its finding-mode needs, one named scope at a time. Name the scopes the ` +
505
+ `job actually spends (e.g. { "id-token": "write" }).`,
506
+ );
507
+ }
508
+ if (!GITHUB_TOKEN_SCOPES.has(scope)) {
509
+ throw new Error(
510
+ `${where}, which is not a GITHUB_TOKEN permission scope. GitHub ignores an unrecognized key, so ` +
511
+ `this would emit a workflow that reads as granted and hands the run nothing. Known scopes: ` +
512
+ `${[...GITHUB_TOKEN_SCOPES].sort().join(", ")}.`,
513
+ );
514
+ }
515
+ if (scope in base) {
516
+ throw new Error(
517
+ `${where}, but its finding-mode already grants "${scope}: ${base[scope]}". These permissions are ` +
518
+ `additive only — they never replace, widen or downgrade a scope the mode computed. Change the ` +
519
+ `Op's findingMode if that set is wrong, and add only scopes no mode grants (e.g. "id-token").`,
520
+ );
521
+ }
522
+ if (scope === "pull-requests" && trigger.kind !== "pull_request") {
523
+ throw new Error(
524
+ `${where}, but this Op's trigger is "${trigger.kind}", which carries no pull request. Pull-request ` +
525
+ `write access belongs to a finding-mode: "pull-request" grants it with the contents: write its ` +
526
+ `branch push needs, and "comment" grants it on the pull_request trigger. Set findingMode instead ` +
527
+ `of adding the scope here.`,
528
+ );
529
+ }
530
+ merged[scope] = value;
531
+ }
532
+ return merged;
533
+ }
534
+
535
+ /**
536
+ * Emit a spec's environment as the job's `environment:` mapping. Always the
537
+ * mapping form, never the `environment: name` string shorthand, so adding a
538
+ * `url` later is a new key rather than a reshaped value.
539
+ */
540
+ function environmentDoc(environment: OpEnvironment): Record<string, unknown> {
541
+ return {
542
+ name: environment.name,
543
+ ...(environment.url === undefined ? {} : { url: environment.url }),
544
+ };
545
+ }
546
+
547
+ /** Emit one setup entry as a GitHub Actions step. */
548
+ function setupStepDoc(step: OpSetupStep): Record<string, unknown> {
549
+ if ("uses" in step) {
550
+ return {
551
+ uses: step.uses,
552
+ ...(step.with && Object.keys(step.with).length > 0 ? { with: step.with } : {}),
553
+ ...(step.env && Object.keys(step.env).length > 0 ? { env: step.env } : {}),
554
+ };
555
+ }
556
+ return {
557
+ run: step.run,
558
+ ...(step.env && Object.keys(step.env).length > 0 ? { env: step.env } : {}),
559
+ };
560
+ }
561
+
562
+ /**
563
+ * Build one `GithubOpPipelineDoc` per scheduled Op: its trigger, its `setup`
564
+ * steps, least-privilege `permissions:` for its finding-mode plus whatever
565
+ * the spec adds, one job that runs `chant run <name>`. Every
566
+ * `ScheduledOpSpec` is independent — unlike the component generator there is
567
+ * no shared graph to resolve — so the only thing this refuses is a spec that
568
+ * contradicts itself: no trigger at all (`resolveOpTrigger`), `findingMode:
569
+ * "comment"` on a trigger that has no pull request ({@link
570
+ * assertTriggerSupportsMode}), an unpinned `setup` action ({@link
571
+ * assertSetupSteps}), or a `permissions` entry that is not additive ({@link
572
+ * mergePermissions}).
148
573
  */
149
574
  export function buildGithubOpPipelineDocs(
150
575
  ops: ScheduledOpSpec[],
@@ -161,6 +586,7 @@ export function buildGithubOpPipelineDocs(
161
586
  for (const spec of ops) {
162
587
  const findingMode = spec.findingMode ?? "report";
163
588
  const trigger = resolveOpTrigger(spec);
589
+ assertTriggerSupportsMode(spec.name, findingMode, trigger);
164
590
  const jobName = toJobName(spec.name);
165
591
  jobs.push({ jobName, op: spec.name, trigger, findingMode });
166
592
 
@@ -171,9 +597,27 @@ export function buildGithubOpPipelineDocs(
171
597
  const stepEnv: Record<string, string> = { GITHUB_TOKEN: "${{ github.token }}" };
172
598
  if (findingMode !== "report") stepEnv.GH_TOKEN = "${{ github.token }}";
173
599
 
600
+ const setup = spec.setup ?? [];
601
+ assertSetupSteps(spec.name, setup);
602
+ if (spec.environment) assertEnvironment(spec.name, spec.environment);
603
+
604
+ // A `push` job is the one that has to survive a gate (#2243): the apply
605
+ // runs with `--gated-exit 0` so a pending approval is a green run, and
606
+ // publishes what it stopped on as job outputs for the notice job below.
607
+ // Every other trigger keeps the plain one-line invocation it always had.
608
+ const gated = trigger.kind === "push";
609
+ const invocation = gated
610
+ ? [...runParts, ...GATED_EXIT_FLAG, "--json"].join(" ")
611
+ : runParts.join(" ");
612
+
174
613
  const steps: Array<Record<string, unknown>> = [{ uses: "actions/checkout@v4" }];
614
+ for (const step of setup) steps.push(setupStepDoc(step));
175
615
  for (const line of beforeScript) steps.push({ run: line });
176
- steps.push({ run: runParts.join(" "), env: stepEnv });
616
+ steps.push(
617
+ gated
618
+ ? { id: RUN_STEP_ID, run: gatedRunScript(spec.name, invocation), env: stepEnv }
619
+ : { run: invocation, env: stepEnv },
620
+ );
177
621
  for (const line of extraScript) steps.push({ run: line });
178
622
 
179
623
  const doc: GithubOpPipelineDoc = {
@@ -182,14 +626,35 @@ export function buildGithubOpPipelineDocs(
182
626
  // One run at a time per Op — a slow audit must not overlap its own next
183
627
  // scheduled trigger.
184
628
  concurrency: { group: jobName, "cancel-in-progress": false },
185
- permissions: permissionsFor(findingMode, trigger),
629
+ permissions: mergePermissions(
630
+ spec.name,
631
+ permissionsFor(findingMode, trigger),
632
+ spec.permissions ?? {},
633
+ trigger,
634
+ ),
186
635
  jobsDoc: {
187
636
  [jobName]: {
188
637
  "runs-on": "ubuntu-latest",
189
638
  container: image,
639
+ // On this Op's own job and never on the notice job beside it: the
640
+ // notice exists to say a chant gate is pending, and putting it
641
+ // behind the same reviewer would hold the message back until
642
+ // somebody had already acted.
643
+ ...(spec.environment ? { environment: environmentDoc(spec.environment) } : {}),
644
+ ...(gated
645
+ ? {
646
+ outputs: Object.fromEntries(
647
+ ["gated", "op", "gate", "approve"].map((name) => [
648
+ name,
649
+ `\${{ steps.${RUN_STEP_ID}.outputs.${name} }}`,
650
+ ]),
651
+ ),
652
+ }
653
+ : {}),
190
654
  steps,
191
655
  },
192
656
  },
657
+ ...(gated ? { gatedNoticeDoc: { [`${jobName}-gate-notice`]: gateNoticeJob(jobName) } } : {}),
193
658
  };
194
659
 
195
660
  files.push({ name: `${spec.name}.yml`, doc });
@@ -205,11 +670,18 @@ export function buildGithubOpPipelineDocs(
205
670
  */
206
671
  export function emitOpPipelineYAML(doc: GithubOpPipelineDoc): string {
207
672
  const sections: string[] = [];
673
+ // A dialect's note about what it could not carry (#2257), when there is
674
+ // one. Absent on github, so an unannotated document is emitted exactly as
675
+ // it was before the field existed.
676
+ if (doc.header && doc.header.length > 0) sections.push(doc.header.join("\n"));
208
677
  sections.push("on:" + emitYAML(doc.on, 1));
209
678
  if (doc.env && Object.keys(doc.env).length > 0) sections.push("env:" + emitYAML(doc.env, 1));
210
679
  sections.push("concurrency:" + emitYAML(doc.concurrency, 1));
211
680
  if (Object.keys(doc.permissions).length > 0) sections.push("permissions:" + emitYAML(doc.permissions, 1));
212
- sections.push("jobs:" + emitYAML(doc.jobsDoc, 1));
681
+ // The gated-apply notice job rides in `jobs:` beside the Op's own job, but
682
+ // is carried separately on the doc so a dialect that cannot run it (forgejo,
683
+ // whose runner has no `gh` pointed at its own instance) drops it by omission.
684
+ sections.push("jobs:" + emitYAML({ ...doc.jobsDoc, ...(doc.gatedNoticeDoc ?? {}) }, 1));
213
685
  return sections.join("\n\n") + "\n";
214
686
  }
215
687
 
@@ -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]]),
@@ -49,17 +49,22 @@ export interface PrPlanReportProps {
49
49
  * Sticky-comment script (#1223's mechanism, reused as-is): find the comment
50
50
  * whose body starts with `$MARKER`, PATCH it if found, POST otherwise. No
51
51
  * marketplace action, nothing extra to pin — `gh` ships on GitHub's hosted
52
- * runners. `-f body=@plan.md` reads the comment body from the file the plan
53
- * step wrote, so a large or multi-line plan never has to survive shell
54
- * quoting.
52
+ * runners. The flag is `-F`, not `-f`: `gh api`'s `-F/--field` is the typed
53
+ * form that reads the value from a file when it starts with `@`, while
54
+ * `-f/--raw-field` adds the parameter as a literal string, so the `-f` form
55
+ * posted the eight characters `@plan.md` (#2236). Reading the body from the
56
+ * file the plan step wrote means a large or multi-line plan never has to
57
+ * survive shell quoting. `-F`'s type coercion of `true`/`false`/`null`/
58
+ * integers does not reach the body: gh resolves the leading `@` first and
59
+ * hands back the file's bytes as a string.
55
60
  */
56
61
  const stickyCommentScript = [
57
62
  'comment_id=$(gh api "repos/$REPO/issues/$PR_NUMBER/comments" --paginate ' +
58
63
  '--jq "map(select(.body | startswith(\\"$MARKER\\"))) | .[0].id // empty")',
59
64
  'if [ -n "$comment_id" ]; then',
60
- ' gh api -X PATCH "repos/$REPO/issues/comments/$comment_id" -f body=@plan.md > /dev/null',
65
+ ' gh api -X PATCH "repos/$REPO/issues/comments/$comment_id" -F body=@plan.md > /dev/null',
61
66
  "else",
62
- ' gh api -X POST "repos/$REPO/issues/$PR_NUMBER/comments" -f body=@plan.md > /dev/null',
67
+ ' gh api -X POST "repos/$REPO/issues/$PR_NUMBER/comments" -F body=@plan.md > /dev/null',
63
68
  "fi",
64
69
  ].join("\n");
65
70