@intentius/chant 0.124.34 → 0.124.35
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/handlers/graph.js +2 -2
- package/dist/cli/handlers/pr.d.ts.map +1 -1
- package/dist/cli/handlers/pr.js +147 -51
- package/dist/cli/handlers/promote.d.ts.map +1 -1
- package/dist/cli/handlers/promote.js +23 -8
- package/dist/cli/handlers/release.d.ts +12 -0
- package/dist/cli/handlers/release.d.ts.map +1 -1
- package/dist/cli/handlers/release.js +125 -10
- package/dist/cli/handlers/run.d.ts +3 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/handlers/run.js +12 -2
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +19 -1
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +17 -0
- package/dist/cli/registry.d.ts +14 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/auto-release.d.ts +8 -1
- package/dist/components/auto-release.d.ts.map +1 -1
- package/dist/components/auto-release.js +3 -2
- package/dist/components/green-cover.d.ts +77 -0
- package/dist/components/green-cover.d.ts.map +1 -0
- package/dist/components/green-cover.js +153 -0
- package/dist/components/pr-pipeline.d.ts +34 -0
- package/dist/components/pr-pipeline.d.ts.map +1 -1
- package/dist/components/pr-pipeline.js +39 -5
- package/dist/components/promote.d.ts +4 -1
- package/dist/components/promote.d.ts.map +1 -1
- package/dist/components/promote.js +6 -3
- package/dist/components/release-promote.d.ts +9 -0
- package/dist/components/release-promote.d.ts.map +1 -1
- package/dist/components/release-promote.js +15 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/deep-observation.d.ts +11 -0
- package/dist/deep-observation.d.ts.map +1 -1
- package/dist/graph-declared.d.ts +5 -1
- package/dist/graph-declared.d.ts.map +1 -1
- package/dist/graph-declared.js +7 -2
- package/dist/graph-detail.d.ts.map +1 -1
- package/dist/graph-detail.js +30 -1
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/graph-ir.js +13 -2
- package/dist/lifecycle/__fixtures__/publish-fakes.d.ts.map +1 -1
- package/dist/lifecycle/__fixtures__/publish-fakes.js +5 -1
- package/dist/lifecycle/compose-refs.d.ts +52 -0
- package/dist/lifecycle/compose-refs.d.ts.map +1 -0
- package/dist/lifecycle/compose-refs.js +115 -0
- package/dist/lifecycle/deep-observe.d.ts.map +1 -1
- package/dist/lifecycle/deep-observe.js +3 -2
- package/dist/lifecycle/green-apply.d.ts +39 -0
- package/dist/lifecycle/green-apply.d.ts.map +1 -0
- package/dist/lifecycle/green-apply.js +51 -0
- package/dist/lifecycle/publish-declaration.d.ts +4 -0
- package/dist/lifecycle/publish-declaration.d.ts.map +1 -1
- package/dist/lifecycle/publish-declaration.js +15 -0
- package/dist/lifecycle/publish-npm.d.ts +7 -4
- package/dist/lifecycle/publish-npm.d.ts.map +1 -1
- package/dist/lifecycle/publish-npm.js +60 -13
- package/dist/lifecycle/release-compose.d.ts +91 -0
- package/dist/lifecycle/release-compose.d.ts.map +1 -0
- package/dist/lifecycle/release-compose.js +235 -0
- package/dist/lifecycle/release-cut.d.ts +8 -0
- package/dist/lifecycle/release-cut.d.ts.map +1 -1
- package/dist/lifecycle/release-cut.js +2 -0
- package/dist/lifecycle/release-publish.d.ts +2 -0
- package/dist/lifecycle/release-publish.d.ts.map +1 -1
- package/dist/lifecycle/release-publish.js +2 -1
- package/dist/lifecycle/release-store.d.ts +15 -0
- package/dist/lifecycle/release-store.d.ts.map +1 -1
- package/dist/lifecycle/release-store.js +5 -1
- package/dist/wave-runtime.mjs +225 -223
- package/dist/workspace/checks/ci-workflow.d.ts.map +1 -1
- package/dist/workspace/checks/ci-workflow.js +8 -1
- package/dist/workspace/ci-green-cli.d.ts.map +1 -1
- package/dist/workspace/ci-green-cli.js +5 -3
- package/dist/workspace/ci-green-workflow.d.ts +18 -2
- package/dist/workspace/ci-green-workflow.d.ts.map +1 -1
- package/dist/workspace/ci-green-workflow.js +27 -5
- package/dist/workspace/ci-green.d.ts +31 -0
- package/dist/workspace/ci-green.d.ts.map +1 -1
- package/dist/workspace/ci-green.js +78 -4
- package/dist/workspace/ci-run.d.ts +6 -0
- package/dist/workspace/ci-run.d.ts.map +1 -1
- package/dist/workspace/ci-run.js +2 -1
- package/dist/workspace/ci-serve.d.ts +94 -13
- package/dist/workspace/ci-serve.d.ts.map +1 -1
- package/dist/workspace/ci-serve.js +248 -30
- package/dist/workspace/ci-workflow.d.ts +4 -4
- package/dist/workspace/ci-workflow.d.ts.map +1 -1
- package/dist/workspace/ci-workflow.js +2 -2
- package/dist/workspace/delivery-metrics.d.ts +3 -1
- package/dist/workspace/delivery-metrics.d.ts.map +1 -1
- package/dist/workspace/delivery-metrics.js +12 -3
- package/dist/workspace/member-commands.d.ts.map +1 -1
- package/dist/workspace/member-commands.js +4 -0
- package/dist/workspace/nested-graph.d.ts.map +1 -1
- package/dist/workspace/nested-graph.js +1 -0
- package/dist/workspace/release-candidates.d.ts +28 -0
- package/dist/workspace/release-candidates.d.ts.map +1 -1
- package/dist/workspace/release-candidates.js +38 -2
- package/dist/workspace/release-candidates.schema.json +51 -0
- package/dist/workspace/releases.schema.json +30 -0
- package/package.json +1 -1
- package/src/cli/handlers/graph.ts +2 -2
- package/src/cli/handlers/pr-green.test.ts +230 -0
- package/src/cli/handlers/pr.ts +148 -66
- package/src/cli/handlers/promote.test.ts +34 -0
- package/src/cli/handlers/promote.ts +22 -8
- package/src/cli/handlers/release-compose.e2e.test.ts +133 -0
- package/src/cli/handlers/release.ts +112 -10
- package/src/cli/handlers/run.ts +12 -1
- package/src/cli/main.ts +19 -1
- package/src/cli/parse-args.ts +13 -0
- package/src/cli/registry.ts +14 -0
- package/src/codegen/release-wiring.test.ts +56 -9
- package/src/components/auto-release.test.ts +22 -0
- package/src/components/auto-release.ts +9 -2
- package/src/components/green-cover.test.ts +170 -0
- package/src/components/green-cover.ts +186 -0
- package/src/components/pr-pipeline.test.ts +14 -4
- package/src/components/pr-pipeline.ts +56 -5
- package/src/components/promote.test.ts +7 -0
- package/src/components/promote.ts +9 -4
- package/src/components/release-promote.test.ts +24 -1
- package/src/components/release-promote.ts +15 -0
- package/src/deep-observation.ts +8 -0
- package/src/graph-declared.test.ts +18 -0
- package/src/graph-declared.ts +8 -1
- package/src/graph-detail.test.ts +68 -1
- package/src/graph-detail.ts +29 -1
- package/src/graph-ir.ts +12 -4
- package/src/lifecycle/__fixtures__/publish-fakes.ts +5 -1
- package/src/lifecycle/compose-refs.ts +125 -0
- package/src/lifecycle/deep-observe.test.ts +20 -0
- package/src/lifecycle/deep-observe.ts +4 -2
- package/src/lifecycle/green-apply.ts +71 -0
- package/src/lifecycle/publish-declaration.ts +15 -0
- package/src/lifecycle/publish-npm.ts +55 -13
- package/src/lifecycle/publish-targets.test.ts +45 -0
- package/src/lifecycle/release-compose.test.ts +125 -0
- package/src/lifecycle/release-compose.ts +312 -0
- package/src/lifecycle/release-cut.ts +6 -0
- package/src/lifecycle/release-publish.ts +4 -1
- package/src/lifecycle/release-store.ts +16 -1
- package/src/workspace/checks/ci-workflow.test.ts +12 -2
- package/src/workspace/checks/ci-workflow.ts +7 -1
- package/src/workspace/ci-green-cli.ts +5 -3
- package/src/workspace/ci-green-workflow.test.ts +52 -0
- package/src/workspace/ci-green-workflow.ts +34 -5
- package/src/workspace/ci-green.test.ts +33 -0
- package/src/workspace/ci-green.ts +85 -4
- package/src/workspace/ci-run.test.ts +18 -13
- package/src/workspace/ci-run.ts +8 -1
- package/src/workspace/ci-serve-land.e2e.test.ts +138 -0
- package/src/workspace/ci-serve-land.test.ts +176 -0
- package/src/workspace/ci-serve.test.ts +77 -43
- package/src/workspace/ci-serve.ts +268 -34
- package/src/workspace/ci-workflow.ts +4 -4
- package/src/workspace/delivery-metrics.ts +12 -3
- package/src/workspace/graph-contract.test.ts +3 -2
- package/src/workspace/member-commands.test.ts +11 -4
- package/src/workspace/member-commands.ts +3 -0
- package/src/workspace/nested-graph.test.ts +4 -0
- package/src/workspace/nested-graph.ts +1 -0
- package/src/workspace/release-candidates.schema.json +51 -0
- package/src/workspace/release-candidates.test.ts +30 -3
- package/src/workspace/release-candidates.ts +54 -2
- package/src/workspace/releases.schema.json +30 -0
|
@@ -196,10 +196,23 @@ export interface PrLoopJob {
|
|
|
196
196
|
env: Record<string, string>;
|
|
197
197
|
/** Shell lines to run before the command, to settle values the forge may leave unusable. */
|
|
198
198
|
setup?: string[];
|
|
199
|
+
/**
|
|
200
|
+
* Shell lines a generated job runs first, before `setup`, to check out the
|
|
201
|
+
* commit it works on (#4168 step 9): the apply checks out the newest green
|
|
202
|
+
* commit. `chant ci run` and `ci serve` already run the job at that commit,
|
|
203
|
+
* so they skip these.
|
|
204
|
+
*/
|
|
205
|
+
checkout?: string[];
|
|
199
206
|
/** With `build` (#4088): the build line and the lint line, run in this order before the command. */
|
|
200
207
|
build?: string[];
|
|
201
208
|
/** With `waveGate` (#4003): the wave a generated apply job runs, 1-based. */
|
|
202
209
|
wave?: number;
|
|
210
|
+
/**
|
|
211
|
+
* The branch an apply job takes its green commit from (#4168 step 9). A
|
|
212
|
+
* wave after the first checks out the commit wave 1 took, which the job
|
|
213
|
+
* receives as `GREEN_SHA` (#4221).
|
|
214
|
+
*/
|
|
215
|
+
greenBranch?: string;
|
|
203
216
|
/**
|
|
204
217
|
* What the generated job runs in place of `command` (#4156): `chant ci run
|
|
205
218
|
* <job> --in-place` with the same environment, gate, forge, events and
|
|
@@ -241,7 +254,7 @@ export function prLoopJobs(forge: ForgeKind, env: string, options: PrLoopPipelin
|
|
|
241
254
|
// #4156: the flags `chant ci run` rebuilds `command` from. It finds the member from the directory it runs in.
|
|
242
255
|
const ciRun = (job: PrLoopJob["jobName"]): string[] => [
|
|
243
256
|
"chant", "ci", "run", job, "--in-place", ...(job === "plan" ? ["--no-push"] : []),
|
|
244
|
-
"--base", '"$BASE_SHA"', ...(job === "plan" ? ["--pr", '"$PR_NUMBER"'] : []),
|
|
257
|
+
...(job === "apply" ? [] : ["--base", '"$BASE_SHA"']), ...(job === "plan" ? ["--pr", '"$PR_NUMBER"'] : []),
|
|
245
258
|
"--env", env, "--gate", gate, "--forge", forge,
|
|
246
259
|
...(job !== "record" && options.waveGate ? ["--wave-gate", options.waveGate] : []),
|
|
247
260
|
...(job !== "plan" && options.branch ? ["--branch", JSON.stringify(options.branch)] : []),
|
|
@@ -254,6 +267,7 @@ export function prLoopJobs(forge: ForgeKind, env: string, options: PrLoopPipelin
|
|
|
254
267
|
const planEnv = github
|
|
255
268
|
? { BASE_SHA: "${{ github.event.pull_request.base.sha }}", PR_NUMBER: "${{ github.event.pull_request.number }}", ...jobEnv }
|
|
256
269
|
: { BASE_SHA: "$CI_MERGE_REQUEST_DIFF_BASE_SHA", PR_NUMBER: "$CI_MERGE_REQUEST_IID", ...jobEnv };
|
|
270
|
+
const greenBranch = github ? JSON.stringify(options.branch ?? "main") : options.branch ? JSON.stringify(options.branch) : "$CI_DEFAULT_BRANCH";
|
|
257
271
|
const applyEnv = github ? { BASE_SHA: "${{ github.event.before }}", ...jobEnv } : { BASE_SHA: "$CI_COMMIT_BEFORE_SHA", ...jobEnv };
|
|
258
272
|
return [
|
|
259
273
|
{
|
|
@@ -265,17 +279,19 @@ export function prLoopJobs(forge: ForgeKind, env: string, options: PrLoopPipelin
|
|
|
265
279
|
run: { command: ciRun("plan").join(" "), env: runEnv(planEnv) },
|
|
266
280
|
},
|
|
267
281
|
{
|
|
282
|
+
// #4168 step 9: the apply applies the newest green commit, measured from the one applied before it.
|
|
268
283
|
jobName: "apply",
|
|
269
284
|
command: [
|
|
270
|
-
"chant", "components", "pr-apply", "--
|
|
285
|
+
"chant", "components", "pr-apply", "--green", ...common,
|
|
271
286
|
...(options.requireReview === true ? ["--require-review"] : []),
|
|
272
287
|
...(options.waveGate ? ["--wave-gate", options.waveGate] : []),
|
|
273
288
|
"--resume", PR_APPLY_RECORD,
|
|
274
289
|
].join(" "),
|
|
275
|
-
env:
|
|
276
|
-
|
|
290
|
+
env: jobEnv,
|
|
291
|
+
checkout: [greenCheckout(greenBranch)],
|
|
292
|
+
greenBranch,
|
|
277
293
|
...build,
|
|
278
|
-
run: { command: ciRun("apply").join(" "), env: runEnv(
|
|
294
|
+
run: { command: ciRun("apply").join(" "), env: runEnv(jobEnv) },
|
|
279
295
|
},
|
|
280
296
|
{
|
|
281
297
|
jobName: "record",
|
|
@@ -288,6 +304,15 @@ export function prLoopJobs(forge: ForgeKind, env: string, options: PrLoopPipelin
|
|
|
288
304
|
];
|
|
289
305
|
}
|
|
290
306
|
|
|
307
|
+
/**
|
|
308
|
+
* What starts the apply under apply on merge (#4168 step 9): on GitHub the
|
|
309
|
+
* tick's workflow finishing (`workflow_run`, since tags its token pushes start
|
|
310
|
+
* no workflow), elsewhere a pushed `ci/green/*` tag. The name is
|
|
311
|
+
* `CI_GREEN_WORKFLOW_NAME` (../workspace/ci-green-workflow.ts).
|
|
312
|
+
*/
|
|
313
|
+
export const GREEN_TICK_WORKFLOW = "chant-ci-green";
|
|
314
|
+
export const GREEN_TAG_PATTERN = "ci/green/**";
|
|
315
|
+
|
|
291
316
|
/** The job a push to the target branch runs (#4168): `record`, or `apply` when the pipeline opts into apply on merge. */
|
|
292
317
|
export function prLoopMergeJob(jobs: readonly PrLoopJob[], options: PrLoopPipelineOptions = {}): PrLoopJob {
|
|
293
318
|
return jobs.find((j) => j.jobName === (options.applyOnMerge ? "apply" : "record"))!;
|
|
@@ -326,6 +351,8 @@ export function prLoopWaveJobs(components: DriverComponent[], apply: PrLoopJob):
|
|
|
326
351
|
...apply,
|
|
327
352
|
command: `${apply.command} --wave ${i + 1}`,
|
|
328
353
|
wave: i + 1,
|
|
354
|
+
// #4221: every wave after the first applies the commit wave 1 took.
|
|
355
|
+
...(i > 0 && apply.greenBranch !== undefined ? { checkout: [pinnedGreenCheckout(apply.greenBranch)] } : {}),
|
|
329
356
|
// #4156: the job runs it through chant ci run, which appends the same --wave to pr-apply.
|
|
330
357
|
run: { ...apply.run, command: `${apply.run.command} --wave ${i + 1}` },
|
|
331
358
|
}));
|
|
@@ -381,6 +408,30 @@ function pushBaseFallback(target: string, commit: string, diffBase?: string): st
|
|
|
381
408
|
);
|
|
382
409
|
}
|
|
383
410
|
|
|
411
|
+
/**
|
|
412
|
+
* The line that checks out the newest green commit of `branch` (#4168 step
|
|
413
|
+
* 9), after fetching the branch and its tags, for the apply job. `chant ci
|
|
414
|
+
* last-green` exits 1 when no commit is green, which fails the job.
|
|
415
|
+
*/
|
|
416
|
+
export function greenCheckout(branch: string): string {
|
|
417
|
+
return `${fetchBranch(branch)} && GREEN_SHA="$(chant ci last-green)" && git checkout --quiet --detach "$GREEN_SHA"`;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* The line a wave after the first runs in place of {@link greenCheckout}
|
|
422
|
+
* (#4221): it checks out `GREEN_SHA`, the commit wave 1 took, so a commit
|
|
423
|
+
* that goes green while the waves run waits for a run of its own. An empty
|
|
424
|
+
* `GREEN_SHA` fails the job.
|
|
425
|
+
*/
|
|
426
|
+
export function pinnedGreenCheckout(branch: string): string {
|
|
427
|
+
return `test -n "$GREEN_SHA" && ${fetchBranch(branch)} && git checkout --quiet --detach "$GREEN_SHA"`;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function fetchBranch(branch: string): string {
|
|
431
|
+
const name = branch.replace(/^"|"$/g, "");
|
|
432
|
+
return `git fetch --quiet --tags origin "+refs/heads/${name}:refs/remotes/origin/${name}"`;
|
|
433
|
+
}
|
|
434
|
+
|
|
384
435
|
/**
|
|
385
436
|
* GitLab sets `CI_COMMIT_BEFORE_SHA` to forty zeros on a branch's first push.
|
|
386
437
|
* The line replaces it with the merge request's diff base when the pipeline
|
|
@@ -197,6 +197,13 @@ describe("taking the build out of a composition", () => {
|
|
|
197
197
|
expect("error" in prepared && prepared.error).toMatch(/no publish step/);
|
|
198
198
|
});
|
|
199
199
|
|
|
200
|
+
test("#4222: no publish step passes when a release records its templates, and says it is template-only", () => {
|
|
201
|
+
const c: DriverComponent = { name: "chart", deploy: [{ phase: "Apply", steps: [{ kind: "helm-upgrade" }] }] };
|
|
202
|
+
const prepared = withoutBuildSteps(c, { templatesPinned: true });
|
|
203
|
+
expect("error" in prepared).toBe(false);
|
|
204
|
+
expect("templateOnly" in prepared && prepared.templateOnly).toBe(true);
|
|
205
|
+
});
|
|
206
|
+
|
|
200
207
|
test("two publish steps are refused", () => {
|
|
201
208
|
const c = service("api");
|
|
202
209
|
c.deploy[1].steps.push({ kind: "publish-artifact", from: "archive:x.jar" });
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
*
|
|
24
24
|
* A composition the transform cannot pin to one recorded digest is refused
|
|
25
25
|
* before anything runs: one with no publish step (nothing would carry the
|
|
26
|
-
* digest), one with more than one (the ledger records one digest per
|
|
26
|
+
* digest) unless a release records its templates (#4222), one with more than one (the ledger records one digest per
|
|
27
27
|
* component), and one whose remaining steps read an output of a removed build
|
|
28
28
|
* step (it would resolve to nothing).
|
|
29
29
|
*
|
|
@@ -402,6 +402,8 @@ export interface PromotableComponent {
|
|
|
402
402
|
component: DriverComponent;
|
|
403
403
|
/** The kinds removed, in composition order. */
|
|
404
404
|
removed: string[];
|
|
405
|
+
/** #4222: the component has no publish step and deploys the templates a release records, so its record carries their digest. */
|
|
406
|
+
templateOnly?: boolean;
|
|
405
407
|
}
|
|
406
408
|
|
|
407
409
|
/**
|
|
@@ -433,7 +435,7 @@ export const recordedDigestCapability: Capability<{ digest: string }, { digest:
|
|
|
433
435
|
*/
|
|
434
436
|
export function withoutBuildSteps(
|
|
435
437
|
component: DriverComponent,
|
|
436
|
-
opts: { pinDigest?: string; verb?: string } = {},
|
|
438
|
+
opts: { pinDigest?: string; verb?: string; templatesPinned?: boolean } = {},
|
|
437
439
|
): PromotableComponent | { error: string } {
|
|
438
440
|
const verb = opts.verb ?? (opts.pinDigest === undefined ? "a promote" : "a rollback");
|
|
439
441
|
const removed: string[] = [];
|
|
@@ -487,7 +489,10 @@ export function withoutBuildSteps(
|
|
|
487
489
|
|
|
488
490
|
const deploy = component.deploy.map(strip).filter((p): p is DriverPhase => p !== undefined);
|
|
489
491
|
|
|
490
|
-
|
|
492
|
+
// #4222: under a release that records this component's templates, the
|
|
493
|
+
// deploy reads them from the release (./release-promote.ts), so a component
|
|
494
|
+
// with no artifact deploys the release's content, not its current source.
|
|
495
|
+
if (publishSteps === 0 && !opts.templatesPinned) {
|
|
491
496
|
return {
|
|
492
497
|
error: `component "${component.name}" has no publish step, so nothing in its deploy carries a recorded digest; ` +
|
|
493
498
|
`${verb} would deploy whatever its current source produces`,
|
|
@@ -519,7 +524,7 @@ export function withoutBuildSteps(
|
|
|
519
524
|
}
|
|
520
525
|
}
|
|
521
526
|
}
|
|
522
|
-
return { component: { ...component, deploy }, removed };
|
|
527
|
+
return { component: { ...component, deploy }, removed, ...(publishSteps === 0 ? { templateOnly: true } : {}) };
|
|
523
528
|
}
|
|
524
529
|
|
|
525
530
|
/** Every same-project wiring reference anywhere in a step's fields (cross-stack `stackOutput` objects excluded). */
|
|
@@ -7,7 +7,7 @@ import { describe, test, expect } from "vitest";
|
|
|
7
7
|
import { contentDigest } from "../content-digest";
|
|
8
8
|
import { makeRelease, templateBytesDigest } from "../lifecycle/release-store";
|
|
9
9
|
import type { DriverComponent } from "./driver";
|
|
10
|
-
import { isImplicitRelease, pinReleaseTemplates, releaseArtifactMismatches, removePinnedTemplates, targetFiles, targetFor, writePinnedTemplates, RELEASE_TEMPLATES_DIR } from "./release-promote";
|
|
10
|
+
import { isImplicitRelease, pinReleaseTemplates, releaseTemplatesDigest, releaseArtifactMismatches, removePinnedTemplates, targetFiles, targetFor, writePinnedTemplates, RELEASE_TEMPLATES_DIR } from "./release-promote";
|
|
11
11
|
import type { ReleaseRecord } from "../lifecycle/release-ledger";
|
|
12
12
|
import type { BuildArchiveManifest } from "./verbs/build-archive";
|
|
13
13
|
import { withTestDir } from "@intentius/chant-test-utils";
|
|
@@ -210,3 +210,26 @@ describe("removePinnedTemplates (#4045)", () => {
|
|
|
210
210
|
});
|
|
211
211
|
});
|
|
212
212
|
});
|
|
213
|
+
|
|
214
|
+
describe("releaseTemplatesDigest (#4222)", () => {
|
|
215
|
+
const target = {
|
|
216
|
+
env: "staging",
|
|
217
|
+
params: [],
|
|
218
|
+
templates: [
|
|
219
|
+
{ component: "infra", path: "b.json", digest: "sha256:b" },
|
|
220
|
+
{ component: "infra", path: "a.json", digest: "sha256:a" },
|
|
221
|
+
{ component: "app", path: "c.json", digest: "sha256:c" },
|
|
222
|
+
],
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
test("digests the component's templates sorted by path, whatever order the target lists them", () => {
|
|
226
|
+
const reordered = { ...target, templates: [target.templates[1], target.templates[2], target.templates[0]] };
|
|
227
|
+
expect(releaseTemplatesDigest(target, "infra")).toBe(contentDigest("a.json\tsha256:a\nb.json\tsha256:b\n"));
|
|
228
|
+
expect(releaseTemplatesDigest(reordered, "infra")).toBe(releaseTemplatesDigest(target, "infra"));
|
|
229
|
+
expect(releaseTemplatesDigest(target, "app")).not.toBe(releaseTemplatesDigest(target, "infra"));
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("is null for a component the target records no template for", () => {
|
|
233
|
+
expect(releaseTemplatesDigest(target, "worker")).toBeNull();
|
|
234
|
+
});
|
|
235
|
+
});
|
|
@@ -31,6 +31,7 @@ import { templateBytesDigest, type Release, type ReleaseTarget } from "../lifecy
|
|
|
31
31
|
import { computePlanDigest } from "../lifecycle/plan-digest";
|
|
32
32
|
import type { ReleaseRecord } from "../lifecycle/release-ledger";
|
|
33
33
|
import type { BuildArchiveManifest } from "./verbs/build-archive";
|
|
34
|
+
import { contentDigest } from "../content-digest";
|
|
34
35
|
|
|
35
36
|
/** Where a promote under a release writes the recorded templates, relative to the member's directory. */
|
|
36
37
|
export const RELEASE_TEMPLATES_DIR = ".chant/release-templates";
|
|
@@ -56,6 +57,20 @@ export function targetFor(release: Release, env: string): { target: ReleaseTarge
|
|
|
56
57
|
};
|
|
57
58
|
}
|
|
58
59
|
|
|
60
|
+
/**
|
|
61
|
+
* The digest a release record carries for a component with no artifact
|
|
62
|
+
* (#4222): `sha256:` over the paths and digests of the templates `target`
|
|
63
|
+
* records for `component`, sorted by path. A template-only component (a
|
|
64
|
+
* `cfn-deploy` with no publish step) deployed under a release is recorded with
|
|
65
|
+
* it, so promote and rollback under a release find what the environment runs.
|
|
66
|
+
* Null when the target records no template for the component.
|
|
67
|
+
*/
|
|
68
|
+
export function releaseTemplatesDigest(target: ReleaseTarget, component: string): string | null {
|
|
69
|
+
const own = target.templates.filter((t) => t.component === component).map((t) => `${t.path}\t${t.digest}`).sort();
|
|
70
|
+
if (own.length === 0) return null;
|
|
71
|
+
return contentDigest(`${own.join("\n")}\n`);
|
|
72
|
+
}
|
|
73
|
+
|
|
59
74
|
/** Whether a release is of a project with no components (#4046): no artifact, and every template it records has `component: null`. */
|
|
60
75
|
export function isImplicitRelease(release: Release): boolean {
|
|
61
76
|
const templates = release.targets.flatMap((t) => t.templates);
|
package/src/deep-observation.ts
CHANGED
|
@@ -334,6 +334,14 @@ export interface DeepNormalizationHooks {
|
|
|
334
334
|
* or a value-derived hash.
|
|
335
335
|
*/
|
|
336
336
|
mask?(node: DeepNode): boolean;
|
|
337
|
+
/**
|
|
338
|
+
* Rewrite one raw property tree before anything else reads it, on both
|
|
339
|
+
* sides. For a difference no per-node hook can reach, such as a map whose
|
|
340
|
+
* keys are references: an ARM `identity.userAssignedIdentities` is keyed by
|
|
341
|
+
* `[resourceId(...)]` in source and by the evaluated resource id live
|
|
342
|
+
* (#4231). Return the tree to compare; leave anything else as it was.
|
|
343
|
+
*/
|
|
344
|
+
prepare?(tree: Record<string, unknown>, context: { entityType: string; side: DeepSide }): Record<string, unknown>;
|
|
337
345
|
}
|
|
338
346
|
|
|
339
347
|
/** Everything the pass needs besides the tree itself. */
|
|
@@ -84,3 +84,21 @@ describe("buildDeclaredPerStack groups by declared stack (#1433)", () => {
|
|
|
84
84
|
}
|
|
85
85
|
});
|
|
86
86
|
});
|
|
87
|
+
|
|
88
|
+
describe("buildDeclaredPerStack takes edges from the lexicon hooks (#4232)", () => {
|
|
89
|
+
test("withEdges sees each stack's entities by bare key, and its edges are qualified with the stack", async () => {
|
|
90
|
+
const seen: string[][] = [];
|
|
91
|
+
const ir = await buildDeclaredPerStack(
|
|
92
|
+
[{ name: "network", src: "network/src" }, { name: "app", src: "app/src" }],
|
|
93
|
+
dir,
|
|
94
|
+
(g, entities) => {
|
|
95
|
+
seen.push([...entities.keys()].sort());
|
|
96
|
+
const [only] = g.nodes;
|
|
97
|
+
return only ? { ...g, edges: [...g.edges, { from: only.id, to: only.id, kind: "ref", viaAttr: "self" }] } : g;
|
|
98
|
+
},
|
|
99
|
+
);
|
|
100
|
+
expect(seen).toEqual([["vpc"], ["service"]]);
|
|
101
|
+
expect(ir.edges).toContainEqual({ from: "network::vpc", to: "network::vpc", kind: "ref", viaAttr: "self" });
|
|
102
|
+
expect(ir.edges).toContainEqual({ from: "app::service", to: "app::service", kind: "ref", viaAttr: "self" });
|
|
103
|
+
});
|
|
104
|
+
});
|
package/src/graph-declared.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import { discover } from "./discovery/index";
|
|
3
|
+
import type { Declarable } from "./declarable";
|
|
3
4
|
import { buildGraphIr, type GraphIR, type IRNode } from "./graph-ir";
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -19,10 +20,14 @@ import { buildGraphIr, type GraphIR, type IRNode } from "./graph-ir";
|
|
|
19
20
|
*
|
|
20
21
|
* A stack without `src` contributes nothing here — it has no declared source to
|
|
21
22
|
* scope to (its live nodes still show as foreign in the overlay).
|
|
23
|
+
*
|
|
24
|
+
* `withEdges` adds what the lexicons' `graphEdges` hooks resolve to each
|
|
25
|
+
* stack's graph before its ids are qualified (#4232).
|
|
22
26
|
*/
|
|
23
27
|
export async function buildDeclaredPerStack(
|
|
24
28
|
stacks: Array<{ name: string; src?: string }>,
|
|
25
29
|
projectPath: string,
|
|
30
|
+
withEdges?: (ir: GraphIR, entities: Map<string, Declarable>) => GraphIR,
|
|
26
31
|
): Promise<GraphIR> {
|
|
27
32
|
const nodes: IRNode[] = [];
|
|
28
33
|
const edges: GraphIR["edges"] = [];
|
|
@@ -36,7 +41,9 @@ export async function buildDeclaredPerStack(
|
|
|
36
41
|
const byStack: Record<string, string[]> = {};
|
|
37
42
|
for (const st of stacks) {
|
|
38
43
|
if (!st.src) continue;
|
|
39
|
-
const
|
|
44
|
+
const { entities } = await discover(resolve(projectPath, st.src));
|
|
45
|
+
const built = buildGraphIr(entities, projectPath);
|
|
46
|
+
const g = withEdges ? withEdges(built, entities) : built;
|
|
40
47
|
const q = (id: string) => `${st.name}::${id}`;
|
|
41
48
|
for (const n of g.nodes) {
|
|
42
49
|
const id = q(n.id);
|
package/src/graph-detail.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, test, expect } from "vitest";
|
|
2
2
|
import { applyDetail, DETAIL, detailInertNotice } from "./graph-detail";
|
|
3
|
-
import type
|
|
3
|
+
import { overlayGraphs, sourceOverlayGraphs, type GraphIR } from "./graph-ir";
|
|
4
4
|
|
|
5
5
|
// A small graph: a gcp vpc/subnet pair plus a k8s namespace and deployment that
|
|
6
6
|
// came from one composite instance ("db"), with the deployment referencing the
|
|
@@ -294,3 +294,70 @@ describe("detailInertNotice (#1489)", () => {
|
|
|
294
294
|
expect(detailInertNotice(edgeless, detailed)).toContain("no edges at all");
|
|
295
295
|
});
|
|
296
296
|
});
|
|
297
|
+
|
|
298
|
+
describe("T1 rolls a composite's overlay status up from its members (#4227)", () => {
|
|
299
|
+
// Two stacks' worth of one FargateAlb, keyed `${stack}::${logicalId}` as a
|
|
300
|
+
// multi-stack live read keys them (#1162).
|
|
301
|
+
const member = (id: string, attrs: Record<string, unknown> = {}) => ({
|
|
302
|
+
id: `three-cloud-aws::${id}`,
|
|
303
|
+
kind: "Resource",
|
|
304
|
+
lexicon: "aws",
|
|
305
|
+
compositeParent: "FargateAlb",
|
|
306
|
+
compositeInstance: "web",
|
|
307
|
+
attrs,
|
|
308
|
+
});
|
|
309
|
+
const declared: GraphIR = {
|
|
310
|
+
nodes: [member("webAlb"), member("webService"), member("webTaskDef")],
|
|
311
|
+
edges: [],
|
|
312
|
+
groups: { byLexicon: {} },
|
|
313
|
+
};
|
|
314
|
+
const live = (ids: string[], extra: GraphIR["nodes"] = []): GraphIR => ({
|
|
315
|
+
nodes: [...ids.map((id) => ({ id: `three-cloud-aws::${id}`, kind: "Resource", lexicon: "aws", attrs: {} })), ...extra],
|
|
316
|
+
edges: [],
|
|
317
|
+
groups: { byLexicon: {} },
|
|
318
|
+
});
|
|
319
|
+
const web = (ir: GraphIR) => applyDetail(ir, DETAIL.COMPOSITES).nodes.find((n) => n.id === "web")!;
|
|
320
|
+
const all = ["webAlb", "webService", "webTaskDef"];
|
|
321
|
+
|
|
322
|
+
test("every member good gives good, on either anchor", () => {
|
|
323
|
+
for (const ir of [sourceOverlayGraphs(declared, live(all)), overlayGraphs(live(all), declared)]) {
|
|
324
|
+
expect(web(ir).attrs).toEqual({ members: 3, _status: "good" });
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test("an absent member makes the composite pending, and is named", () => {
|
|
329
|
+
for (const ir of [sourceOverlayGraphs(declared, live(["webAlb", "webTaskDef"])), overlayGraphs(live(["webAlb", "webTaskDef"]), declared)]) {
|
|
330
|
+
expect(web(ir).attrs).toEqual({ members: 3, _status: "accent", _memberStatus: { accent: ["three-cloud-aws::webService"] } });
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test("an unobserved member gives neutral, and an absent one outranks it", () => {
|
|
335
|
+
const unobserved = { "three-cloud-aws::webTaskDef": { reason: "unsupported-kind" } } as never;
|
|
336
|
+
expect(web(sourceOverlayGraphs(declared, live(["webAlb", "webService"]), { unobserved })).attrs).toEqual({
|
|
337
|
+
members: 3,
|
|
338
|
+
_status: "neutral",
|
|
339
|
+
_memberStatus: { neutral: ["three-cloud-aws::webTaskDef"] },
|
|
340
|
+
});
|
|
341
|
+
expect(web(sourceOverlayGraphs(declared, live(["webAlb"]), { unobserved })).attrs).toEqual({
|
|
342
|
+
members: 3,
|
|
343
|
+
_status: "accent",
|
|
344
|
+
_memberStatus: { accent: ["three-cloud-aws::webService"], neutral: ["three-cloud-aws::webTaskDef"] },
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("a foreign resource stays its own node and leaves the composite good", () => {
|
|
349
|
+
const foreign = { id: "three-cloud-aws::strayBucket", kind: "Bucket", lexicon: "aws", attrs: {} };
|
|
350
|
+
const ir = applyDetail(sourceOverlayGraphs(declared, live(all, [foreign])), DETAIL.COMPOSITES);
|
|
351
|
+
expect(ir.nodes.find((n) => n.id === "web")!.attrs).toEqual({ members: 3, _status: "good" });
|
|
352
|
+
expect(ir.nodes.find((n) => n.id === foreign.id)!.attrs).toEqual({ _status: "warn" });
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test("a member painted with a status the rollup does not know is never hidden", () => {
|
|
356
|
+
const ir: GraphIR = { ...declared, nodes: [member("webAlb", { _status: "good" }), member("webService", { _status: "drifted" })] };
|
|
357
|
+
expect(web(ir).attrs).toEqual({ members: 2, _status: "drifted", _memberStatus: { drifted: ["three-cloud-aws::webService"] } });
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test("a graph read without --overlay carries no _status on the composite", () => {
|
|
361
|
+
expect(web(declared).attrs).toEqual({ members: 3 });
|
|
362
|
+
});
|
|
363
|
+
});
|
package/src/graph-detail.ts
CHANGED
|
@@ -124,6 +124,34 @@ function toDeclarables(ir: GraphIR): GraphIR {
|
|
|
124
124
|
return { ...ir, nodes: ir.nodes.map((n) => ({ ...n, attrs: resourceAttrs(n.attrs) })) };
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
/** Overlay statuses from the least to the most in need of attention: a
|
|
128
|
+
* composite takes its worst member's (#4227). An unknown status ranks above
|
|
129
|
+
* `warn`, so a status this list does not know yet is never hidden. */
|
|
130
|
+
const STATUS_RANK = ["good", "runtime", "neutral", "accent", "warn"];
|
|
131
|
+
|
|
132
|
+
function statusRank(status: string): number {
|
|
133
|
+
const i = STATUS_RANK.indexOf(status);
|
|
134
|
+
return i === -1 ? STATUS_RANK.length : i;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** A collapsed composite's overlay paint, rolled up from its members (#4227):
|
|
138
|
+
* `_status` is the worst member status, and `_memberStatus` lists the members
|
|
139
|
+
* that are not `good` under their status. Nothing when no member carries a
|
|
140
|
+
* `_status`, as in a graph read without `--overlay`. */
|
|
141
|
+
function rollupStatus(members: IRNode[]): Record<string, unknown> {
|
|
142
|
+
const painted = members.filter((m) => typeof m.attrs._status === "string");
|
|
143
|
+
if (painted.length === 0) return {};
|
|
144
|
+
let worst = "good";
|
|
145
|
+
const notGood: Record<string, string[]> = {};
|
|
146
|
+
for (const m of painted) {
|
|
147
|
+
const status = m.attrs._status as string;
|
|
148
|
+
if (statusRank(status) > statusRank(worst)) worst = status;
|
|
149
|
+
if (status !== "good") (notGood[status] ??= []).push(m.id);
|
|
150
|
+
}
|
|
151
|
+
for (const ids of Object.values(notGood)) ids.sort();
|
|
152
|
+
return { _status: worst, ...(Object.keys(notGood).length > 0 ? { _memberStatus: notGood } : {}) };
|
|
153
|
+
}
|
|
154
|
+
|
|
127
155
|
/** T1 — collapse each composite instance to one node; internal edges disappear.
|
|
128
156
|
* Topology view: surviving plain nodes keep only overlay paint, not properties. */
|
|
129
157
|
function toComposites(ir: GraphIR): GraphIR {
|
|
@@ -169,7 +197,7 @@ function toComposites(ir: GraphIR): GraphIR {
|
|
|
169
197
|
id: collapsedId.get(key)!,
|
|
170
198
|
kind: types.size === 1 ? [...types][0] : "Composite",
|
|
171
199
|
lexicon: lexicons.size === 1 ? [...lexicons][0] : "multi",
|
|
172
|
-
attrs: { members: members.length },
|
|
200
|
+
attrs: { members: members.length, ...rollupStatus(members) },
|
|
173
201
|
});
|
|
174
202
|
}
|
|
175
203
|
|
package/src/graph-ir.ts
CHANGED
|
@@ -1078,13 +1078,21 @@ function tagStatus(n: IRNode, status: OverlayNodeStatus, unobserved?: Unobserved
|
|
|
1078
1078
|
* no live edges). Sorted; the live groups pass through unchanged.
|
|
1079
1079
|
*/
|
|
1080
1080
|
export function overlayGraphs(live: GraphIR, declared: GraphIR, opts?: OverlayOptions): GraphIR {
|
|
1081
|
-
const
|
|
1081
|
+
const declaredById = new Map(declared.nodes.map((n) => [n.id, n]));
|
|
1082
1082
|
const liveIds = new Set(live.nodes.map((n) => n.id));
|
|
1083
1083
|
const unobserved = opts?.unobserved ?? {};
|
|
1084
1084
|
|
|
1085
|
-
const nodes: IRNode[] = live.nodes.map((n) =>
|
|
1086
|
-
|
|
1087
|
-
|
|
1085
|
+
const nodes: IRNode[] = live.nodes.map((n) => {
|
|
1086
|
+
const d = declaredById.get(n.id);
|
|
1087
|
+
if (!d) return tagStatus(n, n.runtimeOwner ? "runtime" : "warn");
|
|
1088
|
+
// A managed node keeps the composite it was declared in, so `--detail 1`
|
|
1089
|
+
// collapses it with its siblings and rolls their status up (#4227).
|
|
1090
|
+
const membership = {
|
|
1091
|
+
...(d.compositeInstance ? { compositeInstance: d.compositeInstance } : {}),
|
|
1092
|
+
...(d.compositeParent ? { compositeParent: d.compositeParent } : {}),
|
|
1093
|
+
};
|
|
1094
|
+
return tagStatus({ ...n, ...membership }, "good");
|
|
1095
|
+
});
|
|
1088
1096
|
for (const n of declared.nodes) {
|
|
1089
1097
|
if (liveIds.has(n.id)) continue;
|
|
1090
1098
|
const u = unobserved[n.id];
|
|
@@ -168,7 +168,8 @@ const { execFileSync } = require("child_process");
|
|
|
168
168
|
const args = process.argv.slice(2);
|
|
169
169
|
const token = process.env.NODE_AUTH_TOKEN || "";
|
|
170
170
|
const regFile = ${JSON.stringify(log)} + ".registry.json";
|
|
171
|
-
fs.
|
|
171
|
+
const userconfig = process.env.NPM_CONFIG_USERCONFIG ? fs.readFileSync(process.env.NPM_CONFIG_USERCONFIG, "utf-8") : "";
|
|
172
|
+
fs.appendFileSync(${JSON.stringify(log)}, JSON.stringify({ args, tokenSet: token !== "", ...(userconfig ? { userconfig } : {}) }) + "\\n");
|
|
172
173
|
console.log("npm notice using //registry.example/:_authToken=" + token);
|
|
173
174
|
console.error("npm notice token " + token);
|
|
174
175
|
const reg = fs.existsSync(regFile) ? JSON.parse(fs.readFileSync(regFile, "utf-8")) : { packages: {} };
|
|
@@ -177,6 +178,9 @@ const flag = (name) => { const i = args.indexOf(name); return i < 0 ? null : arg
|
|
|
177
178
|
const split = (spec) => { const at = spec.lastIndexOf("@"); return [spec.slice(0, at), spec.slice(at + 1)]; };
|
|
178
179
|
if (args[0] === "publish") {
|
|
179
180
|
if (process.env.FAKE_NPM_FAIL && args[1] && args[1].includes(process.env.FAKE_NPM_FAIL)) { console.error("npm error 403 Forbidden"); process.exit(1); }
|
|
181
|
+
// A package whose trusted-publisher record is broken: only a token in the user config publishes it.
|
|
182
|
+
if (process.env.FAKE_NPM_NEEDAUTH && args[1] && args[1].includes(process.env.FAKE_NPM_NEEDAUTH) && !userconfig.includes("_authToken=")) { console.error("npm error code ENEEDAUTH"); process.exit(1); }
|
|
183
|
+
if (process.env.FAKE_NPM_TOKEN_NO_PROVENANCE && userconfig && args.includes("--provenance")) { console.error("npm error 422 Unprocessable Entity - provenance statement does not match the publisher"); process.exit(1); }
|
|
180
184
|
const text = fs.statSync(args[1]).isDirectory() ? fs.readFileSync(path.join(args[1], "package.json"), "utf-8") : execFileSync("tar", ["-xzOf", args[1], "package/package.json"], { encoding: "utf-8" });
|
|
181
185
|
const { name, version } = JSON.parse(text);
|
|
182
186
|
const p = reg.packages[name] || (reg.packages[name] = { versions: [], distTags: {} });
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composed commits (#4168 4.3), as git holds them. A composed release lives
|
|
3
|
+
* on a pushed `chant/compose/<name>` branch whose commits each cherry-pick
|
|
4
|
+
* one trunk commit and name it in a `Chant-Composed-From: <sha>` trailer.
|
|
5
|
+
* The base is the commit the first composed commit was picked onto.
|
|
6
|
+
*
|
|
7
|
+
* Kept free of other chant imports, so the ci tick, the cut, the metrics
|
|
8
|
+
* and the compose command read composed commits the same way.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { execFileSync } from "node:child_process";
|
|
12
|
+
|
|
13
|
+
/** The branch prefix a composed release lives under. */
|
|
14
|
+
export const COMPOSE_BRANCH_PREFIX = "chant/compose/";
|
|
15
|
+
|
|
16
|
+
/** The trailer naming the trunk commit a composed commit picks. */
|
|
17
|
+
export const COMPOSED_FROM_TRAILER = "Chant-Composed-From";
|
|
18
|
+
|
|
19
|
+
/** What a composed commit carries: its branch when one is known, its base, and the trunk commits picked onto the base, oldest first. */
|
|
20
|
+
export interface Composition {
|
|
21
|
+
branch: string | null;
|
|
22
|
+
base: string;
|
|
23
|
+
sources: string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function tryGit(top: string, args: string[]): string | null {
|
|
27
|
+
try {
|
|
28
|
+
return execFileSync("git", args, { cwd: top, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 64 * 1024 * 1024 }).trim();
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Whether `branch` names a compose branch. */
|
|
35
|
+
export function isComposeBranch(branch: unknown): branch is string {
|
|
36
|
+
return typeof branch === "string" && branch.startsWith(COMPOSE_BRANCH_PREFIX) && branch.length > COMPOSE_BRANCH_PREFIX.length;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The trunk commits one commit's `Chant-Composed-From` trailers name. */
|
|
40
|
+
export function composedFrom(top: string, sha: string): string[] {
|
|
41
|
+
const out = tryGit(top, ["show", "-s", `--format=%(trailers:key=${COMPOSED_FROM_TRAILER},valueonly,separator=%x00)`, sha]);
|
|
42
|
+
return (out ?? "")
|
|
43
|
+
.split("\0")
|
|
44
|
+
.map((s) => s.trim())
|
|
45
|
+
.filter((s) => /^[0-9a-f]{40}$/.test(s));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The composition `sha` ends: walk its first parents while each commit
|
|
50
|
+
* carries a `Chant-Composed-From` trailer. Null when `sha` carries none.
|
|
51
|
+
*/
|
|
52
|
+
export function compositionOf(top: string, sha: string, branch: string | null = null): Composition | null {
|
|
53
|
+
const sources: string[] = [];
|
|
54
|
+
let at = sha;
|
|
55
|
+
for (;;) {
|
|
56
|
+
const from = composedFrom(top, at);
|
|
57
|
+
if (from.length === 0) break;
|
|
58
|
+
sources.unshift(...from);
|
|
59
|
+
const parent = tryGit(top, ["rev-parse", "--verify", "--quiet", `${at}^1`]);
|
|
60
|
+
if (!parent) break;
|
|
61
|
+
at = parent;
|
|
62
|
+
}
|
|
63
|
+
if (sources.length === 0) return null;
|
|
64
|
+
return { branch, base: at, sources };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The compose branches this clone knows on `remote`, as `{ branch, ref }`, by name. */
|
|
68
|
+
export function remoteComposeBranches(top: string, remote = "origin"): Array<{ branch: string; ref: string }> {
|
|
69
|
+
const prefix = `refs/remotes/${remote}/`;
|
|
70
|
+
const out = tryGit(top, ["for-each-ref", "--format=%(refname)", `${prefix}${COMPOSE_BRANCH_PREFIX}`]);
|
|
71
|
+
return (out ?? "")
|
|
72
|
+
.split("\n")
|
|
73
|
+
.filter(Boolean)
|
|
74
|
+
.map((ref) => ({ branch: ref.slice(prefix.length), ref }))
|
|
75
|
+
.sort((a, b) => (a.branch < b.branch ? -1 : 1));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* What a commit of a compose branch carries beyond trunk: walk its first
|
|
80
|
+
* parents back to the first commit `trunkRef` reaches, which is the base,
|
|
81
|
+
* collecting the `Chant-Composed-From` trailers on the way. A commit a
|
|
82
|
+
* person made on the branch, such as a resolved conflict, carries none and
|
|
83
|
+
* is walked past. Null when `sha` is on trunk or no trunk commit is found.
|
|
84
|
+
*/
|
|
85
|
+
export function compositionOnTrunk(top: string, sha: string, trunkRef: string, branch: string | null = null): Composition | null {
|
|
86
|
+
const onTrunk = (c: string) => tryGit(top, ["merge-base", "--is-ancestor", c, trunkRef]) !== null;
|
|
87
|
+
if (onTrunk(sha)) return null;
|
|
88
|
+
const sources: string[] = [];
|
|
89
|
+
let at: string | null = sha;
|
|
90
|
+
while (at && !onTrunk(at)) {
|
|
91
|
+
sources.unshift(...composedFrom(top, at));
|
|
92
|
+
at = tryGit(top, ["rev-parse", "--verify", "--quiet", `${at}^1`]);
|
|
93
|
+
}
|
|
94
|
+
return at ? { branch, base: at, sources } : null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The pushed compose branch `sha` is a first-parent commit of, as this clone last fetched it, or null. */
|
|
98
|
+
export function composeBranchContaining(top: string, sha: string, remote = "origin"): { branch: string; ref: string } | null {
|
|
99
|
+
for (const b of remoteComposeBranches(top, remote)) {
|
|
100
|
+
const line = tryGit(top, ["rev-list", "--first-parent", b.ref]);
|
|
101
|
+
if (line?.split("\n").includes(sha)) return b;
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The composed commits on the pushed compose branches that pick `trunkSha`. */
|
|
107
|
+
export function composedIncluding(top: string, trunkSha: string, remote = "origin"): Array<{ branch: string; commit: string }> {
|
|
108
|
+
const out: Array<{ branch: string; commit: string }> = [];
|
|
109
|
+
for (const b of remoteComposeBranches(top, remote)) {
|
|
110
|
+
const tip = tryGit(top, ["rev-parse", "--verify", "--quiet", `${b.ref}^{commit}`]);
|
|
111
|
+
if (!tip) continue;
|
|
112
|
+
const composition = compositionOf(top, tip, b.branch);
|
|
113
|
+
if (!composition?.sources.includes(trunkSha)) continue;
|
|
114
|
+
// The composed commit that picked it: walk the composed commits for its trailer.
|
|
115
|
+
let at: string | null = tip;
|
|
116
|
+
while (at && at !== composition.base) {
|
|
117
|
+
if (composedFrom(top, at).includes(trunkSha)) {
|
|
118
|
+
out.push({ branch: b.branch, commit: at });
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
at = tryGit(top, ["rev-parse", "--verify", "--quiet", `${at}^1`]);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
@@ -108,6 +108,26 @@ describe("diffDeepObservation", () => {
|
|
|
108
108
|
expect(result.unchanged).toEqual(["r"]);
|
|
109
109
|
});
|
|
110
110
|
|
|
111
|
+
test("prepare rewrites each raw tree, both sides, before paths are taken (#4231)", () => {
|
|
112
|
+
const seen: string[] = [];
|
|
113
|
+
const rekey: DeepNormalizationHooks = {
|
|
114
|
+
prepare(tree, { side }) {
|
|
115
|
+
seen.push(side);
|
|
116
|
+
const ids = tree.ids as Record<string, unknown> | undefined;
|
|
117
|
+
if (!ids) return tree;
|
|
118
|
+
return { ...tree, ids: Object.fromEntries(Object.entries(ids).map(([k, v]) => [k.toLowerCase(), v])) };
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
const result = diffDeepObservation(
|
|
122
|
+
entities({ r: { entityType: "T", props: { ids: { "A/B": {} } } } }),
|
|
123
|
+
{ resources: { r: { type: "T", properties: { ids: { "a/b": {} } } } }, unobserved: {} },
|
|
124
|
+
rekey,
|
|
125
|
+
);
|
|
126
|
+
expect(seen.sort()).toEqual(["declared", "live"]);
|
|
127
|
+
expect(result.drifted).toEqual([]);
|
|
128
|
+
expect(result.unchanged).toEqual(["r"]);
|
|
129
|
+
});
|
|
130
|
+
|
|
111
131
|
test("a declared property at its default is still compared", () => {
|
|
112
132
|
const result = diffDeepObservation(
|
|
113
133
|
entities({ r: { entityType: "AWS::IAM::Role", props: { Path: "/" } } }),
|