@intentius/chant 0.117.0 → 0.118.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/build.d.ts +1 -1
- package/dist/build.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/handlers/run-wave.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +2 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/discovery/files.d.ts +8 -0
- package/dist/discovery/files.d.ts.map +1 -1
- package/dist/lexicon.d.ts +10 -1
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +11 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/op/activities/lifecycle.d.ts +6 -1
- package/dist/op/activities/lifecycle.d.ts.map +1 -1
- package/dist/op/gate-review.d.ts +113 -0
- package/dist/op/gate-review.d.ts.map +1 -0
- package/dist/op/op-waves-run.d.ts +47 -1
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves.d.ts +20 -0
- package/dist/op/op-waves.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/build.ts +13 -2
- package/src/cli/commands/build.ts +2 -1
- package/src/cli/handlers/run-wave.ts +13 -1
- package/src/cli/main.ts +7 -1
- package/src/cli/plugins.ts +1 -1
- package/src/cli/registry.ts +2 -0
- package/src/discovery/files.ts +18 -0
- package/src/lexicon.ts +10 -1
- package/src/lifecycle/gate-ledger.ts +7 -0
- package/src/op/activities/lifecycle-gate.test.ts +147 -0
- package/src/op/activities/lifecycle.ts +15 -6
- package/src/op/gate-review.test.ts +225 -0
- package/src/op/gate-review.ts +339 -0
- package/src/op/op-waves-run.ts +190 -12
- package/src/op/op-waves.ts +25 -0
package/dist/op/op-waves.d.ts
CHANGED
|
@@ -27,6 +27,12 @@
|
|
|
27
27
|
* the same digest a component fan-out's wave binds). Whether it waits at all
|
|
28
28
|
* is the wave's gate policy, read from the base commit's copy of the spec
|
|
29
29
|
* file, so a change cannot loosen the rule that gates its own merge.
|
|
30
|
+
*
|
|
31
|
+
* So is which approvals count (#3684), the wave's `approval`: `ledger` (any
|
|
32
|
+
* `chant approve` of the digest), `sealed` (only one whose seal verifies
|
|
33
|
+
* against the signers file at base) or `pr-review` (also the merged pull
|
|
34
|
+
* request's review of its head, by a writer other than its author, when the
|
|
35
|
+
* head planned the same digest). See `./gate-review.ts`.
|
|
30
36
|
*/
|
|
31
37
|
import type { OpEnvironment, OpSetupStep } from "../lexicon.js";
|
|
32
38
|
/** When a wave waits for an approval. */
|
|
@@ -38,6 +44,15 @@ export type OpWaveGatePolicy =
|
|
|
38
44
|
/** Never: only review and branch protection stand in front of the wave. */
|
|
39
45
|
| "never";
|
|
40
46
|
export declare const OP_WAVE_GATE_POLICIES: readonly OpWaveGatePolicy[];
|
|
47
|
+
/** Which approvals of a gate's digest count (#3684). See `./gate-review.ts`. */
|
|
48
|
+
export type GateApprovalSource =
|
|
49
|
+
/** Any resolution on `chant/lifecycle` that names the digest. */
|
|
50
|
+
"ledger"
|
|
51
|
+
/** As `ledger`, and the merged pull request's review of its head, by a writer other than its author. */
|
|
52
|
+
| "pr-review"
|
|
53
|
+
/** Only a resolution sealed by a key the signers file at base lists for its approver. */
|
|
54
|
+
| "sealed";
|
|
55
|
+
export declare const GATE_APPROVAL_SOURCES: readonly GateApprovalSource[];
|
|
41
56
|
/** One Op run in a wave: an Op applied to one target (an environment, a database, a tenant). */
|
|
42
57
|
export interface OpWaveRun {
|
|
43
58
|
/** What the run applies to. Unique within its wave; it names the run in the set digest. */
|
|
@@ -55,6 +70,11 @@ export interface OpWave {
|
|
|
55
70
|
* spec file at the base commit, never from the change being applied.
|
|
56
71
|
*/
|
|
57
72
|
gate?: OpWaveGatePolicy;
|
|
73
|
+
/**
|
|
74
|
+
* Which approvals of the wave's digest count (#3684). Default `"ledger"`.
|
|
75
|
+
* Like `gate`, the runner reads it from the spec file at the base commit.
|
|
76
|
+
*/
|
|
77
|
+
approval?: GateApprovalSource;
|
|
58
78
|
/**
|
|
59
79
|
* Split the wave into a deciding job and this many share jobs, each
|
|
60
80
|
* applying a slice of the runs. Default 1: one job plans, decides and
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"op-waves.d.ts","sourceRoot":"","sources":["../../src/op/op-waves.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"op-waves.d.ts","sourceRoot":"","sources":["../../src/op/op-waves.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE7D,yCAAyC;AACzC,MAAM,MAAM,gBAAgB;AAC1B,2CAA2C;AACzC,QAAQ;AACV,gDAAgD;GAC9C,gBAAgB;AAClB,2EAA2E;GACzE,OAAO,CAAC;AAEZ,eAAO,MAAM,qBAAqB,EAAE,SAAS,gBAAgB,EAA0C,CAAC;AAExG,gFAAgF;AAChF,MAAM,MAAM,kBAAkB;AAC5B,iEAAiE;AAC/D,QAAQ;AACV,wGAAwG;GACtG,WAAW;AACb,yFAAyF;GACvF,QAAQ,CAAC;AAEb,eAAO,MAAM,qBAAqB,EAAE,SAAS,kBAAkB,EAAsC,CAAC;AAEtG,gGAAgG;AAChG,MAAM,WAAW,SAAS;IACxB,2FAA2F;IAC3F,MAAM,EAAE,MAAM,CAAC;IACf,+CAA+C;IAC/C,EAAE,CAAC,EAAE,MAAM,CAAC;CACb;AAED,2DAA2D;AAC3D,MAAM,WAAW,MAAM;IACrB,wFAAwF;IACxF,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,SAAS,EAAE,CAAC;IAClB;;;OAGG;IACH,IAAI,CAAC,EAAE,gBAAgB,CAAC;IACxB;;;OAGG;IACH,QAAQ,CAAC,EAAE,kBAAkB,CAAC;IAC9B;;;;OAIG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,qGAAqG;IACrG,WAAW,CAAC,EAAE,aAAa,CAAC;IAC5B,4CAA4C;IAC5C,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACnC,iFAAiF;IACjF,KAAK,CAAC,EAAE,WAAW,EAAE,CAAC;CACvB;AAED;;;;GAIG;AACH,MAAM,WAAW,WAAW;IAC1B,kHAAkH;IAClH,IAAI,EAAE,MAAM,CAAC;IACb,4CAA4C;IAC5C,EAAE,EAAE,MAAM,CAAC;IACX,2CAA2C;IAC3C,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,+DAA+D;IAC/D,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB;;;OAGG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;;OAKG;IACH,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,wFAAwF;IACxF,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;IACjB,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB;;;;;;OAMG;IACH,MAAM,CAAC,EAAE,aAAa,CAAC;CACxB;AAED,gEAAgE;AAChE,MAAM,WAAW,aAAa;IAC5B;;;;OAIG;IACH,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,qCAAqC;AACrC,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,IAAI,CAAC,WAAW,EAAE,MAAM,CAAC,GAAG,MAAM,EAAE,CAE9E;AAED,iCAAiC;AACjC,eAAO,MAAM,qBAAqB,UAAgD,CAAC;AAEnF,uGAAuG;AACvG,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,IAAI,CAAC,WAAW,EAAE,MAAM,CAAC,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAEtF;AAED,yBAAyB;AACzB,MAAM,WAAW,SAAS;IACxB,OAAO,EAAE,MAAM,CAAC;IAChB,2BAA2B;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,uBAAuB;IACvB,QAAQ,EAAE,MAAM,CAAC;IACjB,2FAA2F;IAC3F,IAAI,EAAE,MAAM,GAAG,QAAQ,GAAG,OAAO,CAAC;IAClC,+BAA+B;IAC/B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,8BAA8B;IAC9B,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,kCAAkC;IAClC,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,6FAA6F;IAC7F,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAID,4DAA4D;AAC5D,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,WAAW,GAAG,IAAI,CA0CzD;AAED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,CAAC,SAAS;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,EAAE,IAAI,EAAE,SAAS,CAAC,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,CAAC,EAAE,CAOhH;AAED;;;GAGG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,GAAG,SAAS,EAAE,CA0B3E;AAED,iCAAiC;AACjC,wBAAgB,QAAQ,CAAC,IAAI,EAAE,WAAW,EAAE,GAAG,EAAE,IAAI,CAAC,SAAS,EAAE,MAAM,CAAC,GAAG,MAAM,CAEhF"}
|
package/package.json
CHANGED
package/src/build.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { discover, type DiscoveryResult, type FoldDecision } from "./discovery/i
|
|
|
19
19
|
import { decodeEntitySet, type DiscoveredEntitiesJson } from "./discovery/entity-wire";
|
|
20
20
|
import { buildDependencyGraph } from "./discovery/graph";
|
|
21
21
|
import { topologicalSort } from "./sort";
|
|
22
|
+
import { statSync } from "node:fs";
|
|
22
23
|
import { resolve } from "node:path";
|
|
23
24
|
|
|
24
25
|
/**
|
|
@@ -641,6 +642,15 @@ function generateManifest(
|
|
|
641
642
|
};
|
|
642
643
|
}
|
|
643
644
|
|
|
645
|
+
/** The directory a build discovered from, for build-root contributors; undefined when the build's path is not one (an entity-set JSON's label). */
|
|
646
|
+
function sourceDirOf(path: string): string | undefined {
|
|
647
|
+
try {
|
|
648
|
+
return statSync(path).isDirectory() ? resolve(path) : undefined;
|
|
649
|
+
} catch {
|
|
650
|
+
return undefined;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
644
654
|
/** What merging build-root contributions produced: non-fatal render notes and
|
|
645
655
|
* fatal messages (a failed contributor, a name collision). */
|
|
646
656
|
export interface BuildRootMergeResult {
|
|
@@ -667,6 +677,7 @@ export interface BuildRootMergeResult {
|
|
|
667
677
|
export async function mergeBuildRootEntities(
|
|
668
678
|
entities: Map<string, Declarable>,
|
|
669
679
|
contributors: ReadonlyArray<BuildRootContributor>,
|
|
680
|
+
sourceDir?: string,
|
|
670
681
|
): Promise<BuildRootMergeResult> {
|
|
671
682
|
const warnings: string[] = [];
|
|
672
683
|
const errors: string[] = [];
|
|
@@ -677,7 +688,7 @@ export async function mergeBuildRootEntities(
|
|
|
677
688
|
// ciphertext file to resolve). Contributors run in order, so this also
|
|
678
689
|
// carries what earlier contributors added; the merge below is still the
|
|
679
690
|
// only writer, which is what keeps the collision refusal meaningful.
|
|
680
|
-
const contribution = await contribute({ entities });
|
|
691
|
+
const contribution = await contribute({ entities, ...(sourceDir !== undefined ? { sourceDir } : {}) });
|
|
681
692
|
warnings.push(...(contribution.warnings ?? []));
|
|
682
693
|
for (const [name, entity] of contribution.entities) {
|
|
683
694
|
if (entities.has(name)) {
|
|
@@ -809,7 +820,7 @@ async function buildFromDiscoveryResult(
|
|
|
809
820
|
// object, and re-running the contributors there would duplicate every
|
|
810
821
|
// contributed entity once per child.
|
|
811
822
|
if (!parentBuildStack && options?.buildRoots) {
|
|
812
|
-
const merged = await mergeBuildRootEntities(discoveryResult.entities, options.buildRoots);
|
|
823
|
+
const merged = await mergeBuildRootEntities(discoveryResult.entities, options.buildRoots, sourceDirOf(resolvedPathForChildStack));
|
|
813
824
|
warnings.push(...merged.warnings);
|
|
814
825
|
for (const message of merged.errors) {
|
|
815
826
|
errors.push(new BuildErrorClass("", message));
|
|
@@ -922,7 +922,8 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
|
|
|
922
922
|
const resourceCount = result.entities.size;
|
|
923
923
|
const fileCount = result.sourceFileCount;
|
|
924
924
|
|
|
925
|
-
|
|
925
|
+
// A build root (a lexicon's own source files, a rendered overlay) can declare everything without one .ts file.
|
|
926
|
+
if (fileCount === 0 && resourceCount === 0 && errors.length === 0) {
|
|
926
927
|
console.error(formatInfo("No source files found — create .ts files in the target directory"));
|
|
927
928
|
}
|
|
928
929
|
|
|
@@ -5,13 +5,25 @@
|
|
|
5
5
|
|
|
6
6
|
import { readFileSync } from "node:fs";
|
|
7
7
|
import { relative, resolve } from "node:path";
|
|
8
|
-
import { parseOpWavesSpec, runOpWave } from "../../op/op-waves-run";
|
|
8
|
+
import { parseOpWavesSpec, recordOpWaveHeadPlans, runOpWave } from "../../op/op-waves-run";
|
|
9
9
|
import { formatError } from "../format";
|
|
10
10
|
import type { CommandContext } from "../registry";
|
|
11
11
|
|
|
12
12
|
export async function runOpWaveCommand(ctx: CommandContext): Promise<number> {
|
|
13
13
|
const { args } = ctx;
|
|
14
14
|
const specFile = args.opsSpec;
|
|
15
|
+
if (specFile && args.recordPlans) {
|
|
16
|
+
try {
|
|
17
|
+
const spec = parseOpWavesSpec(readFileSync(resolve(specFile), "utf-8"), `--spec ${specFile}`);
|
|
18
|
+
const plans = await recordOpWaveHeadPlans({ spec });
|
|
19
|
+
if (args.json) console.log(JSON.stringify(plans, null, 2));
|
|
20
|
+
else for (const w of plans.waves) console.error(`wave ${w.wave} (${w.name}) at ${plans.head.slice(0, 12)}: ${w.digest}`);
|
|
21
|
+
return 0;
|
|
22
|
+
} catch (err) {
|
|
23
|
+
console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
|
|
24
|
+
return 1;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
15
27
|
if (!specFile || args.wave === undefined) {
|
|
16
28
|
console.error(formatError({
|
|
17
29
|
message: "`chant run wave` needs --spec <file> and --wave <k>",
|
package/src/cli/main.ts
CHANGED
|
@@ -108,6 +108,7 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
108
108
|
"--root-only",
|
|
109
109
|
"--generated",
|
|
110
110
|
"--decide",
|
|
111
|
+
"--record-plans",
|
|
111
112
|
]);
|
|
112
113
|
|
|
113
114
|
/**
|
|
@@ -359,6 +360,8 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
359
360
|
result.wave = wave;
|
|
360
361
|
} else if (arg === "--decide") {
|
|
361
362
|
result.decide = true;
|
|
363
|
+
} else if (arg === "--record-plans") {
|
|
364
|
+
result.recordPlans = true;
|
|
362
365
|
} else if (arg === "--share") {
|
|
363
366
|
const raw = args[++i];
|
|
364
367
|
const share = Number(raw);
|
|
@@ -925,7 +928,10 @@ Ops:
|
|
|
925
928
|
run wave --spec <file> --wave <k> Plan, gate and apply wave k of an Op
|
|
926
929
|
waves spec (#3679): the job a generated waves
|
|
927
930
|
pipeline runs. --decide plans and decides only;
|
|
928
|
-
--share <i> applies one share of a decided wave
|
|
931
|
+
--share <i> applies one share of a decided wave.
|
|
932
|
+
--record-plans (no --wave), in a pull request's
|
|
933
|
+
pipeline: plan every wave at its head and record the
|
|
934
|
+
digests a pr-review approval mode compares
|
|
929
935
|
run resume [--op <name>] Start again each CI job that waits at a gate an
|
|
930
936
|
approval now answers, through the forge's API
|
|
931
937
|
(#3683); approves nothing. --dry-run lists them
|
package/src/cli/plugins.ts
CHANGED
|
@@ -211,7 +211,7 @@ export function collectBuildRootContributors(
|
|
|
211
211
|
.filter((plugin) => typeof plugin.buildRoots === "function")
|
|
212
212
|
// `entities` is not bindable here — discovery has not run — so the merge
|
|
213
213
|
// hands it in when it calls the closure (#1828 / SOPS provenance).
|
|
214
|
-
.map((plugin): import("../lexicon").BuildRootContributor => (ctx) => plugin.buildRoots!({ projectRoot, config, entities: ctx?.entities }));
|
|
214
|
+
.map((plugin): import("../lexicon").BuildRootContributor => (ctx) => plugin.buildRoots!({ projectRoot, config, entities: ctx?.entities, ...(ctx?.sourceDir !== undefined ? { sourceDir: ctx.sourceDir } : {}) }));
|
|
215
215
|
return [...codegen, ...roots];
|
|
216
216
|
}
|
|
217
217
|
|
package/src/cli/registry.ts
CHANGED
|
@@ -252,6 +252,8 @@ export interface ParsedArgs {
|
|
|
252
252
|
wave?: number;
|
|
253
253
|
/** `chant run wave --decide` (#3679) — plan a wide wave and decide its gate, applying nothing. */
|
|
254
254
|
decide?: boolean;
|
|
255
|
+
/** `chant run wave --record-plans` (#3684) — plan every wave at a pull request's head and record the digests for a `pr-review` gate. */
|
|
256
|
+
recordPlans?: boolean;
|
|
255
257
|
/** `chant run wave --share <i>` (#3679) — apply share `i` of a decided wide wave. */
|
|
256
258
|
share?: number;
|
|
257
259
|
/** `chant components fan-out --canary <component>` (#3049), repeatable — components that form wave 1. */
|
package/src/discovery/files.ts
CHANGED
|
@@ -116,3 +116,21 @@ export async function findInfraFiles(path: string, options?: FindInfraFilesOptio
|
|
|
116
116
|
accept: (name, full) => isSourceFileName(name) && !hasDiscoveryMarkerSync(full),
|
|
117
117
|
});
|
|
118
118
|
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Find the files under `path` that `accept` wants, through the same walk and
|
|
122
|
+
* the same project globs as {@link findInfraFiles}: a lexicon that reads
|
|
123
|
+
* source of its own beside the TypeScript (the sql lexicon's `.sql` files)
|
|
124
|
+
* sees exactly the tree source discovery sees, so `dist`, git-ignored paths,
|
|
125
|
+
* child projects and the project's `exclude` globs are left out for it too.
|
|
126
|
+
*/
|
|
127
|
+
export async function findSourceFiles(path: string, accept: (name: string, full: string) => boolean, options?: FindInfraFilesOptions): Promise<string[]> {
|
|
128
|
+
const globs = options?.globs === null ? undefined : (options?.globs ?? (await resolveDiscoveryGlobs(path)));
|
|
129
|
+
return walkDiscovery({
|
|
130
|
+
walker: "source",
|
|
131
|
+
root: path,
|
|
132
|
+
globs,
|
|
133
|
+
excludeDirs: await workspaceMemberDirs(path),
|
|
134
|
+
accept,
|
|
135
|
+
});
|
|
136
|
+
}
|
package/src/lexicon.ts
CHANGED
|
@@ -984,6 +984,15 @@ export interface BuildRootContext {
|
|
|
984
984
|
* treat an absent map as an empty one.
|
|
985
985
|
*/
|
|
986
986
|
entities?: ReadonlyMap<string, Declarable>;
|
|
987
|
+
/**
|
|
988
|
+
* The directory this build discovered its source in (the path handed to
|
|
989
|
+
* `build()`, which `--src` or `sourceDir` chose), absolute. A contributor
|
|
990
|
+
* that reads files beside the typed source (the sql lexicon's `.sql`
|
|
991
|
+
* files) reads them here, so it sees the tree discovery saw. Optional: a
|
|
992
|
+
* caller that is not a build (the graph paths) omits it, and a hook then
|
|
993
|
+
* falls back to `sourceDir` in `config`, resolved against `projectRoot`.
|
|
994
|
+
*/
|
|
995
|
+
sourceDir?: string;
|
|
987
996
|
}
|
|
988
997
|
|
|
989
998
|
/**
|
|
@@ -1003,7 +1012,7 @@ export interface BuildRootContribution {
|
|
|
1003
1012
|
* the argument is still assignable, which is what every pre-#1828 hook does.
|
|
1004
1013
|
*/
|
|
1005
1014
|
export type BuildRootContributor = (
|
|
1006
|
-
ctx: Pick<BuildRootContext, "entities">,
|
|
1015
|
+
ctx: Pick<BuildRootContext, "entities" | "sourceDir">,
|
|
1007
1016
|
) => Promise<BuildRootContribution>;
|
|
1008
1017
|
|
|
1009
1018
|
/**
|
|
@@ -222,6 +222,13 @@ export interface GateResolutionRecord {
|
|
|
222
222
|
* verifies. Absent on an unsigned approval.
|
|
223
223
|
*/
|
|
224
224
|
seal?: GateApprovalSeal;
|
|
225
|
+
/**
|
|
226
|
+
* The forge review this approval came from (#3684): a wave whose approval
|
|
227
|
+
* mode is `pr-review` writes it when the merged pull request's head was
|
|
228
|
+
* approved by a writer other than its author and planned this digest.
|
|
229
|
+
* `resolvedBy` is the first of `approvers`.
|
|
230
|
+
*/
|
|
231
|
+
review?: { pr: number; head: string; approvers: string[] };
|
|
225
232
|
/**
|
|
226
233
|
* The answer record that passed the gate, on a gate that asks a decision
|
|
227
234
|
* point (#3170). The run writes this resolution itself when the point's
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #3712: a `chant lifecycle diff` that fails yields no digest, so an approval
|
|
3
|
+
* cannot pass a gate for a plan nobody read.
|
|
4
|
+
*
|
|
5
|
+
* Before the fix the activity caught the diff's non-zero exit and digested
|
|
6
|
+
* its error output. Every run whose build failed printed the same lines, so
|
|
7
|
+
* an approval of one such run's digest matched the next, whatever the
|
|
8
|
+
* declarations had become, and the Apply phase (which builds on its own)
|
|
9
|
+
* applied the change.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
13
|
+
|
|
14
|
+
/** What the next `chant lifecycle diff` prints and how it exits. */
|
|
15
|
+
const next = { stdout: "", stderr: "", code: 0 };
|
|
16
|
+
|
|
17
|
+
vi.mock(import("node:child_process"), async (importOriginal) => ({
|
|
18
|
+
...(await importOriginal()),
|
|
19
|
+
exec: ((_command: string, _options: unknown, callback: (err: unknown, out?: { stdout: string; stderr: string }) => void) => {
|
|
20
|
+
if (next.code === 0) callback(null, { stdout: next.stdout, stderr: next.stderr });
|
|
21
|
+
else callback(Object.assign(new Error("exit"), { code: next.code, stdout: next.stdout, stderr: next.stderr }));
|
|
22
|
+
}) as never,
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
const { lifecycleDiff, lifecycleDiffDigest } = await import("./lifecycle");
|
|
26
|
+
const { OpRunFailure, runOpLocally } = await import("../local-executor");
|
|
27
|
+
const { stepOutput } = await import("../step-output-ref");
|
|
28
|
+
type ActivityFn = import("../activity-registry").ActivityFn;
|
|
29
|
+
type ActivityProfile = import("../activity-registry").ActivityProfile;
|
|
30
|
+
type GateLedgerPort = import("../gate").GateLedgerPort;
|
|
31
|
+
type GateResolutionRecord = import("../../lifecycle/gate-ledger").GateResolutionRecord;
|
|
32
|
+
type PendingGateRecord = import("../../lifecycle/gate-ledger").PendingGateRecord;
|
|
33
|
+
type OpConfig = import("../types").OpConfig;
|
|
34
|
+
|
|
35
|
+
const PROFILES: Record<string, ActivityProfile> = {
|
|
36
|
+
fastIdempotent: { timeout: "5m", retry: { maximumAttempts: 1 } },
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const BUILD_FAILED = "error: Build failed for project — fix errors before diffing";
|
|
40
|
+
const ARGS = { env: "dev", live: true };
|
|
41
|
+
|
|
42
|
+
function failDiff(): void {
|
|
43
|
+
Object.assign(next, { stdout: "", stderr: BUILD_FAILED, code: 1 });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function diffPrints(render: string): void {
|
|
47
|
+
Object.assign(next, { stdout: render, stderr: "", code: 0 });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
diffPrints("");
|
|
52
|
+
vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
53
|
+
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("lifecycleDiff on a failed diff (#3712)", () => {
|
|
57
|
+
test("a build error rejects, naming the command and the error, with no digest", async () => {
|
|
58
|
+
failDiff();
|
|
59
|
+
await expect(lifecycleDiff(ARGS)).rejects.toThrow(/chant lifecycle diff dev --live failed \(exit 1\); no plan to digest:\n.*Build failed for project/);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("a diff that succeeds still returns its digest", async () => {
|
|
63
|
+
diffPrints("MISSING (declared, provider reports not in cloud):\n - events");
|
|
64
|
+
const result = await lifecycleDiff(ARGS);
|
|
65
|
+
expect(result.exitCode).toBe(0);
|
|
66
|
+
expect(result.drifted).toBe(true);
|
|
67
|
+
expect(result.planDigest).toBe(lifecycleDiffDigest(ARGS, "MISSING (declared, provider reports not in cloud):\n - events"));
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("ApplyOp's gate over a diff whose build fails (#3712)", () => {
|
|
72
|
+
function ledger(): GateLedgerPort & { approve(planDigest: string): void } {
|
|
73
|
+
const resolutions: GateResolutionRecord[] = [];
|
|
74
|
+
const pending: PendingGateRecord[] = [];
|
|
75
|
+
return {
|
|
76
|
+
approve(planDigest) {
|
|
77
|
+
resolutions.push({ version: 1, op: "apply-dev", gate: "approve-apply-dev", resolvedBy: "alex", timestamp: "2026-10-10T12:05:00.000Z", planDigest });
|
|
78
|
+
},
|
|
79
|
+
async read() {
|
|
80
|
+
return { resolutions: [...resolutions], pending: [...pending] };
|
|
81
|
+
},
|
|
82
|
+
async appendPending(input) {
|
|
83
|
+
const record: PendingGateRecord = { version: 1, kind: "pending", ...input };
|
|
84
|
+
pending.push(record);
|
|
85
|
+
return { record, pushed: true };
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const statuses = (failure: unknown) =>
|
|
91
|
+
(failure as InstanceType<typeof OpRunFailure>).result.records.map((r) => [r.fn, r.status]);
|
|
92
|
+
|
|
93
|
+
/** ApplyOp's Plan, Approve and Apply phases, with the real `lifecycleDiff` and an apply that records what it applied. */
|
|
94
|
+
function harness() {
|
|
95
|
+
const declared = { schema: "A" };
|
|
96
|
+
const applied: string[] = [];
|
|
97
|
+
const activities = new Map<string, ActivityFn>([
|
|
98
|
+
["lifecycleDiff", (args, signal) => lifecycleDiff(args as { env: string; live?: boolean }, signal)],
|
|
99
|
+
["nativeApply", async () => {
|
|
100
|
+
applied.push(declared.schema);
|
|
101
|
+
}],
|
|
102
|
+
]);
|
|
103
|
+
const config: OpConfig = {
|
|
104
|
+
name: "apply-dev",
|
|
105
|
+
overview: "",
|
|
106
|
+
phases: [
|
|
107
|
+
{ name: "Plan", steps: [{ kind: "activity", fn: "lifecycleDiff", id: "plan", args: ARGS }] },
|
|
108
|
+
{ name: "Approve", steps: [{ kind: "gate", gate: "approve-apply-dev", plan: stepOutput("plan", "planDigest") }] },
|
|
109
|
+
{ name: "Apply", steps: [{ kind: "activity", fn: "nativeApply" }] },
|
|
110
|
+
],
|
|
111
|
+
};
|
|
112
|
+
return { declared, applied, activities, config };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
test("an approval of plan A does not pass for plan B when the diff's build fails both times", async () => {
|
|
116
|
+
const { declared, applied, activities, config } = harness();
|
|
117
|
+
const gates = ledger();
|
|
118
|
+
|
|
119
|
+
// Plan A: the diff's build fails. The run stops at Plan: nothing reaches the gate.
|
|
120
|
+
failDiff();
|
|
121
|
+
const first = await runOpLocally(config, activities, PROFILES, undefined, { gates, now: "2026-10-10T12:00:00.000Z" }).catch((e: unknown) => e);
|
|
122
|
+
expect(first).toBeInstanceOf(OpRunFailure);
|
|
123
|
+
expect(statuses(first)).toEqual([["lifecycleDiff", "fail"]]);
|
|
124
|
+
expect(applied).toEqual([]);
|
|
125
|
+
|
|
126
|
+
// The digest the diff's error output used to give: someone approves it.
|
|
127
|
+
gates.approve(lifecycleDiffDigest(ARGS, BUILD_FAILED));
|
|
128
|
+
|
|
129
|
+
// Plan B: the declarations change, and the diff's build still fails.
|
|
130
|
+
declared.schema = "B";
|
|
131
|
+
const second = await runOpLocally(config, activities, PROFILES, undefined, { gates, now: "2026-10-10T12:10:00.000Z" }).catch((e: unknown) => e);
|
|
132
|
+
expect(second).toBeInstanceOf(OpRunFailure);
|
|
133
|
+
expect(statuses(second)).toEqual([["lifecycleDiff", "fail"]]);
|
|
134
|
+
expect(applied).toEqual([]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("with the build fixed, the run stops at the gate for the plan it read", async () => {
|
|
138
|
+
const { applied, activities, config } = harness();
|
|
139
|
+
const gates = ledger();
|
|
140
|
+
const render = "MISSING (declared, provider reports not in cloud):\n - events";
|
|
141
|
+
diffPrints(render);
|
|
142
|
+
const run = await runOpLocally(config, activities, PROFILES, undefined, { gates, now: "2026-10-10T12:00:00.000Z" });
|
|
143
|
+
expect(run.status).toBe("gated");
|
|
144
|
+
expect(run.gate?.planDigest).toBe(lifecycleDiffDigest(ARGS, render));
|
|
145
|
+
expect(applied).toEqual([]);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -32,7 +32,7 @@ export interface LifecycleDiffArgs {
|
|
|
32
32
|
export interface LifecycleDiffResult {
|
|
33
33
|
/** Combined stdout + stderr from the chant command. */
|
|
34
34
|
output: string;
|
|
35
|
-
/** Process exit code
|
|
35
|
+
/** Process exit code: always 0, since a diff that exits non-zero throws (#3712). */
|
|
36
36
|
exitCode: number;
|
|
37
37
|
/**
|
|
38
38
|
* True when the diff output contains any drift indicators
|
|
@@ -121,18 +121,27 @@ export function lifecycleDiffDigest(args: LifecycleDiffArgs, output: string): st
|
|
|
121
121
|
* MISSING / ORPHAN / DRIFTED / DISAPPEARED / PROPERTY DRIFT section headers documented in
|
|
122
122
|
* cli/state.mdx. Pair with `outcomeAttribute: { name: "Drift", from: "drifted" }`
|
|
123
123
|
* on a WatchOp activity step to surface drift as the run's `Drift` outcome.
|
|
124
|
+
*
|
|
125
|
+
* A diff that exits non-zero (#3712) fails the step and yields no digest.
|
|
126
|
+
* Its output is an error, not a change set: every failed run printed the
|
|
127
|
+
* same lines, so a digest over them was one an approval matched whatever
|
|
128
|
+
* the declarations had become since.
|
|
124
129
|
*/
|
|
125
130
|
export async function lifecycleDiff(args: LifecycleDiffArgs, signal?: AbortSignal): Promise<LifecycleDiffResult> {
|
|
126
131
|
const liveFlag = args.live ? " --live" : "";
|
|
132
|
+
const command = `chant lifecycle diff ${args.env}${liveFlag}`;
|
|
133
|
+
let stdout: string;
|
|
134
|
+
let stderr: string;
|
|
127
135
|
try {
|
|
128
|
-
|
|
129
|
-
const output = `${stdout}${stderr}`.trim();
|
|
130
|
-
if (output) console.log(output);
|
|
131
|
-
return { output, exitCode: 0, drifted: detectDrift(output), planDigest: lifecycleDiffDigest(args, output) };
|
|
136
|
+
({ stdout, stderr } = await execAsync(command, { signal }));
|
|
132
137
|
} catch (err) {
|
|
133
138
|
const e = err as { code?: number; stdout?: string; stderr?: string };
|
|
134
139
|
const output = `${e.stdout ?? ""}${e.stderr ?? ""}`.trim();
|
|
135
140
|
if (output) console.error(output);
|
|
136
|
-
|
|
141
|
+
const message = `${command} failed (exit ${String(e.code ?? 1)}); no plan to digest${output ? `:\n${output}` : ""}`;
|
|
142
|
+
throw new Error(message);
|
|
137
143
|
}
|
|
144
|
+
const output = `${stdout}${stderr}`.trim();
|
|
145
|
+
if (output) console.log(output);
|
|
146
|
+
return { output, exitCode: 0, drifted: detectDrift(output), planDigest: lifecycleDiffDigest(args, output) };
|
|
138
147
|
}
|