@intentius/chant 0.124.38 → 0.124.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/dist/cli/component-endpoint.d.ts +20 -0
  2. package/dist/cli/component-endpoint.d.ts.map +1 -0
  3. package/dist/cli/component-endpoint.js +36 -0
  4. package/dist/cli/handlers/fan-out.d.ts.map +1 -1
  5. package/dist/cli/handlers/fan-out.js +10 -1
  6. package/dist/cli/handlers/pr.d.ts.map +1 -1
  7. package/dist/cli/handlers/pr.js +11 -5
  8. package/dist/cli/handlers/promote.d.ts.map +1 -1
  9. package/dist/cli/handlers/promote.js +7 -2
  10. package/dist/cli/handlers/run.d.ts +4 -1
  11. package/dist/cli/handlers/run.d.ts.map +1 -1
  12. package/dist/cli/handlers/run.js +17 -6
  13. package/dist/components/auto-release.d.ts +35 -3
  14. package/dist/components/auto-release.d.ts.map +1 -1
  15. package/dist/components/auto-release.js +29 -2
  16. package/dist/components/cli-support.d.ts +2 -0
  17. package/dist/components/cli-support.d.ts.map +1 -1
  18. package/dist/components/cli-support.js +2 -0
  19. package/dist/components/deploy-units.d.ts.map +1 -1
  20. package/dist/components/deploy-units.js +5 -0
  21. package/dist/components/promote.d.ts.map +1 -1
  22. package/dist/components/promote.js +39 -16
  23. package/dist/lifecycle/__fixtures__/publish-fakes.d.ts.map +1 -1
  24. package/dist/lifecycle/__fixtures__/publish-fakes.js +6 -1
  25. package/dist/lifecycle/publish-declaration.d.ts.map +1 -1
  26. package/dist/lifecycle/publish-declaration.js +4 -3
  27. package/dist/lifecycle/publish-npm.d.ts +2 -2
  28. package/dist/lifecycle/publish-npm.d.ts.map +1 -1
  29. package/dist/lifecycle/publish-npm.js +31 -8
  30. package/dist/lifecycle/release-ledger.d.ts +31 -0
  31. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  32. package/dist/lifecycle/release-ledger.js +19 -1
  33. package/dist/lifecycle/status.d.ts +5 -1
  34. package/dist/lifecycle/status.d.ts.map +1 -1
  35. package/dist/lifecycle/status.js +10 -5
  36. package/dist/live-endpoint.d.ts +18 -0
  37. package/dist/live-endpoint.d.ts.map +1 -1
  38. package/dist/live-endpoint.js +27 -0
  39. package/dist/wave-runtime.mjs +201 -201
  40. package/dist/workspace/ci-builds.d.ts +12 -1
  41. package/dist/workspace/ci-builds.d.ts.map +1 -1
  42. package/dist/workspace/ci-builds.js +26 -0
  43. package/dist/workspace/ci-green-cli.js +1 -1
  44. package/dist/workspace/ci-green-forge.d.ts +3 -1
  45. package/dist/workspace/ci-green-forge.d.ts.map +1 -1
  46. package/dist/workspace/ci-green-forge.js +20 -2
  47. package/dist/workspace/ci-green-workflow.d.ts +6 -4
  48. package/dist/workspace/ci-green-workflow.d.ts.map +1 -1
  49. package/dist/workspace/ci-green-workflow.js +26 -8
  50. package/dist/workspace/ci-run.d.ts +7 -0
  51. package/dist/workspace/ci-run.d.ts.map +1 -1
  52. package/dist/workspace/ci-run.js +21 -3
  53. package/dist/workspace/status.d.ts +3 -1
  54. package/dist/workspace/status.d.ts.map +1 -1
  55. package/dist/workspace/status.js +3 -2
  56. package/dist/workspace/status.schema.json +1 -0
  57. package/package.json +1 -1
  58. package/src/cli/component-endpoint.ts +43 -0
  59. package/src/cli/handlers/fan-out.test.ts +10 -0
  60. package/src/cli/handlers/fan-out.ts +12 -0
  61. package/src/cli/handlers/pr-endpoint.test.ts +169 -0
  62. package/src/cli/handlers/pr.ts +12 -5
  63. package/src/cli/handlers/promote.ts +16 -2
  64. package/src/cli/handlers/run.test.ts +2 -1
  65. package/src/cli/handlers/run.ts +16 -5
  66. package/src/components/auto-release-infra.test.ts +131 -0
  67. package/src/components/auto-release.test.ts +40 -1
  68. package/src/components/auto-release.ts +50 -4
  69. package/src/components/cli-support.ts +4 -0
  70. package/src/components/deploy-units.ts +5 -0
  71. package/src/components/promote.test.ts +39 -0
  72. package/src/components/promote.ts +38 -14
  73. package/src/lifecycle/__fixtures__/publish-fakes.ts +6 -1
  74. package/src/lifecycle/publish-declaration.ts +4 -3
  75. package/src/lifecycle/publish-npm.ts +33 -8
  76. package/src/lifecycle/publish-targets.test.ts +36 -3
  77. package/src/lifecycle/release-ledger.ts +42 -1
  78. package/src/lifecycle/status.test.ts +19 -0
  79. package/src/lifecycle/status.ts +14 -6
  80. package/src/live-endpoint.ts +31 -0
  81. package/src/workspace/ci-builds.e2e.test.ts +7 -2
  82. package/src/workspace/ci-builds.test.ts +46 -1
  83. package/src/workspace/ci-builds.ts +25 -1
  84. package/src/workspace/ci-green-builds.test.ts +26 -0
  85. package/src/workspace/ci-green-cli.ts +1 -1
  86. package/src/workspace/ci-green-forge.test.ts +31 -0
  87. package/src/workspace/ci-green-forge.ts +21 -3
  88. package/src/workspace/ci-green-workflow.test.ts +4 -4
  89. package/src/workspace/ci-green-workflow.ts +22 -8
  90. package/src/workspace/ci-run.test.ts +26 -0
  91. package/src/workspace/ci-run.ts +20 -3
  92. package/src/workspace/status.schema.json +1 -0
  93. package/src/workspace/status.ts +7 -3
@@ -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
  })
@@ -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 wait = (decl.registryWait ?? NPM_REGISTRY_WAIT_SECONDS) * 1000;
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 onRegistry(ctx, pkg))) still.push(pkg);
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
- if (now() - start >= wait) {
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(wait / 1000)}s of the publish, so nothing was audited`,
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
- ctx.log(`waiting for the registry to serve ${names}`);
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
- function lagged(env: Record<string, string>, registryWait?: number) {
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("a version never served: the wait ends with an error naming it, before any install", async () => {
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");
@@ -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
- if (id.digest !== undefined) {
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.inputDigest ?? latestA.digest : undefined;
723
- const idB = latestB ? latestB.inputDigest ?? latestB.digest : undefined;
724
- const comparedOnInputs = (latestA?.inputDigest ?? latestB?.inputDigest) !== undefined;
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,
@@ -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
- m = /^\/repos\/acme\/app\/actions\/jobs\/(\d+)\/rerun$/.exec(url.pathname);
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) {