@intentius/chant 0.105.1 → 0.106.3

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 (111) hide show
  1. package/dist/change-set.d.ts +30 -8
  2. package/dist/change-set.d.ts.map +1 -1
  3. package/dist/cli/commands/build.d.ts +11 -0
  4. package/dist/cli/commands/build.d.ts.map +1 -1
  5. package/dist/cli/commands/lint.d.ts.map +1 -1
  6. package/dist/cli/handlers/build.d.ts.map +1 -1
  7. package/dist/cli/handlers/fan-out.d.ts.map +1 -1
  8. package/dist/cli/handlers/operator.d.ts.map +1 -1
  9. package/dist/cli/handlers/pr.d.ts +16 -2
  10. package/dist/cli/handlers/pr.d.ts.map +1 -1
  11. package/dist/cli/handlers/promote.d.ts.map +1 -1
  12. package/dist/cli/handlers/run.d.ts.map +1 -1
  13. package/dist/cli/main.d.ts.map +1 -1
  14. package/dist/cli/mcp/op-tools.d.ts.map +1 -1
  15. package/dist/cli/registry.d.ts +7 -0
  16. package/dist/cli/registry.d.ts.map +1 -1
  17. package/dist/components/changed-files.d.ts +8 -2
  18. package/dist/components/changed-files.d.ts.map +1 -1
  19. package/dist/components/fan-out-record.d.ts +24 -0
  20. package/dist/components/fan-out-record.d.ts.map +1 -1
  21. package/dist/components/pr-pipeline.d.ts +67 -4
  22. package/dist/components/pr-pipeline.d.ts.map +1 -1
  23. package/dist/components/pr-run.d.ts +94 -3
  24. package/dist/components/pr-run.d.ts.map +1 -1
  25. package/dist/gated-waves.d.ts +4 -1
  26. package/dist/gated-waves.d.ts.map +1 -1
  27. package/dist/lexicon.d.ts +13 -2
  28. package/dist/lexicon.d.ts.map +1 -1
  29. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  30. package/dist/op/gate-name.d.ts +7 -0
  31. package/dist/op/gate-name.d.ts.map +1 -1
  32. package/dist/op/gate-summary.d.ts +2 -0
  33. package/dist/op/gate-summary.d.ts.map +1 -1
  34. package/dist/op/gate.d.ts +10 -2
  35. package/dist/op/gate.d.ts.map +1 -1
  36. package/dist/op/index.d.ts +2 -2
  37. package/dist/op/index.d.ts.map +1 -1
  38. package/dist/op/local-executor.d.ts.map +1 -1
  39. package/dist/op/local-output.d.ts +2 -2
  40. package/dist/op/local-output.d.ts.map +1 -1
  41. package/dist/op/runtime.d.ts +29 -4
  42. package/dist/op/runtime.d.ts.map +1 -1
  43. package/dist/op/runtimes/local.d.ts.map +1 -1
  44. package/dist/pr-loop.d.ts +32 -6
  45. package/dist/pr-loop.d.ts.map +1 -1
  46. package/dist/workspace/lineage-upgrade-cli.d.ts.map +1 -1
  47. package/dist/workspace/member-pipeline.d.ts +15 -1
  48. package/dist/workspace/member-pipeline.d.ts.map +1 -1
  49. package/package.json +1 -1
  50. package/src/__fixtures__/change-set/lifecycle.change-set.golden.json +1 -1
  51. package/src/__fixtures__/change-set/warden.change-set.golden.json +2 -2
  52. package/src/__fixtures__/change-set/warden.plan.json +47 -40
  53. package/src/__fixtures__/plan-summary/normalization-vectors.json +275 -2
  54. package/src/__fixtures__/pr-loop/pr-apply-applied.note.golden.md +1 -1
  55. package/src/__fixtures__/pr-loop/pr-apply-refused.note.golden.md +2 -2
  56. package/src/__fixtures__/pr-loop/pr-plan.golden.json +2 -2
  57. package/src/__fixtures__/pr-loop/pr-plan.note.golden.md +2 -2
  58. package/src/build.test.ts +1 -1
  59. package/src/build.ts +1 -1
  60. package/src/change-set.test.ts +42 -12
  61. package/src/change-set.ts +50 -11
  62. package/src/cli/commands/build.ts +33 -14
  63. package/src/cli/commands/lint.ts +34 -1
  64. package/src/cli/handlers/build-lexicon-output.test.ts +102 -0
  65. package/src/cli/handlers/build.ts +68 -0
  66. package/src/cli/handlers/fan-out.test.ts +12 -1
  67. package/src/cli/handlers/fan-out.ts +7 -3
  68. package/src/cli/handlers/operator.ts +1 -2
  69. package/src/cli/handlers/pr.ts +223 -35
  70. package/src/cli/handlers/promote.test.ts +15 -1
  71. package/src/cli/handlers/promote.ts +5 -3
  72. package/src/cli/handlers/run.test.ts +215 -1
  73. package/src/cli/handlers/run.ts +36 -15
  74. package/src/cli/main.ts +13 -1
  75. package/src/cli/mcp/never-over-mcp.test.ts +120 -0
  76. package/src/cli/mcp/op-approve-origin.test.ts +1 -1
  77. package/src/cli/mcp/op-tools.ts +14 -2
  78. package/src/cli/mcp/server.test.ts +2 -1
  79. package/src/cli/registry.ts +7 -0
  80. package/src/components/changed-files.test.ts +58 -0
  81. package/src/components/changed-files.ts +19 -4
  82. package/src/components/fan-out-record.test.ts +47 -2
  83. package/src/components/fan-out-record.ts +53 -0
  84. package/src/components/pr-pipeline.ts +96 -6
  85. package/src/components/pr-run.test.ts +311 -4
  86. package/src/components/pr-run.ts +198 -5
  87. package/src/gated-waves.test.ts +11 -0
  88. package/src/gated-waves.ts +7 -2
  89. package/src/lexicon.ts +13 -1
  90. package/src/lifecycle/gate-ledger.ts +4 -3
  91. package/src/op/gate-name.ts +17 -0
  92. package/src/op/gate-summary.test.ts +12 -0
  93. package/src/op/gate-summary.ts +3 -1
  94. package/src/op/gate.test.ts +15 -1
  95. package/src/op/gate.ts +21 -4
  96. package/src/op/index.ts +2 -2
  97. package/src/op/local-executor.test.ts +35 -0
  98. package/src/op/local-executor.ts +2 -2
  99. package/src/op/local-output.test.ts +20 -0
  100. package/src/op/local-output.ts +4 -4
  101. package/src/op/runtime.ts +31 -4
  102. package/src/op/runtimes/local.test.ts +15 -0
  103. package/src/op/runtimes/local.ts +3 -1
  104. package/src/pr-loop.test.ts +41 -1
  105. package/src/pr-loop.ts +41 -10
  106. package/src/workspace/change-set.schema.json +1 -1
  107. package/src/workspace/lineage-upgrade-cli.ts +4 -3
  108. package/src/workspace/lineage-upgrade.test.ts +40 -0
  109. package/src/workspace/member-pipeline.test.ts +28 -0
  110. package/src/workspace/member-pipeline.ts +21 -3
  111. package/src/workspace/pr-report.schema.json +14 -1
