@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.
- 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 +192 -192
- 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-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
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
|
);
|
|
@@ -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
|
|
113
|
-
if (
|
|
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
|
|
232
|
-
|
|
233
|
-
|
|
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
|
|
333
|
-
if (
|
|
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 (
|
|
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
|
-
*
|
|
65
|
-
* published before installing them to audit (#4244). A
|
|
66
|
-
* publish before it serves it.
|
|
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
|
})
|