@intentius/chant-lexicon-github 0.124.46 → 0.124.49

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 (69) hide show
  1. package/dist/action-pins.d.ts +17 -27
  2. package/dist/action-pins.d.ts.map +1 -1
  3. package/dist/action-pins.js +39 -27
  4. package/dist/components/ci-jobs.d.ts +5 -2
  5. package/dist/components/ci-jobs.d.ts.map +1 -1
  6. package/dist/components/ci-jobs.js +8 -5
  7. package/dist/components/generate-op-pipeline.d.ts +7 -0
  8. package/dist/components/generate-op-pipeline.d.ts.map +1 -1
  9. package/dist/components/generate-op-pipeline.js +26 -3
  10. package/dist/components/generate-op-waves-pipeline.d.ts +13 -3
  11. package/dist/components/generate-op-waves-pipeline.d.ts.map +1 -1
  12. package/dist/components/generate-op-waves-pipeline.js +24 -5
  13. package/dist/components/generate-pipeline.d.ts.map +1 -1
  14. package/dist/components/generate-pipeline.js +55 -10
  15. package/dist/components/review-jobs.d.ts +55 -0
  16. package/dist/components/review-jobs.d.ts.map +1 -0
  17. package/dist/components/review-jobs.js +130 -0
  18. package/dist/composites/cache.d.ts.map +1 -1
  19. package/dist/composites/cache.js +2 -1
  20. package/dist/composites/checkout.d.ts +3 -3
  21. package/dist/composites/checkout.js +1 -1
  22. package/dist/composites/docker-build.js +4 -4
  23. package/dist/composites/download-artifact.d.ts.map +1 -1
  24. package/dist/composites/download-artifact.js +2 -1
  25. package/dist/composites/go-ci.js +4 -4
  26. package/dist/composites/node-pipeline.js +5 -5
  27. package/dist/composites/python-ci.js +2 -2
  28. package/dist/composites/setup-go.d.ts.map +1 -1
  29. package/dist/composites/setup-go.js +2 -1
  30. package/dist/composites/setup-node.d.ts +3 -3
  31. package/dist/composites/setup-node.js +1 -1
  32. package/dist/composites/setup-python.d.ts.map +1 -1
  33. package/dist/composites/setup-python.js +2 -1
  34. package/dist/composites/upload-artifact.d.ts.map +1 -1
  35. package/dist/composites/upload-artifact.js +2 -1
  36. package/dist/integrity.json +2 -2
  37. package/dist/manifest.json +1 -1
  38. package/package.json +2 -2
  39. package/src/action-pins.test.ts +29 -6
  40. package/src/action-pins.ts +48 -34
  41. package/src/components/__fixtures__/pr-loop-build.github.golden.yml +8 -9
  42. package/src/components/__fixtures__/pr-loop-record.github.golden.yml +4 -5
  43. package/src/components/__fixtures__/pr-loop-waves.github.golden.yml +16 -17
  44. package/src/components/__fixtures__/pr-loop.github.golden.yml +8 -9
  45. package/src/components/__golden__/op-waves-jobs.yml +71 -9
  46. package/src/components/ci-jobs.ts +8 -5
  47. package/src/components/generate-op-pipeline.review.test.ts +152 -0
  48. package/src/components/generate-op-pipeline.test.ts +5 -4
  49. package/src/components/generate-op-pipeline.ts +33 -3
  50. package/src/components/generate-op-waves-pipeline.test.ts +2 -1
  51. package/src/components/generate-op-waves-pipeline.ts +25 -5
  52. package/src/components/generate-pipeline.test.ts +10 -9
  53. package/src/components/generate-pipeline.ts +57 -9
  54. package/src/components/generate-pr-pipeline.locks.test.ts +68 -0
  55. package/src/components/generate-pr-pipeline.test.ts +6 -4
  56. package/src/components/generate-pr-waves-pipeline.test.ts +3 -2
  57. package/src/components/review-jobs.ts +165 -0
  58. package/src/composites/cache.ts +2 -1
  59. package/src/composites/checkout.ts +4 -4
  60. package/src/composites/composites.test.ts +23 -22
  61. package/src/composites/docker-build.ts +4 -4
  62. package/src/composites/download-artifact.ts +2 -1
  63. package/src/composites/go-ci.ts +4 -4
  64. package/src/composites/node-pipeline.ts +5 -5
  65. package/src/composites/python-ci.ts +2 -2
  66. package/src/composites/setup-go.ts +2 -1
  67. package/src/composites/setup-node.ts +4 -4
  68. package/src/composites/setup-python.ts +2 -1
  69. package/src/composites/upload-artifact.ts +2 -1
