@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,371 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Promote a release without rebuilding (#2530).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, test, expect } from "vitest";
|
|
6
|
+
import { CapabilityRegistry, type DeployContext } from "./capability";
|
|
7
|
+
import { memoryGateLedgerPort, type GateLedgerPort } from "../op/gate";
|
|
8
|
+
import type { GateResolutionRecord } from "../lifecycle/gate-ledger";
|
|
9
|
+
import type { DriverComponent } from "./driver";
|
|
10
|
+
import type { ReleaseRecord } from "../lifecycle/release-ledger";
|
|
11
|
+
import {
|
|
12
|
+
planPromotion,
|
|
13
|
+
planRollback,
|
|
14
|
+
rollbackRecord,
|
|
15
|
+
selectRelease,
|
|
16
|
+
withoutBuildSteps,
|
|
17
|
+
runPromotion,
|
|
18
|
+
promotionRecord,
|
|
19
|
+
gateApprover,
|
|
20
|
+
promoteArchivePaths,
|
|
21
|
+
type PromotionPlan,
|
|
22
|
+
} from "./promote";
|
|
23
|
+
|
|
24
|
+
const rec = (component: string, env: string, digest: string, timestamp: string, runId = `run-${timestamp}`): ReleaseRecord => ({
|
|
25
|
+
version: 1,
|
|
26
|
+
component,
|
|
27
|
+
env,
|
|
28
|
+
digest,
|
|
29
|
+
gitSha: "abc123",
|
|
30
|
+
runId,
|
|
31
|
+
timestamp,
|
|
32
|
+
actor: "ci",
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/** Build, publish, apply: the shape the pilots and the adopt example use. */
|
|
36
|
+
const service = (name: string, extra: Partial<DriverComponent> = {}): DriverComponent => ({
|
|
37
|
+
name,
|
|
38
|
+
deploy: [
|
|
39
|
+
{ phase: "Build", steps: [{ kind: "docker-build", context: ".", into: `${name}.tar` }] },
|
|
40
|
+
{ phase: "Publish", steps: [{ kind: "publish-image", from: `archive:${name}.tar`, to: "$env.registry" }] },
|
|
41
|
+
{ phase: "Apply", steps: [{ kind: "apply", imageRef: "@Publish.digest" }] },
|
|
42
|
+
],
|
|
43
|
+
...extra,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("selecting the source release", () => {
|
|
47
|
+
const records = [
|
|
48
|
+
rec("api", "staging", "sha256:old", "2026-01-01T00:00:00Z"),
|
|
49
|
+
rec("api", "staging", "sha256:new", "2026-01-02T00:00:00Z"),
|
|
50
|
+
rec("web", "staging", "sha256:web", "2026-01-01T00:00:00Z"),
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
test("the latest release by default", () => {
|
|
54
|
+
const picked = selectRelease(records, "api", "staging");
|
|
55
|
+
expect("record" in picked && picked.record.digest).toBe("sha256:new");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("a named digest that the ledger recorded", () => {
|
|
59
|
+
const picked = selectRelease(records, "api", "staging", "sha256:old");
|
|
60
|
+
expect("record" in picked && picked.record.timestamp).toBe("2026-01-01T00:00:00Z");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("a digest missing from the source ledger fails and names what is recorded", () => {
|
|
64
|
+
const picked = selectRelease(records, "api", "staging", "sha256:nope");
|
|
65
|
+
expect("error" in picked && picked.error).toMatch(/sha256:nope is not recorded for "api" in "staging"/);
|
|
66
|
+
expect("error" in picked && picked.error).toMatch(/sha256:old, sha256:new/);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("a component with no release in the source fails", () => {
|
|
70
|
+
const picked = selectRelease(records, "db", "staging");
|
|
71
|
+
expect("error" in picked && picked.error).toMatch(/no release of "db" is recorded in "staging"/);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("planning", () => {
|
|
76
|
+
const records = [
|
|
77
|
+
rec("api", "staging", "sha256:api", "2026-01-02T00:00:00Z"),
|
|
78
|
+
rec("gone", "staging", "sha256:gone", "2026-01-02T00:00:00Z"),
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
test("every declared component with a source release, and the rest reported", () => {
|
|
82
|
+
const plan = planPromotion({ from: "staging", to: "prod", sourceRecords: records, declared: ["api", "infra"] });
|
|
83
|
+
expect("items" in plan).toBe(true);
|
|
84
|
+
const { items, notPromoted } = plan as PromotionPlan;
|
|
85
|
+
expect(items.map((i) => [i.component, i.digest])).toEqual([["api", "sha256:api"]]);
|
|
86
|
+
expect(notPromoted).toEqual([{ component: "gone", reason: "recorded but not declared in this checkout" }]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("refuses the same environment on both sides", () => {
|
|
90
|
+
const plan = planPromotion({ from: "prod", to: "prod", sourceRecords: records, declared: ["api"] });
|
|
91
|
+
expect("error" in plan && plan.error).toMatch(/same environment/);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("--digest needs --component", () => {
|
|
95
|
+
const plan = planPromotion({ from: "staging", to: "prod", sourceRecords: records, declared: ["api"], digest: "sha256:api" });
|
|
96
|
+
expect("error" in plan && plan.error).toMatch(/needs --component/);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("an empty source ledger is an error, not a no-op", () => {
|
|
100
|
+
const plan = planPromotion({ from: "staging", to: "prod", sourceRecords: [], declared: ["api"] });
|
|
101
|
+
expect("error" in plan && plan.error).toMatch(/nothing to promote/);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("taking the build out of a composition", () => {
|
|
106
|
+
test("build steps go, publish and apply stay", () => {
|
|
107
|
+
const prepared = withoutBuildSteps(service("api"));
|
|
108
|
+
expect("component" in prepared).toBe(true);
|
|
109
|
+
if (!("component" in prepared)) return;
|
|
110
|
+
expect(prepared.removed).toEqual(["docker-build"]);
|
|
111
|
+
expect(prepared.component.deploy.map((p) => p.phase)).toEqual(["Publish", "Apply"]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("gates survive, even in a phase that loses its build step", () => {
|
|
115
|
+
const c = service("api");
|
|
116
|
+
c.deploy[0].steps.unshift({ kind: "gate", gate: "release" });
|
|
117
|
+
const prepared = withoutBuildSteps(c);
|
|
118
|
+
if (!("component" in prepared)) throw new Error(prepared.error);
|
|
119
|
+
expect(prepared.component.deploy[0]).toEqual({ phase: "Build", steps: [{ kind: "gate", gate: "release" }] });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("no publish step is refused", () => {
|
|
123
|
+
const c: DriverComponent = { name: "chart", deploy: [{ phase: "Apply", steps: [{ kind: "helm-upgrade" }] }] };
|
|
124
|
+
const prepared = withoutBuildSteps(c);
|
|
125
|
+
expect("error" in prepared && prepared.error).toMatch(/no publish step/);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("two publish steps are refused", () => {
|
|
129
|
+
const c = service("api");
|
|
130
|
+
c.deploy[1].steps.push({ kind: "publish-artifact", from: "archive:x.jar" });
|
|
131
|
+
const prepared = withoutBuildSteps(c);
|
|
132
|
+
expect("error" in prepared && prepared.error).toMatch(/2 publish steps/);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("a step reading a removed build step's output is refused", () => {
|
|
136
|
+
const c = service("api");
|
|
137
|
+
c.deploy[1].steps[0] = { kind: "publish-image", from: "@Build.archivePath", to: "$env.registry" };
|
|
138
|
+
const prepared = withoutBuildSteps(c);
|
|
139
|
+
expect("error" in prepared && prepared.error).toMatch(/reads "@Build.archivePath"/);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
/** A registry of fakes. `publishes` is the digest publish-image returns per component. */
|
|
144
|
+
function fakeRegistry(publishes: Record<string, string>) {
|
|
145
|
+
const ran: string[] = [];
|
|
146
|
+
const registry = new CapabilityRegistry();
|
|
147
|
+
const step = (kind: string, run: (ctx: DeployContext, input: Record<string, unknown>) => unknown) =>
|
|
148
|
+
registry.register({ kind, async run(ctx: DeployContext, input: Record<string, unknown>) { ran.push(`${ctx.env}:${ctx.component}:${kind}`); return run(ctx, input); } } as never);
|
|
149
|
+
step("docker-build", () => ({ digest: "sha256:rebuilt" }));
|
|
150
|
+
step("publish-image", (ctx) => ({ digest: publishes[ctx.component], uri: `reg/${ctx.component}@${publishes[ctx.component]}` }));
|
|
151
|
+
step("apply", (_ctx, input) => ({ applied: input.imageRef }));
|
|
152
|
+
return { registry, ran };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function planFor(component: string, digest: string): PromotionPlan {
|
|
156
|
+
return {
|
|
157
|
+
from: "staging",
|
|
158
|
+
to: "prod",
|
|
159
|
+
items: [{ component, digest, source: rec(component, "staging", digest, "2026-01-02T00:00:00Z", "run-7") }],
|
|
160
|
+
notPromoted: [],
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function prepared(c: DriverComponent): DriverComponent {
|
|
165
|
+
const p = withoutBuildSteps(c);
|
|
166
|
+
if ("error" in p) throw new Error(p.error);
|
|
167
|
+
return p.component;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
describe("running a promotion", () => {
|
|
171
|
+
test("deploys the recorded digest to the target without building", async () => {
|
|
172
|
+
const { registry, ran } = fakeRegistry({ api: "sha256:api" });
|
|
173
|
+
const run = await runPromotion({
|
|
174
|
+
plan: planFor("api", "sha256:api"),
|
|
175
|
+
components: [prepared(service("api"))],
|
|
176
|
+
registry,
|
|
177
|
+
gates: memoryGateLedgerPort(),
|
|
178
|
+
});
|
|
179
|
+
expect(run.status).toBe("ok");
|
|
180
|
+
expect(ran).toEqual(["prod:api:publish-image", "prod:api:apply"]);
|
|
181
|
+
const apply = run.results[0].records.find((r) => r.kind === "apply");
|
|
182
|
+
expect(apply?.output).toEqual({ applied: "sha256:api" });
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("a publish that yields another digest fails before apply runs", async () => {
|
|
186
|
+
const { registry, ran } = fakeRegistry({ api: "sha256:different" });
|
|
187
|
+
const run = await runPromotion({
|
|
188
|
+
plan: planFor("api", "sha256:api"),
|
|
189
|
+
components: [prepared(service("api"))],
|
|
190
|
+
registry,
|
|
191
|
+
gates: memoryGateLedgerPort(),
|
|
192
|
+
});
|
|
193
|
+
expect(run.status).toBe("fail");
|
|
194
|
+
expect(ran).not.toContain("prod:api:apply");
|
|
195
|
+
const publish = run.results[0].records.find((r) => r.kind === "publish-image" && r.status === "fail");
|
|
196
|
+
expect(publish?.error).toMatch(/published sha256:different, but the release being promoted recorded sha256:api/);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("the target's gate still applies, and an approval lets the same promote through", async () => {
|
|
200
|
+
const gated = service("api");
|
|
201
|
+
gated.deploy[2].steps.unshift({ kind: "gate", gate: "prod-release" });
|
|
202
|
+
const resolutions: GateResolutionRecord[] = [];
|
|
203
|
+
const memory = memoryGateLedgerPort();
|
|
204
|
+
const gates: GateLedgerPort = {
|
|
205
|
+
async read(op) {
|
|
206
|
+
const read = await memory.read(op);
|
|
207
|
+
return { ...read, resolutions: [...resolutions] };
|
|
208
|
+
},
|
|
209
|
+
appendPending: (input) => memory.appendPending(input),
|
|
210
|
+
};
|
|
211
|
+
const { registry, ran } = fakeRegistry({ api: "sha256:api" });
|
|
212
|
+
const opts = { plan: planFor("api", "sha256:api"), components: [prepared(gated)], registry, gates };
|
|
213
|
+
|
|
214
|
+
const first = await runPromotion({ ...opts, now: "2026-01-03T00:00:00Z" });
|
|
215
|
+
expect(first.status).toBe("gated");
|
|
216
|
+
expect(first.gate?.gate).toBe("prod-release");
|
|
217
|
+
expect(ran).not.toContain("prod:api:apply");
|
|
218
|
+
|
|
219
|
+
resolutions.push({ version: 1, kind: "resolution", op: "api", gate: "prod-release", resolvedBy: "alice", timestamp: "2026-01-03T00:01:00Z" });
|
|
220
|
+
const second = await runPromotion({ ...opts, now: "2026-01-03T00:02:00Z" });
|
|
221
|
+
expect(second.status).toBe("ok");
|
|
222
|
+
expect(gateApprover(second.results[0])).toBe("alice");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("dependencies outside the promotion do not block it", async () => {
|
|
226
|
+
const { registry } = fakeRegistry({ api: "sha256:api" });
|
|
227
|
+
const run = await runPromotion({
|
|
228
|
+
plan: planFor("api", "sha256:api"),
|
|
229
|
+
components: [prepared(service("api", { dependsOn: ["shared-alb"] }))],
|
|
230
|
+
registry,
|
|
231
|
+
gates: memoryGateLedgerPort(),
|
|
232
|
+
});
|
|
233
|
+
expect(run.status).toBe("ok");
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe("the target record", () => {
|
|
238
|
+
test("carries the source release it promoted", () => {
|
|
239
|
+
const plan = planFor("api", "sha256:api");
|
|
240
|
+
plan.items[0].source.manifestDigest = "sha256:manifest";
|
|
241
|
+
const record = promotionRecord(plan.items[0], "prod", {
|
|
242
|
+
runId: "run-9",
|
|
243
|
+
actor: "bob",
|
|
244
|
+
timestamp: "2026-01-04T00:00:00Z",
|
|
245
|
+
approver: "alice",
|
|
246
|
+
});
|
|
247
|
+
expect(record).toEqual({
|
|
248
|
+
component: "api",
|
|
249
|
+
env: "prod",
|
|
250
|
+
digest: "sha256:api",
|
|
251
|
+
gitSha: "abc123",
|
|
252
|
+
runId: "run-9",
|
|
253
|
+
timestamp: "2026-01-04T00:00:00Z",
|
|
254
|
+
actor: "bob",
|
|
255
|
+
approver: "alice",
|
|
256
|
+
manifestDigest: "sha256:manifest",
|
|
257
|
+
promotedFrom: { env: "staging", runId: "run-7", timestamp: "2026-01-02T00:00:00Z" },
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
describe("choosing the release a rollback restores", () => {
|
|
263
|
+
const history = [
|
|
264
|
+
rec("api", "prod", "sha256:a", "2026-01-01T00:00:00Z"),
|
|
265
|
+
rec("api", "prod", "sha256:b", "2026-01-02T00:00:00Z"),
|
|
266
|
+
rec("api", "prod", "sha256:c", "2026-01-03T00:00:00Z"),
|
|
267
|
+
];
|
|
268
|
+
const plan = (input: Partial<Parameters<typeof planRollback>[0]>) =>
|
|
269
|
+
planRollback({ env: "prod", records: history, declared: ["api"], component: "api", ...input });
|
|
270
|
+
|
|
271
|
+
test("the release before the current one by default", () => {
|
|
272
|
+
const p = plan({});
|
|
273
|
+
expect("items" in p && p.items[0].digest).toBe("sha256:b");
|
|
274
|
+
expect("items" in p && p.from).toBe("prod");
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("a redeploy of the current digest does not count as an earlier release", () => {
|
|
278
|
+
const p = plan({ records: [...history, rec("api", "prod", "sha256:c", "2026-01-04T00:00:00Z")] });
|
|
279
|
+
expect("items" in p && p.items[0].digest).toBe("sha256:b");
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("a chosen digest", () => {
|
|
283
|
+
const p = plan({ digest: "sha256:a" });
|
|
284
|
+
expect("items" in p && p.items[0].source.timestamp).toBe("2026-01-01T00:00:00Z");
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("a digest the environment never recorded is refused", () => {
|
|
288
|
+
const p = plan({ digest: "sha256:z" });
|
|
289
|
+
expect("error" in p && p.error).toMatch(/sha256:z is not recorded for "api" in "prod"/);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("the current release is refused", () => {
|
|
293
|
+
const p = plan({ digest: "sha256:c" });
|
|
294
|
+
expect("error" in p && p.error).toMatch(/already the current release/);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("a single release has nothing earlier", () => {
|
|
298
|
+
const p = plan({ records: [history[0]] });
|
|
299
|
+
expect("error" in p && p.error).toMatch(/no earlier release/);
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
describe("pinning the publish step for a rollback", () => {
|
|
304
|
+
test("the publish step is replaced by the recorded digest", () => {
|
|
305
|
+
const p = withoutBuildSteps(service("api"), { pinDigest: "sha256:b" });
|
|
306
|
+
if ("error" in p) throw new Error(p.error);
|
|
307
|
+
expect(p.removed).toEqual(["docker-build", "publish-image"]);
|
|
308
|
+
expect(p.component.deploy[0].steps[0]).toMatchObject({ kind: "recorded-digest", digest: "sha256:b", replaces: "publish-image" });
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("a step reading another publish output is refused", () => {
|
|
312
|
+
const c = service("api");
|
|
313
|
+
c.deploy[2].steps[0] = { kind: "apply", image: "@Publish.uri" };
|
|
314
|
+
const p = withoutBuildSteps(c, { pinDigest: "sha256:b" });
|
|
315
|
+
expect("error" in p && p.error).toMatch(/reads "@Publish.uri", but a rollback knows only the recorded digest/);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("a rollback redeploys the recorded digest and publishes nothing", async () => {
|
|
319
|
+
const { registry, ran } = fakeRegistry({ api: "sha256:new" });
|
|
320
|
+
const p = withoutBuildSteps(service("api"), { pinDigest: "sha256:b" });
|
|
321
|
+
if ("error" in p) throw new Error(p.error);
|
|
322
|
+
const plan: PromotionPlan = {
|
|
323
|
+
from: "prod",
|
|
324
|
+
to: "prod",
|
|
325
|
+
items: [{ component: "api", digest: "sha256:b", source: rec("api", "prod", "sha256:b", "2026-01-02T00:00:00Z", "run-2") }],
|
|
326
|
+
notPromoted: [],
|
|
327
|
+
};
|
|
328
|
+
const run = await runPromotion({ plan, components: [p.component], registry, gates: memoryGateLedgerPort() });
|
|
329
|
+
expect(run.status).toBe("ok");
|
|
330
|
+
expect(ran).toEqual(["prod:api:apply"]);
|
|
331
|
+
expect(run.results[0].records.find((r) => r.kind === "apply")?.output).toEqual({ applied: "sha256:b" });
|
|
332
|
+
|
|
333
|
+
expect(rollbackRecord(plan.items[0], "prod", { runId: "run-5", actor: "bob", timestamp: "2026-01-05T00:00:00Z" })).toMatchObject({
|
|
334
|
+
env: "prod",
|
|
335
|
+
digest: "sha256:b",
|
|
336
|
+
restores: { env: "prod", runId: "run-2", timestamp: "2026-01-02T00:00:00Z" },
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
describe("the archive paths a generated promote job carries (#2575)", () => {
|
|
342
|
+
test("every build step's into, nested phases included, once each and in order", () => {
|
|
343
|
+
const component: DriverComponent = {
|
|
344
|
+
name: "api",
|
|
345
|
+
deploy: [
|
|
346
|
+
{ phase: "Build", steps: [
|
|
347
|
+
{ kind: "docker-build", context: ".", into: "dist/api.tar" },
|
|
348
|
+
{ kind: "generate-sbom", artifactType: "image", path: "dist/api.tar", into: "dist/api.sbom.json" },
|
|
349
|
+
{ phase: "Lambdas", parallel: true, steps: [{ kind: "zip-package", source: "fn", into: "dist/fn.zip" }] },
|
|
350
|
+
] },
|
|
351
|
+
{ phase: "Again", steps: [{ kind: "docker-build", context: ".", into: "dist/api.tar" }] },
|
|
352
|
+
{ phase: "Publish", steps: [{ kind: "publish-image", from: "archive:dist/api.tar" }] },
|
|
353
|
+
],
|
|
354
|
+
};
|
|
355
|
+
expect(promoteArchivePaths(component)).toEqual(["dist/api.tar", "dist/fn.zip"]);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test("a component that builds nothing carries nothing", () => {
|
|
359
|
+
expect(promoteArchivePaths({ name: "infra", deploy: [{ phase: "Apply", steps: [{ kind: "cfn-deploy" }] }] })).toEqual([]);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("an into that is only known at run time is refused by name", () => {
|
|
363
|
+
for (const into of ["@Params.path", "$env.archive", undefined]) {
|
|
364
|
+
const component: DriverComponent = {
|
|
365
|
+
name: "api",
|
|
366
|
+
deploy: [{ phase: "Build", steps: [{ kind: "jvm-build", into }] }],
|
|
367
|
+
};
|
|
368
|
+
expect(() => promoteArchivePaths(component)).toThrow(/component "api": the jvm-build step in phase "Build".*not a literal path/);
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
});
|