@intentius/chant-lexicon-github 0.105.1 → 0.106.1

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.
@@ -1 +1 @@
1
- {"version":3,"file":"generate-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwDG;AAGH,OAAO,EAAyB,KAAK,eAAe,EAAE,MAAM,oCAAoC,CAAC;AAKjG,OAAO,KAAK,EACV,oBAAoB,IAAI,YAAY,EACpC,wBAAwB,IAAI,qBAAqB,EACjD,uBAAuB,IAAI,oBAAoB,EAEhD,MAAM,0BAA0B,CAAC;AAGlC,YAAY,EAAE,YAAY,EAAE,qBAAqB,EAAE,oBAAoB,EAAE,CAAC;AAE1E;;;;;;GAMG;AACH,MAAM,WAAW,iBAAiB;IAChC;;;;;OAKG;IACH,IAAI,EAAE,MAAM,CAAC;IACb,6FAA6F;IAC7F,WAAW,EAAE,MAAM,CAAC;IACpB,8DAA8D;IAC9D,EAAE,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC5B,mIAAmI;IACnI,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACnC;;;;;;OAMG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9B,qDAAqD;IACrD,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC,uEAAuE;IACvE,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,0DAA0D;IAC1D,IAAI,EAAE,YAAY,EAAE,CAAC;CACtB;AAmKD;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,MAAM,CAWpD;AAED;;;;;;;;GAQG;AACH,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,eAAe,EAAE,EAC7B,OAAO,GAAE,qBAA0B,GAClC,iBAAiB,CAgLnB;AA4CD;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,iBAAiB,GAAG,MAAM,CAU/D;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,eAAe,EAAE,EAC7B,OAAO,GAAE,qBAA0B,GAClC,oBAAoB,CAGtB"}
1
+ {"version":3,"file":"generate-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwDG;AAGH,OAAO,EAAyB,KAAK,eAAe,EAAE,MAAM,oCAAoC,CAAC;AAKjG,OAAO,KAAK,EACV,oBAAoB,IAAI,YAAY,EACpC,wBAAwB,IAAI,qBAAqB,EACjD,uBAAuB,IAAI,oBAAoB,EAEhD,MAAM,0BAA0B,CAAC;AAGlC,YAAY,EAAE,YAAY,EAAE,qBAAqB,EAAE,oBAAoB,EAAE,CAAC;AAE1E;;;;;;GAMG;AACH,MAAM,WAAW,iBAAiB;IAChC;;;;;OAKG;IACH,IAAI,EAAE,MAAM,CAAC;IACb,6FAA6F;IAC7F,WAAW,EAAE,MAAM,CAAC;IACpB,8DAA8D;IAC9D,EAAE,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC5B,mIAAmI;IACnI,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACnC;;;;;;OAMG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9B,qDAAqD;IACrD,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC,uEAAuE;IACvE,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,0DAA0D;IAC1D,IAAI,EAAE,YAAY,EAAE,CAAC;CACtB;AAqMD;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,MAAM,CAWpD;AAED;;;;;;;;GAQG;AACH,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,eAAe,EAAE,EAC7B,OAAO,GAAE,qBAA0B,GAClC,iBAAiB,CA+KnB;AA4CD;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,iBAAiB,GAAG,MAAM,CAU/D;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,eAAe,EAAE,EAC7B,OAAO,GAAE,qBAA0B,GAClC,oBAAoB,CAGtB"}
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "f3d178ea3c655fff55f1eca010e2c202c7334b0d8e3734b68b225662e5998431",
4
+ "manifest.json": "86245b91fbd6f3569b7e78ca9b9d4241f4709598483c4b73a55627a46b2c72fd",
5
5
  "meta.json": "c50e0ef3845c370c3ad2210b8bba455980417c0f818aa6ed46e06d4fb8b1b9f9",
6
6
  "types/index.d.ts": "bdd54a4145a39a9dd4f7bbc8c500191acb6bc9057a87da0040475afac84db397",
