@intentius/chant 0.124.34 → 0.124.35
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/cli/handlers/graph.js +2 -2
- package/dist/cli/handlers/pr.d.ts.map +1 -1
- package/dist/cli/handlers/pr.js +147 -51
- package/dist/cli/handlers/promote.d.ts.map +1 -1
- package/dist/cli/handlers/promote.js +23 -8
- package/dist/cli/handlers/release.d.ts +12 -0
- package/dist/cli/handlers/release.d.ts.map +1 -1
- package/dist/cli/handlers/release.js +125 -10
- package/dist/cli/handlers/run.d.ts +3 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/handlers/run.js +12 -2
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +19 -1
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +17 -0
- package/dist/cli/registry.d.ts +14 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/auto-release.d.ts +8 -1
- package/dist/components/auto-release.d.ts.map +1 -1
- package/dist/components/auto-release.js +3 -2
- package/dist/components/green-cover.d.ts +77 -0
- package/dist/components/green-cover.d.ts.map +1 -0
- package/dist/components/green-cover.js +153 -0
- package/dist/components/pr-pipeline.d.ts +34 -0
- package/dist/components/pr-pipeline.d.ts.map +1 -1
- package/dist/components/pr-pipeline.js +39 -5
- package/dist/components/promote.d.ts +4 -1
- package/dist/components/promote.d.ts.map +1 -1
- package/dist/components/promote.js +6 -3
- package/dist/components/release-promote.d.ts +9 -0
- package/dist/components/release-promote.d.ts.map +1 -1
- package/dist/components/release-promote.js +15 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/deep-observation.d.ts +11 -0
- package/dist/deep-observation.d.ts.map +1 -1
- package/dist/graph-declared.d.ts +5 -1
- package/dist/graph-declared.d.ts.map +1 -1
- package/dist/graph-declared.js +7 -2
- package/dist/graph-detail.d.ts.map +1 -1
- package/dist/graph-detail.js +30 -1
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/graph-ir.js +13 -2
- package/dist/lifecycle/__fixtures__/publish-fakes.d.ts.map +1 -1
- package/dist/lifecycle/__fixtures__/publish-fakes.js +5 -1
- package/dist/lifecycle/compose-refs.d.ts +52 -0
- package/dist/lifecycle/compose-refs.d.ts.map +1 -0
- package/dist/lifecycle/compose-refs.js +115 -0
- package/dist/lifecycle/deep-observe.d.ts.map +1 -1
- package/dist/lifecycle/deep-observe.js +3 -2
- package/dist/lifecycle/green-apply.d.ts +39 -0
- package/dist/lifecycle/green-apply.d.ts.map +1 -0
- package/dist/lifecycle/green-apply.js +51 -0
- package/dist/lifecycle/publish-declaration.d.ts +4 -0
- package/dist/lifecycle/publish-declaration.d.ts.map +1 -1
- package/dist/lifecycle/publish-declaration.js +15 -0
- package/dist/lifecycle/publish-npm.d.ts +7 -4
- package/dist/lifecycle/publish-npm.d.ts.map +1 -1
- package/dist/lifecycle/publish-npm.js +60 -13
- package/dist/lifecycle/release-compose.d.ts +91 -0
- package/dist/lifecycle/release-compose.d.ts.map +1 -0
- package/dist/lifecycle/release-compose.js +235 -0
- package/dist/lifecycle/release-cut.d.ts +8 -0
- package/dist/lifecycle/release-cut.d.ts.map +1 -1
- package/dist/lifecycle/release-cut.js +2 -0
- package/dist/lifecycle/release-publish.d.ts +2 -0
- package/dist/lifecycle/release-publish.d.ts.map +1 -1
- package/dist/lifecycle/release-publish.js +2 -1
- package/dist/lifecycle/release-store.d.ts +15 -0
- package/dist/lifecycle/release-store.d.ts.map +1 -1
- package/dist/lifecycle/release-store.js +5 -1
- package/dist/wave-runtime.mjs +225 -223
- package/dist/workspace/checks/ci-workflow.d.ts.map +1 -1
- package/dist/workspace/checks/ci-workflow.js +8 -1
- package/dist/workspace/ci-green-cli.d.ts.map +1 -1
- package/dist/workspace/ci-green-cli.js +5 -3
- package/dist/workspace/ci-green-workflow.d.ts +18 -2
- package/dist/workspace/ci-green-workflow.d.ts.map +1 -1
- package/dist/workspace/ci-green-workflow.js +27 -5
- package/dist/workspace/ci-green.d.ts +31 -0
- package/dist/workspace/ci-green.d.ts.map +1 -1
- package/dist/workspace/ci-green.js +78 -4
- package/dist/workspace/ci-run.d.ts +6 -0
- package/dist/workspace/ci-run.d.ts.map +1 -1
- package/dist/workspace/ci-run.js +2 -1
- package/dist/workspace/ci-serve.d.ts +94 -13
- package/dist/workspace/ci-serve.d.ts.map +1 -1
- package/dist/workspace/ci-serve.js +248 -30
- package/dist/workspace/ci-workflow.d.ts +4 -4
- package/dist/workspace/ci-workflow.d.ts.map +1 -1
- package/dist/workspace/ci-workflow.js +2 -2
- package/dist/workspace/delivery-metrics.d.ts +3 -1
- package/dist/workspace/delivery-metrics.d.ts.map +1 -1
- package/dist/workspace/delivery-metrics.js +12 -3
- package/dist/workspace/member-commands.d.ts.map +1 -1
- package/dist/workspace/member-commands.js +4 -0
- package/dist/workspace/nested-graph.d.ts.map +1 -1
- package/dist/workspace/nested-graph.js +1 -0
- package/dist/workspace/release-candidates.d.ts +28 -0
- package/dist/workspace/release-candidates.d.ts.map +1 -1
- package/dist/workspace/release-candidates.js +38 -2
- package/dist/workspace/release-candidates.schema.json +51 -0
- package/dist/workspace/releases.schema.json +30 -0
- package/package.json +1 -1
- package/src/cli/handlers/graph.ts +2 -2
- package/src/cli/handlers/pr-green.test.ts +230 -0
- package/src/cli/handlers/pr.ts +148 -66
- package/src/cli/handlers/promote.test.ts +34 -0
- package/src/cli/handlers/promote.ts +22 -8
- package/src/cli/handlers/release-compose.e2e.test.ts +133 -0
- package/src/cli/handlers/release.ts +112 -10
- package/src/cli/handlers/run.ts +12 -1
- package/src/cli/main.ts +19 -1
- package/src/cli/parse-args.ts +13 -0
- package/src/cli/registry.ts +14 -0
- package/src/codegen/release-wiring.test.ts +56 -9
- package/src/components/auto-release.test.ts +22 -0
- package/src/components/auto-release.ts +9 -2
- package/src/components/green-cover.test.ts +170 -0
- package/src/components/green-cover.ts +186 -0
- package/src/components/pr-pipeline.test.ts +14 -4
- package/src/components/pr-pipeline.ts +56 -5
- package/src/components/promote.test.ts +7 -0
- package/src/components/promote.ts +9 -4
- package/src/components/release-promote.test.ts +24 -1
- package/src/components/release-promote.ts +15 -0
- package/src/deep-observation.ts +8 -0
- package/src/graph-declared.test.ts +18 -0
- package/src/graph-declared.ts +8 -1
- package/src/graph-detail.test.ts +68 -1
- package/src/graph-detail.ts +29 -1
- package/src/graph-ir.ts +12 -4
- package/src/lifecycle/__fixtures__/publish-fakes.ts +5 -1
- package/src/lifecycle/compose-refs.ts +125 -0
- package/src/lifecycle/deep-observe.test.ts +20 -0
- package/src/lifecycle/deep-observe.ts +4 -2
- package/src/lifecycle/green-apply.ts +71 -0
- package/src/lifecycle/publish-declaration.ts +15 -0
- package/src/lifecycle/publish-npm.ts +55 -13
- package/src/lifecycle/publish-targets.test.ts +45 -0
- package/src/lifecycle/release-compose.test.ts +125 -0
- package/src/lifecycle/release-compose.ts +312 -0
- package/src/lifecycle/release-cut.ts +6 -0
- package/src/lifecycle/release-publish.ts +4 -1
- package/src/lifecycle/release-store.ts +16 -1
- package/src/workspace/checks/ci-workflow.test.ts +12 -2
- package/src/workspace/checks/ci-workflow.ts +7 -1
- package/src/workspace/ci-green-cli.ts +5 -3
- package/src/workspace/ci-green-workflow.test.ts +52 -0
- package/src/workspace/ci-green-workflow.ts +34 -5
- package/src/workspace/ci-green.test.ts +33 -0
- package/src/workspace/ci-green.ts +85 -4
- package/src/workspace/ci-run.test.ts +18 -13
- package/src/workspace/ci-run.ts +8 -1
- package/src/workspace/ci-serve-land.e2e.test.ts +138 -0
- package/src/workspace/ci-serve-land.test.ts +176 -0
- package/src/workspace/ci-serve.test.ts +77 -43
- package/src/workspace/ci-serve.ts +268 -34
- package/src/workspace/ci-workflow.ts +4 -4
- package/src/workspace/delivery-metrics.ts +12 -3
- package/src/workspace/graph-contract.test.ts +3 -2
- package/src/workspace/member-commands.test.ts +11 -4
- package/src/workspace/member-commands.ts +3 -0
- package/src/workspace/nested-graph.test.ts +4 -0
- package/src/workspace/nested-graph.ts +1 -0
- package/src/workspace/release-candidates.schema.json +51 -0
- package/src/workspace/release-candidates.test.ts +30 -3
- package/src/workspace/release-candidates.ts +54 -2
- package/src/workspace/releases.schema.json +30 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant components pr-apply --green` (#4168 step 9): apply on merge applies
|
|
3
|
+
* a green commit, under the approved plans of the merges since the last
|
|
4
|
+
* applied one when they cover it, and otherwise at a gate of its own on the
|
|
5
|
+
* combined digest. Git, the green target, the derivation and the gate ledger
|
|
6
|
+
* are fakes; the planning, the coverage and the apply are the real ones.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
13
|
+
import type { DriverComponent } from "../../components/driver";
|
|
14
|
+
import type { DeployContext } from "../../components/capability";
|
|
15
|
+
import type { ChangeSetDocument } from "../../change-set";
|
|
16
|
+
import type { GreenApplyRecord } from "../../lifecycle/green-apply";
|
|
17
|
+
import type { GreenTargetResult } from "../../components/green-cover";
|
|
18
|
+
import type { ParsedArgs } from "../registry";
|
|
19
|
+
|
|
20
|
+
const HEAD = "a".repeat(40);
|
|
21
|
+
const BASE = "b".repeat(40);
|
|
22
|
+
|
|
23
|
+
vi.mock("../../workspace/security", async (original) => ({
|
|
24
|
+
...(await original<typeof import("../../workspace/security")>()),
|
|
25
|
+
securityGroupsAt: () => new Set<string>(),
|
|
26
|
+
}));
|
|
27
|
+
vi.mock("../../components/changed-files", () => ({
|
|
28
|
+
resolveCommit: async () => HEAD,
|
|
29
|
+
resolveMergeBase: async () => BASE,
|
|
30
|
+
}));
|
|
31
|
+
vi.mock("../../config", async () => {
|
|
32
|
+
const actual = await vi.importActual<typeof import("../../config")>("../../config");
|
|
33
|
+
return { ...actual, loadChantConfig: async () => ({ config: {} }) };
|
|
34
|
+
});
|
|
35
|
+
vi.mock("./fan-out", () => ({
|
|
36
|
+
resolveChangedUnits: async () => ({ units: { changed: ["net"], indeterminate: [] } }),
|
|
37
|
+
}));
|
|
38
|
+
// Nothing here may write or push the real repository's chant/lifecycle.
|
|
39
|
+
vi.mock("../../lifecycle/gate-change-set", () => ({
|
|
40
|
+
storeGateChangeSet: async () => ({ written: false }),
|
|
41
|
+
readGraphIds: async () => ({ ids: [] }),
|
|
42
|
+
setGraphIdReader: () => {},
|
|
43
|
+
gateGraphReader: () => async () => ({ ids: [] }),
|
|
44
|
+
}));
|
|
45
|
+
vi.mock("../../lifecycle/git", async () => {
|
|
46
|
+
const actual = await vi.importActual<typeof import("../../lifecycle/git")>("../../lifecycle/git");
|
|
47
|
+
return { ...actual, pushLifecycleReport: async () => ({}) };
|
|
48
|
+
});
|
|
49
|
+
const applied: GreenApplyRecord[] = [];
|
|
50
|
+
vi.mock("../../lifecycle/green-apply", async () => {
|
|
51
|
+
const actual = await vi.importActual<typeof import("../../lifecycle/green-apply")>("../../lifecycle/green-apply");
|
|
52
|
+
return { ...actual, writeGreenApply: async (r: GreenApplyRecord) => (applied.push(r), "ledger-commit") };
|
|
53
|
+
});
|
|
54
|
+
vi.mock("./run", () => ({
|
|
55
|
+
GATED_EXIT_CODE: 3,
|
|
56
|
+
autoReleaseRun: (id: string) => ({ runId: id, runOrigin: { forge: "local" } }),
|
|
57
|
+
recordAutoReleasesForRun: async () => [],
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
let gateLedger: ReturnType<typeof import("../../op/gate").memoryGateLedgerPort>;
|
|
61
|
+
vi.mock("../../op/gate", async () => {
|
|
62
|
+
const actual = await vi.importActual<typeof import("../../op/gate")>("../../op/gate");
|
|
63
|
+
return { ...actual, gitGateLedgerPort: () => gateLedger, gateIsSealed: async () => false };
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
let target: GreenTargetResult;
|
|
67
|
+
let asked: Array<{ base?: string }> = [];
|
|
68
|
+
vi.mock("../../components/green-cover", async () => {
|
|
69
|
+
const actual = await vi.importActual<typeof import("../../components/green-cover")>("../../components/green-cover");
|
|
70
|
+
return { ...actual, resolveGreenTarget: async (req: { base?: string }) => (asked.push(req), target) };
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/** What each root's plan writes, so a test can move it. */
|
|
74
|
+
const values = new Map<string, string>();
|
|
75
|
+
const ran: string[] = [];
|
|
76
|
+
vi.mock("../../components/fan-out-support", async () => {
|
|
77
|
+
const { CapabilityRegistry } = await import("../../components/capability");
|
|
78
|
+
const { planFanOut } = await import("../../components/fan-out");
|
|
79
|
+
const { computePlanDigest } = await import("../../lifecycle/plan-digest");
|
|
80
|
+
const registry = new CapabilityRegistry();
|
|
81
|
+
registry.register({
|
|
82
|
+
kind: "fake-root",
|
|
83
|
+
async plan(_ctx: DeployContext, input: { root: string }) {
|
|
84
|
+
const value = values.get(input.root) ?? "1";
|
|
85
|
+
const planDigest = computePlanDigest("fake-root", { root: input.root, value });
|
|
86
|
+
return {
|
|
87
|
+
member: input.root,
|
|
88
|
+
planDigest,
|
|
89
|
+
artifact: { planDigest },
|
|
90
|
+
changeSet: {
|
|
91
|
+
member: { member: input.root, lexicon: "terraform", planner: "tofu", status: "planned", planDigest, holes: [] },
|
|
92
|
+
entries: [{ member: input.root, lexicon: "terraform", planner: "tofu", address: `${input.root}.main`, type: "fake", action: "update", attributes: [{ path: "value", after: value }] }],
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
},
|
|
96
|
+
async outputs() {
|
|
97
|
+
return {};
|
|
98
|
+
},
|
|
99
|
+
async run(_ctx: DeployContext, input: { root: string }) {
|
|
100
|
+
ran.push(input.root);
|
|
101
|
+
return { outputs: {} };
|
|
102
|
+
},
|
|
103
|
+
} as never);
|
|
104
|
+
return {
|
|
105
|
+
fanOutRegistry: async () => registry,
|
|
106
|
+
deriveFanOut: async () => ({
|
|
107
|
+
success: true,
|
|
108
|
+
components: ESTATE,
|
|
109
|
+
plan: planFanOut({ components: ESTATE, changed: ["net"] }),
|
|
110
|
+
signal: { changed: ["net"], indeterminate: [], unclaimed: [] },
|
|
111
|
+
}),
|
|
112
|
+
};
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const root = (name: string, dependsOn: string[] = []): DriverComponent => ({
|
|
116
|
+
name,
|
|
117
|
+
dependsOn,
|
|
118
|
+
deploy: [{ phase: "Apply", steps: [{ kind: "fake-root", root: name }] }],
|
|
119
|
+
});
|
|
120
|
+
const ESTATE: DriverComponent[] = [root("net"), root("app", ["net"])];
|
|
121
|
+
|
|
122
|
+
const { memoryGateLedgerPort } = await import("../../op/gate");
|
|
123
|
+
const { runComponentsPrApply, runComponentsPrPlan } = await import("./pr");
|
|
124
|
+
const { composeChangeSet } = await import("../../change-set");
|
|
125
|
+
|
|
126
|
+
let dir: string;
|
|
127
|
+
let errors: string[];
|
|
128
|
+
|
|
129
|
+
const ctx = (a: Partial<ParsedArgs>) =>
|
|
130
|
+
({ args: { command: "components", path: "pr-apply", format: "", fix: false, watch: false, verbose: false, help: false, live: false, base: "main", env: "prod", output: dir, ...a } as ParsedArgs, plugins: [] }) as never;
|
|
131
|
+
|
|
132
|
+
beforeEach(async () => {
|
|
133
|
+
dir = await mkdtemp(join(tmpdir(), "pr-green-"));
|
|
134
|
+
errors = [];
|
|
135
|
+
applied.length = 0;
|
|
136
|
+
ran.length = 0;
|
|
137
|
+
values.clear();
|
|
138
|
+
asked = [];
|
|
139
|
+
gateLedger = memoryGateLedgerPort();
|
|
140
|
+
vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
|
141
|
+
vi.spyOn(console, "error").mockImplementation((...m: unknown[]) => { errors.push(m.map(String).join(" ")); });
|
|
142
|
+
vi.spyOn(console, "log").mockImplementation(() => {});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
afterEach(async () => {
|
|
146
|
+
vi.restoreAllMocks();
|
|
147
|
+
await rm(dir, { recursive: true, force: true });
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
/** The change set a pull request's plan made with the values set now. */
|
|
151
|
+
async function planned(): Promise<ChangeSetDocument> {
|
|
152
|
+
await runComponentsPrPlan(ctx({ pr: 12, path: "pr-plan" }));
|
|
153
|
+
return JSON.parse(await readFile(join(dir, "change-set.json"), "utf8")) as ChangeSetDocument;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const greenTarget = (approved: Array<{ doc: ChangeSetDocument; pr: number; approvedBy: string[] }>): GreenTargetResult => ({
|
|
157
|
+
status: "target",
|
|
158
|
+
target: {
|
|
159
|
+
commit: HEAD,
|
|
160
|
+
base: BASE,
|
|
161
|
+
baseFrom: "applied",
|
|
162
|
+
merges: [BASE.replace(/b/g, "c"), HEAD],
|
|
163
|
+
prs: approved.map((m) => m.pr),
|
|
164
|
+
approved: approved.map((m, i) => ({ commit: i === approved.length - 1 ? HEAD : BASE.replace(/b/g, "c"), ...m })),
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
const OP = `green-${HEAD.slice(0, 12)}`;
|
|
168
|
+
|
|
169
|
+
describe("pr-apply --green (#4168 step 9)", () => {
|
|
170
|
+
test("the merges' approved plans cover the plan at the green commit: it applies under them, with no gate, and records the commit applied", async () => {
|
|
171
|
+
const doc = await planned();
|
|
172
|
+
target = greenTarget([{ doc, pr: 12, approvedBy: ["github:alice"] }]);
|
|
173
|
+
expect(await runComponentsPrApply(ctx({ green: true }))).toBe(0);
|
|
174
|
+
expect(ran).toEqual(["net", "app"]);
|
|
175
|
+
expect(gateLedger.appended).toEqual([]);
|
|
176
|
+
expect(applied).toEqual([expect.objectContaining({ env: "prod", commit: HEAD, base: BASE, approval: "merges", approvedBy: ["github:alice"], digest: doc.digest })]);
|
|
177
|
+
const report = JSON.parse(await readFile(join(dir, "pr-apply.json"), "utf8"));
|
|
178
|
+
expect(report).toMatchObject({ op: OP, pr: null, status: "applied", approval: { status: "approved", approvedBy: ["github:alice"] } });
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("two merges, each approving one member, cover the plan together (the case one pull request's apply stopped at plan-changed)", async () => {
|
|
182
|
+
values.set("app", "1");
|
|
183
|
+
const first = await planned();
|
|
184
|
+
values.set("app", "2");
|
|
185
|
+
const both = await planned();
|
|
186
|
+
// #13 touched app alone, so its plan holds app alone.
|
|
187
|
+
const second = composeChangeSet([{ member: both.members.find((m) => m.member === "app")!, entries: both.entries.filter((e) => e.member === "app") }]);
|
|
188
|
+
// #12 approved net and app at 1; #13 approved app at 2; the green commit plans net at 1 and app at 2.
|
|
189
|
+
target = greenTarget([
|
|
190
|
+
{ doc: first, pr: 12, approvedBy: ["github:alice"] },
|
|
191
|
+
{ doc: second, pr: 13, approvedBy: ["github:bob"] },
|
|
192
|
+
]);
|
|
193
|
+
expect(await runComponentsPrApply(ctx({ green: true }))).toBe(0);
|
|
194
|
+
expect(ran).toEqual(["net", "app"]);
|
|
195
|
+
expect(applied[0]).toMatchObject({ approval: "merges", approvedBy: ["github:alice", "github:bob"] });
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("a plan the merges did not approve waits at the green commit's own gate on the combined digest, then applies once that is approved", async () => {
|
|
199
|
+
const doc = await planned();
|
|
200
|
+
values.set("net", "9");
|
|
201
|
+
target = greenTarget([{ doc, pr: 12, approvedBy: ["github:alice"] }]);
|
|
202
|
+
expect(await runComponentsPrApply(ctx({ green: true }))).toBe(3);
|
|
203
|
+
expect(ran).toEqual([]);
|
|
204
|
+
expect(applied).toEqual([]);
|
|
205
|
+
const refused = JSON.parse(await readFile(join(dir, "pr-apply.json"), "utf8"));
|
|
206
|
+
expect(refused).toMatchObject({ op: OP, status: "refused", refusal: "not-approved" });
|
|
207
|
+
expect(refused.message).toContain("do not cover it: net now writes net.main.value to a different value than the approved plan (checked against #12)");
|
|
208
|
+
expect(refused.message).toContain(`chant approve ${OP} pr-apply --plan ${refused.digest}`);
|
|
209
|
+
|
|
210
|
+
gateLedger = memoryGateLedgerPort({
|
|
211
|
+
resolutions: [{ version: 1, kind: "resolution", op: OP, gate: "pr-apply", resolvedBy: "github:carol", timestamp: new Date().toISOString(), planDigest: refused.digest }],
|
|
212
|
+
});
|
|
213
|
+
expect(await runComponentsPrApply(ctx({ green: true }))).toBe(0);
|
|
214
|
+
expect(ran).toEqual(["net", "app"]);
|
|
215
|
+
expect(applied[0]).toMatchObject({ approval: "gate", approvedBy: ["github:carol"], digest: refused.digest });
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("the green commit last applied is nothing to do, and --green takes no --pr", async () => {
|
|
219
|
+
target = { status: "applied", commit: HEAD };
|
|
220
|
+
expect(await runComponentsPrApply(ctx({ green: true }))).toBe(0);
|
|
221
|
+
expect(ran).toEqual([]);
|
|
222
|
+
expect(errors.join("\n")).toContain("is the green commit last applied to prod");
|
|
223
|
+
expect(await runComponentsPrApply(ctx({ green: true, pr: 12 }))).toBe(1);
|
|
224
|
+
target = { status: "error", message: "abc is not a green commit", hint: "check it out" };
|
|
225
|
+
expect(await runComponentsPrApply(ctx({ green: true }))).toBe(1);
|
|
226
|
+
expect(errors.join("\n")).toContain("abc is not a green commit");
|
|
227
|
+
// --base reaches the target as the first apply's fallback.
|
|
228
|
+
expect(asked.at(-1)).toMatchObject({ base: "main" });
|
|
229
|
+
});
|
|
230
|
+
});
|
package/src/cli/handlers/pr.ts
CHANGED
|
@@ -116,6 +116,7 @@ import { formatError, formatInfo, formatSuccess, formatWarning } from "../format
|
|
|
116
116
|
import { reportUninstalledDependencies } from "../dependencies";
|
|
117
117
|
import { findWorkspaceRoot } from "../../project-root";
|
|
118
118
|
import { resolveChangedUnits } from "./fan-out";
|
|
119
|
+
import { coverByMerges, greenOp, type GreenTarget } from "../../components/green-cover";
|
|
119
120
|
import { GATED_EXIT_CODE, autoReleaseRun, recordAutoReleasesForRun } from "./run";
|
|
120
121
|
import type { CommandContext } from "../registry";
|
|
121
122
|
import type { ComponentChangeSignal, FanOutPlan } from "../../components/fan-out";
|
|
@@ -494,18 +495,26 @@ async function prApply(ctx: CommandContext, stage: StageEvents): Promise<number>
|
|
|
494
495
|
if (args.requireReview && !args.forge) return fail("--require-review reads the pull request's reviews, so it needs --forge <kind>");
|
|
495
496
|
if (args.wave !== undefined && !args.waveGate) return fail("--wave runs one wave of a --wave-gate apply, so it needs --wave-gate <name>");
|
|
496
497
|
if (args.wave !== undefined && args.wave < 1) return fail(`--wave counts from 1, not ${args.wave}`);
|
|
498
|
+
// #4168 step 9: apply on merge applies a green commit, measured from the one applied before it.
|
|
499
|
+
let green: GreenTarget | undefined;
|
|
500
|
+
if (args.green) {
|
|
501
|
+
const target = await greenTargetOf(ctx);
|
|
502
|
+
if (typeof target === "number") return target;
|
|
503
|
+
green = target;
|
|
504
|
+
ctx = { ...ctx, args: { ...ctx.args, base: green.base, head: green.commit } };
|
|
505
|
+
}
|
|
497
506
|
const p = await prepare(ctx, "apply");
|
|
498
507
|
if (typeof p === "number") return p;
|
|
499
508
|
|
|
500
509
|
let pr: number | null = args.pr ?? null;
|
|
501
|
-
if (pr === null && p.forge && p.plan.order.length > 0) {
|
|
510
|
+
if (pr === null && !green && p.forge && p.plan.order.length > 0) {
|
|
502
511
|
try {
|
|
503
512
|
pr = await p.forge.pullRequestFor(p.head);
|
|
504
513
|
} catch (err) {
|
|
505
514
|
return fail(`could not ask ${p.forge.kind} which pull request merged ${p.head}: ${err instanceof Error ? err.message : String(err)}`);
|
|
506
515
|
}
|
|
507
516
|
}
|
|
508
|
-
const op = pr === null ? "pr-unknown" : prOp(pr, p.member);
|
|
517
|
+
const op = green ? greenOp(green.commit, p.member) : pr === null ? "pr-unknown" : prOp(pr, p.member);
|
|
509
518
|
// The run id the release records carry, chosen before the run so that
|
|
510
519
|
// `--events-json` (#4041, #4007) names the run by it too.
|
|
511
520
|
const release = autoReleaseRun(`${op}-${Date.now()}`);
|
|
@@ -513,7 +522,7 @@ async function prApply(ctx: CommandContext, stage: StageEvents): Promise<number>
|
|
|
513
522
|
const events = await openCliRunEvents(args, { kind: "component", name: `pr-apply ${op}`, runId: release.runId, cwd: resolve(".") }, upload?.line);
|
|
514
523
|
stage.stream = events;
|
|
515
524
|
events?.emit({ type: "run-start", waves: p.plan.waves });
|
|
516
|
-
if (args.waveGate) return prApplyWaves(ctx, p, { pr, op, release, upload, events });
|
|
525
|
+
if (args.waveGate) return prApplyWaves(ctx, p, { pr, op, release, upload, events, ...(green ? { green } : {}) });
|
|
517
526
|
|
|
518
527
|
// `--resume` (#3464): an earlier attempt at this change on this commit
|
|
519
528
|
// applied part of an approved set. When the members left are covered by
|
|
@@ -551,7 +560,7 @@ async function prApply(ctx: CommandContext, stage: StageEvents): Promise<number>
|
|
|
551
560
|
if (p.plan.order.length === 0) {
|
|
552
561
|
return stop(prReport({ ...base, approval: { status: "pending" }, status: "nothing" }), 0);
|
|
553
562
|
}
|
|
554
|
-
if (pr === null) {
|
|
563
|
+
if (pr === null && !green) {
|
|
555
564
|
return stop(
|
|
556
565
|
prReport({
|
|
557
566
|
...base,
|
|
@@ -581,41 +590,17 @@ async function prApply(ctx: CommandContext, stage: StageEvents): Promise<number>
|
|
|
581
590
|
await storeChangeSetForGate(ctx, p, set.doc);
|
|
582
591
|
|
|
583
592
|
let approval: PrApproval;
|
|
593
|
+
let via: "merges" | "gate" = "gate";
|
|
584
594
|
if (resumed) {
|
|
585
595
|
approval = resumed.approval;
|
|
586
596
|
} else {
|
|
587
|
-
const
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
decidePrGate(gitGateLedgerPort(), {
|
|
592
|
-
op,
|
|
593
|
-
gate: p.gate,
|
|
594
|
-
digest: set.doc.digest,
|
|
595
|
-
description: `pull request #${pr}: ${p.plan.order.join(", ")}`,
|
|
596
|
-
}),
|
|
597
|
-
(unapproved) => {
|
|
598
|
-
const a = approvalOf(unapproved);
|
|
599
|
-
return a.status === "changed"
|
|
600
|
-
? describePlanChanged({ op, gate: p.gate, digest: set.doc.digest, approval: a })
|
|
601
|
-
: `No approval stands for this plan. Approve it: ${prApproveCommand({ op, gate: p.gate, digest: set.doc.digest })}`;
|
|
602
|
-
},
|
|
603
|
-
);
|
|
604
|
-
approval = approvalOf(check);
|
|
605
|
-
if (!check.satisfied) {
|
|
606
|
-
const partial = { ...base, approval, status: "refused" as const };
|
|
607
|
-
const report = approval.status === "changed"
|
|
608
|
-
? prReport({ ...partial, refusal: "plan-changed", message: describePlanChanged({ op, gate: p.gate, digest: set.doc.digest, approval }) })
|
|
609
|
-
: prReport({
|
|
610
|
-
...partial,
|
|
611
|
-
refusal: "not-approved",
|
|
612
|
-
message: `No approval stands for this plan, so nothing was applied. Approve it: ${prApproveCommand({ op, gate: p.gate, digest: set.doc.digest })}; then run the apply again.`,
|
|
613
|
-
});
|
|
614
|
-
return stop(report, GATED_EXIT_CODE);
|
|
615
|
-
}
|
|
597
|
+
const decided = await decideApplyGate(p, { op, pr, set, events, ...(green ? { green } : {}) });
|
|
598
|
+
approval = decided.approval;
|
|
599
|
+
if (!decided.satisfied) return stop(prReport({ ...base, approval, status: "refused", refusal: decided.refusal, message: decided.message }), GATED_EXIT_CODE);
|
|
600
|
+
via = decided.via;
|
|
616
601
|
}
|
|
617
602
|
|
|
618
|
-
const reviewOf =
|
|
603
|
+
const reviewOf = reviewFor(args, p, pr, green);
|
|
619
604
|
const review = await reviewOf(approval.approvedBy ?? [], "The plan");
|
|
620
605
|
if (review && "error" in review) return fail(review.error);
|
|
621
606
|
if (review) {
|
|
@@ -666,7 +651,7 @@ async function prApply(ctx: CommandContext, stage: StageEvents): Promise<number>
|
|
|
666
651
|
...(roundLedger.approvalRule ? { approvalRule: (g: string, at?: { base?: string }) => roundLedger.approvalRule!(g === gate ? p.gate : g, at) } : {}),
|
|
667
652
|
};
|
|
668
653
|
const check = await emitGateStep(events?.emit, { op, gate }, () =>
|
|
669
|
-
decidePrGate(port, { op, gate, digest: planned.doc.digest, description:
|
|
654
|
+
decidePrGate(port, { op, gate, digest: planned.doc.digest, description: `${changeLabel(pr, green)}, round ${round}: planned again after an upstream applied` }),
|
|
670
655
|
);
|
|
671
656
|
const roundApproval = approvalOf(check);
|
|
672
657
|
if (!check.satisfied) return { satisfied: false, approval: roundApproval };
|
|
@@ -724,6 +709,7 @@ async function prApply(ctx: CommandContext, stage: StageEvents): Promise<number>
|
|
|
724
709
|
// a person. A step that refused its plan because the target moved since
|
|
725
710
|
// the approval exits 3, as a moved digest does before anything applies (#3997).
|
|
726
711
|
const exit = applied.status === "applied" ? 0 : applied.status === "waiting" ? GATED_EXIT_CODE : failedRunExitCode(applied.run.results);
|
|
712
|
+
if (green && applied.status === "applied") await recordGreenApplied(p, green, set.doc.digest, approval, via);
|
|
727
713
|
const code = await stop(report, exit, applied.run.status === "gated" ? "gated" : undefined);
|
|
728
714
|
if (applied.status === "applied") console.error(formatSuccess(`applied the plan ${report.digest} approved by ${(approval.approvedBy ?? []).join(", ")}`));
|
|
729
715
|
return code;
|
|
@@ -846,6 +832,120 @@ async function prRecord(ctx: CommandContext, stage: StageEvents): Promise<number
|
|
|
846
832
|
return stop(report, status === "plan-failed" ? 1 : 0);
|
|
847
833
|
}
|
|
848
834
|
|
|
835
|
+
/** What a gate's description and messages call the change: the pull request, or the green commit (#4168 step 9). */
|
|
836
|
+
function changeLabel(pr: number | null, green: GreenTarget | undefined): string {
|
|
837
|
+
return green ? `green ${green.commit.slice(0, 12)}` : `pull request #${pr}`;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* `pr-apply --green` (#4168 step 9): the green commit at HEAD, the last one
|
|
842
|
+
* applied to the environment, and the merges between with their approvals.
|
|
843
|
+
* Exits 0 when HEAD is the green commit last applied.
|
|
844
|
+
*/
|
|
845
|
+
async function greenTargetOf(ctx: CommandContext): Promise<GreenTarget | number> {
|
|
846
|
+
const { args } = ctx;
|
|
847
|
+
if (args.pr !== undefined) return fail("--green applies a green commit and every merge since the last one applied, so it takes no --pr; the merge records name the pull requests");
|
|
848
|
+
const { resolveGreenTarget } = await import("../../components/green-cover");
|
|
849
|
+
const env = args.env ?? "local";
|
|
850
|
+
const resolved = await resolveGreenTarget({ cwd: resolve("."), env, ...(args.base ? { base: args.base } : {}), gate: args.gate ?? PR_APPLY_GATE });
|
|
851
|
+
if (resolved.status === "error") return fail(resolved.message, resolved.hint);
|
|
852
|
+
if (resolved.status === "applied") {
|
|
853
|
+
console.error(formatInfo(`${resolved.commit.slice(0, 12)} is the green commit last applied to ${env}, so there is nothing to apply`));
|
|
854
|
+
return 0;
|
|
855
|
+
}
|
|
856
|
+
const t = resolved.target;
|
|
857
|
+
const from = { applied: "the green commit last applied", flag: "--base", "previous-green": "the green commit before it", parent: "its parent" }[t.baseFrom];
|
|
858
|
+
console.error(formatInfo(`applying green ${t.commit.slice(0, 12)}, measured from ${t.base.slice(0, 12)} (${from}): ${t.merges.length} commit${t.merges.length === 1 ? "" : "s"}, ${t.approved.length} with an approved plan`));
|
|
859
|
+
return t;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* The first plan's gate. For one pull request, its gate on the plan's digest.
|
|
864
|
+
* For a green commit (#4168 step 9), the approved plans of the merges since
|
|
865
|
+
* the last applied green commit when they cover every member, and otherwise
|
|
866
|
+
* a gate of the green commit's own on the combined digest.
|
|
867
|
+
*/
|
|
868
|
+
async function decideApplyGate(
|
|
869
|
+
p: Prepared,
|
|
870
|
+
at: { op: string; pr: number | null; set: PrPlanSet; events?: RunEventStream; green?: GreenTarget },
|
|
871
|
+
): Promise<{ approval: PrApproval; satisfied: true; via: "merges" | "gate" } | { approval: PrApproval; satisfied: false; refusal: "plan-changed" | "not-approved"; message: string }> {
|
|
872
|
+
const { op, pr, set, events, green } = at;
|
|
873
|
+
let uncovered: string[] = [];
|
|
874
|
+
if (green) {
|
|
875
|
+
const cover = coverByMerges(set.doc, green.approved);
|
|
876
|
+
if (cover.covered) {
|
|
877
|
+
console.error(formatInfo(`the approved plans of ${cover.coveredBy.length} merge${cover.coveredBy.length === 1 ? "" : "s"} cover the plan at ${green.commit.slice(0, 12)}, so it applies under them`));
|
|
878
|
+
return { approval: { status: "approved", approvedBy: cover.approvedBy }, satisfied: true, via: "merges" };
|
|
879
|
+
}
|
|
880
|
+
uncovered = cover.uncovered;
|
|
881
|
+
console.error(formatInfo(`the merged changes' approvals do not cover the plan at ${green.commit.slice(0, 12)}: ${uncovered.join("; ")}`));
|
|
882
|
+
}
|
|
883
|
+
const digest = set.doc.digest;
|
|
884
|
+
const check = await emitGateStep(
|
|
885
|
+
events?.emit,
|
|
886
|
+
{ op, gate: p.gate },
|
|
887
|
+
() => decidePrGate(gitGateLedgerPort(), { op, gate: p.gate, digest, description: `${changeLabel(pr, green)}: ${p.plan.order.join(", ")}` }),
|
|
888
|
+
(unapproved) => {
|
|
889
|
+
const a = approvalOf(unapproved);
|
|
890
|
+
return a.status === "changed"
|
|
891
|
+
? describePlanChanged({ op, gate: p.gate, digest, approval: a })
|
|
892
|
+
: `No approval stands for this plan. Approve it: ${prApproveCommand({ op, gate: p.gate, digest })}`;
|
|
893
|
+
},
|
|
894
|
+
);
|
|
895
|
+
const approval = approvalOf(check);
|
|
896
|
+
if (check.satisfied) return { approval, satisfied: true, via: "gate" };
|
|
897
|
+
if (approval.status === "changed") return { approval, satisfied: false, refusal: "plan-changed", message: describePlanChanged({ op, gate: p.gate, digest, approval }) };
|
|
898
|
+
const why = uncovered.length > 0 ? ` The approvals of the merges since the last applied green commit do not cover it: ${uncovered.join("; ")}.` : "";
|
|
899
|
+
return {
|
|
900
|
+
approval,
|
|
901
|
+
satisfied: false,
|
|
902
|
+
refusal: "not-approved",
|
|
903
|
+
message: `No approval stands for this plan, so nothing was applied.${why} Approve it: ${prApproveCommand({ op, gate: p.gate, digest })}; then run the apply again.`,
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* The `--require-review` check for the apply. A green commit (#4168 step 9)
|
|
909
|
+
* has no pull request of its own, so its approvers are checked against every
|
|
910
|
+
* pull request merged since the last applied green commit for authorship
|
|
911
|
+
* only: whoever wrote one of them never counts.
|
|
912
|
+
*/
|
|
913
|
+
function reviewFor(args: CommandContext["args"], p: Prepared, pr: number | null, green: GreenTarget | undefined): (approvedBy: readonly string[], what: string) => Promise<ReviewStop> {
|
|
914
|
+
if (!green) return reviewCheck(args, p, pr!);
|
|
915
|
+
const checks = green.prs.map((n) => reviewCheck(args, p, n, true));
|
|
916
|
+
return async (approvedBy, what) => {
|
|
917
|
+
for (const check of checks) {
|
|
918
|
+
const stop = await check(approvedBy, what);
|
|
919
|
+
if (stop) return stop;
|
|
920
|
+
}
|
|
921
|
+
return undefined;
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/** Record the green commit as applied to the environment (#4168 step 9), and push `chant/lifecycle`. A failure warns. */
|
|
926
|
+
async function recordGreenApplied(p: Prepared, green: GreenTarget, digest: string, approval: PrApproval, via: "merges" | "gate"): Promise<void> {
|
|
927
|
+
const { writeGreenApply, GREEN_APPLY_CONTRACT } = await import("../../lifecycle/green-apply");
|
|
928
|
+
const { pushLifecycleReport } = await import("../../lifecycle/git");
|
|
929
|
+
try {
|
|
930
|
+
await writeGreenApply({
|
|
931
|
+
contract: GREEN_APPLY_CONTRACT,
|
|
932
|
+
env: p.env,
|
|
933
|
+
...(p.member ? { member: p.member } : {}),
|
|
934
|
+
commit: green.commit,
|
|
935
|
+
base: green.base,
|
|
936
|
+
digest,
|
|
937
|
+
approval: via,
|
|
938
|
+
approvedBy: approval.approvedBy ?? [],
|
|
939
|
+
merges: green.merges,
|
|
940
|
+
appliedAt: new Date().toISOString(),
|
|
941
|
+
});
|
|
942
|
+
const pushed = await pushLifecycleReport();
|
|
943
|
+
if (pushed.pushWarning) console.error(formatWarning({ message: pushed.pushWarning }));
|
|
944
|
+
} catch (err) {
|
|
945
|
+
console.error(formatWarning({ message: `could not record ${green.commit.slice(0, 12)} as applied to ${p.env}: ${err instanceof Error ? err.message : String(err)}` }));
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
849
949
|
/** How a stage stops: settle its events, write and publish the report, say how it ended, and return `code`. */
|
|
850
950
|
function stopper(ctx: CommandContext, p: Prepared, events: RunEventStream | undefined, upload: PrReportUpload | undefined) {
|
|
851
951
|
return async (report: PrReport, code: number, settle?: RunEventSettleStatus): Promise<number> => {
|
|
@@ -867,7 +967,7 @@ type ReviewStop = { refusal: "author-approved" | "not-a-reviewer"; message: stri
|
|
|
867
967
|
* authors are read once, at the first call. Without `--require-review` every
|
|
868
968
|
* approval counts.
|
|
869
969
|
*/
|
|
870
|
-
function reviewCheck(args: CommandContext["args"], p: Prepared, pr: number): (approvedBy: readonly string[], what: string) => Promise<ReviewStop> {
|
|
970
|
+
function reviewCheck(args: CommandContext["args"], p: Prepared, pr: number, authorsOnly = false): (approvedBy: readonly string[], what: string) => Promise<ReviewStop> {
|
|
871
971
|
let read: Promise<{ reviewers: string[]; authors: string[] }> | undefined;
|
|
872
972
|
return async (approvedBy, what) => {
|
|
873
973
|
const forge = p.forge;
|
|
@@ -895,7 +995,7 @@ function reviewCheck(args: CommandContext["args"], p: Prepared, pr: number): (ap
|
|
|
895
995
|
`Have someone else approve the pull request and the plan, then run the apply again.`,
|
|
896
996
|
};
|
|
897
997
|
}
|
|
898
|
-
const strangers = approversWithoutReview(approvedBy, reviewers);
|
|
998
|
+
const strangers = authorsOnly ? [] : approversWithoutReview(approvedBy, reviewers);
|
|
899
999
|
if (strangers.length > 0) {
|
|
900
1000
|
return {
|
|
901
1001
|
refusal: "not-a-reviewer",
|
|
@@ -1127,10 +1227,10 @@ function inWave(body: RunEventBody, wave: number): RunEventBody {
|
|
|
1127
1227
|
async function prApplyWaves(
|
|
1128
1228
|
ctx: CommandContext,
|
|
1129
1229
|
p: Prepared,
|
|
1130
|
-
at: { pr: number | null; op: string; release: ReturnType<typeof autoReleaseRun>; upload?: PrReportUpload; events?: RunEventStream },
|
|
1230
|
+
at: { pr: number | null; op: string; release: ReturnType<typeof autoReleaseRun>; upload?: PrReportUpload; events?: RunEventStream; green?: GreenTarget },
|
|
1131
1231
|
): Promise<number> {
|
|
1132
1232
|
const { args } = ctx;
|
|
1133
|
-
const { pr, op, events, upload } = at;
|
|
1233
|
+
const { pr, op, events, upload, green } = at;
|
|
1134
1234
|
const waveGate = args.waveGate!;
|
|
1135
1235
|
const only = args.wave;
|
|
1136
1236
|
const total = p.plan.waves.length;
|
|
@@ -1139,7 +1239,7 @@ async function prApplyWaves(
|
|
|
1139
1239
|
log({ type: "run-start", waves: p.plan.waves });
|
|
1140
1240
|
const reportBase = { stage: "apply" as const, pr, env: p.env, ...(p.member ? { member: p.member } : {}), base: p.base, head: p.head, op, gate: p.gate, plan: p.plan, signal: p.signal, waveGate };
|
|
1141
1241
|
|
|
1142
|
-
if (p.plan.order.length === 0 || pr === null) {
|
|
1242
|
+
if (p.plan.order.length === 0 || (pr === null && !green)) {
|
|
1143
1243
|
const set = await planAll(p, events?.emit);
|
|
1144
1244
|
if (p.plan.order.length === 0) return stop(prReport({ ...reportBase, set, approval: { status: "pending" }, status: "nothing" }), 0);
|
|
1145
1245
|
return stop(
|
|
@@ -1181,6 +1281,7 @@ async function prApplyWaves(
|
|
|
1181
1281
|
? { doc: carry.record.changeSet, members: carry.record.members, plans: {}, outputs: carry.record.planOutputs, failed: false }
|
|
1182
1282
|
: undefined;
|
|
1183
1283
|
let approval: PrApproval = carry?.approval ?? { status: "pending" };
|
|
1284
|
+
let via: "merges" | "gate" = "gate";
|
|
1184
1285
|
const report = (input: { status: PrReport["status"]; refusal?: PrReport["refusal"]; message?: string }) => {
|
|
1185
1286
|
const set = approved ?? { doc: composeChangeSet([]), members: [], plans: {}, outputs: {}, failed: false };
|
|
1186
1287
|
const members = set.members.map((m): PrMember => {
|
|
@@ -1224,35 +1325,15 @@ async function prApplyWaves(
|
|
|
1224
1325
|
return stop(report({ status: "plan-failed", message: "A member failed to plan, so there is no plan to compare with the approved one. Nothing was applied." }), 1);
|
|
1225
1326
|
}
|
|
1226
1327
|
await storeChangeSetForGate(ctx, p, set.doc);
|
|
1227
|
-
const
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
(unapproved) => {
|
|
1232
|
-
const a = approvalOf(unapproved);
|
|
1233
|
-
return a.status === "changed"
|
|
1234
|
-
? describePlanChanged({ op, gate: p.gate, digest: set.doc.digest, approval: a })
|
|
1235
|
-
: `No approval stands for this plan. Approve it: ${prApproveCommand({ op, gate: p.gate, digest: set.doc.digest })}`;
|
|
1236
|
-
},
|
|
1237
|
-
);
|
|
1238
|
-
approval = approvalOf(check);
|
|
1239
|
-
if (!check.satisfied) {
|
|
1240
|
-
return stop(
|
|
1241
|
-
approval.status === "changed"
|
|
1242
|
-
? report({ status: "refused", refusal: "plan-changed", message: describePlanChanged({ op, gate: p.gate, digest: set.doc.digest, approval }) })
|
|
1243
|
-
: report({
|
|
1244
|
-
status: "refused",
|
|
1245
|
-
refusal: "not-approved",
|
|
1246
|
-
message: `No approval stands for this plan, so nothing was applied. Approve it: ${prApproveCommand({ op, gate: p.gate, digest: set.doc.digest })}; then run the apply again.`,
|
|
1247
|
-
}),
|
|
1248
|
-
GATED_EXIT_CODE,
|
|
1249
|
-
);
|
|
1250
|
-
}
|
|
1328
|
+
const decided = await decideApplyGate(p, { op, pr, set, events, ...(green ? { green } : {}) });
|
|
1329
|
+
approval = decided.approval;
|
|
1330
|
+
if (!decided.satisfied) return stop(report({ status: "refused", refusal: decided.refusal, message: decided.message }), GATED_EXIT_CODE);
|
|
1331
|
+
via = decided.via;
|
|
1251
1332
|
planned = set;
|
|
1252
1333
|
}
|
|
1253
1334
|
const approvedSet = approved!;
|
|
1254
1335
|
|
|
1255
|
-
const review =
|
|
1336
|
+
const review = reviewFor(args, p, pr, green);
|
|
1256
1337
|
const prReview = await review(approval.approvedBy ?? [], "The plan");
|
|
1257
1338
|
if (prReview && "error" in prReview) return fail(prReview.error);
|
|
1258
1339
|
if (prReview) {
|
|
@@ -1336,7 +1417,7 @@ async function prApplyWaves(
|
|
|
1336
1417
|
const check = await emitGateStep(
|
|
1337
1418
|
events?.emit,
|
|
1338
1419
|
{ op, gate },
|
|
1339
|
-
() => decidePrGate(gitGateLedgerPort(), { op, gate, digest, description:
|
|
1420
|
+
() => decidePrGate(gitGateLedgerPort(), { op, gate, digest, description: `${changeLabel(pr, green)}, wave ${k} of ${total}: ${wavePlan.order.join(", ")}` }),
|
|
1340
1421
|
(unapproved) =>
|
|
1341
1422
|
unapproved.mismatch
|
|
1342
1423
|
? `wave ${k}'s plan changed after its approval. Approve the plan now: ${approveLine}`
|
|
@@ -1412,6 +1493,7 @@ async function prApplyWaves(
|
|
|
1412
1493
|
}
|
|
1413
1494
|
|
|
1414
1495
|
const done = p.plan.order.every((name) => completed.has(name));
|
|
1496
|
+
if (green && done) await recordGreenApplied(p, green, approvedSet.doc.digest, approval, via);
|
|
1415
1497
|
return stop(report({ status: done ? "applied" : "wave-applied" }), 0);
|
|
1416
1498
|
}
|
|
1417
1499
|
|
|
@@ -11,6 +11,7 @@ import type { ReleaseRecord } from "../../lifecycle/release-ledger";
|
|
|
11
11
|
import type { ParsedArgs } from "../registry";
|
|
12
12
|
import { makeRelease } from "../../lifecycle/release-store";
|
|
13
13
|
import { contentDigest } from "../../content-digest";
|
|
14
|
+
import { releaseTemplatesDigest } from "../../components/release-promote";
|
|
14
15
|
|
|
15
16
|
const appendReleaseRecordMock = vi.fn();
|
|
16
17
|
const readReleaseLedgerMock = vi.fn();
|
|
@@ -245,6 +246,8 @@ beforeEach(() => {
|
|
|
245
246
|
|
|
246
247
|
afterEach(() => {
|
|
247
248
|
vi.restoreAllMocks();
|
|
249
|
+
// Promote, rollback and redeploy set CHANT_ENV to the environment they deploy (#4222).
|
|
250
|
+
delete process.env.CHANT_ENV;
|
|
248
251
|
});
|
|
249
252
|
|
|
250
253
|
describe("chant components promote", () => {
|
|
@@ -475,6 +478,37 @@ describe("chant components promote --release (#4026)", () => {
|
|
|
475
478
|
expect(ran).toEqual([]);
|
|
476
479
|
});
|
|
477
480
|
|
|
481
|
+
test("#4222: a component with no publish step deploys the release's template and records its templates digest", async () => {
|
|
482
|
+
const templateOnly: DriverComponent = { ...api, deploy: [{ phase: "Apply", steps: [{ kind: "apply", template: "dist/api.template.json" }] }] };
|
|
483
|
+
resolveTargetsMock.mockResolvedValue({ success: true, targets: [templateOnly] });
|
|
484
|
+
expect(await runComponentsPromote(ctx({ release: "v1.0.0" }))).toBe(0);
|
|
485
|
+
expect(ran).toEqual(["prod:apply"]);
|
|
486
|
+
const prodTarget = release.targets.find((t) => t.env === "prod")!;
|
|
487
|
+
expect(appendReleaseRecordMock.mock.calls[0][0]).toMatchObject({
|
|
488
|
+
env: "prod",
|
|
489
|
+
digest: releaseTemplatesDigest(prodTarget, "api"),
|
|
490
|
+
releaseDigest: release.releaseDigest,
|
|
491
|
+
});
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
test("#4222: without --release a component with no publish step is still refused", async () => {
|
|
495
|
+
const templateOnly: DriverComponent = { ...api, deploy: [{ phase: "Apply", steps: [{ kind: "apply", template: "dist/api.template.json" }] }] };
|
|
496
|
+
resolveTargetsMock.mockResolvedValue({ success: true, targets: [templateOnly] });
|
|
497
|
+
expect(await runComponentsPromote(ctx({}))).toBe(1);
|
|
498
|
+
expect(errors.join("\n")).toMatch(/no publish step/);
|
|
499
|
+
expect(ran).toEqual([]);
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
test("#4222: the components load with CHANT_ENV set to --to", async () => {
|
|
503
|
+
let seen: string | undefined;
|
|
504
|
+
resolveTargetsMock.mockImplementation(async () => {
|
|
505
|
+
seen = process.env.CHANT_ENV;
|
|
506
|
+
return { success: true, targets: [withTemplate] };
|
|
507
|
+
});
|
|
508
|
+
expect(await runComponentsPromote(ctx({ release: "v1.0.0" }))).toBe(0);
|
|
509
|
+
expect(seen).toBe("prod");
|
|
510
|
+
});
|
|
511
|
+
|
|
478
512
|
test("a promote without --release reads the checkout's template as before", async () => {
|
|
479
513
|
expect(await runComponentsPromote(ctx({}))).toBe(0);
|
|
480
514
|
expect(stepInputs.find((s) => s.kind === "apply")?.input).toMatchObject({ template: "dist/api.template.json" });
|