@intentius/chant-lexicon-forgejo 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.
@@ -16,13 +16,55 @@
16
16
  * needs still rides the trigger step's `env:` (`GH_TOKEN`/`GITHUB_TOKEN`,
17
17
  * built by github's generator); actual write access on Forgejo is a property
18
18
  * of the runner/token configuration, not the workflow YAML.
19
+ *
20
+ * That covers a spec's additive `permissions` too (#2242): an `id-token:
21
+ * write` added for OIDC is dropped here along with the mode's own scopes,
22
+ * because the Forgejo runner reads none of them and issues no OIDC token off
23
+ * a workflow permission. A Forgejo job authenticates to a cloud provider
24
+ * through whatever the runner already holds. A spec's `setup` steps do cross
25
+ * over unchanged: Forgejo runs `uses:` steps, so they are emitted in the
26
+ * position github puts them (after the checkout, ahead of the `beforeScript`
27
+ * lines) with the dialect's own action-ref rewrite applied — an action with
28
+ * no mapping in ../actions.ts passes through verbatim and resolves only if
29
+ * the runner can fetch it.
30
+ *
31
+ * One finding-mode does not cross over: `comment` (#2231) posts onto the
32
+ * triggering pull request by shelling to `gh` against the GitHub API and
33
+ * reading the GitHub Actions event payload. Forgejo's API is
34
+ * GitHub-compatible in shape, but chant has no Forgejo client and no host
35
+ * configuration to point `gh` at a Forgejo instance, so this refuses the mode
36
+ * by name rather than generating a job whose finding step fails on every run.
37
+ *
38
+ * A spec's `environment` (#2257) is dropped on the same terms as
39
+ * `permissions:`, and for a stronger reason: Forgejo Actions has no
40
+ * environments at all — no protection rules, no required reviewers, no
41
+ * per-environment secrets — so the key names an object that does not exist on
42
+ * the instance. Dropping it silently would be the worst outcome available,
43
+ * because the whole point of the option is a human holding an apply, so this
44
+ * also writes a comment into the generated file's header saying which
45
+ * environment was asked for and that nothing on Forgejo enforces it. What
46
+ * still holds the apply here is chant's own gate (#2119), which is a fact on
47
+ * the `chant/lifecycle` branch and needs nothing from the forge.
48
+ *
49
+ * The gated-apply notice job (#2243) does not cross over either, for the same
50
+ * reason and by the same mechanism as `permissions:`: it shells to `gh`, which
51
+ * a Forgejo `act_runner` neither ships nor can point at its own instance, and
52
+ * it runs outside the Op's container image, where a hosted GitHub runner's
53
+ * preinstalled tools would be. So the doc rebuilt below simply does not carry
54
+ * `gatedNoticeDoc`, and the job is dropped. What does cross over is the half
55
+ * that needs no forge API: a `push` job still runs with `--gated-exit 0`, so a
56
+ * Forgejo apply that stops at its gate is a green run rather than a red one,
57
+ * and `chant run` still writes the gate and the approve command to
58
+ * `GITHUB_STEP_SUMMARY`, which Forgejo Actions sets like GitHub does.
19
59
  */
20
60
  import type { ComponentPipelineOptions, OpPipelineResult, ScheduledOpSpec } from "@intentius/chant/lexicon";
21
61
  import { type ForgejoDialectOptions } from "../dialect.js";
22
62
  /**
23
63
  * Synthesize one `.forgejo/workflows/*.yml` per scheduled Op. Reuses github's
24
- * trigger/job structure ({@link buildGithubOpPipelineDocs}), then applies the
25
- * Forgejo dialect and drops `permissions:` (ignored by the Forgejo runner).
64
+ * trigger/job structure ({@link buildGithubOpPipelineDocs}) its `setup`-step
65
+ * and additive-permission validation included, so an unpinned action ref is
66
+ * refused here on the same terms — then applies the Forgejo dialect and drops
67
+ * `permissions:` (ignored by the Forgejo runner).
26
68
  * Wired into core's Op generate mode via the forgejo lexicon plugin's
27
69
  * `generateOpPipeline` (../plugin.ts).
28
70
  */
