@intentius/chant 0.124.41 → 0.124.43
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/build.d.ts.map +1 -1
- package/dist/cli/handlers/build.js +3 -0
- package/dist/cli/handlers/fan-out.d.ts.map +1 -1
- package/dist/cli/handlers/fan-out.js +27 -3
- package/dist/cli/handlers/pr.d.ts +3 -1
- package/dist/cli/handlers/pr.d.ts.map +1 -1
- package/dist/cli/handlers/pr.js +113 -2
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/handlers/run.js +11 -0
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +8 -2
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +15 -0
- package/dist/cli/registry.d.ts +19 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/capability.d.ts +47 -0
- package/dist/components/capability.d.ts.map +1 -1
- package/dist/components/capability.js +44 -0
- package/dist/components/cli-support.d.ts +6 -0
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/cli-support.js +8 -1
- package/dist/components/driver.d.ts +10 -0
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/components/driver.js +52 -2
- package/dist/components/fan-out-run.d.ts +9 -0
- package/dist/components/fan-out-run.d.ts.map +1 -1
- package/dist/components/fan-out-run.js +17 -1
- package/dist/components/gate-plan.d.ts +2 -0
- package/dist/components/gate-plan.d.ts.map +1 -1
- package/dist/components/gate-plan.js +1 -0
- package/dist/components/pr-pipeline.d.ts +20 -0
- package/dist/components/pr-pipeline.d.ts.map +1 -1
- package/dist/components/pr-pipeline.js +54 -16
- package/dist/components/pr-run.d.ts +6 -0
- package/dist/components/pr-run.d.ts.map +1 -1
- package/dist/components/pr-run.js +3 -0
- package/dist/components/wave-plan.d.ts +14 -1
- package/dist/components/wave-plan.d.ts.map +1 -1
- package/dist/components/wave-plan.js +34 -2
- package/dist/op/activities/pr-refs.d.ts +10 -0
- package/dist/op/activities/pr-refs.d.ts.map +1 -0
- package/dist/op/activities/pr-refs.js +26 -0
- package/dist/op/activities/predict-behaviour.d.ts +2 -4
- package/dist/op/activities/predict-behaviour.d.ts.map +1 -1
- package/dist/op/activities/predict-behaviour.js +2 -21
- package/dist/op/activities/review-agent.js +1 -1
- package/dist/op/index.d.ts +2 -2
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/index.js +1 -1
- package/dist/op/op-waves-run.d.ts +3 -79
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves-run.js +4 -133
- package/dist/op/stage-hooks.d.ts +152 -0
- package/dist/op/stage-hooks.d.ts.map +1 -0
- package/dist/op/stage-hooks.js +199 -0
- package/dist/pr-forge.d.ts +56 -0
- package/dist/pr-forge.d.ts.map +1 -1
- package/dist/pr-forge.js +320 -45
- package/dist/pr-loop.d.ts +18 -1
- package/dist/pr-loop.d.ts.map +1 -1
- package/dist/pr-loop.js +7 -0
- package/dist/pr-requires.d.ts +47 -0
- package/dist/pr-requires.d.ts.map +1 -0
- package/dist/pr-requires.js +53 -0
- package/dist/wave-runtime.mjs +213 -213
- package/dist/workspace/checks/ci-workflow.d.ts.map +1 -1
- package/dist/workspace/checks/ci-workflow.js +19 -4
- package/dist/workspace/ci-init.d.ts.map +1 -1
- package/dist/workspace/ci-init.js +5 -4
- package/dist/workspace/ci-run.d.ts.map +1 -1
- package/dist/workspace/ci-run.js +2 -1
- package/dist/workspace/ci-workflow-at.d.ts +14 -0
- package/dist/workspace/ci-workflow-at.d.ts.map +1 -0
- package/dist/workspace/ci-workflow-at.js +17 -0
- package/dist/workspace/ci-workflow.d.ts +11 -3
- package/dist/workspace/ci-workflow.d.ts.map +1 -1
- package/dist/workspace/ci-workflow.js +10 -2
- package/dist/workspace/declaration.d.ts +6 -3
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.schema.json +2 -2
- package/dist/workspace/pr-report.schema.json +23 -3
- package/dist/workspace/reason-codes.d.ts +1 -1
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/reason-codes.js +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/build.ts +3 -0
- package/src/cli/handlers/fan-out.ts +27 -3
- package/src/cli/handlers/pr-before-merge.test.ts +276 -0
- package/src/cli/handlers/pr.ts +110 -2
- package/src/cli/handlers/run.ts +10 -0
- package/src/cli/main.ts +8 -2
- package/src/cli/parse-args.ts +11 -0
- package/src/cli/registry.ts +19 -0
- package/src/components/capability-targets.test.ts +162 -0
- package/src/components/capability.ts +71 -0
- package/src/components/cli-support.ts +14 -1
- package/src/components/driver.ts +54 -3
- package/src/components/fan-out-run.ts +19 -1
- package/src/components/gate-plan.ts +3 -0
- package/src/components/pr-pipeline.ts +64 -16
- package/src/components/pr-run.ts +9 -0
- package/src/components/wave-plan.ts +38 -3
- package/src/op/activities/pr-refs.ts +28 -0
- package/src/op/activities/predict-behaviour.ts +3 -23
- package/src/op/activities/review-agent.ts +1 -1
- package/src/op/index.ts +4 -0
- package/src/op/op-waves-run.ts +24 -228
- package/src/op/stage-hooks-async.test.ts +79 -0
- package/src/op/stage-hooks.ts +346 -0
- package/src/pr-forge-bundle.test.ts +2 -2
- package/src/pr-forge.ts +334 -36
- package/src/pr-loop-bundle.test.ts +2 -2
- package/src/pr-loop.ts +25 -1
- package/src/pr-requires.test.ts +248 -0
- package/src/pr-requires.ts +93 -0
- package/src/review-agent-bundle.test.ts +35 -0
- package/src/stage-hooks-bundle.test.ts +38 -0
- package/src/workspace/checks/ci-workflow.test.ts +9 -0
- package/src/workspace/checks/ci-workflow.ts +18 -4
- package/src/workspace/ci-init.ts +5 -4
- package/src/workspace/ci-run.ts +2 -1
- package/src/workspace/ci-workflow-at.ts +23 -0
- package/src/workspace/ci-workflow.ts +13 -2
- package/src/workspace/declaration.schema.json +2 -2
- package/src/workspace/declaration.ts +6 -3
- package/src/workspace/pr-report.schema.json +23 -3
- package/src/workspace/reason-codes.ts +1 -1
|
@@ -27,11 +27,11 @@
|
|
|
27
27
|
* `@<component>.publish.*`) resolves.
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
|
-
import { resolveStepInput, unresolvedWiringSources, type DriverComponent, type DriverPhase, type DriverStep } from "./driver";
|
|
30
|
+
import { assertTargetsAccepted, resolveStepInput, unresolvedWiringSources, type DriverComponent, type DriverPhase, type DriverStep } from "./driver";
|
|
31
31
|
import { componentPlanDigest } from "./gate-plan";
|
|
32
32
|
import { componentDeployInputs, type ComponentDeployInputs } from "./deploy-inputs";
|
|
33
33
|
import { computePlanDigest } from "../lifecycle/plan-digest";
|
|
34
|
-
import type { CapabilityRegistry, DeployContext, PlannedOutputs } from "./capability";
|
|
34
|
+
import type { CapabilityPlan, CapabilityRegistry, DeployContext, PlannedOutputs } from "./capability";
|
|
35
35
|
import type { WaveMember } from "../gated-waves";
|
|
36
36
|
import type { ChangeSetPart } from "../change-set";
|
|
37
37
|
|
|
@@ -101,12 +101,13 @@ export async function planWaveComponent(
|
|
|
101
101
|
const plans: Record<string, unknown> = {};
|
|
102
102
|
const parts: ChangeSetPart[] = [];
|
|
103
103
|
let outputs: PlannedOutputs | undefined;
|
|
104
|
+
assertTargetsAccepted(component, registry, ctx);
|
|
104
105
|
for (const step of capabilitySteps(component.deploy)) {
|
|
105
106
|
const capability = registry.resolve(step.kind);
|
|
106
107
|
if (!capability.plan) continue;
|
|
107
108
|
const { kind: _kind, ...rest } = step;
|
|
108
109
|
const input = resolveStepInput(rest, {}, componentOutputs);
|
|
109
|
-
const planned = await capability.plan(planContext(ctx, rest, componentOutputs), input as never);
|
|
110
|
+
const planned = bindTargets(step.kind, ctx, await capability.plan(planContext(ctx, rest, componentOutputs), input as never));
|
|
110
111
|
const member = { member: planned.member, planDigest: planned.planDigest };
|
|
111
112
|
members.push(member);
|
|
112
113
|
parts.push(planned.changeSet ?? emptyPart(member));
|
|
@@ -124,6 +125,7 @@ export async function planWaveComponent(
|
|
|
124
125
|
component,
|
|
125
126
|
...(ctx.vars ? { vars: ctx.vars } : {}),
|
|
126
127
|
inputs: read.inputs,
|
|
128
|
+
...(ctx.targets ? { targets: ctx.targets } : {}),
|
|
127
129
|
}),
|
|
128
130
|
};
|
|
129
131
|
members.push(member);
|
|
@@ -145,6 +147,39 @@ export async function planWaveComponent(
|
|
|
145
147
|
}
|
|
146
148
|
}
|
|
147
149
|
|
|
150
|
+
/**
|
|
151
|
+
* A member's digest with its targets in it (#4300), the way terragucci's
|
|
152
|
+
* cone plan digest binds a cone: the digest over the plan's own digest and
|
|
153
|
+
* the sorted targets, so an approval of one narrowing covers no other.
|
|
154
|
+
*/
|
|
155
|
+
export function targetedPlanDigest(planDigest: string, targets: readonly string[]): string {
|
|
156
|
+
return computePlanDigest("capability-targets", { plan: planDigest, targets: [...new Set(targets)].sort() });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* A step's plan made with `ctx.targets` (#4300), its digest bound to them.
|
|
161
|
+
* The plan must give back the targets it was asked for, which says it planned
|
|
162
|
+
* those alone; one that gives back others, or none, refuses. Without
|
|
163
|
+
* `ctx.targets`, the plan as it came.
|
|
164
|
+
*/
|
|
165
|
+
export function bindTargets(kind: string, ctx: DeployContext, planned: CapabilityPlan): CapabilityPlan {
|
|
166
|
+
const asked = ctx.targets;
|
|
167
|
+
if (!asked || asked.length === 0) return planned;
|
|
168
|
+
const want = [...new Set(asked)].sort();
|
|
169
|
+
const got = [...new Set(planned.targets ?? [])].sort();
|
|
170
|
+
if (got.length !== want.length || got.some((t, i) => t !== want[i])) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
`component "${ctx.component}": ${kind} was asked to plan ${want.join(", ")} but its plan of ${planned.member} ` +
|
|
173
|
+
(got.length === 0 ? "names no targets" : `names ${got.join(", ")}`),
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
const planDigest = targetedPlanDigest(planned.planDigest, want);
|
|
177
|
+
const changeSet = planned.changeSet
|
|
178
|
+
? { ...planned.changeSet, member: { ...planned.changeSet.member, planDigest } }
|
|
179
|
+
: undefined;
|
|
180
|
+
return { ...planned, planDigest, targets: want, ...(changeSet ? { changeSet } : {}) };
|
|
181
|
+
}
|
|
182
|
+
|
|
148
183
|
/** `next` over `prior`, as a later step's outputs win over an earlier one's in a run. */
|
|
149
184
|
function mergePlannedOutputs(prior: PlannedOutputs | undefined, next: PlannedOutputs): PlannedOutputs {
|
|
150
185
|
const known = { ...(prior?.known ?? {}), ...next.known };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pull request's base and head branch names, read off the run's own
|
|
3
|
+
* event (#4299). Its own module so the review agent bundles without
|
|
4
|
+
* predict-behaviour's estate code.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Where the base branch name comes from, most specific first. Pure — exported for testing. */
|
|
8
|
+
export function baseRefFrom(
|
|
9
|
+
env: Record<string, string | undefined>,
|
|
10
|
+
explicit?: string,
|
|
11
|
+
): string | undefined {
|
|
12
|
+
const fromArgs = explicit?.trim();
|
|
13
|
+
if (fromArgs) return fromArgs;
|
|
14
|
+
for (const source of ["GITHUB_BASE_REF", "CI_MERGE_REQUEST_TARGET_BRANCH_NAME"]) {
|
|
15
|
+
const value = env[source]?.trim();
|
|
16
|
+
if (value) return value;
|
|
17
|
+
}
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** What the head side is called in the finding: the source branch when the run names one, else the short commit. */
|
|
22
|
+
export function headRefFrom(env: Record<string, string | undefined>, shortSha?: string): string {
|
|
23
|
+
for (const source of ["GITHUB_HEAD_REF", "CI_MERGE_REQUEST_SOURCE_BRANCH_NAME"]) {
|
|
24
|
+
const value = env[source]?.trim();
|
|
25
|
+
if (value) return value;
|
|
26
|
+
}
|
|
27
|
+
return shortSha?.trim() || "head";
|
|
28
|
+
}
|
|
@@ -85,6 +85,9 @@ import type { BehaviourKinds } from "../../behaviour-kinds";
|
|
|
85
85
|
import { createBehaviourPredict } from "../../behaviour-predict";
|
|
86
86
|
import type { SerializerResult } from "../../serializer";
|
|
87
87
|
import { markerSlug, reconcilePr, suppliedMarker, type ReconcileResult } from "./reconcile";
|
|
88
|
+
import { baseRefFrom, headRefFrom } from "./pr-refs";
|
|
89
|
+
|
|
90
|
+
export { baseRefFrom, headRefFrom };
|
|
88
91
|
|
|
89
92
|
const execAsync = promisify(exec);
|
|
90
93
|
|
|
@@ -294,29 +297,6 @@ export function noBaseRefMessage(): string {
|
|
|
294
297
|
);
|
|
295
298
|
}
|
|
296
299
|
|
|
297
|
-
/** Where the base branch name comes from, most specific first. Pure — exported for testing. */
|
|
298
|
-
export function baseRefFrom(
|
|
299
|
-
env: Record<string, string | undefined>,
|
|
300
|
-
explicit?: string,
|
|
301
|
-
): string | undefined {
|
|
302
|
-
const fromArgs = explicit?.trim();
|
|
303
|
-
if (fromArgs) return fromArgs;
|
|
304
|
-
for (const source of ["GITHUB_BASE_REF", "CI_MERGE_REQUEST_TARGET_BRANCH_NAME"]) {
|
|
305
|
-
const value = env[source]?.trim();
|
|
306
|
-
if (value) return value;
|
|
307
|
-
}
|
|
308
|
-
return undefined;
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
/** What the head side is called in the finding: the source branch when the run names one, else the short commit. */
|
|
312
|
-
export function headRefFrom(env: Record<string, string | undefined>, shortSha?: string): string {
|
|
313
|
-
for (const source of ["GITHUB_HEAD_REF", "CI_MERGE_REQUEST_SOURCE_BRANCH_NAME"]) {
|
|
314
|
-
const value = env[source]?.trim();
|
|
315
|
-
if (value) return value;
|
|
316
|
-
}
|
|
317
|
-
return shortSha?.trim() || "head";
|
|
318
|
-
}
|
|
319
|
-
|
|
320
300
|
/** A checked-out base branch: where its project root is, and how to remove it. */
|
|
321
301
|
export interface BaseCheckout {
|
|
322
302
|
/** The base branch's counterpart of the working directory. */
|
|
@@ -27,7 +27,7 @@ import { spawn, execFile } from "node:child_process";
|
|
|
27
27
|
import { promisify } from "node:util";
|
|
28
28
|
import { readFile } from "node:fs/promises";
|
|
29
29
|
import type { ChangeSetDocument, ChangeSetEntry } from "../../change-set";
|
|
30
|
-
import { baseRefFrom } from "./
|
|
30
|
+
import { baseRefFrom } from "./pr-refs";
|
|
31
31
|
import { markerSlug, reconcilePr, suppliedMarker } from "./reconcile";
|
|
32
32
|
|
|
33
33
|
const execFileAsync = promisify(execFile);
|
package/src/op/index.ts
CHANGED
|
@@ -75,6 +75,8 @@ export {
|
|
|
75
75
|
OP_WAVE_MAX_REPLANS,
|
|
76
76
|
opWaveHooksAtBase,
|
|
77
77
|
runStageHooks,
|
|
78
|
+
runStageHooksAsync,
|
|
79
|
+
spawnExecAsync,
|
|
78
80
|
opWaveNeedsApproval,
|
|
79
81
|
opWavePlansPath,
|
|
80
82
|
opWaveRunsPath,
|
|
@@ -91,6 +93,7 @@ export type {
|
|
|
91
93
|
OpWaveDecision,
|
|
92
94
|
OpWaveApplyHooksRun,
|
|
93
95
|
OpWaveApplyOutcome,
|
|
96
|
+
OpWaveAsyncExec,
|
|
94
97
|
OpWaveExec,
|
|
95
98
|
OpWaveHeldOn,
|
|
96
99
|
OpWaveHoldRequest,
|
|
@@ -106,6 +109,7 @@ export type {
|
|
|
106
109
|
OpWaveShowAtBase,
|
|
107
110
|
RecordHeadPlansOptions,
|
|
108
111
|
RunOpWaveApplyHooksOptions,
|
|
112
|
+
RunStageHooksAsyncOptions,
|
|
109
113
|
RunStageHooksOptions,
|
|
110
114
|
StageHooksRun,
|
|
111
115
|
RunOpWaveOptions,
|
package/src/op/op-waves-run.ts
CHANGED
|
@@ -105,6 +105,30 @@ import {
|
|
|
105
105
|
type OpWaveRun,
|
|
106
106
|
type OpWavesSpec,
|
|
107
107
|
} from "./op-waves";
|
|
108
|
+
import {
|
|
109
|
+
OP_WAVE_PENDING_RESERVED,
|
|
110
|
+
opWaveArgv,
|
|
111
|
+
pendingFields,
|
|
112
|
+
readHoldRequest,
|
|
113
|
+
runHookList,
|
|
114
|
+
runStageHooks,
|
|
115
|
+
runStageHooksAsync,
|
|
116
|
+
spawnExec,
|
|
117
|
+
spawnExecAsync,
|
|
118
|
+
type HookPlace,
|
|
119
|
+
type OpWaveAsyncExec,
|
|
120
|
+
type OpWaveExec,
|
|
121
|
+
type OpWaveHoldRequest,
|
|
122
|
+
type OpWaveHookOutcome,
|
|
123
|
+
type OpWaveHookRun,
|
|
124
|
+
type OtherGateHold,
|
|
125
|
+
type RunStageHooksAsyncOptions,
|
|
126
|
+
type RunStageHooksOptions,
|
|
127
|
+
type StageHooksRun,
|
|
128
|
+
} from "./stage-hooks";
|
|
129
|
+
|
|
130
|
+
export { OP_WAVE_PENDING_RESERVED, opWaveArgv, runStageHooks, runStageHooksAsync, spawnExecAsync };
|
|
131
|
+
export type { OpWaveAsyncExec, OpWaveExec, OpWaveHoldRequest, OpWaveHookOutcome, OpWaveHookRun, RunStageHooksAsyncOptions, RunStageHooksOptions, StageHooksRun };
|
|
108
132
|
|
|
109
133
|
/** What a run's plan command writes to `{plan}`. */
|
|
110
134
|
export interface OpWavePlanFile {
|
|
@@ -154,12 +178,6 @@ export interface OpWavePlansFile {
|
|
|
154
178
|
waves?: number;
|
|
155
179
|
}
|
|
156
180
|
|
|
157
|
-
/** Pending-fact keys chant writes itself, which a plans file's `pending` may not set (#3821). */
|
|
158
|
-
export const OP_WAVE_PENDING_RESERVED = [
|
|
159
|
-
"version", "kind", "op", "gate", "timestamp", "expiresAt", "planDigest", "runId", "url", "resume",
|
|
160
|
-
"environment", "origin", "neverOverMcp", "approval", "approvalMode", "answer", "wave", "heldBy",
|
|
161
|
-
] as const;
|
|
162
|
-
|
|
163
181
|
/** What chant writes to `{runs}` for a wave-level command or a hook (#3762). */
|
|
164
182
|
export interface OpWaveRunsFile {
|
|
165
183
|
/** The wave's name. */
|
|
@@ -305,18 +323,6 @@ export interface OpWaveApplyOutcome {
|
|
|
305
323
|
|
|
306
324
|
export const OP_WAVE_APPLY_OUTCOMES = ["stood-down", "held", "replan"] as const;
|
|
307
325
|
|
|
308
|
-
/** What a hook asks for when it holds a wave on another gate (#3821): it writes this to `CHANT_HOLD` and exits non-zero. */
|
|
309
|
-
export interface OpWaveHoldRequest {
|
|
310
|
-
/** The op the gate is recorded under. Default: the spec's name. */
|
|
311
|
-
op?: string;
|
|
312
|
-
gate: string;
|
|
313
|
-
/** The digest an approval binds. */
|
|
314
|
-
digest: string;
|
|
315
|
-
description?: string;
|
|
316
|
-
/** Fields for the pending fact, as a plans file's `pending`. */
|
|
317
|
-
pending?: Record<string, unknown>;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
326
|
/** A hold on another gate, as the decision records it (#3821). */
|
|
321
327
|
export interface OpWaveHeldOn {
|
|
322
328
|
op: string;
|
|
@@ -347,26 +353,6 @@ export interface OpWaveHeldOnApproval {
|
|
|
347
353
|
approvedAt: string;
|
|
348
354
|
}
|
|
349
355
|
|
|
350
|
-
/** One hook's run (#3762). */
|
|
351
|
-
export interface OpWaveHookOutcome {
|
|
352
|
-
moment: OpWaveHookMoment;
|
|
353
|
-
name: string;
|
|
354
|
-
/** The run, for a `perRun` hook. */
|
|
355
|
-
target?: string;
|
|
356
|
-
exit: number;
|
|
357
|
-
outcome: "passed" | "failed" | "held" | "skipped";
|
|
358
|
-
/** Why the moment was skipped, for `skipped` (#3811). */
|
|
359
|
-
reason?: string;
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
/** One run as a hook sees it (#3804). */
|
|
363
|
-
export interface OpWaveHookRun {
|
|
364
|
-
target: string;
|
|
365
|
-
op: string;
|
|
366
|
-
/** The run's plan file, once planned. */
|
|
367
|
-
plan?: string;
|
|
368
|
-
}
|
|
369
|
-
|
|
370
356
|
/**
|
|
371
357
|
* What chant hands an apply command in `CHANT_HOOKS` (#3804), and what
|
|
372
358
|
* {@link opWaveHooksAtBase} builds for an apply outside the waves. Paths are
|
|
@@ -442,15 +428,6 @@ export interface OpWaveApplyHooksRun {
|
|
|
442
428
|
proceed: boolean;
|
|
443
429
|
}
|
|
444
430
|
|
|
445
|
-
/** Runs one argv; returns its exit code. `env` is added to the job's environment. Injected so tests run no processes. */
|
|
446
|
-
export type OpWaveExec = (argv: string[], cwd: string, env?: Record<string, string>) => number;
|
|
447
|
-
|
|
448
|
-
const spawnExec: OpWaveExec = (argv, cwd, env) => {
|
|
449
|
-
const result = spawnSync(argv[0]!, argv.slice(1), { cwd, stdio: "inherit", ...(env ? { env: { ...process.env, ...env } } : {}) });
|
|
450
|
-
if (result.error) throw result.error;
|
|
451
|
-
return result.status ?? 1;
|
|
452
|
-
};
|
|
453
|
-
|
|
454
431
|
/** Reads one file at one commit, or null when the commit has no such file. Injected for tests. */
|
|
455
432
|
export type OpWaveShowAtBase = (base: string, path: string, cwd: string) => { sha: string; text: string | null };
|
|
456
433
|
|
|
@@ -581,22 +558,6 @@ function pointAtBase(spec: OpWavesSpec, name: string, where: string): Pick<OpWav
|
|
|
581
558
|
return { point: opWavePointAt(spec, name)! };
|
|
582
559
|
}
|
|
583
560
|
|
|
584
|
-
/** Fill one argv template. A placeholder whose value is not given is left as it is. */
|
|
585
|
-
export function opWaveArgv(
|
|
586
|
-
template: readonly string[],
|
|
587
|
-
values: { op: string; wave: string; target?: string; plan?: string; runs?: string; plans?: string },
|
|
588
|
-
): string[] {
|
|
589
|
-
const pairs: Array<[string, string | undefined]> = [
|
|
590
|
-
["{op}", values.op],
|
|
591
|
-
["{target}", values.target],
|
|
592
|
-
["{wave}", values.wave],
|
|
593
|
-
["{plans}", values.plans],
|
|
594
|
-
["{plan}", values.plan],
|
|
595
|
-
["{runs}", values.runs],
|
|
596
|
-
];
|
|
597
|
-
return template.map((part) => pairs.reduce((out, [key, value]) => (value === undefined ? out : out.replaceAll(key, value)), part));
|
|
598
|
-
}
|
|
599
|
-
|
|
600
561
|
/** Where a run's plan file goes. */
|
|
601
562
|
export function opWavePlanPath(spec: Pick<OpWavesSpec, "name">, wave: number, target: string): string {
|
|
602
563
|
return `.chant/op-waves/${spec.name}/wave-${wave}/${target.replace(/[^A-Za-z0-9._-]/g, "_")}.plan.json`;
|
|
@@ -853,16 +814,6 @@ function extraMembers(value: unknown, targets: ReadonlySet<string>, where: strin
|
|
|
853
814
|
return out.length > 0 ? out : undefined;
|
|
854
815
|
}
|
|
855
816
|
|
|
856
|
-
/** A plans file's `pending` (#3821): an object whose keys chant does not write itself, `description` aside. Undefined when absent or empty. */
|
|
857
|
-
function pendingFields(value: unknown, where: string): Record<string, unknown> | undefined {
|
|
858
|
-
if (value === undefined) return undefined;
|
|
859
|
-
if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(`${where}: \`pending\` must be an object of fields for the pending fact`);
|
|
860
|
-
const reserved = Object.keys(value).filter((key) => (OP_WAVE_PENDING_RESERVED as readonly string[]).includes(key));
|
|
861
|
-
if (reserved.length > 0) throw new Error(`${where}: \`pending\` sets ${reserved.join(", ")}, which chant writes itself`);
|
|
862
|
-
if ("description" in value && typeof (value as { description?: unknown }).description !== "string") throw new Error(`${where}: pending.description must be a string`);
|
|
863
|
-
return Object.keys(value).length > 0 ? (value as Record<string, unknown>) : undefined;
|
|
864
|
-
}
|
|
865
|
-
|
|
866
817
|
/**
|
|
867
818
|
* A wave's set digest (#3821): {@link waveSetDigest} over its runs' plans,
|
|
868
819
|
* or under `digest.over: "changing"` over the runs whose plan is not empty,
|
|
@@ -883,115 +834,6 @@ interface HooksOutcome {
|
|
|
883
834
|
waitingOn?: OpWaveHeldOn;
|
|
884
835
|
}
|
|
885
836
|
|
|
886
|
-
/** A hook call that held the wave on another gate (#3821), kept so it can run again once that gate is approved. */
|
|
887
|
-
interface OtherGateHold {
|
|
888
|
-
label: string;
|
|
889
|
-
name: string;
|
|
890
|
-
moment: OpWaveHookMoment;
|
|
891
|
-
target?: string;
|
|
892
|
-
argv: string[];
|
|
893
|
-
env: Record<string, string>;
|
|
894
|
-
request: OpWaveHoldRequest;
|
|
895
|
-
}
|
|
896
|
-
|
|
897
|
-
/** Read the hold file a hook wrote (#3821), or undefined when it wrote none or one that names no gate and digest. */
|
|
898
|
-
function readHoldRequest(path: string, log: (line: string) => void, label: string): OpWaveHoldRequest | undefined {
|
|
899
|
-
if (!existsSync(path)) return undefined;
|
|
900
|
-
try {
|
|
901
|
-
const r = JSON.parse(readFileSync(path, "utf-8")) as Partial<OpWaveHoldRequest>;
|
|
902
|
-
if (typeof r.gate !== "string" || r.gate === "" || typeof r.digest !== "string" || r.digest === "") throw new Error("it names no gate and digest");
|
|
903
|
-
if (r.op !== undefined && (typeof r.op !== "string" || r.op === "")) throw new Error("its op is not a name");
|
|
904
|
-
return { gate: r.gate, digest: r.digest, ...(r.op ? { op: r.op } : {}), ...(typeof r.description === "string" ? { description: r.description } : {}), ...(r.pending ? { pending: pendingFields(r.pending, `the hold ${label} wrote`) ?? {} } : {}) };
|
|
905
|
-
} catch (err) {
|
|
906
|
-
log(`${label} wrote a hold file that cannot be read (${err instanceof Error ? err.message : String(err)}), so it holds the wave on its set digest`);
|
|
907
|
-
return undefined;
|
|
908
|
-
}
|
|
909
|
-
}
|
|
910
|
-
|
|
911
|
-
/** Where hooks run: the wave they belong to and the job's tools. */
|
|
912
|
-
interface HookPlace {
|
|
913
|
-
/** The spec's name and `op`. */
|
|
914
|
-
name: string;
|
|
915
|
-
op: string;
|
|
916
|
-
waveName: string;
|
|
917
|
-
k: number;
|
|
918
|
-
cwd: string;
|
|
919
|
-
exec: OpWaveExec;
|
|
920
|
-
log: (line: string) => void;
|
|
921
|
-
/** Where a hook writes a hold on another gate (`CHANT_HOLD`). Default: under the wave's directory. */
|
|
922
|
-
holdPath?: string;
|
|
923
|
-
}
|
|
924
|
-
|
|
925
|
-
/**
|
|
926
|
-
* Run one moment's hooks, in order: each once for the wave, or once per run
|
|
927
|
-
* for a `perRun` hook. A hook that fails stops the rest, unless its
|
|
928
|
-
* `onFailure` is `approve`: then it holds the wave and the hooks after it run.
|
|
929
|
-
*/
|
|
930
|
-
function runHookList(
|
|
931
|
-
at: HookPlace,
|
|
932
|
-
moment: OpWaveHookMoment,
|
|
933
|
-
hooks: readonly OpWaveHook[] | undefined,
|
|
934
|
-
runs: readonly OpWaveHookRun[],
|
|
935
|
-
runsPath: string,
|
|
936
|
-
extraEnv: Record<string, string> = {},
|
|
937
|
-
): HooksOutcome & { outcomes: OpWaveHookOutcome[]; otherGates: OtherGateHold[] } {
|
|
938
|
-
const out: HooksOutcome & { outcomes: OpWaveHookOutcome[]; otherGates: OtherGateHold[] } = { holds: [], outcomes: [], otherGates: [] };
|
|
939
|
-
if (!hooks || hooks.length === 0) return out;
|
|
940
|
-
const { name: specName, op, waveName, k, cwd, exec, log } = at;
|
|
941
|
-
// #3821: a hook that holds the wave on another gate names it in this file.
|
|
942
|
-
const holdPath = at.holdPath ?? `.chant/op-waves/${specName}/wave-${k}/hold.json`;
|
|
943
|
-
const env = { CHANT_OP_WAVES: specName, CHANT_WAVE: waveName, CHANT_WAVE_NUMBER: String(k), CHANT_HOOK: moment, CHANT_RUNS: runsPath, CHANT_HOLD: holdPath, ...extraEnv };
|
|
944
|
-
for (const hook of hooks) {
|
|
945
|
-
const name = hook.name ?? (hook.command.join(" ").length > 60 ? `${hook.command.join(" ").slice(0, 57)}...` : hook.command.join(" "));
|
|
946
|
-
// #4288: a hook with `targets` is for the runs they match. Once for the wave, it runs when one matches or no run is known yet.
|
|
947
|
-
const mine = opWaveHookRuns(hook, runs);
|
|
948
|
-
if (hook.targets && (hook.perRun ? mine.length === 0 : mine.length === 0 && runs.length > 0)) {
|
|
949
|
-
log(`${moment} hook ${name}: no run matches its targets (${hook.targets.join(", ")}), so it did not run`);
|
|
950
|
-
continue;
|
|
951
|
-
}
|
|
952
|
-
const calls = hook.perRun
|
|
953
|
-
? mine.map((run) => ({
|
|
954
|
-
target: run.target as string | undefined,
|
|
955
|
-
argv: opWaveArgv(hook.command, { op: run.op, wave: waveName, target: run.target, runs: runsPath, ...(run.plan ? { plan: run.plan } : {}) }),
|
|
956
|
-
env: { ...env, CHANT_TARGET: run.target, ...(run.plan ? { CHANT_PLAN: run.plan } : {}) } as Record<string, string>,
|
|
957
|
-
}))
|
|
958
|
-
: [{ target: undefined, argv: opWaveArgv(hook.command, { op, wave: waveName, runs: runsPath }), env: env as Record<string, string> }];
|
|
959
|
-
for (const call of calls) {
|
|
960
|
-
const label = `${moment} hook ${name}${call.target ? ` (${call.target})` : ""}`;
|
|
961
|
-
let code: number;
|
|
962
|
-
rmSync(join(cwd, holdPath), { force: true });
|
|
963
|
-
try {
|
|
964
|
-
code = exec(call.argv, cwd, call.env);
|
|
965
|
-
} catch (err) {
|
|
966
|
-
log(`${label} did not run: ${err instanceof Error ? err.message : String(err)}`);
|
|
967
|
-
code = 127;
|
|
968
|
-
}
|
|
969
|
-
const base = { moment, name, ...(call.target ? { target: call.target } : {}), exit: code };
|
|
970
|
-
if (code === 0) {
|
|
971
|
-
out.outcomes.push({ ...base, outcome: "passed" });
|
|
972
|
-
continue;
|
|
973
|
-
}
|
|
974
|
-
if (hook.onFailure === "approve") {
|
|
975
|
-
out.outcomes.push({ ...base, outcome: "held" });
|
|
976
|
-
const request = readHoldRequest(join(cwd, holdPath), log, label);
|
|
977
|
-
if (request) {
|
|
978
|
-
out.otherGates.push({ label, name, moment, ...(call.target ? { target: call.target } : {}), argv: call.argv, env: call.env, request });
|
|
979
|
-
log(`${label} exited ${code} and holds wave ${k} (${waveName}) on gate ${request.op ?? specName} / ${request.gate}, digest ${request.digest}`);
|
|
980
|
-
continue;
|
|
981
|
-
}
|
|
982
|
-
out.holds.push(label);
|
|
983
|
-
log(`${label} exited ${code}, so wave ${k} (${waveName}) waits for an approval of its set digest`);
|
|
984
|
-
continue;
|
|
985
|
-
}
|
|
986
|
-
out.outcomes.push({ ...base, outcome: "failed" });
|
|
987
|
-
out.failed = `${label} exited ${code}`;
|
|
988
|
-
log(out.failed);
|
|
989
|
-
return out;
|
|
990
|
-
}
|
|
991
|
-
}
|
|
992
|
-
return out;
|
|
993
|
-
}
|
|
994
|
-
|
|
995
837
|
const placeOf = (ctx: WaveContext): HookPlace => ({ name: ctx.spec.name, op: ctx.spec.op, waveName: ctx.wave.name, k: ctx.k, cwd: ctx.cwd, exec: ctx.exec, log: ctx.log });
|
|
996
838
|
|
|
997
839
|
const hookRuns = (spec: OpWavesSpec, k: number, runs: readonly OpWaveRun[], members?: ReadonlyMap<string, OpWaveMember>): OpWaveHookRun[] =>
|
|
@@ -1076,52 +918,6 @@ async function runHooks(
|
|
|
1076
918
|
return back;
|
|
1077
919
|
}
|
|
1078
920
|
|
|
1079
|
-
/** The options of {@link runStageHooks} (#4288). */
|
|
1080
|
-
export interface RunStageHooksOptions {
|
|
1081
|
-
/** What the hooks belong to: `CHANT_OP_WAVES`, and the `{op}` of a hook that runs once. */
|
|
1082
|
-
name: string;
|
|
1083
|
-
op: string;
|
|
1084
|
-
/** `{wave}` and `CHANT_WAVE`. */
|
|
1085
|
-
wave: string;
|
|
1086
|
-
/** `CHANT_WAVE_NUMBER`. Default 0. */
|
|
1087
|
-
k?: number;
|
|
1088
|
-
/** The runs a `perRun` hook runs for and `targets` match against. */
|
|
1089
|
-
runs: readonly OpWaveHookRun[];
|
|
1090
|
-
/** `{runs}` and `CHANT_RUNS`, relative to `cwd`; the caller writes it. */
|
|
1091
|
-
runsFile: string;
|
|
1092
|
-
/** `CHANT_HOLD`, relative to `cwd`. */
|
|
1093
|
-
holdFile: string;
|
|
1094
|
-
/** Added to every hook's environment. */
|
|
1095
|
-
env?: Record<string, string>;
|
|
1096
|
-
cwd?: string;
|
|
1097
|
-
exec?: OpWaveExec;
|
|
1098
|
-
log?: (line: string) => void;
|
|
1099
|
-
}
|
|
1100
|
-
|
|
1101
|
-
/** What {@link runStageHooks} gave. */
|
|
1102
|
-
export interface StageHooksRun {
|
|
1103
|
-
outcomes: OpWaveHookOutcome[];
|
|
1104
|
-
/** Set when a hook with `onFailure: fail` failed; the hooks after it did not run. */
|
|
1105
|
-
failed?: string;
|
|
1106
|
-
/** The `onFailure: approve` hooks that failed. A hold on another gate counts as one here. */
|
|
1107
|
-
holds: string[];
|
|
1108
|
-
}
|
|
1109
|
-
|
|
1110
|
-
/**
|
|
1111
|
-
* Run one moment's hooks outside an Op waves job (#4288): the pull-request
|
|
1112
|
-
* jobs, or a product's own stage, with the same `targets`, `perRun`,
|
|
1113
|
-
* `onFailure` and argv filling a wave job uses. A hook that holds on another
|
|
1114
|
-
* gate (`CHANT_HOLD`) is counted as a plain hold; settling that gate is the
|
|
1115
|
-
* caller's.
|
|
1116
|
-
*/
|
|
1117
|
-
export function runStageHooks(moment: OpWaveHookMoment, hooks: readonly OpWaveHook[] | undefined, options: RunStageHooksOptions): StageHooksRun {
|
|
1118
|
-
const cwd = options.cwd ?? process.cwd();
|
|
1119
|
-
const log = options.log ?? ((line: string) => console.error(line));
|
|
1120
|
-
const place: HookPlace = { name: options.name, op: options.op, waveName: options.wave, k: options.k ?? 0, cwd, exec: options.exec ?? spawnExec, log, holdPath: options.holdFile };
|
|
1121
|
-
const out = runHookList(place, moment, hooks, options.runs, options.runsFile, options.env);
|
|
1122
|
-
return { outcomes: out.outcomes, ...(out.failed ? { failed: out.failed } : {}), holds: [...out.holds, ...out.otherGates.map((g) => `${g.label} (on ${g.request.op ?? options.name} / ${g.request.gate})`)] };
|
|
1123
|
-
}
|
|
1124
|
-
|
|
1125
921
|
/**
|
|
1126
922
|
* The hooks that run before a job plans (#4288): `before-observe`, then
|
|
1127
923
|
* `before-plan`. A failure or a hold on another gate in the first ends it
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runStageHooksAsync (#4334): the async stage runner gives what the sync one
|
|
3
|
+
* gives for the same hooks, and two calls run side by side.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, test } from "vitest";
|
|
6
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import type { OpWaveHook } from "./op-waves";
|
|
10
|
+
import { runStageHooks, runStageHooksAsync, spawnExecAsync, type OpWaveAsyncExec, type OpWaveExec } from "./stage-hooks";
|
|
11
|
+
|
|
12
|
+
const hooks: OpWaveHook[] = [
|
|
13
|
+
{ name: "each", command: ["hook", "each", "{target}", "{op}"], perRun: true, targets: ["api"] },
|
|
14
|
+
{ name: "gate", command: ["hook", "gate", "{wave}"], onFailure: "approve" },
|
|
15
|
+
{ name: "other", command: ["hook", "other"], onFailure: "approve" },
|
|
16
|
+
{ name: "lost", command: ["hook", "lost"] },
|
|
17
|
+
{ name: "stop", command: ["hook", "stop"] },
|
|
18
|
+
{ name: "after", command: ["hook", "after"] },
|
|
19
|
+
];
|
|
20
|
+
const runs = [{ target: "api", op: "pr-7" }, { target: "web", op: "pr-7" }];
|
|
21
|
+
|
|
22
|
+
/** Exit codes by the hook's second word; `other` writes a hold on another gate and `lost` cannot start. */
|
|
23
|
+
function codeOf(argv: string[], cwd: string, env?: Record<string, string>): number {
|
|
24
|
+
if (argv[1] === "other") writeFileSync(join(cwd, env!.CHANT_HOLD!), JSON.stringify({ gate: "dba", digest: "sha256:abc" }));
|
|
25
|
+
if (argv[1] === "lost") throw new Error("spawn hook ENOENT");
|
|
26
|
+
return argv[1] === "gate" || argv[1] === "other" || argv[1] === "stop" ? 1 : 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("runStageHooksAsync (#4334)", () => {
|
|
30
|
+
test("gives the outcomes, holds and failure the sync runner gives", async () => {
|
|
31
|
+
const cwd = mkdtempSync(join(tmpdir(), "stage-hooks-async-"));
|
|
32
|
+
const options = { name: "pr-7", op: "pr-7", wave: "prod", runs, runsFile: "runs.json", holdFile: "hold.json", cwd };
|
|
33
|
+
const syncCalls: string[] = [];
|
|
34
|
+
const syncLog: string[] = [];
|
|
35
|
+
const exec: OpWaveExec = (argv, at, env) => (syncCalls.push(argv.join(" ")), codeOf(argv, at, env));
|
|
36
|
+
const sync = runStageHooks("before-plan", hooks, { ...options, exec, log: (l) => syncLog.push(l) });
|
|
37
|
+
const asyncCalls: string[] = [];
|
|
38
|
+
const asyncLog: string[] = [];
|
|
39
|
+
const asyncExec: OpWaveAsyncExec = async (argv, at, env) => (asyncCalls.push(argv.join(" ")), codeOf(argv, at, env));
|
|
40
|
+
const ran = await runStageHooksAsync("before-plan", hooks, { ...options, exec: asyncExec, log: (l) => asyncLog.push(l) });
|
|
41
|
+
expect(ran).toEqual(sync);
|
|
42
|
+
expect(asyncCalls).toEqual(syncCalls);
|
|
43
|
+
expect(asyncLog).toEqual(syncLog);
|
|
44
|
+
expect(asyncCalls).toEqual(["hook each api pr-7", "hook gate prod", "hook other", "hook lost"]);
|
|
45
|
+
expect(ran.failed).toBe("before-plan hook lost exited 127");
|
|
46
|
+
expect(ran.holds).toEqual(["before-plan hook gate", "before-plan hook other (on pr-7 / dba)"]);
|
|
47
|
+
expect(ran.outcomes.map((o) => o.outcome)).toEqual(["passed", "held", "held", "failed"]);
|
|
48
|
+
expect(asyncLog).toContain("before-plan hook lost did not run: spawn hook ENOENT");
|
|
49
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("two calls run side by side", async () => {
|
|
53
|
+
const cwd = mkdtempSync(join(tmpdir(), "stage-hooks-async-"));
|
|
54
|
+
const started: string[] = [];
|
|
55
|
+
let release!: () => void;
|
|
56
|
+
const both = new Promise<void>((resolve) => (release = resolve));
|
|
57
|
+
const exec: OpWaveAsyncExec = async (argv) => {
|
|
58
|
+
started.push(argv[1]!);
|
|
59
|
+
if (started.length === 2) release();
|
|
60
|
+
await both;
|
|
61
|
+
return 0;
|
|
62
|
+
};
|
|
63
|
+
const one = (name: string) =>
|
|
64
|
+
runStageHooksAsync("before-plan", [{ command: ["hook", name] }], { name, op: name, wave: "prod", runs: [], runsFile: `${name}/runs.json`, holdFile: `${name}/hold.json`, cwd, exec, log: () => {} });
|
|
65
|
+
const [a, b] = await Promise.all([one("a"), one("b")]);
|
|
66
|
+
expect(started.sort()).toEqual(["a", "b"]);
|
|
67
|
+
expect([a.outcomes[0]!.outcome, b.outcomes[0]!.outcome]).toEqual(["passed", "passed"]);
|
|
68
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("the default exec spawns the hook, gives its exit code and rejects a command that cannot start", async () => {
|
|
72
|
+
const cwd = mkdtempSync(join(tmpdir(), "stage-hooks-async-"));
|
|
73
|
+
expect(await spawnExecAsync([process.execPath, "-e", "process.exit(process.env.CODE === '3' ? 3 : 0)"], cwd, { CODE: "3" })).toBe(3);
|
|
74
|
+
await expect(spawnExecAsync(["./no-such-hook"], cwd)).rejects.toThrow(/ENOENT/);
|
|
75
|
+
const ran = await runStageHooksAsync("after-apply", [{ name: "ok", command: [process.execPath, "-e", "process.exit(0)"] }], { name: "s", op: "s", wave: "w", runs: [], runsFile: "runs.json", holdFile: "hold.json", cwd, log: () => {} });
|
|
76
|
+
expect(ran).toEqual({ outcomes: [{ moment: "after-apply", name: "ok", exit: 0, outcome: "passed" }], holds: [] });
|
|
77
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
78
|
+
});
|
|
79
|
+
});
|