@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.
Files changed (39) hide show
  1. package/dist/components/agent-jobs.d.ts +67 -0
  2. package/dist/components/agent-jobs.d.ts.map +1 -0
  3. package/dist/components/agent-jobs.js +139 -0
  4. package/dist/components/control-pipeline.d.ts +34 -0
  5. package/dist/components/control-pipeline.d.ts.map +1 -0
  6. package/dist/components/control-pipeline.js +45 -0
  7. package/dist/components/generate-op-pipeline.d.ts +8 -1
  8. package/dist/components/generate-op-pipeline.d.ts.map +1 -1
  9. package/dist/components/generate-op-pipeline.js +144 -16
  10. package/dist/components/generate-op-waves-pipeline.d.ts +27 -1
  11. package/dist/components/generate-op-waves-pipeline.d.ts.map +1 -1
  12. package/dist/components/generate-op-waves-pipeline.js +135 -16
  13. package/dist/components/generate-pipeline.d.ts.map +1 -1
  14. package/dist/components/generate-pipeline.js +160 -15
  15. package/dist/integrity.json +2 -2
  16. package/dist/manifest.json +1 -1
  17. package/package.json +2 -2
  18. package/src/components/__golden__/op-waves-jobs.yml +20 -0
  19. package/src/components/agent-jobs.ts +177 -0
  20. package/src/components/control-pipeline.ts +65 -0
  21. package/src/components/generate-op-pipeline.agent.test.ts +147 -0
  22. package/src/components/generate-op-pipeline.cloud.test.ts +96 -0
  23. package/src/components/generate-op-pipeline.notice.test.ts +2 -1
  24. package/src/components/generate-op-pipeline.ts +165 -20
  25. package/src/components/generate-op-waves-pipeline.approval.test.ts +46 -0
  26. package/src/components/generate-op-waves-pipeline.control.test.ts +64 -0
  27. package/src/components/generate-op-waves-pipeline.guard.test.ts +57 -0
  28. package/src/components/generate-op-waves-pipeline.identity.test.ts +8 -8
  29. package/src/components/generate-op-waves-pipeline.test.ts +17 -0
  30. package/src/components/generate-op-waves-pipeline.ts +157 -15
  31. package/src/components/generate-pipeline.ts +170 -15
  32. package/src/components/generate-pr-pipeline.comment-locks.test.ts +53 -0
  33. package/src/components/generate-pr-pipeline.comment-preview.test.ts +64 -0
  34. package/src/components/generate-pr-pipeline.comment-targets.test.ts +24 -0
  35. package/src/components/generate-pr-pipeline.control.test.ts +94 -0
  36. package/src/components/generate-pr-pipeline.pulumi-identity.test.ts +43 -0
  37. package/src/components/generate-pr-pipeline.respond.test.ts +47 -0
  38. package/src/components/generate-pr-pipeline.runners.test.ts +61 -0
  39. 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
- GITHUB_RUNNER,
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": GITHUB_RUNNER,
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 steps: Array<Record<string, unknown>> = [checkoutStep(), ...setup.map(setupStepDoc), ...runSteps(beforeScript)];
457
- steps.push(
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
- : { run: invocation, env: stepEnv },
517
+ : driftAgent
518
+ ? driftRunStep(invocation, stepEnv, forge)
519
+ : update
520
+ ? driftFlagStep(invocation, stepEnv)
521
+ : { run: invocation, env: stepEnv },
461
522
  );
462
- steps.push(...runSteps(extraScript));
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: mergePermissions(
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": GITHUB_RUNNER,
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
- doc.jobsDoc = { [jobName]: { ...review.review, ...(spec.environment ? { environment: environmentDoc(spec.environment) } : {}) } };
523
- doc.reviewNoteDoc = { [reviewNoteJobName(jobName)]: review.note };
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[0]!.env![TRADE_SPEC_ENV]!);
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[0]!.name).toBe("Trade the job's OIDC token for its cloud identities");
74
- expect(decide.steps[0]!.run).toMatch(/^\(cd \/ && node -e '/);
75
- expect(decide.steps[1]!.uses).toMatch(/^actions\/checkout@/);
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[1]!.uses).toMatch(/^actions\/checkout@/);
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(1).filter((s) => s.run !== undefined);
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[0]!.env![TRADE_SPEC_ENV]!));
128
- await run("sh", ["-c", decide.steps[0]!.run!.replace("$GITHUB_ENV", githubEnv)], { env: { ...base, [TRADE_SPEC_ENV]: JSON.stringify(spec) } });
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
  });