@intentius/chant-lexicon-github 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.
@@ -114,11 +114,10 @@ export interface GithubOpPipelineDoc {
114
114
  jobsDoc: Record<string, unknown>;
115
115
  /**
116
116
  * The gated-apply notice job (#2243), when this Op's trigger is `push`.
117
- * Kept out of {@link jobsDoc} so a dialect that cannot run it drops it by
118
- * simply not copying it: the job shells to `gh` against the GitHub API and
119
- * needs `gh` on the runner, which is the same reason the `comment` finding
120
- * mode is refused on forgejo (#2231). {@link emitOpPipelineYAML} merges it
121
- * into `jobs:` for the forges that can. GitLab reaches the same outcome
117
+ * Kept out of {@link jobsDoc} so a dialect transforms it on its own terms;
118
+ * the forgejo generator carries it across since #3773, when the job moved
119
+ * off `gh` onto `@intentius/chant/pr-forge`. {@link emitOpPipelineYAML}
120
+ * merges it into `jobs:`. GitLab reaches the same outcome
122
121
  * without this job at all: its push job writes the pending block to an
123
122
  * artifact instead (#2256).
124
123
  */
@@ -174,23 +173,6 @@ export declare function assertEnvironment(name: string, environment: OpEnvironme
174
173
  * push run, grants write access no step in the generated job can spend.
175
174
  */
176
175
  export declare function mergePermissions(name: string, base: Record<string, "read" | "write">, additive: Record<string, "read" | "write">, trigger: OpTrigger): Record<string, "read" | "write">;
177
- /**
178
- * Internal knobs beside the public {@link GenerateGithubOpOptions} — not part
179
- * of that type because they are not something a project author sets, only
180
- * something a dialect on top of this builder (forgejo, #2294) needs to
181
- * change about the shape this function emits for every spec alike.
182
- */
183
- export interface BuildGithubOpPipelineDocsInternalOptions {
184
- /**
185
- * Whether a `push` job's outputs (`gated`/`op`/`gate`/`approve`) and the
186
- * `node -e` step that writes them to `$GITHUB_OUTPUT` are emitted (#2294).
187
- * Default true (github's own behavior, unchanged): the gate-notice job
188
- * beside it reads them. The forgejo generator passes `false` — it never
189
- * carries a gate-notice job (`gatedNoticeDoc` never crosses the dialect),
190
- * so nothing would ever read them there.
191
- */
192
- emitGatedOutputs?: boolean;
193
- }
194
176
  /**
195
177
  * Build one `GithubOpPipelineDoc` per scheduled Op: its trigger, its `setup`
196
178
  * steps, least-privilege `permissions:` for its finding-mode plus whatever
@@ -203,7 +185,7 @@ export interface BuildGithubOpPipelineDocsInternalOptions {
203
185
  * assertSetupSteps}), or a `permissions` entry that is not additive ({@link
204
186
  * mergePermissions}).
205
187
  */
206
- export declare function buildGithubOpPipelineDocs(ops: ScheduledOpSpec[], options?: GenerateGithubOpOptions, internalOptions?: BuildGithubOpPipelineDocsInternalOptions): {
188
+ export declare function buildGithubOpPipelineDocs(ops: ScheduledOpSpec[], options?: GenerateGithubOpOptions): {
207
189
  files: GithubOpPipelineFile[];
208
190
  jobs: OpPipelineJob[];
209
191
  };
@@ -1 +1 @@
1
- {"version":3,"file":"generate-op-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsEG;AAIH,OAAO,KAAK,EACV,wBAAwB,IAAI,uBAAuB,EACnD,aAAa,EAEb,aAAa,EACb,gBAAgB,IAAI,sBAAsB,EAC1C,WAAW,EACX,SAAS,EAET,eAAe,EAChB,MAAM,0BAA0B,CAAC;AAGlC,YAAY,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,CAAC;AAEhE;;;;;;GAMG;AACH,MAAM,WAAW,mBAAmB;IAClC;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB;;;;OAIG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,EAAE,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC5B,2DAA2D;IAC3D,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9B,6DAA6D;IAC7D,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC;;;;OAIG;IACH,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC,8DAA8D;IAC9D,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC;;;;;;;;;OASG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC1C;AAED,qFAAqF;AACrF,MAAM,WAAW,oBAAoB;IACnC,4FAA4F;IAC5F,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,mBAAmB,CAAC;CAC1B;AAmUD;;;;;;;;;GASG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,EAAE,GAAG,IAAI,CA8BzE;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,aAAa,GAAG,IAAI,CAwBhF;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,EACtC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,EAC1C,OAAO,EAAE,SAAS,GACjB,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,CAqClC;AA6BD;;;;;GAKG;AACH,MAAM,WAAW,wCAAwC;IACvD;;;;;;;OAOG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,yBAAyB,CACvC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,EACrC,eAAe,GAAE,wCAA6C,GAC7D;IAAE,KAAK,EAAE,oBAAoB,EAAE,CAAC;IAAC,IAAI,EAAE,aAAa,EAAE,CAAA;CAAE,CAkG1D;AAwCD;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,mBAAmB,GAAG,MAAM,CAgBnE;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CACtC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,GACpC,sBAAsB,CAMxB"}
1
+ {"version":3,"file":"generate-op-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsEG;AAKH,OAAO,KAAK,EACV,wBAAwB,IAAI,uBAAuB,EACnD,aAAa,EAEb,aAAa,EACb,gBAAgB,IAAI,sBAAsB,EAC1C,WAAW,EACX,SAAS,EAET,eAAe,EAChB,MAAM,0BAA0B,CAAC;AAGlC,YAAY,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,CAAC;AAEhE;;;;;;GAMG;AACH,MAAM,WAAW,mBAAmB;IAClC;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB;;;;OAIG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,EAAE,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC5B,2DAA2D;IAC3D,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9B,6DAA6D;IAC7D,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC;;;;OAIG;IACH,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC,8DAA8D;IAC9D,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC;;;;;;;;OAQG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC1C;AAED,qFAAqF;AACrF,MAAM,WAAW,oBAAoB;IACnC,4FAA4F;IAC5F,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,mBAAmB,CAAC;CAC1B;AAgVD;;;;;;;;;GASG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,EAAE,GAAG,IAAI,CA8BzE;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,aAAa,GAAG,IAAI,CAwBhF;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,EACtC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,EAC1C,OAAO,EAAE,SAAS,GACjB,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,CAqClC;AA6BD;;;;;;;;;;;GAWG;AACH,wBAAgB,yBAAyB,CACvC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,GACpC;IAAE,KAAK,EAAE,oBAAoB,EAAE,CAAC;IAAC,IAAI,EAAE,aAAa,EAAE,CAAA;CAAE,CAiG1D;AAwCD;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,mBAAmB,GAAG,MAAM,CAenE;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CACtC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,GACpC,sBAAsB,CAMxB"}
@@ -23,6 +23,10 @@
23
23
  * `pull-requests: read`, so a wave can read the merged pull request's
24
24
  * reviews, and a second workflow, `<name>-plans.yml`, runs `chant run wave
25
25
  * --record-plans` on each pull request to record the digests its head plans.
26
+ *
27
+ * With `notify` on the spec (#3761), the workflow maps each variable it names
28
+ * from the repository secret of the same name, so a wave job can post to
29
+ * Slack, Teams or a signed webhook. `options.variables` wins over the mapping.
26
30
  */
27
31
  import { type OpWavesSpec } from "@intentius/chant/op/op-waves";
28
32
  import type { OpWavesPipelineOptions, OpWavesPipelineResult } from "@intentius/chant/lexicon";
@@ -1 +1 @@
1
- {"version":3,"file":"generate-op-waves-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-waves-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,EAAoC,KAAK,WAAW,EAAE,MAAM,8BAA8B,CAAC;AAClG,OAAO,KAAK,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,0BAA0B,CAAC;AAE9F,OAAO,EAIL,KAAK,mBAAmB,EACzB,MAAM,wBAAwB,CAAC;AAEhC,yFAAyF;AACzF,eAAO,MAAM,cAAc,YAAY,CAAC;AAExC,oEAAoE;AACpE,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,IAAI,CAAC,WAAW,EAAE,MAAM,CAAC,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAE1F;AAOD,qEAAqE;AACrE,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,WAAW,EACjB,OAAO,EAAE,sBAAsB,GAC9B;IAAE,GAAG,EAAE,mBAAmB,CAAC;IAAC,IAAI,EAAE,qBAAqB,CAAC,MAAM,CAAC,CAAC;IAAC,SAAS,CAAC,EAAE,mBAAmB,CAAC;IAAC,QAAQ,CAAC,EAAE,mBAAmB,CAAA;CAAE,CAgEpI;AA4CD,4EAA4E;AAC5E,wBAAgB,6BAA6B,CAAC,IAAI,EAAE,WAAW,EAAE,OAAO,EAAE,sBAAsB,GAAG,qBAAqB,CAMvH"}
1
+ {"version":3,"file":"generate-op-waves-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-waves-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAGH,OAAO,EAAoC,KAAK,WAAW,EAAE,MAAM,8BAA8B,CAAC;AAClG,OAAO,KAAK,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,0BAA0B,CAAC;AAE9F,OAAO,EAIL,KAAK,mBAAmB,EACzB,MAAM,wBAAwB,CAAC;AAEhC,yFAAyF;AACzF,eAAO,MAAM,cAAc,YAAY,CAAC;AAExC,oEAAoE;AACpE,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,IAAI,CAAC,WAAW,EAAE,MAAM,CAAC,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAE1F;AAOD,qEAAqE;AACrE,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,WAAW,EACjB,OAAO,EAAE,sBAAsB,GAC9B;IAAE,GAAG,EAAE,mBAAmB,CAAC;IAAC,IAAI,EAAE,qBAAqB,CAAC,MAAM,CAAC,CAAC;IAAC,SAAS,CAAC,EAAE,mBAAmB,CAAC;IAAC,QAAQ,CAAC,EAAE,mBAAmB,CAAA;CAAE,CAkEpI;AA4CD,4EAA4E;AAC5E,wBAAgB,6BAA6B,CAAC,IAAI,EAAE,WAAW,EAAE,OAAO,EAAE,sBAAsB,GAAG,qBAAqB,CAMvH"}
@@ -1 +1 @@
1
- {"version":3,"file":"pr-plan-report.d.ts","sourceRoot":"","sources":["../../src/composites/pr-plan-report.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,GAAG,EAAqB,MAAM,oBAAoB,CAAC;AAI5D,MAAM,WAAW,iBAAiB;IAChC;;;OAGG;IACH,WAAW,EAAE,MAAM,CAAC;IACpB,4EAA4E;IAC5E,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,kEAAkE;IAClE,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,+CAA+C;IAC/C,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,iEAAiE;IACjE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,+DAA+D;IAC/D,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB;;;;;OAKG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,6DAA6D;IAC7D,QAAQ,CAAC,EAAE;QACT,GAAG,CAAC,EAAE,OAAO,CAAC,qBAAqB,CAAC,OAAO,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;KACrD,CAAC;CACH;AA2CD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,YAAY;;EAkEP,CAAC"}
1
+ {"version":3,"file":"pr-plan-report.d.ts","sourceRoot":"","sources":["../../src/composites/pr-plan-report.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,GAAG,EAAqB,MAAM,oBAAoB,CAAC;AAI5D,MAAM,WAAW,iBAAiB;IAChC;;;OAGG;IACH,WAAW,EAAE,MAAM,CAAC;IACpB,4EAA4E;IAC5E,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,kEAAkE;IAClE,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,+CAA+C;IAC/C,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,iEAAiE;IACjE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,+DAA+D;IAC/D,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB;;;;;OAKG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,6DAA6D;IAC7D,QAAQ,CAAC,EAAE;QACT,GAAG,CAAC,EAAE,OAAO,CAAC,qBAAqB,CAAC,OAAO,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;KACrD,CAAC;CACH;AAqDD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,YAAY;;EAqEP,CAAC"}
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "0561ec74f93d01ae2522f2209a5762fab154580cbd4ef770ffe901ecddcde603",
4
+ "manifest.json": "ddb4b5dde531f86c72dfd39f31cf9a136cac1502167761af71c68c30cf7a456d",
5
5
  "meta.json": "c50e0ef3845c370c3ad2210b8bba455980417c0f818aa6ed46e06d4fb8b1b9f9",
6
6
  "types/index.d.ts": "0ead95536e947ddfb98a5acf0ddb88547913b5a53a342df67711c59266f84b3e",
7
7
  "rules/deprecated-action-version.ts": "d41e6e532ab7f623af1bee4ac5279fcb2baada7defa1c5d022a5bc71983e8797",
@@ -78,5 +78,5 @@
78
78
  "skills/chant-github-patterns.md": "bb3abef289a8fdfcf07d6bb2d7289dcb2f38bc0cb0321ea320b78b45a6f548c0",
79
79
  "skills/chant-github-security.md": "aab111cb0871cad30281ce48d7da23663689619351029219e2be019a1a61e394"
80
80
  },
81
- "composite": "403d789cf4d232bae6da4d851ddee1506e9e21c747f4e846d35e0740d88678c9"
81
+ "composite": "8ce3c463bf312052392409997e3971a57aee545fec0127b566c4200c425900e5"
82
82
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github",
3
- "version": "0.122.0",
3
+ "version": "0.123.0",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "GitHub",
6
6
  "intrinsics": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-github",
3
- "version": "0.122.0",
3
+ "version": "0.123.0",
4
4
  "description": "GitHub Actions lexicon for chant — declarative IaC in TypeScript",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -61,7 +61,7 @@
61
61
  "typescript": "^5.9.3"
62
62
  },
63
63
  "peerDependencies": {
64
- "@intentius/chant": "^0.122.0",
64
+ "@intentius/chant": "^0.123.0",
65
65
  "typescript": "^5.9.3"
66
66
  }
67
67
  }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * chant #3773: the gate-notice job's script, run.
3
+ *
4
+ * The generator emits a node script that posts through
5
+ * `@intentius/chant/pr-forge`. These tests take that script out of the
6
+ * generated workflow, run it with tsx against this checkout's chant, the way
7
+ * the job runs it against the release it installs, and answer its calls from
8
+ * a fake forge: GitHub's shapes at an `api.github.com`-style base, Forgejo's
9
+ * at `/api/v1`.
10
+ */
11
+
12
+ import { execFile } from "node:child_process";
13
+ import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
14
+ import { createServer, type IncomingMessage, type Server } from "node:http";
15
+ import type { AddressInfo } from "node:net";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+ import { promisify } from "node:util";
20
+ import { afterEach, describe, expect, test } from "vitest";
21
+ import { parseYAML } from "@intentius/chant/yaml";
22
+ import { generateGithubOpPipeline } from "./generate-op-pipeline";
23
+
24
+ const run = promisify(execFile);
25
+ const REPO = fileURLToPath(new URL("../../../../", import.meta.url));
26
+ const SHA = "0123456789abcdef0123456789abcdef01234567";
27
+
28
+ interface Call {
29
+ method: string;
30
+ url: string;
31
+ auth?: string;
32
+ body?: Record<string, unknown>;
33
+ }
34
+
35
+ /** The node script inside the generated notice step's heredoc. */
36
+ function noticeScript(): string {
37
+ const yaml = generateGithubOpPipeline([{ name: "app-apply", trigger: { kind: "push", branches: ["main"] } }]).files[0].yaml;
38
+ const doc = parseYAML(yaml) as { jobs: Record<string, { steps: Array<{ run?: string }> }> };
39
+ const shell = doc.jobs["app-apply-gate-notice"].steps[0].run ?? "";
40
+ const m = /cat > notice\.mjs <<'NOTICE'\n([\s\S]*?)\nNOTICE\n/.exec(shell);
41
+ if (!m) throw new Error("no notice.mjs heredoc in the notice step");
42
+ return m[1];
43
+ }
44
+
45
+ const dirs: string[] = [];
46
+
47
+ /** A directory laid out like the job's: notice.mjs, and chant and tsx under node_modules. */
48
+ function jobDir(): string {
49
+ const dir = mkdtempSync(join(tmpdir(), "chant-gate-notice-"));
50
+ dirs.push(dir);
51
+ mkdirSync(join(dir, "node_modules", "@intentius"), { recursive: true });
52
+ symlinkSync(join(REPO, "packages", "core"), join(dir, "node_modules", "@intentius", "chant"));
53
+ symlinkSync(join(REPO, "node_modules", "tsx"), join(dir, "node_modules", "tsx"));
54
+ writeFileSync(join(dir, "notice.mjs"), noticeScript());
55
+ return dir;
56
+ }
57
+
58
+ function readBody(req: IncomingMessage): Promise<Record<string, unknown> | undefined> {
59
+ return new Promise((resolve) => {
60
+ let text = "";
61
+ req.on("data", (c) => (text += c));
62
+ req.on("end", () => resolve(text ? (JSON.parse(text) as Record<string, unknown>) : undefined));
63
+ });
64
+ }
65
+
66
+ let server: Server | undefined;
67
+ afterEach(() => {
68
+ server?.close();
69
+ server = undefined;
70
+ for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
71
+ });
72
+
73
+ /** A fake forge answering `routes` by `METHOD path`, query string dropped. */
74
+ async function fakeForge(routes: Record<string, (body?: Record<string, unknown>) => [number, unknown]>): Promise<{ base: string; calls: Call[] }> {
75
+ const calls: Call[] = [];
76
+ server = createServer(async (req, res) => {
77
+ const body = await readBody(req);
78
+ const path = (req.url ?? "").split("?")[0];
79
+ calls.push({ method: req.method ?? "", url: req.url ?? "", auth: req.headers.authorization, ...(body ? { body } : {}) });
80
+ const route = routes[`${req.method} ${path}`];
81
+ const [status, answer] = route ? route(body) : [404, { message: "not found" }];
82
+ res.writeHead(status, { "Content-Type": "application/json" });
83
+ res.end(JSON.stringify(answer));
84
+ });
85
+ await new Promise<void>((resolve) => server!.listen(0, "127.0.0.1", resolve));
86
+ return { base: `http://127.0.0.1:${(server!.address() as AddressInfo).port}`, calls };
87
+ }
88
+
89
+ async function runNotice(env: Record<string, string>): Promise<string> {
90
+ const { stdout } = await run(process.execPath, ["--import", "tsx", "notice.mjs"], {
91
+ cwd: jobDir(),
92
+ env: {
93
+ PATH: process.env.PATH ?? "",
94
+ TSX_DISABLE_CACHE: "1",
95
+ GITHUB_ACTIONS: "true",
96
+ GITHUB_REPOSITORY: "acme/infra",
97
+ GITHUB_SHA: SHA,
98
+ GITHUB_TOKEN: "job-token",
99
+ CHANT_OP: "app-apply",
100
+ CHANT_GATE: "apply",
101
+ CHANT_APPROVE: "chant approve app-apply apply",
102
+ ...env,
103
+ },
104
+ });
105
+ return stdout.trim();
106
+ }
107
+
108
+ describe("the gate-notice script (#3773)", () => {
109
+ test("on GitHub it comments on the pull request the commit merged", async () => {
110
+ const { base, calls } = await fakeForge({
111
+ [`GET /repos/acme/infra/commits/${SHA}/pulls`]: () => [200, [{ number: 7, merged_at: "2026-10-11T00:00:00Z", merge_commit_sha: SHA }]],
112
+ "GET /repos/acme/infra/issues/7/comments": () => [200, []],
113
+ "POST /repos/acme/infra/issues/7/comments": () => [201, { id: 11, html_url: "https://github.example/acme/infra/pull/7#c11" }],
114
+ });
115
+ const out = await runNotice({ GITHUB_API_URL: base, GITHUB_SERVER_URL: "https://github.com" });
116
+ expect(out).toBe("https://github.example/acme/infra/pull/7#c11");
117
+ const post = calls.find((c) => c.method === "POST")!;
118
+ expect(post.auth).toBe("Bearer job-token");
119
+ const body = String(post.body?.body);
120
+ expect(body.startsWith("<!-- chant-gate:app-apply -->\n\nThe `app-apply` apply for " + SHA + " stopped at gate `apply`")).toBe(true);
121
+ expect(body).toContain("```\nchant approve app-apply apply --approver <you>\n```");
122
+ expect(body).toContain("`_gates/app-apply.jsonl` on the `chant/lifecycle` branch");
123
+ });
124
+
125
+ test("on GitHub it edits its own comment on a later gated push", async () => {
126
+ const { base, calls } = await fakeForge({
127
+ [`GET /repos/acme/infra/commits/${SHA}/pulls`]: () => [200, [{ number: 7, merged_at: "x", merge_commit_sha: SHA }]],
128
+ "GET /repos/acme/infra/issues/7/comments": () => [200, [{ id: 5, body: "<!-- chant-gate:app-apply -->\n\nolder", html_url: "u5" }]],
129
+ "PATCH /repos/acme/infra/issues/comments/5": () => [200, { id: 5, html_url: "u5" }],
130
+ });
131
+ expect(await runNotice({ GITHUB_API_URL: base })).toBe("u5");
132
+ expect(calls.map((c) => c.method)).toEqual(["GET", "GET", "PATCH"]);
133
+ });
134
+
135
+ test("on Forgejo a commit with no pull request gets a tracking issue", async () => {
136
+ const { base, calls } = await fakeForge({
137
+ "GET /api/v1/repos/acme/infra/issues": () => [200, []],
138
+ "POST /api/v1/repos/acme/infra/issues": () => [201, { number: 3, html_url: "https://forgejo.example/acme/infra/issues/3" }],
139
+ });
140
+ const out = await runNotice({ FORGEJO_ACTIONS: "true", GITHUB_API_URL: `${base}/api/v1`, GITHUB_SERVER_URL: base });
141
+ expect(out).toBe("https://forgejo.example/acme/infra/issues/3");
142
+ // Forgejo's own lookup, answered 404 here: no pull request merged this commit.
143
+ expect(calls[0]).toMatchObject({ method: "GET", url: `/api/v1/repos/acme/infra/commits/${SHA}/pull` });
144
+ const post = calls.find((c) => c.method === "POST")!;
145
+ expect(post.auth).toBe("token job-token");
146
+ expect(post.body?.title).toBe("app-apply is waiting on gate apply");
147
+ expect(String(post.body?.body).startsWith("<!-- chant-gate:app-apply -->")).toBe(true);
148
+ });
149
+ });
@@ -11,6 +11,7 @@
11
11
  * is a single edit reflected in every generated file.
12
12
  */
13
13
 
14
+ import { readFileSync } from "node:fs";
14
15
  import { describe, test, expect } from "vitest";
15
16
  import { parseYAML } from "@intentius/chant/yaml";
16
17
  import { generateGithubOpPipeline } from "./generate-op-pipeline";
@@ -492,33 +493,38 @@ describe("generateGithubOpPipeline: the gated apply on push (#2243)", () => {
492
493
  const notice = pushDoc().jobs!["app-apply-gate-notice"];
493
494
  expect(notice.needs).toBe("app-apply");
494
495
  expect(notice.if).toBe("needs.app-apply.outputs.gated == 'true'");
495
- // It shells to `gh`, which a hosted runner carries and the Op's own
496
- // container image does not.
497
- expect(notice.container).toBeUndefined();
496
+ // The Op's own image, which has node and npm, rather than gh on a hosted
497
+ // runner image (#3773).
498
+ expect(notice.container).toBe("node:22");
498
499
  const script = notice.steps[0].run ?? "";
499
- expect(script).toContain('gh api "$api_base/repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls"');
500
- expect(script).toContain('marker="<!-- chant-gate:$CHANT_OP -->"');
501
- expect(script).toContain("gh issue create");
502
- });
503
-
504
- // chant #2305 — same anti-pattern `stickyCommentScript`
505
- // (`lexicons/github/src/composites/pr-plan-report.ts`) and `reconcilePr`'s
506
- // `postOrUpdateComment` (#2291) had: a bare relative path, which `gh`
507
- // resolves against `/api/v3` for any host but github.com. This job never
508
- // reaches Forgejo today (the forgejo generator never carries
509
- // `gatedNoticeDoc` across the dialect, #2294), so the fix changes nothing
510
- // observable on github.com or GHES — it only stops a future Forgejo notice
511
- // job from inheriting the same bug.
512
- test("the notice job's gh api calls resolve their base from $GITHUB_API_URL, not a bare path (#2305)", () => {
500
+ expect(script).toContain('import { forgeClient, forgeTargetFromEnv } from "@intentius/chant/pr-forge";');
501
+ expect(script).toContain('const marker = "<!-- chant-gate:" + op + " -->";');
502
+ expect(script).toContain("forge.pullRequestFor(target.sha)");
503
+ expect(script).toContain("forge.upsertNote(pr, marker, body)");
504
+ expect(script).toContain('forge.upsertIssue(marker, op + " is waiting on gate " + gate, body)');
505
+ expect(script).toContain("node --import tsx notice.mjs");
506
+ });
507
+
508
+ // #3773 (and #3780 for its `gh issue create`): no `gh` call is left to
509
+ // default to github.com on a GHES or Forgejo host. The forge, the REST base
510
+ // and the host come from the job's environment through pr-forge.
511
+ test("the notice job runs no gh at all", () => {
512
+ const notice = pushDoc().jobs!["app-apply-gate-notice"];
513
+ expect(notice.steps[0].run ?? "").not.toMatch(/(^|\s)gh\s/m);
514
+ const env = notice.steps[0].env ?? {};
515
+ expect(env.GITHUB_TOKEN).toBe("${{ github.token }}");
516
+ expect(env).not.toHaveProperty("GH_REPO");
517
+ });
518
+
519
+ test("the notice job installs the chant release this lexicon was generated with", () => {
520
+ const { version } = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")) as { version: string };
513
521
  const script = pushDoc().jobs!["app-apply-gate-notice"].steps[0].run ?? "";
514
- expect(script).toContain('api_base="${GITHUB_API_URL%/}"');
515
- expect(script).toContain('api_base="${api_base:-https://api.github.com}"');
516
- expect(script).toContain('gh api "$api_base/repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls"');
517
- expect(script).toContain('gh api "$api_base/repos/$GITHUB_REPOSITORY/issues/$pr/comments"');
518
- expect(script).toContain('gh api --method PATCH "$api_base/repos/$GITHUB_REPOSITORY/issues/comments/$id"');
519
- expect(script).toContain('gh api --method POST "$api_base/repos/$GITHUB_REPOSITORY/issues/$pr/comments"');
520
- // No bare-path call survives.
521
- expect(script).not.toMatch(/gh api[^\n]*"repos\//);
522
+ expect(script).toContain(`npm install --omit=peer --no-audit --no-fund --loglevel=error @intentius/chant@${version}`);
523
+ });
524
+
525
+ test("the notice job runs in options.image when one is given", () => {
526
+ const doc = parseFile(generateGithubOpPipeline([pushSpec], { image: "registry.example/node:22" }).files[0].yaml);
527
+ expect(doc.jobs!["app-apply-gate-notice"].container).toBe("registry.example/node:22");
522
528
  });
523
529
 
524
530
  test("the notice job's permissions are its two posting paths and the lookup", () => {
@@ -544,7 +550,7 @@ describe("generateGithubOpPipeline: the gated apply on push (#2243)", () => {
544
550
 
545
551
  /**
546
552
  * chant #2299 — every Op job carries `container:` (this generator sets one
547
- * unconditionally, default `node:22-slim`), and a container job's default
553
+ * unconditionally, default `node:22`), and a container job's default
548
554
  * shell on GitHub Actions is `sh`, not bash. `sh` rejects `set -o pipefail`
549
555
  * outright (`Illegal option -o pipefail`) and fails the step before `chant`
550
556
  * is ever reached, which is exactly what happened on a real run (choudoufu
@@ -555,7 +561,7 @@ describe("generateGithubOpPipeline: the gated apply on push (#2243)", () => {
555
561
  test("the run step needs no shell override — its script is POSIX sh, not bash (#2299)", () => {
556
562
  const doc = pushDoc();
557
563
  const job = doc.jobs!["app-apply"];
558
- expect(job.container).toBe("node:22-slim");
564
+ expect(job.container).toBe("node:22");
559
565
  const step = job.steps.find((s) => s.id === "chant-run");
560
566
  expect(step?.shell).toBeUndefined();
561
567
  expect(step?.run).not.toContain("pipefail");
@@ -620,7 +626,7 @@ describe("generateGithubOpPipeline: a deployment environment on the Op's job (#2
620
626
  "jobs:",
621
627
  " actions-audit:",
622
628
  " runs-on: ubuntu-latest",
623
- " container: node:22-slim",
629
+ " container: node:22",
624
630
  " steps:",
625
631
  " - uses: actions/checkout@v7",
626
632
  " - run: chant run actions-audit",
@@ -70,6 +70,7 @@
70
70
  * that job either, same as `environment:` above does not).
71
71
  */
72
72
 
73
+ import { readFileSync } from "node:fs";
73
74
  import { emitYAML, emitYAMLEntry } from "@intentius/chant/yaml";
74
75
  import { memberPathFilter, resolveOpTrigger } from "@intentius/chant/lexicon";
75
76
  import type {
@@ -130,11 +131,10 @@ export interface GithubOpPipelineDoc {
130
131
  jobsDoc: Record<string, unknown>;
131
132
  /**
132
133
  * The gated-apply notice job (#2243), when this Op's trigger is `push`.
133
- * Kept out of {@link jobsDoc} so a dialect that cannot run it drops it by
134
- * simply not copying it: the job shells to `gh` against the GitHub API and
135
- * needs `gh` on the runner, which is the same reason the `comment` finding
136
- * mode is refused on forgejo (#2231). {@link emitOpPipelineYAML} merges it
137
- * into `jobs:` for the forges that can. GitLab reaches the same outcome
134
+ * Kept out of {@link jobsDoc} so a dialect transforms it on its own terms;
135
+ * the forgejo generator carries it across since #3773, when the job moved
136
+ * off `gh` onto `@intentius/chant/pr-forge`. {@link emitOpPipelineYAML}
137
+ * merges it into `jobs:`. GitLab reaches the same outcome
138
138
  * without this job at all: its push job writes the pending block to an
139
139
  * artifact instead (#2256).
140
140
  */
@@ -153,7 +153,7 @@ function toJobName(opName: string): string {
153
153
  return opName.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
154
154
  }
155
155
 
156
- const DEFAULT_IMAGE = "node:22-slim";
156
+ const DEFAULT_IMAGE = "node:22";
157
157
 
158
158
  /**
159
159
  * Default branch assumed for a `push` trigger with no `branches` override
@@ -287,18 +287,14 @@ const GATE_OUTPUT_SCRIPT =
287
287
  *
288
288
  * The `node -e` line and the job's own `outputs:` mapping exist for exactly
289
289
  * one reader: the gate-notice job beside it, which is `needs:`-only readable
290
- * through `steps.<id>.outputs`/`needs.<job>.outputs`. `emitOutputs: false`
291
- * (forgejo, #2294 — the notice job does not cross the dialect at all) drops
292
- * that line: nothing on Forgejo reads `$GITHUB_OUTPUT` for this job, and
293
- * keeping the line would be a wired-up mechanism with no reader, which is the
294
- * exact thing #2294 is about. The tee'd invocation survives either way — it's
295
- * what puts the run's own JSON record in the log, gate-notice job or not.
290
+ * through `steps.<id>.outputs`/`needs.<job>.outputs`. Forgejo dropped both
291
+ * while it had no notice job (#2294) and carries them again since #3773. The
292
+ * tee'd invocation is what puts the run's own JSON record in the log.
296
293
  */
297
294
  function gatedRunStep(
298
295
  id: string,
299
296
  op: string,
300
297
  invocation: string,
301
- emitOutputs: boolean,
302
298
  env: Record<string, string>,
303
299
  ): Record<string, unknown> {
304
300
  const lines = [
@@ -309,70 +305,86 @@ function gatedRunStep(
309
305
  'code=$(cat "$status")',
310
306
  '[ "$code" -eq 0 ] || exit "$code"',
311
307
  ];
312
- if (emitOutputs) lines.push(`node -e '${GATE_OUTPUT_SCRIPT}' "$json"`);
308
+ lines.push(`node -e '${GATE_OUTPUT_SCRIPT}' "$json"`);
313
309
  return { id, run: lines.join("\n"), env };
314
310
  }
315
311
 
316
312
  /**
317
- * The notice body's `printf` format. Kept out of {@link gateNoticeScript} so
318
- * the shell quoting stays readable: it is single-quoted in the emitted script
319
- * because it carries markdown backticks, which a double-quoted shell string
320
- * would run as command substitution.
313
+ * The chant release the notice job installs: this lexicon's own version.
314
+ * The github lexicon and `@intentius/chant` release in lockstep, so the pr-forge
315
+ * a generated workflow runs is the one this generator was built against, and
316
+ * it changes only when the workflow is regenerated, the same as a pinned
317
+ * action ref.
321
318
  */
322
- const NOTICE_BODY_FORMAT =
323
- "%s\\n\\nThe `%s` apply for %s stopped at gate `%s` and is waiting for an approval. Nothing was applied." +
324
- "\\n\\n```\\n%s --approver <you>\\n```\\n\\nThe pending fact is on `_gates/%s.jsonl` on the " +
325
- "`chant/lifecycle` branch. Approving is a commit: push it and this workflow runs again and applies.\\n";
319
+ function chantVersion(): string {
320
+ const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")) as { version?: unknown };
321
+ if (typeof pkg.version !== "string" || pkg.version === "") {
322
+ throw new Error("the github lexicon's package.json has no version to pin the gate-notice job's chant to");
323
+ }
324
+ return pkg.version;
325
+ }
326
326
 
327
327
  /**
328
- * What the notice job posts. The sticky-comment recipe `reconcilePr`'s
329
- * `comment` mode already uses (#2231), spelled in shell because this job runs
330
- * no Op: a hidden marker as the body's first line, found again with
331
- * `startswith` on the next run, PATCHed when it is there and POSTed when it is
332
- * not. So a branch that merges three times before anyone approves carries one
333
- * comment saying what is pending, not three.
334
- *
335
- * A GitHub `push` event carries no pull request, so the target is looked up:
336
- * `repos/{repo}/commits/{sha}/pulls` is the commit's own associated-pull-request
337
- * endpoint, exact rather than a search index, and on a merge commit it answers
338
- * with the pull request that just merged. When it answers with nothing — a
339
- * direct push to the branch, a merge whose commit the API does not associate —
340
- * the notice becomes an issue instead, which is the `issue` finding mode's own
341
- * recipe and the reason this job carries `issues: write`.
328
+ * What the notice job runs under node (#3773): the sticky-comment recipe
329
+ * `reconcilePr`'s `comment` mode uses, through the same
330
+ * `@intentius/chant/pr-forge` clients, so it posts on GitHub, GitHub
331
+ * Enterprise Server and Forgejo alike. `forgeTargetFromEnv` reads the forge,
332
+ * the REST base, the repository, the commit and the token off the job's
333
+ * environment (`CHANT_FORGEJO_TOKEN`, then `GH_TOKEN`, then `GITHUB_TOKEN`),
334
+ * and `pullRequestFor` is the commit-to-pull-request lookup each forge spells
335
+ * its own way (`commits/{sha}/pulls` on GitHub, `commits/{sha}/pull` on
336
+ * Forgejo). A commit with no pull request, a direct push, gets a tracking
337
+ * issue keyed on the same marker, so a second gated push edits it rather
338
+ * than opening another.
342
339
  *
343
- * `$api_base` (chant #2305), resolved the same way `stickyCommentScript`
344
- * (`lexicons/github/src/composites/pr-plan-report.ts`) and `reconcilePr`'s
345
- * `githubApiBaseFrom` (`packages/core/src/op/activities/reconcile.ts`)
346
- * resolve it: `$GITHUB_API_URL` with a trailing slash trimmed, falling back
347
- * to `https://api.github.com`. This job never crosses to Forgejo today — the
348
- * forgejo generator's `gatedNoticeDoc` is never carried across the dialect
349
- * (#2294) — so github.com and GitHub Enterprise Server are the only hosts
350
- * that run this script, and both already resolved correctly under `gh`'s own
351
- * bare-path guess. Built from `$api_base` instead, the URL is byte-identical
352
- * on those two hosts, and the same anti-pattern that broke Forgejo elsewhere
353
- * (#2291, #2305) does not get a second copy here in case a future notice job
354
- * does reach Forgejo.
340
+ * Plain JavaScript in double-quoted strings, so the markdown backticks need
341
+ * no escaping and nothing in it reads as a `${{ }}` expression.
342
+ */
343
+ const NOTICE_SCRIPT = [
344
+ 'import { forgeClient, forgeTargetFromEnv } from "@intentius/chant/pr-forge";',
345
+ "const { CHANT_OP: op, CHANT_GATE: gate, CHANT_APPROVE: approve } = process.env;",
346
+ "const target = forgeTargetFromEnv(process.env);",
347
+ "const forge = forgeClient(target);",
348
+ 'const marker = "<!-- chant-gate:" + op + " -->";',
349
+ 'const fence = "```";',
350
+ "const body = [",
351
+ " marker,",
352
+ ' "",',
353
+ ' "The `" + op + "` apply for " + target.sha + " stopped at gate `" + gate + "` and is waiting for an approval. Nothing was applied.",',
354
+ ' "",',
355
+ " fence,",
356
+ ' approve + " --approver <you>",',
357
+ " fence,",
358
+ ' "",',
359
+ ' "The pending fact is on `_gates/" + op + ".jsonl` on the `chant/lifecycle` branch. Approving is a commit: push it and this workflow runs again and applies.",',
360
+ '].join("\\n") + "\\n";',
361
+ "const pr = target.sha ? await forge.pullRequestFor(target.sha) : null;",
362
+ "const url = pr",
363
+ " ? await forge.upsertNote(pr, marker, body)",
364
+ ' : await forge.upsertIssue(marker, op + " is waiting on gate " + gate, body);',
365
+ 'console.log(url ?? (pr ? "commented on #" + pr : "kept the tracking issue"));',
366
+ ].join("\n");
367
+
368
+ /**
369
+ * The notice step's shell (#3773). It installs the pinned chant into the
370
+ * runner's temp directory and runs {@link NOTICE_SCRIPT} with tsx, which
371
+ * chant depends on and which is how `chant` itself runs its TypeScript.
372
+ * `--omit=peer` keeps chant's optional vitest peer out: about 17 packages,
373
+ * a few seconds. The `package.json` written first stops npm from walking up
374
+ * to some other project root. No checkout and no `beforeScript`: the job
375
+ * reads nothing out of the repository.
355
376
  */
356
- function gateNoticeScript(): string {
377
+ function gateNoticeScript(version: string): string {
357
378
  return [
358
- 'api_base="${GITHUB_API_URL%/}"',
359
- 'api_base="${api_base:-https://api.github.com}"',
360
- 'marker="<!-- chant-gate:$CHANT_OP -->"',
361
- "body=$(printf '" + NOTICE_BODY_FORMAT + "' " +
362
- '"$marker" "$CHANT_OP" "$GITHUB_SHA" "$CHANT_GATE" "$CHANT_APPROVE" "$CHANT_OP")',
363
- 'pr=$(gh api "$api_base/repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls" --jq ".[0].number // empty")',
364
- 'if [ -z "$pr" ]; then',
365
- ' gh issue create --title "$CHANT_OP is waiting on gate $CHANT_GATE" --body "$body"',
366
- " exit 0",
367
- "fi",
368
- 'id=$(gh api "$api_base/repos/$GITHUB_REPOSITORY/issues/$pr/comments" --paginate ' +
369
- '--jq "map(select(.body | startswith(\\"$marker\\"))) | .[0].id // empty" ' +
370
- '| grep -m1 -E "^[0-9]+$" || true)',
371
- 'if [ -n "$id" ]; then',
372
- ' gh api --method PATCH "$api_base/repos/$GITHUB_REPOSITORY/issues/comments/$id" -f "body=$body" --jq .html_url',
373
- "else",
374
- ' gh api --method POST "$api_base/repos/$GITHUB_REPOSITORY/issues/$pr/comments" -f "body=$body" --jq .html_url',
375
- "fi",
379
+ 'dir="${RUNNER_TEMP:-/tmp}/chant-gate-notice"',
380
+ 'mkdir -p "$dir"',
381
+ 'cd "$dir"',
382
+ "echo '{\"private\":true}' > package.json",
383
+ `npm install --omit=peer --no-audit --no-fund --loglevel=error @intentius/chant@${version}`,
384
+ "cat > notice.mjs <<'NOTICE'",
385
+ NOTICE_SCRIPT,
386
+ "NOTICE",
387
+ "node --import tsx notice.mjs",
376
388
  ].join("\n");
377
389
  }
378
390
 
@@ -380,34 +392,35 @@ function gateNoticeScript(): string {
380
392
  * The follow-up job: `needs:` the apply, runs only when the apply reported
381
393
  * gated, and puts the pending state somewhere other than the Actions log.
382
394
  *
383
- * No `container:`. It needs `gh`, which GitHub-hosted runner images carry and
384
- * an Op's own image (`node:22-slim` by default) does not; it reads nothing out
385
- * of the repository, so it also needs no checkout.
395
+ * It runs in the Op's own image, which carries node and npm by construction
396
+ * (the apply's `node -e` output step needs them too), so it runs the same on
397
+ * GitHub's hosted runners and on a Forgejo runner. It checks nothing out.
386
398
  *
387
399
  * Its `permissions:` are its own, replacing the workflow-level set for this
388
400
  * job alone: `contents: read` for the commit-to-pull-request lookup,
389
401
  * `pull-requests: write` for the sticky comment, `issues: write` for the
390
- * fallback when the push has no pull request. Nothing wider — it opens no
391
- * branch and merges nothing.
402
+ * tracking issue when the push has no pull request. Nothing wider: it opens
403
+ * no branch and merges nothing. The Forgejo dialect drops them, as it drops
404
+ * every scope but `id-token`.
392
405
  */
393
- function gateNoticeJob(applyJobName: string): Record<string, unknown> {
406
+ function gateNoticeJob(applyJobName: string, image: string): Record<string, unknown> {
394
407
  const output = (name: string) => "${{ needs." + applyJobName + ".outputs." + name + ' }}';
395
408
  return {
396
409
  needs: applyJobName,
397
410
  if: `needs.${applyJobName}.outputs.gated == 'true'`,
398
411
  "runs-on": "ubuntu-latest",
412
+ container: image,
399
413
  permissions: { contents: "read", issues: "write", "pull-requests": "write" },
400
414
  steps: [
401
415
  {
402
416
  name: "Report the pending gate",
403
417
  env: {
404
- GH_TOKEN: "${{ github.token }}",
405
- GH_REPO: "${{ github.repository }}",
418
+ GITHUB_TOKEN: "${{ github.token }}",
406
419
  CHANT_OP: output("op"),
407
420
  CHANT_GATE: output("gate"),
408
421
  CHANT_APPROVE: output("approve"),
409
422
  },
410
- run: gateNoticeScript(),
423
+ run: gateNoticeScript(chantVersion()),
411
424
  },
412
425
  ],
413
426
  };
@@ -639,24 +652,6 @@ function setupStepDoc(step: OpSetupStep): Record<string, unknown> {
639
652
  };
640
653
  }
641
654
 
642
- /**
643
- * Internal knobs beside the public {@link GenerateGithubOpOptions} — not part
644
- * of that type because they are not something a project author sets, only
645
- * something a dialect on top of this builder (forgejo, #2294) needs to
646
- * change about the shape this function emits for every spec alike.
647
- */
648
- export interface BuildGithubOpPipelineDocsInternalOptions {
649
- /**
650
- * Whether a `push` job's outputs (`gated`/`op`/`gate`/`approve`) and the
651
- * `node -e` step that writes them to `$GITHUB_OUTPUT` are emitted (#2294).
652
- * Default true (github's own behavior, unchanged): the gate-notice job
653
- * beside it reads them. The forgejo generator passes `false` — it never
654
- * carries a gate-notice job (`gatedNoticeDoc` never crosses the dialect),
655
- * so nothing would ever read them there.
656
- */
657
- emitGatedOutputs?: boolean;
658
- }
659
-
660
655
  /**
661
656
  * Build one `GithubOpPipelineDoc` per scheduled Op: its trigger, its `setup`
662
657
  * steps, least-privilege `permissions:` for its finding-mode plus whatever
@@ -672,9 +667,7 @@ export interface BuildGithubOpPipelineDocsInternalOptions {
672
667
  export function buildGithubOpPipelineDocs(
673
668
  ops: ScheduledOpSpec[],
674
669
  options: GenerateGithubOpOptions = {},
675
- internalOptions: BuildGithubOpPipelineDocsInternalOptions = {},
676
670
  ): { files: GithubOpPipelineFile[]; jobs: OpPipelineJob[] } {
677
- const emitGatedOutputs = internalOptions.emitGatedOutputs ?? true;
678
671
  const image = options.image ?? DEFAULT_IMAGE;
679
672
  const runCommand = options.runCommand ?? ["chant", "run", "{name}"];
680
673
  const beforeScript = options.beforeScript ?? [];
@@ -715,7 +708,7 @@ export function buildGithubOpPipelineDocs(
715
708
  for (const line of beforeScript) steps.push({ run: line });
716
709
  steps.push(
717
710
  gated
718
- ? gatedRunStep(RUN_STEP_ID, spec.name, invocation, emitGatedOutputs, stepEnv)
711
+ ? gatedRunStep(RUN_STEP_ID, spec.name, invocation, stepEnv)
719
712
  : { run: invocation, env: stepEnv },
720
713
  );
721
714
  for (const line of extraScript) steps.push({ run: line });
@@ -747,7 +740,7 @@ export function buildGithubOpPipelineDocs(
747
740
  // behind the same reviewer would hold the message back until
748
741
  // somebody had already acted.
749
742
  ...(spec.environment ? { environment: environmentDoc(spec.environment) } : {}),
750
- ...(gated && emitGatedOutputs
743
+ ...(gated
751
744
  ? {
752
745
  outputs: Object.fromEntries(
753
746
  ["gated", "op", "gate", "approve"].map((name) => [
@@ -760,7 +753,7 @@ export function buildGithubOpPipelineDocs(
760
753
  steps,
761
754
  },
762
755
  },
763
- ...(gated ? { gatedNoticeDoc: { [`${jobName}-gate-notice`]: gateNoticeJob(jobName) } } : {}),
756
+ ...(gated ? { gatedNoticeDoc: { [`${jobName}-gate-notice`]: gateNoticeJob(jobName, image) } } : {}),
764
757
  };
765
758
 
766
759
  files.push({ name: `${spec.name}.yml`, doc });
@@ -827,9 +820,8 @@ export function emitOpPipelineYAML(doc: GithubOpPipelineDoc): string {
827
820
  if (doc.env && Object.keys(doc.env).length > 0) sections.push(emitYAMLEntry("env", doc.env));
828
821
  sections.push(emitYAMLEntry("concurrency", doc.concurrency));
829
822
  if (Object.keys(doc.permissions).length > 0) sections.push(emitYAMLEntry("permissions", doc.permissions));
830
- // The gated-apply notice job rides in `jobs:` beside the Op's own job, but
831
- // is carried separately on the doc so a dialect that cannot run it (forgejo,
832
- // whose runner has no `gh` pointed at its own instance) drops it by omission.
823
+ // The gated-apply notice job rides in `jobs:` beside the Op's own job; it
824
+ // is carried separately on the doc so a dialect transforms it on its own.
833
825
  sections.push(emitYAMLEntry("jobs", { ...doc.jobsDoc, ...(doc.gatedNoticeDoc ?? {}) }));
834
826
  return sections.join("\n\n") + "\n";
835
827
  }
@@ -31,6 +31,15 @@ describe("generateGithubOpWavesPipeline", () => {
31
31
  expect(yaml).toContain("environment:\n name: production");
32
32
  });
33
33
 
34
+ test("notify maps each variable it names from the secret of that name, and options.variables wins (#3761)", () => {
35
+ const notify = { webhook: "CHANT_WEBHOOK", key: "CHANT_WEBHOOK_KEY", slack: "CHANT_SLACK_WEBHOOK" };
36
+ const yaml = generateGithubOpWavesPipeline({ ...spec, notify }, { specFile: "waves.json", variables: { CHANT_SLACK_WEBHOOK: "${{ vars.SLACK }}" } }).files[0]!.yaml;
37
+ expect(yaml).toContain("env:\n CHANT_WEBHOOK: '${{ secrets.CHANT_WEBHOOK }}'");
38
+ expect(yaml).toContain("CHANT_WEBHOOK_KEY: '${{ secrets.CHANT_WEBHOOK_KEY }}'");
39
+ expect(yaml).toContain("CHANT_SLACK_WEBHOOK: '${{ vars.SLACK }}'");
40
+ expect(generateGithubOpWavesPipeline(spec, { specFile: "waves.json" }).files[0]!.yaml).not.toContain("secrets.");
41
+ });
42
+
34
43
  test("resume renders a scheduled workflow that re-runs approved waves with actions: write (#3683)", () => {
35
44
  const { files } = generateGithubOpWavesPipeline({ ...spec, resume: { schedule: "*/10 * * * *" } }, { specFile: "waves.json" });
36
45
  expect(files.map((f) => f.name)).toEqual([`${spec.name}.yml`, `${spec.name}-resume.yml`]);
@@ -49,4 +58,19 @@ describe("generateGithubOpWavesPipeline", () => {
49
58
  expect(files[1]!.yaml).toContain("run: chant run wave --spec waves.json --record-plans");
50
59
  expect(files[0]!.yaml).toMatch(/permissions:\n\s+contents: write\n\s+pull-requests: read/);
51
60
  });
61
+
62
+ test("a wave-level plan and apply, and stage hooks, render the same jobs: chant run wave runs them (#3762)", () => {
63
+ const { plan: _plan, ...rest } = spec;
64
+ const waveLevel: OpWavesSpec = {
65
+ ...rest,
66
+ planWave: ["./bin/plan-wave", "{runs}", "{plans}"],
67
+ applyWave: ["./bin/apply-wave", "{runs}"],
68
+ hooks: { before: { apply: [{ command: ["./bin/check", "{runs}"], onFailure: "approve" }] } },
69
+ };
70
+ const plain = generateGithubOpWavesPipeline(spec, { specFile: "waves.json" });
71
+ const rendered = generateGithubOpWavesPipeline(waveLevel, { specFile: "waves.json" });
72
+ expect(rendered.files).toEqual(plain.files);
73
+ expect(rendered.jobs).toEqual(plain.jobs);
74
+ expect(() => generateGithubOpWavesPipeline({ ...waveLevel, hooks: { after: { apply: [{ command: ["./bin/smoke"], onFailure: "approve" }] } } }, { specFile: "waves.json" })).toThrow(/nothing left to hold/);
75
+ });
52
76
  });
@@ -23,8 +23,13 @@
23
23
  * `pull-requests: read`, so a wave can read the merged pull request's
24
24
  * reviews, and a second workflow, `<name>-plans.yml`, runs `chant run wave
25
25
  * --record-plans` on each pull request to record the digests its head plans.
26
+ *
27
+ * With `notify` on the spec (#3761), the workflow maps each variable it names
28
+ * from the repository secret of the same name, so a wave job can post to
29
+ * Slack, Teams or a signed webhook. `options.variables` wins over the mapping.
26
30
  */
27
31
 
32
+ import { notifyVariables } from "@intentius/chant/notify";
28
33
  import { opWaveJobs, opWavesResumeCommand, type OpWavesSpec } from "@intentius/chant/op/op-waves";
29
34
  import type { OpWavesPipelineOptions, OpWavesPipelineResult } from "@intentius/chant/lexicon";
30
35
  import { actionRef } from "../action-pins";
@@ -102,10 +107,12 @@ export function buildGithubOpWavesDoc(
102
107
  };
103
108
  }
104
109
 
110
+ // #3761: the notify variables come from the secrets of the same name.
111
+ const env = { ...Object.fromEntries(notifyVariables(spec.notify).map((n) => [n, `\${{ secrets.${n} }}`])), ...(options.variables ?? {}) };
105
112
  const doc: GithubOpPipelineDoc = {
106
113
  name: spec.name,
107
114
  on: { push: { branches: spec.branches?.length ? spec.branches : ["main"] }, workflow_dispatch: {} },
108
- ...(options.variables && Object.keys(options.variables).length > 0 ? { env: options.variables } : {}),
115
+ ...(Object.keys(env).length > 0 ? { env } : {}),
109
116
  concurrency: { group: spec.name, "cancel-in-progress": false },
110
117
  permissions: { contents: "write", ...(byReview ? { "pull-requests": "read" } : {}) },
111
118
  jobsDoc,
@@ -338,7 +338,7 @@ describe("generateGithubPipeline: a promote job (#2575)", () => {
338
338
 
339
339
  const promote = jobs["promote-prod"];
340
340
  expect(promote.needs).toEqual(["api", "shared-alb", "worker"]);
341
- expect(promote.container).toBe("node:22-slim");
341
+ expect(promote.container).toBe("node:22");
342
342
  const downloads = promote.steps.filter((s) => s.uses === "actions/download-artifact@v4");
343
343
  expect(downloads.map((s) => s.with)).toEqual([
344
344
  { name: "api-archive", path: "dist/images" },
@@ -282,7 +282,7 @@ function toJobName(componentName: string): string {
282
282
  return componentName.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
283
283
  }
284
284
 
285
- const DEFAULT_IMAGE = "node:22-slim";
285
+ const DEFAULT_IMAGE = "node:22";
286
286
 
287
287
  /** The file a producer dumps its resolved outputs to, and a dependent seeds from — one per component, matching gitlab's naming so both generators produce interchangeable artifacts. */
288
288
  function outputsFile(name: string): string {
@@ -20,7 +20,7 @@ import type { SerializerResult } from "@intentius/chant/serializer";
20
20
  * loosely-typed instance directly.
21
21
  */
22
22
  interface StepLike {
23
- props: { name?: string; run?: string };
23
+ props: { name?: string; run?: string; env?: Record<string, string> };
24
24
  }
25
25
  interface JobLike {
26
26
  props: {
@@ -125,6 +125,19 @@ describe("PrPlanReport composite (#1983)", () => {
125
125
  expect(run).not.toMatch(/gh api[^\n]*"repos\//);
126
126
  });
127
127
 
128
+ // #2352: `gh` sends GH_TOKEN only to github.com and ghe.com hosts. Checked
129
+ // against a real Forgejo 16.0.5 instance with no `gh auth login`: GH_TOKEN
130
+ // alone posted with no Authorization header and got 401; with
131
+ // GH_ENTERPRISE_TOKEN beside it the POST got 201 and a re-run PATCHed.
132
+ test("the post step carries the token as GH_ENTERPRISE_TOKEN too, which a non-github.com host reads (#2352)", () => {
133
+ const { job } = PrPlanReport({ environment: "prod" });
134
+ const postStep = steps(job).find((s) => s.props.name === "Post or update PR comment")!;
135
+ expect(postStep.props.env).toEqual({
136
+ GH_TOKEN: "${{ secrets.GITHUB_TOKEN }}",
137
+ GH_ENTERPRISE_TOKEN: "${{ secrets.GITHUB_TOKEN }}",
138
+ });
139
+ });
140
+
128
141
  test("the emitted workflow passes the github lexicon's own lint — no errors, pinned actions included", () => {
129
142
  const { job } = PrPlanReport({ environment: "prod", before: ["aws sts get-caller-identity"] });
130
143
  const workflow = new Workflow({
@@ -73,6 +73,16 @@ export interface PrPlanReportProps {
73
73
  * does not serve `/api/v3` and answered 404 for GET and POST alike. On
74
74
  * github.com and GHES the resolved URL is byte-identical to `gh`'s own
75
75
  * guess, so this changes nothing there.
76
+ *
77
+ * The step that runs it sets the token twice, as `GH_TOKEN` and as
78
+ * `GH_ENTERPRISE_TOKEN` (chant #2352). `gh` sends `GH_TOKEN` only to
79
+ * github.com and `ghe.com` hosts and reads `GH_ENTERPRISE_TOKEN` for every
80
+ * other host, Forgejo and GitHub Enterprise Server included. With `GH_TOKEN`
81
+ * alone, this script's POST to a real Forgejo 16.0.5 instance went out with
82
+ * no `Authorization` header and answered 401 `token is required`; with both
83
+ * set it answered 201, and a second run PATCHed the same comment. On
84
+ * github.com `GH_TOKEN` still wins, so nothing changes there. The same fix
85
+ * `ghCredentialEnv` made for `reconcilePr` at runtime (#2333).
76
86
  */
77
87
  const stickyCommentScript = [
78
88
  'api_base="${GITHUB_API_URL%/}"',
@@ -151,7 +161,10 @@ export const PrPlanReport = Composite((props: PrPlanReportProps) => {
151
161
  ? [
152
162
  new Step({
153
163
  name: "Post or update PR comment",
154
- env: { GH_TOKEN: "${{ secrets.GITHUB_TOKEN }}" },
164
+ env: {
165
+ GH_TOKEN: "${{ secrets.GITHUB_TOKEN }}",
166
+ GH_ENTERPRISE_TOKEN: "${{ secrets.GITHUB_TOKEN }}",
167
+ },
155
168
  run: stickyCommentScript,
156
169
  }),
157
170
  ]