@intentius/chant 0.124.32 → 0.124.33
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/ci-jobs.d.ts +4 -0
- package/dist/ci-jobs.d.ts.map +1 -1
- package/dist/ci-jobs.js +9 -2
- package/dist/cli/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/build.js +18 -3
- package/dist/cli/handlers/lifecycle-health.d.ts +16 -0
- package/dist/cli/handlers/lifecycle-health.d.ts.map +1 -0
- package/dist/cli/handlers/lifecycle-health.js +150 -0
- package/dist/cli/handlers/lifecycle.d.ts +26 -0
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.js +1 -1
- package/dist/cli/handlers/pr.d.ts +2 -0
- package/dist/cli/handlers/pr.d.ts.map +1 -1
- package/dist/cli/handlers/pr.js +124 -2
- package/dist/cli/handlers/promote.d.ts.map +1 -1
- package/dist/cli/handlers/promote.js +16 -1
- package/dist/cli/handlers/release.d.ts +16 -1
- package/dist/cli/handlers/release.d.ts.map +1 -1
- package/dist/cli/handlers/release.js +83 -11
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +81 -21
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +23 -1
- package/dist/cli/registry.d.ts +18 -4
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/deploy-inputs.d.ts +91 -0
- package/dist/components/deploy-inputs.d.ts.map +1 -0
- package/dist/components/deploy-inputs.js +184 -0
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/components/driver.js +9 -0
- package/dist/components/gate-plan.d.ts +8 -0
- package/dist/components/gate-plan.d.ts.map +1 -1
- package/dist/components/gate-plan.js +6 -0
- package/dist/components/pr-pipeline.d.ts +33 -10
- package/dist/components/pr-pipeline.d.ts.map +1 -1
- package/dist/components/pr-pipeline.js +42 -11
- package/dist/components/pr-run.d.ts +1 -1
- package/dist/components/pr-run.d.ts.map +1 -1
- package/dist/components/release-compile.d.ts +37 -2
- package/dist/components/release-compile.d.ts.map +1 -1
- package/dist/components/release-compile.js +64 -8
- package/dist/components/release-promote.d.ts +17 -3
- package/dist/components/release-promote.d.ts.map +1 -1
- package/dist/components/release-promote.js +79 -18
- package/dist/components/verbs/bom-writer.d.ts +1 -1
- package/dist/components/verbs/bom-writer.d.ts.map +1 -1
- package/dist/components/verbs/lockfile-sbom-generator.d.ts +20 -0
- package/dist/components/verbs/lockfile-sbom-generator.d.ts.map +1 -1
- package/dist/components/verbs/lockfile-sbom-generator.js +41 -4
- package/dist/components/wave-plan.d.ts +10 -0
- package/dist/components/wave-plan.d.ts.map +1 -1
- package/dist/components/wave-plan.js +32 -4
- package/dist/graph-detail.d.ts.map +1 -1
- package/dist/graph-detail.js +32 -7
- package/dist/health.d.ts +110 -0
- package/dist/health.d.ts.map +1 -0
- package/dist/health.js +105 -0
- package/dist/health.schema.json +191 -0
- package/dist/lexicon.d.ts +27 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/merge-record.d.ts +104 -0
- package/dist/lifecycle/merge-record.d.ts.map +1 -0
- package/dist/lifecycle/merge-record.js +102 -0
- package/dist/lifecycle/publish-attachments.d.ts +8 -5
- package/dist/lifecycle/publish-attachments.d.ts.map +1 -1
- package/dist/lifecycle/publish-attachments.js +27 -13
- package/dist/lifecycle/publish-npm.d.ts +3 -2
- package/dist/lifecycle/publish-npm.d.ts.map +1 -1
- package/dist/lifecycle/publish-npm.js +4 -3
- package/dist/lifecycle/release-at.d.ts +40 -0
- package/dist/lifecycle/release-at.d.ts.map +1 -0
- package/dist/lifecycle/release-at.js +83 -0
- package/dist/lifecycle/release-cut.d.ts.map +1 -1
- package/dist/lifecycle/release-cut.js +23 -12
- package/dist/lifecycle/release-publish.d.ts +28 -12
- package/dist/lifecycle/release-publish.d.ts.map +1 -1
- package/dist/lifecycle/release-publish.js +29 -17
- package/dist/lifecycle/release-store.d.ts +25 -2
- package/dist/lifecycle/release-store.d.ts.map +1 -1
- package/dist/lifecycle/release-store.js +40 -13
- package/dist/lifecycle/run-ledger.d.ts.map +1 -1
- package/dist/lifecycle/run-ledger.js +1 -0
- package/dist/lint/rules/op/index.d.ts +2 -1
- package/dist/lint/rules/op/index.d.ts.map +1 -1
- package/dist/lint/rules/op/index.js +4 -2
- package/dist/lint/rules/op/ops018-shell-image-digest.d.ts +10 -0
- package/dist/lint/rules/op/ops018-shell-image-digest.d.ts.map +1 -0
- package/dist/lint/rules/op/ops018-shell-image-digest.js +38 -0
- package/dist/op/activities/activity-contracts.d.ts +5 -0
- package/dist/op/activities/activity-contracts.d.ts.map +1 -1
- package/dist/op/activities/activity-contracts.js +3 -0
- package/dist/op/activities/shell.d.ts +38 -0
- package/dist/op/activities/shell.d.ts.map +1 -1
- package/dist/op/activities/shell.js +89 -3
- package/dist/op/builders.d.ts +7 -0
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/builders.js +7 -0
- package/dist/op/runtime.d.ts +2 -0
- package/dist/op/runtime.d.ts.map +1 -1
- package/dist/pr-loop.d.ts +14 -3
- package/dist/pr-loop.d.ts.map +1 -1
- package/dist/pr-loop.js +15 -5
- package/dist/pr-report-url.d.ts +2 -2
- package/dist/pr-report-url.d.ts.map +1 -1
- package/dist/runner-start.d.ts +179 -0
- package/dist/runner-start.d.ts.map +1 -0
- package/dist/runner-start.js +277 -0
- package/dist/wave-runtime.mjs +235 -228
- package/dist/workspace/check.schema.json +70 -2
- package/dist/workspace/checks/security.d.ts +35 -0
- package/dist/workspace/checks/security.d.ts.map +1 -0
- package/dist/workspace/checks/security.js +305 -0
- package/dist/workspace/checks.d.ts +2 -0
- package/dist/workspace/checks.d.ts.map +1 -1
- package/dist/workspace/checks.js +5 -0
- package/dist/workspace/ci-builds.d.ts +163 -0
- package/dist/workspace/ci-builds.d.ts.map +1 -0
- package/dist/workspace/ci-builds.js +315 -0
- package/dist/workspace/ci-green-cli.d.ts +2 -1
- package/dist/workspace/ci-green-cli.d.ts.map +1 -1
- package/dist/workspace/ci-green-cli.js +25 -5
- package/dist/workspace/ci-green-forge.d.ts +12 -0
- package/dist/workspace/ci-green-forge.d.ts.map +1 -1
- package/dist/workspace/ci-green-forge.js +12 -0
- package/dist/workspace/ci-green-workflow.d.ts +55 -1
- package/dist/workspace/ci-green-workflow.d.ts.map +1 -1
- package/dist/workspace/ci-green-workflow.js +128 -1
- package/dist/workspace/ci-green.d.ts +89 -4
- package/dist/workspace/ci-green.d.ts.map +1 -1
- package/dist/workspace/ci-green.js +310 -23
- package/dist/workspace/ci-last-green.schema.json +49 -0
- package/dist/workspace/ci-list.schema.json +35 -1
- package/dist/workspace/ci-run-index.d.ts +1 -1
- package/dist/workspace/ci-run-index.d.ts.map +1 -1
- package/dist/workspace/ci-run-index.schema.json +1 -1
- package/dist/workspace/ci-run.d.ts +12 -6
- package/dist/workspace/ci-run.d.ts.map +1 -1
- package/dist/workspace/ci-run.js +72 -16
- package/dist/workspace/ci-runner-start.d.ts +77 -0
- package/dist/workspace/ci-runner-start.d.ts.map +1 -0
- package/dist/workspace/ci-runner-start.js +185 -0
- package/dist/workspace/ci-serve.d.ts +76 -7
- package/dist/workspace/ci-serve.d.ts.map +1 -1
- package/dist/workspace/ci-serve.js +153 -19
- package/dist/workspace/declaration.d.ts +17 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.js +3 -0
- package/dist/workspace/declaration.schema.json +23 -0
- package/dist/workspace/ls.d.ts +2 -0
- package/dist/workspace/ls.d.ts.map +1 -1
- package/dist/workspace/ls.js +1 -1
- package/dist/workspace/ls.schema.json +2 -1
- package/dist/workspace/member-pipeline.d.ts +1 -0
- package/dist/workspace/member-pipeline.d.ts.map +1 -1
- package/dist/workspace/member-pipeline.js +2 -0
- package/dist/workspace/merge-record.schema.json +49 -0
- package/dist/workspace/pr-report.schema.json +3 -3
- package/dist/workspace/reason-codes.d.ts +17 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/reason-codes.js +18 -0
- package/dist/workspace/release-candidates.d.ts +129 -0
- package/dist/workspace/release-candidates.d.ts.map +1 -0
- package/dist/workspace/release-candidates.js +233 -0
- package/dist/workspace/release-candidates.schema.json +353 -0
- package/dist/workspace/security.d.ts +40 -0
- package/dist/workspace/security.d.ts.map +1 -0
- package/dist/workspace/security.js +99 -0
- package/package.json +8 -1
- package/src/__fixtures__/pr-loop/pr-plan.note.golden.md +1 -1
- package/src/ci-jobs.ts +11 -2
- package/src/cli/handlers/build.test.ts +36 -0
- package/src/cli/handlers/build.ts +18 -3
- package/src/cli/handlers/lifecycle-health.test.ts +123 -0
- package/src/cli/handlers/lifecycle-health.ts +157 -0
- package/src/cli/handlers/lifecycle.ts +1 -1
- package/src/cli/handlers/pr-deploy-inputs.test.ts +209 -0
- package/src/cli/handlers/pr-record.test.ts +201 -0
- package/src/cli/handlers/pr.test.ts +22 -0
- package/src/cli/handlers/pr.ts +127 -4
- package/src/cli/handlers/promote.test.ts +17 -0
- package/src/cli/handlers/promote.ts +16 -1
- package/src/cli/handlers/release-cut-at.e2e.test.ts +115 -0
- package/src/cli/handlers/release-publish-targets.e2e.test.ts +2 -2
- package/src/cli/handlers/release-verify.e2e.test.ts +12 -10
- package/src/cli/handlers/release.e2e.test.ts +19 -28
- package/src/cli/handlers/release.ts +81 -11
- package/src/cli/main.ts +81 -21
- package/src/cli/parse-args.ts +17 -1
- package/src/cli/registry.ts +18 -4
- package/src/components/deploy-inputs.test.ts +108 -0
- package/src/components/deploy-inputs.ts +225 -0
- package/src/components/driver.ts +11 -0
- package/src/components/gate-plan.ts +9 -0
- package/src/components/pr-pipeline.ts +56 -15
- package/src/components/pr-run.ts +1 -1
- package/src/components/release-compile.test.ts +34 -0
- package/src/components/release-compile.ts +89 -8
- package/src/components/release-promote.test.ts +55 -2
- package/src/components/release-promote.ts +93 -23
- package/src/components/verbs/bom-writer.ts +1 -1
- package/src/components/verbs/lockfile-sbom-generator.test.ts +36 -0
- package/src/components/verbs/lockfile-sbom-generator.ts +43 -5
- package/src/components/wave-plan.ts +32 -4
- package/src/graph-detail.test.ts +57 -0
- package/src/graph-detail.ts +33 -7
- package/src/health.schema.json +191 -0
- package/src/health.test.ts +62 -0
- package/src/health.ts +178 -0
- package/src/lexicon.ts +41 -1
- package/src/lifecycle/merge-record.test.ts +111 -0
- package/src/lifecycle/merge-record.ts +154 -0
- package/src/lifecycle/publish-attachments.ts +25 -13
- package/src/lifecycle/publish-npm.ts +4 -3
- package/src/lifecycle/publish-targets.test.ts +34 -4
- package/src/lifecycle/release-at.test.ts +48 -0
- package/src/lifecycle/release-at.ts +100 -0
- package/src/lifecycle/release-cut.test.ts +41 -0
- package/src/lifecycle/release-cut.ts +22 -11
- package/src/lifecycle/release-publish.test.ts +43 -13
- package/src/lifecycle/release-publish.ts +38 -18
- package/src/lifecycle/release-store.ts +57 -13
- package/src/lifecycle/run-ledger.test.ts +13 -0
- package/src/lifecycle/run-ledger.ts +1 -0
- package/src/lint/rules/op/index.ts +4 -2
- package/src/lint/rules/op/ops018-shell-image-digest.test.ts +60 -0
- package/src/lint/rules/op/ops018-shell-image-digest.ts +48 -0
- package/src/op/activities/activity-contracts.ts +3 -0
- package/src/op/activities/shell-container.e2e.test.ts +67 -0
- package/src/op/activities/shell.test.ts +44 -1
- package/src/op/activities/shell.ts +110 -3
- package/src/op/builders.ts +7 -0
- package/src/op/runtime.ts +2 -0
- package/src/op/step-console.test.ts +4 -2
- package/src/pr-loop.test.ts +29 -0
- package/src/pr-loop.ts +26 -7
- package/src/pr-report-url.ts +2 -2
- package/src/runner-start-bundle.test.ts +29 -0
- package/src/runner-start.test.ts +179 -0
- package/src/runner-start.ts +329 -0
- package/src/workspace/check-contract.test.ts +3 -2
- package/src/workspace/check.schema.json +70 -2
- package/src/workspace/checks/security.test.ts +138 -0
- package/src/workspace/checks/security.ts +305 -0
- package/src/workspace/checks.ts +5 -0
- package/src/workspace/ci-builds.e2e.test.ts +208 -0
- package/src/workspace/ci-builds.test.ts +174 -0
- package/src/workspace/ci-builds.ts +385 -0
- package/src/workspace/ci-green-builds.test.ts +180 -0
- package/src/workspace/ci-green-cli.test.ts +54 -0
- package/src/workspace/ci-green-cli.ts +26 -5
- package/src/workspace/ci-green-forge.ts +24 -0
- package/src/workspace/ci-green-workflow.test.ts +84 -1
- package/src/workspace/ci-green-workflow.ts +139 -2
- package/src/workspace/ci-green.test.ts +5 -2
- package/src/workspace/ci-green.ts +359 -27
- package/src/workspace/ci-last-green.schema.json +49 -0
- package/src/workspace/ci-list.schema.json +35 -1
- package/src/workspace/ci-run-index.schema.json +1 -1
- package/src/workspace/ci-run-index.ts +1 -1
- package/src/workspace/ci-run.test.ts +24 -7
- package/src/workspace/ci-run.ts +78 -16
- package/src/workspace/ci-runner-start.test.ts +151 -0
- package/src/workspace/ci-runner-start.ts +193 -0
- package/src/workspace/ci-serve-builds.test.ts +86 -0
- package/src/workspace/ci-serve.test.ts +59 -0
- package/src/workspace/ci-serve.ts +177 -22
- package/src/workspace/declaration.schema.json +23 -0
- package/src/workspace/declaration.test.ts +17 -0
- package/src/workspace/declaration.ts +22 -1
- package/src/workspace/ls.schema.json +2 -1
- package/src/workspace/ls.ts +3 -2
- package/src/workspace/member-pipeline.ts +2 -1
- package/src/workspace/merge-record.schema.json +49 -0
- package/src/workspace/pr-report.schema.json +3 -3
- package/src/workspace/reason-codes.test.ts +2 -0
- package/src/workspace/reason-codes.ts +18 -0
- package/src/workspace/release-candidates.schema.json +353 -0
- package/src/workspace/release-candidates.test.ts +106 -0
- package/src/workspace/release-candidates.ts +315 -0
- package/src/workspace/security.test.ts +70 -0
- package/src/workspace/security.ts +118 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #4155: a pull request's approval binds the files a step without a `plan`
|
|
3
|
+
* deploys from. A chart changed after approval, or a function's sources
|
|
4
|
+
* beside a planning step, moves the digest and pr-apply exits 3. A build
|
|
5
|
+
* whose output differs every time does not.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
12
|
+
import type { DriverComponent } from "../../components/driver";
|
|
13
|
+
import type { DeployContext } from "../../components/capability";
|
|
14
|
+
import type { ParsedArgs } from "../registry";
|
|
15
|
+
|
|
16
|
+
const HEAD = "a".repeat(40);
|
|
17
|
+
const BASE = "b".repeat(40);
|
|
18
|
+
|
|
19
|
+
vi.mock("../../components/changed-files", () => ({
|
|
20
|
+
resolveCommit: async () => HEAD,
|
|
21
|
+
resolveMergeBase: async () => BASE,
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
vi.mock("../../config", async () => {
|
|
25
|
+
const actual = await vi.importActual<typeof import("../../config")>("../../config");
|
|
26
|
+
return { ...actual, loadChantConfig: async () => ({ config: {} }) };
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
vi.mock("./fan-out", () => ({
|
|
30
|
+
resolveChangedUnits: async () => ({ units: { changed: [ESTATE[0]!.name], indeterminate: [] } }),
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
vi.mock("../../lifecycle/gate-change-set", () => ({
|
|
34
|
+
storeGateChangeSet: async () => ({ written: false }),
|
|
35
|
+
readGraphIds: async () => ({ ids: [] }),
|
|
36
|
+
setGraphIdReader: () => {},
|
|
37
|
+
gateGraphReader: () => async () => ({ ids: [] }),
|
|
38
|
+
}));
|
|
39
|
+
vi.mock("../../lifecycle/git", async () => {
|
|
40
|
+
const actual = await vi.importActual<typeof import("../../lifecycle/git")>("../../lifecycle/git");
|
|
41
|
+
return { ...actual, pushLifecycleReport: async () => { throw new Error("pr-deploy-inputs.test must not push chant/lifecycle"); } };
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
vi.mock("./run", () => ({
|
|
45
|
+
GATED_EXIT_CODE: 3,
|
|
46
|
+
autoReleaseRun: (id: string) => ({ runId: id, runOrigin: { forge: "local" } }),
|
|
47
|
+
recordAutoReleasesForRun: async () => [],
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
let gateLedger: ReturnType<typeof import("../../op/gate").memoryGateLedgerPort>;
|
|
51
|
+
vi.mock("../../op/gate", async () => {
|
|
52
|
+
const actual = await vi.importActual<typeof import("../../op/gate")>("../../op/gate");
|
|
53
|
+
return { ...actual, gitGateLedgerPort: () => gateLedger, gateIsSealed: async () => false };
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
/** The components under test; each case sets its own. */
|
|
57
|
+
let ESTATE: DriverComponent[] = [];
|
|
58
|
+
|
|
59
|
+
vi.mock("../../components/fan-out-support", async () => {
|
|
60
|
+
const { CapabilityRegistry } = await import("../../components/capability");
|
|
61
|
+
const { planFanOut } = await import("../../components/fan-out");
|
|
62
|
+
const { computePlanDigest } = await import("../../lifecycle/plan-digest");
|
|
63
|
+
const registry = new CapabilityRegistry();
|
|
64
|
+
// A planning step, as cfn-deploy is.
|
|
65
|
+
registry.register({
|
|
66
|
+
kind: "fake-root",
|
|
67
|
+
async plan(_ctx: DeployContext, input: { root: string }) {
|
|
68
|
+
const planDigest = computePlanDigest("fake-root", { root: input.root });
|
|
69
|
+
return {
|
|
70
|
+
member: input.root,
|
|
71
|
+
planDigest,
|
|
72
|
+
artifact: { planDigest },
|
|
73
|
+
changeSet: { member: { member: input.root, lexicon: "aws", planner: "cfn", status: "planned", planDigest, holes: [] }, entries: [] },
|
|
74
|
+
};
|
|
75
|
+
},
|
|
76
|
+
async run() {
|
|
77
|
+
return { outputs: {} };
|
|
78
|
+
},
|
|
79
|
+
} as never);
|
|
80
|
+
// Steps with no plan, under the kinds whose fields name what they deploy.
|
|
81
|
+
for (const kind of ["helm-upgrade", "lambda-deploy", "shell"]) {
|
|
82
|
+
registry.register({ kind, async run() { return { outputs: {} }; } } as never);
|
|
83
|
+
}
|
|
84
|
+
// A build that is not reproducible: a new uri every run.
|
|
85
|
+
registry.register({ kind: "zip-package", async run() { return { outputs: { uri: `s3://bundles/${Math.random()}` } }; } } as never);
|
|
86
|
+
return {
|
|
87
|
+
fanOutRegistry: async () => registry,
|
|
88
|
+
deriveFanOut: async () => ({
|
|
89
|
+
success: true,
|
|
90
|
+
components: ESTATE,
|
|
91
|
+
plan: planFanOut({ components: ESTATE, changed: [ESTATE[0]!.name] }),
|
|
92
|
+
signal: { changed: [ESTATE[0]!.name], indeterminate: [], unclaimed: [] },
|
|
93
|
+
}),
|
|
94
|
+
};
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const { memoryGateLedgerPort } = await import("../../op/gate");
|
|
98
|
+
const { runComponentsPrApply, runComponentsPrPlan } = await import("./pr");
|
|
99
|
+
|
|
100
|
+
let dir: string;
|
|
101
|
+
let project: string;
|
|
102
|
+
|
|
103
|
+
const ctx = (a: Partial<ParsedArgs>) =>
|
|
104
|
+
({ 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;
|
|
105
|
+
|
|
106
|
+
beforeEach(async () => {
|
|
107
|
+
dir = await mkdtemp(join(tmpdir(), "pr-inputs-out-"));
|
|
108
|
+
project = await mkdtemp(join(tmpdir(), "pr-inputs-project-"));
|
|
109
|
+
gateLedger = memoryGateLedgerPort();
|
|
110
|
+
// The steps read their paths from the member's directory, which is the working directory.
|
|
111
|
+
vi.spyOn(process, "cwd").mockReturnValue(project);
|
|
112
|
+
vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
|
113
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
114
|
+
vi.spyOn(console, "log").mockImplementation(() => {});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
afterEach(async () => {
|
|
118
|
+
vi.restoreAllMocks();
|
|
119
|
+
await rm(dir, { recursive: true, force: true });
|
|
120
|
+
await rm(project, { recursive: true, force: true });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
/** Plan pull request 12 and approve the digest it printed, as a reviewer would. */
|
|
124
|
+
async function planAndApprove(): Promise<string> {
|
|
125
|
+
expect(await runComponentsPrPlan(ctx({ pr: 12 }))).toBe(0);
|
|
126
|
+
const digest = JSON.parse(await readFile(join(dir, "pr-plan.json"), "utf8")).digest as string;
|
|
127
|
+
gateLedger = memoryGateLedgerPort({
|
|
128
|
+
resolutions: [{ version: 1, kind: "resolution", op: "pr-12", gate: "pr-apply", resolvedBy: "github:alice", timestamp: new Date().toISOString(), planDigest: digest }],
|
|
129
|
+
});
|
|
130
|
+
return digest;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function write(path: string, content: string): Promise<void> {
|
|
134
|
+
await mkdir(join(project, path, ".."), { recursive: true });
|
|
135
|
+
await writeFile(join(project, path), content);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
describe("a Helm chart changed after approval (#4155)", () => {
|
|
139
|
+
beforeEach(async () => {
|
|
140
|
+
ESTATE = [{ name: "web", dependsOn: [], deploy: [{ phase: "Deploy", steps: [{ kind: "helm-upgrade", release: "web", chart: "chart", values: ["values-prod.yaml"] }] }] }];
|
|
141
|
+
await write("chart/Chart.yaml", "name: web\nversion: 1.0.0\n");
|
|
142
|
+
await write("chart/templates/deployment.yaml", "replicas: 2\n");
|
|
143
|
+
await write("values-prod.yaml", "image: web\n");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("applies under the approval when nothing changed", async () => {
|
|
147
|
+
await planAndApprove();
|
|
148
|
+
expect(await runComponentsPrApply(ctx({ pr: 12 }))).toBe(0);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("a file in the chart changed: pr-apply exits 3 and applies nothing", async () => {
|
|
152
|
+
await planAndApprove();
|
|
153
|
+
await write("chart/templates/deployment.yaml", "replicas: 20\n");
|
|
154
|
+
expect(await runComponentsPrApply(ctx({ pr: 12 }))).toBe(3);
|
|
155
|
+
const report = JSON.parse(await readFile(join(dir, "pr-apply.json"), "utf8"));
|
|
156
|
+
expect(report.status).not.toBe("applied");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("a file added to the chart, or a changed values file, does the same", async () => {
|
|
160
|
+
await planAndApprove();
|
|
161
|
+
await write("chart/templates/service.yaml", "port: 80\n");
|
|
162
|
+
expect(await runComponentsPrApply(ctx({ pr: 12 }))).toBe(3);
|
|
163
|
+
await planAndApprove();
|
|
164
|
+
await write("values-prod.yaml", "image: other\n");
|
|
165
|
+
expect(await runComponentsPrApply(ctx({ pr: 12 }))).toBe(3);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe("a function's sources beside a planning step (#4155)", () => {
|
|
170
|
+
beforeEach(async () => {
|
|
171
|
+
ESTATE = [{
|
|
172
|
+
name: "api",
|
|
173
|
+
dependsOn: [],
|
|
174
|
+
deploy: [
|
|
175
|
+
{ phase: "Build", steps: [{ kind: "zip-package", source: "fn" }] },
|
|
176
|
+
{ phase: "Deploy", steps: [{ kind: "fake-root", root: "api-stack" }, { kind: "lambda-deploy", functionName: "api", codeRef: "@Build.uri", version: "1" }] },
|
|
177
|
+
],
|
|
178
|
+
}];
|
|
179
|
+
await write("fn/index.js", "export const handler = () => 1;\n");
|
|
180
|
+
await write("fn/package-lock.json", "{}\n");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("the plan has a member for the component's other steps", async () => {
|
|
184
|
+
await planAndApprove();
|
|
185
|
+
const plan = JSON.parse(await readFile(join(dir, "pr-plan.json"), "utf8"));
|
|
186
|
+
expect(JSON.stringify(plan)).toContain("api#inputs");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("a changed source moves the digest: pr-apply exits 3", async () => {
|
|
190
|
+
await planAndApprove();
|
|
191
|
+
await write("fn/index.js", "export const handler = () => 2;\n");
|
|
192
|
+
expect(await runComponentsPrApply(ctx({ pr: 12 }))).toBe(3);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("an unchanged rebuild whose output differs every run still applies under the approval", async () => {
|
|
196
|
+
await planAndApprove();
|
|
197
|
+
expect(await runComponentsPrApply(ctx({ pr: 12 }))).toBe(0);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe("a step chant can't bind (#4155)", () => {
|
|
202
|
+
test("is a hole on the plan, named unbound, and the plan still applies once approved", async () => {
|
|
203
|
+
ESTATE = [{ name: "ops", dependsOn: [], deploy: [{ phase: "Deploy", steps: [{ kind: "shell", cmd: "./deploy.sh", reason: "t" }] }] }];
|
|
204
|
+
await planAndApprove();
|
|
205
|
+
const plan = JSON.parse(await readFile(join(dir, "pr-plan.json"), "utf8"));
|
|
206
|
+
expect(JSON.stringify(plan)).toMatch(/unbound: a shell step runs content chant can't name/);
|
|
207
|
+
expect(await runComponentsPrApply(ctx({ pr: 12 }))).toBe(0);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant components pr-record` (#4168 step 1): the record stage a merge
|
|
3
|
+
* runs unless apply on merge is opted in. It plans the merged commit,
|
|
4
|
+
* decides the pull request's gate without writing to the ledger, writes the
|
|
5
|
+
* merge record and the report, and applies nothing. Git, the change signal,
|
|
6
|
+
* the derivation and the gate ledger are fakes, as in pr.test.ts.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
14
|
+
import type { DriverComponent } from "../../components/driver";
|
|
15
|
+
import type { DeployContext } from "../../components/capability";
|
|
16
|
+
import type { ParsedArgs } from "../registry";
|
|
17
|
+
import { prStatusState } from "../../pr-loop";
|
|
18
|
+
|
|
19
|
+
const HEAD = "a".repeat(40);
|
|
20
|
+
const BASE = "b".repeat(40);
|
|
21
|
+
|
|
22
|
+
vi.mock("../../components/changed-files", () => ({
|
|
23
|
+
resolveCommit: async () => HEAD,
|
|
24
|
+
resolveMergeBase: async () => BASE,
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
vi.mock("../../config", async () => {
|
|
28
|
+
const actual = await vi.importActual<typeof import("../../config")>("../../config");
|
|
29
|
+
return { ...actual, loadChantConfig: async () => ({ config: {} }) };
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
vi.mock("./fan-out", () => ({
|
|
33
|
+
resolveChangedUnits: async () => ({ units: { changed: ["net"], indeterminate: [] } }),
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
// #4022: pr-apply stores the change set its gate binds on chant/lifecycle and
|
|
37
|
+
// pushes it. These tests run in the chant checkout, so the store is replaced:
|
|
38
|
+
// it must never write or push the real repository's lifecycle branch.
|
|
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.test must not push chant/lifecycle"); } };
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// #4168: the record job writes the merge record on chant/lifecycle. Captured here, never written.
|
|
51
|
+
const written: Array<Record<string, unknown>> = [];
|
|
52
|
+
vi.mock("../../lifecycle/merge-record", async () => {
|
|
53
|
+
const actual = await vi.importActual<typeof import("../../lifecycle/merge-record")>("../../lifecycle/merge-record");
|
|
54
|
+
return { ...actual, writeMergeRecord: async (r: Record<string, unknown>) => (written.push(r), { commit: null, written: false }) };
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
vi.mock("./run", () => ({
|
|
58
|
+
GATED_EXIT_CODE: 3,
|
|
59
|
+
autoReleaseRun: (id: string) => ({ runId: id, runOrigin: { forge: "local" } }),
|
|
60
|
+
recordAutoReleasesForRun: async () => [],
|
|
61
|
+
}));
|
|
62
|
+
|
|
63
|
+
let gateLedger: ReturnType<typeof import("../../op/gate").memoryGateLedgerPort>;
|
|
64
|
+
vi.mock("../../op/gate", async () => {
|
|
65
|
+
const actual = await vi.importActual<typeof import("../../op/gate")>("../../op/gate");
|
|
66
|
+
return { ...actual, gitGateLedgerPort: () => gateLedger, gateIsSealed: async () => false };
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const failing = new Set<string>();
|
|
70
|
+
/** The error a root's apply throws, in place of applying (#3997). */
|
|
71
|
+
const refusal = new Map<string, Error>();
|
|
72
|
+
/** Called as a component's apply starts, before it can fail. */
|
|
73
|
+
let onRun: (root: string) => void = () => {};
|
|
74
|
+
vi.mock("../../components/fan-out-support", async () => {
|
|
75
|
+
const { CapabilityRegistry } = await import("../../components/capability");
|
|
76
|
+
const { planFanOut } = await import("../../components/fan-out");
|
|
77
|
+
const { computePlanDigest } = await import("../../lifecycle/plan-digest");
|
|
78
|
+
const registry = new CapabilityRegistry();
|
|
79
|
+
registry.register({
|
|
80
|
+
kind: "fake-root",
|
|
81
|
+
async plan(_ctx: DeployContext, input: { root: string }) {
|
|
82
|
+
const planDigest = computePlanDigest("fake-root", { root: input.root });
|
|
83
|
+
return {
|
|
84
|
+
member: input.root,
|
|
85
|
+
planDigest,
|
|
86
|
+
artifact: { planDigest },
|
|
87
|
+
changeSet: { member: { member: input.root, lexicon: "terraform", planner: "tofu", status: "planned", planDigest, holes: [] }, entries: [] },
|
|
88
|
+
};
|
|
89
|
+
},
|
|
90
|
+
async outputs() {
|
|
91
|
+
return {};
|
|
92
|
+
},
|
|
93
|
+
async run(_ctx: DeployContext, input: { root: string }) {
|
|
94
|
+
onRun(input.root);
|
|
95
|
+
if (refusal.has(input.root)) throw refusal.get(input.root);
|
|
96
|
+
if (failing.has(input.root)) throw new Error(`${input.root} failed`);
|
|
97
|
+
return { outputs: {} };
|
|
98
|
+
},
|
|
99
|
+
} as never);
|
|
100
|
+
return {
|
|
101
|
+
fanOutRegistry: async () => registry,
|
|
102
|
+
deriveFanOut: async () => ({
|
|
103
|
+
success: true,
|
|
104
|
+
components: ESTATE,
|
|
105
|
+
plan: planFanOut({ components: ESTATE, changed: ["net"] }),
|
|
106
|
+
signal: { changed: ["net"], indeterminate: [], unclaimed: [] },
|
|
107
|
+
}),
|
|
108
|
+
};
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const root = (name: string, dependsOn: string[] = []): DriverComponent => ({
|
|
112
|
+
name,
|
|
113
|
+
dependsOn,
|
|
114
|
+
deploy: [{ phase: "Apply", steps: [{ kind: "fake-root", root: name }] }],
|
|
115
|
+
});
|
|
116
|
+
const ESTATE: DriverComponent[] = [root("net"), root("app", ["net"])];
|
|
117
|
+
|
|
118
|
+
const { memoryGateLedgerPort } = await import("../../op/gate");
|
|
119
|
+
const { runComponentsPrPlan, runComponentsPrRecord } = await import("./pr");
|
|
120
|
+
const { checkRunEventLines, stepTrail } = await import("../../workspace/__fixtures__/run-event-check");
|
|
121
|
+
|
|
122
|
+
let dir: string;
|
|
123
|
+
let errors: string[];
|
|
124
|
+
let applied: string[];
|
|
125
|
+
|
|
126
|
+
const ctx = (a: Partial<ParsedArgs>) =>
|
|
127
|
+
({ args: { command: "components", path: "pr-record", format: "", fix: false, watch: false, verbose: false, help: false, live: false, base: "main", env: "prod", output: dir, ...a } as ParsedArgs, plugins: [] }) as never;
|
|
128
|
+
|
|
129
|
+
beforeEach(async () => {
|
|
130
|
+
dir = await mkdtemp(join(tmpdir(), "pr-record-"));
|
|
131
|
+
errors = [];
|
|
132
|
+
applied = [];
|
|
133
|
+
written.length = 0;
|
|
134
|
+
failing.clear();
|
|
135
|
+
refusal.clear();
|
|
136
|
+
onRun = (r) => applied.push(r);
|
|
137
|
+
gateLedger = memoryGateLedgerPort();
|
|
138
|
+
vi.spyOn(console, "error").mockImplementation((...m: unknown[]) => { errors.push(m.map(String).join(" ")); });
|
|
139
|
+
vi.spyOn(console, "log").mockImplementation(() => {});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
afterEach(async () => {
|
|
143
|
+
vi.restoreAllMocks();
|
|
144
|
+
await rm(dir, { recursive: true, force: true });
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const report = (stage: string) => JSON.parse(readFileSync(join(dir, `pr-${stage}.json`), "utf8")) as Record<string, unknown> & { approval: { status: string }; digest: string };
|
|
148
|
+
const approve = (digest: string) =>
|
|
149
|
+
(gateLedger = memoryGateLedgerPort({
|
|
150
|
+
resolutions: [{ version: 1, kind: "resolution", op: "pr-12", gate: "pr-apply", resolvedBy: "github:alice", timestamp: new Date().toISOString(), planDigest: digest }],
|
|
151
|
+
}));
|
|
152
|
+
|
|
153
|
+
describe("pr-record (#4168)", () => {
|
|
154
|
+
test("an unapproved merge is recorded, exits 0, writes nothing to the gate ledger and applies nothing", async () => {
|
|
155
|
+
expect(await runComponentsPrRecord(ctx({ pr: 12 }))).toBe(0);
|
|
156
|
+
const r = report("record");
|
|
157
|
+
expect(r).toMatchObject({ stage: "record", status: "recorded", pr: 12, head: HEAD, base: BASE, approval: { status: "pending" } });
|
|
158
|
+
expect(gateLedger.appended).toEqual([]);
|
|
159
|
+
expect(applied).toEqual([]);
|
|
160
|
+
expect(written).toEqual([
|
|
161
|
+
expect.objectContaining({ commit: HEAD, base: BASE, env: "prod", source: { kind: "pull-request", pr: 12 }, op: "pr-12", gate: "pr-apply", digest: r.digest, plan: "unapproved", approvedBy: [], components: ["net", "app"] }),
|
|
162
|
+
]);
|
|
163
|
+
expect(prStatusState(r as never)).toBe("success");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("the plan a reviewer approved is recorded as approved, by whom", async () => {
|
|
167
|
+
await runComponentsPrPlan(ctx({ pr: 12 }));
|
|
168
|
+
approve(report("plan").digest);
|
|
169
|
+
expect(await runComponentsPrRecord(ctx({ pr: 12 }))).toBe(0);
|
|
170
|
+
expect(report("record").approval).toEqual({ status: "approved", approvedBy: ["github:alice"] });
|
|
171
|
+
expect(written[0]).toMatchObject({ plan: "approved", approvedBy: ["github:alice"] });
|
|
172
|
+
expect(applied).toEqual([]);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("a plan that moved since its approval is recorded, exits 0, and its status fails (decision 1)", async () => {
|
|
176
|
+
const other = `jcs1-sha256:${"9".repeat(64)}`;
|
|
177
|
+
approve(other);
|
|
178
|
+
expect(await runComponentsPrRecord(ctx({ pr: 12 }))).toBe(0);
|
|
179
|
+
const r = report("record");
|
|
180
|
+
expect(r.approval).toEqual({ status: "changed", approved: other });
|
|
181
|
+
expect(String(r.message)).toMatch(/The merge stands and nothing was applied/);
|
|
182
|
+
expect(prStatusState(r as never)).toBe("failure");
|
|
183
|
+
expect(written[0]).toMatchObject({ plan: "changed", approvedDigest: other, approvedBy: [] });
|
|
184
|
+
expect(applied).toEqual([]);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("with no pull request the record names none and reads no gate", async () => {
|
|
188
|
+
approve(`jcs1-sha256:${"9".repeat(64)}`);
|
|
189
|
+
expect(await runComponentsPrRecord(ctx({}))).toBe(0);
|
|
190
|
+
expect(report("record")).toMatchObject({ pr: null, op: "pr-unknown", approval: { status: "pending" } });
|
|
191
|
+
expect(written[0]).toMatchObject({ source: { kind: "direct-push" }, op: null, plan: "unapproved" });
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("a member that fails to plan is recorded as plan-failed and exits 1", async () => {
|
|
195
|
+
const plan = vi.spyOn(await import("../../components/pr-run"), "planPrSet");
|
|
196
|
+
plan.mockImplementationOnce(async (input) => ({ ...(await (await vi.importActual<typeof import("../../components/pr-run")>("../../components/pr-run")).planPrSet(input)), failed: true }));
|
|
197
|
+
expect(await runComponentsPrRecord(ctx({ pr: 12 }))).toBe(1);
|
|
198
|
+
expect(report("record")).toMatchObject({ status: "plan-failed" });
|
|
199
|
+
expect(written[0]).toMatchObject({ plan: "plan-failed" });
|
|
200
|
+
});
|
|
201
|
+
});
|
|
@@ -19,6 +19,13 @@ import { forgePrincipalOf } from "../../pr-forge";
|
|
|
19
19
|
const HEAD = "a".repeat(40);
|
|
20
20
|
const BASE = "b".repeat(40);
|
|
21
21
|
|
|
22
|
+
/** The security groups pr-apply reads from the declaration (#4168); none unless a test sets them. */
|
|
23
|
+
let groups: string[] = [];
|
|
24
|
+
vi.mock("../../workspace/security", async (original) => ({
|
|
25
|
+
...(await original<typeof import("../../workspace/security")>()),
|
|
26
|
+
securityGroupsAt: () => new Set(groups),
|
|
27
|
+
}));
|
|
28
|
+
|
|
22
29
|
vi.mock("../../components/changed-files", () => ({
|
|
23
30
|
resolveCommit: async () => HEAD,
|
|
24
31
|
resolveMergeBase: async () => BASE,
|
|
@@ -126,6 +133,7 @@ beforeEach(async () => {
|
|
|
126
133
|
failing.clear();
|
|
127
134
|
refusal.clear();
|
|
128
135
|
onRun = () => {};
|
|
136
|
+
groups = [];
|
|
129
137
|
gateLedger = memoryGateLedgerPort();
|
|
130
138
|
vi.spyOn(process.stdout, "write").mockImplementation((chunk: string | Uint8Array) => { out.push(String(chunk)); return true; });
|
|
131
139
|
vi.spyOn(console, "error").mockImplementation((...m: unknown[]) => { errors.push(m.map(String).join(" ")); });
|
|
@@ -345,6 +353,12 @@ describe("--events-json beside --json", () => {
|
|
|
345
353
|
});
|
|
346
354
|
});
|
|
347
355
|
|
|
356
|
+
test("under the review-rules security group pr-apply needs --forge, since it reads the pull request's reviews (#4168)", async () => {
|
|
357
|
+
groups = ["review-rules"];
|
|
358
|
+
expect(await runComponentsPrApply(ctx({ pr: 12, eventsJson: false }))).toBe(1);
|
|
359
|
+
expect(errors.join("\n")).toContain("the workspace opts into review-rules");
|
|
360
|
+
});
|
|
361
|
+
|
|
348
362
|
describe("--require-review: whoever wrote the change never counts (#4000)", () => {
|
|
349
363
|
type Kind = "github" | "forgejo" | "gitlab";
|
|
350
364
|
const ENV: Record<Kind, Record<string, string>> = {
|
|
@@ -439,6 +453,14 @@ describe("--require-review: whoever wrote the change never counts (#4000)", () =
|
|
|
439
453
|
expect(report.message).toContain(`approved by ${forgePrincipalOf(kind, HOST[kind], "dave")}, who wrote #12`);
|
|
440
454
|
});
|
|
441
455
|
|
|
456
|
+
test("under the review-rules security group the author's approval is refused without --require-review (#4168)", async () => {
|
|
457
|
+
serve(kind, "carol");
|
|
458
|
+
await approveAs(kind, "carol");
|
|
459
|
+
groups = ["review-rules"];
|
|
460
|
+
expect(await runComponentsPrApply(ctx({ pr: 12, forge: kind, eventsJson: false }))).toBe(3);
|
|
461
|
+
expect(await applyReport()).toMatchObject({ status: "refused", refusal: "author-approved" });
|
|
462
|
+
});
|
|
463
|
+
|
|
442
464
|
test("an outside reviewer approves: the plan applies", async () => {
|
|
443
465
|
serve(kind, "erin");
|
|
444
466
|
await approveAs(kind, "erin");
|
package/src/cli/handlers/pr.ts
CHANGED
|
@@ -153,7 +153,7 @@ const fail = (message: string, hint?: string): number => {
|
|
|
153
153
|
};
|
|
154
154
|
|
|
155
155
|
/** Everything both stages share: the change and the derivation. Each stage plans the members itself. */
|
|
156
|
-
async function prepare(ctx: CommandContext, stage: "plan" | "apply"): Promise<Prepared | number> {
|
|
156
|
+
async function prepare(ctx: CommandContext, stage: "plan" | "apply" | "record"): Promise<Prepared | number> {
|
|
157
157
|
const { args } = ctx;
|
|
158
158
|
const projectPath = resolve(".");
|
|
159
159
|
const { config } = await loadChantConfig(projectPath).catch(() => ({ config: {} as ChantConfig }));
|
|
@@ -297,6 +297,7 @@ function settleOf(status: PrReport["status"]): RunEventSettleStatus {
|
|
|
297
297
|
switch (status) {
|
|
298
298
|
case "nothing":
|
|
299
299
|
case "planned":
|
|
300
|
+
case "recorded":
|
|
300
301
|
case "applied":
|
|
301
302
|
return "ok";
|
|
302
303
|
case "refused":
|
|
@@ -405,7 +406,7 @@ async function withStageEvents(stage: string, ctx: CommandContext, run: (events:
|
|
|
405
406
|
* Open the stage's `--report-url` upload under its pull request and commit,
|
|
406
407
|
* when the flag was given. Its events are the lines `openCliRunEvents` writes.
|
|
407
408
|
*/
|
|
408
|
-
function openUpload(events: StageEvents, stage: "plan" | "apply", pr: number | null, p: Prepared): PrReportUpload | undefined {
|
|
409
|
+
function openUpload(events: StageEvents, stage: "plan" | "apply" | "record", pr: number | null, p: Prepared): PrReportUpload | undefined {
|
|
409
410
|
if (!events.target) return undefined;
|
|
410
411
|
events.upload = openPrReportUpload({
|
|
411
412
|
target: events.target,
|
|
@@ -484,6 +485,11 @@ export function runComponentsPrApply(ctx: CommandContext): Promise<number> {
|
|
|
484
485
|
}
|
|
485
486
|
|
|
486
487
|
async function prApply(ctx: CommandContext, stage: StageEvents): Promise<number> {
|
|
488
|
+
// #4168: the review-rules security group turns --require-review on, read from the declaration at base.
|
|
489
|
+
const { securityGroupsAt } = await import("../../workspace/security");
|
|
490
|
+
const reviewRules = !ctx.args.requireReview && securityGroupsAt(process.cwd(), ctx.args.base).has("review-rules");
|
|
491
|
+
if (reviewRules && !ctx.args.forge) return fail("the workspace opts into review-rules, which reads the pull request's reviews, so pr-apply needs --forge <kind>");
|
|
492
|
+
if (reviewRules) ctx = { ...ctx, args: { ...ctx.args, requireReview: true } };
|
|
487
493
|
const { args } = ctx;
|
|
488
494
|
if (args.requireReview && !args.forge) return fail("--require-review reads the pull request's reviews, so it needs --forge <kind>");
|
|
489
495
|
if (args.wave !== undefined && !args.waveGate) return fail("--wave runs one wave of a --wave-gate apply, so it needs --wave-gate <name>");
|
|
@@ -723,12 +729,129 @@ async function prApply(ctx: CommandContext, stage: StageEvents): Promise<number>
|
|
|
723
729
|
return code;
|
|
724
730
|
}
|
|
725
731
|
|
|
732
|
+
/** chant components pr-record --base <ref> [--pr <n>] [--env <env>] [--gate <name>] [--forge <kind>] [--output <dir>] [--json] [--events-json] [--report-url <url>] */
|
|
733
|
+
export function runComponentsPrRecord(ctx: CommandContext): Promise<number> {
|
|
734
|
+
if (refusesEventsWithJson(ctx.args, "the stage's report")) return Promise.resolve(1);
|
|
735
|
+
return withStageEvents("pr-record", ctx, (events) => prRecord(ctx, events));
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* The record stage (#4168 step 1): what a merge does unless apply on merge
|
|
740
|
+
* is opted in. It plans the members the change reaches on the merged
|
|
741
|
+
* commit, stores the change set, decides the pull request's gate against
|
|
742
|
+
* it without writing to the ledger, and writes the merge record
|
|
743
|
+
* (`../../lifecycle/merge-record.ts`). It applies nothing and refuses
|
|
744
|
+
* nothing. A plan that differs from the approved one is recorded, and the
|
|
745
|
+
* `chant/record` status fails (decision 1). It exits 1 only when a member
|
|
746
|
+
* failed to plan.
|
|
747
|
+
*/
|
|
748
|
+
async function prRecord(ctx: CommandContext, stage: StageEvents): Promise<number> {
|
|
749
|
+
const { args } = ctx;
|
|
750
|
+
const p = await prepare(ctx, "record");
|
|
751
|
+
if (typeof p === "number") return p;
|
|
752
|
+
|
|
753
|
+
let pr: number | null = args.pr ?? null;
|
|
754
|
+
if (pr === null && p.forge) {
|
|
755
|
+
try {
|
|
756
|
+
pr = await p.forge.pullRequestFor(p.head);
|
|
757
|
+
} catch (err) {
|
|
758
|
+
console.error(formatWarning({ message: `could not ask ${p.forge.kind} which pull request merged ${p.head}, so the record names none: ${err instanceof Error ? err.message : String(err)}` }));
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
const op = pr === null ? "pr-unknown" : prOp(pr, p.member);
|
|
762
|
+
const upload = openUpload(stage, "record", pr, p);
|
|
763
|
+
const events = await openCliRunEvents(args, { kind: "component", name: `pr-record ${op}`, cwd: resolve(".") }, upload?.line);
|
|
764
|
+
stage.stream = events;
|
|
765
|
+
events?.emit({ type: "run-start", waves: p.plan.waves });
|
|
766
|
+
const set = await planAll(p, events?.emit);
|
|
767
|
+
const nothing = p.plan.order.length === 0;
|
|
768
|
+
|
|
769
|
+
let approval: PrApproval = { status: "pending" };
|
|
770
|
+
if (!nothing && !set.failed) {
|
|
771
|
+
await storeChangeSetForGate(ctx, p, set.doc);
|
|
772
|
+
if (pr !== null) {
|
|
773
|
+
try {
|
|
774
|
+
const check = await emitGateStep(events?.emit, { op, gate: p.gate }, () =>
|
|
775
|
+
decidePrGate(readOnlyLedger(gitGateLedgerPort()), { op, gate: p.gate, digest: set.doc.digest }),
|
|
776
|
+
);
|
|
777
|
+
approval = approvalOf(check);
|
|
778
|
+
} catch (err) {
|
|
779
|
+
console.error(formatWarning({ message: `could not read the gate ledger, so the record says no approval: ${err instanceof Error ? err.message : String(err)}` }));
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// #4000: the record keeps an approval by someone who wrote the change, which review rules would not count.
|
|
785
|
+
let authorApproved: string[] | undefined;
|
|
786
|
+
if (pr !== null && p.forge && approval.status === "approved") {
|
|
787
|
+
try {
|
|
788
|
+
const authors = (await p.forge.authors(pr)).map((login) => p.forge!.principalOf(login));
|
|
789
|
+
authorApproved = approversWhoAuthored(approval.approvedBy ?? [], authors);
|
|
790
|
+
} catch (err) {
|
|
791
|
+
console.error(formatWarning({ message: `could not read the authors of #${pr}: ${err instanceof Error ? err.message : String(err)}` }));
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
const status = nothing ? "nothing" : set.failed ? "plan-failed" : "recorded";
|
|
796
|
+
const report = prReport({
|
|
797
|
+
stage: "record",
|
|
798
|
+
pr,
|
|
799
|
+
env: p.env,
|
|
800
|
+
...(p.member ? { member: p.member } : {}),
|
|
801
|
+
base: p.base,
|
|
802
|
+
head: p.head,
|
|
803
|
+
op,
|
|
804
|
+
gate: p.gate,
|
|
805
|
+
plan: p.plan,
|
|
806
|
+
signal: p.signal,
|
|
807
|
+
set,
|
|
808
|
+
approval,
|
|
809
|
+
status,
|
|
810
|
+
...(status === "plan-failed" ? { message: "A member failed to plan on the merged commit, so the record has no plan to compare with the approved one. Nothing was applied." } : {}),
|
|
811
|
+
...(status === "recorded" && approval.status === "changed"
|
|
812
|
+
? { message: `${describePlanChanged({ op, gate: p.gate, digest: set.doc.digest, approval })} The merge stands and nothing was applied; the release gate decides before this deploys.` }
|
|
813
|
+
: {}),
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
const { mergeSourceOf, writeMergeRecord, MERGE_RECORD_SCHEMA_ID, MERGE_RECORD_CONTRACT } = await import("../../lifecycle/merge-record");
|
|
817
|
+
const { pushLifecycleReport } = await import("../../lifecycle/git");
|
|
818
|
+
try {
|
|
819
|
+
const written = await writeMergeRecord({
|
|
820
|
+
$schema: MERGE_RECORD_SCHEMA_ID,
|
|
821
|
+
contract: MERGE_RECORD_CONTRACT,
|
|
822
|
+
commit: p.head,
|
|
823
|
+
base: p.base,
|
|
824
|
+
env: p.env,
|
|
825
|
+
...(p.member ? { member: p.member } : {}),
|
|
826
|
+
source: mergeSourceOf(resolve("."), p.head, pr),
|
|
827
|
+
op: pr === null ? null : op,
|
|
828
|
+
gate: p.gate,
|
|
829
|
+
digest: nothing ? null : set.doc.digest,
|
|
830
|
+
plan: nothing ? "nothing" : set.failed ? "plan-failed" : approval.status === "approved" ? "approved" : approval.status === "changed" ? "changed" : "unapproved",
|
|
831
|
+
...(approval.status === "changed" && approval.approved ? { approvedDigest: approval.approved } : {}),
|
|
832
|
+
approvedBy: approval.status === "approved" ? (approval.approvedBy ?? []) : [],
|
|
833
|
+
...(authorApproved && authorApproved.length > 0 ? { authorApproved } : {}),
|
|
834
|
+
components: p.plan.order,
|
|
835
|
+
recordedAt: new Date().toISOString(),
|
|
836
|
+
});
|
|
837
|
+
if (written.written) {
|
|
838
|
+
const pushed = await pushLifecycleReport();
|
|
839
|
+
if (pushed.pushWarning) console.error(formatWarning({ message: pushed.pushWarning }));
|
|
840
|
+
}
|
|
841
|
+
} catch (err) {
|
|
842
|
+
console.error(formatWarning({ message: `could not write the merge record for ${p.head}: ${err instanceof Error ? err.message : String(err)}` }));
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
const stop = stopper(ctx, p, events, upload);
|
|
846
|
+
return stop(report, status === "plan-failed" ? 1 : 0);
|
|
847
|
+
}
|
|
848
|
+
|
|
726
849
|
/** How a stage stops: settle its events, write and publish the report, say how it ended, and return `code`. */
|
|
727
850
|
function stopper(ctx: CommandContext, p: Prepared, events: RunEventStream | undefined, upload: PrReportUpload | undefined) {
|
|
728
851
|
return async (report: PrReport, code: number, settle?: RunEventSettleStatus): Promise<number> => {
|
|
729
|
-
events?.settle(settle ?? settleOf(report.status), report.status === "plan-failed" || report.status === "failed" ? report.message ?? `pr
|
|
852
|
+
events?.settle(settle ?? settleOf(report.status), report.status === "plan-failed" || report.status === "failed" ? report.message ?? `pr-${report.stage} ${report.status}` : undefined);
|
|
730
853
|
await finish(ctx, p, report, upload);
|
|
731
|
-
console.error((code === 0 ? formatInfo : (m: string) => formatWarning({ message: m }))(`pr
|
|
854
|
+
console.error((code === 0 ? formatInfo : (m: string) => formatWarning({ message: m }))(`pr-${report.stage}: ${report.status}${report.refusal ? ` (${report.refusal})` : ""}`));
|
|
732
855
|
if (report.message) console.error(report.message);
|
|
733
856
|
printMembers(report);
|
|
734
857
|
return code;
|
|
@@ -156,6 +156,12 @@ vi.mock("../../lifecycle/release-revocation", async () => {
|
|
|
156
156
|
return { ...actual, readRevokedCommits: async () => revokedCommits };
|
|
157
157
|
});
|
|
158
158
|
|
|
159
|
+
// #4168: the security groups and the release verification, set per test.
|
|
160
|
+
let groups: string[] = [];
|
|
161
|
+
let verification: { status: "verified" | "unsigned" | "unverified"; digestMatches: boolean; reason: string } = { status: "unsigned", digestMatches: true, reason: "no cut of the release carries a signature" };
|
|
162
|
+
vi.mock("../../workspace/security", async (original) => ({ ...(await original<typeof import("../../workspace/security")>()), securityGroupsAt: () => new Set(groups) }));
|
|
163
|
+
vi.mock("../../lifecycle/release-verify", async (original) => ({ ...(await original<typeof import("../../lifecycle/release-verify")>()), verifyRelease: async () => verification }));
|
|
164
|
+
|
|
159
165
|
// The run handler pulls in every runtime; the promote needs only its exit code.
|
|
160
166
|
vi.mock("./run", () => ({ GATED_EXIT_CODE: 3 }));
|
|
161
167
|
|
|
@@ -214,6 +220,8 @@ beforeEach(() => {
|
|
|
214
220
|
knownLabels.clear();
|
|
215
221
|
outcomes.length = 0;
|
|
216
222
|
revokedCommits.clear();
|
|
223
|
+
groups = [];
|
|
224
|
+
verification = { status: "unsigned", digestMatches: true, reason: "no cut of the release carries a signature" };
|
|
217
225
|
removedTemplates.length = 0;
|
|
218
226
|
ran.length = 0;
|
|
219
227
|
stepInputs.length = 0;
|
|
@@ -571,6 +579,15 @@ describe("chant components promote --release binds its gate (#4045)", () => {
|
|
|
571
579
|
expect(appendReleaseRecordMock).not.toHaveBeenCalled();
|
|
572
580
|
});
|
|
573
581
|
|
|
582
|
+
test("under verified-releases a release that does not verify is refused before anything runs, and one that does goes on to its gate (#4168)", async () => {
|
|
583
|
+
groups = ["verified-releases"];
|
|
584
|
+
expect(await runComponentsPromote(ctx({ release: "v1.0.0" }))).toBe(1);
|
|
585
|
+
expect(errors.join("\n")).toMatch(/release-unsigned: release v1\.0\.0 does not verify \(no cut of the release carries a signature\), and the workspace opts into verified-releases/);
|
|
586
|
+
expect(ran).toEqual([]);
|
|
587
|
+
verification = { status: "verified", digestMatches: true, reason: "signed by alice" };
|
|
588
|
+
expect(await runComponentsPromote(ctx({ release: "v1.0.0" }))).toBe(3);
|
|
589
|
+
});
|
|
590
|
+
|
|
574
591
|
test("an approval for one release can't be used by another", async () => {
|
|
575
592
|
expect(await runComponentsPromote(ctx({ release: "v1.0.0" }))).toBe(3);
|
|
576
593
|
const pending = releaseGatePort!.appended[0];
|