@intentius/chant 0.124.38 → 0.124.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/component-endpoint.d.ts +20 -0
- package/dist/cli/component-endpoint.d.ts.map +1 -0
- package/dist/cli/component-endpoint.js +36 -0
- package/dist/cli/handlers/fan-out.d.ts.map +1 -1
- package/dist/cli/handlers/fan-out.js +10 -1
- package/dist/cli/handlers/pr.d.ts.map +1 -1
- package/dist/cli/handlers/pr.js +11 -5
- package/dist/cli/handlers/promote.d.ts.map +1 -1
- package/dist/cli/handlers/promote.js +7 -2
- package/dist/cli/handlers/run.d.ts +4 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/handlers/run.js +17 -6
- package/dist/components/auto-release.d.ts +35 -3
- package/dist/components/auto-release.d.ts.map +1 -1
- package/dist/components/auto-release.js +29 -2
- package/dist/components/cli-support.d.ts +2 -0
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/cli-support.js +2 -0
- package/dist/components/deploy-units.d.ts.map +1 -1
- package/dist/components/deploy-units.js +5 -0
- package/dist/components/promote.d.ts.map +1 -1
- package/dist/components/promote.js +39 -16
- package/dist/lifecycle/__fixtures__/publish-fakes.d.ts.map +1 -1
- package/dist/lifecycle/__fixtures__/publish-fakes.js +6 -1
- package/dist/lifecycle/publish-declaration.d.ts.map +1 -1
- package/dist/lifecycle/publish-declaration.js +4 -3
- package/dist/lifecycle/publish-npm.d.ts +2 -2
- package/dist/lifecycle/publish-npm.d.ts.map +1 -1
- package/dist/lifecycle/publish-npm.js +31 -8
- package/dist/lifecycle/release-ledger.d.ts +31 -0
- package/dist/lifecycle/release-ledger.d.ts.map +1 -1
- package/dist/lifecycle/release-ledger.js +19 -1
- package/dist/lifecycle/status.d.ts +5 -1
- package/dist/lifecycle/status.d.ts.map +1 -1
- package/dist/lifecycle/status.js +10 -5
- package/dist/live-endpoint.d.ts +18 -0
- package/dist/live-endpoint.d.ts.map +1 -1
- package/dist/live-endpoint.js +27 -0
- package/dist/wave-runtime.mjs +201 -201
- package/dist/workspace/ci-builds.d.ts +12 -1
- package/dist/workspace/ci-builds.d.ts.map +1 -1
- package/dist/workspace/ci-builds.js +26 -0
- package/dist/workspace/ci-green-cli.js +1 -1
- package/dist/workspace/ci-green-forge.d.ts +3 -1
- package/dist/workspace/ci-green-forge.d.ts.map +1 -1
- package/dist/workspace/ci-green-forge.js +20 -2
- package/dist/workspace/ci-green-workflow.d.ts +6 -4
- package/dist/workspace/ci-green-workflow.d.ts.map +1 -1
- package/dist/workspace/ci-green-workflow.js +26 -8
- package/dist/workspace/ci-run.d.ts +7 -0
- package/dist/workspace/ci-run.d.ts.map +1 -1
- package/dist/workspace/ci-run.js +21 -3
- package/dist/workspace/status.d.ts +3 -1
- package/dist/workspace/status.d.ts.map +1 -1
- package/dist/workspace/status.js +3 -2
- package/dist/workspace/status.schema.json +1 -0
- package/package.json +1 -1
- package/src/cli/component-endpoint.ts +43 -0
- package/src/cli/handlers/fan-out.test.ts +10 -0
- package/src/cli/handlers/fan-out.ts +12 -0
- package/src/cli/handlers/pr-endpoint.test.ts +169 -0
- package/src/cli/handlers/pr.ts +12 -5
- package/src/cli/handlers/promote.ts +16 -2
- package/src/cli/handlers/run.test.ts +2 -1
- package/src/cli/handlers/run.ts +16 -5
- package/src/components/auto-release-infra.test.ts +131 -0
- package/src/components/auto-release.test.ts +40 -1
- package/src/components/auto-release.ts +50 -4
- package/src/components/cli-support.ts +4 -0
- package/src/components/deploy-units.ts +5 -0
- package/src/components/promote.test.ts +39 -0
- package/src/components/promote.ts +38 -14
- package/src/lifecycle/__fixtures__/publish-fakes.ts +6 -1
- package/src/lifecycle/publish-declaration.ts +4 -3
- package/src/lifecycle/publish-npm.ts +33 -8
- package/src/lifecycle/publish-targets.test.ts +36 -3
- package/src/lifecycle/release-ledger.ts +42 -1
- package/src/lifecycle/status.test.ts +19 -0
- package/src/lifecycle/status.ts +14 -6
- package/src/live-endpoint.ts +31 -0
- package/src/workspace/ci-builds.e2e.test.ts +7 -2
- package/src/workspace/ci-builds.test.ts +46 -1
- package/src/workspace/ci-builds.ts +25 -1
- package/src/workspace/ci-green-builds.test.ts +26 -0
- package/src/workspace/ci-green-cli.ts +1 -1
- package/src/workspace/ci-green-forge.test.ts +31 -0
- package/src/workspace/ci-green-forge.ts +21 -3
- package/src/workspace/ci-green-workflow.test.ts +4 -4
- package/src/workspace/ci-green-workflow.ts +22 -8
- package/src/workspace/ci-run.test.ts +26 -0
- package/src/workspace/ci-run.ts +20 -3
- package/src/workspace/status.schema.json +1 -0
- package/src/workspace/status.ts +7 -3
|
@@ -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
|
})
|
|
@@ -160,10 +160,12 @@ function run(bin: string, args: string[], ctx: PublishContext, cwd: string = ctx
|
|
|
160
160
|
});
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
-
/** Seconds the reveal waits for the registry to serve what it published, by default (#4244). */
|
|
164
|
-
export const NPM_REGISTRY_WAIT_SECONDS = 600;
|
|
165
163
|
/** Between polls of the registry while it waits. */
|
|
166
164
|
export const NPM_REGISTRY_POLL_MS = 10_000;
|
|
165
|
+
/** Between progress lines while the reveal waits for the registry (#4271). */
|
|
166
|
+
export const NPM_REGISTRY_PROGRESS_MS = 60_000;
|
|
167
|
+
/** `npm view`'s answers for a version the registry does not serve yet; any other failure is the registry's error. */
|
|
168
|
+
const NPM_VIEW_NOT_YET = /\bE404\b|\b404 Not Found\b|is not in (this|the npm) registry|No match found for version|\b(ETARGET|notarget)\b/;
|
|
167
169
|
/** npm's errors for a version the registry does not serve yet. */
|
|
168
170
|
const NPM_NOT_SERVED = /\b(ETARGET|ERESOLVE|notarget)\b|No matching version found/;
|
|
169
171
|
|
|
@@ -225,29 +227,52 @@ export function npmTarget(decl: NpmPublishDeclaration, deps: PublishDeps): Publi
|
|
|
225
227
|
* `npm audit signatures` there (#4049), which checks each package's
|
|
226
228
|
* registry signature and provenance attestation.
|
|
227
229
|
*/
|
|
230
|
+
/**
|
|
231
|
+
* Whether the registry serves the version yet, while the reveal waits for
|
|
232
|
+
* it (#4271). A 404, or an answer without the version, is not yet; any other
|
|
233
|
+
* failure of `npm view` is the registry's error and ends the wait.
|
|
234
|
+
*/
|
|
235
|
+
const probe = async (ctx: PublishContext, pkg: NpmPackage): Promise<boolean> => {
|
|
236
|
+
const r = await npm(ctx, ["view", `${pkg.name}@${pkg.version}`, "version", ...(decl.registry ? ["--registry", decl.registry] : [])]);
|
|
237
|
+
if (r.code === 0) return r.output.some((l) => l.trim() === pkg.version || l.trim() === JSON.stringify(pkg.version));
|
|
238
|
+
if (r.output.some((l) => NPM_VIEW_NOT_YET.test(l))) return false;
|
|
239
|
+
const said = r.output.filter((l) => /^npm (error|ERR!)/.test(l)).at(-1) ?? r.output.at(-1) ?? "npm printed nothing";
|
|
240
|
+
throw new PublishTargetError(
|
|
241
|
+
"publish-npm-registry-failed",
|
|
242
|
+
`npm view ${pkg.name}@${pkg.version} exited ${r.code} while the reveal waited for the registry to serve it, so nothing was audited: ${said}`,
|
|
243
|
+
"Each package was published. Once the registry answers, run the publish again: it publishes nothing twice and audits again.",
|
|
244
|
+
);
|
|
245
|
+
};
|
|
228
246
|
/**
|
|
229
247
|
* Wait until the registry serves every version (#4244): npm accepts a
|
|
230
248
|
* publish minutes before `npm view` and `npm install` find it, so an
|
|
231
|
-
* install straight after fails with ETARGET or ERESOLVE.
|
|
249
|
+
* install straight after fails with ETARGET or ERESOLVE. The wait ends when
|
|
250
|
+
* every version is served or the registry fails (#4271), with a progress
|
|
251
|
+
* line about once a minute. `registryWait` caps it only when declared.
|
|
232
252
|
*/
|
|
233
253
|
const served = async (ctx: PublishContext, pkgs: NpmPackage[]): Promise<void> => {
|
|
234
|
-
const
|
|
254
|
+
const cap = decl.registryWait === undefined ? undefined : decl.registryWait * 1000;
|
|
235
255
|
const start = now();
|
|
256
|
+
let logged: number | undefined;
|
|
236
257
|
let missing = pkgs;
|
|
237
258
|
for (;;) {
|
|
238
259
|
const still: NpmPackage[] = [];
|
|
239
|
-
for (const pkg of missing) if (!(await
|
|
260
|
+
for (const pkg of missing) if (!(await probe(ctx, pkg))) still.push(pkg);
|
|
240
261
|
missing = still;
|
|
241
262
|
if (missing.length === 0) return;
|
|
242
263
|
const names = missing.map((p) => `${p.name}@${p.version}`).join(", ");
|
|
243
|
-
|
|
264
|
+
const waited = now() - start;
|
|
265
|
+
if (cap !== undefined && waited >= cap) {
|
|
244
266
|
throw new PublishTargetError(
|
|
245
267
|
"publish-npm-not-served",
|
|
246
|
-
`the registry did not serve ${names} within ${Math.round(
|
|
268
|
+
`the registry did not serve ${names} within ${Math.round(cap / 1000)}s of the publish, so nothing was audited`,
|
|
247
269
|
"Each package was published. Once npm serves them, run the publish again: it publishes nothing twice and audits again. `registryWait` sets the wait.",
|
|
248
270
|
);
|
|
249
271
|
}
|
|
250
|
-
|
|
272
|
+
if (logged === undefined || now() - logged >= NPM_REGISTRY_PROGRESS_MS) {
|
|
273
|
+
ctx.log(`waiting for the registry to serve ${names}${waited > 0 ? ` (${Math.round(waited / 1000)}s so far)` : ""}`);
|
|
274
|
+
logged = now();
|
|
275
|
+
}
|
|
251
276
|
await sleep(NPM_REGISTRY_POLL_MS);
|
|
252
277
|
}
|
|
253
278
|
};
|
|
@@ -486,7 +486,8 @@ describe("SBOMs and provenance (#4049)", () => {
|
|
|
486
486
|
});
|
|
487
487
|
|
|
488
488
|
describe("npm: the reveal waits for the registry to serve what it published (#4244)", () => {
|
|
489
|
-
|
|
489
|
+
// `step` is how far the clock moves per sleep, so a long wait costs few npm runs.
|
|
490
|
+
function lagged(env: Record<string, string>, registryWait?: number, step?: number) {
|
|
490
491
|
const dir = temp();
|
|
491
492
|
writePackage(join(dir, "core"), "@acme/core", "1.0.0");
|
|
492
493
|
writePackage(join(dir, "cli"), "@acme/cli", "1.0.0");
|
|
@@ -494,7 +495,7 @@ describe("SBOMs and provenance (#4049)", () => {
|
|
|
494
495
|
const npm = writeFakeNpm(join(dir, "bin"), log);
|
|
495
496
|
let clock = 0;
|
|
496
497
|
const sleeps: number[] = [];
|
|
497
|
-
const deps = { npm, now: () => clock, sleep: async (ms: number) => { sleeps.push(ms); clock += ms; } };
|
|
498
|
+
const deps = { npm, now: () => clock, sleep: async (ms: number) => { sleeps.push(ms); clock += step ?? ms; } };
|
|
498
499
|
const ctx = context(dir, { env: { PATH: process.env.PATH, ...env }, provenance: OIDC });
|
|
499
500
|
const d = decl({ type: "npm", packages: ["core", "cli"], stage: false, ...(registryWait !== undefined ? { registryWait } : {}) });
|
|
500
501
|
return { log, deps, ctx, d, sleeps };
|
|
@@ -518,7 +519,39 @@ describe("SBOMs and provenance (#4049)", () => {
|
|
|
518
519
|
expect(installs(log)).toBe(1);
|
|
519
520
|
});
|
|
520
521
|
|
|
521
|
-
test("
|
|
522
|
+
test("with no registryWait it waits past 600 seconds until npm serves, naming what is missing about once a minute (#4271)", async () => {
|
|
523
|
+
// 21 polls, the clock moving 30s each: served after 630s.
|
|
524
|
+
const { log, deps, ctx, d, sleeps } = lagged({ FAKE_NPM_LAG: "@acme/core=21,@acme/cli=1" }, undefined, 30_000);
|
|
525
|
+
const [target] = await createPublishTargets([d], deps);
|
|
526
|
+
await target!.reveal(ctx, await target!.stage(ctx));
|
|
527
|
+
expect(sleeps.length).toBe(21);
|
|
528
|
+
const progress = ctx.lines.filter((l) => l.startsWith("waiting for the registry to serve"));
|
|
529
|
+
expect(progress[0]).toBe("waiting for the registry to serve @acme/core@1.0.0, @acme/cli@1.0.0");
|
|
530
|
+
expect(progress[1]).toBe("waiting for the registry to serve @acme/core@1.0.0 (60s so far)");
|
|
531
|
+
expect(progress.at(-1)).toBe("waiting for the registry to serve @acme/core@1.0.0 (600s so far)");
|
|
532
|
+
expect(progress.length).toBe(11);
|
|
533
|
+
expect(installs(log)).toBe(1);
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
test("a 404 for the version is not yet served, and the wait goes on (#4271)", async () => {
|
|
537
|
+
const { log, deps, ctx, d, sleeps } = lagged({ FAKE_NPM_LAG: "@acme/cli=2", FAKE_NPM_LAG_404: "1" });
|
|
538
|
+
const [target] = await createPublishTargets([d], deps);
|
|
539
|
+
await target!.reveal(ctx, await target!.stage(ctx));
|
|
540
|
+
expect(sleeps).toEqual([10_000, 10_000]);
|
|
541
|
+
expect(installs(log)).toBe(1);
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
test("a registry error that is not a 404 ends the wait with npm's error, before any install (#4271)", async () => {
|
|
545
|
+
const { log, deps, ctx, d, sleeps } = lagged({ FAKE_NPM_VIEW_ERROR: "@acme/cli" });
|
|
546
|
+
const [target] = await createPublishTargets([d], deps);
|
|
547
|
+
const err = await refused(target!.reveal(ctx, await target!.stage(ctx)));
|
|
548
|
+
expect(err.code).toBe("publish-npm-registry-failed");
|
|
549
|
+
expect(err.message).toBe("npm view @acme/cli@1.0.0 exited 1 while the reveal waited for the registry to serve it, so nothing was audited: npm error 500 Internal Server Error - GET https://registry.example/@acme/cli");
|
|
550
|
+
expect(sleeps).toEqual([]);
|
|
551
|
+
expect(installs(log)).toBe(0);
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
test("with registryWait declared, a version never served: the wait ends with an error naming it, before any install", async () => {
|
|
522
555
|
const { log, deps, ctx, d } = lagged({ FAKE_NPM_LAG: "@acme/core=1000" }, 30);
|
|
523
556
|
const [target] = await createPublishTargets([d], deps);
|
|
524
557
|
const err = await refused(target!.reveal(ctx, await target!.stage(ctx)));
|
|
@@ -242,6 +242,35 @@ export interface ReleaseRecord {
|
|
|
242
242
|
* deploy that was not made from a release.
|
|
243
243
|
*/
|
|
244
244
|
releaseDigest?: string;
|
|
245
|
+
/**
|
|
246
|
+
* Optional: what `digest` names when it is not an artifact digest (#4274).
|
|
247
|
+
* Absent on a record whose digest is the artifact the deploy promoted, and on
|
|
248
|
+
* every record written before 0.124.35.
|
|
249
|
+
*
|
|
250
|
+
* - `plan`: the component published nothing, and the digest is its plan
|
|
251
|
+
* digest, the one its approval bound (`pr-apply`).
|
|
252
|
+
* - `deploy-input`: the component published nothing and planned nothing, and
|
|
253
|
+
* the digest is its deploy-input digest (`componentInputDigests`,
|
|
254
|
+
* ../components/fan-out-inputs.ts).
|
|
255
|
+
* - `templates`: the digest of the templates the release records for the
|
|
256
|
+
* component (#4222, `releaseTemplatesDigest`).
|
|
257
|
+
*
|
|
258
|
+
* A `plan` or `deploy-input` record says the component applied; it names no
|
|
259
|
+
* artifact, so promote, rollback and redeploy never deploy it
|
|
260
|
+
* ({@link isApplyRecord}).
|
|
261
|
+
*/
|
|
262
|
+
digestKind?: ReleaseDigestKind;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** What a release record's `digest` names when it is not an artifact digest (#4274). */
|
|
266
|
+
export type ReleaseDigestKind = "plan" | "deploy-input" | "templates";
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Whether `record` only says its component applied (#4274): its digest is a
|
|
270
|
+
* plan or deploy-input digest, not one anything can be deployed from again.
|
|
271
|
+
*/
|
|
272
|
+
export function isApplyRecord(record: Pick<ReleaseRecord, "digestKind">): boolean {
|
|
273
|
+
return record.digestKind === "plan" || record.digestKind === "deploy-input";
|
|
245
274
|
}
|
|
246
275
|
|
|
247
276
|
/** Thrown by `appendReleaseRecord` when the record is missing a required field — a release record can never be recorded partially, since it is never editable afterward. */
|
|
@@ -319,9 +348,21 @@ export function resolveCiRun(env: NodeJS.ProcessEnv = process.env): { runId: str
|
|
|
319
348
|
},
|
|
320
349
|
};
|
|
321
350
|
}
|
|
351
|
+
// #4275: a step of a job the chant runner ran. The forge branches come first, and the runner
|
|
352
|
+
// strips the forge's run variables from its steps, so neither kind of job is taken for the other.
|
|
353
|
+
if (env[CHANT_CI_RUN_ID_ENV] && env[CHANT_CI_RUN_FORGE_ENV] === "chant") {
|
|
354
|
+
return { runId: env[CHANT_CI_RUN_ID_ENV], runOrigin: { forge: "chant" } };
|
|
355
|
+
}
|
|
322
356
|
return undefined;
|
|
323
357
|
}
|
|
324
358
|
|
|
359
|
+
/** The run id `chant ci run` hands each step of a job it runs (#4275), the id its run index lists. */
|
|
360
|
+
export const CHANT_CI_RUN_ID_ENV = "CHANT_CI_RUN_ID";
|
|
361
|
+
/** The run's origin `chant ci run` hands each step with {@link CHANT_CI_RUN_ID_ENV}: always `chant`. */
|
|
362
|
+
export const CHANT_CI_RUN_FORGE_ENV = "CHANT_CI_RUN_FORGE";
|
|
363
|
+
/** The forge's run variables, which a step of a job the chant runner ran never gets (#4275). */
|
|
364
|
+
export const FORGE_RUN_ENV = ["GITHUB_RUN_ID", "GITHUB_RUN_ATTEMPT", "GITHUB_JOB", "CI_PIPELINE_ID", "CI_PIPELINE_URL", "CI_JOB_ID"] as const;
|
|
365
|
+
|
|
325
366
|
/**
|
|
326
367
|
* The CI run the environment names, as {@link resolveCiRun} has it, with the
|
|
327
368
|
* job within the run and the run's attempt when the forge gives them
|
|
@@ -332,7 +373,7 @@ export function resolveCiJob(env: NodeJS.ProcessEnv = process.env): { runId: str
|
|
|
332
373
|
const ci = resolveCiRun(env);
|
|
333
374
|
if (!ci) return undefined;
|
|
334
375
|
const github = ci.runOrigin.forge === "github";
|
|
335
|
-
const jobId = github ? env.GITHUB_JOB : env.CI_JOB_ID;
|
|
376
|
+
const jobId = github ? env.GITHUB_JOB : ci.runOrigin.forge === "gitlab" ? env.CI_JOB_ID : undefined;
|
|
336
377
|
const attempt = github ? Number(env.GITHUB_RUN_ATTEMPT) : NaN;
|
|
337
378
|
return {
|
|
338
379
|
runId: ci.runId,
|
|
@@ -432,6 +432,16 @@ describe("status", () => {
|
|
|
432
432
|
expect(result.digestB).toBe("sha256:prod-bytes");
|
|
433
433
|
});
|
|
434
434
|
|
|
435
|
+
test("apply records compare on the commit, since their digests take in the environment (#4274)", () => {
|
|
436
|
+
const apply = (env: string, gitSha: string) =>
|
|
437
|
+
record({ component: "infra", env, gitSha, digest: `jcs1-sha256:${env.padEnd(64, "0").slice(0, 64)}`, digestKind: "deploy-input" });
|
|
438
|
+
const same = compareAcrossEnvironments("infra", { name: "staging", records: [apply("staging", "a1")] }, { name: "prod", records: [apply("prod", "a1")] });
|
|
439
|
+
expect(same.same).toBe(true);
|
|
440
|
+
expect(same.comparedOn).toEqual({ a: "git:a1", b: "git:a1" });
|
|
441
|
+
const moved = compareAcrossEnvironments("infra", { name: "staging", records: [apply("staging", "b2")] }, { name: "prod", records: [apply("prod", "a1")] });
|
|
442
|
+
expect(moved.same).toBe(false);
|
|
443
|
+
});
|
|
444
|
+
|
|
435
445
|
test("differing inputDigests are a real difference, whatever the bytes say", () => {
|
|
436
446
|
const result = compareAcrossEnvironments(
|
|
437
447
|
"web",
|
|
@@ -671,6 +681,15 @@ describe("drifted: live evidence contradicts the recorded release (#2513)", () =
|
|
|
671
681
|
expect(row.reconciliation).toBe("reconciled");
|
|
672
682
|
});
|
|
673
683
|
|
|
684
|
+
test("reconciled: an apply record's plan digest is never held against a live digest (#4274)", () => {
|
|
685
|
+
const row = reconcile(
|
|
686
|
+
[entry()],
|
|
687
|
+
new Map([["svc", { now: live({ attributes: { digest: "sha256:other", gitSha: "1111111" } }) }]]),
|
|
688
|
+
{ ...released, digest: `jcs1-sha256:${"a".repeat(64)}`, digestKind: "plan" },
|
|
689
|
+
);
|
|
690
|
+
expect(row.reconciliation).toBe("reconciled");
|
|
691
|
+
});
|
|
692
|
+
|
|
674
693
|
test("drifted: a foreign digest", () => {
|
|
675
694
|
const row = reconcile([entry()], new Map([["svc", { now: live({ attributes: { digest: "sha256:other" } }) }]]));
|
|
676
695
|
expect(row.reconciliation).toBe("drifted");
|
package/src/lifecycle/status.ts
CHANGED
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
|
|
44
44
|
import type { ChangeSet, ChangeAction } from "./change-set";
|
|
45
45
|
import type { ResourceMetadata } from "../lexicon";
|
|
46
|
-
import { latestPerComponent, type ReleaseRecord } from "./release-ledger";
|
|
46
|
+
import { isApplyRecord, latestPerComponent, type ReleaseRecord } from "./release-ledger";
|
|
47
47
|
import { isLegacyContentDigest } from "./legacy-digest";
|
|
48
48
|
import type { BuildLedgerEntry, ComponentBomSummary } from "./build-ledger";
|
|
49
49
|
import { unobservedReasonText, type UnobservedReason } from "../observation";
|
|
@@ -647,7 +647,8 @@ function notBefore(since: string, recordedAt: string): boolean {
|
|
|
647
647
|
*/
|
|
648
648
|
function contradictsRecord(recorded: ReleaseRecord, evidence: LiveComponentEvidence): string | undefined {
|
|
649
649
|
for (const id of evidence.identities ?? []) {
|
|
650
|
-
|
|
650
|
+
// #4274: an apply's digest is a plan or deploy-input digest, which no live resource reports.
|
|
651
|
+
if (id.digest !== undefined && !isApplyRecord(recorded)) {
|
|
651
652
|
const matches =
|
|
652
653
|
sameDigest(id.digest, recorded.digest) ||
|
|
653
654
|
(recorded.inputDigest !== undefined && sameDigest(id.digest, recorded.inputDigest));
|
|
@@ -697,7 +698,9 @@ export interface CrossEnvComparison {
|
|
|
697
698
|
* environments legitimately render to different bytes, so cross-environment
|
|
698
699
|
* "is prod running what staging tested" joins on the input side while
|
|
699
700
|
* `digest` proves the exact bytes each cluster got. Records without it
|
|
700
|
-
* compare on `digest` exactly as before.
|
|
701
|
+
* compare on `digest` exactly as before. An apply record (#4274) compares
|
|
702
|
+
* on its `gitSha`, since its plan or deploy-input digest takes in the
|
|
703
|
+
* environment and so differs between any two.
|
|
701
704
|
*/
|
|
702
705
|
same: boolean;
|
|
703
706
|
/** The identities `same` was decided on (`inputDigest ?? digest` per side), when both sides had one and they differ from the digests shown. */
|
|
@@ -711,6 +714,11 @@ export interface CrossEnvComparison {
|
|
|
711
714
|
mixedDigestForms?: true;
|
|
712
715
|
}
|
|
713
716
|
|
|
717
|
+
/** What two environments' records of a component are compared on: the input side when there is one, the commit for an apply (#4274). */
|
|
718
|
+
export function comparableIdentity(record: ReleaseRecord): string {
|
|
719
|
+
return isApplyRecord(record) ? `git:${record.gitSha}` : record.inputDigest ?? record.digest;
|
|
720
|
+
}
|
|
721
|
+
|
|
714
722
|
/** Compare the latest recorded digest for `component` between two environments' release records. */
|
|
715
723
|
export function compareAcrossEnvironments(
|
|
716
724
|
component: string,
|
|
@@ -719,9 +727,9 @@ export function compareAcrossEnvironments(
|
|
|
719
727
|
): CrossEnvComparison {
|
|
720
728
|
const latestA = latestPerComponent(envA.records).get(component);
|
|
721
729
|
const latestB = latestPerComponent(envB.records).get(component);
|
|
722
|
-
const idA = latestA ? latestA
|
|
723
|
-
const idB = latestB ? latestB
|
|
724
|
-
const comparedOnInputs = (latestA
|
|
730
|
+
const idA = latestA ? comparableIdentity(latestA) : undefined;
|
|
731
|
+
const idB = latestB ? comparableIdentity(latestB) : undefined;
|
|
732
|
+
const comparedOnInputs = (latestA !== undefined && idA !== latestA.digest) || (latestB !== undefined && idB !== latestB.digest);
|
|
725
733
|
return {
|
|
726
734
|
component,
|
|
727
735
|
envA: envA.name,
|
package/src/live-endpoint.ts
CHANGED
|
@@ -145,3 +145,34 @@ export function zeroResourcesWarning(
|
|
|
145
145
|
if (Object.keys(observed.unobserved).length > 0) return undefined;
|
|
146
146
|
return `${lexicon}: 0 live resources for env "${environment}" (${declaredCount} declared) — check the endpoint/credentials`;
|
|
147
147
|
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Run `run` with `environment`'s declared endpoint applied, as
|
|
151
|
+
* {@link applyLiveEndpoint} applies it to a `--live` read, and restored after
|
|
152
|
+
* (#4281). The component plan and apply paths (`components pr-plan`,
|
|
153
|
+
* `pr-apply`, `pr-record`, `fan-out`, `promote`, `rollback`, `redeploy` and
|
|
154
|
+
* `chant run --components`) run capabilities that honor the same ambient vars:
|
|
155
|
+
* azure's `arm-deploy` reads `AZURE_ENDPOINT_URL`, gcp's `gcp-apply`
|
|
156
|
+
* `GCP_ENDPOINT_URL`, aws's `cfn-deploy` `AWS_ENDPOINT_URL`. Without this, a
|
|
157
|
+
* plan for an environment declared against an emulator went to the real cloud
|
|
158
|
+
* unless the shell exported the var by hand.
|
|
159
|
+
*
|
|
160
|
+
* `lexicons` is called only when the environment declares an endpoint, so a
|
|
161
|
+
* caller that has to load its plugins pays for that only then.
|
|
162
|
+
*/
|
|
163
|
+
export async function withEnvironmentEndpoint<T>(
|
|
164
|
+
environments: EnvironmentDeclaration[] | undefined,
|
|
165
|
+
environment: string,
|
|
166
|
+
lexicons: () => Promise<readonly EndpointLexicon[]> | readonly EndpointLexicon[],
|
|
167
|
+
run: () => Promise<T>,
|
|
168
|
+
options: { warn?: (message: string) => void; env?: NodeJS.ProcessEnv } = {},
|
|
169
|
+
): Promise<T> {
|
|
170
|
+
if (!environmentEndpoint(environments, environment)) return run();
|
|
171
|
+
const applied = applyLiveEndpoint(environments, environment, await lexicons(), options.env);
|
|
172
|
+
if (applied.notice) options.warn?.(applied.notice);
|
|
173
|
+
try {
|
|
174
|
+
return await run();
|
|
175
|
+
} finally {
|
|
176
|
+
applied.restore();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -37,6 +37,8 @@ interface Run {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
/** The fake GitHub: runs per commit, and what dispatching the suites at a commit concludes. */
|
|
40
|
+
const RUN_BASE = 100_000;
|
|
41
|
+
|
|
40
42
|
class FakeGithub {
|
|
41
43
|
runs = new Map<string, Run[]>();
|
|
42
44
|
dispatched: string[] = [];
|
|
@@ -74,9 +76,12 @@ class FakeGithub {
|
|
|
74
76
|
const runs = this.runs.get(m[1]) ?? [];
|
|
75
77
|
return send(200, { total_count: runs.length, check_runs: runs.map((r) => ({ ...r, completed_at: null, html_url: null })) });
|
|
76
78
|
}
|
|
77
|
-
|
|
79
|
+
// Each job is its own workflow run here, numbered RUN_BASE above the job's id.
|
|
80
|
+
m = /^\/repos\/acme\/app\/actions\/jobs\/(\d+)$/.exec(url.pathname);
|
|
81
|
+
if (req.method === "GET" && m) return send(200, { id: Number(m[1]), run_id: RUN_BASE + Number(m[1]) });
|
|
82
|
+
m = /^\/repos\/acme\/app\/actions\/runs\/(\d+)\/rerun-failed-jobs$/.exec(url.pathname);
|
|
78
83
|
if (req.method === "POST" && m) {
|
|
79
|
-
const id = Number(m[1]);
|
|
84
|
+
const id = Number(m[1]) - RUN_BASE;
|
|
80
85
|
this.reruns.push(id);
|
|
81
86
|
// The job runs again and fails the same way.
|
|
82
87
|
for (const [sha, runs] of this.runs) {
|