7
7
  "rules/deprecated-action-version.ts": "d41e6e532ab7f623af1bee4ac5279fcb2baada7defa1c5d022a5bc71983e8797",
@@ -78,5 +78,5 @@
78
78
  "skills/chant-github-patterns.md": "bb3abef289a8fdfcf07d6bb2d7289dcb2f38bc0cb0321ea320b78b45a6f548c0",
79
79
  "skills/chant-github-security.md": "aab111cb0871cad30281ce48d7da23663689619351029219e2be019a1a61e394"
80
80
  },
81
- "composite": "eea703724be625f8a5a47551ed5f5b211fb78a13a5846a3682d492abee6e0106"
81
+ "composite": "4970f8db5a1ce849770c89a6393a48f46854cc626fc8e458a21afe9c0567e9f3"
82
82
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github",
3
- "version": "0.105.1",
3
+ "version": "0.106.1",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "GitHub",
6
6
  "intrinsics": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-github",
3
- "version": "0.105.1",
3
+ "version": "0.106.1",
4
4
  "description": "GitHub Actions lexicon for chant — declarative IaC in TypeScript",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -61,7 +61,7 @@
61
61
  "typescript": "^5.9.3"
62
62
  },
63
63
  "peerDependencies": {
64
- "@intentius/chant": "^0.105.1",
64
+ "@intentius/chant": "^0.106.1",
65
65
  "typescript": "^5.9.3"
66
66
  }
67
67
  }
@@ -15,7 +15,7 @@ jobs:
15
15
  plan:
16
16
  if: github.event_name == 'pull_request'
17
17
  runs-on: ubuntu-latest
18
- container: node:22-slim
18
+ container: node:22
19
19
  permissions:
20
20
  contents: read
21
21
  pull-requests: write
@@ -24,6 +24,7 @@ jobs:
24
24
  - uses: actions/checkout@v7
25
25
  with:
26
26
  fetch-depth: 0
27
+ - run: command -v tofu >/dev/null 2>&1 || (curl -fsSL https://get.opentofu.org/install-opentofu.sh -o /tmp/install-opentofu.sh && sh /tmp/install-opentofu.sh --install-method standalone)
27
28
  - name: Plan the members this pull request reaches
28
29
  env:
29
30
  BASE_SHA: '${{ github.event.pull_request.base.sha }}'
@@ -41,7 +42,7 @@ jobs:
41
42
  apply:
42
43
  if: github.event_name == 'push'
43
44
  runs-on: ubuntu-latest
44
- container: node:22-slim
45
+ container: node:22
45
46
  concurrency:
46
47
  group: chant-apply-prod
47
48
  cancel-in-progress: false
@@ -53,11 +54,24 @@ jobs:
53
54
  - uses: actions/checkout@v7
54
55
  with:
55
56
  fetch-depth: 0
57
+ - run: command -v tofu >/dev/null 2>&1 || (curl -fsSL https://get.opentofu.org/install-opentofu.sh -o /tmp/install-opentofu.sh && sh /tmp/install-opentofu.sh --install-method standalone)
58
+ - name: Restore the record of an earlier attempt at this commit
59
+ uses: actions/cache/restore@v4
60
+ with:
61
+ path: .chant/pr-resume/pr-apply.json
62
+ key: chant-apply-prod-${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }}
63
+ restore-keys: chant-apply-prod-${{ github.sha }}-
56
64
  - name: Apply the plan a reviewer approved
57
65
  env:
58
66
  BASE_SHA: '${{ github.event.before }}'
59
67
  GITHUB_TOKEN: '${{ github.token }}'
60
- run: chant components pr-apply --base "$BASE_SHA" --env prod --gate pr-apply --output .chant/pr --forge github --require-review
68
+ run: chant components pr-apply --base "$BASE_SHA" --env prod --gate pr-apply --output .chant/pr --forge github --require-review --resume .chant/pr-resume/pr-apply.json
69
+ - name: Keep the record for a re-run
70
+ if: always()
71
+ uses: actions/cache/save@v4
72
+ with:
73
+ path: .chant/pr-resume/pr-apply.json
74
+ key: chant-apply-prod-${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }}
61
75
  - name: Keep the apply report
