@intentius/chant 0.124.39 → 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 (74) 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 +192 -192
  40. package/dist/workspace/ci-run.d.ts +7 -0
  41. package/dist/workspace/ci-run.d.ts.map +1 -1
  42. package/dist/workspace/ci-run.js +21 -3
  43. package/dist/workspace/status.d.ts +3 -1
  44. package/dist/workspace/status.d.ts.map +1 -1
  45. package/dist/workspace/status.js +3 -2
  46. package/dist/workspace/status.schema.json +1 -0
  47. package/package.json +1 -1
  48. package/src/cli/component-endpoint.ts +43 -0
  49. package/src/cli/handlers/fan-out.test.ts +10 -0
  50. package/src/cli/handlers/fan-out.ts +12 -0
  51. package/src/cli/handlers/pr-endpoint.test.ts +169 -0
  52. package/src/cli/handlers/pr.ts +12 -5
  53. package/src/cli/handlers/promote.ts +16 -2
  54. package/src/cli/handlers/run.test.ts +2 -1
  55. package/src/cli/handlers/run.ts +16 -5
  56. package/src/components/auto-release-infra.test.ts +131 -0
  57. package/src/components/auto-release.test.ts +40 -1
  58. package/src/components/auto-release.ts +50 -4
  59. package/src/components/cli-support.ts +4 -0
  60. package/src/components/deploy-units.ts +5 -0
  61. package/src/components/promote.test.ts +39 -0
  62. package/src/components/promote.ts +38 -14
  63. package/src/lifecycle/__fixtures__/publish-fakes.ts +6 -1
  64. package/src/lifecycle/publish-declaration.ts +4 -3
  65. package/src/lifecycle/publish-npm.ts +33 -8
  66. package/src/lifecycle/publish-targets.test.ts +36 -3
  67. package/src/lifecycle/release-ledger.ts +42 -1
  68. package/src/lifecycle/status.test.ts +19 -0
  69. package/src/lifecycle/status.ts +14 -6
  70. package/src/live-endpoint.ts +31 -0
  71. package/src/workspace/ci-run.test.ts +26 -0
  72. package/src/workspace/ci-run.ts +20 -3
  73. package/src/workspace/status.schema.json +1 -0
  74. package/src/workspace/status.ts +7 -3
@@ -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
  );
