@intentius/chant 0.124.39 → 0.124.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/component-endpoint.d.ts +20 -0
- package/dist/cli/component-endpoint.d.ts.map +1 -0
- package/dist/cli/component-endpoint.js +36 -0
- package/dist/cli/handlers/fan-out.d.ts.map +1 -1
- package/dist/cli/handlers/fan-out.js +10 -1
- package/dist/cli/handlers/pr.d.ts.map +1 -1
- package/dist/cli/handlers/pr.js +11 -5
- package/dist/cli/handlers/promote.d.ts.map +1 -1
- package/dist/cli/handlers/promote.js +7 -2
- package/dist/cli/handlers/run.d.ts +4 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/handlers/run.js +17 -6
- package/dist/components/auto-release.d.ts +35 -3
- package/dist/components/auto-release.d.ts.map +1 -1
- package/dist/components/auto-release.js +29 -2
- package/dist/components/cli-support.d.ts +2 -0
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/cli-support.js +2 -0
- package/dist/components/deploy-units.d.ts.map +1 -1
- package/dist/components/deploy-units.js +5 -0
- package/dist/components/promote.d.ts.map +1 -1
- package/dist/components/promote.js +39 -16
- package/dist/lifecycle/__fixtures__/publish-fakes.d.ts.map +1 -1
- package/dist/lifecycle/__fixtures__/publish-fakes.js +6 -1
- package/dist/lifecycle/publish-declaration.d.ts.map +1 -1
- package/dist/lifecycle/publish-declaration.js +4 -3
- package/dist/lifecycle/publish-npm.d.ts +2 -2
- package/dist/lifecycle/publish-npm.d.ts.map +1 -1
- package/dist/lifecycle/publish-npm.js +31 -8
- package/dist/lifecycle/release-ledger.d.ts +31 -0
- package/dist/lifecycle/release-ledger.d.ts.map +1 -1
- package/dist/lifecycle/release-ledger.js +19 -1
- package/dist/lifecycle/status.d.ts +5 -1
- package/dist/lifecycle/status.d.ts.map +1 -1
- package/dist/lifecycle/status.js +10 -5
- package/dist/live-endpoint.d.ts +18 -0
- package/dist/live-endpoint.d.ts.map +1 -1
- package/dist/live-endpoint.js +27 -0
- package/dist/wave-runtime.mjs +192 -192
- package/dist/workspace/ci-run.d.ts +7 -0
- package/dist/workspace/ci-run.d.ts.map +1 -1
- package/dist/workspace/ci-run.js +21 -3
- package/dist/workspace/status.d.ts +3 -1
- package/dist/workspace/status.d.ts.map +1 -1
- package/dist/workspace/status.js +3 -2
- package/dist/workspace/status.schema.json +1 -0
- package/package.json +1 -1
- package/src/cli/component-endpoint.ts +43 -0
- package/src/cli/handlers/fan-out.test.ts +10 -0
- package/src/cli/handlers/fan-out.ts +12 -0
- package/src/cli/handlers/pr-endpoint.test.ts +169 -0
- package/src/cli/handlers/pr.ts +12 -5
- package/src/cli/handlers/promote.ts +16 -2
- package/src/cli/handlers/run.test.ts +2 -1
- package/src/cli/handlers/run.ts +16 -5
- package/src/components/auto-release-infra.test.ts +131 -0
- package/src/components/auto-release.test.ts +40 -1
- package/src/components/auto-release.ts +50 -4
- package/src/components/cli-support.ts +4 -0
- package/src/components/deploy-units.ts +5 -0
- package/src/components/promote.test.ts +39 -0
- package/src/components/promote.ts +38 -14
- package/src/lifecycle/__fixtures__/publish-fakes.ts +6 -1
- package/src/lifecycle/publish-declaration.ts +4 -3
- package/src/lifecycle/publish-npm.ts +33 -8
- package/src/lifecycle/publish-targets.test.ts +36 -3
- package/src/lifecycle/release-ledger.ts +42 -1
- package/src/lifecycle/status.test.ts +19 -0
- package/src/lifecycle/status.ts +14 -6
- package/src/live-endpoint.ts +31 -0
- package/src/workspace/ci-run.test.ts +26 -0
- package/src/workspace/ci-run.ts +20 -3
- package/src/workspace/status.schema.json +1 -0
- package/src/workspace/status.ts +7 -3
|
@@ -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
|
+
}
|
|
@@ -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 });
|
package/src/workspace/ci-run.ts
CHANGED
|
@@ -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."
|
package/src/workspace/status.ts
CHANGED
|
@@ -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 =>
|
|
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]));
|