@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,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A `shell()` step in a container (#4015), run for real with `docker run
|
|
3
|
+
* --rm`. Skipped when Docker is not running. The containers are `--rm` and
|
|
4
|
+
* mount only host directories, so nothing is left behind.
|
|
5
|
+
*/
|
|
6
|
+
import { execSync } from "node:child_process";
|
|
7
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
11
|
+
import { shellCmd } from "./shell";
|
|
12
|
+
|
|
13
|
+
// alpine 3.21, pinned by its index digest.
|
|
14
|
+
const IMAGE = process.env.CHANT_SHELL_E2E_IMAGE ?? "alpine:3.21@sha256:ce64758a109eb420d874a118f87920e625e12d3634e03b4a5573fd9f6e5d3507";
|
|
15
|
+
|
|
16
|
+
function available(cmd: string): boolean {
|
|
17
|
+
try {
|
|
18
|
+
execSync(cmd, { stdio: "ignore" });
|
|
19
|
+
return true;
|
|
20
|
+
} catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const hasDocker = available("docker info");
|
|
26
|
+
const skipReason = hasDocker ? "" : " (skipped: Docker is not running)";
|
|
27
|
+
|
|
28
|
+
describe.skipIf(!hasDocker)(`shellCmd with an image${skipReason}`, () => {
|
|
29
|
+
let dir: string;
|
|
30
|
+
|
|
31
|
+
beforeAll(async () => {
|
|
32
|
+
dir = await mkdtemp(join(tmpdir(), "chant-shell-e2e-"));
|
|
33
|
+
await writeFile(join(dir, "input.txt"), "from the host\n");
|
|
34
|
+
execSync(`docker pull -q ${IMAGE}`, { stdio: "ignore" });
|
|
35
|
+
}, 120_000);
|
|
36
|
+
|
|
37
|
+
afterAll(async () => {
|
|
38
|
+
await rm(dir, { recursive: true, force: true });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("runs in the image, reads the workspace read-only, sees env by name, and writes its output directory on the host", async () => {
|
|
42
|
+
process.env.CHANT_SHELL_E2E_HOST_ONLY = "leaked";
|
|
43
|
+
try {
|
|
44
|
+
const result = await shellCmd({
|
|
45
|
+
cmd: 'cat /etc/alpine-release >/dev/null && echo "$GREETING:$CHANT_SHELL_E2E_HOST_ONLY" && cat input.txt > out/copy.txt; touch input.txt 2>/dev/null || echo read-only',
|
|
46
|
+
image: IMAGE,
|
|
47
|
+
cwd: dir,
|
|
48
|
+
env: { GREETING: "hi" },
|
|
49
|
+
outputs: ["out"],
|
|
50
|
+
});
|
|
51
|
+
expect(result.stdout.split("\n")).toEqual(["hi:", "read-only"]);
|
|
52
|
+
expect(await readFile(join(dir, "out", "copy.txt"), "utf8")).toBe("from the host\n");
|
|
53
|
+
} finally {
|
|
54
|
+
delete process.env.CHANT_SHELL_E2E_HOST_ONLY;
|
|
55
|
+
}
|
|
56
|
+
}, 60_000);
|
|
57
|
+
|
|
58
|
+
test("a declared output directory the command left empty fails the step", async () => {
|
|
59
|
+
await expect(shellCmd({ cmd: "true", image: IMAGE, cwd: dir, outputs: ["empty"] })).rejects.toThrow(/left it empty/);
|
|
60
|
+
}, 60_000);
|
|
61
|
+
|
|
62
|
+
test("keeps the command's exit status, and okExit still applies", async () => {
|
|
63
|
+
await expect(shellCmd({ cmd: "exit 4", image: IMAGE, cwd: dir })).rejects.toThrow(/command exited 4/);
|
|
64
|
+
const ok = await shellCmd({ cmd: "echo done; exit 1", image: IMAGE, cwd: dir, okExit: [0, 1] });
|
|
65
|
+
expect(ok).toMatchObject({ stdout: "done", exitCode: 1 });
|
|
66
|
+
}, 60_000);
|
|
67
|
+
});
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { describe, test, expect } from "vitest";
|
|
12
12
|
import { z } from "zod";
|
|
13
|
-
import { shellCmd } from "./shell";
|
|
13
|
+
import { containerCommand, imageProblem, shellCmd } from "./shell";
|
|
14
14
|
import { shellCmdContract } from "./activity-contracts";
|
|
15
15
|
import { shell } from "../builders";
|
|
16
16
|
import { activity, phase } from "../builders";
|
|
@@ -244,3 +244,46 @@ describe("a step's own timeout (#2787)", () => {
|
|
|
244
244
|
}
|
|
245
245
|
});
|
|
246
246
|
});
|
|
247
|
+
|
|
248
|
+
describe("shellCmd in a container (#4015)", () => {
|
|
249
|
+
const PINNED = `alpine:3.21@sha256:${"e".repeat(64)}`;
|
|
250
|
+
|
|
251
|
+
test("builds a docker run --rm line: workspace read-only, env by name, the command under sh -c", () => {
|
|
252
|
+
const line = containerCommand({ cmd: "make test", image: PINNED, env: { TOKEN: "x", MODE: "ci" } }, { cwd: "/src/app" });
|
|
253
|
+
expect(line).toBe(`docker run --rm -v '/src/app:/workspace:ro' -w /workspace -e 'TOKEN' -e 'MODE' '${PINNED}' sh -c 'make test'`);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("mounts each output directory writable over a read-only workspace, and none over a writable one", () => {
|
|
257
|
+
const ro = containerCommand({ cmd: "npm run build", image: PINNED, outputs: ["dist", "./reports/"] }, { cwd: "/src/app", cli: "podman", user: "501:20" });
|
|
258
|
+
expect(ro).toBe(
|
|
259
|
+
`podman run --rm --user 501:20 -v '/src/app:/workspace:ro' -v '/src/app/dist:/workspace/dist:rw' -v '/src/app/reports:/workspace/reports:rw' -w /workspace '${PINNED}' sh -c 'npm run build'`,
|
|
260
|
+
);
|
|
261
|
+
const rw = containerCommand({ cmd: "make", image: PINNED, mounts: { workspace: "rw" }, outputs: ["dist"] }, { cwd: "/src/app" });
|
|
262
|
+
expect(rw).toBe(`docker run --rm -v '/src/app:/workspace:rw' -w /workspace '${PINNED}' sh -c 'make'`);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("an image is pinned by digest, as a literal string", () => {
|
|
266
|
+
expect(imageProblem(PINNED)).toBeUndefined();
|
|
267
|
+
expect(imageProblem("alpine:3.21")).toMatch(/not pinned by digest/);
|
|
268
|
+
expect(imageProblem(`alpine@sha256:${"E".repeat(64)}`)).toMatch(/not pinned by digest/);
|
|
269
|
+
expect(imageProblem(42)).toMatch(/literal string/);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("refuses before running anything: a tag, an output outside the working directory, outputs without an image", async () => {
|
|
273
|
+
await expect(shellCmd({ cmd: "true", image: "alpine:3.21" })).rejects.toThrow(/not pinned by digest/);
|
|
274
|
+
await expect(shellCmd({ cmd: "true", image: PINNED, outputs: ["../escape"] })).rejects.toThrow(/inside the working directory/);
|
|
275
|
+
await expect(shellCmd({ cmd: "true", image: PINNED, outputs: ["/etc"] })).rejects.toThrow(/inside the working directory/);
|
|
276
|
+
await expect(shellCmd({ cmd: "true", outputs: ["dist"] })).rejects.toThrow(/give the step an image/);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("the contract accepts image, mounts and outputs", () => {
|
|
280
|
+
expect(shellCmdContract.args.safeParse({ cmd: "x", image: PINNED, mounts: { workspace: "rw" }, outputs: ["dist"] }).success).toBe(true);
|
|
281
|
+
expect(shellCmdContract.args.safeParse({ cmd: "x", mounts: { workspace: "rwx" } }).success).toBe(false);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("the shell() builder passes them through to the step", () => {
|
|
285
|
+
const step = shell("npm test", { image: PINNED, outputs: ["coverage"] });
|
|
286
|
+
expect(step.args).toEqual({ cmd: "npm test", image: PINNED, outputs: ["coverage"] });
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { exec } from "node:child_process";
|
|
2
|
+
import { mkdir, readdir } from "node:fs/promises";
|
|
3
|
+
import { isAbsolute, join, normalize, resolve } from "node:path";
|
|
2
4
|
import { promisify } from "node:util";
|
|
3
5
|
import { GateWait, asPendingGate } from "../gate-wait";
|
|
4
6
|
import type { PendingGateRecord } from "../../lifecycle/gate-ledger";
|
|
@@ -56,6 +58,84 @@ export interface ShellCmdArgs {
|
|
|
56
58
|
* something else. Empty stdout is `null`.
|
|
57
59
|
*/
|
|
58
60
|
json?: boolean;
|
|
61
|
+
/**
|
|
62
|
+
* Run the command in a container (#4015) instead of on the host: an image
|
|
63
|
+
* pinned by digest, `<ref>@sha256:<64 hex>`, so the step names the
|
|
64
|
+
* toolchain it ran with. The step's working directory is mounted at
|
|
65
|
+
* `/workspace`, which is the command's working directory; the container
|
|
66
|
+
* sees `env` by name and nothing of this process's environment, whatever
|
|
67
|
+
* `inheritEnv` says. Run with `docker run --rm`, or the command
|
|
68
|
+
* `CHANT_CONTAINER_CLI` names (`podman`).
|
|
69
|
+
*/
|
|
70
|
+
image?: string;
|
|
71
|
+
/** How the container mounts the working directory (#4015). Default `{ workspace: "ro" }`. */
|
|
72
|
+
mounts?: { workspace?: "ro" | "rw" };
|
|
73
|
+
/**
|
|
74
|
+
* Directories under the working directory the containerized command writes
|
|
75
|
+
* (#4015). Each is created on the host if missing and mounted writable at
|
|
76
|
+
* the same path, so what the command leaves there stays on the host. A
|
|
77
|
+
* directory still empty when the command ends fails a step that otherwise
|
|
78
|
+
* passed. Only with `image`.
|
|
79
|
+
*/
|
|
80
|
+
outputs?: string[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** An image reference pinned by digest (#4015): anything, then `@sha256:` and 64 lowercase hex digits. */
|
|
84
|
+
export const PINNED_IMAGE = /^[^\s@]+@sha256:[0-9a-f]{64}$/;
|
|
85
|
+
|
|
86
|
+
/** Why `image` can't run, or undefined when it is a reference pinned by digest. */
|
|
87
|
+
export function imageProblem(image: unknown): string | undefined {
|
|
88
|
+
if (typeof image !== "string") return "image must be a literal string, an image reference pinned by digest";
|
|
89
|
+
if (!PINNED_IMAGE.test(image)) return `image ${JSON.stringify(image)} is not pinned by digest: name it as <ref>@sha256:<digest>`;
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Why an `outputs` path can't be copied back, or undefined when it is a relative path inside the working directory. */
|
|
94
|
+
function outputProblem(path: string): string | undefined {
|
|
95
|
+
const n = normalize(path);
|
|
96
|
+
if (path === "" || isAbsolute(path) || n === ".." || n.startsWith("../") || n === ".") return `output ${JSON.stringify(path)} must be a relative path inside the working directory`;
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function shellQuote(s: string): string {
|
|
101
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Where a containerized command sees the working directory. */
|
|
105
|
+
const WORKSPACE = "/workspace";
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The `docker run --rm` line for a step with `image` (#4015). The working
|
|
109
|
+
* directory is mounted at `/workspace`, read-only by default, and each
|
|
110
|
+
* declared output directory is mounted writable on top of it.
|
|
111
|
+
*/
|
|
112
|
+
export function containerCommand(args: ShellCmdArgs & { image: string }, where: { cwd: string; cli?: string; user?: string }): string {
|
|
113
|
+
const mode = args.mounts?.workspace ?? "ro";
|
|
114
|
+
const outputs = mode === "ro" ? (args.outputs ?? []).map((p) => normalize(p).replace(/\/+$/, "")) : [];
|
|
115
|
+
return [
|
|
116
|
+
where.cli ?? "docker",
|
|
117
|
+
"run",
|
|
118
|
+
"--rm",
|
|
119
|
+
...(where.user ? ["--user", where.user] : []),
|
|
120
|
+
"-v",
|
|
121
|
+
shellQuote(`${where.cwd}:${WORKSPACE}:${mode}`),
|
|
122
|
+
...outputs.flatMap((p) => ["-v", shellQuote(`${join(where.cwd, p)}:${WORKSPACE}/${p}:rw`)]),
|
|
123
|
+
"-w",
|
|
124
|
+
WORKSPACE,
|
|
125
|
+
...Object.keys(args.env ?? {}).flatMap((name) => ["-e", shellQuote(name)]),
|
|
126
|
+
shellQuote(args.image),
|
|
127
|
+
"sh",
|
|
128
|
+
"-c",
|
|
129
|
+
shellQuote(args.cmd),
|
|
130
|
+
].join(" ");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Throw when a declared output directory is still empty after a command that passed. */
|
|
134
|
+
async function checkOutputs(outputs: readonly string[], cwd: string): Promise<void> {
|
|
135
|
+
for (const p of outputs) {
|
|
136
|
+
const entries = await readdir(join(cwd, normalize(p))).catch(() => []);
|
|
137
|
+
if (entries.length === 0) throw new Error(`the step declares output ${JSON.stringify(p)}, and the command left it empty`);
|
|
138
|
+
}
|
|
59
139
|
}
|
|
60
140
|
|
|
61
141
|
/**
|
|
@@ -130,12 +210,20 @@ export async function shellCmd(args: ShellCmdArgs, signal?: AbortSignal): Promis
|
|
|
130
210
|
};
|
|
131
211
|
const out = stepLineSplitter();
|
|
132
212
|
const err = stepLineSplitter();
|
|
213
|
+
if (args.image === undefined && (args.outputs !== undefined || args.mounts !== undefined)) {
|
|
214
|
+
throw new Error("outputs and mounts apply only to a step that runs in a container: give the step an image");
|
|
215
|
+
}
|
|
216
|
+
const container = args.image !== undefined ? await prepareContainer(args) : undefined;
|
|
133
217
|
try {
|
|
134
|
-
const running = execAsync(args.cmd, {
|
|
218
|
+
const running = execAsync(container?.cmd ?? args.cmd, {
|
|
135
219
|
cwd: args.cwd,
|
|
136
220
|
// In a run that keeps an in-flight record, CHANT_RUN_ACTIVITY names the
|
|
137
|
-
// file the command appends activity lines to (../run-live.ts).
|
|
138
|
-
|
|
221
|
+
// file the command appends activity lines to (../run-live.ts). Under
|
|
222
|
+
// `image` the container CLI gets this process's environment, so it can
|
|
223
|
+
// reach its daemon, and the container gets `env` alone, by name.
|
|
224
|
+
env: container
|
|
225
|
+
? { ...process.env, ...args.env }
|
|
226
|
+
: { ...(args.inheritEnv === false ? {} : process.env), ...liveRunEnv(), ...args.env },
|
|
139
227
|
maxBuffer: MAX_STDOUT_BYTES,
|
|
140
228
|
signal,
|
|
141
229
|
});
|
|
@@ -152,6 +240,7 @@ export async function shellCmd(args: ShellCmdArgs, signal?: AbortSignal): Promis
|
|
|
152
240
|
}
|
|
153
241
|
const { stdout, stderr } = settled;
|
|
154
242
|
printStderr(stderr);
|
|
243
|
+
if (container) await checkOutputs(args.outputs ?? [], container.cwd);
|
|
155
244
|
return withJson({ stdout: stdout.trim(), stderr: stderr.trim(), exitCode: 0 }, args);
|
|
156
245
|
} catch (caught) {
|
|
157
246
|
const failure = caught as ExecFailure;
|
|
@@ -179,6 +268,24 @@ export async function shellCmd(args: ShellCmdArgs, signal?: AbortSignal): Promis
|
|
|
179
268
|
}
|
|
180
269
|
}
|
|
181
270
|
|
|
271
|
+
/** The container line for a step with `image` (#4015), checked first, with each output directory made on the host. */
|
|
272
|
+
async function prepareContainer(args: ShellCmdArgs): Promise<{ cmd: string; cwd: string }> {
|
|
273
|
+
const problem = imageProblem(args.image) ?? (args.outputs ?? []).map(outputProblem).find((p) => p !== undefined);
|
|
274
|
+
if (problem) throw new Error(problem);
|
|
275
|
+
const cwd = resolve(args.cwd ?? process.cwd());
|
|
276
|
+
// A writable mount inside a read-only one needs its mountpoint to exist already.
|
|
277
|
+
for (const p of args.outputs ?? []) await mkdir(join(cwd, normalize(p)), { recursive: true });
|
|
278
|
+
// The host's uid and gid, so what the command writes belongs to whoever
|
|
279
|
+
// runs chant rather than to the image's root.
|
|
280
|
+
const user = typeof process.getuid === "function" && typeof process.getgid === "function" ? `${process.getuid()}:${process.getgid()}` : undefined;
|
|
281
|
+
const cmd = containerCommand(args as ShellCmdArgs & { image: string }, {
|
|
282
|
+
cwd,
|
|
283
|
+
...(process.env.CHANT_CONTAINER_CLI ? { cli: process.env.CHANT_CONTAINER_CLI } : {}),
|
|
284
|
+
...(user ? { user } : {}),
|
|
285
|
+
});
|
|
286
|
+
return { cmd, cwd };
|
|
287
|
+
}
|
|
288
|
+
|
|
182
289
|
/** The pending fact a command's stdout carries: the whole of it as JSON, or its last line. */
|
|
183
290
|
function pendingFromStdout(stdout: string): PendingGateRecord | undefined {
|
|
184
291
|
const lines = stdout.split("\n");
|
package/src/op/builders.ts
CHANGED
|
@@ -391,6 +391,13 @@ export const lifecycleSnapshot = (env: string, opts?: { id?: string }): NamedAct
|
|
|
391
391
|
* twenty minutes, and keeps the profile's retries: `shell("./build.sh", {
|
|
392
392
|
* timeout: "45m" })`. At most six hours (`MAX_STEP_TIMEOUT`).
|
|
393
393
|
*
|
|
394
|
+
* `image` runs the command in a container instead of on the host (#4015),
|
|
395
|
+
* pinned by digest, with the working directory mounted read-only at
|
|
396
|
+
* `/workspace` unless `mounts` says `rw`, and each `outputs` directory
|
|
397
|
+
* mounted writable:
|
|
398
|
+
* `shell("npm run build", { image: "node:24@sha256:<digest>", outputs: ["dist"] })`.
|
|
399
|
+
* OPS018 refuses an image that is not pinned by digest.
|
|
400
|
+
*
|
|
394
401
|
* `cmd` stays a plain `string` and takes no references. A value spliced into
|
|
395
402
|
* a command line is a quoting decision chant would then be making on the
|
|
396
403
|
* author's behalf, and `env` carries the same value into the same command
|
package/src/op/runtime.ts
CHANGED
|
@@ -62,6 +62,8 @@ export interface OpRunStepRecord {
|
|
|
62
62
|
gate?: { op: string; gate: string };
|
|
63
63
|
/** The run whose checkpoint supplied this step's output, for a step a resumed run skipped (#3947). */
|
|
64
64
|
resumed?: { run: string; gate: string };
|
|
65
|
+
/** The image a `shellCmd` step ran in (#4015), pinned by digest, for a step that ran in a container. */
|
|
66
|
+
image?: string;
|
|
65
67
|
}
|
|
66
68
|
|
|
67
69
|
/** One phase's steps and the verdict they add up to. */
|
|
@@ -118,13 +118,15 @@ describe("a quiet step says it is still running (#3956)", () => {
|
|
|
118
118
|
|
|
119
119
|
test("a step printing output is not interrupted by the still-running line", async () => {
|
|
120
120
|
const out = recorder();
|
|
121
|
-
|
|
121
|
+
// sh, not node: node's start can outlast the heartbeat on a loaded runner,
|
|
122
|
+
// and the gap before the first line is what the heartbeat measures.
|
|
123
|
+
const chatty = `sh -c 'for n in 0 1 2 3 4 5; do echo "tick $n"; sleep 0.04; done'`;
|
|
122
124
|
await runOpLocally(
|
|
123
125
|
{ name: "chatty", overview: "", phases: [phase("Go", [shell(chatty, { id: "ticks" })])] },
|
|
124
126
|
new Map([["shellCmd", shellFn]]),
|
|
125
127
|
ACTIVITY_PROFILES,
|
|
126
128
|
undefined,
|
|
127
|
-
{ gates: memoryGateLedgerPort(), stepOutput: out.write, stepHeartbeatMs:
|
|
129
|
+
{ gates: memoryGateLedgerPort(), stepOutput: out.write, stepHeartbeatMs: 400 },
|
|
128
130
|
);
|
|
129
131
|
expect(out.lines.filter((l) => l.text.includes("still running"))).toEqual([]);
|
|
130
132
|
expect(out.lines.map((l) => l.text)).toContain("[Go] ticks | tick 5");
|
package/src/pr-loop.test.ts
CHANGED
|
@@ -229,6 +229,35 @@ describe("the note", () => {
|
|
|
229
229
|
});
|
|
230
230
|
});
|
|
231
231
|
|
|
232
|
+
describe("the record stage (#4168)", () => {
|
|
233
|
+
const recorded: PrReport = { ...planReport, stage: "record", status: "recorded", approval: { status: "approved", approvedBy: ["github:rev"] } };
|
|
234
|
+
const moved: PrReport = { ...recorded, approval: { status: "changed", approved: changedDigest } };
|
|
235
|
+
const unapproved: PrReport = { ...recorded, approval: { status: "pending" } };
|
|
236
|
+
|
|
237
|
+
test("its reports validate", () => {
|
|
238
|
+
for (const report of [recorded, moved, unapproved]) expectValid(report);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("a plan that differs from the approved one fails the chant/record status; an approved or unapproved one passes", () => {
|
|
242
|
+
expect(prStatusContext("record")).toBe("chant/record");
|
|
243
|
+
expect(prStatusContext("record", "net")).toBe("chant/record/net");
|
|
244
|
+
expect(prStatusState(recorded)).toBe("success");
|
|
245
|
+
expect(prStatusState(unapproved)).toBe("success");
|
|
246
|
+
expect(prStatusState(moved)).toBe("failure");
|
|
247
|
+
expect(prStatusDescription(recorded)).toMatch(/^recorded 2 members: the approved plan; plan [0-9a-f]{12}$/);
|
|
248
|
+
expect(prStatusDescription(moved)).toMatch(/^recorded 2 members: the plan differs from the approved one/);
|
|
249
|
+
expect(prStatusDescription(unapproved)).toMatch(/^recorded 2 members: no approval for this plan/);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("the note says nothing applied and a release deploys it", () => {
|
|
253
|
+
const note = renderPrNote(unapproved);
|
|
254
|
+
expect(note).toContain("### chant record for `prod`");
|
|
255
|
+
expect(note).toContain("Recorded the plan of 2 members on merge. Nothing applied: a release that carries this commit deploys it.");
|
|
256
|
+
expect(note).toContain("No approval stands for this plan. The release gate decides before it deploys.");
|
|
257
|
+
expect(renderPrNote(moved)).toContain("the plan changed since it was approved");
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
232
261
|
describe("the status", () => {
|
|
233
262
|
test("describes each stage in at most 140 characters", () => {
|
|
234
263
|
expect(prStatusDescription(planReport)).toMatch(/^2 members: 1 to change, 0 to destroy or replace; plan [0-9a-f]{12}$/);
|
package/src/pr-loop.ts
CHANGED
|
@@ -68,6 +68,9 @@ export const PR_REPORT_WAVES_FLOOR = "0.124.32";
|
|
|
68
68
|
*/
|
|
69
69
|
export const PR_REPORT_PLANNED_OUTPUTS_FLOOR = "0.124.32";
|
|
70
70
|
|
|
71
|
+
/** The chant that writes the `record` stage and the `recorded` status (#4168 step 1). */
|
|
72
|
+
export const PR_REPORT_RECORD_FLOOR = "0.124.33";
|
|
73
|
+
|
|
71
74
|
/** The gate a pull request's apply waits on, unless the pipeline names another. */
|
|
72
75
|
export const PR_APPLY_GATE = "pr-apply";
|
|
73
76
|
|
|
@@ -97,11 +100,14 @@ export function prNoteMarker(env: string, member?: string): string {
|
|
|
97
100
|
return member ? `<!-- chant-pr:${env}:${member} -->` : `<!-- chant-pr:${env} -->`;
|
|
98
101
|
}
|
|
99
102
|
|
|
100
|
-
/** The status contexts the
|
|
101
|
-
export const PR_STATUS_CONTEXTS = { plan: "chant/plan", apply: "chant/apply" } as const;
|
|
103
|
+
/** The status contexts the stages set on the commit. */
|
|
104
|
+
export const PR_STATUS_CONTEXTS = { plan: "chant/plan", apply: "chant/apply", record: "chant/record" } as const;
|
|
105
|
+
|
|
106
|
+
/** A pull-request loop stage: plan on the pull request, then record (#4168) or, opted in, apply on merge. */
|
|
107
|
+
export type PrStage = keyof typeof PR_STATUS_CONTEXTS;
|
|
102
108
|
|
|
103
109
|
/** A stage's status context: `chant/plan`, or `chant/plan/<member>` for a workspace member's pipeline. */
|
|
104
|
-
export function prStatusContext(stage:
|
|
110
|
+
export function prStatusContext(stage: PrStage, member?: string): string {
|
|
105
111
|
return member ? `${PR_STATUS_CONTEXTS[stage]}/${member}` : PR_STATUS_CONTEXTS[stage];
|
|
106
112
|
}
|
|
107
113
|
|
|
@@ -207,6 +213,12 @@ export interface PrApproval {
|
|
|
207
213
|
export type PrStatus =
|
|
208
214
|
/** Plan stage: every member planned. */
|
|
209
215
|
| "planned"
|
|
216
|
+
/**
|
|
217
|
+
* Record stage (#4168): every member planned on the merged commit and the
|
|
218
|
+
* plan is kept with the merge record. Nothing applied; `approval` says
|
|
219
|
+
* whether the plan is the one approved.
|
|
220
|
+
*/
|
|
221
|
+
| "recorded"
|
|
210
222
|
/** Plan stage: a member failed to plan. Apply stage: the same, so nothing applied. */
|
|
211
223
|
| "plan-failed"
|
|
212
224
|
/** Nothing the change touches is deployed by a component. */
|
|
@@ -284,7 +296,7 @@ export interface PrWave {
|
|
|
284
296
|
export interface PrReport {
|
|
285
297
|
$schema: typeof PR_REPORT_SCHEMA_ID;
|
|
286
298
|
contract: typeof PR_REPORT_CONTRACT;
|
|
287
|
-
stage:
|
|
299
|
+
stage: PrStage;
|
|
288
300
|
/** The pull or merge request number, when known. */
|
|
289
301
|
pr: number | null;
|
|
290
302
|
env: string;
|
|
@@ -406,6 +418,8 @@ function headline(report: PrReport): string {
|
|
|
406
418
|
return "Nothing to plan: no component deploys what this change touches.";
|
|
407
419
|
case "planned":
|
|
408
420
|
return `Planned ${members}.`;
|
|
421
|
+
case "recorded":
|
|
422
|
+
return `Recorded the plan of ${members} on merge. Nothing applied: a release that carries this commit deploys it.`;
|
|
409
423
|
case "plan-failed":
|
|
410
424
|
return `Planning failed for ${report.members.filter((m) => m.status === "plan-failed").length} of ${members}, so nothing can apply.`;
|
|
411
425
|
case "applied":
|
|
@@ -468,7 +482,7 @@ function approvalLines(report: PrReport, approver: string): string[] {
|
|
|
468
482
|
const lines =
|
|
469
483
|
a.status === "changed"
|
|
470
484
|
? [`An approval stands for ${code(a.approved ?? "(none)")}, not for this digest: the plan changed since it was approved.`]
|
|
471
|
-
: ["Not approved yet."];
|
|
485
|
+
: [report.stage === "record" ? "No approval stands for this plan. The release gate decides before it deploys." : "Not approved yet."];
|
|
472
486
|
if (report.stage === "plan" && report.status === "planned") {
|
|
473
487
|
lines.push(
|
|
474
488
|
"",
|
|
@@ -478,7 +492,7 @@ function approvalLines(report: PrReport, approver: string): string[] {
|
|
|
478
492
|
prApproveCommand(report, approver),
|
|
479
493
|
"```",
|
|
480
494
|
"",
|
|
481
|
-
"
|
|
495
|
+
"On merge the plan is recorded, and a release deploys it. With apply on merge it applies if the plan is still this one; if it moved, the apply refuses and names both digests.",
|
|
482
496
|
);
|
|
483
497
|
}
|
|
484
498
|
return lines;
|
|
@@ -494,7 +508,7 @@ export function renderPrNote(report: PrReport, options: { limit?: number; approv
|
|
|
494
508
|
const limit = options.limit ?? GITHUB_COMMENT_LIMIT;
|
|
495
509
|
const out: string[] = [
|
|
496
510
|
prNoteMarker(report.env, report.member),
|
|
497
|
-
`### chant ${report.stage
|
|
511
|
+
`### chant ${report.stage} for ${code(report.env)}${report.member ? ` in member ${code(report.member)}` : ""}`,
|
|
498
512
|
"",
|
|
499
513
|
`${headline(report)} Head ${code(short(report.head))}, measured from ${code(short(report.base))}.`,
|
|
500
514
|
];
|
|
@@ -594,6 +608,9 @@ export function prStatusDescription(report: PrReport): string {
|
|
|
594
608
|
case "plan-failed":
|
|
595
609
|
text = `a member failed to plan${digest}`;
|
|
596
610
|
break;
|
|
611
|
+
case "recorded":
|
|
612
|
+
text = `recorded ${n} member${n === 1 ? "" : "s"}: ${report.approval.status === "approved" ? "the approved plan" : report.approval.status === "changed" ? "the plan differs from the approved one" : "no approval for this plan"}${digest}`;
|
|
613
|
+
break;
|
|
597
614
|
case "applied":
|
|
598
615
|
text = `applied ${n} member${n === 1 ? "" : "s"}${digest}`;
|
|
599
616
|
break;
|
|
@@ -627,6 +644,8 @@ export function prStatusDescription(report: PrReport): string {
|
|
|
627
644
|
|
|
628
645
|
/** The commit status state for a report: success, failure, or pending while it waits on approval. */
|
|
629
646
|
export function prStatusState(report: PrReport): "success" | "failure" | "pending" {
|
|
647
|
+
// #4168 decision 1: a merge whose plan differs from the approved one is recorded, and its status fails.
|
|
648
|
+
if (report.status === "recorded") return report.approval.status === "changed" ? "failure" : "success";
|
|
630
649
|
if (report.status === "planned" || report.status === "applied" || report.status === "wave-applied" || report.status === "nothing") return "success";
|
|
631
650
|
if (report.status === "refused" && (report.refusal === "not-approved" || report.refusal === "wave-not-approved")) return "pending";
|
|
632
651
|
if (report.status === "waiting") return "pending";
|
package/src/pr-report-url.ts
CHANGED
|
@@ -71,7 +71,7 @@ export function reportKey(pr: number | null, commit: string, member?: string): s
|
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
/** The events file of a stage. */
|
|
74
|
-
export const reportEventsFile = (stage: "plan" | "apply"): string => `pr-${stage}.events.jsonl`;
|
|
74
|
+
export const reportEventsFile = (stage: "plan" | "apply" | "record"): string => `pr-${stage}.events.jsonl`;
|
|
75
75
|
|
|
76
76
|
/** Puts one object: `key` below the URL. Rejects with a line a person can read. */
|
|
77
77
|
export type ReportPut = (key: string, body: string, contentType: string) => Promise<void>;
|
|
@@ -194,7 +194,7 @@ const messageOf = (err: unknown): string => (err instanceof Error ? err.message
|
|
|
194
194
|
export function openPrReportUpload(input: {
|
|
195
195
|
target: ReportTarget;
|
|
196
196
|
key: string;
|
|
197
|
-
stage: "plan" | "apply";
|
|
197
|
+
stage: "plan" | "apply" | "record";
|
|
198
198
|
warn: (message: string) => void;
|
|
199
199
|
put?: ReportPut;
|
|
200
200
|
}): PrReportUpload {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@intentius/chant/runner-start` bundles small (#4147): a stage that runs
|
|
3
|
+
* as one bundled file checks its runner before it trades for credentials,
|
|
4
|
+
* so the verifier reaches nothing but itself, the DSSE helpers and
|
|
5
|
+
* `node:crypto` and `node:fs`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { build } from "esbuild";
|
|
10
|
+
import { expect, test } from "vitest";
|
|
11
|
+
import { REPO } from "./workspace/__fixtures__/contract-repo";
|
|
12
|
+
|
|
13
|
+
test("the runner-start verifier holds itself and the DSSE helpers, and imports only node:crypto and node:fs", async () => {
|
|
14
|
+
const result = await build({
|
|
15
|
+
entryPoints: [join(REPO, "packages", "core", "src", "runner-start.ts")],
|
|
16
|
+
bundle: true,
|
|
17
|
+
platform: "node",
|
|
18
|
+
format: "esm",
|
|
19
|
+
write: false,
|
|
20
|
+
metafile: true,
|
|
21
|
+
outdir: "out",
|
|
22
|
+
logLevel: "silent",
|
|
23
|
+
});
|
|
24
|
+
const inputs = Object.keys(result.metafile.inputs).map((i) => i.replace(/^.*?packages\//, "packages/")).sort();
|
|
25
|
+
const imports = [...new Set(Object.values(result.metafile.outputs).flatMap((o) => o.imports.map((i) => i.path)))].sort();
|
|
26
|
+
expect(inputs).toEqual(["packages/core/src/runner-start.ts", "packages/core/src/workspace/trust/dsse.ts"]);
|
|
27
|
+
expect(imports).toEqual(["node:crypto", "node:fs"]);
|
|
28
|
+
expect(result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0)).toBeLessThan(24 * 1024);
|
|
29
|
+
});
|