@@ -489,6 +489,8 @@ export interface RunComponentsResult {
489
489
  run?: DriverRunResult;
490
490
  /** Component names actually dispatched to the driver, in run order. */
491
491
  selected: string[];
492
+ /** The components dispatched, as they ran (#4274): what a component's deploy-input digest is taken over. */
493
+ components?: DriverComponent[];
492
494
  error?: string;
493
495
  /** Set when the run stopped at a `gate` nobody has approved (#2119): the component it stopped at and the pending fact recorded for it. */
494
496
  gated?: {
@@ -718,6 +720,7 @@ export async function runComponents(
718
720
  success: run.status === "ok",
719
721
  run,
720
722
  selected,
723
+ components: resolvedTargets,
721
724
  buildParams: options.buildParams,
722
725
  ...(run.status === "gated" && run.gate
723
726
  ? {
@@ -785,6 +788,7 @@ export async function runComponents(
785
788
  success: componentResult.ok,
786
789
  run,
787
790
  selected,
791
+ components: resolvedTargets,
788
792
  buildParams: options.buildParams,
789
793
  ...(componentResult.status === "gated" && componentResult.gate
790
794
  ? {
@@ -59,6 +59,11 @@ export const DEPLOY_UNIT_RULES: readonly DeployUnitRule[] = [
59
59
  // A Terraform-family root (#3049). Its unit is the root's name in
60
60
  // `terraform.roots`, which is also the member a gated wave's digest names.
61
61
  { kind: "terraform-apply", field: "root", lexicon: "terraform" },
62
+ // An ARM template applied into one resource group (#4273). Its unit is the group.
63
+ { kind: "arm-deploy", field: "resourceGroup", lexicon: "azure" },
64
+ // A Config Connector manifest applied over REST (#4273). Its unit is the
65
+ // step's `stack`, the member its plan names.
66
+ { kind: "gcp-apply", field: "stack", lexicon: "gcp" },
62
67
  ];
63
68
 
64
69
  /** One resolved unit a component's deploy composition targets. */
@@ -108,6 +108,45 @@ describe("planning", () => {
108
108
  });
109
109
  });
110
110
 
111
+ describe("records of an apply, not an artifact (#4274)", () => {
112
+ const apply = (component: string, timestamp: string, digestKind: "plan" | "deploy-input" = "plan"): ReleaseRecord => ({
113
+ ...rec(component, "staging", `jcs1-sha256:${timestamp.slice(0, 10)}`, timestamp),
114
+ digestKind,
115
+ });
116
+ const records = [
117
+ rec("api", "staging", "sha256:api", "2026-01-02T00:00:00Z"),
118
+ apply("infra", "2026-01-02T00:00:00Z"),
119
+ apply("infra", "2026-01-03T00:00:00Z", "deploy-input"),
120
+ ];
121
+
122
+ test("promoting every component leaves an infrastructure component out and says why", () => {
123
+ const plan = planPromotion({ from: "staging", to: "prod", sourceRecords: records, declared: ["api", "infra"] }) as PromotionPlan;
124
+ expect(plan.items.map((i) => i.component)).toEqual(["api"]);
125
+ expect(plan.notPromoted).toEqual([{ component: "infra", reason: expect.stringMatching(/only applies are recorded/) }]);
126
+ });
127
+
128
+ test("a newer apply never hides the artifact release before it", () => {
129
+ const mixed = [rec("api", "staging", "sha256:api", "2026-01-01T00:00:00Z"), apply("api", "2026-01-05T00:00:00Z")];
130
+ const picked = selectRelease(mixed, "api", "staging");
131
+ expect("record" in picked && picked.record.digest).toBe("sha256:api");
132
+ });
133
+
134
+ test("promote, rollback and redeploy of a component with only applies are refused by name", () => {
135
+ const promote = planPromotion({ from: "staging", to: "prod", sourceRecords: records, declared: ["api", "infra"], component: "infra" });
136
+ expect("error" in promote && promote.error).toMatch(/"infra" has only applies recorded in "staging"/);
137
+ const rollback = planRollback({ env: "staging", records, declared: ["infra"], component: "infra" });
138
+ expect("error" in rollback && rollback.error).toMatch(/only applies recorded/);
139
+ const redeploy = planRedeploy({ env: "staging", records, declared: ["infra"], component: "infra" });
140
+ expect("error" in redeploy && redeploy.error).toMatch(/only applies recorded/);
141
+ });
142
+
143
+ test("a rollback or redeploy carries a templates digest's kind forward", () => {
144
+ const source: ReleaseRecord = { ...rec("infra", "staging", "sha256:tpl", "2026-01-02T00:00:00Z"), digestKind: "templates" };
145
+ const run = { runId: "r2", actor: "ci", timestamp: "2026-01-04T00:00:00Z" };
146
+ expect(rollbackRecord({ component: "infra", digest: "sha256:tpl", source }, "staging", run)).toMatchObject({ digestKind: "templates" });
147
+ });
148
+ });
149
+
111
150
  describe("pinning each component to a digest (#2602)", () => {
112
151
  // api's pinned release is older than its latest one: another pipeline
113
152
  // recorded sha256:newer while this one ran.
@@ -39,7 +39,7 @@
39
39
  */
40
40
 
41
41
  import { parseWiringRef, type WiringRef } from "../lint/rules/comp/support";
42
- import { latestPerComponent, type ReleaseRecord, type ReleaseRecordInput, type ReleaseAdmission, type RunOrigin } from "../lifecycle/release-ledger";
42
+ import { isApplyRecord, latestPerComponent, type ReleaseRecord, type ReleaseRecordInput, type ReleaseAdmission, type RunOrigin } from "../lifecycle/release-ledger";
43
43
  import { CapabilityRegistry, type Capability } from "./capability";
44
44
  import {
45
45
  runInterpretDriver,
@@ -109,19 +109,35 @@ export function selectRelease(
109
109
  env: string,
110
110
  digest?: string,
111
111
  ): { record: ReleaseRecord } | { error: string } {
112
- const own = records.filter((r) => r.component === component);
113
- if (own.length === 0) return { error: `no release of "${component}" is recorded in "${env}"` };
114
- if (digest === undefined) return { record: latestPerComponent(own).get(component)! };
115
- const matching = own.filter((r) => r.digest === digest);
112
+ const own = deployableRecords(records, component, env);
113
+ if ("error" in own) return own;
114
+ if (digest === undefined) return { record: latestPerComponent(own.records).get(component)! };
115
+ const matching = own.records.filter((r) => r.digest === digest);
116
116
  if (matching.length === 0) {
117
117
  return {
118
118
  error: `digest ${digest} is not recorded for "${component}" in "${env}"; ` +
119
- `its recorded digests are ${[...new Set(own.map((r) => r.digest))].join(", ")}`,
119
+ `its recorded digests are ${[...new Set(own.records.map((r) => r.digest))].join(", ")}`,
120
120
  };
121
121
  }
122
122
  return { record: latestPerComponent(matching).get(component)! };
123
123
  }
124
124
 
125
+ /**
126
+ * The records of `component` a promote, rollback or redeploy can deploy from
127
+ * (#4274): every one but those that only say it applied ({@link isApplyRecord}),
128
+ * whose digest is a plan or deploy-input digest and names no artifact.
129
+ */
130
+ function deployableRecords(records: ReleaseRecord[], component: string, env: string): { records: ReleaseRecord[] } | { error: string } {
131
+ const own = records.filter((r) => r.component === component);
132
+ if (own.length === 0) return { error: `no release of "${component}" is recorded in "${env}"` };
133
+ const deployable = own.filter((r) => !isApplyRecord(r));
134
+ if (deployable.length === 0) return { error: appliesOnly(component, env) };
135
+ return { records: deployable };
136
+ }
137
+
138
+ const appliesOnly = (component: string, env: string): string =>
139
+ `"${component}" has only applies recorded in "${env}", each by a plan or deploy-input digest, so no artifact of it can be deployed again`;
140
+
125
141
  /**
126
142
  * Read `--digest <component>=<digest>` values (#2602) into a component-to-
127
143
  * digest map. A value with no `=` is left to the single-component form
@@ -198,13 +214,17 @@ export function planPromotion(input: {
198
214
  return { from, to, items: [{ component, digest: picked.record.digest, source: picked.record }], notPromoted: [] };
199
215
  }
200
216
 
201
- const latest = latestPerComponent(sourceRecords);
217
+ const latest = latestPerComponent(sourceRecords.filter((r) => !isApplyRecord(r)));
202
218
  const items: PromotionItem[] = [];
203
219
  const notPromoted: PromotionPlan["notPromoted"] = [];
204
220
  for (const [name, record] of [...latest].sort(([a], [b]) => a.localeCompare(b))) {
205
221
  if (declared.includes(name)) items.push({ component: name, digest: record.digest, source: record });
206
222
  else notPromoted.push({ component: name, reason: "recorded but not declared in this checkout" });
207
223
  }
224
+ // #4274: a component that only applied (infrastructure, no artifact) has nothing to promote.
225
+ for (const name of [...new Set(sourceRecords.filter((r) => isApplyRecord(r) && !latest.has(r.component)).map((r) => r.component))].sort()) {
226
+ notPromoted.push({ component: name, reason: "only applies are recorded, by a plan or deploy-input digest; there is no artifact to promote" });
227
+ }
208
228
  if (items.length === 0) {
209
229
  return { error: `nothing to promote: no component declared here has a release recorded in "${from}"` };
210
230
  }
@@ -228,9 +248,10 @@ export function planRollback(input: {
228
248
  if (!declared.includes(component)) {
229
249
  return { error: `component "${component}" is not declared in this checkout` };
230
250
  }
231
- const own = records.filter((r) => r.component === component);
232
- const current = latestPerComponent(own).get(component);
233
- if (!current) return { error: `no release of "${component}" is recorded in "${env}"` };
251
+ const deployable = deployableRecords(records, component, env);
252
+ if ("error" in deployable) return deployable;
253
+ const own = deployable.records;
254
+ const current = latestPerComponent(own).get(component)!;
234
255
 
235
256
  let target: ReleaseRecord;
236
257
  if (digest !== undefined) {
@@ -284,7 +305,7 @@ export function planReleaseRollback(input: {
284
305
  notPromoted.push({ component, reason: `in release ${name} but not declared in this checkout` });
285
306
  continue;
286
307
  }
287
- const ran = records.filter((r) => r.component === component && r.releaseDigest === release.releaseDigest);
308
+ const ran = records.filter((r) => r.component === component && r.releaseDigest === release.releaseDigest && !isApplyRecord(r));
288
309
  if (ran.length === 0) {
289
310
  neverRan.push(component);
290
311
  continue;
@@ -299,7 +320,7 @@ export function planReleaseRollback(input: {
299
320
  };
300
321
  }
301
322
  if (items.length === 0) return { error: `release ${name} has no component declared in this checkout` };
302
- const current = latestPerComponent(records);
323
+ const current = latestPerComponent(records.filter((r) => !isApplyRecord(r)));
303
324
  const already = items.every((i) => {
304
325
  const now = current.get(i.component);
305
326
  return now !== undefined && now.releaseDigest === release.releaseDigest && now.digest === i.digest && !input.unsettled?.has(i.component);
@@ -329,8 +350,9 @@ export function planRedeploy(input: {
329
350
  if (!declared.includes(component)) {
330
351
  return { error: `component "${component}" is not declared in this checkout` };
331
352
  }
332
- const current = latestPerComponent(records.filter((r) => r.component === component)).get(component);
333
- if (!current) return { error: `no release of "${component}" is recorded in "${env}"` };
353
+ const deployable = deployableRecords(records, component, env);
354
+ if ("error" in deployable) return deployable;
355
+ const current = latestPerComponent(deployable.records).get(component)!;
334
356
  if (digest !== undefined && digest !== current.digest) {
335
357
  return {
336
358
  error: `${digest} is not the current release of "${component}" in "${env}" (that is ${current.digest}); ` +
@@ -699,6 +721,8 @@ function sharedRecord(item: PromotionItem, env: string, run: DeployRunInfo): Rel
699
721
  ...(run.admission ? { admission: run.admission } : {}),
700
722
  ...(source.manifestDigest ? { manifestDigest: source.manifestDigest } : {}),
701
723
  ...(source.inputDigest ? { inputDigest: source.inputDigest } : {}),
724
+ // #4274: a release's templates digest stays one when it is deployed again.
725
+ ...(source.digestKind ? { digestKind: source.digestKind } : {}),
702
726
  };
703
727
  }
704
728
 
@@ -203,7 +203,12 @@ if (args[0] === "publish") {
203
203
  const [name, version] = split(args[1]);
204
204
  const p = reg.packages[name];
205
205
  if (!p) { console.error("npm error 404 Not Found - " + name); process.exit(1); }
206
- if (p.lag && p.lag[version] > 0) { p.lag[version] -= 1; save(); }
206
+ if (process.env.FAKE_NPM_VIEW_ERROR && name.includes(process.env.FAKE_NPM_VIEW_ERROR) && p.versions.includes(version)) { console.error("npm error code E500"); console.error("npm error 500 Internal Server Error - GET https://registry.example/" + name); process.exit(1); }
207
+ if (p.lag && p.lag[version] > 0) {
208
+ p.lag[version] -= 1; save();
209
+ // A new package npm does not serve yet answers 404, as the real registry does.
210
+ if (process.env.FAKE_NPM_LAG_404) { console.error("npm error code E404"); console.error("npm error 404 No match found for version " + version); process.exit(1); }
211
+ }
207
212
  else if (p.versions.includes(version)) console.log(version);
208
213
  } else if (args[0] === "dist-tag" && args[1] === "add") {
209
214
  const [name, version] = split(args[2]);
@@ -61,9 +61,10 @@ export const NpmPublishSchema = z
61
61
  */
62
62
  tokenEnv: envName.optional(),
63
63
  /**
64
- * Seconds the reveal waits for the registry to serve every version it
65
- * published before installing them to audit (#4244). A registry accepts a
66
- * publish before it serves it. Default 600.
64
+ * A cap, in seconds, on the reveal's wait for the registry to serve every
65
+ * version it published before installing them to audit (#4244). A
66
+ * registry accepts a publish before it serves it. Unset, the wait ends
67
+ * only when every version is served or the registry fails (#4271).
67
68
  */
68
69
  registryWait: z.number().int().min(0).optional(),
69
70
  })