@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.
Files changed (93) hide show
  1. package/dist/cli/component-endpoint.d.ts +20 -0
  2. package/dist/cli/component-endpoint.d.ts.map +1 -0
  3. package/dist/cli/component-endpoint.js +36 -0
  4. package/dist/cli/handlers/fan-out.d.ts.map +1 -1
  5. package/dist/cli/handlers/fan-out.js +10 -1
  6. package/dist/cli/handlers/pr.d.ts.map +1 -1
  7. package/dist/cli/handlers/pr.js +11 -5
  8. package/dist/cli/handlers/promote.d.ts.map +1 -1
  9. package/dist/cli/handlers/promote.js +7 -2
  10. package/dist/cli/handlers/run.d.ts +4 -1
  11. package/dist/cli/handlers/run.d.ts.map +1 -1
  12. package/dist/cli/handlers/run.js +17 -6
  13. package/dist/components/auto-release.d.ts +35 -3
  14. package/dist/components/auto-release.d.ts.map +1 -1
  15. package/dist/components/auto-release.js +29 -2
  16. package/dist/components/cli-support.d.ts +2 -0
  17. package/dist/components/cli-support.d.ts.map +1 -1
  18. package/dist/components/cli-support.js +2 -0
  19. package/dist/components/deploy-units.d.ts.map +1 -1
  20. package/dist/components/deploy-units.js +5 -0
  21. package/dist/components/promote.d.ts.map +1 -1
  22. package/dist/components/promote.js +39 -16
  23. package/dist/lifecycle/__fixtures__/publish-fakes.d.ts.map +1 -1
  24. package/dist/lifecycle/__fixtures__/publish-fakes.js +6 -1
  25. package/dist/lifecycle/publish-declaration.d.ts.map +1 -1
  26. package/dist/lifecycle/publish-declaration.js +4 -3
  27. package/dist/lifecycle/publish-npm.d.ts +2 -2
  28. package/dist/lifecycle/publish-npm.d.ts.map +1 -1
  29. package/dist/lifecycle/publish-npm.js +31 -8
  30. package/dist/lifecycle/release-ledger.d.ts +31 -0
  31. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  32. package/dist/lifecycle/release-ledger.js +19 -1
  33. package/dist/lifecycle/status.d.ts +5 -1
  34. package/dist/lifecycle/status.d.ts.map +1 -1
  35. package/dist/lifecycle/status.js +10 -5
  36. package/dist/live-endpoint.d.ts +18 -0
  37. package/dist/live-endpoint.d.ts.map +1 -1
  38. package/dist/live-endpoint.js +27 -0
  39. package/dist/wave-runtime.mjs +201 -201
  40. package/dist/workspace/ci-builds.d.ts +12 -1
  41. package/dist/workspace/ci-builds.d.ts.map +1 -1
  42. package/dist/workspace/ci-builds.js +26 -0
  43. package/dist/workspace/ci-green-cli.js +1 -1
  44. package/dist/workspace/ci-green-forge.d.ts +3 -1
  45. package/dist/workspace/ci-green-forge.d.ts.map +1 -1
  46. package/dist/workspace/ci-green-forge.js +20 -2
  47. package/dist/workspace/ci-green-workflow.d.ts +6 -4
  48. package/dist/workspace/ci-green-workflow.d.ts.map +1 -1
  49. package/dist/workspace/ci-green-workflow.js +26 -8
  50. package/dist/workspace/ci-run.d.ts +7 -0
  51. package/dist/workspace/ci-run.d.ts.map +1 -1
  52. package/dist/workspace/ci-run.js +21 -3
  53. package/dist/workspace/status.d.ts +3 -1
  54. package/dist/workspace/status.d.ts.map +1 -1
  55. package/dist/workspace/status.js +3 -2
  56. package/dist/workspace/status.schema.json +1 -0
  57. package/package.json +1 -1
  58. package/src/cli/component-endpoint.ts +43 -0
  59. package/src/cli/handlers/fan-out.test.ts +10 -0
  60. package/src/cli/handlers/fan-out.ts +12 -0
  61. package/src/cli/handlers/pr-endpoint.test.ts +169 -0
  62. package/src/cli/handlers/pr.ts +12 -5
  63. package/src/cli/handlers/promote.ts +16 -2
  64. package/src/cli/handlers/run.test.ts +2 -1
  65. package/src/cli/handlers/run.ts +16 -5
  66. package/src/components/auto-release-infra.test.ts +131 -0
  67. package/src/components/auto-release.test.ts +40 -1
  68. package/src/components/auto-release.ts +50 -4
  69. package/src/components/cli-support.ts +4 -0
  70. package/src/components/deploy-units.ts +5 -0
  71. package/src/components/promote.test.ts +39 -0
  72. package/src/components/promote.ts +38 -14
  73. package/src/lifecycle/__fixtures__/publish-fakes.ts +6 -1
  74. package/src/lifecycle/publish-declaration.ts +4 -3
  75. package/src/lifecycle/publish-npm.ts +33 -8
  76. package/src/lifecycle/publish-targets.test.ts +36 -3
  77. package/src/lifecycle/release-ledger.ts +42 -1
  78. package/src/lifecycle/status.test.ts +19 -0
  79. package/src/lifecycle/status.ts +14 -6
  80. package/src/live-endpoint.ts +31 -0
  81. package/src/workspace/ci-builds.e2e.test.ts +7 -2
  82. package/src/workspace/ci-builds.test.ts +46 -1
  83. package/src/workspace/ci-builds.ts +25 -1
  84. package/src/workspace/ci-green-builds.test.ts +26 -0
  85. package/src/workspace/ci-green-cli.ts +1 -1
  86. package/src/workspace/ci-green-forge.test.ts +31 -0
  87. package/src/workspace/ci-green-forge.ts +21 -3
  88. package/src/workspace/ci-green-workflow.test.ts +4 -4
  89. package/src/workspace/ci-green-workflow.ts +22 -8
  90. package/src/workspace/ci-run.test.ts +26 -0
  91. package/src/workspace/ci-run.ts +20 -3
  92. package/src/workspace/status.schema.json +1 -0
  93. 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
+ });
@@ -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
- async function deployPlan(
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", () => ({
@@ -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
- return withRecordingChoice(ctx, () => runComponentSelection(ctx));
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
- const recorded = await recordAutoReleasesForRun(result.run.results, env, release.runId, disabled, release.runOrigin, underRelease?.releaseDigest, templatesDigestOf);
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, recorded.map((r) => `${r.component}=${r.digest}\n`).join(""));
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 type { DriverStepRecord } from "./driver";
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 digest = run.digest ?? extractRunDigest(run.records ?? []) ?? (run.releaseDigest ? run.templatesDigest : undefined);
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
  );