@intentius/chant-lexicon-gitlab 0.124.13 → 0.124.15
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/ci-jobs.d.ts +13 -0
- package/dist/components/ci-jobs.d.ts.map +1 -0
- package/dist/components/generate-op-pipeline.d.ts +1 -1
- package/dist/components/generate-op-pipeline.d.ts.map +1 -1
- package/dist/components/generate-op-waves-pipeline.d.ts.map +1 -1
- package/dist/components/generate-pipeline.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/package.json +3 -3
- package/src/components/ci-jobs.ts +18 -0
- package/src/components/generate-op-pipeline.ts +31 -204
- package/src/components/generate-op-waves-pipeline.test.ts +9 -0
- package/src/components/generate-op-waves-pipeline.ts +53 -50
- package/src/components/generate-pipeline.ts +38 -38
- package/src/components/generate-pr-pipeline.test.ts +6 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The GitLab CI pieces the three generators share (#3765): the Op pipeline
|
|
3
|
+
* (`./generate-op-pipeline.ts`), the Op waves pipeline
|
|
4
|
+
* (`./generate-op-waves-pipeline.ts`) and the component pipeline
|
|
5
|
+
* (`./generate-pipeline.ts`). The `rules:` expressions, `id_tokens:`, setup
|
|
6
|
+
* and environment checks are core's, in `@intentius/chant/ci-jobs`.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* A `.gitlab-ci.yml` document: the comment header, when there is one, then
|
|
10
|
+
* each top-level entry in the order given, a blank line between each.
|
|
11
|
+
*/
|
|
12
|
+
export declare function emitGitlabFile(entries: ReadonlyArray<readonly [string, unknown]>, header?: readonly string[]): string;
|
|
13
|
+
//# sourceMappingURL=ci-jobs.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ci-jobs.d.ts","sourceRoot":"","sources":["../../src/components/ci-jobs.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAIH;;;GAGG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,aAAa,CAAC,SAAS,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,EAAE,MAAM,GAAE,SAAS,MAAM,EAAO,GAAG,MAAM,CAGzH"}
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
* option's lines in this same lexicon, and which concatenates identically at
|
|
62
62
|
* run time. An additive `permissions` map has exactly one entry with a GitLab
|
|
63
63
|
* meaning: `id-token: write` becomes an `id_tokens:` declaration ({@link
|
|
64
|
-
*
|
|
64
|
+
* gitlabIdTokens}), the OIDC surface the old refusal already named as the shape
|
|
65
65
|
* to reach for. Every other scope is still refused, because there is no
|
|
66
66
|
* per-job token-scope mapping to put it in and a silently dropped scope emits
|
|
67
67
|
* a job that reads as granted and runs with nothing.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"generate-op-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyGG;
|
|
1
|
+
{"version":3,"file":"generate-op-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyGG;AAeH,OAAO,KAAK,EACV,wBAAwB,IAAI,uBAAuB,EAInD,gBAAgB,IAAI,sBAAsB,EAE1C,eAAe,EAChB,MAAM,0BAA0B,CAAC;AAIlC,YAAY,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,CAAC;AAqHhE;;;;GAIG;AACH,wBAAgB,wBAAwB,CACtC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,GACpC,sBAAsB,CAsGxB"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"generate-op-waves-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-waves-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;
|
|
1
|
+
{"version":3,"file":"generate-op-waves-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-waves-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAeH,OAAO,EAAoC,KAAK,WAAW,EAAE,MAAM,8BAA8B,CAAC;AAClG,OAAO,KAAK,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,0BAA0B,CAAC;AAG9F,gHAAgH;AAChH,wBAAgB,6BAA6B,CAAC,IAAI,EAAE,WAAW,EAAE,OAAO,EAAE,sBAAsB,GAAG,qBAAqB,CAqEvH"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"generate-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;
|
|
1
|
+
{"version":3,"file":"generate-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAWH,OAAO,EAAyB,KAAK,eAAe,EAAE,MAAM,oCAAoC,CAAC;AAKjG,OAAO,KAAK,EACV,oBAAoB,IAAI,YAAY,EACpC,wBAAwB,IAAI,qBAAqB,EACjD,uBAAuB,IAAI,oBAAoB,EAEhD,MAAM,0BAA0B,CAAC;AAIlC,YAAY,EAAE,YAAY,EAAE,qBAAqB,EAAE,oBAAoB,EAAE,CAAC;AAE1E;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,eAAe,EAAE,EAC7B,OAAO,GAAE,qBAA0B,GAClC,oBAAoB,CAkJtB"}
|
package/dist/integrity.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"algorithm": "sha256",
|
|
3
3
|
"artifacts": {
|
|
4
|
-
"manifest.json": "
|
|
4
|
+
"manifest.json": "d159b23db036b47dbf7b08997d690987e67ae767fdc7cfc974166e82a3e554e2",
|
|
5
5
|
"meta.json": "db082fbf90c5941c498b2bee0f106103449c1a4c257716ca2220f0dd490450b2",
|
|
6
6
|
"types/index.d.ts": "a048a66ebe20da0cb5fc889054cac69044d9bf882e2c56b5c5bf113d1f3ba57c",
|
|
7
7
|
"rules/artifact-no-expiry.ts": "0ca5bb972d4a542126e8a0229d43b55658394cb7dd9b9923b2c590bec1c2ad7d",
|
|
@@ -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": "1024305a66cb4b31fdc78d78424aa9c84bff0c4161728ea2424591b895df6892"
|
|
59
59
|
}
|
package/dist/manifest.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-gitlab",
|
|
3
|
-
"version": "0.124.
|
|
3
|
+
"version": "0.124.15",
|
|
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.124.
|
|
71
|
-
"@intentius/chant-lexicon-github": "^0.124.
|
|
70
|
+
"@intentius/chant": "^0.124.15",
|
|
71
|
+
"@intentius/chant-lexicon-github": "^0.124.15"
|
|
72
72
|
},
|
|
73
73
|
"peerDependenciesMeta": {
|
|
74
74
|
"@intentius/chant-lexicon-github": {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The GitLab CI pieces the three generators share (#3765): the Op pipeline
|
|
3
|
+
* (`./generate-op-pipeline.ts`), the Op waves pipeline
|
|
4
|
+
* (`./generate-op-waves-pipeline.ts`) and the component pipeline
|
|
5
|
+
* (`./generate-pipeline.ts`). The `rules:` expressions, `id_tokens:`, setup
|
|
6
|
+
* and environment checks are core's, in `@intentius/chant/ci-jobs`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { emitYAMLEntry } from "@intentius/chant/yaml";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A `.gitlab-ci.yml` document: the comment header, when there is one, then
|
|
13
|
+
* each top-level entry in the order given, a blank line between each.
|
|
14
|
+
*/
|
|
15
|
+
export function emitGitlabFile(entries: ReadonlyArray<readonly [string, unknown]>, header: readonly string[] = []): string {
|
|
16
|
+
const body = entries.map(([key, value]) => emitYAMLEntry(key, value)).join("\n\n") + "\n";
|
|
17
|
+
return header.length > 0 ? header.join("\n") + "\n\n" + body : body;
|
|
18
|
+
}
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
* option's lines in this same lexicon, and which concatenates identically at
|
|
62
62
|
* run time. An additive `permissions` map has exactly one entry with a GitLab
|
|
63
63
|
* meaning: `id-token: write` becomes an `id_tokens:` declaration ({@link
|
|
64
|
-
*
|
|
64
|
+
* gitlabIdTokens}), the OIDC surface the old refusal already named as the shape
|
|
65
65
|
* to reach for. Every other scope is still refused, because there is no
|
|
66
66
|
* per-job token-scope mapping to put it in and a silently dropped scope emits
|
|
67
67
|
* a job that reads as granted and runs with nothing.
|
|
@@ -105,7 +105,18 @@
|
|
|
105
105
|
* beside it does not also point.
|
|
106
106
|
*/
|
|
107
107
|
|
|
108
|
-
import {
|
|
108
|
+
import {
|
|
109
|
+
assertGitlabEnvironment,
|
|
110
|
+
CI_IMAGE,
|
|
111
|
+
ciJobName,
|
|
112
|
+
environmentDoc,
|
|
113
|
+
fillCommand,
|
|
114
|
+
gitlabIdTokens,
|
|
115
|
+
gitlabRules,
|
|
116
|
+
gitlabSetupScript,
|
|
117
|
+
nonEmpty,
|
|
118
|
+
pushBranches,
|
|
119
|
+
} from "@intentius/chant/ci-jobs";
|
|
109
120
|
import { memberGitlabChanges, memberRepoPath, memberShellDir, resolveOpTrigger } from "@intentius/chant/lexicon";
|
|
110
121
|
import type {
|
|
111
122
|
ComponentPipelineOptions as GenerateGitlabOpOptions,
|
|
@@ -117,14 +128,9 @@ import type {
|
|
|
117
128
|
ScheduledOpSpec,
|
|
118
129
|
} from "@intentius/chant/lexicon";
|
|
119
130
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
/** 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`). */
|
|
123
|
-
function toJobName(opName: string): string {
|
|
124
|
-
return opName.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
|
125
|
-
}
|
|
131
|
+
import { emitGitlabFile } from "./ci-jobs";
|
|
126
132
|
|
|
127
|
-
|
|
133
|
+
export type { GenerateGitlabOpOptions, GenerateGitlabOpResult };
|
|
128
134
|
|
|
129
135
|
/**
|
|
130
136
|
* Default stage every generated job runs in, and the default base of the
|
|
@@ -145,33 +151,6 @@ const DEFAULT_STAGE = "ops";
|
|
|
145
151
|
/** The CI/CD variable a Pipeline Schedule sets to select which job it runs. */
|
|
146
152
|
const SELECTOR_VAR = "CHANT_SCHEDULED_OP";
|
|
147
153
|
|
|
148
|
-
/**
|
|
149
|
-
* Default branch assumed for a `push` trigger with no `branches` override.
|
|
150
|
-
* The same value github's generator documents, for the same reason: this
|
|
151
|
-
* operates on a `ScheduledOpSpec` rather than a git checkout, so it cannot
|
|
152
|
-
* read a downstream project's actual default branch. Set `trigger.branches`
|
|
153
|
-
* explicitly on a project whose default branch is something else.
|
|
154
|
-
*/
|
|
155
|
-
const DEFAULT_PUSH_BRANCH = "main";
|
|
156
|
-
|
|
157
|
-
/** The pipeline source GitLab reports for a merge-request pipeline. */
|
|
158
|
-
const MERGE_REQUEST_SOURCE = '$CI_PIPELINE_SOURCE == "merge_request_event"';
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* The `id_tokens:` entry an `id-token: write` spec gets. The name is the
|
|
162
|
-
* environment variable the JWT lands in, which the job's own setup line reads
|
|
163
|
-
* (`aws sts assume-role-with-web-identity --web-identity-token
|
|
164
|
-
* "$CHANT_ID_TOKEN"`, or the provider's equivalent).
|
|
165
|
-
*
|
|
166
|
-
* `$CI_SERVER_URL` as the audience is GitLab's own documented default: an
|
|
167
|
-
* identity provider federated to a GitLab instance is registered with that
|
|
168
|
-
* instance's URL as its audience, and the variable expands to exactly that on
|
|
169
|
-
* gitlab.com and on a self-managed instance alike. A project whose provider
|
|
170
|
-
* was registered with some other audience edits the generated declaration.
|
|
171
|
-
*/
|
|
172
|
-
const ID_TOKEN_NAME = "CHANT_ID_TOKEN";
|
|
173
|
-
const ID_TOKEN_AUDIENCE = "$CI_SERVER_URL";
|
|
174
|
-
|
|
175
154
|
/**
|
|
176
155
|
* `chant run` returns 3 when a run stops at an unapproved gate; `--gated-exit
|
|
177
156
|
* 0` maps that one outcome to success (#2243). `push` only — a cron watch or
|
|
@@ -185,28 +164,6 @@ const GATE_SUMMARY_VAR = "CHANT_GATE_SUMMARY";
|
|
|
185
164
|
/** How long a pending-gate artifact is worth keeping: long enough to outlive the approval it is waiting for. */
|
|
186
165
|
const GATE_ARTIFACT_EXPIRY = "30 days";
|
|
187
166
|
|
|
188
|
-
/**
|
|
189
|
-
* Refuse a branch name that cannot be interpolated into a `rules:`
|
|
190
|
-
* if-expression. The expression is a string GitLab parses, so a `"` would end
|
|
191
|
-
* it early and a `$` would expand as a variable — either one silently changes
|
|
192
|
-
* which pipelines match the job, which is worse than not generating it.
|
|
193
|
-
*/
|
|
194
|
-
function assertBranchName(specName: string, branch: string): void {
|
|
195
|
-
if (branch.trim() === "") {
|
|
196
|
-
throw new Error(
|
|
197
|
-
`Scheduled Op "${specName}" has an empty branch filter. Name a branch, or drop the filter.`,
|
|
198
|
-
);
|
|
199
|
-
}
|
|
200
|
-
if (/["$\\]/.test(branch)) {
|
|
201
|
-
throw new Error(
|
|
202
|
-
`Scheduled Op "${specName}" filters on branch "${branch}", which carries a character this generator ` +
|
|
203
|
-
`cannot put in a GitLab \`rules:\` expression: a quote would end the expression early and a "$" ` +
|
|
204
|
-
`would expand as a CI/CD variable, either of which silently changes which pipelines run the job. ` +
|
|
205
|
-
`Name a branch without \`"\`, \`$\` or \`\\\`.`,
|
|
206
|
-
);
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
167
|
/** Refuse a blank `opsStage` (#2293): every job's `stage:` and the top-level `stages:` entry would otherwise be an empty string. */
|
|
211
168
|
function assertOpsStage(stage: string): void {
|
|
212
169
|
if (stage.trim() === "") {
|
|
@@ -227,33 +184,6 @@ function assertOpsFileName(fileName: string): void {
|
|
|
227
184
|
}
|
|
228
185
|
}
|
|
229
186
|
|
|
230
|
-
/**
|
|
231
|
-
* This trigger's `rules:` entries. Two or more branches are two or more
|
|
232
|
-
* entries: `rules:` is an OR over its list, which is how GitLab spells the
|
|
233
|
-
* alternation github expresses as a `branches:` array.
|
|
234
|
-
*/
|
|
235
|
-
function rulesFor(spec: ScheduledOpSpec, trigger: OpTrigger): Array<{ if: string }> {
|
|
236
|
-
switch (trigger.kind) {
|
|
237
|
-
case "cron":
|
|
238
|
-
return [{ if: `$CI_PIPELINE_SOURCE == "schedule" && $${SELECTOR_VAR} == "${spec.name}"` }];
|
|
239
|
-
case "pull_request": {
|
|
240
|
-
const branches = trigger.branches ?? [];
|
|
241
|
-
if (branches.length === 0) return [{ if: MERGE_REQUEST_SOURCE }];
|
|
242
|
-
return branches.map((branch) => {
|
|
243
|
-
assertBranchName(spec.name, branch);
|
|
244
|
-
return { if: `${MERGE_REQUEST_SOURCE} && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "${branch}"` };
|
|
245
|
-
});
|
|
246
|
-
}
|
|
247
|
-
case "push": {
|
|
248
|
-
const branches = trigger.branches?.length ? trigger.branches : [DEFAULT_PUSH_BRANCH];
|
|
249
|
-
return branches.map((branch) => {
|
|
250
|
-
assertBranchName(spec.name, branch);
|
|
251
|
-
return { if: `$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "${branch}"` };
|
|
252
|
-
});
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
187
|
/**
|
|
258
188
|
* Refuse `findingMode: "comment"` on a trigger that has no merge request
|
|
259
189
|
* (#2231, #2256). The mode's activity reads the merge request out of the
|
|
@@ -272,99 +202,6 @@ function assertTriggerSupportsMode(name: string, mode: OpFindingMode, trigger: O
|
|
|
272
202
|
);
|
|
273
203
|
}
|
|
274
204
|
|
|
275
|
-
/**
|
|
276
|
-
* Turn a spec's additive `permissions` (#2242) into the one GitLab
|
|
277
|
-
* declaration that carries the same meaning, refusing everything else by name.
|
|
278
|
-
*
|
|
279
|
-
* `id-token: write` is that one: GitLab's OIDC surface is `id_tokens:`, a
|
|
280
|
-
* per-job declaration of a JWT the job exchanges for cloud credentials
|
|
281
|
-
* itself. `id-token: read` is refused rather than mapped, because GitLab
|
|
282
|
-
* either mints the token into the job or does not — there is no read-only
|
|
283
|
-
* half of it, and emitting the declaration for a spec that asked for read
|
|
284
|
-
* would hand the job more than it asked for.
|
|
285
|
-
*
|
|
286
|
-
* Every other scope is refused, unchanged from the pre-#2256 behaviour:
|
|
287
|
-
* GitLab has no per-job token-scope mapping at all, and a scope quietly
|
|
288
|
-
* dropped would emit a job that reads as granted and runs with nothing.
|
|
289
|
-
*/
|
|
290
|
-
function idTokensFor(spec: ScheduledOpSpec): Record<string, unknown> | undefined {
|
|
291
|
-
let wantsIdToken = false;
|
|
292
|
-
for (const [rawScope, value] of Object.entries(spec.permissions ?? {})) {
|
|
293
|
-
const scope = rawScope.trim();
|
|
294
|
-
if (scope === "id-token") {
|
|
295
|
-
if (value !== "write") {
|
|
296
|
-
throw new Error(
|
|
297
|
-
`Scheduled Op "${spec.name}" adds permission "id-token: ${value}", but GitLab either mints an ` +
|
|
298
|
-
`OIDC token into a job or does not — there is no read-only half of an \`id_tokens:\` ` +
|
|
299
|
-
`declaration. Ask for { "id-token": "write" }, or drop the option.`,
|
|
300
|
-
);
|
|
301
|
-
}
|
|
302
|
-
wantsIdToken = true;
|
|
303
|
-
continue;
|
|
304
|
-
}
|
|
305
|
-
throw new Error(
|
|
306
|
-
`Scheduled Op "${spec.name}" adds permission "${scope}: ${value}", but GitLab CI has no per-job ` +
|
|
307
|
-
`token-scope mapping — there is no \`permissions:\` key to add it to (#2242). The one scope that ` +
|
|
308
|
-
`does cross over is "id-token": "write", which becomes an \`id_tokens:\` declaration. Drop the ` +
|
|
309
|
-
`option here, or generate this Op for github.`,
|
|
310
|
-
);
|
|
311
|
-
}
|
|
312
|
-
return wantsIdToken ? { [ID_TOKEN_NAME]: { aud: ID_TOKEN_AUDIENCE } } : undefined;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
/**
|
|
316
|
-
* Refuse a `uses` setup step by name and return the `run` lines that do
|
|
317
|
-
* translate. See the module doc for why a marketplace action is a refusal
|
|
318
|
-
* rather than a silent drop.
|
|
319
|
-
*/
|
|
320
|
-
function gitlabSetupScript(spec: ScheduledOpSpec): string[] {
|
|
321
|
-
const lines: string[] = [];
|
|
322
|
-
(spec.setup ?? []).forEach((step, index) => {
|
|
323
|
-
if ("uses" in step) {
|
|
324
|
-
throw new Error(
|
|
325
|
-
`Scheduled Op "${spec.name}" setup step ${index + 1} is \`uses: "${step.uses}"\`, a GitHub Actions ` +
|
|
326
|
-
`marketplace action. GitLab CI jobs run \`script\` lines only, so there is nothing to translate it ` +
|
|
327
|
-
`into (#2242). Express the setup as a \`{ run }\` entry, or generate this Op for github/forgejo.`,
|
|
328
|
-
);
|
|
329
|
-
}
|
|
330
|
-
lines.push(step.run);
|
|
331
|
-
});
|
|
332
|
-
return lines;
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
/**
|
|
336
|
-
* Validate a spec's `environment` (#2257) on GitLab's own terms. A GitLab job
|
|
337
|
-
* naming an environment that does not exist creates an unprotected one on
|
|
338
|
-
* first deploy rather than failing, so — as on github — the only shapes worth
|
|
339
|
-
* refusing at build time are the ones that could never bind: a blank name,
|
|
340
|
-
* and a `url` that is neither absolute nor a variable expression GitLab
|
|
341
|
-
* expands, which would render as a dead "View deployment" link.
|
|
342
|
-
*/
|
|
343
|
-
function assertGitlabEnvironment(name: string, environment: OpEnvironment): void {
|
|
344
|
-
const where = `Scheduled Op "${name}" environment`;
|
|
345
|
-
if (environment.name.trim() === "") {
|
|
346
|
-
throw new Error(
|
|
347
|
-
`${where} has an empty \`name\`. A GitLab environment is a project object, and its approval ` +
|
|
348
|
-
`rules live on that object rather than in this file, so a blank name resolves to nothing. ` +
|
|
349
|
-
`Give it the environment's name, or drop the option.`,
|
|
350
|
-
);
|
|
351
|
-
}
|
|
352
|
-
if (environment.url === undefined) return;
|
|
353
|
-
const url = environment.url.trim();
|
|
354
|
-
if (url === "") {
|
|
355
|
-
throw new Error(
|
|
356
|
-
`${where} "${environment.name}" has an empty \`url\`. Omit the field rather than setting it to "".`,
|
|
357
|
-
);
|
|
358
|
-
}
|
|
359
|
-
if (!/^https?:\/\//.test(url) && !url.includes("$")) {
|
|
360
|
-
throw new Error(
|
|
361
|
-
`${where} "${environment.name}" has \`url: "${environment.url}"\`, which is neither an absolute ` +
|
|
362
|
-
`http(s) URL nor a variable expression GitLab expands. It becomes the environment's own link, ` +
|
|
363
|
-
`so a relative path is a dead link on the environment page rather than an error anywhere.`,
|
|
364
|
-
);
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
|
|
368
205
|
/**
|
|
369
206
|
* The follow-up line an Op with an `environment` adds under its own header
|
|
370
207
|
* line (#2257). The `environment:` key on the job binds it to the
|
|
@@ -401,7 +238,7 @@ function headerLineFor(
|
|
|
401
238
|
return `# ${jobName}: merge_request_event onto ${onto}, finding-mode ${mode}${tokenNote}`;
|
|
402
239
|
}
|
|
403
240
|
case "push": {
|
|
404
|
-
const branches = trigger.branches
|
|
241
|
+
const branches = pushBranches(trigger.branches);
|
|
405
242
|
return (
|
|
406
243
|
`# ${jobName}: push to ${branches.join(", ")}, finding-mode ${mode}${tokenNote}` +
|
|
407
244
|
` — a gated apply stays green, its pending gate in the log and in ${gateSummaryPath(jobName)}`
|
|
@@ -419,7 +256,7 @@ export function generateGitlabOpPipeline(
|
|
|
419
256
|
ops: ScheduledOpSpec[],
|
|
420
257
|
options: GenerateGitlabOpOptions = {},
|
|
421
258
|
): GenerateGitlabOpResult {
|
|
422
|
-
const image = options.image ??
|
|
259
|
+
const image = options.image ?? CI_IMAGE;
|
|
423
260
|
const runCommand = options.runCommand ?? ["chant", "run", "{name}"];
|
|
424
261
|
const beforeScript = options.beforeScript ?? [];
|
|
425
262
|
const extraScript = options.extraScript ?? [];
|
|
@@ -437,21 +274,21 @@ export function generateGitlabOpPipeline(
|
|
|
437
274
|
const cdLine = member && member.dir !== "." && member.dir !== "" ? [`cd ${memberShellDir(member)}`] : [];
|
|
438
275
|
|
|
439
276
|
const jobs: OpPipelineJob[] = [];
|
|
440
|
-
const doc: Record<string, unknown> = { stages: [stage] };
|
|
441
|
-
if (options.variables && Object.keys(options.variables).length > 0) doc.variables = options.variables;
|
|
277
|
+
const doc: Record<string, unknown> = { stages: [stage], ...nonEmpty("variables", options.variables) };
|
|
442
278
|
|
|
443
279
|
const opLines: string[] = [];
|
|
444
280
|
let anyCron = false;
|
|
445
281
|
|
|
446
282
|
for (const spec of ops) {
|
|
447
|
-
const
|
|
448
|
-
const
|
|
283
|
+
const subject = `Scheduled Op "${spec.name}"`;
|
|
284
|
+
const setupScript = gitlabSetupScript(subject, spec.setup);
|
|
285
|
+
const idTokens = gitlabIdTokens(spec.name, spec.permissions);
|
|
449
286
|
const findingMode = spec.findingMode ?? "report";
|
|
450
287
|
const trigger = resolveOpTrigger(spec);
|
|
451
288
|
assertTriggerSupportsMode(spec.name, findingMode, trigger);
|
|
452
289
|
if (trigger.kind === "cron") anyCron = true;
|
|
453
290
|
|
|
454
|
-
const jobName = jobPrefix +
|
|
291
|
+
const jobName = jobPrefix + ciJobName(spec.name);
|
|
455
292
|
jobs.push({ jobName, op: spec.name, trigger, findingMode });
|
|
456
293
|
opLines.push(headerLineFor(spec, trigger, jobName, findingMode));
|
|
457
294
|
if (spec.environment) {
|
|
@@ -461,8 +298,9 @@ export function generateGitlabOpPipeline(
|
|
|
461
298
|
|
|
462
299
|
// A push job is the one a gate would otherwise paint red on every merge
|
|
463
300
|
// (#2243). Every other trigger keeps the plain one-line invocation.
|
|
301
|
+
const rules = gitlabRules(subject, trigger, { variable: SELECTOR_VAR, value: spec.name });
|
|
464
302
|
const gated = trigger.kind === "push";
|
|
465
|
-
const runParts = runCommand
|
|
303
|
+
const runParts = fillCommand(runCommand, spec.name);
|
|
466
304
|
const invocation = gated ? [...runParts, ...GATED_EXIT_FLAG] : runParts;
|
|
467
305
|
const gateSummary = gateSummaryPath(jobName);
|
|
468
306
|
|
|
@@ -483,16 +321,9 @@ export function generateGitlabOpPipeline(
|
|
|
483
321
|
// next run rather than cancel the current one.
|
|
484
322
|
resource_group: jobName,
|
|
485
323
|
...(idTokens ? { id_tokens: idTokens } : {}),
|
|
486
|
-
...(spec.environment
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
name: spec.environment.name,
|
|
490
|
-
...(spec.environment.url === undefined ? {} : { url: spec.environment.url }),
|
|
491
|
-
},
|
|
492
|
-
}
|
|
493
|
-
: {}),
|
|
494
|
-
...(Object.keys(jobVariables).length > 0 ? { variables: jobVariables } : {}),
|
|
495
|
-
rules: changes && trigger.kind !== "cron" ? rulesFor(spec, trigger).map((r) => ({ ...r, changes })) : rulesFor(spec, trigger),
|
|
324
|
+
...(spec.environment ? { environment: environmentDoc(spec.environment) } : {}),
|
|
325
|
+
...nonEmpty("variables", jobVariables),
|
|
326
|
+
rules: changes && trigger.kind !== "cron" ? rules.map((r) => ({ ...r, changes })) : rules,
|
|
496
327
|
script: [...cdLine, ...setupScript, ...beforeScript, invocation.join(" "), ...extraScript],
|
|
497
328
|
// `when: always` because the run this publishes for is the green one: a
|
|
498
329
|
// gated apply succeeds, and the block is the only thing that says a
|
|
@@ -520,14 +351,10 @@ export function generateGitlabOpPipeline(
|
|
|
520
351
|
}
|
|
521
352
|
headerLines.push("#", ...opLines);
|
|
522
353
|
|
|
523
|
-
const
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
sections.push(emitYAMLEntry(jobName, doc[jobName]));
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
const yaml = headerLines.join("\n") + "\n\n" + sections.join("\n\n") + "\n";
|
|
354
|
+
const yaml = emitGitlabFile(
|
|
355
|
+
[["stages", doc.stages], ...(doc.variables ? [["variables", doc.variables] as const] : []), ...jobs.map(({ jobName }) => [jobName, doc[jobName]] as const)],
|
|
356
|
+
headerLines,
|
|
357
|
+
);
|
|
531
358
|
|
|
532
359
|
return { files: [{ name: fileName, yaml }], jobs };
|
|
533
360
|
}
|
|
@@ -60,3 +60,12 @@ describe("generateGitlabOpWavesPipeline", () => {
|
|
|
60
60
|
expect(() => generateGitlabOpWavesPipeline({ ...waveLevel, hooks: { after: { apply: [{ command: ["./bin/smoke"], onFailure: "approve" }] } } }, { specFile: "waves.json" })).toThrow(/nothing left to hold/);
|
|
61
61
|
});
|
|
62
62
|
});
|
|
63
|
+
|
|
64
|
+
describe("a wave's environment is checked as an Op's is (#3765)", () => {
|
|
65
|
+
test("a blank name or a relative url is refused, and a $VARIABLE url passes", () => {
|
|
66
|
+
const one = (environment: { name: string; url?: string }): OpWavesSpec => ({ ...spec, waves: [{ name: "dev", runs: [{ target: "dev" }], environment }] });
|
|
67
|
+
expect(() => generateGitlabOpWavesPipeline(one({ name: " " }), { specFile: "waves.json" })).toThrow(/environment has an empty `name`/);
|
|
68
|
+
expect(() => generateGitlabOpWavesPipeline(one({ name: "dev", url: "dev/app" }), { specFile: "waves.json" })).toThrow(/neither an absolute/);
|
|
69
|
+
expect(generateGitlabOpWavesPipeline(one({ name: "dev", url: "$CI_ENVIRONMENT_URL" }), { specFile: "waves.json" }).files[0]!.yaml).toContain("url: '$CI_ENVIRONMENT_URL'");
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -20,79 +20,82 @@
|
|
|
20
20
|
* pipelines only, which records the digests the merge request's head plans.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
assertGitlabBranch,
|
|
25
|
+
assertGitlabEnvironment,
|
|
26
|
+
CI_IMAGE,
|
|
27
|
+
environmentDoc,
|
|
28
|
+
GITLAB_FULL_HISTORY,
|
|
29
|
+
GITLAB_MERGE_REQUEST_SOURCE,
|
|
30
|
+
GITLAB_SCHEDULE_SOURCE,
|
|
31
|
+
gitlabPushRule,
|
|
32
|
+
gitlabSetupScript,
|
|
33
|
+
nonEmpty,
|
|
34
|
+
pushBranches,
|
|
35
|
+
} from "@intentius/chant/ci-jobs";
|
|
24
36
|
import { opWaveJobs, opWavesResumeCommand, type OpWavesSpec } from "@intentius/chant/op/op-waves";
|
|
25
37
|
import type { OpWavesPipelineOptions, OpWavesPipelineResult } from "@intentius/chant/lexicon";
|
|
26
|
-
|
|
27
|
-
/** The default image: it carries git, which the runner needs to read the base commit. */
|
|
28
|
-
const OP_WAVES_IMAGE = "node:22";
|
|
38
|
+
import { emitGitlabFile } from "./ci-jobs";
|
|
29
39
|
|
|
30
40
|
/** Render an Op waves spec as one GitLab CI file, `<name>.gitlab-ci.yml` unless `opsFileName` names another. */
|
|
31
41
|
export function generateGitlabOpWavesPipeline(spec: OpWavesSpec, options: OpWavesPipelineOptions): OpWavesPipelineResult {
|
|
32
42
|
const jobs = opWaveJobs(spec, options.specFile);
|
|
33
|
-
const image = options.image ??
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const
|
|
43
|
+
const image = options.image ?? CI_IMAGE;
|
|
44
|
+
const subject = `Op waves "${spec.name}"`;
|
|
45
|
+
const rules = pushBranches(spec.branches).map((branch) => {
|
|
46
|
+
assertGitlabBranch(subject, branch);
|
|
47
|
+
return { if: gitlabPushRule(JSON.stringify(branch)) };
|
|
48
|
+
});
|
|
49
|
+
// The setup and environment each wave's jobs carry, checked as an Op's are.
|
|
50
|
+
const setupOf = spec.waves.map((wave) => gitlabSetupScript(`${subject} wave "${wave.name}"`, wave.setup));
|
|
51
|
+
for (const wave of spec.waves) if (wave.environment) assertGitlabEnvironment(`${spec.name}/${wave.name}`, wave.environment);
|
|
41
52
|
|
|
42
53
|
const byReview = spec.waves.some((w) => w.approval === "pr-review");
|
|
43
54
|
const stages = [...(byReview ? ["plans"] : []), ...spec.waves.map((wave, i) => `wave-${i + 1}-${wave.name}`)];
|
|
44
55
|
if (spec.resume) stages.push("resume");
|
|
45
|
-
const
|
|
46
|
-
|
|
56
|
+
const entries: Array<readonly [string, unknown]> = [["stages", stages]];
|
|
57
|
+
const variables = nonEmpty("variables", options.variables).variables;
|
|
58
|
+
if (variables) entries.push(["variables", variables]);
|
|
59
|
+
if (byReview) {
|
|
60
|
+
entries.push([
|
|
61
|
+
`${spec.name}-record-plans`,
|
|
62
|
+
{
|
|
63
|
+
stage: "plans",
|
|
64
|
+
image,
|
|
65
|
+
variables: { ...GITLAB_FULL_HISTORY },
|
|
66
|
+
rules: [{ if: GITLAB_MERGE_REQUEST_SOURCE }],
|
|
67
|
+
script: [...(options.beforeScript ?? []), ["chant", "run", "wave", "--spec", options.specFile, "--record-plans"].join(" ")],
|
|
68
|
+
},
|
|
69
|
+
]);
|
|
70
|
+
}
|
|
47
71
|
for (const job of jobs) {
|
|
48
72
|
const wave = spec.waves[job.wave - 1]!;
|
|
49
|
-
const setup =
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
`Actions marketplace action. GitLab CI runs script lines only; express it as a { run } entry.`,
|
|
54
|
-
);
|
|
55
|
-
}
|
|
56
|
-
return step.run;
|
|
57
|
-
});
|
|
58
|
-
sections.push(
|
|
59
|
-
emitYAMLEntry(job.jobName, {
|
|
73
|
+
const setup = setupOf[job.wave - 1]!;
|
|
74
|
+
entries.push([
|
|
75
|
+
job.jobName,
|
|
76
|
+
{
|
|
60
77
|
stage: stages[job.wave - 1 + (byReview ? 1 : 0)],
|
|
61
78
|
image,
|
|
62
79
|
resource_group: `${spec.name}-${wave.name}`,
|
|
63
|
-
...(wave.environment
|
|
64
|
-
|
|
65
|
-
: {}),
|
|
66
|
-
variables: { GIT_DEPTH: "0", ...(wave.variables ?? {}) },
|
|
80
|
+
...(wave.environment ? { environment: environmentDoc(wave.environment) } : {}),
|
|
81
|
+
variables: { ...GITLAB_FULL_HISTORY, ...(wave.variables ?? {}) },
|
|
67
82
|
rules,
|
|
68
83
|
...(job.needs.length > 0 ? { needs: job.needs } : {}),
|
|
69
84
|
script: [...setup, ...(options.beforeScript ?? []), job.command.join(" "), ...(options.extraScript ?? [])],
|
|
70
85
|
...(job.kind === "decide" ? { artifacts: { when: "always", paths: [job.record!], expire_in: "30 days" } } : {}),
|
|
71
|
-
}
|
|
72
|
-
);
|
|
86
|
+
},
|
|
87
|
+
]);
|
|
73
88
|
}
|
|
74
89
|
if (spec.resume) {
|
|
75
|
-
|
|
76
|
-
|
|
90
|
+
entries.push([
|
|
91
|
+
`${spec.name}-resume`,
|
|
92
|
+
{
|
|
77
93
|
stage: "resume",
|
|
78
94
|
image,
|
|
79
|
-
rules: [{ if:
|
|
95
|
+
rules: [{ if: GITLAB_SCHEDULE_SOURCE }],
|
|
80
96
|
script: [...(options.beforeScript ?? []), opWavesResumeCommand(spec).join(" ")],
|
|
81
|
-
}
|
|
82
|
-
);
|
|
83
|
-
}
|
|
84
|
-
if (byReview) {
|
|
85
|
-
sections.splice(
|
|
86
|
-
options.variables && Object.keys(options.variables).length > 0 ? 2 : 1,
|
|
87
|
-
0,
|
|
88
|
-
emitYAMLEntry(`${spec.name}-record-plans`, {
|
|
89
|
-
stage: "plans",
|
|
90
|
-
image,
|
|
91
|
-
variables: { GIT_DEPTH: "0" },
|
|
92
|
-
rules: [{ if: '$CI_PIPELINE_SOURCE == "merge_request_event"' }],
|
|
93
|
-
script: [...(options.beforeScript ?? []), ["chant", "run", "wave", "--spec", options.specFile, "--record-plans"].join(" ")],
|
|
94
|
-
}),
|
|
95
|
-
);
|
|
97
|
+
},
|
|
98
|
+
]);
|
|
96
99
|
}
|
|
97
100
|
const header = [
|
|
98
101
|
`# chant Op waves "${spec.name}": one stage per wave, each needing the one before.`,
|
|
@@ -103,5 +106,5 @@ export function generateGitlabOpWavesPipeline(spec: OpWavesSpec, options: OpWave
|
|
|
103
106
|
: []),
|
|
104
107
|
];
|
|
105
108
|
const name = options.opsFileName ?? `${spec.name}.gitlab-ci.yml`;
|
|
106
|
-
return { files: [{ name, yaml:
|
|
109
|
+
return { files: [{ name, yaml: emitGitlabFile(entries, header) }], jobs };
|
|
107
110
|
}
|
|
@@ -32,11 +32,19 @@
|
|
|
32
32
|
* touching the component declarations.
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
assertGitlabBranch,
|
|
37
|
+
CI_IMAGE,
|
|
38
|
+
ciJobName,
|
|
39
|
+
fillCommand,
|
|
40
|
+
GITLAB_FULL_HISTORY,
|
|
41
|
+
GITLAB_MERGE_REQUEST_SOURCE,
|
|
42
|
+
gitlabPushRule,
|
|
43
|
+
} from "@intentius/chant/ci-jobs";
|
|
36
44
|
import { resolveComponentGraph, type DriverComponent } from "@intentius/chant/components/driver";
|
|
37
45
|
import { hasPublishStep, promoteArchivePaths } from "@intentius/chant/components/promote";
|
|
38
46
|
import { GATED_WAVE_RECORD, gatedWaveJobs } from "@intentius/chant/components/gated-wave-pipeline";
|
|
39
|
-
import { PR_APPLY_RECORD,
|
|
47
|
+
import { PR_APPLY_RECORD, PR_LOOP_REPORT_DIR, prApplyGroup, prApplyRecordKey, prLoopJobs, prLoopSetup } from "@intentius/chant/components/pr-pipeline";
|
|
40
48
|
import { memberGitlabChanges, memberRepoPath, memberShellDir } from "@intentius/chant/lexicon";
|
|
41
49
|
import type {
|
|
42
50
|
ComponentPipelineJob as GeneratedJob,
|
|
@@ -45,14 +53,9 @@ import type {
|
|
|
45
53
|
PipelineMember,
|
|
46
54
|
} from "@intentius/chant/lexicon";
|
|
47
55
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
/** GitLab CI job names must be safe YAML keys; component names are already kebab-case in every fixture, but normalize defensively. */
|
|
51
|
-
function toJobName(componentName: string): string {
|
|
52
|
-
return componentName.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
|
53
|
-
}
|
|
56
|
+
import { emitGitlabFile } from "./ci-jobs";
|
|
54
57
|
|
|
55
|
-
|
|
58
|
+
export type { GeneratedJob, GenerateGitlabOptions, GenerateGitlabResult };
|
|
56
59
|
|
|
57
60
|
/**
|
|
58
61
|
* Synthesize a `.gitlab-ci.yml` pipeline from a set of components: one stage
|
|
@@ -67,7 +70,7 @@ export function generateGitlabPipeline(
|
|
|
67
70
|
options: GenerateGitlabOptions = {},
|
|
68
71
|
): GenerateGitlabResult {
|
|
69
72
|
const env = options.env ?? "production";
|
|
70
|
-
const image = options.image ??
|
|
73
|
+
const image = options.image ?? CI_IMAGE;
|
|
71
74
|
const runCommand = options.runCommand ?? ["chant", "run", "--components", "{name}", "--env", env];
|
|
72
75
|
const beforeScript = options.beforeScript ?? [];
|
|
73
76
|
const extraScript = options.extraScript ?? [];
|
|
@@ -126,7 +129,7 @@ export function generateGitlabPipeline(
|
|
|
126
129
|
const jobs: GeneratedJob[] = [];
|
|
127
130
|
const jobNameByComponent = new Map<string, string>();
|
|
128
131
|
for (const wave of waves) {
|
|
129
|
-
for (const name of wave) jobNameByComponent.set(name,
|
|
132
|
+
for (const name of wave) jobNameByComponent.set(name, ciJobName(name));
|
|
130
133
|
}
|
|
131
134
|
|
|
132
135
|
const doc: Record<string, unknown> = {};
|
|
@@ -152,7 +155,7 @@ export function generateGitlabPipeline(
|
|
|
152
155
|
// Build the run invocation, then append output-threading flags: seed from
|
|
153
156
|
// each dependency's dumped outputs, and dump this component's own outputs
|
|
154
157
|
// if a dependent will need them.
|
|
155
|
-
const runParts = runCommand
|
|
158
|
+
const runParts = fillCommand(runCommand, name);
|
|
156
159
|
for (const dep of component.dependsOn ?? []) runParts.push("--seed-outputs", outputsFile(dep));
|
|
157
160
|
if (dependedUpon.has(name)) runParts.push("--dump-outputs", outputsFile(name));
|
|
158
161
|
if (pinned.has(name)) runParts.push("--digest-file", digestFile(name));
|
|
@@ -178,7 +181,7 @@ export function generateGitlabPipeline(
|
|
|
178
181
|
|
|
179
182
|
let promoteJob: string | undefined;
|
|
180
183
|
if (promoteTo !== undefined) {
|
|
181
|
-
promoteJob = `promote-${
|
|
184
|
+
promoteJob = `promote-${ciJobName(promoteTo)}`;
|
|
182
185
|
if (promoteJob in doc) {
|
|
183
186
|
throw new Error(`the promote job "${promoteJob}" has the same name as a component job; rename the component`);
|
|
184
187
|
}
|
|
@@ -200,20 +203,17 @@ export function generateGitlabPipeline(
|
|
|
200
203
|
promoteJob = scopeToMember(doc, jobs, promoteJob, options.member, env);
|
|
201
204
|
}
|
|
202
205
|
|
|
203
|
-
const sections: string[] = [];
|
|
204
|
-
sections.push(emitYAMLEntry("workflow", doc.workflow));
|
|
205
206
|
// The promote stage is YAML only: `stages` in the result stays one entry
|
|
206
207
|
// per graph wave.
|
|
207
208
|
const yamlStages = promoteJob ? [...stages, "promote"] : stages;
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
return { yaml: sections.join("\n\n") + "\n", stages, jobs, env };
|
|
209
|
+
const yaml = emitGitlabFile([
|
|
210
|
+
["workflow", doc.workflow],
|
|
211
|
+
["stages", yamlStages],
|
|
212
|
+
["variables", doc.variables],
|
|
213
|
+
...jobs.map((job) => [job.jobName, doc[job.jobName]] as const),
|
|
214
|
+
...(promoteJob ? [[promoteJob, doc[promoteJob]] as const] : []),
|
|
215
|
+
]);
|
|
216
|
+
return { yaml, stages, jobs, env };
|
|
217
217
|
}
|
|
218
218
|
|
|
219
219
|
/**
|
|
@@ -244,20 +244,20 @@ function gatedWaveGitlabPipeline(
|
|
|
244
244
|
doc[job.jobName] = {
|
|
245
245
|
stage: job.jobName,
|
|
246
246
|
image,
|
|
247
|
-
variables: {
|
|
247
|
+
variables: { ...GITLAB_FULL_HISTORY },
|
|
248
248
|
script: [...prLoopSetup(image), ...beforeScript, job.command.join(" "), ...extraScript],
|
|
249
249
|
...(job.needs.length > 0 ? { needs: job.needs } : {}),
|
|
250
250
|
artifacts: { when: "always", paths: [GATED_WAVE_RECORD] },
|
|
251
251
|
};
|
|
252
252
|
}
|
|
253
253
|
if (options.member) scopeToMember(doc, jobs, undefined, options.member, env);
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
...jobs.map((job) =>
|
|
259
|
-
];
|
|
260
|
-
return { yaml
|
|
254
|
+
const yaml = emitGitlabFile([
|
|
255
|
+
["workflow", doc.workflow],
|
|
256
|
+
["stages", stages],
|
|
257
|
+
["variables", doc.variables],
|
|
258
|
+
...jobs.map((job) => [job.jobName, doc[job.jobName]] as const),
|
|
259
|
+
]);
|
|
260
|
+
return { yaml, stages, jobs, env };
|
|
261
261
|
}
|
|
262
262
|
|
|
263
263
|
/**
|
|
@@ -297,9 +297,9 @@ function prLoopGitlabPipeline(
|
|
|
297
297
|
const reportPath = member ? memberRepoPath(member, PR_LOOP_REPORT_DIR) : PR_LOOP_REPORT_DIR;
|
|
298
298
|
const planJob = member ? `${member.name}-plan` : "plan";
|
|
299
299
|
const applyJob = member ? `${member.name}-apply` : "apply";
|
|
300
|
-
const onMergeRequest =
|
|
301
|
-
|
|
302
|
-
const onMerge =
|
|
300
|
+
const onMergeRequest = GITLAB_MERGE_REQUEST_SOURCE;
|
|
301
|
+
if (loop.branch !== undefined) assertGitlabBranch("The merge-request pipeline", loop.branch);
|
|
302
|
+
const onMerge = gitlabPushRule(loop.branch ? JSON.stringify(loop.branch) : "$CI_DEFAULT_BRANCH");
|
|
303
303
|
// A member's jobs start in its directory, after the image's install lines.
|
|
304
304
|
const enter = rooted ? [] : [`cd ${memberShellDir(member!)}`];
|
|
305
305
|
const script = (job: typeof plan): string[] => [...prLoopSetup(image), ...enter, ...beforeScript, ...(job.setup ?? []), job.command, ...extraScript];
|
|
@@ -309,7 +309,7 @@ function prLoopGitlabPipeline(
|
|
|
309
309
|
// the rules every member shares and no name: two names would collide.
|
|
310
310
|
workflow: { ...(member ? {} : { name: `chant-pr-${env}` }), rules: [{ if: onMergeRequest }, { if: onMerge }] },
|
|
311
311
|
stages,
|
|
312
|
-
variables: { ...options.variables, CHANT_ENV: env,
|
|
312
|
+
variables: { ...options.variables, CHANT_ENV: env, ...GITLAB_FULL_HISTORY },
|
|
313
313
|
[planJob]: {
|
|
314
314
|
stage: "plan",
|
|
315
315
|
image,
|
|
@@ -339,8 +339,8 @@ function prLoopGitlabPipeline(
|
|
|
339
339
|
{ jobName: planJob, component: "merge request plan", stage: "plan", needs: [] },
|
|
340
340
|
{ jobName: applyJob, component: "merge request apply", stage: "apply", needs: [] },
|
|
341
341
|
];
|
|
342
|
-
const
|
|
343
|
-
return { yaml
|
|
342
|
+
const yaml = emitGitlabFile(["workflow", "stages", "variables", planJob, applyJob].map((key) => [key, doc[key]] as const));
|
|
343
|
+
return { yaml, stages, jobs, env };
|
|
344
344
|
}
|
|
345
345
|
|
|
346
346
|
/**
|
|
@@ -119,3 +119,9 @@ describe("the GitLab merge-request pipeline", () => {
|
|
|
119
119
|
expect(apply.cache).toEqual({ key: "chant-apply.network.prod-$CI_COMMIT_SHA", paths: ["infra/network/.chant/pr-resume/pr-apply.json"], when: "always" });
|
|
120
120
|
});
|
|
121
121
|
});
|
|
122
|
+
|
|
123
|
+
describe("the merge-request pipeline's branch (#3765)", () => {
|
|
124
|
+
test("a branch that would break the rules: expression is refused, as an Op's is", () => {
|
|
125
|
+
expect(() => generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { branch: "re$lease" } })).toThrow(/filters on branch "re\$lease"/);
|
|
126
|
+
});
|
|
127
|
+
});
|