@intentius/chant-lexicon-forgejo 0.122.0 → 0.123.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.
@@ -29,62 +29,28 @@
29
29
  * the runner can fetch it.
30
30
  *
31
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.
32
+ * triggering pull request against `${GITHUB_API_URL}`, and a Forgejo Actions
33
+ * job already sets that (and `github.token`) the same way a GitHub Actions
34
+ * job does. This generator used to refuse the mode by name here, on the
35
+ * premise that chant had no way to reach a Forgejo instance; that premise
36
+ * was checked against a real Forgejo 12.0.4+gitea-1.22.0 instance during
37
+ * INTENTIUS/choudoufu#1027 and found false. Since chant #3757 the post goes
38
+ * through the Forgejo client in `packages/core/src/pr-forge.ts` over `fetch`
39
+ * rather than through `gh`, so the job's image needs no `gh` either.
42
40
  *
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 did not: `postOrUpdateGithubIssue`'s POST and
56
- * PATCH calls carried only `GH_TOKEN` (in fact, on the `issue` path, not even
57
- * that — `reconcilePr` handed 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. The same shape was what
65
- * `postOrUpdateComment`'s writes carried too, which is why #2304's "verified
66
- * against a real Forgejo instance" could not have gone through the generated
67
- * workflow's own credential path — either that session had a `gh auth login`
68
- * already stored for the test instance, which a real Actions job's fresh
69
- * checkout never has, or a different `gh` build was in play.
70
- *
71
- * Chant #2333 fixed that credential for both modes: `ghCredentialEnv` now
72
- * forwards the resolved token as `GH_ENTERPRISE_TOKEN` beside `GH_TOKEN`,
73
- * which is the variable `gh` reads for a host in neither of the two classes
74
- * above. #2333's own probe against the same image also narrowed the claim
75
- * made here: `GH_ENTERPRISE_TOKEN` authenticated the identical call *without*
76
- * a `GH_HOST` beside it, because the full URL #2291 built already names the
77
- * host. So the premise this refusal was reinstated on no longer holds, and
78
- * both halves of `postOrUpdateGithubIssue` — the paginated read and the
79
- * POST/PATCH write — were driven green end to end against a live
80
- * 12.0.4+gitea-1.22.0 instance under #2333, from a shell with no stored
81
- * `gh auth login`.
82
- *
83
- * The refusal below is nonetheless left standing here, because lifting it is
84
- * a generator behavior change with its own YAML surface to settle and #2333
85
- * was scoped to the credential. Lifting it is filed separately; this comment
86
- * is corrected rather than acted on so the stated reason does not outlive the
87
- * fact it rested on.
41
+ * `issue` crosses over too, since chant #2351. It was refused here from
42
+ * #2332 on, after chant #2315 drove the mode against a real Forgejo
43
+ * 12.0.4+gitea-1.22.0 instance: the paginated read and the `.pull_request ==
44
+ * null` filter behaved as on GitHub, but every POST and PATCH answered
45
+ * `{"message":"token is required"}` (HTTP 401), because `gh` sends
46
+ * `GH_TOKEN` only to github.com and `ghe.com` hosts. #2333 fixed that
47
+ * credential, and #3757 moved the mode off `gh` onto the Forgejo client in
48
+ * `packages/core/src/pr-forge.ts`, which sends `Authorization: token …`
49
+ * itself. The job this emits for a cron `issue` Op is github's, unchanged:
50
+ * the same trigger, and the same `GH_TOKEN`/`GITHUB_TOKEN` from
51
+ * `github.token` on the run step, which is the credential the mode reads.
52
+ * Only `permissions:` is dropped, as for every mode, so what that token may
53
+ * write on a scheduled run is the runner's configuration.
88
54
  *
89
55
  * A spec's `environment` (#2257) is dropped on the same terms as
90
56
  * `permissions:`, and for a stronger reason: Forgejo Actions has no
@@ -97,25 +63,18 @@
97
63
  * still holds the apply here is chant's own gate (#2119), which is a fact on
98
64
  * the `chant/lifecycle` branch and needs nothing from the forge.
99
65
  *
100
- * The gated-apply notice job (#2243) does not cross over either, for the same
101
- * reason and by the same mechanism as `permissions:`: it shells to `gh`, which
102
- * a Forgejo `act_runner` neither ships nor can point at its own instance, and
103
- * it runs outside the Op's container image, where a hosted GitHub runner's
104
- * preinstalled tools would be. So the doc rebuilt below simply does not carry
105
- * `gatedNoticeDoc`, and the job is dropped. What does cross over is the half
106
- * that needs no forge API: a `push` job still runs with `--gated-exit 0`, so a
107
- * Forgejo apply that stops at its gate is a green run rather than a red one,
108
- * and `chant run` still writes the gate and the approve command to
109
- * `GITHUB_STEP_SUMMARY`, which Forgejo Actions sets like GitHub does.
110
- *
111
- * The gate-notice job's own reason for being — `outputs: { gated, op, gate,
112
- * approve }` on the Op's job, and the `node -e` step that writes them to
113
- * `$GITHUB_OUTPUT` — does not survive it either (#2294). Both existed only for
114
- * that job to read via `needs.<job>.outputs`; with the job gone, so is every
115
- * reader, and `buildGithubOpPipelineDocs` is asked for neither
116
- * (`emitGatedOutputs: false` below) rather than emitting them here and
117
- * stripping them back out. Should the Forgejo posting work in #2291 grow a
118
- * notice job of its own, this is the one flag that brings both back.
66
+ * The gated-apply notice job (#2243) crosses over since #3773. It used to
67
+ * shell to `gh` on GitHub's hosted runner image, which a Forgejo runner
68
+ * neither ships nor can point at its own instance, so it was dropped here
69
+ * along with the apply job's `gated`/`op`/`gate`/`approve` outputs that
70
+ * only it read (#2294). It now runs in the Op's own image and posts through
71
+ * `@intentius/chant/pr-forge`, whose Forgejo client finds the pull request
72
+ * a commit merged with `commits/{sha}/pull` and sends `Authorization: token`
73
+ * itself. So the doc rebuilt below carries `gatedNoticeDoc` through the
74
+ * same dialect transform as the jobs, which drops its `permissions:` the
75
+ * way it drops every scope but `id-token`. A `push` job also still runs
76
+ * with `--gated-exit 0`, and `chant run` still writes the gate and the
77
+ * approve command to `GITHUB_STEP_SUMMARY`.
119
78
  *
120
79
  * A spec's own `variables` (#2290) crosses over unchanged: forgejo reuses the
121
80
  * same job-level `env:` github's builder emits, and the dialect transform
@@ -1 +1 @@
1
- {"version":3,"file":"generate-op-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+HG;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+ClB"}
1
+ {"version":3,"file":"generate-op-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsFG;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,CA6BlB"}
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "394736ec8b39447a6889f39cde46a43490537a102e326dd74841ab8f4f2d23cc",
4
+ "manifest.json": "151167f06f66d77050d96f35856fc324fddd493067350967d26bb1dc3c995c63",
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": "f28d7fb69fb1cfd992496ef41fda0ea5ccd54a29b84ea1c501a44f6a3992c4d5"
11
11
  },
12
- "composite": "0c39972e554f303c0ee13c68abff63d7f9105ca95350966af4200206cd4a7dc6"
12
+ "composite": "14703e6af2b10d8c29a0924bae768c39d2c913ba486a3fb0af8c7b285195062f"
13
13
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgejo",
3
- "version": "0.122.0",
3
+ "version": "0.123.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.122.0",
3
+ "version": "0.123.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.122.0",
58
- "@intentius/chant-lexicon-github": "^0.122.0"
57
+ "@intentius/chant": "^0.123.0",
58
+ "@intentius/chant-lexicon-github": "^0.123.0"
59
59
  },
60
60
  "scripts": {
61
61
  "generate": "tsx src/codegen/generate-cli.ts",
@@ -13,6 +13,9 @@ import { generateGithubOpPipeline } from "@intentius/chant-lexicon-github/compon
13
13
  import type { ScheduledOpSpec } from "@intentius/chant/lexicon";
14
14
 
15
15
  interface ParsedJob {
16
+ needs?: string;
17
+ if?: string;
18
+ permissions?: Record<string, string>;
16
19
  "runs-on"?: string;
17
20
  container?: string;
18
21
  environment?: Record<string, string>;
@@ -33,7 +36,7 @@ describe("generateForgejoOpPipeline: structure (github-shaped)", () => {
33
36
  test("one file per scheduled Op, each a valid workflow with one job", () => {
34
37
  const specs: ScheduledOpSpec[] = [
35
38
  { name: "actions-audit", schedule: "0 6 * * *" },
36
- { name: "prod-reconcile", schedule: "0 * * * *", findingMode: "pull-request" },
39
+ { name: "prod-reconcile", schedule: "0 * * * *", findingMode: "issue" },
37
40
  ];
38
41
  const result = generateForgejoOpPipeline(specs);
39
42
 
@@ -78,7 +81,7 @@ describe("generateForgejoOpPipeline: dialect applied", () => {
78
81
  describe("generateForgejoOpPipeline: non-cron trigger survives the dialect transform (#2084)", () => {
79
82
  test("a pull_request trigger round-trips through the Forgejo dialect, with permissions: still dropped", () => {
80
83
  const specs: ScheduledOpSpec[] = [
81
- { name: "tf-plan", trigger: { kind: "pull_request", branches: ["main"] }, findingMode: "pull-request" },
84
+ { name: "tf-plan", trigger: { kind: "pull_request", branches: ["main"] }, findingMode: "issue" },
82
85
  ];
83
86
  const fj = generateForgejoOpPipeline(specs);
84
87
  const gh = generateGithubOpPipeline(specs);
@@ -149,32 +152,41 @@ describe("generateForgejoOpPipeline: comment finding mode crosses over (#2291)",
149
152
  });
150
153
  });
151
154
 
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" }];
163
- expect(() => generateForgejoOpPipeline(specs)).toThrow(
164
- /Scheduled Op "prod-watch".*findingMode "issue".*token is required/s,
165
- );
166
- });
167
-
168
- test("github generates the same spec cleanly, so the refusal is forgejo's and not the shared builder's", () => {
155
+ describe("generateForgejoOpPipeline: issue finding mode crosses over (#2351)", () => {
156
+ // Refused here from #2332 to #2351: a real Forgejo instance answered 401 to
157
+ // the writes `gh` made for this mode, because `gh` never sent its token to
158
+ // a self-hosted host (#2315). #2333 fixed that credential, and #3757 moved
159
+ // the mode onto the forge client in `pr-forge.ts`, which sends the header
160
+ // itself. So the generator emits the mode as github does: same trigger,
161
+ // same job, same step env, with `permissions:` dropped by the dialect.
162
+ test("a cron issue Op generates cleanly", () => {
169
163
  const specs: ScheduledOpSpec[] = [{ name: "prod-watch", schedule: "0 6 * * *", findingMode: "issue" }];
170
- expect(() => generateGithubOpPipeline(specs)).not.toThrow();
164
+ expect(() => generateForgejoOpPipeline(specs)).not.toThrow();
171
165
  });
172
166
 
173
- test("a pull_request-triggered Op is refused the same way — issue mode needs no trigger to fail on", () => {
167
+ test("job/trigger/findingMode parity with the github generator", () => {
174
168
  const specs: ScheduledOpSpec[] = [
169
+ { name: "prod-watch", schedule: "0 6 * * *", findingMode: "issue" },
175
170
  { name: "tf-plan", trigger: { kind: "pull_request", branches: ["main"] }, findingMode: "issue" },
176
171
  ];
177
- expect(() => generateForgejoOpPipeline(specs)).toThrow(/findingMode "issue"/);
172
+ expect(generateForgejoOpPipeline(specs).jobs).toEqual(generateGithubOpPipeline(specs).jobs);
173
+ });
174
+
175
+ test("the cron job carries github.token as GH_TOKEN and GITHUB_TOKEN, the credential the mode reads", () => {
176
+ const specs: ScheduledOpSpec[] = [{ name: "prod-watch", schedule: "0 6 * * *", findingMode: "issue" }];
177
+ const fjDoc = parseFile(generateForgejoOpPipeline(specs).files[0].yaml);
178
+ const ghDoc = parseFile(generateGithubOpPipeline(specs).files[0].yaml);
179
+ const run = fjDoc.jobs!["prod-watch"].steps.at(-1)!;
180
+ expect(run.run).toBe("chant run prod-watch");
181
+ expect((run as { env?: Record<string, string> }).env).toEqual({
182
+ GITHUB_TOKEN: "${{ github.token }}",
183
+ GH_TOKEN: "${{ github.token }}",
184
+ });
185
+ expect(fjDoc.on).toEqual(ghDoc.on);
186
+ expect(fjDoc.permissions).toBeUndefined();
187
+ // github grants the scope the mode needs; on Forgejo what the token may
188
+ // write is the runner's configuration.
189
+ expect(ghDoc.permissions).toEqual({ contents: "read", issues: "write" });
178
190
  });
179
191
  });
180
192
 
@@ -238,14 +250,18 @@ describe("generateForgejoOpPipeline: the gated apply on push (#2243)", () => {
238
250
  expect(step?.run).toContain("chant run app-apply --gated-exit 0 --json");
239
251
  });
240
252
 
241
- test("the notice job does not: it shells to `gh`, which no Forgejo runner points at its own instance", () => {
253
+ test("the notice job crosses over too, posting through pr-forge rather than gh (#3773)", () => {
242
254
  const fj = parseFile(generateForgejoOpPipeline([pushSpec]).files[0].yaml);
243
- const gh = parseFile(generateGithubOpPipeline([pushSpec]).files[0].yaml);
244
- // Dropped the same way `permissions:` is dropped — by not being carried
245
- // onto the rebuilt doc — and github still has it, so the omission is
246
- // forgejo's rather than the shared builder's.
247
- expect(Object.keys(fj.jobs ?? {})).toEqual(["app-apply"]);
248
- expect(gh.jobs).toHaveProperty("app-apply-gate-notice");
255
+ expect(Object.keys(fj.jobs ?? {})).toEqual(["app-apply", "app-apply-gate-notice"]);
256
+ const notice = fj.jobs!["app-apply-gate-notice"];
257
+ expect(notice.needs).toBe("app-apply");
258
+ expect(notice.if).toBe("needs.app-apply.outputs.gated == 'true'");
259
+ expect(notice.container).toBe("node:22");
260
+ // The dialect drops the job's permissions like every scope but id-token.
261
+ expect(notice.permissions).toBeUndefined();
262
+ const script = notice.steps[0].run ?? "";
263
+ expect(script).toContain('from "@intentius/chant/pr-forge"');
264
+ expect(script).not.toMatch(/(^|\s)gh\s/m);
249
265
  });
250
266
 
251
267
  /**
@@ -262,7 +278,7 @@ describe("generateForgejoOpPipeline: the gated apply on push (#2243)", () => {
262
278
  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
279
  const doc = parseFile(generateForgejoOpPipeline([pushSpec]).files[0].yaml);
264
280
  const job = doc.jobs!["app-apply"];
265
- expect(job.container).toBe("node:22-slim");
281
+ expect(job.container).toBe("node:22");
266
282
  const step = job.steps.find((s) => s.id === "chant-run");
267
283
  expect(step?.shell).toBeUndefined();
268
284
  expect(step?.run).not.toContain("pipefail");
@@ -318,7 +334,7 @@ describe("generateForgejoOpPipeline: a dropped deployment environment (#2257)",
318
334
  "jobs:",
319
335
  " actions-audit:",
320
336
  " runs-on: docker",
321
- " container: node:22-slim",
337
+ " container: node:22",
322
338
  " steps:",
323
339
  " - uses: https://code.forgejo.org/actions/checkout@v7",
324
340
  " - run: chant run actions-audit",
@@ -329,7 +345,7 @@ describe("generateForgejoOpPipeline: a dropped deployment environment (#2257)",
329
345
 
330
346
  test("a spec with no environment emits the bytes it emitted before the option existed", () => {
331
347
  const yaml = generateForgejoOpPipeline([
332
- { name: "actions-audit", schedule: "0 6 * * *", findingMode: "pull-request" },
348
+ { name: "actions-audit", schedule: "0 6 * * *", findingMode: "issue" },
333
349
  ]).files[0].yaml;
334
350
  expect(yaml).toBe(AUDIT_YAML_BEFORE_2257);
335
351
  });
@@ -456,41 +472,23 @@ describe("generateForgejoOpPipeline: per-Op variables land on the job, not the w
456
472
  });
457
473
 
458
474
  /**
459
- * chant #2294 — the gated-apply outputs survive the notice job that consumed
460
- * them. Asserted directly against forgejo's own output: no `outputs:` on the
461
- * push job, no `node -e` line writing to `$GITHUB_OUTPUT`, while github's
462
- * generator (same spec) still carries both because its notice job still reads
463
- * them.
475
+ * chant #2294 dropped the gated-apply outputs on Forgejo while nothing read
476
+ * them; #3773 brings back the notice job that does, and the outputs with it.
464
477
  */
465
- describe("generateForgejoOpPipeline: no unconsumed outputs (#2294)", () => {
478
+ describe("generateForgejoOpPipeline: the outputs the notice job reads (#2294, #3773)", () => {
466
479
  const pushSpec: ScheduledOpSpec = { name: "live-apply", trigger: { kind: "push", branches: ["main"] } };
467
480
 
468
- test("the push job carries no outputs: block", () => {
469
- const doc = parseFile(generateForgejoOpPipeline([pushSpec]).files[0].yaml);
470
- expect(doc.jobs!["live-apply"].outputs).toBeUndefined();
471
- });
472
-
473
- test("the run step carries no node -e line writing to $GITHUB_OUTPUT", () => {
481
+ test("the push job publishes what it stopped on, as on github", () => {
474
482
  const doc = parseFile(generateForgejoOpPipeline([pushSpec]).files[0].yaml);
475
- const step = doc.jobs!["live-apply"].steps.find((s) => s.id === "chant-run");
476
- expect(step?.run).not.toContain("node -e");
477
- expect(step?.run).not.toContain("GITHUB_OUTPUT");
478
- // The tee'd invocation survives — it's what puts the run's own record in
479
- // the log, gate-notice job or not.
480
- expect(step?.run).toContain("| tee");
481
- expect(step?.run).toContain("chant run live-apply --gated-exit 0 --json");
482
- });
483
-
484
- test("github, generated from the same spec, still carries both — the drop is forgejo's dialect, not the shared builder", () => {
485
- const gh = parseFile(generateGithubOpPipeline([pushSpec]).files[0].yaml);
486
- const ghStep = gh.jobs!["live-apply"].steps.find((s) => s.id === "chant-run");
487
- expect(gh.jobs!["live-apply"].outputs).toEqual({
483
+ expect(doc.jobs!["live-apply"].outputs).toEqual({
488
484
  gated: "${{ steps.chant-run.outputs.gated }}",
489
485
  op: "${{ steps.chant-run.outputs.op }}",
490
486
  gate: "${{ steps.chant-run.outputs.gate }}",
491
487
  approve: "${{ steps.chant-run.outputs.approve }}",
492
488
  });
493
- expect(ghStep?.run).toContain("node -e");
489
+ const step = doc.jobs!["live-apply"].steps.find((s) => s.id === "chant-run");
490
+ expect(step?.run).toContain("GITHUB_OUTPUT");
491
+ expect(step?.run).toContain("chant run live-apply --gated-exit 0 --json");
494
492
  });
495
493
 
496
494
  test("a non-push (cron) job carries no outputs and no gate script either way", () => {
@@ -29,62 +29,28 @@
29
29
  * the runner can fetch it.
30
30
  *
31
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.
32
+ * triggering pull request against `${GITHUB_API_URL}`, and a Forgejo Actions
33
+ * job already sets that (and `github.token`) the same way a GitHub Actions
34
+ * job does. This generator used to refuse the mode by name here, on the
35
+ * premise that chant had no way to reach a Forgejo instance; that premise
36
+ * was checked against a real Forgejo 12.0.4+gitea-1.22.0 instance during
37
+ * INTENTIUS/choudoufu#1027 and found false. Since chant #3757 the post goes
38
+ * through the Forgejo client in `packages/core/src/pr-forge.ts` over `fetch`
39
+ * rather than through `gh`, so the job's image needs no `gh` either.
42
40
  *
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 did not: `postOrUpdateGithubIssue`'s POST and
56
- * PATCH calls carried only `GH_TOKEN` (in fact, on the `issue` path, not even
57
- * that — `reconcilePr` handed 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. The same shape was what
65
- * `postOrUpdateComment`'s writes carried too, which is why #2304's "verified
66
- * against a real Forgejo instance" could not have gone through the generated
67
- * workflow's own credential path — either that session had a `gh auth login`
68
- * already stored for the test instance, which a real Actions job's fresh
69
- * checkout never has, or a different `gh` build was in play.
70
- *
71
- * Chant #2333 fixed that credential for both modes: `ghCredentialEnv` now
72
- * forwards the resolved token as `GH_ENTERPRISE_TOKEN` beside `GH_TOKEN`,
73
- * which is the variable `gh` reads for a host in neither of the two classes
74
- * above. #2333's own probe against the same image also narrowed the claim
75
- * made here: `GH_ENTERPRISE_TOKEN` authenticated the identical call *without*
76
- * a `GH_HOST` beside it, because the full URL #2291 built already names the
77
- * host. So the premise this refusal was reinstated on no longer holds, and
78
- * both halves of `postOrUpdateGithubIssue` — the paginated read and the
79
- * POST/PATCH write — were driven green end to end against a live
80
- * 12.0.4+gitea-1.22.0 instance under #2333, from a shell with no stored
81
- * `gh auth login`.
82
- *
83
- * The refusal below is nonetheless left standing here, because lifting it is
84
- * a generator behavior change with its own YAML surface to settle and #2333
85
- * was scoped to the credential. Lifting it is filed separately; this comment
86
- * is corrected rather than acted on so the stated reason does not outlive the
87
- * fact it rested on.
41
+ * `issue` crosses over too, since chant #2351. It was refused here from
42
+ * #2332 on, after chant #2315 drove the mode against a real Forgejo
43
+ * 12.0.4+gitea-1.22.0 instance: the paginated read and the `.pull_request ==
44
+ * null` filter behaved as on GitHub, but every POST and PATCH answered
45
+ * `{"message":"token is required"}` (HTTP 401), because `gh` sends
46
+ * `GH_TOKEN` only to github.com and `ghe.com` hosts. #2333 fixed that
47
+ * credential, and #3757 moved the mode off `gh` onto the Forgejo client in
48
+ * `packages/core/src/pr-forge.ts`, which sends `Authorization: token …`
49
+ * itself. The job this emits for a cron `issue` Op is github's, unchanged:
50
+ * the same trigger, and the same `GH_TOKEN`/`GITHUB_TOKEN` from
51
+ * `github.token` on the run step, which is the credential the mode reads.
52
+ * Only `permissions:` is dropped, as for every mode, so what that token may
53
+ * write on a scheduled run is the runner's configuration.
88
54
  *
89
55
  * A spec's `environment` (#2257) is dropped on the same terms as
90
56
  * `permissions:`, and for a stronger reason: Forgejo Actions has no
@@ -97,25 +63,18 @@
97
63
  * still holds the apply here is chant's own gate (#2119), which is a fact on
98
64
  * the `chant/lifecycle` branch and needs nothing from the forge.
99
65
  *
100
- * The gated-apply notice job (#2243) does not cross over either, for the same
101
- * reason and by the same mechanism as `permissions:`: it shells to `gh`, which
102
- * a Forgejo `act_runner` neither ships nor can point at its own instance, and
103
- * it runs outside the Op's container image, where a hosted GitHub runner's
104
- * preinstalled tools would be. So the doc rebuilt below simply does not carry
105
- * `gatedNoticeDoc`, and the job is dropped. What does cross over is the half
106
- * that needs no forge API: a `push` job still runs with `--gated-exit 0`, so a
107
- * Forgejo apply that stops at its gate is a green run rather than a red one,
108
- * and `chant run` still writes the gate and the approve command to
109
- * `GITHUB_STEP_SUMMARY`, which Forgejo Actions sets like GitHub does.
110
- *
111
- * The gate-notice job's own reason for being — `outputs: { gated, op, gate,
112
- * approve }` on the Op's job, and the `node -e` step that writes them to
113
- * `$GITHUB_OUTPUT` — does not survive it either (#2294). Both existed only for
114
- * that job to read via `needs.<job>.outputs`; with the job gone, so is every
115
- * reader, and `buildGithubOpPipelineDocs` is asked for neither
116
- * (`emitGatedOutputs: false` below) rather than emitting them here and
117
- * stripping them back out. Should the Forgejo posting work in #2291 grow a
118
- * notice job of its own, this is the one flag that brings both back.
66
+ * The gated-apply notice job (#2243) crosses over since #3773. It used to
67
+ * shell to `gh` on GitHub's hosted runner image, which a Forgejo runner
68
+ * neither ships nor can point at its own instance, so it was dropped here
69
+ * along with the apply job's `gated`/`op`/`gate`/`approve` outputs that
70
+ * only it read (#2294). It now runs in the Op's own image and posts through
71
+ * `@intentius/chant/pr-forge`, whose Forgejo client finds the pull request
72
+ * a commit merged with `commits/{sha}/pull` and sends `Authorization: token`
73
+ * itself. So the doc rebuilt below carries `gatedNoticeDoc` through the
74
+ * same dialect transform as the jobs, which drops its `permissions:` the
75
+ * way it drops every scope but `id-token`. A `push` job also still runs
76
+ * with `--gated-exit 0`, and `chant run` still writes the gate and the
77
+ * approve command to `GITHUB_STEP_SUMMARY`.
119
78
  *
120
79
  * A spec's own `variables` (#2290) crosses over unchanged: forgejo reuses the
121
80
  * same job-level `env:` github's builder emits, and the dialect transform
@@ -186,31 +145,11 @@ export function generateForgejoOpPipeline(
186
145
  options: ComponentPipelineOptions = {},
187
146
  dialectOptions: ForgejoDialectOptions = {},
188
147
  ): OpPipelineResult {
189
- // `findingMode: "comment"` used to be refused by name here (#2231); lifted
190
- // in #2291 once a real Forgejo instance showed the forge itself was never
191
- // the obstacle — see the module doc above.
192
-
193
- // `findingMode: "issue"` is refused by name here (#2315): unlike `comment`,
194
- // a real Forgejo instance did not clear it — see the module doc above for
195
- // the reproduction.
196
- for (const spec of ops) {
197
- if (spec.findingMode === "issue") {
198
- throw new Error(
199
- `Scheduled Op "${spec.name}" has findingMode "issue", which opens or edits a GitHub-shaped issue by ` +
200
- `shelling to \`gh\`. Checked against a real Forgejo 12.0.4+gitea-1.22.0 instance (chant #2315): the ` +
201
- `search this mode does works there, but the POST/PATCH it writes with does not — \`gh\`'s own ` +
202
- `GH_TOKEN/GITHUB_TOKEN only authenticate a request to github.com or a ghe.com subdomain, never a ` +
203
- `self-hosted Forgejo, and neither this mode nor its caller sets GH_HOST or GH_ENTERPRISE_TOKEN, so ` +
204
- `every write fails with Forgejo's "token is required" (HTTP 401). Use findingMode "comment" on a ` +
205
- `pull_request trigger here, or generate this Op for github.`,
206
- );
207
- }
208
- }
148
+ // `findingMode: "comment"` used to be refused by name here (#2231), and
149
+ // `findingMode: "issue"` from #2332 to #2351. Neither is now: both post
150
+ // through the Forgejo client — see the module doc above.
209
151
 
