@intentius/chant-lexicon-github 0.60.0 → 0.62.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.
@@ -57,6 +57,17 @@
57
57
  * environment protection is repository configuration, not a token scope — and
58
58
  * {@link assertEnvironment} refuses only what would emit as configured and
59
59
  * bind nothing.
60
+ *
61
+ * A fourth is per-Op credentials (#2290): a spec's `variables` emits the Op's
62
+ * own job-level `env:`, beside (and layered over, on a key collision)
63
+ * `options.variables`'s workflow-level `env:`. `options.variables` keeps
64
+ * meaning what it always has — set once, landing on every generated file — so
65
+ * a caller who declares nothing per-Op sees byte-identical output; a spec's
66
+ * own `variables` is the way one Op's job can hold a credential no other Op's
67
+ * job receives, which a workflow-level declaration can never express because
68
+ * every generated file here has exactly the one Op job (plus, on a `push`
69
+ * trigger, the gate-notice job beside it — job-level `env:` does not reach
70
+ * that job either, same as `environment:` above does not).
60
71
  */
61
72
  import type { ComponentPipelineOptions as GenerateGithubOpOptions, OpEnvironment, OpPipelineJob, OpPipelineResult as GenerateGithubOpResult, OpSetupStep, OpTrigger, ScheduledOpSpec } from "@intentius/chant/lexicon";
62
73
  export type { GenerateGithubOpOptions, GenerateGithubOpResult };
@@ -157,6 +168,23 @@ export declare function assertEnvironment(name: string, environment: OpEnvironme
157
168
  * push run, grants write access no step in the generated job can spend.
158
169
  */
159
170
  export declare function mergePermissions(name: string, base: Record<string, "read" | "write">, additive: Record<string, "read" | "write">, trigger: OpTrigger): Record<string, "read" | "write">;
171
+ /**
172
+ * Internal knobs beside the public {@link GenerateGithubOpOptions} — not part
173
+ * of that type because they are not something a project author sets, only
174
+ * something a dialect on top of this builder (forgejo, #2294) needs to
175
+ * change about the shape this function emits for every spec alike.
176
+ */
177
+ export interface BuildGithubOpPipelineDocsInternalOptions {
178
+ /**
179
+ * Whether a `push` job's outputs (`gated`/`op`/`gate`/`approve`) and the
180
+ * `node -e` step that writes them to `$GITHUB_OUTPUT` are emitted (#2294).
181
+ * Default true (github's own behavior, unchanged): the gate-notice job
182
+ * beside it reads them. The forgejo generator passes `false` — it never
183
+ * carries a gate-notice job (`gatedNoticeDoc` never crosses the dialect),
184
+ * so nothing would ever read them there.
185
+ */
186
+ emitGatedOutputs?: boolean;
187
+ }
160
188
  /**
161
189
  * Build one `GithubOpPipelineDoc` per scheduled Op: its trigger, its `setup`
162
190
  * steps, least-privilege `permissions:` for its finding-mode plus whatever
@@ -169,7 +197,7 @@ export declare function mergePermissions(name: string, base: Record<string, "rea
169
197
  * assertSetupSteps}), or a `permissions` entry that is not additive ({@link
170
198
  * mergePermissions}).
171
199
  */
172
- export declare function buildGithubOpPipelineDocs(ops: ScheduledOpSpec[], options?: GenerateGithubOpOptions): {
200
+ export declare function buildGithubOpPipelineDocs(ops: ScheduledOpSpec[], options?: GenerateGithubOpOptions, internalOptions?: BuildGithubOpPipelineDocsInternalOptions): {
173
201
  files: GithubOpPipelineFile[];
174
202
  jobs: OpPipelineJob[];
175
203
  };
@@ -1 +1 @@
1
- {"version":3,"file":"generate-op-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2DG;AAIH,OAAO,KAAK,EACV,wBAAwB,IAAI,uBAAuB,EACnD,aAAa,EAEb,aAAa,EACb,gBAAgB,IAAI,sBAAsB,EAC1C,WAAW,EACX,SAAS,EACT,eAAe,EAChB,MAAM,0BAA0B,CAAC;AAElC,YAAY,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,CAAC;AAEhE;;;;;;GAMG;AACH,MAAM,WAAW,mBAAmB;IAClC;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB;;;;OAIG;IACH,EAAE,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC5B,2DAA2D;IAC3D,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9B,6DAA6D;IAC7D,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC;;;;OAIG;IACH,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC,8DAA8D;IAC9D,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC;;;;;;;;;OASG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC1C;AAED,qFAAqF;AACrF,MAAM,WAAW,oBAAoB;IACnC,4FAA4F;IAC5F,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,mBAAmB,CAAC;CAC1B;AAsQD;;;;;;;;;GASG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,EAAE,GAAG,IAAI,CA8BzE;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,aAAa,GAAG,IAAI,CAwBhF;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,EACtC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,EAC1C,OAAO,EAAE,SAAS,GACjB,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,CAqClC;AA6BD;;;;;;;;;;;GAWG;AACH,wBAAgB,yBAAyB,CACvC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,GACpC;IAAE,KAAK,EAAE,oBAAoB,EAAE,CAAC;IAAC,IAAI,EAAE,aAAa,EAAE,CAAA;CAAE,CAuF1D;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,mBAAmB,GAAG,MAAM,CAenE;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CACtC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,GACpC,sBAAsB,CAMxB"}
1
+ {"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,EACT,eAAe,EAChB,MAAM,0BAA0B,CAAC;AAElC,YAAY,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,CAAC;AAEhE;;;;;;GAMG;AACH,MAAM,WAAW,mBAAmB;IAClC;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB;;;;OAIG;IACH,EAAE,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC5B,2DAA2D;IAC3D,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9B,6DAA6D;IAC7D,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC;;;;OAIG;IACH,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC,8DAA8D;IAC9D,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC;;;;;;;;;OASG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC1C;AAED,qFAAqF;AACrF,MAAM,WAAW,oBAAoB;IACnC,4FAA4F;IAC5F,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,mBAAmB,CAAC;CAC1B;AAqTD;;;;;;;;;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,CA6F1D;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,mBAAmB,GAAG,MAAM,CAenE;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CACtC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,GACpC,sBAAsB,CAMxB"}
@@ -51,7 +51,9 @@ export interface PrPlanReportProps {
51
51
  * proved (#1223), reused here rather than reinvented. The forgejo lexicon
52
52
  * inherits this composite through its github re-export, since Forgejo Actions
53
53
  * runs the same workflow shape and the Forgejo API accepts the same `gh api`
54
- * calls against its GitHub-compatible surface.
54
+ * calls against its GitHub-compatible surface — once they target a full URL
55
+ * rather than a bare relative path, which `stickyCommentScript` now does
56
+ * (#2305, the same fix #2291 made for `reconcilePr`'s `postOrUpdateComment`).
55
57
  *
56
58
  * Only meaningful on a `pull_request`-triggered workflow — the job guards on
57
59
  * `github.event_name` since the comment targets `github.event.number`, which
@@ -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;AAyBD;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,YAAY;;EAkEP,CAAC"}
1
+ {"version":3,"file":"pr-plan-report.d.ts","sourceRoot":"","sources":["../../src/composites/pr-plan-report.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,GAAG,EAAqB,MAAM,oBAAoB,CAAC;AAI5D,MAAM,WAAW,iBAAiB;IAChC;;;OAGG;IACH,WAAW,EAAE,MAAM,CAAC;IACpB,4EAA4E;IAC5E,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,kEAAkE;IAClE,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,+CAA+C;IAC/C,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,iEAAiE;IACjE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,+DAA+D;IAC/D,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB;;;;;OAKG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,6DAA6D;IAC7D,QAAQ,CAAC,EAAE;QACT,GAAG,CAAC,EAAE,OAAO,CAAC,qBAAqB,CAAC,OAAO,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;KACrD,CAAC;CACH;AA2CD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,YAAY;;EAkEP,CAAC"}
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "bc5ba7395f22f836b8889f84a6b6b3ec39c4158d6dc8967f5d1315a578d99f32",
4
+ "manifest.json": "1c9c51090ae4df5488b7a558b098177d5f40d9efccc4ab8b89bb5caf91d1b953",
5
5
  "meta.json": "dc3977afc2b6ddc4904de906d1b1e448b786b2aced9d82f8eade2ff915203344",
6
6
  "types/index.d.ts": "f207946d7ab52f712d0c09995366440a6bba8b39358057b0da38ff9c0acca429",
7
7
  "rules/deprecated-action-version.ts": "d41e6e532ab7f623af1bee4ac5279fcb2baada7defa1c5d022a5bc71983e8797",
@@ -77,5 +77,5 @@
77
77
  "skills/chant-github-patterns.md": "bb3abef289a8fdfcf07d6bb2d7289dcb2f38bc0cb0321ea320b78b45a6f548c0",
78
78
  "skills/chant-github-security.md": "aab111cb0871cad30281ce48d7da23663689619351029219e2be019a1a61e394"
79
79
  },
80
- "composite": "b137c6aa19b27d166416427a565ce61b4af0e16fbeaa27253f83470af045a7b6"
80
+ "composite": "92101a972617cf05fe02d8c0ff00f3ef87c8b769e768e1da63a6ed7730df17af"
81
81
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github",
3
- "version": "0.60.0",
3
+ "version": "0.62.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.60.0",
3
+ "version": "0.62.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.60.0",
64
+ "@intentius/chant": "^0.62.0",
65
65
  "typescript": "^5.9.3"
66
66
  }
67
67
  }
@@ -21,6 +21,7 @@ interface ParsedStep {
21
21
  id?: string;
22
22
  uses?: string;
23
23
  run?: string;
24
+ shell?: string;
24
25
  env?: Record<string, string>;
25
26
  }
26
27
 
@@ -32,6 +33,7 @@ interface ParsedJob {
32
33
  if?: string;
33
34
  permissions?: Record<string, string>;
34
35
  outputs?: Record<string, string>;
36
+ env?: Record<string, string>;
35
37
  steps: ParsedStep[];
36
38
  }
37
39
 
@@ -494,11 +496,31 @@ describe("generateGithubOpPipeline: the gated apply on push (#2243)", () => {
494
496
  // container image does not.
495
497
  expect(notice.container).toBeUndefined();
496
498
  const script = notice.steps[0].run ?? "";
497
- expect(script).toContain('gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls"');
499
+ expect(script).toContain('gh api "$api_base/repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls"');
498
500
  expect(script).toContain('marker="<!-- chant-gate:$CHANT_OP -->"');
499
501
  expect(script).toContain("gh issue create");
500
502
  });
501
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)", () => {
513
+ 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
+ });
523
+
502
524
  test("the notice job's permissions are its two posting paths and the lookup", () => {
503
525
  const notice = pushDoc().jobs!["app-apply-gate-notice"];
504
526
  expect(notice.permissions).toEqual({
@@ -515,6 +537,24 @@ describe("generateGithubOpPipeline: the gated apply on push (#2243)", () => {
515
537
  const step = pushDoc().jobs!["app-apply"].steps.find((s) => s.id === "chant-run");
516
538
  expect(step?.run).toContain("set -o pipefail");
517
539
  });
540
+
541
+ /**
542
+ * chant #2299 — every Op job carries `container:` (this generator sets one
543
+ * unconditionally, default `node:22-slim`), and a container job's default
544
+ * shell on GitHub Actions is `sh`, not bash. `sh` rejects `set -o pipefail`
545
+ * outright (`Illegal option -o pipefail`) and fails the step before `chant`
546
+ * is ever reached, which is exactly what happened on a real run (choudoufu
547
+ * #1026, run 34312967579). The step that emits `pipefail` must declare
548
+ * `shell: bash` itself.
549
+ */
550
+ test("the pipefail step declares shell: bash — its job runs in a container, whose default shell is sh (#2299)", () => {
551
+ const doc = pushDoc();
552
+ const job = doc.jobs!["app-apply"];
553
+ expect(job.container).toBe("node:22-slim");
554
+ const step = job.steps.find((s) => s.id === "chant-run");
555
+ expect(step?.run).toContain("set -o pipefail");
556
+ expect(step?.shell).toBe("bash");
557
+ });
518
558
  });
519
559
 
520
560
  /**
@@ -642,3 +682,67 @@ describe("generateGithubOpPipeline: a deployment environment on the Op's job (#2
642
682
  ).toThrow(/has an empty `url`/);
643
683
  });
644
684
  });
685
+
686
+ /**
687
+ * chant #2290 — per-Op credentials, so a pull-request job need not hold the
688
+ * apply credential. `variables` lands as the Op's own job-level `env:`, one
689
+ * level more specific than `options.variables`'s workflow-level `env:`, and
690
+ * never on the gate-notice job beside it.
691
+ */
692
+ describe("generateGithubOpPipeline: per-Op variables on the job (#2290)", () => {
693
+ const CREDENTIAL = { AWS_ACCESS_KEY_ID: "${{ secrets.AWS_ACCESS_KEY_ID }}" };
694
+
695
+ test("a spec with no variables emits no job-level env:, even when options.variables is set", () => {
696
+ const doc = parseFile(
697
+ generateGithubOpPipeline([{ name: "live-check", trigger: { kind: "pull_request", branches: ["main"] } }], {
698
+ variables: { CHANT_FORGE: "github" },
699
+ }).files[0].yaml,
700
+ );
701
+ expect(doc.jobs!["live-check"].env).toBeUndefined();
702
+ });
703
+
704
+ test("a spec's own variables land as the job's env:, beside the workflow env:", () => {
705
+ const doc = parseFile(
706
+ generateGithubOpPipeline(
707
+ [{ name: "live-apply", trigger: { kind: "push", branches: ["main"] }, variables: CREDENTIAL }],
708
+ { variables: { CHANT_FORGE: "github" } },
709
+ ).files[0].yaml,
710
+ );
711
+ expect(doc.jobs!["live-apply"].env).toEqual(CREDENTIAL);
712
+ });
713
+
714
+ test("a per-Op key wins over a same-named forge-wide one", () => {
715
+ const doc = parseFile(
716
+ generateGithubOpPipeline(
717
+ [
718
+ {
719
+ name: "live-apply",
720
+ trigger: { kind: "push", branches: ["main"] },
721
+ variables: { CHANT_FORGE: "overridden" },
722
+ },
723
+ ],
724
+ { variables: { CHANT_FORGE: "github" } },
725
+ ).files[0].yaml,
726
+ );
727
+ expect(doc.jobs!["live-apply"].env).toEqual({ CHANT_FORGE: "overridden" });
728
+ });
729
+
730
+ test("the gate-notice job never carries the Op's own job-level variables", () => {
731
+ const doc = parseFile(
732
+ generateGithubOpPipeline([
733
+ { name: "live-apply", trigger: { kind: "push", branches: ["main"] }, variables: CREDENTIAL },
734
+ ]).files[0].yaml,
735
+ );
736
+ expect(doc.jobs!["live-apply-gate-notice"].env).toBeUndefined();
737
+ });
738
+
739
+ test("a spec declaring variables changes exactly the job's env: block and nothing else", () => {
740
+ const spec: ScheduledOpSpec = { name: "actions-audit", schedule: "0 6 * * *" };
741
+ const before = generateGithubOpPipeline([spec]).files[0].yaml;
742
+ const after = generateGithubOpPipeline([{ ...spec, variables: CREDENTIAL }]).files[0].yaml;
743
+ expect(after).toContain(" env:\n AWS_ACCESS_KEY_ID: '${{ secrets.AWS_ACCESS_KEY_ID }}'\n");
744
+ expect(
745
+ after.replace(" env:\n AWS_ACCESS_KEY_ID: '${{ secrets.AWS_ACCESS_KEY_ID }}'\n", ""),
746
+ ).toBe(before);
747
+ });
748
+ });
@@ -57,6 +57,17 @@
57
57
  * environment protection is repository configuration, not a token scope — and
58
58
  * {@link assertEnvironment} refuses only what would emit as configured and
59
59
  * bind nothing.
60
+ *
61
+ * A fourth is per-Op credentials (#2290): a spec's `variables` emits the Op's
62
+ * own job-level `env:`, beside (and layered over, on a key collision)
63
+ * `options.variables`'s workflow-level `env:`. `options.variables` keeps
64
+ * meaning what it always has — set once, landing on every generated file — so
65
+ * a caller who declares nothing per-Op sees byte-identical output; a spec's
66
+ * own `variables` is the way one Op's job can hold a credential no other Op's
67
+ * job receives, which a workflow-level declaration can never express because
68
+ * every generated file here has exactly the one Op job (plus, on a `push`
69
+ * trigger, the gate-notice job beside it — job-level `env:` does not reach
70
+ * that job either, same as `environment:` above does not).
60
71
  */
61
72
 
62
73
  import { emitYAML } from "@intentius/chant/yaml";
@@ -234,18 +245,50 @@ const GATE_OUTPUT_SCRIPT =
234
245
  * `--json`, tee'd so the record is both in the log and on disk, then read for
235
246
  * the job's outputs.
236
247
  *
237
- * `set -o pipefail` is not decoration. GitHub's default shell is `bash -e`,
238
- * which does not set it, so a failing `chant run` piped into `tee` would come
239
- * back as `tee`'s zero and turn a broken apply green the exact thing this
240
- * whole change must not do.
248
+ * `set -o pipefail` is not decoration. Without it, a failing `chant run`
249
+ * piped into `tee` comes back as `tee`'s own zero and turns a broken apply
250
+ * green the exact thing this whole change must not do. But `pipefail` is a
251
+ * bash-ism: GitHub's (and Forgejo's) default shell for a job with no
252
+ * `container:` is `bash --noprofile --norc -eo pipefail`, which already sets
253
+ * it, but the default for a job that *does* carry `container:` — which every
254
+ * Op job does, this one included (#2299) — is plain `sh`, which rejects `set
255
+ * -o pipefail` outright (`Illegal option -o pipefail`) and fails the step
256
+ * before `chant` ever runs. This generator's own Op job always sets
257
+ * `container:` (see {@link buildGithubOpPipelineDocs}'s `container: image`),
258
+ * so the failure is not a corner case — it is what every gated apply and
259
+ * gated adopt does today.
260
+ *
261
+ * That is why this function returns the whole step record rather than just
262
+ * the script text: `shell: "bash"` is bundled into the same return value as
263
+ * the `pipefail` line, so nothing that calls it can get one without the
264
+ * other. A future edit that inlines another `pipefail`-bearing script has to
265
+ * either reuse this function or open a new call site that a reviewer can see
266
+ * declares no shell — the property that made #2299 possible in the first
267
+ * place is no longer available by accident.
268
+ *
269
+ * The `node -e` line and the job's own `outputs:` mapping exist for exactly
270
+ * one reader: the gate-notice job beside it, which is `needs:`-only readable
271
+ * through `steps.<id>.outputs`/`needs.<job>.outputs`. `emitOutputs: false`
272
+ * (forgejo, #2294 — the notice job does not cross the dialect at all) drops
273
+ * that line: nothing on Forgejo reads `$GITHUB_OUTPUT` for this job, and
274
+ * keeping the line would be a wired-up mechanism with no reader, which is the
275
+ * exact thing #2294 is about. The tee'd invocation survives either way — it's
276
+ * what puts the run's own JSON record in the log, gate-notice job or not.
241
277
  */
242
- function gatedRunScript(op: string, invocation: string): string {
243
- return [
278
+ function gatedRunStep(
279
+ id: string,
280
+ op: string,
281
+ invocation: string,
282
+ emitOutputs: boolean,
283
+ env: Record<string, string>,
284
+ ): Record<string, unknown> {
285
+ const lines = [
244
286
  "set -o pipefail",
245
287
  'json="${RUNNER_TEMP:-/tmp}/chant-run-' + op + '.json"',
246
288
  `${invocation} | tee "$json"`,
247
- `node -e '${GATE_OUTPUT_SCRIPT}' "$json"`,
248
- ].join("\n");
289
+ ];
290
+ if (emitOutputs) lines.push(`node -e '${GATE_OUTPUT_SCRIPT}' "$json"`);
291
+ return { id, run: lines.join("\n"), shell: "bash", env };
249
292
  }
250
293
 
251
294
  /**
@@ -274,24 +317,39 @@ const NOTICE_BODY_FORMAT =
274
317
  * direct push to the branch, a merge whose commit the API does not associate —
275
318
  * the notice becomes an issue instead, which is the `issue` finding mode's own
276
319
  * recipe and the reason this job carries `issues: write`.
320
+ *
321
+ * `$api_base` (chant #2305), resolved the same way `stickyCommentScript`
322
+ * (`lexicons/github/src/composites/pr-plan-report.ts`) and `reconcilePr`'s
323
+ * `githubApiBaseFrom` (`packages/core/src/op/activities/reconcile.ts`)
324
+ * resolve it: `$GITHUB_API_URL` with a trailing slash trimmed, falling back
325
+ * to `https://api.github.com`. This job never crosses to Forgejo today — the
326
+ * forgejo generator's `gatedNoticeDoc` is never carried across the dialect
327
+ * (#2294) — so github.com and GitHub Enterprise Server are the only hosts
328
+ * that run this script, and both already resolved correctly under `gh`'s own
329
+ * bare-path guess. Built from `$api_base` instead, the URL is byte-identical
330
+ * on those two hosts, and the same anti-pattern that broke Forgejo elsewhere
331
+ * (#2291, #2305) does not get a second copy here in case a future notice job
332
+ * does reach Forgejo.
277
333
  */
278
334
  function gateNoticeScript(): string {
279
335
  return [
336
+ 'api_base="${GITHUB_API_URL%/}"',
337
+ 'api_base="${api_base:-https://api.github.com}"',
280
338
  'marker="<!-- chant-gate:$CHANT_OP -->"',
281
339
  "body=$(printf '" + NOTICE_BODY_FORMAT + "' " +
282
340
  '"$marker" "$CHANT_OP" "$GITHUB_SHA" "$CHANT_GATE" "$CHANT_APPROVE" "$CHANT_OP")',
283
- 'pr=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls" --jq ".[0].number // empty")',
341
+ 'pr=$(gh api "$api_base/repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls" --jq ".[0].number // empty")',
284
342
  'if [ -z "$pr" ]; then',
285
343
  ' gh issue create --title "$CHANT_OP is waiting on gate $CHANT_GATE" --body "$body"',
286
344
  " exit 0",
287
345
  "fi",
288
- 'id=$(gh api "repos/$GITHUB_REPOSITORY/issues/$pr/comments" --paginate ' +
346
+ 'id=$(gh api "$api_base/repos/$GITHUB_REPOSITORY/issues/$pr/comments" --paginate ' +
289
347
  '--jq "map(select(.body | startswith(\\"$marker\\"))) | .[0].id // empty" ' +
290
348
  '| grep -m1 -E "^[0-9]+$" || true)',
291
349
  'if [ -n "$id" ]; then',
292
- ' gh api --method PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$id" -f "body=$body" --jq .html_url',
350
+ ' gh api --method PATCH "$api_base/repos/$GITHUB_REPOSITORY/issues/comments/$id" -f "body=$body" --jq .html_url',
293
351
  "else",
294
- ' gh api --method POST "repos/$GITHUB_REPOSITORY/issues/$pr/comments" -f "body=$body" --jq .html_url',
352
+ ' gh api --method POST "$api_base/repos/$GITHUB_REPOSITORY/issues/$pr/comments" -f "body=$body" --jq .html_url',
295
353
  "fi",
296
354
  ].join("\n");
297
355
  }
@@ -559,6 +617,24 @@ function setupStepDoc(step: OpSetupStep): Record<string, unknown> {
559
617
  };
560
618
  }
561
619
 
620
+ /**
621
+ * Internal knobs beside the public {@link GenerateGithubOpOptions} — not part
622
+ * of that type because they are not something a project author sets, only
623
+ * something a dialect on top of this builder (forgejo, #2294) needs to
624
+ * change about the shape this function emits for every spec alike.
625
+ */
626
+ export interface BuildGithubOpPipelineDocsInternalOptions {
627
+ /**
628
+ * Whether a `push` job's outputs (`gated`/`op`/`gate`/`approve`) and the
629
+ * `node -e` step that writes them to `$GITHUB_OUTPUT` are emitted (#2294).
630
+ * Default true (github's own behavior, unchanged): the gate-notice job
631
+ * beside it reads them. The forgejo generator passes `false` — it never
632
+ * carries a gate-notice job (`gatedNoticeDoc` never crosses the dialect),
633
+ * so nothing would ever read them there.
634
+ */
635
+ emitGatedOutputs?: boolean;
636
+ }
637
+
562
638
  /**
563
639
  * Build one `GithubOpPipelineDoc` per scheduled Op: its trigger, its `setup`
564
640
  * steps, least-privilege `permissions:` for its finding-mode plus whatever
@@ -574,7 +650,9 @@ function setupStepDoc(step: OpSetupStep): Record<string, unknown> {
574
650
  export function buildGithubOpPipelineDocs(
575
651
  ops: ScheduledOpSpec[],
576
652
  options: GenerateGithubOpOptions = {},
653
+ internalOptions: BuildGithubOpPipelineDocsInternalOptions = {},
577
654
  ): { files: GithubOpPipelineFile[]; jobs: OpPipelineJob[] } {
655
+ const emitGatedOutputs = internalOptions.emitGatedOutputs ?? true;
578
656
  const image = options.image ?? DEFAULT_IMAGE;
579
657
  const runCommand = options.runCommand ?? ["chant", "run", "{name}"];
580
658
  const beforeScript = options.beforeScript ?? [];
@@ -615,7 +693,7 @@ export function buildGithubOpPipelineDocs(
615
693
  for (const line of beforeScript) steps.push({ run: line });
616
694
  steps.push(
617
695
  gated
618
- ? { id: RUN_STEP_ID, run: gatedRunScript(spec.name, invocation), env: stepEnv }
696
+ ? gatedRunStep(RUN_STEP_ID, spec.name, invocation, emitGatedOutputs, stepEnv)
619
697
  : { run: invocation, env: stepEnv },
620
698
  );
621
699
  for (const line of extraScript) steps.push({ run: line });
@@ -636,12 +714,17 @@ export function buildGithubOpPipelineDocs(
636
714
  [jobName]: {
637
715
  "runs-on": "ubuntu-latest",
638
716
  container: image,
717
+ // Per-Op credentials (#2290): job-level `env:`, one level more
718
+ // specific than `options.variables`'s workflow-level `env:` above,
719
+ // and never on the notice job beside it — same reason `environment:`
720
+ // isn't either, it's this Op's own job alone.
721
+ ...(spec.variables && Object.keys(spec.variables).length > 0 ? { env: spec.variables } : {}),
639
722
  // On this Op's own job and never on the notice job beside it: the
640
723
  // notice exists to say a chant gate is pending, and putting it
641
724
  // behind the same reviewer would hold the message back until
642
725
  // somebody had already acted.
643
726
  ...(spec.environment ? { environment: environmentDoc(spec.environment) } : {}),
644
- ...(gated
727
+ ...(gated && emitGatedOutputs
645
728
  ? {
646
729
  outputs: Object.fromEntries(
647
730
  ["gated", "op", "gate", "approve"].map((name) => [
@@ -89,8 +89,8 @@ describe("PrPlanReport composite (#1983)", () => {
89
89
  const { job } = PrPlanReport({ environment: "prod" });
90
90
  const postStep = steps(job).find((s) => s.props.name === "Post or update PR comment")!;
91
91
  const run = postStep.props.run!;
92
- expect(run).toContain('gh api -X PATCH "repos/$REPO/issues/comments/$comment_id" -F body=@plan.md');
93
- expect(run).toContain('gh api -X POST "repos/$REPO/issues/$PR_NUMBER/comments" -F body=@plan.md');
92
+ expect(run).toContain('gh api -X PATCH "$api_base/repos/$REPO/issues/comments/$comment_id" -F body=@plan.md');
93
+ expect(run).toContain('gh api -X POST "$api_base/repos/$REPO/issues/$PR_NUMBER/comments" -F body=@plan.md');
94
94
  expect(run).not.toContain("-f body=@");
95
95
  // The plan step writes the marker as plan.md's first line, so the body the
96
96
  // -F read now starts with `$MARKER` and the jq `startswith` search finds
@@ -101,6 +101,30 @@ describe("PrPlanReport composite (#1983)", () => {
101
101
  expect(planStep.props.run).toContain("> plan.md");
102
102
  });
103
103
 
104
+ // chant #2305 — this script's three `gh api` calls used to take a bare
105
+ // relative path (`repos/$REPO/...`), which `gh` resolves against `/api/v3`
106
+ // for any host but github.com. GitHub Enterprise Server serves that path,
107
+ // so the bug was invisible there; Forgejo does not, and answers 404 for
108
+ // GET and POST alike (settled against a real instance in #2291/#2304, the
109
+ // same fix this composite gets here). Every call now targets `$api_base`,
110
+ // built from `$GITHUB_API_URL` the same way `githubApiBaseFrom`
111
+ // (`packages/core/src/op/activities/reconcile.ts`) reads it for
112
+ // `reconcilePr`, with the github.com fallback inlined since this is a shell
113
+ // string emitted into YAML rather than a TypeScript call.
114
+ test("the sticky-comment script resolves its API base from $GITHUB_API_URL, not a bare path (#2305)", () => {
115
+ const { job } = PrPlanReport({ environment: "prod" });
116
+ const postStep = steps(job).find((s) => s.props.name === "Post or update PR comment")!;
117
+ const run = postStep.props.run!;
118
+ expect(run).toContain('api_base="${GITHUB_API_URL%/}"');
119
+ expect(run).toContain('api_base="${api_base:-https://api.github.com}"');
120
+ expect(run).toContain('gh api "$api_base/repos/$REPO/issues/$PR_NUMBER/comments" --paginate');
121
+ expect(run).toContain('gh api -X PATCH "$api_base/repos/$REPO/issues/comments/$comment_id"');
122
+ expect(run).toContain('gh api -X POST "$api_base/repos/$REPO/issues/$PR_NUMBER/comments"');
123
+ // No bare-path call survives: every `gh api` invocation is immediately
124
+ // followed by `"$api_base/repos/`, never by `"repos/` on its own.
125
+ expect(run).not.toMatch(/gh api[^\n]*"repos\//);
126
+ });
127
+
104
128
  test("the emitted workflow passes the github lexicon's own lint — no errors, pinned actions included", () => {
105
129
  const { job } = PrPlanReport({ environment: "prod", before: ["aws sts get-caller-identity"] });
106
130
  const workflow = new Workflow({
@@ -46,25 +46,43 @@ export interface PrPlanReportProps {
46
46
  }
47
47
 
48
48
  /**
49
- * Sticky-comment script (#1223's mechanism, reused as-is): find the comment
50
- * whose body starts with `$MARKER`, PATCH it if found, POST otherwise. No
51
- * marketplace action, nothing extra to pin — `gh` ships on GitHub's hosted
52
- * runners. The flag is `-F`, not `-f`: `gh api`'s `-F/--field` is the typed
53
- * form that reads the value from a file when it starts with `@`, while
54
- * `-f/--raw-field` adds the parameter as a literal string, so the `-f` form
55
- * posted the eight characters `@plan.md` (#2236). Reading the body from the
56
- * file the plan step wrote means a large or multi-line plan never has to
57
- * survive shell quoting. `-F`'s type coercion of `true`/`false`/`null`/
58
- * integers does not reach the body: gh resolves the leading `@` first and
59
- * hands back the file's bytes as a string.
49
+ * Sticky-comment script (#1223's mechanism), find the comment whose body
50
+ * starts with `$MARKER`, PATCH it if found, POST otherwise. No marketplace
51
+ * action, nothing extra to pin — `gh` ships on GitHub's hosted runners. The
52
+ * flag is `-F`, not `-f`: `gh api`'s `-F/--field` is the typed form that
53
+ * reads the value from a file when it starts with `@`, while `-f/--raw-field`
54
+ * adds the parameter as a literal string, so the `-f` form posted the eight
55
+ * characters `@plan.md` (#2236). Reading the body from the file the plan step
56
+ * wrote means a large or multi-line plan never has to survive shell quoting.
57
+ * `-F`'s type coercion of `true`/`false`/`null`/integers does not reach the
58
+ * body: gh resolves the leading `@` first and hands back the file's bytes as
59
+ * a string.
60
+ *
61
+ * `$api_base` (chant #2305) is `$GITHUB_API_URL` with any trailing slash
62
+ * trimmed, falling back to `https://api.github.com` when it is unset —
63
+ * `$GITHUB_API_URL` is already correct on every GitHub Actions and Forgejo
64
+ * Actions job (`https://api.github.com` on github.com, `<host>/api/v3` on
65
+ * GitHub Enterprise Server, `<host>/api/v1` on Forgejo), the same fact
66
+ * `githubApiBaseFrom` in `packages/core/src/op/activities/reconcile.ts`
67
+ * builds on for `reconcilePr`'s `postOrUpdateComment` (#2291). This script
68
+ * cannot import that helper — it is a shell string emitted into YAML, not a
69
+ * TypeScript call — so the equivalent base-URL construction is inlined here
70
+ * instead. Without it, every `gh api` call below took a bare relative path,
71
+ * which `gh` resolves against `/api/v3` for any host but github.com; that
72
+ * guess is right for GitHub Enterprise Server but wrong for Forgejo, which
73
+ * does not serve `/api/v3` and answered 404 for GET and POST alike. On
74
+ * github.com and GHES the resolved URL is byte-identical to `gh`'s own
75
+ * guess, so this changes nothing there.
60
76
  */
61
77
  const stickyCommentScript = [
62
- 'comment_id=$(gh api "repos/$REPO/issues/$PR_NUMBER/comments" --paginate ' +
78
+ 'api_base="${GITHUB_API_URL%/}"',
79
+ 'api_base="${api_base:-https://api.github.com}"',
80
+ 'comment_id=$(gh api "$api_base/repos/$REPO/issues/$PR_NUMBER/comments" --paginate ' +
63
81
  '--jq "map(select(.body | startswith(\\"$MARKER\\"))) | .[0].id // empty")',
64
82
  'if [ -n "$comment_id" ]; then',
65
- ' gh api -X PATCH "repos/$REPO/issues/comments/$comment_id" -F body=@plan.md > /dev/null',
83
+ ' gh api -X PATCH "$api_base/repos/$REPO/issues/comments/$comment_id" -F body=@plan.md > /dev/null',
66
84
  "else",
67
- ' gh api -X POST "repos/$REPO/issues/$PR_NUMBER/comments" -F body=@plan.md > /dev/null',
85
+ ' gh api -X POST "$api_base/repos/$REPO/issues/$PR_NUMBER/comments" -F body=@plan.md > /dev/null',
68
86
  "fi",
69
87
  ].join("\n");
70
88
 
@@ -79,7 +97,9 @@ const stickyCommentScript = [
79
97
  * proved (#1223), reused here rather than reinvented. The forgejo lexicon
80
98
  * inherits this composite through its github re-export, since Forgejo Actions
81
99
  * runs the same workflow shape and the Forgejo API accepts the same `gh api`
82
- * calls against its GitHub-compatible surface.
100
+ * calls against its GitHub-compatible surface — once they target a full URL
101
+ * rather than a bare relative path, which `stickyCommentScript` now does
102
+ * (#2305, the same fix #2291 made for `reconcilePr`'s `postOrUpdateComment`).
83
103
  *
84
104
  * Only meaningful on a `pull_request`-triggered workflow — the job guards on
85
105
  * `github.event_name` since the comment targets `github.event.number`, which