@intentius/chant-lexicon-gitlab 0.124.55 → 0.124.56
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.map +1 -1
- package/dist/components/generate-op-pipeline.js +44 -5
- package/dist/components/generate-op-waves-pipeline.d.ts +13 -2
- package/dist/components/generate-op-waves-pipeline.d.ts.map +1 -1
- package/dist/components/generate-op-waves-pipeline.js +53 -9
- package/dist/components/generate-pipeline.d.ts.map +1 -1
- package/dist/components/generate-pipeline.js +27 -5
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/package.json +3 -3
- package/src/components/__golden__/op-waves-jobs.yml +11 -1
- package/src/components/generate-op-pipeline.cloud.test.ts +40 -0
- package/src/components/generate-op-pipeline.ts +44 -5
- package/src/components/generate-op-waves-pipeline.approval.test.ts +28 -0
- package/src/components/generate-op-waves-pipeline.control.test.ts +1 -1
- package/src/components/generate-op-waves-pipeline.guard.test.ts +63 -0
- package/src/components/generate-op-waves-pipeline.test.ts +5 -3
- package/src/components/generate-op-waves-pipeline.ts +56 -8
- package/src/components/generate-pipeline.ts +29 -3
- package/src/components/generate-pr-pipeline.comment-targets.test.ts +20 -0
- package/src/components/generate-pr-pipeline.respond.test.ts +27 -0
- package/src/components/generate-pr-pipeline.runners.test.ts +41 -0
- package/src/components/generate-pr-pipeline.stale-notes.test.ts +24 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"generate-op-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkHG;AAiBH,OAAO,KAAK,EACV,wBAAwB,IAAI,uBAAuB,EAInD,gBAAgB,IAAI,sBAAsB,EAE1C,eAAe,EAChB,MAAM,0BAA0B,CAAC;
|
|
1
|
+
{"version":3,"file":"generate-op-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkHG;AAiBH,OAAO,KAAK,EACV,wBAAwB,IAAI,uBAAuB,EAInD,gBAAgB,IAAI,sBAAsB,EAE1C,eAAe,EAChB,MAAM,0BAA0B,CAAC;AAUlC,YAAY,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,CAAC;AA4HhE;;;;GAIG;AACH,wBAAgB,wBAAwB,CACtC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,GACpC,sBAAsB,CA6OxB"}
|
|
@@ -120,6 +120,8 @@ import { assertReviewSplit, GITLAB_REVIEW_NOTES_VAR, gitlabReviewJobs, REVIEW_DI
|
|
|
120
120
|
import { AGENT_DIR, resolveAgentSpec } from "@intentius/chant/ci-agent";
|
|
121
121
|
import { activeProductWords } from "@intentius/chant/ci-product-words";
|
|
122
122
|
import { gitlabAgentHeader, gitlabAgentJobs, gitlabAgentPushJobName, gitlabDriftArtifacts, gitlabDriftRunLines } from "./agent-jobs.js";
|
|
123
|
+
import { assertScheduledOpCloud, scheduledOpUpdateJobName } from "@intentius/chant/scheduled-op-cloud";
|
|
124
|
+
import { gitlabTradeIdTokens, gitlabTradeLines, TRADE_SPEC_ENV } from "@intentius/chant/ci-identity";
|
|
123
125
|
/**
|
|
124
126
|
* Default stage every generated job runs in, and the default base of the
|
|
125
127
|
* generated file's name (#2293). Overridable via `options.opsStage` (and the
|
|
@@ -247,6 +249,11 @@ export function generateGitlabOpPipeline(ops, options = {}) {
|
|
|
247
249
|
const words = activeProductWords();
|
|
248
250
|
for (const spec of ops) {
|
|
249
251
|
const subject = `Scheduled Op "${spec.name}"`;
|
|
252
|
+
// #4631: the job's cloud identities and the update on drift, checked before anything renders.
|
|
253
|
+
assertScheduledOpCloud(spec);
|
|
254
|
+
if (spec.update?.permissions && Object.keys(spec.update.permissions).length > 0) {
|
|
255
|
+
throw new Error(`${subject}: update.permissions has no GitLab form; a GitLab job's token scopes are the project's (contents: write included: whether a job token may push is a project setting), and its cloud identity is update.cloud.`);
|
|
256
|
+
}
|
|
250
257
|
// #4616: GitLab picks a runner by tags.
|
|
251
258
|
assertRunnerLabels(subject, "runner", spec.runner, "GitLab");
|
|
252
259
|
const tags = gitlabTags(spec.runner);
|
|
@@ -273,7 +280,10 @@ export function generateGitlabOpPipeline(ops, options = {}) {
|
|
|
273
280
|
noteJobs.push(pushName);
|
|
274
281
|
continue;
|
|
275
282
|
}
|
|
276
|
-
const
|
|
283
|
+
const declaredIdTokens = gitlabIdTokens(spec.name, spec.permissions);
|
|
284
|
+
// #4631: a cloud trade takes an id token per cloud, beside the ones the spec declares.
|
|
285
|
+
const idTokens = spec.cloud ? { ...(declaredIdTokens ?? {}), ...gitlabTradeIdTokens(spec.cloud) } : declaredIdTokens;
|
|
286
|
+
const update = spec.update;
|
|
277
287
|
const findingMode = spec.findingMode ?? "report";
|
|
278
288
|
const trigger = resolveOpTrigger(spec);
|
|
279
289
|
assertTriggerSupportsMode(spec.name, findingMode, trigger);
|
|
@@ -313,6 +323,7 @@ export function generateGitlabOpPipeline(ops, options = {}) {
|
|
|
313
323
|
// than a caller being able to repoint the gate block's own artifact path.
|
|
314
324
|
const jobVariables = {
|
|
315
325
|
...(spec.variables ?? {}),
|
|
326
|
+
...(spec.cloud ? { [TRADE_SPEC_ENV]: JSON.stringify(spec.cloud) } : {}),
|
|
316
327
|
...(gated ? { [GATE_SUMMARY_VAR]: gateSummary } : {}),
|
|
317
328
|
};
|
|
318
329
|
const rulesFor = changes && trigger.kind !== "cron" ? rules.map((r) => ({ ...r, changes })) : rules;
|
|
@@ -335,7 +346,10 @@ export function generateGitlabOpPipeline(ops, options = {}) {
|
|
|
335
346
|
noteJobs.push(noteName);
|
|
336
347
|
continue;
|
|
337
348
|
}
|
|
338
|
-
|
|
349
|
+
// #4631: an update follows a drift check the same way the drift agent does, on the dotenv the run leaves.
|
|
350
|
+
const flagsDrift = driftAgent !== undefined || update !== undefined;
|
|
351
|
+
const runLines = flagsDrift ? gitlabDriftRunLines(invocation.join(" ")) : [invocation.join(" ")];
|
|
352
|
+
const driftDir = member ? memberRepoPath(member, AGENT_DIR) : AGENT_DIR;
|
|
339
353
|
doc[jobName] = {
|
|
340
354
|
stage,
|
|
341
355
|
image,
|
|
@@ -347,17 +361,42 @@ export function generateGitlabOpPipeline(ops, options = {}) {
|
|
|
347
361
|
...(spec.environment ? { environment: environmentDoc(spec.environment) } : {}),
|
|
348
362
|
...nonEmpty("variables", jobVariables),
|
|
349
363
|
rules: rulesFor,
|
|
350
|
-
|
|
364
|
+
// #4631: the trade first, before any line of the checkout's code runs, and the token variables unset by it.
|
|
365
|
+
script: [...(spec.cloud ? gitlabTradeLines() : []), ...cdLine, ...setupScript, ...beforeScript, ...runLines, ...extraScript],
|
|
351
366
|
// `when: always` because the run this publishes for is the green one: a
|
|
352
367
|
// gated apply succeeds, and the block is the only thing that says a
|
|
353
368
|
// human still has to act. A run that walked through its gate writes no
|
|
354
369
|
// block, and GitLab reports the empty upload as a warning, not a failure.
|
|
355
370
|
...(gated
|
|
356
371
|
? { artifacts: { when: "always", paths: [member ? memberRepoPath(member, gateSummary) : gateSummary], expire_in: GATE_ARTIFACT_EXPIRY } }
|
|
357
|
-
:
|
|
358
|
-
? { artifacts: gitlabDriftArtifacts(
|
|
372
|
+
: flagsDrift
|
|
373
|
+
? { artifacts: gitlabDriftArtifacts(driftDir) }
|
|
359
374
|
: {}),
|
|
360
375
|
};
|
|
376
|
+
if (update) {
|
|
377
|
+
// #4631: the update's own job, which alone holds the update's identity, after a run that found drift.
|
|
378
|
+
const name = scheduledOpUpdateJobName(jobName);
|
|
379
|
+
opLines.push(`# update: ${name} runs \`${update.command}\` when ${jobName} exits with chant's drift code${update.cloud ? ", holding its own cloud identity" : ""}`);
|
|
380
|
+
doc[name] = {
|
|
381
|
+
stage,
|
|
382
|
+
image,
|
|
383
|
+
...(tags ? { tags } : {}),
|
|
384
|
+
needs: [{ job: jobName, artifacts: true }],
|
|
385
|
+
...(update.cloud ? { id_tokens: gitlabTradeIdTokens(update.cloud) } : {}),
|
|
386
|
+
...(update.environment ? { environment: environmentDoc(update.environment) } : {}),
|
|
387
|
+
...nonEmpty("variables", { ...(update.variables ?? {}), ...(update.cloud ? { [TRADE_SPEC_ENV]: JSON.stringify(update.cloud) } : {}) }),
|
|
388
|
+
rules: rulesFor.map((r) => ({ ...r, when: "always" })),
|
|
389
|
+
script: [
|
|
390
|
+
`if [ "\${CHANT_AGENT_DRIFT:-0}" != "1" ]; then echo "no drift, so the update does not run"; exit 0; fi`,
|
|
391
|
+
...(update.cloud ? gitlabTradeLines() : []),
|
|
392
|
+
...cdLine,
|
|
393
|
+
...setupScript,
|
|
394
|
+
...beforeScript,
|
|
395
|
+
update.command,
|
|
396
|
+
],
|
|
397
|
+
};
|
|
398
|
+
noteJobs.push(name);
|
|
399
|
+
}
|
|
361
400
|
if (driftAgent) {
|
|
362
401
|
const name = `${jobName}-agent`;
|
|
363
402
|
opLines.push(...gitlabAgentHeader(name, driftAgent, spec.agent?.tokenEnv));
|
|
@@ -24,8 +24,9 @@
|
|
|
24
24
|
* arrived, with `CHANT_FORGE_TOKEN` (a project access token with the `api`
|
|
25
25
|
* scope and the Developer role; the job token cannot retry jobs).
|
|
26
26
|
*
|
|
27
|
-
* A `pr-review` wave (#3684)
|
|
28
|
-
* pipelines only, which records
|
|
27
|
+
* A `pr-review` wave (#3684), or a `pull-request` wave (#4630), adds
|
|
28
|
+
* `<name>-record-plans`, run in merge request pipelines only, which records
|
|
29
|
+
* the digests the merge request's head plans.
|
|
29
30
|
*
|
|
30
31
|
* `runner` (on the spec, a wave or a job group, #3914) is the job's `tags:`; a
|
|
31
32
|
* GitHub runner group is a spec error here. A wave's `jobs` renders its
|
|
@@ -49,9 +50,19 @@
|
|
|
49
50
|
* unsetting the control repo's token variables and the product's two
|
|
50
51
|
* secret names; the dispatch step reads the token from the product's
|
|
51
52
|
* variable alone and hands it to curl on its stdin (#4568).
|
|
53
|
+
*
|
|
54
|
+
* Each wave job's script starts with the apply-job guard (#4624,
|
|
55
|
+
* `@intentius/chant/ci-job-guard`), before the trade: it stops the job
|
|
56
|
+
* unless `CI_COMMIT_REF_NAME` is one of the spec's branches and protected,
|
|
57
|
+
* `CI_ENVIRONMENT_NAME` is the job's environment, and `CI_RUNNER_TAGS`
|
|
58
|
+
* holds each of the job's tags. A refused guard exits chant's `misplaced`
|
|
59
|
+
* (9), outside the job's allowed failure, so the job fails and the waves
|
|
60
|
+
* after it stop.
|
|
52
61
|
*/
|
|
53
62
|
import { type OpWavesSpec } from "@intentius/chant/op/op-waves";
|
|
54
63
|
import type { OpWavesPipelineOptions, OpWavesPipelineResult } from "@intentius/chant/lexicon";
|
|
64
|
+
/** The one exit an op-wave job allows as a failure (#4322): a run that failed. The guard's refusal (9) is outside it. */
|
|
65
|
+
export declare const OP_WAVE_ALLOWED_EXIT: 1;
|
|
55
66
|
/** Render an Op waves spec as one GitLab CI file, `<name>.gitlab-ci.yml` unless `opsFileName` names another. */
|
|
56
67
|
export declare function generateGitlabOpWavesPipeline(spec: OpWavesSpec, options: OpWavesPipelineOptions): OpWavesPipelineResult;
|
|
57
68
|
//# sourceMappingURL=generate-op-waves-pipeline.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"generate-op-waves-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-waves-pipeline.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"generate-op-waves-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-waves-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4DG;AAkBH,OAAO,EAYL,KAAK,WAAW,EACjB,MAAM,8BAA8B,CAAC;AAEtC,OAAO,KAAK,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,0BAA0B,CAAC;AAO9F,yHAAyH;AACzH,eAAO,MAAM,oBAAoB,GAAoB,CAAC;AAEtD,gHAAgH;AAChH,wBAAgB,6BAA6B,CAAC,IAAI,EAAE,WAAW,EAAE,OAAO,EAAE,sBAAsB,GAAG,qBAAqB,CAqKvH"}
|
|
@@ -24,8 +24,9 @@
|
|
|
24
24
|
* arrived, with `CHANT_FORGE_TOKEN` (a project access token with the `api`
|
|
25
25
|
* scope and the Developer role; the job token cannot retry jobs).
|
|
26
26
|
*
|
|
27
|
-
* A `pr-review` wave (#3684)
|
|
28
|
-
* pipelines only, which records
|
|
27
|
+
* A `pr-review` wave (#3684), or a `pull-request` wave (#4630), adds
|
|
28
|
+
* `<name>-record-plans`, run in merge request pipelines only, which records
|
|
29
|
+
* the digests the merge request's head plans.
|
|
29
30
|
*
|
|
30
31
|
* `runner` (on the spec, a wave or a job group, #3914) is the job's `tags:`; a
|
|
31
32
|
* GitHub runner group is a spec error here. A wave's `jobs` renders its
|
|
@@ -49,12 +50,25 @@
|
|
|
49
50
|
* unsetting the control repo's token variables and the product's two
|
|
50
51
|
* secret names; the dispatch step reads the token from the product's
|
|
51
52
|
* variable alone and hands it to curl on its stdin (#4568).
|
|
53
|
+
*
|
|
54
|
+
* Each wave job's script starts with the apply-job guard (#4624,
|
|
55
|
+
* `@intentius/chant/ci-job-guard`), before the trade: it stops the job
|
|
56
|
+
* unless `CI_COMMIT_REF_NAME` is one of the spec's branches and protected,
|
|
57
|
+
* `CI_ENVIRONMENT_NAME` is the job's environment, and `CI_RUNNER_TAGS`
|
|
58
|
+
* holds each of the job's tags. A refused guard exits chant's `misplaced`
|
|
59
|
+
* (9), outside the job's allowed failure, so the job fails and the waves
|
|
60
|
+
* after it stop.
|
|
52
61
|
*/
|
|
53
62
|
import { assertGitlabBranch, assertGitlabEnvironment, assertRunnerLabels, CI_IMAGE, environmentDoc, GITLAB_FULL_HISTORY, GITLAB_MERGE_REQUEST_SOURCE, GITLAB_SCHEDULE_SOURCE, gitlabIdTokensDoc, gitlabPushRule, gitlabSetupScript, gitlabTags, nonEmpty, pushBranches, } from "@intentius/chant/ci-jobs";
|
|
54
|
-
import { opWaveGroupOf, opWaveJobs, opWaveOutcomeDir, opWavesControlTarget, opWavesLastJobs, opWavesOutcomeCommand, opWavesRecordPlansCommand, opWavesResumeCommand, } from "@intentius/chant/op/op-waves";
|
|
63
|
+
import { opWaveGroupOf, opWaveJobs, opWaveOutcomeDir, opWavesControlTarget, opWavesLastJobs, OP_WAVES_APPROVAL_STATUS_VAR, opWavesApprovalStatusCommand, opWavesPostsApprovalStatus, opWavesOutcomeCommand, opWavesRecordPlansCommand, opWavesResumeCommand, } from "@intentius/chant/op/op-waves";
|
|
55
64
|
import { CONTROL_OUTCOME_VAR, CONTROL_TOKEN_VARS, controlDispatchJob } from "@intentius/chant/control/dispatch";
|
|
56
65
|
import { gitlabTradeIdTokens, gitlabTradeLines, TRADE_SPEC_ENV } from "@intentius/chant/ci-identity";
|
|
66
|
+
import { JOB_GUARD_ENV, jobGuardLine, jobGuardSpec, opWaveJobLabel } from "@intentius/chant/ci-job-guard";
|
|
67
|
+
import { CHANT_EXIT } from "@intentius/chant/exit-codes";
|
|
68
|
+
import { resolveProductWords } from "@intentius/chant/ci-product-words";
|
|
57
69
|
import { emitGitlabFile } from "./ci-jobs.js";
|
|
70
|
+
/** The one exit an op-wave job allows as a failure (#4322): a run that failed. The guard's refusal (9) is outside it. */
|
|
71
|
+
export const OP_WAVE_ALLOWED_EXIT = CHANT_EXIT.failed;
|
|
58
72
|
/** Render an Op waves spec as one GitLab CI file, `<name>.gitlab-ci.yml` unless `opsFileName` names another. */
|
|
59
73
|
export function generateGitlabOpWavesPipeline(spec, options) {
|
|
60
74
|
const jobs = opWaveJobs(spec, options.specFile, { bin: options.bin });
|
|
@@ -82,7 +96,9 @@ export function generateGitlabOpWavesPipeline(spec, options) {
|
|
|
82
96
|
}
|
|
83
97
|
}
|
|
84
98
|
const specTags = gitlabTags(spec.runner);
|
|
85
|
-
const
|
|
99
|
+
const product = resolveProductWords(options.words ?? {}).product;
|
|
100
|
+
// #4630: a pr-review or pull-request wave records each head's plans and posts the approval status.
|
|
101
|
+
const byReview = opWavesPostsApprovalStatus(spec);
|
|
86
102
|
const stages = [...(byReview ? ["plans"] : []), ...spec.waves.map((wave, i) => `wave-${i + 1}-${wave.name}`)];
|
|
87
103
|
// #4617: the control repo's token variables, which every job but the dispatch drops first.
|
|
88
104
|
const control = spec.control ? opWavesControlTarget(spec.control) : undefined;
|
|
@@ -107,6 +123,18 @@ export function generateGitlabOpWavesPipeline(spec, options) {
|
|
|
107
123
|
script: [...dropControl, ...(options.beforeScript ?? []), opWavesRecordPlansCommand(options.specFile, options.bin).join(" ")],
|
|
108
124
|
},
|
|
109
125
|
]);
|
|
126
|
+
// #4630: a GitLab approval starts no pipeline, so a schedule of the target branch posts every open merge request's status.
|
|
127
|
+
entries.push([
|
|
128
|
+
`${spec.name}-approval-status`,
|
|
129
|
+
{
|
|
130
|
+
stage: "plans",
|
|
131
|
+
image,
|
|
132
|
+
...(specTags ? { tags: specTags } : {}),
|
|
133
|
+
variables: { ...GITLAB_FULL_HISTORY },
|
|
134
|
+
rules: [{ if: `${GITLAB_SCHEDULE_SOURCE} && $${OP_WAVES_APPROVAL_STATUS_VAR} == "1"` }],
|
|
135
|
+
script: [...dropControl, ...(options.beforeScript ?? []), opWavesApprovalStatusCommand(options.specFile, "gitlab", options.bin).join(" ")],
|
|
136
|
+
},
|
|
137
|
+
]);
|
|
110
138
|
}
|
|
111
139
|
for (const job of jobs) {
|
|
112
140
|
const wave = spec.waves[job.wave - 1];
|
|
@@ -118,6 +146,16 @@ export function generateGitlabOpWavesPipeline(spec, options) {
|
|
|
118
146
|
const cloud = group?.cloud ?? wave.cloud;
|
|
119
147
|
const declared = gitlabIdTokensDoc(group?.idTokens ?? wave.idTokens);
|
|
120
148
|
const idTokens = cloud ? { ...declared, ...gitlabTradeIdTokens(cloud) } : declared;
|
|
149
|
+
// #4624: the apply-job guard, the script's first line, before the trade.
|
|
150
|
+
const runner = group?.runner ?? wave.runner ?? spec.runner;
|
|
151
|
+
const guard = jobGuardSpec({
|
|
152
|
+
forge: "gitlab",
|
|
153
|
+
job: opWaveJobLabel(spec.name, job),
|
|
154
|
+
product,
|
|
155
|
+
...(environment ? { environment: environment.name } : {}),
|
|
156
|
+
branches: pushBranches(spec.branches),
|
|
157
|
+
...(runner !== undefined ? { runner } : {}),
|
|
158
|
+
});
|
|
121
159
|
entries.push([
|
|
122
160
|
job.jobName,
|
|
123
161
|
{
|
|
@@ -127,12 +165,13 @@ export function generateGitlabOpWavesPipeline(spec, options) {
|
|
|
127
165
|
resource_group: `${spec.name}-${wave.name}`,
|
|
128
166
|
...(environment ? { environment: environmentDoc(environment) } : {}),
|
|
129
167
|
...(idTokens ? { id_tokens: idTokens } : {}),
|
|
130
|
-
variables: { ...GITLAB_FULL_HISTORY, ...(wave.variables ?? {}), ...(group?.variables ?? {}), ...(cloud ? { [TRADE_SPEC_ENV]: JSON.stringify(cloud) } : {}) },
|
|
168
|
+
variables: { ...GITLAB_FULL_HISTORY, ...(wave.variables ?? {}), ...(group?.variables ?? {}), ...(cloud ? { [TRADE_SPEC_ENV]: JSON.stringify(cloud) } : {}), [JOB_GUARD_ENV]: JSON.stringify(guard) },
|
|
131
169
|
rules,
|
|
132
170
|
...(job.needs.length > 0 ? { needs: job.needs } : {}),
|
|
133
|
-
script: [...(cloud ? gitlabTradeLines() : []), ...dropControl, ...setup, ...(options.beforeScript ?? []), job.command.join(" "), ...(options.extraScript ?? [])],
|
|
134
|
-
// #4322: a run that failed, or was blocked, lets the next wave run; any other failure stops it
|
|
135
|
-
|
|
171
|
+
script: [jobGuardLine(), ...(cloud ? gitlabTradeLines() : []), ...dropControl, ...setup, ...(options.beforeScript ?? []), job.command.join(" "), ...(options.extraScript ?? [])],
|
|
172
|
+
// #4322: a run that failed, or was blocked, lets the next wave run; any other failure stops it,
|
|
173
|
+
// a refused guard (9, #4624) included.
|
|
174
|
+
allow_failure: { exit_codes: [OP_WAVE_ALLOWED_EXIT] },
|
|
136
175
|
artifacts: { when: "always", paths: [...(job.kind === "decide" ? [job.record] : []), `${opWaveOutcomeDir(spec)}/`], expire_in: "30 days" },
|
|
137
176
|
},
|
|
138
177
|
]);
|
|
@@ -188,10 +227,15 @@ export function generateGitlabOpWavesPipeline(spec, options) {
|
|
|
188
227
|
`# chant Op waves "${spec.name}": one stage per wave, each needing the one before.`,
|
|
189
228
|
"# A wave that waits at its gate exits 3 and stops the waves after it; approve the",
|
|
190
229
|
"# digest it prints, then retry the job. A job whose runs failed exits 1, an allowed",
|
|
191
|
-
"# failure, and the waves after it run what does not depend on those runs.",
|
|
230
|
+
"# failure, and the waves after it run what does not depend on those runs. A job",
|
|
231
|
+
"# whose guard finds it running where the spec did not set it to run exits 9 and",
|
|
232
|
+
"# stops the waves after it.",
|
|
192
233
|
...(spec.resume
|
|
193
234
|
? [`# The ${spec.name}-resume job retries it for you in a pipeline schedule; give the schedule the cron "${spec.resume.schedule}".`]
|
|
194
235
|
: []),
|
|
236
|
+
...(byReview
|
|
237
|
+
? [`# The ${spec.name}-approval-status job posts each open merge request's approval status from a pipeline schedule that sets ${OP_WAVES_APPROVAL_STATUS_VAR}=1.`]
|
|
238
|
+
: []),
|
|
195
239
|
...(control ? [`# After the last wave, the control-dispatch job tells ${control.repo} how the waves ended.`] : []),
|
|
196
240
|
];
|
|
197
241
|
const name = options.opsFileName ?? `${spec.name}.gitlab-ci.yml`;
|
|
@@ -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;AAaH,OAAO,EAAyB,KAAK,eAAe,EAAE,MAAM,oCAAoC,CAAC;AAkCjG,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"}
|
|
@@ -31,11 +31,11 @@
|
|
|
31
31
|
* demonstration: one generator-option edit reflects in every job without
|
|
32
32
|
* touching the component declarations.
|
|
33
33
|
*/
|
|
34
|
-
import { assertGitlabBranch, CI_IMAGE, ciJobName, fillCommand, GITLAB_FULL_HISTORY, GITLAB_MERGE_REQUEST_SOURCE, gitlabPushRule, } from "@intentius/chant/ci-jobs";
|
|
34
|
+
import { assertGitlabBranch, assertRunnerLabels, CI_IMAGE, ciJobName, fillCommand, GITLAB_FULL_HISTORY, GITLAB_MERGE_REQUEST_SOURCE, gitlabPushRule, gitlabTags, } from "@intentius/chant/ci-jobs";
|
|
35
35
|
import { resolveComponentGraph } from "@intentius/chant/components/driver";
|
|
36
36
|
import { hasPublishStep, promoteArchivePaths } from "@intentius/chant/components/promote";
|
|
37
37
|
import { GATED_WAVE_RECORD, gatedWaveJobs } from "@intentius/chant/components/gated-wave-pipeline";
|
|
38
|
-
import { PR_APPLY_RECORD, PR_LOOP_REPORT_DIR, prApplyGroup, prRecordGroup, prApplyRecordKey, prApplyWaveJobName, prLoopJobs, GITLAB_APPLY_PR_VAR, GITLAB_PLAN_NOTES_VAR, gitlabPlanJobName, prLoopPostJob, prLoopSetup, PR_PLAN_NOTE_JOB, prLoopWaveJobs, prLoopWithWaves, prEnvJobs, prCommentLockJobs, prCommentPreviewJob, PR_ENV_REPORT_DIR, PR_ENV_POST_JOB, } from "@intentius/chant/components/pr-pipeline";
|
|
38
|
+
import { PR_APPLY_RECORD, PR_LOOP_REPORT_DIR, prApplyGroup, prRecordGroup, prApplyRecordKey, prApplyWaveJobName, prLoopJobs, GITLAB_APPLY_PR_VAR, GITLAB_PLAN_NOTES_VAR, gitlabPlanJobName, prLoopPostJob, prLoopSetup, PR_PLAN_NOTE_JOB, prLoopWaveJobs, prLoopWithWaves, prEnvJobs, prCommentLockJobs, prCommentPreviewJob, PR_ENV_REPORT_DIR, PR_ENV_POST_JOB, PR_STALE_JOB, prStaleJob, prLoopJobStage, prLoopRunner, } from "@intentius/chant/components/pr-pipeline";
|
|
39
39
|
import { REPO_APPLY_GROUP } from "@intentius/chant/components/repo-lock";
|
|
40
40
|
import { memberGitlabChanges, memberRepoPath, memberShellDir } from "@intentius/chant/lexicon";
|
|
41
41
|
import { FORGE_TOKEN_VARS, GITLAB_DROP_FORGE_TOKENS } from "@intentius/chant/ci-handover";
|
|
@@ -374,9 +374,12 @@ function prLoopGitlabPipeline(components, env, image, beforeScript, extraScript,
|
|
|
374
374
|
const previewRule = preview ? `$CI_PIPELINE_SOURCE =~ /^(web|api|trigger)$/ && $${preview.gitlabVar} =~ /^[0-9]+$/ && $CI_COMMIT_BRANCH == ${targetBranch}` : undefined;
|
|
375
375
|
const previewName = preview ? named(preview.jobName) : undefined;
|
|
376
376
|
const previewNoteName = preview ? named(preview.noteJobName) : undefined;
|
|
377
|
-
|
|
377
|
+
// #4628: a push to the target branch marks the open merge requests' notes posted against another commit stale.
|
|
378
|
+
const stale = prStaleJob("gitlab", env, loop, member?.name);
|
|
379
|
+
const staleName = stale ? named(PR_STALE_JOB) : undefined;
|
|
380
|
+
const stages = ["plan", PR_PLAN_NOTE_JOB, ...(stale ? [PR_STALE_JOB] : []), ...commentLocks.map((c) => c.job.jobName), ...(preview ? [preview.jobName, preview.noteJobName] : []), ...applies.map((a) => a.stage), ...(control ? [control.name] : []), ...(prEnv ? [PR_ENV_STAGE, PR_ENV_POST_STAGE] : [])];
|
|
378
381
|
const noteJob = named(PR_PLAN_NOTE_JOB);
|
|
379
|
-
const post = prLoopPostJob("gitlab", env, member?.name, loop.words ? { ...(loop.words.bin ? { bin: loop.words.bin } : {}) } : undefined);
|
|
382
|
+
const post = prLoopPostJob("gitlab", env, member?.name, loop.words ? { ...(loop.words.bin ? { bin: loop.words.bin } : {}) } : undefined, { ...(loop.staleNotes ? { staleNotes: true } : {}), ...(loop.respond ? { respond: loop.respond } : {}), ...(loop.decide ? { decide: loop.decide } : {}) });
|
|
380
383
|
// #4619: with a control repo named, the note job reads its chant/lifecycle, read-only, so pr-post marks the cross-stack edges.
|
|
381
384
|
const controlRead = loop.control ? controlNoteCheckout("gitlab", loop.control.target, loop.words ?? {}) : undefined;
|
|
382
385
|
const doc = {
|
|
@@ -401,8 +404,15 @@ function prLoopGitlabPipeline(components, env, image, beforeScript, extraScript,
|
|
|
401
404
|
stage: PR_PLAN_NOTE_JOB,
|
|
402
405
|
image,
|
|
403
406
|
rules: [{ if: onPlanNotes }],
|
|
407
|
+
...(Object.keys(post.env).length > 0 ? { variables: post.env } : {}),
|
|
404
408
|
script: [...enter, ...beforeScript, ...(controlRead ? [controlRead.script] : []), post.command],
|
|
405
409
|
},
|
|
410
|
+
...(stale && staleName
|
|
411
|
+
? {
|
|
412
|
+
// The pushed commit of the target branch: none of a merge request's code is here.
|
|
413
|
+
[staleName]: { stage: PR_STALE_JOB, image, rules: [{ if: onMerge }], variables: stale.env, script: [...enter, ...beforeScript, stale.command] },
|
|
414
|
+
}
|
|
415
|
+
: {}),
|
|
406
416
|
...Object.fromEntries(commentLocks.map(({ job, name, rule }) => [
|
|
407
417
|
name,
|
|
408
418
|
// The target branch's commit, so none of the merge request's code runs here.
|
|
@@ -496,6 +506,7 @@ function prLoopGitlabPipeline(components, env, image, beforeScript, extraScript,
|
|
|
496
506
|
const jobs = [
|
|
497
507
|
{ jobName: planJob, component: "merge request plan", stage: "plan", needs: [] },
|
|
498
508
|
{ jobName: noteJob, component: "merge request plan note", stage: PR_PLAN_NOTE_JOB, needs: [] },
|
|
509
|
+
...(stale && staleName ? [{ jobName: staleName, component: "merge request stale notes", stage: PR_STALE_JOB, needs: [] }] : []),
|
|
499
510
|
...commentLocks.map(({ job, name }) => ({ jobName: name, component: `merge request ${job.command} comment`, stage: job.jobName, needs: [] })),
|
|
500
511
|
...(preview && previewName && previewNoteName
|
|
501
512
|
? [
|
|
@@ -512,7 +523,18 @@ function prLoopGitlabPipeline(components, env, image, beforeScript, extraScript,
|
|
|
512
523
|
...(controlJob ? [{ jobName: controlJob, component: "control repo dispatch", stage: control.name, needs: [lastGreen] }] : []),
|
|
513
524
|
...(prEnv ? prEnv.listed : []),
|
|
514
525
|
];
|
|
515
|
-
|
|
526
|
+
// #4625: each job on its stage's runner tags, the plan side's, the apply side's, or the default.
|
|
527
|
+
if (loop.runners) {
|
|
528
|
+
for (const stage of ["plan", "apply", "default"])
|
|
529
|
+
assertRunnerLabels("The merge-request pipeline", `runners.${stage}`, loop.runners[stage], "GitLab");
|
|
530
|
+
for (const job of jobs) {
|
|
531
|
+
const tags = gitlabTags(prLoopRunner(loop, prLoopJobStage(job.jobName, member?.name)));
|
|
532
|
+
const entry = doc[job.jobName];
|
|
533
|
+
if (tags && entry)
|
|
534
|
+
doc[job.jobName] = { ...entry, tags };
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
const yaml = emitGitlabFile(["workflow", "stages", "variables", planJob, noteJob, ...(staleName ? [staleName] : []), ...commentLocks.map((c) => c.name), ...(previewName && previewNoteName ? [previewName, previewNoteName] : []), ...applies.map((a) => a.name), ...(controlJob ? [controlJob] : []), ...Object.keys(prEnv?.jobs ?? {})].map((key) => [key, doc[key]]));
|
|
516
538
|
return { yaml, stages, jobs, env };
|
|
517
539
|
}
|
|
518
540
|
/** The stage a pull request environment's jobs run in (#4298). */
|
package/dist/integrity.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"algorithm": "sha256",
|
|
3
3
|
"artifacts": {
|
|
4
|
-
"manifest.json": "
|
|
4
|
+
"manifest.json": "601276a64d47a1758b2b6e26d232c5f746ec007b05950d993fc37ef4f09e2250",
|
|
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": "9ed920259d4f54b7734a8887fd98ea4e6357311cde69b7a84197365dc95505b5"
|
|
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.56",
|
|
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.56",
|
|
71
|
+
"@intentius/chant-lexicon-github": "^0.124.56"
|
|
72
72
|
},
|
|
73
73
|
"peerDependenciesMeta": {
|
|
74
74
|
"@intentius/chant-lexicon-github": {
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
# chant Op waves "migrations": one stage per wave, each needing the one before.
|
|
2
2
|
# A wave that waits at its gate exits 3 and stops the waves after it; approve the
|
|
3
3
|
# digest it prints, then retry the job. A job whose runs failed exits 1, an allowed
|
|
4
|
-
# failure, and the waves after it run what does not depend on those runs.
|
|
4
|
+
# failure, and the waves after it run what does not depend on those runs. A job
|
|
5
|
+
# whose guard finds it running where the spec did not set it to run exits 9 and
|
|
6
|
+
# stops the waves after it.
|
|
5
7
|
|
|
6
8
|
stages:
|
|
7
9
|
- wave-1-dev
|
|
@@ -16,9 +18,11 @@ wave-1-dev:
|
|
|
16
18
|
resource_group: migrations-dev
|
|
17
19
|
variables:
|
|
18
20
|
GIT_DEPTH: '0'
|
|
21
|
+
CHANT_JOB_GUARD: '{"forge":"gitlab","job":"wave 1 (dev) of migrations","branches":["main"],"runnerTags":["docker","dev"]}'
|
|
19
22
|
rules:
|
|
20
23
|
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "main"'
|
|
21
24
|
script:
|
|
25
|
+
- '(cd / && node -e ''"use strict";const E=process.env,A="chant-check-job";const shown=(v)=>v===undefined||v===null||v===""?"none":v;const fail=(m)=>{console.error("check-job: "+m);process.exit(9)};let x;try{x=JSON.parse(E.CHANT_JOB_GUARD||"")}catch(e){fail("CHANT_JOB_GUARD is not JSON")}if(!x||!["github","gitlab","forgejo"].includes(x.forge)||!Array.isArray(x.branches))fail("CHANT_JOB_GUARD is not a guard spec");const tags=(r)=>{if(r===undefined)return undefined;const t=r.trim();if(t.startsWith("[")){try{const v=JSON.parse(t);if(Array.isArray(v))return v.map(String)}catch(e){}}return t===""?[]:t.split(",").map((s)=>s.trim()).filter((s)=>s!=="")};const claimsOf=(tok)=>{const p=tok.split(".")[1];if(!p)throw new Error("the forge\u0027s OIDC token is not a JWT");const c=JSON.parse(Buffer.from(p.replace(/-/g,"+").replace(/_/g,"/"),"base64").toString("utf-8"));if(!c||typeof c!=="object")throw new Error("the forge\u0027s OIDC token has no claims");return c};async function seenOf(){if(x.forge==="gitlab")return{forgeEnvironment:E.CI_ENVIRONMENT_NAME||null,ref:E.CI_COMMIT_REF_NAME,refProtected:E.CI_COMMIT_REF_PROTECTED,runnerTags:tags(E.CI_RUNNER_TAGS)};const s={ref:E.GITHUB_REF};let c;if(x.forge==="github"&&x.forgeEnvironment!==undefined){try{if(!E.ACTIONS_ID_TOKEN_REQUEST_URL||!E.ACTIONS_ID_TOKEN_REQUEST_TOKEN)throw new Error("the job may request no OIDC token (no ACTIONS_ID_TOKEN_REQUEST_URL); it needs the id-token: write permission");const r=await fetch(E.ACTIONS_ID_TOKEN_REQUEST_URL+"&audience="+encodeURIComponent(A),{headers:{authorization:"bearer "+E.ACTIONS_ID_TOKEN_REQUEST_TOKEN}});if(!r.ok)throw new Error("the OIDC token request answered "+r.status);const v=(await r.json()).value;if(typeof v!=="string")throw new Error("the OIDC token request answered no token");c=claimsOf(v);s.forgeEnvironment=typeof c.environment==="string"&&c.environment!==""?c.environment:null}catch(e){s.forgeEnvironmentError=e&&e.message?e.message:String(e)}}const run=c&&typeof c.runner_environment==="string"?c.runner_environment:E.RUNNER_ENVIRONMENT;if(run)s.runnerEnvironment=run;return s}seenOf().then((s)=>{const P=[],C=[],S=[];const fe=x.forgeEnvironment;if(x.forge==="forgejo")S.push("forge environment (Forgejo has none)");else if(fe!==undefined){if(s.forgeEnvironmentError!==undefined)P.push("forge environment: expected "+fe+", and it could not be read: "+s.forgeEnvironmentError);else if(s.forgeEnvironment!==fe)P.push("forge environment: expected "+fe+", found "+shown(s.forgeEnvironment));else C.push("forge environment "+fe)}const b=x.branches.filter((v)=>v!=="");if(b.length===0)P.push("ref: the job names no branch to run from");else if(x.forge==="gitlab"){if(!s.ref||!b.includes(s.ref))P.push("ref: expected "+b.join(" or ")+", found "+shown(s.ref));else C.push("ref "+s.ref);if(s.refProtected!=="true")P.push("ref: expected a protected ref (CI_COMMIT_REF_PROTECTED true), found "+shown(s.refProtected));else C.push("protected ref")}else{const refs=b.map((v)=>"refs/heads/"+v);if(!s.ref||!refs.includes(s.ref))P.push("ref: expected "+refs.join(" or ")+", found "+shown(s.ref));else C.push("ref "+s.ref)}if(x.forge==="gitlab"){const w=x.runnerTags||[];if(w.length>0){const h=s.runnerTags;const miss=w.filter((t)=>!(h||[]).includes(t));if(h===undefined)P.push("runner: expected tags "+w.join(", ")+", and the job has no CI_RUNNER_TAGS");else if(miss.length>0)P.push("runner: expected tags "+w.join(", ")+", found "+(h.length?h.join(", "):"none"));else C.push("runner tags "+w.join(", "))}}else if(x.selfHosted){if(s.runnerEnvironment===undefined)S.push("runner (the runner says nothing about itself: no RUNNER_ENVIRONMENT)");else if(s.runnerEnvironment!=="self-hosted")P.push("runner: expected self-hosted, found "+s.runnerEnvironment);else C.push("self-hosted runner")}const at=(x.product||"chant")+" check-job: "+x.job;if(P.length>0){for(const p of P)console.error(at+": "+p);console.error(at+": this job does not run where the spec set it to run, so it stops before any credential is taken or code checked out");process.exit(9)}console.log(at+": "+(C.length?C.join(", "):"nothing to check")+(S.length?"; not checked: "+S.join(", "):""))}).catch((e)=>fail(e&&e.message?e.message:String(e)));'')'
|
|
22
26
|
- chant run wave --spec waves.json --wave 1
|
|
23
27
|
allow_failure:
|
|
24
28
|
exit_codes:
|
|
@@ -40,11 +44,13 @@ wave-2-prod-decide:
|
|
|
40
44
|
aud: plan.example
|
|
41
45
|
variables:
|
|
42
46
|
GIT_DEPTH: '0'
|
|
47
|
+
CHANT_JOB_GUARD: '{"forge":"gitlab","job":"the deciding job of wave 2 (prod) of migrations","branches":["main"],"runnerTags":["plan"]}'
|
|
43
48
|
rules:
|
|
44
49
|
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "main"'
|
|
45
50
|
needs:
|
|
46
51
|
- wave-1-dev
|
|
47
52
|
script:
|
|
53
|
+
- '(cd / && node -e ''"use strict";const E=process.env,A="chant-check-job";const shown=(v)=>v===undefined||v===null||v===""?"none":v;const fail=(m)=>{console.error("check-job: "+m);process.exit(9)};let x;try{x=JSON.parse(E.CHANT_JOB_GUARD||"")}catch(e){fail("CHANT_JOB_GUARD is not JSON")}if(!x||!["github","gitlab","forgejo"].includes(x.forge)||!Array.isArray(x.branches))fail("CHANT_JOB_GUARD is not a guard spec");const tags=(r)=>{if(r===undefined)return undefined;const t=r.trim();if(t.startsWith("[")){try{const v=JSON.parse(t);if(Array.isArray(v))return v.map(String)}catch(e){}}return t===""?[]:t.split(",").map((s)=>s.trim()).filter((s)=>s!=="")};const claimsOf=(tok)=>{const p=tok.split(".")[1];if(!p)throw new Error("the forge\u0027s OIDC token is not a JWT");const c=JSON.parse(Buffer.from(p.replace(/-/g,"+").replace(/_/g,"/"),"base64").toString("utf-8"));if(!c||typeof c!=="object")throw new Error("the forge\u0027s OIDC token has no claims");return c};async function seenOf(){if(x.forge==="gitlab")return{forgeEnvironment:E.CI_ENVIRONMENT_NAME||null,ref:E.CI_COMMIT_REF_NAME,refProtected:E.CI_COMMIT_REF_PROTECTED,runnerTags:tags(E.CI_RUNNER_TAGS)};const s={ref:E.GITHUB_REF};let c;if(x.forge==="github"&&x.forgeEnvironment!==undefined){try{if(!E.ACTIONS_ID_TOKEN_REQUEST_URL||!E.ACTIONS_ID_TOKEN_REQUEST_TOKEN)throw new Error("the job may request no OIDC token (no ACTIONS_ID_TOKEN_REQUEST_URL); it needs the id-token: write permission");const r=await fetch(E.ACTIONS_ID_TOKEN_REQUEST_URL+"&audience="+encodeURIComponent(A),{headers:{authorization:"bearer "+E.ACTIONS_ID_TOKEN_REQUEST_TOKEN}});if(!r.ok)throw new Error("the OIDC token request answered "+r.status);const v=(await r.json()).value;if(typeof v!=="string")throw new Error("the OIDC token request answered no token");c=claimsOf(v);s.forgeEnvironment=typeof c.environment==="string"&&c.environment!==""?c.environment:null}catch(e){s.forgeEnvironmentError=e&&e.message?e.message:String(e)}}const run=c&&typeof c.runner_environment==="string"?c.runner_environment:E.RUNNER_ENVIRONMENT;if(run)s.runnerEnvironment=run;return s}seenOf().then((s)=>{const P=[],C=[],S=[];const fe=x.forgeEnvironment;if(x.forge==="forgejo")S.push("forge environment (Forgejo has none)");else if(fe!==undefined){if(s.forgeEnvironmentError!==undefined)P.push("forge environment: expected "+fe+", and it could not be read: "+s.forgeEnvironmentError);else if(s.forgeEnvironment!==fe)P.push("forge environment: expected "+fe+", found "+shown(s.forgeEnvironment));else C.push("forge environment "+fe)}const b=x.branches.filter((v)=>v!=="");if(b.length===0)P.push("ref: the job names no branch to run from");else if(x.forge==="gitlab"){if(!s.ref||!b.includes(s.ref))P.push("ref: expected "+b.join(" or ")+", found "+shown(s.ref));else C.push("ref "+s.ref);if(s.refProtected!=="true")P.push("ref: expected a protected ref (CI_COMMIT_REF_PROTECTED true), found "+shown(s.refProtected));else C.push("protected ref")}else{const refs=b.map((v)=>"refs/heads/"+v);if(!s.ref||!refs.includes(s.ref))P.push("ref: expected "+refs.join(" or ")+", found "+shown(s.ref));else C.push("ref "+s.ref)}if(x.forge==="gitlab"){const w=x.runnerTags||[];if(w.length>0){const h=s.runnerTags;const miss=w.filter((t)=>!(h||[]).includes(t));if(h===undefined)P.push("runner: expected tags "+w.join(", ")+", and the job has no CI_RUNNER_TAGS");else if(miss.length>0)P.push("runner: expected tags "+w.join(", ")+", found "+(h.length?h.join(", "):"none"));else C.push("runner tags "+w.join(", "))}}else if(x.selfHosted){if(s.runnerEnvironment===undefined)S.push("runner (the runner says nothing about itself: no RUNNER_ENVIRONMENT)");else if(s.runnerEnvironment!=="self-hosted")P.push("runner: expected self-hosted, found "+s.runnerEnvironment);else C.push("self-hosted runner")}const at=(x.product||"chant")+" check-job: "+x.job;if(P.length>0){for(const p of P)console.error(at+": "+p);console.error(at+": this job does not run where the spec set it to run, so it stops before any credential is taken or code checked out");process.exit(9)}console.log(at+": "+(C.length?C.join(", "):"nothing to check")+(S.length?"; not checked: "+S.join(", "):""))}).catch((e)=>fail(e&&e.message?e.message:String(e)));'')'
|
|
48
54
|
- chant run wave --spec waves.json --wave 2 --decide
|
|
49
55
|
allow_failure:
|
|
50
56
|
exit_codes:
|
|
@@ -70,11 +76,13 @@ wave-2-prod-us:
|
|
|
70
76
|
variables:
|
|
71
77
|
GIT_DEPTH: '0'
|
|
72
78
|
REGION: us
|
|
79
|
+
CHANT_JOB_GUARD: '{"forge":"gitlab","job":"job group us of wave 2 (prod) of migrations","forgeEnvironment":"prod-us","branches":["main"],"runnerTags":["us-apply"]}'
|
|
73
80
|
rules:
|
|
74
81
|
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "main"'
|
|
75
82
|
needs:
|
|
76
83
|
- wave-2-prod-decide
|
|
77
84
|
script:
|
|
85
|
+
- '(cd / && node -e ''"use strict";const E=process.env,A="chant-check-job";const shown=(v)=>v===undefined||v===null||v===""?"none":v;const fail=(m)=>{console.error("check-job: "+m);process.exit(9)};let x;try{x=JSON.parse(E.CHANT_JOB_GUARD||"")}catch(e){fail("CHANT_JOB_GUARD is not JSON")}if(!x||!["github","gitlab","forgejo"].includes(x.forge)||!Array.isArray(x.branches))fail("CHANT_JOB_GUARD is not a guard spec");const tags=(r)=>{if(r===undefined)return undefined;const t=r.trim();if(t.startsWith("[")){try{const v=JSON.parse(t);if(Array.isArray(v))return v.map(String)}catch(e){}}return t===""?[]:t.split(",").map((s)=>s.trim()).filter((s)=>s!=="")};const claimsOf=(tok)=>{const p=tok.split(".")[1];if(!p)throw new Error("the forge\u0027s OIDC token is not a JWT");const c=JSON.parse(Buffer.from(p.replace(/-/g,"+").replace(/_/g,"/"),"base64").toString("utf-8"));if(!c||typeof c!=="object")throw new Error("the forge\u0027s OIDC token has no claims");return c};async function seenOf(){if(x.forge==="gitlab")return{forgeEnvironment:E.CI_ENVIRONMENT_NAME||null,ref:E.CI_COMMIT_REF_NAME,refProtected:E.CI_COMMIT_REF_PROTECTED,runnerTags:tags(E.CI_RUNNER_TAGS)};const s={ref:E.GITHUB_REF};let c;if(x.forge==="github"&&x.forgeEnvironment!==undefined){try{if(!E.ACTIONS_ID_TOKEN_REQUEST_URL||!E.ACTIONS_ID_TOKEN_REQUEST_TOKEN)throw new Error("the job may request no OIDC token (no ACTIONS_ID_TOKEN_REQUEST_URL); it needs the id-token: write permission");const r=await fetch(E.ACTIONS_ID_TOKEN_REQUEST_URL+"&audience="+encodeURIComponent(A),{headers:{authorization:"bearer "+E.ACTIONS_ID_TOKEN_REQUEST_TOKEN}});if(!r.ok)throw new Error("the OIDC token request answered "+r.status);const v=(await r.json()).value;if(typeof v!=="string")throw new Error("the OIDC token request answered no token");c=claimsOf(v);s.forgeEnvironment=typeof c.environment==="string"&&c.environment!==""?c.environment:null}catch(e){s.forgeEnvironmentError=e&&e.message?e.message:String(e)}}const run=c&&typeof c.runner_environment==="string"?c.runner_environment:E.RUNNER_ENVIRONMENT;if(run)s.runnerEnvironment=run;return s}seenOf().then((s)=>{const P=[],C=[],S=[];const fe=x.forgeEnvironment;if(x.forge==="forgejo")S.push("forge environment (Forgejo has none)");else if(fe!==undefined){if(s.forgeEnvironmentError!==undefined)P.push("forge environment: expected "+fe+", and it could not be read: "+s.forgeEnvironmentError);else if(s.forgeEnvironment!==fe)P.push("forge environment: expected "+fe+", found "+shown(s.forgeEnvironment));else C.push("forge environment "+fe)}const b=x.branches.filter((v)=>v!=="");if(b.length===0)P.push("ref: the job names no branch to run from");else if(x.forge==="gitlab"){if(!s.ref||!b.includes(s.ref))P.push("ref: expected "+b.join(" or ")+", found "+shown(s.ref));else C.push("ref "+s.ref);if(s.refProtected!=="true")P.push("ref: expected a protected ref (CI_COMMIT_REF_PROTECTED true), found "+shown(s.refProtected));else C.push("protected ref")}else{const refs=b.map((v)=>"refs/heads/"+v);if(!s.ref||!refs.includes(s.ref))P.push("ref: expected "+refs.join(" or ")+", found "+shown(s.ref));else C.push("ref "+s.ref)}if(x.forge==="gitlab"){const w=x.runnerTags||[];if(w.length>0){const h=s.runnerTags;const miss=w.filter((t)=>!(h||[]).includes(t));if(h===undefined)P.push("runner: expected tags "+w.join(", ")+", and the job has no CI_RUNNER_TAGS");else if(miss.length>0)P.push("runner: expected tags "+w.join(", ")+", found "+(h.length?h.join(", "):"none"));else C.push("runner tags "+w.join(", "))}}else if(x.selfHosted){if(s.runnerEnvironment===undefined)S.push("runner (the runner says nothing about itself: no RUNNER_ENVIRONMENT)");else if(s.runnerEnvironment!=="self-hosted")P.push("runner: expected self-hosted, found "+s.runnerEnvironment);else C.push("self-hosted runner")}const at=(x.product||"chant")+" check-job: "+x.job;if(P.length>0){for(const p of P)console.error(at+": "+p);console.error(at+": this job does not run where the spec set it to run, so it stops before any credential is taken or code checked out");process.exit(9)}console.log(at+": "+(C.length?C.join(", "):"nothing to check")+(S.length?"; not checked: "+S.join(", "):""))}).catch((e)=>fail(e&&e.message?e.message:String(e)));'')'
|
|
78
86
|
- echo us
|
|
79
87
|
- chant run wave --spec waves.json --wave 2 --group us
|
|
80
88
|
allow_failure:
|
|
@@ -99,11 +107,13 @@ wave-2-prod-eu:
|
|
|
99
107
|
aud: eu.example
|
|
100
108
|
variables:
|
|
101
109
|
GIT_DEPTH: '0'
|
|
110
|
+
CHANT_JOB_GUARD: '{"forge":"gitlab","job":"job group eu of wave 2 (prod) of migrations","forgeEnvironment":"prod-eu","branches":["main"],"runnerTags":["eu-apply"]}'
|
|
102
111
|
rules:
|
|
103
112
|
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "main"'
|
|
104
113
|
needs:
|
|
105
114
|
- wave-2-prod-decide
|
|
106
115
|
script:
|
|
116
|
+
- '(cd / && node -e ''"use strict";const E=process.env,A="chant-check-job";const shown=(v)=>v===undefined||v===null||v===""?"none":v;const fail=(m)=>{console.error("check-job: "+m);process.exit(9)};let x;try{x=JSON.parse(E.CHANT_JOB_GUARD||"")}catch(e){fail("CHANT_JOB_GUARD is not JSON")}if(!x||!["github","gitlab","forgejo"].includes(x.forge)||!Array.isArray(x.branches))fail("CHANT_JOB_GUARD is not a guard spec");const tags=(r)=>{if(r===undefined)return undefined;const t=r.trim();if(t.startsWith("[")){try{const v=JSON.parse(t);if(Array.isArray(v))return v.map(String)}catch(e){}}return t===""?[]:t.split(",").map((s)=>s.trim()).filter((s)=>s!=="")};const claimsOf=(tok)=>{const p=tok.split(".")[1];if(!p)throw new Error("the forge\u0027s OIDC token is not a JWT");const c=JSON.parse(Buffer.from(p.replace(/-/g,"+").replace(/_/g,"/"),"base64").toString("utf-8"));if(!c||typeof c!=="object")throw new Error("the forge\u0027s OIDC token has no claims");return c};async function seenOf(){if(x.forge==="gitlab")return{forgeEnvironment:E.CI_ENVIRONMENT_NAME||null,ref:E.CI_COMMIT_REF_NAME,refProtected:E.CI_COMMIT_REF_PROTECTED,runnerTags:tags(E.CI_RUNNER_TAGS)};const s={ref:E.GITHUB_REF};let c;if(x.forge==="github"&&x.forgeEnvironment!==undefined){try{if(!E.ACTIONS_ID_TOKEN_REQUEST_URL||!E.ACTIONS_ID_TOKEN_REQUEST_TOKEN)throw new Error("the job may request no OIDC token (no ACTIONS_ID_TOKEN_REQUEST_URL); it needs the id-token: write permission");const r=await fetch(E.ACTIONS_ID_TOKEN_REQUEST_URL+"&audience="+encodeURIComponent(A),{headers:{authorization:"bearer "+E.ACTIONS_ID_TOKEN_REQUEST_TOKEN}});if(!r.ok)throw new Error("the OIDC token request answered "+r.status);const v=(await r.json()).value;if(typeof v!=="string")throw new Error("the OIDC token request answered no token");c=claimsOf(v);s.forgeEnvironment=typeof c.environment==="string"&&c.environment!==""?c.environment:null}catch(e){s.forgeEnvironmentError=e&&e.message?e.message:String(e)}}const run=c&&typeof c.runner_environment==="string"?c.runner_environment:E.RUNNER_ENVIRONMENT;if(run)s.runnerEnvironment=run;return s}seenOf().then((s)=>{const P=[],C=[],S=[];const fe=x.forgeEnvironment;if(x.forge==="forgejo")S.push("forge environment (Forgejo has none)");else if(fe!==undefined){if(s.forgeEnvironmentError!==undefined)P.push("forge environment: expected "+fe+", and it could not be read: "+s.forgeEnvironmentError);else if(s.forgeEnvironment!==fe)P.push("forge environment: expected "+fe+", found "+shown(s.forgeEnvironment));else C.push("forge environment "+fe)}const b=x.branches.filter((v)=>v!=="");if(b.length===0)P.push("ref: the job names no branch to run from");else if(x.forge==="gitlab"){if(!s.ref||!b.includes(s.ref))P.push("ref: expected "+b.join(" or ")+", found "+shown(s.ref));else C.push("ref "+s.ref);if(s.refProtected!=="true")P.push("ref: expected a protected ref (CI_COMMIT_REF_PROTECTED true), found "+shown(s.refProtected));else C.push("protected ref")}else{const refs=b.map((v)=>"refs/heads/"+v);if(!s.ref||!refs.includes(s.ref))P.push("ref: expected "+refs.join(" or ")+", found "+shown(s.ref));else C.push("ref "+s.ref)}if(x.forge==="gitlab"){const w=x.runnerTags||[];if(w.length>0){const h=s.runnerTags;const miss=w.filter((t)=>!(h||[]).includes(t));if(h===undefined)P.push("runner: expected tags "+w.join(", ")+", and the job has no CI_RUNNER_TAGS");else if(miss.length>0)P.push("runner: expected tags "+w.join(", ")+", found "+(h.length?h.join(", "):"none"));else C.push("runner tags "+w.join(", "))}}else if(x.selfHosted){if(s.runnerEnvironment===undefined)S.push("runner (the runner says nothing about itself: no RUNNER_ENVIRONMENT)");else if(s.runnerEnvironment!=="self-hosted")P.push("runner: expected self-hosted, found "+s.runnerEnvironment);else C.push("self-hosted runner")}const at=(x.product||"chant")+" check-job: "+x.job;if(P.length>0){for(const p of P)console.error(at+": "+p);console.error(at+": this job does not run where the spec set it to run, so it stops before any credential is taken or code checked out");process.exit(9)}console.log(at+": "+(C.length?C.join(", "):"nothing to check")+(S.length?"; not checked: "+S.join(", "):""))}).catch((e)=>fail(e&&e.message?e.message:String(e)));'')'
|
|
107
117
|
- chant run wave --spec waves.json --wave 2 --group eu
|
|
108
118
|
allow_failure:
|
|
109
119
|
exit_codes:
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** #4631: a GitLab scheduled Op job trades first, and an update on drift is a job of its own on the run's dotenv. */
|
|
2
|
+
import { describe, expect, test } from "vitest";
|
|
3
|
+
import { parseYAML } from "@intentius/chant/yaml";
|
|
4
|
+
import type { ScheduledOpSpec } from "@intentius/chant/lexicon";
|
|
5
|
+
import { generateGitlabOpPipeline } from "./generate-op-pipeline";
|
|
6
|
+
|
|
7
|
+
interface Job { needs?: unknown; rules?: Array<Record<string, unknown>>; id_tokens?: Record<string, { aud: string }>; variables?: Record<string, string>; script: string[]; artifacts?: Record<string, unknown> }
|
|
8
|
+
|
|
9
|
+
const PREVIEW = { aws: { role: "arn:aws:iam::1:role/preview", region: "us-east-1", audience: "sts.amazonaws.com" } };
|
|
10
|
+
const UPDATE = { aws: { role: "arn:aws:iam::1:role/update", region: "us-east-1", audience: "sts.amazonaws.com" } };
|
|
11
|
+
const spec: ScheduledOpSpec = { name: "drift-dev", trigger: { kind: "cron", schedule: "0 6 * * *" }, findingMode: "issue", cloud: PREVIEW, update: { command: "plywood up", cloud: UPDATE } };
|
|
12
|
+
|
|
13
|
+
describe("cloud identities on a GitLab scheduled Op (#4631)", () => {
|
|
14
|
+
const doc = parseYAML(generateGitlabOpPipeline([spec]).files[0]!.yaml) as unknown as Record<string, Job>;
|
|
15
|
+
const check = doc["drift-dev"]!;
|
|
16
|
+
const update = doc["drift-dev-update"]!;
|
|
17
|
+
|
|
18
|
+
test("the check's script trades first and unsets the token variables, with an id token per cloud", () => {
|
|
19
|
+
expect(check.id_tokens).toEqual({ CHANT_ID_TOKEN_AWS: { aud: "sts.amazonaws.com" } });
|
|
20
|
+
expect(JSON.parse(check.variables!.CHANT_CLOUD_TRADE!)).toEqual(PREVIEW);
|
|
21
|
+
expect(check.script[0]).toContain("node -e");
|
|
22
|
+
expect(check.script[1]).toMatch(/^unset .*CHANT_ID_TOKEN_AWS/);
|
|
23
|
+
expect(check.script.join("\n")).toContain('echo "CHANT_AGENT_DRIFT=1"');
|
|
24
|
+
expect(check.artifacts).toMatchObject({ reports: { dotenv: ".chant/agent/drift.env" } });
|
|
25
|
+
expect(JSON.stringify(check)).not.toContain("role/update");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("the update job needs the check, stops with no drift, and trades for its own identity", () => {
|
|
29
|
+
expect(update.needs).toEqual([{ job: "drift-dev", artifacts: true }]);
|
|
30
|
+
expect(update.rules!.every((r) => r.when === "always")).toBe(true);
|
|
31
|
+
expect(update.script[0]).toContain('"${CHANT_AGENT_DRIFT:-0}" != "1"');
|
|
32
|
+
expect(JSON.parse(update.variables!.CHANT_CLOUD_TRADE!)).toEqual(UPDATE);
|
|
33
|
+
expect(update.script.at(-1)).toBe("plywood up");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("update.permissions has no GitLab form", () => {
|
|
37
|
+
expect(() => generateGitlabOpPipeline([{ ...spec, update: { command: "x", permissions: { issues: "write" } } }])).toThrow(/no GitLab form/);
|
|
38
|
+
expect(() => generateGitlabOpPipeline([{ ...spec, update: { command: "x", permissions: { contents: "write" } } }])).toThrow(/no GitLab form/);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -144,6 +144,8 @@ import { assertReviewSplit, GITLAB_REVIEW_NOTES_VAR, gitlabReviewJobs, REVIEW_DI
|
|
|
144
144
|
import { AGENT_DIR, resolveAgentSpec } from "@intentius/chant/ci-agent";
|
|
145
145
|
import { activeProductWords } from "@intentius/chant/ci-product-words";
|
|
146
146
|
import { gitlabAgentHeader, gitlabAgentJobs, gitlabAgentPushJobName, gitlabDriftArtifacts, gitlabDriftRunLines } from "./agent-jobs";
|
|
147
|
+
import { assertScheduledOpCloud, scheduledOpUpdateJobName } from "@intentius/chant/scheduled-op-cloud";
|
|
148
|
+
import { gitlabTradeIdTokens, gitlabTradeLines, TRADE_SPEC_ENV } from "@intentius/chant/ci-identity";
|
|
147
149
|
|
|
148
150
|
export type { GenerateGitlabOpOptions, GenerateGitlabOpResult };
|
|
149
151
|
|
|
@@ -307,6 +309,11 @@ export function generateGitlabOpPipeline(
|
|
|
307
309
|
|
|
308
310
|
for (const spec of ops) {
|
|
309
311
|
const subject = `Scheduled Op "${spec.name}"`;
|
|
312
|
+
// #4631: the job's cloud identities and the update on drift, checked before anything renders.
|
|
313
|
+
assertScheduledOpCloud(spec);
|
|
314
|
+
if (spec.update?.permissions && Object.keys(spec.update.permissions).length > 0) {
|
|
315
|
+
throw new Error(`${subject}: update.permissions has no GitLab form; a GitLab job's token scopes are the project's (contents: write included: whether a job token may push is a project setting), and its cloud identity is update.cloud.`);
|
|
316
|
+
}
|
|
310
317
|
// #4616: GitLab picks a runner by tags.
|
|
311
318
|
assertRunnerLabels(subject, "runner", spec.runner, "GitLab");
|
|
312
319
|
const tags = gitlabTags(spec.runner);
|
|
@@ -333,7 +340,10 @@ export function generateGitlabOpPipeline(
|
|
|
333
340
|
noteJobs.push(pushName);
|
|
334
341
|
continue;
|
|
335
342
|
}
|
|
336
|
-
const
|
|
343
|
+
const declaredIdTokens = gitlabIdTokens(spec.name, spec.permissions);
|
|
344
|
+
// #4631: a cloud trade takes an id token per cloud, beside the ones the spec declares.
|
|
345
|
+
const idTokens = spec.cloud ? { ...(declaredIdTokens ?? {}), ...gitlabTradeIdTokens(spec.cloud) } : declaredIdTokens;
|
|
346
|
+
const update = spec.update;
|
|
337
347
|
const findingMode = spec.findingMode ?? "report";
|
|
338
348
|
const trigger = resolveOpTrigger(spec);
|
|
339
349
|
assertTriggerSupportsMode(spec.name, findingMode, trigger);
|
|
@@ -376,6 +386,7 @@ export function generateGitlabOpPipeline(
|
|
|
376
386
|
// than a caller being able to repoint the gate block's own artifact path.
|
|
377
387
|
const jobVariables: Record<string, string> = {
|
|
378
388
|
...(spec.variables ?? {}),
|
|
389
|
+
...(spec.cloud ? { [TRADE_SPEC_ENV]: JSON.stringify(spec.cloud) } : {}),
|
|
379
390
|
...(gated ? { [GATE_SUMMARY_VAR]: gateSummary } : {}),
|
|
380
391
|
};
|
|
381
392
|
|
|
@@ -400,7 +411,10 @@ export function generateGitlabOpPipeline(
|
|
|
400
411
|
continue;
|
|
401
412
|
}
|
|
402
413
|
|
|
403
|
-
|
|
414
|
+
// #4631: an update follows a drift check the same way the drift agent does, on the dotenv the run leaves.
|
|
415
|
+
const flagsDrift = driftAgent !== undefined || update !== undefined;
|
|
416
|
+
const runLines = flagsDrift ? gitlabDriftRunLines(invocation.join(" ")) : [invocation.join(" ")];
|
|
417
|
+
const driftDir = member ? memberRepoPath(member, AGENT_DIR) : AGENT_DIR;
|
|
404
418
|
doc[jobName] = {
|
|
405
419
|
stage,
|
|
406
420
|
image,
|
|
@@ -412,17 +426,42 @@ export function generateGitlabOpPipeline(
|
|
|
412
426
|
...(spec.environment ? { environment: environmentDoc(spec.environment) } : {}),
|
|
413
427
|
...nonEmpty("variables", jobVariables),
|
|
414
428
|
rules: rulesFor,
|
|
415
|
-
|
|
429
|
+
// #4631: the trade first, before any line of the checkout's code runs, and the token variables unset by it.
|
|
430
|
+
script: [...(spec.cloud ? gitlabTradeLines() : []), ...cdLine, ...setupScript, ...beforeScript, ...runLines, ...extraScript],
|
|
416
431
|
// `when: always` because the run this publishes for is the green one: a
|
|
417
432
|
// gated apply succeeds, and the block is the only thing that says a
|
|
418
433
|
// human still has to act. A run that walked through its gate writes no
|
|
419
434
|
// block, and GitLab reports the empty upload as a warning, not a failure.
|
|
420
435
|
...(gated
|
|
421
436
|
? { artifacts: { when: "always", paths: [member ? memberRepoPath(member, gateSummary) : gateSummary], expire_in: GATE_ARTIFACT_EXPIRY } }
|
|
422
|
-
:
|
|
423
|
-
? { artifacts: gitlabDriftArtifacts(
|
|
437
|
+
: flagsDrift
|
|
438
|
+
? { artifacts: gitlabDriftArtifacts(driftDir) }
|
|
424
439
|
: {}),
|
|
425
440
|
};
|
|
441
|
+
if (update) {
|
|
442
|
+
// #4631: the update's own job, which alone holds the update's identity, after a run that found drift.
|
|
443
|
+
const name = scheduledOpUpdateJobName(jobName);
|
|
444
|
+
opLines.push(`# update: ${name} runs \`${update.command}\` when ${jobName} exits with chant's drift code${update.cloud ? ", holding its own cloud identity" : ""}`);
|
|
445
|
+
doc[name] = {
|
|
446
|
+
stage,
|
|
447
|
+
image,
|
|
448
|
+
...(tags ? { tags } : {}),
|
|
449
|
+
needs: [{ job: jobName, artifacts: true }],
|
|
450
|
+
...(update.cloud ? { id_tokens: gitlabTradeIdTokens(update.cloud) } : {}),
|
|
451
|
+
...(update.environment ? { environment: environmentDoc(update.environment) } : {}),
|
|
452
|
+
...nonEmpty("variables", { ...(update.variables ?? {}), ...(update.cloud ? { [TRADE_SPEC_ENV]: JSON.stringify(update.cloud) } : {}) }),
|
|
453
|
+
rules: rulesFor.map((r) => ({ ...(r as Record<string, unknown>), when: "always" })),
|
|
454
|
+
script: [
|
|
455
|
+
`if [ "\${CHANT_AGENT_DRIFT:-0}" != "1" ]; then echo "no drift, so the update does not run"; exit 0; fi`,
|
|
456
|
+
...(update.cloud ? gitlabTradeLines() : []),
|
|
457
|
+
...cdLine,
|
|
458
|
+
...setupScript,
|
|
459
|
+
...beforeScript,
|
|
460
|
+
update.command,
|
|
461
|
+
],
|
|
462
|
+
};
|
|
463
|
+
noteJobs.push(name);
|
|
464
|
+
}
|
|
426
465
|
if (driftAgent) {
|
|
427
466
|
const name = `${jobName}-agent`;
|
|
428
467
|
opLines.push(...gitlabAgentHeader(name, driftAgent, spec.agent?.tokenEnv));
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** #4630: GitLab approvals start no pipeline, so a schedule posts every open merge request's approval status. */
|
|
2
|
+
import { describe, expect, test } from "vitest";
|
|
3
|
+
import type { OpWavesSpec } from "@intentius/chant/op/op-waves";
|
|
4
|
+
import { generateGitlabOpWavesPipeline } from "./generate-op-waves-pipeline";
|
|
5
|
+
|
|
6
|
+
const spec: OpWavesSpec = {
|
|
7
|
+
name: "plywood-up",
|
|
8
|
+
op: "plywood-up",
|
|
9
|
+
plan: ["./bin/plan", "{target}", "{plan}"],
|
|
10
|
+
waves: [{ name: "prod", runs: [{ target: "app" }], approval: "pr-review" }],
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
describe("the approval status job on GitLab (#4630)", () => {
|
|
14
|
+
test("runs in a schedule that sets CHANT_APPROVAL_STATUS, with --open", () => {
|
|
15
|
+
const yaml = generateGitlabOpWavesPipeline(spec, { specFile: "waves.json" }).files[0]!.yaml;
|
|
16
|
+
expect(yaml).toContain("plywood-up-approval-status:");
|
|
17
|
+
expect(yaml).toContain('$CI_PIPELINE_SOURCE == "schedule" && $CHANT_APPROVAL_STATUS == "1"');
|
|
18
|
+
expect(yaml).toContain("chant run wave --spec waves.json --approval-status --open --forge gitlab");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("a pull-request wave records its plans and posts the status too (2026-10-28 ruling)", () => {
|
|
22
|
+
const pullRequest: OpWavesSpec = { ...spec, waves: [{ name: "prod", runs: [{ target: "app" }], approval: "pull-request" }], approvals: { pullRequest: { gate: "up" } } };
|
|
23
|
+
const yaml = generateGitlabOpWavesPipeline(pullRequest, { specFile: "waves.json" }).files[0]!.yaml;
|
|
24
|
+
expect(yaml).toContain("plywood-up-record-plans:");
|
|
25
|
+
expect(yaml).toContain("plywood-up-approval-status:");
|
|
26
|
+
expect(yaml).toContain("chant run wave --spec waves.json --approval-status --open --forge gitlab");
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -42,7 +42,7 @@ describe("the control dispatch after the last wave on GitLab (#4617)", () => {
|
|
|
42
42
|
|
|
43
43
|
test("every wave job and the resume job drop the control repo's token variables first", () => {
|
|
44
44
|
const yaml = generateGitlabOpWavesPipeline(spec, options).files[0]!.yaml;
|
|
45
|
-
expect(yaml).toMatch(/wave-1-canary:[\s\S]*script:\n\s+- unset CHANT_CONTROL_TOKEN CHANT_CONTROL_READ_TOKEN PLYWOOD_CONTROL_TOKEN\n\s+- npx --no-install plywood run wave --spec waves.json --wave 1/);
|
|
45
|
+
expect(yaml).toMatch(/wave-1-canary:[\s\S]*script:\n\s+- [^\n]*node -e[^\n]*\n\s+- unset CHANT_CONTROL_TOKEN CHANT_CONTROL_READ_TOKEN PLYWOOD_CONTROL_TOKEN\n\s+- npx --no-install plywood run wave --spec waves.json --wave 1/);
|
|
46
46
|
expect(yaml).toMatch(/stacks-resume:[\s\S]*script:\n\s+- unset CHANT_CONTROL_TOKEN CHANT_CONTROL_READ_TOKEN PLYWOOD_CONTROL_TOKEN/);
|
|
47
47
|
});
|
|
48
48
|
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The apply-job guard in GitLab op-wave jobs (#4624): the script's first
|
|
3
|
+
* line, before the trade, with the job's expectation in a variable: the
|
|
4
|
+
* environment, the branches and the runner's tags. Written for the
|
|
5
|
+
* ci-products post-merge pass.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from "vitest";
|
|
9
|
+
import type { OpWavesSpec } from "@intentius/chant/op/op-waves";
|
|
10
|
+
import { spawnSync } from "node:child_process";
|
|
11
|
+
import { JOB_GUARD_ENV, JOB_GUARD_JS } from "@intentius/chant/ci-job-guard";
|
|
12
|
+
import { CHANT_EXIT } from "@intentius/chant/exit-codes";
|
|
13
|
+
import { generateGitlabOpWavesPipeline, OP_WAVE_ALLOWED_EXIT } from "./generate-op-waves-pipeline";
|
|
14
|
+
|
|
15
|
+
const spec: OpWavesSpec = {
|
|
16
|
+
name: "deploy",
|
|
17
|
+
op: "pulumi-up",
|
|
18
|
+
plan: ["./bin/plan", "{target}", "{plan}"],
|
|
19
|
+
branches: ["main"],
|
|
20
|
+
runner: ["deploy"],
|
|
21
|
+
waves: [
|
|
22
|
+
{ name: "dev", runs: [{ target: "dev" }], gate: "never" },
|
|
23
|
+
{ name: "prod", runs: [{ target: "prod" }], environment: { name: "production" } },
|
|
24
|
+
],
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
describe("the apply-job guard on GitLab (#4624)", () => {
|
|
28
|
+
test("the first script line of every wave job", () => {
|
|
29
|
+
const yaml = generateGitlabOpWavesPipeline(spec, { specFile: "waves.json", words: { product: "plywood" } }).files[0]!.yaml;
|
|
30
|
+
for (const job of ["wave-1-dev", "wave-2-prod"]) {
|
|
31
|
+
const body = yaml.slice(yaml.indexOf(`${job}:`));
|
|
32
|
+
const script = body.slice(body.indexOf("script:"));
|
|
33
|
+
expect(script.split("\n")[1]).toContain(JOB_GUARD_JS.slice(0, 40));
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("the variable names the environment, the branches, the tags and the product", () => {
|
|
38
|
+
const yaml = generateGitlabOpWavesPipeline(spec, { specFile: "waves.json", words: { product: "plywood" } }).files[0]!.yaml;
|
|
39
|
+
const prod = yaml.slice(yaml.indexOf("wave-2-prod:"));
|
|
40
|
+
expect(prod).toContain(JOB_GUARD_ENV);
|
|
41
|
+
for (const part of ['"forge":"gitlab"', '"forgeEnvironment":"production"', '"branches":["main"]', '"runnerTags":["deploy"]', '"product":"plywood"', '"job":"wave 2 (prod) of deploy"']) {
|
|
42
|
+
expect(prod).toContain(part);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("a refused guard fails the job and stops the wave (#4624 ruling: fail hard)", () => {
|
|
47
|
+
const yaml = generateGitlabOpWavesPipeline(spec, { specFile: "waves.json", words: { product: "plywood" } }).files[0]!.yaml;
|
|
48
|
+
const prod = yaml.slice(yaml.indexOf("wave-2-prod:"));
|
|
49
|
+
expect(prod).toContain(`exit_codes:\n - ${OP_WAVE_ALLOWED_EXIT}`);
|
|
50
|
+
const guard = { forge: "gitlab", job: "wave 2 (prod) of deploy", forgeEnvironment: "production", branches: ["main"], runnerTags: ["deploy"] };
|
|
51
|
+
const r = spawnSync(process.execPath, ["-e", JOB_GUARD_JS], {
|
|
52
|
+
env: { PATH: process.env.PATH ?? "", [JOB_GUARD_ENV]: JSON.stringify(guard), CI_ENVIRONMENT_NAME: "dev", CI_COMMIT_REF_NAME: "main", CI_COMMIT_REF_PROTECTED: "true", CI_RUNNER_TAGS: "deploy" },
|
|
53
|
+
encoding: "utf-8",
|
|
54
|
+
});
|
|
55
|
+
expect(r.status).toBe(CHANT_EXIT.misplaced);
|
|
56
|
+
expect(r.status).not.toBe(OP_WAVE_ALLOWED_EXIT);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("a guard with no spec to read fails the job too", () => {
|
|
60
|
+
const r = spawnSync(process.execPath, ["-e", JOB_GUARD_JS], { env: { PATH: process.env.PATH ?? "" }, encoding: "utf-8" });
|
|
61
|
+
expect(r.status).toBe(CHANT_EXIT.misplaced);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -97,7 +97,7 @@ describe("a runner per wave and job groups per wave (#3914)", () => {
|
|
|
97
97
|
expect(yaml).toMatch(/wave-1-dev:\n\s+stage: wave-1-dev\n\s+image: node:22\n\s+tags:\n\s+- docker\n\s+- dev\n/);
|
|
98
98
|
expect(yaml).toMatch(/wave-2-prod-decide:\n\s+stage: wave-2-prod\n\s+image: node:22\n\s+tags:\n\s+- plan\n\s+resource_group: migrations-prod\n\s+id_tokens:\n\s+PLAN_TOKEN:\n\s+aud: plan.example\n/);
|
|
99
99
|
expect(yaml).toMatch(
|
|
100
|
-
/wave-2-prod-us:\n\s+stage: wave-2-prod\n\s+image: node:22\n\s+tags:\n\s+- us-apply\n\s+resource_group: migrations-prod\n\s+environment:\n\s+name: prod-us\n\s+id_tokens:\n\s+CLOUD_TOKEN:\n\s+aud: us.example\n\s+variables:\n\s+GIT_DEPTH: '0'\n\s+REGION: us\n[\s\S]*?needs:\n\s+- wave-2-prod-decide\n\s+script:\n\s+- echo us\n\s+- chant run wave --spec waves.json --wave 2 --group us\n/,
|
|
100
|
+
/wave-2-prod-us:\n\s+stage: wave-2-prod\n\s+image: node:22\n\s+tags:\n\s+- us-apply\n\s+resource_group: migrations-prod\n\s+environment:\n\s+name: prod-us\n\s+id_tokens:\n\s+CLOUD_TOKEN:\n\s+aud: us.example\n\s+variables:\n\s+GIT_DEPTH: '0'\n\s+REGION: us\n\s+CHANT_JOB_GUARD: [^\n]*\n[\s\S]*?needs:\n\s+- wave-2-prod-decide\n\s+script:\n\s+- [^\n]*node -e[^\n]*\n\s+- echo us\n\s+- chant run wave --spec waves.json --wave 2 --group us\n/,
|
|
101
101
|
);
|
|
102
102
|
expect(yaml).toMatch(/wave-2-prod-eu:[\s\S]*?id_tokens:\n\s+CLOUD_TOKEN:\n\s+aud: eu.example\n/);
|
|
103
103
|
expect(yaml).not.toContain("_AUDIENCE");
|
|
@@ -122,8 +122,10 @@ describe("an Op wave's jobs by cloud identity (#4332)", () => {
|
|
|
122
122
|
for (const [name, ids] of Object.entries(want)) {
|
|
123
123
|
const job = doc[name]!;
|
|
124
124
|
expect(job.id_tokens).toEqual({ OTHER_TOKEN: { aud: "other.example" }, CHANT_ID_TOKEN_AWS: { aud: "sts.amazonaws.com" } });
|
|
125
|
-
|
|
126
|
-
expect(job.script[
|
|
125
|
+
// #4624: the apply-job guard first, then the trade.
|
|
126
|
+
expect(job.script[0]).toMatch(/^\(cd \/ && node -e '/);
|
|
127
|
+
expect(job.script.slice(1, 4)).toEqual(gitlabTradeLines());
|
|
128
|
+
expect(job.script[4]).toBe("npm ci");
|
|
127
129
|
expect(tradeIdentities(JSON.parse(job.variables[TRADE_SPEC_ENV]!))).toEqual(ids);
|
|
128
130
|
}
|
|
129
131
|
expect(JSON.stringify(doc["wave-1-prod-decide"])).not.toContain("apply\"");
|
|
@@ -24,8 +24,9 @@
|
|
|
24
24
|
* arrived, with `CHANT_FORGE_TOKEN` (a project access token with the `api`
|
|
25
25
|
* scope and the Developer role; the job token cannot retry jobs).
|
|
26
26
|
*
|
|
27
|
-
* A `pr-review` wave (#3684)
|
|
28
|
-
* pipelines only, which records
|
|
27
|
+
* A `pr-review` wave (#3684), or a `pull-request` wave (#4630), adds
|
|
28
|
+
* `<name>-record-plans`, run in merge request pipelines only, which records
|
|
29
|
+
* the digests the merge request's head plans.
|
|
29
30
|
*
|
|
30
31
|
* `runner` (on the spec, a wave or a job group, #3914) is the job's `tags:`; a
|
|
31
32
|
* GitHub runner group is a spec error here. A wave's `jobs` renders its
|
|
@@ -49,6 +50,14 @@
|
|
|
49
50
|
* unsetting the control repo's token variables and the product's two
|
|
50
51
|
* secret names; the dispatch step reads the token from the product's
|
|
51
52
|
* variable alone and hands it to curl on its stdin (#4568).
|
|
53
|
+
*
|
|
54
|
+
* Each wave job's script starts with the apply-job guard (#4624,
|
|
55
|
+
* `@intentius/chant/ci-job-guard`), before the trade: it stops the job
|
|
56
|
+
* unless `CI_COMMIT_REF_NAME` is one of the spec's branches and protected,
|
|
57
|
+
* `CI_ENVIRONMENT_NAME` is the job's environment, and `CI_RUNNER_TAGS`
|
|
58
|
+
* holds each of the job's tags. A refused guard exits chant's `misplaced`
|
|
59
|
+
* (9), outside the job's allowed failure, so the job fails and the waves
|
|
60
|
+
* after it stop.
|
|
52
61
|
*/
|
|
53
62
|
|
|
54
63
|
import {
|
|
@@ -73,6 +82,9 @@ import {
|
|
|
73
82
|
opWaveOutcomeDir,
|
|
74
83
|
opWavesControlTarget,
|
|
75
84
|
opWavesLastJobs,
|
|
85
|
+
OP_WAVES_APPROVAL_STATUS_VAR,
|
|
86
|
+
opWavesApprovalStatusCommand,
|
|
87
|
+
opWavesPostsApprovalStatus,
|
|
76
88
|
opWavesOutcomeCommand,
|
|
77
89
|
opWavesRecordPlansCommand,
|
|
78
90
|
opWavesResumeCommand,
|
|
@@ -81,8 +93,14 @@ import {
|
|
|
81
93
|
import { CONTROL_OUTCOME_VAR, CONTROL_TOKEN_VARS, controlDispatchJob } from "@intentius/chant/control/dispatch";
|
|
82
94
|
import type { OpWavesPipelineOptions, OpWavesPipelineResult } from "@intentius/chant/lexicon";
|
|
83
95
|
import { gitlabTradeIdTokens, gitlabTradeLines, TRADE_SPEC_ENV } from "@intentius/chant/ci-identity";
|
|
96
|
+
import { JOB_GUARD_ENV, jobGuardLine, jobGuardSpec, opWaveJobLabel } from "@intentius/chant/ci-job-guard";
|
|
97
|
+
import { CHANT_EXIT } from "@intentius/chant/exit-codes";
|
|
98
|
+
import { resolveProductWords } from "@intentius/chant/ci-product-words";
|
|
84
99
|
import { emitGitlabFile } from "./ci-jobs";
|
|
85
100
|
|
|
101
|
+
/** The one exit an op-wave job allows as a failure (#4322): a run that failed. The guard's refusal (9) is outside it. */
|
|
102
|
+
export const OP_WAVE_ALLOWED_EXIT = CHANT_EXIT.failed;
|
|
103
|
+
|
|
86
104
|
/** Render an Op waves spec as one GitLab CI file, `<name>.gitlab-ci.yml` unless `opsFileName` names another. */
|
|
87
105
|
export function generateGitlabOpWavesPipeline(spec: OpWavesSpec, options: OpWavesPipelineOptions): OpWavesPipelineResult {
|
|
88
106
|
const jobs = opWaveJobs(spec, options.specFile, { bin: options.bin });
|
|
@@ -107,8 +125,10 @@ export function generateGitlabOpWavesPipeline(spec: OpWavesSpec, options: OpWave
|
|
|
107
125
|
}
|
|
108
126
|
}
|
|
109
127
|
const specTags = gitlabTags(spec.runner);
|
|
128
|
+
const product = resolveProductWords(options.words ?? {}).product;
|
|
110
129
|
|
|
111
|
-
|
|
130
|
+
// #4630: a pr-review or pull-request wave records each head's plans and posts the approval status.
|
|
131
|
+
const byReview = opWavesPostsApprovalStatus(spec);
|
|
112
132
|
const stages = [...(byReview ? ["plans"] : []), ...spec.waves.map((wave, i) => `wave-${i + 1}-${wave.name}`)];
|
|
113
133
|
// #4617: the control repo's token variables, which every job but the dispatch drops first.
|
|
114
134
|
const control = spec.control ? opWavesControlTarget(spec.control) : undefined;
|
|
@@ -130,6 +150,18 @@ export function generateGitlabOpWavesPipeline(spec: OpWavesSpec, options: OpWave
|
|
|
130
150
|
script: [...dropControl, ...(options.beforeScript ?? []), opWavesRecordPlansCommand(options.specFile, options.bin).join(" ")],
|
|
131
151
|
},
|
|
132
152
|
]);
|
|
153
|
+
// #4630: a GitLab approval starts no pipeline, so a schedule of the target branch posts every open merge request's status.
|
|
154
|
+
entries.push([
|
|
155
|
+
`${spec.name}-approval-status`,
|
|
156
|
+
{
|
|
157
|
+
stage: "plans",
|
|
158
|
+
image,
|
|
159
|
+
...(specTags ? { tags: specTags } : {}),
|
|
160
|
+
variables: { ...GITLAB_FULL_HISTORY },
|
|
161
|
+
rules: [{ if: `${GITLAB_SCHEDULE_SOURCE} && $${OP_WAVES_APPROVAL_STATUS_VAR} == "1"` }],
|
|
162
|
+
script: [...dropControl, ...(options.beforeScript ?? []), opWavesApprovalStatusCommand(options.specFile, "gitlab", options.bin).join(" ")],
|
|
163
|
+
},
|
|
164
|
+
]);
|
|
133
165
|
}
|
|
134
166
|
for (const job of jobs) {
|
|
135
167
|
const wave = spec.waves[job.wave - 1]!;
|
|
@@ -141,6 +173,16 @@ export function generateGitlabOpWavesPipeline(spec: OpWavesSpec, options: OpWave
|
|
|
141
173
|
const cloud = group?.cloud ?? wave.cloud;
|
|
142
174
|
const declared = gitlabIdTokensDoc(group?.idTokens ?? wave.idTokens);
|
|
143
175
|
const idTokens = cloud ? { ...declared, ...gitlabTradeIdTokens(cloud) } : declared;
|
|
176
|
+
// #4624: the apply-job guard, the script's first line, before the trade.
|
|
177
|
+
const runner = group?.runner ?? wave.runner ?? spec.runner;
|
|
178
|
+
const guard = jobGuardSpec({
|
|
179
|
+
forge: "gitlab",
|
|
180
|
+
job: opWaveJobLabel(spec.name, job),
|
|
181
|
+
product,
|
|
182
|
+
...(environment ? { environment: environment.name } : {}),
|
|
183
|
+
branches: pushBranches(spec.branches),
|
|
184
|
+
...(runner !== undefined ? { runner } : {}),
|
|
185
|
+
});
|
|
144
186
|
entries.push([
|
|
145
187
|
job.jobName,
|
|
146
188
|
{
|
|
@@ -150,12 +192,13 @@ export function generateGitlabOpWavesPipeline(spec: OpWavesSpec, options: OpWave
|
|
|
150
192
|
resource_group: `${spec.name}-${wave.name}`,
|
|
151
193
|
...(environment ? { environment: environmentDoc(environment) } : {}),
|
|
152
194
|
...(idTokens ? { id_tokens: idTokens } : {}),
|
|
153
|
-
variables: { ...GITLAB_FULL_HISTORY, ...(wave.variables ?? {}), ...(group?.variables ?? {}), ...(cloud ? { [TRADE_SPEC_ENV]: JSON.stringify(cloud) } : {}) },
|
|
195
|
+
variables: { ...GITLAB_FULL_HISTORY, ...(wave.variables ?? {}), ...(group?.variables ?? {}), ...(cloud ? { [TRADE_SPEC_ENV]: JSON.stringify(cloud) } : {}), [JOB_GUARD_ENV]: JSON.stringify(guard) },
|
|
154
196
|
rules,
|
|
155
197
|
...(job.needs.length > 0 ? { needs: job.needs } : {}),
|
|
156
|
-
script: [...(cloud ? gitlabTradeLines() : []), ...dropControl, ...setup, ...(options.beforeScript ?? []), job.command.join(" "), ...(options.extraScript ?? [])],
|
|
157
|
-
// #4322: a run that failed, or was blocked, lets the next wave run; any other failure stops it
|
|
158
|
-
|
|
198
|
+
script: [jobGuardLine(), ...(cloud ? gitlabTradeLines() : []), ...dropControl, ...setup, ...(options.beforeScript ?? []), job.command.join(" "), ...(options.extraScript ?? [])],
|
|
199
|
+
// #4322: a run that failed, or was blocked, lets the next wave run; any other failure stops it,
|
|
200
|
+
// a refused guard (9, #4624) included.
|
|
201
|
+
allow_failure: { exit_codes: [OP_WAVE_ALLOWED_EXIT] },
|
|
159
202
|
artifacts: { when: "always", paths: [...(job.kind === "decide" ? [job.record!] : []), `${opWaveOutcomeDir(spec)}/`], expire_in: "30 days" },
|
|
160
203
|
},
|
|
161
204
|
]);
|
|
@@ -211,10 +254,15 @@ export function generateGitlabOpWavesPipeline(spec: OpWavesSpec, options: OpWave
|
|
|
211
254
|
`# chant Op waves "${spec.name}": one stage per wave, each needing the one before.`,
|
|
212
255
|
"# A wave that waits at its gate exits 3 and stops the waves after it; approve the",
|
|
213
256
|
"# digest it prints, then retry the job. A job whose runs failed exits 1, an allowed",
|
|
214
|
-
"# failure, and the waves after it run what does not depend on those runs.",
|
|
257
|
+
"# failure, and the waves after it run what does not depend on those runs. A job",
|
|
258
|
+
"# whose guard finds it running where the spec did not set it to run exits 9 and",
|
|
259
|
+
"# stops the waves after it.",
|
|
215
260
|
...(spec.resume
|
|
216
261
|
? [`# The ${spec.name}-resume job retries it for you in a pipeline schedule; give the schedule the cron "${spec.resume.schedule}".`]
|
|
217
262
|
: []),
|
|
263
|
+
...(byReview
|
|
264
|
+
? [`# The ${spec.name}-approval-status job posts each open merge request's approval status from a pipeline schedule that sets ${OP_WAVES_APPROVAL_STATUS_VAR}=1.`]
|
|
265
|
+
: []),
|
|
218
266
|
...(control ? [`# After the last wave, the control-dispatch job tells ${control.repo} how the waves ended.`] : []),
|
|
219
267
|
];
|
|
220
268
|
const name = options.opsFileName ?? `${spec.name}.gitlab-ci.yml`;
|
|
@@ -34,12 +34,14 @@
|
|
|
34
34
|
|
|
35
35
|
import {
|
|
36
36
|
assertGitlabBranch,
|
|
37
|
+
assertRunnerLabels,
|
|
37
38
|
CI_IMAGE,
|
|
38
39
|
ciJobName,
|
|
39
40
|
fillCommand,
|
|
40
41
|
GITLAB_FULL_HISTORY,
|
|
41
42
|
GITLAB_MERGE_REQUEST_SOURCE,
|
|
42
43
|
gitlabPushRule,
|
|
44
|
+
gitlabTags,
|
|
43
45
|
} from "@intentius/chant/ci-jobs";
|
|
44
46
|
import { resolveComponentGraph, type DriverComponent } from "@intentius/chant/components/driver";
|
|
45
47
|
import { hasPublishStep, promoteArchivePaths } from "@intentius/chant/components/promote";
|
|
@@ -65,6 +67,10 @@ import {
|
|
|
65
67
|
prCommentPreviewJob,
|
|
66
68
|
PR_ENV_REPORT_DIR,
|
|
67
69
|
PR_ENV_POST_JOB,
|
|
70
|
+
PR_STALE_JOB,
|
|
71
|
+
prStaleJob,
|
|
72
|
+
prLoopJobStage,
|
|
73
|
+
prLoopRunner,
|
|
68
74
|
} from "@intentius/chant/components/pr-pipeline";
|
|
69
75
|
import { REPO_APPLY_GROUP } from "@intentius/chant/components/repo-lock";
|
|
70
76
|
import { memberGitlabChanges, memberRepoPath, memberShellDir } from "@intentius/chant/lexicon";
|
|
@@ -441,9 +447,12 @@ function prLoopGitlabPipeline(
|
|
|
441
447
|
const previewRule = preview ? `$CI_PIPELINE_SOURCE =~ /^(web|api|trigger)$/ && $${preview.gitlabVar} =~ /^[0-9]+$/ && $CI_COMMIT_BRANCH == ${targetBranch}` : undefined;
|
|
442
448
|
const previewName = preview ? named(preview.jobName) : undefined;
|
|
443
449
|
const previewNoteName = preview ? named(preview.noteJobName) : undefined;
|
|
444
|
-
|
|
450
|
+
// #4628: a push to the target branch marks the open merge requests' notes posted against another commit stale.
|
|
451
|
+
const stale = prStaleJob("gitlab", env, loop, member?.name);
|
|
452
|
+
const staleName = stale ? named(PR_STALE_JOB) : undefined;
|
|
453
|
+
const stages = ["plan", PR_PLAN_NOTE_JOB, ...(stale ? [PR_STALE_JOB] : []), ...commentLocks.map((c) => c.job.jobName), ...(preview ? [preview.jobName, preview.noteJobName] : []), ...applies.map((a) => a.stage), ...(control ? [control.name] : []), ...(prEnv ? [PR_ENV_STAGE, PR_ENV_POST_STAGE] : [])];
|
|
445
454
|
const noteJob = named(PR_PLAN_NOTE_JOB);
|
|
446
|
-
const post = prLoopPostJob("gitlab", env, member?.name, loop.words ? { ...(loop.words.bin ? { bin: loop.words.bin } : {}) } : undefined);
|
|
455
|
+
const post = prLoopPostJob("gitlab", env, member?.name, loop.words ? { ...(loop.words.bin ? { bin: loop.words.bin } : {}) } : undefined, { ...(loop.staleNotes ? { staleNotes: true } : {}), ...(loop.respond ? { respond: loop.respond } : {}), ...(loop.decide ? { decide: loop.decide } : {}) });
|
|
447
456
|
// #4619: with a control repo named, the note job reads its chant/lifecycle, read-only, so pr-post marks the cross-stack edges.
|
|
448
457
|
const controlRead = loop.control ? controlNoteCheckout("gitlab", loop.control.target, loop.words ?? {}) : undefined;
|
|
449
458
|
const doc: Record<string, unknown> = {
|
|
@@ -468,8 +477,15 @@ function prLoopGitlabPipeline(
|
|
|
468
477
|
stage: PR_PLAN_NOTE_JOB,
|
|
469
478
|
image,
|
|
470
479
|
rules: [{ if: onPlanNotes }],
|
|
480
|
+
...(Object.keys(post.env).length > 0 ? { variables: post.env } : {}),
|
|
471
481
|
script: [...enter, ...beforeScript, ...(controlRead ? [controlRead.script] : []), post.command],
|
|
472
482
|
},
|
|
483
|
+
...(stale && staleName
|
|
484
|
+
? {
|
|
485
|
+
// The pushed commit of the target branch: none of a merge request's code is here.
|
|
486
|
+
[staleName]: { stage: PR_STALE_JOB, image, rules: [{ if: onMerge }], variables: stale.env, script: [...enter, ...beforeScript, stale.command] },
|
|
487
|
+
}
|
|
488
|
+
: {}),
|
|
473
489
|
...Object.fromEntries(
|
|
474
490
|
commentLocks.map(({ job, name, rule }) => [
|
|
475
491
|
name,
|
|
@@ -567,6 +583,7 @@ function prLoopGitlabPipeline(
|
|
|
567
583
|
const jobs: GeneratedJob[] = [
|
|
568
584
|
{ jobName: planJob, component: "merge request plan", stage: "plan", needs: [] },
|
|
569
585
|
{ jobName: noteJob, component: "merge request plan note", stage: PR_PLAN_NOTE_JOB, needs: [] },
|
|
586
|
+
...(stale && staleName ? [{ jobName: staleName, component: "merge request stale notes", stage: PR_STALE_JOB, needs: [] }] : []),
|
|
570
587
|
...commentLocks.map(({ job, name }) => ({ jobName: name, component: `merge request ${job.command} comment`, stage: job.jobName, needs: [] })),
|
|
571
588
|
...(preview && previewName && previewNoteName
|
|
572
589
|
? [
|
|
@@ -583,7 +600,16 @@ function prLoopGitlabPipeline(
|
|
|
583
600
|
...(controlJob ? [{ jobName: controlJob, component: "control repo dispatch", stage: control!.name, needs: [lastGreen!] }] : []),
|
|
584
601
|
...(prEnv ? prEnv.listed : []),
|
|
585
602
|
];
|
|
586
|
-
|
|
603
|
+
// #4625: each job on its stage's runner tags, the plan side's, the apply side's, or the default.
|
|
604
|
+
if (loop.runners) {
|
|
605
|
+
for (const stage of ["plan", "apply", "default"] as const) assertRunnerLabels("The merge-request pipeline", `runners.${stage}`, loop.runners[stage], "GitLab");
|
|
606
|
+
for (const job of jobs) {
|
|
607
|
+
const tags = gitlabTags(prLoopRunner(loop, prLoopJobStage(job.jobName, member?.name)));
|
|
608
|
+
const entry = doc[job.jobName] as Record<string, unknown> | undefined;
|
|
609
|
+
if (tags && entry) doc[job.jobName] = { ...entry, tags };
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
const yaml = emitGitlabFile(["workflow", "stages", "variables", planJob, noteJob, ...(staleName ? [staleName] : []), ...commentLocks.map((c) => c.name), ...(previewName && previewNoteName ? [previewName, previewNoteName] : []), ...applies.map((a) => a.name), ...(controlJob ? [controlJob] : []), ...Object.keys(prEnv?.jobs ?? {})].map((key) => [key, doc[key]] as const));
|
|
587
613
|
return { yaml, stages, jobs, env };
|
|
588
614
|
}
|
|
589
615
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The preview comment's --target on GitLab (#4626): the preview job runs
|
|
3
|
+
* `pr-plan --comment-targets`, which reads CHANT_PREVIEW_COMMENT, a variable
|
|
4
|
+
* of the target-branch pipeline. Written for the ci-products post-merge pass.
|
|
5
|
+
*/
|
|
6
|
+
import { expect, test } from "vitest";
|
|
7
|
+
import { parseYAML } from "@intentius/chant/yaml";
|
|
8
|
+
import type { DriverComponent } from "@intentius/chant/components/driver";
|
|
9
|
+
import { generateGitlabPipeline } from "./generate-pipeline";
|
|
10
|
+
|
|
11
|
+
const ESTATE: DriverComponent[] = [{ name: "net", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: "net" }] }] }];
|
|
12
|
+
const WORDS = { product: "plywood", stages: { plan: "preview", apply: "up" }, commands: { preview: "plan" as const }, bin: "npx --no-install plywood" };
|
|
13
|
+
|
|
14
|
+
test("the preview job runs pr-plan --comment-targets (#4626)", () => {
|
|
15
|
+
const doc = parseYAML(generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { previewComment: true, words: WORDS } }).yaml) as Record<string, { script: string[]; variables: Record<string, string> }>;
|
|
16
|
+
const preview = doc["comment-preview"]!;
|
|
17
|
+
expect(preview.script.join("\n")).toContain("--comment-targets");
|
|
18
|
+
expect(preview.variables).not.toHaveProperty("CHANT_PREVIEW_COMMENT");
|
|
19
|
+
expect(doc.plan!.script.join("\n")).not.toContain("--comment-targets");
|
|
20
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pull request loop's responses and description check on GitLab
|
|
3
|
+
* (#4629), as `chant build --pr-loop --respond --decide-backend
|
|
4
|
+
* --decide-model` sets them: `pr-post --open` asks the decide service, and
|
|
5
|
+
* the apply before merge carries CHANT_RESPOND and the merge (#4636).
|
|
6
|
+
*/
|
|
7
|
+
import { expect, test } from "vitest";
|
|
8
|
+
import { parseYAML } from "@intentius/chant/yaml";
|
|
9
|
+
import type { DriverComponent } from "@intentius/chant/components/driver";
|
|
10
|
+
import { generateGitlabPipeline } from "./generate-pipeline";
|
|
11
|
+
|
|
12
|
+
const ESTATE: DriverComponent[] = [{ name: "net", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: "net" }] }] }];
|
|
13
|
+
|
|
14
|
+
type Job = { variables?: Record<string, string>; script?: string[] };
|
|
15
|
+
|
|
16
|
+
test("the note and apply jobs carry the responses, the decide service and the merge (#4629, #4636)", () => {
|
|
17
|
+
const doc = parseYAML(generateGitlabPipeline(ESTATE, {
|
|
18
|
+
env: "prod",
|
|
19
|
+
prLoop: { applyBeforeMerge: true, applyMerge: { tokenEnv: "CHANT_MERGE_TOKEN" }, respond: { description: "check" }, decide: { backend: "review", model: "m-1" } },
|
|
20
|
+
}).yaml) as Record<string, Job>;
|
|
21
|
+
const note = doc["plan-note"]!;
|
|
22
|
+
expect(note.script!.at(-1)).toMatch(/pr-post --open .* --decide-backend "review" --decide-model "m-1"$/);
|
|
23
|
+
expect(note.variables!.CHANT_RESPOND).toBe("description=check");
|
|
24
|
+
const apply = Object.values(doc).find((j) => typeof j === "object" && j?.script?.some((l) => l.includes("--apply-before-merge")))!;
|
|
25
|
+
expect(apply.script!.join("\n")).toContain("--merge auto --merge-token-env CHANT_MERGE_TOKEN");
|
|
26
|
+
expect(apply.variables!.CHANT_RESPOND).toBe("description=check");
|
|
27
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The merge request pipeline's runners, variables and passed secrets on
|
|
3
|
+
* GitLab (#4625): each job's `tags` from its stage's runner, and a passed
|
|
4
|
+
* secret mapped from the CI/CD variable when the names differ. Written for
|
|
5
|
+
* the ci-products post-merge pass.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from "vitest";
|
|
9
|
+
import { parseYAML } from "@intentius/chant/yaml";
|
|
10
|
+
import type { DriverComponent } from "@intentius/chant/components/driver";
|
|
11
|
+
import { generateGitlabPipeline } from "./generate-pipeline";
|
|
12
|
+
|
|
13
|
+
const ESTATE: DriverComponent[] = [{ name: "net", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: "net" }] }] }];
|
|
14
|
+
|
|
15
|
+
type Job = { tags?: string[]; variables: Record<string, string>; script: string[] };
|
|
16
|
+
type Doc = Record<string, Job>;
|
|
17
|
+
|
|
18
|
+
describe("the GitLab merge request pipeline's runners and variables (#4625)", () => {
|
|
19
|
+
const doc = parseYAML(
|
|
20
|
+
generateGitlabPipeline(ESTATE, {
|
|
21
|
+
env: "prod",
|
|
22
|
+
prLoop: { applyOnMerge: true, runners: { plan: "planner", apply: ["deploy", "eu"], default: "small" }, variables: { REGION: "eu" }, secrets: { DB_PASSWORD: "PROD_DB", SAME: "SAME" } },
|
|
23
|
+
}).yaml,
|
|
24
|
+
) as Doc;
|
|
25
|
+
|
|
26
|
+
test("each job's tags come from its stage", () => {
|
|
27
|
+
expect(doc.plan!.tags).toEqual(["planner"]);
|
|
28
|
+
expect(doc.apply!.tags).toEqual(["deploy", "eu"]);
|
|
29
|
+
expect(doc.record!.tags).toEqual(["deploy", "eu"]);
|
|
30
|
+
expect(doc["plan-note"]!.tags).toEqual(["small"]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("the plan's variables carry the variable and the renamed secret; a same-named one needs no line", () => {
|
|
34
|
+
expect(doc.plan!.variables).toMatchObject({ REGION: "eu", DB_PASSWORD: "$PROD_DB" });
|
|
35
|
+
expect(doc.plan!.variables).not.toHaveProperty("SAME");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("a runner group is refused", () => {
|
|
39
|
+
expect(() => generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { runners: { plan: { group: "g" } } } })).toThrow(/runner group/);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stale marking on GitLab (#4628): the note job's `pr-post --open` passes
|
|
3
|
+
* the target branch pipeline's commit, and a stale-notes job in the push
|
|
4
|
+
* pipeline of the target branch runs `pr-post --stale`. Written for the
|
|
5
|
+
* ci-products post-merge pass.
|
|
6
|
+
*/
|
|
7
|
+
import { expect, test } from "vitest";
|
|
8
|
+
import { parseYAML } from "@intentius/chant/yaml";
|
|
9
|
+
import type { DriverComponent } from "@intentius/chant/components/driver";
|
|
10
|
+
import { generateGitlabPipeline } from "./generate-pipeline";
|
|
11
|
+
|
|
12
|
+
const ESTATE: DriverComponent[] = [{ name: "net", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: "net" }] }] }];
|
|
13
|
+
|
|
14
|
+
test("the note job carries the tip and a push job marks the notes stale (#4628)", () => {
|
|
15
|
+
const doc = parseYAML(generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { staleNotes: true } }).yaml) as Record<string, unknown> & { stages: string[] };
|
|
16
|
+
const note = doc["plan-note"] as { variables: Record<string, string>; script: string[] };
|
|
17
|
+
expect(note.variables.TIP_SHA).toBe("$CI_COMMIT_SHA");
|
|
18
|
+
expect(note.script.at(-1)).toContain('--tip "$TIP_SHA"');
|
|
19
|
+
const stale = doc["stale-notes"] as { stage: string; rules: Array<{ if: string }>; variables: Record<string, string>; script: string[] };
|
|
20
|
+
expect(doc.stages).toContain("stale-notes");
|
|
21
|
+
expect(stale.rules[0]!.if).toContain("$CI_PIPELINE_SOURCE == \"push\"");
|
|
22
|
+
expect(stale.variables).toEqual({ TIP_SHA: "$CI_COMMIT_SHA" });
|
|
23
|
+
expect(stale.script.at(-1)).toBe('chant components pr-post --stale --tip "$TIP_SHA" --env prod --forge gitlab');
|
|
24
|
+
});
|