210
- // `emitGatedOutputs: false` (#2294): forgejo never carries a gate-notice job
211
- // (`gatedNoticeDoc` never crosses the dialect, below), so the job outputs
212
- // and the `node -e` step that populate them would have no reader.
213
- const { files, jobs } = buildGithubOpPipelineDocs(ops, options, { emitGatedOutputs: false });
152
+ const { files, jobs } = buildGithubOpPipelineDocs(ops, options);
214
153
 
215
154
  return {
216
155
  // One file per spec, in spec order, which is what lets the header below
@@ -227,6 +166,8 @@ export function generateForgejoOpPipeline(
227
166
  concurrency: forgejoize(doc.concurrency, dialectOptions),
228
167
  permissions: {},
229
168
  jobsDoc: forgejoize(withoutEnvironment(doc.jobsDoc), dialectOptions),
169
+ // The gate-notice job (#3773), dialect applied like the Op's own job.
170
+ ...(doc.gatedNoticeDoc ? { gatedNoticeDoc: forgejoize(doc.gatedNoticeDoc, dialectOptions) } : {}),
230
171
  };
231
172
  return { name, yaml: emitOpPipelineYAML(forgejoDoc) };
232
173
  }),
@@ -47,4 +47,19 @@ describe("generateForgejoOpWavesPipeline", () => {
47
47
  expect(files[1]!.yaml).toContain("pull_request:");
48
48
  expect(files[1]!.yaml).toContain("run: chant run wave --spec waves.json --record-plans");
49
49
  });
50
+
51
+ test("a wave-level plan and apply, and stage hooks, render the same jobs: chant run wave runs them (#3762)", () => {
52
+ const { plan: _plan, ...rest } = spec;
53
+ const waveLevel: OpWavesSpec = {
54
+ ...rest,
55
+ planWave: ["./bin/plan-wave", "{runs}", "{plans}"],
56
+ applyWave: ["./bin/apply-wave", "{runs}"],
57
+ hooks: { before: { apply: [{ command: ["./bin/check", "{runs}"], onFailure: "approve" }] } },
58
+ };
59
+ const plain = generateForgejoOpWavesPipeline(spec, { specFile: "waves.json" });
60
+ const rendered = generateForgejoOpWavesPipeline(waveLevel, { specFile: "waves.json" });
61
+ expect(rendered.files).toEqual(plain.files);
62
+ expect(rendered.jobs).toEqual(plain.jobs);
63
+ expect(() => generateForgejoOpWavesPipeline({ ...waveLevel, hooks: { after: { apply: [{ command: ["./bin/smoke"], onFailure: "approve" }] } } }, { specFile: "waves.json" })).toThrow(/nothing left to hold/);
64
+ });
50
65
  });