@@ -1 +1 @@
1
- {"version":3,"file":"generate-op-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAOH,OAAO,KAAK,EACV,wBAAwB,EACxB,gBAAgB,EAChB,eAAe,EAChB,MAAM,0BAA0B,CAAC;AAClC,OAAO,EAA2B,KAAK,qBAAqB,EAAE,MAAM,YAAY,CAAC;AAOjF;;;;;;GAMG;AACH,wBAAgB,yBAAyB,CACvC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,wBAA6B,EACtC,cAAc,GAAE,qBAA0B,GACzC,gBAAgB,CAgBlB"}
1
+ {"version":3,"file":"generate-op-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0DG;AAOH,OAAO,KAAK,EACV,wBAAwB,EACxB,gBAAgB,EAChB,eAAe,EAChB,MAAM,0BAA0B,CAAC;AAClC,OAAO,EAA2B,KAAK,qBAAqB,EAAE,MAAM,YAAY,CAAC;AAmCjF;;;;;;;;GAQG;AACH,wBAAgB,yBAAyB,CACvC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,wBAA6B,EACtC,cAAc,GAAE,qBAA0B,GACzC,gBAAgB,CA+BlB"}
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "680e6414892aa4dcb70e374a8a17761020aed986ed03d988ca82ad685654f632",
4
+ "manifest.json": "56a899fd87d95111c56cb551f5a4961c91af788fb0a11c3ef4f9fb207f5abd3f",
5
5
  "meta.json": "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
6
6
  "types/index.d.ts": "66204549b2a864ab5489e3f02c1878e73ab4b3c9d938cfb480b66f71543a43de",
7
7
  "rules/delegate-to-github.ts": "1060cda40f4b73d6cca3ba3fa13b80b4886147d4034c88584c2d80c5510dbe5e",
@@ -9,5 +9,5 @@
9
9
  "rules/wfj011.ts": "fcb8bf6685d744af20ad804ce929488257995c74b9a3353b2eacae3768d1c83e",
10
10
  "skills/chant-forgejo.md": "a1a560429db736c187e0b34cf8dd9efc6ad771afefcbf6f802d160d3d5274257"
11
11
  },
12
- "composite": "c2da4b8dc0e9d2cc2f1f2d6d0e800fddfe16e1eccf2e23b196883b937b8f56bc"
12
+ "composite": "390ec307dcfc42c1e376d49ee30e4f78429b316ded7bce20355efded9327d04e"
13
13
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgejo",
3
- "version": "0.58.0",
3
+ "version": "0.60.0",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "Forgejo",
6
6
  "intrinsics": [],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-forgejo",
3
- "version": "0.58.0",
3
+ "version": "0.60.0",
4
4
  "description": "Forgejo / Codeberg / Gitea Actions lexicon for chant — a thin GitHub Actions dialect",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -54,8 +54,8 @@
54
54
  },
55
55
  "peerDependencies": {
56
56
  "zod": "^4.3.6",
57
- "@intentius/chant": "^0.58.0",
58
- "@intentius/chant-lexicon-github": "^0.58.0"
57
+ "@intentius/chant": "^0.60.0",
58
+ "@intentius/chant-lexicon-github": "^0.60.0"
59
59
  },
60
60
  "scripts": {
61
61
  "generate": "tsx src/codegen/generate-cli.ts",
@@ -14,7 +14,9 @@ import type { ScheduledOpSpec } from "@intentius/chant/lexicon";
14
14
 
15
15
  interface ParsedJob {
16
16
  "runs-on"?: string;
17
- steps: Array<{ uses?: string; run?: string }>;
17
+ environment?: Record<string, string>;
18
+ outputs?: Record<string, string>;
19
+ steps: Array<{ id?: string; uses?: string; run?: string }>;
18
20
  }
19
21
  interface ParsedDoc {
20
22
  on?: Record<string, unknown>;
@@ -102,3 +104,176 @@ describe("generateForgejoOpPipeline: non-cron trigger survives the dialect trans
102
104
  expect(fj.files[0].yaml).not.toMatch(/^permissions:/m);
103
105
  });
104
106
  });
