@intentius/chant 0.60.0 → 0.62.0
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/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts +0 -18
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/codegen/json-schema.d.ts +5 -2
- package/dist/codegen/json-schema.d.ts.map +1 -1
- package/dist/graph-ir.d.ts +70 -2
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/lexicon.d.ts +51 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/assert-live.d.ts.map +1 -1
- package/dist/lifecycle/git.d.ts +117 -0
- package/dist/lifecycle/git.d.ts.map +1 -1
- package/dist/lifecycle/observe.d.ts.map +1 -1
- package/dist/observation.d.ts +21 -1
- package/dist/observation.d.ts.map +1 -1
- package/dist/op/activities/lexicon-upgrade.d.ts +19 -1
- package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
- package/dist/op/activities/reconcile.d.ts +225 -14
- package/dist/op/activities/reconcile.d.ts.map +1 -1
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +17 -2
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/operator.d.ts.map +1 -1
- package/dist/op/runtimes/local.d.ts.map +1 -1
- package/dist/runtime-adapter.d.ts +8 -0
- package/dist/runtime-adapter.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/components.ts +1 -1
- package/src/cli/handlers/lifecycle.ts +1 -0
- package/src/cli/handlers/operator.test.ts +102 -1
- package/src/cli/handlers/operator.ts +75 -5
- package/src/codegen/json-schema.test.ts +159 -0
- package/src/codegen/json-schema.ts +106 -8
- package/src/graph-ir.test.ts +63 -0
- package/src/graph-ir.ts +125 -8
- package/src/lexicon.ts +51 -0
- package/src/lifecycle/assert-live.ts +1 -0
- package/src/lifecycle/git.test.ts +49 -5
- package/src/lifecycle/git.ts +312 -11
- package/src/lifecycle/observe.ts +3 -0
- package/src/observation.test.ts +21 -9
- package/src/observation.ts +31 -2
- package/src/op/activities/lexicon-upgrade.test.ts +122 -39
- package/src/op/activities/lexicon-upgrade.ts +55 -9
- package/src/op/activities/reconcile.test.ts +527 -1
- package/src/op/activities/reconcile.ts +446 -23
- package/src/op/gate.test.ts +504 -0
- package/src/op/gate.ts +9 -1
- package/src/op/local-executor.test.ts +115 -0
- package/src/op/local-executor.ts +130 -21
- package/src/op/operator.test.ts +20 -0
- package/src/op/operator.ts +39 -1
- package/src/op/runtimes/local.ts +11 -0
- package/src/runtime-adapter.ts +17 -3
package/src/graph-ir.ts
CHANGED
|
@@ -13,7 +13,8 @@ import type { UnobservedEntity } from "./observation";
|
|
|
13
13
|
* resolved infrastructure graph. Painters (mermaid, graphviz, custom SVG) and
|
|
14
14
|
* the agentic diagrammer consume this; it is a pure function of lint-clean
|
|
15
15
|
* source. Every node traces to the file that declared it; every edge is a real
|
|
16
|
-
* cross-resource reference
|
|
16
|
+
* cross-resource reference, either an `AttrRef` this module resolves itself or
|
|
17
|
+
* an {@link EntityReference} the producing lexicon resolved (#2265).
|
|
17
18
|
*
|
|
18
19
|
* Emitted by `chant graph --format ir`. See issue #493 / epic #492.
|
|
19
20
|
*/
|
|
@@ -90,6 +91,88 @@ export interface IREdge {
|
|
|
90
91
|
toAttr?: string;
|
|
91
92
|
}
|
|
92
93
|
|
|
94
|
+
/**
|
|
95
|
+
* One reference an entity declares in a vocabulary this module cannot read
|
|
96
|
+
* (#2265), already resolved to the entity key it points at.
|
|
97
|
+
*
|
|
98
|
+
* `collectEdges` below finds references two ways: an {@link AttrRef} object a
|
|
99
|
+
* typed lexicon puts in its props, and a `Ref`-shaped intrinsic. Both are
|
|
100
|
+
* objects with a resolvable target, which is what makes them findable by a
|
|
101
|
+
* walk that knows no lexicon. A lexicon that PARSES someone else's source has
|
|
102
|
+
* neither: the terraform lexicon's entities keep their block body verbatim
|
|
103
|
+
* from hcl2json, where a reference to another block is the string
|
|
104
|
+
* `"${aws_vpc.main.id}"` and nothing else. The walk finds nothing, correctly,
|
|
105
|
+
* and a 247-node estate graphs with zero edges.
|
|
106
|
+
*
|
|
107
|
+
* The reference is not missing, it is just in the producer's vocabulary, and
|
|
108
|
+
* resolving it needs an HCL expression parser and the lexicon's own key shape.
|
|
109
|
+
* So the lexicon resolves it and says so here, on the entity, in the one
|
|
110
|
+
* vocabulary this module does understand: an entity key, plus the two
|
|
111
|
+
* attribute names {@link IREdge} already carries.
|
|
112
|
+
*
|
|
113
|
+
* The hook is a plain duck-typed optional field any entity MAY carry beside
|
|
114
|
+
* its `props`, read through {@link entityReferences}. That is the same shape
|
|
115
|
+
* (and the same reasoning) as `suppressions`, ../lint/suppressions.ts: a
|
|
116
|
+
* reference is a fact ABOUT one entity, `Declarable` already lets each lexicon
|
|
117
|
+
* carry its own fields beside `props`, and nothing that constructs or merges
|
|
118
|
+
* an entity map had to change for it. A lexicon that wants edges adopts the
|
|
119
|
+
* convention on its own entities; every other lexicon keeps the AttrRef walk
|
|
120
|
+
* it already had, and the two sources of edges merge in one place.
|
|
121
|
+
*
|
|
122
|
+
* An entry whose `to` is not a node in this graph is dropped rather than kept
|
|
123
|
+
* as a dangling endpoint, the same rule {@link buildLiveGraphIr} applies to an
|
|
124
|
+
* observed edge.
|
|
125
|
+
*/
|
|
126
|
+
export interface EntityReference {
|
|
127
|
+
/** The referenced entity's key, in the same space as the IR's node ids. */
|
|
128
|
+
to: string;
|
|
129
|
+
/** The consumer-side attribute the reference flows through (`IREdge.viaAttr`). */
|
|
130
|
+
viaAttr?: string;
|
|
131
|
+
/** The producer-side attribute referenced (`IREdge.toAttr`), when the reference named exactly one. */
|
|
132
|
+
toAttr?: string;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** True when `value` has the {@link EntityReference} shape. */
|
|
136
|
+
function isEntityReference(value: unknown): value is EntityReference {
|
|
137
|
+
return (
|
|
138
|
+
typeof value === "object" &&
|
|
139
|
+
value !== null &&
|
|
140
|
+
typeof (value as { to?: unknown }).to === "string"
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The references an entity declared for itself (#2265), or an empty list.
|
|
146
|
+
* Duck-typed on purpose, see {@link EntityReference}.
|
|
147
|
+
*/
|
|
148
|
+
export function entityReferences(entity: Declarable): readonly EntityReference[] {
|
|
149
|
+
const declared = (entity as unknown as { references?: unknown }).references;
|
|
150
|
+
if (!Array.isArray(declared)) return [];
|
|
151
|
+
return declared.filter(isEntityReference);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The deployable unit an entity says it belongs to (#2266), or undefined.
|
|
156
|
+
*
|
|
157
|
+
* Same duck-typed channel as {@link entityReferences}, for the other half of
|
|
158
|
+
* the same problem. `groups.byStack` keys by lexicon partition when nothing
|
|
159
|
+
* says otherwise, on the reasoning that one lexicon serialises to one
|
|
160
|
+
* deployable stack. That reasoning is exactly wrong for a lexicon reading an
|
|
161
|
+
* estate that already has several: a terraform project declaring five
|
|
162
|
+
* `terraform.roots` has five things `terraform apply` runs against, and each
|
|
163
|
+
* one is a stack in every sense `byStack` means, so five roots landed in one
|
|
164
|
+
* bucket named `terraform`.
|
|
165
|
+
*
|
|
166
|
+
* The root is not inferable here and should not be: it is the producer's own
|
|
167
|
+
* name for its own unit. A consumer could recover it from the node id prefix
|
|
168
|
+
* the terraform lexicon mints, but an id convention is not a promise, and
|
|
169
|
+
* `byStack` is the field whose whole job is saying which box a node goes in.
|
|
170
|
+
*/
|
|
171
|
+
export function entityStack(entity: Declarable): string | undefined {
|
|
172
|
+
const stack = (entity as unknown as { stack?: unknown }).stack;
|
|
173
|
+
return typeof stack === "string" && stack.length > 0 ? stack : undefined;
|
|
174
|
+
}
|
|
175
|
+
|
|
93
176
|
/** Grouping metadata for cluster/subgraph rendering. Maps group name -> node ids. */
|
|
94
177
|
export interface IRGroups {
|
|
95
178
|
byLexicon?: Record<string, string[]>;
|
|
@@ -99,11 +182,15 @@ export interface IRGroups {
|
|
|
99
182
|
* boundary boxes) read this rather than inferring stacks — which is the point,
|
|
100
183
|
* so it should never require one.
|
|
101
184
|
*
|
|
102
|
-
*
|
|
185
|
+
* Three sources, depending on how the project is shaped:
|
|
103
186
|
*
|
|
104
187
|
* - **side-by-side stacks**, declared in config and composed by
|
|
105
188
|
* `buildDeclaredPerStack` — keys are the declared stack names. Nothing is
|
|
106
189
|
* inferred; the project stated both the names and the membership.
|
|
190
|
+
* - **entities that name their own unit** ({@link entityStack}, #2266) —
|
|
191
|
+
* keys are those names, one entry per unit. A terraform root is the case
|
|
192
|
+
* this exists for: five `terraform.roots` are five `terraform apply`s and
|
|
193
|
+
* five boundary boxes, not one bucket called `terraform`.
|
|
107
194
|
* - **one source tree** — keys are lexicon partitions, since each lexicon
|
|
108
195
|
* serialises to one deployable stack.
|
|
109
196
|
*
|
|
@@ -315,7 +402,12 @@ function project(value: unknown, seen: Set<unknown>, reverse: Map<object, string
|
|
|
315
402
|
return out;
|
|
316
403
|
}
|
|
317
404
|
|
|
318
|
-
|
|
405
|
+
// `references` and `stack` are the two duck-typed channels above
|
|
406
|
+
// ({@link entityReferences}, {@link entityStack}). They are metadata about the
|
|
407
|
+
// entity's place in the graph, not declared configuration, and both are read
|
|
408
|
+
// directly, so projecting them into `attrs` would only restate an edge and a
|
|
409
|
+
// group key the IR already carries.
|
|
410
|
+
const SKIP_KEYS = new Set(["lexicon", "entityType", "kind", "attributes", "Ref", "references", "stack"]);
|
|
319
411
|
|
|
320
412
|
/** The config bag of a node, paired with each key. Lexicon entities keep their
|
|
321
413
|
* declared props in a (usually non-enumerable) `props` object; simpler entities
|
|
@@ -349,7 +441,17 @@ function projectConfig(
|
|
|
349
441
|
return out;
|
|
350
442
|
}
|
|
351
443
|
|
|
352
|
-
/**
|
|
444
|
+
/**
|
|
445
|
+
* Collect ref edges from one node, labelling each with its consumer property.
|
|
446
|
+
*
|
|
447
|
+
* Two sources, merged here. First the references the entity resolved for
|
|
448
|
+
* itself ({@link EntityReference}), which is how a lexicon that parses HCL,
|
|
449
|
+
* YAML or any other foreign source says what its strings point at. Then the
|
|
450
|
+
* walk over the config bag for {@link AttrRef} objects and `Ref` intrinsics,
|
|
451
|
+
* which is how a typed lexicon's props carry the same fact. A lexicon uses one
|
|
452
|
+
* or the other; nothing stops it using both, and the dedup in `buildGraphIr`
|
|
453
|
+
* collapses an edge two sources agree on.
|
|
454
|
+
*/
|
|
353
455
|
function collectEdges(
|
|
354
456
|
entity: Declarable,
|
|
355
457
|
from: string,
|
|
@@ -357,6 +459,16 @@ function collectEdges(
|
|
|
357
459
|
reverse: Map<object, string>,
|
|
358
460
|
): IREdge[] {
|
|
359
461
|
const edges: IREdge[] = [];
|
|
462
|
+
for (const ref of entityReferences(entity)) {
|
|
463
|
+
if (ref.to === from || !nodeIds.has(ref.to)) continue;
|
|
464
|
+
edges.push({
|
|
465
|
+
from,
|
|
466
|
+
to: ref.to,
|
|
467
|
+
kind: "ref",
|
|
468
|
+
...(ref.viaAttr ? { viaAttr: ref.viaAttr } : {}),
|
|
469
|
+
...(ref.toAttr ? { toAttr: ref.toAttr } : {}),
|
|
470
|
+
});
|
|
471
|
+
}
|
|
360
472
|
const seen = new Set<unknown>();
|
|
361
473
|
const visit = (value: unknown, viaAttr: string): void => {
|
|
362
474
|
if (value === null || typeof value !== "object") return;
|
|
@@ -440,9 +552,14 @@ export function buildGraphIr(
|
|
|
440
552
|
nodes.push(node);
|
|
441
553
|
|
|
442
554
|
(byLexicon[entity.lexicon] ??= []).push(name);
|
|
443
|
-
//
|
|
444
|
-
//
|
|
445
|
-
//
|
|
555
|
+
// An entity that names its own deployable unit is taken at its word
|
|
556
|
+
// (#2266): a terraform root is one `terraform apply`, and a project
|
|
557
|
+
// declaring five of them has five stacks whatever the lexicon count says.
|
|
558
|
+
// See {@link entityStack}.
|
|
559
|
+
//
|
|
560
|
+
// Otherwise, within ONE source tree a stack is a lexicon partition — each
|
|
561
|
+
// lexicon serialises to one deployable stack (a CloudFormation template, a
|
|
562
|
+
// CI config) — so that is what `byStack` reports. It stays a distinct axis
|
|
446
563
|
// from `byLexicon` (which is for provenance/colouring), emitted separately
|
|
447
564
|
// even where the two coincide.
|
|
448
565
|
//
|
|
@@ -455,7 +572,7 @@ export function buildGraphIr(
|
|
|
455
572
|
// child-project". #513 is closed, that phase was never filed, and directory
|
|
456
573
|
// partitioning is the exception rather than the rule — so the promise is
|
|
457
574
|
// withdrawn rather than left pointing at a closed issue.
|
|
458
|
-
(byStack[entity.lexicon] ??= []).push(name);
|
|
575
|
+
(byStack[entityStack(entity) ?? entity.lexicon] ??= []).push(name);
|
|
459
576
|
if (prov?.composite) (byComposite[prov.composite] ??= []).push(name);
|
|
460
577
|
}
|
|
461
578
|
|
package/src/lexicon.ts
CHANGED
|
@@ -465,6 +465,25 @@ export interface ComponentPipelineOptions {
|
|
|
465
465
|
image?: string;
|
|
466
466
|
/** Top-level CI `variables:` block. */
|
|
467
467
|
variables?: Record<string, string>;
|
|
468
|
+
/**
|
|
469
|
+
* The stage every generated Op job runs in, and (on GitLab) the default
|
|
470
|
+
* base of the generated file's own name (#2293). GitLab-only: a GitHub or
|
|
471
|
+
* Forgejo Op workflow is one file per Op with no shared stage, so this does
|
|
472
|
+
* nothing there. Default `"ops"` — every trigger kind (cron, merge-request,
|
|
473
|
+
* push) a project mixes into one Op pipeline lands under the same stage,
|
|
474
|
+
* unlike the pre-#2293 constant `"scheduled-ops"`, which named only the
|
|
475
|
+
* cron-only pipeline the generator used to emit. See `opsFileName` for the
|
|
476
|
+
* file name, which follows this unless overridden separately.
|
|
477
|
+
*/
|
|
478
|
+
opsStage?: string;
|
|
479
|
+
/**
|
|
480
|
+
* The generated Op pipeline file's name — GitLab only (#2293). Defaults to
|
|
481
|
+
* `` `${opsStage}.gitlab-ci.yml` `` (`ops.gitlab-ci.yml` with no `opsStage`
|
|
482
|
+
* override), so setting `opsStage` alone renames both the stage GitLab's UI
|
|
483
|
+
* groups jobs under and the file a consumer's `include:` line names. Set
|
|
484
|
+
* this too when the file should land under some other name than its stage.
|
|
485
|
+
*/
|
|
486
|
+
opsFileName?: string;
|
|
468
487
|
}
|
|
469
488
|
|
|
470
489
|
/** The synthesized CI pipeline for a component graph (generate mode). */
|
|
@@ -638,6 +657,38 @@ export interface ScheduledOpSpec {
|
|
|
638
657
|
* own gate.
|
|
639
658
|
*/
|
|
640
659
|
environment?: OpEnvironment;
|
|
660
|
+
/**
|
|
661
|
+
* Variables and secrets for this Op's generated job alone (#2290). Same
|
|
662
|
+
* shape as {@link ComponentPipelineOptions.variables} — chant draws no
|
|
663
|
+
* type-level line between a plain value and a credential, both are strings
|
|
664
|
+
* a generator drops into the job's environment — but scoped to one Op's job
|
|
665
|
+
* rather than the whole generated file, for the reason `setup` and
|
|
666
|
+
* `permissions` are per-Op already: what a job may hold is a property of
|
|
667
|
+
* that job. A `live-check` plan job that makes no cloud call declares none
|
|
668
|
+
* of this and inherits none of it.
|
|
669
|
+
*
|
|
670
|
+
* **The rule where both are set** (#2290): `ComponentPipelineOptions.variables`
|
|
671
|
+
* (forge-wide) keeps landing on the workflow/file-level `env:`
|
|
672
|
+
* (github/forgejo) or top-level `variables:` (gitlab) exactly as it always
|
|
673
|
+
* has — every caller that declares nothing here sees byte-identical output.
|
|
674
|
+
* This field lands one level down, on the job itself — github/forgejo emit
|
|
675
|
+
* it as the job's own `env:` mapping, gitlab merges it into the job's own
|
|
676
|
+
* `variables:` — and a key present in both wins at the job, the same
|
|
677
|
+
* last-one-wins precedence GitHub Actions and GitLab CI already give
|
|
678
|
+
* step/job env over workflow env. Declaring a credential here rather than
|
|
679
|
+
* in the forge-wide options is therefore how a caller keeps it off every
|
|
680
|
+
* *other* Op's job: nothing about the forge-wide options changes shape,
|
|
681
|
+
* only which of a project's own Ops asks for the credential at all.
|
|
682
|
+
*
|
|
683
|
+
* Supported everywhere a per-Op `env:`/`variables:` block is expressible:
|
|
684
|
+
* github and forgejo both emit job-level `env:`; gitlab merges these into
|
|
685
|
+
* the job's own `variables:` block (already used there for the gated
|
|
686
|
+
* apply's `CHANT_GATE_SUMMARY`, so the merge is native rather than bolted
|
|
687
|
+
* on). No generator refuses this option — unlike `setup`'s `uses:` shape or
|
|
688
|
+
* an additive `permissions` scope, every forge chant targets has some
|
|
689
|
+
* per-job environment mapping to put a value in.
|
|
690
|
+
*/
|
|
691
|
+
variables?: Record<string, string>;
|
|
641
692
|
}
|
|
642
693
|
|
|
643
694
|
/**
|
|
@@ -420,8 +420,18 @@ describe("lifecycle/git", () => {
|
|
|
420
420
|
}
|
|
421
421
|
});
|
|
422
422
|
|
|
423
|
-
|
|
424
|
-
|
|
423
|
+
/**
|
|
424
|
+
* Rewritten for #2309's review. This used to assert that operator B's push
|
|
425
|
+
* was *rejected*: B had no local `chant/lifecycle`, so its write built an
|
|
426
|
+
* unrelated root commit, and the lease was the only thing standing between
|
|
427
|
+
* that fork and A's history on the remote.
|
|
428
|
+
*
|
|
429
|
+
* A write that has to be caught by a lease is the bug, not the contract.
|
|
430
|
+
* `writeBlobToPath` now brings the branch in before it creates one, so B
|
|
431
|
+
* never forks: it appends to A's history and pushes cleanly, and both
|
|
432
|
+
* snapshots survive. The genuine stale-lease case is the test below.
|
|
433
|
+
*/
|
|
434
|
+
test("a second writer with no local ledger appends to the first's history rather than forking", async () => {
|
|
425
435
|
const { clonePath: cloneA, remotePath, cleanup } = await setupClonePair();
|
|
426
436
|
const cloneB = join(tmpdir(), `chant-state-clone-b-${Date.now()}-${Math.random()}`);
|
|
427
437
|
try {
|
|
@@ -433,9 +443,43 @@ describe("lifecycle/git", () => {
|
|
|
433
443
|
await writeSnapshot("prod", "aws", JSON.stringify({ a: 1 }), { cwd: cloneA });
|
|
434
444
|
expect(await pushLifecycle({ cwd: cloneA })).toBe(true);
|
|
435
445
|
|
|
436
|
-
// Operator B
|
|
437
|
-
|
|
438
|
-
|
|
446
|
+
// Operator B has never seen `chant/lifecycle`.
|
|
447
|
+
expect(git(["rev-parse", "--verify", "refs/heads/chant/lifecycle"], cloneB).exitCode).not.toBe(0);
|
|
448
|
+
await writeSnapshot("staging", "gcp", JSON.stringify({ b: 2 }), { cwd: cloneB });
|
|
449
|
+
expect(await pushLifecycle({ cwd: cloneB })).toBe(true);
|
|
450
|
+
|
|
451
|
+
// Neither snapshot was lost.
|
|
452
|
+
expect(await readSnapshot("prod", "aws", { cwd: cloneB })).toBe(JSON.stringify({ a: 1 }));
|
|
453
|
+
expect(await readSnapshot("staging", "gcp", { cwd: cloneB })).toBe(JSON.stringify({ b: 2 }));
|
|
454
|
+
} finally {
|
|
455
|
+
await cleanup();
|
|
456
|
+
const { rm } = await import("node:fs/promises");
|
|
457
|
+
await rm(cloneB, { recursive: true, force: true });
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
test("concurrent write rejected: second push throws StaleLifecycleBranchError", async () => {
|
|
462
|
+
const { clonePath: cloneA, remotePath, cleanup } = await setupClonePair();
|
|
463
|
+
const cloneB = join(tmpdir(), `chant-state-clone-b-${Date.now()}-${Math.random()}`);
|
|
464
|
+
try {
|
|
465
|
+
git(["clone", "-q", remotePath, cloneB], tmpdir());
|
|
466
|
+
git(["config", "user.email", "test@chant.dev"], cloneB);
|
|
467
|
+
git(["config", "user.name", "Test"], cloneB);
|
|
468
|
+
|
|
469
|
+
await writeSnapshot("prod", "aws", JSON.stringify({ a: 1 }), { cwd: cloneA });
|
|
470
|
+
expect(await pushLifecycle({ cwd: cloneA })).toBe(true);
|
|
471
|
+
|
|
472
|
+
// B takes a copy of the branch, so it has local history and a
|
|
473
|
+
// remote-tracking ref pinned to what it saw.
|
|
474
|
+
git(["fetch", "-q", "origin", "chant/lifecycle:chant/lifecycle"], cloneB);
|
|
475
|
+
|
|
476
|
+
// A moves the remote on again, behind B's back.
|
|
477
|
+
git(["fetch", "-q", "origin", "+refs/heads/chant/lifecycle:refs/remotes/origin/chant/lifecycle"], cloneA);
|
|
478
|
+
await writeSnapshot("prod", "aws", JSON.stringify({ a: 2 }), { cwd: cloneA });
|
|
479
|
+
expect(await pushLifecycle({ cwd: cloneA })).toBe(true);
|
|
480
|
+
|
|
481
|
+
// B appends to the tip it knows and pushes against a lease that no
|
|
482
|
+
// longer matches the remote.
|
|
439
483
|
await writeSnapshot("staging", "gcp", JSON.stringify({ b: 2 }), { cwd: cloneB });
|
|
440
484
|
await expect(pushLifecycle({ cwd: cloneB })).rejects.toBeInstanceOf(StaleLifecycleBranchError);
|
|
441
485
|
} finally {
|