62
76
  if: always()
63
77
  uses: actions/upload-artifact@v4
@@ -438,10 +438,13 @@ describe("generateGithubPipeline: gated waves (#3049)", () => {
438
438
  const steps = parsed.jobs["wave-2"].steps;
439
439
  expect(steps[0].with).toEqual({ "fetch-depth": 0 });
440
440
  expect(steps[1]).toMatchObject({ uses: "actions/download-artifact@v4", with: { name: "fan-out-record-wave-1", path: ".chant" } });
441
- expect(steps[2].run).toBe(
441
+ // The default image is the pr-loop one: node:22, with OpenTofu installed when missing.
442
+ expect(parsed.jobs["wave-2"]).toMatchObject({ container: "node:22" });
443
+ expect(steps[2].run).toMatch(/^command -v tofu /);
444
+ expect(steps[3].run).toBe(
442
445
  "chant components fan-out --base HEAD~1 --env prod --wave-gate release --wave 2 --resume .chant/fan-out.json",
443
446
  );
444
- expect(steps[3]).toMatchObject({ if: "always()", with: { name: "fan-out-record-wave-2", path: ".chant/fan-out.json" } });
447
+ expect(steps[4]).toMatchObject({ if: "always()", with: { name: "fan-out-record-wave-2", path: ".chant/fan-out.json" } });
445
448
  // Wave 1 has nothing to download.
446
449
  expect(parsed.jobs["wave-1"].steps.some((s) => s.uses === "actions/download-artifact@v4")).toBe(false);
447
450
  });
@@ -60,7 +60,7 @@ import { emitYAML, emitYAMLEntry } from "@intentius/chant/yaml";
60
60
  import { resolveComponentGraph, type DriverComponent } from "@intentius/chant/components/driver";
61
61
  import { hasPublishStep, promoteArchivePaths } from "@intentius/chant/components/promote";
62
62
  import { GATED_WAVE_RECORD, gatedWaveJobs } from "@intentius/chant/components/gated-wave-pipeline";
63
- import { PR_LOOP_REPORT_DIR, prApplyGroup, prLoopJobs } from "@intentius/chant/components/pr-pipeline";
63
+ import { PR_APPLY_RECORD, PR_LOOP_IMAGE, PR_LOOP_REPORT_DIR, prApplyGroup, prApplyRecordKey, prLoopJobs, prLoopSetup } from "@intentius/chant/components/pr-pipeline";
64
64
  import { memberRepoPath } from "@intentius/chant/lexicon";
65
65
  import type {
66
66
  ComponentPipelineJob as GeneratedJob,
@@ -142,6 +142,7 @@ function gatedWaveGithubDoc(
142
142
  with: { name: waveRecordArtifact(job.wave - 1), path: dirnameOf(GATED_WAVE_RECORD) },
143
143
  });
144
144
  }
145
+ for (const line of prLoopSetup(image)) steps.push({ run: line });
145
146
  for (const line of beforeScript) steps.push({ run: line });
146
147
  steps.push({ run: job.command.join(" ") });
147
148
  for (const line of extraScript) steps.push({ run: line });
