@intentius/chant-lexicon-gitlab 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.
- package/dist/components/generate-op-pipeline.d.ts +87 -50
- package/dist/components/generate-op-pipeline.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/components/generate-op-pipeline.test.ts +309 -22
- package/src/components/generate-op-pipeline.ts +376 -106
- package/src/plugin.ts +2 -1
|
@@ -1,65 +1,102 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Generate mode —
|
|
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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* `
|
|
11
|
-
*
|
|
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
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* the
|
|
43
|
-
*
|
|
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
|
|
46
|
-
*
|
|
47
|
-
* branch red on every merge
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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
|
|
60
|
-
*
|
|
61
|
-
*
|
|
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
|
|
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"}
|
package/dist/integrity.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"algorithm": "sha256",
|
|
3
3
|
"artifacts": {
|
|
4
|
-
"manifest.json": "
|
|
4
|
+
"manifest.json": "c68e008d70828d518ed8908e47729c0783c3b473fe4997f54d67366ae58be0b5",
|
|
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": "
|
|
58
|
+
"composite": "40909e34fc71fe00e52febba2c3bd87e1287416e19c8ab58a5f769a24af7f34e"
|
|
59
59
|
}
|
package/dist/manifest.json
CHANGED
package/dist/plugin.d.ts.map
CHANGED
|
@@ -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,
|
|
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.
|
|
3
|
+
"version": "0.61.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.
|
|
71
|
-
"@intentius/chant-lexicon-github": "^0.
|
|
70
|
+
"@intentius/chant": "^0.61.0",
|
|
71
|
+
"@intentius/chant-lexicon-github": "^0.61.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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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("
|
|
80
|
-
const
|
|
81
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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("
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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: { "
|
|
357
|
+
{ name: "actions-audit", schedule: "0 6 * * *", permissions: { "pull-requests": "write" } },
|
|
168
358
|
]),
|
|
169
|
-
).toThrow(/adds
|
|
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 —
|
|
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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* `
|
|
11
|
-
*
|
|
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
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* the
|
|
43
|
-
*
|
|
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
|
|
46
|
-
*
|
|
47
|
-
* branch red on every merge
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
79
|
-
`mapping — there is no \`permissions:\` key to add
|
|
80
|
-
|
|
81
|
-
`
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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
|
|
109
|
-
|
|
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
|
|
112
|
-
function
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
119
|
-
*
|
|
120
|
-
*
|
|
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
|
|
137
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
179
|
-
|
|
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):
|
|
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: {
|