@@ -282,6 +282,9 @@ export interface ParsedArgs {
282
282
  * the identical command finishes what an interrupted attempt left, because
283
283
  * the plan is narrowed by this file before the gate is decided and the
284
284
  * digest is carried rather than recomputed, so the approval still stands.
285
+ * `chant components pr-apply --resume <file>` (#3464) keeps the same record
286
+ * with the approved change set in it, and finishes an apply that failed
287
+ * partway under that approval.
285
288
  */
286
289
  resume?: string;
287
290
  /** `chant audit --tier merge-worthy|all` */
@@ -446,6 +449,8 @@ export interface ParsedArgs {
446
449
  /**
447
450
  * `chant workspace build|lint|audit|graph --member <name>` (#2537): run only
448
451
  * the named members or example groups. Repeatable, and a comma list works too.
452
+ * `chant components pr-plan|pr-apply --member <name>` (#3465) takes exactly
453
+ * one: the workspace member whose pull-request loop runs.
449
454
  */
450
455
  members?: string[];
451
456
  /**
@@ -524,6 +529,8 @@ export interface ParsedArgs {
524
529
  foldRankCollapsedFile?: string;
525
530
  /** `chant build --param name=value` (#1064) — repeatable. Bound to `params.<name>` (`@intentius/chant/params`) for source to reference, after validation against `chant.config.ts`'s declared `buildParams`. Highest precedence over `--params-file`/a declared `env` mapping/the declared `default`. */
526
531
  param?: string[];
532
+ /** `chant build --lexicon-output <lexicon>=<path>` — repeatable. Writes that lexicon's output to its own file, so one project can declare several lexicons (otel and prometheus) without their outputs sharing a file. Lexicons not named are built into `--output` when given, and skipped otherwise. */
533
+ lexiconOutput?: string[];
527
534
  /** `chant build --params-file <path>` (#1064) — a JSON file of `{ "name": value }` build-time parameter values. Second precedence, after `--param`. */
528
535
  paramsFile?: string;
529
536
  /** `chant graph --components --format ir --projection <lexicon>` (#989) — add
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The files a change touched (#3183), as a project in a workspace member
3
+ * sees them (#3465): every changed file in the repository, relative to the
4
+ * member's directory, so one outside it reads `../...`.
5
+ */
6
+
7
+ import { execFileSync } from "node:child_process";
8
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { dirname, join } from "node:path";
11
+ import { afterAll, describe, expect, test } from "vitest";
12
+ import { changedFilesBetween } from "./changed-files";
13
+
14
+ const repo = mkdtempSync(join(tmpdir(), "chant-changed-files-"));
15
+ afterAll(() => rmSync(repo, { recursive: true, force: true }));
16
+
17
+ const git = (...args: string[]): string =>
18
+ execFileSync("git", args, {
19
+ cwd: repo,
20
+ encoding: "utf8",
21
+ env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@example.com", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@example.com" },
22
+ }).trim();
23
+
24
+ function put(path: string, text: string): void {
25
+ mkdirSync(dirname(join(repo, path)), { recursive: true });
26
+ writeFileSync(join(repo, path), text);
27
+ }
28
+
29
+ git("init", "-q", "-b", "main");
30
+ put("infra/network/roots/vpc/main.tf", "");
31
+ put("shared/modules/base/main.tf", "");
32
+ put("apps/web/main.tf", "");
33
+ git("add", "-A");
34
+ git("commit", "-q", "-m", "base");
35
+ const base = git("rev-parse", "HEAD");
36
+ put("infra/network/roots/vpc/main.tf", "# changed\n");
37
+ put("shared/modules/base/main.tf", "# changed\n");
38
+ put("apps/web/main.tf", "# changed\n");
39
+ git("add", "-A");
40
+ git("commit", "-q", "-m", "change");
41
+
42
+ describe("changedFilesBetween", () => {
43
+ test("at the repository root, every file by its repository path", async () => {
44
+ expect(await changedFilesBetween(repo, base)).toEqual([
45
+ "apps/web/main.tf",
46
+ "infra/network/roots/vpc/main.tf",
47
+ "shared/modules/base/main.tf",
48
+ ]);
49
+ });
50
+
51
+ test("in a member's directory, every file relative to the member, outside ones included", async () => {
52
+ expect(await changedFilesBetween(join(repo, "infra/network"), base)).toEqual([
53
+ "../../apps/web/main.tf",
54
+ "../../shared/modules/base/main.tf",
55
+ "roots/vpc/main.tf",
56
+ ]);
57
+ });
58
+ });
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import { execFile } from "node:child_process";
12
+ import { posix } from "node:path";
12
13
  import { promisify } from "node:util";
13
14
  import type { LexiconPlugin } from "../lexicon";
14
15
 
@@ -43,12 +44,26 @@ export async function resolveCommit(cwd: string, ref: string): Promise<string> {
43
44
  }
44
45
 
45
46
  /**
46
- * Files changed between `base` and `head`, relative to `cwd` and limited to
47
- * it, with `/` separators. A rename counts as its old and its new path.
47
+ * Files changed between `base` and `head`, relative to `cwd`, with `/`
48
+ * separators. A rename counts as its old and its new path.
49
+ *
50
+ * Every changed file in the repository is listed, not only those under
51
+ * `cwd`: one outside it reads `../...`. A project in a workspace member
52
+ * (#3465) can call a module that lives in another member or beside the
53
+ * members, and a change there reaches it; `changedUnits` decides which of
54
+ * these paths matter.
48
55
  */
49
56
  export async function changedFilesBetween(cwd: string, base: string, head = "HEAD"): Promise<string[]> {
50
- const out = await git(cwd, ["diff", "--name-only", "-z", "--no-renames", "--relative", base, head]);
51
- return out.split("\0").filter(Boolean).sort();
57
+ const prefix = (await git(cwd, ["rev-parse", "--show-prefix"])).trim().replace(/\/+$/, "");
58
+ const out = await git(cwd, ["diff", "--name-only", "-z", "--no-renames", "--no-relative", base, head]);
59
+ return out
60
+ .split("\0")
61
+ .filter(Boolean)
62
+ .map((file) => {
63
+ const rel = posix.relative(prefix, file);
64
+ return rel === "" ? "." : rel;
65
+ })
66
+ .sort();
52
67
  }
53
68
 
54
69
  /** The union of every plugin's `changedUnits` answer, sorted. */
@@ -1,13 +1,16 @@
1
1
  /**
2
2
  * The attempt record keeps what steps carried per member (#3459), so the
3
- * next attempt, in this process or in the next CI job, hands it back.
3
+ * next attempt, in this process or in the next CI job, hands it back. It
4
+ * also keeps the change set a pull request's apply approved (#3464), so a
5
+ * re-run finishes it under that approval.
4
6
  */
5
7
 
6
8
  import { describe, expect, test } from "vitest";
7
9
  import { mkdtempSync, writeFileSync } from "node:fs";
8
10
  import { tmpdir } from "node:os";
9
11
  import { join } from "node:path";
10
- import { readFanOutAttempt, writeFanOutAttempt } from "./fan-out-record";
12
+ import { readFanOutAttempt, writeFanOutAttempt, type PrApplyRecord } from "./fan-out-record";
13
+ import { composeChangeSet } from "../change-set";
11
14
 
12
15
  describe("carried", () => {
13
16
  test("round-trips through the record", () => {
@@ -25,3 +28,45 @@ describe("carried", () => {
25
28
  expect(readFanOutAttempt(join(dir, "b.json"))?.carried).toBeUndefined();
26
29
  });
27
30
  });
31
+
32
+ describe("prApply (#3464)", () => {
33
+ const changeSet = composeChangeSet([
34
+ {
35
+ member: { member: "net", lexicon: "terraform", planner: "tofu", status: "planned", planDigest: `jcs1-sha256:${"1".repeat(64)}`, holes: [] },
36
+ entries: [],
37
+ },
38
+ ]);
39
+ const prApply: PrApplyRecord = {
40
+ op: "pr-12",
41
+ gate: "pr-apply",
42
+ head: "2".repeat(40),
43
+ digest: changeSet.digest,
44
+ approvedBy: ["github:alice"],
45
+ members: [
46
+ {
47
+ member: "net",
48
+ component: "net",
49
+ planDigest: `jcs1-sha256:${"1".repeat(64)}`,
50
+ status: "planned",
51
+ counts: { create: 0, update: 0, replace: 0, delete: 0 },
52
+ },
53
+ ],
54
+ changeSet,
55
+ planOutputs: { net: { cidr: "10.0.0.0/16" } },
56
+ };
57
+
58
+ test("round-trips through the record", () => {
59
+ const path = join(mkdtempSync(join(tmpdir(), "fan-out-record-")), "pr-apply.json");
60
+ writeFanOutAttempt(path, { digest: "d", completed: ["net"], failed: [], outputs: { net: { cidr: "10.0.0.0/16" } }, prApply });
61
+ expect(readFanOutAttempt(path)?.prApply).toEqual(prApply);
62
+ });
63
+
64
+ test("an entry whose change set does not carry its digest, or that misses a field, reads as none", () => {
65
+ const dir = mkdtempSync(join(tmpdir(), "fan-out-record-"));
66
+ const base = { digest: "d", completed: [], failed: [], outputs: {} };
67
+ writeFileSync(join(dir, "a.json"), JSON.stringify({ ...base, prApply: { ...prApply, digest: `jcs1-sha256:${"9".repeat(64)}` } }));
68
+ writeFileSync(join(dir, "b.json"), JSON.stringify({ ...base, prApply: { ...prApply, head: undefined } }));
69
+ writeFileSync(join(dir, "c.json"), JSON.stringify({ ...base, prApply: "pr-12" }));
70
+ for (const name of ["a.json", "b.json", "c.json"]) expect(readFanOutAttempt(join(dir, name))?.prApply).toBeUndefined();
71
+ });
72
+ });
@@ -30,6 +30,29 @@
30
30
  import { dirname } from "node:path";
31
31
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
32
32
  import { readWaveRecords, type WaveRecord } from "../gated-waves";
33
+ import type { ChangeSetDocument } from "../change-set";
34
+ import type { PrMember } from "../pr-loop";
35
+
36
+ /** What a pull request's apply approved, kept so a re-run can finish it (#3464). */
37
+ export interface PrApplyRecord {
38
+ /** The gate's op and name: `pr-<n>` and `pr-apply` unless the pipeline names another. */
39
+ op: string;
40
+ gate: string;
41
+ /** The commit the apply ran on. A record from another commit does not carry. */
42
+ head: string;
43
+ /** The change-set digest the approval stands for. */
44
+ digest: string;
45
+ approvedBy: string[];
46
+ /** Every member of the approved set, as planned. */
47
+ members: PrMember[];
48
+ /** The approved change-set document. Its entries are what a re-planned member is checked against. */
49
+ changeSet: ChangeSetDocument;
50
+ /**
51
+ * The outputs the approved plans read. The members left are planned
52
+ * against them again, so an unchanged member plans to the same digest.
53
+ */
54
+ planOutputs: Record<string, Record<string, unknown>>;
55
+ }
33
56
 
34
57
  export interface FanOutAttempt {
35
58
  digest: string;
@@ -44,11 +67,39 @@ export interface FanOutAttempt {
44
67
  * root, choudoufu's wave resume file.
45
68
  */
46
69
  carried?: Record<string, unknown>;
70
+ /** A pull request's apply only (#3464). */
71
+ prApply?: PrApplyRecord;
47
72
  }
48
73
 
49
74
  const isRecord = (value: unknown): value is Record<string, unknown> =>
50
75
  typeof value === "object" && value !== null && !Array.isArray(value);
51
76
 
77
+ const isStringArray = (value: unknown): value is string[] =>
78
+ Array.isArray(value) && value.every((v) => typeof v === "string");
79
+
80
+ /** The `prApply` entry, when it has every field a resume needs. Anything else reads as no entry. */
81
+ function readPrApplyRecord(value: unknown): PrApplyRecord | undefined {
82
+ if (!isRecord(value)) return undefined;
83
+ const { op, gate, head, digest, approvedBy, members, changeSet, planOutputs } = value;
84
+ if (typeof op !== "string" || typeof gate !== "string" || typeof head !== "string" || typeof digest !== "string") return undefined;
85
+ if (!isStringArray(approvedBy) || !Array.isArray(members) || !members.every(isRecord)) return undefined;
86
+ if (!isRecord(changeSet) || changeSet.digest !== digest || !Array.isArray(changeSet.members) || !Array.isArray(changeSet.entries)) return undefined;
87
+ const outputs: Record<string, Record<string, unknown>> = {};
88
+ if (isRecord(planOutputs)) {
89
+ for (const [name, v] of Object.entries(planOutputs)) if (isRecord(v)) outputs[name] = v;
90
+ }
91
+ return {
92
+ op,
93
+ gate,
94
+ head,
95
+ digest,
96
+ approvedBy,
97
+ members: members as unknown as PrMember[],
98
+ changeSet: changeSet as unknown as ChangeSetDocument,
99
+ planOutputs: outputs,
100
+ };
101
+ }
102
+
52
103
  /** Read an attempt record, or `undefined` when there is none yet. Throws on a file that is not JSON. */
53
104
  export function readFanOutAttempt(path: string): FanOutAttempt | undefined {
54
105
  if (!existsSync(path)) return undefined;
@@ -58,6 +109,7 @@ export function readFanOutAttempt(path: string): FanOutAttempt | undefined {
58
109
  for (const [name, value] of Object.entries(parsed.outputs)) if (isRecord(value)) outputs[name] = value;
59
110
  }
60
111
  const waves = readWaveRecords(parsed.waves);
112
+ const prApply = readPrApplyRecord(parsed.prApply);
61
113
  return {
62
114
  digest: typeof parsed.digest === "string" ? parsed.digest : "",
63
115
  completed: Array.isArray(parsed.completed) ? parsed.completed.filter((n): n is string => typeof n === "string") : [],
@@ -65,6 +117,7 @@ export function readFanOutAttempt(path: string): FanOutAttempt | undefined {
65
117
  outputs,
66
118
  ...(waves.length > 0 ? { waves } : {}),
67
119
  ...(isRecord(parsed.carried) && Object.keys(parsed.carried).length > 0 ? { carried: parsed.carried } : {}),
120
+ ...(prApply ? { prApply } : {}),
68
121
  };
69
122
  }
70
123
 
@@ -17,9 +17,23 @@
17
17
  * to `chant/lifecycle`, to record the pending fact when the gate stops it.
18
18
  * One apply runs at a time per environment.
19
19
  *
20
+ * The apply job passes `--resume` (#3543) with a record path outside the
21
+ * report directory, and each forge keeps that file in its CI cache under a
22
+ * key that names the environment, the member and the pushed commit
23
+ * (`prApplyRecordKey`). A re-run of a failed apply job restores it and
24
+ * finishes the approved set under the same approval; the record itself is
25
+ * checked against the pull request's gate, the commit, the selection and
26
+ * the ledger before it counts (`pr-apply --resume`, #3464).
27
+ *
20
28
  * Measuring the push from the commit it replaced works for merge commits,
21
29
  * squash merges and rebase merges alike, and every job measures from the
22
30
  * merge base of that commit and its own head (`resolveMergeBase`).
31
+ *
32
+ * In a workspace member (#3465) both commands run in the member's directory
33
+ * and pass `--member <name>`, so the member's gate (`pr-<number>-<member>`),
34
+ * note and statuses stay apart from every other member's on the same pull
35
+ * request. They still measure the whole change: a file outside the member
36
+ * that one of its units reaches selects that unit.
23
37
  */
24
38
 
25
39
  import type { ForgeKind } from "../pr-forge";
@@ -28,6 +42,31 @@ import { PR_APPLY_GATE } from "../pr-loop";
28
42
  /** Where both jobs write their reports, kept as a CI artifact. */
29
43
  export const PR_LOOP_REPORT_DIR = ".chant/pr";
30
44
 
45
+ /**
46
+ * The attempt record the apply job resumes from (#3543), relative to the
47
+ * directory the job runs in. It sits outside `PR_LOOP_REPORT_DIR`, so the
48
+ * report artifact never carries it: it holds the outputs the members read.
49
+ */
50
+ export const PR_APPLY_RECORD = ".chant/pr-resume/pr-apply.json";
51
+
52
+ /**
53
+ * The image the pull-request pipeline runs in when the caller sets none. The
54
+ * full `node` image has git, which both jobs measure the change with;
55
+ * `PR_LOOP_SETUP` adds OpenTofu on top of it. A caller who sets `image`
56
+ * brings both and gets no setup lines.
57
+ */
58
+ export const PR_LOOP_IMAGE = "node:22";
59
+
60
+ /** Installs OpenTofu into `PR_LOOP_IMAGE`. */
61
+ export const PR_LOOP_SETUP = [
62
+ "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)",
63
+ ];
64
+
65
+ /** The setup lines for a pipeline running in `image`: the install when it is the default image, none otherwise. */
66
+ export function prLoopSetup(image: string): string[] {
67
+ return image === PR_LOOP_IMAGE ? PR_LOOP_SETUP : [];
68
+ }
69
+
31
70
  /** The `prLoop` generator option (`ComponentPipelineOptions.prLoop`). */
32
71
  export interface PrLoopPipelineOptions {
33
72
  /** The gate the apply waits on. Default `pr-apply`. */
@@ -58,12 +97,21 @@ export interface PrLoopJob {
58
97
  * variables on GitLab.
59
98
  */
60
99
  env: Record<string, string>;
100
+ /** Shell lines to run before the command, to settle values the forge may leave unusable. */
101
+ setup?: string[];
61
102
  }
62
103
 
63
- /** The two jobs for `forge`. */
64
- export function prLoopJobs(forge: ForgeKind, env: string, options: PrLoopPipelineOptions = {}): PrLoopJob[] {
104
+ /**
105
+ * The two jobs for `forge`. `member` is the workspace member the pipeline is
106
+ * generated for; the commands then run in its directory, so `--output` is
107
+ * relative to it.
108
+ */
109
+ export function prLoopJobs(forge: ForgeKind, env: string, options: PrLoopPipelineOptions = {}, member?: string): PrLoopJob[] {
65
110
  const gate = options.gate ?? PR_APPLY_GATE;
66
- const common = ["--env", env, "--gate", gate, "--output", PR_LOOP_REPORT_DIR, "--forge", forge];
111
+ const common = [
112
+ "--env", env, "--gate", gate, "--output", PR_LOOP_REPORT_DIR, "--forge", forge,
113
+ ...(member ? ["--member", member] : []),
114
+ ];
67
115
  const github = forge !== "gitlab";
68
116
  const token: Record<string, string> = github ? { GITHUB_TOKEN: "${{ github.token }}" } : {};
69
117
  return [
@@ -79,13 +127,55 @@ export function prLoopJobs(forge: ForgeKind, env: string, options: PrLoopPipelin
79
127
  command: [
80
128
  "chant", "components", "pr-apply", "--base", '"$BASE_SHA"', ...common,
81
129
  ...(options.requireReview === false ? [] : ["--require-review"]),
130
+ "--resume", PR_APPLY_RECORD,
82
131
  ].join(" "),
83
132
  env: github ? { BASE_SHA: "${{ github.event.before }}", ...token } : { BASE_SHA: "$CI_COMMIT_BEFORE_SHA" },
133
+ ...(github ? {} : { setup: [gitlabBaseFallback(options.branch)] }),
84
134
  },
85
135
  ];
86
136
  }
87
137
 
88
- /** The concurrency group (GitHub, Forgejo) or resource group (GitLab) that keeps one apply at a time per environment. */
89
- export function prApplyGroup(env: string): string {
90
- return `chant-apply-${env}`;
138
+ /**
139
+ * The concurrency group (GitHub, Forgejo) or resource group (GitLab) that
140
+ * keeps one apply at a time per environment, and per member in a workspace.
141
+ *
142
+ * A member's group is `chant-apply.<member>.<env>`. Member names hold only
143
+ * lower-case letters, digits and `-`, so the first `.` ends the member and no
144
+ * two member and environment pairs share a group: member `a` with environment
145
+ * `b-c` is `chant-apply.a.b-c`, and member `a-b` with environment `c` is
146
+ * `chant-apply.a-b.c`. The `.` after `chant-apply` keeps a member's groups
147
+ * apart from a single project's `chant-apply-<env>`.
148
+ */
149
+ export function prApplyGroup(env: string, member?: string): string {
150
+ return member ? `chant-apply.${member}.${env}` : `chant-apply-${env}`;
151
+ }
152
+
153
+ /**
154
+ * The cache key the apply job keeps its attempt record under (#3543), with
155
+ * `commit` the forge's expression for the pushed commit. A push of another
156
+ * commit never restores a record, and the environment and member keep each
157
+ * pipeline's record apart. The pull request and the gate digest are not in
158
+ * the key, since neither is known before the job runs; `pr-apply --resume`
159
+ * refuses a record made for another pull request, gate or digest.
160
+ */
161
+ export function prApplyRecordKey(env: string, commit: string, member?: string): string {
162
+ return `${prApplyGroup(env, member)}-${commit}`;
163
+ }
164
+
165
+ /**
166
+ * GitLab sets `CI_COMMIT_BEFORE_SHA` to forty zeros on a branch's first push.
167
+ * The line replaces it with the merge request's diff base when the pipeline
168
+ * has one, and otherwise with the merge base of the target branch and the
169
+ * pushed commit, or the pushed commit's parent when that is the commit itself.
170
+ */
171
+ export function gitlabBaseFallback(branch?: string): string {
172
+ const target = branch ? JSON.stringify(branch) : "$CI_DEFAULT_BRANCH";
173
+ return (
174
+ 'if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then ' +
175
+ 'BASE_SHA="${CI_MERGE_REQUEST_DIFF_BASE_SHA:-}"; ' +
176
+ 'if [ -z "$BASE_SHA" ]; then ' +
177
+ `git fetch origin ${target} && BASE_SHA="$(git merge-base FETCH_HEAD "$CI_COMMIT_SHA")"; ` +
178
+ 'if [ "$BASE_SHA" = "$CI_COMMIT_SHA" ]; then BASE_SHA="$(git rev-parse "$CI_COMMIT_SHA^")"; fi; ' +
179
+ "fi; fi; export BASE_SHA"
180
+ );
91
181
  }