@intentius/chant-lexicon-forgejo 0.61.0 → 0.63.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.
@@ -28,12 +28,52 @@
28
28
  * no mapping in ../actions.ts passes through verbatim and resolves only if
29
29
  * the runner can fetch it.
30
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.
31
+ * `comment` (#2231) crosses over, since chant #2291: it posts onto the
32
+ * triggering pull request by shelling to `gh` against `${GITHUB_API_URL}`,
33
+ * and a Forgejo Actions job already sets that (and `github.token`) the same
34
+ * way a GitHub Actions job does. This generator used to refuse the mode by
35
+ * name here, on the premise that chant had no way to point `gh` at a Forgejo
36
+ * instance; that premise was checked against a real Forgejo
37
+ * 12.0.4+gitea-1.22.0 instance during INTENTIUS/choudoufu#1027 and found
38
+ * false — the failure was `gh api` resolving a *relative* path against
39
+ * `/api/v3`, which Forgejo does not serve, not an unreachable forge. See
40
+ * `reconcilePr`'s `postOrUpdateComment` (`packages/core/src/op/activities/reconcile.ts`)
41
+ * for the fix.
42
+ *
43
+ * `issue` (`gh issue create`/`postOrUpdateGithubIssue`) does not cross over,
44
+ * and chant #2315 is why: it remained un-refused-but-unverified after #2304
45
+ * lifted `comment`'s refusal, and settling that gap the same way — against a
46
+ * real instance rather than a mock — found a real failure, so the refusal
47
+ * below is reinstated rather than lifted. Tested against the same
48
+ * 12.0.4+gitea-1.22.0 image (`codeberg.org/forgejo/forgejo:12`) with a repo,
49
+ * an issue, and a pull request created on it: the *read* half of
50
+ * `postOrUpdateGithubIssue` checks out fine on Forgejo — plain paginated
51
+ * `GET .../issues?state=open` (no `/search/issues`, confirmed absent from a
52
+ * live Forgejo's own OpenAPI spec) returns pull requests interleaved with
53
+ * issues exactly as GitHub's endpoint does, and the `.pull_request == null`
54
+ * filter and the marker `startswith` match both behave identically to
55
+ * GitHub. The *write* half does not: `postOrUpdateGithubIssue`'s POST and
56
+ * PATCH calls carry only `GH_TOKEN` (in fact, on the `issue` path, not even
57
+ * that — `reconcilePr` hands it `execAsync` with no `env` override at all,
58
+ * chant #2320), and `gh`'s own documented environment variables (`gh help
59
+ * environment`) scope `GH_TOKEN`/`GITHUB_TOKEN` to "github.com or a subdomain
60
+ * of ghe.com" — never a self-hosted Forgejo. A `GH_DEBUG=api` POST against
61
+ * the live instance, with `GH_TOKEN` and `GITHUB_API_URL` set exactly as the
62
+ * generated workflow sets them, sent no `Authorization` header at all and
63
+ * Forgejo answered `{"message":"token is required"}` (HTTP 401); adding
64
+ * `GH_HOST` alongside `GH_TOKEN` made no difference. Only `GH_ENTERPRISE_TOKEN`
65
+ * paired with a matching `GH_HOST` authenticated the same call. Since neither
66
+ * `postOrUpdateGithubIssue` nor its caller sets either, every write this mode
67
+ * makes on Forgejo fails — not a slow search, a 401 on every run. The same
68
+ * shape (`GH_TOKEN` only, no `GH_HOST`/`GH_ENTERPRISE_TOKEN`) is what
69
+ * `postOrUpdateComment`'s writes carry too, which means #2304's "verified
70
+ * against a real Forgejo instance" could not have gone through the generated
71
+ * workflow's own credential path — either that session had a `gh auth login`
72
+ * already stored for the test instance, which a real Actions job's fresh
73
+ * checkout never has, or a different `gh` build was in play. That is
74
+ * `comment` mode's problem to re-verify, not `issue` mode's, and out of
75
+ * scope for chant #2315; flagged here because finding it is what closes that
76
+ * issue's question of whether to lift or reinstate this refusal.
37
77
  *
38
78
  * A spec's `environment` (#2257) is dropped on the same terms as
39
79
  * `permissions:`, and for a stronger reason: Forgejo Actions has no
@@ -56,6 +96,24 @@
56
96
  * Forgejo apply that stops at its gate is a green run rather than a red one,
57
97
  * and `chant run` still writes the gate and the approve command to
58
98
  * `GITHUB_STEP_SUMMARY`, which Forgejo Actions sets like GitHub does.
99
+ *
100
+ * The gate-notice job's own reason for being — `outputs: { gated, op, gate,
101
+ * approve }` on the Op's job, and the `node -e` step that writes them to
102
+ * `$GITHUB_OUTPUT` — does not survive it either (#2294). Both existed only for
103
+ * that job to read via `needs.<job>.outputs`; with the job gone, so is every
104
+ * reader, and `buildGithubOpPipelineDocs` is asked for neither
105
+ * (`emitGatedOutputs: false` below) rather than emitting them here and
106
+ * stripping them back out. Should the Forgejo posting work in #2291 grow a
107
+ * notice job of its own, this is the one flag that brings both back.
108
+ *
109
+ * A spec's own `variables` (#2290) crosses over unchanged: forgejo reuses the
110
+ * same job-level `env:` github's builder emits, and the dialect transform
111
+ * touches only `permissions:`/`environment:`/action refs, never a job's `env:`
112
+ * mapping. That is what makes a per-Op credential expressible here at all —
113
+ * `ComponentPipelineOptions.variables` is workflow-scoped and, on Forgejo,
114
+ * Actions mints no OIDC token to put in `setup` instead, so a job-level
115
+ * `variables` entry is the only way one Op's job can hold a credential no
116
+ * sibling Op's job receives.
59
117
  */
60
118
  import type { ComponentPipelineOptions, OpPipelineResult, ScheduledOpSpec } from "@intentius/chant/lexicon";
61
119
  import { type ForgejoDialectOptions } from "../dialect.js";
@@ -1 +1 @@
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
+ {"version":3,"file":"generate-op-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoHG;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,CA4ClB"}
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "3ad50028d0c5d782a8ab2eb2698b266fc87cc318ca165f3afb975673e50fbc12",
4
+ "manifest.json": "320a18e182b02a18de130ee2975fae9e5b3c163c87d571f26fa74ac18e4f0f2b",
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": "6b8ee9eafa0ad60a4c7bff3c450faf3de13857d7b67766ec6a870f0695152fd6"
12
+ "composite": "52d7d99cea1db89b3e91637f18abf541193279984fdbf6cf6156e7c24378706c"
13
13
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgejo",
3
- "version": "0.61.0",
3
+ "version": "0.63.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.61.0",
3
+ "version": "0.63.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.61.0",
58
- "@intentius/chant-lexicon-github": "^0.61.0"
57
+ "@intentius/chant": "^0.63.0",
58
+ "@intentius/chant-lexicon-github": "^0.63.0"
59
59
  },
60
60
  "scripts": {
61
61
  "generate": "tsx src/codegen/generate-cli.ts",
@@ -14,9 +14,11 @@ import type { ScheduledOpSpec } from "@intentius/chant/lexicon";
14
14
 
15
15
  interface ParsedJob {
16
16
  "runs-on"?: string;
17
+ container?: string;
17
18
  environment?: Record<string, string>;
18
19
  outputs?: Record<string, string>;
19
- steps: Array<{ id?: string; uses?: string; run?: string }>;
20
+ env?: Record<string, string>;
21
+ steps: Array<{ id?: string; uses?: string; run?: string; shell?: string }>;
20
22
  }
21
23
  interface ParsedDoc {
22
24
  on?: Record<string, unknown>;
@@ -31,7 +33,7 @@ describe("generateForgejoOpPipeline: structure (github-shaped)", () => {
31
33
  test("one file per scheduled Op, each a valid workflow with one job", () => {
32
34
  const specs: ScheduledOpSpec[] = [
33
35
  { name: "actions-audit", schedule: "0 6 * * *" },
34
- { name: "prod-reconcile", schedule: "0 * * * *", findingMode: "issue" },
36
+ { name: "prod-reconcile", schedule: "0 * * * *", findingMode: "pull-request" },
35
37
  ];
36
38
  const result = generateForgejoOpPipeline(specs);
37
39
 
@@ -76,7 +78,7 @@ describe("generateForgejoOpPipeline: dialect applied", () => {
76
78
  describe("generateForgejoOpPipeline: non-cron trigger survives the dialect transform (#2084)", () => {
77
79
  test("a pull_request trigger round-trips through the Forgejo dialect, with permissions: still dropped", () => {
78
80
  const specs: ScheduledOpSpec[] = [
79
- { name: "tf-plan", trigger: { kind: "pull_request", branches: ["main"] }, findingMode: "issue" },
81
+ { name: "tf-plan", trigger: { kind: "pull_request", branches: ["main"] }, findingMode: "pull-request" },
80
82
  ];
81
83
  const fj = generateForgejoOpPipeline(specs);
82
84
  const gh = generateGithubOpPipeline(specs);
@@ -105,27 +107,74 @@ describe("generateForgejoOpPipeline: non-cron trigger survives the dialect trans
105
107
  });
106
108
  });
107
109
 
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.
110
+ describe("generateForgejoOpPipeline: comment finding mode crosses over (#2291)", () => {
111
+ // Used to be refused by name here (#2231), on the premise that chant had no
112
+ // way to point `gh` at a Forgejo instance. Checked against a real Forgejo
113
+ // 12.0.4+gitea-1.22.0 instance during INTENTIUS/choudoufu#1027 and found
114
+ // false: Forgejo's own `/api/v1` takes the same GET/POST/PATCH calls
115
+ // `reconcilePr`'s `postOrUpdateComment` makes, once it targets the right
116
+ // URL. See `packages/core/src/op/activities/reconcile.test.ts` for the
117
+ // mocked-transport proof of that fix; this file only proves the generator
118
+ // no longer refuses the mode and produces the same job shape github does.
119
+ test("findingMode comment generates cleanly on the pull_request trigger it needs", () => {
120
+ const specs: ScheduledOpSpec[] = [
121
+ { name: "app-plan", trigger: { kind: "pull_request", branches: ["main"] }, findingMode: "comment" },
122
+ ];
123
+ expect(() => generateForgejoOpPipeline(specs)).not.toThrow();
124
+ });
125
+
126
+ test("job/trigger/findingMode parity with the github generator (same input, same job list)", () => {
127
+ const specs: ScheduledOpSpec[] = [
128
+ { name: "app-plan", trigger: { kind: "pull_request", branches: ["main"] }, findingMode: "comment" },
129
+ ];
130
+ const fj = generateForgejoOpPipeline(specs);
131
+ const gh = generateGithubOpPipeline(specs);
132
+ expect(fj.jobs).toEqual(gh.jobs);
133
+ });
134
+
135
+ test("the run step carries GH_TOKEN/GITHUB_TOKEN, same as github's — permissions: still dropped by the dialect", () => {
115
136
  const specs: ScheduledOpSpec[] = [
116
137
  { name: "app-plan", trigger: { kind: "pull_request", branches: ["main"] }, findingMode: "comment" },
117
138
  ];
139
+ const fjYaml = generateForgejoOpPipeline(specs).files[0].yaml;
140
+ const ghDoc = parseFile(generateGithubOpPipeline(specs).files[0].yaml);
141
+
142
+ expect(fjYaml).toContain("GH_TOKEN: '${{ github.token }}'");
143
+ expect(fjYaml).toContain("GITHUB_TOKEN: '${{ github.token }}'");
144
+ expect(fjYaml).not.toMatch(/^permissions:/m);
145
+ // github still declares the scope the mode needs — proving the omission
146
+ // above is the Forgejo dialect dropping `permissions:` wholesale, not a
147
+ // sign the mode generated with no token.
148
+ expect(ghDoc.permissions).toEqual({ contents: "read", "pull-requests": "write" });
149
+ });
150
+ });
151
+
152
+ describe("generateForgejoOpPipeline: issue finding mode is refused by name (#2315)", () => {
153
+ // `comment` (above) was checked against a real Forgejo instance and cleared
154
+ // (#2291, #2304). `issue` stayed un-refused-but-unverified after that —
155
+ // this is the same settling, and it came out the other way: a real
156
+ // instance showed the write half of `postOrUpdateGithubIssue` cannot
157
+ // authenticate (`gh`'s GH_TOKEN never reaches a self-hosted Forgejo), so
158
+ // the refusal this mode used to carry (pre-#2304, for a different reason)
159
+ // is reinstated rather than left lifted. See the module doc above for the
160
+ // reproduction.
161
+ test("findingMode issue is refused, naming the Op and the real-instance failure", () => {
162
+ const specs: ScheduledOpSpec[] = [{ name: "prod-watch", schedule: "0 6 * * *", findingMode: "issue" }];
118
163
  expect(() => generateForgejoOpPipeline(specs)).toThrow(
119
- /Scheduled Op "app-plan".*findingMode "comment".*no Forgejo API client/s,
164
+ /Scheduled Op "prod-watch".*findingMode "issue".*token is required/s,
120
165
  );
121
166
  });
122
167
 
123
- test("github generates the same spec, so the refusal is forgejo's and not the shared builder's", () => {
168
+ test("github generates the same spec cleanly, so the refusal is forgejo's and not the shared builder's", () => {
169
+ const specs: ScheduledOpSpec[] = [{ name: "prod-watch", schedule: "0 6 * * *", findingMode: "issue" }];
170
+ expect(() => generateGithubOpPipeline(specs)).not.toThrow();
171
+ });
172
+
173
+ test("a pull_request-triggered Op is refused the same way — issue mode needs no trigger to fail on", () => {
124
174
  const specs: ScheduledOpSpec[] = [
125
- { name: "app-plan", trigger: { kind: "pull_request", branches: ["main"] }, findingMode: "comment" },
175
+ { name: "tf-plan", trigger: { kind: "pull_request", branches: ["main"] }, findingMode: "issue" },
126
176
  ];
127
- const gh = parseFile(generateGithubOpPipeline(specs).files[0].yaml);
128
- expect(gh.permissions).toEqual({ contents: "read", "pull-requests": "write" });
177
+ expect(() => generateForgejoOpPipeline(specs)).toThrow(/findingMode "issue"/);
129
178
  });
130
179
  });
131
180
 
@@ -198,6 +247,42 @@ describe("generateForgejoOpPipeline: the gated apply on push (#2243)", () => {
198
247
  expect(Object.keys(fj.jobs ?? {})).toEqual(["app-apply"]);
199
248
  expect(gh.jobs).toHaveProperty("app-apply-gate-notice");
200
249
  });
250
+
251
+ /**
252
+ * chant #2299 — Forgejo Actions runs the same act_runner semantics as
253
+ * GitHub Actions: a job with `container:` (which this generator's Op job
254
+ * always carries, unconditionally) defaults to `sh`, and `sh` rejects `set
255
+ * -o pipefail`. This generator reuses github's `buildGithubOpPipelineDocs`
256
+ * to build the job, then the dialect transform in ../dialect.ts to cross
257
+ * it, so the fix has to survive both: the exit-code capture is plain POSIX
258
+ * `sh` (chant #2321 — no `shell: bash` to drop or rename in the first
259
+ * place, unlike before #2321, when an unconditional `shell: bash` broke
260
+ * any consumer image without bash — see the sibling test below).
261
+ */
262
+ test("the exit-code capture crosses the Forgejo dialect with no shell override — its job runs in a container, whose default shell is sh", () => {
263
+ const doc = parseFile(generateForgejoOpPipeline([pushSpec]).files[0].yaml);
264
+ const job = doc.jobs!["app-apply"];
265
+ expect(job.container).toBe("node:22-slim");
266
+ const step = job.steps.find((s) => s.id === "chant-run");
267
+ expect(step?.shell).toBeUndefined();
268
+ expect(step?.run).not.toContain("pipefail");
269
+ expect(step?.run).toContain('[ "$code" -eq 0 ] || exit "$code"');
270
+ });
271
+
272
+ /**
273
+ * chant #2321 — same regression as the github generator's, since this one
274
+ * reuses `buildGithubOpPipelineDocs` to build the job before crossing the
275
+ * dialect: a consumer's non-bash `options.image` (alpine, distroless, …)
276
+ * must not get an unconditional `shell: bash` it cannot run.
277
+ */
278
+ test("a consumer-set non-bash image's gated job can still start on Forgejo (#2321)", () => {
279
+ const doc = parseFile(generateForgejoOpPipeline([pushSpec], { image: "alpine:3.20" }).files[0].yaml);
280
+ const job = doc.jobs!["app-apply"];
281
+ expect(job.container).toBe("alpine:3.20");
282
+ const step = job.steps.find((s) => s.id === "chant-run");
283
+ expect(step?.shell).toBeUndefined();
284
+ expect(step?.run).not.toContain("pipefail");
285
+ });
201
286
  });
202
287
 
203
288
  /**
@@ -239,7 +324,7 @@ describe("generateForgejoOpPipeline: a dropped deployment environment (#2257)",
239
324
 
240
325
  test("a spec with no environment emits the bytes it emitted before the option existed", () => {
241
326
  const yaml = generateForgejoOpPipeline([
242
- { name: "actions-audit", schedule: "0 6 * * *", findingMode: "issue" },
327
+ { name: "actions-audit", schedule: "0 6 * * *", findingMode: "pull-request" },
243
328
  ]).files[0].yaml;
244
329
  expect(yaml).toBe(AUDIT_YAML_BEFORE_2257);
245
330
  });
@@ -277,3 +362,129 @@ describe("generateForgejoOpPipeline: a dropped deployment environment (#2257)",
277
362
  ).toThrow(/neither an absolute http\(s\) URL nor a/);
278
363
  });
279
364
  });
365
+
366
+ /**
367
+ * chant #2290 — per-Op credentials, so a pull-request job need not hold the
368
+ * apply credential. The consuming project's own shape: `live-check` (no
369
+ * `variables`) carries no credential while `live-apply` (`variables` set)
370
+ * carries one, both generated from forge-wide `options.variables` that name
371
+ * no credential at all.
372
+ */
373
+ describe("generateForgejoOpPipeline: per-Op variables land on the job, not the workflow (#2290)", () => {
374
+ const FORGE_WIDE = { CHANT_FORGE: "forgejo", AWS_REGION: "${{ vars.AWS_REGION }}" };
375
+ const CREDENTIAL = { AWS_ACCESS_KEY_ID: "${{ secrets.AWS_ACCESS_KEY_ID }}" };
376
+
377
+ test("a job whose spec declares no variables carries none, even when the forge-wide options do", () => {
378
+ const doc = parseFile(
379
+ generateForgejoOpPipeline([{ name: "live-check", trigger: { kind: "pull_request", branches: ["main"] } }], {
380
+ variables: FORGE_WIDE,
381
+ }).files[0].yaml,
382
+ );
383
+ expect(doc.jobs!["live-check"].env).toBeUndefined();
384
+ });
385
+
386
+ test("a job whose spec declares variables carries them as the job's own env:, beside the workflow env:", () => {
387
+ const doc = parseFile(
388
+ generateForgejoOpPipeline(
389
+ [{ name: "live-apply", trigger: { kind: "push", branches: ["main"] }, variables: CREDENTIAL }],
390
+ { variables: FORGE_WIDE },
391
+ ).files[0].yaml,
392
+ );
393
+ expect(doc.jobs!["live-apply"].env).toEqual(CREDENTIAL);
394
+ });
395
+
396
+ /**
397
+ * The proof the issue names: a generated workflow whose `live-check` job
398
+ * carries no credential while its `live-apply` job does, from one forge-wide
399
+ * options object that itself names no credential — the property the
400
+ * consuming project (`examples/ci-pipelines`) needs to be able to state.
401
+ */
402
+ test("live-check carries no credential while live-apply does, from the same forge-wide options", () => {
403
+ const files = generateForgejoOpPipeline(
404
+ [
405
+ { name: "live-check", trigger: { kind: "pull_request", branches: ["main"] } },
406
+ { name: "live-apply", trigger: { kind: "push", branches: ["main"] }, variables: CREDENTIAL },
407
+ ],
408
+ { variables: FORGE_WIDE },
409
+ ).files;
410
+ const check = parseFile(files.find((f) => f.name === "live-check.yml")!.yaml);
411
+ const apply = parseFile(files.find((f) => f.name === "live-apply.yml")!.yaml);
412
+
413
+ expect(JSON.stringify(check.jobs!["live-check"])).not.toContain("AWS_ACCESS_KEY_ID");
414
+ expect(apply.jobs!["live-apply"].env).toEqual(CREDENTIAL);
415
+ });
416
+
417
+ test("a per-Op key wins over a same-named forge-wide one — the job is more specific", () => {
418
+ const doc = parseFile(
419
+ generateForgejoOpPipeline(
420
+ [
421
+ {
422
+ name: "live-apply",
423
+ trigger: { kind: "push", branches: ["main"] },
424
+ variables: { CHANT_FORGE: "overridden" },
425
+ },
426
+ ],
427
+ { variables: FORGE_WIDE },
428
+ ).files[0].yaml,
429
+ );
430
+ expect(doc.jobs!["live-apply"].env).toEqual({ CHANT_FORGE: "overridden" });
431
+ });
432
+
433
+ test("a spec declaring variables changes exactly the job's env: block and nothing else", () => {
434
+ const spec: ScheduledOpSpec = { name: "live-apply", trigger: { kind: "push", branches: ["main"] } };
435
+ const before = generateForgejoOpPipeline([spec]).files[0].yaml;
436
+ const after = generateForgejoOpPipeline([{ ...spec, variables: CREDENTIAL }]).files[0].yaml;
437
+ expect(after).toContain(" env:\n AWS_ACCESS_KEY_ID: '${{ secrets.AWS_ACCESS_KEY_ID }}'\n");
438
+ expect(
439
+ after.replace(" env:\n AWS_ACCESS_KEY_ID: '${{ secrets.AWS_ACCESS_KEY_ID }}'\n", ""),
440
+ ).toBe(before);
441
+ });
442
+ });
443
+
444
+ /**
445
+ * chant #2294 — the gated-apply outputs survive the notice job that consumed
446
+ * them. Asserted directly against forgejo's own output: no `outputs:` on the
447
+ * push job, no `node -e` line writing to `$GITHUB_OUTPUT`, while github's
448
+ * generator (same spec) still carries both because its notice job still reads
449
+ * them.
450
+ */
451
+ describe("generateForgejoOpPipeline: no unconsumed outputs (#2294)", () => {
452
+ const pushSpec: ScheduledOpSpec = { name: "live-apply", trigger: { kind: "push", branches: ["main"] } };
453
+
454
+ test("the push job carries no outputs: block", () => {
455
+ const doc = parseFile(generateForgejoOpPipeline([pushSpec]).files[0].yaml);
456
+ expect(doc.jobs!["live-apply"].outputs).toBeUndefined();
457
+ });
458
+
459
+ test("the run step carries no node -e line writing to $GITHUB_OUTPUT", () => {
460
+ const doc = parseFile(generateForgejoOpPipeline([pushSpec]).files[0].yaml);
461
+ const step = doc.jobs!["live-apply"].steps.find((s) => s.id === "chant-run");
462
+ expect(step?.run).not.toContain("node -e");
463
+ expect(step?.run).not.toContain("GITHUB_OUTPUT");
464
+ // The tee'd invocation survives — it's what puts the run's own record in
465
+ // the log, gate-notice job or not.
466
+ expect(step?.run).toContain("| tee");
467
+ expect(step?.run).toContain("chant run live-apply --gated-exit 0 --json");
468
+ });
469
+
470
+ test("github, generated from the same spec, still carries both — the drop is forgejo's dialect, not the shared builder", () => {
471
+ const gh = parseFile(generateGithubOpPipeline([pushSpec]).files[0].yaml);
472
+ const ghStep = gh.jobs!["live-apply"].steps.find((s) => s.id === "chant-run");
473
+ expect(gh.jobs!["live-apply"].outputs).toEqual({
474
+ gated: "${{ steps.chant-run.outputs.gated }}",
475
+ op: "${{ steps.chant-run.outputs.op }}",
476
+ gate: "${{ steps.chant-run.outputs.gate }}",
477
+ approve: "${{ steps.chant-run.outputs.approve }}",
478
+ });
479
+ expect(ghStep?.run).toContain("node -e");
480
+ });
481
+
482
+ test("a non-push (cron) job carries no outputs and no gate script either way", () => {
483
+ const doc = parseFile(
484
+ generateForgejoOpPipeline([{ name: "live-discover", schedule: "0 6 * * *" }]).files[0].yaml,
485
+ );
486
+ expect(doc.jobs!["live-discover"].outputs).toBeUndefined();
487
+ const step = doc.jobs!["live-discover"].steps.find((s) => typeof s.run === "string");
488
+ expect(step?.run).not.toContain("node -e");
489
+ });
490
+ });
@@ -28,12 +28,52 @@
28
28
  * no mapping in ../actions.ts passes through verbatim and resolves only if
29
29
  * the runner can fetch it.
30
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.
31
+ * `comment` (#2231) crosses over, since chant #2291: it posts onto the
32
+ * triggering pull request by shelling to `gh` against `${GITHUB_API_URL}`,
33
+ * and a Forgejo Actions job already sets that (and `github.token`) the same
34
+ * way a GitHub Actions job does. This generator used to refuse the mode by
35
+ * name here, on the premise that chant had no way to point `gh` at a Forgejo
36
+ * instance; that premise was checked against a real Forgejo
37
+ * 12.0.4+gitea-1.22.0 instance during INTENTIUS/choudoufu#1027 and found
38
+ * false — the failure was `gh api` resolving a *relative* path against
39
+ * `/api/v3`, which Forgejo does not serve, not an unreachable forge. See
40
+ * `reconcilePr`'s `postOrUpdateComment` (`packages/core/src/op/activities/reconcile.ts`)
41
+ * for the fix.
42
+ *
43
+ * `issue` (`gh issue create`/`postOrUpdateGithubIssue`) does not cross over,
44
+ * and chant #2315 is why: it remained un-refused-but-unverified after #2304
45
+ * lifted `comment`'s refusal, and settling that gap the same way — against a
46
+ * real instance rather than a mock — found a real failure, so the refusal
47
+ * below is reinstated rather than lifted. Tested against the same
48
+ * 12.0.4+gitea-1.22.0 image (`codeberg.org/forgejo/forgejo:12`) with a repo,
49
+ * an issue, and a pull request created on it: the *read* half of
50
+ * `postOrUpdateGithubIssue` checks out fine on Forgejo — plain paginated
51
+ * `GET .../issues?state=open` (no `/search/issues`, confirmed absent from a
52
+ * live Forgejo's own OpenAPI spec) returns pull requests interleaved with
53
+ * issues exactly as GitHub's endpoint does, and the `.pull_request == null`
54
+ * filter and the marker `startswith` match both behave identically to
55
+ * GitHub. The *write* half does not: `postOrUpdateGithubIssue`'s POST and
56
+ * PATCH calls carry only `GH_TOKEN` (in fact, on the `issue` path, not even
57
+ * that — `reconcilePr` hands it `execAsync` with no `env` override at all,
58
+ * chant #2320), and `gh`'s own documented environment variables (`gh help
59
+ * environment`) scope `GH_TOKEN`/`GITHUB_TOKEN` to "github.com or a subdomain
60
+ * of ghe.com" — never a self-hosted Forgejo. A `GH_DEBUG=api` POST against
61
+ * the live instance, with `GH_TOKEN` and `GITHUB_API_URL` set exactly as the
62
+ * generated workflow sets them, sent no `Authorization` header at all and
63
+ * Forgejo answered `{"message":"token is required"}` (HTTP 401); adding
64
+ * `GH_HOST` alongside `GH_TOKEN` made no difference. Only `GH_ENTERPRISE_TOKEN`
65
+ * paired with a matching `GH_HOST` authenticated the same call. Since neither
66
+ * `postOrUpdateGithubIssue` nor its caller sets either, every write this mode
67
+ * makes on Forgejo fails — not a slow search, a 401 on every run. The same
68
+ * shape (`GH_TOKEN` only, no `GH_HOST`/`GH_ENTERPRISE_TOKEN`) is what
69
+ * `postOrUpdateComment`'s writes carry too, which means #2304's "verified
70
+ * against a real Forgejo instance" could not have gone through the generated
71
+ * workflow's own credential path — either that session had a `gh auth login`
72
+ * already stored for the test instance, which a real Actions job's fresh
73
+ * checkout never has, or a different `gh` build was in play. That is
74
+ * `comment` mode's problem to re-verify, not `issue` mode's, and out of
75
+ * scope for chant #2315; flagged here because finding it is what closes that
76
+ * issue's question of whether to lift or reinstate this refusal.
37
77
  *
38
78
  * A spec's `environment` (#2257) is dropped on the same terms as
39
79
  * `permissions:`, and for a stronger reason: Forgejo Actions has no
@@ -56,6 +96,24 @@
56
96
  * Forgejo apply that stops at its gate is a green run rather than a red one,
57
97
  * and `chant run` still writes the gate and the approve command to
58
98
  * `GITHUB_STEP_SUMMARY`, which Forgejo Actions sets like GitHub does.
99
+ *
100
+ * The gate-notice job's own reason for being — `outputs: { gated, op, gate,
101
+ * approve }` on the Op's job, and the `node -e` step that writes them to
102
+ * `$GITHUB_OUTPUT` — does not survive it either (#2294). Both existed only for
103
+ * that job to read via `needs.<job>.outputs`; with the job gone, so is every
104
+ * reader, and `buildGithubOpPipelineDocs` is asked for neither
105
+ * (`emitGatedOutputs: false` below) rather than emitting them here and
106
+ * stripping them back out. Should the Forgejo posting work in #2291 grow a
107
+ * notice job of its own, this is the one flag that brings both back.
108
+ *
109
+ * A spec's own `variables` (#2290) crosses over unchanged: forgejo reuses the
110
+ * same job-level `env:` github's builder emits, and the dialect transform
111
+ * touches only `permissions:`/`environment:`/action refs, never a job's `env:`
112
+ * mapping. That is what makes a per-Op credential expressible here at all —
113
+ * `ComponentPipelineOptions.variables` is workflow-scoped and, on Forgejo,
114
+ * Actions mints no OIDC token to put in `setup` instead, so a job-level
115
+ * `variables` entry is the only way one Op's job can hold a credential no
116
+ * sibling Op's job receives.
59
117
  */
60
118
 
61
119
  import {
@@ -117,18 +175,31 @@ export function generateForgejoOpPipeline(
117
175
  options: ComponentPipelineOptions = {},
118
176
  dialectOptions: ForgejoDialectOptions = {},
119
177
  ): OpPipelineResult {
178
+ // `findingMode: "comment"` used to be refused by name here (#2231); lifted
179
+ // in #2291 once a real Forgejo instance showed the forge itself was never
180
+ // the obstacle — see the module doc above.
181
+
182
+ // `findingMode: "issue"` is refused by name here (#2315): unlike `comment`,
183
+ // a real Forgejo instance did not clear it — see the module doc above for
184
+ // the reproduction.
120
185
  for (const spec of ops) {
121
- if (spec.findingMode === "comment") {
186
+ if (spec.findingMode === "issue") {
122
187
  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.`,
188
+ `Scheduled Op "${spec.name}" has findingMode "issue", which opens or edits a GitHub-shaped issue by ` +
189
+ `shelling to \`gh\`. Checked against a real Forgejo 12.0.4+gitea-1.22.0 instance (chant #2315): the ` +
190
+ `search this mode does works there, but the POST/PATCH it writes with does not \`gh\`'s own ` +
191
+ `GH_TOKEN/GITHUB_TOKEN only authenticate a request to github.com or a ghe.com subdomain, never a ` +
192
+ `self-hosted Forgejo, and neither this mode nor its caller sets GH_HOST or GH_ENTERPRISE_TOKEN, so ` +
193
+ `every write fails with Forgejo's "token is required" (HTTP 401). Use findingMode "comment" on a ` +
194
+ `pull_request trigger here, or generate this Op for github.`,
127
195
  );
128
196
  }
129
197
  }
