@intentius/chant 0.124.39 → 0.124.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/dist/cli/component-endpoint.d.ts +20 -0
  2. package/dist/cli/component-endpoint.d.ts.map +1 -0
  3. package/dist/cli/component-endpoint.js +36 -0
  4. package/dist/cli/handlers/fan-out.d.ts.map +1 -1
  5. package/dist/cli/handlers/fan-out.js +10 -1
  6. package/dist/cli/handlers/pr.d.ts.map +1 -1
  7. package/dist/cli/handlers/pr.js +11 -5
  8. package/dist/cli/handlers/promote.d.ts.map +1 -1
  9. package/dist/cli/handlers/promote.js +7 -2
  10. package/dist/cli/handlers/run.d.ts +4 -1
  11. package/dist/cli/handlers/run.d.ts.map +1 -1
  12. package/dist/cli/handlers/run.js +17 -6
  13. package/dist/components/auto-release.d.ts +35 -3
  14. package/dist/components/auto-release.d.ts.map +1 -1
  15. package/dist/components/auto-release.js +29 -2
  16. package/dist/components/cli-support.d.ts +2 -0
  17. package/dist/components/cli-support.d.ts.map +1 -1
  18. package/dist/components/cli-support.js +2 -0
  19. package/dist/components/deploy-units.d.ts.map +1 -1
  20. package/dist/components/deploy-units.js +5 -0
  21. package/dist/components/promote.d.ts.map +1 -1
  22. package/dist/components/promote.js +39 -16
  23. package/dist/lifecycle/__fixtures__/publish-fakes.d.ts.map +1 -1
  24. package/dist/lifecycle/__fixtures__/publish-fakes.js +6 -1
  25. package/dist/lifecycle/publish-declaration.d.ts.map +1 -1
  26. package/dist/lifecycle/publish-declaration.js +4 -3
  27. package/dist/lifecycle/publish-npm.d.ts +2 -2
  28. package/dist/lifecycle/publish-npm.d.ts.map +1 -1
  29. package/dist/lifecycle/publish-npm.js +31 -8
  30. package/dist/lifecycle/release-ledger.d.ts +31 -0
  31. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  32. package/dist/lifecycle/release-ledger.js +19 -1
  33. package/dist/lifecycle/status.d.ts +5 -1
  34. package/dist/lifecycle/status.d.ts.map +1 -1
  35. package/dist/lifecycle/status.js +10 -5
  36. package/dist/live-endpoint.d.ts +18 -0
  37. package/dist/live-endpoint.d.ts.map +1 -1
  38. package/dist/live-endpoint.js +27 -0
  39. package/dist/wave-runtime.mjs +192 -192
  40. package/dist/workspace/ci-run.d.ts +7 -0
  41. package/dist/workspace/ci-run.d.ts.map +1 -1
  42. package/dist/workspace/ci-run.js +21 -3
  43. package/dist/workspace/status.d.ts +3 -1
  44. package/dist/workspace/status.d.ts.map +1 -1
  45. package/dist/workspace/status.js +3 -2
  46. package/dist/workspace/status.schema.json +1 -0
  47. package/package.json +1 -1
  48. package/src/cli/component-endpoint.ts +43 -0
  49. package/src/cli/handlers/fan-out.test.ts +10 -0
  50. package/src/cli/handlers/fan-out.ts +12 -0
  51. package/src/cli/handlers/pr-endpoint.test.ts +169 -0
  52. package/src/cli/handlers/pr.ts +12 -5
  53. package/src/cli/handlers/promote.ts +16 -2
  54. package/src/cli/handlers/run.test.ts +2 -1
  55. package/src/cli/handlers/run.ts +16 -5
  56. package/src/components/auto-release-infra.test.ts +131 -0
  57. package/src/components/auto-release.test.ts +40 -1
  58. package/src/components/auto-release.ts +50 -4
  59. package/src/components/cli-support.ts +4 -0
  60. package/src/components/deploy-units.ts +5 -0
  61. package/src/components/promote.test.ts +39 -0
  62. package/src/components/promote.ts +38 -14
  63. package/src/lifecycle/__fixtures__/publish-fakes.ts +6 -1
  64. package/src/lifecycle/publish-declaration.ts +4 -3
  65. package/src/lifecycle/publish-npm.ts +33 -8
  66. package/src/lifecycle/publish-targets.test.ts +36 -3
  67. package/src/lifecycle/release-ledger.ts +42 -1
  68. package/src/lifecycle/status.test.ts +19 -0
  69. package/src/lifecycle/status.ts +14 -6
  70. package/src/live-endpoint.ts +31 -0
  71. package/src/workspace/ci-run.test.ts +26 -0
  72. package/src/workspace/ci-run.ts +20 -3
  73. package/src/workspace/status.schema.json +1 -0
  74. package/src/workspace/status.ts +7 -3