@@ -183,6 +184,15 @@ function gatedWaveGithubDoc(
183
184
  * job can push, to record a pending fact on `chant/lifecycle`, and runs one
184
185
  * at a time per environment. Both keep their report as an artifact, and both
185
186
  * check out the full history, since each measures the change with git.
187
+ * The apply job restores its attempt record from the cache before it runs
188
+ * and saves it afterwards, failed or not, so a re-run of a failed apply
189
+ * finishes it under the same approval (#3543).
190
+ *
191
+ * For a workspace member (#3465) the run steps start in the member's
192
+ * directory and the report artifact is kept from there. The triggers take no
193
+ * path filter, since a change outside the member can reach it; the plan
194
+ * selects from the whole change and passes `--member`, which keeps the
195
+ * member's gate, note and statuses apart from the other members'.
186
196
  */
187
197
  function prLoopGithubDoc(
188
198
  env: string,
@@ -193,17 +203,40 @@ function prLoopGithubDoc(
193
203
  ): GithubPipelineDoc {
194
204
  const loop = options.prLoop!;
195
205
  const branch = loop.branch ?? "main";
196
- const [plan, apply] = prLoopJobs(loop.forge ?? "github", env, loop);
206
+ const member = options.member;
207
+ const rooted = !member || member.dir === "." || member.dir === "";
208
+ const [plan, apply] = prLoopJobs(loop.forge ?? "github", env, loop, member?.name);
209
+ const reportPath = member ? memberRepoPath(member, PR_LOOP_REPORT_DIR) : PR_LOOP_REPORT_DIR;
210
+ // The apply's attempt record (#3543) lives in the cache under the pushed
211
+ // commit. Cache keys cannot be overwritten, so each attempt saves under
212
+ // its run and attempt, and a re-run restores the newest for the commit.
213
+ const recordPath = member ? memberRepoPath(member, PR_APPLY_RECORD) : PR_APPLY_RECORD;
214
+ const recordPrefix = `${prApplyRecordKey(env, "${{ github.sha }}", member?.name)}-`;
215
+ const recordKey = `${recordPrefix}\${{ github.run_id }}-\${{ github.run_attempt }}`;
216
+ const restoreRecord = {
217
+ name: "Restore the record of an earlier attempt at this commit",
218
+ uses: "actions/cache/restore@v4",
219
+ with: { path: recordPath, key: recordKey, "restore-keys": recordPrefix },
220
+ };
221
+ const saveRecord = {
222
+ name: "Keep the record for a re-run",
223
+ if: "always()",
224
+ uses: "actions/cache/save@v4",
225
+ with: { path: recordPath, key: recordKey },
226
+ };
197
227
  const steps = (job: typeof plan, name: string): Array<Record<string, unknown>> => [
198
228
  { uses: actionRef("actions/checkout"), with: { "fetch-depth": 0 } },
229
+ ...prLoopSetup(image).map((line) => ({ run: line })),
199
230
  ...beforeScript.map((line) => ({ run: line })),
231
+ ...(job.jobName === "apply" ? [restoreRecord] : []),
200
232
  { name, env: job.env, run: job.command },
201
233
  ...extraScript.map((line) => ({ run: line })),
234
+ ...(job.jobName === "apply" ? [saveRecord] : []),
202
235
  {
203
236
  name: `Keep the ${job.jobName} report`,
204
237
  if: "always()",
205
238
  uses: "actions/upload-artifact@v4",
206
- with: { name: `chant-pr-${job.jobName}`, path: PR_LOOP_REPORT_DIR, "if-no-files-found": "ignore", "include-hidden-files": true },
239
+ with: { name: `chant-pr-${job.jobName}`, path: reportPath, "if-no-files-found": "ignore", "include-hidden-files": true },
207
240
  },
208
241
  ];
209
242
  const jobsDoc: Record<string, unknown> = {
@@ -218,15 +251,16 @@ function prLoopGithubDoc(
218
251
  if: "github.event_name == 'push'",
219
252
  "runs-on": "ubuntu-latest",
220
253
  container: image,
221
- concurrency: { group: prApplyGroup(env), "cancel-in-progress": false },
254
+ concurrency: { group: prApplyGroup(env, member?.name), "cancel-in-progress": false },
222
255
  permissions: { contents: "write", "pull-requests": "write", statuses: "write" },
223
256
  steps: steps(apply, "Apply the plan a reviewer approved"),
224
257
  },
225
258
  };
226
259
  return {
227
- name: `chant-pr-${env}`,
260
+ name: member ? `chant-pr-${member.name}-${env}` : `chant-pr-${env}`,
228
261
  environment: env,
229
262
  on: { pull_request: { branches: [branch] }, push: { branches: [branch] } },
263
+ ...(rooted ? {} : { defaults: { run: { "working-directory": member!.dir } } }),
230
264
  env: { ...options.variables, CHANT_ENV: env },
231
265
  jobsDoc,
232
266
  stages: ["plan", "apply"],
@@ -302,7 +336,7 @@ export function buildGithubPipelineDoc(
302
336
  options: GenerateGithubOptions = {},
303
337
  ): GithubPipelineDoc {
304
338
  const env = options.env ?? "production";
305
- const image = options.image ?? DEFAULT_IMAGE;
339
+ const image = options.image ?? (options.prLoop || options.gatedWaves ? PR_LOOP_IMAGE : DEFAULT_IMAGE);
306
340
  const runCommand = options.runCommand ?? ["chant", "run", "--components", "{name}", "--env", env];
307
341
  const beforeScript = options.beforeScript ?? [];
308
342
  const extraScript = options.extraScript ?? [];
@@ -314,7 +348,6 @@ export function buildGithubPipelineDoc(
314
348
  if (options.gatedWaves || options.promoteTo !== undefined) {
315
349
  throw new Error("a pull-request pipeline has no wave or promote jobs; drop --wave-gate and --promote-to, or --pr-loop");
316
350
  }
317
- if (options.member) throw new Error("a pull-request pipeline is not generated for a workspace member yet; generate it from the project outside the workspace");
318
351
  return prLoopGithubDoc(env, image, beforeScript, extraScript, options);
319
352
  }
320
353
 
@@ -30,6 +30,8 @@ function hasActionlint(): boolean {
30
30
  }
31
31
 
32
32
  interface Step {
33
+ name?: string;
34
+ if?: string;
33
35
  uses?: string;
34
36
  run?: string;
35
37
  env?: Record<string, string>;
@@ -73,6 +75,35 @@ describe("the GitHub pull-request workflow", () => {
73
75
  for (const job of Object.values(doc.jobs)) expect(job.steps[0].with).toEqual({ "fetch-depth": 0 });
74
76
  });
75
77
 
78
+ test("the apply resumes from a record the cache keeps for the pushed commit across a re-run (#3543)", () => {
79
+ const steps = doc.jobs.apply.steps;
80
+ const restore = steps.findIndex((s) => s.uses === "actions/cache/restore@v4");
81
+ const run = steps.findIndex((s) => s.run?.includes("pr-apply"));
82
+ const save = steps.findIndex((s) => s.uses === "actions/cache/save@v4");
83
+ expect(restore).toBeGreaterThan(-1);
84
+ expect(restore).toBeLessThan(run);
85
+ expect(save).toBeGreaterThan(run);
86
+ expect(steps[run].run).toContain("--resume .chant/pr-resume/pr-apply.json");
87
+ const key = "chant-apply-prod-${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }}";
88
+ expect(steps[restore].with).toEqual({ path: ".chant/pr-resume/pr-apply.json", key, "restore-keys": "chant-apply-prod-${{ github.sha }}-" });
89
+ // Saved even when the apply failed, which is when a re-run needs it.
90
+ expect(steps[save].if).toBe("always()");
91
+ expect(steps[save].with).toEqual({ path: ".chant/pr-resume/pr-apply.json", key });
92
+ // The plan job runs the pull request's code: it neither reads nor writes the record.
93
+ expect(doc.jobs.plan.steps.some((s) => s.uses?.startsWith("actions/cache"))).toBe(false);
94
+ expect(doc.jobs.plan.steps.find((s) => s.run?.includes("pr-plan"))!.run).not.toContain("--resume");
95
+ // The report artifact does not carry the record, which holds the members' outputs.
96
+ const keep = steps.find((s) => s.uses?.startsWith("actions/upload-artifact"))!;
97
+ expect(keep.with?.path).toBe(".chant/pr");
98
+ });
99
+
100
+ test("runs in an image with git and installs OpenTofu, unless the caller names an image", () => {
101
+ expect(doc.jobs.plan.steps[1].run).toContain("install-opentofu");
102
+ const own = generateGithubPipeline(ESTATE, { env: "prod", image: "example/ci:1", prLoop: {} }).yaml;
103
+ expect(own).toContain("container: example/ci:1");
104
+ expect(own).not.toContain("install-opentofu");
105
+ });
106
+
76
107
  test("the gate, branch and review requirement are options", () => {
77
108
  const yaml = generateGithubPipeline(ESTATE, { env: "prod", prLoop: { gate: "infra-apply", branch: "trunk", requireReview: false } }).yaml;
78
109
  expect(yaml).toContain("--gate infra-apply");
@@ -85,6 +116,43 @@ describe("the GitHub pull-request workflow", () => {
85
116
  expect(() => generateGithubPipeline(ESTATE, { prLoop: {}, promoteTo: "prod" })).toThrow(/pull-request pipeline has no wave or promote jobs/);
86
117
  });
87
118
 
119
+ test("inside a workspace member: runs in the member's directory and keeps its own gate, note and apply group (#3465)", () => {
120
+ const member = { name: "network", dir: "infra/network", file: ".github/workflows/chant-pr-network-prod.yml" };
121
+ const scoped = generateGithubPipeline(ESTATE, { env: "prod", prLoop: {}, member });
122
+ const mdoc = parseYAML(scoped.yaml) as {
123
+ name: string;
124
+ on: Record<string, unknown>;
125
+ defaults?: { run: Record<string, string> };
126
+ jobs: Record<string, Job>;
127
+ };
128
+ expect(mdoc.name).toBe("chant-pr-network-prod");
129
+ // No path filter: a change outside the member can reach it.
130
+ expect(mdoc.on).toEqual({ pull_request: { branches: ["main"] }, push: { branches: ["main"] } });
131
+ expect(mdoc.defaults).toEqual({ run: { "working-directory": "infra/network" } });
132
+ const plan = mdoc.jobs.plan.steps.find((s) => s.run?.includes("pr-plan"))!;
133
+ expect(plan.run).toBe('chant components pr-plan --base "$BASE_SHA" --pr "$PR_NUMBER" --env prod --gate pr-apply --output .chant/pr --forge github --member network');
134
+ const apply = mdoc.jobs.apply.steps.find((s) => s.run?.includes("pr-apply"))!;
135
+ expect(apply.run).toContain("--member network");
136
+ expect(mdoc.jobs.apply.concurrency).toEqual({ group: "chant-apply-network-prod", "cancel-in-progress": false });
137
+ // The record path is relative to the member for --resume and to the repository for the cache.
138
+ expect(apply.run).toContain("--resume .chant/pr-resume/pr-apply.json");
139
+ const restore = mdoc.jobs.apply.steps.find((s) => s.uses === "actions/cache/restore@v4")!;
140
+ expect(restore.with).toMatchObject({ path: "infra/network/.chant/pr-resume/pr-apply.json", "restore-keys": "chant-apply-network-prod-${{ github.sha }}-" });
141
+ for (const job of Object.values(mdoc.jobs)) {
142
+ const keep = job.steps.find((s) => s.uses?.startsWith("actions/upload-artifact"))!;
143
+ expect(keep.with?.path).toBe("infra/network/.chant/pr");
144
+ }
145
+ });
146
+
147
+ test("a member at the workspace root keeps the root's paths but its own names", () => {
148
+ const yaml = generateGithubPipeline(ESTATE, { env: "prod", prLoop: {}, member: { name: "root", dir: "." } }).yaml;
149
+ const mdoc = parseYAML(yaml) as { name: string; defaults?: unknown; jobs: Record<string, Job> };
150
+ expect(mdoc.name).toBe("chant-pr-root-prod");
151
+ expect(mdoc.defaults).toBeUndefined();
152
+ expect(yaml).toContain("--member root");
153
+ expect(yaml).toContain("path: .chant/pr");
154
+ });
155
+
88
156
  test.skipIf(!hasActionlint())("passes actionlint", () => {
89
157
  const dir = mkdtempSync(join(tmpdir(), "chant-actionlint-"));
90
158
  try {