@intentius/chant-lexicon-gitlab 0.59.0 → 0.60.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.
@@ -1,65 +1,102 @@
1
1
  /**
2
- * Generate mode — scheduled Op → GitLab CI YAML (#927).
2
+ * Generate mode — Op → GitLab CI YAML (#927, #2084, #2256).
3
3
  *
4
4
  * The Op counterpart to `./generate-pipeline.ts` (#563): that module
5
5
  * synthesizes a deploy-time component graph as one `.gitlab-ci.yml`; this one
6
- * synthesizes a cron-triggered job per stateless Op. An Op's cadence is an
7
- * `OpSchedule` on the Op itself (`packages/core/src/op/types.ts`),
8
- * runtime-neutral data each reader interprets; this module is the reader that
9
- * turns it into a GitLab pipeline schedule (`WorkflowAuditOp`/
10
- * `PipelineAuditOp`/`ReconcileOp` all accept an optional `schedule` precisely
11
- * for this).
6
+ * synthesizes one job per Op, selected by its own `rules:`.
7
+ *
8
+ * What GitLab genuinely lacks is in-file cron. A schedule is a project-level
9
+ * object (Settings → CI/CD → Schedules) that runs the project's *existing*
10
+ * `.gitlab-ci.yml` with a chosen cron and CI/CD variables, so a cron-triggered
11
+ * Op becomes a job gated on `$CI_PIPELINE_SOURCE == "schedule"` plus a per-Op
12
+ * selector variable, and the generated file's header says what to set up. What
13
+ * GitLab does not lack, and what this generator wrongly refused until #2256,
14
+ * is the other two triggers (#2084): `$CI_PIPELINE_SOURCE` distinguishes
15
+ * `merge_request_event` from `push` on every pipeline, and `rules:` selects a
16
+ * job on either. So:
17
+ *
18
+ * - `cron` → `$CI_PIPELINE_SOURCE == "schedule" && $CHANT_SCHEDULED_OP ==
19
+ * "<name>"`, unchanged;
20
+ * - `pull_request` → `$CI_PIPELINE_SOURCE == "merge_request_event"`, with the
21
+ * branch filter mapped to `$CI_MERGE_REQUEST_TARGET_BRANCH_NAME`, which is
22
+ * the branch the merge request would merge INTO — the same thing github's
23
+ * `on.pull_request.branches` filters on;
24
+ * - `push` → `$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH ==
25
+ * "<branch>"`, defaulting to the same `main` github's generator assumes.
26
+ *
27
+ * Two or more branches are two or more rule entries, because `rules:` is an
28
+ * OR over its entries and a regex alternation would need every branch name
29
+ * escaped into a pattern.
30
+ *
31
+ * Every job carries a `resource_group`, GitLab's stand-in for github's per-Op
32
+ * `concurrency` group: one run at a time per Op, the next one queued rather
33
+ * than cancelled. On an apply job that is also what stops two runs racing for
34
+ * the same state lock.
35
+ *
36
+ * Unlike github, all of this lands in ONE file. A GitHub trigger is
37
+ * workflow-scoped, so an Op there needs its own workflow; a GitLab trigger is
38
+ * job-scoped, so the whole set is one document with one job apiece.
12
39
  *
13
- * Unlike GitHub Actions' per-workflow `on.schedule`, GitLab has no in-file
14
- * cron at all — a schedule is a project-level object (Settings → CI/CD →
15
- * Schedules) that runs the project's *existing* `.gitlab-ci.yml` with a
16
- * chosen cron and CI/CD variables. So every scheduled Op here becomes one
17
- * job in a single generated file, gated to run only under its own Pipeline
18
- * Schedule (`$CI_PIPELINE_SOURCE == "schedule"` plus a per-op selector
19
- * variable) — the cron itself is configured on the Pipeline Schedule, not in
20
- * this YAML, and the generated file's header comment states what to set up.
21
40
  * Each job runs exactly one invocation, `chant run <name>` by default — never
22
- * inlined audit/reconcile logic. The finding-mode itself is already baked
23
- * into the Op's own activity args at build time by the composite that
24
- * created it; GitLab has no per-job `permissions:` concept (unlike GitHub
25
- * Actions), and `findingMode: "comment"` is refused by name here because it
26
- * posts onto a GitHub pull-request event GitLab does not have (#2231), so a
27
- * non-`report` mode's write access comes from whatever
28
- * `GITLAB_TOKEN`/CI-CD-variable configuration the project already has —
29
- * this generator documents the requirement rather than fabricating a
30
- * variable nothing reads.
41
+ * inlined audit/reconcile logic. The finding-mode is already baked into the
42
+ * Op's own activity args at build time by the composite that created it; this
43
+ * generator only wires what the mode needs to act.
44
+ *
45
+ * `findingMode: "comment"` works here since #2256: `reconcilePr` writes a
46
+ * merge-request note when the run is a `merge_request_event` pipeline, by the
47
+ * same hidden-marker edit-in-place recipe it uses on a GitHub pull request.
48
+ * What survives of the old blanket refusal is the constraint github already
49
+ * has — the mode posts onto the merge request that triggered the run, so a
50
+ * cron or push job carrying it is refused by name at build time rather than
51
+ * emitted to fail at its Report step ({@link assertTriggerSupportsMode}).
52
+ * GitLab has no per-job `permissions:` concept, so the note's write access
53
+ * comes from a `GITLAB_TOKEN` CI/CD variable, which the header names.
54
+ *
55
+ * #2242's two per-Op options cross over one and a half times. A `setup` entry
56
+ * spelled `{ uses }` is a GitHub Actions marketplace action; GitLab CI has
57
+ * `script` and nothing else, so there is no shape to translate it into and it
58
+ * is refused by name. A `{ run }` entry translates exactly, and is emitted
59
+ * ahead of the `beforeScript` lines — in `script:` rather than
60
+ * `before_script:`, which is where `./generate-pipeline.ts` puts the same
61
+ * option's lines in this same lexicon, and which concatenates identically at
62
+ * run time. An additive `permissions` map has exactly one entry with a GitLab
63
+ * meaning: `id-token: write` becomes an `id_tokens:` declaration ({@link
64
+ * idTokensFor}), the OIDC surface the old refusal already named as the shape
65
+ * to reach for. Every other scope is still refused, because there is no
66
+ * per-job token-scope mapping to put it in and a silently dropped scope emits
67
+ * a job that reads as granted and runs with nothing.
31
68
  *
32
- * Neither of #2242's two per-Op options survives the crossing, and both are
33
- * refused by name rather than dropped. A `setup` entry spelled `{ uses }` is
34
- * a GitHub Actions marketplace action; GitLab CI has `script` and nothing
35
- * else, so there is no shape to translate it into and no way to approximate
36
- * `aws-actions/configure-aws-credentials` in a shell line. A `{ run }` entry
37
- * translates exactly, and is emitted ahead of the `beforeScript` lines, the
38
- * same position the github generator gives it. An additive `permissions` map
39
- * is refused for the same reason `permissions:` is absent here at all: GitLab
40
- * has no per-job token-scope mapping, and its OIDC surface is a different
41
- * declaration (`id_tokens:` with an `aud`, exchanged for cloud credentials by
42
- * the job itself) that chant does not generate. Ignoring the map would emit a
43
- * job that reads as having OIDC and runs with no credentials.
69
+ * A spec's `environment` (#2257) does cross, because GitLab has the concept
70
+ * under the same key and with the same two fields: `environment: { name, url
71
+ * }` on the job, an environment object in the project, and — on a protected
72
+ * environment — an approval rule that holds the deployment job until an
73
+ * approver releases it. So the reviewer gate the option exists for is
74
+ * expressible here, unlike the `uses` setup step above, and it is emitted
75
+ * rather than refused. What GitLab does not have is any way for this file to
76
+ * declare the protection: an environment's approval rules are project
77
+ * settings (Settings > CI/CD > Protected environments), exactly as GitHub's
78
+ * required reviewers are repository settings, so the generated header names
79
+ * the environment to protect the way it already names the schedule to create.
80
+ * chant's own gate (#2119) runs inside the job either way.
44
81
  *
45
- * The gated-apply mapping (#2243) has nothing to attach to here. It exists
46
- * because a push-to-main apply that stops at its gate exits 3 and paints the
47
- * branch red on every merge; this generator has no push pipeline at all,
48
- * refusing a `push` trigger by name (#2084) because a GitLab schedule is a
49
- * project-level cron object rather than an event. GitLab does have its own
50
- * equivalent of the mapping should one ever be wanted — `allow_failure:
51
- * { exit_codes: [3] }` turns one exit code into a warning rather than a
52
- * failure, without a flag on the invocation — so the day this generator
53
- * grows a push trigger, that is the shape to reach for rather than
54
- * `--gated-exit`.
82
+ * The gated apply (#2243) lands in the two surfaces GitLab has. `chant run`
83
+ * returns 3 when a run stops at an unapproved gate, so a push-to-default apply
84
+ * would paint the branch red on every merge until someone approves; the push
85
+ * job runs with `--gated-exit 0`, mapping that one outcome and nothing else.
86
+ * Where GitHub Actions gets the pending block on its run page through
87
+ * `GITHUB_STEP_SUMMARY`, GitLab has no step summary at all, so the job sets
88
+ * `CHANT_GATE_SUMMARY` to a path it also declares under `artifacts:` and the
89
+ * block is downloadable from the pipeline. The human render is already in the
90
+ * job log, since the invocation carries no `--json`. There is no follow-up
91
+ * notice job: it would need a forge API call and a token this generator does
92
+ * not require of a `report`-mode Op.
55
93
  */
