@intentius/chant-lexicon-github 0.59.0 → 0.61.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.
@@ -46,8 +46,19 @@
46
46
  * reason) and never touching one it does; {@link mergePermissions} refuses a
47
47
  * blanket grant, an overlap with the mode's own set, an unknown scope name,
48
48
  * and pull-request write on a trigger that has no pull request.
49
+ *
50
+ * A third widens it the other way (#2257): a spec's `environment` emits
51
+ * `environment:` on the Op's own job, which is how a GitHub environment's
52
+ * protection rules — required reviewers above all — come to hold a generated
53
+ * apply. That is a second gate beside chant's own, not a replacement for it:
54
+ * the reviewer stops the job before any step runs, chant's gate ledger
55
+ * (#2119) stops the apply inside a run that already started, and the two
56
+ * compose in either combination. It costs {@link permissionsFor} nothing —
57
+ * environment protection is repository configuration, not a token scope — and
58
+ * {@link assertEnvironment} refuses only what would emit as configured and
59
+ * bind nothing.
49
60
  */
50
- import type { ComponentPipelineOptions as GenerateGithubOpOptions, OpPipelineJob, OpPipelineResult as GenerateGithubOpResult, OpSetupStep, OpTrigger, ScheduledOpSpec } from "@intentius/chant/lexicon";
61
+ import type { ComponentPipelineOptions as GenerateGithubOpOptions, OpEnvironment, OpPipelineJob, OpPipelineResult as GenerateGithubOpResult, OpSetupStep, OpTrigger, ScheduledOpSpec } from "@intentius/chant/lexicon";
51
62
  export type { GenerateGithubOpOptions, GenerateGithubOpResult };
52
63
  /**
53
64
  * The structured pipeline document behind one generated file, before YAML
@@ -57,6 +68,15 @@ export type { GenerateGithubOpOptions, GenerateGithubOpResult };
57
68
  * `./generate-pipeline.ts`'s `GithubPipelineDoc` split.
58
69
  */
59
70
  export interface GithubOpPipelineDoc {
71
+ /**
72
+ * Comment lines emitted above the document, `#` prefix included, when a
73
+ * dialect has something to say about what it could not carry across
74
+ * (#2257). Empty on github, which drops nothing; the forgejo dialect uses
75
+ * it to name the `environment:` its runner has no concept of, so the fact
76
+ * that a reviewer gate did not survive is readable in the generated file
77
+ * rather than only in a build warning.
78
+ */
79
+ header?: string[];
60
80
  /**
61
81
  * The `on:` trigger mapping, per {@link ScheduledOpSpec}'s trigger kind
62
82
  * (#2084): `{ schedule, workflow_dispatch }` for cron, `{ pull_request }`
@@ -80,8 +100,10 @@ export interface GithubOpPipelineDoc {
80
100
  * Kept out of {@link jobsDoc} so a dialect that cannot run it drops it by
81
101
  * simply not copying it: the job shells to `gh` against the GitHub API and
82
102
  * needs `gh` on the runner, which is the same reason the `comment` finding
83
- * mode is refused on forgejo and gitlab (#2231). {@link emitOpPipelineYAML}
84
- * merges it into `jobs:` for the forges that can.
103
+ * mode is refused on forgejo (#2231). {@link emitOpPipelineYAML} merges it
104
+ * into `jobs:` for the forges that can. GitLab reaches the same outcome
105
+ * without this job at all: its push job writes the pending block to an
106
+ * artifact instead (#2256).
85
107
  */
86
108
  gatedNoticeDoc?: Record<string, unknown>;
87
109
  }
@@ -102,6 +124,18 @@ export interface GithubOpPipelineFile {
102
124
  * cannot know a local path resolves there.
103
125
  */
104
126
  export declare function assertSetupSteps(name: string, setup: OpSetupStep[]): void;
127
+ /**
128
+ * Validate a spec's `environment` (#2257). GitHub creates an environment it
129
+ * has never seen on first use rather than failing the run, and an environment
130
+ * created that way carries no protection rules at all — so a job can name one
131
+ * and read as gated while being gated by nothing. Neither this generator nor
132
+ * GitHub can tell those apart at build time (the environment and its
133
+ * reviewers are repository configuration, not workflow content), which is why
134
+ * what is refused here is only the shape that could never bind: a name that
135
+ * is blank, and a `url` that is neither absolute nor an expression the forge
136
+ * resolves. The rest is the README's job to say out loud.
137
+ */
138
+ export declare function assertEnvironment(name: string, environment: OpEnvironment): void;
105
139
  /**
106
140
  * Merge a spec's additive `permissions` over the finding-mode's own set
107
141
  * (#2242), refusing by name anything that is not strictly additive:
@@ -1 +1 @@
1
- {"version":3,"file":"generate-op-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgDG;AAIH,OAAO,KAAK,EACV,wBAAwB,IAAI,uBAAuB,EAEnD,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;;;;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;;;;;;;OAOG;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;;;;;;;;;;;;;;;;;;;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;AAiBD;;;;;;;;;;;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,CAiF1D;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,mBAAmB,GAAG,MAAM,CAWnE;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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;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,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "3015db1776d8467a6769b316b4ffde3bbd225d53df528e97605b4b1645be3f27",
4
+ "manifest.json": "81759e8725dbe9f79f6747195efe17ce7e0504182857c99a317784b02c7ef629",
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": "17318d4ba3cbdc98681abd0d398a4e5ea0c596397464c6622c53e6f2289669e4"
80
+ "composite": "b47a71d4987ee8534216ed299add82e196f2ba21026e79bf6490473bb8df525c"
81
81
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github",
3
- "version": "0.59.0",
3
+ "version": "0.61.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.59.0",
3
+ "version": "0.61.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.59.0",
64
+ "@intentius/chant": "^0.61.0",
65
65
  "typescript": "^5.9.3"
66
66
  }
67
67
  }
@@ -27,6 +27,7 @@ interface ParsedStep {
27
27
  interface ParsedJob {
28
28
  "runs-on"?: string;
29
29
  container?: string;
30
+ environment?: Record<string, string>;
30
31
  needs?: string;
31
32
  if?: string;
32
33
  permissions?: Record<string, string>;
@@ -515,3 +516,129 @@ describe("generateGithubOpPipeline: the gated apply on push (#2243)", () => {
515
516
  expect(step?.run).toContain("set -o pipefail");
516
517
  });
517
518
  });
519
+
520
+ /**
521
+ * chant #2257 — the second gate. A GitHub environment carries its own
522
+ * protection rules (required reviewers above all), and until this existed no
523
+ * generated job named one, so a `production` environment declared in a
524
+ * repository bound nothing chant generated. What is asserted here is the key
525
+ * on the right job, the key's absence everywhere else, and that adding it
526
+ * changes nothing else in the document.
527
+ */
528
+ describe("generateGithubOpPipeline: a deployment environment on the Op's job (#2257)", () => {
529
+ const APPLY: ScheduledOpSpec = { name: "app-apply", trigger: { kind: "push", branches: ["main"] } };
530
+ const GATED: ScheduledOpSpec = { ...APPLY, environment: { name: "production" } };
531
+
532
+ /**
533
+ * The exact document a spec with no `environment` emitted before the option
534
+ * existed, produced by the generator at the commit this change branched
535
+ * from. The claim the option makes is that it is additive; this is what
536
+ * makes that claim falsifiable rather than a sentence in a PR body.
537
+ */
538
+ const AUDIT_YAML_BEFORE_2257 =
539
+ [
540
+ "on:",
541
+ " schedule:",
542
+ " - cron: '0 6 * * *'",
543
+ " workflow_dispatch: {}",
544
+ "",
545
+ "concurrency:",
546
+ " group: actions-audit",
547
+ " cancel-in-progress: false",
548
+ "",
549
+ "permissions:",
550
+ " contents: read",
551
+ " issues: write",
552
+ "",
553
+ "jobs:",
554
+ " actions-audit:",
555
+ " runs-on: ubuntu-latest",
556
+ " container: node:22-slim",
557
+ " steps:",
558
+ " - uses: actions/checkout@v4",
559
+ " - run: chant run actions-audit",
560
+ " env:",
561
+ " GITHUB_TOKEN: '${{ github.token }}'",
562
+ " GH_TOKEN: '${{ github.token }}'",
563
+ ].join("\n") + "\n";
564
+
565
+ test("a spec with no environment emits the bytes it emitted before the option existed", () => {
566
+ const yaml = generateGithubOpPipeline([
567
+ { name: "actions-audit", schedule: "0 6 * * *", findingMode: "issue" },
568
+ ]).files[0].yaml;
569
+ expect(yaml).toBe(AUDIT_YAML_BEFORE_2257);
570
+ });
571
+
572
+ test("emits environment: on the Op's job, as a mapping rather than the string shorthand", () => {
573
+ const job = parseFile(generateGithubOpPipeline([GATED]).files[0].yaml).jobs!["app-apply"];
574
+ expect(job.environment).toEqual({ name: "production" });
575
+ });
576
+
577
+ test("carries the url when the spec sets one", () => {
578
+ const spec: ScheduledOpSpec = {
579
+ ...APPLY,
580
+ environment: { name: "production", url: "https://app.example.com" },
581
+ };
582
+ const job = parseFile(generateGithubOpPipeline([spec]).files[0].yaml).jobs!["app-apply"];
583
+ expect(job.environment).toEqual({
584
+ name: "production",
585
+ url: "https://app.example.com",
586
+ });
587
+ });
588
+
589
+ test("adding the environment changes exactly the environment block and nothing else", () => {
590
+ const before = generateGithubOpPipeline([APPLY]).files[0].yaml;
591
+ const after = generateGithubOpPipeline([GATED]).files[0].yaml;
592
+ expect(after).toContain(" environment:\n name: production\n");
593
+ expect(after.replace(" environment:\n name: production\n", "")).toBe(before);
594
+ });
595
+
596
+ test("the gate-notice job is not held behind the same reviewer", () => {
597
+ // It exists to say a chant gate is pending. Behind the environment it
598
+ // would only be readable after somebody had already released the job it
599
+ // is reporting on, which is after the message stops being useful.
600
+ const notice = parseFile(generateGithubOpPipeline([GATED]).files[0].yaml).jobs![
601
+ "app-apply-gate-notice"
602
+ ];
603
+ expect(notice.environment).toBeUndefined();
604
+ });
605
+
606
+ test("costs no token scope: environment protection is repository configuration", () => {
607
+ // `permissionsFor` gains nothing from the option — the reviewer lives on
608
+ // the environment object, not on GITHUB_TOKEN — so the workflow-level set
609
+ // is identical with and without it.
610
+ const withEnv = parseFile(generateGithubOpPipeline([GATED]).files[0].yaml);
611
+ const withoutEnv = parseFile(generateGithubOpPipeline([APPLY]).files[0].yaml);
612
+ expect(withEnv.permissions).toEqual(withoutEnv.permissions);
613
+ expect(withEnv.permissions).toEqual({ contents: "read" });
614
+ });
615
+
616
+ test("applies to any trigger, so a pull_request plan can name a review environment too", () => {
617
+ const spec: ScheduledOpSpec = {
618
+ name: "app-plan",
619
+ trigger: { kind: "pull_request", branches: ["main"] },
620
+ findingMode: "comment",
621
+ environment: { name: "review", url: "${{ steps.deploy.outputs.url }}" },
622
+ };
623
+ const job = parseFile(generateGithubOpPipeline([spec]).files[0].yaml).jobs!["app-plan"];
624
+ expect(job.environment?.name).toBe("review");
625
+ });
626
+
627
+ test("refuses a blank environment name, which resolves to nothing", () => {
628
+ expect(() =>
629
+ generateGithubOpPipeline([{ ...APPLY, environment: { name: " " } }]),
630
+ ).toThrow(/environment has an empty `name`/);
631
+ });
632
+
633
+ test("refuses a url that is neither absolute nor an expression, which renders as a dead link", () => {
634
+ expect(() =>
635
+ generateGithubOpPipeline([{ ...APPLY, environment: { name: "production", url: "/deploys" } }]),
636
+ ).toThrow(/neither an absolute http\(s\) URL nor a/);
637
+ });
638
+
639
+ test("refuses an empty url rather than emitting one", () => {
640
+ expect(() =>
641
+ generateGithubOpPipeline([{ ...APPLY, environment: { name: "production", url: "" } }]),
642
+ ).toThrow(/has an empty `url`/);
643
+ });
644
+ });
@@ -46,12 +46,24 @@
46
46
  * reason) and never touching one it does; {@link mergePermissions} refuses a
47
47
  * blanket grant, an overlap with the mode's own set, an unknown scope name,
48
48
  * and pull-request write on a trigger that has no pull request.
49
+ *
50
+ * A third widens it the other way (#2257): a spec's `environment` emits
51
+ * `environment:` on the Op's own job, which is how a GitHub environment's
52
+ * protection rules — required reviewers above all — come to hold a generated
53
+ * apply. That is a second gate beside chant's own, not a replacement for it:
54
+ * the reviewer stops the job before any step runs, chant's gate ledger
55
+ * (#2119) stops the apply inside a run that already started, and the two
56
+ * compose in either combination. It costs {@link permissionsFor} nothing —
57
+ * environment protection is repository configuration, not a token scope — and
58
+ * {@link assertEnvironment} refuses only what would emit as configured and
59
+ * bind nothing.
49
60
  */
50
61
 
51
62
  import { emitYAML } from "@intentius/chant/yaml";
52
63
  import { resolveOpTrigger } from "@intentius/chant/lexicon";
53
64
  import type {
54
65
  ComponentPipelineOptions as GenerateGithubOpOptions,
66
+ OpEnvironment,
55
67
  OpFindingMode,
56
68
  OpPipelineJob,
57
69
  OpPipelineResult as GenerateGithubOpResult,
@@ -70,6 +82,15 @@ export type { GenerateGithubOpOptions, GenerateGithubOpResult };
70
82
  * `./generate-pipeline.ts`'s `GithubPipelineDoc` split.
71
83
  */
72
84
  export interface GithubOpPipelineDoc {
85
+ /**
86
+ * Comment lines emitted above the document, `#` prefix included, when a
87
+ * dialect has something to say about what it could not carry across
88
+ * (#2257). Empty on github, which drops nothing; the forgejo dialect uses
89
+ * it to name the `environment:` its runner has no concept of, so the fact
90
+ * that a reviewer gate did not survive is readable in the generated file
91
+ * rather than only in a build warning.
92
+ */
93
+ header?: string[];
73
94
  /**
74
95
  * The `on:` trigger mapping, per {@link ScheduledOpSpec}'s trigger kind
75
96
  * (#2084): `{ schedule, workflow_dispatch }` for cron, `{ pull_request }`
@@ -93,8 +114,10 @@ export interface GithubOpPipelineDoc {
93
114
  * Kept out of {@link jobsDoc} so a dialect that cannot run it drops it by
94
115
  * simply not copying it: the job shells to `gh` against the GitHub API and
95
116
  * needs `gh` on the runner, which is the same reason the `comment` finding
96
- * mode is refused on forgejo and gitlab (#2231). {@link emitOpPipelineYAML}
97
- * merges it into `jobs:` for the forges that can.
117
+ * mode is refused on forgejo (#2231). {@link emitOpPipelineYAML} merges it
118
+ * into `jobs:` for the forges that can. GitLab reaches the same outcome
119
+ * without this job at all: its push job writes the pending block to an
120
+ * artifact instead (#2256).
98
121
  */
99
122
  gatedNoticeDoc?: Record<string, unknown>;
100
123
  }
@@ -408,6 +431,43 @@ export function assertSetupSteps(name: string, setup: OpSetupStep[]): void {
408
431
  });
409
432
  }
410
433
 
434
+ /**
435
+ * Validate a spec's `environment` (#2257). GitHub creates an environment it
436
+ * has never seen on first use rather than failing the run, and an environment
437
+ * created that way carries no protection rules at all — so a job can name one
438
+ * and read as gated while being gated by nothing. Neither this generator nor
439
+ * GitHub can tell those apart at build time (the environment and its
440
+ * reviewers are repository configuration, not workflow content), which is why
441
+ * what is refused here is only the shape that could never bind: a name that
442
+ * is blank, and a `url` that is neither absolute nor an expression the forge
443
+ * resolves. The rest is the README's job to say out loud.
444
+ */
445
+ export function assertEnvironment(name: string, environment: OpEnvironment): void {
446
+ const where = `Scheduled Op "${name}" environment`;
447
+ if (environment.name.trim() === "") {
448
+ throw new Error(
449
+ `${where} has an empty \`name\`. An environment is named repository configuration — the ` +
450
+ `protection rules and reviewers live on the environment, not in this workflow — so there is ` +
451
+ `nothing for a blank name to resolve to. Give it the environment's name, or drop the option.`,
452
+ );
453
+ }
454
+ if (environment.url === undefined) return;
455
+ const url = environment.url.trim();
456
+ if (url === "") {
457
+ throw new Error(
458
+ `${where} "${environment.name}" has an empty \`url\`. Omit the field rather than setting it to "".`,
459
+ );
460
+ }
461
+ if (!/^https?:\/\//.test(url) && !url.includes("${{")) {
462
+ throw new Error(
463
+ `${where} "${environment.name}" has \`url: "${environment.url}"\`, which is neither an absolute ` +
464
+ `http(s) URL nor a \${{ }} expression. GitHub renders this value as the deployment's own link, ` +
465
+ `so a relative path becomes a dead link on the environment page rather than an error anywhere. ` +
466
+ `Write the full URL, or an expression the run resolves to one.`,
467
+ );
468
+ }
469
+ }
470
+
411
471
  /**
412
472
  * Merge a spec's additive `permissions` over the finding-mode's own set
413
473
  * (#2242), refusing by name anything that is not strictly additive:
@@ -472,6 +532,18 @@ export function mergePermissions(
472
532
  return merged;
473
533
  }
474
534
 
535
+ /**
536
+ * Emit a spec's environment as the job's `environment:` mapping. Always the
537
+ * mapping form, never the `environment: name` string shorthand, so adding a
538
+ * `url` later is a new key rather than a reshaped value.
539
+ */
540
+ function environmentDoc(environment: OpEnvironment): Record<string, unknown> {
541
+ return {
542
+ name: environment.name,
543
+ ...(environment.url === undefined ? {} : { url: environment.url }),
544
+ };
545
+ }
546
+
475
547
  /** Emit one setup entry as a GitHub Actions step. */
476
548
  function setupStepDoc(step: OpSetupStep): Record<string, unknown> {
477
549
  if ("uses" in step) {
@@ -527,6 +599,7 @@ export function buildGithubOpPipelineDocs(
527
599
 
528
600
  const setup = spec.setup ?? [];
529
601
  assertSetupSteps(spec.name, setup);
602
+ if (spec.environment) assertEnvironment(spec.name, spec.environment);
530
603
 
531
604
  // A `push` job is the one that has to survive a gate (#2243): the apply
532
605
  // runs with `--gated-exit 0` so a pending approval is a green run, and
@@ -563,6 +636,11 @@ export function buildGithubOpPipelineDocs(
563
636
  [jobName]: {
564
637
  "runs-on": "ubuntu-latest",
565
638
  container: image,
639
+ // On this Op's own job and never on the notice job beside it: the
640
+ // notice exists to say a chant gate is pending, and putting it
641
+ // behind the same reviewer would hold the message back until
642
+ // somebody had already acted.
643
+ ...(spec.environment ? { environment: environmentDoc(spec.environment) } : {}),
566
644
  ...(gated
567
645
  ? {
568
646
  outputs: Object.fromEntries(
@@ -592,6 +670,10 @@ export function buildGithubOpPipelineDocs(
592
670
  */
593
671
  export function emitOpPipelineYAML(doc: GithubOpPipelineDoc): string {
594
672
  const sections: string[] = [];
673
+ // A dialect's note about what it could not carry (#2257), when there is
674
+ // one. Absent on github, so an unannotated document is emitted exactly as
675
+ // it was before the field existed.
676
+ if (doc.header && doc.header.length > 0) sections.push(doc.header.join("\n"));
595
677
  sections.push("on:" + emitYAML(doc.on, 1));
596
678
  if (doc.env && Object.keys(doc.env).length > 0) sections.push("env:" + emitYAML(doc.env, 1));
597
679
  sections.push("concurrency:" + emitYAML(doc.concurrency, 1));