@@ -0,0 +1,152 @@
1
+ /**
2
+ * The split review (#4326): `ScheduledOpSpec.reviewAgent` makes two jobs, a
3
+ * review job that runs the change's code with no forge write token and a note
4
+ * job that checks nothing out and posts.
5
+ */
6
+
7
+ import { describe, expect, test } from "vitest";
8
+ import { parseYAML } from "@intentius/chant/yaml";
9
+ import { CHECKOUT_TOKEN_ENV, checkoutScript, FORGE_TOKEN_VARS } from "@intentius/chant/ci-handover";
10
+ import type { ScheduledOpSpec } from "@intentius/chant/lexicon";
11
+ import { generateGithubOpPipeline } from "./generate-op-pipeline";
12
+
13
+ interface Step {
14
+ id?: string;
15
+ uses?: string;
16
+ run?: string;
17
+ env?: Record<string, string>;
18
+ with?: Record<string, unknown>;
19
+ "working-directory"?: string;
20
+ }
21
+ interface Job {
22
+ needs?: string;
23
+ if?: string;
24
+ permissions?: Record<string, string>;
25
+ env?: Record<string, string>;
26
+ outputs?: Record<string, string>;
27
+ steps: Step[];
28
+ }
29
+ interface Doc {
30
+ permissions?: Record<string, string>;
31
+ env?: Record<string, string>;
32
+ jobs: Record<string, Job>;
33
+ }
34
+
35
+ const SPEC: ScheduledOpSpec = { name: "pr-review", trigger: { kind: "pull_request" }, reviewAgent: true };
36
+ const gen = (spec: Partial<ScheduledOpSpec> = {}, options = {}) => {
37
+ const out = generateGithubOpPipeline([{ ...SPEC, ...spec }], options);
38
+ return { doc: parseYAML(out.files[0]!.yaml) as unknown as Doc, yaml: out.files[0]!.yaml, name: out.files[0]!.name };
39
+ };
40
+
41
+ describe("the review job", () => {
42
+ const { doc } = gen();
43
+ const job = doc.jobs["pr-review"]!;
44
+
45
+ test("holds contents: read and nothing that writes to the forge", () => {
46
+ expect(doc.permissions).toEqual({ contents: "read" });
47
+ expect(job.permissions).toBeUndefined();
48
+ });
49
+
50
+ test("no step but the checkout's fetches holds a forge token, and none uses actions/checkout", () => {
51
+ for (const step of job.steps) {
52
+ const named = Object.keys(step.env ?? {}).filter((n) => (FORGE_TOKEN_VARS as readonly string[]).includes(n));
53
+ expect(step.uses ?? "").not.toMatch(/^actions\/checkout@/);
54
+ if (step.run?.trimEnd() === checkoutScript(true).trimEnd()) {
55
+ expect(named).toEqual([CHECKOUT_TOKEN_ENV]);
56
+ continue;
57
+ }
58
+ expect(named).toEqual([]);
59
+ expect(JSON.stringify(step.env ?? {}) + JSON.stringify(step.with ?? {})).not.toMatch(/secrets\.|github\.token/);
60
+ }
61
+ expect(job.env ?? {}).toEqual({});
62
+ expect(doc.env ?? {}).toEqual({});
63
+ });
64
+
65
+ test("the checkout carries the base branch's history for the diff", () => {
66
+ expect(job.steps[0]!.run!.trimEnd()).toBe(checkoutScript(true).trimEnd());
67
+ expect(job.steps[0]!.run).toContain("+refs/heads/*:refs/remotes/origin/*");
68
+ });
69
+
70
+ test("the Op's step starts without the token variables, names the handoff directory, and hands the review on as the job's outputs", () => {
71
+ const run = job.steps.at(-1)!;
72
+ expect(run.id).toBe("chant-review");
73
+ expect(run.run).toContain("chant run pr-review");
74
+ expect(run.run).toMatch(/^if .*exec env -u CHANT_FORGE_TOKEN/);
75
+ expect(run.run).toContain("trap chant_handover EXIT");
76
+ expect(run.env).toEqual({ CHANT_REVIEW_DIR: ".chant/review" });
77
+ expect(job.outputs).toEqual({ report_0: "${{ steps.chant-review.outputs.report_0 }}" });
78
+ });
79
+
80
+ test("the Op's step is the last one, so nothing starts beside what the change's code left running", () => {
81
+ expect(job.steps.at(-1)!.id).toBe("chant-review");
82
+ });
83
+ });
84
+
85
+ describe("the note job", () => {
86
+ const { doc } = gen();
87
+ const note = doc.jobs["pr-review-note"]!;
88
+
89
+ test("needs the review job and runs when it succeeded", () => {
90
+ expect(note.needs).toBe("pr-review");
91
+ expect(note.if).toBe("needs.pr-review.result == 'success'");
92
+ });
93
+
94
+ test("checks nothing out and holds the pull-request write scope alone", () => {
95
+ expect(note.permissions).toEqual({ "pull-requests": "write" });
96
+ expect(note.steps.some((s) => (s.uses ?? "").startsWith("actions/checkout"))).toBe(false);
97
+ expect(note.steps.some((s) => (s.run ?? "").includes("git "))).toBe(false);
98
+ });
99
+
100
+ test("takes the review as data and posts it with the job's token, naming the Op itself", () => {
101
+ expect(note.steps[0]!.run).toContain("toJSON(needs.pr-review.outputs.report_0)");
102
+ const post = note.steps[1]!;
103
+ expect(post.env).toEqual({ GITHUB_TOKEN: "${{ github.token }}", CHANT_OP: "pr-review" });
104
+ expect(post.run).toContain('import { postReviewNote } from "@intentius/chant/op/activities/review-agent";');
105
+ expect(post.run).toMatch(/npm install .* @intentius\/chant@\d+\.\d+\.\d+/);
106
+ expect(post.run).not.toContain("${{");
107
+ });
108
+
109
+ test("the job that runs the change's code has no pull-requests scope the note job lacks, and the note job runs none of the change's code", () => {
110
+ const review = doc.jobs["pr-review"]!;
111
+ expect(JSON.stringify(review)).not.toContain("pull-requests");
112
+ expect(note.steps.map((s) => s.run ?? "").join("\n")).not.toContain("chant run");
113
+ });
114
+ });
115
+
116
+ describe("what the shape refuses and carries", () => {
117
+ test("a trigger with no pull request, a finding-mode that writes and a write scope are refused by name", () => {
118
+ expect(() => gen({ trigger: { kind: "push" } })).toThrow(/pr-review.*"push"/);
119
+ expect(() => gen({ findingMode: "comment" })).toThrow(/findingMode "comment".*must be "report"/);
120
+ expect(() => gen({ permissions: { "pull-requests": "write" } })).toThrow(/pull-requests: write/);
121
+ expect(() => gen({ permissions: { "id-token": "write" } })).not.toThrow();
122
+ });
123
+
124
+ test("setup steps, per-Op variables, beforeScript and extraScript land in the review job", () => {
125
+ const { doc } = gen(
126
+ { setup: [{ uses: "aws-actions/configure-aws-credentials@" + "a".repeat(40), with: { "role-to-assume": "arn:aws:iam::1:role/plan" } }], variables: { AWS_REGION: "eu-west-1" }, permissions: { "id-token": "write" } },
127
+ { beforeScript: ["npm ci"], extraScript: ["echo done"] },
128
+ );
129
+ const job = doc.jobs["pr-review"]!;
130
+ expect(job.env).toEqual({ AWS_REGION: "eu-west-1" });
131
+ expect(job.steps.some((s) => (s.uses ?? "").startsWith("aws-actions/configure-aws-credentials"))).toBe(true);
132
+ expect(job.steps.some((s) => (s.run ?? "").includes("\nnpm ci"))).toBe(true);
133
+ expect(job.steps.at(-1)!.run).toContain("echo done");
134
+ expect(doc.permissions).toEqual({ contents: "read", "id-token": "write" });
135
+ });
136
+
137
+ test("a member's directory scopes the review job, and the checkout still runs at the repository root", () => {
138
+ const { doc, name } = gen({}, { member: { name: "network", dir: "infra/network", file: "network.yml" } });
139
+ expect(name).toBe("network-pr-review.yml");
140
+ const job = doc.jobs["pr-review"] as Job & { defaults?: { run?: { "working-directory"?: string } } };
141
+ expect(job.defaults?.run?.["working-directory"]).toBe("infra/network");
142
+ expect(job.steps[0]!["working-directory"]).toBe(".");
143
+ expect((doc.jobs["pr-review-note"] as { defaults?: unknown }).defaults).toBeUndefined();
144
+ });
145
+
146
+ test("an Op without reviewAgent keeps its one job and its token", () => {
147
+ const out = generateGithubOpPipeline([{ name: "plain", trigger: { kind: "pull_request" } }]);
148
+ const doc = parseYAML(out.files[0]!.yaml) as unknown as Doc;
149
+ expect(Object.keys(doc.jobs)).toEqual(["plain"]);
150
+ expect(out.files[0]!.yaml).toContain("GITHUB_TOKEN");
151
+ });
152
+ });
@@ -16,6 +16,7 @@ import { describe, test, expect } from "vitest";
16
16
  import { parseYAML } from "@intentius/chant/yaml";