56
94
  import type { ComponentPipelineOptions as GenerateGitlabOpOptions, OpPipelineResult as GenerateGitlabOpResult, ScheduledOpSpec } from "@intentius/chant/lexicon";
57
95
  export type { GenerateGitlabOpOptions, GenerateGitlabOpResult };
58
96
  /**
59
- * Synthesize one `.gitlab-ci.yml` job per scheduled Op, all in a single file
60
- * (GitLab has no per-file cron — see the module doc). Wired into core's Op
61
- * generate mode via the gitlab lexicon plugin's `generateOpPipeline`
62
- * (../plugin.ts).
97
+ * Synthesize one `.gitlab-ci.yml` job per Op, all in a single file (a GitLab
98
+ * trigger is job-scoped — see the module doc). Wired into core's Op generate
99
+ * mode via the gitlab lexicon plugin's `generateOpPipeline` (../plugin.ts).
63
100
  */
64
101
  export declare function generateGitlabOpPipeline(ops: ScheduledOpSpec[], options?: GenerateGitlabOpOptions): GenerateGitlabOpResult;
65
102
  //# sourceMappingURL=generate-op-pipeline.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"generate-op-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsDG;AAIH,OAAO,KAAK,EACV,wBAAwB,IAAI,uBAAuB,EAGnD,gBAAgB,IAAI,sBAAsB,EAC1C,eAAe,EAChB,MAAM,0BAA0B,CAAC;AAiClC,YAAY,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,CAAC;AAmBhE;;;;;GAKG;AACH,wBAAgB,wBAAwB,CACtC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,GACpC,sBAAsB,CAmExB"}
1
+ {"version":3,"file":"generate-op-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4FG;AAIH,OAAO,KAAK,EACV,wBAAwB,IAAI,uBAAuB,EAInD,gBAAgB,IAAI,sBAAsB,EAE1C,eAAe,EAChB,MAAM,0BAA0B,CAAC;AAElC,YAAY,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,CAAC;AAkQhE;;;;GAIG;AACH,wBAAgB,wBAAwB,CACtC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,GACpC,sBAAsB,CA0FxB"}
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "05eca8e8e24c1045cd8d1b9903dfa59661449d40b52b9d2f880eb2a5a00c67d2",
4
+ "manifest.json": "5d264d53893a2aab9313a8f3285ca1a231e36963a92bbd31c841ea3a4ea55472",
5
5
  "meta.json": "931fc3246a55645b1493080bbeb160e5d205e42349e8bdca96ca243adb5f0da3",
6
6
  "types/index.d.ts": "5cd2e99f135a929b72bdd822d00d780d39b1cd407ba0cfb3d511c7ac9d667b58",
7
7
  "rules/artifact-no-expiry.ts": "3f3cabf9792cbf8207e53a25f506715466b19ec25e9c3b4d0d77fed6b2eb4542",
@@ -55,5 +55,5 @@
55
55
  "skills/chant-gitlab-migrate.md": "4853d04980560b379e5b0f7267dcb0a1e5d5879cb9e9931681ce76b2a5fe69d8",
56
56
  "skills/chant-gitlab-patterns.md": "6d9a44e9e8de4c3820be9d65381b0d2ede4cb3626aaaf0011e21838a9fbf556e"
57
57
  },
58
- "composite": "5ba4e358f25944a07f0966355b274aebb3c199d11fc997aa7713830c7ff5320e"
58
+ "composite": "1e6b47ac90ebf64923dd27b5b5b3cecf91c1853eeb876d923568538fd1615678"
59
59
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitlab",
3
- "version": "0.59.0",
3
+ "version": "0.60.0",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "GitLab",
6
6
  "intrinsics": [
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAiC,MAAM,0BAA0B,CAAC;AAoB7F,eAAO,MAAM,YAAY,EAAE,aAmhB1B,CAAC"}
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAiC,MAAM,0BAA0B,CAAC;AAoB7F,eAAO,MAAM,YAAY,EAAE,aAohB1B,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-gitlab",
3
- "version": "0.59.0",
3
+ "version": "0.60.0",
4
4
  "description": "GitLab CI lexicon for chant — declarative IaC in TypeScript",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -67,8 +67,8 @@
67
67
  "typescript": "^5.9.3"
68
68
  },
69
69
  "peerDependencies": {
70
- "@intentius/chant": "^0.59.0",
71
- "@intentius/chant-lexicon-github": "^0.59.0",
70
+ "@intentius/chant": "^0.60.0",
71
+ "@intentius/chant-lexicon-github": "^0.60.0",
72
72
  "typescript": "^5.9.3"
73
73
  },
74
74
  "peerDependenciesMeta": {
@@ -70,33 +70,190 @@ describe("generateGitlabOpPipeline: one file, one job per scheduled Op", () => {
70
70
  });
71
71
  });
72
72
 