107
+
108
+ describe("generateForgejoOpPipeline: no comment finding mode (#2231)", () => {
109
+ test("findingMode comment is refused by name, on the pull_request trigger it would otherwise fit", () => {
110
+ // Forgejo Actions runs the same workflow shape and Forgejo's API is
111
+ // GitHub-compatible, but the activity behind the mode shells to `gh`
112
+ // against github.com and reads the GitHub Actions event payload. Nothing
113
+ // in chant points either at a Forgejo instance, so the mode is refused
114
+ // here rather than generating a job that fails at its Report step.
115
+ const specs: ScheduledOpSpec[] = [
116
+ { name: "app-plan", trigger: { kind: "pull_request", branches: ["main"] }, findingMode: "comment" },
117
+ ];
118
+ expect(() => generateForgejoOpPipeline(specs)).toThrow(
119
+ /Scheduled Op "app-plan".*findingMode "comment".*no Forgejo API client/s,
120
+ );
121
+ });
122
+
123
+ test("github generates the same spec, so the refusal is forgejo's and not the shared builder's", () => {
124
+ const specs: ScheduledOpSpec[] = [
125
+ { name: "app-plan", trigger: { kind: "pull_request", branches: ["main"] }, findingMode: "comment" },
126
+ ];
127
+ const gh = parseFile(generateGithubOpPipeline(specs).files[0].yaml);
128
+ expect(gh.permissions).toEqual({ contents: "read", "pull-requests": "write" });
129
+ });
130
+ });
131
+
132
+ /**
133
+ * #2242 crosses the dialect asymmetrically: Forgejo runs `uses:` steps, so a
134
+ * spec's `setup` list is emitted; it ignores `permissions:`, so an additive
135
+ * scope is dropped with the rest of the section rather than emitted as a
136
+ * control the runner never reads.
137
+ */
138
+ describe("generateForgejoOpPipeline: setup steps and additive permissions (#2242)", () => {
139
+ const OIDC_SPEC: ScheduledOpSpec = {
140
+ name: "app-apply",
141
+ trigger: { kind: "push", branches: ["main"] },
142
+ setup: [
143
+ {
144
+ uses: "aws-actions/configure-aws-credentials@v6",
145
+ with: { "role-to-assume": "${{ vars.AWS_ROLE_ARN }}", "aws-region": "eu-west-1" },
146
+ },
147
+ ],
148
+ permissions: { "id-token": "write" },
149
+ };
150
+
151
+ test("emits the setup step after the checkout, with its `with:` intact", () => {
152
+ const doc = parseFile(generateForgejoOpPipeline([OIDC_SPEC]).files[0].yaml);
153
+ const steps = doc.jobs!["app-apply"].steps;
154
+ // The checkout is rewritten to the Forgejo mirror; an action with no
155
+ // mapping in ../actions.ts passes through verbatim.
156
+ expect(steps[0].uses).toContain("actions/checkout@v4");
157
+ expect(steps[1].uses).toBe("aws-actions/configure-aws-credentials@v6");
158
+ expect((steps[1] as { with?: Record<string, string> }).with).toEqual({
159
+ "role-to-assume": "${{ vars.AWS_ROLE_ARN }}",
160
+ "aws-region": "eu-west-1",
161
+ });
162
+ });
163
+
164
+ test("drops the additive permission along with the mode's own scopes", () => {
165
+ const yaml = generateForgejoOpPipeline([OIDC_SPEC]).files[0].yaml;
166
+ expect(yaml).not.toContain("permissions:");
167
+ expect(yaml).not.toContain("id-token");
168
+ // The same spec on github does carry it — this is a dialect drop, not a
169
+ // generator that never computed the scope.
170
+ expect(generateGithubOpPipeline([OIDC_SPEC]).files[0].yaml).toContain("id-token: write");
171
+ });
172
+
173
+ test("refuses an unpinned action ref on the same terms as github", () => {
174
+ expect(() =>
175
+ generateForgejoOpPipeline([{ ...OIDC_SPEC, setup: [{ uses: "aws-actions/configure-aws-credentials@main" }] }]),
176
+ ).toThrow(/the action repository's own default branch/);
177
+ });
178
+ });
179
+
180
+ describe("generateForgejoOpPipeline: the gated apply on push (#2243)", () => {
181
+ const pushSpec: ScheduledOpSpec = { name: "app-apply", trigger: { kind: "push", branches: ["main"] } };
182
+
183
+ test("the exit mapping crosses over: a gated apply is a green Forgejo run too", () => {
184
+ // `--gated-exit 0` is `chant run`'s own, so it needs nothing from the
185
+ // runner. `GITHUB_STEP_SUMMARY`, which the gate block goes to, is set by
186
+ // Forgejo's act_runner the same way GitHub sets it.
187
+ const doc = parseFile(generateForgejoOpPipeline([pushSpec]).files[0].yaml);
188
+ const step = doc.jobs!["app-apply"].steps.find((s) => s.id === "chant-run");
189
+ expect(step?.run).toContain("chant run app-apply --gated-exit 0 --json");
190
+ });
191
+
192
+ test("the notice job does not: it shells to `gh`, which no Forgejo runner points at its own instance", () => {
193
+ const fj = parseFile(generateForgejoOpPipeline([pushSpec]).files[0].yaml);
194
+ const gh = parseFile(generateGithubOpPipeline([pushSpec]).files[0].yaml);
195
+ // Dropped the same way `permissions:` is dropped — by not being carried
196
+ // onto the rebuilt doc — and github still has it, so the omission is
197
+ // forgejo's rather than the shared builder's.
198
+ expect(Object.keys(fj.jobs ?? {})).toEqual(["app-apply"]);
199
+ expect(gh.jobs).toHaveProperty("app-apply-gate-notice");
200
+ });
201
+ });
202
+
203
+ /**
204
+ * chant #2257 — the option Forgejo has no concept for at all. Unlike
205
+ * `permissions:`, which the runner reads and ignores, an environment is an
206
+ * object that does not exist on a Forgejo instance: no protection rules, no
207
+ * required reviewers, no wait timers. Emitting the key would read as a
208
+ * deployment gate and hold nothing back, so it is dropped — and, because the
209
+ * whole point of the option is a human holding an apply, the drop is said out
210
+ * loud in the file rather than only in a build warning.
211
+ */
212
+ describe("generateForgejoOpPipeline: a dropped deployment environment (#2257)", () => {
213
+ const APPLY: ScheduledOpSpec = { name: "app-apply", trigger: { kind: "push", branches: ["main"] } };
214
+ const GATED: ScheduledOpSpec = { ...APPLY, environment: { name: "production" } };
215
+
216
+ /** The document a spec with no `environment` emitted before the option existed. */
217
+ const AUDIT_YAML_BEFORE_2257 =
218
+ [
219
+ "on:",
220
+ " schedule:",
221
+ " - cron: '0 6 * * *'",
222
+ " workflow_dispatch: {}",
223
+ "",
224
+ "concurrency:",
225
+ " group: actions-audit",
226
+ " cancel-in-progress: false",
227
+ "",
228
+ "jobs:",
229
+ " actions-audit:",
230
+ " runs-on: docker",
231
+ " container: node:22-slim",
232
+ " steps:",
233
+ " - uses: https://code.forgejo.org/actions/checkout@v4",
234
+ " - run: chant run actions-audit",
235
+ " env:",
236
+ " GITHUB_TOKEN: '${{ github.token }}'",
237
+ " GH_TOKEN: '${{ github.token }}'",
238
+ ].join("\n") + "\n";
239
+
240
+ test("a spec with no environment emits the bytes it emitted before the option existed", () => {
241
+ const yaml = generateForgejoOpPipeline([
242
+ { name: "actions-audit", schedule: "0 6 * * *", findingMode: "issue" },
243
+ ]).files[0].yaml;
244
+ expect(yaml).toBe(AUDIT_YAML_BEFORE_2257);
245
+ });
246
+
247
+ test("drops the key, and github with the same spec keeps it", () => {
248
+ const fj = parseFile(generateForgejoOpPipeline([GATED]).files[0].yaml);
249
+ expect(fj.jobs!["app-apply"].environment).toBeUndefined();
250
+ // A dialect drop, not a builder that never computed it.
251
+ expect(generateGithubOpPipeline([GATED]).files[0].yaml).toContain("environment:");
252
+ });
253
+
254
+ test("says in the generated header which environment did not survive, and what still gates", () => {
255
+ const yaml = generateForgejoOpPipeline([GATED]).files[0].yaml;
256
+ expect(yaml.startsWith("# chant dropped `environment: production`")).toBe(true);
257
+ expect(yaml).toContain("Forgejo Actions has no");
258
+ expect(yaml).toContain("not held back by anything on the forge");
259
+ // The gate that does survive is chant's own, which needs nothing from the forge.
260
+ expect(yaml).toContain("chant/lifecycle");
261
+ expect(yaml).toContain("approve <op> <gate>");
262
+ });
263
+
264
+ test("the header only appears for the Op that asked for an environment", () => {
265
+ const files = generateForgejoOpPipeline([
266
+ { ...GATED, name: "app-apply" },
267
+ { name: "app-watch", schedule: "0 6 * * *" },
268
+ ]).files;
269
+ expect(files[0].yaml).toContain("# chant dropped `environment: production`");
270
+ expect(files[1].yaml).not.toContain("# chant dropped");
271
+ expect(files[1].yaml.startsWith("on:")).toBe(true);
272
+ });
273
+
274
+ test("refuses the same malformed environment github refuses, through the shared builder", () => {
275
+ expect(() =>
276
+ generateForgejoOpPipeline([{ ...APPLY, environment: { name: "production", url: "/deploys" } }]),
277
+ ).toThrow(/neither an absolute http\(s\) URL nor a/);
278
+ });
279
+ });
@@ -16,6 +16,46 @@
16
16
  * needs still rides the trigger step's `env:` (`GH_TOKEN`/`GITHUB_TOKEN`,
17
17
  * built by github's generator); actual write access on Forgejo is a property
18
18
  * of the runner/token configuration, not the workflow YAML.
19
+ *
20
+ * That covers a spec's additive `permissions` too (#2242): an `id-token:
21
+ * write` added for OIDC is dropped here along with the mode's own scopes,
22
+ * because the Forgejo runner reads none of them and issues no OIDC token off
23
+ * a workflow permission. A Forgejo job authenticates to a cloud provider
24
+ * through whatever the runner already holds. A spec's `setup` steps do cross
25
+ * over unchanged: Forgejo runs `uses:` steps, so they are emitted in the
26
+ * position github puts them (after the checkout, ahead of the `beforeScript`
27
+ * lines) with the dialect's own action-ref rewrite applied — an action with
28
+ * no mapping in ../actions.ts passes through verbatim and resolves only if
29
+ * the runner can fetch it.
30
+ *
31
+ * One finding-mode does not cross over: `comment` (#2231) posts onto the
32
+ * triggering pull request by shelling to `gh` against the GitHub API and
33
+ * reading the GitHub Actions event payload. Forgejo's API is
34
+ * GitHub-compatible in shape, but chant has no Forgejo client and no host
35
+ * configuration to point `gh` at a Forgejo instance, so this refuses the mode
36
+ * by name rather than generating a job whose finding step fails on every run.
37
+ *
38
+ * A spec's `environment` (#2257) is dropped on the same terms as
39
+ * `permissions:`, and for a stronger reason: Forgejo Actions has no
40
+ * environments at all — no protection rules, no required reviewers, no
41
+ * per-environment secrets — so the key names an object that does not exist on
42
+ * the instance. Dropping it silently would be the worst outcome available,
43
+ * because the whole point of the option is a human holding an apply, so this
44
+ * also writes a comment into the generated file's header saying which
45
+ * environment was asked for and that nothing on Forgejo enforces it. What
46
+ * still holds the apply here is chant's own gate (#2119), which is a fact on
47
+ * the `chant/lifecycle` branch and needs nothing from the forge.
48
+ *
49
+ * The gated-apply notice job (#2243) does not cross over either, for the same
50
+ * reason and by the same mechanism as `permissions:`: it shells to `gh`, which
51
+ * a Forgejo `act_runner` neither ships nor can point at its own instance, and
52
+ * it runs outside the Op's container image, where a hosted GitHub runner's
53
+ * preinstalled tools would be. So the doc rebuilt below simply does not carry
54
+ * `gatedNoticeDoc`, and the job is dropped. What does cross over is the half
55
+ * that needs no forge API: a `push` job still runs with `--gated-exit 0`, so a
56
+ * Forgejo apply that stops at its gate is a green run rather than a red one,
57
+ * and `chant run` still writes the gate and the approve command to
58
+ * `GITHUB_STEP_SUMMARY`, which Forgejo Actions sets like GitHub does.
19
59
  */
20
60
 
21
61
  import {
@@ -35,10 +75,40 @@ function forgejoize(value: Record<string, unknown>, dialect: ForgejoDialectOptio
35
75
  return transformWorkflowObject(value, dialect).value as Record<string, unknown>;
36
76
  }
37
77
 
78
+ /**
79
+ * Strip the job-level `environment:` github emitted (#2257). Forgejo Actions
80
+ * has no environments, so the key would read as a deployment gate and hold
81
+ * nothing back. Dropped from the rebuilt doc rather than from the dialect's
82
+ * own `DROPPED_KEYS`, so this touches the Op generator alone and leaves every
83
+ * other document the dialect transforms exactly as it emits today.
84
+ */
85
+ function withoutEnvironment(jobsDoc: Record<string, unknown>): Record<string, unknown> {
86
+ return Object.fromEntries(
87
+ Object.entries(jobsDoc).map(([jobName, job]) => {
88
+ if (typeof job !== "object" || job === null || !("environment" in job)) return [jobName, job];
89
+ const { environment: _dropped, ...rest } = job as Record<string, unknown>;
90
+ return [jobName, rest];
91
+ }),
92
+ );
93
+ }
94
+
95
+ /** Say in the generated file which environment gate did not survive the crossing. */
96
+ function droppedEnvironmentHeader(environmentName: string): string[] {
97
+ return [
98
+ `# chant dropped \`environment: ${environmentName}\` from this workflow: Forgejo Actions has no`,
99
+ "# environments, so there is no protection rule, required reviewer or wait timer for the key to",
100
+ "# name. This job is not held back by anything on the forge. What still stops the apply is chant's",
101
+ "# own gate: the run records a pending fact on the chant/lifecycle branch and ends, and `chant",
102
+ "# approve <op> <gate>` is what lets the next run through.",
103
+ ];
104
+ }
105
+
38
106
  /**
39
107
  * Synthesize one `.forgejo/workflows/*.yml` per scheduled Op. Reuses github's
40
- * trigger/job structure ({@link buildGithubOpPipelineDocs}), then applies the
41
- * Forgejo dialect and drops `permissions:` (ignored by the Forgejo runner).
108
+ * trigger/job structure ({@link buildGithubOpPipelineDocs}) its `setup`-step
109
+ * and additive-permission validation included, so an unpinned action ref is
110
+ * refused here on the same terms — then applies the Forgejo dialect and drops
111
+ * `permissions:` (ignored by the Forgejo runner).
42
112
  * Wired into core's Op generate mode via the forgejo lexicon plugin's
43
113
  * `generateOpPipeline` (../plugin.ts).
44
114
  */
