@intentius/chant 0.124.31 → 0.124.32
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/bin/chant +7 -2
- package/dist/cli/commands/check-lexicon-plugin.d.ts +7 -4
- package/dist/cli/commands/check-lexicon-plugin.d.ts.map +1 -1
- package/dist/cli/commands/check-lexicon-plugin.js +10 -7
- package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
- package/dist/cli/commands/check-lexicon.js +18 -12
- package/dist/cli/commands/import.d.ts +10 -0
- package/dist/cli/commands/import.d.ts.map +1 -1
- package/dist/cli/commands/import.js +37 -0
- package/dist/cli/commands/init-lexicon/templates/project.d.ts.map +1 -1
- package/dist/cli/commands/init-lexicon/templates/project.js +4 -3
- package/dist/cli/commands/onboard.d.ts.map +1 -1
- package/dist/cli/commands/onboard.js +8 -4
- package/dist/cli/commands/update.d.ts.map +1 -1
- package/dist/cli/commands/update.js +2 -1
- package/dist/cli/dependencies.d.ts +18 -0
- package/dist/cli/dependencies.d.ts.map +1 -0
- package/dist/cli/dependencies.js +120 -0
- package/dist/cli/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/build.js +23 -4
- package/dist/cli/handlers/fan-out.d.ts.map +1 -1
- package/dist/cli/handlers/fan-out.js +12 -4
- package/dist/cli/handlers/graph.d.ts.map +1 -1
- package/dist/cli/handlers/graph.js +16 -6
- package/dist/cli/handlers/lint.d.ts.map +1 -1
- package/dist/cli/handlers/lint.js +6 -0
- package/dist/cli/handlers/operator.d.ts +2 -2
- package/dist/cli/handlers/operator.js +3 -3
- package/dist/cli/handlers/pr.d.ts +14 -0
- package/dist/cli/handlers/pr.d.ts.map +1 -1
- package/dist/cli/handlers/pr.js +488 -32
- package/dist/cli/handlers/promote.d.ts.map +1 -1
- package/dist/cli/handlers/promote.js +3 -1
- package/dist/cli/handlers/run.d.ts +13 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/handlers/run.js +38 -15
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +44 -16
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +15 -0
- package/dist/cli/registry.d.ts +13 -4
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/cli/registry.js +6 -2
- package/dist/codegen/generate-post-synth-barrel.js +2 -2
- package/dist/codegen/lexicon-regen.d.ts +4 -0
- package/dist/codegen/lexicon-regen.d.ts.map +1 -1
- package/dist/codegen/lexicon-regen.js +1 -1
- package/dist/codegen/pinned-upgrade.js +2 -2
- package/dist/components/capability.d.ts +44 -3
- package/dist/components/capability.d.ts.map +1 -1
- package/dist/components/capability.js +21 -0
- package/dist/components/changed-files.d.ts +7 -1
- package/dist/components/changed-files.d.ts.map +1 -1
- package/dist/components/changed-files.js +9 -1
- package/dist/components/driver.d.ts +38 -0
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/components/driver.js +47 -6
- package/dist/components/fan-out-record.d.ts +6 -1
- package/dist/components/fan-out-record.d.ts.map +1 -1
- package/dist/components/fan-out-record.js +24 -0
- package/dist/components/fan-out-run.d.ts +20 -0
- package/dist/components/fan-out-run.d.ts.map +1 -1
- package/dist/components/fan-out-run.js +21 -1
- package/dist/components/pr-pipeline.d.ts +65 -2
- package/dist/components/pr-pipeline.d.ts.map +1 -1
- package/dist/components/pr-pipeline.js +107 -15
- package/dist/components/pr-run.d.ts +127 -29
- package/dist/components/pr-run.d.ts.map +1 -1
- package/dist/components/pr-run.js +290 -70
- package/dist/components/wave-plan.d.ts +5 -2
- package/dist/components/wave-plan.d.ts.map +1 -1
- package/dist/components/wave-plan.js +12 -1
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/discovery/fold-import.js +16 -6
- package/dist/discovery/sandbox/bundle.d.ts.map +1 -1
- package/dist/discovery/sandbox/bundle.js +6 -0
- package/dist/discovery/specifier-resolve.d.ts.map +1 -1
- package/dist/discovery/specifier-resolve.js +8 -2
- package/dist/fold/fold.d.ts.map +1 -1
- package/dist/fold/fold.js +68 -1
- package/dist/fold/subset.d.ts +37 -0
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/fold/subset.js +88 -0
- package/dist/lexicon-module.d.ts +10 -0
- package/dist/lexicon-module.d.ts.map +1 -1
- package/dist/lexicon-module.js +53 -8
- package/dist/lexicon-plugin-helpers.d.ts.map +1 -1
- package/dist/lexicon-plugin-helpers.js +6 -4
- package/dist/lexicon.d.ts +2 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/affected.d.ts.map +1 -1
- package/dist/lifecycle/affected.js +7 -1
- package/dist/lifecycle/release-ledger.d.ts +21 -0
- package/dist/lifecycle/release-ledger.d.ts.map +1 -1
- package/dist/lifecycle/release-ledger.js +24 -0
- package/dist/lifecycle/work-dir.d.ts +45 -0
- package/dist/lifecycle/work-dir.d.ts.map +1 -0
- package/dist/lifecycle/work-dir.js +78 -0
- package/dist/lint/discover.d.ts +17 -9
- package/dist/lint/discover.d.ts.map +1 -1
- package/dist/lint/discover.js +62 -54
- package/dist/module-source.d.ts +57 -0
- package/dist/module-source.d.ts.map +1 -0
- package/dist/module-source.js +104 -0
- package/dist/op/activities/factory.js +1 -1
- package/dist/op/discover.d.ts.map +1 -1
- package/dist/op/discover.js +13 -3
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/gate.js +3 -0
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves-run.js +20 -5
- package/dist/op/operator.d.ts +2 -2
- package/dist/op/operator.js +2 -2
- package/dist/op/steward.d.ts +6 -4
- package/dist/op/steward.d.ts.map +1 -1
- package/dist/op/steward.js +5 -3
- package/dist/op/work-lease-run.d.ts +20 -7
- package/dist/op/work-lease-run.d.ts.map +1 -1
- package/dist/op/work-lease-run.js +56 -12
- package/dist/pr-forge.d.ts +10 -0
- package/dist/pr-forge.d.ts.map +1 -1
- package/dist/pr-forge.js +39 -0
- package/dist/pr-loop.d.ts +141 -4
- package/dist/pr-loop.d.ts.map +1 -1
- package/dist/pr-loop.js +122 -6
- package/dist/pr-report-url.d.ts +2 -2
- package/dist/pr-report-url.d.ts.map +1 -1
- package/dist/pr-report-url.js +5 -5
- package/dist/ts-loader.d.ts +38 -4
- package/dist/ts-loader.d.ts.map +1 -1
- package/dist/ts-loader.js +190 -16
- package/dist/wave-runtime.mjs +208 -208
- package/dist/workspace/chant-migrations/fountain-retired-kinds.d.ts +34 -0
- package/dist/workspace/chant-migrations/fountain-retired-kinds.d.ts.map +1 -0
- package/dist/workspace/chant-migrations/fountain-retired-kinds.js +176 -0
- package/dist/workspace/chant-migrations.d.ts.map +1 -1
- package/dist/workspace/chant-migrations.js +2 -0
- package/dist/workspace/checks/boxes.d.ts +6 -6
- package/dist/workspace/checks/boxes.js +9 -9
- package/dist/workspace/ci-green-forge.d.ts +6 -4
- package/dist/workspace/ci-green-forge.d.ts.map +1 -1
- package/dist/workspace/ci-green.d.ts +28 -12
- package/dist/workspace/ci-green.d.ts.map +1 -1
- package/dist/workspace/ci-green.js +49 -21
- package/dist/workspace/ci-list.schema.json +12 -6
- package/dist/workspace/ci-report-url.d.ts +18 -0
- package/dist/workspace/ci-report-url.d.ts.map +1 -0
- package/dist/workspace/ci-report-url.js +33 -0
- package/dist/workspace/ci-run-index.d.ts +12 -0
- package/dist/workspace/ci-run-index.d.ts.map +1 -1
- package/dist/workspace/ci-run-index.js +6 -4
- package/dist/workspace/ci-run-index.schema.json +5 -3
- package/dist/workspace/ci-run.d.ts +75 -2
- package/dist/workspace/ci-run.d.ts.map +1 -1
- package/dist/workspace/ci-run.js +195 -53
- package/dist/workspace/ci-runner-key.d.ts +16 -3
- package/dist/workspace/ci-runner-key.d.ts.map +1 -1
- package/dist/workspace/ci-runner-key.js +30 -10
- package/dist/workspace/ci-serve.d.ts +54 -4
- package/dist/workspace/ci-serve.d.ts.map +1 -1
- package/dist/workspace/ci-serve.js +175 -26
- package/dist/workspace/declaration.d.ts +8 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.js +11 -2
- package/dist/workspace/declaration.schema.json +7 -2
- package/dist/workspace/export.d.ts +1 -1
- package/dist/workspace/export.d.ts.map +1 -1
- package/dist/workspace/export.js +7 -3
- package/dist/workspace/ls.d.ts +6 -1
- package/dist/workspace/ls.d.ts.map +1 -1
- package/dist/workspace/ls.js +1 -0
- package/dist/workspace/ls.schema.json +7 -1
- package/dist/workspace/pin-integrity.d.ts.map +1 -1
- package/dist/workspace/pin-integrity.js +3 -2
- package/dist/workspace/pr-report.schema.json +74 -7
- package/dist/workspace/reason-codes.d.ts +1 -1
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/reason-codes.js +2 -2
- package/dist/workspace/run-event.schema.json +3 -1
- package/dist/workspace/status.schema.json +3 -1
- package/dist/workspace/write-scope.d.ts +9 -0
- package/dist/workspace/write-scope.d.ts.map +1 -1
- package/dist/workspace/write-scope.js +11 -1
- package/package.json +1 -1
- package/src/__fixtures__/pr-loop/pr-apply-wave-waiting.note.golden.md +48 -0
- package/src/cli/commands/__fixtures__/init-lexicon-output/package.json +3 -3
- package/src/cli/commands/audit.test.ts +2 -2
- package/src/cli/commands/check-lexicon-plugin.test.ts +9 -0
- package/src/cli/commands/check-lexicon-plugin.ts +11 -8
- package/src/cli/commands/check-lexicon.test.ts +1 -1
- package/src/cli/commands/check-lexicon.ts +16 -11
- package/src/cli/commands/import.ts +40 -0
- package/src/cli/commands/init-lexicon/templates/project.ts +4 -3
- package/src/cli/commands/init-lexicon.test.ts +8 -5
- package/src/cli/commands/onboard.test.ts +45 -0
- package/src/cli/commands/onboard.ts +8 -4
- package/src/cli/commands/update.test.ts +6 -0
- package/src/cli/commands/update.ts +4 -1
- package/src/cli/compiled-cli.e2e.test.ts +76 -11
- package/src/cli/dependencies.e2e.test.ts +101 -0
- package/src/cli/dependencies.test.ts +93 -0
- package/src/cli/dependencies.ts +122 -0
- package/src/cli/handlers/build.test.ts +69 -0
- package/src/cli/handlers/build.ts +20 -4
- package/src/cli/handlers/fan-out.test.ts +73 -0
- package/src/cli/handlers/fan-out.ts +11 -4
- package/src/cli/handlers/graph.test.ts +76 -0
- package/src/cli/handlers/graph.ts +19 -12
- package/src/cli/handlers/lint.test.ts +42 -0
- package/src/cli/handlers/lint.ts +6 -0
- package/src/cli/handlers/operator.ts +3 -3
- package/src/cli/handlers/pr-waves.test.ts +449 -0
- package/src/cli/handlers/pr.test.ts +134 -0
- package/src/cli/handlers/pr.ts +523 -31
- package/src/cli/handlers/promote.test.ts +26 -1
- package/src/cli/handlers/promote.ts +3 -2
- package/src/cli/handlers/run-components-exit.test.ts +99 -0
- package/src/cli/handlers/run-signer.test.ts +79 -0
- package/src/cli/handlers/run.ts +34 -16
- package/src/cli/main.test.ts +18 -0
- package/src/cli/main.ts +43 -16
- package/src/cli/parse-args.ts +12 -0
- package/src/cli/registry.ts +19 -6
- package/src/codegen/generate-post-synth-barrel.ts +2 -2
- package/src/codegen/lexicon-regen.ts +1 -1
- package/src/codegen/pinned-upgrade.ts +2 -2
- package/src/codegen/post-synth-barrel-guards.test.ts +6 -11
- package/src/components/capability.ts +51 -3
- package/src/components/changed-files.test.ts +16 -1
- package/src/components/changed-files.ts +9 -1
- package/src/components/driver.test.ts +36 -1
- package/src/components/driver.ts +76 -4
- package/src/components/fan-out-record.ts +29 -1
- package/src/components/fan-out-run.test.ts +35 -0
- package/src/components/fan-out-run.ts +40 -1
- package/src/components/pr-pipeline.test.ts +100 -0
- package/src/components/pr-pipeline.ts +132 -17
- package/src/components/pr-run.test.ts +323 -18
- package/src/components/pr-run.ts +354 -75
- package/src/components/wave-plan.ts +16 -4
- package/src/discovery/fold-host-method.test.ts +87 -0
- package/src/discovery/fold-import.ts +15 -5
- package/src/discovery/fold-package-composite.test.ts +27 -0
- package/src/discovery/fold-specifiers.test.ts +8 -0
- package/src/discovery/sandbox/bundle.ts +6 -0
- package/src/discovery/specifier-resolve.ts +8 -2
- package/src/fold/fold.test.ts +72 -0
- package/src/fold/fold.ts +71 -0
- package/src/fold/subset.test.ts +31 -0
- package/src/fold/subset.ts +99 -0
- package/src/lexicon-dist.e2e.test.ts +254 -0
- package/src/lexicon-module.test.ts +47 -0
- package/src/lexicon-module.ts +49 -7
- package/src/lexicon-plugin-helpers.ts +6 -4
- package/src/lexicon.ts +2 -0
- package/src/lifecycle/affected.test.ts +7 -0
- package/src/lifecycle/affected.ts +6 -1
- package/src/lifecycle/release-ledger.ts +34 -0
- package/src/lifecycle/work-dir.test.ts +77 -0
- package/src/lifecycle/work-dir.ts +82 -0
- package/src/lint/discover.test.ts +46 -2
- package/src/lint/discover.ts +55 -49
- package/src/module-source.test.ts +85 -0
- package/src/module-source.ts +112 -0
- package/src/op/activities/factory.ts +1 -1
- package/src/op/discover-root.test.ts +31 -2
- package/src/op/discover.ts +14 -4
- package/src/op/gate.test.ts +30 -0
- package/src/op/gate.ts +3 -0
- package/src/op/op-waves-run.ts +20 -5
- package/src/op/op-waves.test.ts +49 -0
- package/src/op/operator.ts +2 -2
- package/src/op/steward.ts +6 -4
- package/src/op/work-lease-run.test.ts +88 -2
- package/src/op/work-lease-run.ts +52 -12
- package/src/pr-forge-bundle.test.ts +2 -2
- package/src/pr-forge.test.ts +42 -0
- package/src/pr-forge.ts +40 -0
- package/src/pr-loop-bundle.test.ts +2 -1
- package/src/pr-loop.test.ts +117 -1
- package/src/pr-loop.ts +252 -10
- package/src/pr-report-url.ts +5 -5
- package/src/terraform/__fixtures__/advise.test.ts +21 -2
- package/src/ts-loader.test.ts +89 -1
- package/src/ts-loader.ts +180 -13
- package/src/workspace/changes.test.ts +1 -1
- package/src/workspace/chant-migrations/fountain-retired-kinds.test.ts +33 -0
- package/src/workspace/chant-migrations/fountain-retired-kinds.ts +190 -0
- package/src/workspace/chant-migrations.e2e.test.ts +5 -0
- package/src/workspace/chant-migrations.ts +2 -0
- package/src/workspace/checks/boxes.ts +9 -9
- package/src/workspace/ci-green-forge.ts +6 -4
- package/src/workspace/ci-green-runs.test.ts +53 -11
- package/src/workspace/ci-green.test.ts +1 -1
- package/src/workspace/ci-green.ts +50 -22
- package/src/workspace/ci-list.schema.json +12 -6
- package/src/workspace/ci-report-url.test.ts +220 -0
- package/src/workspace/ci-report-url.ts +33 -0
- package/src/workspace/ci-run-index.schema.json +5 -3
- package/src/workspace/ci-run-index.test.ts +42 -3
- package/src/workspace/ci-run-index.ts +18 -4
- package/src/workspace/ci-run.test.ts +185 -7
- package/src/workspace/ci-run.ts +224 -54
- package/src/workspace/ci-runner-key.test.ts +55 -17
- package/src/workspace/ci-runner-key.ts +36 -11
- package/src/workspace/ci-serve.test.ts +150 -6
- package/src/workspace/ci-serve.ts +169 -25
- package/src/workspace/declaration.schema.json +7 -2
- package/src/workspace/declaration.test.ts +23 -0
- package/src/workspace/declaration.ts +19 -3
- package/src/workspace/export.ts +6 -3
- package/src/workspace/ls.schema.json +7 -1
- package/src/workspace/ls.ts +7 -2
- package/src/workspace/pin-integrity.test.ts +5 -0
- package/src/workspace/pin-integrity.ts +3 -2
- package/src/workspace/pr-report.schema.json +74 -7
- package/src/workspace/reason-codes.ts +2 -2
- package/src/workspace/run-event.schema.json +3 -1
- package/src/workspace/status.schema.json +3 -1
- package/src/workspace/write-scope.test.ts +29 -1
- package/src/workspace/write-scope.ts +12 -1
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant components pr-apply --wave-gate` (#4003): the pull request's plan
|
|
3
|
+
* and approval, then a gate per wave on merge. Wave 1 applies under the pull
|
|
4
|
+
* request's approval; wave 2 is planned against what wave 1 applied, and
|
|
5
|
+
* applies under that approval when the approved plan covers it, or waits at
|
|
6
|
+
* `<gate>-wave-2` when it moved. Git, the change signal, the derivation and
|
|
7
|
+
* the gate ledger are fakes; the planning, the coverage check and the apply
|
|
8
|
+
* are the real ones.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { existsSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
16
|
+
import type { DriverComponent } from "../../components/driver";
|
|
17
|
+
import type { DeployContext } from "../../components/capability";
|
|
18
|
+
import type { ParsedArgs } from "../registry";
|
|
19
|
+
import type { PrReport } from "../../pr-loop";
|
|
20
|
+
|
|
21
|
+
const HEAD = "a".repeat(40);
|
|
22
|
+
const BASE = "b".repeat(40);
|
|
23
|
+
|
|
24
|
+
vi.mock("../../components/changed-files", () => ({
|
|
25
|
+
resolveCommit: async () => HEAD,
|
|
26
|
+
resolveMergeBase: async () => BASE,
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
vi.mock("../../config", async () => {
|
|
30
|
+
const actual = await vi.importActual<typeof import("../../config")>("../../config");
|
|
31
|
+
return { ...actual, loadChantConfig: async () => ({ config: {} }) };
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
vi.mock("./fan-out", () => ({
|
|
35
|
+
resolveChangedUnits: async () => ({ units: { changed: ["net"], indeterminate: [] } }),
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
// pr-apply stores the change set a gate binds on chant/lifecycle (#4022); never in the real repository.
|
|
39
|
+
vi.mock("../../lifecycle/gate-change-set", () => ({
|
|
40
|
+
storeGateChangeSet: async () => ({ written: false }),
|
|
41
|
+
readGraphIds: async () => ({ ids: [] }),
|
|
42
|
+
setGraphIdReader: () => {},
|
|
43
|
+
gateGraphReader: () => async () => ({ ids: [] }),
|
|
44
|
+
}));
|
|
45
|
+
vi.mock("../../lifecycle/git", async () => {
|
|
46
|
+
const actual = await vi.importActual<typeof import("../../lifecycle/git")>("../../lifecycle/git");
|
|
47
|
+
return { ...actual, pushLifecycleReport: async () => { throw new Error("pr-waves.test must not push chant/lifecycle"); } };
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
vi.mock("./run", () => ({
|
|
51
|
+
GATED_EXIT_CODE: 3,
|
|
52
|
+
autoReleaseRun: (id: string) => ({ runId: id, runOrigin: { forge: "local" } }),
|
|
53
|
+
recordAutoReleasesForRun: async () => [],
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
let gateLedger: ReturnType<typeof import("../../op/gate").memoryGateLedgerPort>;
|
|
57
|
+
vi.mock("../../op/gate", async () => {
|
|
58
|
+
const actual = await vi.importActual<typeof import("../../op/gate")>("../../op/gate");
|
|
59
|
+
return { ...actual, gitGateLedgerPort: () => gateLedger, gateIsSealed: async () => false };
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/** The outputs each root exposes on the estate: what `outputs` reads and `run` writes. */
|
|
63
|
+
const deployed: Record<string, Record<string, unknown>> = {};
|
|
64
|
+
/** The roots that applied, in order. */
|
|
65
|
+
let ran: string[] = [];
|
|
66
|
+
/**
|
|
67
|
+
* Whether a root's plan says the `cidr` it will expose (#3999), as
|
|
68
|
+
* terraform's `planned_values` does. Off, every output of a root the change
|
|
69
|
+
* plans is known only after apply.
|
|
70
|
+
*/
|
|
71
|
+
let predicts = true;
|
|
72
|
+
vi.mock("../../components/fan-out-support", async () => {
|
|
73
|
+
const { CapabilityRegistry } = await import("../../components/capability");
|
|
74
|
+
const { planFanOut } = await import("../../components/fan-out");
|
|
75
|
+
const { computePlanDigest } = await import("../../lifecycle/plan-digest");
|
|
76
|
+
const registry = new CapabilityRegistry();
|
|
77
|
+
/** A root whose one resource writes its `cidr` input: the plan says so, so a moved input moves the entry. */
|
|
78
|
+
registry.register({
|
|
79
|
+
kind: "fake-root",
|
|
80
|
+
async plan(_ctx: DeployContext, input: { root: string; cidr?: string }) {
|
|
81
|
+
const cidr = input.cidr ?? null;
|
|
82
|
+
const planDigest = computePlanDigest("fake-root", { root: input.root, cidr });
|
|
83
|
+
return {
|
|
84
|
+
member: input.root,
|
|
85
|
+
planDigest,
|
|
86
|
+
artifact: { planDigest, cidr },
|
|
87
|
+
...(predicts ? { outputs: { known: { cidr }, unknown: [] } } : {}),
|
|
88
|
+
changeSet: {
|
|
89
|
+
member: { member: input.root, lexicon: "terraform", planner: "tofu", status: "planned", planDigest, holes: [] },
|
|
90
|
+
entries: [
|
|
91
|
+
{
|
|
92
|
+
member: input.root,
|
|
93
|
+
lexicon: "terraform",
|
|
94
|
+
planner: "tofu",
|
|
95
|
+
address: `${input.root}.main`,
|
|
96
|
+
type: "fake_resource",
|
|
97
|
+
action: "update",
|
|
98
|
+
attributes: [{ path: "cidr", before: deployed[input.root]?.cidr ?? null, after: cidr }],
|
|
99
|
+
},
|
|
100
|
+
],
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
},
|
|
104
|
+
async outputs(_ctx: DeployContext, input: { root: string }) {
|
|
105
|
+
return deployed[input.root] ?? {};
|
|
106
|
+
},
|
|
107
|
+
// Applies the plan it is handed, as a planned root does, else what its input says now.
|
|
108
|
+
async run(ctx: DeployContext, input: { root: string; cidr?: string }) {
|
|
109
|
+
ran.push(input.root);
|
|
110
|
+
const planned = (ctx as { plans?: Record<string, { cidr: string | null }> }).plans?.[input.root];
|
|
111
|
+
deployed[input.root] = { cidr: planned ? planned.cidr : (input.cidr ?? null) };
|
|
112
|
+
return { outputs: deployed[input.root] };
|
|
113
|
+
},
|
|
114
|
+
} as never);
|
|
115
|
+
return {
|
|
116
|
+
fanOutRegistry: async () => registry,
|
|
117
|
+
deriveFanOut: async () => ({
|
|
118
|
+
success: true,
|
|
119
|
+
components: ESTATE,
|
|
120
|
+
plan: planFanOut({ components: ESTATE, changed: ["net"] }),
|
|
121
|
+
signal: { changed: ["net"], indeterminate: [], unclaimed: [] },
|
|
122
|
+
}),
|
|
123
|
+
};
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
/** `net` writes the cidr the change sets; `app` reads it from `net`'s outputs. */
|
|
127
|
+
const ESTATE: DriverComponent[] = [
|
|
128
|
+
{ name: "net", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "fake-root", root: "net", cidr: "10.0.0.0/16" }] }] },
|
|
129
|
+
{ name: "app", dependsOn: ["net"], deploy: [{ phase: "Apply", steps: [{ kind: "fake-root", root: "app", cidr: { stackOutput: { stack: "net", name: "cidr" } } }] }] },
|
|
130
|
+
];
|
|
131
|
+
const netStep = ESTATE[0]!.deploy[0]!.steps[0] as Record<string, unknown>;
|
|
132
|
+
|
|
133
|
+
const { memoryGateLedgerPort } = await import("../../op/gate");
|
|
134
|
+
const { runComponentsPrApply, runComponentsPrPlan } = await import("./pr");
|
|
135
|
+
const { checkRunEventLines } = await import("../../workspace/__fixtures__/run-event-check");
|
|
136
|
+
|
|
137
|
+
let dir: string;
|
|
138
|
+
let record: string;
|
|
139
|
+
let errors: string[];
|
|
140
|
+
let out: string[];
|
|
141
|
+
|
|
142
|
+
const ctx = (a: Partial<ParsedArgs>) =>
|
|
143
|
+
({ args: { command: "components", path: "pr-apply", format: "", fix: false, watch: false, verbose: false, help: false, live: false, base: "main", env: "prod", output: dir, ...a } as ParsedArgs, plugins: [] }) as never;
|
|
144
|
+
/** One wave's job, as the generated pipeline runs it. */
|
|
145
|
+
const wave = (k?: number, a: Partial<ParsedArgs> = {}) => ctx({ pr: 12, waveGate: "release", resume: record, ...(k !== undefined ? { wave: k } : {}), ...a });
|
|
146
|
+
|
|
147
|
+
const resolution = (gate: string, planDigest: string, by = "github:alice") => ({
|
|
148
|
+
version: 1 as const,
|
|
149
|
+
kind: "resolution" as const,
|
|
150
|
+
op: "pr-12",
|
|
151
|
+
gate,
|
|
152
|
+
resolvedBy: by,
|
|
153
|
+
timestamp: new Date().toISOString(),
|
|
154
|
+
planDigest,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const report = async (): Promise<PrReport> => JSON.parse(await readFile(join(dir, "pr-apply.json"), "utf8")) as PrReport;
|
|
158
|
+
const note = async (): Promise<string> => readFile(join(dir, "pr-note.md"), "utf8");
|
|
159
|
+
|
|
160
|
+
/** Plan pull request 12, as its plan job does, and approve its digest as alice. */
|
|
161
|
+
async function planAndApprove(): Promise<string> {
|
|
162
|
+
expect(await runComponentsPrPlan(ctx({ pr: 12 }))).toBe(0);
|
|
163
|
+
const digest = (JSON.parse(await readFile(join(dir, "pr-plan.json"), "utf8")) as PrReport).digest;
|
|
164
|
+
gateLedger = memoryGateLedgerPort({ resolutions: [resolution("pr-apply", digest)] });
|
|
165
|
+
errors = [];
|
|
166
|
+
return digest;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
beforeEach(async () => {
|
|
170
|
+
dir = await mkdtemp(join(tmpdir(), "pr-waves-"));
|
|
171
|
+
record = join(dir, "resume", "pr-apply.json");
|
|
172
|
+
errors = [];
|
|
173
|
+
ran = [];
|
|
174
|
+
predicts = true;
|
|
175
|
+
for (const k of Object.keys(deployed)) delete deployed[k];
|
|
176
|
+
deployed.net = { cidr: "10.0.0.0/16" };
|
|
177
|
+
deployed.app = { cidr: "10.0.0.0/16" };
|
|
178
|
+
netStep.cidr = "10.0.0.0/16";
|
|
179
|
+
gateLedger = memoryGateLedgerPort();
|
|
180
|
+
out = [];
|
|
181
|
+
vi.spyOn(process.stdout, "write").mockImplementation((chunk: string | Uint8Array) => { out.push(String(chunk)); return true; });
|
|
182
|
+
vi.spyOn(console, "error").mockImplementation((...m: unknown[]) => { errors.push(m.map(String).join(" ")); });
|
|
183
|
+
vi.spyOn(console, "log").mockImplementation(() => {});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
afterEach(async () => {
|
|
187
|
+
vi.restoreAllMocks();
|
|
188
|
+
await rm(dir, { recursive: true, force: true });
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe("pr-apply --wave-gate: wave 2's plan matches the pull request's", () => {
|
|
192
|
+
test("wave 1 and then wave 2 apply under the pull request's approval, each in its own job", async () => {
|
|
193
|
+
const digest = await planAndApprove();
|
|
194
|
+
|
|
195
|
+
expect(await runComponentsPrApply(wave(1))).toBe(0);
|
|
196
|
+
expect(ran).toEqual(["net"]);
|
|
197
|
+
const first = await report();
|
|
198
|
+
expect(first).toMatchObject({ status: "wave-applied", waveGate: "release", digest, approval: { status: "approved", approvedBy: ["github:alice"] } });
|
|
199
|
+
expect(first.waves).toEqual([
|
|
200
|
+
{ wave: 1, components: ["net"], status: "applied", approval: "pull-request", gate: "pr-apply", digest, approvedBy: ["github:alice"] },
|
|
201
|
+
]);
|
|
202
|
+
expect(first.members.map((m) => [m.member, m.status])).toEqual([["app", "planned"], ["net", "applied"]]);
|
|
203
|
+
|
|
204
|
+
expect(await runComponentsPrApply(wave(2))).toBe(0);
|
|
205
|
+
expect(ran).toEqual(["net", "app"]);
|
|
206
|
+
const second = await report();
|
|
207
|
+
expect(second.status).toBe("applied");
|
|
208
|
+
expect(second.waves!.map((w) => [w.wave, w.status, w.approval, w.gate])).toEqual([
|
|
209
|
+
[1, "applied", "pull-request", "pr-apply"],
|
|
210
|
+
[2, "applied", "pull-request", "pr-apply"],
|
|
211
|
+
]);
|
|
212
|
+
expect(second.members.every((m) => m.status === "applied")).toBe(true);
|
|
213
|
+
// No wave gate was asked for: nothing waits on release-wave-2.
|
|
214
|
+
expect(gateLedger.appended.filter((r) => r.gate.startsWith("release-wave"))).toEqual([]);
|
|
215
|
+
expect(await note()).toContain("| 2 | `app` | applied | the pull request's `pr-apply` by `github:alice` |");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("a job past the change's last wave finds nothing left and exits 0", async () => {
|
|
219
|
+
await planAndApprove();
|
|
220
|
+
expect(await runComponentsPrApply(wave(1))).toBe(0);
|
|
221
|
+
expect(await runComponentsPrApply(wave(2))).toBe(0);
|
|
222
|
+
ran = [];
|
|
223
|
+
expect(await runComponentsPrApply(wave(3))).toBe(0);
|
|
224
|
+
expect(ran).toEqual([]);
|
|
225
|
+
expect((await report()).status).toBe("applied");
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe("pr-apply --wave-gate: wave 2's plan differs from the pull request's", () => {
|
|
230
|
+
/**
|
|
231
|
+
* The change moves net's cidr, which app reads, and net's plan cannot say
|
|
232
|
+
* it (#3999): the pull request planned app without it.
|
|
233
|
+
*/
|
|
234
|
+
async function movedChange(): Promise<string> {
|
|
235
|
+
predicts = false;
|
|
236
|
+
netStep.cidr = "10.1.0.0/16";
|
|
237
|
+
const digest = await planAndApprove();
|
|
238
|
+
expect(await runComponentsPrApply(wave(1))).toBe(0);
|
|
239
|
+
expect(deployed.net).toEqual({ cidr: "10.1.0.0/16" });
|
|
240
|
+
return digest;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
test("wave 2 waits at release-wave-2 with nothing applied and exits 3; the report and the note say why and how to approve", async () => {
|
|
244
|
+
const digest = await movedChange();
|
|
245
|
+
expect(await runComponentsPrApply(wave(2))).toBe(3);
|
|
246
|
+
expect(ran).toEqual(["net"]);
|
|
247
|
+
const r = await report();
|
|
248
|
+
expect(r).toMatchObject({ status: "refused", refusal: "wave-not-approved", digest });
|
|
249
|
+
const w2 = r.waves!.find((w) => w.wave === 2)!;
|
|
250
|
+
expect(w2).toMatchObject({ status: "waiting", approval: "wave", gate: "release-wave-2", components: ["app"] });
|
|
251
|
+
expect(w2.digest).not.toBe(digest);
|
|
252
|
+
expect(w2.moved).toEqual(["app now writes app.main.cidr to a different value than the approved plan"]);
|
|
253
|
+
expect(r.members.find((m) => m.member === "app")?.unknownInputs).toEqual(["net.cidr"]);
|
|
254
|
+
// The wave's own gate is pending under the pull request's op, bound to its new plan.
|
|
255
|
+
expect(gateLedger.appended).toEqual([expect.objectContaining({ kind: "pending", op: "pr-12", gate: "release-wave-2", planDigest: w2.digest })]);
|
|
256
|
+
expect(existsSync(join(dir, "change-set-wave-2.json"))).toBe(true);
|
|
257
|
+
expect(r.message).toContain(`chant approve pr-12 release-wave-2 --plan ${w2.digest}`);
|
|
258
|
+
const text = await note();
|
|
259
|
+
expect(text).toContain("Wave 2 waits at its gate `release-wave-2`; nothing in it applied.");
|
|
260
|
+
expect(text).toContain("| 1 | `net` | applied | the pull request's `pr-apply` by `github:alice` |");
|
|
261
|
+
expect(text).toContain("| 2 | `app` | waiting | its own `release-wave-2`, not approved yet |");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("--events-json: wave 2's job settles wave 1 already-applied, then its own gate waits, on one schema-valid stream", async () => {
|
|
265
|
+
await movedChange();
|
|
266
|
+
out = [];
|
|
267
|
+
expect(await runComponentsPrApply(wave(2, { eventsJson: true }))).toBe(3);
|
|
268
|
+
const events = checkRunEventLines(out);
|
|
269
|
+
expect(events[0]).toMatchObject({ type: "run-start", waves: [["net"], ["app"]] });
|
|
270
|
+
expect(events.find((e) => e.type === "component-settle")).toMatchObject({ component: "net", wave: 1, status: "already-applied" });
|
|
271
|
+
expect(events.find((e) => e.type === "gate-resolved")).toMatchObject({ op: "pr-12", gate: "pr-apply", resolvedBy: "github:alice" });
|
|
272
|
+
expect(events.find((e) => e.type === "gate-waiting")).toMatchObject({ op: "pr-12", gate: "release-wave-2" });
|
|
273
|
+
expect(events.some((e) => e.type === "wave-start")).toBe(false);
|
|
274
|
+
expect(events.at(-1)).toMatchObject({ type: "run-settle", status: "gated" });
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("an approval of wave 2's new plan releases it: the re-run applies it under that approval", async () => {
|
|
278
|
+
const digest = await movedChange();
|
|
279
|
+
expect(await runComponentsPrApply(wave(2))).toBe(3);
|
|
280
|
+
const w2 = (await report()).waves!.find((w) => w.wave === 2)!;
|
|
281
|
+
gateLedger = memoryGateLedgerPort({ resolutions: [resolution("pr-apply", digest), resolution("release-wave-2", w2.digest, "github:bob")] });
|
|
282
|
+
|
|
283
|
+
out = [];
|
|
284
|
+
expect(await runComponentsPrApply(wave(2, { eventsJson: true }))).toBe(0);
|
|
285
|
+
expect(ran).toEqual(["net", "app"]);
|
|
286
|
+
expect(deployed.app).toEqual({ cidr: "10.1.0.0/16" });
|
|
287
|
+
// The one wave's fan-out carries the derivation's wave number in the stream and the log.
|
|
288
|
+
const events = checkRunEventLines(out);
|
|
289
|
+
expect(events.filter((e) => e.type === "wave-start")).toEqual([expect.objectContaining({ wave: 2, components: ["app"] })]);
|
|
290
|
+
expect(events.find((e) => e.type === "component-settle" && e.component === "app")).toMatchObject({ wave: 2, status: "ok" });
|
|
291
|
+
expect(errors.some((e) => /wave 2\/2: app/.test(e))).toBe(true);
|
|
292
|
+
const r = await report();
|
|
293
|
+
expect(r.status).toBe("applied");
|
|
294
|
+
expect(r.waves!.map((w) => [w.wave, w.approval, w.gate, w.approvedBy])).toEqual([
|
|
295
|
+
[1, "pull-request", "pr-apply", ["github:alice"]],
|
|
296
|
+
[2, "wave", "release-wave-2", ["github:bob"]],
|
|
297
|
+
]);
|
|
298
|
+
expect(await note()).toContain("| 2 | `app` | applied | its own `release-wave-2` by `github:bob` |");
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("an approval of wave 2's gate for another plan is refused as wave-plan-changed, naming both digests", async () => {
|
|
302
|
+
const digest = await movedChange();
|
|
303
|
+
const stale = "jcs1-sha256:" + "0".repeat(64);
|
|
304
|
+
gateLedger = memoryGateLedgerPort({ resolutions: [resolution("pr-apply", digest), resolution("release-wave-2", stale, "github:bob")] });
|
|
305
|
+
expect(await runComponentsPrApply(wave(2))).toBe(3);
|
|
306
|
+
expect(ran).toEqual(["net"]);
|
|
307
|
+
const r = await report();
|
|
308
|
+
expect(r).toMatchObject({ status: "refused", refusal: "wave-plan-changed" });
|
|
309
|
+
expect(r.waves!.find((w) => w.wave === 2)).toMatchObject({ status: "waiting", approved: stale });
|
|
310
|
+
expect(r.message).toContain(`approved: ${stale}; planned now: ${r.waves![1]!.digest}`);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("without --wave the waves run in order in one job, which stops at wave 2's gate and finishes once it is approved", async () => {
|
|
314
|
+
predicts = false;
|
|
315
|
+
netStep.cidr = "10.1.0.0/16";
|
|
316
|
+
const digest = await planAndApprove();
|
|
317
|
+
expect(await runComponentsPrApply(wave())).toBe(3);
|
|
318
|
+
expect(ran).toEqual(["net"]);
|
|
319
|
+
const w2 = (await report()).waves!.find((w) => w.wave === 2)!;
|
|
320
|
+
gateLedger = memoryGateLedgerPort({ resolutions: [resolution("pr-apply", digest), resolution("release-wave-2", w2.digest, "github:bob")] });
|
|
321
|
+
expect(await runComponentsPrApply(wave())).toBe(0);
|
|
322
|
+
expect(ran).toEqual(["net", "app"]);
|
|
323
|
+
expect((await report()).status).toBe("applied");
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test("without --wave-gate the same change holds app back and plans it again at pr-apply-round-2 once net applied (#3999)", async () => {
|
|
327
|
+
predicts = false;
|
|
328
|
+
netStep.cidr = "10.1.0.0/16";
|
|
329
|
+
await planAndApprove();
|
|
330
|
+
expect(await runComponentsPrApply(ctx({ pr: 12 }))).toBe(3);
|
|
331
|
+
expect(ran).toEqual(["net"]);
|
|
332
|
+
expect(deployed.app).toEqual({ cidr: "10.0.0.0/16" });
|
|
333
|
+
const r = await report();
|
|
334
|
+
expect(r.status).toBe("waiting");
|
|
335
|
+
expect(r.rounds?.map((x) => [x.gate, x.components, x.status])).toEqual([["pr-apply-round-2", ["app"], "waiting"]]);
|
|
336
|
+
expect(r.members.find((m) => m.member === "app")).toMatchObject({ status: "waiting", round: 2 });
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
describe("a moved output a plan can say (#3999)", () => {
|
|
341
|
+
test("pr-plan plans app against the cidr net's plan says, so wave 2 applies under the pull request's approval", async () => {
|
|
342
|
+
netStep.cidr = "10.1.0.0/16";
|
|
343
|
+
const digest = await planAndApprove();
|
|
344
|
+
const plan = JSON.parse(await readFile(join(dir, "pr-plan.json"), "utf8")) as PrReport;
|
|
345
|
+
expect(plan.plannedOutputs).toEqual({ net: ["cidr"] });
|
|
346
|
+
expect(await runComponentsPrApply(wave(1))).toBe(0);
|
|
347
|
+
expect(await runComponentsPrApply(wave(2))).toBe(0);
|
|
348
|
+
expect(deployed.app).toEqual({ cidr: "10.1.0.0/16" });
|
|
349
|
+
const r = await report();
|
|
350
|
+
expect(r.waves!.map((w) => [w.wave, w.approval, w.digest])).toEqual([
|
|
351
|
+
[1, "pull-request", digest],
|
|
352
|
+
[2, "pull-request", digest],
|
|
353
|
+
]);
|
|
354
|
+
expect(gateLedger.appended.filter((x) => x.gate.startsWith("release-wave"))).toEqual([]);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test("without --wave-gate the same change applies app against the new cidr under the one approval", async () => {
|
|
358
|
+
netStep.cidr = "10.1.0.0/16";
|
|
359
|
+
await planAndApprove();
|
|
360
|
+
expect(await runComponentsPrApply(ctx({ pr: 12 }))).toBe(0);
|
|
361
|
+
expect(deployed.app).toEqual({ cidr: "10.1.0.0/16" });
|
|
362
|
+
expect((await report()).rounds).toBeUndefined();
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
describe("pr-plan --wave-gate", () => {
|
|
367
|
+
test("the plan's report names the wave gate and its note says later waves may stop at their own gate", async () => {
|
|
368
|
+
expect(await runComponentsPrPlan(ctx({ pr: 12, waveGate: "release" }))).toBe(0);
|
|
369
|
+
const plan = JSON.parse(await readFile(join(dir, "pr-plan.json"), "utf8")) as PrReport;
|
|
370
|
+
expect(plan.waveGate).toBe("release");
|
|
371
|
+
expect(plan.waves).toBeUndefined();
|
|
372
|
+
expect(await readFile(join(dir, "pr-note.md"), "utf8")).toContain("stops at its own gate, `release-wave-<k>`, for approval.");
|
|
373
|
+
expect(await runComponentsPrPlan(ctx({ pr: 12 }))).toBe(0);
|
|
374
|
+
expect(await readFile(join(dir, "pr-note.md"), "utf8")).not.toContain("planned again");
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
describe("pr-apply --wave-gate refuses", () => {
|
|
379
|
+
test("wave 1 without the pull request's approval: not-approved, exit 3, nothing applied", async () => {
|
|
380
|
+
expect(await runComponentsPrPlan(ctx({ pr: 12 }))).toBe(0);
|
|
381
|
+
expect(await runComponentsPrApply(wave(1))).toBe(3);
|
|
382
|
+
expect(ran).toEqual([]);
|
|
383
|
+
expect(await report()).toMatchObject({ status: "refused", refusal: "not-approved" });
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("wave 2 before wave 1 applied, or with no record of it: earlier-wave, exit 1, nothing planned or applied", async () => {
|
|
387
|
+
await planAndApprove();
|
|
388
|
+
expect(await runComponentsPrApply(wave(2))).toBe(1);
|
|
389
|
+
expect(ran).toEqual([]);
|
|
390
|
+
const r = await report();
|
|
391
|
+
expect(r).toMatchObject({ status: "refused", refusal: "earlier-wave" });
|
|
392
|
+
expect(r.message).toContain("Wave 2 cannot run yet: wave 1 has not applied (net)");
|
|
393
|
+
expect(gateLedger.appended).toEqual([]);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test("a record whose pull-request approval was withdrawn does not carry, so wave 2 refuses", async () => {
|
|
397
|
+
await planAndApprove();
|
|
398
|
+
expect(await runComponentsPrApply(wave(1))).toBe(0);
|
|
399
|
+
gateLedger = memoryGateLedgerPort();
|
|
400
|
+
expect(await runComponentsPrApply(wave(2))).toBe(1);
|
|
401
|
+
expect(errors.some((e) => e.includes("no approval stands for the plan it applied"))).toBe(true);
|
|
402
|
+
expect(await report()).toMatchObject({ status: "refused", refusal: "earlier-wave" });
|
|
403
|
+
expect(ran).toEqual(["net"]);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
test("--wave without --wave-gate", async () => {
|
|
407
|
+
expect(await runComponentsPrApply(ctx({ pr: 12, wave: 1 }))).toBe(1);
|
|
408
|
+
expect(errors.some((e) => e.includes("--wave runs one wave of a --wave-gate apply"))).toBe(true);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
describe("--require-review on the wave's own approval (#4000)", () => {
|
|
412
|
+
const ENV = { GITHUB_REPOSITORY: "acme/infra", GITHUB_TOKEN: "t0k", GITHUB_API_URL: "https://api.github.test", GITHUB_SERVER_URL: "https://github.com" };
|
|
413
|
+
beforeEach(() => {
|
|
414
|
+
for (const [k, v] of Object.entries(ENV)) vi.stubEnv(k, v);
|
|
415
|
+
// Pull request 12: carol wrote it, alice and carol approved it.
|
|
416
|
+
const ok = (value: unknown) => ({ ok: true, status: 200, text: async () => JSON.stringify(value) });
|
|
417
|
+
vi.stubGlobal("fetch", async (url: string, init: { method: string }) => {
|
|
418
|
+
const { pathname } = new URL(url);
|
|
419
|
+
if (init.method !== "GET") return ok({ id: 1 });
|
|
420
|
+
const pull = "/repos/acme/infra/pulls/12";
|
|
421
|
+
if (pathname === `${pull}/reviews`) return ok([{ user: { login: "alice" }, state: "APPROVED" }, { user: { login: "carol" }, state: "APPROVED" }]);
|
|
422
|
+
if (pathname === pull) return ok({ number: 12, user: { login: "carol" } });
|
|
423
|
+
if (pathname === `${pull}/commits`) return ok([{ author: { login: "carol" }, committer: { login: "carol" } }]);
|
|
424
|
+
return ok([]);
|
|
425
|
+
});
|
|
426
|
+
});
|
|
427
|
+
afterEach(() => {
|
|
428
|
+
vi.unstubAllGlobals();
|
|
429
|
+
vi.unstubAllEnvs();
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test("the author approves wave 2's moved plan: refused as author-approved, and wave 2 still waits", async () => {
|
|
433
|
+
predicts = false;
|
|
434
|
+
netStep.cidr = "10.1.0.0/16";
|
|
435
|
+
const digest = await planAndApprove();
|
|
436
|
+
const review = { forge: "github", requireReview: true };
|
|
437
|
+
expect(await runComponentsPrApply(wave(1, review))).toBe(0);
|
|
438
|
+
expect(await runComponentsPrApply(wave(2, review))).toBe(3);
|
|
439
|
+
const w2 = (await report()).waves!.find((w) => w.wave === 2)!;
|
|
440
|
+
gateLedger = memoryGateLedgerPort({ resolutions: [resolution("pr-apply", digest), resolution("release-wave-2", w2.digest, "github:carol")] });
|
|
441
|
+
expect(await runComponentsPrApply(wave(2, review))).toBe(3);
|
|
442
|
+
const r = await report();
|
|
443
|
+
expect(r).toMatchObject({ status: "refused", refusal: "author-approved" });
|
|
444
|
+
expect(r.message).toContain("Wave 2's plan is approved by github:carol, who wrote #12");
|
|
445
|
+
expect(r.waves!.find((w) => w.wave === 2)).toMatchObject({ status: "waiting" });
|
|
446
|
+
expect(ran).toEqual(["net"]);
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
});
|
|
@@ -14,6 +14,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
|
14
14
|
import type { DriverComponent } from "../../components/driver";
|
|
15
15
|
import type { DeployContext } from "../../components/capability";
|
|
16
16
|
import type { ParsedArgs } from "../registry";
|
|
17
|
+
import { forgePrincipalOf } from "../../pr-forge";
|
|
17
18
|
|
|
18
19
|
const HEAD = "a".repeat(40);
|
|
19
20
|
const BASE = "b".repeat(40);
|
|
@@ -59,6 +60,8 @@ vi.mock("../../op/gate", async () => {
|
|
|
59
60
|
});
|
|
60
61
|
|
|
61
62
|
const failing = new Set<string>();
|
|
63
|
+
/** The error a root's apply throws, in place of applying (#3997). */
|
|
64
|
+
const refusal = new Map<string, Error>();
|
|
62
65
|
/** Called as a component's apply starts, before it can fail. */
|
|
63
66
|
let onRun: (root: string) => void = () => {};
|
|
64
67
|
vi.mock("../../components/fan-out-support", async () => {
|
|
@@ -82,6 +85,7 @@ vi.mock("../../components/fan-out-support", async () => {
|
|
|
82
85
|
},
|
|
83
86
|
async run(_ctx: DeployContext, input: { root: string }) {
|
|
84
87
|
onRun(input.root);
|
|
88
|
+
if (refusal.has(input.root)) throw refusal.get(input.root);
|
|
85
89
|
if (failing.has(input.root)) throw new Error(`${input.root} failed`);
|
|
86
90
|
return { outputs: {} };
|
|
87
91
|
},
|
|
@@ -120,6 +124,7 @@ beforeEach(async () => {
|
|
|
120
124
|
out = [];
|
|
121
125
|
errors = [];
|
|
122
126
|
failing.clear();
|
|
127
|
+
refusal.clear();
|
|
123
128
|
onRun = () => {};
|
|
124
129
|
gateLedger = memoryGateLedgerPort();
|
|
125
130
|
vi.spyOn(process.stdout, "write").mockImplementation((chunk: string | Uint8Array) => { out.push(String(chunk)); return true; });
|
|
@@ -339,3 +344,132 @@ describe("--events-json beside --json", () => {
|
|
|
339
344
|
expect(out).toEqual([]);
|
|
340
345
|
});
|
|
341
346
|
});
|
|
347
|
+
|
|
348
|
+
describe("--require-review: whoever wrote the change never counts (#4000)", () => {
|
|
349
|
+
type Kind = "github" | "forgejo" | "gitlab";
|
|
350
|
+
const ENV: Record<Kind, Record<string, string>> = {
|
|
351
|
+
github: { GITHUB_REPOSITORY: "acme/infra", GITHUB_TOKEN: "t0k", GITHUB_API_URL: "https://api.github.test", GITHUB_SERVER_URL: "https://github.com" },
|
|
352
|
+
forgejo: { GITHUB_REPOSITORY: "acme/infra", GITHUB_TOKEN: "t0k", GITHUB_API_URL: "https://code.example.test/api/v1", GITHUB_SERVER_URL: "https://code.example.test" },
|
|
353
|
+
gitlab: { CI_API_V4_URL: "https://gitlab.example.test/api/v4", CI_PROJECT_ID: "42", CHANT_FORGE_TOKEN: "t0k", CI_SERVER_HOST: "gitlab.example.test" },
|
|
354
|
+
};
|
|
355
|
+
const HOST: Record<Kind, string> = { github: "github.com", forgejo: "code.example.test", gitlab: "gitlab.example.test" };
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Pull request 12 as each forge serves it: carol opened it, dave pushed a
|
|
359
|
+
* commit to it, and `approver` approved it. GitLab names a commit's people
|
|
360
|
+
* by email, which the users search maps to an account.
|
|
361
|
+
*/
|
|
362
|
+
function serve(kind: Kind, approver: string): string[] {
|
|
363
|
+
const asked: string[] = [];
|
|
364
|
+
const ok = (value: unknown) => ({ ok: true, status: 200, text: async () => JSON.stringify(value) });
|
|
365
|
+
vi.stubGlobal("fetch", async (url: string, init: { method: string }) => {
|
|
366
|
+
const { pathname, searchParams } = new URL(url);
|
|
367
|
+
asked.push(`${init.method} ${pathname}`);
|
|
368
|
+
if (init.method !== "GET") return ok({ id: 1 });
|
|
369
|
+
if (kind === "gitlab") {
|
|
370
|
+
const mr = "/api/v4/projects/42/merge_requests/12";
|
|
371
|
+
if (pathname === `${mr}/approvals`) return ok({ approved_by: [{ user: { username: approver } }] });
|
|
372
|
+
if (pathname === mr) return ok({ iid: 12, author: { username: "carol" } });
|
|
373
|
+
if (pathname === `${mr}/commits`)
|
|
374
|
+
return ok([
|
|
375
|
+
{ author_email: "carol@example.test", committer_email: "carol@example.test" },
|
|
376
|
+
{ author_email: "Dave@example.test", committer_email: "dave@example.test" },
|
|
377
|
+
]);
|
|
378
|
+
if (pathname === "/api/v4/users") return ok({ "carol@example.test": [{ username: "carol" }], "dave@example.test": [{ username: "dave" }] }[searchParams.get("search")!] ?? []);
|
|
379
|
+
return ok([]);
|
|
380
|
+
}
|
|
381
|
+
const pull = `${kind === "github" ? "" : "/api/v1"}/repos/acme/infra/pulls/12`;
|
|
382
|
+
if (pathname === `${pull}/reviews`) return ok([{ user: { login: approver }, state: "APPROVED" }]);
|
|
383
|
+
if (pathname === pull) return ok({ number: 12, user: { login: "carol" } });
|
|
384
|
+
if (pathname === `${pull}/commits`)
|
|
385
|
+
return ok([
|
|
386
|
+
{ author: { login: "carol" }, committer: { login: "carol" } },
|
|
387
|
+
{ author: null, committer: { login: "dave" } },
|
|
388
|
+
]);
|
|
389
|
+
return ok([]);
|
|
390
|
+
});
|
|
391
|
+
return asked;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async function approveAs(kind: Kind, login: string): Promise<void> {
|
|
395
|
+
await runComponentsPrPlan(ctx({ pr: 12 }));
|
|
396
|
+
const digest = await plannedDigest();
|
|
397
|
+
gateLedger = memoryGateLedgerPort({
|
|
398
|
+
resolutions: [{ version: 1, kind: "resolution", op: "pr-12", gate: "pr-apply", resolvedBy: forgePrincipalOf(kind, HOST[kind], login), timestamp: new Date().toISOString(), planDigest: digest }],
|
|
399
|
+
});
|
|
400
|
+
out = [];
|
|
401
|
+
errors = [];
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const applyReport = async () => {
|
|
405
|
+
const { readFile } = await import("node:fs/promises");
|
|
406
|
+
return JSON.parse(await readFile(join(dir, "pr-apply.json"), "utf8")) as { status: string; refusal?: string; message?: string };
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
afterEach(() => {
|
|
410
|
+
vi.unstubAllGlobals();
|
|
411
|
+
vi.unstubAllEnvs();
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
for (const kind of ["github", "forgejo", "gitlab"] as const) {
|
|
415
|
+
describe(kind, () => {
|
|
416
|
+
beforeEach(() => {
|
|
417
|
+
for (const [k, v] of Object.entries(ENV[kind])) vi.stubEnv(k, v);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test("the author approves: refused as author-approved, naming them, and nothing applies", async () => {
|
|
421
|
+
serve(kind, "carol");
|
|
422
|
+
await approveAs(kind, "carol");
|
|
423
|
+
let ran = false;
|
|
424
|
+
onRun = () => { ran = true; };
|
|
425
|
+
expect(await runComponentsPrApply(ctx({ pr: 12, forge: kind, requireReview: true, eventsJson: false }))).toBe(3);
|
|
426
|
+
const report = await applyReport();
|
|
427
|
+
expect(report).toMatchObject({ status: "refused", refusal: "author-approved" });
|
|
428
|
+
expect(report.message).toContain(`approved by ${forgePrincipalOf(kind, HOST[kind], "carol")}, who wrote #12`);
|
|
429
|
+
expect(report.message).toContain("never counts");
|
|
430
|
+
expect(ran).toBe(false);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test("a co-author who pushed a commit approves: refused as author-approved", async () => {
|
|
434
|
+
serve(kind, "dave");
|
|
435
|
+
await approveAs(kind, "dave");
|
|
436
|
+
expect(await runComponentsPrApply(ctx({ pr: 12, forge: kind, requireReview: true, eventsJson: false }))).toBe(3);
|
|
437
|
+
const report = await applyReport();
|
|
438
|
+
expect(report).toMatchObject({ status: "refused", refusal: "author-approved" });
|
|
439
|
+
expect(report.message).toContain(`approved by ${forgePrincipalOf(kind, HOST[kind], "dave")}, who wrote #12`);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
test("an outside reviewer approves: the plan applies", async () => {
|
|
443
|
+
serve(kind, "erin");
|
|
444
|
+
await approveAs(kind, "erin");
|
|
445
|
+
expect(await runComponentsPrApply(ctx({ pr: 12, forge: kind, requireReview: true, eventsJson: false }))).toBe(0);
|
|
446
|
+
expect(await applyReport()).toMatchObject({ status: "applied" });
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
test("--no-status (#4161): pr-plan asks the forge nothing, and pr-apply only reads the pull request", async () => {
|
|
450
|
+
const asked = serve(kind, "erin");
|
|
451
|
+
expect(await runComponentsPrPlan(ctx({ pr: 12, forge: kind, noStatus: true, eventsJson: false }))).toBe(0);
|
|
452
|
+
expect(asked).toEqual([]);
|
|
453
|
+
await approveAs(kind, "erin");
|
|
454
|
+
expect(await runComponentsPrApply(ctx({ pr: 12, forge: kind, requireReview: true, noStatus: true, eventsJson: false }))).toBe(0);
|
|
455
|
+
expect(await applyReport()).toMatchObject({ status: "applied" });
|
|
456
|
+
expect(asked.length).toBeGreaterThan(0);
|
|
457
|
+
expect(asked.filter((a) => !a.startsWith("GET "))).toEqual([]);
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
describe("pr-apply: a step that refuses its plan because the plan moved (#3997)", () => {
|
|
464
|
+
test.each([
|
|
465
|
+
["marked planMoved", 3, { planMoved: true }],
|
|
466
|
+
["with exitCode 3", 3, { exitCode: 3 }],
|
|
467
|
+
["with exitCode 7", 1, { exitCode: 7 }],
|
|
468
|
+
["unmarked", 1, {}],
|
|
469
|
+
])("an error %s exits %i, after the gate passed", async (_label, code, mark) => {
|
|
470
|
+
await planAndApprove();
|
|
471
|
+
refusal.set("app", Object.assign(new Error("app: refusing to apply, the plan moved"), mark));
|
|
472
|
+
expect(await runComponentsPrApply(ctx({ pr: 12, eventsJson: false }))).toBe(code);
|
|
473
|
+
expect(logLines()).toEqual(["wave 1/2: net", " net: applied", "wave 2/2: app", " app: failed"]);
|
|
474
|
+
});
|
|
475
|
+
});
|