17
17
  import { generateGithubOpPipeline } from "./generate-op-pipeline";
18
18
  import type { ScheduledOpSpec } from "@intentius/chant/lexicon";
19
+ import { ACTION_PINS, usesRef } from "@intentius/chant/action-pins";
19
20
 
20
21
  interface ParsedStep {
21
22
  name?: string;
@@ -73,7 +74,7 @@ describe("generateGithubOpPipeline: one file per scheduled Op", () => {
73
74
  const job = doc.jobs!["actions-audit"];
74
75
 
75
76
  expect(job["runs-on"]).toBe("ubuntu-latest");
76
- expect(job.steps[0].uses).toBe("actions/checkout@v7");
77
+ expect(job.steps[0].uses).toBe(`${usesRef(ACTION_PINS.checkout7)}`);
77
78
  const runStep = job.steps.find((s) => typeof s.run === "string")!;
78
79
  expect(runStep.run).toBe("chant run actions-audit");
79
80
  });
@@ -339,7 +340,7 @@ describe("generateGithubOpPipeline: setup steps and additive permissions (#2242)
339
340
  const steps = doc.jobs!["app-apply"].steps;
340
341
 
341
342
  expect(steps.slice(0, 3).map((s) => s.uses ?? s.run)).toEqual([
342
- "actions/checkout@v7",
343
+ `${usesRef(ACTION_PINS.checkout7)}`,
343
344
  "aws-actions/configure-aws-credentials@v6",
344
345
  "install terraform",
345
346
  ]);
@@ -397,7 +398,7 @@ describe("generateGithubOpPipeline: setup steps and additive permissions (#2242)
397
398
  ];
398
399
  const steps = parseFile(generateGithubOpPipeline(specs).files[0].yaml).jobs!["app-apply"].steps;
399
400
  expect(steps.map((s) => s.uses).filter(Boolean)).toEqual([
400
- "actions/checkout@v7",
401
+ `${usesRef(ACTION_PINS.checkout7)}`,
401
402
  "github/codeql-action/upload-sarif@v4",
402
403
  "aws-actions/configure-aws-credentials@0e613a0980cbf65ed5b322eb7a1e075d28913a83",
403
404
  ]);
@@ -639,7 +640,7 @@ describe("generateGithubOpPipeline: a deployment environment on the Op's job (#2
639
640
  " runs-on: ubuntu-latest",
640
641
  " container: node:22",
641
642
  " steps:",
642
- " - uses: actions/checkout@v7",
643
+ ` - uses: ${usesRef(ACTION_PINS.checkout7)} # ${ACTION_PINS.checkout7.tag}`,
643
644
  " - run: chant run actions-audit",
644
645
  " env:",
645
646
  " GITHUB_TOKEN: '${{ github.token }}'",
@@ -96,6 +96,7 @@ import type {
96
96
  ScheduledOpSpec,
97
97
  } from "@intentius/chant/lexicon";
98
98
  import { checkoutStep, concurrency, emitWorkflowYAML, jobTokenEnv, onTrigger, opConcurrencyGroup, runSteps } from "./ci-jobs";
99
+ import { assertReviewSplit, reviewJobs, reviewNoteJobName } from "./review-jobs";
99
100
 
100
101
  export type { GenerateGithubOpOptions, GenerateGithubOpResult };
101
102
 
@@ -154,6 +155,13 @@ export interface GithubOpPipelineDoc {
154
155
  * artifact instead (#2256).
155
156
  */
156
157
  gatedNoticeDoc?: Record<string, unknown>;
158
+ /**
159
+ * The review note job (#4326), when the spec sets `reviewAgent`. Kept out
160
+ * of {@link jobsDoc} for the reason the gate notice is: a member's
161
+ * `working-directory` default belongs to the job that runs the Op, and a
162
+ * dialect transforms this job on its own terms.
163
+ */
164
+ reviewNoteDoc?: Record<string, unknown>;
157
165
  }
158
166
 
159
167
  /** One generated file: a suggested name plus its pipeline document, pre-emission. */
@@ -427,7 +435,10 @@ export function buildGithubOpPipelineDocs(
427
435
 
428
436
  // A live-resolution read (rate limits) always benefits from a token;
429
437
  // creating an issue/PR additionally needs `gh` CLI's own token variable.
430
- const stepEnv = jobTokenEnv(findingMode !== "report");
438
+ // A split review (#4326) runs the change's code beside a model: no step of that job holds a forge token.
439
+ const split = spec.reviewAgent === true;
440
+ if (split) assertReviewSplit(spec.name, trigger, findingMode, spec.permissions);
441
+ const stepEnv = split ? {} : jobTokenEnv(findingMode !== "report");
431
442
 
432
443
  const setup = spec.setup ?? [];
433
444
  assertSetupSteps(spec.name, setup);
@@ -450,6 +461,20 @@ export function buildGithubOpPipelineDocs(
450
461
  );
451
462
  steps.push(...runSteps(extraScript));
452
463
 
464
+ const review = split
465
+ ? reviewJobs({
466
+ name: spec.name,
467
+ jobName,
468
+ image,
469
+ invocation,
470
+ setup: setup.map(setupStepDoc),
471
+ beforeScript,
472
+ extraScript,
473
+ ...(spec.variables ? { variables: spec.variables } : {}),
474
+ ...(options.member ? { member: options.member } : {}),
475
+ })
476
+ : undefined;
477
+
453
478
  const doc: GithubOpPipelineDoc = {
454
479
  name: spec.name,
455
480
  on: onTrigger(trigger),
@@ -492,6 +517,11 @@ export function buildGithubOpPipelineDocs(
492
517
  },
493
518
  ...(gated ? { gatedNoticeDoc: { [`${jobName}-gate-notice`]: gateNoticeJob(jobName, image) } } : {}),
494
519
  };
520
+ // A split review (#4326): the Op's job is the review job, and the note job rides beside it.
521
+ if (review) {
522
+ doc.jobsDoc = { [jobName]: { ...review.review, ...(spec.environment ? { environment: environmentDoc(spec.environment) } : {}) } };
523
+ doc.reviewNoteDoc = { [reviewNoteJobName(jobName)]: review.note };
524
+ }
495
525
 
496
526
  files.push({ name: `${spec.name}.yml`, doc });
497
527
  }
@@ -550,8 +580,8 @@ export function emitOpPipelineYAML(doc: GithubOpPipelineDoc): string {
550
580
  // The gated-apply notice job rides in `jobs:` beside the Op's own job; it
551
581
  // is carried separately on the doc so a dialect transforms it on its own.
552
582
  // A dialect's `header` note (#2257) is absent on github.
553
- const { gatedNoticeDoc, ...workflow } = doc;
554
- return emitWorkflowYAML({ ...workflow, jobsDoc: { ...doc.jobsDoc, ...(gatedNoticeDoc ?? {}) } });
583
+ const { gatedNoticeDoc, reviewNoteDoc, ...workflow } = doc;
584
+ return emitWorkflowYAML({ ...workflow, jobsDoc: { ...doc.jobsDoc, ...(gatedNoticeDoc ?? {}), ...(reviewNoteDoc ?? {}) } });
555
585
  }
556
586
 
557
587
  /**
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, test } from "vitest";
2
2
  import type { OpWavesSpec } from "@intentius/chant/op/op-waves";
3
3
  import { generateGithubOpWavesPipeline } from "./generate-op-waves-pipeline";
4
+ import { ACTION_PINS, usesRef } from "@intentius/chant/action-pins";
4
5
 
5
6
  const spec: OpWavesSpec = {
6
7
  name: "migrations",
@@ -26,7 +27,7 @@ describe("generateGithubOpWavesPipeline", () => {
26
27
  expect(yaml).toContain("run: chant run wave --spec waves.json --wave 2 --decide");
27
28
  expect(yaml).toMatch(/wave-2-prod-share-2:\n\s+runs-on: ubuntu-latest\n\s+needs:\n\s+- wave-2-prod-decide/);
28
29
  expect(yaml).toContain("run: chant run wave --spec waves.json --wave 2 --share 2");
29
- expect(yaml).toContain("uses: actions/upload-artifact@v4");
30
+ expect(yaml).toContain(`uses: ${usesRef(ACTION_PINS.uploadArtifact4)}`);
30
31
  expect(yaml).toContain("name: migrations-wave-2-decision");
31
32
  expect(yaml).toContain("environment:\n name: production");
32
33
  });
@@ -3,9 +3,17 @@
3
3
  *
4
4
  * One workflow for the whole spec, on a push to its branches (and by hand),
5
5
  * one run at a time. Each job is one of core's `opWaveJobs`: a wave, or a wide
6
- * wave's deciding job and share jobs, chained by `needs:` so a wave that waits
7
- * at its gate (exit 3) or fails stops every later wave. Re-running the failed
8
- * jobs after `chant approve` carries on from that wave.
6
+ * wave's deciding job and share jobs, chained by `needs:`. Re-running the
7
+ * failed jobs after `chant approve` carries on from that wave.
8
+ *
9
+ * Each job keeps its outcome file as an artifact whatever its exit code
10
+ * (#4322), and a job with `needs:` downloads every artifact of the run into
11
+ * the outcome directory first. A wave or deciding job after the first runs
12
+ * unless the run was cancelled (`if: !cancelled()`), so it runs after a
13
+ * failure in the wave before: it blocks the runs that depend on a failed one
14
+ * and applies the rest, and it stops by itself when an earlier wave waits at
15
+ * its gate (exit 3) or applied nothing for another reason. A share job still
16
+ * needs its deciding job to succeed.
9
17
  *
10
18
  * Every job checks out the full history, because the runner reads the gate
11
19
  * policy from the spec file at the base commit and records pending gates on
@@ -43,7 +51,7 @@
43
51
  * and cannot request one for another group's identity.
44
52
  */
45
53
 
46
- import { notifyVariables, opWaveGroupOf, opWaveJobs, opWavesResumeCommand, type OpWavesSpec } from "@intentius/chant/op/op-waves";
54
+ import { notifyVariables, opWaveGroupOf, opWaveJobs, opWaveOutcomeDir, opWavesResumeCommand, type OpWavesSpec } from "@intentius/chant/op/op-waves";
47
55
  import type { OpWavesPipelineOptions, OpWavesPipelineResult } from "@intentius/chant/lexicon";
48
56
  import {
49
57
  assertEnvironment,
@@ -60,7 +68,7 @@ import {
60
68
  setupStepDoc,
61
69
  } from "@intentius/chant/ci-jobs";
62
70
  import { OIDC_REQUEST_VARS, TRADE_SPEC_ENV, tradeLine, type CloudTradeSpec } from "@intentius/chant/ci-identity";
63
- import { checkoutStep, concurrency, downloadArtifactStep, keepRecordStep, runSteps } from "./ci-jobs";
71
+ import { checkoutStep, concurrency, DOWNLOAD_ARTIFACT, downloadArtifactStep, keepRecordStep, runSteps } from "./ci-jobs";
64
72
  import { emitOpPipelineYAML, type GithubOpPipelineDoc } from "./generate-op-pipeline";
65
73
 
66
74
  /** The default image: it carries git, which the runner needs to read the base commit. */
@@ -71,6 +79,11 @@ export function opWaveRecordArtifact(spec: Pick<OpWavesSpec, "name">, wave: numb
71
79
  return `${spec.name}-wave-${wave}-decision`;
72
80
  }
73
81
 
82
+ /** The artifact a job keeps its outcome file in (#4322). */
83
+ export function opWaveOutcomeArtifact(spec: Pick<OpWavesSpec, "name">, jobName: string): string {
84
+ return `${spec.name}-${jobName}-outcome`;
85
+ }
86
+
74
87
  /** The workflow document, before emission, and the jobs it holds. */
75
88
  export function buildGithubOpWavesDoc(
76
89
  spec: OpWavesSpec,
@@ -100,6 +113,10 @@ export function buildGithubOpWavesDoc(
100
113
  if (job.kind === "share") {
101
114
  steps.push(downloadArtifactStep(`Download wave ${job.wave}'s decision`, opWaveRecordArtifact(spec, job.wave), dirnameOf(job.record!)));
102
115
  }
116
+ if (job.needs.length > 0) {
117
+ // #4322: every artifact of the run, so the job reads each earlier job's outcome file.
118
+ steps.push({ name: "Download the earlier jobs' outcomes", uses: DOWNLOAD_ARTIFACT, with: { path: opWaveOutcomeDir(spec) } });
119
+ }
103
120
  const later: Array<Record<string, unknown>> = [];
104
121
  later.push(...[...(wave.setup ?? []), ...(group?.setup ?? [])].map(setupStepDoc), ...runSteps(options.beforeScript ?? []));
105
122
  later.push({ name: describeJob(job.kind, job.wave, wave.name, job.share, job.group), run: job.command.join(" ") });
@@ -108,12 +125,15 @@ export function buildGithubOpWavesDoc(
108
125
  if (job.kind === "decide") {
109
126
  steps.push(keepRecordStep(`Keep wave ${job.wave}'s decision`, opWaveRecordArtifact(spec, job.wave), job.record!));
110
127
  }
128
+ steps.push(keepRecordStep(`Keep the job's outcome`, opWaveOutcomeArtifact(spec, job.jobName), job.outcome));
111
129
  const environment = group?.environment ?? wave.environment;
112
130
  const idTokens = group?.idTokens ?? wave.idTokens;
113
131
  const idToken = idTokens !== undefined || cloud !== undefined;
114
132
  jobsDoc[job.jobName] = {
115
133
  "runs-on": githubRunsOn(group?.runner ?? wave.runner ?? spec.runner),
116
134
  ...(job.needs.length > 0 ? { needs: job.needs } : {}),
135
+ // #4322: a wave's jobs run after a failure in the wave before; a share job runs only on its deciding job's success.
136
+ ...(job.needs.length > 0 && job.kind !== "share" ? { if: "!cancelled()" } : {}),
117
137
  container: image,
118
138
  ...(environment ? { environment: environmentDoc(environment) } : {}),
119
139
  // #3914: a job's own permissions replace the workflow's, so they repeat them.
@@ -23,6 +23,7 @@ import { artifactRoot, generateGithubPipeline } from "./generate-pipeline";
23
23
  import { resolveComponentGraph, DependencyCycleError, UnknownDependencyError, type DriverComponent } from "@intentius/chant/components/driver";
24
24
  import { searchService } from "@intentius/chant/components/pilots/alb-ecs.pilot";
25
25
  import { ordersTable } from "@intentius/chant/components/pilots/dynamodb.pilot";
26
+ import { ACTION_PINS, usesRef } from "@intentius/chant/action-pins";
26
27
 
27
28
  /** Real pilot components (#555) plus the shared-alb infra `search-service` depends on, as a realistic multi-wave input. */
28
29
  function pilotComponents(): DriverComponent[] {
@@ -67,21 +68,21 @@ describe("generateGithubPipeline: cross-stack output threading (artifacts)", ()
67
68
  // job, so it dumps them and uploads the file as a workflow artifact.
68
69
  const shared = jobs["shared-alb"];
69
70
  expect(runLines(shared).some((line) => line.includes("--dump-outputs shared-alb.outputs.json"))).toBe(true);
70
- const uploadStep = shared.steps.find((s) => s.uses === "actions/upload-artifact@v4");
71
+ const uploadStep = shared.steps.find((s) => s.uses === `${usesRef(ACTION_PINS.uploadArtifact4)}`);
71
72
  expect(uploadStep?.with).toEqual({ name: "shared-alb-outputs", path: "shared-alb.outputs.json" });
72
73
 
73
74
  // Consumer: search-service downloads shared-alb's uploaded artifact and
74
75
  // seeds from the dumped file so its stackOutput() references resolve.
75
76
  const svc = jobs["search-service"];
76
- const downloadStep = svc.steps.find((s) => s.uses === "actions/download-artifact@v4");
77
+ const downloadStep = svc.steps.find((s) => s.uses === `${usesRef(ACTION_PINS.downloadArtifact4)}`);
77
78
  expect(downloadStep?.with).toEqual({ name: "shared-alb-outputs", path: "." });
78
79
  expect(runLines(svc).some((line) => line.includes("--seed-outputs shared-alb.outputs.json"))).toBe(true);
79
80
 
80
81
  // A component nothing depends on neither dumps nor uploads an artifact.
81
82
  const orders = jobs["orders-table"];
82
83
  expect(runLines(orders).some((line) => line.includes("--dump-outputs"))).toBe(false);
83
- expect(orders.steps.some((s) => s.uses === "actions/upload-artifact@v4")).toBe(false);
84
- expect(orders.steps.some((s) => s.uses === "actions/download-artifact@v4")).toBe(false);
84
+ expect(orders.steps.some((s) => s.uses === `${usesRef(ACTION_PINS.uploadArtifact4)}`)).toBe(false);
85
+ expect(orders.steps.some((s) => s.uses === `${usesRef(ACTION_PINS.downloadArtifact4)}`)).toBe(false);
85
86
  expect(runLines(orders).some((line) => line.includes("--seed-outputs"))).toBe(false);
86
87
  });
87
88
  });
@@ -104,7 +105,7 @@ describe("generateGithubPipeline: structurally valid YAML", () => {
104
105
  expect(job.steps.length).toBeGreaterThan(0);
105
106
  // Every job starts with a checkout step — a job runs on a fresh runner
106
107
  // with no repo present, unlike GitLab's auto-cloned workspace.
107
- expect(job.steps[0].uses).toBe("actions/checkout@v7");
108
+ expect(job.steps[0].uses).toBe(`${usesRef(ACTION_PINS.checkout7)}`);
108
109
  }
109
110
  });
110
111
 
@@ -327,7 +328,7 @@ describe("generateGithubPipeline: a promote job (#2575)", () => {
327
328
  const jobs = parsedJobs(result.yaml);
328
329
 
329
330
  const upload = jobs["api"].steps.find((s) => s.name === "Upload api build archive")!;
330
- expect(upload.uses).toBe("actions/upload-artifact@v4");
331
+ expect(upload.uses).toBe(`${usesRef(ACTION_PINS.uploadArtifact4)}`);
331
332
  expect(upload.with).toEqual({
332
333
  name: "api-archive",
333
334
  path: "dist/images/api.tar",
@@ -339,7 +340,7 @@ describe("generateGithubPipeline: a promote job (#2575)", () => {
339
340
  const promote = jobs["promote-prod"];
340
341
  expect(promote.needs).toEqual(["api", "shared-alb", "worker"]);
341
342
  expect(promote.container).toBe("node:22");
342
- const downloads = promote.steps.filter((s) => s.uses === "actions/download-artifact@v4");
343
+ const downloads = promote.steps.filter((s) => s.uses === `${usesRef(ACTION_PINS.downloadArtifact4)}`);
343
344
  expect(downloads.map((s) => s.with)).toEqual([
344
345
  { name: "api-archive", path: "dist/images" },
345
346
  { name: "worker-archive", path: "." },
@@ -440,7 +441,7 @@ describe("generateGithubPipeline: gated waves (#3049)", () => {
440
441
 
441
442
  const steps = parsed.jobs["wave-2"].steps;
442
443
  expect(steps[0].with).toEqual({ "fetch-depth": 0 });
443
- expect(steps[1]).toMatchObject({ uses: "actions/download-artifact@v4", with: { name: "fan-out-record-wave-1", path: ".chant" } });
444
+ expect(steps[1]).toMatchObject({ uses: `${usesRef(ACTION_PINS.downloadArtifact4)}`, with: { name: "fan-out-record-wave-1", path: ".chant" } });
444
445
  // The default image is the pr-loop one: node:22, with OpenTofu installed when missing.
445
446
  expect(parsed.jobs["wave-2"]).toMatchObject({ container: "node:22" });
446
447
  expect(steps[2].run).toMatch(/^command -v tofu /);
@@ -449,7 +450,7 @@ describe("generateGithubPipeline: gated waves (#3049)", () => {
449
450
  );
450
451
  expect(steps[4]).toMatchObject({ if: "always()", with: { name: "fan-out-record-wave-2", path: ".chant/fan-out.json" } });
451
452
  // Wave 1 has nothing to download.
452
- expect(parsed.jobs["wave-1"].steps.some((s) => s.uses === "actions/download-artifact@v4")).toBe(false);
453
+ expect(parsed.jobs["wave-1"].steps.some((s) => s.uses === `${usesRef(ACTION_PINS.downloadArtifact4)}`)).toBe(false);
453
454
  });
454
455
 
455
456
  test("a gated-wave pipeline has no promote job", () => {
@@ -76,6 +76,10 @@ import {
76
76
  prLoopPostJob,
77
77
  prLoopSetup,
78
78
  PR_PLAN_NOTE_JOB,
79
+ PR_LOCK_JOB,
80
+ PR_UNLOCK_JOB,
81
+ PR_UNLOCK_COMMAND,
82
+ prLockCommand,
79
83
  prLoopWaveJobs,
80
84
  prLoopWithWaves,
81
85
  prEnvJobs,
@@ -90,8 +94,7 @@ import type {
90
94
  ComponentPipelineResult as GenerateGithubResult,
91
95
  PipelineMember,
92
96
  } from "@intentius/chant/lexicon";
93
- import { afterEarlierWave, checkoutStep, concurrency, downloadArtifactStep, emitWorkflowYAML, keepRecordStep, runSteps, UPLOAD_ARTIFACT } from "./ci-jobs";
94
- import { actionRef } from "../action-pins";
97
+ import { afterEarlierWave, CACHE_RESTORE, CACHE_SAVE, CHECKOUT, checkoutStep, concurrency, downloadArtifactStep, emitWorkflowYAML, keepRecordStep, runSteps, UPLOAD_ARTIFACT } from "./ci-jobs";
95
98
 
96
99
  export type { GeneratedJob, GenerateGithubOptions, GenerateGithubResult };
97
100
 
@@ -270,13 +273,13 @@ function prLoopGithubDoc(
270
273
  const recordKey = (wave?: number) => `${recordPrefix}\${{ github.run_id }}-\${{ github.run_attempt }}${wave ? `-wave-${wave}` : ""}`;
271
274
  const restoreRecord = (wave?: number) => ({
272
275
  name: wave ? "Restore the newest record of this commit's apply" : "Restore the record of an earlier attempt at this commit",
273
- uses: "actions/cache/restore@v4",
276
+ uses: CACHE_RESTORE,
274
277
  with: { path: recordPath, key: recordKey(wave), "restore-keys": recordPrefix },
275
278
  });
276
279
  const saveRecord = (wave?: number) => ({
277
280
  name: wave ? "Keep the record for the next wave and a re-run" : "Keep the record for a re-run",
278
281
  if: "always()",
279
- uses: "actions/cache/save@v4",
282
+ uses: CACHE_SAVE,
280
283
  with: { path: recordPath, key: recordKey(wave) },
281
284
  });
282
285
  // #4296: the step that trades the job's OIDC token for its cloud identity, before the checkout.
@@ -380,7 +383,7 @@ function prLoopGithubDoc(
380
383
  const post = prLoopPostJob(forgeName, env, member?.name);
381
384
  const noteSteps: Array<Record<string, unknown>> = [
382
385
  // The target branch's commit: the chant this job runs is the target branch's, and none of the change's code is here.
383
- { name: "Check out the target branch's commit", uses: actionRef("actions/checkout"), with: { ref: "${{ github.event.pull_request.base.sha }}" } },
386
+ { name: "Check out the target branch's commit", uses: CHECKOUT, with: { ref: "${{ github.event.pull_request.base.sha }}" } },
384
387
  ...runSteps(beforeScript),
385
388
  { name: "Take the plan report from the plan job", run: takeOverScript("plan", PR_LOOP_REPORT_DIR, forgeName) },
386
389
  { name: "Keep the plan report", if: "always()", uses: UPLOAD_ARTIFACT, with: { name: reportName(plan), path: reportPath, "if-no-files-found": "ignore", "include-hidden-files": true } },
@@ -388,9 +391,46 @@ function prLoopGithubDoc(
388
391
  ];
389
392
  // #4298: with a pull request environment the pull_request trigger fires on close too, and the plan skips that event.
390
393
  const prEnv = loop.prEnvironment ? prEnvGithubJobs(loop.forge ?? "github", image, beforeScript, extraScript, loop, member) : undefined;
394
+ // #4327: with ci.locks: plan the pull_request trigger fires on close too, for the unlock job, and the plan skips that event.
395
+ const locks = loop.locks === "plan";
396
+ const closes = Boolean(prEnv) || locks;
397
+ const lockJobs: Record<string, unknown> = locks
398
+ ? {
399
+ [PR_LOCK_JOB]: {
400
+ // The plan job's outputs carry the report; a plan that was skipped or failed leaves this job skipped.
401
+ needs: ["plan"],
402
+ if: "github.event_name == 'pull_request'",
403
+ "runs-on": GITHUB_RUNNER,
404
+ container: image,
405
+ permissions: { contents: "write", "pull-requests": "read", statuses: "write" },
406
+ steps: [
407
+ // The target branch's commit: this job pushes chant/lifecycle, so none of the change's code runs in it.
408
+ { name: "Check out the target branch's commit", uses: CHECKOUT, with: { ref: "${{ github.event.pull_request.base.sha }}", "fetch-depth": 0 } },
409
+ ...runSteps(beforeScript),
410
+ { name: "Take the plan report from the plan job", run: takeOverScript("plan", PR_LOOP_REPORT_DIR, forgeName) },
411
+ {
412
+ name: "Lock the components the plan selected",
413
+ env: { PR_NUMBER: "${{ github.event.pull_request.number }}", HEAD_SHA: "${{ github.event.pull_request.head.sha }}", GITHUB_TOKEN: GITHUB_JOB_TOKEN },
414
+ run: prLockCommand(forgeName),
415
+ },
416
+ ],
417
+ },
418
+ [PR_UNLOCK_JOB]: {
419
+ if: "github.event_name == 'pull_request' && github.event.action == 'closed'",
420
+ "runs-on": GITHUB_RUNNER,
421
+ container: image,
422
+ permissions: { contents: "write" },
423
+ steps: [
424
+ { name: "Check out the target branch's commit", uses: CHECKOUT, with: { ref: "${{ github.event.pull_request.base.sha }}", "fetch-depth": 0 } },
425
+ ...runSteps(beforeScript),
426
+ { name: "Release the pull request's locks", env: { PR_NUMBER: "${{ github.event.pull_request.number }}" }, run: PR_UNLOCK_COMMAND },
427
+ ],
428
+ },
429
+ }
430
+ : {};
391
431
  const jobsDoc: Record<string, unknown> = {
392
432
  plan: {
393
- if: prEnv ? "github.event_name == 'pull_request' && github.event.action != 'closed'" : "github.event_name == 'pull_request'",
433
+ if: closes ? "github.event_name == 'pull_request' && github.event.action != 'closed'" : "github.event_name == 'pull_request'",
394
434
  "runs-on": GITHUB_RUNNER,
395
435
  container: image,
396
436
  permissions: withIdToken({ contents: "read" }),
@@ -406,6 +446,7 @@ function prLoopGithubDoc(
406
446
  permissions: { actions: "read", contents: "read", "pull-requests": "write", statuses: "write" },
407
447
  steps: noteSteps,
408
448
  },
449
+ ...lockJobs,
409
450
  ...Object.fromEntries(applyJobs),
410
451
  ...(prEnv ? prEnv.jobs : {}),
411
452
  };
@@ -415,6 +456,7 @@ function prLoopGithubDoc(
415
456
  : forgejo
416
457
  ? { pull_request: { branches: [branch] }, push: { branches: [branch], tags: [GREEN_TAG_PATTERN] } }
417
458
  : { pull_request: { branches: [branch] }, push: { branches: [branch] }, workflow_run: { workflows: [GREEN_TICK_WORKFLOW], types: ["completed"] }, workflow_dispatch: {} };
459
+ if (closes) on.pull_request = { branches: [branch], types: ["opened", "synchronize", "reopened", "closed"] };
418
460
  if (prEnv) {
419
461
  on.pull_request = { branches: [branch], types: ["opened", "synchronize", "reopened", "closed"] };
420
462
  on.schedule = [{ cron: PR_ENV_SWEEP_CRON }];
@@ -428,10 +470,16 @@ function prLoopGithubDoc(
428
470
  ...(rooted ? {} : { defaults: { run: { "working-directory": member!.dir } } }),
429
471
  env: { ...options.variables, CHANT_ENV: env },
430
472
  jobsDoc,
431
- stages: ["plan", PR_PLAN_NOTE_JOB, ...applyNames, ...(prEnv ? ["env-up", PR_ENV_POST_JOB, "env-down", "env-sweep"] : [])],
473
+ stages: ["plan", PR_PLAN_NOTE_JOB, ...(locks ? [PR_LOCK_JOB, PR_UNLOCK_JOB] : []), ...applyNames, ...(prEnv ? ["env-up", PR_ENV_POST_JOB, "env-down", "env-sweep"] : [])],
432
474
  jobs: [
433
475
  { jobName: "plan", component: "pull request plan", stage: "plan", needs: [] },
434
476
  { jobName: PR_PLAN_NOTE_JOB, component: "pull request plan note", stage: PR_PLAN_NOTE_JOB, needs: ["plan"] },
477
+ ...(locks
478
+ ? [
479
+ { jobName: PR_LOCK_JOB, component: "pull request lock", stage: PR_LOCK_JOB, needs: ["plan"] },
480
+ { jobName: PR_UNLOCK_JOB, component: "pull request unlock", stage: PR_UNLOCK_JOB, needs: [] },
481
+ ]
482
+ : []),
435
483
  ...applyNames.map((name, i) => ({
436
484
  jobName: name,
437
485
  component: name === "record" ? "merge record" : loop.applyBeforeMerge ? "apply before merge" : loop.waveGate ? `green apply, wave ${i}` : "green apply",
@@ -519,7 +567,7 @@ function prEnvGithubJobs(
519
567
  permissions: writes,
520
568
  steps: [
521
569
  // The target branch's commit: the chant this job runs is the target branch's, and none of the change's code is here.
522
- { name: "Check out the target branch's commit", uses: actionRef("actions/checkout"), with: { ref: "${{ github.event.pull_request.base.sha }}", "fetch-depth": 0 } },
570
+ { name: "Check out the target branch's commit", uses: CHECKOUT, with: { ref: "${{ github.event.pull_request.base.sha }}", "fetch-depth": 0 } },
523
571
  ...runSteps(beforeScript),
524
572
  { name: "Take the environment's report from the env-up job", run: takeOverScript("env-up", PR_ENV_REPORT_DIR, forge) },
525
573
  keep("env-up"),
@@ -534,7 +582,7 @@ function prEnvGithubJobs(
534
582
  permissions: withIdToken(writes),
535
583
  steps: [
536
584
  ...tradeStep,
537
- { uses: actionRef("actions/checkout"), with: { "fetch-depth": 0, ...(ref ? { ref } : {}) } },
585
+ { uses: CHECKOUT, with: { "fetch-depth": 0, ...(ref ? { ref } : {}) } },
538
586
  ...runSteps(prLoopSetup(image)),
539
587
  ...runSteps(beforeScript),
540
588
  { name, env: j.env, run: j.command },