@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.
- package/dist/change-set.d.ts +30 -8
- package/dist/change-set.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts +11 -0
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/fan-out.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/pr.d.ts +16 -2
- package/dist/cli/handlers/pr.d.ts.map +1 -1
- package/dist/cli/handlers/promote.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/op-tools.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +7 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/changed-files.d.ts +8 -2
- package/dist/components/changed-files.d.ts.map +1 -1
- package/dist/components/fan-out-record.d.ts +24 -0
- package/dist/components/fan-out-record.d.ts.map +1 -1
- package/dist/components/pr-pipeline.d.ts +67 -4
- package/dist/components/pr-pipeline.d.ts.map +1 -1
- package/dist/components/pr-run.d.ts +94 -3
- package/dist/components/pr-run.d.ts.map +1 -1
- package/dist/gated-waves.d.ts +4 -1
- package/dist/gated-waves.d.ts.map +1 -1
- package/dist/lexicon.d.ts +13 -2
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/op/gate-name.d.ts +7 -0
- package/dist/op/gate-name.d.ts.map +1 -1
- package/dist/op/gate-summary.d.ts +2 -0
- package/dist/op/gate-summary.d.ts.map +1 -1
- package/dist/op/gate.d.ts +10 -2
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/index.d.ts +2 -2
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-output.d.ts +2 -2
- package/dist/op/local-output.d.ts.map +1 -1
- package/dist/op/runtime.d.ts +29 -4
- package/dist/op/runtime.d.ts.map +1 -1
- package/dist/op/runtimes/local.d.ts.map +1 -1
- package/dist/pr-loop.d.ts +32 -6
- package/dist/pr-loop.d.ts.map +1 -1
- package/dist/workspace/lineage-upgrade-cli.d.ts.map +1 -1
- package/dist/workspace/member-pipeline.d.ts +15 -1
- package/dist/workspace/member-pipeline.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/__fixtures__/change-set/lifecycle.change-set.golden.json +1 -1
- package/src/__fixtures__/change-set/warden.change-set.golden.json +2 -2
- package/src/__fixtures__/change-set/warden.plan.json +47 -40
- package/src/__fixtures__/plan-summary/normalization-vectors.json +275 -2
- package/src/__fixtures__/pr-loop/pr-apply-applied.note.golden.md +1 -1
- package/src/__fixtures__/pr-loop/pr-apply-refused.note.golden.md +2 -2
- package/src/__fixtures__/pr-loop/pr-plan.golden.json +2 -2
- package/src/__fixtures__/pr-loop/pr-plan.note.golden.md +2 -2
- package/src/build.test.ts +1 -1
- package/src/build.ts +1 -1
- package/src/change-set.test.ts +42 -12
- package/src/change-set.ts +50 -11
- package/src/cli/commands/build.ts +33 -14
- package/src/cli/commands/lint.ts +34 -1
- package/src/cli/handlers/build-lexicon-output.test.ts +102 -0
- package/src/cli/handlers/build.ts +68 -0
- package/src/cli/handlers/fan-out.test.ts +12 -1
- package/src/cli/handlers/fan-out.ts +7 -3
- package/src/cli/handlers/operator.ts +1 -2
- package/src/cli/handlers/pr.ts +223 -35
- package/src/cli/handlers/promote.test.ts +15 -1
- package/src/cli/handlers/promote.ts +5 -3
- package/src/cli/handlers/run.test.ts +215 -1
- package/src/cli/handlers/run.ts +36 -15
- package/src/cli/main.ts +13 -1
- package/src/cli/mcp/never-over-mcp.test.ts +120 -0
- package/src/cli/mcp/op-approve-origin.test.ts +1 -1
- package/src/cli/mcp/op-tools.ts +14 -2
- package/src/cli/mcp/server.test.ts +2 -1
- package/src/cli/registry.ts +7 -0
- package/src/components/changed-files.test.ts +58 -0
- package/src/components/changed-files.ts +19 -4
- package/src/components/fan-out-record.test.ts +47 -2
- package/src/components/fan-out-record.ts +53 -0
- package/src/components/pr-pipeline.ts +96 -6
- package/src/components/pr-run.test.ts +311 -4
- package/src/components/pr-run.ts +198 -5
- package/src/gated-waves.test.ts +11 -0
- package/src/gated-waves.ts +7 -2
- package/src/lexicon.ts +13 -1
- package/src/lifecycle/gate-ledger.ts +4 -3
- package/src/op/gate-name.ts +17 -0
- package/src/op/gate-summary.test.ts +12 -0
- package/src/op/gate-summary.ts +3 -1
- package/src/op/gate.test.ts +15 -1
- package/src/op/gate.ts +21 -4
- package/src/op/index.ts +2 -2
- package/src/op/local-executor.test.ts +35 -0
- package/src/op/local-executor.ts +2 -2
- package/src/op/local-output.test.ts +20 -0
- package/src/op/local-output.ts +4 -4
- package/src/op/runtime.ts +31 -4
- package/src/op/runtimes/local.test.ts +15 -0
- package/src/op/runtimes/local.ts +3 -1
- package/src/pr-loop.test.ts +41 -1
- package/src/pr-loop.ts +41 -10
- package/src/workspace/change-set.schema.json +1 -1
- package/src/workspace/lineage-upgrade-cli.ts +4 -3
- package/src/workspace/lineage-upgrade.test.ts +40 -0
- package/src/workspace/member-pipeline.test.ts +28 -0
- package/src/workspace/member-pipeline.ts +21 -3
- package/src/workspace/pr-report.schema.json +14 -1
package/src/cli/registry.ts
CHANGED
|
@@ -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
|
|
47
|
-
*
|
|
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
|
|
51
|
-
|
|
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
|
-
/**
|
|
64
|
-
|
|
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 = [
|
|
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
|
-
/**
|
|
89
|
-
|
|
90
|
-
|
|
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
|
}
|