73
- describe("generateGitlabOpPipeline: no pull_request/push event model (#2084)", () => {
74
- test("a pull_request trigger throws a clear error instead of silently ignoring it", () => {
75
- const specs: ScheduledOpSpec[] = [{ name: "tf-plan", trigger: { kind: "pull_request" } }];
76
- expect(() => generateGitlabOpPipeline(specs)).toThrow(/pull_request.*GitLab has no pull_request\/push event model/s);
73
+ /**
74
+ * #2084's trigger pair, now that GitLab has both events after all (#2256).
75
+ *
76
+ * The two refusals this replaces ("GitLab has no pull_request/push event
77
+ * model" on either trigger) were wrong about GitLab rather than about chant:
78
+ * `$CI_PIPELINE_SOURCE` distinguishes `merge_request_event` from `push` on
79
+ * every pipeline, and `rules:` selects a job on either. What is genuinely
80
+ * absent is in-file cron, which is why the cron path still runs off a
81
+ * project-level Pipeline Schedule and is unchanged below.
82
+ */
83
+ describe("generateGitlabOpPipeline: the merge_request and push triggers (#2084, #2256)", () => {
84
+ test("a pull_request trigger becomes a merge_request_event rule filtered to the target branch", () => {
85
+ const result = generateGitlabOpPipeline([
86
+ { name: "tf-plan", trigger: { kind: "pull_request", branches: ["main"] } },
87
+ ]);
88
+ const job = parseYAML(result.files[0].yaml)["tf-plan"] as Record<string, unknown>;
89
+ expect(job.rules).toEqual([
90
+ {
91
+ if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main"',
92
+ },
93
+ ]);
94
+ });
95
+
96
+ test("an unfiltered pull_request trigger fires on every merge request", () => {
97
+ const result = generateGitlabOpPipeline([{ name: "tf-plan", trigger: { kind: "pull_request" } }]);
98
+ const job = parseYAML(result.files[0].yaml)["tf-plan"] as Record<string, unknown>;
99
+ expect(job.rules).toEqual([{ if: '$CI_PIPELINE_SOURCE == "merge_request_event"' }]);
100
+ });
101
+
102
+ test("two target branches are two rules, which is how GitLab spells an OR", () => {
103
+ const result = generateGitlabOpPipeline([
104
+ { name: "tf-plan", trigger: { kind: "pull_request", branches: ["main", "release"] } },
105
+ ]);
106
+ const job = parseYAML(result.files[0].yaml)["tf-plan"] as { rules: Array<{ if: string }> };
107
+ expect(job.rules.map((r) => r.if)).toEqual([
108
+ '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main"',
109
+ '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "release"',
110
+ ]);
111
+ });
112
+
113
+ test("a push trigger becomes a push rule on the named branch", () => {
114
+ const result = generateGitlabOpPipeline([
115
+ { name: "tf-apply", trigger: { kind: "push", branches: ["release"] } },
116
+ ]);
117
+ const job = parseYAML(result.files[0].yaml)["tf-apply"] as Record<string, unknown>;
118
+ expect(job.rules).toEqual([
119
+ { if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "release"' },
120
+ ]);
121
+ });
122
+
123
+ test("an unfiltered push trigger defaults to main, the same branch github's generator assumes", () => {
124
+ const result = generateGitlabOpPipeline([{ name: "tf-apply", trigger: { kind: "push" } }]);
125
+ const job = parseYAML(result.files[0].yaml)["tf-apply"] as { rules: Array<{ if: string }> };
126
+ expect(job.rules[0].if).toBe('$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "main"');
127
+ });
128
+
129
+ test("every job carries a resource_group, GitLab's own per-Op concurrency", () => {
130
+ // github's `concurrency: { group, cancel-in-progress: false }` queues a
131
+ // second run rather than cancelling the first, which is exactly what a
132
+ // resource_group does — and on an apply job it is also the thing that
133
+ // stops two runs racing for the same state lock.
134
+ const result = generateGitlabOpPipeline([
135
+ { name: "tf-plan", trigger: { kind: "pull_request" } },
136
+ { name: "tf-apply", trigger: { kind: "push" } },
137
+ { name: "nightly", schedule: "0 6 * * *" },
138
+ ]);
139
+ const parsed = parseYAML(result.files[0].yaml);
140
+ for (const name of ["tf-plan", "tf-apply", "nightly"]) {
141
+ expect((parsed[name] as Record<string, unknown>).resource_group).toBe(name);
142
+ }
77
143
  });
78
144
 
79
- test("a push trigger throws a clear error instead of silently ignoring it", () => {
80
- const specs: ScheduledOpSpec[] = [{ name: "tf-apply", trigger: { kind: "push" } }];
81
- expect(() => generateGitlabOpPipeline(specs)).toThrow(/push.*GitLab has no pull_request\/push event model/s);
145
+ test("mixed triggers still land in one file, because a GitLab trigger is job-scoped", () => {
146
+ const result = generateGitlabOpPipeline([
147
+ { name: "tf-plan", trigger: { kind: "pull_request", branches: ["main"] } },
148
+ { name: "tf-apply", trigger: { kind: "push", branches: ["main"] } },
149
+ { name: "nightly", schedule: "0 6 * * *" },
150
+ ]);
151
+ expect(result.files).toHaveLength(1);
152
+ expect(result.jobs.map((j) => j.trigger.kind)).toEqual(["pull_request", "push", "cron"]);
153
+ });
154
+
155
+ test("the header sets up a Pipeline Schedule only for the Ops that need one", () => {
156
+ const yaml = generateGitlabOpPipeline([
157
+ { name: "tf-plan", trigger: { kind: "pull_request", branches: ["main"] } },
158
+ { name: "tf-apply", trigger: { kind: "push", branches: ["main"] } },
159
+ ]).files[0].yaml;
160
+ // No cron Op here, so nothing has to be created in Settings > CI/CD.
161
+ expect(yaml).not.toContain("Pipeline Schedule");
162
+ expect(yaml).toContain("merge_request_event onto main");
163
+ expect(yaml).toContain("push to main");
164
+ });
165
+
166
+ test("a branch name that would break out of the rule expression is refused by name", () => {
167
+ // A `rules:` if-expression is a string GitLab parses; a quote in a branch
168
+ // name would end it early and silently change which pipelines match.
169
+ expect(() =>
170
+ generateGitlabOpPipeline([
171
+ { name: "tf-plan", trigger: { kind: "pull_request", branches: ['main" || $CI_PIPELINE_SOURCE == "push'] } },
172
+ ]),
173
+ ).toThrow(/branch/i);
82
174
  });
83
175
  });
84
176
 
85
- describe("generateGitlabOpPipeline: no comment finding mode (#2231)", () => {
86
- test("findingMode comment is refused by name, even on a cron trigger GitLab does support", () => {
87
- const specs: ScheduledOpSpec[] = [
88
- { name: "app-plan", schedule: "0 6 * * *", findingMode: "comment" },
89
- ];
90
- expect(() => generateGitlabOpPipeline(specs)).toThrow(
91
- /Scheduled Op "app-plan".*findingMode "comment".*GitLab has no pull_request event/s,
92
- );
177
+ /**
178
+ * #2231's finding mode, now that there is a GitLab merge-request note
179
+ * activity to spend it (#2256). The blanket refusal this replaces was about
180
+ * chant having no way to post the note; what remains is the same constraint
181
+ * github already has — the mode posts onto the merge request that triggered
182
+ * the run, so it needs a trigger that has one.
183
+ */
184
+ describe("generateGitlabOpPipeline: the comment finding mode on a merge request (#2231, #2256)", () => {
185
+ test("findingMode comment is accepted on a pull_request trigger", () => {
186
+ const result = generateGitlabOpPipeline([
187
+ { name: "app-plan", trigger: { kind: "pull_request", branches: ["main"] }, findingMode: "comment" },
188
+ ]);
189
+ expect(result.jobs[0].findingMode).toBe("comment");
190
+ const job = parseYAML(result.files[0].yaml)["app-plan"] as { rules: Array<{ if: string }> };
191
+ expect(job.rules[0].if).toContain("merge_request_event");
192
+ });
193
+
194
+ test("the header names the token the note is written with", () => {
195
+ const yaml = generateGitlabOpPipeline([
196
+ { name: "app-plan", trigger: { kind: "pull_request" }, findingMode: "comment" },
197
+ ]).files[0].yaml;
198
+ expect(yaml).toContain("GITLAB_TOKEN");
199
+ });
200
+
201
+ test("findingMode comment on a cron trigger is refused, because a schedule has no merge request", () => {
202
+ expect(() =>
203
+ generateGitlabOpPipeline([{ name: "app-plan", schedule: "0 6 * * *", findingMode: "comment" }]),
204
+ ).toThrow(/findingMode "comment".*trigger is "cron"/s);
93
205
  });
94
206
 
95
- test("the refusal names the modes GitLab does have", () => {
96
- const specs: ScheduledOpSpec[] = [{ name: "app-plan", schedule: "0 6 * * *", findingMode: "comment" }];
97
- expect(() => generateGitlabOpPipeline(specs)).toThrow(
98
- /findingMode "issue" or "merge-request"/,
207
+ test("findingMode comment on a push trigger is refused, naming the trigger", () => {
208
+ expect(() =>
209
+ generateGitlabOpPipeline([
210
+ { name: "app-plan", trigger: { kind: "push" }, findingMode: "comment" },
211
+ ]),
212
+ ).toThrow(/findingMode "comment".*trigger is "push"/s);
213
+ });
214
+ });
215
+
216
+ /**
217
+ * #2243's green-gated apply, in the two surfaces GitLab has: the job's own
218
+ * log, and an artifact. There is no step summary to write to and no
219
+ * cross-job output to publish, so there is also no follow-up job.
220
+ */
221
+ describe("generateGitlabOpPipeline: a gated apply is a green run (#2243, #2256)", () => {
222
+ function applyJob(): Record<string, unknown> {
223
+ const result = generateGitlabOpPipeline([
224
+ { name: "app-apply", trigger: { kind: "push", branches: ["main"] } },
225
+ ]);
226
+ return parseYAML(result.files[0].yaml)["app-apply"] as Record<string, unknown>;
227
+ }
228
+
229
+ test("the push job maps the gated exit code and nothing else", () => {
230
+ expect((applyJob().script as string[]).at(-1)).toBe("chant run app-apply --gated-exit 0");
231
+ });
232
+
233
+ test("the pending gate is written to a path the job also publishes as an artifact", () => {
234
+ const job = applyJob();
235
+ expect((job.variables as Record<string, string>).CHANT_GATE_SUMMARY).toBe(
236
+ "chant-gate-app-apply.md",
99
237
  );
238
+ expect(job.artifacts).toEqual({
239
+ when: "always",
240
+ paths: ["chant-gate-app-apply.md"],
241
+ expire_in: "30 days",
242
+ });
243
+ });
244
+
245
+ test("no other trigger is gated: a plan or a watch that stops is a signal, not noise", () => {
246
+ const result = generateGitlabOpPipeline([
247
+ { name: "app-plan", trigger: { kind: "pull_request" } },
248
+ { name: "nightly", schedule: "0 6 * * *" },
249
+ ]);
250
+ const parsed = parseYAML(result.files[0].yaml);
251
+ for (const name of ["app-plan", "nightly"]) {
252
+ const job = parsed[name] as Record<string, unknown>;
253
+ expect((job.script as string[]).at(-1)).toBe(`chant run ${name}`);
254
+ expect(job.artifacts).toBeUndefined();
255
+ expect(job.variables).toBeUndefined();
256
+ }
100
257
  });
101
258
  });
102
259
 
@@ -161,11 +318,141 @@ describe("generateGitlabOpPipeline: setup steps and additive permissions (#2242)
161
318
  ]);
162
319
  });
163
320
 
164
- test("refuses additive permissions by name, and points at GitLab's own OIDC surface", () => {
321
+ /**
322
+ * `id-token: write` is the one additive scope that has a GitLab meaning
323
+ * (#2256): the refusal it replaces already named `id_tokens:` as the shape
324
+ * to reach for, and this reaches for it rather than describing it. Every
325
+ * other scope is still refused, because GitLab has no per-job token-scope
326
+ * mapping to put it in.
327
+ */
328
+ test("id-token: write becomes an id_tokens declaration, GitLab's own OIDC surface", () => {
329
+ const result = generateGitlabOpPipeline([
330
+ {
331
+ name: "app-apply",
332
+ trigger: { kind: "push", branches: ["main"] },
333
+ permissions: { "id-token": "write" },
334
+ },
335
+ ]);
336
+ const job = parseYAML(result.files[0].yaml)["app-apply"] as Record<string, unknown>;
337
+ expect(job.id_tokens).toEqual({ CHANT_ID_TOKEN: { aud: "$CI_SERVER_URL" } });
338
+ });
339
+
340
+ test("an Op that adds no permissions declares no id_tokens", () => {
341
+ const result = generateGitlabOpPipeline([{ name: "actions-audit", schedule: "0 6 * * *" }]);
342
+ const job = parseYAML(result.files[0].yaml)["actions-audit"] as Record<string, unknown>;
343
+ expect(job.id_tokens).toBeUndefined();
344
+ });
345
+
346
+ test("id-token: read is refused: GitLab either mints the token or does not", () => {
347
+ expect(() =>
348
+ generateGitlabOpPipeline([
349
+ { name: "actions-audit", schedule: "0 6 * * *", permissions: { "id-token": "read" } },
350
+ ]),
351
+ ).toThrow(/id-token: read/);
352
+ });
353
+
354
+ test("any other additive scope is still refused by name", () => {
165
355
  expect(() =>
166
356
  generateGitlabOpPipeline([
167
- { name: "actions-audit", schedule: "0 6 * * *", permissions: { "id-token": "write" } },
357
+ { name: "actions-audit", schedule: "0 6 * * *", permissions: { "pull-requests": "write" } },
168
358
  ]),
169
- ).toThrow(/adds permissions \{ id-token: write \}.*id_tokens:/s);
359
+ ).toThrow(/adds permission "pull-requests: write".*no per-job token-scope/s);
360
+ });
361
+ });
362
+
363
+ /**
364
+ * chant #2257 — the one option of the three that GitLab actually has. An
365
+ * environment is a project object here too, with the same two fields on the
366
+ * job and a protected-environment approval rule behind it, so the reviewer
367
+ * gate the option exists for is expressible and is emitted rather than
368
+ * refused. What is not expressible is the protection itself, which is a
369
+ * project setting, so the header names it the way it already names the
370
+ * schedule to create.
371
+ */
372
+ describe("generateGitlabOpPipeline: a deployment environment (#2257)", () => {
373
+ const AUDIT: ScheduledOpSpec = { name: "actions-audit", schedule: "0 6 * * *", findingMode: "issue" };
374
+
375
+ /**
376
+ * The document a cron spec with no `environment` emits. #2257 pinned this to
377
+ * prove its option is additive; #2256 moved two lines of it and it is
378
+ * re-pinned rather than loosened, so it still proves the same thing.
379
+ *
380
+ * What moved: the header's opening paragraph, because a merge_request_event
381
+ * or push job needs no Pipeline Schedule and the cron instructions are now
382
+ * printed only for the Ops that do; and `resource_group`, which every job
383
+ * gains as GitLab's stand-in for github's per-Op concurrency group. The
384
+ * cron job's own rule, script and stage are byte-for-byte what they were.
385
+ */
386
+ const YAML_BEFORE_2257 =
387
+ [
388
+ "# chant Ops (#927, #2084) — one job per Op, each selected by its own",
389
+ "# rules:. A merge_request_event or push job needs no setup; its rule",
390
+ "# fires on the event itself.",
391
+ "#",
392
+ "# GitLab has no in-file cron. Create one Pipeline Schedule per cron Op",
393
+ "# below (Settings > CI/CD > Schedules): set its cron to the value noted",
394
+ "# here and its CHANT_SCHEDULED_OP CI/CD variable to the Op's name, so only",
395
+ "# that job runs on that schedule.",
396
+ "#",
397
+ '# actions-audit: cron "0 6 * * *", CHANT_SCHEDULED_OP="actions-audit", finding-mode issue' +
398
+ " — needs a GITLAB_TOKEN CI/CD variable (masked, scope: api)",
399
+ "",
400
+ "stages:",
401
+ " - scheduled-ops",
402
+ "",
403
+ "actions-audit:",
404
+ " stage: scheduled-ops",
405
+ " image: node:22-slim",
406
+ " resource_group: actions-audit",
407
+ " rules:",
408
+ ` - if: '$CI_PIPELINE_SOURCE == "schedule" && $CHANT_SCHEDULED_OP == "actions-audit"'`,
409
+ " script:",
410
+ " - chant run actions-audit",
411
+ ].join("\n") + "\n";
412
+
413
+ test("a spec with no environment emits the bytes it emitted before the option existed", () => {
414
+ expect(generateGitlabOpPipeline([AUDIT]).files[0].yaml).toBe(YAML_BEFORE_2257);
415
+ });
416
+
417
+ test("maps it onto GitLab's own environment: key, with the url when there is one", () => {
418
+ const spec: ScheduledOpSpec = {
419
+ ...AUDIT,
420
+ environment: { name: "production", url: "https://app.example.com" },
421
+ };
422
+ const job = parseYAML(generateGitlabOpPipeline([spec]).files[0].yaml)["actions-audit"] as {
423
+ environment?: Record<string, string>;
424
+ };
425
+ expect(job.environment).toEqual({ name: "production", url: "https://app.example.com" });
426
+ });
427
+
428
+ test("names the environment to protect in the header, beside the schedule to create", () => {
429
+ const spec: ScheduledOpSpec = { ...AUDIT, environment: { name: "production" } };
430
+ const yaml = generateGitlabOpPipeline([spec]).files[0].yaml;
431
+ expect(yaml).toContain('deploys to environment "production"');
432
+ expect(yaml).toContain("Settings > CI/CD > Protected environments");
433
+ // The approval rule is a project setting; this file can only bind the job
434
+ // to the environment, so the header says where the rule is set.
435
+ expect(yaml).toContain("require an approval before the job runs");
436
+ });
437
+
438
+ test("adding the environment changes only the header line and the job's own key", () => {
439
+ const spec: ScheduledOpSpec = { ...AUDIT, environment: { name: "production" } };
440
+ const after = generateGitlabOpPipeline([spec]).files[0].yaml;
441
+ const withoutHeaderLine = after
442
+ .split("\n")
443
+ .filter((line) => !line.startsWith("# deploys to environment"))
444
+ .join("\n");
445
+ expect(withoutHeaderLine.replace(" environment:\n name: production\n", "")).toBe(
446
+ YAML_BEFORE_2257,
447
+ );
448
+ });
449
+
450
+ test("refuses a blank name and a url that is neither absolute nor a variable expression", () => {
451
+ expect(() => generateGitlabOpPipeline([{ ...AUDIT, environment: { name: " " } }])).toThrow(
452
+ /environment has an empty `name`/,
453
+ );
454
+ expect(() =>
455
+ generateGitlabOpPipeline([{ ...AUDIT, environment: { name: "production", url: "/deploys" } }]),
456
+ ).toThrow(/neither an absolute http\(s\) URL nor a variable expression/);
170
457
  });
171
458
  });
@@ -1,86 +1,275 @@
1
1
  /**
2
- * Generate mode — scheduled Op → GitLab CI YAML (#927).
2
+ * Generate mode — Op → GitLab CI YAML (#927, #2084, #2256).
3
3
  *
4
4
  * The Op counterpart to `./generate-pipeline.ts` (#563): that module
5
5
  * synthesizes a deploy-time component graph as one `.gitlab-ci.yml`; this one
6
- * synthesizes a cron-triggered job per stateless Op. An Op's cadence is an
7
- * `OpSchedule` on the Op itself (`packages/core/src/op/types.ts`),
8
- * runtime-neutral data each reader interprets; this module is the reader that
9
- * turns it into a GitLab pipeline schedule (`WorkflowAuditOp`/
10
- * `PipelineAuditOp`/`ReconcileOp` all accept an optional `schedule` precisely
11
- * for this).
6
+ * synthesizes one job per Op, selected by its own `rules:`.
7
+ *
8
+ * What GitLab genuinely lacks is in-file cron. A schedule is a project-level
9
+ * object (Settings → CI/CD → Schedules) that runs the project's *existing*
10
+ * `.gitlab-ci.yml` with a chosen cron and CI/CD variables, so a cron-triggered
11
+ * Op becomes a job gated on `$CI_PIPELINE_SOURCE == "schedule"` plus a per-Op
12
+ * selector variable, and the generated file's header says what to set up. What
13
+ * GitLab does not lack, and what this generator wrongly refused until #2256,
14
+ * is the other two triggers (#2084): `$CI_PIPELINE_SOURCE` distinguishes
15
+ * `merge_request_event` from `push` on every pipeline, and `rules:` selects a
16
+ * job on either. So:
17
+ *
18
+ * - `cron` → `$CI_PIPELINE_SOURCE == "schedule" && $CHANT_SCHEDULED_OP ==
19
+ * "<name>"`, unchanged;
20
+ * - `pull_request` → `$CI_PIPELINE_SOURCE == "merge_request_event"`, with the
21
+ * branch filter mapped to `$CI_MERGE_REQUEST_TARGET_BRANCH_NAME`, which is
22
+ * the branch the merge request would merge INTO — the same thing github's
23
+ * `on.pull_request.branches` filters on;
24
+ * - `push` → `$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH ==
25
+ * "<branch>"`, defaulting to the same `main` github's generator assumes.
26
+ *
27
+ * Two or more branches are two or more rule entries, because `rules:` is an
28
+ * OR over its entries and a regex alternation would need every branch name
29
+ * escaped into a pattern.
30
+ *
31
+ * Every job carries a `resource_group`, GitLab's stand-in for github's per-Op
32
+ * `concurrency` group: one run at a time per Op, the next one queued rather
33
+ * than cancelled. On an apply job that is also what stops two runs racing for
34
+ * the same state lock.
35
+ *
36
+ * Unlike github, all of this lands in ONE file. A GitHub trigger is
37
+ * workflow-scoped, so an Op there needs its own workflow; a GitLab trigger is
38
+ * job-scoped, so the whole set is one document with one job apiece.
12
39
  *
13
- * Unlike GitHub Actions' per-workflow `on.schedule`, GitLab has no in-file
14
- * cron at all — a schedule is a project-level object (Settings → CI/CD →
15
- * Schedules) that runs the project's *existing* `.gitlab-ci.yml` with a
16
- * chosen cron and CI/CD variables. So every scheduled Op here becomes one
17
- * job in a single generated file, gated to run only under its own Pipeline
18
- * Schedule (`$CI_PIPELINE_SOURCE == "schedule"` plus a per-op selector
19
- * variable) — the cron itself is configured on the Pipeline Schedule, not in
20
- * this YAML, and the generated file's header comment states what to set up.
21
40
  * Each job runs exactly one invocation, `chant run <name>` by default — never
22
- * inlined audit/reconcile logic. The finding-mode itself is already baked
23
- * into the Op's own activity args at build time by the composite that
24
- * created it; GitLab has no per-job `permissions:` concept (unlike GitHub
25
- * Actions), and `findingMode: "comment"` is refused by name here because it
26
- * posts onto a GitHub pull-request event GitLab does not have (#2231), so a
27
- * non-`report` mode's write access comes from whatever
28
- * `GITLAB_TOKEN`/CI-CD-variable configuration the project already has —
29
- * this generator documents the requirement rather than fabricating a
30
- * variable nothing reads.
41
+ * inlined audit/reconcile logic. The finding-mode is already baked into the
42
+ * Op's own activity args at build time by the composite that created it; this
43
+ * generator only wires what the mode needs to act.
44
+ *
45
+ * `findingMode: "comment"` works here since #2256: `reconcilePr` writes a
46
+ * merge-request note when the run is a `merge_request_event` pipeline, by the
47
+ * same hidden-marker edit-in-place recipe it uses on a GitHub pull request.
48
+ * What survives of the old blanket refusal is the constraint github already
49
+ * has — the mode posts onto the merge request that triggered the run, so a
50
+ * cron or push job carrying it is refused by name at build time rather than
51
+ * emitted to fail at its Report step ({@link assertTriggerSupportsMode}).
52
+ * GitLab has no per-job `permissions:` concept, so the note's write access
53
+ * comes from a `GITLAB_TOKEN` CI/CD variable, which the header names.
54
+ *
55
+ * #2242's two per-Op options cross over one and a half times. A `setup` entry
56
+ * spelled `{ uses }` is a GitHub Actions marketplace action; GitLab CI has
57
+ * `script` and nothing else, so there is no shape to translate it into and it
58
+ * is refused by name. A `{ run }` entry translates exactly, and is emitted
59
+ * ahead of the `beforeScript` lines — in `script:` rather than
60
+ * `before_script:`, which is where `./generate-pipeline.ts` puts the same
61
+ * option's lines in this same lexicon, and which concatenates identically at
62
+ * run time. An additive `permissions` map has exactly one entry with a GitLab
63
+ * meaning: `id-token: write` becomes an `id_tokens:` declaration ({@link
64
+ * idTokensFor}), the OIDC surface the old refusal already named as the shape
65
+ * to reach for. Every other scope is still refused, because there is no
66
+ * per-job token-scope mapping to put it in and a silently dropped scope emits
67
+ * a job that reads as granted and runs with nothing.
31
68
  *
32
- * Neither of #2242's two per-Op options survives the crossing, and both are
33
- * refused by name rather than dropped. A `setup` entry spelled `{ uses }` is
34
- * a GitHub Actions marketplace action; GitLab CI has `script` and nothing
35
- * else, so there is no shape to translate it into and no way to approximate
36
- * `aws-actions/configure-aws-credentials` in a shell line. A `{ run }` entry
37
- * translates exactly, and is emitted ahead of the `beforeScript` lines, the
38
- * same position the github generator gives it. An additive `permissions` map
39
- * is refused for the same reason `permissions:` is absent here at all: GitLab
40
- * has no per-job token-scope mapping, and its OIDC surface is a different
41
- * declaration (`id_tokens:` with an `aud`, exchanged for cloud credentials by
42
- * the job itself) that chant does not generate. Ignoring the map would emit a
43
- * job that reads as having OIDC and runs with no credentials.
69
+ * A spec's `environment` (#2257) does cross, because GitLab has the concept
70
+ * under the same key and with the same two fields: `environment: { name, url
71
+ * }` on the job, an environment object in the project, and — on a protected
72
+ * environment — an approval rule that holds the deployment job until an
73
+ * approver releases it. So the reviewer gate the option exists for is
74
+ * expressible here, unlike the `uses` setup step above, and it is emitted
75
+ * rather than refused. What GitLab does not have is any way for this file to
76
+ * declare the protection: an environment's approval rules are project
77
+ * settings (Settings > CI/CD > Protected environments), exactly as GitHub's
78
+ * required reviewers are repository settings, so the generated header names
79
+ * the environment to protect the way it already names the schedule to create.
80
+ * chant's own gate (#2119) runs inside the job either way.
44
81
  *
45
- * The gated-apply mapping (#2243) has nothing to attach to here. It exists
46
- * because a push-to-main apply that stops at its gate exits 3 and paints the
47
- * branch red on every merge; this generator has no push pipeline at all,
48
- * refusing a `push` trigger by name (#2084) because a GitLab schedule is a
49
- * project-level cron object rather than an event. GitLab does have its own
50
- * equivalent of the mapping should one ever be wanted — `allow_failure:
51
- * { exit_codes: [3] }` turns one exit code into a warning rather than a
52
- * failure, without a flag on the invocation — so the day this generator
53
- * grows a push trigger, that is the shape to reach for rather than
54
- * `--gated-exit`.
82
+ * The gated apply (#2243) lands in the two surfaces GitLab has. `chant run`
83
+ * returns 3 when a run stops at an unapproved gate, so a push-to-default apply
84
+ * would paint the branch red on every merge until someone approves; the push
85
+ * job runs with `--gated-exit 0`, mapping that one outcome and nothing else.
86
+ * Where GitHub Actions gets the pending block on its run page through
87
+ * `GITHUB_STEP_SUMMARY`, GitLab has no step summary at all, so the job sets
88
+ * `CHANT_GATE_SUMMARY` to a path it also declares under `artifacts:` and the
89
+ * block is downloadable from the pipeline. The human render is already in the
90
+ * job log, since the invocation carries no `--json`. There is no follow-up
91
+ * notice job: it would need a forge API call and a token this generator does
92
+ * not require of a `report`-mode Op.
55
93
  */
56
94
 
57
95
  import { emitYAML } from "@intentius/chant/yaml";
58
96
  import { resolveOpTrigger } from "@intentius/chant/lexicon";
59
97
  import type {
60
98
  ComponentPipelineOptions as GenerateGitlabOpOptions,
99
+ OpEnvironment,
61
100
  OpFindingMode,
62
101
  OpPipelineJob,
63
102
  OpPipelineResult as GenerateGitlabOpResult,
103
+ OpTrigger,
64
104
  ScheduledOpSpec,
65
105
  } from "@intentius/chant/lexicon";
66
106
 
107
+ export type { GenerateGitlabOpOptions, GenerateGitlabOpResult };
108
+
109
+ /** GitLab CI job names must be safe YAML keys; Op names are already kebab-case in every fixture, but normalize defensively (mirrors `./generate-pipeline.ts`'s `toJobName`). */
110
+ function toJobName(opName: string): string {
111
+ return opName.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
112
+ }
113
+
114
+ const DEFAULT_IMAGE = "node:22-slim";
115
+ const STAGE = "scheduled-ops";
116
+
117
+ /** The CI/CD variable a Pipeline Schedule sets to select which job it runs. */
118
+ const SELECTOR_VAR = "CHANT_SCHEDULED_OP";
119
+
67
120
  /**
68
- * Refuse the two #2242 options GitLab cannot honour, by name and before any
69
- * YAML exists, and return the `run` setup lines that do translate. See the
70
- * module doc for why each one is a refusal rather than a silent drop.
121
+ * Default branch assumed for a `push` trigger with no `branches` override.
122
+ * The same value github's generator documents, for the same reason: this
123
+ * operates on a `ScheduledOpSpec` rather than a git checkout, so it cannot
124
+ * read a downstream project's actual default branch. Set `trigger.branches`
125
+ * explicitly on a project whose default branch is something else.
71
126
  */
72
- function gitlabSetupScript(spec: ScheduledOpSpec): string[] {
73
- if (spec.permissions && Object.keys(spec.permissions).length > 0) {
74
- const scopes = Object.entries(spec.permissions)
75
- .map(([scope, value]) => `${scope}: ${value}`)
76
- .join(", ");
127
+ const DEFAULT_PUSH_BRANCH = "main";
128
+
129
+ /** The pipeline source GitLab reports for a merge-request pipeline. */
130
+ const MERGE_REQUEST_SOURCE = '$CI_PIPELINE_SOURCE == "merge_request_event"';
131
+
132
+ /**
133
+ * The `id_tokens:` entry an `id-token: write` spec gets. The name is the
134
+ * environment variable the JWT lands in, which the job's own setup line reads
135
+ * (`aws sts assume-role-with-web-identity --web-identity-token
136
+ * "$CHANT_ID_TOKEN"`, or the provider's equivalent).
137
+ *
138
+ * `$CI_SERVER_URL` as the audience is GitLab's own documented default: an
139
+ * identity provider federated to a GitLab instance is registered with that
140
+ * instance's URL as its audience, and the variable expands to exactly that on
141
+ * gitlab.com and on a self-managed instance alike. A project whose provider
142
+ * was registered with some other audience edits the generated declaration.
143
+ */
144
+ const ID_TOKEN_NAME = "CHANT_ID_TOKEN";
145
+ const ID_TOKEN_AUDIENCE = "$CI_SERVER_URL";
146
+
147
+ /**
148
+ * `chant run` returns 3 when a run stops at an unapproved gate; `--gated-exit
149
+ * 0` maps that one outcome to success (#2243). `push` only — a cron watch or
150
+ * a merge-request plan that stops at a gate is a signal, not noise on a merge.
151
+ */
152
+ const GATED_EXIT_FLAG = ["--gated-exit", "0"];
153
+
154
+ /** The variable core's `writeGatedRunSummary` writes the pending-gate block to when the forge sets no step summary (#2256). */
155
+ const GATE_SUMMARY_VAR = "CHANT_GATE_SUMMARY";
156
+
157
+ /** How long a pending-gate artifact is worth keeping: long enough to outlive the approval it is waiting for. */
158
+ const GATE_ARTIFACT_EXPIRY = "30 days";
159
+
160
+ /**
161
+ * Refuse a branch name that cannot be interpolated into a `rules:`
162
+ * if-expression. The expression is a string GitLab parses, so a `"` would end
163
+ * it early and a `$` would expand as a variable — either one silently changes
164
+ * which pipelines match the job, which is worse than not generating it.
165
+ */
166
+ function assertBranchName(specName: string, branch: string): void {
167
+ if (branch.trim() === "") {
168
+ throw new Error(
169
+ `Scheduled Op "${specName}" has an empty branch filter. Name a branch, or drop the filter.`,
170
+ );
171
+ }
172
+ if (/["$\\]/.test(branch)) {
173
+ throw new Error(
174
+ `Scheduled Op "${specName}" filters on branch "${branch}", which carries a character this generator ` +
175
+ `cannot put in a GitLab \`rules:\` expression: a quote would end the expression early and a "$" ` +
176
+ `would expand as a CI/CD variable, either of which silently changes which pipelines run the job. ` +
177
+ `Name a branch without \`"\`, \`$\` or \`\\\`.`,
178
+ );
179
+ }
180
+ }
181
+
182
+ /**
183
+ * This trigger's `rules:` entries. Two or more branches are two or more
184
+ * entries: `rules:` is an OR over its list, which is how GitLab spells the
185
+ * alternation github expresses as a `branches:` array.
186
+ */
187
+ function rulesFor(spec: ScheduledOpSpec, trigger: OpTrigger): Array<{ if: string }> {
188
+ switch (trigger.kind) {
189
+ case "cron":
190
+ return [{ if: `$CI_PIPELINE_SOURCE == "schedule" && $${SELECTOR_VAR} == "${spec.name}"` }];
191
+ case "pull_request": {
192
+ const branches = trigger.branches ?? [];
193
+ if (branches.length === 0) return [{ if: MERGE_REQUEST_SOURCE }];
194
+ return branches.map((branch) => {
195
+ assertBranchName(spec.name, branch);
196
+ return { if: `${MERGE_REQUEST_SOURCE} && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "${branch}"` };
197
+ });
198
+ }
199
+ case "push": {
200
+ const branches = trigger.branches?.length ? trigger.branches : [DEFAULT_PUSH_BRANCH];
201
+ return branches.map((branch) => {
202
+ assertBranchName(spec.name, branch);
203
+ return { if: `$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "${branch}"` };
204
+ });
205
+ }
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Refuse `findingMode: "comment"` on a trigger that has no merge request
211
+ * (#2231, #2256). The mode's activity reads the merge request out of the
212
+ * pipeline's own variables at run time, so a cron- or push-triggered job
213
+ * carrying it would generate fine and then fail on every run. Refusing here
214
+ * names the Op, the mode and the trigger at build time instead — the same
215
+ * refusal github's generator makes, on the same terms.
216
+ */
217
+ function assertTriggerSupportsMode(name: string, mode: OpFindingMode, trigger: OpTrigger): void {
218
+ if (mode !== "comment" || trigger.kind === "pull_request") return;
219
+ throw new Error(
220
+ `Scheduled Op "${name}" has findingMode "comment", which posts its finding as a note on the merge ` +
221
+ `request that triggered the run, but its trigger is "${trigger.kind}". A ${trigger.kind} pipeline has ` +
222
+ `no merge request to post on. Give it a { kind: "pull_request" } trigger, or use findingMode "issue" ` +
223
+ `or "merge-request".`,
224
+ );
225
+ }
226
+
227
+ /**
228
+ * Turn a spec's additive `permissions` (#2242) into the one GitLab
229
+ * declaration that carries the same meaning, refusing everything else by name.
230
+ *
231
+ * `id-token: write` is that one: GitLab's OIDC surface is `id_tokens:`, a
232
+ * per-job declaration of a JWT the job exchanges for cloud credentials
233
+ * itself. `id-token: read` is refused rather than mapped, because GitLab
234
+ * either mints the token into the job or does not — there is no read-only
235
+ * half of it, and emitting the declaration for a spec that asked for read
236
+ * would hand the job more than it asked for.
237
+ *
238
+ * Every other scope is refused, unchanged from the pre-#2256 behaviour:
239
+ * GitLab has no per-job token-scope mapping at all, and a scope quietly
240
+ * dropped would emit a job that reads as granted and runs with nothing.
241
+ */
242
+ function idTokensFor(spec: ScheduledOpSpec): Record<string, unknown> | undefined {
243
+ let wantsIdToken = false;
244
+ for (const [rawScope, value] of Object.entries(spec.permissions ?? {})) {
245
+ const scope = rawScope.trim();
246
+ if (scope === "id-token") {
247
+ if (value !== "write") {
248
+ throw new Error(
249
+ `Scheduled Op "${spec.name}" adds permission "id-token: ${value}", but GitLab either mints an ` +
250
+ `OIDC token into a job or does not — there is no read-only half of an \`id_tokens:\` ` +
251
+ `declaration. Ask for { "id-token": "write" }, or drop the option.`,
252
+ );
253
+ }
254
+ wantsIdToken = true;
255
+ continue;
256
+ }
77
257
  throw new Error(
78
- `Scheduled Op "${spec.name}" adds permissions { ${scopes} }, but GitLab CI has no per-job token-scope ` +
79
- `mapping — there is no \`permissions:\` key to add them to (#2242). Its OIDC surface is a separate ` +
80
- `\`id_tokens:\` declaration the job exchanges for cloud credentials itself, which chant does not ` +
81
- `generate. Drop the option here, or generate this Op for github.`,
258
+ `Scheduled Op "${spec.name}" adds permission "${scope}: ${value}", but GitLab CI has no per-job ` +
259
+ `token-scope mapping — there is no \`permissions:\` key to add it to (#2242). The one scope that ` +
260
+ `does cross over is "id-token": "write", which becomes an \`id_tokens:\` declaration. Drop the ` +
261
+ `option here, or generate this Op for github.`,
82
262
  );
83
263
  }
264
+ return wantsIdToken ? { [ID_TOKEN_NAME]: { aud: ID_TOKEN_AUDIENCE } } : undefined;
265
+ }
266
+
267
+ /**
268
+ * Refuse a `uses` setup step by name and return the `run` lines that do
269
+ * translate. See the module doc for why a marketplace action is a refusal
270
+ * rather than a silent drop.
271
+ */
272
+ function gitlabSetupScript(spec: ScheduledOpSpec): string[] {
84
273
  const lines: string[] = [];
85
274
  (spec.setup ?? []).forEach((step, index) => {
86
275
  if ("uses" in step) {
@@ -95,30 +284,88 @@ function gitlabSetupScript(spec: ScheduledOpSpec): string[] {
95
284
  return lines;
96
285
  }
97
286
 
98
- export type { GenerateGitlabOpOptions, GenerateGitlabOpResult };
99
-
100
- /** GitLab CI job names must be safe YAML keys; Op names are already kebab-case in every fixture, but normalize defensively (mirrors `./generate-pipeline.ts`'s `toJobName`). */
101
- function toJobName(opName: string): string {
102
- return opName.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
287
+ /**
288
+ * Validate a spec's `environment` (#2257) on GitLab's own terms. A GitLab job
289
+ * naming an environment that does not exist creates an unprotected one on
290
+ * first deploy rather than failing, so — as on github — the only shapes worth
291
+ * refusing at build time are the ones that could never bind: a blank name,
292
+ * and a `url` that is neither absolute nor a variable expression GitLab
293
+ * expands, which would render as a dead "View deployment" link.
294
+ */
295
+ function assertGitlabEnvironment(name: string, environment: OpEnvironment): void {
296
+ const where = `Scheduled Op "${name}" environment`;
297
+ if (environment.name.trim() === "") {
298
+ throw new Error(
299
+ `${where} has an empty \`name\`. A GitLab environment is a project object, and its approval ` +
300
+ `rules live on that object rather than in this file, so a blank name resolves to nothing. ` +
301
+ `Give it the environment's name, or drop the option.`,
302
+ );
303
+ }
304
+ if (environment.url === undefined) return;
305
+ const url = environment.url.trim();
306
+ if (url === "") {
307
+ throw new Error(
308
+ `${where} "${environment.name}" has an empty \`url\`. Omit the field rather than setting it to "".`,
309
+ );
310
+ }
311
+ if (!/^https?:\/\//.test(url) && !url.includes("$")) {
312
+ throw new Error(
313
+ `${where} "${environment.name}" has \`url: "${environment.url}"\`, which is neither an absolute ` +
314
+ `http(s) URL nor a variable expression GitLab expands. It becomes the environment's own link, ` +
315
+ `so a relative path is a dead link on the environment page rather than an error anywhere.`,
316
+ );
317
+ }
103
318
  }
104
319
 
105
- const DEFAULT_IMAGE = "node:22-slim";
106
- const STAGE = "scheduled-ops";
320
+ /**
321
+ * The follow-up line an Op with an `environment` adds under its own header
322
+ * line (#2257). The `environment:` key on the job binds it to the
323
+ * environment; it cannot declare the approval rule, which is a project
324
+ * setting, so the header says where that is set the same way it says where a
325
+ * schedule is created.
326
+ */
327
+ function environmentLine(environment: OpEnvironment): string {
328
+ return (
329
+ `# deploys to environment "${environment.name}" — protect it under Settings > CI/CD >` +
330
+ " Protected environments to require an approval before the job runs"
331
+ );
332
+ }
107
333
 
108
- /** The CI/CD variable a Pipeline Schedule sets to select which job it runs. */
109
- const SELECTOR_VAR = "CHANT_SCHEDULED_OP";
334
+ /** The artifact path a push job's pending-gate block is written to (#2243, #2256). */
335
+ function gateSummaryPath(jobName: string): string {
336
+ return `chant-gate-${jobName}.md`;
337
+ }
110
338
 
111
- /** One setup line per Op in the generated file's header comment. */
112
- function setupLine(spec: ScheduledOpSpec, cron: string, jobName: string, mode: OpFindingMode): string {
113
- const tokenNote = mode === "report" ? "" : " — needs a GITLAB_TOKEN CI/CD variable (masked, scope: api)";
114
- return `# ${jobName}: cron "${cron}", ${SELECTOR_VAR}="${spec.name}", finding-mode ${mode}${tokenNote}`;
339
+ /** One line per Op in the generated file's header comment, naming what fires it. */
340
+ function headerLineFor(
341
+ spec: ScheduledOpSpec,
342
+ trigger: OpTrigger,
343
+ jobName: string,
344
+ mode: OpFindingMode,
345
+ ): string {
346
+ const tokenNote =
347
+ mode === "report" ? "" : " — needs a GITLAB_TOKEN CI/CD variable (masked, scope: api)";
348
+ switch (trigger.kind) {
349
+ case "cron":
350
+ return `# ${jobName}: cron "${trigger.schedule}", ${SELECTOR_VAR}="${spec.name}", finding-mode ${mode}${tokenNote}`;
351
+ case "pull_request": {
352
+ const onto = trigger.branches?.length ? trigger.branches.join(", ") : "any branch";
353
+ return `# ${jobName}: merge_request_event onto ${onto}, finding-mode ${mode}${tokenNote}`;
354
+ }
355
+ case "push": {
356
+ const branches = trigger.branches?.length ? trigger.branches : [DEFAULT_PUSH_BRANCH];
357
+ return (
358
+ `# ${jobName}: push to ${branches.join(", ")}, finding-mode ${mode}${tokenNote}` +
359
+ ` — a gated apply stays green, its pending gate in the log and in ${gateSummaryPath(jobName)}`
360
+ );
361
+ }
362
+ }
115
363
  }
116
364
 
117
365
  /**
118
- * Synthesize one `.gitlab-ci.yml` job per scheduled Op, all in a single file
119
- * (GitLab has no per-file cron — see the module doc). Wired into core's Op
120
- * generate mode via the gitlab lexicon plugin's `generateOpPipeline`
121
- * (../plugin.ts).
366
+ * Synthesize one `.gitlab-ci.yml` job per Op, all in a single file (a GitLab
367
+ * trigger is job-scoped — see the module doc). Wired into core's Op generate
368
+ * mode via the gitlab lexicon plugin's `generateOpPipeline` (../plugin.ts).
122
369
  */
123
370
  export function generateGitlabOpPipeline(
124
371
  ops: ScheduledOpSpec[],
@@ -133,53 +380,76 @@ export function generateGitlabOpPipeline(
133
380
  const doc: Record<string, unknown> = { stages: [STAGE] };
134
381
  if (options.variables && Object.keys(options.variables).length > 0) doc.variables = options.variables;
135
382
 
136
- const headerLines = [
137
- "# Scheduled Ops (chant #927) — GitLab has no in-file cron. Create one",
138
- "# Pipeline Schedule per Op below (Settings > CI/CD > Schedules): set its",
139
- `# cron to the value noted here and its ${SELECTOR_VAR} CI/CD variable to`,
140
- "# the Op's name, so only that job runs on that schedule.",
141
- "#",
142
- ];
383
+ const opLines: string[] = [];
384
+ let anyCron = false;
143
385
 
144
386
  for (const spec of ops) {
145
387
  const setupScript = gitlabSetupScript(spec);
388
+ const idTokens = idTokensFor(spec);
146
389
  const findingMode = spec.findingMode ?? "report";
147
- if (findingMode === "comment") {
148
- // The mode posts onto the pull request that triggered the run (#2231),
149
- // read out of the GitHub Actions event payload by `reconcilePr`. GitLab
150
- // has neither that event model nor that payload, and core carries no
151
- // GitLab API client that would post the merge-request note instead, so
152
- // this refuses the mode by name rather than emitting a job whose finding
153
- // step fails on every pipeline.
154
- throw new Error(
155
- `Scheduled Op "${spec.name}" has findingMode "comment", which posts its finding on the pull request ` +
156
- `that triggered the run. GitLab has no pull_request event and chant has no GitLab merge-request ` +
157
- `note activity (#2231). Use findingMode "issue" or "merge-request" here, or generate this Op for ` +
158
- `github.`,
159
- );
160
- }
161
390
  const trigger = resolveOpTrigger(spec);
162
- if (trigger.kind !== "cron") {
163
- throw new Error(
164
- `Scheduled Op "${spec.name}" has a "${trigger.kind}" trigger, but GitLab has no pull_request/push event model ` +
165
- `(#2084) — only a project-level Pipeline Schedule (cron). Give it a cron trigger, or generate it for github/forgejo instead.`,
166
- );
167
- }
391
+ assertTriggerSupportsMode(spec.name, findingMode, trigger);
392
+ if (trigger.kind === "cron") anyCron = true;
393
+
168
394
  const jobName = toJobName(spec.name);
169
395
  jobs.push({ jobName, op: spec.name, trigger, findingMode });
170
- headerLines.push(setupLine(spec, trigger.schedule, jobName, findingMode));
396
+ opLines.push(headerLineFor(spec, trigger, jobName, findingMode));
397
+ if (spec.environment) {
398
+ assertGitlabEnvironment(spec.name, spec.environment);
399
+ opLines.push(environmentLine(spec.environment));
400
+ }
171
401
 
402
+ // A push job is the one a gate would otherwise paint red on every merge
403
+ // (#2243). Every other trigger keeps the plain one-line invocation.
404
+ const gated = trigger.kind === "push";
172
405
  const runParts = runCommand.map((part) => part.replace("{name}", spec.name));
173
- const script = [...setupScript, ...beforeScript, runParts.join(" "), ...extraScript];
406
+ const invocation = gated ? [...runParts, ...GATED_EXIT_FLAG] : runParts;
407
+ const gateSummary = gateSummaryPath(jobName);
174
408
 
175
409
  doc[jobName] = {
176
410
  stage: STAGE,
177
411
  image,
178
- rules: [{ if: `$CI_PIPELINE_SOURCE == "schedule" && $${SELECTOR_VAR} == "${spec.name}"` }],
179
- script,
412
+ // GitLab's stand-in for github's per-Op concurrency group: queue the
413
+ // next run rather than cancel the current one.
414
+ resource_group: jobName,
415
+ ...(idTokens ? { id_tokens: idTokens } : {}),
416
+ ...(spec.environment
417
+ ? {
418
+ environment: {
419
+ name: spec.environment.name,
420
+ ...(spec.environment.url === undefined ? {} : { url: spec.environment.url }),
421
+ },
422
+ }
423
+ : {}),
424
+ ...(gated ? { variables: { [GATE_SUMMARY_VAR]: gateSummary } } : {}),
425
+ rules: rulesFor(spec, trigger),
426
+ script: [...setupScript, ...beforeScript, invocation.join(" "), ...extraScript],
427
+ // `when: always` because the run this publishes for is the green one: a
428
+ // gated apply succeeds, and the block is the only thing that says a
429
+ // human still has to act. A run that walked through its gate writes no
430
+ // block, and GitLab reports the empty upload as a warning, not a failure.
431
+ ...(gated
432
+ ? { artifacts: { when: "always", paths: [gateSummary], expire_in: GATE_ARTIFACT_EXPIRY } }
433
+ : {}),
180
434
  };
181
435
  }
182
436
 
437
+ const headerLines = [
438
+ "# chant Ops (#927, #2084) — one job per Op, each selected by its own",
439
+ "# rules:. A merge_request_event or push job needs no setup; its rule",
440
+ "# fires on the event itself.",
441
+ ];
442
+ if (anyCron) {
443
+ headerLines.push(
444
+ "#",
445
+ "# GitLab has no in-file cron. Create one Pipeline Schedule per cron Op",
446
+ "# below (Settings > CI/CD > Schedules): set its cron to the value noted",
447
+ `# here and its ${SELECTOR_VAR} CI/CD variable to the Op's name, so only`,
448
+ "# that job runs on that schedule.",
449
+ );
450
+ }
451
+ headerLines.push("#", ...opLines);
452
+
183
453
  const sections: string[] = [];
184
454
  sections.push("stages:" + emitYAML(doc.stages, 1));
185
455
  if (doc.variables) sections.push("variables:" + emitYAML(doc.variables, 1));
package/src/plugin.ts CHANGED
@@ -30,7 +30,8 @@ export const gitlabPlugin: LexiconPlugin = {
30
30
  auditCatalog: () => gitlabAuditCatalog,
31
31
  // Generate mode (#688): synthesize a .gitlab-ci.yml from the component graph.
32
32
  generateComponentPipeline: (components, options) => generateGitlabPipeline(components, options),
33
- // Generate mode, Op counterpart (#927): scheduled Op → GitLab CI job.
33
+ // Generate mode, Op counterpart (#927): an Op → one GitLab CI job, on a
34
+ // Pipeline Schedule, a merge request or a push (#2084, #2256).
34
35
  generateOpPipeline: (ops, options) => generateGitlabOpPipeline(ops, options),
35
36
  // Self-upgrade: where the pinned GitLab schema version lives + its upstream (#685).
36
37
  upstreamPin: {