@intentius/chant-lexicon-gitlab 0.124.52 → 0.124.55
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/agent-jobs.d.ts +68 -0
- package/dist/components/agent-jobs.d.ts.map +1 -0
- package/dist/components/agent-jobs.js +135 -0
- package/dist/components/control-pipeline.d.ts +24 -0
- package/dist/components/control-pipeline.d.ts.map +1 -0
- package/dist/components/control-pipeline.js +28 -0
- package/dist/components/generate-op-pipeline.d.ts +9 -0
- package/dist/components/generate-op-pipeline.d.ts.map +1 -1
- package/dist/components/generate-op-pipeline.js +107 -22
- package/dist/components/generate-op-waves-pipeline.d.ts +12 -0
- package/dist/components/generate-op-waves-pipeline.d.ts.map +1 -1
- package/dist/components/generate-op-waves-pipeline.js +59 -5
- package/dist/components/generate-pipeline.d.ts.map +1 -1
- package/dist/components/generate-pipeline.js +112 -10
- package/dist/components/review-jobs.d.ts +72 -0
- package/dist/components/review-jobs.d.ts.map +1 -0
- package/dist/components/review-jobs.js +124 -0
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/package.json +3 -3
- package/src/components/agent-jobs.ts +176 -0
- package/src/components/control-pipeline.ts +38 -0
- package/src/components/generate-op-pipeline.agent.test.ts +99 -0
- package/src/components/generate-op-pipeline.review.test.ts +72 -7
- package/src/components/generate-op-pipeline.ts +117 -23
- package/src/components/generate-op-waves-pipeline.control.test.ts +55 -0
- package/src/components/generate-op-waves-pipeline.test.ts +17 -0
- package/src/components/generate-op-waves-pipeline.ts +68 -5
- package/src/components/generate-pipeline.ts +117 -9
- package/src/components/generate-pr-pipeline.comment-locks.test.ts +43 -0
- package/src/components/generate-pr-pipeline.comment-preview.test.ts +61 -0
- package/src/components/generate-pr-pipeline.control.test.ts +81 -0
- package/src/components/generate-pr-pipeline.pulumi-identity.test.ts +46 -0
- package/src/components/review-jobs.ts +163 -0
|
@@ -37,6 +37,18 @@
|
|
|
37
37
|
* entry per cloud, and its script starts by trading them for those
|
|
38
38
|
* identities and unsetting the token variables, before any line of the
|
|
39
39
|
* checkout runs.
|
|
40
|
+
*
|
|
41
|
+
* With `control` on the spec (#4617), a `control-dispatch` job in a
|
|
42
|
+
* `control` stage needs the last wave's jobs, whose artifacts hold every
|
|
43
|
+
* job's outcome file. It adds them up with `chant run wave --waves-outcome`
|
|
44
|
+
* (through the product's bin) into `CHANT_CONTROL_OUTCOME` and sends the
|
|
45
|
+
* control repo one dispatch carrying it and `$CI_COMMIT_SHA`. A last wave
|
|
46
|
+
* whose runs failed (exit 1) still lets it run; a wave that waits at its
|
|
47
|
+
* gate stops it with the waves, and it runs once the retried job carries
|
|
48
|
+
* on. A CI/CD variable reaches every job, so every other job starts by
|
|
49
|
+
* unsetting the control repo's token variables and the product's two
|
|
50
|
+
* secret names; the dispatch step reads the token from the product's
|
|
51
|
+
* variable alone and hands it to curl on its stdin (#4568).
|
|
40
52
|
*/
|
|
41
53
|
|
|
42
54
|
import {
|
|
@@ -55,14 +67,25 @@ import {
|
|
|
55
67
|
nonEmpty,
|
|
56
68
|
pushBranches,
|
|
57
69
|
} from "@intentius/chant/ci-jobs";
|
|
58
|
-
import {
|
|
70
|
+
import {
|
|
71
|
+
opWaveGroupOf,
|
|
72
|
+
opWaveJobs,
|
|
73
|
+
opWaveOutcomeDir,
|
|
74
|
+
opWavesControlTarget,
|
|
75
|
+
opWavesLastJobs,
|
|
76
|
+
opWavesOutcomeCommand,
|
|
77
|
+
opWavesRecordPlansCommand,
|
|
78
|
+
opWavesResumeCommand,
|
|
79
|
+
type OpWavesSpec,
|
|
80
|
+
} from "@intentius/chant/op/op-waves";
|
|
81
|
+
import { CONTROL_OUTCOME_VAR, CONTROL_TOKEN_VARS, controlDispatchJob } from "@intentius/chant/control/dispatch";
|
|
59
82
|
import type { OpWavesPipelineOptions, OpWavesPipelineResult } from "@intentius/chant/lexicon";
|
|
60
83
|
import { gitlabTradeIdTokens, gitlabTradeLines, TRADE_SPEC_ENV } from "@intentius/chant/ci-identity";
|
|
61
84
|
import { emitGitlabFile } from "./ci-jobs";
|
|
62
85
|
|
|
63
86
|
/** Render an Op waves spec as one GitLab CI file, `<name>.gitlab-ci.yml` unless `opsFileName` names another. */
|
|
64
87
|
export function generateGitlabOpWavesPipeline(spec: OpWavesSpec, options: OpWavesPipelineOptions): OpWavesPipelineResult {
|
|
65
|
-
const jobs = opWaveJobs(spec, options.specFile);
|
|
88
|
+
const jobs = opWaveJobs(spec, options.specFile, { bin: options.bin });
|
|
66
89
|
const image = options.image ?? CI_IMAGE;
|
|
67
90
|
const subject = `Op waves "${spec.name}"`;
|
|
68
91
|
const rules = pushBranches(spec.branches).map((branch) => {
|
|
@@ -87,6 +110,10 @@ export function generateGitlabOpWavesPipeline(spec: OpWavesSpec, options: OpWave
|
|
|
87
110
|
|
|
88
111
|
const byReview = spec.waves.some((w) => w.approval === "pr-review");
|
|
89
112
|
const stages = [...(byReview ? ["plans"] : []), ...spec.waves.map((wave, i) => `wave-${i + 1}-${wave.name}`)];
|
|
113
|
+
// #4617: the control repo's token variables, which every job but the dispatch drops first.
|
|
114
|
+
const control = spec.control ? opWavesControlTarget(spec.control) : undefined;
|
|
115
|
+
const dropControl = control ? [`unset ${[...new Set([...CONTROL_TOKEN_VARS, control.tokenSecret, control.readTokenSecret])].join(" ")}`] : [];
|
|
116
|
+
if (control) stages.push("control");
|
|
90
117
|
if (spec.resume) stages.push("resume");
|
|
91
118
|
const entries: Array<readonly [string, unknown]> = [["stages", stages]];
|
|
92
119
|
const variables = nonEmpty("variables", options.variables).variables;
|
|
@@ -100,7 +127,7 @@ export function generateGitlabOpWavesPipeline(spec: OpWavesSpec, options: OpWave
|
|
|
100
127
|
...(specTags ? { tags: specTags } : {}),
|
|
101
128
|
variables: { ...GITLAB_FULL_HISTORY },
|
|
102
129
|
rules: [{ if: GITLAB_MERGE_REQUEST_SOURCE }],
|
|
103
|
-
script: [...(options.beforeScript ?? []),
|
|
130
|
+
script: [...dropControl, ...(options.beforeScript ?? []), opWavesRecordPlansCommand(options.specFile, options.bin).join(" ")],
|
|
104
131
|
},
|
|
105
132
|
]);
|
|
106
133
|
}
|
|
@@ -126,13 +153,48 @@ export function generateGitlabOpWavesPipeline(spec: OpWavesSpec, options: OpWave
|
|
|
126
153
|
variables: { ...GITLAB_FULL_HISTORY, ...(wave.variables ?? {}), ...(group?.variables ?? {}), ...(cloud ? { [TRADE_SPEC_ENV]: JSON.stringify(cloud) } : {}) },
|
|
127
154
|
rules,
|
|
128
155
|
...(job.needs.length > 0 ? { needs: job.needs } : {}),
|
|
129
|
-
script: [...(cloud ? gitlabTradeLines() : []), ...setup, ...(options.beforeScript ?? []), job.command.join(" "), ...(options.extraScript ?? [])],
|
|
156
|
+
script: [...(cloud ? gitlabTradeLines() : []), ...dropControl, ...setup, ...(options.beforeScript ?? []), job.command.join(" "), ...(options.extraScript ?? [])],
|
|
130
157
|
// #4322: a run that failed, or was blocked, lets the next wave run; any other failure stops it.
|
|
131
158
|
allow_failure: { exit_codes: [1] },
|
|
132
159
|
artifacts: { when: "always", paths: [...(job.kind === "decide" ? [job.record!] : []), `${opWaveOutcomeDir(spec)}/`], expire_in: "30 days" },
|
|
133
160
|
},
|
|
134
161
|
]);
|
|
135
162
|
}
|
|
163
|
+
if (control) {
|
|
164
|
+
const dispatch = controlDispatchJob("gitlab", control, "$CI_COMMIT_SHA", {
|
|
165
|
+
...(options.words ? { words: options.words } : {}),
|
|
166
|
+
...(spec.control!.sentCheck ? { sentCheck: spec.control!.sentCheck } : {}),
|
|
167
|
+
...(spec.control!.markSent ? { markSent: spec.control!.markSent } : {}),
|
|
168
|
+
outcome: true,
|
|
169
|
+
});
|
|
170
|
+
entries.push([
|
|
171
|
+
dispatch.name,
|
|
172
|
+
{
|
|
173
|
+
stage: "control",
|
|
174
|
+
image,
|
|
175
|
+
...(specTags ? { tags: specTags } : {}),
|
|
176
|
+
rules,
|
|
177
|
+
// The last wave's jobs hand on every job's outcome file through their artifacts.
|
|
178
|
+
needs: opWavesLastJobs(jobs),
|
|
179
|
+
// The setup and the outcome run in a subshell without the control token, and only the outcome reaches stdout.
|
|
180
|
+
script: [
|
|
181
|
+
[
|
|
182
|
+
'outcome="$(',
|
|
183
|
+
...dropControl,
|
|
184
|
+
"{ :",
|
|
185
|
+
...setupOf[0]!,
|
|
186
|
+
...(options.beforeScript ?? []),
|
|
187
|
+
"} 1>&2",
|
|
188
|
+
opWavesOutcomeCommand(options.specFile, options.bin).join(" "),
|
|
189
|
+
')" || exit 1',
|
|
190
|
+
].join("\n"),
|
|
191
|
+
`export ${CONTROL_OUTCOME_VAR}="$outcome"`,
|
|
192
|
+
...Object.entries(dispatch.env).map(([k, v]) => `export ${k}="${v}"`),
|
|
193
|
+
`bash <<'CONTROL_DISPATCH' || exit $?\n${dispatch.script}\nCONTROL_DISPATCH`,
|
|
194
|
+
],
|
|
195
|
+
},
|
|
196
|
+
]);
|
|
197
|
+
}
|
|
136
198
|
if (spec.resume) {
|
|
137
199
|
entries.push([
|
|
138
200
|
`${spec.name}-resume`,
|
|
@@ -141,7 +203,7 @@ export function generateGitlabOpWavesPipeline(spec: OpWavesSpec, options: OpWave
|
|
|
141
203
|
image,
|
|
142
204
|
...(specTags ? { tags: specTags } : {}),
|
|
143
205
|
rules: [{ if: GITLAB_SCHEDULE_SOURCE }],
|
|
144
|
-
script: [...(options.beforeScript ?? []), opWavesResumeCommand(spec).join(" ")],
|
|
206
|
+
script: [...dropControl, ...(options.beforeScript ?? []), opWavesResumeCommand(spec, options.bin).join(" ")],
|
|
145
207
|
},
|
|
146
208
|
]);
|
|
147
209
|
}
|
|
@@ -153,6 +215,7 @@ export function generateGitlabOpWavesPipeline(spec: OpWavesSpec, options: OpWave
|
|
|
153
215
|
...(spec.resume
|
|
154
216
|
? [`# The ${spec.name}-resume job retries it for you in a pipeline schedule; give the schedule the cron "${spec.resume.schedule}".`]
|
|
155
217
|
: []),
|
|
218
|
+
...(control ? [`# After the last wave, the control-dispatch job tells ${control.repo} how the waves ended.`] : []),
|
|
156
219
|
];
|
|
157
220
|
const name = options.opsFileName ?? `${spec.name}.gitlab-ci.yml`;
|
|
158
221
|
return { files: [{ name, yaml: emitGitlabFile(entries, header) }], jobs };
|
|
@@ -61,11 +61,15 @@ import {
|
|
|
61
61
|
prLoopWaveJobs,
|
|
62
62
|
prLoopWithWaves,
|
|
63
63
|
prEnvJobs,
|
|
64
|
+
prCommentLockJobs,
|
|
65
|
+
prCommentPreviewJob,
|
|
64
66
|
PR_ENV_REPORT_DIR,
|
|
65
67
|
PR_ENV_POST_JOB,
|
|
66
68
|
} from "@intentius/chant/components/pr-pipeline";
|
|
69
|
+
import { REPO_APPLY_GROUP } from "@intentius/chant/components/repo-lock";
|
|
67
70
|
import { memberGitlabChanges, memberRepoPath, memberShellDir } from "@intentius/chant/lexicon";
|
|
68
71
|
import { FORGE_TOKEN_VARS, GITLAB_DROP_FORGE_TOKENS } from "@intentius/chant/ci-handover";
|
|
72
|
+
import { CONTROL_TOKEN_VARS, controlDispatchJob, controlNoteCheckout } from "@intentius/chant/control/dispatch";
|
|
69
73
|
import { gitlabTradeIdTokens, gitlabTradeLines, TRADE_SPEC_ENV, type CloudTradeSpec } from "@intentius/chant/ci-identity";
|
|
70
74
|
import type {
|
|
71
75
|
ComponentPipelineJob as GeneratedJob,
|
|
@@ -358,6 +362,9 @@ function gitlabPlanNotesRule(branch: string): string {
|
|
|
358
362
|
return `$CI_PIPELINE_SOURCE =~ /^(schedule|web|api|trigger)$/ && $${GITLAB_PLAN_NOTES_VAR} == "1" && $CI_COMMIT_BRANCH == ${branch}`;
|
|
359
363
|
}
|
|
360
364
|
|
|
365
|
+
/** Drops the credentials GitLab's runner writes into origin's URL (#4620), before a merge request's code runs in a pipeline of the target branch. */
|
|
366
|
+
const GITLAB_SCRUB_ORIGIN = `git remote set-url origin "$(git remote get-url origin | sed -E 's#//[^@/]*@#//#')"`;
|
|
367
|
+
|
|
361
368
|
/** The rule of a pipeline for a pushed `ci/green/` tag (#4168 step 9), which runs the apply under apply on merge. */
|
|
362
369
|
const GITLAB_GREEN_TAG_RULE = '$CI_COMMIT_TAG =~ /^ci\\/green\\//';
|
|
363
370
|
|
|
@@ -397,7 +404,7 @@ function prLoopGitlabPipeline(
|
|
|
397
404
|
// The command runs through chant ci run (#4156), which writes the job's run record, signed when the CHANT_RUNNER_KEY variable is set.
|
|
398
405
|
// #4221: a wave job hands the commit it applies to the next wave as GREEN_SHA, through a dotenv report.
|
|
399
406
|
const handOff = (job: typeof plan): string[] => (job.wave ? [`echo "GREEN_SHA=$GREEN_SHA" > "$CI_PROJECT_DIR/${WAVE_GREEN_DOTENV}"`] : []);
|
|
400
|
-
const script = (job: typeof plan): string[] => [...prLoopSetup(image), ...enter, ...beforeScript, ...(job.checkout ?? []), ...handOff(job), ...(job.setup ?? []), ...(job.build ?? []), job.run.command, ...extraScript];
|
|
407
|
+
const script = (job: typeof plan): string[] => [...prLoopSetup(image, loop.engineSetup), ...enter, ...beforeScript, ...(job.checkout ?? []), ...handOff(job), ...(job.setup ?? []), ...(job.build ?? []), job.run.command, ...extraScript];
|
|
401
408
|
// #4296: each job trades its OIDC token for its cloud identity before anything else in its script.
|
|
402
409
|
const identity = loop.identity;
|
|
403
410
|
const trading = (spec: CloudTradeSpec | undefined): Record<string, unknown> =>
|
|
@@ -406,13 +413,43 @@ function prLoopGitlabPipeline(
|
|
|
406
413
|
const applySpec = (job: typeof plan): CloudTradeSpec | undefined => (identity ? (job.jobName === "record" ? identity.plan : identity.apply) : undefined);
|
|
407
414
|
// #4298: a pull request environment's jobs, in a stage of their own after the plan.
|
|
408
415
|
const prEnv = loop.prEnvironment ? prEnvGitlabJobs(image, beforeScript, extraScript, enter, loop, member, named, onMergeRequest) : undefined;
|
|
409
|
-
|
|
416
|
+
// #4568: once the last apply on merge succeeded, one job tells the control repo this member applied.
|
|
417
|
+
const lastGreen = loop.control && !loop.applyBeforeMerge && greens.length > 0 ? greens[greens.length - 1]!.name : undefined;
|
|
418
|
+
const control =
|
|
419
|
+
loop.control && lastGreen
|
|
420
|
+
? controlDispatchJob("gitlab", loop.control.target, loop.waveGate ? "$GREEN_SHA" : "$CI_COMMIT_SHA", {
|
|
421
|
+
...(loop.words ? { words: loop.words } : {}),
|
|
422
|
+
...(loop.control.sentCheck ? { sentCheck: loop.control.sentCheck } : {}),
|
|
423
|
+
...(loop.control.markSent ? { markSent: loop.control.markSent } : {}),
|
|
424
|
+
})
|
|
425
|
+
: undefined;
|
|
426
|
+
const controlJob = control ? named(control.name) : undefined;
|
|
427
|
+
// A CI/CD variable reaches every job, so the plan job drops the control repo's tokens with the forge tokens.
|
|
428
|
+
const dropControl = loop.control
|
|
429
|
+
? [`unset ${[...new Set([...CONTROL_TOKEN_VARS, loop.control.target.tokenSecret, loop.control.target.readTokenSecret])].join(" ")}`]
|
|
430
|
+
: [];
|
|
431
|
+
// #4588: `/<product> lock` and `/<product> unlock`: GitLab starts no pipeline on a note, so each runs in a
|
|
432
|
+
// pipeline of the target branch started with CHANT_LOCK_PR or CHANT_UNLOCK_PR set, as the apply before merge does.
|
|
433
|
+
const targetBranch = loop.branch ? JSON.stringify(loop.branch) : "$CI_DEFAULT_BRANCH";
|
|
434
|
+
const commentLocks = prCommentLockJobs("gitlab", loop).map((job) => ({
|
|
435
|
+
job,
|
|
436
|
+
name: named(job.jobName),
|
|
437
|
+
rule: `$CI_PIPELINE_SOURCE =~ /^(web|api|trigger)$/ && $${job.gitlabVar} =~ /^[0-9]+$/ && $CI_COMMIT_BRANCH == ${targetBranch}`,
|
|
438
|
+
}));
|
|
439
|
+
// #4620: `/<product> <plan word>`: a pipeline of the target branch started with CHANT_PREVIEW_PR set previews the merge request's head again.
|
|
440
|
+
const preview = prCommentPreviewJob("gitlab", env, loop, member?.name);
|
|
441
|
+
const previewRule = preview ? `$CI_PIPELINE_SOURCE =~ /^(web|api|trigger)$/ && $${preview.gitlabVar} =~ /^[0-9]+$/ && $CI_COMMIT_BRANCH == ${targetBranch}` : undefined;
|
|
442
|
+
const previewName = preview ? named(preview.jobName) : undefined;
|
|
443
|
+
const previewNoteName = preview ? named(preview.noteJobName) : undefined;
|
|
444
|
+
const stages = ["plan", PR_PLAN_NOTE_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] : [])];
|
|
410
445
|
const noteJob = named(PR_PLAN_NOTE_JOB);
|
|
411
|
-
const post = prLoopPostJob("gitlab", env, member?.name);
|
|
446
|
+
const post = prLoopPostJob("gitlab", env, member?.name, loop.words ? { ...(loop.words.bin ? { bin: loop.words.bin } : {}) } : undefined);
|
|
447
|
+
// #4619: with a control repo named, the note job reads its chant/lifecycle, read-only, so pr-post marks the cross-stack edges.
|
|
448
|
+
const controlRead = loop.control ? controlNoteCheckout("gitlab", loop.control.target, loop.words ?? {}) : undefined;
|
|
412
449
|
const doc: Record<string, unknown> = {
|
|
413
450
|
// Included files merge their top-level keys, so a member's file carries
|
|
414
451
|
// the rules every member shares and no name: two names would collide.
|
|
415
|
-
workflow: { ...(member ? {} : { name: `chant-pr-${env}` }), rules: [{ if: onMergeRequest }, { if: onMerge }, { if: onPlanNotes }, ...(greens.length > 0 ? [{ if: onGreen }] : []), ...(prEnv ? [{ if: GITLAB_SCHEDULE_RULE }] : [])] },
|
|
452
|
+
workflow: { ...(member ? {} : { name: `chant-pr-${env}` }), rules: [{ if: onMergeRequest }, { if: onMerge }, { if: onPlanNotes }, ...(greens.length > 0 ? [{ if: onGreen }] : []), ...commentLocks.map((c) => ({ if: c.rule })), ...(previewRule ? [{ if: previewRule }] : []), ...(prEnv ? [{ if: GITLAB_SCHEDULE_RULE }] : [])] },
|
|
416
453
|
stages,
|
|
417
454
|
variables: { ...options.variables, CHANT_ENV: env, ...GITLAB_FULL_HISTORY },
|
|
418
455
|
[planJob]: {
|
|
@@ -423,7 +460,7 @@ function prLoopGitlabPipeline(
|
|
|
423
460
|
variables: { ...plan.run.env, ...tradeVars(identity?.plan) },
|
|
424
461
|
// #4295: the merge request's code runs in this job, so the job's shell drops the forge tokens first.
|
|
425
462
|
// #4296: with identity, the trade runs before that, and the same unset drops the token variables too.
|
|
426
|
-
script: [...(identity ? gitlabTradeLines(FORGE_TOKEN_VARS) : [GITLAB_DROP_FORGE_TOKENS]), ...script(plan)],
|
|
463
|
+
script: [...(identity ? gitlabTradeLines(FORGE_TOKEN_VARS) : [GITLAB_DROP_FORGE_TOKENS]), ...dropControl, ...script(plan)],
|
|
427
464
|
artifacts: { when: "always", paths: [reportPath] },
|
|
428
465
|
},
|
|
429
466
|
// #4295, #4328: a job of the target branch's pipeline posts each open merge request's note and status from its plan job's report.
|
|
@@ -431,8 +468,55 @@ function prLoopGitlabPipeline(
|
|
|
431
468
|
stage: PR_PLAN_NOTE_JOB,
|
|
432
469
|
image,
|
|
433
470
|
rules: [{ if: onPlanNotes }],
|
|
434
|
-
script: [...enter, ...beforeScript, post.command],
|
|
471
|
+
script: [...enter, ...beforeScript, ...(controlRead ? [controlRead.script] : []), post.command],
|
|
435
472
|
},
|
|
473
|
+
...Object.fromEntries(
|
|
474
|
+
commentLocks.map(({ job, name, rule }) => [
|
|
475
|
+
name,
|
|
476
|
+
// The target branch's commit, so none of the merge request's code runs here.
|
|
477
|
+
{ stage: job.jobName, image, rules: [{ if: rule }], variables: job.env, script: [...enter, ...beforeScript, job.line] },
|
|
478
|
+
]),
|
|
479
|
+
),
|
|
480
|
+
...(preview && previewName && previewNoteName && previewRule
|
|
481
|
+
? {
|
|
482
|
+
[previewName]: {
|
|
483
|
+
stage: preview.jobName,
|
|
484
|
+
image,
|
|
485
|
+
rules: [{ if: previewRule }],
|
|
486
|
+
...trading(identity?.plan),
|
|
487
|
+
variables: { ...preview.plan.run.env, ...tradeVars(identity?.plan) },
|
|
488
|
+
// A pipeline of the target branch holds the protected tokens, so the job drops them before it checks out the merge request's head.
|
|
489
|
+
script: [
|
|
490
|
+
...(identity ? gitlabTradeLines(FORGE_TOKEN_VARS) : [GITLAB_DROP_FORGE_TOKENS]),
|
|
491
|
+
...dropControl,
|
|
492
|
+
"unset CHANT_RUNNER_KEY",
|
|
493
|
+
...("line" in preview.head ? [preview.head.line] : []),
|
|
494
|
+
// The ledger while the clone can still fetch it: the plan reads the gate ledger from the local branch.
|
|
495
|
+
"git fetch -q --no-tags origin '+refs/heads/chant/lifecycle:refs/heads/chant/lifecycle' 2>/dev/null || true",
|
|
496
|
+
// The runner's clone keeps the job token in origin's URL; the merge request's code never sees it.
|
|
497
|
+
GITLAB_SCRUB_ORIGIN,
|
|
498
|
+
preview.base,
|
|
499
|
+
...prLoopSetup(image, loop.engineSetup),
|
|
500
|
+
...enter,
|
|
501
|
+
...beforeScript,
|
|
502
|
+
...(preview.plan.setup ?? []),
|
|
503
|
+
...(preview.plan.build ?? []),
|
|
504
|
+
preview.plan.run.command,
|
|
505
|
+
...extraScript,
|
|
506
|
+
],
|
|
507
|
+
artifacts: { when: "always", paths: [reportPath] },
|
|
508
|
+
},
|
|
509
|
+
[previewNoteName]: {
|
|
510
|
+
stage: preview.noteJobName,
|
|
511
|
+
image,
|
|
512
|
+
needs: [{ job: previewName, artifacts: true }],
|
|
513
|
+
rules: [{ if: previewRule, when: "always" }],
|
|
514
|
+
variables: preview.post.env,
|
|
515
|
+
// The target branch's commit: the job's own clone, where none of the merge request's code runs.
|
|
516
|
+
script: [...enter, ...beforeScript, ...(controlRead ? [controlRead.script] : []), ...(preview.post.setup ?? []), preview.post.command],
|
|
517
|
+
},
|
|
518
|
+
}
|
|
519
|
+
: {}),
|
|
436
520
|
...Object.fromEntries(
|
|
437
521
|
applies.map(({ job, name, stage }, i) => [
|
|
438
522
|
name,
|
|
@@ -440,7 +524,8 @@ function prLoopGitlabPipeline(
|
|
|
440
524
|
stage,
|
|
441
525
|
image,
|
|
442
526
|
...(i > 1 ? { needs: [applies[i - 1]!.name] } : {}),
|
|
443
|
-
|
|
527
|
+
// #4552: on the repo grain every apply of the repository shares one resource group.
|
|
528
|
+
resource_group: job.jobName === "record" ? prRecordGroup(env, member?.name) : loop.lockGrain === "repo" ? REPO_APPLY_GROUP : prApplyGroup(env, member?.name),
|
|
444
529
|
// #4291: a wave after the first runs after one that failed, and blocks only what depends on the failure.
|
|
445
530
|
rules: [{ if: job.jobName === "record" ? onMerge : onGreen, ...(job.wave && i > 1 ? { when: "always" } : {}) }],
|
|
446
531
|
...trading(applySpec(job)),
|
|
@@ -462,20 +547,43 @@ function prLoopGitlabPipeline(
|
|
|
462
547
|
},
|
|
463
548
|
]),
|
|
464
549
|
),
|
|
550
|
+
...(control && controlJob
|
|
551
|
+
? {
|
|
552
|
+
[controlJob]: {
|
|
553
|
+
stage: control.name,
|
|
554
|
+
image,
|
|
555
|
+
needs: [lastGreen],
|
|
556
|
+
rules: [{ if: onGreen }],
|
|
557
|
+
// The runner sets a needed job's dotenv values when the script starts, so the commit is read there.
|
|
558
|
+
script: [
|
|
559
|
+
...Object.entries(control.env).map(([k, v]) => `export ${k}="${v}"`),
|
|
560
|
+
`bash <<'CONTROL_DISPATCH' || exit $?\n${control.script}\nCONTROL_DISPATCH`,
|
|
561
|
+
],
|
|
562
|
+
},
|
|
563
|
+
}
|
|
564
|
+
: {}),
|
|
465
565
|
...(prEnv ? prEnv.jobs : {}),
|
|
466
566
|
};
|
|
467
567
|
const jobs: GeneratedJob[] = [
|
|
468
568
|
{ jobName: planJob, component: "merge request plan", stage: "plan", needs: [] },
|
|
469
569
|
{ jobName: noteJob, component: "merge request plan note", stage: PR_PLAN_NOTE_JOB, needs: [] },
|
|
570
|
+
...commentLocks.map(({ job, name }) => ({ jobName: name, component: `merge request ${job.command} comment`, stage: job.jobName, needs: [] })),
|
|
571
|
+
...(preview && previewName && previewNoteName
|
|
572
|
+
? [
|
|
573
|
+
{ jobName: previewName, component: "merge request preview comment", stage: preview.jobName, needs: [] },
|
|
574
|
+
{ jobName: previewNoteName, component: "merge request preview comment note", stage: preview.noteJobName, needs: [previewName] },
|
|
575
|
+
]
|
|
576
|
+
: []),
|
|
470
577
|
...applies.map(({ job, name, stage }, i) => ({
|
|
471
578
|
jobName: name,
|
|
472
579
|
component: job.wave ? `green apply, wave ${job.wave}` : job.jobName === "record" ? "merge record" : loop.applyBeforeMerge ? "apply before merge" : "green apply",
|
|
473
580
|
stage,
|
|
474
581
|
needs: i > 1 ? [applies[i - 1]!.name] : [],
|
|
475
582
|
})),
|
|
583
|
+
...(controlJob ? [{ jobName: controlJob, component: "control repo dispatch", stage: control!.name, needs: [lastGreen!] }] : []),
|
|
476
584
|
...(prEnv ? prEnv.listed : []),
|
|
477
585
|
];
|
|
478
|
-
const yaml = emitGitlabFile(["workflow", "stages", "variables", planJob, noteJob, ...applies.map((a) => a.name), ...Object.keys(prEnv?.jobs ?? {})].map((key) => [key, doc[key]] as const));
|
|
586
|
+
const yaml = emitGitlabFile(["workflow", "stages", "variables", planJob, noteJob, ...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));
|
|
479
587
|
return { yaml, stages, jobs, env };
|
|
480
588
|
}
|
|
481
589
|
|
|
@@ -511,7 +619,7 @@ function prEnvGitlabJobs(
|
|
|
511
619
|
const reportPath = member ? memberRepoPath(member, PR_ENV_REPORT_DIR) : PR_ENV_REPORT_DIR;
|
|
512
620
|
const environment = "pr-$CI_MERGE_REQUEST_IID";
|
|
513
621
|
const group = `chant-pr-env-${member ? `${member.name}-` : ""}$CI_MERGE_REQUEST_IID`;
|
|
514
|
-
const script = (j: typeof up): string[] => [...prLoopSetup(image), ...enter, ...beforeScript, ...(j.build ?? []), j.command, ...extraScript];
|
|
622
|
+
const script = (j: typeof up): string[] => [...prLoopSetup(image, loop.engineSetup), ...enter, ...beforeScript, ...(j.build ?? []), j.command, ...extraScript];
|
|
515
623
|
const artifacts = { when: "always", paths: [reportPath] };
|
|
516
624
|
// #4330: env-up, env-down and env-sweep trade the job's OIDC token for the pull request environments' identity first.
|
|
517
625
|
const identity = loop.prEnvIdentity;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/<product> lock` and `/<product> unlock` on GitLab (#4588). GitLab starts
|
|
3
|
+
* no pipeline on a note, so each job runs in a pipeline of the target
|
|
4
|
+
* branch started with CHANT_LOCK_PR or CHANT_UNLOCK_PR set to the merge
|
|
5
|
+
* request's iid, as the apply before merge does with CHANT_APPLY_PR.
|
|
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[] = [
|
|
14
|
+
{ name: "net", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: "net" }] }] },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const WORDS = { product: "plywood", commands: { lock: "lock" as const, unlock: "unlock" as const }, bin: "npx --no-install plywood" };
|
|
18
|
+
|
|
19
|
+
interface Job { stage: string; rules: Array<{ if: string }>; variables: Record<string, string>; script: string[] }
|
|
20
|
+
type Doc = { workflow: { rules: Array<{ if: string }> }; stages: string[] } & Record<string, Job>;
|
|
21
|
+
|
|
22
|
+
describe("the GitLab pipeline's lock and unlock comments", () => {
|
|
23
|
+
const doc = parseYAML(generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { locks: "plan", words: WORDS } }).yaml) as Doc;
|
|
24
|
+
const LOCK = '$CI_PIPELINE_SOURCE =~ /^(web|api|trigger)$/ && $CHANT_LOCK_PR =~ /^[0-9]+$/ && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH';
|
|
25
|
+
|
|
26
|
+
test("each job runs in a target-branch pipeline started with its variable", () => {
|
|
27
|
+
expect(doc["comment-lock"]!.rules).toEqual([{ if: LOCK }]);
|
|
28
|
+
expect(doc.workflow.rules.map((r) => r.if)).toContain(LOCK);
|
|
29
|
+
expect(doc["comment-unlock"]!.rules[0]!.if).toContain("$CHANT_UNLOCK_PR");
|
|
30
|
+
expect(doc.stages).toEqual(expect.arrayContaining(["comment-lock", "comment-unlock"]));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("the merge request's iid comes from the variable, and the lines run the product's bin", () => {
|
|
34
|
+
expect(doc["comment-lock"]!.variables).toEqual({ PR_NUMBER: "$CHANT_LOCK_PR" });
|
|
35
|
+
expect(doc["comment-lock"]!.script.at(-1)).toBe('npx --no-install plywood lock --keep --pr "$PR_NUMBER" --forge gitlab');
|
|
36
|
+
expect(doc["comment-unlock"]!.script.at(-1)).toBe('npx --no-install plywood unlock --pr "$PR_NUMBER"');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("neither job without ci.locks", () => {
|
|
40
|
+
const plain = parseYAML(generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { words: WORDS } }).yaml) as Doc;
|
|
41
|
+
expect(plain["comment-lock"]).toBeUndefined();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The preview comment on GitLab (#4620). GitLab starts no pipeline on a
|
|
3
|
+
* note, so both jobs run in a pipeline of the target branch started with
|
|
4
|
+
* CHANT_PREVIEW_PR set to the merge request's iid. The preview job drops the
|
|
5
|
+
* protected tokens and origin's credentials before the merge request's code
|
|
6
|
+
* runs; the note job posts from its own clone.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from "vitest";
|
|
10
|
+
import { parseYAML } from "@intentius/chant/yaml";
|
|
11
|
+
import type { DriverComponent } from "@intentius/chant/components/driver";
|
|
12
|
+
import { generateGitlabPipeline } from "./generate-pipeline";
|
|
13
|
+
|
|
14
|
+
const ESTATE: DriverComponent[] = [
|
|
15
|
+
{ name: "net", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: "net" }] }] },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const WORDS = { product: "plywood", stages: { plan: "preview", apply: "up" }, commands: { preview: "plan" as const }, bin: "npx --no-install plywood" };
|
|
19
|
+
|
|
20
|
+
interface Job { stage: string; rules: Array<{ if: string; when?: string }>; needs?: unknown[]; variables: Record<string, string>; script: string[] }
|
|
21
|
+
type Doc = { workflow: { rules: Array<{ if: string }> }; stages: string[] } & Record<string, Job>;
|
|
22
|
+
|
|
23
|
+
describe("the GitLab pipeline's preview comment", () => {
|
|
24
|
+
const doc = parseYAML(generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { previewComment: true, words: WORDS } }).yaml) as Doc;
|
|
25
|
+
const RULE = '$CI_PIPELINE_SOURCE =~ /^(web|api|trigger)$/ && $CHANT_PREVIEW_PR =~ /^[0-9]+$/ && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH';
|
|
26
|
+
const preview = doc["comment-preview"]!;
|
|
27
|
+
const note = doc["comment-preview-note"]!;
|
|
28
|
+
|
|
29
|
+
test("both jobs run in a target-branch pipeline started with CHANT_PREVIEW_PR", () => {
|
|
30
|
+
expect(preview.rules).toEqual([{ if: RULE }]);
|
|
31
|
+
expect(note.rules).toEqual([{ if: RULE, when: "always" }]);
|
|
32
|
+
expect(doc.workflow.rules.map((r) => r.if)).toContain(RULE);
|
|
33
|
+
expect(doc.stages).toEqual(expect.arrayContaining(["comment-preview", "comment-preview-note"]));
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("the preview job drops the tokens, then checks out the head and scrubs origin before any of its code runs", () => {
|
|
37
|
+
const script = preview.script;
|
|
38
|
+
const drop = script.findIndex((l) => l.startsWith("unset CHANT_FORGE_TOKEN"));
|
|
39
|
+
const head = script.findIndex((l) => l.includes("refs/merge-requests/$PR_NUMBER/head"));
|
|
40
|
+
const scrub = script.findIndex((l) => l.startsWith("git remote set-url origin"));
|
|
41
|
+
const plan = script.findIndex((l) => l.includes("plywood ci run plan"));
|
|
42
|
+
expect(drop).toBeGreaterThanOrEqual(0);
|
|
43
|
+
expect(script).toContain("unset CHANT_RUNNER_KEY");
|
|
44
|
+
expect(drop).toBeLessThan(head);
|
|
45
|
+
expect(head).toBeLessThan(scrub);
|
|
46
|
+
expect(scrub).toBeLessThan(plan);
|
|
47
|
+
expect(preview.variables.PR_NUMBER).toBe("$CHANT_PREVIEW_PR");
|
|
48
|
+
expect(preview.variables.BASE_SHA).toBe("$CI_COMMIT_SHA");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("the note job finds the head again and posts for that merge request", () => {
|
|
52
|
+
expect(note.variables).toEqual({ PR_NUMBER: "$CHANT_PREVIEW_PR" });
|
|
53
|
+
expect(note.script.some((l) => l.includes("git ls-remote origin"))).toBe(true);
|
|
54
|
+
expect(note.script.at(-1)).toBe('npx --no-install plywood components pr-post --pr "$PR_NUMBER" --head "$HEAD_SHA" --env prod --output .chant/pr --forge gitlab');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("no preview job without previewComment", () => {
|
|
58
|
+
const plain = parseYAML(generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { words: WORDS } }).yaml) as Doc;
|
|
59
|
+
expect(plain["comment-preview"]).toBeUndefined();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #4568: with a control repo, the GitLab merge-request pipeline adds a
|
|
3
|
+
* `control-dispatch` job after the last apply on merge, and the plan job
|
|
4
|
+
* unsets the control repo's token variables with the forge tokens, since a
|
|
5
|
+
* CI/CD variable reaches every job. The control repo's own pipeline runs on
|
|
6
|
+
* a trigger. Generated YAML only; the request is tested against fake forges
|
|
7
|
+
* in packages/core/src/control/control.test.ts.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, expect, test } from "vitest";
|
|
11
|
+
import { parseYAML } from "@intentius/chant/yaml";
|
|
12
|
+
import type { DriverComponent } from "@intentius/chant/components/driver";
|
|
13
|
+
import { resolveControlTarget } from "@intentius/chant/control/dispatch";
|
|
14
|
+
import { generateGitlabPipeline } from "./generate-pipeline";
|
|
15
|
+
import { generateControlPipeline } from "./control-pipeline";
|
|
16
|
+
|
|
17
|
+
const root = (name: string, dependsOn: string[] = []): DriverComponent => ({ name, dependsOn, deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: name }] }] });
|
|
18
|
+
const ESTATE: DriverComponent[] = [root("net"), root("app", ["net"])];
|
|
19
|
+
const control = { target: resolveControlTarget({ repo: "gitlab.com/acme/control", op: "deploy", tokenSecret: "TERRAGUCCI_CONTROL_TOKEN" }, "network") };
|
|
20
|
+
|
|
21
|
+
interface Job {
|
|
22
|
+
stage: string;
|
|
23
|
+
needs?: string[];
|
|
24
|
+
rules: Array<{ if: string }>;
|
|
25
|
+
script: string[];
|
|
26
|
+
resource_group?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("the GitLab pipeline's control dispatch (#4568)", () => {
|
|
30
|
+
test("one job after the apply, and the plan job drops the token variables", () => {
|
|
31
|
+
const { yaml } = generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { applyOnMerge: true, control, words: { product: "terragucci" } } });
|
|
32
|
+
const doc = parseYAML(yaml) as Record<string, Job> & { stages: string[] };
|
|
33
|
+
const job = doc["control-dispatch"]!;
|
|
34
|
+
expect(job.needs).toEqual(["apply"]);
|
|
35
|
+
expect(doc.stages.at(-1)).toBe("control-dispatch");
|
|
36
|
+
expect(job.script[0]).toBe('export CHANT_CONTROL_SHA="$CI_COMMIT_SHA"');
|
|
37
|
+
expect(job.script[1]).toContain('CHANT_CONTROL_TOKEN="${TERRAGUCCI_CONTROL_TOKEN:-}"');
|
|
38
|
+
expect(job.script[1]).toContain("/trigger/pipeline");
|
|
39
|
+
expect(doc.plan!.script.some((l) => l.startsWith("unset ") && l.includes("TERRAGUCCI_CONTROL_TOKEN") && l.includes("CHANT_CONTROL_TOKEN"))).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("with a gate per wave, the job follows the last wave and reads its GREEN_SHA", () => {
|
|
43
|
+
const { yaml } = generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { applyOnMerge: true, control }, gatedWaves: { gate: "release" } });
|
|
44
|
+
const doc = parseYAML(yaml) as Record<string, Job>;
|
|
45
|
+
expect(doc["control-dispatch"]!.needs).toEqual(["apply-wave-2"]);
|
|
46
|
+
expect(doc["control-dispatch"]!.script[0]).toBe('export CHANT_CONTROL_SHA="$GREEN_SHA"');
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("the control repo's pipeline on GitLab (#4568)", () => {
|
|
51
|
+
test("a trigger with the member and Op runs the dependents, one member at a time", () => {
|
|
52
|
+
const file = generateControlPipeline({ words: { product: "terragucci" } });
|
|
53
|
+
expect(file.path).toBe(".gitlab/terragucci-dependents.yml");
|
|
54
|
+
const doc = parseYAML(file.content) as Record<string, Job>;
|
|
55
|
+
expect(doc.dependents!.rules[0]!.if).toBe('$CI_PIPELINE_SOURCE == "trigger" && $CHANT_CONTROL_MEMBER && $CHANT_CONTROL_OP');
|
|
56
|
+
expect(doc.dependents!.resource_group).toBe("terragucci-dependents-$CHANT_CONTROL_MEMBER");
|
|
57
|
+
expect(doc.dependents!.script[0]).toContain("workspace run --dependents-of");
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// #4619: the note job reads the control repo, read-only, so the note marks the cross-stack edges.
|
|
62
|
+
describe("the GitLab plan-note job's read of the control repo (#4619)", () => {
|
|
63
|
+
test("fetches the control repo's chant/lifecycle before it posts, and exports CHANT_CONTROL_DIR", () => {
|
|
64
|
+
const { yaml } = generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { applyOnMerge: true, control, words: { product: "terragucci" } } });
|
|
65
|
+
const doc = parseYAML(yaml) as Record<string, Job>;
|
|
66
|
+
const script = doc["plan-note"]!.script;
|
|
67
|
+
const read = script.findIndex((l) => l.includes("refs/heads/chant/lifecycle"));
|
|
68
|
+
expect(read).toBeGreaterThan(-1);
|
|
69
|
+
expect(script[read + 1]).toContain("components pr-post");
|
|
70
|
+
expect(script[read]).toContain('CHANT_CONTROL_READ_TOKEN="${CHANT_CONTROL_READ_TOKEN:-}"');
|
|
71
|
+
expect(script[read]).toContain('export CHANT_CONTROL_DIR="$chant_control_dir"');
|
|
72
|
+
expect(script[read]).toContain("'https://gitlab.com/acme/control.git'");
|
|
73
|
+
// The dispatch token is not the note job's.
|
|
74
|
+
expect(script[read]).not.toContain("TERRAGUCCI_CONTROL_TOKEN");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("without a control repo the note job reads none", () => {
|
|
78
|
+
const { yaml } = generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { applyOnMerge: true } });
|
|
79
|
+
expect(yaml).not.toContain("CHANT_CONTROL_DIR");
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #4549: with a Pulumi Cloud identity, the merge-request pipeline's plan and
|
|
3
|
+
* record jobs trade their `id_tokens` for a preview token and the apply job
|
|
4
|
+
* for an up token. No job reads a Pulumi token from a CI/CD variable, and
|
|
5
|
+
* the plan job names no up token. Generated YAML only; the trade itself is
|
|
6
|
+
* tested against the fake exchange in packages/core/src/ci-identity-pulumi.test.ts.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from "vitest";
|
|
10
|
+
import { parseYAML } from "@intentius/chant/yaml";
|
|
11
|
+
import type { DriverComponent } from "@intentius/chant/components/driver";
|
|
12
|
+
import { gitlabTradeLines, resolveCloudIdentity, TRADE_SPEC_ENV } from "@intentius/chant/ci-identity";
|
|
13
|
+
import { FORGE_TOKEN_VARS } from "@intentius/chant/ci-handover";
|
|
14
|
+
import { generateGitlabPipeline } from "./generate-pipeline";
|
|
15
|
+
|
|
16
|
+
const ESTATE: DriverComponent[] = [{ name: "app", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: "app" }] }] }];
|
|
17
|
+
|
|
18
|
+
const IDENTITY = resolveCloudIdentity({ pulumi: { organization: "acme", rules: [{ plan: "team:preview", apply: "team:deploy" }] } }, "prod")!;
|
|
19
|
+
|
|
20
|
+
interface GitlabJob {
|
|
21
|
+
id_tokens?: Record<string, { aud: string }>;
|
|
22
|
+
variables?: Record<string, string>;
|
|
23
|
+
script: string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("the GitLab pipeline's Pulumi Cloud tokens (#4549)", () => {
|
|
27
|
+
const { yaml } = generateGitlabPipeline(ESTATE, { env: "prod", image: "example/ci:1", prLoop: { applyOnMerge: true, identity: IDENTITY } });
|
|
28
|
+
const doc = parseYAML(yaml) as Record<string, GitlabJob>;
|
|
29
|
+
|
|
30
|
+
test("the plan job asks for the Pulumi id token and trades for the preview token before anything else", () => {
|
|
31
|
+
expect(doc.plan!.id_tokens).toEqual({ CHANT_ID_TOKEN_PULUMI: { aud: "urn:pulumi:org:acme" } });
|
|
32
|
+
expect(doc.plan!.script.slice(0, 3)).toEqual(gitlabTradeLines(FORGE_TOKEN_VARS));
|
|
33
|
+
expect(JSON.parse(doc.plan!.variables![TRADE_SPEC_ENV]!)).toEqual(IDENTITY.plan);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("the record job takes the preview token and the apply job the up token", () => {
|
|
37
|
+
expect(JSON.parse(doc.record!.variables![TRADE_SPEC_ENV]!)).toEqual(IDENTITY.plan);
|
|
38
|
+
expect(JSON.parse(doc.apply!.variables![TRADE_SPEC_ENV]!)).toEqual(IDENTITY.apply);
|
|
39
|
+
expect(doc.apply!.id_tokens).toEqual(doc.plan!.id_tokens);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("the plan job names no up token, and nothing reads PULUMI_ACCESS_TOKEN from a CI/CD variable", () => {
|
|
43
|
+
expect(JSON.stringify(doc.plan)).not.toContain("team:deploy");
|
|
44
|
+
expect(yaml).not.toContain("$PULUMI_ACCESS_TOKEN");
|
|
45
|
+
});
|
|
46
|
+
});
|