@intentius/chant 0.124.33 → 0.124.34
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/promote.d.ts.map +1 -1
- package/dist/cli/handlers/promote.js +261 -73
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +18 -1
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +7 -0
- package/dist/cli/registry.d.ts +3 -1
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/promote-rounds.d.ts +102 -0
- package/dist/components/promote-rounds.d.ts.map +1 -0
- package/dist/components/promote-rounds.js +174 -0
- package/dist/components/release-gate.d.ts +30 -0
- package/dist/components/release-gate.d.ts.map +1 -1
- package/dist/components/release-gate.js +92 -42
- package/dist/graph-declared.d.ts +4 -0
- package/dist/graph-declared.d.ts.map +1 -1
- package/dist/graph-declared.js +11 -1
- package/dist/wave-runtime.mjs +39 -39
- package/dist/workspace/__fixtures__/contract-repo.d.ts.map +1 -1
- package/dist/workspace/__fixtures__/contract-repo.js +13 -2
- package/dist/workspace/check.schema.json +20 -2
- package/dist/workspace/checks/ci-workflow.d.ts +17 -0
- package/dist/workspace/checks/ci-workflow.d.ts.map +1 -0
- package/dist/workspace/checks/ci-workflow.js +123 -0
- package/dist/workspace/checks/security.d.ts +16 -1
- package/dist/workspace/checks/security.d.ts.map +1 -1
- package/dist/workspace/checks/security.js +4 -4
- package/dist/workspace/checks.d.ts +1 -1
- package/dist/workspace/checks.d.ts.map +1 -1
- package/dist/workspace/checks.js +4 -1
- package/dist/workspace/ci-init.d.ts +100 -0
- package/dist/workspace/ci-init.d.ts.map +1 -0
- package/dist/workspace/ci-init.js +434 -0
- package/dist/workspace/ci-workflow.d.ts +91 -0
- package/dist/workspace/ci-workflow.d.ts.map +1 -0
- package/dist/workspace/ci-workflow.js +33 -0
- package/dist/workspace/declaration.d.ts +18 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.js +9 -2
- package/dist/workspace/declaration.schema.json +28 -0
- package/dist/workspace/init.js +2 -2
- package/dist/workspace/reason-codes.d.ts +9 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/reason-codes.js +10 -0
- package/dist/workspace/release-workflow.d.ts +31 -0
- package/dist/workspace/release-workflow.d.ts.map +1 -0
- package/dist/workspace/release-workflow.js +150 -0
- package/package.json +1 -1
- package/src/cli/handlers/promote.ts +280 -83
- package/src/cli/main.ts +18 -1
- package/src/cli/parse-args.ts +5 -0
- package/src/cli/registry.ts +3 -1
- package/src/components/promote-rounds.test.ts +220 -0
- package/src/components/promote-rounds.ts +232 -0
- package/src/components/release-gate.test.ts +47 -0
- package/src/components/release-gate.ts +111 -46
- package/src/graph-declared-exports.test.ts +40 -0
- package/src/graph-declared.ts +9 -1
- package/src/workspace/__fixtures__/contract-repo.ts +11 -2
- package/src/workspace/check-contract.test.ts +3 -2
- package/src/workspace/check.schema.json +20 -2
- package/src/workspace/checks/ci-workflow.test.ts +150 -0
- package/src/workspace/checks/ci-workflow.ts +129 -0
- package/src/workspace/checks/security.ts +4 -4
- package/src/workspace/checks.ts +4 -1
- package/src/workspace/ci-init.test.ts +159 -0
- package/src/workspace/ci-init.ts +445 -0
- package/src/workspace/ci-report-url.test.ts +6 -1
- package/src/workspace/ci-workflow.ts +71 -0
- package/src/workspace/declaration.schema.json +28 -0
- package/src/workspace/declaration.ts +32 -3
- package/src/workspace/init.ts +2 -2
- package/src/workspace/reason-codes.test.ts +2 -0
- package/src/workspace/reason-codes.ts +10 -0
- package/src/workspace/release-workflow.ts +167 -0
|
@@ -39,6 +39,14 @@
|
|
|
39
39
|
* planned by that capability, as a pull request's plan is. A plan that
|
|
40
40
|
* returns no change-set part is a hole, since nothing says what it changes.
|
|
41
41
|
*
|
|
42
|
+
* A dependency the promote also deploys is read from its plan, not from the
|
|
43
|
+
* estate (#4168 step 4, the rule #3999 gave a pull request): a dependent is
|
|
44
|
+
* planned against the outputs its upstream's plan says it will expose, and a
|
|
45
|
+
* reference the plan can't say is listed in `unknownInputs`. Any other
|
|
46
|
+
* dependency is read as it stands. What each component was planned against is
|
|
47
|
+
* kept in `reads`, so the promote can hold it back when an upstream applies
|
|
48
|
+
* as another value (`./promote-rounds.ts`).
|
|
49
|
+
*
|
|
42
50
|
* Any other component is a `chant` member compared with what the
|
|
43
51
|
* environment runs now: its latest deploy record and the release that record
|
|
44
52
|
* names. The artifact digest, each template the release's target records for
|
|
@@ -52,8 +60,8 @@
|
|
|
52
60
|
* recorded since (#4035): its record names what ran before that run.
|
|
53
61
|
*/
|
|
54
62
|
|
|
55
|
-
import type { CapabilityRegistry } from "./capability";
|
|
56
|
-
import { deployContext, resolveComponentGraph, resolveStepInput, type DriverComponent, type DriverGate, type DriverPhase, type DriverStep } from "./driver";
|
|
63
|
+
import type { CapabilityRegistry, PlannedOutputs } from "./capability";
|
|
64
|
+
import { deployContext, resolveComponentGraph, resolveStepInput, unresolvedWiringReferences, type DriverComponent, type DriverGate, type DriverPhase, type DriverStep } from "./driver";
|
|
57
65
|
import { componentPlanDigest } from "./gate-plan";
|
|
58
66
|
import { gateName, RELEASE_GATE_OP } from "../op/gate-name";
|
|
59
67
|
import { capabilitySteps, planContext, readComponentOutputs } from "./wave-plan";
|
|
@@ -123,6 +131,23 @@ export interface ReleaseChangeSetInput {
|
|
|
123
131
|
seededOutputs?: Record<string, Record<string, unknown>>;
|
|
124
132
|
/** The chant version to record in the document. */
|
|
125
133
|
chant?: string;
|
|
134
|
+
/**
|
|
135
|
+
* Plan only these components (a later round or wave, ./promote-rounds.ts).
|
|
136
|
+
* The others are not planned; a dependency among them is read from
|
|
137
|
+
* `seededOutputs`, which then holds what the run left. Default: every one.
|
|
138
|
+
*/
|
|
139
|
+
only?: ReadonlySet<string>;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** A release's change set for an environment, and what each component was planned against. */
|
|
143
|
+
export interface ReleasePlan {
|
|
144
|
+
doc: ChangeSetDocument;
|
|
145
|
+
/** Per planned component, the outputs of its dependencies as its plan read them. */
|
|
146
|
+
reads: Record<string, Record<string, Record<string, unknown>>>;
|
|
147
|
+
/** Per dependency read from its plan, the names of the outputs the plan knows, sorted. Absent when none. */
|
|
148
|
+
plannedOutputs?: Record<string, string[]>;
|
|
149
|
+
/** Per component, the references to a promoted upstream that no value stands for until it applies, sorted. Absent when none. */
|
|
150
|
+
unknownInputs?: Record<string, string[]>;
|
|
126
151
|
}
|
|
127
152
|
|
|
128
153
|
const byString = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
|
|
@@ -240,65 +265,105 @@ export function chantComponentPart(input: {
|
|
|
240
265
|
* that fails to plan: it becomes a failed member.
|
|
241
266
|
*/
|
|
242
267
|
export async function planReleaseChangeSet(input: ReleaseChangeSetInput): Promise<ChangeSetDocument> {
|
|
268
|
+
return (await planReleaseSet(input)).doc;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* {@link planReleaseChangeSet}, with what each component read. Components are
|
|
273
|
+
* planned a wave at a time, so a dependency the promote deploys has planned
|
|
274
|
+
* before its dependents read its outputs.
|
|
275
|
+
*/
|
|
276
|
+
export async function planReleaseSet(input: ReleaseChangeSetInput): Promise<ReleasePlan> {
|
|
243
277
|
const { release, env, registry } = input;
|
|
244
278
|
const components = [...input.components];
|
|
245
279
|
const byName = new Map(components.map((c) => [c.name, c]));
|
|
246
280
|
const outputs: Record<string, Record<string, unknown>> = { ...(input.seededOutputs ?? {}) };
|
|
247
281
|
const read = new Set(Object.keys(outputs));
|
|
248
282
|
const parts: ChangeSetPart[] = [];
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
283
|
+
const plannedBy = new Map<string, PlannedOutputs | undefined>();
|
|
284
|
+
const planned: Record<string, string[]> = {};
|
|
285
|
+
const unknownInputs: Record<string, string[]> = {};
|
|
286
|
+
const reads: ReleasePlan["reads"] = {};
|
|
287
|
+
const inPlan = (name: string): boolean => input.only === undefined || input.only.has(name);
|
|
288
|
+
const isPlannable = (c: DriverComponent): boolean =>
|
|
289
|
+
capabilitySteps(c.deploy).some((s) => registry.has(s.kind) && registry.resolve(s.kind).plan !== undefined);
|
|
290
|
+
const { waves } = resolveComponentGraph(components.map((c) => ({ ...c, dependsOn: (c.dependsOn ?? []).filter((d) => byName.has(d)) })));
|
|
291
|
+
for (const wave of waves) {
|
|
292
|
+
const planning = wave.filter(inPlan);
|
|
293
|
+
for (const name of planning) {
|
|
294
|
+
const component = byName.get(name)!;
|
|
295
|
+
const digest = input.digests.get(name) ?? "";
|
|
296
|
+
const plannable = capabilitySteps(component.deploy).filter((s) => registry.has(s.kind) && registry.resolve(s.kind).plan !== undefined);
|
|
297
|
+
if (plannable.length === 0) {
|
|
298
|
+
parts.push(chantComponentPart({ component, release, env, digest, current: input.current.get(name), unsettled: input.unsettled?.get(name) }));
|
|
299
|
+
continue;
|
|
265
300
|
}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
301
|
+
const ctx = deployContext({ env }, name);
|
|
302
|
+
try {
|
|
303
|
+
for (const dep of component.dependsOn ?? []) {
|
|
304
|
+
if (read.has(dep) || !byName.has(dep)) continue;
|
|
305
|
+
read.add(dep);
|
|
306
|
+
// A dependency this promote plans exposes what its plan says (#3999).
|
|
307
|
+
if (plannedBy.has(dep) && isPlannable(byName.get(dep)!)) {
|
|
308
|
+
const known = plannedBy.get(dep)?.known ?? {};
|
|
309
|
+
outputs[dep] = { ...known };
|
|
310
|
+
planned[dep] = Object.keys(known).sort();
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
const found = await readComponentOutputs(byName.get(dep)!, registry, deployContext({ env }, dep), outputs);
|
|
314
|
+
if (found) outputs[dep] = found;
|
|
315
|
+
}
|
|
316
|
+
reads[name] = Object.fromEntries((component.dependsOn ?? []).filter((d) => d in outputs).map((d) => [d, structuredClone(outputs[d])]));
|
|
317
|
+
const unknown = new Set<string>();
|
|
318
|
+
let exposes: PlannedOutputs | undefined;
|
|
319
|
+
for (const step of plannable) {
|
|
320
|
+
const { kind, ...rest } = step;
|
|
321
|
+
for (const r of unresolvedWiringReferences(rest, outputs)) if (plannedBy.has(r.source) && !r.publish) unknown.add(r.ref);
|
|
322
|
+
const result = await registry.resolve(kind).plan!(planContext(ctx, rest, outputs), resolveStepInput(rest, {}, outputs) as never);
|
|
323
|
+
if (result.outputs) exposes = { known: { ...(exposes?.known ?? {}), ...result.outputs.known }, unknown: [...new Set([...(exposes?.unknown ?? []), ...result.outputs.unknown])].sort() };
|
|
324
|
+
parts.push(
|
|
325
|
+
result.changeSet ?? {
|
|
326
|
+
member: {
|
|
327
|
+
member: result.member,
|
|
328
|
+
lexicon: "chant",
|
|
329
|
+
planner: "chant",
|
|
330
|
+
status: "planned",
|
|
331
|
+
planDigest: result.planDigest,
|
|
332
|
+
holes: [{ address: result.member, reason: `its ${kind} plan describes no changes, so what it changes is unknown` }],
|
|
333
|
+
},
|
|
334
|
+
entries: [],
|
|
278
335
|
},
|
|
279
|
-
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
plannedBy.set(name, exposes);
|
|
339
|
+
if (unknown.size > 0) unknownInputs[name] = [...unknown].sort();
|
|
340
|
+
} catch (err) {
|
|
341
|
+
plannedBy.set(name, undefined);
|
|
342
|
+
parts.push({
|
|
343
|
+
member: {
|
|
344
|
+
member: name,
|
|
345
|
+
lexicon: "chant",
|
|
346
|
+
planner: "chant",
|
|
347
|
+
status: "failed",
|
|
348
|
+
error: err instanceof Error ? err.message : String(err),
|
|
349
|
+
planDigest: null,
|
|
350
|
+
holes: [],
|
|
280
351
|
},
|
|
281
|
-
|
|
352
|
+
entries: [],
|
|
353
|
+
});
|
|
282
354
|
}
|
|
283
|
-
} catch (err) {
|
|
284
|
-
parts.push({
|
|
285
|
-
member: {
|
|
286
|
-
member: name,
|
|
287
|
-
lexicon: "chant",
|
|
288
|
-
planner: "chant",
|
|
289
|
-
status: "failed",
|
|
290
|
-
error: err instanceof Error ? err.message : String(err),
|
|
291
|
-
planDigest: null,
|
|
292
|
-
holes: [],
|
|
293
|
-
},
|
|
294
|
-
entries: [],
|
|
295
|
-
});
|
|
296
355
|
}
|
|
297
356
|
}
|
|
298
|
-
|
|
357
|
+
const doc = composeChangeSet(parts, {
|
|
299
358
|
...(input.chant !== undefined ? { chant: input.chant } : {}),
|
|
300
359
|
planned: { releaseDigest: release.releaseDigest, environment: env },
|
|
301
360
|
});
|
|
361
|
+
return {
|
|
362
|
+
doc,
|
|
363
|
+
reads,
|
|
364
|
+
...(Object.keys(planned).length > 0 ? { plannedOutputs: planned } : {}),
|
|
365
|
+
...(Object.keys(unknownInputs).length > 0 ? { unknownInputs } : {}),
|
|
366
|
+
};
|
|
302
367
|
}
|
|
303
368
|
|
|
304
369
|
/** The members a release gate refuses: provisional ones, which no approval of the document covers (`change-set.mdx`). */
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, test, expect, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Side-by-side stacks keep their exports and imports (#4202). The live overlay
|
|
5
|
+
* builds the declared canvas per stack, and that canvas dropped the member's
|
|
6
|
+
* exports, so a workspace viewer had nothing to join another member's import
|
|
7
|
+
* to once an environment was picked.
|
|
8
|
+
*/
|
|
9
|
+
vi.mock("./discovery/index", () => ({ discover: vi.fn(async () => ({ entities: new Map(), errors: [] })) }));
|
|
10
|
+
vi.mock("./graph-ir", () => ({
|
|
11
|
+
buildGraphIr: vi.fn(() => ({
|
|
12
|
+
nodes: [
|
|
13
|
+
{ id: "webAlb", kind: "AWS::ElasticLoadBalancingV2::LoadBalancer", lexicon: "aws" },
|
|
14
|
+
{ id: "zoneId", kind: "AWS::CloudFormation::Parameter", lexicon: "aws" },
|
|
15
|
+
],
|
|
16
|
+
edges: [],
|
|
17
|
+
groups: {},
|
|
18
|
+
exports: [{ name: "awsEndpoint", node: "webAlb", attr: "DNSName" }],
|
|
19
|
+
imports: [{ name: "zoneId", node: "zoneId" }],
|
|
20
|
+
})),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
const { buildDeclaredPerStack } = await import("./graph-declared");
|
|
24
|
+
|
|
25
|
+
describe("buildDeclaredPerStack keeps exports and imports (#4202)", () => {
|
|
26
|
+
test("an export's node is qualified like the nodes, and its name is unchanged", async () => {
|
|
27
|
+
const ir = await buildDeclaredPerStack([{ name: "three-cloud-aws", src: "src" }], "/project");
|
|
28
|
+
expect(ir.nodes.map((n) => n.id)).toContain("three-cloud-aws::webAlb");
|
|
29
|
+
expect(ir.exports).toEqual([{ name: "awsEndpoint", node: "three-cloud-aws::webAlb", attr: "DNSName" }]);
|
|
30
|
+
expect(ir.imports).toEqual([{ name: "zoneId", node: "three-cloud-aws::zoneId" }]);
|
|
31
|
+
const ids = new Set(ir.nodes.map((n) => n.id));
|
|
32
|
+
for (const e of ir.exports ?? []) expect(ids.has(e.node!)).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("a stack without src brings no exports", async () => {
|
|
36
|
+
const ir = await buildDeclaredPerStack([{ name: "unbuilt" }], "/project");
|
|
37
|
+
expect(ir.exports).toBeUndefined();
|
|
38
|
+
expect(ir.imports).toBeUndefined();
|
|
39
|
+
});
|
|
40
|
+
});
|
package/src/graph-declared.ts
CHANGED
|
@@ -13,6 +13,10 @@ import { buildGraphIr, type GraphIR, type IRNode } from "./graph-ir";
|
|
|
13
13
|
* that live side, build each stack's `src` in isolation and qualify its node ids
|
|
14
14
|
* and edge endpoints the same way. Stacks are merged into one graph.
|
|
15
15
|
*
|
|
16
|
+
* Each stack's exports and imports come along with their `node` qualified the
|
|
17
|
+
* same way (#4202), so a workspace viewer can still match an import in another
|
|
18
|
+
* member to an export here once an environment is picked.
|
|
19
|
+
*
|
|
16
20
|
* A stack without `src` contributes nothing here — it has no declared source to
|
|
17
21
|
* scope to (its live nodes still show as foreign in the overlay).
|
|
18
22
|
*/
|
|
@@ -22,6 +26,8 @@ export async function buildDeclaredPerStack(
|
|
|
22
26
|
): Promise<GraphIR> {
|
|
23
27
|
const nodes: IRNode[] = [];
|
|
24
28
|
const edges: GraphIR["edges"] = [];
|
|
29
|
+
const exports: NonNullable<GraphIR["exports"]> = [];
|
|
30
|
+
const imports: NonNullable<GraphIR["imports"]> = [];
|
|
25
31
|
// Which nodes belong to which stack (#1433). This is not derived or guessed:
|
|
26
32
|
// the stack is declared in config and every node here is being renamed with
|
|
27
33
|
// its name on the line below. The membership was always known and thrown
|
|
@@ -38,8 +44,10 @@ export async function buildDeclaredPerStack(
|
|
|
38
44
|
(byStack[st.name] ??= []).push(id);
|
|
39
45
|
}
|
|
40
46
|
for (const e of g.edges) edges.push({ ...e, from: q(e.from), to: q(e.to) });
|
|
47
|
+
for (const e of g.exports ?? []) exports.push({ ...e, ...(e.node ? { node: q(e.node) } : {}) });
|
|
48
|
+
for (const i of g.imports ?? []) imports.push({ ...i, node: q(i.node) });
|
|
41
49
|
}
|
|
42
50
|
for (const ids of Object.values(byStack)) ids.sort();
|
|
43
51
|
const groups: GraphIR["groups"] = Object.keys(byStack).length ? { byStack } : {};
|
|
44
|
-
return { nodes, edges, groups };
|
|
52
|
+
return { nodes, edges, groups, ...(exports.length ? { exports } : {}), ...(imports.length ? { imports } : {}) };
|
|
45
53
|
}
|
|
@@ -27,7 +27,7 @@ export function scratchDir(prefix = "chant-contract-"): string {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
export function git(cwd: string, ...args: string[]): string {
|
|
30
|
-
return execFileSync("git", ["-c", "user.name=t", "-c", "user.email=t@example.com", "-c", "commit.gpgsign=false", ...args], {
|
|
30
|
+
return execFileSync("git", ["-c", "user.name=t", "-c", "user.email=t@example.com", "-c", "commit.gpgsign=false", "-c", "gc.auto=0", "-c", "maintenance.auto=false", ...args], {
|
|
31
31
|
cwd,
|
|
32
32
|
encoding: "utf-8",
|
|
33
33
|
stdio: ["ignore", "pipe", "pipe"],
|
|
@@ -67,7 +67,16 @@ export function repoFixture(build: () => string, prefix = "chant-contract-copy-"
|
|
|
67
67
|
return () => {
|
|
68
68
|
if (template === undefined || !existsSync(template)) template = build();
|
|
69
69
|
const root = scratchDir(prefix);
|
|
70
|
-
|
|
70
|
+
// A commit can leave a detached `git gc --auto` or maintenance run moving
|
|
71
|
+
// the template's objects, and a copy that meets it fails with ENOENT on
|
|
72
|
+
// .git/objects. Copy once more after it settles.
|
|
73
|
+
try {
|
|
74
|
+
cpSync(template, root, { recursive: true });
|
|
75
|
+
} catch (err) {
|
|
76
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
|
77
|
+
rmSync(root, { recursive: true, force: true });
|
|
78
|
+
cpSync(template, root, { recursive: true });
|
|
79
|
+
}
|
|
71
80
|
return root;
|
|
72
81
|
};
|
|
73
82
|
}
|
|
@@ -14,6 +14,7 @@ import { BOX_FINDING_CODES } from "./checks/boxes";
|
|
|
14
14
|
import { RECORD_FINDING_CODES } from "./checks/records";
|
|
15
15
|
import { DIAGRAM_FINDING_CODES } from "./checks/diagrams";
|
|
16
16
|
import { SECURITY_FINDING_CODES } from "./checks/security";
|
|
17
|
+
import { CI_WORKFLOW_FINDING_CODES } from "./ci-workflow";
|
|
17
18
|
import { WORKSPACE_ERROR_CODES } from "./declaration";
|
|
18
19
|
import { CHECK_CODES, CHECK_CONTRACT_VERSION, CHECK_ERROR_CODES, CHECK_OUTPUT_SCHEMA_ID, runChecks, type CheckDocument } from "./lineage-check";
|
|
19
20
|
|
|
@@ -39,8 +40,8 @@ describe("check output schema", () => {
|
|
|
39
40
|
test("lists exactly the codes the code can return", () => {
|
|
40
41
|
expect(schema.$defs.lockFinding.properties.code.enum).toEqual([...CHECK_CODES]);
|
|
41
42
|
expect(schema.$defs.failure.properties.error.properties.code.enum).toEqual([...CHECK_ERROR_CODES]);
|
|
42
|
-
expect(schema.$defs.diagnostic.properties.code.enum).toEqual([...WORKSPACE_ERROR_CODES, ...BOX_FINDING_CODES, ...RECORD_FINDING_CODES, ...DIAGRAM_FINDING_CODES, ...SECURITY_FINDING_CODES]);
|
|
43
|
-
expect(schema.$defs.suppressed.properties.code.enum).toEqual([...WORKSPACE_ERROR_CODES, ...BOX_FINDING_CODES, ...RECORD_FINDING_CODES, ...DIAGRAM_FINDING_CODES, ...SECURITY_FINDING_CODES]);
|
|
43
|
+
expect(schema.$defs.diagnostic.properties.code.enum).toEqual([...WORKSPACE_ERROR_CODES, ...BOX_FINDING_CODES, ...RECORD_FINDING_CODES, ...DIAGRAM_FINDING_CODES, ...SECURITY_FINDING_CODES, ...CI_WORKFLOW_FINDING_CODES]);
|
|
44
|
+
expect(schema.$defs.suppressed.properties.code.enum).toEqual([...WORKSPACE_ERROR_CODES, ...BOX_FINDING_CODES, ...RECORD_FINDING_CODES, ...DIAGRAM_FINDING_CODES, ...SECURITY_FINDING_CODES, ...CI_WORKFLOW_FINDING_CODES]);
|
|
44
45
|
});
|
|
45
46
|
});
|
|
46
47
|
|
|
@@ -332,7 +332,16 @@
|
|
|
332
332
|
|
|
333
333
|
"security-runner-start-no-role",
|
|
334
334
|
|
|
335
|
-
"security-trust-unreadable"
|
|
335
|
+
"security-trust-unreadable",
|
|
336
|
+
"ci-workflow-green-undeclared",
|
|
337
|
+
"ci-workflow-no-pre-merge",
|
|
338
|
+
"ci-workflow-apply-in-green",
|
|
339
|
+
"ci-workflow-no-components",
|
|
340
|
+
"ci-workflow-no-environments",
|
|
341
|
+
"ci-workflow-ungated-env",
|
|
342
|
+
"ci-workflow-pipeline-missing",
|
|
343
|
+
"ci-workflow-pipeline-differs",
|
|
344
|
+
"ci-workflow-publish-missing"
|
|
336
345
|
],
|
|
337
346
|
"description": "For WSP001: why the declaration can't be read. For WSP121 to WSP127, the box finding (#2726, #2727, #2780, #2850). For WSP117, work-acceptance-unmet (#2772). For WSP118, flag-stale (#4024). For WSP131 to WSP133, the diagram finding (#2764)."
|
|
338
347
|
}
|
|
@@ -445,7 +454,16 @@
|
|
|
445
454
|
|
|
446
455
|
"security-runner-start-no-role",
|
|
447
456
|
|
|
448
|
-
"security-trust-unreadable"
|
|
457
|
+
"security-trust-unreadable",
|
|
458
|
+
"ci-workflow-green-undeclared",
|
|
459
|
+
"ci-workflow-no-pre-merge",
|
|
460
|
+
"ci-workflow-apply-in-green",
|
|
461
|
+
"ci-workflow-no-components",
|
|
462
|
+
"ci-workflow-no-environments",
|
|
463
|
+
"ci-workflow-ungated-env",
|
|
464
|
+
"ci-workflow-pipeline-missing",
|
|
465
|
+
"ci-workflow-pipeline-differs",
|
|
466
|
+
"ci-workflow-publish-missing"
|
|
449
467
|
],
|
|
450
468
|
"description": "For WSP001: why the declaration can't be read. For WSP121 to WSP127, the box finding (#2726, #2727, #2780, #2850). For WSP117, work-acceptance-unmet (#2772). For WSP118, flag-stale (#4024). For WSP131 to WSP133, the diagram finding (#2764)."
|
|
451
469
|
},
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WSP151, what a member under ci.workflow still lacks (#4168 step 6), and the
|
|
3
|
+
* reference page printed from CI_WORKFLOW_REQUIREMENTS.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { afterAll, describe, expect, test } from "vitest";
|
|
12
|
+
import { runDeclarationChecks } from "../checks";
|
|
13
|
+
import { ciWorkflowTable, CI_WORKFLOW_FINDING_CODES, workflowOf } from "../ci-workflow";
|
|
14
|
+
import { REASON_CODES } from "../reason-codes";
|
|
15
|
+
|
|
16
|
+
const DOCS = fileURLToPath(new URL("../../../../../docs/src/content/docs/", import.meta.url));
|
|
17
|
+
|
|
18
|
+
const scratch: string[] = [];
|
|
19
|
+
afterAll(() => {
|
|
20
|
+
for (const d of scratch) rmSync(d, { recursive: true, force: true });
|
|
21
|
+
});
|
|
22
|
+
function repo(files: Record<string, string>): string {
|
|
23
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), "chant-ci-workflow-")));
|
|
24
|
+
scratch.push(root);
|
|
25
|
+
execFileSync("git", ["init", "-q"], { cwd: root });
|
|
26
|
+
for (const [path, text] of Object.entries(files)) {
|
|
27
|
+
mkdirSync(dirname(join(root, path)), { recursive: true });
|
|
28
|
+
writeFileSync(join(root, path), text);
|
|
29
|
+
}
|
|
30
|
+
return root;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const member = (extra: Record<string, unknown> = {}) => ({ name: "app", dir: "app", kind: "other", because: "the app", ...extra });
|
|
34
|
+
const declaration = (extra: Record<string, unknown> = {}, members = [member()]) => JSON.stringify({ name: "acme", schema: 1, members, ...extra }, null, 2);
|
|
35
|
+
const green = (extra: Record<string, unknown> = {}) => ({ branch: "main", phases: { check: ["check"], suites: ["suites"] }, require: ["check", "suites"], preMerge: ["check"], ...extra });
|
|
36
|
+
|
|
37
|
+
const CONFIG = 'export default { lexicons: ["aws"], environments: ["staging", "prod"] };\n';
|
|
38
|
+
const COMPONENT = 'import { gate, phase } from "@intentius/chant/components/component";\nexport const app = { name: "app", deploy: [phase("Apply", [gate("prod")])] };\n';
|
|
39
|
+
const PIPELINE = [
|
|
40
|
+
"on: [pull_request, push]",
|
|
41
|
+
"jobs:",
|
|
42
|
+
" plan:",
|
|
43
|
+
" steps:",
|
|
44
|
+
" - run: chant ci run plan --in-place --no-push --env prod --gate pr-apply --forge github",
|
|
45
|
+
" record:",
|
|
46
|
+
" steps:",
|
|
47
|
+
" - run: chant ci run record --in-place --env prod --gate pr-apply --forge github",
|
|
48
|
+
"",
|
|
49
|
+
].join("\n");
|
|
50
|
+
const TICK = "jobs:\n tick:\n steps:\n - run: npx --yes @intentius/chant@0.124.33 ci tick\n";
|
|
51
|
+
const PUBLISH = "jobs:\n publish:\n steps:\n - run: npx chant release publish \"$LABEL\"\n";
|
|
52
|
+
|
|
53
|
+
/** A member under trunk-curated with everything the workflow needs. */
|
|
54
|
+
const complete = (extra: Record<string, string> = {}) => ({
|
|
55
|
+
"chant.workspace.json": declaration({ ci: { workflow: "trunk-curated", green: green() } }),
|
|
56
|
+
"app/chant.config.ts": CONFIG,
|
|
57
|
+
"app/app.component.ts": COMPONENT,
|
|
58
|
+
".github/workflows/chant-pr.yml": PIPELINE,
|
|
59
|
+
".github/workflows/chant-ci-green.yml": TICK,
|
|
60
|
+
".github/workflows/chant-release.yml": PUBLISH,
|
|
61
|
+
...extra,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/** The WSP151 findings as [code, entity]. */
|
|
65
|
+
const found = async (root: string) =>
|
|
66
|
+
(await runDeclarationChecks(root, (f) => f, { gather: false })).diagnostics.filter((d) => d.ruleId === "WSP151").map((d) => [d.code, d.entity ?? null]);
|
|
67
|
+
|
|
68
|
+
describe("ci.workflow (WSP151)", () => {
|
|
69
|
+
test("a declaration that names no workflow gets no finding", async () => {
|
|
70
|
+
const root = repo({ "chant.workspace.json": declaration(), "app/chant.config.ts": "export default {};\n" });
|
|
71
|
+
expect(await found(root)).toEqual([]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("a workspace with everything trunk-curated needs passes", async () => {
|
|
75
|
+
expect(await found(repo(complete()))).toEqual([]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("an empty workspace under trunk-curated reports each missing piece", async () => {
|
|
79
|
+
const root = repo({ "chant.workspace.json": declaration({ ci: { workflow: "trunk-curated" } }), "app/chant.config.ts": "export default {};\n" });
|
|
80
|
+
expect(await found(root)).toEqual([
|
|
81
|
+
["ci-workflow-no-environments", "app"],
|
|
82
|
+
["ci-workflow-no-components", "app"],
|
|
83
|
+
["ci-workflow-green-undeclared", null],
|
|
84
|
+
["ci-workflow-pipeline-missing", null],
|
|
85
|
+
["ci-workflow-publish-missing", null],
|
|
86
|
+
]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("no preMerge phases, and a required phase that matches the apply job", async () => {
|
|
90
|
+
const root = repo(complete({ "chant.workspace.json": declaration({ ci: { workflow: "trunk-curated", green: green({ phases: { check: ["check"], deploy: ["*apply"] }, require: ["check", "deploy"], preMerge: [] }) } }) }));
|
|
91
|
+
expect(await found(root)).toEqual([
|
|
92
|
+
["ci-workflow-no-pre-merge", null],
|
|
93
|
+
["ci-workflow-apply-in-green", null],
|
|
94
|
+
]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("a component with no gate", async () => {
|
|
98
|
+
const root = repo(complete({ "app/app.component.ts": 'export const app = { name: "app", deploy: [] };\n' }));
|
|
99
|
+
expect(await found(root)).toEqual([["ci-workflow-ungated-env", "app"]]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("trunk-curated with an apply job on merge differs", async () => {
|
|
103
|
+
const root = repo(complete({ ".github/workflows/chant-pr.yml": PIPELINE.replace("ci run record", "ci run apply") }));
|
|
104
|
+
expect(await found(root)).toEqual([["ci-workflow-pipeline-differs", null]]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("trunk-curated+apply-on-merge with only a record job differs, and with an apply job passes", async () => {
|
|
108
|
+
const decl = declaration({ ci: { workflow: "trunk-curated+apply-on-merge", green: green() } });
|
|
109
|
+
expect(await found(repo(complete({ "chant.workspace.json": decl })))).toEqual([["ci-workflow-pipeline-differs", null]]);
|
|
110
|
+
expect(await found(repo(complete({ "chant.workspace.json": decl, ".github/workflows/chant-pr.yml": PIPELINE.replace("ci run record", "ci run apply") })))).toEqual([]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("a member's own workflow wins, and a top-level workflow skips members that don't build with chant", async () => {
|
|
114
|
+
const root = repo({
|
|
115
|
+
...complete(),
|
|
116
|
+
"chant.workspace.json": declaration({ ci: { green: green() } }, [member({ ci: { workflow: "trunk-curated" } }), member({ name: "docs", dir: "docs" })]),
|
|
117
|
+
"docs/README.md": "",
|
|
118
|
+
});
|
|
119
|
+
expect(await found(root)).toEqual([]);
|
|
120
|
+
const top = repo({ ...complete(), "docs/README.md": "", "chant.workspace.json": declaration({ ci: { workflow: "trunk-curated", green: green() } }, [member(), member({ name: "docs", dir: "docs" })]) });
|
|
121
|
+
expect(await found(top)).toEqual([]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("ci.green.preMerge must name declared phases", async () => {
|
|
125
|
+
const root = repo({ "chant.workspace.json": declaration({ ci: { green: green({ preMerge: ["lint"] }) } }) });
|
|
126
|
+
const report = await runDeclarationChecks(root, (f) => f, { gather: false });
|
|
127
|
+
expect(report.diagnostics.find((d) => d.ruleId === "WSP001")?.message).toMatch(/ci\.green\.preMerge names the phase "lint"/);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("workflowOf", () => {
|
|
132
|
+
test("the member's own wins over the top level's", () => {
|
|
133
|
+
const d = { ci: { green: null, workflow: "trunk-curated" as const, reportUrl: null } };
|
|
134
|
+
expect(workflowOf(d, { ciWorkflow: null })).toBe("trunk-curated");
|
|
135
|
+
expect(workflowOf(d, { ciWorkflow: "trunk-curated+apply-on-merge" })).toBe("trunk-curated+apply-on-merge");
|
|
136
|
+
expect(workflowOf({ ci: null }, null)).toBeNull();
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("the ci.workflow codes and reference page", () => {
|
|
141
|
+
test("every code is a read-contract reason code", () => {
|
|
142
|
+
for (const code of CI_WORKFLOW_FINDING_CODES) expect(REASON_CODES, code).toContain(code);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("the CI workflows page carries the table CI_WORKFLOW_REQUIREMENTS prints", () => {
|
|
146
|
+
const page = readFileSync(join(DOCS, "reference", "ci-workflows.mdx"), "utf-8");
|
|
147
|
+
const table = /^\| Code \| Requirement \|[^\n]*\n(?:\|[^\n]*\n)*/m.exec(page)?.[0].trimEnd();
|
|
148
|
+
expect(table, "regenerate the table under Requirements from ciWorkflowTable()").toBe(ciWorkflowTable());
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WSP151 (#4168 step 6): what a member under `ci.workflow` still lacks.
|
|
3
|
+
*
|
|
4
|
+
* A declaration that names no workflow gets no finding from this file. The
|
|
5
|
+
* requirements and their codes are {@link CI_WORKFLOW_REQUIREMENTS}
|
|
6
|
+
* (../ci-workflow.ts). Like the security checklists (./security.ts), the
|
|
7
|
+
* check reads files and never runs them: `chant.config.ts` for the
|
|
8
|
+
* environments (its literal `environments`, as `workspace status` reads it),
|
|
9
|
+
* `*.component.ts` for components and gates, and the forge pipeline files for
|
|
10
|
+
* the jobs. A generated file that differs from what its generator writes is
|
|
11
|
+
* WSP101 to WSP106's to report; this check asks only whether the jobs the
|
|
12
|
+
* workflow needs are there.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { WorkspaceCheck, WorkspaceDiagnostic } from "../checks";
|
|
16
|
+
import type { CiWorkflow, Member } from "../declaration";
|
|
17
|
+
import { appliesOnMerge, STUB_MARKER, workflowOf, type CiWorkflowCode } from "../ci-workflow";
|
|
18
|
+
import { matchesCheckRun } from "../ci-green";
|
|
19
|
+
import { staticPromotionOrder } from "../promotion-order";
|
|
20
|
+
import { joinPath } from "../tree";
|
|
21
|
+
import { allPipelineFiles, commandLines, componentFiles, read } from "./security";
|
|
22
|
+
|
|
23
|
+
export const WSP_CI_WORKFLOW = "WSP151";
|
|
24
|
+
|
|
25
|
+
const memberDir = (dir: string) => (dir === "." ? "" : dir);
|
|
26
|
+
|
|
27
|
+
const PLAN = /\bci\s+run\s+plan\b|\bcomponents\s+pr-plan\b/;
|
|
28
|
+
const RECORD = /\bci\s+run\s+record\b|\bcomponents\s+pr-record\b/;
|
|
29
|
+
const APPLY = /\bci\s+run\s+apply\b|\bcomponents\s+pr-apply\b/;
|
|
30
|
+
const TICK = /\bci\s+tick\b/;
|
|
31
|
+
const PUBLISH = /\brelease\s+publish\b/;
|
|
32
|
+
|
|
33
|
+
/** The apply job names a required phase could match. */
|
|
34
|
+
const APPLY_JOB_NAMES = ["apply", "pr-apply"];
|
|
35
|
+
|
|
36
|
+
export const CI_WORKFLOW_CHECKS: readonly WorkspaceCheck[] = [
|
|
37
|
+
{
|
|
38
|
+
id: WSP_CI_WORKFLOW,
|
|
39
|
+
name: "ci-workflow",
|
|
40
|
+
description: "Under ci.workflow: ci.green with pre-merge phases and no apply job required, each member with a component, environments and a gate on each component, and pipeline files that plan, record (or apply under apply on merge), tick and publish.",
|
|
41
|
+
severity: "warning",
|
|
42
|
+
configurable: true,
|
|
43
|
+
check(ctx) {
|
|
44
|
+
const d = ctx.declaration;
|
|
45
|
+
const under = d.members
|
|
46
|
+
.map((m) => ({ member: m, workflow: workflowOf(d, m) }))
|
|
47
|
+
.filter((u): u is { member: Member; workflow: CiWorkflow } => u.workflow !== null)
|
|
48
|
+
// The top level's workflow covers the members that build with chant; a member that names one itself is held to it.
|
|
49
|
+
.filter((u) => u.member.ciWorkflow !== null || ctx.tree.stat(joinPath(memberDir(u.member.dir), "chant.config.ts")) === "file");
|
|
50
|
+
if (under.length === 0 && !d.ci?.workflow) return [];
|
|
51
|
+
const pointer = d.ci?.workflow ? "/ci/workflow" : `${under[0]!.member.pointer}/ci/workflow`;
|
|
52
|
+
const out: WorkspaceDiagnostic[] = [];
|
|
53
|
+
const finding = (code: CiWorkflowCode, message: string, at: { pointer?: string; entity?: string; treeFile?: string; line?: number } = {}): void => {
|
|
54
|
+
out.push({
|
|
55
|
+
checkId: this.id,
|
|
56
|
+
severity: this.severity,
|
|
57
|
+
code,
|
|
58
|
+
message: `${code}: ${message}`,
|
|
59
|
+
pointer: at.pointer ?? pointer,
|
|
60
|
+
...(at.entity !== undefined ? { entity: at.entity } : {}),
|
|
61
|
+
...(at.treeFile !== undefined ? { treeFile: at.treeFile, line: at.line ?? 1, column: 1 } : {}),
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const green = d.ci?.green ?? null;
|
|
66
|
+
if (!green) {
|
|
67
|
+
finding("ci-workflow-green-undeclared", "the workspace follows a ci.workflow and declares no ci.green, so no commit gets a verdict and no release can be cut; run chant ci init or add a ci.green block");
|
|
68
|
+
} else {
|
|
69
|
+
if ((green.preMerge ?? []).length === 0) {
|
|
70
|
+
finding("ci-workflow-no-pre-merge", "ci.green.preMerge names no phase, so nothing says which checks run before the branch moves; name the quick ones", { pointer: green.pointer });
|
|
71
|
+
}
|
|
72
|
+
for (const name of green.require) {
|
|
73
|
+
const phase = green.phases.find((p) => p.name === name);
|
|
74
|
+
const hit = phase?.runs.find((pattern) => APPLY_JOB_NAMES.some((job) => matchesCheckRun(pattern, job)));
|
|
75
|
+
if (phase && hit) {
|
|
76
|
+
finding("ci-workflow-apply-in-green", `the required phase ${name} matches the apply job with ${JSON.stringify(hit)}, so an apply that waits for a green commit would wait on itself; drop the pattern`, { pointer: phase.pointer });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
for (const { member } of under) {
|
|
82
|
+
const dir = memberDir(member.dir);
|
|
83
|
+
const components = componentFiles(ctx.tree, dir);
|
|
84
|
+
if (components.length === 0) {
|
|
85
|
+
finding("ci-workflow-no-components", `member ${member.name} declares no component, so nothing of it can be cut or promoted; chant ci init writes a stub`, { pointer: member.pointer, entity: member.name });
|
|
86
|
+
} else if (components.every((f) => (read(ctx.tree, f) ?? "").includes(STUB_MARKER))) {
|
|
87
|
+
finding("ci-workflow-no-components", `member ${member.name}'s only component is the stub chant ci init wrote (${components[0]}); say how it is built and deployed, then delete its ${STUB_MARKER} line`, { pointer: member.pointer, entity: member.name, treeFile: components[0] });
|
|
88
|
+
}
|
|
89
|
+
const configPath = joinPath(dir, "chant.config.ts");
|
|
90
|
+
const config = read(ctx.tree, configPath);
|
|
91
|
+
// A config whose environments aren't literals can't be read without running it; say nothing rather than guess.
|
|
92
|
+
const order = config === null ? [] : staticPromotionOrder(config);
|
|
93
|
+
if (order !== undefined && order.length === 0) {
|
|
94
|
+
finding("ci-workflow-no-environments", `member ${member.name}'s chant.config.ts declares no environments, so a release has nowhere to be promoted to`, { pointer: member.pointer, entity: member.name, ...(config !== null ? { treeFile: configPath } : {}) });
|
|
95
|
+
}
|
|
96
|
+
for (const file of components) {
|
|
97
|
+
const text = read(ctx.tree, file) ?? "";
|
|
98
|
+
if (/\bgate\s*\(|kind\s*:\s*["']gate["']/.test(text)) continue;
|
|
99
|
+
const last = order && order.length > 0 ? order[order.length - 1]!.name : "the last environment";
|
|
100
|
+
finding("ci-workflow-ungated-env", `${file} has no gate, so a promote to ${last} waits on no approval; add a gate() step`, { pointer: member.pointer, entity: member.name, treeFile: file });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const files = allPipelineFiles(ctx);
|
|
105
|
+
const has = (re: RegExp) => files.some((f) => f.lines.some((l) => re.test(l)));
|
|
106
|
+
const applyOnMerge = under.some((u) => appliesOnMerge(u.workflow)) || (d.ci?.workflow ? appliesOnMerge(d.ci.workflow) : false);
|
|
107
|
+
const missing = [
|
|
108
|
+
...(has(PLAN) ? [] : ["a plan job (ci run plan)"]),
|
|
109
|
+
...(has(RECORD) || has(APPLY) ? [] : [applyOnMerge ? "an apply job (ci run apply)" : "a record job (ci run record)"]),
|
|
110
|
+
...(has(TICK) ? [] : ["chant ci tick"]),
|
|
111
|
+
];
|
|
112
|
+
if (missing.length > 0) {
|
|
113
|
+
finding("ci-workflow-pipeline-missing", `no pipeline file runs ${missing.join(", ")}; generate them with chant build --components --generate <forge> --pr-loop and chant ci workflow`);
|
|
114
|
+
}
|
|
115
|
+
if (!applyOnMerge) {
|
|
116
|
+
for (const l of commandLines(ctx, APPLY)) {
|
|
117
|
+
finding("ci-workflow-pipeline-differs", `${l.path} applies on merge, and the workflow is trunk-curated, where a merge records the plan and deploys nothing; regenerate without --apply-on-merge, or set ci.workflow to trunk-curated+apply-on-merge`, { treeFile: l.path, line: l.line });
|
|
118
|
+
}
|
|
119
|
+
} else if (!has(APPLY) && has(RECORD)) {
|
|
120
|
+
const l = commandLines(ctx, RECORD)[0]!;
|
|
121
|
+
finding("ci-workflow-pipeline-differs", `${l.path} records on merge, and the workflow is trunk-curated+apply-on-merge; regenerate with --apply-on-merge`, { treeFile: l.path, line: l.line });
|
|
122
|
+
}
|
|
123
|
+
if (!has(PUBLISH)) {
|
|
124
|
+
finding("ci-workflow-publish-missing", "no pipeline file runs chant release publish, so a cut release is never published; generate one with chant release workflow");
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
];
|