@intentius/chant 0.124.33 → 0.124.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/handlers/graph.js +2 -2
- package/dist/cli/handlers/pr.d.ts.map +1 -1
- package/dist/cli/handlers/pr.js +147 -51
- package/dist/cli/handlers/promote.d.ts.map +1 -1
- package/dist/cli/handlers/promote.js +284 -81
- package/dist/cli/handlers/release.d.ts +12 -0
- package/dist/cli/handlers/release.d.ts.map +1 -1
- package/dist/cli/handlers/release.js +125 -10
- package/dist/cli/handlers/run.d.ts +3 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/handlers/run.js +12 -2
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +37 -2
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +24 -0
- package/dist/cli/registry.d.ts +17 -1
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/auto-release.d.ts +8 -1
- package/dist/components/auto-release.d.ts.map +1 -1
- package/dist/components/auto-release.js +3 -2
- package/dist/components/green-cover.d.ts +77 -0
- package/dist/components/green-cover.d.ts.map +1 -0
- package/dist/components/green-cover.js +153 -0
- package/dist/components/pr-pipeline.d.ts +34 -0
- package/dist/components/pr-pipeline.d.ts.map +1 -1
- package/dist/components/pr-pipeline.js +39 -5
- package/dist/components/promote-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/promote.d.ts +4 -1
- package/dist/components/promote.d.ts.map +1 -1
- package/dist/components/promote.js +6 -3
- package/dist/components/release-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/components/release-promote.d.ts +9 -0
- package/dist/components/release-promote.d.ts.map +1 -1
- package/dist/components/release-promote.js +15 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/deep-observation.d.ts +11 -0
- package/dist/deep-observation.d.ts.map +1 -1
- package/dist/graph-declared.d.ts +9 -1
- package/dist/graph-declared.d.ts.map +1 -1
- package/dist/graph-declared.js +18 -3
- package/dist/graph-detail.d.ts.map +1 -1
- package/dist/graph-detail.js +30 -1
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/graph-ir.js +13 -2
- package/dist/lifecycle/__fixtures__/publish-fakes.d.ts.map +1 -1
- package/dist/lifecycle/__fixtures__/publish-fakes.js +5 -1
- package/dist/lifecycle/compose-refs.d.ts +52 -0
- package/dist/lifecycle/compose-refs.d.ts.map +1 -0
- package/dist/lifecycle/compose-refs.js +115 -0
- package/dist/lifecycle/deep-observe.d.ts.map +1 -1
- package/dist/lifecycle/deep-observe.js +3 -2
- package/dist/lifecycle/green-apply.d.ts +39 -0
- package/dist/lifecycle/green-apply.d.ts.map +1 -0
- package/dist/lifecycle/green-apply.js +51 -0
- package/dist/lifecycle/publish-declaration.d.ts +4 -0
- package/dist/lifecycle/publish-declaration.d.ts.map +1 -1
- package/dist/lifecycle/publish-declaration.js +15 -0
- package/dist/lifecycle/publish-npm.d.ts +7 -4
- package/dist/lifecycle/publish-npm.d.ts.map +1 -1
- package/dist/lifecycle/publish-npm.js +60 -13
- package/dist/lifecycle/release-compose.d.ts +91 -0
- package/dist/lifecycle/release-compose.d.ts.map +1 -0
- package/dist/lifecycle/release-compose.js +235 -0
- package/dist/lifecycle/release-cut.d.ts +8 -0
- package/dist/lifecycle/release-cut.d.ts.map +1 -1
- package/dist/lifecycle/release-cut.js +2 -0
- package/dist/lifecycle/release-publish.d.ts +2 -0
- package/dist/lifecycle/release-publish.d.ts.map +1 -1
- package/dist/lifecycle/release-publish.js +2 -1
- package/dist/lifecycle/release-store.d.ts +15 -0
- package/dist/lifecycle/release-store.d.ts.map +1 -1
- package/dist/lifecycle/release-store.js +5 -1
- package/dist/wave-runtime.mjs +226 -224
- 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 +130 -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-green-cli.d.ts.map +1 -1
- package/dist/workspace/ci-green-cli.js +5 -3
- package/dist/workspace/ci-green-workflow.d.ts +18 -2
- package/dist/workspace/ci-green-workflow.d.ts.map +1 -1
- package/dist/workspace/ci-green-workflow.js +27 -5
- package/dist/workspace/ci-green.d.ts +31 -0
- package/dist/workspace/ci-green.d.ts.map +1 -1
- package/dist/workspace/ci-green.js +78 -4
- package/dist/workspace/ci-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-run.d.ts +6 -0
- package/dist/workspace/ci-run.d.ts.map +1 -1
- package/dist/workspace/ci-run.js +2 -1
- package/dist/workspace/ci-serve.d.ts +94 -13
- package/dist/workspace/ci-serve.d.ts.map +1 -1
- package/dist/workspace/ci-serve.js +248 -30
- package/dist/workspace/ci-workflow.d.ts +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/delivery-metrics.d.ts +3 -1
- package/dist/workspace/delivery-metrics.d.ts.map +1 -1
- package/dist/workspace/delivery-metrics.js +12 -3
- package/dist/workspace/init.js +2 -2
- package/dist/workspace/member-commands.d.ts.map +1 -1
- package/dist/workspace/member-commands.js +4 -0
- package/dist/workspace/nested-graph.d.ts.map +1 -1
- package/dist/workspace/nested-graph.js +1 -0
- package/dist/workspace/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-candidates.d.ts +28 -0
- package/dist/workspace/release-candidates.d.ts.map +1 -1
- package/dist/workspace/release-candidates.js +38 -2
- package/dist/workspace/release-candidates.schema.json +51 -0
- package/dist/workspace/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/dist/workspace/releases.schema.json +30 -0
- package/package.json +1 -1
- package/src/cli/handlers/graph.ts +2 -2
- package/src/cli/handlers/pr-green.test.ts +230 -0
- package/src/cli/handlers/pr.ts +148 -66
- package/src/cli/handlers/promote.test.ts +34 -0
- package/src/cli/handlers/promote.ts +302 -91
- package/src/cli/handlers/release-compose.e2e.test.ts +133 -0
- package/src/cli/handlers/release.ts +112 -10
- package/src/cli/handlers/run.ts +12 -1
- package/src/cli/main.ts +37 -2
- package/src/cli/parse-args.ts +18 -0
- package/src/cli/registry.ts +17 -1
- package/src/codegen/release-wiring.test.ts +56 -9
- package/src/components/auto-release.test.ts +22 -0
- package/src/components/auto-release.ts +9 -2
- package/src/components/green-cover.test.ts +170 -0
- package/src/components/green-cover.ts +186 -0
- package/src/components/pr-pipeline.test.ts +14 -4
- package/src/components/pr-pipeline.ts +56 -5
- package/src/components/promote-rounds.test.ts +220 -0
- package/src/components/promote-rounds.ts +232 -0
- package/src/components/promote.test.ts +7 -0
- package/src/components/promote.ts +9 -4
- package/src/components/release-gate.test.ts +47 -0
- package/src/components/release-gate.ts +111 -46
- package/src/components/release-promote.test.ts +24 -1
- package/src/components/release-promote.ts +15 -0
- package/src/deep-observation.ts +8 -0
- package/src/graph-declared-exports.test.ts +40 -0
- package/src/graph-declared.test.ts +18 -0
- package/src/graph-declared.ts +17 -2
- package/src/graph-detail.test.ts +68 -1
- package/src/graph-detail.ts +29 -1
- package/src/graph-ir.ts +12 -4
- package/src/lifecycle/__fixtures__/publish-fakes.ts +5 -1
- package/src/lifecycle/compose-refs.ts +125 -0
- package/src/lifecycle/deep-observe.test.ts +20 -0
- package/src/lifecycle/deep-observe.ts +4 -2
- package/src/lifecycle/green-apply.ts +71 -0
- package/src/lifecycle/publish-declaration.ts +15 -0
- package/src/lifecycle/publish-npm.ts +55 -13
- package/src/lifecycle/publish-targets.test.ts +45 -0
- package/src/lifecycle/release-compose.test.ts +125 -0
- package/src/lifecycle/release-compose.ts +312 -0
- package/src/lifecycle/release-cut.ts +6 -0
- package/src/lifecycle/release-publish.ts +4 -1
- package/src/lifecycle/release-store.ts +16 -1
- package/src/workspace/__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 +160 -0
- package/src/workspace/checks/ci-workflow.ts +135 -0
- package/src/workspace/checks/security.ts +4 -4
- package/src/workspace/checks.ts +4 -1
- package/src/workspace/ci-green-cli.ts +5 -3
- package/src/workspace/ci-green-workflow.test.ts +52 -0
- package/src/workspace/ci-green-workflow.ts +34 -5
- package/src/workspace/ci-green.test.ts +33 -0
- package/src/workspace/ci-green.ts +85 -4
- package/src/workspace/ci-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-run.test.ts +18 -13
- package/src/workspace/ci-run.ts +8 -1
- package/src/workspace/ci-serve-land.e2e.test.ts +138 -0
- package/src/workspace/ci-serve-land.test.ts +176 -0
- package/src/workspace/ci-serve.test.ts +77 -43
- package/src/workspace/ci-serve.ts +268 -34
- package/src/workspace/ci-workflow.ts +71 -0
- package/src/workspace/declaration.schema.json +28 -0
- package/src/workspace/declaration.ts +32 -3
- package/src/workspace/delivery-metrics.ts +12 -3
- package/src/workspace/graph-contract.test.ts +3 -2
- package/src/workspace/init.ts +2 -2
- package/src/workspace/member-commands.test.ts +11 -4
- package/src/workspace/member-commands.ts +3 -0
- package/src/workspace/nested-graph.test.ts +4 -0
- package/src/workspace/nested-graph.ts +1 -0
- package/src/workspace/reason-codes.test.ts +2 -0
- package/src/workspace/reason-codes.ts +10 -0
- package/src/workspace/release-candidates.schema.json +51 -0
- package/src/workspace/release-candidates.test.ts +30 -3
- package/src/workspace/release-candidates.ts +54 -2
- package/src/workspace/release-workflow.ts +167 -0
- package/src/workspace/releases.schema.json +30 -0
|
@@ -196,10 +196,23 @@ export interface PrLoopJob {
|
|
|
196
196
|
env: Record<string, string>;
|
|
197
197
|
/** Shell lines to run before the command, to settle values the forge may leave unusable. */
|
|
198
198
|
setup?: string[];
|
|
199
|
+
/**
|
|
200
|
+
* Shell lines a generated job runs first, before `setup`, to check out the
|
|
201
|
+
* commit it works on (#4168 step 9): the apply checks out the newest green
|
|
202
|
+
* commit. `chant ci run` and `ci serve` already run the job at that commit,
|
|
203
|
+
* so they skip these.
|
|
204
|
+
*/
|
|
205
|
+
checkout?: string[];
|
|
199
206
|
/** With `build` (#4088): the build line and the lint line, run in this order before the command. */
|
|
200
207
|
build?: string[];
|
|
201
208
|
/** With `waveGate` (#4003): the wave a generated apply job runs, 1-based. */
|
|
202
209
|
wave?: number;
|
|
210
|
+
/**
|
|
211
|
+
* The branch an apply job takes its green commit from (#4168 step 9). A
|
|
212
|
+
* wave after the first checks out the commit wave 1 took, which the job
|
|
213
|
+
* receives as `GREEN_SHA` (#4221).
|
|
214
|
+
*/
|
|
215
|
+
greenBranch?: string;
|
|
203
216
|
/**
|
|
204
217
|
* What the generated job runs in place of `command` (#4156): `chant ci run
|
|
205
218
|
* <job> --in-place` with the same environment, gate, forge, events and
|
|
@@ -241,7 +254,7 @@ export function prLoopJobs(forge: ForgeKind, env: string, options: PrLoopPipelin
|
|
|
241
254
|
// #4156: the flags `chant ci run` rebuilds `command` from. It finds the member from the directory it runs in.
|
|
242
255
|
const ciRun = (job: PrLoopJob["jobName"]): string[] => [
|
|
243
256
|
"chant", "ci", "run", job, "--in-place", ...(job === "plan" ? ["--no-push"] : []),
|
|
244
|
-
"--base", '"$BASE_SHA"', ...(job === "plan" ? ["--pr", '"$PR_NUMBER"'] : []),
|
|
257
|
+
...(job === "apply" ? [] : ["--base", '"$BASE_SHA"']), ...(job === "plan" ? ["--pr", '"$PR_NUMBER"'] : []),
|
|
245
258
|
"--env", env, "--gate", gate, "--forge", forge,
|
|
246
259
|
...(job !== "record" && options.waveGate ? ["--wave-gate", options.waveGate] : []),
|
|
247
260
|
...(job !== "plan" && options.branch ? ["--branch", JSON.stringify(options.branch)] : []),
|
|
@@ -254,6 +267,7 @@ export function prLoopJobs(forge: ForgeKind, env: string, options: PrLoopPipelin
|
|
|
254
267
|
const planEnv = github
|
|
255
268
|
? { BASE_SHA: "${{ github.event.pull_request.base.sha }}", PR_NUMBER: "${{ github.event.pull_request.number }}", ...jobEnv }
|
|
256
269
|
: { BASE_SHA: "$CI_MERGE_REQUEST_DIFF_BASE_SHA", PR_NUMBER: "$CI_MERGE_REQUEST_IID", ...jobEnv };
|
|
270
|
+
const greenBranch = github ? JSON.stringify(options.branch ?? "main") : options.branch ? JSON.stringify(options.branch) : "$CI_DEFAULT_BRANCH";
|
|
257
271
|
const applyEnv = github ? { BASE_SHA: "${{ github.event.before }}", ...jobEnv } : { BASE_SHA: "$CI_COMMIT_BEFORE_SHA", ...jobEnv };
|
|
258
272
|
return [
|
|
259
273
|
{
|
|
@@ -265,17 +279,19 @@ export function prLoopJobs(forge: ForgeKind, env: string, options: PrLoopPipelin
|
|
|
265
279
|
run: { command: ciRun("plan").join(" "), env: runEnv(planEnv) },
|
|
266
280
|
},
|
|
267
281
|
{
|
|
282
|
+
// #4168 step 9: the apply applies the newest green commit, measured from the one applied before it.
|
|
268
283
|
jobName: "apply",
|
|
269
284
|
command: [
|
|
270
|
-
"chant", "components", "pr-apply", "--
|
|
285
|
+
"chant", "components", "pr-apply", "--green", ...common,
|
|
271
286
|
...(options.requireReview === true ? ["--require-review"] : []),
|
|
272
287
|
...(options.waveGate ? ["--wave-gate", options.waveGate] : []),
|
|
273
288
|
"--resume", PR_APPLY_RECORD,
|
|
274
289
|
].join(" "),
|
|
275
|
-
env:
|
|
276
|
-
|
|
290
|
+
env: jobEnv,
|
|
291
|
+
checkout: [greenCheckout(greenBranch)],
|
|
292
|
+
greenBranch,
|
|
277
293
|
...build,
|
|
278
|
-
run: { command: ciRun("apply").join(" "), env: runEnv(
|
|
294
|
+
run: { command: ciRun("apply").join(" "), env: runEnv(jobEnv) },
|
|
279
295
|
},
|
|
280
296
|
{
|
|
281
297
|
jobName: "record",
|
|
@@ -288,6 +304,15 @@ export function prLoopJobs(forge: ForgeKind, env: string, options: PrLoopPipelin
|
|
|
288
304
|
];
|
|
289
305
|
}
|
|
290
306
|
|
|
307
|
+
/**
|
|
308
|
+
* What starts the apply under apply on merge (#4168 step 9): on GitHub the
|
|
309
|
+
* tick's workflow finishing (`workflow_run`, since tags its token pushes start
|
|
310
|
+
* no workflow), elsewhere a pushed `ci/green/*` tag. The name is
|
|
311
|
+
* `CI_GREEN_WORKFLOW_NAME` (../workspace/ci-green-workflow.ts).
|
|
312
|
+
*/
|
|
313
|
+
export const GREEN_TICK_WORKFLOW = "chant-ci-green";
|
|
314
|
+
export const GREEN_TAG_PATTERN = "ci/green/**";
|
|
315
|
+
|
|
291
316
|
/** The job a push to the target branch runs (#4168): `record`, or `apply` when the pipeline opts into apply on merge. */
|
|
292
317
|
export function prLoopMergeJob(jobs: readonly PrLoopJob[], options: PrLoopPipelineOptions = {}): PrLoopJob {
|
|
293
318
|
return jobs.find((j) => j.jobName === (options.applyOnMerge ? "apply" : "record"))!;
|
|
@@ -326,6 +351,8 @@ export function prLoopWaveJobs(components: DriverComponent[], apply: PrLoopJob):
|
|
|
326
351
|
...apply,
|
|
327
352
|
command: `${apply.command} --wave ${i + 1}`,
|
|
328
353
|
wave: i + 1,
|
|
354
|
+
// #4221: every wave after the first applies the commit wave 1 took.
|
|
355
|
+
...(i > 0 && apply.greenBranch !== undefined ? { checkout: [pinnedGreenCheckout(apply.greenBranch)] } : {}),
|
|
329
356
|
// #4156: the job runs it through chant ci run, which appends the same --wave to pr-apply.
|
|
330
357
|
run: { ...apply.run, command: `${apply.run.command} --wave ${i + 1}` },
|
|
331
358
|
}));
|
|
@@ -381,6 +408,30 @@ function pushBaseFallback(target: string, commit: string, diffBase?: string): st
|
|
|
381
408
|
);
|
|
382
409
|
}
|
|
383
410
|
|
|
411
|
+
/**
|
|
412
|
+
* The line that checks out the newest green commit of `branch` (#4168 step
|
|
413
|
+
* 9), after fetching the branch and its tags, for the apply job. `chant ci
|
|
414
|
+
* last-green` exits 1 when no commit is green, which fails the job.
|
|
415
|
+
*/
|
|
416
|
+
export function greenCheckout(branch: string): string {
|
|
417
|
+
return `${fetchBranch(branch)} && GREEN_SHA="$(chant ci last-green)" && git checkout --quiet --detach "$GREEN_SHA"`;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* The line a wave after the first runs in place of {@link greenCheckout}
|
|
422
|
+
* (#4221): it checks out `GREEN_SHA`, the commit wave 1 took, so a commit
|
|
423
|
+
* that goes green while the waves run waits for a run of its own. An empty
|
|
424
|
+
* `GREEN_SHA` fails the job.
|
|
425
|
+
*/
|
|
426
|
+
export function pinnedGreenCheckout(branch: string): string {
|
|
427
|
+
return `test -n "$GREEN_SHA" && ${fetchBranch(branch)} && git checkout --quiet --detach "$GREEN_SHA"`;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function fetchBranch(branch: string): string {
|
|
431
|
+
const name = branch.replace(/^"|"$/g, "");
|
|
432
|
+
return `git fetch --quiet --tags origin "+refs/heads/${name}:refs/remotes/origin/${name}"`;
|
|
433
|
+
}
|
|
434
|
+
|
|
384
435
|
/**
|
|
385
436
|
* GitLab sets `CI_COMMIT_BEFORE_SHA` to forty zeros on a branch's first push.
|
|
386
437
|
* The line replaces it with the merge request's diff base when the pipeline
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rounds and per-wave gates at promote (#4168 step 4): a dependent planned
|
|
3
|
+
* against its upstream's planned outputs is planned again once the upstream
|
|
4
|
+
* applied as another value, and `--wave-gate` checks each wave.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from "vitest";
|
|
8
|
+
import { composeChangeSet, type ChangeSetDocument } from "../change-set";
|
|
9
|
+
import type { DriverComponent, DriverRunResult, ReleaseGateBinding } from "./driver";
|
|
10
|
+
import { movedInputs, renameGates, runPromotionInRounds } from "./promote-rounds";
|
|
11
|
+
|
|
12
|
+
const sha = (c: string) => `sha256:${c.repeat(64)}`;
|
|
13
|
+
|
|
14
|
+
const net: DriverComponent = { name: "net", deploy: [{ phase: "Apply", steps: [{ kind: "gate", gate: "prod-release" }, { kind: "tf", name: "net" }] }] };
|
|
15
|
+
const svc: DriverComponent = {
|
|
16
|
+
name: "svc",
|
|
17
|
+
dependsOn: ["net"],
|
|
18
|
+
deploy: [{ phase: "Apply", steps: [{ kind: "gate", gate: "prod-release" }, { kind: "tf", name: "svc", vpc: { stackOutput: { stack: "net", name: "id" } } }] }],
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** A change set of one planned member per name, each at the digest given; `writes` adds an update of that member's `vpc`. */
|
|
22
|
+
const doc = (members: Record<string, string>, writes: Record<string, string> = {}): ChangeSetDocument =>
|
|
23
|
+
composeChangeSet(
|
|
24
|
+
Object.entries(members).map(([member, digest]) => ({
|
|
25
|
+
member: { member, lexicon: "terraform", planner: "terraform" as const, status: "planned" as const, planDigest: digest, holes: [] },
|
|
26
|
+
entries: writes[member]
|
|
27
|
+
? [{ member, lexicon: "terraform", planner: "terraform" as const, address: `${member}.main`, type: "x", name: "main", action: "update" as const, attributes: [{ path: "vpc", after: writes[member] }] }]
|
|
28
|
+
: [],
|
|
29
|
+
})),
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const approved = doc({ net: sha("1"), svc: sha("2") }, { svc: "vpc-new" });
|
|
33
|
+
const binding: ReleaseGateBinding = { op: "release", planDigest: approved.digest, releaseDigest: sha("r") };
|
|
34
|
+
|
|
35
|
+
/** A run that applies every component and leaves `exposes` as its outputs, recording what it was handed. */
|
|
36
|
+
function runner(exposes: Record<string, Record<string, unknown>>) {
|
|
37
|
+
const calls: Array<{ names: string[]; gates: string[]; binding: string; outputs: Record<string, Record<string, unknown>> }> = [];
|
|
38
|
+
const run = async (components: DriverComponent[], outputs: Record<string, Record<string, unknown>>, b: ReleaseGateBinding): Promise<DriverRunResult> => {
|
|
39
|
+
const gates = components.flatMap((c) => c.deploy.flatMap((p) => p.steps.filter((s) => (s as { kind?: string }).kind === "gate").map((s) => (s as { gate: string }).gate)));
|
|
40
|
+
calls.push({ names: components.map((c) => c.name), gates, binding: b.planDigest, outputs });
|
|
41
|
+
const out = { ...outputs };
|
|
42
|
+
for (const c of components) if (exposes[c.name]) out[c.name] = exposes[c.name];
|
|
43
|
+
return {
|
|
44
|
+
order: components.map((c) => c.name),
|
|
45
|
+
waves: [components.map((c) => c.name)],
|
|
46
|
+
results: components.map((c) => ({ component: c.name, ok: true, status: "ok" as const, records: [] })),
|
|
47
|
+
ok: true,
|
|
48
|
+
status: "ok",
|
|
49
|
+
componentOutputs: out,
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
return { run, calls };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const bindings: string[][] = [];
|
|
56
|
+
const bind = async (d: ChangeSetDocument, gates: string[]): Promise<ReleaseGateBinding> => {
|
|
57
|
+
bindings.push(gates);
|
|
58
|
+
return { op: "release", planDigest: d.digest, releaseDigest: sha("r") };
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
describe("movedInputs", () => {
|
|
62
|
+
test("names each reference that resolves to another value than its plan read", () => {
|
|
63
|
+
expect(movedInputs(svc, { net: { id: "vpc-new" } }, { net: { id: "vpc-new" } })).toEqual([]);
|
|
64
|
+
expect(movedInputs(svc, { net: { id: "vpc-new" } }, { net: { id: "vpc-other" } })).toEqual(["net.id"]);
|
|
65
|
+
expect(movedInputs(svc, undefined, { net: { id: "x" } })).toEqual([]);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("renameGates", () => {
|
|
70
|
+
test("renames every gate, nested phases included, and drops the old key", () => {
|
|
71
|
+
const c: DriverComponent = { name: "x", deploy: [{ phase: "A", steps: [{ phase: "B", steps: [{ kind: "gate", signalName: "old" }] }, { kind: "gate", gate: "g" }] }] };
|
|
72
|
+
const renamed = renameGates(c, (g) => `${g}-round-2`);
|
|
73
|
+
expect(JSON.stringify(renamed)).toContain('"gate":"old-round-2"');
|
|
74
|
+
expect(JSON.stringify(renamed)).toContain('"gate":"g-round-2"');
|
|
75
|
+
expect(JSON.stringify(renamed)).not.toContain("signalName");
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("runPromotionInRounds", () => {
|
|
80
|
+
test("an upstream that applies as its plan said leaves the dependent under the approval it has", async () => {
|
|
81
|
+
const { run, calls } = runner({ net: { id: "vpc-new" } });
|
|
82
|
+
const result = await runPromotionInRounds({
|
|
83
|
+
components: [svc, net],
|
|
84
|
+
outputs: {},
|
|
85
|
+
binding,
|
|
86
|
+
approved,
|
|
87
|
+
reads: { svc: { net: { id: "vpc-new" } } },
|
|
88
|
+
run,
|
|
89
|
+
replan: async () => { throw new Error("not planned again"); },
|
|
90
|
+
bind,
|
|
91
|
+
});
|
|
92
|
+
expect(result.run.status).toBe("ok");
|
|
93
|
+
expect(result.stages).toEqual([]);
|
|
94
|
+
expect(calls.map((c) => [c.names, c.binding])).toEqual([[["net"], approved.digest], [["svc"], approved.digest]]);
|
|
95
|
+
expect(calls[1].outputs).toEqual({ net: { id: "vpc-new" } });
|
|
96
|
+
expect(result.completed).toEqual(["net", "svc"]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("an upstream that applies as another value plans the dependent again; a covered plan applies under the approval", async () => {
|
|
100
|
+
const { run, calls } = runner({ net: { id: "vpc-other" } });
|
|
101
|
+
const result = await runPromotionInRounds({
|
|
102
|
+
components: [net, svc],
|
|
103
|
+
outputs: {},
|
|
104
|
+
binding,
|
|
105
|
+
approved,
|
|
106
|
+
reads: { svc: { net: { id: "vpc-new" } } },
|
|
107
|
+
run,
|
|
108
|
+
replan: async () => doc({ svc: sha("2") }, { svc: "vpc-new" }),
|
|
109
|
+
bind,
|
|
110
|
+
});
|
|
111
|
+
expect(result.stages).toEqual([]);
|
|
112
|
+
expect(calls[1]).toMatchObject({ names: ["svc"], binding: approved.digest, gates: ["prod-release"] });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("a moved plan the approval does not cover waits at <gate>-round-2, bound to the new digest", async () => {
|
|
116
|
+
const { run, calls } = runner({ net: { id: "vpc-other" } });
|
|
117
|
+
const fresh = doc({ svc: sha("9") }, { svc: "vpc-other" });
|
|
118
|
+
const result = await runPromotionInRounds({
|
|
119
|
+
components: [net, svc],
|
|
120
|
+
outputs: {},
|
|
121
|
+
binding,
|
|
122
|
+
approved,
|
|
123
|
+
reads: { svc: { net: { id: "vpc-new" } } },
|
|
124
|
+
run,
|
|
125
|
+
replan: async (only, outputs) => {
|
|
126
|
+
expect([...only]).toEqual(["svc"]);
|
|
127
|
+
expect(outputs).toEqual({ net: { id: "vpc-other" } });
|
|
128
|
+
return fresh;
|
|
129
|
+
},
|
|
130
|
+
bind,
|
|
131
|
+
});
|
|
132
|
+
expect(result.stages).toHaveLength(1);
|
|
133
|
+
expect(result.stages[0]).toMatchObject({ kind: "round", number: 2, components: ["svc"], digest: fresh.digest, status: "applied" });
|
|
134
|
+
expect(result.stages[0].notCovered[0]).toMatch(/svc/);
|
|
135
|
+
expect(calls[1]).toMatchObject({ names: ["svc"], binding: fresh.digest, gates: ["prod-release-round-2"] });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("--wave-gate checks every wave after the first and names one gate per wave", async () => {
|
|
139
|
+
const { run, calls } = runner({ net: { id: "vpc-new" } });
|
|
140
|
+
const fresh = doc({ svc: sha("8") }, { svc: "vpc-other" });
|
|
141
|
+
const result = await runPromotionInRounds({
|
|
142
|
+
components: [net, svc],
|
|
143
|
+
outputs: {},
|
|
144
|
+
binding,
|
|
145
|
+
approved,
|
|
146
|
+
reads: { svc: { net: { id: "vpc-new" } } },
|
|
147
|
+
waveGate: "deploy",
|
|
148
|
+
run,
|
|
149
|
+
replan: async () => fresh,
|
|
150
|
+
bind,
|
|
151
|
+
});
|
|
152
|
+
expect(result.stages[0]).toMatchObject({ kind: "wave", number: 2, digest: fresh.digest });
|
|
153
|
+
expect(calls[1].gates).toEqual(["deploy-wave-2"]);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("a stage whose components declare no gate stops with nothing more deployed", async () => {
|
|
157
|
+
const plain: DriverComponent = { ...svc, deploy: [{ phase: "Apply", steps: [{ kind: "tf", name: "svc", vpc: { stackOutput: { stack: "net", name: "id" } } }] }] };
|
|
158
|
+
const { run, calls } = runner({ net: { id: "vpc-other" } });
|
|
159
|
+
const result = await runPromotionInRounds({
|
|
160
|
+
components: [net, plain],
|
|
161
|
+
outputs: {},
|
|
162
|
+
binding,
|
|
163
|
+
approved,
|
|
164
|
+
reads: { svc: { net: { id: "vpc-new" } } },
|
|
165
|
+
run,
|
|
166
|
+
replan: async () => doc({ svc: sha("9") }, { svc: "vpc-other" }),
|
|
167
|
+
bind,
|
|
168
|
+
});
|
|
169
|
+
expect(result.run.status).toBe("fail");
|
|
170
|
+
expect(result.stages[0]).toMatchObject({ status: "failed" });
|
|
171
|
+
expect(result.stages[0].message).toMatch(/no gate/);
|
|
172
|
+
expect(calls.map((c) => c.names)).toEqual([["net"]]);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("a resume skips what applied and starts from the outputs it kept", async () => {
|
|
176
|
+
const { run, calls } = runner({});
|
|
177
|
+
const result = await runPromotionInRounds({
|
|
178
|
+
components: [net, svc],
|
|
179
|
+
outputs: { net: { id: "vpc-new" } },
|
|
180
|
+
binding,
|
|
181
|
+
approved,
|
|
182
|
+
reads: { svc: { net: { id: "vpc-new" } } },
|
|
183
|
+
completed: ["net"],
|
|
184
|
+
run,
|
|
185
|
+
replan: async () => { throw new Error("not planned again"); },
|
|
186
|
+
bind,
|
|
187
|
+
});
|
|
188
|
+
expect(calls.map((c) => c.names)).toEqual([["svc"]]);
|
|
189
|
+
expect(result.completed).toEqual(["net", "svc"]);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("a gated wave stops the run and leaves what applied", async () => {
|
|
193
|
+
const calls: string[][] = [];
|
|
194
|
+
const result = await runPromotionInRounds({
|
|
195
|
+
components: [net, svc],
|
|
196
|
+
outputs: {},
|
|
197
|
+
binding,
|
|
198
|
+
approved,
|
|
199
|
+
reads: {},
|
|
200
|
+
run: async (components, outputs) => {
|
|
201
|
+
calls.push(components.map((c) => c.name));
|
|
202
|
+
return {
|
|
203
|
+
order: ["net"],
|
|
204
|
+
waves: [["net"]],
|
|
205
|
+
results: [{ component: "net", ok: false, status: "gated", records: [] }],
|
|
206
|
+
ok: false,
|
|
207
|
+
status: "gated",
|
|
208
|
+
gatedComponent: "net",
|
|
209
|
+
componentOutputs: outputs,
|
|
210
|
+
};
|
|
211
|
+
},
|
|
212
|
+
replan: async () => approved,
|
|
213
|
+
bind,
|
|
214
|
+
});
|
|
215
|
+
expect(result.run.status).toBe("gated");
|
|
216
|
+
expect(result.run.gatedComponent).toBe("net");
|
|
217
|
+
expect(calls).toEqual([["net"]]);
|
|
218
|
+
expect(result.completed).toEqual([]);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A promote under a release that plans again once an upstream applied, and a
|
|
3
|
+
* gate per wave (#4168 step 4). Merging deploys nothing since step 1, so what
|
|
4
|
+
* `pr-apply` did on merge happens here, at promote:
|
|
5
|
+
*
|
|
6
|
+
* - **Rounds** (#3999's rule, `../pr-loop.ts`). The release gate approves a
|
|
7
|
+
* change set in which each dependent was planned against its upstream's
|
|
8
|
+
* planned outputs (`planReleaseSet` in ./release-gate.ts). The promote runs
|
|
9
|
+
* a wave at a time. Before a wave runs, a component whose plan read an
|
|
10
|
+
* upstream output that the run left as another value
|
|
11
|
+
* (`movedWiringReferences`) is planned again against what stands. When the
|
|
12
|
+
* approved change set covers the new plan (`waveNotCovered`, the test a
|
|
13
|
+
* pull request's resume uses), it applies under the approval it has.
|
|
14
|
+
* Otherwise its gates are decided as `<gate>-round-<n>` (`prRoundGate`),
|
|
15
|
+
* bound to the new plan's digest.
|
|
16
|
+
* - **A gate per wave** (`--wave-gate <gate>`, #4157's rule). Every wave
|
|
17
|
+
* after the first is planned again against what the waves before it left,
|
|
18
|
+
* and a wave the approved change set does not cover waits at
|
|
19
|
+
* `<gate>-wave-<k>` (`waveGateName`), one gate for the whole wave, bound to
|
|
20
|
+
* that wave's digest.
|
|
21
|
+
*
|
|
22
|
+
* A stage whose components declare no gate has nowhere to wait, so the
|
|
23
|
+
* promote stops there with nothing more deployed.
|
|
24
|
+
*
|
|
25
|
+
* A promote that stops at a round's or a wave's gate writes a resume record
|
|
26
|
+
* ({@link PromoteResume}): the components that applied and the outputs they
|
|
27
|
+
* left. The same promote run again with it skips what applied and carries on
|
|
28
|
+
* from the wave it stopped at, so an approval of the round or wave it waits on
|
|
29
|
+
* is decided against the same plan.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { movedWiringReferences, type DriverComponent, type DriverGate, type DriverPhase, type DriverRunResult, type DriverStep, type ReleaseGateBinding } from "./driver";
|
|
33
|
+
import { resolveComponentGraph } from "./driver";
|
|
34
|
+
import { waveNotCovered } from "./pr-run";
|
|
35
|
+
import { prRoundGate } from "../pr-loop";
|
|
36
|
+
import { waveGateName } from "../gated-waves";
|
|
37
|
+
import { capabilitySteps } from "./wave-plan";
|
|
38
|
+
import type { ChangeSetDocument } from "../change-set";
|
|
39
|
+
import type { ReleasePlan } from "./release-gate";
|
|
40
|
+
|
|
41
|
+
export const PROMOTE_RESUME_SCHEMA = "chant-promote-resume/v1";
|
|
42
|
+
|
|
43
|
+
/** What a promote that stopped at a round's or a wave's gate leaves for the next attempt. */
|
|
44
|
+
export interface PromoteResume {
|
|
45
|
+
schema: typeof PROMOTE_RESUME_SCHEMA;
|
|
46
|
+
env: string;
|
|
47
|
+
releaseDigest: string;
|
|
48
|
+
/** The change set the release gate approved before anything applied. */
|
|
49
|
+
approvedDigest: string;
|
|
50
|
+
/** Components that applied, in run order. */
|
|
51
|
+
completed: string[];
|
|
52
|
+
/** The outputs standing after them. */
|
|
53
|
+
outputs: Record<string, Record<string, unknown>>;
|
|
54
|
+
/** ISO 8601. */
|
|
55
|
+
at: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** One round or wave that needed its own approval. */
|
|
59
|
+
export interface PromoteStage {
|
|
60
|
+
kind: "round" | "wave";
|
|
61
|
+
/** The round number (2 and up) or the wave number (1-based). */
|
|
62
|
+
number: number;
|
|
63
|
+
components: string[];
|
|
64
|
+
/** The plan the stage's gates bind. */
|
|
65
|
+
digest: string;
|
|
66
|
+
/** Why the approved change set did not cover it, one line per member. */
|
|
67
|
+
notCovered: string[];
|
|
68
|
+
status: "applied" | "waiting" | "failed";
|
|
69
|
+
/** With `failed` before anything ran: why. */
|
|
70
|
+
message?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface PromoteRoundsOptions {
|
|
74
|
+
/** The promoted compositions, in no particular order. */
|
|
75
|
+
components: DriverComponent[];
|
|
76
|
+
/** Outputs standing before the run: the seeds, and on a resume the record's. */
|
|
77
|
+
outputs: Record<string, Record<string, unknown>>;
|
|
78
|
+
/** The release gate the change set was approved under. */
|
|
79
|
+
binding: ReleaseGateBinding;
|
|
80
|
+
/** The approved change set, and what each component's plan read. */
|
|
81
|
+
approved: ChangeSetDocument;
|
|
82
|
+
reads: ReleasePlan["reads"];
|
|
83
|
+
/** Components an earlier attempt applied. Skipped. */
|
|
84
|
+
completed?: readonly string[];
|
|
85
|
+
/** `--wave-gate <gate>`: check every wave after the first, not only the components whose inputs moved, and name a wave's gate `<gate>-wave-<k>`. */
|
|
86
|
+
waveGate?: string;
|
|
87
|
+
/** Run one subset of the components with `outputs` standing (`runPromotion` with the binding). */
|
|
88
|
+
run: (components: DriverComponent[], outputs: Record<string, Record<string, unknown>>, binding: ReleaseGateBinding) => Promise<DriverRunResult>;
|
|
89
|
+
/** Plan `only` again with `outputs` standing (`planReleaseSet`). */
|
|
90
|
+
replan: (only: ReadonlySet<string>, outputs: Record<string, Record<string, unknown>>) => Promise<ChangeSetDocument>;
|
|
91
|
+
/** Store a stage's plan and bind its gates (empty resolutions included). */
|
|
92
|
+
bind: (doc: ChangeSetDocument, gates: string[]) => Promise<ReleaseGateBinding>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface PromoteRoundsResult {
|
|
96
|
+
run: DriverRunResult;
|
|
97
|
+
/** The rounds and waves that needed their own approval. */
|
|
98
|
+
stages: PromoteStage[];
|
|
99
|
+
/** Every component that applied, earlier attempts included, in run order. */
|
|
100
|
+
completed: string[];
|
|
101
|
+
outputs: Record<string, Record<string, unknown>>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function isGate(step: DriverStep | DriverGate | DriverPhase): step is DriverGate {
|
|
105
|
+
return (step as { kind?: unknown }).kind === "gate";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isPhase(step: DriverStep | DriverGate | DriverPhase): step is DriverPhase {
|
|
109
|
+
return typeof (step as { phase?: unknown }).phase === "string" && Array.isArray((step as DriverPhase).steps);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** `component` with each gate renamed by `rename`, nested phases included. */
|
|
113
|
+
export function renameGates(component: DriverComponent, rename: (gate: string) => string): DriverComponent {
|
|
114
|
+
const walk = (phase: DriverPhase): DriverPhase => ({
|
|
115
|
+
...phase,
|
|
116
|
+
steps: phase.steps.map((step) => {
|
|
117
|
+
if (isPhase(step)) return walk(step);
|
|
118
|
+
if (!isGate(step)) return step;
|
|
119
|
+
const { signalName: _old, ...rest } = step;
|
|
120
|
+
return { ...rest, gate: rename(step.gate ?? step.signalName ?? "") };
|
|
121
|
+
}),
|
|
122
|
+
});
|
|
123
|
+
return { ...component, deploy: component.deploy.map(walk) };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The gate names `components` declare, sorted and distinct. */
|
|
127
|
+
function gatesOf(components: readonly DriverComponent[]): string[] {
|
|
128
|
+
const names = new Set<string>();
|
|
129
|
+
const walk = (steps: DriverPhase["steps"]): void => {
|
|
130
|
+
for (const step of steps) {
|
|
131
|
+
if (isPhase(step)) walk(step.steps);
|
|
132
|
+
else if (isGate(step)) names.add(step.gate ?? step.signalName ?? "");
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
for (const c of components) for (const phase of c.deploy) walk(phase.steps);
|
|
136
|
+
return [...names].sort();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The references `component`'s steps make that resolve to another value now than when its plan read them. */
|
|
140
|
+
export function movedInputs(component: DriverComponent, read: Record<string, Record<string, unknown>> | undefined, now: Record<string, Record<string, unknown>>): string[] {
|
|
141
|
+
if (!read) return [];
|
|
142
|
+
const moved = new Set<string>();
|
|
143
|
+
for (const step of capabilitySteps(component.deploy)) {
|
|
144
|
+
const { kind: _kind, ...rest } = step;
|
|
145
|
+
for (const r of movedWiringReferences(rest, read, now)) if (!r.publish) moved.add(r.ref);
|
|
146
|
+
}
|
|
147
|
+
return [...moved].sort();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function joinRuns(runs: DriverRunResult[], outputs: Record<string, Record<string, unknown>>): DriverRunResult {
|
|
151
|
+
const last = runs[runs.length - 1];
|
|
152
|
+
const base: DriverRunResult = {
|
|
153
|
+
order: runs.flatMap((r) => r.order),
|
|
154
|
+
waves: runs.flatMap((r) => r.waves),
|
|
155
|
+
results: runs.flatMap((r) => r.results),
|
|
156
|
+
ok: runs.every((r) => r.ok),
|
|
157
|
+
status: "ok",
|
|
158
|
+
componentOutputs: outputs,
|
|
159
|
+
};
|
|
160
|
+
if (!last || last.status === "ok") return base;
|
|
161
|
+
const { order: _o, waves: _w, results: _r, ok: _ok, componentOutputs: _c, ...stop } = last;
|
|
162
|
+
return { ...base, ...stop, ok: false };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Run the promote a wave at a time, planning again where an upstream applied
|
|
167
|
+
* as another value, and with `waveGate`, checking each wave. Stops at the
|
|
168
|
+
* first wave that fails or waits on a gate.
|
|
169
|
+
*/
|
|
170
|
+
export async function runPromotionInRounds(options: PromoteRoundsOptions): Promise<PromoteRoundsResult> {
|
|
171
|
+
const byName = new Map(options.components.map((c) => [c.name, c]));
|
|
172
|
+
const { waves } = resolveComponentGraph(options.components.map((c) => ({ ...c, dependsOn: (c.dependsOn ?? []).filter((d) => byName.has(d)) })));
|
|
173
|
+
const completed = [...(options.completed ?? [])];
|
|
174
|
+
const done = new Set(completed);
|
|
175
|
+
let outputs = { ...options.outputs };
|
|
176
|
+
const runs: DriverRunResult[] = [];
|
|
177
|
+
const stages: PromoteStage[] = [];
|
|
178
|
+
let round = 1;
|
|
179
|
+
|
|
180
|
+
for (const [index, wave] of waves.entries()) {
|
|
181
|
+
const waveNumber = index + 1;
|
|
182
|
+
const pending = wave.filter((name) => !done.has(name));
|
|
183
|
+
if (pending.length === 0) continue;
|
|
184
|
+
const check = options.waveGate !== undefined && waveNumber > 1
|
|
185
|
+
? pending
|
|
186
|
+
: pending.filter((name) => movedInputs(byName.get(name)!, options.reads[name], outputs).length > 0);
|
|
187
|
+
const batches: Array<{ names: string[]; binding: ReleaseGateBinding; stage?: PromoteStage; rename?: (g: string) => string }> = [];
|
|
188
|
+
const rest = pending.filter((name) => !check.includes(name));
|
|
189
|
+
if (check.length > 0) {
|
|
190
|
+
const fresh = await options.replan(new Set(check), outputs);
|
|
191
|
+
const notCovered = waveNotCovered(options.approved, fresh);
|
|
192
|
+
if (notCovered.length === 0) {
|
|
193
|
+
rest.push(...check);
|
|
194
|
+
} else {
|
|
195
|
+
const kind: PromoteStage["kind"] = options.waveGate !== undefined ? "wave" : "round";
|
|
196
|
+
if (kind === "round") round += 1;
|
|
197
|
+
const number = kind === "wave" ? waveNumber : round;
|
|
198
|
+
const base = options.waveGate;
|
|
199
|
+
const rename = (g: string) => (base !== undefined ? waveGateName(base, number) : prRoundGate(g, number));
|
|
200
|
+
const renamed = check.map((name) => renameGates(byName.get(name)!, rename));
|
|
201
|
+
const gates = gatesOf(renamed);
|
|
202
|
+
const stage: PromoteStage = { kind, number, components: check, digest: fresh.digest, notCovered, status: "waiting" };
|
|
203
|
+
stages.push(stage);
|
|
204
|
+
if (gates.length === 0) {
|
|
205
|
+
stage.status = "failed";
|
|
206
|
+
stage.message = `${check.join(", ")} planned again past the approved change set and declare${check.length === 1 ? "s" : ""} no gate to approve the new plan on`;
|
|
207
|
+
const stop: DriverRunResult = { order: [], waves: [], results: [], ok: false, status: "fail", failedComponent: check[0], componentOutputs: outputs };
|
|
208
|
+
return { run: joinRuns([...runs, stop], outputs), stages, completed, outputs };
|
|
209
|
+
}
|
|
210
|
+
const binding = await options.bind(fresh, gates);
|
|
211
|
+
batches.push({ names: check, binding, stage, rename });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (rest.length > 0) batches.unshift({ names: pending.filter((n) => rest.includes(n)), binding: options.binding });
|
|
215
|
+
|
|
216
|
+
for (const batch of batches) {
|
|
217
|
+
const components = batch.names.map((name) => (batch.rename ? renameGates(byName.get(name)!, batch.rename) : byName.get(name)!));
|
|
218
|
+
const run = await options.run(components, outputs, batch.binding);
|
|
219
|
+
runs.push(run);
|
|
220
|
+
outputs = { ...outputs, ...run.componentOutputs };
|
|
221
|
+
for (const r of run.results) {
|
|
222
|
+
if (r.status === "ok" && !done.has(r.component)) {
|
|
223
|
+
done.add(r.component);
|
|
224
|
+
completed.push(r.component);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (batch.stage) batch.stage.status = run.status === "ok" ? "applied" : run.status === "gated" ? "waiting" : "failed";
|
|
228
|
+
if (run.status !== "ok") return { run: joinRuns(runs, outputs), stages, completed, outputs };
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return { run: joinRuns(runs, outputs), stages, completed, outputs };
|
|
232
|
+
}
|
|
@@ -197,6 +197,13 @@ describe("taking the build out of a composition", () => {
|
|
|
197
197
|
expect("error" in prepared && prepared.error).toMatch(/no publish step/);
|
|
198
198
|
});
|
|
199
199
|
|
|
200
|
+
test("#4222: no publish step passes when a release records its templates, and says it is template-only", () => {
|
|
201
|
+
const c: DriverComponent = { name: "chart", deploy: [{ phase: "Apply", steps: [{ kind: "helm-upgrade" }] }] };
|
|
202
|
+
const prepared = withoutBuildSteps(c, { templatesPinned: true });
|
|
203
|
+
expect("error" in prepared).toBe(false);
|
|
204
|
+
expect("templateOnly" in prepared && prepared.templateOnly).toBe(true);
|
|
205
|
+
});
|
|
206
|
+
|
|
200
207
|
test("two publish steps are refused", () => {
|
|
201
208
|
const c = service("api");
|
|
202
209
|
c.deploy[1].steps.push({ kind: "publish-artifact", from: "archive:x.jar" });
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
*
|
|
24
24
|
* A composition the transform cannot pin to one recorded digest is refused
|
|
25
25
|
* before anything runs: one with no publish step (nothing would carry the
|
|
26
|
-
* digest), one with more than one (the ledger records one digest per
|
|
26
|
+
* digest) unless a release records its templates (#4222), one with more than one (the ledger records one digest per
|
|
27
27
|
* component), and one whose remaining steps read an output of a removed build
|
|
28
28
|
* step (it would resolve to nothing).
|
|
29
29
|
*
|
|
@@ -402,6 +402,8 @@ export interface PromotableComponent {
|
|
|
402
402
|
component: DriverComponent;
|
|
403
403
|
/** The kinds removed, in composition order. */
|
|
404
404
|
removed: string[];
|
|
405
|
+
/** #4222: the component has no publish step and deploys the templates a release records, so its record carries their digest. */
|
|
406
|
+
templateOnly?: boolean;
|
|
405
407
|
}
|
|
406
408
|
|
|
407
409
|
/**
|
|
@@ -433,7 +435,7 @@ export const recordedDigestCapability: Capability<{ digest: string }, { digest:
|
|
|
433
435
|
*/
|
|
434
436
|
export function withoutBuildSteps(
|
|
435
437
|
component: DriverComponent,
|
|
436
|
-
opts: { pinDigest?: string; verb?: string } = {},
|
|
438
|
+
opts: { pinDigest?: string; verb?: string; templatesPinned?: boolean } = {},
|
|
437
439
|
): PromotableComponent | { error: string } {
|
|
438
440
|
const verb = opts.verb ?? (opts.pinDigest === undefined ? "a promote" : "a rollback");
|
|
439
441
|
const removed: string[] = [];
|
|
@@ -487,7 +489,10 @@ export function withoutBuildSteps(
|
|
|
487
489
|
|
|
488
490
|
const deploy = component.deploy.map(strip).filter((p): p is DriverPhase => p !== undefined);
|
|
489
491
|
|
|
490
|
-
|
|
492
|
+
// #4222: under a release that records this component's templates, the
|
|
493
|
+
// deploy reads them from the release (./release-promote.ts), so a component
|
|
494
|
+
// with no artifact deploys the release's content, not its current source.
|
|
495
|
+
if (publishSteps === 0 && !opts.templatesPinned) {
|
|
491
496
|
return {
|
|
492
497
|
error: `component "${component.name}" has no publish step, so nothing in its deploy carries a recorded digest; ` +
|
|
493
498
|
`${verb} would deploy whatever its current source produces`,
|
|
@@ -519,7 +524,7 @@ export function withoutBuildSteps(
|
|
|
519
524
|
}
|
|
520
525
|
}
|
|
521
526
|
}
|
|
522
|
-
return { component: { ...component, deploy }, removed };
|
|
527
|
+
return { component: { ...component, deploy }, removed, ...(publishSteps === 0 ? { templateOnly: true } : {}) };
|
|
523
528
|
}
|
|
524
529
|
|
|
525
530
|
/** Every same-project wiring reference anywhere in a step's fields (cross-stack `stackOutput` objects excluded). */
|