@intentius/chant-lexicon-gitlab 0.124.54 → 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/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 +147 -23
- package/dist/components/generate-op-waves-pipeline.d.ts +25 -2
- package/dist/components/generate-op-waves-pipeline.d.ts.map +1 -1
- package/dist/components/generate-op-waves-pipeline.js +110 -12
- package/dist/components/generate-pipeline.d.ts.map +1 -1
- package/dist/components/generate-pipeline.js +135 -11
- 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/__golden__/op-waves-jobs.yml +11 -1
- 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.cloud.test.ts +40 -0
- package/src/components/generate-op-pipeline.review.test.ts +72 -7
- package/src/components/generate-op-pipeline.ts +157 -24
- package/src/components/generate-op-waves-pipeline.approval.test.ts +28 -0
- package/src/components/generate-op-waves-pipeline.control.test.ts +55 -0
- package/src/components/generate-op-waves-pipeline.guard.test.ts +63 -0
- package/src/components/generate-op-waves-pipeline.test.ts +22 -3
- package/src/components/generate-op-waves-pipeline.ts +123 -12
- package/src/components/generate-pipeline.ts +143 -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.comment-targets.test.ts +20 -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/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
- package/src/components/review-jobs.ts +163 -0
|
@@ -103,6 +103,15 @@
|
|
|
103
103
|
* internal and always wins at the job over a same-named spec entry, so a
|
|
104
104
|
* caller cannot point the gate block somewhere the `artifacts:` declaration
|
|
105
105
|
* beside it does not also point.
|
|
106
|
+
*
|
|
107
|
+
* `reviewAgent` (#4544) splits the review in two, as on GitHub and Forgejo,
|
|
108
|
+
* with GitLab's own shape (`./review-jobs.ts`). The review job runs in the
|
|
109
|
+
* merge request pipeline, unsets every forge token variable and keeps the
|
|
110
|
+
* review as its artifact. Its note job runs in a pipeline of the default
|
|
111
|
+
* branch that a schedule starts with `CHANT_REVIEW_NOTES` set to `1`, checks
|
|
112
|
+
* nothing out and posts each open merge request's review with the protected
|
|
113
|
+
* `CHANT_FORGE_TOKEN`. The header says to create that schedule and protect
|
|
114
|
+
* the token.
|
|
106
115
|
*/
|
|
107
116
|
|
|
108
117
|
import {
|
|
@@ -114,6 +123,8 @@ import {
|
|
|
114
123
|
gitlabIdTokens,
|
|
115
124
|
gitlabRules,
|
|
116
125
|
gitlabSetupScript,
|
|
126
|
+
gitlabTags,
|
|
127
|
+
assertRunnerLabels,
|
|
117
128
|
nonEmpty,
|
|
118
129
|
pushBranches,
|
|
119
130
|
} from "@intentius/chant/ci-jobs";
|
|
@@ -129,6 +140,12 @@ import type {
|
|
|
129
140
|
} from "@intentius/chant/lexicon";
|
|
130
141
|
|
|
131
142
|
import { emitGitlabFile } from "./ci-jobs";
|
|
143
|
+
import { assertReviewSplit, GITLAB_REVIEW_NOTES_VAR, gitlabReviewJobs, REVIEW_DIR, reviewNoteJobName } from "./review-jobs";
|
|
144
|
+
import { AGENT_DIR, resolveAgentSpec } from "@intentius/chant/ci-agent";
|
|
145
|
+
import { activeProductWords } from "@intentius/chant/ci-product-words";
|
|
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";
|
|
132
149
|
|
|
133
150
|
export type { GenerateGitlabOpOptions, GenerateGitlabOpResult };
|
|
134
151
|
|
|
@@ -202,24 +219,6 @@ function assertTriggerSupportsMode(name: string, mode: OpFindingMode, trigger: O
|
|
|
202
219
|
);
|
|
203
220
|
}
|
|
204
221
|
|
|
205
|
-
/**
|
|
206
|
-
* Refuse `reviewAgent` (#4326). The split posts the review from a second job
|
|
207
|
-
* that holds the forge write token, and a GitLab merge request pipeline hands
|
|
208
|
-
* every job the project's unprotected CI/CD variables, the review job's
|
|
209
|
-
* included. A token protected enough to stay out of it is not available to a
|
|
210
|
-
* job of that pipeline either, so the post has to start from a pipeline of
|
|
211
|
-
* the default branch, which this generator does not build. The refusal names
|
|
212
|
-
* the Op and the way out rather than emitting a review job that holds the token.
|
|
213
|
-
*/
|
|
214
|
-
function assertNoReviewSplit(spec: ScheduledOpSpec): void {
|
|
215
|
-
if (!spec.reviewAgent) return;
|
|
216
|
-
throw new Error(
|
|
217
|
-
`Scheduled Op "${spec.name}" sets reviewAgent, which keeps the model's job free of the forge write token and posts the note from a second job. ` +
|
|
218
|
-
`A GitLab merge request pipeline hands every job the project's unprotected variables, so the gitlab generator does not build that split. ` +
|
|
219
|
-
`Drop reviewAgent and run the review with findingMode "comment" (the model command still runs with a cleared environment), or generate this Op for GitHub or Forgejo.`,
|
|
220
|
-
);
|
|
221
|
-
}
|
|
222
|
-
|
|
223
222
|
/**
|
|
224
223
|
* The follow-up line an Op with an `environment` adds under its own header
|
|
225
224
|
* line (#2257). The `environment:` key on the job binds it to the
|
|
@@ -303,20 +302,70 @@ export function generateGitlabOpPipeline(
|
|
|
303
302
|
|
|
304
303
|
const opLines: string[] = [];
|
|
305
304
|
let anyCron = false;
|
|
305
|
+
let anyReview = false;
|
|
306
|
+
// The note job of each split review (#4544), emitted after the Op jobs.
|
|
307
|
+
const noteJobs: string[] = [];
|
|
308
|
+
const words = activeProductWords();
|
|
306
309
|
|
|
307
310
|
for (const spec of ops) {
|
|
308
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
|
+
}
|
|
317
|
+
// #4616: GitLab picks a runner by tags.
|
|
318
|
+
assertRunnerLabels(subject, "runner", spec.runner, "GitLab");
|
|
319
|
+
const tags = gitlabTags(spec.runner);
|
|
309
320
|
const setupScript = gitlabSetupScript(subject, spec.setup);
|
|
310
|
-
|
|
321
|
+
// #4616: the comment agent is its own pair of jobs, in a pipeline of the default branch; the spec's trigger is not read.
|
|
322
|
+
if (spec.agent?.on === "comment") {
|
|
323
|
+
const settings = resolveAgentSpec(spec.name, spec.agent, words.product);
|
|
324
|
+
const jobName = jobPrefix + ciJobName(spec.name);
|
|
325
|
+
jobs.push({ jobName, op: spec.name, trigger: { kind: "pull_request" }, findingMode: "report" });
|
|
326
|
+
opLines.push(...gitlabAgentHeader(jobName, settings, spec.agent.tokenEnv));
|
|
327
|
+
const { agent, push } = gitlabAgentJobs({
|
|
328
|
+
jobName,
|
|
329
|
+
stage,
|
|
330
|
+
image,
|
|
331
|
+
settings,
|
|
332
|
+
...(spec.runner !== undefined ? { runner: spec.runner } : {}),
|
|
333
|
+
script: [...setupScript, ...beforeScript],
|
|
334
|
+
...(spec.agent.tokenEnv ? { tokenEnv: spec.agent.tokenEnv } : {}),
|
|
335
|
+
...(words.bin ? { bin: words.bin } : {}),
|
|
336
|
+
});
|
|
337
|
+
doc[jobName] = agent;
|
|
338
|
+
const pushName = gitlabAgentPushJobName(jobName);
|
|
339
|
+
doc[pushName] = push;
|
|
340
|
+
noteJobs.push(pushName);
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
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;
|
|
311
347
|
const findingMode = spec.findingMode ?? "report";
|
|
312
348
|
const trigger = resolveOpTrigger(spec);
|
|
313
349
|
assertTriggerSupportsMode(spec.name, findingMode, trigger);
|
|
314
|
-
|
|
350
|
+
// A split review (#4544): the merge request pipeline runs the model with no forge token, and a pipeline of the default branch posts.
|
|
351
|
+
const split = spec.reviewAgent === true;
|
|
352
|
+
if (split) assertReviewSplit(spec.name, trigger, findingMode, spec.permissions);
|
|
315
353
|
if (trigger.kind === "cron") anyCron = true;
|
|
354
|
+
const driftAgent = spec.agent?.on === "drift" ? resolveAgentSpec(spec.name, spec.agent, words.product) : undefined;
|
|
355
|
+
if (driftAgent && (split || trigger.kind === "push")) {
|
|
356
|
+
throw new Error(`${subject} sets a drift agent, which follows a drift check's run; give it a cron or pull_request trigger and no reviewAgent.`);
|
|
357
|
+
}
|
|
316
358
|
|
|
317
359
|
const jobName = jobPrefix + ciJobName(spec.name);
|
|
318
360
|
jobs.push({ jobName, op: spec.name, trigger, findingMode });
|
|
319
361
|
opLines.push(headerLineFor(spec, trigger, jobName, findingMode));
|
|
362
|
+
if (split) {
|
|
363
|
+
anyReview = true;
|
|
364
|
+
opLines.push(
|
|
365
|
+
`# reviewAgent: the review job holds no forge token and keeps its review as an artifact; ${reviewNoteJobName(jobName)} posts it` +
|
|
366
|
+
` from a pipeline of the default branch started with ${GITLAB_REVIEW_NOTES_VAR}="1"`,
|
|
367
|
+
);
|
|
368
|
+
}
|
|
320
369
|
if (spec.environment) {
|
|
321
370
|
assertGitlabEnvironment(spec.name, spec.environment);
|
|
322
371
|
opLines.push(environmentLine(spec.environment));
|
|
@@ -337,28 +386,102 @@ export function generateGitlabOpPipeline(
|
|
|
337
386
|
// than a caller being able to repoint the gate block's own artifact path.
|
|
338
387
|
const jobVariables: Record<string, string> = {
|
|
339
388
|
...(spec.variables ?? {}),
|
|
389
|
+
...(spec.cloud ? { [TRADE_SPEC_ENV]: JSON.stringify(spec.cloud) } : {}),
|
|
340
390
|
...(gated ? { [GATE_SUMMARY_VAR]: gateSummary } : {}),
|
|
341
391
|
};
|
|
342
392
|
|
|
393
|
+
const rulesFor = changes && trigger.kind !== "cron" ? rules.map((r) => ({ ...r, changes })) : rules;
|
|
394
|
+
if (split) {
|
|
395
|
+
const { review, note } = gitlabReviewJobs({
|
|
396
|
+
name: spec.name,
|
|
397
|
+
jobName,
|
|
398
|
+
stage,
|
|
399
|
+
image,
|
|
400
|
+
rules: rulesFor,
|
|
401
|
+
script: [...cdLine, ...setupScript, ...beforeScript, invocation.join(" "), ...extraScript],
|
|
402
|
+
...(spec.variables ? { variables: spec.variables } : {}),
|
|
403
|
+
...(idTokens ? { idTokens } : {}),
|
|
404
|
+
artifactDir: member ? memberRepoPath(member, REVIEW_DIR) : REVIEW_DIR,
|
|
405
|
+
...(trigger.kind === "pull_request" && trigger.branches?.length ? { branches: trigger.branches } : {}),
|
|
406
|
+
});
|
|
407
|
+
doc[jobName] = { ...review, ...(tags ? { tags } : {}), ...(spec.environment ? { environment: environmentDoc(spec.environment) } : {}) };
|
|
408
|
+
const noteName = reviewNoteJobName(jobName);
|
|
409
|
+
doc[noteName] = { ...note, ...(tags ? { tags } : {}) };
|
|
410
|
+
noteJobs.push(noteName);
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
|
|
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;
|
|
343
418
|
doc[jobName] = {
|
|
344
419
|
stage,
|
|
345
420
|
image,
|
|
421
|
+
...(tags ? { tags } : {}),
|
|
346
422
|
// GitLab's stand-in for github's per-Op concurrency group: queue the
|
|
347
423
|
// next run rather than cancel the current one.
|
|
348
424
|
resource_group: jobName,
|
|
349
425
|
...(idTokens ? { id_tokens: idTokens } : {}),
|
|
350
426
|
...(spec.environment ? { environment: environmentDoc(spec.environment) } : {}),
|
|
351
427
|
...nonEmpty("variables", jobVariables),
|
|
352
|
-
rules:
|
|
353
|
-
|
|
428
|
+
rules: rulesFor,
|
|
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],
|
|
354
431
|
// `when: always` because the run this publishes for is the green one: a
|
|
355
432
|
// gated apply succeeds, and the block is the only thing that says a
|
|
356
433
|
// human still has to act. A run that walked through its gate writes no
|
|
357
434
|
// block, and GitLab reports the empty upload as a warning, not a failure.
|
|
358
435
|
...(gated
|
|
359
436
|
? { artifacts: { when: "always", paths: [member ? memberRepoPath(member, gateSummary) : gateSummary], expire_in: GATE_ARTIFACT_EXPIRY } }
|
|
360
|
-
:
|
|
437
|
+
: flagsDrift
|
|
438
|
+
? { artifacts: gitlabDriftArtifacts(driftDir) }
|
|
439
|
+
: {}),
|
|
361
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
|
+
}
|
|
465
|
+
if (driftAgent) {
|
|
466
|
+
const name = `${jobName}-agent`;
|
|
467
|
+
opLines.push(...gitlabAgentHeader(name, driftAgent, spec.agent?.tokenEnv));
|
|
468
|
+
const { agent, push } = gitlabAgentJobs({
|
|
469
|
+
jobName: name,
|
|
470
|
+
stage,
|
|
471
|
+
image,
|
|
472
|
+
settings: driftAgent,
|
|
473
|
+
...(spec.runner !== undefined ? { runner: spec.runner } : {}),
|
|
474
|
+
script: [...setupScript, ...beforeScript],
|
|
475
|
+
...(spec.agent?.tokenEnv ? { tokenEnv: spec.agent.tokenEnv } : {}),
|
|
476
|
+
...(words.bin ? { bin: words.bin } : {}),
|
|
477
|
+
after: { job: jobName, rules: rulesFor },
|
|
478
|
+
// #4619: a member's drift Op left the drift under the member's directory.
|
|
479
|
+
...(member ? { dir: memberRepoPath(member, AGENT_DIR) } : {}),
|
|
480
|
+
});
|
|
481
|
+
doc[name] = agent;
|
|
482
|
+
doc[gitlabAgentPushJobName(name)] = push;
|
|
483
|
+
noteJobs.push(name, gitlabAgentPushJobName(name));
|
|
484
|
+
}
|
|
362
485
|
}
|
|
363
486
|
|
|
364
487
|
const headerLines = [
|
|
@@ -375,10 +498,20 @@ export function generateGitlabOpPipeline(
|
|
|
375
498
|
"# that job runs on that schedule.",
|
|
376
499
|
);
|
|
377
500
|
}
|
|
501
|
+
if (anyReview) {
|
|
502
|
+
headerLines.push(
|
|
503
|
+
"#",
|
|
504
|
+
"# A reviewAgent Op's note job posts from a pipeline of the default branch,",
|
|
505
|
+
`# never from the merge request's own. Create a Pipeline Schedule on the`,
|
|
506
|
+
`# default branch with ${GITLAB_REVIEW_NOTES_VAR}="1", and make CHANT_FORGE_TOKEN`,
|
|
507
|
+
"# (a token with the api scope) a protected, masked CI/CD variable so no",
|
|
508
|
+
"# merge request pipeline gets it.",
|
|
509
|
+
);
|
|
510
|
+
}
|
|
378
511
|
headerLines.push("#", ...opLines);
|
|
379
512
|
|
|
380
513
|
const yaml = emitGitlabFile(
|
|
381
|
-
[["stages", doc.stages], ...(doc.variables ? [["variables", doc.variables] as const] : []), ...jobs.map(({ jobName }) => [jobName, doc[jobName]] as const)],
|
|
514
|
+
[["stages", doc.stages], ...(doc.variables ? [["variables", doc.variables] as const] : []), ...jobs.map(({ jobName }) => [jobName, doc[jobName]] as const), ...noteJobs.map((name) => [name, doc[name]] as const)],
|
|
382
515
|
headerLines,
|
|
383
516
|
);
|
|
384
517
|
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An Op waves spec's `control` on GitLab (#4617): a control stage after the
|
|
3
|
+
* waves, the outcome added up without the token, and every other job
|
|
4
|
+
* dropping the control repo's token variables. Written for the ci-products
|
|
5
|
+
* session; run at epic-ready.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from "vitest";
|
|
9
|
+
import type { OpWavesSpec } from "@intentius/chant/op/op-waves";
|
|
10
|
+
import { generateGitlabOpWavesPipeline } from "./generate-op-waves-pipeline";
|
|
11
|
+
|
|
12
|
+
const spec: OpWavesSpec = {
|
|
13
|
+
name: "stacks",
|
|
14
|
+
op: "pulumi-up",
|
|
15
|
+
plan: ["./bin/plan", "{target}", "{plan}"],
|
|
16
|
+
waves: [
|
|
17
|
+
{ name: "canary", runs: [{ target: "dev" }], gate: "never" },
|
|
18
|
+
{ name: "rest", runs: [{ target: "a" }] },
|
|
19
|
+
],
|
|
20
|
+
resume: { schedule: "*/10 * * * *" },
|
|
21
|
+
control: { repo: "gitlab.com/acme/control", op: "deploy", member: "network", tokenSecret: "PLYWOOD_CONTROL_TOKEN" },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const options = { specFile: "waves.json", bin: "npx --no-install plywood", words: { product: "plywood" } };
|
|
25
|
+
|
|
26
|
+
describe("the control dispatch after the last wave on GitLab (#4617)", () => {
|
|
27
|
+
test("a control stage after the waves, needing the last wave's job", () => {
|
|
28
|
+
const yaml = generateGitlabOpWavesPipeline(spec, options).files[0]!.yaml;
|
|
29
|
+
expect(yaml).toMatch(/stages:\n\s+- wave-1-canary\n\s+- wave-2-rest\n\s+- control\n\s+- resume/);
|
|
30
|
+
expect(yaml).toMatch(/control-dispatch:\n\s+stage: control[\s\S]*needs:\n\s+- wave-2-rest\n/);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("adds up the outcome in a subshell without the token, then sends it with the commit", () => {
|
|
34
|
+
const yaml = generateGitlabOpWavesPipeline(spec, options).files[0]!.yaml;
|
|
35
|
+
expect(yaml).toContain("unset CHANT_CONTROL_TOKEN CHANT_CONTROL_READ_TOKEN PLYWOOD_CONTROL_TOKEN\n { :\n } 1>&2\n npx --no-install plywood run wave --spec waves.json --waves-outcome");
|
|
36
|
+
expect(yaml).toContain('export CHANT_CONTROL_OUTCOME="$outcome"');
|
|
37
|
+
expect(yaml).toContain('export CHANT_CONTROL_SHA="$CI_COMMIT_SHA"');
|
|
38
|
+
expect(yaml).toContain('CHANT_CONTROL_TOKEN="${PLYWOOD_CONTROL_TOKEN:-}"');
|
|
39
|
+
expect(yaml).toContain("--form 'token=<-'");
|
|
40
|
+
expect(yaml).toContain('--form "variables[CHANT_CONTROL_OUTCOME]=$outcome"');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("every wave job and the resume job drop the control repo's token variables first", () => {
|
|
44
|
+
const yaml = generateGitlabOpWavesPipeline(spec, options).files[0]!.yaml;
|
|
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
|
+
expect(yaml).toMatch(/stacks-resume:[\s\S]*script:\n\s+- unset CHANT_CONTROL_TOKEN CHANT_CONTROL_READ_TOKEN PLYWOOD_CONTROL_TOKEN/);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("no control, no stage, no unset", () => {
|
|
50
|
+
const { control: _drop, ...plain } = spec;
|
|
51
|
+
const yaml = generateGitlabOpWavesPipeline(plain, options).files[0]!.yaml;
|
|
52
|
+
expect(yaml).not.toContain("control-dispatch");
|
|
53
|
+
expect(yaml).not.toContain("unset CHANT_CONTROL_TOKEN");
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -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,11 +122,30 @@ 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\"");
|
|
130
132
|
expect(JSON.stringify(doc["wave-1-prod-default"])).not.toContain("prod-apply");
|
|
131
133
|
});
|
|
134
|
+
|
|
135
|
+
test("words.bin is the command in every job line: the waves, resume and the plans job (#4607)", () => {
|
|
136
|
+
const bin = "npx --no-install plywood";
|
|
137
|
+
const withBin = { ...spec, resume: { schedule: "*/10 * * * *" }, waves: spec.waves.map((w, i) => (i === spec.waves.length - 1 ? { ...w, approval: "pr-review" as const } : w)) };
|
|
138
|
+
const { files, jobs } = generateGitlabOpWavesPipeline(withBin, { specFile: "waves.json", bin });
|
|
139
|
+
expect(jobs.every((j) => j.command.slice(0, 3).join(" ") === bin)).toBe(true);
|
|
140
|
+
const yaml = files.map((f) => f.yaml).join("\n");
|
|
141
|
+
expect(yaml).toContain("- npx --no-install plywood run wave --spec waves.json --wave 1");
|
|
142
|
+
expect(yaml).toContain("npx --no-install plywood run resume --op migrations");
|
|
143
|
+
expect(yaml).toContain("npx --no-install plywood run wave --spec waves.json --record-plans");
|
|
144
|
+
expect(yaml).not.toMatch(/(^|\s)chant run (wave|resume)/m);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("no bin keeps chant (#4607)", () => {
|
|
148
|
+
const { jobs } = generateGitlabOpWavesPipeline(spec, { specFile: "waves.json" });
|
|
149
|
+
expect(jobs.every((j) => j.command[0] === "chant")).toBe(true);
|
|
150
|
+
});
|
|
132
151
|
});
|