@intentius/chant-lexicon-gitlab 0.124.54 → 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
|
@@ -35,9 +35,11 @@ import { assertGitlabBranch, CI_IMAGE, ciJobName, fillCommand, GITLAB_FULL_HISTO
|
|
|
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, 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, } from "@intentius/chant/components/pr-pipeline";
|
|
39
|
+
import { REPO_APPLY_GROUP } from "@intentius/chant/components/repo-lock";
|
|
39
40
|
import { memberGitlabChanges, memberRepoPath, memberShellDir } from "@intentius/chant/lexicon";
|
|
40
41
|
import { FORGE_TOKEN_VARS, GITLAB_DROP_FORGE_TOKENS } from "@intentius/chant/ci-handover";
|
|
42
|
+
import { CONTROL_TOKEN_VARS, controlDispatchJob, controlNoteCheckout } from "@intentius/chant/control/dispatch";
|
|
41
43
|
import { gitlabTradeIdTokens, gitlabTradeLines, TRADE_SPEC_ENV } from "@intentius/chant/ci-identity";
|
|
42
44
|
import { emitGitlabFile } from "./ci-jobs.js";
|
|
43
45
|
/**
|
|
@@ -303,6 +305,8 @@ function gitlabApplyPrRule(branch) {
|
|
|
303
305
|
function gitlabPlanNotesRule(branch) {
|
|
304
306
|
return `$CI_PIPELINE_SOURCE =~ /^(schedule|web|api|trigger)$/ && $${GITLAB_PLAN_NOTES_VAR} == "1" && $CI_COMMIT_BRANCH == ${branch}`;
|
|
305
307
|
}
|
|
308
|
+
/** 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. */
|
|
309
|
+
const GITLAB_SCRUB_ORIGIN = `git remote set-url origin "$(git remote get-url origin | sed -E 's#//[^@/]*@#//#')"`;
|
|
306
310
|
/** The rule of a pipeline for a pushed `ci/green/` tag (#4168 step 9), which runs the apply under apply on merge. */
|
|
307
311
|
const GITLAB_GREEN_TAG_RULE = '$CI_COMMIT_TAG =~ /^ci\\/green\\//';
|
|
308
312
|
function prLoopGitlabPipeline(components, env, image, beforeScript, extraScript, options) {
|
|
@@ -335,7 +339,7 @@ function prLoopGitlabPipeline(components, env, image, beforeScript, extraScript,
|
|
|
335
339
|
// The command runs through chant ci run (#4156), which writes the job's run record, signed when the CHANT_RUNNER_KEY variable is set.
|
|
336
340
|
// #4221: a wave job hands the commit it applies to the next wave as GREEN_SHA, through a dotenv report.
|
|
337
341
|
const handOff = (job) => (job.wave ? [`echo "GREEN_SHA=$GREEN_SHA" > "$CI_PROJECT_DIR/${WAVE_GREEN_DOTENV}"`] : []);
|
|
338
|
-
const script = (job) => [...prLoopSetup(image), ...enter, ...beforeScript, ...(job.checkout ?? []), ...handOff(job), ...(job.setup ?? []), ...(job.build ?? []), job.run.command, ...extraScript];
|
|
342
|
+
const script = (job) => [...prLoopSetup(image, loop.engineSetup), ...enter, ...beforeScript, ...(job.checkout ?? []), ...handOff(job), ...(job.setup ?? []), ...(job.build ?? []), job.run.command, ...extraScript];
|
|
339
343
|
// #4296: each job trades its OIDC token for its cloud identity before anything else in its script.
|
|
340
344
|
const identity = loop.identity;
|
|
341
345
|
const trading = (spec) => spec ? { id_tokens: gitlabTradeIdTokens(spec) } : {};
|
|
@@ -343,13 +347,42 @@ function prLoopGitlabPipeline(components, env, image, beforeScript, extraScript,
|
|
|
343
347
|
const applySpec = (job) => (identity ? (job.jobName === "record" ? identity.plan : identity.apply) : undefined);
|
|
344
348
|
// #4298: a pull request environment's jobs, in a stage of their own after the plan.
|
|
345
349
|
const prEnv = loop.prEnvironment ? prEnvGitlabJobs(image, beforeScript, extraScript, enter, loop, member, named, onMergeRequest) : undefined;
|
|
346
|
-
|
|
350
|
+
// #4568: once the last apply on merge succeeded, one job tells the control repo this member applied.
|
|
351
|
+
const lastGreen = loop.control && !loop.applyBeforeMerge && greens.length > 0 ? greens[greens.length - 1].name : undefined;
|
|
352
|
+
const control = loop.control && lastGreen
|
|
353
|
+
? controlDispatchJob("gitlab", loop.control.target, loop.waveGate ? "$GREEN_SHA" : "$CI_COMMIT_SHA", {
|
|
354
|
+
...(loop.words ? { words: loop.words } : {}),
|
|
355
|
+
...(loop.control.sentCheck ? { sentCheck: loop.control.sentCheck } : {}),
|
|
356
|
+
...(loop.control.markSent ? { markSent: loop.control.markSent } : {}),
|
|
357
|
+
})
|
|
358
|
+
: undefined;
|
|
359
|
+
const controlJob = control ? named(control.name) : undefined;
|
|
360
|
+
// A CI/CD variable reaches every job, so the plan job drops the control repo's tokens with the forge tokens.
|
|
361
|
+
const dropControl = loop.control
|
|
362
|
+
? [`unset ${[...new Set([...CONTROL_TOKEN_VARS, loop.control.target.tokenSecret, loop.control.target.readTokenSecret])].join(" ")}`]
|
|
363
|
+
: [];
|
|
364
|
+
// #4588: `/<product> lock` and `/<product> unlock`: GitLab starts no pipeline on a note, so each runs in a
|
|
365
|
+
// pipeline of the target branch started with CHANT_LOCK_PR or CHANT_UNLOCK_PR set, as the apply before merge does.
|
|
366
|
+
const targetBranch = loop.branch ? JSON.stringify(loop.branch) : "$CI_DEFAULT_BRANCH";
|
|
367
|
+
const commentLocks = prCommentLockJobs("gitlab", loop).map((job) => ({
|
|
368
|
+
job,
|
|
369
|
+
name: named(job.jobName),
|
|
370
|
+
rule: `$CI_PIPELINE_SOURCE =~ /^(web|api|trigger)$/ && $${job.gitlabVar} =~ /^[0-9]+$/ && $CI_COMMIT_BRANCH == ${targetBranch}`,
|
|
371
|
+
}));
|
|
372
|
+
// #4620: `/<product> <plan word>`: a pipeline of the target branch started with CHANT_PREVIEW_PR set previews the merge request's head again.
|
|
373
|
+
const preview = prCommentPreviewJob("gitlab", env, loop, member?.name);
|
|
374
|
+
const previewRule = preview ? `$CI_PIPELINE_SOURCE =~ /^(web|api|trigger)$/ && $${preview.gitlabVar} =~ /^[0-9]+$/ && $CI_COMMIT_BRANCH == ${targetBranch}` : undefined;
|
|
375
|
+
const previewName = preview ? named(preview.jobName) : undefined;
|
|
376
|
+
const previewNoteName = preview ? named(preview.noteJobName) : undefined;
|
|
377
|
+
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] : [])];
|
|
347
378
|
const noteJob = named(PR_PLAN_NOTE_JOB);
|
|
348
|
-
const post = prLoopPostJob("gitlab", env, member?.name);
|
|
379
|
+
const post = prLoopPostJob("gitlab", env, member?.name, loop.words ? { ...(loop.words.bin ? { bin: loop.words.bin } : {}) } : undefined);
|
|
380
|
+
// #4619: with a control repo named, the note job reads its chant/lifecycle, read-only, so pr-post marks the cross-stack edges.
|
|
381
|
+
const controlRead = loop.control ? controlNoteCheckout("gitlab", loop.control.target, loop.words ?? {}) : undefined;
|
|
349
382
|
const doc = {
|
|
350
383
|
// Included files merge their top-level keys, so a member's file carries
|
|
351
384
|
// the rules every member shares and no name: two names would collide.
|
|
352
|
-
workflow: { ...(member ? {} : { name: `chant-pr-${env}` }), rules: [{ if: onMergeRequest }, { if: onMerge }, { if: onPlanNotes }, ...(greens.length > 0 ? [{ if: onGreen }] : []), ...(prEnv ? [{ if: GITLAB_SCHEDULE_RULE }] : [])] },
|
|
385
|
+
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 }] : [])] },
|
|
353
386
|
stages,
|
|
354
387
|
variables: { ...options.variables, CHANT_ENV: env, ...GITLAB_FULL_HISTORY },
|
|
355
388
|
[planJob]: {
|
|
@@ -360,7 +393,7 @@ function prLoopGitlabPipeline(components, env, image, beforeScript, extraScript,
|
|
|
360
393
|
variables: { ...plan.run.env, ...tradeVars(identity?.plan) },
|
|
361
394
|
// #4295: the merge request's code runs in this job, so the job's shell drops the forge tokens first.
|
|
362
395
|
// #4296: with identity, the trade runs before that, and the same unset drops the token variables too.
|
|
363
|
-
script: [...(identity ? gitlabTradeLines(FORGE_TOKEN_VARS) : [GITLAB_DROP_FORGE_TOKENS]), ...script(plan)],
|
|
396
|
+
script: [...(identity ? gitlabTradeLines(FORGE_TOKEN_VARS) : [GITLAB_DROP_FORGE_TOKENS]), ...dropControl, ...script(plan)],
|
|
364
397
|
artifacts: { when: "always", paths: [reportPath] },
|
|
365
398
|
},
|
|
366
399
|
// #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.
|
|
@@ -368,15 +401,61 @@ function prLoopGitlabPipeline(components, env, image, beforeScript, extraScript,
|
|
|
368
401
|
stage: PR_PLAN_NOTE_JOB,
|
|
369
402
|
image,
|
|
370
403
|
rules: [{ if: onPlanNotes }],
|
|
371
|
-
script: [...enter, ...beforeScript, post.command],
|
|
404
|
+
script: [...enter, ...beforeScript, ...(controlRead ? [controlRead.script] : []), post.command],
|
|
372
405
|
},
|
|
406
|
+
...Object.fromEntries(commentLocks.map(({ job, name, rule }) => [
|
|
407
|
+
name,
|
|
408
|
+
// The target branch's commit, so none of the merge request's code runs here.
|
|
409
|
+
{ stage: job.jobName, image, rules: [{ if: rule }], variables: job.env, script: [...enter, ...beforeScript, job.line] },
|
|
410
|
+
])),
|
|
411
|
+
...(preview && previewName && previewNoteName && previewRule
|
|
412
|
+
? {
|
|
413
|
+
[previewName]: {
|
|
414
|
+
stage: preview.jobName,
|
|
415
|
+
image,
|
|
416
|
+
rules: [{ if: previewRule }],
|
|
417
|
+
...trading(identity?.plan),
|
|
418
|
+
variables: { ...preview.plan.run.env, ...tradeVars(identity?.plan) },
|
|
419
|
+
// A pipeline of the target branch holds the protected tokens, so the job drops them before it checks out the merge request's head.
|
|
420
|
+
script: [
|
|
421
|
+
...(identity ? gitlabTradeLines(FORGE_TOKEN_VARS) : [GITLAB_DROP_FORGE_TOKENS]),
|
|
422
|
+
...dropControl,
|
|
423
|
+
"unset CHANT_RUNNER_KEY",
|
|
424
|
+
...("line" in preview.head ? [preview.head.line] : []),
|
|
425
|
+
// The ledger while the clone can still fetch it: the plan reads the gate ledger from the local branch.
|
|
426
|
+
"git fetch -q --no-tags origin '+refs/heads/chant/lifecycle:refs/heads/chant/lifecycle' 2>/dev/null || true",
|
|
427
|
+
// The runner's clone keeps the job token in origin's URL; the merge request's code never sees it.
|
|
428
|
+
GITLAB_SCRUB_ORIGIN,
|
|
429
|
+
preview.base,
|
|
430
|
+
...prLoopSetup(image, loop.engineSetup),
|
|
431
|
+
...enter,
|
|
432
|
+
...beforeScript,
|
|
433
|
+
...(preview.plan.setup ?? []),
|
|
434
|
+
...(preview.plan.build ?? []),
|
|
435
|
+
preview.plan.run.command,
|
|
436
|
+
...extraScript,
|
|
437
|
+
],
|
|
438
|
+
artifacts: { when: "always", paths: [reportPath] },
|
|
439
|
+
},
|
|
440
|
+
[previewNoteName]: {
|
|
441
|
+
stage: preview.noteJobName,
|
|
442
|
+
image,
|
|
443
|
+
needs: [{ job: previewName, artifacts: true }],
|
|
444
|
+
rules: [{ if: previewRule, when: "always" }],
|
|
445
|
+
variables: preview.post.env,
|
|
446
|
+
// The target branch's commit: the job's own clone, where none of the merge request's code runs.
|
|
447
|
+
script: [...enter, ...beforeScript, ...(controlRead ? [controlRead.script] : []), ...(preview.post.setup ?? []), preview.post.command],
|
|
448
|
+
},
|
|
449
|
+
}
|
|
450
|
+
: {}),
|
|
373
451
|
...Object.fromEntries(applies.map(({ job, name, stage }, i) => [
|
|
374
452
|
name,
|
|
375
453
|
{
|
|
376
454
|
stage,
|
|
377
455
|
image,
|
|
378
456
|
...(i > 1 ? { needs: [applies[i - 1].name] } : {}),
|
|
379
|
-
|
|
457
|
+
// #4552: on the repo grain every apply of the repository shares one resource group.
|
|
458
|
+
resource_group: job.jobName === "record" ? prRecordGroup(env, member?.name) : loop.lockGrain === "repo" ? REPO_APPLY_GROUP : prApplyGroup(env, member?.name),
|
|
380
459
|
// #4291: a wave after the first runs after one that failed, and blocks only what depends on the failure.
|
|
381
460
|
rules: [{ if: job.jobName === "record" ? onMerge : onGreen, ...(job.wave && i > 1 ? { when: "always" } : {}) }],
|
|
382
461
|
...trading(applySpec(job)),
|
|
@@ -397,20 +476,43 @@ function prLoopGitlabPipeline(components, env, image, beforeScript, extraScript,
|
|
|
397
476
|
artifacts: { when: "always", paths: [reportPath], ...(job.wave ? { reports: { dotenv: WAVE_GREEN_DOTENV } } : {}) },
|
|
398
477
|
},
|
|
399
478
|
])),
|
|
479
|
+
...(control && controlJob
|
|
480
|
+
? {
|
|
481
|
+
[controlJob]: {
|
|
482
|
+
stage: control.name,
|
|
483
|
+
image,
|
|
484
|
+
needs: [lastGreen],
|
|
485
|
+
rules: [{ if: onGreen }],
|
|
486
|
+
// The runner sets a needed job's dotenv values when the script starts, so the commit is read there.
|
|
487
|
+
script: [
|
|
488
|
+
...Object.entries(control.env).map(([k, v]) => `export ${k}="${v}"`),
|
|
489
|
+
`bash <<'CONTROL_DISPATCH' || exit $?\n${control.script}\nCONTROL_DISPATCH`,
|
|
490
|
+
],
|
|
491
|
+
},
|
|
492
|
+
}
|
|
493
|
+
: {}),
|
|
400
494
|
...(prEnv ? prEnv.jobs : {}),
|
|
401
495
|
};
|
|
402
496
|
const jobs = [
|
|
403
497
|
{ jobName: planJob, component: "merge request plan", stage: "plan", needs: [] },
|
|
404
498
|
{ jobName: noteJob, component: "merge request plan note", stage: PR_PLAN_NOTE_JOB, needs: [] },
|
|
499
|
+
...commentLocks.map(({ job, name }) => ({ jobName: name, component: `merge request ${job.command} comment`, stage: job.jobName, needs: [] })),
|
|
500
|
+
...(preview && previewName && previewNoteName
|
|
501
|
+
? [
|
|
502
|
+
{ jobName: previewName, component: "merge request preview comment", stage: preview.jobName, needs: [] },
|
|
503
|
+
{ jobName: previewNoteName, component: "merge request preview comment note", stage: preview.noteJobName, needs: [previewName] },
|
|
504
|
+
]
|
|
505
|
+
: []),
|
|
405
506
|
...applies.map(({ job, name, stage }, i) => ({
|
|
406
507
|
jobName: name,
|
|
407
508
|
component: job.wave ? `green apply, wave ${job.wave}` : job.jobName === "record" ? "merge record" : loop.applyBeforeMerge ? "apply before merge" : "green apply",
|
|
408
509
|
stage,
|
|
409
510
|
needs: i > 1 ? [applies[i - 1].name] : [],
|
|
410
511
|
})),
|
|
512
|
+
...(controlJob ? [{ jobName: controlJob, component: "control repo dispatch", stage: control.name, needs: [lastGreen] }] : []),
|
|
411
513
|
...(prEnv ? prEnv.listed : []),
|
|
412
514
|
];
|
|
413
|
-
const yaml = emitGitlabFile(["workflow", "stages", "variables", planJob, noteJob, ...applies.map((a) => a.name), ...Object.keys(prEnv?.jobs ?? {})].map((key) => [key, doc[key]]));
|
|
515
|
+
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]]));
|
|
414
516
|
return { yaml, stages, jobs, env };
|
|
415
517
|
}
|
|
416
518
|
/** The stage a pull request environment's jobs run in (#4298). */
|
|
@@ -434,7 +536,7 @@ function prEnvGitlabJobs(image, beforeScript, extraScript, enter, loop, member,
|
|
|
434
536
|
const reportPath = member ? memberRepoPath(member, PR_ENV_REPORT_DIR) : PR_ENV_REPORT_DIR;
|
|
435
537
|
const environment = "pr-$CI_MERGE_REQUEST_IID";
|
|
436
538
|
const group = `chant-pr-env-${member ? `${member.name}-` : ""}$CI_MERGE_REQUEST_IID`;
|
|
437
|
-
const script = (j) => [...prLoopSetup(image), ...enter, ...beforeScript, ...(j.build ?? []), j.command, ...extraScript];
|
|
539
|
+
const script = (j) => [...prLoopSetup(image, loop.engineSetup), ...enter, ...beforeScript, ...(j.build ?? []), j.command, ...extraScript];
|
|
438
540
|
const artifacts = { when: "always", paths: [reportPath] };
|
|
439
541
|
// #4330: env-up, env-down and env-sweep trade the job's OIDC token for the pull request environments' identity first.
|
|
440
542
|
const identity = loop.prEnvIdentity;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two jobs of a split review on GitLab (#4544): `ScheduledOpSpec.reviewAgent`.
|
|
3
|
+
*
|
|
4
|
+
* An Op that runs a `reviewAgent` step in `handoff` mode runs the change's
|
|
5
|
+
* code (`chant run` loads the merge request's Op files) beside a model.
|
|
6
|
+
* GitHub and Forgejo keep the write token out of that job with per-job
|
|
7
|
+
* permissions (`lexicons/github/src/components/review-jobs.ts`). GitLab has
|
|
8
|
+
* none: a merge request pipeline is built from the merge request's own files
|
|
9
|
+
* and every job in it gets the project's unprotected variables. So the token
|
|
10
|
+
* stays out of that pipeline altogether, the way the plan note does (#4328):
|
|
11
|
+
*
|
|
12
|
+
* - the review job runs in the merge request pipeline. Its script starts by
|
|
13
|
+
* unsetting every forge token variable (`GITLAB_DROP_FORGE_TOKENS`), and it
|
|
14
|
+
* keeps the review directory as its artifact. `CHANT_FORGE_TOKEN` is a
|
|
15
|
+
* protected variable, which a merge request pipeline never gets.
|
|
16
|
+
* - the note job runs in a pipeline of the default branch, built from that
|
|
17
|
+
* branch's files, that a pipeline schedule (or a person, the API or a
|
|
18
|
+
* trigger) starts with `CHANT_REVIEW_NOTES` set to `1`. It checks nothing
|
|
19
|
+
* out (`GIT_STRATEGY: none`), installs the chant release this lexicon
|
|
20
|
+
* belongs to, and runs `postOpenReviewNotes` with the protected token: for
|
|
21
|
+
* each open merge request whose newest pipeline's review job succeeded it
|
|
22
|
+
* reads `review.json` from that job's artifacts as data and posts the note.
|
|
23
|
+
* The Op's name, the review job's name and the branches come from this
|
|
24
|
+
* file, never from the artifact.
|
|
25
|
+
*/
|
|
26
|
+
import type { OpTrigger } from "@intentius/chant/lexicon";
|
|
27
|
+
/** The variable `reviewAgent` reads its handoff directory from (`REVIEW_DIR_ENV`, review-agent.ts). */
|
|
28
|
+
export declare const REVIEW_DIR_ENV = "CHANT_REVIEW_DIR";
|
|
29
|
+
/** Where the review job writes the review, from the directory its script runs in. */
|
|
30
|
+
export declare const REVIEW_DIR = ".chant/review";
|
|
31
|
+
/** The variable a pipeline of the default branch is started with to run the note jobs. */
|
|
32
|
+
export declare const GITLAB_REVIEW_NOTES_VAR = "CHANT_REVIEW_NOTES";
|
|
33
|
+
/** The name of the note job beside the Op's own job. */
|
|
34
|
+
export declare function reviewNoteJobName(jobName: string): string;
|
|
35
|
+
/**
|
|
36
|
+
* The note job's rule: a pipeline of the default branch that a pipeline
|
|
37
|
+
* schedule, a person, the API or a trigger started with `CHANT_REVIEW_NOTES`
|
|
38
|
+
* set to `1`. A merge request pipeline's own trigger job starts a pipeline
|
|
39
|
+
* whose source is `pipeline`, which this rule leaves out.
|
|
40
|
+
*/
|
|
41
|
+
export declare const GITLAB_REVIEW_NOTES_RULE = "$CI_PIPELINE_SOURCE =~ /^(schedule|web|api|trigger)$/ && $CHANT_REVIEW_NOTES == \"1\" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH";
|
|
42
|
+
/**
|
|
43
|
+
* Refuse a spec whose review cannot be split the way this shape needs: a
|
|
44
|
+
* trigger with no merge request, a finding-mode that would post from the
|
|
45
|
+
* review job, or an additive scope other than `id-token: write`.
|
|
46
|
+
*/
|
|
47
|
+
export declare function assertReviewSplit(name: string, trigger: OpTrigger, findingMode: string, permissions: Record<string, string> | undefined): void;
|
|
48
|
+
export interface GitlabReviewJobsInput {
|
|
49
|
+
/** The Op's name: what `chant run` targets and what keys the note. */
|
|
50
|
+
name: string;
|
|
51
|
+
/** The review job's name: the Op's job name. */
|
|
52
|
+
jobName: string;
|
|
53
|
+
stage: string;
|
|
54
|
+
image: string;
|
|
55
|
+
/** The review job's `rules:`, the Op's merge request rules. */
|
|
56
|
+
rules: unknown[];
|
|
57
|
+
/** The review job's script after the token unset: cd, setup, before-script, the invocation and the extra script. */
|
|
58
|
+
script: string[];
|
|
59
|
+
/** The Op's own per-job environment (`ScheduledOpSpec.variables`). */
|
|
60
|
+
variables?: Record<string, string>;
|
|
61
|
+
idTokens?: Record<string, unknown>;
|
|
62
|
+
/** The review directory from the repository root: `.chant/review`, under a workspace member's directory for a member. */
|
|
63
|
+
artifactDir: string;
|
|
64
|
+
/** The branches the merge requests merge into, from the trigger; the default branch when absent. */
|
|
65
|
+
branches?: readonly string[];
|
|
66
|
+
}
|
|
67
|
+
/** The review job and the note job of one split review. */
|
|
68
|
+
export declare function gitlabReviewJobs(i: GitlabReviewJobsInput): {
|
|
69
|
+
review: Record<string, unknown>;
|
|
70
|
+
note: Record<string, unknown>;
|
|
71
|
+
};
|
|
72
|
+
//# sourceMappingURL=review-jobs.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"review-jobs.d.ts","sourceRoot":"","sources":["../../src/components/review-jobs.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAIH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,0BAA0B,CAAC;AAE1D,uGAAuG;AACvG,eAAO,MAAM,cAAc,qBAAqB,CAAC;AAEjD,qFAAqF;AACrF,eAAO,MAAM,UAAU,kBAAkB,CAAC;AAE1C,0FAA0F;AAC1F,eAAO,MAAM,uBAAuB,uBAAuB,CAAC;AAK5D,wDAAwD;AACxD,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAEzD;AAED;;;;;GAKG;AACH,eAAO,MAAM,wBAAwB,qIAA2I,CAAC;AAEjL;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,SAAS,GAAG,IAAI,CAkB9I;AAyCD,MAAM,WAAW,qBAAqB;IACpC,sEAAsE;IACtE,IAAI,EAAE,MAAM,CAAC;IACb,gDAAgD;IAChD,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,+DAA+D;IAC/D,KAAK,EAAE,OAAO,EAAE,CAAC;IACjB,oHAAoH;IACpH,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,sEAAsE;IACtE,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACnC,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACnC,yHAAyH;IACzH,WAAW,EAAE,MAAM,CAAC;IACpB,oGAAoG;IACpG,QAAQ,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC9B;AAED,2DAA2D;AAC3D,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,qBAAqB,GAAG;IAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,CAsB7H"}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two jobs of a split review on GitLab (#4544): `ScheduledOpSpec.reviewAgent`.
|
|
3
|
+
*
|
|
4
|
+
* An Op that runs a `reviewAgent` step in `handoff` mode runs the change's
|
|
5
|
+
* code (`chant run` loads the merge request's Op files) beside a model.
|
|
6
|
+
* GitHub and Forgejo keep the write token out of that job with per-job
|
|
7
|
+
* permissions (`lexicons/github/src/components/review-jobs.ts`). GitLab has
|
|
8
|
+
* none: a merge request pipeline is built from the merge request's own files
|
|
9
|
+
* and every job in it gets the project's unprotected variables. So the token
|
|
10
|
+
* stays out of that pipeline altogether, the way the plan note does (#4328):
|
|
11
|
+
*
|
|
12
|
+
* - the review job runs in the merge request pipeline. Its script starts by
|
|
13
|
+
* unsetting every forge token variable (`GITLAB_DROP_FORGE_TOKENS`), and it
|
|
14
|
+
* keeps the review directory as its artifact. `CHANT_FORGE_TOKEN` is a
|
|
15
|
+
* protected variable, which a merge request pipeline never gets.
|
|
16
|
+
* - the note job runs in a pipeline of the default branch, built from that
|
|
17
|
+
* branch's files, that a pipeline schedule (or a person, the API or a
|
|
18
|
+
* trigger) starts with `CHANT_REVIEW_NOTES` set to `1`. It checks nothing
|
|
19
|
+
* out (`GIT_STRATEGY: none`), installs the chant release this lexicon
|
|
20
|
+
* belongs to, and runs `postOpenReviewNotes` with the protected token: for
|
|
21
|
+
* each open merge request whose newest pipeline's review job succeeded it
|
|
22
|
+
* reads `review.json` from that job's artifacts as data and posts the note.
|
|
23
|
+
* The Op's name, the review job's name and the branches come from this
|
|
24
|
+
* file, never from the artifact.
|
|
25
|
+
*/
|
|
26
|
+
import { readFileSync } from "node:fs";
|
|
27
|
+
import { GITLAB_DROP_FORGE_TOKENS } from "@intentius/chant/ci-handover";
|
|
28
|
+
/** The variable `reviewAgent` reads its handoff directory from (`REVIEW_DIR_ENV`, review-agent.ts). */
|
|
29
|
+
export const REVIEW_DIR_ENV = "CHANT_REVIEW_DIR";
|
|
30
|
+
/** Where the review job writes the review, from the directory its script runs in. */
|
|
31
|
+
export const REVIEW_DIR = ".chant/review";
|
|
32
|
+
/** The variable a pipeline of the default branch is started with to run the note jobs. */
|
|
33
|
+
export const GITLAB_REVIEW_NOTES_VAR = "CHANT_REVIEW_NOTES";
|
|
34
|
+
/** How long a review artifact is kept: long enough for a schedule to come round and post it. */
|
|
35
|
+
const REVIEW_ARTIFACT_EXPIRY = "7 days";
|
|
36
|
+
/** The name of the note job beside the Op's own job. */
|
|
37
|
+
export function reviewNoteJobName(jobName) {
|
|
38
|
+
return `${jobName}-note`;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The note job's rule: a pipeline of the default branch that a pipeline
|
|
42
|
+
* schedule, a person, the API or a trigger started with `CHANT_REVIEW_NOTES`
|
|
43
|
+
* set to `1`. A merge request pipeline's own trigger job starts a pipeline
|
|
44
|
+
* whose source is `pipeline`, which this rule leaves out.
|
|
45
|
+
*/
|
|
46
|
+
export const GITLAB_REVIEW_NOTES_RULE = `$CI_PIPELINE_SOURCE =~ /^(schedule|web|api|trigger)$/ && $${GITLAB_REVIEW_NOTES_VAR} == "1" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH`;
|
|
47
|
+
/**
|
|
48
|
+
* Refuse a spec whose review cannot be split the way this shape needs: a
|
|
49
|
+
* trigger with no merge request, a finding-mode that would post from the
|
|
50
|
+
* review job, or an additive scope other than `id-token: write`.
|
|
51
|
+
*/
|
|
52
|
+
export function assertReviewSplit(name, trigger, findingMode, permissions) {
|
|
53
|
+
if (trigger.kind !== "pull_request") {
|
|
54
|
+
throw new Error(`Scheduled Op "${name}" sets reviewAgent, which posts the review as a note on the merge request that triggered the run, but its trigger is "${trigger.kind}". Give it a { kind: "pull_request" } trigger.`);
|
|
55
|
+
}
|
|
56
|
+
if (findingMode !== "report") {
|
|
57
|
+
throw new Error(`Scheduled Op "${name}" sets reviewAgent with findingMode "${findingMode}". The review job holds no forge token, and the note job posts from a pipeline of the default branch instead, so the Op's own finding-mode must be "report".`);
|
|
58
|
+
}
|
|
59
|
+
for (const [scope, level] of Object.entries(permissions ?? {})) {
|
|
60
|
+
if (level === "write" && scope !== "id-token") {
|
|
61
|
+
throw new Error(`Scheduled Op "${name}" sets reviewAgent and adds the permission "${scope}: write". The review job runs the change's code and may hold only "id-token: write".`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** The chant release the note job installs: this lexicon's own, which releases in lockstep with `@intentius/chant`. */
|
|
66
|
+
function chantVersion() {
|
|
67
|
+
const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
|
|
68
|
+
if (typeof pkg.version !== "string" || pkg.version === "") {
|
|
69
|
+
throw new Error("the gitlab lexicon's package.json has no version to pin the review note job's chant to");
|
|
70
|
+
}
|
|
71
|
+
return pkg.version;
|
|
72
|
+
}
|
|
73
|
+
/** What the note job runs under node. The inputs are JSON literals from this file; nothing comes from the merge request. */
|
|
74
|
+
function noteModule(op, job, artifactDir, branches) {
|
|
75
|
+
return [
|
|
76
|
+
'import { forgeFromEnv } from "@intentius/chant/pr-forge";',
|
|
77
|
+
'import { postOpenReviewNotes } from "@intentius/chant/op/activities/review-agent";',
|
|
78
|
+
`const branches = ${JSON.stringify(branches ?? null)} ?? [process.env.CI_DEFAULT_BRANCH];`,
|
|
79
|
+
"const forge = forgeFromEnv(\"gitlab\");",
|
|
80
|
+
"let failed = false;",
|
|
81
|
+
"for (const branch of branches) {",
|
|
82
|
+
` const { posts, warnings } = await postOpenReviewNotes({ forge, branch, job: ${JSON.stringify(job)}, artifactDir: ${JSON.stringify(artifactDir)}, op: ${JSON.stringify(op)} });`,
|
|
83
|
+
" for (const p of posts) console.log(`!${p.pr} at ${p.head.slice(0, 12)}: ${p.skipped ?? `posted, risk ${p.risk}`}`);",
|
|
84
|
+
" for (const w of warnings) { console.error(w); failed = true; }",
|
|
85
|
+
"}",
|
|
86
|
+
"if (failed) process.exitCode = 1;",
|
|
87
|
+
].join("\n");
|
|
88
|
+
}
|
|
89
|
+
/** The note job's script: install the pinned chant outside any checkout and run {@link noteModule} with tsx. */
|
|
90
|
+
function noteScript(version, module) {
|
|
91
|
+
return [
|
|
92
|
+
'dir="${TMPDIR:-/tmp}/chant-review-note"',
|
|
93
|
+
'mkdir -p "$dir"',
|
|
94
|
+
'cd "$dir"',
|
|
95
|
+
"echo '{\"private\":true}' > package.json",
|
|
96
|
+
`npm install --omit=peer --no-audit --no-fund --loglevel=error @intentius/chant@${version}`,
|
|
97
|
+
["cat > note.mjs <<'NOTE'", module, "NOTE"].join("\n"),
|
|
98
|
+
"node --import tsx note.mjs",
|
|
99
|
+
];
|
|
100
|
+
}
|
|
101
|
+
/** The review job and the note job of one split review. */
|
|
102
|
+
export function gitlabReviewJobs(i) {
|
|
103
|
+
const review = {
|
|
104
|
+
stage: i.stage,
|
|
105
|
+
image: i.image,
|
|
106
|
+
resource_group: i.jobName,
|
|
107
|
+
...(i.idTokens ? { id_tokens: i.idTokens } : {}),
|
|
108
|
+
variables: { ...(i.variables ?? {}), [REVIEW_DIR_ENV]: REVIEW_DIR },
|
|
109
|
+
rules: i.rules,
|
|
110
|
+
// The merge request's code runs in this job, so its shell drops every forge token first.
|
|
111
|
+
script: [GITLAB_DROP_FORGE_TOKENS, ...i.script],
|
|
112
|
+
artifacts: { paths: [i.artifactDir], expire_in: REVIEW_ARTIFACT_EXPIRY },
|
|
113
|
+
};
|
|
114
|
+
const note = {
|
|
115
|
+
stage: i.stage,
|
|
116
|
+
image: i.image,
|
|
117
|
+
resource_group: reviewNoteJobName(i.jobName),
|
|
118
|
+
// Nothing of the repository is needed: the review is read from the review job's artifacts through the API.
|
|
119
|
+
variables: { GIT_STRATEGY: "none" },
|
|
120
|
+
rules: [{ if: GITLAB_REVIEW_NOTES_RULE }],
|
|
121
|
+
script: noteScript(chantVersion(), noteModule(i.name, i.jobName, i.artifactDir, i.branches)),
|
|
122
|
+
};
|
|
123
|
+
return { review, note };
|
|
124
|
+
}
|
package/dist/integrity.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"algorithm": "sha256",
|
|
3
3
|
"artifacts": {
|
|
4
|
-
"manifest.json": "
|
|
4
|
+
"manifest.json": "f5a242ce00bc716bcb44314a1301000b9ecad9187d4591c776829885535369e2",
|
|
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": "91aa3c4a176252cd5cd8ab7f02ecef7274e79a95aa85a1bfc31270ef32021897"
|
|
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.55",
|
|
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.55",
|
|
71
|
+
"@intentius/chant-lexicon-github": "^0.124.55"
|
|
72
72
|
},
|
|
73
73
|
"peerDependenciesMeta": {
|
|
74
74
|
"@intentius/chant-lexicon-github": {
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The agent jobs on GitLab (#4616): `ScheduledOpSpec.agent`. What each job
|
|
3
|
+
* does is @intentius/chant/ci-agent's; this file lays them out in GitLab's
|
|
4
|
+
* terms.
|
|
5
|
+
*
|
|
6
|
+
* GitLab starts no pipeline on a note, and every job in a pipeline gets the
|
|
7
|
+
* same protected variables. So:
|
|
8
|
+
*
|
|
9
|
+
* - a comment agent runs in a pipeline of the default branch that the API, a
|
|
10
|
+
* trigger or a person starts with `CHANT_AGENT_PR` (the merge request's
|
|
11
|
+
* iid) and `CHANT_AGENT_ASK` (the ask). Whoever starts that pipeline is
|
|
12
|
+
* who asked. A drift agent follows the drift Op's job in its scheduled
|
|
13
|
+
* pipeline and runs when that job's run exited with chant's `drift` code.
|
|
14
|
+
* - the agent job unsets every forge token variable and drops the job token
|
|
15
|
+
* from the remote before the model runs, and keeps the agent directory as
|
|
16
|
+
* an artifact. It holds the model's key.
|
|
17
|
+
* - the push job runs in the same pipeline under environment
|
|
18
|
+
* `<agent>-push`, takes the artifact, and pushes with `tokenEnv`
|
|
19
|
+
* (`CHANT_FORGE_TOKEN` by default). Scope that variable to the
|
|
20
|
+
* `<agent>-push` environment, protected and masked, so the agent job never
|
|
21
|
+
* holds it.
|
|
22
|
+
*
|
|
23
|
+
* A workspace member's drift Op runs in the member's directory, so its
|
|
24
|
+
* artifact holds `<member>/.chant/agent` (#4619). Both agent jobs run at
|
|
25
|
+
* the repository root and take `dir`, that path, as their agent directory.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { GITLAB_DROP_FORGE_TOKENS } from "@intentius/chant/ci-handover";
|
|
29
|
+
import { gitlabTags, type CiRunner } from "@intentius/chant/ci-jobs";
|
|
30
|
+
import {
|
|
31
|
+
AGENT_DECISION_FILE,
|
|
32
|
+
AGENT_DIR,
|
|
33
|
+
AGENT_DRIFT_FILE,
|
|
34
|
+
AGENT_GITLAB_TOKEN_ENV,
|
|
35
|
+
AGENT_HEAD_REF,
|
|
36
|
+
agentCommandLines,
|
|
37
|
+
agentGitAuthLines,
|
|
38
|
+
agentHeadFetchLine,
|
|
39
|
+
agentSettingsEnv,
|
|
40
|
+
GITLAB_AGENT_ASK_VAR,
|
|
41
|
+
GITLAB_AGENT_PR_VAR,
|
|
42
|
+
type AgentSettings,
|
|
43
|
+
} from "@intentius/chant/ci-agent";
|
|
44
|
+
import { CHANT_EXIT } from "@intentius/chant/exit-codes";
|
|
45
|
+
|
|
46
|
+
/** The dotenv the drift Op's job leaves, which says whether it found drift. */
|
|
47
|
+
const DRIFT_DOTENV = `${AGENT_DIR}/drift.env`;
|
|
48
|
+
const AGENT_ARTIFACT_EXPIRY = "7 days";
|
|
49
|
+
|
|
50
|
+
/** The comment agent's rule: a pipeline of the default branch started with the merge request and the ask. */
|
|
51
|
+
export const GITLAB_AGENT_RULE = `$CI_PIPELINE_SOURCE =~ /^(web|api|trigger|chat)$/ && $${GITLAB_AGENT_PR_VAR} =~ /^[0-9]+$/ && $${GITLAB_AGENT_ASK_VAR} && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH`;
|
|
52
|
+
|
|
53
|
+
export function gitlabAgentPushJobName(jobName: string): string {
|
|
54
|
+
return `${jobName}-push`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The environment the push job runs under, which the push token is scoped to. */
|
|
58
|
+
export function gitlabAgentEnvironment(jobName: string): string {
|
|
59
|
+
return `${jobName}-push`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The remote without the job token GitLab put in its URL. */
|
|
63
|
+
const PLAIN_REMOTE = 'git remote set-url origin "$CI_SERVER_URL/$CI_PROJECT_PATH.git"';
|
|
64
|
+
/** The ledger branch, for the run count and the record. */
|
|
65
|
+
const LEDGER_FETCH = "git fetch -q --no-tags origin '+refs/heads/chant/lifecycle:refs/heads/chant/lifecycle' 2>/dev/null || true";
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The drift Op's script lines (#4616): the run with its output kept for the
|
|
69
|
+
* agent, and `CHANT_AGENT_DRIFT=1` in a dotenv when it exits with chant's
|
|
70
|
+
* `drift` code. The job still exits with the run's code.
|
|
71
|
+
*/
|
|
72
|
+
export function gitlabDriftRunLines(invocation: string): string[] {
|
|
73
|
+
return [
|
|
74
|
+
`mkdir -p ${AGENT_DIR}`,
|
|
75
|
+
`set +e; { ${invocation}; echo "$?" > ${AGENT_DIR}/code; } 2>&1 | tee ${AGENT_DIR}/${AGENT_DRIFT_FILE}; code=$(cat ${AGENT_DIR}/code); set -e`,
|
|
76
|
+
`if [ "$code" -eq ${CHANT_EXIT.drift} ]; then echo "CHANT_AGENT_DRIFT=1" > ${DRIFT_DOTENV}; else echo "CHANT_AGENT_DRIFT=0" > ${DRIFT_DOTENV}; fi`,
|
|
77
|
+
'exit "$code"',
|
|
78
|
+
];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The drift Op job's artifacts: the output for the agent and the dotenv. */
|
|
82
|
+
export function gitlabDriftArtifacts(dir: string): Record<string, unknown> {
|
|
83
|
+
return { when: "always", paths: [dir], reports: { dotenv: `${dir}/drift.env` }, expire_in: AGENT_ARTIFACT_EXPIRY };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface GitlabAgentJobsInput {
|
|
87
|
+
jobName: string;
|
|
88
|
+
stage: string;
|
|
89
|
+
image: string;
|
|
90
|
+
settings: AgentSettings;
|
|
91
|
+
runner?: CiRunner;
|
|
92
|
+
/** Setup and before-script lines, run before chant in both jobs. */
|
|
93
|
+
script: readonly string[];
|
|
94
|
+
/** The variable holding the push token. Default {@link AGENT_GITLAB_TOKEN_ENV}. */
|
|
95
|
+
tokenEnv?: string;
|
|
96
|
+
bin?: string;
|
|
97
|
+
/** For a drift agent: the Op's job and its rules. */
|
|
98
|
+
after?: { job: string; rules: unknown[] };
|
|
99
|
+
/** The agent directory from the repository root: where the drift Op's artifact put the drift. Default {@link AGENT_DIR}. */
|
|
100
|
+
dir?: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The agent job and the push job. */
|
|
104
|
+
export function gitlabAgentJobs(i: GitlabAgentJobsInput): { agent: Record<string, unknown>; push: Record<string, unknown> } {
|
|
105
|
+
const s = i.settings;
|
|
106
|
+
const dir = i.dir ?? AGENT_DIR;
|
|
107
|
+
const lines = agentCommandLines("gitlab", i.bin, dir);
|
|
108
|
+
const comment = s.on === "comment";
|
|
109
|
+
const tags = gitlabTags(i.runner);
|
|
110
|
+
const variables = { ...agentSettingsEnv(s), GIT_DEPTH: "0" };
|
|
111
|
+
const rules = comment ? [{ if: GITLAB_AGENT_RULE }] : i.after!.rules.map((r) => ({ ...(r as Record<string, unknown>), when: "always" }));
|
|
112
|
+
const head = comment ? [agentHeadFetchLine("gitlab", GITLAB_AGENT_PR_VAR)] : [];
|
|
113
|
+
// A drift agent's job runs whatever the drift check found, and stops here when it found none.
|
|
114
|
+
const driftGate = comment ? [] : [`if [ "\${CHANT_AGENT_DRIFT:-0}" != "1" ]; then echo "no drift, so the agent does not run"; exit 0; fi`];
|
|
115
|
+
const agent: Record<string, unknown> = {
|
|
116
|
+
stage: i.stage,
|
|
117
|
+
image: i.image,
|
|
118
|
+
...(tags ? { tags } : {}),
|
|
119
|
+
resource_group: i.jobName,
|
|
120
|
+
timeout: `${s.timeout + 15} minutes`,
|
|
121
|
+
variables,
|
|
122
|
+
rules,
|
|
123
|
+
...(comment ? {} : { needs: [{ job: i.after!.job, artifacts: true }] }),
|
|
124
|
+
script: [
|
|
125
|
+
...driftGate,
|
|
126
|
+
...head,
|
|
127
|
+
LEDGER_FETCH,
|
|
128
|
+
PLAIN_REMOTE,
|
|
129
|
+
...i.script,
|
|
130
|
+
// No forge token reaches the model: the decision needs none on GitLab, and the push job checks the merge request.
|
|
131
|
+
GITLAB_DROP_FORGE_TOKENS,
|
|
132
|
+
lines.prepare,
|
|
133
|
+
lines.run,
|
|
134
|
+
],
|
|
135
|
+
artifacts: { when: "always", paths: [dir], expire_in: AGENT_ARTIFACT_EXPIRY },
|
|
136
|
+
};
|
|
137
|
+
const tokenVar = i.tokenEnv ?? AGENT_GITLAB_TOKEN_ENV;
|
|
138
|
+
const push: Record<string, unknown> = {
|
|
139
|
+
stage: i.stage,
|
|
140
|
+
image: i.image,
|
|
141
|
+
...(tags ? { tags } : {}),
|
|
142
|
+
resource_group: gitlabAgentPushJobName(i.jobName),
|
|
143
|
+
environment: { name: gitlabAgentEnvironment(i.jobName), action: "access" },
|
|
144
|
+
variables,
|
|
145
|
+
rules,
|
|
146
|
+
needs: [{ job: i.jobName, artifacts: true }],
|
|
147
|
+
script: [
|
|
148
|
+
// The agent job leaves a decision whenever it ran; with none, the drift check found nothing.
|
|
149
|
+
...(comment ? [] : [`if [ ! -f ${dir}/${AGENT_DECISION_FILE} ]; then echo "the agent did not run"; exit 0; fi`]),
|
|
150
|
+
...head,
|
|
151
|
+
LEDGER_FETCH,
|
|
152
|
+
PLAIN_REMOTE,
|
|
153
|
+
...i.script,
|
|
154
|
+
`export CHANT_GITLAB_TOKEN="$${tokenVar}"`,
|
|
155
|
+
...agentGitAuthLines(tokenVar, "oauth2"),
|
|
156
|
+
lines.land,
|
|
157
|
+
],
|
|
158
|
+
};
|
|
159
|
+
return { agent, push };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The header lines a file with agent jobs carries: how to start a comment agent and where the token goes. */
|
|
163
|
+
export function gitlabAgentHeader(jobName: string, s: AgentSettings, tokenVar = AGENT_GITLAB_TOKEN_ENV): string[] {
|
|
164
|
+
return [
|
|
165
|
+
comment(s)
|
|
166
|
+
? `# ${jobName}: answers ${s.comment} from a pipeline of the default branch started with ${GITLAB_AGENT_PR_VAR}=<iid> and ${GITLAB_AGENT_ASK_VAR}=<ask>`
|
|
167
|
+
: `# ${jobName}: after the drift check exits ${CHANT_EXIT.drift}, opens or updates a merge request from ${s.branch}`,
|
|
168
|
+
`# ${gitlabAgentPushJobName(jobName)} pushes with ${tokenVar}: scope it to environment ${gitlabAgentEnvironment(jobName)}, protected and masked; the model's key is ${s.keyEnv}`,
|
|
169
|
+
`# the fetched head is ${AGENT_HEAD_REF}; runs are limited to ${s.maxRuns ?? "no fixed number of"} a day on the ledger` +
|
|
170
|
+
(s.maxCostUsd !== undefined ? `, and to $${s.maxCostUsd} a day of recorded cost` : ""),
|
|
171
|
+
];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function comment(s: AgentSettings): boolean {
|
|
175
|
+
return s.on === "comment";
|
|
176
|
+
}
|