@intentius/chant-lexicon-github 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 +67 -0
- package/dist/components/agent-jobs.d.ts.map +1 -0
- package/dist/components/agent-jobs.js +139 -0
- package/dist/components/control-pipeline.d.ts +34 -0
- package/dist/components/control-pipeline.d.ts.map +1 -0
- package/dist/components/control-pipeline.js +45 -0
- package/dist/components/generate-op-pipeline.d.ts +8 -1
- package/dist/components/generate-op-pipeline.d.ts.map +1 -1
- package/dist/components/generate-op-pipeline.js +144 -16
- package/dist/components/generate-op-waves-pipeline.d.ts +27 -1
- package/dist/components/generate-op-waves-pipeline.d.ts.map +1 -1
- package/dist/components/generate-op-waves-pipeline.js +135 -16
- package/dist/components/generate-pipeline.d.ts.map +1 -1
- package/dist/components/generate-pipeline.js +160 -15
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/package.json +2 -2
- package/src/components/__golden__/op-waves-jobs.yml +20 -0
- package/src/components/agent-jobs.ts +177 -0
- package/src/components/control-pipeline.ts +65 -0
- package/src/components/generate-op-pipeline.agent.test.ts +147 -0
- package/src/components/generate-op-pipeline.cloud.test.ts +96 -0
- package/src/components/generate-op-pipeline.notice.test.ts +2 -1
- package/src/components/generate-op-pipeline.ts +165 -20
- package/src/components/generate-op-waves-pipeline.approval.test.ts +46 -0
- package/src/components/generate-op-waves-pipeline.control.test.ts +64 -0
- package/src/components/generate-op-waves-pipeline.guard.test.ts +57 -0
- package/src/components/generate-op-waves-pipeline.identity.test.ts +8 -8
- package/src/components/generate-op-waves-pipeline.test.ts +17 -0
- package/src/components/generate-op-waves-pipeline.ts +157 -15
- package/src/components/generate-pipeline.ts +170 -15
- package/src/components/generate-pr-pipeline.comment-locks.test.ts +53 -0
- package/src/components/generate-pr-pipeline.comment-preview.test.ts +64 -0
- package/src/components/generate-pr-pipeline.comment-targets.test.ts +24 -0
- package/src/components/generate-pr-pipeline.control.test.ts +94 -0
- package/src/components/generate-pr-pipeline.pulumi-identity.test.ts +43 -0
- package/src/components/generate-pr-pipeline.respond.test.ts +47 -0
- package/src/components/generate-pr-pipeline.runners.test.ts +61 -0
- package/src/components/generate-pr-pipeline.stale-notes.test.ts +42 -0
|
@@ -79,7 +79,7 @@ import {
|
|
|
79
79
|
environmentDoc,
|
|
80
80
|
fillCommand,
|
|
81
81
|
GITHUB_JOB_TOKEN,
|
|
82
|
-
|
|
82
|
+
githubRunsOn,
|
|
83
83
|
mergePermissions,
|
|
84
84
|
nonEmpty,
|
|
85
85
|
permissionsFor,
|
|
@@ -97,6 +97,12 @@ import type {
|
|
|
97
97
|
} from "@intentius/chant/lexicon";
|
|
98
98
|
import { checkoutStep, concurrency, emitWorkflowYAML, jobTokenEnv, onTrigger, opConcurrencyGroup, runSteps } from "./ci-jobs";
|
|
99
99
|
import { assertReviewSplit, reviewJobs, reviewNoteJobName } from "./review-jobs";
|
|
100
|
+
import { resolveAgentSpec } from "@intentius/chant/ci-agent";
|
|
101
|
+
import { activeProductWords } from "@intentius/chant/ci-product-words";
|
|
102
|
+
import { agentJobName, agentPushJobName, driftRunOutputs, driftRunStep, githubAgentJobs } from "./agent-jobs";
|
|
103
|
+
import { assertScheduledOpCloud, scheduledOpUpdateJobName, withoutTokenRequestLine } from "@intentius/chant/scheduled-op-cloud";
|
|
104
|
+
import { TRADE_SPEC_ENV, tradeLine, type CloudTradeSpec } from "@intentius/chant/ci-identity";
|
|
105
|
+
import { CHANT_EXIT } from "@intentius/chant/exit-codes";
|
|
100
106
|
|
|
101
107
|
export type { GenerateGithubOpOptions, GenerateGithubOpResult };
|
|
102
108
|
|
|
@@ -162,6 +168,13 @@ export interface GithubOpPipelineDoc {
|
|
|
162
168
|
* dialect transforms this job on its own terms.
|
|
163
169
|
*/
|
|
164
170
|
reviewNoteDoc?: Record<string, unknown>;
|
|
171
|
+
/**
|
|
172
|
+
* The agent jobs (#4616), when the spec sets `agent`: the agent job and
|
|
173
|
+
* the push job. Kept out of {@link jobsDoc} so a member's
|
|
174
|
+
* `working-directory` default stays on the Op's own job: these check out
|
|
175
|
+
* at the repository root.
|
|
176
|
+
*/
|
|
177
|
+
agentDoc?: Record<string, unknown>;
|
|
165
178
|
}
|
|
166
179
|
|
|
167
180
|
/** One generated file: a suggested name plus its pipeline document, pre-emission. */
|
|
@@ -361,12 +374,12 @@ function gateNoticeScript(version: string): string {
|
|
|
361
374
|
* no branch and merges nothing. The Forgejo dialect drops them, as it drops
|
|
362
375
|
* every scope but `id-token`.
|
|
363
376
|
*/
|
|
364
|
-
function gateNoticeJob(applyJobName: string, image: string): Record<string, unknown> {
|
|
377
|
+
function gateNoticeJob(applyJobName: string, image: string, runner?: ScheduledOpSpec["runner"]): Record<string, unknown> {
|
|
365
378
|
const output = (name: string) => "${{ needs." + applyJobName + ".outputs." + name + ' }}';
|
|
366
379
|
return {
|
|
367
380
|
needs: applyJobName,
|
|
368
381
|
if: `needs.${applyJobName}.outputs.gated == 'true'`,
|
|
369
|
-
"runs-on":
|
|
382
|
+
"runs-on": githubRunsOn(runner),
|
|
370
383
|
container: image,
|
|
371
384
|
permissions: { contents: "read", issues: "write", "pull-requests": "write" },
|
|
372
385
|
steps: [
|
|
@@ -415,6 +428,7 @@ function assertTriggerSupportsMode(name: string, mode: OpFindingMode, trigger: O
|
|
|
415
428
|
export function buildGithubOpPipelineDocs(
|
|
416
429
|
ops: ScheduledOpSpec[],
|
|
417
430
|
options: GenerateGithubOpOptions = {},
|
|
431
|
+
forge: "github" | "forgejo" = "github",
|
|
418
432
|
): { files: GithubOpPipelineFile[]; jobs: OpPipelineJob[] } {
|
|
419
433
|
const image = options.image ?? CI_IMAGE;
|
|
420
434
|
const runCommand = options.runCommand ?? ["chant", "run", "{name}"];
|
|
@@ -424,7 +438,43 @@ export function buildGithubOpPipelineDocs(
|
|
|
424
438
|
const files: GithubOpPipelineFile[] = [];
|
|
425
439
|
const jobs: OpPipelineJob[] = [];
|
|
426
440
|
|
|
441
|
+
const words = activeProductWords();
|
|
427
442
|
for (const spec of ops) {
|
|
443
|
+
// #4631: the job's cloud identities and the update on drift, checked before anything renders.
|
|
444
|
+
assertScheduledOpCloud(spec);
|
|
445
|
+
// #4616: the comment agent is a workflow of its own, on pull request comments; the spec's trigger is not read.
|
|
446
|
+
if (spec.agent?.on === "comment") {
|
|
447
|
+
const settings = resolveAgentSpec(spec.name, spec.agent, words.product);
|
|
448
|
+
const jobName = ciJobName(spec.name);
|
|
449
|
+
const setup = spec.setup ?? [];
|
|
450
|
+
assertSetupSteps(spec.name, setup);
|
|
451
|
+
jobs.push({ jobName, op: spec.name, trigger: { kind: "pull_request" }, findingMode: "report" });
|
|
452
|
+
const { agent, push } = githubAgentJobs({
|
|
453
|
+
forge,
|
|
454
|
+
jobName,
|
|
455
|
+
image,
|
|
456
|
+
settings,
|
|
457
|
+
...(spec.runner !== undefined ? { runner: spec.runner } : {}),
|
|
458
|
+
setup: setup.map(setupStepDoc),
|
|
459
|
+
beforeScript,
|
|
460
|
+
...(spec.agent.tokenEnv ? { tokenEnv: spec.agent.tokenEnv } : {}),
|
|
461
|
+
...(words.bin ? { bin: words.bin } : {}),
|
|
462
|
+
});
|
|
463
|
+
files.push({
|
|
464
|
+
name: `${spec.name}.yml`,
|
|
465
|
+
doc: {
|
|
466
|
+
name: spec.name,
|
|
467
|
+
on: { issue_comment: { types: ["created"] } },
|
|
468
|
+
...nonEmpty("env", options.variables),
|
|
469
|
+
// One agent run at a time, so two asks never count the limit at once.
|
|
470
|
+
concurrency: concurrency(jobName),
|
|
471
|
+
permissions: { contents: "read" },
|
|
472
|
+
jobsDoc: {},
|
|
473
|
+
agentDoc: { [jobName]: agent, [agentPushJobName(jobName)]: push },
|
|
474
|
+
},
|
|
475
|
+
});
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
428
478
|
const findingMode = spec.findingMode ?? "report";
|
|
429
479
|
const trigger = resolveOpTrigger(spec);
|
|
430
480
|
assertTriggerSupportsMode(spec.name, findingMode, trigger);
|
|
@@ -438,7 +488,14 @@ export function buildGithubOpPipelineDocs(
|
|
|
438
488
|
// A split review (#4326) runs the change's code beside a model: no step of that job holds a forge token.
|
|
439
489
|
const split = spec.reviewAgent === true;
|
|
440
490
|
if (split) assertReviewSplit(spec.name, trigger, findingMode, spec.permissions);
|
|
491
|
+
// #4616: a drift agent follows the Op's job, which hands its output on and says whether it found drift.
|
|
492
|
+
const driftAgent = spec.agent?.on === "drift" ? resolveAgentSpec(spec.name, spec.agent, words.product) : undefined;
|
|
493
|
+
if (driftAgent && (split || trigger.kind === "push")) {
|
|
494
|
+
throw new Error(`Scheduled Op "${spec.name}" sets a drift agent, which follows a drift check's run; give it a cron or pull_request trigger and no reviewAgent.`);
|
|
495
|
+
}
|
|
441
496
|
const stepEnv = split ? {} : jobTokenEnv(findingMode !== "report");
|
|
497
|
+
const cloud = spec.cloud;
|
|
498
|
+
const update = spec.update;
|
|
442
499
|
|
|
443
500
|
const setup = spec.setup ?? [];
|
|
444
501
|
assertSetupSteps(spec.name, setup);
|
|
@@ -453,13 +510,19 @@ export function buildGithubOpPipelineDocs(
|
|
|
453
510
|
? [...runParts, ...GATED_EXIT_FLAG, "--json"].join(" ")
|
|
454
511
|
: runParts.join(" ");
|
|
455
512
|
|
|
456
|
-
const
|
|
457
|
-
|
|
513
|
+
const later: Array<Record<string, unknown>> = [...setup.map(setupStepDoc), ...runSteps(beforeScript)];
|
|
514
|
+
later.push(
|
|
458
515
|
gated
|
|
459
516
|
? gatedRunStep(RUN_STEP_ID, spec.name, invocation, stepEnv)
|
|
460
|
-
:
|
|
517
|
+
: driftAgent
|
|
518
|
+
? driftRunStep(invocation, stepEnv, forge)
|
|
519
|
+
: update
|
|
520
|
+
? driftFlagStep(invocation, stepEnv)
|
|
521
|
+
: { run: invocation, env: stepEnv },
|
|
461
522
|
);
|
|
462
|
-
|
|
523
|
+
later.push(...runSteps(extraScript));
|
|
524
|
+
// #4631: the trade before the checkout, and every step after it without the token request variables.
|
|
525
|
+
const steps: Array<Record<string, unknown>> = cloud ? [tradeStep(cloud), checkoutStep(), ...later.map(withoutTokenRequest)] : [checkoutStep(), ...later];
|
|
463
526
|
|
|
464
527
|
const review = split
|
|
465
528
|
? reviewJobs({
|
|
@@ -475,6 +538,12 @@ export function buildGithubOpPipelineDocs(
|
|
|
475
538
|
})
|
|
476
539
|
: undefined;
|
|
477
540
|
|
|
541
|
+
const workflowPermissions = mergePermissions(
|
|
542
|
+
spec.name,
|
|
543
|
+
permissionsFor(findingMode, trigger),
|
|
544
|
+
spec.permissions ?? {},
|
|
545
|
+
trigger,
|
|
546
|
+
);
|
|
478
547
|
const doc: GithubOpPipelineDoc = {
|
|
479
548
|
name: spec.name,
|
|
480
549
|
on: onTrigger(trigger),
|
|
@@ -482,15 +551,10 @@ export function buildGithubOpPipelineDocs(
|
|
|
482
551
|
// One run at a time per Op — a slow audit must not overlap its own next
|
|
483
552
|
// scheduled trigger. A push Op's `build` (#4169) widens the group.
|
|
484
553
|
concurrency: concurrency(opConcurrencyGroup(jobName, trigger)),
|
|
485
|
-
permissions:
|
|
486
|
-
spec.name,
|
|
487
|
-
permissionsFor(findingMode, trigger),
|
|
488
|
-
spec.permissions ?? {},
|
|
489
|
-
trigger,
|
|
490
|
-
),
|
|
554
|
+
permissions: workflowPermissions,
|
|
491
555
|
jobsDoc: {
|
|
492
556
|
[jobName]: {
|
|
493
|
-
"runs-on":
|
|
557
|
+
"runs-on": githubRunsOn(spec.runner),
|
|
494
558
|
container: image,
|
|
495
559
|
// Per-Op credentials (#2290): job-level `env:`, one level more
|
|
496
560
|
// specific than `options.variables`'s workflow-level `env:` above,
|
|
@@ -502,6 +566,8 @@ export function buildGithubOpPipelineDocs(
|
|
|
502
566
|
// behind the same reviewer would hold the message back until
|
|
503
567
|
// somebody had already acted.
|
|
504
568
|
...(spec.environment ? { environment: environmentDoc(spec.environment) } : {}),
|
|
569
|
+
// #4631: the trade needs id-token: write, on this job alone; the finding-mode's scopes stay as they are.
|
|
570
|
+
...(cloud ? { permissions: { ...workflowPermissions, "id-token": "write" } } : {}),
|
|
505
571
|
...(gated
|
|
506
572
|
? {
|
|
507
573
|
outputs: Object.fromEntries(
|
|
@@ -511,16 +577,47 @@ export function buildGithubOpPipelineDocs(
|
|
|
511
577
|
]),
|
|
512
578
|
),
|
|
513
579
|
}
|
|
514
|
-
:
|
|
580
|
+
: driftAgent
|
|
581
|
+
? { outputs: driftRunOutputs(forge) }
|
|
582
|
+
: update
|
|
583
|
+
? { outputs: { drift: `\${{ steps.${DRIFT_FLAG_STEP_ID}.outputs.drift }}` } }
|
|
584
|
+
: {}),
|
|
515
585
|
steps,
|
|
516
586
|
},
|
|
587
|
+
...(update
|
|
588
|
+
? {
|
|
589
|
+
[scheduledOpUpdateJobName(jobName)]: updateJob(jobName, update, {
|
|
590
|
+
image,
|
|
591
|
+
...(spec.runner !== undefined ? { runner: spec.runner } : {}),
|
|
592
|
+
setup: setup.map(setupStepDoc),
|
|
593
|
+
beforeScript,
|
|
594
|
+
}),
|
|
595
|
+
}
|
|
596
|
+
: {}),
|
|
517
597
|
},
|
|
518
|
-
...(gated ? { gatedNoticeDoc: { [`${jobName}-gate-notice`]: gateNoticeJob(jobName, image) } } : {}),
|
|
598
|
+
...(gated ? { gatedNoticeDoc: { [`${jobName}-gate-notice`]: gateNoticeJob(jobName, image, spec.runner) } } : {}),
|
|
519
599
|
};
|
|
600
|
+
if (driftAgent) {
|
|
601
|
+
const name = agentJobName(jobName, "drift");
|
|
602
|
+
const { agent, push } = githubAgentJobs({
|
|
603
|
+
forge,
|
|
604
|
+
jobName: name,
|
|
605
|
+
image,
|
|
606
|
+
settings: driftAgent,
|
|
607
|
+
...(spec.runner !== undefined ? { runner: spec.runner } : {}),
|
|
608
|
+
setup: setup.map(setupStepDoc),
|
|
609
|
+
beforeScript,
|
|
610
|
+
...(spec.agent?.tokenEnv ? { tokenEnv: spec.agent.tokenEnv } : {}),
|
|
611
|
+
...(words.bin ? { bin: words.bin } : {}),
|
|
612
|
+
after: jobName,
|
|
613
|
+
});
|
|
614
|
+
doc.agentDoc = { [name]: agent, [agentPushJobName(name)]: push };
|
|
615
|
+
}
|
|
520
616
|
// A split review (#4326): the Op's job is the review job, and the note job rides beside it.
|
|
521
617
|
if (review) {
|
|
522
|
-
|
|
523
|
-
doc.
|
|
618
|
+
const runsOn = spec.runner !== undefined ? { "runs-on": githubRunsOn(spec.runner) } : {};
|
|
619
|
+
doc.jobsDoc = { [jobName]: { ...review.review, ...runsOn, ...(spec.environment ? { environment: environmentDoc(spec.environment) } : {}) } };
|
|
620
|
+
doc.reviewNoteDoc = { [reviewNoteJobName(jobName)]: { ...review.note, ...runsOn } };
|
|
524
621
|
}
|
|
525
622
|
|
|
526
623
|
files.push({ name: `${spec.name}.yml`, doc });
|
|
@@ -580,8 +677,8 @@ export function emitOpPipelineYAML(doc: GithubOpPipelineDoc): string {
|
|
|
580
677
|
// The gated-apply notice job rides in `jobs:` beside the Op's own job; it
|
|
581
678
|
// is carried separately on the doc so a dialect transforms it on its own.
|
|
582
679
|
// A dialect's `header` note (#2257) is absent on github.
|
|
583
|
-
const { gatedNoticeDoc, reviewNoteDoc, ...workflow } = doc;
|
|
584
|
-
return emitWorkflowYAML({ ...workflow, jobsDoc: { ...doc.jobsDoc, ...(gatedNoticeDoc ?? {}), ...(reviewNoteDoc ?? {}) } });
|
|
680
|
+
const { gatedNoticeDoc, reviewNoteDoc, agentDoc, ...workflow } = doc;
|
|
681
|
+
return emitWorkflowYAML({ ...workflow, jobsDoc: { ...doc.jobsDoc, ...(gatedNoticeDoc ?? {}), ...(reviewNoteDoc ?? {}), ...(agentDoc ?? {}) } });
|
|
585
682
|
}
|
|
586
683
|
|
|
587
684
|
/**
|
|
@@ -600,3 +697,51 @@ export function generateGithubOpPipeline(
|
|
|
600
697
|
jobs,
|
|
601
698
|
};
|
|
602
699
|
}
|
|
700
|
+
|
|
701
|
+
/** The id of the run step that says whether an update's drift check found drift (#4631). */
|
|
702
|
+
const DRIFT_FLAG_STEP_ID = "drift-check";
|
|
703
|
+
|
|
704
|
+
/** The Op's run step when an update follows it (#4631): `drift=true` as an output on chant's drift code, and the run's own exit code. */
|
|
705
|
+
function driftFlagStep(invocation: string, env: Record<string, string>): Record<string, unknown> {
|
|
706
|
+
return {
|
|
707
|
+
id: DRIFT_FLAG_STEP_ID,
|
|
708
|
+
env,
|
|
709
|
+
run: ["set +e", invocation, "code=$?", `if [ "$code" -eq ${CHANT_EXIT.drift} ]; then echo "drift=true" >>"$GITHUB_OUTPUT"; fi`, 'exit "$code"'].join("\n"),
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/** The trade step (#4631), as an op wave job's (#4332): the job's OIDC token for `cloud`, written to GITHUB_ENV. */
|
|
714
|
+
function tradeStep(cloud: CloudTradeSpec): Record<string, unknown> {
|
|
715
|
+
return { name: "Trade the job's OIDC token for its cloud identities", env: { [TRADE_SPEC_ENV]: JSON.stringify(cloud) }, run: tradeLine("$GITHUB_ENV", "github-env") };
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/** A run step after the trade, with the token request variables unset first. A `uses:` step is left as it is. */
|
|
719
|
+
function withoutTokenRequest(step: Record<string, unknown>): Record<string, unknown> {
|
|
720
|
+
return typeof step.run === "string" ? { ...step, run: withoutTokenRequestLine(step.run) } : step;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* The update job (#4631): after the Op's job, only when its run found
|
|
725
|
+
* drift; its own trade for `update.cloud` before its checkout, then the
|
|
726
|
+
* update's command. Its permissions are `contents: read`, `id-token: write`
|
|
727
|
+
* with a cloud, and the update's own scopes, which may raise `contents` to
|
|
728
|
+
* `write` so the update records on `chant/lifecycle` (2026-10-28 ruling).
|
|
729
|
+
* The Op's own job keeps its scopes.
|
|
730
|
+
*/
|
|
731
|
+
function updateJob(
|
|
732
|
+
after: string,
|
|
733
|
+
update: NonNullable<ScheduledOpSpec["update"]>,
|
|
734
|
+
i: { image: string; runner?: ScheduledOpSpec["runner"]; setup: Array<Record<string, unknown>>; beforeScript: readonly string[] },
|
|
735
|
+
): Record<string, unknown> {
|
|
736
|
+
const later: Array<Record<string, unknown>> = [...i.setup, ...runSteps(i.beforeScript), { name: "Update what drifted", run: update.command }];
|
|
737
|
+
return {
|
|
738
|
+
needs: after,
|
|
739
|
+
if: `always() && needs.${after}.outputs.drift == 'true'`,
|
|
740
|
+
"runs-on": githubRunsOn(i.runner),
|
|
741
|
+
container: i.image,
|
|
742
|
+
permissions: { contents: "read", ...(update.cloud ? { "id-token": "write" } : {}), ...(update.permissions ?? {}) },
|
|
743
|
+
...nonEmpty("env", update.variables),
|
|
744
|
+
...(update.environment ? { environment: environmentDoc(update.environment) } : {}),
|
|
745
|
+
steps: update.cloud ? [tradeStep(update.cloud), checkoutStep(), ...later.map(withoutTokenRequest)] : [checkoutStep(), ...later],
|
|
746
|
+
};
|
|
747
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #4630: a pr-review wave's plans workflow posts `<product>/approval` on the
|
|
3
|
+
* head after the plans are recorded and on every review, with a token that
|
|
4
|
+
* reads and writes statuses only.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test } from "vitest";
|
|
7
|
+
import type { OpWavesSpec } from "@intentius/chant/op/op-waves";
|
|
8
|
+
import { generateGithubOpWavesPipeline } from "./generate-op-waves-pipeline";
|
|
9
|
+
|
|
10
|
+
const spec: OpWavesSpec = {
|
|
11
|
+
name: "plywood-up",
|
|
12
|
+
op: "plywood-up",
|
|
13
|
+
plan: ["./bin/plan", "{target}", "{plan}"],
|
|
14
|
+
waves: [{ name: "prod", runs: [{ target: "app" }], approval: "pr-review" }],
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
describe("the approval status job (#4630)", () => {
|
|
18
|
+
test("runs after record-plans on a push and alone on a review, with a read token", () => {
|
|
19
|
+
const { files } = generateGithubOpWavesPipeline(spec, { specFile: ".chant/op-waves/plywood-up.spec.json", bin: "npx --no-install plywood" });
|
|
20
|
+
const plans = files.find((f) => f.name === "plywood-up-plans.yml")!.yaml;
|
|
21
|
+
expect(plans).toContain("pull_request_review:");
|
|
22
|
+
expect(plans).toMatch(/types:\n\s+- submitted\n\s+- dismissed/);
|
|
23
|
+
expect(plans).toContain("github.event_name == 'pull_request'");
|
|
24
|
+
expect(plans).toMatch(/approval-status:\n(.*\n)*?\s+needs:\n\s+- record-plans/);
|
|
25
|
+
expect(plans).toMatch(/permissions:\n\s+contents: read\n\s+pull-requests: read\n\s+statuses: write/);
|
|
26
|
+
expect(plans).toContain('npx --no-install plywood run wave --spec .chant/op-waves/plywood-up.spec.json --approval-status --pr "$PR_NUMBER" --head "$HEAD_SHA" --base "$BASE_SHA" --forge github');
|
|
27
|
+
expect(plans).toContain("HEAD_SHA: '${{ github.event.pull_request.head.sha }}'");
|
|
28
|
+
expect(plans).toContain("BASE_SHA: '${{ github.event.pull_request.base.sha }}'");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("a spec with no pr-review wave has no plans workflow", () => {
|
|
32
|
+
const { files } = generateGithubOpWavesPipeline({ ...spec, waves: [{ name: "prod", runs: [{ target: "app" }] }] }, { specFile: "waves.json" });
|
|
33
|
+
expect(files.map((f) => f.name)).toEqual(["plywood-up.yml"]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("a pull-request wave gets the plans workflow and the status too (2026-10-28 ruling)", () => {
|
|
37
|
+
const pullRequest: OpWavesSpec = { ...spec, waves: [{ name: "prod", runs: [{ target: "app" }], approval: "pull-request" }], approvals: { pullRequest: { gate: "up" } } };
|
|
38
|
+
const { files } = generateGithubOpWavesPipeline(pullRequest, { specFile: "waves.json", bin: "npx --no-install plywood" });
|
|
39
|
+
expect(files.map((f) => f.name)).toEqual(["plywood-up.yml", "plywood-up-plans.yml"]);
|
|
40
|
+
const plans = files.find((f) => f.name === "plywood-up-plans.yml")!.yaml;
|
|
41
|
+
expect(plans).toContain("npx --no-install plywood run wave --spec waves.json --record-plans");
|
|
42
|
+
expect(plans).toContain("--approval-status --pr \"$PR_NUMBER\"");
|
|
43
|
+
// The waves read no review, so they ask for no pull-requests scope.
|
|
44
|
+
expect(files[0]!.yaml).not.toContain("pull-requests: read");
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An Op waves spec's `control` (#4617): after the last wave, one job adds up
|
|
3
|
+
* the wave jobs' outcomes and tells the control repo how the waves ended.
|
|
4
|
+
* Written for the ci-products session; run at epic-ready.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from "vitest";
|
|
8
|
+
import type { OpWavesSpec } from "@intentius/chant/op/op-waves";
|
|
9
|
+
import { generateGithubOpWavesPipeline } from "./generate-op-waves-pipeline";
|
|
10
|
+
|
|
11
|
+
const spec: OpWavesSpec = {
|
|
12
|
+
name: "stacks",
|
|
13
|
+
op: "pulumi-up",
|
|
14
|
+
plan: ["./bin/plan", "{target}", "{plan}"],
|
|
15
|
+
waves: [
|
|
16
|
+
{ name: "canary", runs: [{ target: "dev" }], gate: "never" },
|
|
17
|
+
{ name: "rest", runs: [{ target: "a" }, { target: "b" }], shares: 2 },
|
|
18
|
+
],
|
|
19
|
+
control: { repo: "github.com/acme/control", op: "deploy", member: "network", tokenSecret: "PLYWOOD_CONTROL_TOKEN" },
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const options = { specFile: "waves.json", bin: "npx --no-install plywood", words: { product: "plywood" } };
|
|
23
|
+
|
|
24
|
+
describe("the control dispatch after the last wave (#4617)", () => {
|
|
25
|
+
test("needs the last wave's share jobs and runs after a failure, unless the run was cancelled", () => {
|
|
26
|
+
const yaml = generateGithubOpWavesPipeline(spec, options).files[0]!.yaml;
|
|
27
|
+
expect(yaml).toMatch(/control-dispatch:\n\s+runs-on: ubuntu-latest\n\s+needs:\n\s+- wave-2-rest-share-1\n\s+- wave-2-rest-share-2\n\s+if: '!cancelled\(\)'/);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("downloads every outcome file and adds them up through the product's bin", () => {
|
|
31
|
+
const yaml = generateGithubOpWavesPipeline(spec, options).files[0]!.yaml;
|
|
32
|
+
expect(yaml).toContain("name: Download the waves' outcomes");
|
|
33
|
+
expect(yaml).toContain("path: .chant/op-waves/stacks/outcomes");
|
|
34
|
+
expect(yaml).toContain('outcome="$(npx --no-install plywood run wave --spec waves.json --waves-outcome)" || exit 1');
|
|
35
|
+
expect(yaml).toContain('echo "CHANT_CONTROL_OUTCOME=$outcome" >> "$GITHUB_ENV"');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("the dispatch step alone holds the token, maps it from the product's secret and pipes it to curl", () => {
|
|
39
|
+
const yaml = generateGithubOpWavesPipeline(spec, options).files[0]!.yaml;
|
|
40
|
+
expect(yaml.match(/secrets\.PLYWOOD_CONTROL_TOKEN/g)).toHaveLength(1);
|
|
41
|
+
expect(yaml).toContain("CHANT_CONTROL_TOKEN: '${{ secrets.PLYWOOD_CONTROL_TOKEN }}'");
|
|
42
|
+
expect(yaml).toContain("CHANT_CONTROL_SHA: '${{ github.sha }}'");
|
|
43
|
+
expect(yaml).toContain("-H @-");
|
|
44
|
+
expect(yaml).toContain('"event_type":"plywood-applied"');
|
|
45
|
+
expect(yaml).toContain(`"outcome":"'"$outcome"'"`);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("markSent asks for statuses: write and hands the job token over", () => {
|
|
49
|
+
const yaml = generateGithubOpWavesPipeline({ ...spec, control: { ...spec.control!, markSent: "plywood post status control success" } }, options).files[0]!.yaml;
|
|
50
|
+
expect(yaml).toMatch(/control-dispatch:[\s\S]*permissions:\n\s+contents: write\n\s+statuses: write/);
|
|
51
|
+
expect(yaml).toContain("plywood post status control success");
|
|
52
|
+
expect(yaml).toContain("GITHUB_TOKEN: '${{ github.token }}'");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("no control, no job", () => {
|
|
56
|
+
const { control: _drop, ...plain } = spec;
|
|
57
|
+
expect(generateGithubOpWavesPipeline(plain, options).files[0]!.yaml).not.toContain("control-dispatch");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("a control without a member is refused, since a spec has no repository name to default it to", () => {
|
|
61
|
+
const { member: _m, ...noMember } = spec.control!;
|
|
62
|
+
expect(() => generateGithubOpWavesPipeline({ ...spec, control: noMember as never }, options)).toThrow(/control\.member/);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The apply-job guard in GitHub op-wave jobs (#4624): the first step of
|
|
3
|
+
* every wave, deciding and share job, before the trade and the checkout,
|
|
4
|
+
* carrying the job's forge environment, branches and runner. Written for
|
|
5
|
+
* the 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 { JOB_GUARD_ENV, jobGuardLine } from "@intentius/chant/ci-job-guard";
|
|
11
|
+
import { buildGithubOpWavesDoc } from "./generate-op-waves-pipeline";
|
|
12
|
+
|
|
13
|
+
const spec: OpWavesSpec = {
|
|
14
|
+
name: "deploy",
|
|
15
|
+
op: "pulumi-up",
|
|
16
|
+
plan: ["./bin/plan", "{target}", "{plan}"],
|
|
17
|
+
branches: ["main", "release"],
|
|
18
|
+
runner: ["self-hosted", "linux"],
|
|
19
|
+
waves: [
|
|
20
|
+
{ name: "dev", runs: [{ target: "dev" }], gate: "never" },
|
|
21
|
+
{ name: "prod", runs: [{ target: "prod" }], environment: { name: "production" }, idTokens: { CLOUD_TOKEN: "sts.amazonaws.com" } },
|
|
22
|
+
{ name: "wide", runs: [{ target: "a" }, { target: "b" }], shares: 2, environment: { name: "production" } },
|
|
23
|
+
],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type Job = { steps: Array<{ name?: string; env?: Record<string, string>; run?: string; uses?: string }> };
|
|
27
|
+
const jobsOf = (s: OpWavesSpec, words?: { product: string }) => buildGithubOpWavesDoc(s, { specFile: "waves.json", ...(words ? { words } : {}) }).doc.jobsDoc as Record<string, Job>;
|
|
28
|
+
const guardOf = (job: Job) => JSON.parse(job.steps[0]!.env![JOB_GUARD_ENV]!) as Record<string, unknown>;
|
|
29
|
+
|
|
30
|
+
describe("the apply-job guard on GitHub (#4624)", () => {
|
|
31
|
+
test("every wave job starts with it, before the checkout", () => {
|
|
32
|
+
const jobs = jobsOf(spec);
|
|
33
|
+
for (const [name, job] of Object.entries(jobs)) {
|
|
34
|
+
expect(job.steps[0]!.run, name).toBe(jobGuardLine());
|
|
35
|
+
expect(job.steps[1]!.uses, name).toMatch(/checkout/);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("carries the branches, the runner and, where it can read it, the environment", () => {
|
|
40
|
+
const jobs = jobsOf(spec);
|
|
41
|
+
expect(guardOf(jobs["wave-1-dev"]!)).toEqual({ forge: "github", job: "wave 1 (dev) of deploy", branches: ["main", "release"], selfHosted: true });
|
|
42
|
+
// Its id tokens give the job id-token: write, so the guard reads the environment from a token.
|
|
43
|
+
expect(guardOf(jobs["wave-2-prod"]!)).toMatchObject({ forgeEnvironment: "production" });
|
|
44
|
+
// A job that may request no token cannot be checked for its environment on GitHub.
|
|
45
|
+
expect(guardOf(jobs["wave-3-wide-decide"]!)).not.toHaveProperty("forgeEnvironment");
|
|
46
|
+
expect(guardOf(jobs["wave-3-wide-share-2"]!)).toMatchObject({ job: "share 2 of wave 3 (wide) of deploy" });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("names the product", () => {
|
|
50
|
+
expect(guardOf(jobsOf(spec, { product: "plywood" })["wave-1-dev"]!)).toMatchObject({ product: "plywood" });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("the default branch when the spec names none", () => {
|
|
54
|
+
const { branches: _b, ...plain } = spec;
|
|
55
|
+
expect(guardOf(jobsOf(plain)["wave-1-dev"]!)).toMatchObject({ branches: ["main"] });
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -57,7 +57,7 @@ interface Job {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
const jobsOf = (spec: OpWavesSpec): Record<string, Job> => (parseYAML(generateGithubOpWavesPipeline(spec, { specFile: ".chant/op-waves/tf-apply.spec.json", beforeScript: ["npm ci"] }).files[0]!.yaml) as { jobs: Record<string, Job> }).jobs;
|
|
60
|
-
const tradeOf = (job: Job): unknown => JSON.parse(job.steps[
|
|
60
|
+
const tradeOf = (job: Job): unknown => JSON.parse(job.steps[1]!.env![TRADE_SPEC_ENV]!);
|
|
61
61
|
const UNSET = `unset ${OIDC_REQUEST_VARS.join(" ")}`;
|
|
62
62
|
|
|
63
63
|
describe("an Op wave's jobs by cloud identity (#4332)", () => {
|
|
@@ -70,9 +70,9 @@ describe("an Op wave's jobs by cloud identity (#4332)", () => {
|
|
|
70
70
|
|
|
71
71
|
test("the deciding job trades for every plan identity, before the checkout, and names no apply identity", () => {
|
|
72
72
|
const decide = jobs["wave-1-wave-1-decide"]!;
|
|
73
|
-
expect(decide.steps[
|
|
74
|
-
expect(decide.steps[
|
|
75
|
-
expect(decide.steps[
|
|
73
|
+
expect(decide.steps[1]!.name).toBe("Trade the job's OIDC token for its cloud identities");
|
|
74
|
+
expect(decide.steps[1]!.run).toMatch(/^\(cd \/ && node -e '/);
|
|
75
|
+
expect(decide.steps[2]!.uses).toMatch(/^actions\/checkout@/);
|
|
76
76
|
expect(tradeOf(decide)).toEqual(plan);
|
|
77
77
|
expect(decide.permissions).toEqual({ contents: "write", "id-token": "write" });
|
|
78
78
|
for (const id of [role("prod-apply"), role("staging-apply"), role("own-apply")]) expect(JSON.stringify(decide)).not.toContain(id);
|
|
@@ -83,14 +83,14 @@ describe("an Op wave's jobs by cloud identity (#4332)", () => {
|
|
|
83
83
|
for (const [name, id] of Object.entries(want)) {
|
|
84
84
|
const job = jobs[name]!;
|
|
85
85
|
expect(tradeIdentities(tradeOf(job) as never)).toEqual([id]);
|
|
86
|
-
expect(job.steps[
|
|
86
|
+
expect(job.steps[2]!.uses).toMatch(/^actions\/checkout@/);
|
|
87
87
|
for (const other of Object.values(want).filter((o) => o !== id)) expect(JSON.stringify(job)).not.toContain(other);
|
|
88
88
|
}
|
|
89
89
|
});
|
|
90
90
|
|
|
91
91
|
test("every run step after the trade starts by unsetting the token request variables", () => {
|
|
92
92
|
for (const job of Object.values(jobs)) {
|
|
93
|
-
const runs = job.steps.slice(
|
|
93
|
+
const runs = job.steps.slice(2).filter((s) => s.run !== undefined);
|
|
94
94
|
expect(runs.length).toBeGreaterThan(0);
|
|
95
95
|
for (const s of runs) expect(s.run!.split("\n")[0]).toBe(UNSET);
|
|
96
96
|
}
|
|
@@ -124,8 +124,8 @@ describe("the deciding job, run step by step against the emulator (#4332)", () =
|
|
|
124
124
|
const githubEnv = join(temp, "github-env");
|
|
125
125
|
writeFileSync(githubEnv, "");
|
|
126
126
|
const base = { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: process.env.HOME ?? "/tmp", RUNNER_TEMP: temp, ...emu.requestEnv };
|
|
127
|
-
const spec = emu.pointAt(JSON.parse(decide.steps[
|
|
128
|
-
await run("sh", ["-c", decide.steps[
|
|
127
|
+
const spec = emu.pointAt(JSON.parse(decide.steps[1]!.env![TRADE_SPEC_ENV]!));
|
|
128
|
+
await run("sh", ["-c", decide.steps[1]!.run!.replace("$GITHUB_ENV", githubEnv)], { env: { ...base, [TRADE_SPEC_ENV]: JSON.stringify(spec) } });
|
|
129
129
|
const traded = Object.fromEntries(readFileSync(githubEnv, "utf-8").split("\n").filter(Boolean).map((l) => [l.slice(0, l.indexOf("=")), l.slice(l.indexOf("=") + 1)]));
|
|
130
130
|
expect(traded.AWS_ACCESS_KEY_ID).toBe(emulatedCredentials(role("own-plan")).accessKeyId);
|
|
131
131
|
const before = emu.log.minted.length;
|
|
@@ -121,4 +121,21 @@ describe("a runner per wave and job groups per wave (#3914)", () => {
|
|
|
121
121
|
expect(files.map((f) => f.name)).toEqual(["migrations.yml", "migrations-resume.yml", "migrations-plans.yml"]);
|
|
122
122
|
for (const f of files) expect(f.yaml).not.toContain("ubuntu-latest");
|
|
123
123
|
});
|
|
124
|
+
|
|
125
|
+
test("words.bin is the command in every job line: the waves, resume and the plans job (#4607)", () => {
|
|
126
|
+
const bin = "npx --no-install plywood";
|
|
127
|
+
const withBin = { ...spec, resume: { schedule: "*/10 * * * *" }, waves: spec.waves.map((w, i) => (i === spec.waves.length - 1 ? { ...w, approval: "pr-review" as const } : w)) };
|
|
128
|
+
const { files, jobs } = generateGithubOpWavesPipeline(withBin, { specFile: "waves.json", bin });
|
|
129
|
+
expect(jobs.every((j) => j.command.slice(0, 3).join(" ") === bin)).toBe(true);
|
|
130
|
+
const yaml = files.map((f) => f.yaml).join("\n");
|
|
131
|
+
expect(yaml).toContain("run: npx --no-install plywood run wave --spec waves.json --wave 1");
|
|
132
|
+
expect(yaml).toContain("npx --no-install plywood run resume --op migrations");
|
|
133
|
+
expect(yaml).toContain("npx --no-install plywood run wave --spec waves.json --record-plans");
|
|
134
|
+
expect(yaml).not.toMatch(/(^|\s)chant run (wave|resume)/m);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("no bin keeps chant (#4607)", () => {
|
|
138
|
+
const { jobs } = generateGithubOpWavesPipeline(spec, { specFile: "waves.json" });
|
|
139
|
+
expect(jobs.every((j) => j.command[0] === "chant")).toBe(true);
|
|
140
|
+
});
|
|
124
141
|
});
|