@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,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #4169 — `ci serve --build-command` serves each `ci/build/<sha>` tag once,
|
|
3
|
+
* within `maxJobs`, and records how each build ended.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { afterAll, describe, expect, test } from "vitest";
|
|
9
|
+
import { cleanScratch, git, scratchDir, writeFiles } from "./__fixtures__/contract-repo";
|
|
10
|
+
import { buildRequestAnnotation, requestedBuild } from "./ci-builds";
|
|
11
|
+
import { ciServeBuilds, ciServePass, formatCiServeEvent, newCiServeRuntime, type CiServeEvent, type CiServeOptions } from "./ci-serve";
|
|
12
|
+
import type { CiGreen } from "./declaration";
|
|
13
|
+
|
|
14
|
+
afterAll(cleanScratch);
|
|
15
|
+
|
|
16
|
+
const GREEN = { branch: "main", phases: [], require: ["suites"] } as unknown as CiGreen;
|
|
17
|
+
|
|
18
|
+
function clone(): { root: string; shas: string[] } {
|
|
19
|
+
const origin = scratchDir("chant-serve-builds-origin-");
|
|
20
|
+
git(origin, "init", "-q", "--bare");
|
|
21
|
+
const root = scratchDir("chant-serve-builds-");
|
|
22
|
+
git(root, "init", "-q");
|
|
23
|
+
git(root, "symbolic-ref", "HEAD", "refs/heads/main");
|
|
24
|
+
git(root, "config", "user.name", "t");
|
|
25
|
+
git(root, "config", "user.email", "t@example.com");
|
|
26
|
+
git(root, "remote", "add", "origin", origin);
|
|
27
|
+
const shas: string[] = [];
|
|
28
|
+
for (const n of ["a", "b"]) {
|
|
29
|
+
writeFiles(root, { [`${n}.txt`]: `${n}\n` });
|
|
30
|
+
git(root, "add", "-A");
|
|
31
|
+
git(root, "commit", "-q", "-m", n);
|
|
32
|
+
shas.push(git(root, "rev-parse", "HEAD"));
|
|
33
|
+
const r = requestedBuild(GREEN, shas.at(-1)!, ["suites"], new Date(`2026-10-20T1${shas.length}:00:00Z`));
|
|
34
|
+
git(root, "tag", "-a", "-m", buildRequestAnnotation(r), r.tag, r.commit);
|
|
35
|
+
}
|
|
36
|
+
git(root, "push", "-q", "origin", "main", "--tags");
|
|
37
|
+
// The serve's clone learns the tags from the forge.
|
|
38
|
+
for (const s of shas) git(root, "tag", "-d", `ci/build/${s}`);
|
|
39
|
+
return { root, shas };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const options = (root: string, built: string[], log: CiServeEvent[], extra: Partial<CiServeOptions> = {}): CiServeOptions => ({
|
|
43
|
+
cwd: root,
|
|
44
|
+
forge: "github",
|
|
45
|
+
repo: "acme/app",
|
|
46
|
+
env: "prod",
|
|
47
|
+
client: { openPullRequests: async () => [], branchHead: async () => undefined },
|
|
48
|
+
stateDir: join(root, ".serve"),
|
|
49
|
+
buildCommand: "true",
|
|
50
|
+
build: async ({ request }) => (built.push(request.commit), request.commit === built[0] ? 0 : 2),
|
|
51
|
+
log: (e) => log.push(e),
|
|
52
|
+
...extra,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("ci serve --build-command (#4169)", () => {
|
|
56
|
+
test("serves each request once, oldest first, within maxJobs, and records how it ended", async () => {
|
|
57
|
+
const { root, shas } = clone();
|
|
58
|
+
const built: string[] = [];
|
|
59
|
+
const log: CiServeEvent[] = [];
|
|
60
|
+
const rt = newCiServeRuntime();
|
|
61
|
+
const first = ciServeBuilds(options(root, built, log), rt);
|
|
62
|
+
expect(first.events.map((e) => e.type === "build" && e.status)).toEqual(["started", "held"]);
|
|
63
|
+
await Promise.all(first.started);
|
|
64
|
+
const second = ciServeBuilds(options(root, built, log), rt);
|
|
65
|
+
await Promise.all(second.started);
|
|
66
|
+
expect(built).toEqual(shas);
|
|
67
|
+
const state = JSON.parse(readFileSync(join(root, ".serve", "chant-apply-prod.json"), "utf-8"));
|
|
68
|
+
expect(Object.fromEntries(Object.entries(state.builds).map(([k, v]) => [k, (v as { status: string }).status]))).toEqual({ [`build:${shas[0]}`]: "ok", [`build:${shas[1]}`]: "fail" });
|
|
69
|
+
// Served once: another pass starts nothing.
|
|
70
|
+
expect(ciServeBuilds(options(root, built, log), rt).started).toEqual([]);
|
|
71
|
+
expect(log.map((e) => formatCiServeEvent(e))).toContain(`the build of suites at ${shas[1].slice(0, 12)} failed with exit code 2`);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("without --build-command a pass leaves the tags alone; the default build runs the command in a worktree of the commit", async () => {
|
|
75
|
+
const { root, shas } = clone();
|
|
76
|
+
const log: CiServeEvent[] = [];
|
|
77
|
+
await ciServePass({ ...options(root, [], log), buildCommand: undefined });
|
|
78
|
+
expect(log.filter((e) => e.type === "build")).toEqual([]);
|
|
79
|
+
const out = join(root, ".serve-out");
|
|
80
|
+
await ciServePass({ ...options(root, [], log), build: undefined, buildCommand: `git rev-parse HEAD >> ${out}; echo "$CHANT_BUILD_PHASES $CHANT_BUILD_REASON" >> ${out}`, maxJobs: 2 });
|
|
81
|
+
expect(existsSync(out)).toBe(true);
|
|
82
|
+
const lines = readFileSync(out, "utf-8").trim().split("\n");
|
|
83
|
+
expect(lines.filter((l) => /^[0-9a-f]{40}$/.test(l)).sort()).toEqual([...shas].sort());
|
|
84
|
+
expect(lines.filter((l) => l === "suites requested")).toHaveLength(2);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -143,6 +143,8 @@ function serve(app: string, client: CiServeForge, extra: Partial<CiServeOptions>
|
|
|
143
143
|
client,
|
|
144
144
|
stateDir: state,
|
|
145
145
|
holder: "serve-test",
|
|
146
|
+
// Most tests here cover the apply on merge, which #4168 made opt-in; the record tests pass false.
|
|
147
|
+
applyOnMerge: true,
|
|
146
148
|
// A job image of its own, so no setup line downloads OpenTofu.
|
|
147
149
|
job: { image: "chant-ci-serve-test:local", chant: fakeChant(), hostEnv: env, passEnv: Object.keys(hostEnv ?? {}) },
|
|
148
150
|
log: (e) => events.push(e),
|
|
@@ -177,6 +179,63 @@ const isAncestor = (cwd: string, a: string, b: string): boolean => {
|
|
|
177
179
|
const argsOf = (report: string, stage: string): string[] => readFileSync(join(report, `${stage}.args`), "utf-8").trimEnd().split("\n");
|
|
178
180
|
const ofType = <T extends CiServeEvent["type"]>(events: CiServeEvent[], type: T) => events.filter((e): e is Extract<CiServeEvent, { type: T }> => e.type === type);
|
|
179
181
|
|
|
182
|
+
describe("ci serve records each merge unless apply on merge is opted in (#4168)", () => {
|
|
183
|
+
test("a merge to the target branch gets the record job, measured from the head before it, and no resume record", async () => {
|
|
184
|
+
const { root, app, main } = workspace();
|
|
185
|
+
const forge = fakeForge(main);
|
|
186
|
+
const { opts, events, state } = serve(app, forge, { applyOnMerge: false });
|
|
187
|
+
await ciServePass(opts);
|
|
188
|
+
const merged = commit(root, "app/merged.txt");
|
|
189
|
+
git(root, "push", "-q", "origin", "main");
|
|
190
|
+
forge.head = merged;
|
|
191
|
+
forge.merges.set(merged, 21);
|
|
192
|
+
|
|
193
|
+
const pass = await ciServePass(opts);
|
|
194
|
+
expect(pass.jobs).toEqual([expect.objectContaining({ job: "record", commit: merged, base: main, pr: 21, status: "ok" })]);
|
|
195
|
+
const argv = argsOf(pass.jobs[0]!.report!, "pr-record");
|
|
196
|
+
expect(argv.slice(0, 4)).toEqual(["components", "pr-record", "--base", main]);
|
|
197
|
+
expect(argv).not.toContain("--resume");
|
|
198
|
+
expect(ofType(events, "started")[0]).not.toHaveProperty("resume");
|
|
199
|
+
expect(existsSync(join(state, "resume"))).toBe(false);
|
|
200
|
+
const saved = readCiServeState(join(state, `${prApplyGroup("prod", "app")}.json`), { forge: "forgejo", repo: "demo/estate", env: "prod", branch: "main" });
|
|
201
|
+
expect(saved.target).toBe(merged);
|
|
202
|
+
expect(saved.jobs[ciServeKey({ job: "record", commit: merged })]).toMatchObject({ status: "ok" });
|
|
203
|
+
expect((await ciServePass(opts)).jobs).toEqual([]);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("each settled job runs the tick, and a tick that fails is an error event, never a failed job", async () => {
|
|
207
|
+
const { root, app, main } = workspace();
|
|
208
|
+
const forge = fakeForge(main);
|
|
209
|
+
const ticks: string[] = [];
|
|
210
|
+
let fail = false;
|
|
211
|
+
const { opts, events } = serve(app, forge, {
|
|
212
|
+
applyOnMerge: false,
|
|
213
|
+
tick: async () => {
|
|
214
|
+
ticks.push("tick");
|
|
215
|
+
if (fail) throw new Error("forge said no");
|
|
216
|
+
return ["ci/green/abc"];
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
await ciServePass(opts);
|
|
220
|
+
expect(ticks).toEqual([]);
|
|
221
|
+
const merged = commit(root, "app/merged.txt");
|
|
222
|
+
git(root, "push", "-q", "origin", "main");
|
|
223
|
+
forge.head = merged;
|
|
224
|
+
await ciServePass(opts);
|
|
225
|
+
expect(ticks).toEqual(["tick"]);
|
|
226
|
+
expect(ofType(events, "tick")).toEqual([{ type: "tick", made: ["ci/green/abc"] }]);
|
|
227
|
+
expect(formatCiServeEvent({ type: "tick", made: ["ci/green/abc"] })).toBe("ci tick made ci/green/abc");
|
|
228
|
+
|
|
229
|
+
fail = true;
|
|
230
|
+
const next = commit(root, "app/next.txt");
|
|
231
|
+
git(root, "push", "-q", "origin", "main");
|
|
232
|
+
forge.head = next;
|
|
233
|
+
const pass = await ciServePass(opts);
|
|
234
|
+
expect(pass.jobs[0]).toMatchObject({ job: "record", status: "ok" });
|
|
235
|
+
expect(ofType(events, "error").map((e) => e.message)).toContain("ci tick after a job settled: forge said no");
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
180
239
|
describe("chant ci serve (#4095)", () => {
|
|
181
240
|
test("one poll plans a new pull request head once, and skips forks and authors not given", async () => {
|
|
182
241
|
const { root, app, main } = workspace();
|
|
@@ -3,11 +3,15 @@
|
|
|
3
3
|
* host. It polls the forge for the open pull requests into the target branch
|
|
4
4
|
* and for the branch's head, and runs the pull-request jobs with
|
|
5
5
|
* {@link ciRun} (./ci-run.ts): `plan` for each new pull request head, and
|
|
6
|
-
* `
|
|
7
|
-
* the previous poll saw (the push's `before`).
|
|
6
|
+
* `record` for each new commit on the target branch (#4168), measured from
|
|
7
|
+
* the head the previous poll saw (the push's `before`). The record applies
|
|
8
|
+
* nothing; with `applyOnMerge` (`--apply-on-merge`) the commit gets `apply`
|
|
9
|
+
* in its place, as before #4168. After each job settles it runs `ci tick`
|
|
10
|
+
* from the runs the jobs recorded, when the declaration has `ci.green`.
|
|
11
|
+
* What the list below says of the apply holds for it under `applyOnMerge`.
|
|
8
12
|
*
|
|
9
13
|
* - Dedupe. A state file in the state directory keeps every job it started,
|
|
10
|
-
* keyed by job and commit (`plan:<pr>:<sha>`, `apply:<sha>`), with how it
|
|
14
|
+
* keyed by job and commit (`plan:<pr>:<sha>`, `record:<sha>`, `apply:<sha>`), with how it
|
|
11
15
|
* ended, and the target head the last apply was measured to. A job already
|
|
12
16
|
* in it is not started again, unless it failed and `rerunFailed` asks for
|
|
13
17
|
* it: the forge's "re-run failed jobs".
|
|
@@ -37,6 +41,14 @@
|
|
|
37
41
|
* - `[skip ci]` (#4122). A target head or pull request head whose commit
|
|
38
42
|
* message says `[skip ci]` or `[ci skip]` is recorded as seen and not run,
|
|
39
43
|
* as Actions does.
|
|
44
|
+
* - Build requests (#4169). With `buildCommand`, each `ci/build/<sha>`
|
|
45
|
+
* tag the tick or `ci run --at` pushed is served once: the command runs
|
|
46
|
+
* in a fresh detached worktree of the commit, with `CHANT_BUILD_COMMIT`,
|
|
47
|
+
* `CHANT_BUILD_PHASES` (comma-separated) and `CHANT_BUILD_REASON` set,
|
|
48
|
+
* and is expected to run the phases' Ops (`chant run <op>`), whose
|
|
49
|
+
* records the tick reads. A build counts against `maxJobs`, and the state
|
|
50
|
+
* file keeps it under `build:<sha>`. Without `buildCommand` the tags are
|
|
51
|
+
* left alone.
|
|
40
52
|
* - The forge's ledger (#4121). Before each job, when no other job of this
|
|
41
53
|
* serve runs, the clone's `chant/lifecycle` follows the forge's: it is
|
|
42
54
|
* deleted when the forge has none, and reset to the forge's when neither
|
|
@@ -54,9 +66,11 @@
|
|
|
54
66
|
* turn a checked payload into the same triggers and schedule them.
|
|
55
67
|
*/
|
|
56
68
|
|
|
57
|
-
import { execFileSync, spawnSync } from "node:child_process";
|
|
58
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
69
|
+
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
|
70
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
71
|
+
import { tmpdir } from "node:os";
|
|
59
72
|
import { join, resolve } from "node:path";
|
|
73
|
+
import { CI_BUILD_TAG_PREFIX, readBuildRequests, type BuildRequest } from "./ci-builds";
|
|
60
74
|
import { prApplyGroup, prApplyRecordKey } from "../components/pr-pipeline";
|
|
61
75
|
import { forgeFromEnv, type OpenPullRequest } from "../pr-forge";
|
|
62
76
|
import type { CommandContext } from "../cli/registry";
|
|
@@ -65,7 +79,7 @@ import { acquireLease, currentHolderId, releaseLease, DEFAULT_LEASE_TTL_MS, type
|
|
|
65
79
|
import { parseDuration } from "../op/duration";
|
|
66
80
|
import { reportUrlOf } from "./ci-report-url";
|
|
67
81
|
import { CI_JOB_OPS, ciJobPlace, ciLoopOptions, ciSecretsOf, ciRun, ciStatusFromEnv, CiRunError, type CiJobName, type CiRunRequest, type CiRunResult } from "./ci-run";
|
|
68
|
-
import { RUNNER_KEY_ENV, signedRunsRequired } from "./ci-green";
|
|
82
|
+
import { CiGreenError, ciTick, RUNNER_KEY_ENV, signedRunsRequired } from "./ci-green";
|
|
69
83
|
import { gitTop } from "./tree";
|
|
70
84
|
import { ciServeRunnerKey, formatRunnerKeySetup, setupRunnerKey } from "./ci-runner-key";
|
|
71
85
|
|
|
@@ -80,10 +94,14 @@ export interface CiServeForge {
|
|
|
80
94
|
pullRequestFor?(sha: string): Promise<number | null>;
|
|
81
95
|
}
|
|
82
96
|
|
|
83
|
-
/**
|
|
97
|
+
/**
|
|
98
|
+
* A job the forge's state calls for: the plan of a pull request's head, or
|
|
99
|
+
* for a target-branch commit its record (#4168) or, with `applyOnMerge`,
|
|
100
|
+
* its apply.
|
|
101
|
+
*/
|
|
84
102
|
export type CiServeTrigger =
|
|
85
103
|
| { job: "plan"; commit: string; base: string; pr: number; rerun?: boolean }
|
|
86
|
-
| { job: "apply"; commit: string; base: string; rerun?: boolean };
|
|
104
|
+
| { job: "apply" | "record"; commit: string; base: string; rerun?: boolean };
|
|
87
105
|
|
|
88
106
|
/** One job in the state file. */
|
|
89
107
|
export interface CiServeJobState {
|
|
@@ -116,6 +134,8 @@ export interface CiServeState {
|
|
|
116
134
|
jobs: Record<string, CiServeJobState>;
|
|
117
135
|
/** Plans seen and not run, by state key, with why (#4122). */
|
|
118
136
|
skipped?: Record<string, "skip-ci">;
|
|
137
|
+
/** The build requests served (#4169), by `build:<sha>`, with how each ended. */
|
|
138
|
+
builds?: Record<string, { tag: string; status: "running" | "ok" | "fail"; startedAt: string; settledAt?: string }>;
|
|
119
139
|
}
|
|
120
140
|
|
|
121
141
|
/** What a pass did, in order, for the log. */
|
|
@@ -128,6 +148,9 @@ export type CiServeEvent =
|
|
|
128
148
|
| { type: "settled"; job: CiJobName; commit: string; pr?: number; status: "ok" | "fail"; failure?: string; report?: string }
|
|
129
149
|
| { type: "not-run"; job: CiJobName; commit: string; pr?: number; reason: "not-fast-forward" | "skip-ci"; branch?: string; from?: string }
|
|
130
150
|
| { type: "ledger"; action: "deleted" | "reset"; local: string; forge?: string }
|
|
151
|
+
| { type: "build"; commit: string; phases: string[]; status: "started" | "ok" | "fail" | "held"; exitCode?: number }
|
|
152
|
+
/** `ci tick` after the pass (#4168): the tags it made from the runs. */
|
|
153
|
+
| { type: "tick"; made: string[] }
|
|
131
154
|
| { type: "error"; message: string };
|
|
132
155
|
|
|
133
156
|
/** The `ciRun` request fields `ci serve` passes through to every job: the generator's options and the runner's. */
|
|
@@ -149,6 +172,18 @@ export interface CiServeOptions {
|
|
|
149
172
|
authors?: readonly string[];
|
|
150
173
|
/** Run again the failed apply at the target's head, and the failed plans of open heads. */
|
|
151
174
|
rerunFailed?: boolean;
|
|
175
|
+
/**
|
|
176
|
+
* Apply each new target-branch commit (#4168), as `ci serve` did before
|
|
177
|
+
* #4168. Default false: each new commit gets the record job, which applies
|
|
178
|
+
* nothing.
|
|
179
|
+
*/
|
|
180
|
+
applyOnMerge?: boolean;
|
|
181
|
+
/**
|
|
182
|
+
* Run after each pass that settled a job (#4168): `ci tick` from the runs
|
|
183
|
+
* the jobs recorded. Returns the tags it made, or undefined when the
|
|
184
|
+
* declaration has no `ci.green`. The command passes one; a test may not.
|
|
185
|
+
*/
|
|
186
|
+
tick?: () => Promise<string[] | undefined>;
|
|
152
187
|
/** Passed to each job. `job.hostEnv` gets `GITHUB_REPOSITORY` set to `repo`. */
|
|
153
188
|
job?: CiServeJobOptions;
|
|
154
189
|
/** This process's lease identity. Default `currentHolderId()`. */
|
|
@@ -158,18 +193,26 @@ export interface CiServeOptions {
|
|
|
158
193
|
now?: () => Date;
|
|
159
194
|
/** Runs a job. Default {@link ciRun}. */
|
|
160
195
|
run?: (req: CiRunRequest) => Promise<CiRunResult>;
|
|
196
|
+
/**
|
|
197
|
+
* The shell line that builds a requested commit (#4169), run in a fresh
|
|
198
|
+
* worktree of it. Without it `ci/build/<sha>` tags are not served.
|
|
199
|
+
*/
|
|
200
|
+
buildCommand?: string;
|
|
201
|
+
/** Runs a build. Default: `sh -c <buildCommand>` in a detached worktree of the commit. */
|
|
202
|
+
build?: (input: { top: string; request: BuildRequest; command: string }) => Promise<number>;
|
|
161
203
|
}
|
|
162
204
|
|
|
163
205
|
/** The jobs this process runs now, by state key, shared by every pass of one loop. */
|
|
164
206
|
export interface CiServeRuntime {
|
|
165
|
-
|
|
207
|
+
/** Jobs and build requests (#4169) alike, by state key: they share `maxJobs`. */
|
|
208
|
+
inflight: Map<string, Promise<unknown>>;
|
|
166
209
|
}
|
|
167
210
|
|
|
168
211
|
export const newCiServeRuntime = (): CiServeRuntime => ({ inflight: new Map() });
|
|
169
212
|
|
|
170
213
|
/** The state key of a job. */
|
|
171
214
|
export function ciServeKey(t: { job: CiJobName; commit: string; pr?: number }): string {
|
|
172
|
-
return t.job === "plan" ? `plan:${t.pr}:${t.commit}` :
|
|
215
|
+
return t.job === "plan" ? `plan:${t.pr}:${t.commit}` : `${t.job}:${t.commit}`;
|
|
173
216
|
}
|
|
174
217
|
|
|
175
218
|
const message = (err: unknown): string => (err instanceof Error ? err.message : String(err));
|
|
@@ -324,6 +367,8 @@ export async function ciServeObserve(opts: CiServeOptions, rt: CiServeRuntime =
|
|
|
324
367
|
const triggers: CiServeTrigger[] = [];
|
|
325
368
|
const events: CiServeEvent[] = [];
|
|
326
369
|
const [head, prs] = await Promise.all([opts.client.branchHead(place.branch), opts.client.openPullRequests(place.branch)]);
|
|
370
|
+
// #4168: a new target commit is recorded, and applied only with applyOnMerge.
|
|
371
|
+
const onMerge: "apply" | "record" = opts.applyOnMerge ? "apply" : "record";
|
|
327
372
|
|
|
328
373
|
if (head === undefined) {
|
|
329
374
|
events.push({ type: "no-branch", branch: place.branch });
|
|
@@ -336,7 +381,7 @@ export async function ciServeObserve(opts: CiServeOptions, rt: CiServeRuntime =
|
|
|
336
381
|
} else if (head !== state.target) {
|
|
337
382
|
const from = state.target;
|
|
338
383
|
const move = targetMove(place.top, place.branch, from, head);
|
|
339
|
-
const entry = state.jobs[ciServeKey({ job:
|
|
384
|
+
const entry = state.jobs[ciServeKey({ job: onMerge, commit: head })];
|
|
340
385
|
if (move === "unknown") {
|
|
341
386
|
events.push({ type: "error", message: `${place.branch} is at ${head.slice(0, 12)} on the forge, and that commit could not be fetched; the next poll tries again` });
|
|
342
387
|
} else if (move === "other" || (!entry && skipsCi(place.top, head))) {
|
|
@@ -344,13 +389,13 @@ export async function ciServeObserve(opts: CiServeOptions, rt: CiServeRuntime =
|
|
|
344
389
|
update((s) => {
|
|
345
390
|
s.target = head;
|
|
346
391
|
});
|
|
347
|
-
events.push({ type: "not-run", job:
|
|
348
|
-
} else if (!entry) triggers.push({ job:
|
|
349
|
-
else if (opts.rerunFailed && failedEntry(entry, ciServeKey(entry), rt)) triggers.push({ job:
|
|
392
|
+
events.push({ type: "not-run", job: onMerge, commit: head, reason: move === "other" ? "not-fast-forward" : "skip-ci", ...(move === "other" ? { branch: place.branch, from } : {}) });
|
|
393
|
+
} else if (!entry) triggers.push({ job: onMerge, commit: head, base: from });
|
|
394
|
+
else if (opts.rerunFailed && failedEntry(entry, ciServeKey(entry), rt)) triggers.push({ job: onMerge, commit: head, base: entry.base, rerun: true });
|
|
350
395
|
} else {
|
|
351
|
-
const key = ciServeKey({ job:
|
|
396
|
+
const key = ciServeKey({ job: onMerge, commit: head });
|
|
352
397
|
const entry = state.jobs[key];
|
|
353
|
-
if (opts.rerunFailed && failedEntry(entry, key, rt)) triggers.push({ job:
|
|
398
|
+
if (opts.rerunFailed && failedEntry(entry, key, rt)) triggers.push({ job: onMerge, commit: head, base: entry.base, rerun: true });
|
|
354
399
|
}
|
|
355
400
|
|
|
356
401
|
const authors = opts.authors?.length ? new Set(opts.authors.map((a) => a.toLowerCase())) : undefined;
|
|
@@ -453,7 +498,7 @@ export async function ciServeSchedule(opts: CiServeOptions, triggers: readonly C
|
|
|
453
498
|
const entry: CiServeJobState = { job: t.job, commit: t.commit, base: t.base, ...pr, status: "running", runId, attempts: attempt, startedAt: now().toISOString() };
|
|
454
499
|
update((s) => {
|
|
455
500
|
s.jobs[key] = entry;
|
|
456
|
-
if (t.job
|
|
501
|
+
if (t.job !== "plan" && !t.rerun) s.target = t.commit;
|
|
457
502
|
});
|
|
458
503
|
emit({ type: "started", job: t.job, commit: t.commit, ...pr, base: t.base, runId, attempt, ...(resume && existsSync(resume) ? { resume } : {}) });
|
|
459
504
|
|
|
@@ -468,7 +513,7 @@ export async function ciServeSchedule(opts: CiServeOptions, triggers: readonly C
|
|
|
468
513
|
emit({ type: "error", message: `${key} settled, and the state file could not be written: ${message(err)}` });
|
|
469
514
|
}
|
|
470
515
|
emit({ type: "settled", job: t.job, commit: t.commit, ...pr, status: settled.status === "ok" ? "ok" : "fail", ...(settled.failure ? { failure: settled.failure } : {}), ...(settled.report ? { report: settled.report } : {}) });
|
|
471
|
-
return settled;
|
|
516
|
+
return serveTick(opts).then(() => settled);
|
|
472
517
|
});
|
|
473
518
|
rt.inflight.set(key, job);
|
|
474
519
|
started.push(job);
|
|
@@ -496,7 +541,7 @@ async function runJob(
|
|
|
496
541
|
tryGit(place.top, ["fetch", "--quiet", "--no-tags", "origin", t.job === "plan" ? `refs/pull/${t.pr}/head` : place.branch]);
|
|
497
542
|
const hostEnv = { ...(opts.job?.hostEnv ?? process.env), GITHUB_REPOSITORY: opts.repo };
|
|
498
543
|
// An apply is for a merge: pass the pull request whose merge commit it is, so the commit status link and the run index use `<pr>/<commit>`. None found, or a forge that errs, is today's behaviour.
|
|
499
|
-
if (t.job
|
|
544
|
+
if (t.job !== "plan" && opts.client.pullRequestFor) merged = (await opts.client.pullRequestFor(t.commit).catch(() => null)) ?? undefined;
|
|
500
545
|
const run = opts.run ?? ciRun;
|
|
501
546
|
const r = await run({
|
|
502
547
|
...opts.job,
|
|
@@ -522,13 +567,101 @@ async function runJob(
|
|
|
522
567
|
}
|
|
523
568
|
}
|
|
524
569
|
|
|
525
|
-
/** One pass: observe, schedule, and with `wait` wait for the jobs it started. */
|
|
570
|
+
/** One pass: observe, schedule, serve build requests, and with `wait` wait for the jobs it started. */
|
|
526
571
|
export async function ciServePass(opts: CiServeOptions, rt: CiServeRuntime = newCiServeRuntime(), wait = true): Promise<{ events: CiServeEvent[]; jobs: CiServeJobState[] }> {
|
|
527
572
|
const observed = await ciServeObserve(opts, rt);
|
|
528
573
|
for (const e of observed.events) opts.log?.(e);
|
|
529
574
|
const scheduled = await ciServeSchedule(opts, observed.triggers, rt);
|
|
575
|
+
const builds = opts.buildCommand ? ciServeBuilds(opts, rt) : { started: [], events: [] };
|
|
530
576
|
const jobs = wait ? await Promise.all(scheduled.started) : [];
|
|
531
|
-
|
|
577
|
+
if (wait) await Promise.all(builds.started);
|
|
578
|
+
return { events: [...observed.events, ...scheduled.events, ...builds.events], jobs };
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Serve the build requests (#4169): fetch the `ci/build/*` tags, and start
|
|
583
|
+
* `buildCommand` for each one not in the state file yet, oldest first, while
|
|
584
|
+
* fewer than `maxJobs` jobs run. A build that fails is recorded and not run
|
|
585
|
+
* again; the tick decides what its phases say.
|
|
586
|
+
*/
|
|
587
|
+
export function ciServeBuilds(opts: CiServeOptions, rt: CiServeRuntime): { started: Promise<void>[]; events: CiServeEvent[] } {
|
|
588
|
+
const place = servePlace(opts);
|
|
589
|
+
const fresh = { forge: opts.forge, repo: opts.repo, env: opts.env, branch: place.branch, ...(place.member ? { member: place.member } : {}) };
|
|
590
|
+
const update = (fn: (s: CiServeState) => void): void => {
|
|
591
|
+
const st = readCiServeState(place.stateFile, fresh);
|
|
592
|
+
fn(st);
|
|
593
|
+
writeState(place.stateFile, st);
|
|
594
|
+
};
|
|
595
|
+
const events: CiServeEvent[] = [];
|
|
596
|
+
const started: Promise<void>[] = [];
|
|
597
|
+
tryGit(place.top, ["fetch", "--quiet", "--prune", "origin", `+refs/tags/${CI_BUILD_TAG_PREFIX}*:refs/tags/${CI_BUILD_TAG_PREFIX}*`]);
|
|
598
|
+
const served = readCiServeState(place.stateFile, fresh).builds ?? {};
|
|
599
|
+
const max = opts.maxJobs ?? 1;
|
|
600
|
+
const now = opts.now ?? (() => new Date());
|
|
601
|
+
const requests = [...readBuildRequests(place.top).values()].sort((a, b) => a.at.localeCompare(b.at));
|
|
602
|
+
for (const request of requests) {
|
|
603
|
+
const key = `build:${request.commit}`;
|
|
604
|
+
if (served[key]?.tag === request.tag || rt.inflight.has(key)) continue;
|
|
605
|
+
const log = (e: CiServeEvent) => {
|
|
606
|
+
events.push(e);
|
|
607
|
+
opts.log?.(e);
|
|
608
|
+
};
|
|
609
|
+
if (rt.inflight.size >= max) {
|
|
610
|
+
log({ type: "build", commit: request.commit, phases: request.phases, status: "held" });
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
update((st) => {
|
|
614
|
+
st.builds = { ...st.builds, [key]: { tag: request.tag, status: "running", startedAt: now().toISOString() } };
|
|
615
|
+
});
|
|
616
|
+
log({ type: "build", commit: request.commit, phases: request.phases, status: "started" });
|
|
617
|
+
const build = opts.build ?? runBuild;
|
|
618
|
+
const job = build({ top: place.top, request, command: opts.buildCommand! })
|
|
619
|
+
.catch(() => 1)
|
|
620
|
+
.then((code) => {
|
|
621
|
+
update((st) => {
|
|
622
|
+
st.builds = { ...st.builds, [key]: { tag: request.tag, status: code === 0 ? "ok" : "fail", startedAt: st.builds?.[key]?.startedAt ?? now().toISOString(), settledAt: now().toISOString() } };
|
|
623
|
+
});
|
|
624
|
+
opts.log?.({ type: "build", commit: request.commit, phases: request.phases, status: code === 0 ? "ok" : "fail", exitCode: code });
|
|
625
|
+
});
|
|
626
|
+
const tracked = job.finally(() => rt.inflight.delete(key));
|
|
627
|
+
rt.inflight.set(key, tracked);
|
|
628
|
+
started.push(tracked);
|
|
629
|
+
}
|
|
630
|
+
return { started, events };
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/** `sh -c <command>` in a fresh detached worktree of the request's commit, removed after. Resolves to the exit code. */
|
|
634
|
+
async function runBuild(input: { top: string; request: BuildRequest; command: string }): Promise<number> {
|
|
635
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-ci-build-"));
|
|
636
|
+
try {
|
|
637
|
+
execFileSync("git", ["worktree", "add", "--quiet", "--detach", dir, input.request.commit], { cwd: input.top, stdio: ["ignore", "pipe", "pipe"] });
|
|
638
|
+
return await new Promise<number>((done) => {
|
|
639
|
+
const child = spawn("sh", ["-c", input.command], {
|
|
640
|
+
cwd: dir,
|
|
641
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
642
|
+
env: { ...process.env, CHANT_BUILD_COMMIT: input.request.commit, CHANT_BUILD_PHASES: input.request.phases.join(","), CHANT_BUILD_REASON: input.request.reason },
|
|
643
|
+
});
|
|
644
|
+
child.on("error", () => done(127));
|
|
645
|
+
child.on("close", (code) => done(code ?? 1));
|
|
646
|
+
});
|
|
647
|
+
} finally {
|
|
648
|
+
tryGit(input.top, ["worktree", "remove", "--force", dir]);
|
|
649
|
+
rmSync(dir, { recursive: true, force: true });
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/** `ci tick` after a job settled (#4168), logged; a tick that fails is an error event and never fails the job. */
|
|
654
|
+
async function serveTick(opts: CiServeOptions): Promise<CiServeEvent | undefined> {
|
|
655
|
+
if (!opts.tick) return undefined;
|
|
656
|
+
let event: CiServeEvent | undefined;
|
|
657
|
+
try {
|
|
658
|
+
const made = await opts.tick();
|
|
659
|
+
event = made === undefined ? undefined : { type: "tick", made };
|
|
660
|
+
} catch (err) {
|
|
661
|
+
event = { type: "error", message: `ci tick after a job settled: ${message(err)}` };
|
|
662
|
+
}
|
|
663
|
+
if (event) opts.log?.(event);
|
|
664
|
+
return event;
|
|
532
665
|
}
|
|
533
666
|
|
|
534
667
|
/**
|
|
@@ -579,6 +712,13 @@ export function formatCiServeEvent(e: CiServeEvent): string {
|
|
|
579
712
|
return e.action === "deleted"
|
|
580
713
|
? `the forge has no ${LEDGER}, so this clone's, at ${e.local.slice(0, 12)}, was deleted`
|
|
581
714
|
: `the forge's ${LEDGER} at ${e.forge!.slice(0, 12)} does not contain this clone's at ${e.local.slice(0, 12)}, so this clone's was reset to the forge's and not pushed`;
|
|
715
|
+
case "build": {
|
|
716
|
+
const what = `the build of ${e.phases.join(", ")} at ${e.commit.slice(0, 12)}`;
|
|
717
|
+
if (e.status === "held") return `${what} waits: --max-jobs jobs run`;
|
|
718
|
+
return e.status === "started" ? `${what} started` : e.status === "ok" ? `${what} passed` : `${what} failed${e.exitCode !== undefined ? ` with exit code ${e.exitCode}` : ""}`;
|
|
719
|
+
}
|
|
720
|
+
case "tick":
|
|
721
|
+
return e.made.length > 0 ? `ci tick made ${e.made.join(", ")}` : "ci tick: no tag due";
|
|
582
722
|
case "error":
|
|
583
723
|
return `error: ${e.message}`;
|
|
584
724
|
}
|
|
@@ -615,7 +755,7 @@ export function runnerKeySetupCommand(ctx: CommandContext): number {
|
|
|
615
755
|
const USAGE =
|
|
616
756
|
"chant ci serve --setup-key [--trust [--principal <name>]] [--state <dir>] [--branch <name>] | " +
|
|
617
757
|
"chant ci serve --forge forgejo|github --repo <owner/repo> --env <env> [--interval <s>] [--max-jobs <n>] [--state <dir>] " +
|
|
618
|
-
"[--author <login>]... [--once] [--rerun-failed] [--gate <name>] [--wave-gate <name>] [--branch <name>] [--events-json] [--report-url <url>] [--no-status] [--status-url <url template>] " +
|
|
758
|
+
"[--author <login>]... [--once] [--rerun-failed] [--build-command <line>] [--gate <name>] [--wave-gate <name>] [--branch <name>] [--events-json] [--report-url <url>] [--no-status] [--status-url <url template>] " +
|
|
619
759
|
"[--image <image>] [--before-script <line>]... [--pr-build] [--pr-build-command <line>] [--chant <command>] [--pass-env <NAME>]... [--secrets <file>] [--key <runner key.pem>] [--unsigned]";
|
|
620
760
|
|
|
621
761
|
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
@@ -638,6 +778,8 @@ export async function runCiServe(ctx: CommandContext): Promise<number> {
|
|
|
638
778
|
if (!args.ciRepo) throw new CiRunError("--repo names the repository whose pull requests ci serve polls: --repo <owner/repo>");
|
|
639
779
|
if (!args.env) throw new CiRunError("--env names the environment the pipeline is for: --env <env>");
|
|
640
780
|
for (const name of args.passEnv ?? []) if (!ENV_NAME.test(name)) throw new CiRunError(`--pass-env takes a variable's name, not ${JSON.stringify(name)}`);
|
|
781
|
+
// #4168: a gate per wave gates the apply on merge, which a merge runs only when opted in.
|
|
782
|
+
if (args.waveGate && !args.applyOnMerge) throw new CiRunError("--wave-gate gates the apply on merge, so it needs --apply-on-merge (#4168); without it a merge gets the record job, which applies nothing");
|
|
641
783
|
const intervalMs = ciServeInterval(args.interval);
|
|
642
784
|
const cwd = process.cwd();
|
|
643
785
|
const top = gitTop(cwd);
|
|
@@ -670,6 +812,8 @@ export async function runCiServe(ctx: CommandContext): Promise<number> {
|
|
|
670
812
|
const { status, warning } = ciStatusFromEnv({ forge, hostEnv, noStatus: args.noStatus, statusUrl: args.statusUrl });
|
|
671
813
|
if (warning) console.error(formatWarning({ message: warning }));
|
|
672
814
|
if (!client.openPullRequests || !client.branchHead) throw new CiRunError(`the ${forge} client cannot list pull requests`);
|
|
815
|
+
// #4168: a host that applied each merge before the upgrade hears that it no longer does.
|
|
816
|
+
if (!args.applyOnMerge) console.error("ci serve: each new commit on the target branch gets the record job, which applies nothing (#4168); --apply-on-merge applies it");
|
|
673
817
|
const opts: CiServeOptions = {
|
|
674
818
|
cwd,
|
|
675
819
|
forge,
|
|
@@ -680,6 +824,17 @@ export async function runCiServe(ctx: CommandContext): Promise<number> {
|
|
|
680
824
|
...(args.maxJobs ? { maxJobs: args.maxJobs } : {}),
|
|
681
825
|
...(args.ciAuthors?.length ? { authors: args.ciAuthors } : {}),
|
|
682
826
|
...(args.rerunFailed ? { rerunFailed: true } : {}),
|
|
827
|
+
...(args.ciBuildCommand ? { buildCommand: args.ciBuildCommand } : {}),
|
|
828
|
+
...(args.applyOnMerge ? { applyOnMerge: true } : {}),
|
|
829
|
+
// #4168: the tick from the runs the jobs recorded, signed with the runs' key. No ci.green, no tick.
|
|
830
|
+
tick: async () => {
|
|
831
|
+
try {
|
|
832
|
+
return (await ciTick({ cwd, source: "runs", ...(runnerKey !== undefined ? { runnerKey } : {}) })).made;
|
|
833
|
+
} catch (err) {
|
|
834
|
+
if (err instanceof CiGreenError && err.code === "ci-green-undeclared") return undefined;
|
|
835
|
+
throw err;
|
|
836
|
+
}
|
|
837
|
+
},
|
|
683
838
|
job: {
|
|
684
839
|
loop: ciLoopOptions(reportUrl ? { ...args, reportUrl } : args),
|
|
685
840
|
...(args.runIndexLimit ? { runIndexLimit: args.runIndexLimit } : {}),
|
|
@@ -236,6 +236,14 @@
|
|
|
236
236
|
"description": "A deploy counts as failed when, within this many days after it, the commit it deployed is revoked or the environment is rolled back from it. The default is 7."
|
|
237
237
|
}
|
|
238
238
|
}
|
|
239
|
+
},
|
|
240
|
+
"security": {
|
|
241
|
+
"type": "array",
|
|
242
|
+
"uniqueItems": true,
|
|
243
|
+
"description": "The security groups the workspace opts into (#4168), for every member; a member's own security adds to them. With none, a security feature the workspace has not turned on never refuses, warns or hides anything. verified-releases: release publish and promote --release take only a verified release. supply-chain: a publish without OIDC provenance warns, and a target with no SBOM source is refused. who-ran-it: ci tick needs a runner key. review-rules: pr-apply requires a forge review. isolated-runners: an apply job requires a runner-start statement. workspace check reports what a declared group lacks (WSP161 to WSP165). Added in schema 1 by chant 0.124.33, so a declaration that uses it sets minReader to 0.124.33 or newer.",
|
|
244
|
+
"items": {
|
|
245
|
+
"enum": ["verified-releases", "supply-chain", "who-ran-it", "review-rules", "isolated-runners"]
|
|
246
|
+
}
|
|
239
247
|
}
|
|
240
248
|
},
|
|
241
249
|
"patternProperties": {
|
|
@@ -487,6 +495,14 @@
|
|
|
487
495
|
"type": "boolean",
|
|
488
496
|
"description": "Whether the member goes with an export (#2524 D10, #2552). chant workspace export writes only members that set travel to true into the export member, and refuses to name one that does not. Added in schema 1 by chant 0.101.0, so a declaration that uses it sets minReader to 0.101.0 or newer."
|
|
489
497
|
},
|
|
498
|
+
"security": {
|
|
499
|
+
"type": "array",
|
|
500
|
+
"uniqueItems": true,
|
|
501
|
+
"description": "The security groups this member opts into, added to the declaration's top-level security (#4168). A release, publish or pull-request job of this member runs under both lists. Added in schema 1 by chant 0.124.33, so a declaration that uses it sets minReader to 0.124.33 or newer.",
|
|
502
|
+
"items": {
|
|
503
|
+
"enum": ["verified-releases", "supply-chain", "who-ran-it", "review-rules", "isolated-runners"]
|
|
504
|
+
}
|
|
505
|
+
},
|
|
490
506
|
"because": {
|
|
491
507
|
"type": "string",
|
|
492
508
|
"minLength": 1,
|
|
@@ -1562,6 +1578,13 @@
|
|
|
1562
1578
|
"fail"
|
|
1563
1579
|
],
|
|
1564
1580
|
"description": "What a check run that finished as skipped counts as. fail (the default): it never passes. pass: it passes, for a job a path filter runs only on some commits."
|
|
1581
|
+
},
|
|
1582
|
+
"build": {
|
|
1583
|
+
"enum": [
|
|
1584
|
+
"every",
|
|
1585
|
+
"newest"
|
|
1586
|
+
],
|
|
1587
|
+
"description": "Added by #4169, read by chant 0.124.33 and newer. Which first-parent commits of the branch the phase runs on. every (the default): each one. newest: only the newest commit when a runner frees. Under either, a run that was cancelled leaves the commit unbuilt rather than red. Under newest, a commit a newer run superseded is unbuilt too, and a red commit starts a bisect over the unbuilt commits back to the last one the phase passed on. chant ci workflow writes the triggers and concurrency of the workflows holding a phase that sets build, and leaves them alone for a phase that does not."
|
|
1565
1588
|
}
|
|
1566
1589
|
},
|
|
1567
1590
|
"patternProperties": {
|
|
@@ -459,6 +459,23 @@ describe("ci.reportUrl (#4160)", () => {
|
|
|
459
459
|
});
|
|
460
460
|
});
|
|
461
461
|
|
|
462
|
+
describe("security (#4168)", () => {
|
|
463
|
+
test("the groups are read in file order, empty when absent, and securityGroupsOf reads them", async () => {
|
|
464
|
+
const { securityGroupsOf } = await import("./security");
|
|
465
|
+
expect(parse(base([])).security).toEqual([]);
|
|
466
|
+
const decl = parse(base([], { security: ["supply-chain", "verified-releases"] }));
|
|
467
|
+
expect(decl.security).toEqual(["supply-chain", "verified-releases"]);
|
|
468
|
+
expect([...securityGroupsOf(decl)]).toEqual(["supply-chain", "verified-releases"]);
|
|
469
|
+
expect(securityGroupsOf(null).size).toBe(0);
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
test("an unknown group, a repeated group and a non-array are refused", () => {
|
|
473
|
+
for (const security of [["signed"], ["supply-chain", "supply-chain"], "supply-chain"]) {
|
|
474
|
+
expect(failure(base([], { security })).code).toBe("declaration-invalid");
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
|
|
462
479
|
describe("metrics (#4039)", () => {
|
|
463
480
|
test("failureWindowDays is read as written, without x- keys, and the block is null when absent", () => {
|
|
464
481
|
expect(parse(base([])).metrics).toBeNull();
|