@@ -47,16 +117,31 @@ export function generateForgejoOpPipeline(
47
117
  options: ComponentPipelineOptions = {},
48
118
  dialectOptions: ForgejoDialectOptions = {},
49
119
  ): OpPipelineResult {
120
+ for (const spec of ops) {
121
+ if (spec.findingMode === "comment") {
122
+ throw new Error(
123
+ `Scheduled Op "${spec.name}" has findingMode "comment", which posts its finding on the pull request ` +
124
+ `that triggered the run. That activity shells to \`gh\` against the GitHub API and reads the ` +
125
+ `GitHub Actions event payload; chant carries no Forgejo API client to post the equivalent comment ` +
126
+ `(#2231). Use findingMode "issue" here, or generate this Op for github.`,
127
+ );
128
+ }
129
+ }
130
+
50
131
  const { files, jobs } = buildGithubOpPipelineDocs(ops, options);
51
132
 
52
133
  return {
53
- files: files.map(({ name, doc }) => {
134
+ // One file per spec, in spec order, which is what lets the header below
135
+ // read its Op's own `environment` off the input by position.
136
+ files: files.map(({ name, doc }, index) => {
137
+ const environment = ops[index].environment;
54
138
  const forgejoDoc: GithubOpPipelineDoc = {
139
+ ...(environment ? { header: droppedEnvironmentHeader(environment.name) } : {}),
55
140
  on: forgejoize(doc.on, dialectOptions),
56
141
  ...(doc.env ? { env: forgejoize(doc.env, dialectOptions) } : {}),
57
142
  concurrency: forgejoize(doc.concurrency, dialectOptions),
58
143
  permissions: {},
59
- jobsDoc: forgejoize(doc.jobsDoc, dialectOptions),
144
+ jobsDoc: forgejoize(withoutEnvironment(doc.jobsDoc), dialectOptions),
60
145
  };
61
146
  return { name, yaml: emitOpPipelineYAML(forgejoDoc) };
62
147
  }),
@@ -151,5 +151,9 @@ describe("forgejoSerializer — inherits PrPlanReport from github (#1983)", () =
151
151
  // The sticky-comment mechanism is a raw script, nothing to remap or drop.
152
152
  expect(result.primary).toContain("Post or update PR comment");
153
153
  expect(result.primary).toContain("gh api");
154
+ // Including the `-F` that reads the body from plan.md — forgejo renders
155
+ // the same script, so the #2236 regression would show up here as well.
156
+ expect(result.primary).toContain("-F body=@plan.md");
157
+ expect(result.primary).not.toContain("-f body=@");
154
158
  });
155
159
  });