@intentius/chant 0.79.0 → 0.80.0
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/audit/core.d.ts.map +1 -1
- package/dist/audit/discover.d.ts +38 -0
- package/dist/audit/discover.d.ts.map +1 -1
- package/dist/audit/terraform-state.d.ts +14 -0
- package/dist/audit/terraform-state.d.ts.map +1 -1
- package/dist/cli/command-group.d.ts +6 -0
- package/dist/cli/command-group.d.ts.map +1 -1
- package/dist/cli/commands/audit.d.ts +27 -2
- package/dist/cli/commands/audit.d.ts.map +1 -1
- package/dist/cli/commands/check-lexicon-examples.d.ts.map +1 -1
- package/dist/cli/commands/doctor.d.ts.map +1 -1
- package/dist/cli/commands/import-agents.d.ts.map +1 -1
- package/dist/cli/commands/init.d.ts +6 -0
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/commands/onboard.d.ts.map +1 -1
- package/dist/cli/commands/update.d.ts.map +1 -1
- package/dist/cli/conflict-check.d.ts +6 -0
- package/dist/cli/conflict-check.d.ts.map +1 -1
- package/dist/cli/handlers/dev.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/misc.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/promote.d.ts +17 -0
- package/dist/cli/handlers/promote.d.ts.map +1 -0
- package/dist/cli/handlers/run-generate.d.ts +39 -0
- package/dist/cli/handlers/run-generate.d.ts.map +1 -0
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/op-tools.d.ts.map +1 -1
- package/dist/cli/plugins.d.ts +9 -0
- package/dist/cli/plugins.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +14 -1
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/capability-plugin-loader.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/discover.d.ts.map +1 -1
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/components/fan-out-support.d.ts.map +1 -1
- package/dist/components/pilots/alb-ecs.pilot.d.ts +7 -1
- package/dist/components/pilots/alb-ecs.pilot.d.ts.map +1 -1
- package/dist/components/presets/ecs-fargate.d.ts +10 -4
- package/dist/components/presets/ecs-fargate.d.ts.map +1 -1
- package/dist/components/promote.d.ts +211 -0
- package/dist/components/promote.d.ts.map +1 -0
- package/dist/components/unbound-gate-approval.d.ts +24 -0
- package/dist/components/unbound-gate-approval.d.ts.map +1 -0
- package/dist/config.d.ts +82 -5
- package/dist/config.d.ts.map +1 -1
- package/dist/discovery/convergence.d.ts +97 -0
- package/dist/discovery/convergence.d.ts.map +1 -0
- package/dist/discovery/files.d.ts +41 -2
- package/dist/discovery/files.d.ts.map +1 -1
- package/dist/discovery/fold-import.d.ts +15 -0
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/graph-ir.d.ts +13 -0
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/lexicon-module.d.ts +65 -0
- package/dist/lexicon-module.d.ts.map +1 -0
- package/dist/lexicon.d.ts +24 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/build-ledger-store.d.ts.map +1 -1
- package/dist/lifecycle/legacy-digest.d.ts +26 -0
- package/dist/lifecycle/legacy-digest.d.ts.map +1 -0
- package/dist/lifecycle/release-ledger.d.ts +25 -0
- package/dist/lifecycle/release-ledger.d.ts.map +1 -1
- package/dist/lint/rules/comp/comp003-mutating-no-rollback.d.ts +2 -3
- package/dist/lint/rules/comp/comp003-mutating-no-rollback.d.ts.map +1 -1
- package/dist/op/activity-contract-registry.d.ts.map +1 -1
- package/dist/op/activity-registry.d.ts.map +1 -1
- package/dist/op/discover.d.ts.map +1 -1
- package/dist/op/gate-approval.d.ts.map +1 -1
- package/dist/op/generate-pipeline.d.ts.map +1 -1
- package/dist/op/runtimes/local.d.ts.map +1 -1
- package/dist/workspace/record-source.d.ts +22 -0
- package/dist/workspace/record-source.d.ts.map +1 -0
- package/dist/workspace/records-cli.d.ts +57 -0
- package/dist/workspace/records-cli.d.ts.map +1 -0
- package/dist/workspace/records.d.ts +118 -0
- package/dist/workspace/records.d.ts.map +1 -0
- package/package.json +3 -3
- package/src/audit/core.ts +13 -2
- package/src/audit/discover.ts +90 -12
- package/src/audit/terraform-state.test.ts +86 -1
- package/src/audit/terraform-state.ts +26 -0
- package/src/cli/command-group.ts +7 -0
- package/src/cli/commands/audit-walk-warnings.test.ts +156 -0
- package/src/cli/commands/audit.ts +93 -19
- package/src/cli/commands/check-lexicon-examples.ts +2 -1
- package/src/cli/commands/doctor.ts +3 -0
- package/src/cli/commands/import-agents.ts +11 -4
- package/src/cli/commands/init.ts +57 -25
- package/src/cli/commands/lint.test.ts +58 -1
- package/src/cli/commands/lint.ts +48 -18
- package/src/cli/commands/onboard.ts +15 -0
- package/src/cli/commands/update.ts +5 -1
- package/src/cli/conflict-check.test.ts +18 -1
- package/src/cli/conflict-check.ts +13 -0
- package/src/cli/discovery-skip.test.ts +168 -0
- package/src/cli/handlers/build.test.ts +11 -0
- package/src/cli/handlers/build.ts +1 -1
- package/src/cli/handlers/dev.ts +3 -0
- package/src/cli/handlers/graph.test.ts +20 -0
- package/src/cli/handlers/graph.ts +4 -2
- package/src/cli/handlers/lifecycle.ts +2 -1
- package/src/cli/handlers/misc.ts +16 -0
- package/src/cli/handlers/operator.ts +2 -1
- package/src/cli/handlers/promote.test.ts +261 -0
- package/src/cli/handlers/promote.ts +325 -0
- package/src/cli/handlers/run-generate.test.ts +160 -0
- package/src/cli/handlers/run-generate.ts +133 -0
- package/src/cli/handlers/run.ts +13 -1
- package/src/cli/main.test.ts +33 -1
- package/src/cli/main.ts +69 -16
- package/src/cli/mcp/op-tools.ts +2 -1
- package/src/cli/path-lexicon-messages.test.ts +152 -0
- package/src/cli/plugins.ts +44 -6
- package/src/cli/registry.ts +14 -1
- package/src/components/__fixtures__/alb-ecs-service.json +0 -8
- package/src/components/capability-plugin-loader.ts +3 -1
- package/src/components/cli-support.ts +3 -2
- package/src/components/discover.ts +26 -2
- package/src/components/driver.test.ts +12 -11
- package/src/components/driver.ts +2 -0
- package/src/components/fan-out-support.ts +2 -1
- package/src/components/pilots/README.md +1 -1
- package/src/components/pilots/alb-ecs.pilot.ts +7 -7
- package/src/components/presets/ecs-fargate.ts +10 -5
- package/src/components/promote.test.ts +371 -0
- package/src/components/promote.ts +537 -0
- package/src/components/unbound-gate-approval.test.ts +82 -0
- package/src/components/unbound-gate-approval.ts +51 -0
- package/src/config.test.ts +40 -0
- package/src/config.ts +128 -11
- package/src/discovery/convergence.test.ts +231 -0
- package/src/discovery/convergence.ts +399 -0
- package/src/discovery/files.test.ts +127 -1
- package/src/discovery/files.ts +97 -9
- package/src/discovery/fold-import.ts +83 -9
- package/src/discovery/sandbox/path-lexicon-parity.test.ts +162 -0
- package/src/graph-ir.ts +14 -0
- package/src/lexicon-module.test.ts +203 -0
- package/src/lexicon-module.ts +117 -0
- package/src/lexicon.ts +26 -0
- package/src/lifecycle/build-ledger-store.ts +11 -1
- package/src/lifecycle/legacy-digest.test.ts +114 -0
- package/src/lifecycle/legacy-digest.ts +62 -0
- package/src/lifecycle/release-ledger.ts +34 -0
- package/src/lint/rules/comp/comp003-mutating-no-rollback.ts +2 -3
- package/src/op/activity-contract-registry.ts +18 -4
- package/src/op/activity-registry.ts +26 -0
- package/src/op/discover-convergence.test.ts +70 -0
- package/src/op/discover.ts +4 -0
- package/src/op/gate-approval.ts +3 -1
- package/src/op/generate-pipeline.ts +2 -1
- package/src/op/runtimes/local.ts +2 -1
- package/src/workspace/record-source.ts +117 -0
- package/src/workspace/records-cli.ts +126 -0
- package/src/workspace/records-contract.test.ts +88 -0
- package/src/workspace/records.schema.json +120 -0
- package/src/workspace/records.test.ts +243 -0
- package/src/workspace/records.ts +383 -0
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Promote a release to another environment without rebuilding (#2530).
|
|
3
|
+
*
|
|
4
|
+
* `chant run --components <name> --env prod` runs the component's whole
|
|
5
|
+
* `deploy` composition, and a composition that builds (a `Build` phase with
|
|
6
|
+
* `docker-build`) builds again in prod. The release ledger already knows which
|
|
7
|
+
* digest staging is running, so a promote reads that record and deploys the
|
|
8
|
+
* same artifact to the target environment instead.
|
|
9
|
+
*
|
|
10
|
+
* The mechanism is a transform on the composition, not a change to the driver:
|
|
11
|
+
*
|
|
12
|
+
* - build-time steps (`BUILD_STEP_KINDS`) are removed, so nothing is built;
|
|
13
|
+
* - the publish step stays. It moves the archived bytes to the target
|
|
14
|
+
* environment, which is what "promote by digest" means in the build-archive
|
|
15
|
+
* model, and its output is checked against the recorded digest. A publish
|
|
16
|
+
* that produces any other digest fails the step, and so the component,
|
|
17
|
+
* before anything after it runs;
|
|
18
|
+
* - gates, apply, verify and every other step run exactly as a normal deploy
|
|
19
|
+
* to the target environment would run them, so the target's gates apply.
|
|
20
|
+
*
|
|
21
|
+
* A composition the transform cannot pin to one recorded digest is refused
|
|
22
|
+
* before anything runs: one with no publish step (nothing would carry the
|
|
23
|
+
* digest), one with more than one (the ledger records one digest per
|
|
24
|
+
* component), and one whose remaining steps read an output of a removed build
|
|
25
|
+
* step (it would resolve to nothing).
|
|
26
|
+
*
|
|
27
|
+
* A rollback (#2531) is the same run against one environment's own earlier
|
|
28
|
+
* release. There the publish step is replaced by the recorded digest rather
|
|
29
|
+
* than run, since the environment already received that artifact when the
|
|
30
|
+
* release was first deployed (see `withoutBuildSteps`'s `pinDigest`).
|
|
31
|
+
*
|
|
32
|
+
* The driver stays capability-agnostic. The two kind lists below are data, in
|
|
33
|
+
* the same spirit as `DEPLOY_UNIT_RULES` (./deploy-units.ts).
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { parseWiringRef, type WiringRef } from "../lint/rules/comp/support";
|
|
37
|
+
import { latestPerComponent, type ReleaseRecord, type ReleaseRecordInput, type RunOrigin } from "../lifecycle/release-ledger";
|
|
38
|
+
import { CapabilityRegistry, type Capability } from "./capability";
|
|
39
|
+
import {
|
|
40
|
+
runInterpretDriver,
|
|
41
|
+
DriverRunFailure,
|
|
42
|
+
type DriverComponent,
|
|
43
|
+
type DriverComponentResult,
|
|
44
|
+
type DriverGate,
|
|
45
|
+
type DriverPhase,
|
|
46
|
+
type DriverRunResult,
|
|
47
|
+
type DriverStep,
|
|
48
|
+
} from "./driver";
|
|
49
|
+
import type { GateLedgerPort } from "../op/gate";
|
|
50
|
+
import type { RunProgressEvent } from "./run-progress";
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Step kinds that produce an artifact at build time: the build family, and the
|
|
54
|
+
* SBOM steps that read the freshly built archive. A promote removes them.
|
|
55
|
+
*/
|
|
56
|
+
export const BUILD_STEP_KINDS: readonly string[] = [
|
|
57
|
+
"docker-build",
|
|
58
|
+
"zip-package",
|
|
59
|
+
"jvm-build",
|
|
60
|
+
"generate-sbom",
|
|
61
|
+
"extract-config-bom",
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The publish family (the same set COMP001 recognises, plus the
|
|
66
|
+
* `publish-asset` alias). Its output digest is the one a release record binds.
|
|
67
|
+
*/
|
|
68
|
+
export const PUBLISH_STEP_KINDS: readonly string[] = [
|
|
69
|
+
"publish-image",
|
|
70
|
+
"publish-artifact",
|
|
71
|
+
"publish-asset",
|
|
72
|
+
"load-image-on-host",
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
/** One component a promote will deploy, and the source release it deploys. */
|
|
76
|
+
export interface PromotionItem {
|
|
77
|
+
component: string;
|
|
78
|
+
/** The recorded digest being promoted. */
|
|
79
|
+
digest: string;
|
|
80
|
+
/** The source environment's release record the digest came from. */
|
|
81
|
+
source: ReleaseRecord;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** What a promote will do, decided before anything runs. */
|
|
85
|
+
export interface PromotionPlan {
|
|
86
|
+
from: string;
|
|
87
|
+
to: string;
|
|
88
|
+
items: PromotionItem[];
|
|
89
|
+
/** Components left alone, and why. Reported, never an error. */
|
|
90
|
+
notPromoted: Array<{ component: string; reason: string }>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Pick one release of `component` out of an environment's records: the latest
|
|
95
|
+
* one, or, when `digest` is given, the latest one that recorded that digest.
|
|
96
|
+
* A digest the ledger never recorded for the component is an error.
|
|
97
|
+
*/
|
|
98
|
+
export function selectRelease(
|
|
99
|
+
records: ReleaseRecord[],
|
|
100
|
+
component: string,
|
|
101
|
+
env: string,
|
|
102
|
+
digest?: string,
|
|
103
|
+
): { record: ReleaseRecord } | { error: string } {
|
|
104
|
+
const own = records.filter((r) => r.component === component);
|
|
105
|
+
if (own.length === 0) return { error: `no release of "${component}" is recorded in "${env}"` };
|
|
106
|
+
if (digest === undefined) return { record: latestPerComponent(own).get(component)! };
|
|
107
|
+
const matching = own.filter((r) => r.digest === digest);
|
|
108
|
+
if (matching.length === 0) {
|
|
109
|
+
return {
|
|
110
|
+
error: `digest ${digest} is not recorded for "${component}" in "${env}"; ` +
|
|
111
|
+
`its recorded digests are ${[...new Set(own.map((r) => r.digest))].join(", ")}`,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
return { record: latestPerComponent(matching).get(component)! };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Decide which releases a promote deploys. With `component`, exactly that
|
|
119
|
+
* component's latest source release (or the one recording `digest`). Without
|
|
120
|
+
* it, the latest source release of every component this checkout declares;
|
|
121
|
+
* a recorded component the checkout no longer declares is reported and left
|
|
122
|
+
* alone, since there is no composition to deploy it with.
|
|
123
|
+
*/
|
|
124
|
+
export function planPromotion(input: {
|
|
125
|
+
from: string;
|
|
126
|
+
to: string;
|
|
127
|
+
sourceRecords: ReleaseRecord[];
|
|
128
|
+
/** Names of the components this checkout declares. */
|
|
129
|
+
declared: string[];
|
|
130
|
+
component?: string;
|
|
131
|
+
digest?: string;
|
|
132
|
+
}): PromotionPlan | { error: string } {
|
|
133
|
+
const { from, to, sourceRecords, declared, component, digest } = input;
|
|
134
|
+
if (from === to) return { error: `--from and --to name the same environment ("${from}")` };
|
|
135
|
+
if (digest !== undefined && component === undefined) {
|
|
136
|
+
return { error: "--digest picks one release of one component, so it needs --component <name>" };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (component !== undefined) {
|
|
140
|
+
if (!declared.includes(component)) {
|
|
141
|
+
return { error: `component "${component}" is not declared in this checkout` };
|
|
142
|
+
}
|
|
143
|
+
const picked = selectRelease(sourceRecords, component, from, digest);
|
|
144
|
+
if ("error" in picked) return picked;
|
|
145
|
+
return { from, to, items: [{ component, digest: picked.record.digest, source: picked.record }], notPromoted: [] };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const latest = latestPerComponent(sourceRecords);
|
|
149
|
+
const items: PromotionItem[] = [];
|
|
150
|
+
const notPromoted: PromotionPlan["notPromoted"] = [];
|
|
151
|
+
for (const [name, record] of [...latest].sort(([a], [b]) => a.localeCompare(b))) {
|
|
152
|
+
if (declared.includes(name)) items.push({ component: name, digest: record.digest, source: record });
|
|
153
|
+
else notPromoted.push({ component: name, reason: "recorded but not declared in this checkout" });
|
|
154
|
+
}
|
|
155
|
+
if (items.length === 0) {
|
|
156
|
+
return { error: `nothing to promote: no component declared here has a release recorded in "${from}"` };
|
|
157
|
+
}
|
|
158
|
+
return { from, to, items, notPromoted };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Decide which release a rollback restores: the release of `component` in
|
|
163
|
+
* `env` given by `digest`, or by default the most recent release whose digest
|
|
164
|
+
* differs from the current one. Restoring the release already current is
|
|
165
|
+
* refused, since there is nothing to roll back.
|
|
166
|
+
*/
|
|
167
|
+
export function planRollback(input: {
|
|
168
|
+
env: string;
|
|
169
|
+
records: ReleaseRecord[];
|
|
170
|
+
declared: string[];
|
|
171
|
+
component: string;
|
|
172
|
+
digest?: string;
|
|
173
|
+
}): PromotionPlan | { error: string } {
|
|
174
|
+
const { env, records, declared, component, digest } = input;
|
|
175
|
+
if (!declared.includes(component)) {
|
|
176
|
+
return { error: `component "${component}" is not declared in this checkout` };
|
|
177
|
+
}
|
|
178
|
+
const own = records.filter((r) => r.component === component);
|
|
179
|
+
const current = latestPerComponent(own).get(component);
|
|
180
|
+
if (!current) return { error: `no release of "${component}" is recorded in "${env}"` };
|
|
181
|
+
|
|
182
|
+
let target: ReleaseRecord;
|
|
183
|
+
if (digest !== undefined) {
|
|
184
|
+
const picked = selectRelease(own, component, env, digest);
|
|
185
|
+
if ("error" in picked) return picked;
|
|
186
|
+
target = picked.record;
|
|
187
|
+
} else {
|
|
188
|
+
const earlier = own.filter((r) => r.digest !== current.digest);
|
|
189
|
+
if (earlier.length === 0) {
|
|
190
|
+
return { error: `"${component}" has no earlier release in "${env}" with a digest other than the current ${current.digest}` };
|
|
191
|
+
}
|
|
192
|
+
target = latestPerComponent(earlier).get(component)!;
|
|
193
|
+
}
|
|
194
|
+
if (target.digest === current.digest) {
|
|
195
|
+
return { error: `${target.digest} is already the current release of "${component}" in "${env}"; nothing to roll back` };
|
|
196
|
+
}
|
|
197
|
+
return { from: env, to: env, items: [{ component, digest: target.digest, source: target }], notPromoted: [] };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function isGate(step: DriverStep | DriverGate | DriverPhase): step is DriverGate {
|
|
201
|
+
return (step as { kind?: unknown }).kind === "gate";
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function isPhase(step: DriverStep | DriverGate | DriverPhase): step is DriverPhase {
|
|
205
|
+
return typeof (step as { phase?: unknown }).phase === "string" && Array.isArray((step as DriverPhase).steps);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The build steps that write the archive a promote's publish step reads, each
|
|
210
|
+
* to its `into` path. The SBOM steps are left out: a promote refuses a step
|
|
211
|
+
* that reads their output, so it never needs it.
|
|
212
|
+
*/
|
|
213
|
+
export const ARCHIVE_STEP_KINDS: readonly string[] = ["docker-build", "zip-package", "jvm-build"];
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The paths `component`'s build steps write its archive to, in composition
|
|
217
|
+
* order without repeats (#2575). A generated pipeline carries these files from
|
|
218
|
+
* the job that deployed to the source environment to the job that promotes,
|
|
219
|
+
* since a promote publishes from the archive on disk. Throws when an `into` is
|
|
220
|
+
* not a literal path, such as a wiring reference or an `$env.` value, because
|
|
221
|
+
* a generator cannot name a file that is only known at run time.
|
|
222
|
+
*/
|
|
223
|
+
export function promoteArchivePaths(component: DriverComponent): string[] {
|
|
224
|
+
const paths: string[] = [];
|
|
225
|
+
const walk = (phases: DriverPhase[]): void => {
|
|
226
|
+
for (const phase of phases) {
|
|
227
|
+
for (const step of phase.steps) {
|
|
228
|
+
if (isPhase(step)) walk([step]);
|
|
229
|
+
else if (!isGate(step) && ARCHIVE_STEP_KINDS.includes(step.kind)) {
|
|
230
|
+
const into = step.into;
|
|
231
|
+
if (typeof into !== "string" || into === "" || into.startsWith("@") || into.startsWith("$")) {
|
|
232
|
+
throw new Error(
|
|
233
|
+
`component "${component.name}": the ${step.kind} step in phase "${phase.phase}" writes to ` +
|
|
234
|
+
`${JSON.stringify(into)}, which is not a literal path, so a generated promote job cannot carry its archive`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
if (!paths.includes(into)) paths.push(into);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
walk(component.deploy);
|
|
243
|
+
return paths;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** A component's composition with its build-time steps taken out. */
|
|
247
|
+
export interface PromotableComponent {
|
|
248
|
+
component: DriverComponent;
|
|
249
|
+
/** The kinds removed, in composition order. */
|
|
250
|
+
removed: string[];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* The step a rollback puts where the publish step was: it publishes nothing
|
|
255
|
+
* and returns the recorded digest, so `@Publish.digest` resolves to it.
|
|
256
|
+
*/
|
|
257
|
+
export const RECORDED_DIGEST_KIND = "recorded-digest";
|
|
258
|
+
|
|
259
|
+
/** The capability behind {@link RECORDED_DIGEST_KIND}. */
|
|
260
|
+
export const recordedDigestCapability: Capability<{ digest: string }, { digest: string }> = {
|
|
261
|
+
kind: RECORDED_DIGEST_KIND,
|
|
262
|
+
rollbackPolicy: "none-by-design",
|
|
263
|
+
async run(_ctx, input) {
|
|
264
|
+
return { digest: input.digest };
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Remove every build-time step from `component`'s `deploy` composition and
|
|
270
|
+
* check that what is left can be pinned to one recorded digest. A phase left
|
|
271
|
+
* with nothing to run is dropped. `rollback` phases are untouched.
|
|
272
|
+
*
|
|
273
|
+
* With `pinDigest` (a rollback, #2531), the publish step is replaced too, by a
|
|
274
|
+
* {@link RECORDED_DIGEST_KIND} step carrying that digest: the environment
|
|
275
|
+
* already received the artifact when the release being restored was
|
|
276
|
+
* deployed, and the archive it came from is usually long gone. Only the
|
|
277
|
+
* digest is known then, so a step reading any other publish output (such as
|
|
278
|
+
* `@Publish.uri`) is refused.
|
|
279
|
+
*/
|
|
280
|
+
export function withoutBuildSteps(
|
|
281
|
+
component: DriverComponent,
|
|
282
|
+
opts: { pinDigest?: string } = {},
|
|
283
|
+
): PromotableComponent | { error: string } {
|
|
284
|
+
const verb = opts.pinDigest === undefined ? "a promote" : "a rollback";
|
|
285
|
+
const removed: string[] = [];
|
|
286
|
+
/** Phases a build step was removed from, and whether anything else is left in them. */
|
|
287
|
+
const emptied = new Map<string, boolean>();
|
|
288
|
+
/** Phases a publish step was pinned in, and whether anything else is left in them. */
|
|
289
|
+
const pinned = new Map<string, boolean>();
|
|
290
|
+
let publishSteps = 0;
|
|
291
|
+
const kept: DriverStep[] = [];
|
|
292
|
+
|
|
293
|
+
const strip = (phase: DriverPhase): DriverPhase | undefined => {
|
|
294
|
+
const steps: DriverPhase["steps"] = [];
|
|
295
|
+
let lostBuildStep = false;
|
|
296
|
+
let pinnedHere = false;
|
|
297
|
+
for (const step of phase.steps) {
|
|
298
|
+
if (isGate(step)) {
|
|
299
|
+
steps.push(step);
|
|
300
|
+
} else if (isPhase(step)) {
|
|
301
|
+
const nested = strip(step);
|
|
302
|
+
if (nested) steps.push(nested);
|
|
303
|
+
} else if (BUILD_STEP_KINDS.includes(step.kind)) {
|
|
304
|
+
removed.push(step.kind);
|
|
305
|
+
lostBuildStep = true;
|
|
306
|
+
} else if (PUBLISH_STEP_KINDS.includes(step.kind)) {
|
|
307
|
+
publishSteps++;
|
|
308
|
+
if (opts.pinDigest === undefined) {
|
|
309
|
+
kept.push(step);
|
|
310
|
+
steps.push(step);
|
|
311
|
+
} else {
|
|
312
|
+
removed.push(step.kind);
|
|
313
|
+
pinnedHere = true;
|
|
314
|
+
steps.push({
|
|
315
|
+
kind: RECORDED_DIGEST_KIND,
|
|
316
|
+
digest: opts.pinDigest,
|
|
317
|
+
replaces: step.kind,
|
|
318
|
+
noRollback: "publishes nothing; it hands on a digest the environment already received",
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
} else {
|
|
322
|
+
kept.push(step);
|
|
323
|
+
steps.push(step);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
const otherWork = steps.some((s) => !isGate(s) && (s as DriverStep).kind !== RECORDED_DIGEST_KIND);
|
|
327
|
+
if (lostBuildStep) emptied.set(phase.phase, !otherWork && !pinnedHere);
|
|
328
|
+
if (pinnedHere) pinned.set(phase.phase, !otherWork);
|
|
329
|
+
// A phase holding only gates after the strip still decides them.
|
|
330
|
+
if (steps.length === 0) return undefined;
|
|
331
|
+
return { ...phase, steps };
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
const deploy = component.deploy.map(strip).filter((p): p is DriverPhase => p !== undefined);
|
|
335
|
+
|
|
336
|
+
if (publishSteps === 0) {
|
|
337
|
+
return {
|
|
338
|
+
error: `component "${component.name}" has no publish step, so nothing in its deploy carries a recorded digest; ` +
|
|
339
|
+
`${verb} would deploy whatever its current source produces`,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
if (publishSteps > 1) {
|
|
343
|
+
return {
|
|
344
|
+
error: `component "${component.name}" has ${publishSteps} publish steps, but its release record carries one digest`,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
for (const step of kept) {
|
|
348
|
+
const { kind: _kind, ...fields } = step;
|
|
349
|
+
for (const ref of refsIn(fields)) {
|
|
350
|
+
if (ref.kind === "prior-step" && emptied.get(ref.phaseName) === true) {
|
|
351
|
+
return {
|
|
352
|
+
error: `component "${component.name}" reads "@${ref.phaseName}.${ref.field}", an output of a build step ` +
|
|
353
|
+
`${verb} does not run`,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
const readsPinned =
|
|
357
|
+
(ref.kind === "prior-step" && pinned.get(ref.phaseName) === true && ref.field !== "digest") ||
|
|
358
|
+
(ref.kind === "component-artifact" && ref.componentName === component.name && ref.field !== "digest" && pinned.size > 0);
|
|
359
|
+
if (readsPinned) {
|
|
360
|
+
const spelled = ref.kind === "prior-step" ? `@${ref.phaseName}.${ref.field}` : `@${ref.componentName}.publish.${ref.field}`;
|
|
361
|
+
return {
|
|
362
|
+
error: `component "${component.name}" reads "${spelled}", but ${verb} knows only the recorded digest; ` +
|
|
363
|
+
`wire the digest instead`,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return { component: { ...component, deploy }, removed };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Every same-project wiring reference anywhere in a step's fields (cross-stack `stackOutput` objects excluded). */
|
|
372
|
+
function refsIn(value: unknown): WiringRef[] {
|
|
373
|
+
const refs: WiringRef[] = [];
|
|
374
|
+
const walk = (v: unknown): void => {
|
|
375
|
+
if (typeof v === "string") {
|
|
376
|
+
const ref = parseWiringRef(v);
|
|
377
|
+
if (ref) refs.push(ref);
|
|
378
|
+
} else if (Array.isArray(v)) {
|
|
379
|
+
v.forEach(walk);
|
|
380
|
+
} else if (v && typeof v === "object" && !("stackOutput" in v)) {
|
|
381
|
+
Object.values(v as Record<string, unknown>).forEach(walk);
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
walk(value);
|
|
385
|
+
return refs;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* A registry whose publish-family capabilities check what they published
|
|
390
|
+
* against the digest being promoted for that component. A mismatch fails the
|
|
391
|
+
* step, which fails the component through the driver's ordinary path. Every
|
|
392
|
+
* other capability is passed through untouched, and the recorded-digest step a
|
|
393
|
+
* rollback's composition carries is added.
|
|
394
|
+
*/
|
|
395
|
+
export function checkPublishedDigest(
|
|
396
|
+
registry: CapabilityRegistry,
|
|
397
|
+
expected: ReadonlyMap<string, string>,
|
|
398
|
+
): CapabilityRegistry {
|
|
399
|
+
const checked = new CapabilityRegistry();
|
|
400
|
+
if (!registry.has(RECORDED_DIGEST_KIND)) checked.register(recordedDigestCapability);
|
|
401
|
+
for (const kind of registry.kinds()) {
|
|
402
|
+
const inner = registry.resolve(kind) as Capability<unknown, unknown>;
|
|
403
|
+
if (!PUBLISH_STEP_KINDS.includes(kind)) {
|
|
404
|
+
checked.register(inner);
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
const wrapped: Capability<unknown, unknown> = {
|
|
408
|
+
kind,
|
|
409
|
+
...(inner.rollbackPolicy ? { rollbackPolicy: inner.rollbackPolicy } : {}),
|
|
410
|
+
async run(ctx, input) {
|
|
411
|
+
let output: unknown;
|
|
412
|
+
try {
|
|
413
|
+
output = await inner.run(ctx, input);
|
|
414
|
+
} catch (err) {
|
|
415
|
+
throw new Error(
|
|
416
|
+
`${kind} could not publish from the build archive: ${err instanceof Error ? err.message : String(err)}. ` +
|
|
417
|
+
`A promote publishes the archive the source release was built from, so it has to run where that archive is on disk`,
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
const want = expected.get(ctx.component);
|
|
421
|
+
const got = (output as { digest?: unknown } | undefined)?.digest;
|
|
422
|
+
if (want !== undefined && got !== want) {
|
|
423
|
+
throw new Error(
|
|
424
|
+
`${kind} published ${typeof got === "string" ? got : "no digest"}, but the release being promoted recorded ${want}; ` +
|
|
425
|
+
`the build archive on disk is not the one that release was built from`,
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
return output;
|
|
429
|
+
},
|
|
430
|
+
...(inner.rollback
|
|
431
|
+
? { rollback: (ctx, input, output) => inner.rollback!(ctx, input, output) }
|
|
432
|
+
: {}),
|
|
433
|
+
};
|
|
434
|
+
checked.register(wrapped);
|
|
435
|
+
}
|
|
436
|
+
return checked;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Options for {@link runPromotion}. */
|
|
440
|
+
export interface RunPromotionOptions {
|
|
441
|
+
plan: PromotionPlan;
|
|
442
|
+
/** The promotable compositions (see {@link withoutBuildSteps}), one per plan item. */
|
|
443
|
+
components: DriverComponent[];
|
|
444
|
+
registry: CapabilityRegistry;
|
|
445
|
+
componentOutputs?: Record<string, Record<string, unknown>>;
|
|
446
|
+
gates?: GateLedgerPort;
|
|
447
|
+
now?: string;
|
|
448
|
+
onProgress?: (event: RunProgressEvent) => void;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Deploy the plan's components to `plan.to`, in dependency order. A
|
|
453
|
+
* `dependsOn` edge to a component outside the promotion is dropped, the same
|
|
454
|
+
* way a single-component `chant run --components` runs without its
|
|
455
|
+
* dependencies; their outputs come from `componentOutputs`. Returns the
|
|
456
|
+
* driver's result for every outcome, including a failure.
|
|
457
|
+
*/
|
|
458
|
+
export async function runPromotion(options: RunPromotionOptions): Promise<DriverRunResult> {
|
|
459
|
+
const { plan } = options;
|
|
460
|
+
const names = new Set(plan.items.map((i) => i.component));
|
|
461
|
+
const targets = options.components
|
|
462
|
+
.filter((c) => names.has(c.name))
|
|
463
|
+
.map((c) => ({ ...c, dependsOn: (c.dependsOn ?? []).filter((d) => names.has(d)) }));
|
|
464
|
+
const expected = new Map(plan.items.map((i) => [i.component, i.digest]));
|
|
465
|
+
try {
|
|
466
|
+
return await runInterpretDriver(targets, checkPublishedDigest(options.registry, expected), {
|
|
467
|
+
env: plan.to,
|
|
468
|
+
componentOutputs: options.componentOutputs ?? {},
|
|
469
|
+
...(options.onProgress ? { onProgress: options.onProgress } : {}),
|
|
470
|
+
...(options.gates ? { gates: options.gates } : {}),
|
|
471
|
+
...(options.now ? { now: options.now } : {}),
|
|
472
|
+
});
|
|
473
|
+
} catch (err) {
|
|
474
|
+
if (err instanceof DriverRunFailure) return err.result;
|
|
475
|
+
throw err;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** Who approved the last gate a component passed in this run, if any. */
|
|
480
|
+
export function gateApprover(result: DriverComponentResult | undefined): string | undefined {
|
|
481
|
+
let approver: string | undefined;
|
|
482
|
+
for (const record of result?.records ?? []) {
|
|
483
|
+
if (record.approval) approver = record.approval.resolvedBy;
|
|
484
|
+
}
|
|
485
|
+
return approver;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** What a promote or rollback knows about its own run, for the records it writes. */
|
|
489
|
+
export interface DeployRunInfo {
|
|
490
|
+
runId: string;
|
|
491
|
+
runOrigin?: RunOrigin;
|
|
492
|
+
actor: string;
|
|
493
|
+
timestamp: string;
|
|
494
|
+
approver?: string;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** The fields a promote and a rollback record share: the artifact is the earlier release's own. */
|
|
498
|
+
function redeployRecord(item: PromotionItem, env: string, run: DeployRunInfo): ReleaseRecordInput {
|
|
499
|
+
const { source } = item;
|
|
500
|
+
return {
|
|
501
|
+
component: item.component,
|
|
502
|
+
env,
|
|
503
|
+
digest: item.digest,
|
|
504
|
+
gitSha: source.gitSha,
|
|
505
|
+
runId: run.runId,
|
|
506
|
+
...(run.runOrigin ? { runOrigin: run.runOrigin } : {}),
|
|
507
|
+
timestamp: run.timestamp,
|
|
508
|
+
actor: run.actor,
|
|
509
|
+
...(run.approver ? { approver: run.approver } : {}),
|
|
510
|
+
...(source.manifestDigest ? { manifestDigest: source.manifestDigest } : {}),
|
|
511
|
+
...(source.inputDigest ? { inputDigest: source.inputDigest } : {}),
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** Name an earlier record the way `promotedFrom` and `restores` do. */
|
|
516
|
+
function refTo(record: ReleaseRecord) {
|
|
517
|
+
return { env: record.env, runId: record.runId, timestamp: record.timestamp };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* The release record a successful promotion appends to the target ledger.
|
|
522
|
+
* The digest, git sha and archive identities are the source release's own,
|
|
523
|
+
* since the artifact is the one built there; `promotedFrom` names that
|
|
524
|
+
* release.
|
|
525
|
+
*/
|
|
526
|
+
export function promotionRecord(item: PromotionItem, to: string, run: DeployRunInfo): ReleaseRecordInput {
|
|
527
|
+
return { ...redeployRecord(item, to, run), promotedFrom: refTo(item.source) };
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* The release record a successful rollback appends. Like a promotion's, it
|
|
532
|
+
* carries the restored release's artifact identities; `restores` names that
|
|
533
|
+
* release.
|
|
534
|
+
*/
|
|
535
|
+
export function rollbackRecord(item: PromotionItem, env: string, run: DeployRunInfo): ReleaseRecordInput {
|
|
536
|
+
return { ...redeployRecord(item, env, run), restores: refTo(item.source) };
|
|
537
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The warning a release ahead of environment- and plan-bound component gate
|
|
3
|
+
* approvals (chant #2574). The gate decision itself must not change.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
7
|
+
import { CapabilityRegistry } from "./capability";
|
|
8
|
+
import { runComponentDeploy, type DriverComponent } from "./driver";
|
|
9
|
+
import { memoryGateLedgerPort } from "../op/gate";
|
|
10
|
+
import type { GateResolutionRecord, PendingGateRecord } from "../lifecycle/gate-ledger";
|
|
11
|
+
import { resetUnboundGateApprovalWarnings, warnOnUnboundComponentApproval } from "./unbound-gate-approval";
|
|
12
|
+
|
|
13
|
+
const NOW = "2026-09-05T12:00:00.000Z";
|
|
14
|
+
|
|
15
|
+
const component: DriverComponent = {
|
|
16
|
+
name: "search-service",
|
|
17
|
+
deploy: [{ phase: "Apply", steps: [{ kind: "gate", gate: "approve-prod" }, { kind: "ecs-deploy" }] }],
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const pending: PendingGateRecord = {
|
|
21
|
+
version: 1, kind: "pending", op: "search-service", gate: "approve-prod",
|
|
22
|
+
timestamp: "2026-09-05T10:00:00.000Z", expiresAt: "2026-09-07T10:00:00.000Z",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const unbound: GateResolutionRecord = {
|
|
26
|
+
version: 1, op: "search-service", gate: "approve-prod",
|
|
27
|
+
resolvedBy: "alex", timestamp: "2026-09-05T11:00:00.000Z",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function registry(calls: string[]): CapabilityRegistry {
|
|
31
|
+
const r = new CapabilityRegistry();
|
|
32
|
+
r.register({ kind: "ecs-deploy", async run() { calls.push("ecs-deploy"); return { ok: true }; } });
|
|
33
|
+
return r;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("warnOnUnboundComponentApproval (#2574)", () => {
|
|
37
|
+
let warn: ReturnType<typeof vi.spyOn>;
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
resetUnboundGateApprovalWarnings();
|
|
40
|
+
warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
41
|
+
});
|
|
42
|
+
afterEach(() => warn.mockRestore());
|
|
43
|
+
|
|
44
|
+
it("warns once per component and gate when the approval records no plan", () => {
|
|
45
|
+
expect(warnOnUnboundComponentApproval("search-service", "approve-prod", "prod", unbound)).toBe(true);
|
|
46
|
+
expect(warnOnUnboundComponentApproval("search-service", "approve-prod", "staging", unbound)).toBe(false);
|
|
47
|
+
expect(warnOnUnboundComponentApproval("orders-table", "approve-prod", "prod", { ...unbound, op: "orders-table" })).toBe(true);
|
|
48
|
+
expect(warn).toHaveBeenCalledTimes(2);
|
|
49
|
+
const line = String(warn.mock.calls[0]![0]);
|
|
50
|
+
expect(line).toContain('gate "approve-prod" on component "search-service" passed in "prod"');
|
|
51
|
+
expect(line).toContain("From the next chant release");
|
|
52
|
+
expect(line).toContain("chant approve search-service approve-prod");
|
|
53
|
+
expect(line).toContain("issues/2574");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("says nothing for an approval that records a plan", () => {
|
|
57
|
+
const bound = { ...unbound, planDigest: `sha256:${"a".repeat(64)}` };
|
|
58
|
+
expect(warnOnUnboundComponentApproval("search-service", "approve-prod", "prod", bound)).toBe(false);
|
|
59
|
+
expect(warn).not.toHaveBeenCalled();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("a deploy passes the gate exactly as before, and warns", async () => {
|
|
63
|
+
const calls: string[] = [];
|
|
64
|
+
const result = await runComponentDeploy(
|
|
65
|
+
component, { env: "prod", component: "search-service" }, registry(calls), {}, undefined,
|
|
66
|
+
{ port: memoryGateLedgerPort({ pending: [pending], resolutions: [unbound] }), now: NOW },
|
|
67
|
+
);
|
|
68
|
+
expect(result.status).toBe("ok");
|
|
69
|
+
expect(calls).toEqual(["ecs-deploy"]);
|
|
70
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("a gated deploy does not warn", async () => {
|
|
74
|
+
const calls: string[] = [];
|
|
75
|
+
const result = await runComponentDeploy(
|
|
76
|
+
component, { env: "prod", component: "search-service" }, registry(calls), {}, undefined,
|
|
77
|
+
{ port: memoryGateLedgerPort(), now: NOW },
|
|
78
|
+
);
|
|
79
|
+
expect(result.status).toBe("gated");
|
|
80
|
+
expect(warn).not.toHaveBeenCalled();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The warning a release ahead of environment- and plan-bound component gate
|
|
3
|
+
* approvals (chant #2574).
|
|
4
|
+
*
|
|
5
|
+
* A component gate approval is keyed by the component and the gate name only,
|
|
6
|
+
* so an approval given for one environment, or for one deploy, can pass the
|
|
7
|
+
* same gate later on another environment or on a promote or rollback. The
|
|
8
|
+
* next release binds each approval to the environment and the plan digest it
|
|
9
|
+
* was given for, the way Op gates bind a plan since #2300, and refuses an
|
|
10
|
+
* approval that records neither.
|
|
11
|
+
*
|
|
12
|
+
* This release only says so. When a component gate passes on an approval that
|
|
13
|
+
* records no plan, the driver prints one warning per component and gate, per
|
|
14
|
+
* process. The decision itself is unchanged.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { GateResolutionRecord } from "../lifecycle/gate-ledger";
|
|
18
|
+
import { approveCommand } from "../op/gate";
|
|
19
|
+
|
|
20
|
+
/** `component\0gate` pairs already warned about in this process. */
|
|
21
|
+
const warned = new Set<string>();
|
|
22
|
+
|
|
23
|
+
/** Forget which gates were warned about. For tests. */
|
|
24
|
+
export function resetUnboundGateApprovalWarnings(): void {
|
|
25
|
+
warned.clear();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Warn once for `component`'s `gate` when the approval that just passed it in
|
|
30
|
+
* `env` records no plan. Returns whether a warning was printed.
|
|
31
|
+
*/
|
|
32
|
+
export function warnOnUnboundComponentApproval(
|
|
33
|
+
component: string,
|
|
34
|
+
gate: string,
|
|
35
|
+
env: string,
|
|
36
|
+
resolution: GateResolutionRecord,
|
|
37
|
+
): boolean {
|
|
38
|
+
if (resolution.planDigest !== undefined) return false;
|
|
39
|
+
const key = `${component}\0${gate}`;
|
|
40
|
+
if (warned.has(key)) return false;
|
|
41
|
+
warned.add(key);
|
|
42
|
+
console.warn(
|
|
43
|
+
`[chant] warning: gate "${gate}" on component "${component}" passed in "${env}" on an approval by ` +
|
|
44
|
+
`${resolution.resolvedBy} at ${resolution.timestamp} that records no environment and no plan. ` +
|
|
45
|
+
"From the next chant release, a component gate approval counts only for the environment and plan " +
|
|
46
|
+
"digest it was given for, and approvals recorded before then are refused. After upgrading, run the " +
|
|
47
|
+
`deploy again and approve the gate it stops at with \`${approveCommand(component, gate)}\`. ` +
|
|
48
|
+
"See https://github.com/INTENTIUS/chant/issues/2574",
|
|
49
|
+
);
|
|
50
|
+
return true;
|
|
51
|
+
}
|