130
198
 
131
- const { files, jobs } = buildGithubOpPipelineDocs(ops, options);
199
+ // `emitGatedOutputs: false` (#2294): forgejo never carries a gate-notice job
200
+ // (`gatedNoticeDoc` never crosses the dialect, below), so the job outputs
201
+ // and the `node -e` step that populate them would have no reader.
202
+ const { files, jobs } = buildGithubOpPipelineDocs(ops, options, { emitGatedOutputs: false });
132
203
 
133
204
  return {
134
205
  // One file per spec, in spec order, which is what lets the header below
@@ -128,10 +128,27 @@ describe("forgejoSerializer — multi-workflow output", () => {
128
128
  // "@intentius/chant-lexicon-github"`); nothing forgejo-specific is written for
129
129
  // it. This is the functional check that inheritance actually holds, not just
130
130
  // that the type is importable: the dialect still drops the job's
131
- // `permissions` and remaps its runner label, while the sticky-comment step —
132
- // a plain `gh api` script with no `uses:` at all — passes through untouched,
133
- // since Forgejo's API accepts the same calls against its GitHub-compatible
134
- // surface.
131
+ // `permissions` and remaps its runner label, and the sticky-comment step — a
132
+ // plain `gh api` script with no `uses:` at all — passes through the dialect
133
+ // transform untouched, because the dialect only ever rewrites job-level
134
+ // `permissions:`/`environment:`/action refs and never looks at step content.
135
+ //
136
+ // That passing-through is a fact about serialization, not about whether the
137
+ // script would work if run: chant #2291 settled, against a real Forgejo
138
+ // 12.0.4+gitea-1.22.0 instance, that Forgejo's `/api/v1` does accept the same
139
+ // GET/POST/PATCH calls this script makes — but only when they target a full
140
+ // URL. Until chant #2305, this script's `gh api "repos/$REPO/issues/…"` took
141
+ // a bare relative path, which `gh` resolves against `/api/v3` on any host but
142
+ // github.com, and Forgejo does not serve `/api/v3`. `reconcilePr`'s
143
+ // `postOrUpdateComment` got the URL fix #2291 made
144
+ // (`packages/core/src/op/activities/reconcile.ts`), and `PrPlanReport`'s own
145
+ // script (`lexicons/github/src/composites/pr-plan-report.ts`) — out of
146
+ // #2291's scope, being the component composite rather than the Op generator —
147
+ // got the equivalent fix in #2305: every call now targets `$api_base`, built
148
+ // from `$GITHUB_API_URL` inline in the shell script. The dialect still leaves
149
+ // step content untouched, so that base-URL construction crosses over exactly
150
+ // as written, which is what makes the untouched script below a working
151
+ // Forgejo comment rather than just a passed-through one.
135
152
  describe("forgejoSerializer — inherits PrPlanReport from github (#1983)", () => {
136
153
  test("dialect still applies to a github-lexicon composite's job", () => {
137
154
  const workflow = new Workflow({
@@ -154,6 +171,10 @@ describe("forgejoSerializer — inherits PrPlanReport from github (#1983)", () =
154
171
  // Including the `-F` that reads the body from plan.md — forgejo renders
155
172
  // the same script, so the #2236 regression would show up here as well.
156
173
  expect(result.primary).toContain("-F body=@plan.md");
174
+ // And the #2305 fix: the resolved-base construction and every `gh api`
175
+ // call built from it cross the dialect untouched too.
176
+ expect(result.primary).toContain('api_base="${GITHUB_API_URL%/}"');
177
+ expect(result.primary).not.toMatch(/gh api[^\n]*"repos\//);
157
178
  expect(result.primary).not.toContain("-f body=@");
158
179
  });
159
180
  });