@intentius/chant 0.124.38 → 0.124.40
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/component-endpoint.d.ts +20 -0
- package/dist/cli/component-endpoint.d.ts.map +1 -0
- package/dist/cli/component-endpoint.js +36 -0
- package/dist/cli/handlers/fan-out.d.ts.map +1 -1
- package/dist/cli/handlers/fan-out.js +10 -1
- package/dist/cli/handlers/pr.d.ts.map +1 -1
- package/dist/cli/handlers/pr.js +11 -5
- package/dist/cli/handlers/promote.d.ts.map +1 -1
- package/dist/cli/handlers/promote.js +7 -2
- package/dist/cli/handlers/run.d.ts +4 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/handlers/run.js +17 -6
- package/dist/components/auto-release.d.ts +35 -3
- package/dist/components/auto-release.d.ts.map +1 -1
- package/dist/components/auto-release.js +29 -2
- package/dist/components/cli-support.d.ts +2 -0
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/cli-support.js +2 -0
- package/dist/components/deploy-units.d.ts.map +1 -1
- package/dist/components/deploy-units.js +5 -0
- package/dist/components/promote.d.ts.map +1 -1
- package/dist/components/promote.js +39 -16
- package/dist/lifecycle/__fixtures__/publish-fakes.d.ts.map +1 -1
- package/dist/lifecycle/__fixtures__/publish-fakes.js +6 -1
- package/dist/lifecycle/publish-declaration.d.ts.map +1 -1
- package/dist/lifecycle/publish-declaration.js +4 -3
- package/dist/lifecycle/publish-npm.d.ts +2 -2
- package/dist/lifecycle/publish-npm.d.ts.map +1 -1
- package/dist/lifecycle/publish-npm.js +31 -8
- package/dist/lifecycle/release-ledger.d.ts +31 -0
- package/dist/lifecycle/release-ledger.d.ts.map +1 -1
- package/dist/lifecycle/release-ledger.js +19 -1
- package/dist/lifecycle/status.d.ts +5 -1
- package/dist/lifecycle/status.d.ts.map +1 -1
- package/dist/lifecycle/status.js +10 -5
- package/dist/live-endpoint.d.ts +18 -0
- package/dist/live-endpoint.d.ts.map +1 -1
- package/dist/live-endpoint.js +27 -0
- package/dist/wave-runtime.mjs +201 -201
- package/dist/workspace/ci-builds.d.ts +12 -1
- package/dist/workspace/ci-builds.d.ts.map +1 -1
- package/dist/workspace/ci-builds.js +26 -0
- package/dist/workspace/ci-green-cli.js +1 -1
- package/dist/workspace/ci-green-forge.d.ts +3 -1
- package/dist/workspace/ci-green-forge.d.ts.map +1 -1
- package/dist/workspace/ci-green-forge.js +20 -2
- package/dist/workspace/ci-green-workflow.d.ts +6 -4
- package/dist/workspace/ci-green-workflow.d.ts.map +1 -1
- package/dist/workspace/ci-green-workflow.js +26 -8
- package/dist/workspace/ci-run.d.ts +7 -0
- package/dist/workspace/ci-run.d.ts.map +1 -1
- package/dist/workspace/ci-run.js +21 -3
- package/dist/workspace/status.d.ts +3 -1
- package/dist/workspace/status.d.ts.map +1 -1
- package/dist/workspace/status.js +3 -2
- package/dist/workspace/status.schema.json +1 -0
- package/package.json +1 -1
- package/src/cli/component-endpoint.ts +43 -0
- package/src/cli/handlers/fan-out.test.ts +10 -0
- package/src/cli/handlers/fan-out.ts +12 -0
- package/src/cli/handlers/pr-endpoint.test.ts +169 -0
- package/src/cli/handlers/pr.ts +12 -5
- package/src/cli/handlers/promote.ts +16 -2
- package/src/cli/handlers/run.test.ts +2 -1
- package/src/cli/handlers/run.ts +16 -5
- package/src/components/auto-release-infra.test.ts +131 -0
- package/src/components/auto-release.test.ts +40 -1
- package/src/components/auto-release.ts +50 -4
- package/src/components/cli-support.ts +4 -0
- package/src/components/deploy-units.ts +5 -0
- package/src/components/promote.test.ts +39 -0
- package/src/components/promote.ts +38 -14
- package/src/lifecycle/__fixtures__/publish-fakes.ts +6 -1
- package/src/lifecycle/publish-declaration.ts +4 -3
- package/src/lifecycle/publish-npm.ts +33 -8
- package/src/lifecycle/publish-targets.test.ts +36 -3
- package/src/lifecycle/release-ledger.ts +42 -1
- package/src/lifecycle/status.test.ts +19 -0
- package/src/lifecycle/status.ts +14 -6
- package/src/live-endpoint.ts +31 -0
- package/src/workspace/ci-builds.e2e.test.ts +7 -2
- package/src/workspace/ci-builds.test.ts +46 -1
- package/src/workspace/ci-builds.ts +25 -1
- package/src/workspace/ci-green-builds.test.ts +26 -0
- package/src/workspace/ci-green-cli.ts +1 -1
- package/src/workspace/ci-green-forge.test.ts +31 -0
- package/src/workspace/ci-green-forge.ts +21 -3
- package/src/workspace/ci-green-workflow.test.ts +4 -4
- package/src/workspace/ci-green-workflow.ts +22 -8
- package/src/workspace/ci-run.test.ts +26 -0
- package/src/workspace/ci-run.ts +20 -3
- package/src/workspace/status.schema.json +1 -0
- package/src/workspace/status.ts +7 -3
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant components pr-plan` for an environment that declares an endpoint
|
|
3
|
+
* (#4281): every request a capability's plan sends goes to that endpoint and
|
|
4
|
+
* no other host, the way a `--live` read does (`../../live-endpoint.ts`).
|
|
5
|
+
*
|
|
6
|
+
* The capability stands in for azure's `arm-deploy`, gcp's `gcp-apply` and
|
|
7
|
+
* aws's `cfn-deploy`: its plan reads the endpoint from its lexicon's ambient
|
|
8
|
+
* var, else the real cloud's host, and sends a request through `fetch`, which
|
|
9
|
+
* is replaced here and only records. Nothing leaves the process. Git, the
|
|
10
|
+
* change signal, the derivation and the gate ledger are fakes, and the
|
|
11
|
+
* lifecycle branch can't be pushed.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
18
|
+
import type { DriverComponent } from "../../components/driver";
|
|
19
|
+
import type { DeployContext } from "../../components/capability";
|
|
20
|
+
import type { EmulatorCapability } from "../../op/emulator-lifecycle";
|
|
21
|
+
import type { ParsedArgs } from "../registry";
|
|
22
|
+
|
|
23
|
+
const HEAD = "a".repeat(40);
|
|
24
|
+
const BASE = "b".repeat(40);
|
|
25
|
+
const EMULATOR = "http://localhost:4566";
|
|
26
|
+
const REAL_CLOUD = "https://management.fakecloud.example";
|
|
27
|
+
const ENDPOINT_VAR = "FAKECLOUD_ENDPOINT_URL";
|
|
28
|
+
|
|
29
|
+
vi.mock("../../components/changed-files", () => ({
|
|
30
|
+
resolveCommit: async () => HEAD,
|
|
31
|
+
resolveMergeBase: async () => BASE,
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
vi.mock("../../config", async () => {
|
|
35
|
+
const actual = await vi.importActual<typeof import("../../config")>("../../config");
|
|
36
|
+
return {
|
|
37
|
+
...actual,
|
|
38
|
+
loadChantConfig: async () => ({
|
|
39
|
+
config: { environments: [{ name: "floci", endpoint: EMULATOR }, { name: "prod" }] },
|
|
40
|
+
}),
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
vi.mock("./fan-out", () => ({
|
|
45
|
+
resolveChangedUnits: async () => ({ units: { changed: ["net"], indeterminate: [] } }),
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
vi.mock("../../lifecycle/gate-change-set", () => ({
|
|
49
|
+
storeGateChangeSet: async () => ({ written: false }),
|
|
50
|
+
readGraphIds: async () => ({ ids: [] }),
|
|
51
|
+
setGraphIdReader: () => {},
|
|
52
|
+
gateGraphReader: () => async () => ({ ids: [] }),
|
|
53
|
+
}));
|
|
54
|
+
vi.mock("../../lifecycle/git", async () => {
|
|
55
|
+
const actual = await vi.importActual<typeof import("../../lifecycle/git")>("../../lifecycle/git");
|
|
56
|
+
return { ...actual, pushLifecycleReport: async () => { throw new Error("pr-endpoint.test must not push chant/lifecycle"); } };
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
vi.mock("./run", () => ({
|
|
60
|
+
GATED_EXIT_CODE: 3,
|
|
61
|
+
autoReleaseRun: (id: string) => ({ runId: id, runOrigin: { forge: "local" } }),
|
|
62
|
+
recordAutoReleasesForRun: async () => [],
|
|
63
|
+
}));
|
|
64
|
+
|
|
65
|
+
vi.mock("../../op/gate", async () => {
|
|
66
|
+
const actual = await vi.importActual<typeof import("../../op/gate")>("../../op/gate");
|
|
67
|
+
return { ...actual, gitGateLedgerPort: () => actual.memoryGateLedgerPort(), gateIsSealed: async () => false };
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
vi.mock("../../components/fan-out-support", async () => {
|
|
71
|
+
const { CapabilityRegistry } = await import("../../components/capability");
|
|
72
|
+
const { planFanOut } = await import("../../components/fan-out");
|
|
73
|
+
const { computePlanDigest } = await import("../../lifecycle/plan-digest");
|
|
74
|
+
const registry = new CapabilityRegistry();
|
|
75
|
+
registry.register({
|
|
76
|
+
kind: "fakecloud-deploy",
|
|
77
|
+
async plan(_ctx: DeployContext, input: { root: string }) {
|
|
78
|
+
// What arm-deploy, gcp-apply and cfn-deploy do: the ambient var, else the real host.
|
|
79
|
+
const base = process.env[ENDPOINT_VAR] ?? REAL_CLOUD;
|
|
80
|
+
await fetch(`${base}/deployments/${input.root}`);
|
|
81
|
+
const planDigest = computePlanDigest("fakecloud-deploy", { root: input.root });
|
|
82
|
+
return {
|
|
83
|
+
member: input.root,
|
|
84
|
+
planDigest,
|
|
85
|
+
artifact: { planDigest },
|
|
86
|
+
changeSet: { member: { member: input.root, lexicon: "fakecloud", planner: "fakecloud", status: "planned", planDigest, holes: [] }, entries: [] },
|
|
87
|
+
};
|
|
88
|
+
},
|
|
89
|
+
async outputs() {
|
|
90
|
+
return {};
|
|
91
|
+
},
|
|
92
|
+
async run() {
|
|
93
|
+
throw new Error("pr-plan must not apply");
|
|
94
|
+
},
|
|
95
|
+
} as never);
|
|
96
|
+
const component = (name: string, dependsOn: string[] = []): DriverComponent => ({
|
|
97
|
+
name,
|
|
98
|
+
dependsOn,
|
|
99
|
+
deploy: [{ phase: "Apply", steps: [{ kind: "fakecloud-deploy", root: name }] }],
|
|
100
|
+
});
|
|
101
|
+
const estate = [component("net"), component("app", ["net"])];
|
|
102
|
+
return {
|
|
103
|
+
fanOutRegistry: async () => registry,
|
|
104
|
+
deriveFanOut: async () => ({
|
|
105
|
+
success: true,
|
|
106
|
+
components: estate,
|
|
107
|
+
plan: planFanOut({ components: estate, changed: ["net"] }),
|
|
108
|
+
signal: { changed: ["net"], indeterminate: [], unclaimed: [] },
|
|
109
|
+
}),
|
|
110
|
+
};
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
/** The lexicon plugin, declaring its endpoint var through its emulator as aws, azure and gcp do. */
|
|
114
|
+
const emulator: EmulatorCapability = {
|
|
115
|
+
spec: { name: "chant-fakecloud", image: "fakecloud:0", containerPort: 4566, healthPath: "/health" },
|
|
116
|
+
env: (endpoint) => ({ [ENDPOINT_VAR]: endpoint, FAKECLOUD_ACCESS_KEY: "test" }),
|
|
117
|
+
};
|
|
118
|
+
const plugins = [{ name: "fakecloud", emulator }];
|
|
119
|
+
|
|
120
|
+
const { runComponentsPrPlan } = await import("./pr");
|
|
121
|
+
|
|
122
|
+
let dir: string;
|
|
123
|
+
let hosts: string[];
|
|
124
|
+
|
|
125
|
+
const ctx = (env: string) =>
|
|
126
|
+
({
|
|
127
|
+
args: { command: "components", path: "pr-plan", format: "", fix: false, watch: false, verbose: false, help: false, live: false, base: "main", env, output: dir, pr: 7 } as ParsedArgs,
|
|
128
|
+
plugins,
|
|
129
|
+
}) as never;
|
|
130
|
+
|
|
131
|
+
beforeEach(async () => {
|
|
132
|
+
dir = await mkdtemp(join(tmpdir(), "pr-endpoint-"));
|
|
133
|
+
hosts = [];
|
|
134
|
+
delete process.env[ENDPOINT_VAR];
|
|
135
|
+
vi.stubGlobal("fetch", vi.fn(async (url: string | URL) => {
|
|
136
|
+
hosts.push(new URL(String(url)).host);
|
|
137
|
+
return new Response("{}", { status: 200 });
|
|
138
|
+
}));
|
|
139
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
140
|
+
vi.spyOn(console, "log").mockImplementation(() => {});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
afterEach(async () => {
|
|
144
|
+
vi.unstubAllGlobals();
|
|
145
|
+
vi.restoreAllMocks();
|
|
146
|
+
delete process.env[ENDPOINT_VAR];
|
|
147
|
+
await rm(dir, { recursive: true, force: true });
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("pr-plan against an environment that declares an endpoint (#4281)", () => {
|
|
151
|
+
test("every plan request goes to the declared endpoint and no other host, and the var is restored after", async () => {
|
|
152
|
+
expect(await runComponentsPrPlan(ctx("floci"))).toBe(0);
|
|
153
|
+
expect(hosts.length).toBe(2);
|
|
154
|
+
expect(new Set(hosts)).toEqual(new Set(["localhost:4566"]));
|
|
155
|
+
expect(process.env[ENDPOINT_VAR]).toBeUndefined();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("an environment with no endpoint is left to the capability's own default", async () => {
|
|
159
|
+
expect(await runComponentsPrPlan(ctx("prod"))).toBe(0);
|
|
160
|
+
expect(new Set(hosts)).toEqual(new Set([new URL(REAL_CLOUD).host]));
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("an ambient var already set wins over the declared endpoint", async () => {
|
|
164
|
+
process.env[ENDPOINT_VAR] = "http://127.0.0.1:9999";
|
|
165
|
+
expect(await runComponentsPrPlan(ctx("floci"))).toBe(0);
|
|
166
|
+
expect(new Set(hosts)).toEqual(new Set(["127.0.0.1:9999"]));
|
|
167
|
+
expect(process.env[ENDPOINT_VAR]).toBe("http://127.0.0.1:9999");
|
|
168
|
+
});
|
|
169
|
+
});
|
package/src/cli/handlers/pr.ts
CHANGED
|
@@ -118,7 +118,9 @@ import { findWorkspaceRoot } from "../../project-root";
|
|
|
118
118
|
import { resolveChangedUnits } from "./fan-out";
|
|
119
119
|
import { coverByMerges, greenOp, type GreenTarget } from "../../components/green-cover";
|
|
120
120
|
import { GATED_EXIT_CODE, autoReleaseRun, recordAutoReleasesForRun } from "./run";
|
|
121
|
+
import { appliedDigestOf } from "../../components/auto-release";
|
|
121
122
|
import type { CommandContext } from "../registry";
|
|
123
|
+
import { withComponentEndpoint } from "../component-endpoint";
|
|
122
124
|
import type { ComponentChangeSignal, FanOutPlan } from "../../components/fan-out";
|
|
123
125
|
import { failedRunExitCode, type DriverComponent } from "../../components/driver";
|
|
124
126
|
import type { CapabilityRegistry } from "../../components/capability";
|
|
@@ -421,7 +423,7 @@ function openUpload(events: StageEvents, stage: "plan" | "apply" | "record", pr:
|
|
|
421
423
|
/** chant components pr-plan --base <ref> --pr <n> [--env <env>] [--gate <name>] [--forge <kind>] [--output <dir>] [--json] [--events-json] [--report-url <url>] */
|
|
422
424
|
export function runComponentsPrPlan(ctx: CommandContext): Promise<number> {
|
|
423
425
|
if (refusesEventsWithJson(ctx.args, "the stage's report")) return Promise.resolve(1);
|
|
424
|
-
return withStageEvents("pr-plan", ctx, (events) => prPlan(ctx, events));
|
|
426
|
+
return withStageEvents("pr-plan", ctx, (events) => withComponentEndpoint(ctx, () => prPlan(ctx, events)));
|
|
425
427
|
}
|
|
426
428
|
|
|
427
429
|
async function prPlan(ctx: CommandContext, stage: StageEvents): Promise<number> {
|
|
@@ -482,7 +484,7 @@ async function prPlan(ctx: CommandContext, stage: StageEvents): Promise<number>
|
|
|
482
484
|
/** chant components pr-apply --base <ref> [--pr <n>] [--env <env>] [--gate <name>] [--forge <kind>] [--require-review] [--output <dir>] [--json] [--events-json] [--report-url <url>] */
|
|
483
485
|
export function runComponentsPrApply(ctx: CommandContext): Promise<number> {
|
|
484
486
|
if (refusesEventsWithJson(ctx.args, "the stage's report")) return Promise.resolve(1);
|
|
485
|
-
return withStageEvents("pr-apply", ctx, (events) => prApply(ctx, events));
|
|
487
|
+
return withStageEvents("pr-apply", ctx, (events) => withComponentEndpoint(ctx, () => prApply(ctx, events)));
|
|
486
488
|
}
|
|
487
489
|
|
|
488
490
|
async function prApply(ctx: CommandContext, stage: StageEvents): Promise<number> {
|
|
@@ -695,7 +697,7 @@ async function prApply(ctx: CommandContext, stage: StageEvents): Promise<number>
|
|
|
695
697
|
: {}),
|
|
696
698
|
});
|
|
697
699
|
recordAttempt();
|
|
698
|
-
await recordAutoReleasesForRun(applied.run.results, p.env, release.runId, resolveAutoReleaseDisabled(p.config, args.noReleaseRecord), release.runOrigin);
|
|
700
|
+
await recordAutoReleasesForRun(applied.run.results, p.env, release.runId, resolveAutoReleaseDisabled(p.config, args.noReleaseRecord), release.runOrigin, undefined, undefined, prAppliedDigests(p, applied.members));
|
|
699
701
|
const report = prReport({
|
|
700
702
|
...base,
|
|
701
703
|
approval,
|
|
@@ -715,10 +717,15 @@ async function prApply(ctx: CommandContext, stage: StageEvents): Promise<number>
|
|
|
715
717
|
return code;
|
|
716
718
|
}
|
|
717
719
|
|
|
720
|
+
/** #4274: a component that published nothing is recorded by the plan digest its approval bound. */
|
|
721
|
+
function prAppliedDigests(p: Prepared, members: PrMember[]) {
|
|
722
|
+
return appliedDigestOf({ components: p.components, env: p.env, projectRoot: resolve("."), members });
|
|
723
|
+
}
|
|
724
|
+
|
|
718
725
|
/** chant components pr-record --base <ref> [--pr <n>] [--env <env>] [--gate <name>] [--forge <kind>] [--output <dir>] [--json] [--events-json] [--report-url <url>] */
|
|
719
726
|
export function runComponentsPrRecord(ctx: CommandContext): Promise<number> {
|
|
720
727
|
if (refusesEventsWithJson(ctx.args, "the stage's report")) return Promise.resolve(1);
|
|
721
|
-
return withStageEvents("pr-record", ctx, (events) => prRecord(ctx, events));
|
|
728
|
+
return withStageEvents("pr-record", ctx, (events) => withComponentEndpoint(ctx, () => prRecord(ctx, events)));
|
|
722
729
|
}
|
|
723
730
|
|
|
724
731
|
/**
|
|
@@ -1485,7 +1492,7 @@ async function prApplyWaves(
|
|
|
1485
1492
|
if (outputs) outputsSoFar[name] = outputs;
|
|
1486
1493
|
}
|
|
1487
1494
|
setWave({ ...entry, status: applied.status === "applied" ? "applied" : "failed" });
|
|
1488
|
-
await recordAutoReleasesForRun(applied.run.results, p.env, at.release.runId, resolveAutoReleaseDisabled(p.config, args.noReleaseRecord), at.release.runOrigin);
|
|
1495
|
+
await recordAutoReleasesForRun(applied.run.results, p.env, at.release.runId, resolveAutoReleaseDisabled(p.config, args.noReleaseRecord), at.release.runOrigin, undefined, undefined, prAppliedDigests(p, applied.members));
|
|
1489
1496
|
if (applied.status !== "applied") {
|
|
1490
1497
|
return stop(report({ status: "failed" }), failedRunExitCode(applied.run.results), applied.run.status === "gated" ? "gated" : undefined);
|
|
1491
1498
|
}
|
|
@@ -68,6 +68,7 @@ import { resolveCliBuildParams, parseParamFlags } from "../build-params-cli";
|
|
|
68
68
|
import { formatError, formatWarning, formatSuccess, formatBold, formatInfo } from "../format";
|
|
69
69
|
import { GATED_EXIT_CODE } from "./run";
|
|
70
70
|
import type { CommandContext } from "../registry";
|
|
71
|
+
import { withComponentEndpoint } from "../component-endpoint";
|
|
71
72
|
import { redeployLine, releaseRollbackLine, rollbackTarget, unsettledComponents } from "../../lifecycle/mixed-environment";
|
|
72
73
|
|
|
73
74
|
const PROMOTE_USAGE =
|
|
@@ -390,7 +391,20 @@ function approvalLine(r: import("../../lifecycle/merge-record").MergeRecord): st
|
|
|
390
391
|
* which differ only in how the plan was chosen, whether the publish step runs,
|
|
391
392
|
* and which field names the earlier release on the new record.
|
|
392
393
|
*/
|
|
393
|
-
|
|
394
|
+
/** {@link deployPlanIn}, with `plan.to`'s declared endpoint applied (#4281). */
|
|
395
|
+
function deployPlan(
|
|
396
|
+
ctx: CommandContext,
|
|
397
|
+
verb: Verb,
|
|
398
|
+
plan: PromotionPlan,
|
|
399
|
+
project: { projectPath: string; config: ChantConfig; targets: DriverComponent[] },
|
|
400
|
+
actor: string | undefined,
|
|
401
|
+
record: (item: PromotionItem, env: string, run: DeployRunInfo) => ReleaseRecordInput,
|
|
402
|
+
under?: UnderRelease,
|
|
403
|
+
): Promise<number> {
|
|
404
|
+
return withComponentEndpoint(ctx, () => deployPlanIn(ctx, verb, plan, project, actor, record, under), { config: project.config, environment: plan.to });
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async function deployPlanIn(
|
|
394
408
|
ctx: CommandContext,
|
|
395
409
|
verb: Verb,
|
|
396
410
|
plan: PromotionPlan,
|
|
@@ -661,7 +675,7 @@ async function deployPlan(
|
|
|
661
675
|
const admission = gateAdmission(result);
|
|
662
676
|
const input = record(item, plan.to, { runId, ...(runOrigin ? { runOrigin } : {}), actor: actor!, timestamp, ...(approver ? { approver } : {}), ...(admission ? { admission } : {}) });
|
|
663
677
|
const templates = templateDigests.get(item.component);
|
|
664
|
-
const { record: written } = await appendReleaseRecord(under ? { ...input, ...(templates ? { digest: templates } : {}), releaseDigest: under.release.releaseDigest } : input);
|
|
678
|
+
const { record: written } = await appendReleaseRecord(under ? { ...input, ...(templates ? { digest: templates, digestKind: "templates" as const } : {}), releaseDigest: under.release.releaseDigest } : input);
|
|
665
679
|
recorded.push(written);
|
|
666
680
|
}
|
|
667
681
|
if (outcome) await appendDeployOutcome(outcome);
|
|
@@ -46,7 +46,8 @@ vi.mock("../../config", async () => {
|
|
|
46
46
|
const actual = await vi.importActual<typeof import("../../config")>("../../config");
|
|
47
47
|
return { ...actual, loadChantConfig: (...args: unknown[]) => loadChantConfigMock(...args) };
|
|
48
48
|
});
|
|
49
|
-
vi.mock("../../components/auto-release", () => ({
|
|
49
|
+
vi.mock("../../components/auto-release", async () => ({
|
|
50
|
+
...(await vi.importActual<typeof import("../../components/auto-release")>("../../components/auto-release")),
|
|
50
51
|
maybeRecordAutoRelease: (...args: unknown[]) => maybeRecordAutoReleaseMock(...args),
|
|
51
52
|
}));
|
|
52
53
|
vi.mock("../../components/manifest-persistence", () => ({
|
package/src/cli/handlers/run.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { recordGateApproval } from "./operator";
|
|
|
17
17
|
import { formatError, formatWarning, formatSuccess, formatBold, formatInfo } from "../format";
|
|
18
18
|
import { parseParamFlags } from "../build-params-cli";
|
|
19
19
|
import type { CommandContext } from "../registry";
|
|
20
|
+
import { withComponentEndpoint } from "../component-endpoint";
|
|
20
21
|
import { stewardBesideFor, stewardFormFor, stewardTurnLeaseName, DEFAULT_STEWARD_ENV, type StewardDeclaration } from "../../op/steward";
|
|
21
22
|
import { holdBesideLease } from "../../op/steward-beside";
|
|
22
23
|
import { acquireStewardTurn, STEWARD_TURN_WAIT_MS } from "../../op/operator";
|
|
@@ -29,11 +30,11 @@ import { ndjsonProgressSink } from "../../components/run-progress";
|
|
|
29
30
|
import { findStoredRun, readRunEvents } from "../../run-event-store";
|
|
30
31
|
import type { RunEvent } from "../../run-event";
|
|
31
32
|
import { openCliRunEvents, refusesBothStreams, stdoutLine } from "../run-events";
|
|
32
|
-
import { maybeRecordAutoRelease } from "../../components/auto-release";
|
|
33
|
+
import { appliedDigestOf, extractRunDigest, maybeRecordAutoRelease, type AppliedDigest } from "../../components/auto-release";
|
|
33
34
|
import { maybePersistBuildManifest } from "../../components/manifest-persistence";
|
|
34
35
|
import { failedRunExitCode, type DriverComponentResult } from "../../components/driver";
|
|
35
36
|
import { isComponentList, type ComponentRunPlan, type RunComponentsResult } from "../../components/cli-support";
|
|
36
|
-
import { resolveCiRun, type ReleaseRecord, type RunOrigin } from "../../lifecycle/release-ledger";
|
|
37
|
+
import { isApplyRecord, resolveCiRun, type ReleaseRecord, type RunOrigin } from "../../lifecycle/release-ledger";
|
|
37
38
|
import { gateAdmission, gateApprover } from "../../components/promote";
|
|
38
39
|
import { runOpGenerate } from "./run-generate";
|
|
39
40
|
|
|
@@ -935,10 +936,14 @@ export async function recordAutoReleasesForRun(
|
|
|
935
936
|
releaseDigest?: string,
|
|
936
937
|
/** #4222: the digest of the templates the release records for a component in `env`, for one that publishes no artifact. */
|
|
937
938
|
templatesDigestOf?: (component: string) => string | null,
|
|
939
|
+
/** #4274: what a component that publishes no artifact applied (`appliedDigestOf`), so it is recorded too. */
|
|
940
|
+
appliedDigestOf?: (component: string) => AppliedDigest | null,
|
|
938
941
|
): Promise<ReleaseRecord[]> {
|
|
939
942
|
const recorded: ReleaseRecord[] = [];
|
|
940
943
|
for (const componentResult of results) {
|
|
941
944
|
if (!componentResult.ok) continue;
|
|
945
|
+
// Hashed only for a component that promoted no artifact, the only one it is recorded for.
|
|
946
|
+
const applied = appliedDigestOf && !disabled && extractRunDigest(componentResult.records) === undefined ? appliedDigestOf(componentResult.component) : null;
|
|
942
947
|
const outcome = await maybeRecordAutoRelease(
|
|
943
948
|
{
|
|
944
949
|
component: componentResult.component,
|
|
@@ -954,6 +959,7 @@ export async function recordAutoReleasesForRun(
|
|
|
954
959
|
// #4043: the release the deploy was made under, when `--release` named one.
|
|
955
960
|
...(releaseDigest !== undefined ? { releaseDigest } : {}),
|
|
956
961
|
...(releaseDigest !== undefined && templatesDigestOf?.(componentResult.component) ? { templatesDigest: templatesDigestOf(componentResult.component)! } : {}),
|
|
962
|
+
...(applied ? { appliedDigest: applied } : {}),
|
|
957
963
|
},
|
|
958
964
|
{ disabled },
|
|
959
965
|
);
|
|
@@ -1023,7 +1029,8 @@ export async function recordAutoReleasesForRun(
|
|
|
1023
1029
|
* rather than loaded a second time.
|
|
1024
1030
|
*/
|
|
1025
1031
|
export function runOpComponents(ctx: CommandContext): Promise<number> {
|
|
1026
|
-
|
|
1032
|
+
// #4281: the components run against the environment's declared endpoint.
|
|
1033
|
+
return withRecordingChoice(ctx, () => withComponentEndpoint(ctx, () => runComponentSelection(ctx)));
|
|
1027
1034
|
}
|
|
1028
1035
|
|
|
1029
1036
|
/**
|
|
@@ -1281,7 +1288,11 @@ async function runComponentSelection(ctx: CommandContext): Promise<number> {
|
|
|
1281
1288
|
// #3955: a run that records nothing has no release to report either.
|
|
1282
1289
|
const disabled = resolveAutoReleaseDisabled(config, ctx.args.noReleaseRecord) || ledgerRecordingOff();
|
|
1283
1290
|
const templatesDigestOf = underRelease ? await releaseTemplatesDigestOf(underRelease.release, env) : undefined;
|
|
1284
|
-
|
|
1291
|
+
// #4274: a component that publishes nothing is recorded by its deploy-input digest.
|
|
1292
|
+
const appliedOf = result.components ? appliedDigestOf({ components: result.components, env, projectRoot: projectPath }) : undefined;
|
|
1293
|
+
const recorded = await recordAutoReleasesForRun(result.run.results, env, release.runId, disabled, release.runOrigin, underRelease?.releaseDigest, templatesDigestOf, appliedOf);
|
|
1294
|
+
// A generated promote job promotes artifacts; an apply has none to name.
|
|
1295
|
+
const promotable = recorded.filter((r) => !isApplyRecord(r));
|
|
1285
1296
|
if (underRelease) await warnOnArtifactMismatch(underRelease.release, recorded);
|
|
1286
1297
|
// `--digest-file` (#2602): the releases this run recorded, one
|
|
1287
1298
|
// `<component>=<digest>` line each, the form `chant components promote
|
|
@@ -1290,7 +1301,7 @@ async function runComponentSelection(ctx: CommandContext): Promise<number> {
|
|
|
1290
1301
|
if (ctx.args.digestFile) {
|
|
1291
1302
|
const digestPath = resolve(ctx.args.digestFile);
|
|
1292
1303
|
mkdirSync(dirname(digestPath), { recursive: true });
|
|
1293
|
-
writeFileSync(digestPath,
|
|
1304
|
+
writeFileSync(digestPath, promotable.map((r) => `${r.component}=${r.digest}\n`).join(""));
|
|
1294
1305
|
}
|
|
1295
1306
|
}
|
|
1296
1307
|
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #4274: a component that only deploys infrastructure gets a release record
|
|
3
|
+
* from `pr-apply` and `chant run --components`. Its digest is the plan digest
|
|
4
|
+
* its approval bound, or its deploy-input digest when nothing planned, and
|
|
5
|
+
* `digestKind` says which. Written into a workspace member's ledger, under
|
|
6
|
+
* the member's prefix on chant/lifecycle.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { afterAll, afterEach, beforeEach, describe, expect, test } from "vitest";
|
|
10
|
+
import { spawnSync } from "node:child_process";
|
|
11
|
+
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
import { appliedDigestOf } from "./auto-release";
|
|
15
|
+
import { recordAutoReleasesForRun } from "../cli/handlers/run";
|
|
16
|
+
import { clearMemberLedgerCache } from "../lifecycle/member-ledger";
|
|
17
|
+
import { readReleaseLedger } from "../lifecycle/release-ledger";
|
|
18
|
+
import type { DriverComponent, DriverComponentResult } from "./driver";
|
|
19
|
+
|
|
20
|
+
const scratch: string[] = [];
|
|
21
|
+
afterAll(() => {
|
|
22
|
+
for (const d of scratch) rmSync(d, { recursive: true, force: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function git(args: string[], cwd: string): string {
|
|
26
|
+
const r = spawnSync("git", args, { cwd, encoding: "utf-8" });
|
|
27
|
+
if (r.status !== 0) throw new Error(`git ${args.join(" ")}: ${r.stderr}`);
|
|
28
|
+
return r.stdout;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A workspace whose member `infra` deploys one CloudFormation template and publishes nothing. */
|
|
32
|
+
function workspace(): { root: string; member: string } {
|
|
33
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), "chant-infra-record-")));
|
|
34
|
+
scratch.push(root);
|
|
35
|
+
const files: Record<string, string> = {
|
|
36
|
+
"chant.workspace.json": JSON.stringify({ name: "acme", schema: 1, members: [{ name: "infra", dir: "services/infra", kind: "chant" }] }),
|
|
37
|
+
"services/infra/chant.config.json": JSON.stringify({ lexicons: ["aws"] }),
|
|
38
|
+
"services/infra/dist/infra.template.json": JSON.stringify({ Resources: {} }),
|
|
39
|
+
};
|
|
40
|
+
for (const [path, text] of Object.entries(files)) {
|
|
41
|
+
mkdirSync(dirname(join(root, path)), { recursive: true });
|
|
42
|
+
writeFileSync(join(root, path), text);
|
|
43
|
+
}
|
|
44
|
+
git(["init", "-q", "-b", "main"], root);
|
|
45
|
+
git(["config", "user.email", "test@chant.dev"], root);
|
|
46
|
+
git(["config", "user.name", "Test"], root);
|
|
47
|
+
git(["add", "-A"], root);
|
|
48
|
+
git(["commit", "-q", "-m", "init"], root);
|
|
49
|
+
return { root, member: join(root, "services/infra") };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const infra: DriverComponent = {
|
|
53
|
+
name: "infra",
|
|
54
|
+
deploy: [{ name: "Deploy", steps: [{ kind: "cfn-deploy", stackName: "infra", template: "dist/infra.template.json" }] }],
|
|
55
|
+
} as unknown as DriverComponent;
|
|
56
|
+
|
|
57
|
+
const applied = {
|
|
58
|
+
component: "infra",
|
|
59
|
+
ok: true,
|
|
60
|
+
status: "ok",
|
|
61
|
+
records: [{ component: "infra", phase: "Deploy", kind: "cfn-deploy", status: "ok", durationMs: 1, output: { stackId: "arn:aws:cloudformation:infra" } }],
|
|
62
|
+
} as unknown as DriverComponentResult;
|
|
63
|
+
|
|
64
|
+
const planDigest = `jcs1-sha256:${"a".repeat(64)}`;
|
|
65
|
+
|
|
66
|
+
let cwd: string;
|
|
67
|
+
beforeEach(() => {
|
|
68
|
+
cwd = process.cwd();
|
|
69
|
+
clearMemberLedgerCache();
|
|
70
|
+
process.env.GITHUB_ACTOR = "alice";
|
|
71
|
+
});
|
|
72
|
+
afterEach(() => {
|
|
73
|
+
process.chdir(cwd);
|
|
74
|
+
delete process.env.GITHUB_ACTOR;
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("appliedDigestOf", () => {
|
|
78
|
+
test("a component with one planned member gets that member's plan digest", () => {
|
|
79
|
+
const of = appliedDigestOf({ components: [infra], env: "staging", projectRoot: "/nowhere", members: [{ member: "infra", component: "infra", planDigest }] });
|
|
80
|
+
expect(of("infra")).toEqual({ digest: planDigest, kind: "plan" });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("a component with several planned members gets the set digest over them, the same whatever their order", () => {
|
|
84
|
+
const members = [
|
|
85
|
+
{ member: "infra/a", component: "infra", planDigest },
|
|
86
|
+
{ member: "infra/b", component: "infra", planDigest: `jcs1-sha256:${"b".repeat(64)}` },
|
|
87
|
+
];
|
|
88
|
+
const one = appliedDigestOf({ components: [infra], env: "staging", projectRoot: "/nowhere", members })("infra");
|
|
89
|
+
const two = appliedDigestOf({ components: [infra], env: "staging", projectRoot: "/nowhere", members: [...members].reverse() })("infra");
|
|
90
|
+
expect(one?.kind).toBe("plan");
|
|
91
|
+
expect(one?.digest).not.toBe(planDigest);
|
|
92
|
+
expect(two).toEqual(one);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("a component that planned nothing gets its deploy-input digest, which moves with the template it deploys", () => {
|
|
96
|
+
const { member } = workspace();
|
|
97
|
+
const before = appliedDigestOf({ components: [infra], env: "staging", projectRoot: member })("infra");
|
|
98
|
+
expect(before?.kind).toBe("deploy-input");
|
|
99
|
+
writeFileSync(join(member, "dist/infra.template.json"), JSON.stringify({ Resources: { Bucket: {} } }));
|
|
100
|
+
const after = appliedDigestOf({ components: [infra], env: "staging", projectRoot: member })("infra");
|
|
101
|
+
expect(after?.kind).toBe("deploy-input");
|
|
102
|
+
expect(after?.digest).not.toBe(before?.digest);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("a component the run doesn't know has none", () => {
|
|
106
|
+
expect(appliedDigestOf({ components: [infra], env: "staging", projectRoot: "/nowhere" })("other")).toBeNull();
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("recordAutoReleasesForRun for an infrastructure-only component", () => {
|
|
111
|
+
test("a pr-apply records the plan digest under the member's prefix", async () => {
|
|
112
|
+
const { root, member } = workspace();
|
|
113
|
+
process.chdir(member);
|
|
114
|
+
const of = appliedDigestOf({ components: [infra], env: "staging", projectRoot: member, members: [{ member: "infra", component: "infra", planDigest }] });
|
|
115
|
+
const recorded = await recordAutoReleasesForRun([applied], "staging", "run-1", false, { forge: "local" }, undefined, undefined, of);
|
|
116
|
+
expect(recorded).toHaveLength(1);
|
|
117
|
+
expect(recorded[0]).toMatchObject({ component: "infra", env: "staging", digest: planDigest, digestKind: "plan", actor: "alice" });
|
|
118
|
+
|
|
119
|
+
const files = git(["ls-tree", "-r", "--name-only", "chant/lifecycle"], root).split("\n").filter(Boolean);
|
|
120
|
+
expect(files).toContain("_members/infra/staging/releases.jsonl");
|
|
121
|
+
const { records } = await readReleaseLedger("staging", { cwd: member });
|
|
122
|
+
expect(records).toEqual([expect.objectContaining({ digest: planDigest, digestKind: "plan" })]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("without an applied digest it records nothing, as before", async () => {
|
|
126
|
+
const { member } = workspace();
|
|
127
|
+
process.chdir(member);
|
|
128
|
+
const recorded = await recordAutoReleasesForRun([applied], "staging", "run-1", false);
|
|
129
|
+
expect(recorded).toEqual([]);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -181,7 +181,46 @@ describe("maybeRecordAutoRelease", () => {
|
|
|
181
181
|
);
|
|
182
182
|
expect(outcome).toMatchObject({ recorded: true });
|
|
183
183
|
const [input] = appendReleaseRecordMock.mock.calls[0];
|
|
184
|
-
expect(input).toMatchObject({ digest: "sha256:tpl", releaseDigest: "sha256:rel" });
|
|
184
|
+
expect(input).toMatchObject({ digest: "sha256:tpl", releaseDigest: "sha256:rel", digestKind: "templates" });
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("#4274: a run that published nothing records what it applied, and says which digest that is", async () => {
|
|
188
|
+
appendReleaseRecordMock.mockResolvedValue({
|
|
189
|
+
commit: "c".repeat(40),
|
|
190
|
+
record: { version: 1, component: "infra", env: "staging", digest: "sha256:plan", gitSha: "x", runId: "r", timestamp: "t", actor: "alice", digestKind: "plan" },
|
|
191
|
+
});
|
|
192
|
+
const outcome = await maybeRecordAutoRelease(
|
|
193
|
+
{ component: "infra", env: "staging", success: true, records: [applyRecord()], runId: "run-1", appliedDigest: { digest: "sha256:plan", kind: "plan" } },
|
|
194
|
+
{ actor: "alice", gitSha: "a".repeat(40) },
|
|
195
|
+
);
|
|
196
|
+
expect(outcome).toMatchObject({ recorded: true });
|
|
197
|
+
expect(appendReleaseRecordMock.mock.calls[0][0]).toMatchObject({ digest: "sha256:plan", digestKind: "plan" });
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("#4274: an artifact digest wins over what the run applied, and carries no digestKind", async () => {
|
|
201
|
+
appendReleaseRecordMock.mockResolvedValue({
|
|
202
|
+
commit: "c".repeat(40),
|
|
203
|
+
record: { version: 1, component: "svc", env: "prod", digest: "sha256:abc", gitSha: "x", runId: "r", timestamp: "t", actor: "alice" },
|
|
204
|
+
});
|
|
205
|
+
await maybeRecordAutoRelease(
|
|
206
|
+
{ component: "svc", env: "prod", success: true, records: [publishRecord("sha256:abc")], runId: "run-1", appliedDigest: { digest: "sha256:in", kind: "deploy-input" } },
|
|
207
|
+
{ actor: "alice", gitSha: "a".repeat(40) },
|
|
208
|
+
);
|
|
209
|
+
const [input] = appendReleaseRecordMock.mock.calls[0];
|
|
210
|
+
expect(input).toMatchObject({ digest: "sha256:abc" });
|
|
211
|
+
expect(input).not.toHaveProperty("digestKind");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("#4274: a templates digest under a release wins over what the run applied", async () => {
|
|
215
|
+
appendReleaseRecordMock.mockResolvedValue({
|
|
216
|
+
commit: "c".repeat(40),
|
|
217
|
+
record: { version: 1, component: "infra", env: "staging", digest: "sha256:tpl", gitSha: "x", runId: "r", timestamp: "t", actor: "alice" },
|
|
218
|
+
});
|
|
219
|
+
await maybeRecordAutoRelease(
|
|
220
|
+
{ component: "infra", env: "staging", success: true, records: [applyRecord()], runId: "run-1", releaseDigest: "sha256:rel", templatesDigest: "sha256:tpl", appliedDigest: { digest: "sha256:in", kind: "deploy-input" } },
|
|
221
|
+
{ actor: "alice", gitSha: "a".repeat(40) },
|
|
222
|
+
);
|
|
223
|
+
expect(appendReleaseRecordMock.mock.calls[0][0]).toMatchObject({ digest: "sha256:tpl", digestKind: "templates" });
|
|
185
224
|
});
|
|
186
225
|
|
|
187
226
|
test("#4222: a templates digest without a release is ignored", async () => {
|
|
@@ -26,8 +26,10 @@
|
|
|
26
26
|
* env vars, timestamp is a real `new Date()` taken at record time).
|
|
27
27
|
*/
|
|
28
28
|
import { getHeadCommit, pushLifecycle, StaleLifecycleBranchError } from "../lifecycle/git";
|
|
29
|
-
import { appendReleaseRecord, InvalidReleaseRecordError, type ReleaseAdmission, type ReleaseRecord, type RunOrigin } from "../lifecycle/release-ledger";
|
|
30
|
-
import
|
|
29
|
+
import { appendReleaseRecord, InvalidReleaseRecordError, type ReleaseAdmission, type ReleaseDigestKind, type ReleaseRecord, type RunOrigin } from "../lifecycle/release-ledger";
|
|
30
|
+
import { changeSetDigest } from "../change-set";
|
|
31
|
+
import type { DriverComponent, DriverStepRecord } from "./driver";
|
|
32
|
+
import { componentInputDigests } from "./fan-out-inputs";
|
|
31
33
|
|
|
32
34
|
/**
|
|
33
35
|
* A step output that represents a *promoted* artifact — a publish-family result
|
|
@@ -68,6 +70,32 @@ export function extractRunDigest(records: DriverStepRecord[]): string | undefine
|
|
|
68
70
|
return found;
|
|
69
71
|
}
|
|
70
72
|
|
|
73
|
+
/**
|
|
74
|
+
* What each component of a run applied, for the ones that publish no artifact
|
|
75
|
+
* (#4274). A component with planned members (`pr-apply`'s change set) gets its
|
|
76
|
+
* plan digest: the one member's `planDigest`, or with several the
|
|
77
|
+
* change-set digest over them, the way a set's digest is taken. Any other gets
|
|
78
|
+
* its deploy-input digest from `componentInputDigests`, computed on first use.
|
|
79
|
+
*/
|
|
80
|
+
export function appliedDigestOf(input: {
|
|
81
|
+
/** Every component of the project, so a component's upstream digests count. */
|
|
82
|
+
components: DriverComponent[];
|
|
83
|
+
env: string;
|
|
84
|
+
projectRoot: string;
|
|
85
|
+
/** The planned members a pull request applied, when there were any. */
|
|
86
|
+
members?: ReadonlyArray<{ member: string; component: string; planDigest: string | null }>;
|
|
87
|
+
}): (component: string) => AppliedDigest | null {
|
|
88
|
+
let inputs: Record<string, string> | undefined;
|
|
89
|
+
return (component) => {
|
|
90
|
+
const planned = (input.members ?? []).filter((m): m is typeof m & { planDigest: string } => m.component === component && m.planDigest !== null);
|
|
91
|
+
if (planned.length === 1) return { digest: planned[0]!.planDigest, kind: "plan" };
|
|
92
|
+
if (planned.length > 1) return { digest: changeSetDigest(planned), kind: "plan" };
|
|
93
|
+
inputs ??= componentInputDigests(input.components, { projectRoot: input.projectRoot, env: input.env });
|
|
94
|
+
const digest = inputs[component];
|
|
95
|
+
return digest === undefined ? null : { digest, kind: "deploy-input" };
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
71
99
|
/** Explicit reasons `maybeRecordAutoRelease` declined to write a record — never a thrown error, since "nothing to record" (or "opted out") is an expected, common outcome. */
|
|
72
100
|
export type AutoReleaseSkipReason =
|
|
73
101
|
| "opted-out"
|
|
@@ -109,6 +137,19 @@ export interface AutoReleaseRunInfo {
|
|
|
109
137
|
* when the run published no artifact. Set only with `releaseDigest`.
|
|
110
138
|
*/
|
|
111
139
|
templatesDigest?: string;
|
|
140
|
+
/**
|
|
141
|
+
* What the run applied, for a component that published no artifact (#4274):
|
|
142
|
+
* its plan digest, or its deploy-input digest when nothing planned. Recorded
|
|
143
|
+
* as `digest` with `digestKind`, after an artifact digest and
|
|
144
|
+
* `templatesDigest`, so an infrastructure-only component has a record too.
|
|
145
|
+
*/
|
|
146
|
+
appliedDigest?: AppliedDigest;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** A digest naming what a component applied, not an artifact (#4274). */
|
|
150
|
+
export interface AppliedDigest {
|
|
151
|
+
digest: string;
|
|
152
|
+
kind: Extract<ReleaseDigestKind, "plan" | "deploy-input">;
|
|
112
153
|
}
|
|
113
154
|
|
|
114
155
|
/** Opt-out + field-override knobs for auto-release recording, threaded from CLI flags/config (#597: "opt-out-able (flag/config), documented default"). */
|
|
@@ -142,7 +183,8 @@ function resolveActor(override?: string): string | undefined {
|
|
|
142
183
|
* - `run.success` false — defensive; callers should not reach here on failure.
|
|
143
184
|
* - no digest found in the run's records — a component with no publish step
|
|
144
185
|
* has nothing to bind a release record to, unless the run was made under a
|
|
145
|
-
* release that records its templates (`templatesDigest`, #4222)
|
|
186
|
+
* release that records its templates (`templatesDigest`, #4222) or the
|
|
187
|
+
* caller names what it applied (`appliedDigest`, #4274).
|
|
146
188
|
* - no resolvable actor — mirrors `runComponentsReleaseRecord`'s hard
|
|
147
189
|
* requirement; an unattributed record defeats the ledger's purpose.
|
|
148
190
|
*
|
|
@@ -166,7 +208,10 @@ export async function maybeRecordAutoRelease(
|
|
|
166
208
|
return { recorded: false, reason: "run-not-successful" };
|
|
167
209
|
}
|
|
168
210
|
|
|
169
|
-
const
|
|
211
|
+
const artifact = run.digest ?? extractRunDigest(run.records ?? []);
|
|
212
|
+
const templates = artifact === undefined && run.releaseDigest ? run.templatesDigest : undefined;
|
|
213
|
+
const digest = artifact ?? templates ?? run.appliedDigest?.digest;
|
|
214
|
+
const digestKind: ReleaseDigestKind | undefined = artifact !== undefined ? undefined : templates !== undefined ? "templates" : run.appliedDigest?.kind;
|
|
170
215
|
if (!digest) {
|
|
171
216
|
return { recorded: false, reason: "no-digest", detail: `component "${run.component}" published no digest-bearing artifact` };
|
|
172
217
|
}
|
|
@@ -201,6 +246,7 @@ export async function maybeRecordAutoRelease(
|
|
|
201
246
|
...(run.profileOverride ? { profileOverride: run.profileOverride } : {}),
|
|
202
247
|
...(run.inputDigest ? { inputDigest: run.inputDigest } : {}),
|
|
203
248
|
...(run.releaseDigest ? { releaseDigest: run.releaseDigest } : {}),
|
|
249
|
+
...(digestKind ? { digestKind } : {}),
|
|
204
250
|
},
|
|
205
251
|
{ cwd: options.cwd },
|
|
206
252
|
);
|