@@ -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
+ }
@@ -24,6 +24,7 @@ import type { StepRecord } from "../op/local-executor";
24
24
  import { sshFromEd25519 } from "./trust/dsse";
25
25
  import { verifyOpRunStatement } from "./trust/op-run-statement";
26
26
  import { readRunLedger } from "../lifecycle/run-ledger";
27
+ import { resolveRunId } from "../lifecycle/release-ledger";
27
28
  import { prLoopJobs, PR_LOOP_SETUP } from "../components/pr-pipeline";
28
29
  import { parseArgs } from "../cli/parse-args";
29
30
  import { ciSecretsFor, readCiSecrets } from "./ci-secrets";
@@ -837,6 +838,31 @@ describe("a generated forge job runs ci run --in-place (#4156)", () => {
837
838
  expect(index.runs[0]).toMatchObject({ job: "plan", runId: "forge-plan-1", signer: null, runOrigin: { forge: "github", jobId: "plan", attempt: 2 } });
838
839
  });
839
840
 
841
+ test("a release record written in a runner job names the run the run index lists, and a forge job's never does (#4275)", async () => {
842
+ const { app, base } = workspace();
843
+ const reports = scratchDir("chant-ci-run-index-");
844
+ const output = join(scratchDir("chant-ci-run-out-"), "pr");
845
+ // The host's forge run id, even passed on by name, does not reach a runner job's steps.
846
+ const r = await ciRun({ image: JOB_IMAGE, cwd: app, job: "plan", env: "prod", forge: "forgejo", base, pr: 7, chant: fakeChant(), hostEnv: { ...HOST, ...FORGE }, passEnv: ["GITHUB_RUN_ID", "GITHUB_JOB"], push: false, output, loop: { reportUrl: `file://${reports}` } });
847
+ expect(r.status).toBe("ok");
848
+ const step = envFile(output, "pr-plan");
849
+ expect(step).not.toHaveProperty("GITHUB_RUN_ID");
850
+ const index = JSON.parse(readFileSync(join(reports, "runs", "index.json"), "utf-8")) as { runs: { runId: string; runOrigin: unknown }[] };
851
+ expect(index.runs[0]!.runId).toMatch(/^chant-\d+$/);
852
+ expect(resolveRunId(undefined, step)).toEqual({ runId: index.runs[0]!.runId, runOrigin: index.runs[0]!.runOrigin });
853
+ expect(index.runs[0]!.runOrigin).toEqual({ forge: "chant" });
854
+
855
+ // In place in a forge job, the runner's variables from the host are dropped, and the record names the forge's run.
856
+ const forge = await ciRun({ cwd: app, job: "plan", env: "prod", forge: "forgejo", base, pr: 7, chant: fakeChant(), hostEnv: { ...HOST, ...FORGE, CHANT_CI_RUN_ID: "chant-1", CHANT_CI_RUN_FORGE: "chant" }, inPlace: true, push: false, runId: "forge-plan-2" });
857
+ expect(forge.status).toBe("ok");
858
+ const inPlace = envFile(join(app, ".chant/pr"), "pr-plan");
859
+ expect(inPlace).not.toHaveProperty("CHANT_CI_RUN_ID");
860
+ expect(resolveRunId(undefined, inPlace).runOrigin).toMatchObject({ forge: "github", repo: "acme/estate" });
861
+ // And a forge's run id wins over the runner's variables wherever both appear.
862
+ expect(resolveRunId(undefined, { GITHUB_RUN_ID: "4242", CHANT_CI_RUN_ID: "chant-1", CHANT_CI_RUN_FORGE: "chant" }).runId).toBe("4242");
863
+ expect(resolveRunId(undefined, { CHANT_CI_RUN_ID: "chant-1" }).runOrigin).toEqual({ forge: "local" });
864
+ });
865
+
840
866
  test("a failed command's exit code is the job's, and --wave reaches pr-apply", async () => {
841
867
  const { app, base } = workspace();
842
868
  const r = await ciRun({ cwd: app, job: "apply", env: "prod", forge: "github", base, chant: fakeChant(), hostEnv: { ...HOST, ...FORGE, FAKE_EXIT: "3" }, inPlace: true, push: false, loop: { waveGate: "release" }, wave: 2 });
@@ -79,7 +79,7 @@ import { detectForge, ForgeEnvironmentError, forgeFromEnv, FORGE_KINDS, type Com
79
79
  import { PR_APPLY_GATE } from "../pr-loop";
80
80
  import type { CommandContext } from "../cli/registry";
81
81
  import { formatError, formatWarning } from "../cli/format";
82
- import { resolveCiJob, type RunOrigin } from "../lifecycle/release-ledger";
82
+ import { CHANT_CI_RUN_FORGE_ENV, CHANT_CI_RUN_ID_ENV, FORGE_RUN_ENV, resolveCiJob, type RunOrigin } from "../lifecycle/release-ledger";
83
83
  import { fetchLifecycleStatus, pushLifecycleReport } from "../lifecycle/git";
84
84
  import { ACTIVITY_PROFILES } from "../op/activity-profiles";
85
85
  import type { ActivityFn, ActivityProfile } from "../op/activity-registry";
@@ -761,6 +761,23 @@ function ciRunSigner(req: CiRunRequest, at: { cwd: string; top: string; commit:
761
761
  }
762
762
  }
763
763
 
764
+ /**
765
+ * The steps' environment (#4275). A job the chant runner ran gets its run's id
766
+ * and origin, so a release record written inside it names the run the run
767
+ * index lists, and none of the forge's run variables. Any other job, in place
768
+ * in a forge job or on this machine, never carries the runner's.
769
+ */
770
+ export function ciRunStepEnv(jobEnv: Record<string, string>, origin: RunOrigin, runId: string): Record<string, string> {
771
+ const out = { ...jobEnv };
772
+ delete out[CHANT_CI_RUN_ID_ENV];
773
+ delete out[CHANT_CI_RUN_FORGE_ENV];
774
+ if (origin.forge !== "chant") return out;
775
+ for (const name of FORGE_RUN_ENV) delete out[name];
776
+ out[CHANT_CI_RUN_ID_ENV] = runId;
777
+ out[CHANT_CI_RUN_FORGE_ENV] = "chant";
778
+ return out;
779
+ }
780
+
764
781
  /** Where the job runs: a fresh worktree of the commit, or with `inPlace` the checkout itself, which is left as it is. */
765
782
  function jobCheckout(top: string, commit: string, inPlace?: boolean): { dir: string; remove(): void } {
766
783
  return inPlace ? { dir: top, remove() {} } : freshCheckout(top, commit);
@@ -788,16 +805,16 @@ async function runCiJob(
788
805
  const checkout = jobCheckout(top, commit, req.inPlace);
789
806
  try {
790
807
  const jobDir = place.dir === "." ? checkout.dir : join(checkout.dir, place.dir);
808
+ const runId = req.runId ?? `chant-${Date.now()}`;
791
809
  const op = ciJobOp({
792
810
  job,
793
811
  env: req.env,
794
812
  cwd: jobDir,
795
- jobEnv,
813
+ jobEnv: ciRunStepEnv(jobEnv, origin, runId),
796
814
  setup: req.inPlace ? [] : prLoopSetup(req.image ?? PR_LOOP_IMAGE),
797
815
  build: [...(req.beforeScript ?? []), ...(job.build ?? [])],
798
816
  ...(req.chant ? { chant: req.chant } : {}),
799
817
  });
800
- const runId = req.runId ?? `chant-${Date.now()}`;
801
818
  const warnings: string[] = [...early];
802
819
  const resume = req.job === "apply" && req.resumeRecord ? { host: resolve(req.resumeRecord), job: join(jobDir, PR_APPLY_RECORD) } : undefined;
803
820
  if (resume && existsSync(resume.host)) {
@@ -1149,6 +1149,7 @@
1149
1149
  "releaseDigest": { "type": ["string", "null"], "pattern": "^(?:jcs1-)?sha256:[0-9a-f]{64}$", "description": "Added in contract 1 by #4043, written by chant 0.124.29 and newer. The release this deploy was made from: the releaseDigest of _releases/<releaseDigest>.json. A promote with --release writes it, a run --components with --release writes it, and a rollback or redeploy copies it from the record it restores. Null for a deploy that named no release. With the item's environment it names the release target." },
1150
1150
  "releaseLabel": { "type": ["string", "null"], "description": "Added in contract 1 by #4043, written by chant 0.124.29 and newer. The label that release was cut with, from _releases/cuts.jsonl. Null when releaseDigest is null, when the release has no label, and when the cuts can't be read." },
1151
1151
  "flags": { "type": "array", "items": { "enum": ["legacy-digest"] } },
1152
+ "digestKind": { "enum": ["plan", "deploy-input", "templates"], "description": "Added in contract 1 by #4274, written by chant 0.124.35 and newer. What digest names when it is not an artifact digest: plan, the plan digest a pr-apply of a component that published nothing applied under its approval; deploy-input, that component's deploy-input digest when nothing planned; templates, the digest of the templates a release records for the component (#4222). Absent for an artifact digest. Compare joins a plan or deploy-input record on gitSha." },
1152
1153
  "plan": {
1153
1154
  "type": ["object", "null"],
1154
1155
  "description": "The release plan digest names, read from _plans/<digest>.json on chant/lifecycle (ws-055, #2733): the work items and evidence this release shipped. Null when this checkout has no plan stored under that digest — most releases carry no plan, and a plan not yet fetched also reads null."
@@ -38,7 +38,7 @@ import { formatError } from "../cli/format";
38
38
  import type { CommandContext } from "../cli/registry";
39
39
  import { GATES_DIR } from "../lifecycle/gate-ledger";
40
40
  import { readPathSha } from "../lifecycle/git";
41
- import { latestPerComponent, readReleaseLedger, type ReleaseRecord } from "../lifecycle/release-ledger";
41
+ import { latestPerComponent, readReleaseLedger, type ReleaseDigestKind, type ReleaseRecord } from "../lifecycle/release-ledger";
42
42
  import { describeRevocation, revokedCommitsAt, revokedReleaseLookup, type ReleaseRevocation, type RevokedRelease } from "../lifecycle/release-revocation";
43
43
  import { readReleasePlan, type ReleasePlan } from "../lifecycle/plan-ledger";
44
44
  import { readDeployOutcomes, type DeployOutcome } from "../lifecycle/deploy-outcome";
@@ -138,6 +138,8 @@ export interface StatusRelease {
138
138
  releaseLabel: string | null;
139
139
  /** Read flags, such as `legacy-digest` (#2514). */
140
140
  flags: string[];
141
+ /** What `digest` names when it is not an artifact digest (#4274): `plan`, `deploy-input` or `templates`. Absent for an artifact digest. */
142
+ digestKind?: ReleaseDigestKind;
141
143
  /**
142
144
  * The release plan `digest` names, read from `_plans/<digest>.json` on
143
145
  * chant/lifecycle (ws-055, #2733) — the work items and evidence this
@@ -611,6 +613,7 @@ async function readEnvironment(
611
613
  releaseDigest: r.releaseDigest ?? null,
612
614
  releaseLabel: (r.releaseDigest !== undefined ? labels.get(r.releaseDigest) : undefined) ?? null,
613
615
  flags: [...(r.flags ?? [])],
616
+ ...(r.digestKind ? { digestKind: r.digestKind } : {}),
614
617
  plan: await readReleasePlan(r.digest, { cwd, prefix: planPrefix }),
615
618
  ...(await revokedItem(r, env, revokedOf, back)),
616
619
  })),
@@ -665,8 +668,9 @@ async function supersedeRevokedGates(gates: StatusGate[], revokedOf: RevokedOf):
665
668
  );
666
669
  }
667
670
 
668
- /** The digest two environments are compared on: the input side when there is one. */
669
- const compareKey = (r: StatusRelease): string => r.inputDigest ?? r.digest;
671
+ /** The digest two environments are compared on: the input side when there is one, the commit for an apply (#4274). */
672
+ const compareKey = (r: StatusRelease): string =>
673
+ r.digestKind === "plan" || r.digestKind === "deploy-input" ? `git:${r.gitSha}` : r.inputDigest ?? r.digest;
670
674
 
671
675
  export function compareEnvironments(a: StatusEnvironment, b: StatusEnvironment): StatusCompare {
672
676
  const left = new Map(a.releases.map((r) => [r.component, r]));