@intentius/chant 0.124.32 → 0.124.34
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 +277 -74
- 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 +99 -22
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +30 -1
- package/dist/cli/registry.d.ts +21 -5
- 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/promote-rounds.d.ts +102 -0
- package/dist/components/promote-rounds.d.ts.map +1 -0
- package/dist/components/promote-rounds.js +174 -0
- 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-gate.d.ts +30 -0
- package/dist/components/release-gate.d.ts.map +1 -1
- package/dist/components/release-gate.js +92 -42
- 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-declared.d.ts +4 -0
- package/dist/graph-declared.d.ts.map +1 -1
- package/dist/graph-declared.js +11 -1
- 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 +236 -229
- package/dist/workspace/__fixtures__/contract-repo.d.ts.map +1 -1
- package/dist/workspace/__fixtures__/contract-repo.js +13 -2
- package/dist/workspace/check.schema.json +88 -2
- package/dist/workspace/checks/ci-workflow.d.ts +17 -0
- package/dist/workspace/checks/ci-workflow.d.ts.map +1 -0
- package/dist/workspace/checks/ci-workflow.js +123 -0
- package/dist/workspace/checks/security.d.ts +50 -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 +8 -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-init.d.ts +100 -0
- package/dist/workspace/ci-init.d.ts.map +1 -0
- package/dist/workspace/ci-init.js +434 -0
- 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/ci-workflow.d.ts +91 -0
- package/dist/workspace/ci-workflow.d.ts.map +1 -0
- package/dist/workspace/ci-workflow.js +33 -0
- package/dist/workspace/declaration.d.ts +35 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.js +12 -2
- package/dist/workspace/declaration.schema.json +51 -0
- package/dist/workspace/init.js +2 -2
- 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 +26 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/reason-codes.js +28 -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/release-workflow.d.ts +31 -0
- package/dist/workspace/release-workflow.d.ts.map +1 -0
- package/dist/workspace/release-workflow.js +150 -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 +296 -84
- 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 +99 -22
- package/src/cli/parse-args.ts +22 -1
- package/src/cli/registry.ts +21 -5
- 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/promote-rounds.test.ts +220 -0
- package/src/components/promote-rounds.ts +232 -0
- package/src/components/release-compile.test.ts +34 -0
- package/src/components/release-compile.ts +89 -8
- package/src/components/release-gate.test.ts +47 -0
- package/src/components/release-gate.ts +111 -46
- 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-declared-exports.test.ts +40 -0
- package/src/graph-declared.ts +9 -1
- 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/__fixtures__/contract-repo.ts +11 -2
- package/src/workspace/check-contract.test.ts +4 -2
- package/src/workspace/check.schema.json +88 -2
- package/src/workspace/checks/ci-workflow.test.ts +150 -0
- package/src/workspace/checks/ci-workflow.ts +129 -0
- package/src/workspace/checks/security.test.ts +138 -0
- package/src/workspace/checks/security.ts +305 -0
- package/src/workspace/checks.ts +8 -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-init.test.ts +159 -0
- package/src/workspace/ci-init.ts +445 -0
- package/src/workspace/ci-last-green.schema.json +49 -0
- package/src/workspace/ci-list.schema.json +35 -1
- package/src/workspace/ci-report-url.test.ts +6 -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/ci-workflow.ts +71 -0
- package/src/workspace/declaration.schema.json +51 -0
- package/src/workspace/declaration.test.ts +17 -0
- package/src/workspace/declaration.ts +53 -3
- package/src/workspace/init.ts +2 -2
- 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 +4 -0
- package/src/workspace/reason-codes.ts +28 -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/release-workflow.ts +167 -0
- package/src/workspace/security.test.ts +70 -0
- package/src/workspace/security.ts +118 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #4169 — which commits get built: supersession, retries, requests and
|
|
3
|
+
* bisection over judged commits, and the records each commit gets.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "vitest";
|
|
7
|
+
import { agedOut, BUILD_REQUEST_TTL_MS, planBuilds, recordOf, RETRY_TTL_MS, sameRecord, type BuildRequest, type CiRecord } from "./ci-builds";
|
|
8
|
+
import { evaluateCommit, type TickCommit } from "./ci-green";
|
|
9
|
+
import type { CheckRun } from "./ci-green-forge";
|
|
10
|
+
import type { CiGreen } from "./declaration";
|
|
11
|
+
|
|
12
|
+
const NOW = new Date("2026-10-20T12:00:00Z");
|
|
13
|
+
const ago = (ms: number) => new Date(NOW.getTime() - ms).toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
14
|
+
|
|
15
|
+
const GREEN: CiGreen = {
|
|
16
|
+
branch: "main",
|
|
17
|
+
window: "24h",
|
|
18
|
+
phases: [
|
|
19
|
+
{ name: "check", runs: ["check"], skipped: "fail", build: "every", pointer: "/ci/green/phases/check" },
|
|
20
|
+
{ name: "suites", runs: ["suites"], skipped: "fail", build: "newest", pointer: "/ci/green/phases/suites" },
|
|
21
|
+
],
|
|
22
|
+
require: ["check", "suites"],
|
|
23
|
+
signedSince: null,
|
|
24
|
+
source: "checks",
|
|
25
|
+
pointer: "/ci/green",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
let nextId = 1;
|
|
29
|
+
const run = (name: string, conclusion: string | null, status = "completed"): CheckRun => ({ id: nextId++, name, status, conclusion: status === "completed" ? conclusion : null, completedAt: null, url: null });
|
|
30
|
+
const sha = (n: number) => String(n).padStart(40, "0");
|
|
31
|
+
|
|
32
|
+
/** Commits newest first, each with its runs, judged as the tick judges them. */
|
|
33
|
+
function judged(runsPerCommit: CheckRun[][], before: TickCommit["before"][] = []): { commits: TickCommit[]; runs: Map<string, CheckRun[]> } {
|
|
34
|
+
const runs = new Map<string, CheckRun[]>();
|
|
35
|
+
const commits = runsPerCommit.map((r, i) => {
|
|
36
|
+
const s = sha(runsPerCommit.length - i);
|
|
37
|
+
runs.set(s, r);
|
|
38
|
+
return { sha: s, committed: NOW.toISOString(), before: before[i] ?? "none", verdict: evaluateCommit(GREEN, r), made: null } as TickCommit;
|
|
39
|
+
});
|
|
40
|
+
return { commits, runs };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const plan = (commits: TickCommit[], runs: Map<string, CheckRun[]>, extra: Partial<Parameters<typeof planBuilds>[0]> = {}) =>
|
|
44
|
+
planBuilds({ green: GREEN, commits, runs, requests: new Map(), records: new Map(), now: NOW, retryVia: null, ...extra });
|
|
45
|
+
|
|
46
|
+
const suites = (c: TickCommit) => c.verdict!.phases.find((p) => p.phase === "suites")!;
|
|
47
|
+
|
|
48
|
+
describe("supersession (#4169)", () => {
|
|
49
|
+
test("a newest phase with no run below a commit that has one is unbuilt, covered by it; the newest commit stays pending", () => {
|
|
50
|
+
const { commits, runs } = judged([[run("check", "success")], [run("check", "success"), run("suites", "success")], [run("check", "success")]]);
|
|
51
|
+
plan(commits, runs);
|
|
52
|
+
expect(commits.map((c) => c.verdict!.verdict)).toEqual(["pending", "pass", "unbuilt"]);
|
|
53
|
+
expect(suites(commits[2])).toMatchObject({ verdict: "unbuilt", reason: "superseded", coveredBy: commits[1].sha });
|
|
54
|
+
expect(suites(commits[0])).toMatchObject({ verdict: "pending" });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("an every phase is never superseded, and a cancelled run is unbuilt under either", () => {
|
|
58
|
+
const { commits, runs } = judged([[run("check", "success"), run("suites", "success")], [run("suites", "success")], [run("check", "cancelled"), run("suites", "success")]]);
|
|
59
|
+
plan(commits, runs);
|
|
60
|
+
expect(commits.map((c) => c.verdict!.verdict)).toEqual(["pass", "pending", "unbuilt"]);
|
|
61
|
+
expect(commits[2].verdict!.phases[0]).toMatchObject({ phase: "check", verdict: "unbuilt", reason: "cancelled" });
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("bisect (#4169)", () => {
|
|
66
|
+
const green = () => [run("check", "success"), run("suites", "success")];
|
|
67
|
+
const red = () => [run("check", "success"), run("suites", "failure")];
|
|
68
|
+
const none = () => [run("check", "success")];
|
|
69
|
+
|
|
70
|
+
test("a red newest commit asks for the one just below it when four or fewer are unbuilt", () => {
|
|
71
|
+
const { commits, runs } = judged([red(), none(), none(), green()]);
|
|
72
|
+
const p = plan(commits, runs);
|
|
73
|
+
expect(p.requests).toEqual([expect.objectContaining({ commit: commits[1].sha, phases: ["suites"], reason: "bisect", tag: `ci/build/${commits[1].sha}` })]);
|
|
74
|
+
expect(suites(commits[1])).toMatchObject({ verdict: "pending", reason: "requested" });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("halves the range above four, and asks for nothing while a request in it waits", () => {
|
|
78
|
+
const { commits, runs } = judged([red(), none(), none(), none(), none(), none(), none(), green()]);
|
|
79
|
+
const p = plan(commits, runs);
|
|
80
|
+
expect(p.requests.map((r) => r.commit)).toEqual([commits[4].sha]);
|
|
81
|
+
const waiting: BuildRequest = { ...p.requests[0], at: ago(60_000) };
|
|
82
|
+
const again = judged([red(), none(), none(), none(), none(), none(), none(), green()]);
|
|
83
|
+
expect(plan(again.commits, again.runs, { requests: new Map([[waiting.commit, waiting]]) }).requests).toEqual([]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("names the culprit on it and every commit after it once the red sits on a green", () => {
|
|
87
|
+
const { commits, runs } = judged([red(), none(), red(), green()]);
|
|
88
|
+
// The commit below the newest red is unbuilt between two reds: the culprit is at or below the older red.
|
|
89
|
+
plan(commits, runs);
|
|
90
|
+
expect(commits.map((c) => suites(c).culprit)).toEqual([commits[2].sha, commits[2].sha, commits[2].sha, undefined]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("names no culprit without a green below, and an every phase gets one too", () => {
|
|
94
|
+
const noGreen = judged([red(), red()]);
|
|
95
|
+
plan(noGreen.commits, noGreen.runs);
|
|
96
|
+
expect(noGreen.commits.map((c) => suites(c).culprit)).toEqual([undefined, undefined]);
|
|
97
|
+
const every = judged([[run("check", "failure")], [run("check", "failure")], [run("check", "success")]]);
|
|
98
|
+
plan(every.commits, every.runs);
|
|
99
|
+
expect(every.commits.map((c) => c.verdict!.phases[0].culprit)).toEqual([every.commits[1].sha, every.commits[1].sha, undefined]);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("requests (#4169)", () => {
|
|
104
|
+
const req = (commit: string, at: string): BuildRequest => ({ schema: 1, tag: `ci/build/${commit}`, commit, branch: "main", phases: ["suites"], reason: "requested", at });
|
|
105
|
+
|
|
106
|
+
test("a request holds its phase pending; once decided, or too old, or out of the window, it is done", () => {
|
|
107
|
+
const { commits, runs } = judged([[run("check", "success"), run("suites", "success")], [run("check", "success")]]);
|
|
108
|
+
const waiting = req(commits[1].sha, ago(60_000));
|
|
109
|
+
const p = plan(commits, runs, { requests: new Map([[waiting.commit, waiting]]) });
|
|
110
|
+
expect(suites(commits[1])).toMatchObject({ verdict: "pending", reason: "requested" });
|
|
111
|
+
expect(p.resolved).toEqual([]);
|
|
112
|
+
|
|
113
|
+
const old = judged([[run("check", "success"), run("suites", "success")], [run("check", "success")]]);
|
|
114
|
+
const stale = req(old.commits[1].sha, ago(BUILD_REQUEST_TTL_MS + 1));
|
|
115
|
+
expect(plan(old.commits, old.runs, { requests: new Map([[stale.commit, stale]]) }).resolved).toEqual([stale.tag]);
|
|
116
|
+
expect(suites(old.commits[1])).toMatchObject({ verdict: "unbuilt", reason: "request-unserved" });
|
|
117
|
+
|
|
118
|
+
const done = judged([[run("check", "success"), run("suites", "success")]]);
|
|
119
|
+
const served = req(done.commits[0].sha, ago(60_000));
|
|
120
|
+
const gone = req(sha(99), ago(60_000));
|
|
121
|
+
expect(plan(done.commits, done.runs, { requests: new Map([[served.commit, served], [gone.commit, gone]]) }).resolved.sort()).toEqual([served.tag, gone.tag].sort());
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("retry (#4169)", () => {
|
|
126
|
+
test("a failed run with one attempt is run again and holds the commit pending; a second attempt decides", () => {
|
|
127
|
+
const failed = run("check", "failure");
|
|
128
|
+
const { commits, runs } = judged([[failed, run("suites", "success")]]);
|
|
129
|
+
const p = plan(commits, runs, { retryVia: "forge" });
|
|
130
|
+
expect(p.retries).toEqual([{ sha: commits[0].sha, run: failed }]);
|
|
131
|
+
expect(commits[0].verdict!.verdict).toBe("pending");
|
|
132
|
+
expect(commits[0].verdict!.phases[0]).toMatchObject({ verdict: "pending", reason: "retrying" });
|
|
133
|
+
|
|
134
|
+
const twice = judged([[failed, run("check", "failure"), run("suites", "success")]]);
|
|
135
|
+
expect(plan(twice.commits, twice.runs, { retryVia: "forge" }).retries).toEqual([]);
|
|
136
|
+
expect(twice.commits[0].verdict!.verdict).toBe("fail");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("a retry asked for waits an hour for its attempt, then the failure counts; chant runs ask by request; a green commit is never retried", () => {
|
|
140
|
+
const failed = run("check", "failure");
|
|
141
|
+
const asked = (at: string): CiRecord => ({ schema: 1, commit: sha(1), branch: "main", state: "pending", at, phases: {}, retried: { check: { run: failed.id, at } } });
|
|
142
|
+
const waiting = judged([[failed, run("suites", "success")]]);
|
|
143
|
+
expect(plan(waiting.commits, waiting.runs, { retryVia: "forge", records: new Map([[sha(1), asked(ago(60_000))]]) }).retries).toEqual([]);
|
|
144
|
+
expect(waiting.commits[0].verdict!.verdict).toBe("pending");
|
|
145
|
+
const late = judged([[failed, run("suites", "success")]]);
|
|
146
|
+
plan(late.commits, late.runs, { retryVia: "forge", records: new Map([[sha(1), asked(ago(RETRY_TTL_MS + 1))]]) });
|
|
147
|
+
expect(late.commits[0].verdict!.verdict).toBe("fail");
|
|
148
|
+
|
|
149
|
+
const viaRequest = judged([[failed, run("suites", "success")]]);
|
|
150
|
+
expect(plan(viaRequest.commits, viaRequest.runs, { retryVia: "request" }).requests).toEqual([expect.objectContaining({ commit: sha(1), phases: ["check"], reason: "retry" })]);
|
|
151
|
+
|
|
152
|
+
const tagged = judged([[failed, run("suites", "success")]], ["green"]);
|
|
153
|
+
expect(plan(tagged.commits, tagged.runs, { retryVia: "forge" }).retries).toEqual([]);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("records (#4169)", () => {
|
|
158
|
+
test("say green, red, unbuilt or pending with each phase's verdict, reason and covering commit, and skip the time when compared", () => {
|
|
159
|
+
const { commits, runs } = judged([[run("check", "success"), run("suites", "success")], [run("check", "success")]]);
|
|
160
|
+
plan(commits, runs);
|
|
161
|
+
commits[0].made = { tag: `ci/green/${commits[0].sha}`, message: "" };
|
|
162
|
+
const top = recordOf({ green: GREEN, commit: commits[0], retries: [], requests: new Map(), now: NOW });
|
|
163
|
+
expect(top).toMatchObject({ state: "green", commit: commits[0].sha, phases: { check: { verdict: "pass", build: "every" }, suites: { verdict: "pass", build: "newest" } } });
|
|
164
|
+
const below = recordOf({ green: GREEN, commit: commits[1], retries: [], requests: new Map(), now: NOW });
|
|
165
|
+
expect(below).toMatchObject({ state: "unbuilt", phases: { suites: { verdict: "unbuilt", reason: "superseded", coveredBy: commits[0].sha } } });
|
|
166
|
+
expect(sameRecord({ ...below, at: "2020-01-01T00:00:00Z" }, below)).toBe(true);
|
|
167
|
+
expect(sameRecord({ ...below, state: "pending" }, below)).toBe(false);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("a pending record that left the window is rewritten unbuilt, aged out", () => {
|
|
171
|
+
const pending: CiRecord = { schema: 1, commit: sha(1), branch: "main", state: "pending", at: ago(1), phases: { suites: { verdict: "pending", build: "newest", runs: [] } } };
|
|
172
|
+
expect(agedOut(pending, NOW)).toMatchObject({ state: "unbuilt", phases: { suites: { verdict: "unbuilt", reason: "aged-out" } } });
|
|
173
|
+
});
|
|
174
|
+
});
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which commits get built, and what each one ends with (#4169).
|
|
3
|
+
*
|
|
4
|
+
* A `ci.green` phase declares `build: "every"` (the default) or
|
|
5
|
+
* `build: "newest"`. Under `newest` the phase's workflow keeps one run
|
|
6
|
+
* waiting and drops the rest, so only the newest commit is built when a
|
|
7
|
+
* runner frees. This module does the tick's part of that, given the
|
|
8
|
+
* verdicts {@link planTick} (./ci-green.ts) judged:
|
|
9
|
+
*
|
|
10
|
+
* - Superseded. A commit with no run of a `newest` phase, below a newer
|
|
11
|
+
* commit that has one, is `unbuilt` for that phase, covered by the newer
|
|
12
|
+
* commit. A cancelled run makes any phase `unbuilt` (`phaseVerdictOf`).
|
|
13
|
+
* - Retry. A failed run with one attempt is run again once before the
|
|
14
|
+
* commit counts red: through the forge's re-run (`CiForge.rerun`) for
|
|
15
|
+
* check runs, or a build request for chant runs. A retry that has not
|
|
16
|
+
* started an hour on counts as the failure it was.
|
|
17
|
+
* - Bisect. When the newest built commit of a `newest` phase is red, the
|
|
18
|
+
* unbuilt commits back to the last one the phase passed on are built, one
|
|
19
|
+
* request per tick: the commit halfway when more than four are left, else
|
|
20
|
+
* the one just below the red. The first red commit found is the culprit,
|
|
21
|
+
* and it and every commit after it name it.
|
|
22
|
+
* - Requests. A build is asked for with an annotated tag,
|
|
23
|
+
* `ci/build/<sha>`, whose record names the phases and why. The tick
|
|
24
|
+
* starts a workflow at that tag on GitHub (`CiForge.dispatch`), and `ci
|
|
25
|
+
* serve --build-command` serves it for chant runs. A phase a request
|
|
26
|
+
* waits on is `pending`. A request no runner served in six hours leaves
|
|
27
|
+
* the phase `unbuilt`, and once its phases are decided the tick deletes
|
|
28
|
+
* the tag.
|
|
29
|
+
* - Records. Every first-parent commit the tick judged gets
|
|
30
|
+
* `_ci/<sha>.json` on `chant/lifecycle`, rewritten only when what it says
|
|
31
|
+
* changes: `green`, `red`, `unbuilt` or `pending`, with each required
|
|
32
|
+
* phase's verdict, runs, reason, the commit that covers it and the culprit.
|
|
33
|
+
* A commit that leaves the window still `pending` is rewritten `unbuilt`,
|
|
34
|
+
* for the reason `aged-out`. The `ci/green` and `ci/revoked` tags stay
|
|
35
|
+
* what decides a release; the records say what happened to the rest.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { execFileSync } from "node:child_process";
|
|
39
|
+
import type { CiBuild, CiGreen } from "./declaration";
|
|
40
|
+
import type { CheckRun } from "./ci-green-forge";
|
|
41
|
+
import { commitVerdictOf, phaseVerdictOf, type PhaseReason, type PhaseVerdict, type TickCommit, type Verdict } from "./ci-green";
|
|
42
|
+
|
|
43
|
+
export const CI_BUILD_TAG_PREFIX = "ci/build/";
|
|
44
|
+
/** The directory on `chant/lifecycle` holding one record per commit. */
|
|
45
|
+
export const CI_RECORDS_DIR = "_ci";
|
|
46
|
+
const LEDGER_REF = "refs/heads/chant/lifecycle";
|
|
47
|
+
|
|
48
|
+
/** How long a build request may wait for a runner before its phases count as unbuilt. */
|
|
49
|
+
export const BUILD_REQUEST_TTL_MS = 6 * 3_600_000;
|
|
50
|
+
/** How long a retry may take to start before the failure it was counts. */
|
|
51
|
+
export const RETRY_TTL_MS = 3_600_000;
|
|
52
|
+
/** Above this many unbuilt commits a bisect halves the range; at or below it, it steps back one commit at a time. */
|
|
53
|
+
export const BISECT_HALVE_ABOVE = 4;
|
|
54
|
+
|
|
55
|
+
export type BuildRequestReason = "bisect" | "retry" | "requested";
|
|
56
|
+
|
|
57
|
+
/** A `ci/build/<sha>` tag's record: the phases to build at the commit, and why. */
|
|
58
|
+
export interface BuildRequest {
|
|
59
|
+
schema: 1;
|
|
60
|
+
tag: string;
|
|
61
|
+
commit: string;
|
|
62
|
+
branch: string;
|
|
63
|
+
phases: string[];
|
|
64
|
+
reason: BuildRequestReason;
|
|
65
|
+
/** When it was asked for, ISO 8601. */
|
|
66
|
+
at: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** One record, `_ci/<sha>.json` on `chant/lifecycle`. */
|
|
70
|
+
export interface CiRecord {
|
|
71
|
+
schema: 1;
|
|
72
|
+
commit: string;
|
|
73
|
+
branch: string;
|
|
74
|
+
/** `green` and `red` follow the tags: a green tag, or a failed phase or a revoked tag. */
|
|
75
|
+
state: "green" | "red" | "unbuilt" | "pending";
|
|
76
|
+
/** When this content was written, ISO 8601. Not compared when deciding whether to write again. */
|
|
77
|
+
at: string;
|
|
78
|
+
/** Set when the commit has a revoked tag. */
|
|
79
|
+
revoked?: true;
|
|
80
|
+
phases: Record<string, CiRecordPhase>;
|
|
81
|
+
/** The first required phase that failed, and its run, when the state is red. */
|
|
82
|
+
failure?: { phase: string; run: CiRecordRun };
|
|
83
|
+
/** The retries asked for, by run name: the run that failed and when. */
|
|
84
|
+
retried?: Record<string, { run: number; at: string }>;
|
|
85
|
+
/** The build request waiting on this commit, when there is one. */
|
|
86
|
+
request?: { tag: string; reason: BuildRequestReason; phases: string[]; at: string; dispatched?: true };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface CiRecordRun {
|
|
90
|
+
name: string;
|
|
91
|
+
id: number | null;
|
|
92
|
+
conclusion: string | null;
|
|
93
|
+
url: string | null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface CiRecordPhase {
|
|
97
|
+
verdict: Verdict;
|
|
98
|
+
build: CiBuild;
|
|
99
|
+
runs: CiRecordRun[];
|
|
100
|
+
reason?: PhaseReason | "aged-out";
|
|
101
|
+
coveredBy?: string;
|
|
102
|
+
culprit?: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const iso = (d: Date) => d.toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
106
|
+
|
|
107
|
+
/** How a tick can run a failed job again: the forge's re-run, a build request, or not at all. */
|
|
108
|
+
export type RetryVia = "forge" | "request" | null;
|
|
109
|
+
|
|
110
|
+
export interface BuildsInput {
|
|
111
|
+
green: CiGreen;
|
|
112
|
+
/** Newest first, as {@link planTick} returns them. */
|
|
113
|
+
commits: TickCommit[];
|
|
114
|
+
/** Every attempt of every run, by commit. */
|
|
115
|
+
runs: ReadonlyMap<string, readonly CheckRun[]>;
|
|
116
|
+
requests: ReadonlyMap<string, BuildRequest>;
|
|
117
|
+
records: ReadonlyMap<string, CiRecord>;
|
|
118
|
+
now: Date;
|
|
119
|
+
retryVia: RetryVia;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface BuildsPlan {
|
|
123
|
+
/** Requests to make: bisect steps, and retries of chant runs. */
|
|
124
|
+
requests: BuildRequest[];
|
|
125
|
+
/** Failed check runs to run again through the forge, by commit. */
|
|
126
|
+
retries: { sha: string; run: CheckRun }[];
|
|
127
|
+
/** Request tags whose phases are decided, or that waited too long, or whose commit left the window. */
|
|
128
|
+
resolved: string[];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const phaseOf = (green: CiGreen, name: string) => green.phases.find((p) => p.name === name)!;
|
|
132
|
+
const buildOf = (green: CiGreen, name: string): CiBuild => phaseOf(green, name).build ?? "every";
|
|
133
|
+
const decided = (v: Verdict | undefined) => v === "pass" || v === "fail";
|
|
134
|
+
const age = (at: string, now: Date) => now.getTime() - Date.parse(at);
|
|
135
|
+
|
|
136
|
+
function setPhase(c: TickCommit, phase: string, fn: (p: PhaseVerdict) => PhaseVerdict): void {
|
|
137
|
+
const v = c.verdict;
|
|
138
|
+
if (!v) return;
|
|
139
|
+
c.verdict = commitVerdictOf(v.phases.map((p) => (p.phase === phase ? fn(p) : p)));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const phaseIn = (c: TickCommit, phase: string) => c.verdict?.phases.find((p) => p.phase === phase);
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Apply supersession, retries, requests and bisection to the judged commits,
|
|
146
|
+
* in place, and say what the tick has to ask for. Pure: it reads and writes
|
|
147
|
+
* nothing but its input.
|
|
148
|
+
*/
|
|
149
|
+
export function planBuilds(input: BuildsInput): BuildsPlan {
|
|
150
|
+
const { green, commits, now } = input;
|
|
151
|
+
const plan: BuildsPlan = { requests: [], retries: [], resolved: [] };
|
|
152
|
+
const inWindow = new Set(commits.map((c) => c.sha));
|
|
153
|
+
const live = (sha: string, phase: string): BuildRequest | undefined => {
|
|
154
|
+
const r = input.requests.get(sha);
|
|
155
|
+
return r && r.phases.includes(phase) && age(r.at, now) < BUILD_REQUEST_TTL_MS ? r : undefined;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
// A request waits on a phase with no run yet; one no runner served in time leaves it unbuilt.
|
|
159
|
+
for (const c of commits) {
|
|
160
|
+
const r = input.requests.get(c.sha);
|
|
161
|
+
if (!r || !c.verdict) continue;
|
|
162
|
+
for (const name of r.phases) {
|
|
163
|
+
const p = phaseIn(c, name);
|
|
164
|
+
if (!p || p.runs.length > 0) continue;
|
|
165
|
+
setPhase(c, name, (q) => (live(c.sha, name) ? { ...q, verdict: "pending", reason: "requested" } : { ...q, verdict: "unbuilt", reason: "request-unserved" }));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Superseded: newest first, a newest phase with no run below a commit with one.
|
|
170
|
+
for (const name of green.require) {
|
|
171
|
+
if (buildOf(green, name) !== "newest") continue;
|
|
172
|
+
let cover: string | undefined;
|
|
173
|
+
for (const c of commits) {
|
|
174
|
+
const p = phaseIn(c, name);
|
|
175
|
+
if (!p) continue;
|
|
176
|
+
if (p.runs.length > 0) {
|
|
177
|
+
cover = c.sha;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (cover && p.reason !== "requested" && p.reason !== "request-unserved") setPhase(c, name, (q) => ({ ...q, verdict: "unbuilt", reason: "superseded", coveredBy: cover }));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Retry once: a commit with no tag whose failed run has one attempt.
|
|
185
|
+
if (input.retryVia) {
|
|
186
|
+
for (const c of commits) {
|
|
187
|
+
if (c.before !== "none" || c.verdict?.verdict !== "fail") continue;
|
|
188
|
+
const all = input.runs.get(c.sha) ?? [];
|
|
189
|
+
const retried = input.records.get(c.sha)?.retried ?? {};
|
|
190
|
+
for (const p of c.verdict.phases) {
|
|
191
|
+
for (const run of p.runs.filter((r) => r.verdict === "fail")) {
|
|
192
|
+
if (all.filter((r) => r.name === run.name).length > 1) continue;
|
|
193
|
+
const asked = retried[run.name];
|
|
194
|
+
if (asked && asked.run === run.id && age(asked.at, now) >= RETRY_TTL_MS) continue;
|
|
195
|
+
if (!asked || asked.run !== run.id) {
|
|
196
|
+
const checkRun = all.find((r) => r.name === run.name && r.id === run.id)!;
|
|
197
|
+
if (input.retryVia === "forge") plan.retries.push({ sha: c.sha, run: checkRun });
|
|
198
|
+
else if (!input.requests.has(c.sha) && !plan.requests.some((q) => q.commit === c.sha)) plan.requests.push(request(green, c.sha, [p.phase], "retry", now));
|
|
199
|
+
}
|
|
200
|
+
setPhase(c, p.phase, (q) => {
|
|
201
|
+
const runs = q.runs.map((r) => (r.name === run.name && r.id === run.id ? { ...r, verdict: "pending" as const } : r));
|
|
202
|
+
return { ...q, runs, ...phaseVerdictOf(runs, q.unmatched), reason: "retrying" };
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Culprits for every phase, and a bisect request for a newest one.
|
|
210
|
+
for (const name of green.require) {
|
|
211
|
+
const verdicts = commits.map((c) => phaseIn(c, name));
|
|
212
|
+
const top = verdicts.findIndex((p) => decided(p?.verdict));
|
|
213
|
+
if (top < 0 || verdicts[top]!.verdict !== "fail") continue;
|
|
214
|
+
let oldestRed = top;
|
|
215
|
+
let between: number[] = [];
|
|
216
|
+
let passed = -1;
|
|
217
|
+
for (let i = top + 1; i < commits.length; i++) {
|
|
218
|
+
const v = verdicts[i]?.verdict;
|
|
219
|
+
if (v === "fail") {
|
|
220
|
+
oldestRed = i;
|
|
221
|
+
between = [];
|
|
222
|
+
} else if (v === "pass") {
|
|
223
|
+
passed = i;
|
|
224
|
+
break;
|
|
225
|
+
} else if (v !== undefined) between.push(i);
|
|
226
|
+
}
|
|
227
|
+
if (between.length === 0) {
|
|
228
|
+
if (passed < 0) continue;
|
|
229
|
+
const culprit = commits[oldestRed].sha;
|
|
230
|
+
for (let i = oldestRed; i >= 0; i--) {
|
|
231
|
+
if (verdicts[i]?.verdict === "pass") break;
|
|
232
|
+
if (verdicts[i]) setPhase(commits[i], name, (q) => ({ ...q, culprit }));
|
|
233
|
+
}
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (buildOf(green, name) !== "newest") continue;
|
|
237
|
+
if (between.some((i) => live(commits[i].sha, name))) continue;
|
|
238
|
+
const pick = between.length > BISECT_HALVE_ABOVE ? between[Math.floor(between.length / 2)] : between[0];
|
|
239
|
+
const sha = commits[pick].sha;
|
|
240
|
+
if (input.requests.has(sha) || plan.requests.some((q) => q.commit === sha)) continue;
|
|
241
|
+
plan.requests.push(request(green, sha, [name], "bisect", now));
|
|
242
|
+
setPhase(commits[pick], name, (q) => ({ ...q, verdict: "pending", reason: "requested", coveredBy: undefined }));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// A request is done once its phases are decided, it has waited too long, or its commit left the window.
|
|
246
|
+
for (const [sha, r] of input.requests) {
|
|
247
|
+
const c = commits.find((x) => x.sha === sha);
|
|
248
|
+
const done = !inWindow.has(sha) || age(r.at, now) >= BUILD_REQUEST_TTL_MS || (c !== undefined && (c.before === "revoked" || r.phases.every((name) => decided(phaseIn(c, name)?.verdict))));
|
|
249
|
+
if (done) plan.resolved.push(r.tag);
|
|
250
|
+
}
|
|
251
|
+
return plan;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function request(green: CiGreen, commit: string, phases: string[], reason: BuildRequestReason, now: Date): BuildRequest {
|
|
255
|
+
return { schema: 1, tag: `${CI_BUILD_TAG_PREFIX}${commit}`, commit, branch: green.branch, phases, reason, at: iso(now) };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ── Records ──────────────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
const recordRun = (r: { name: string; id: number; conclusion: string | null; url: string | null; opRun?: unknown }): CiRecordRun => ({ name: r.name, id: r.opRun ? null : r.id, conclusion: r.conclusion, url: r.url });
|
|
261
|
+
|
|
262
|
+
/** The record a judged commit gets, carrying the retries asked for before and now. */
|
|
263
|
+
export function recordOf(input: { green: CiGreen; commit: TickCommit; previous?: CiRecord; retries: readonly { sha: string; run: CheckRun }[]; requests: ReadonlyMap<string, BuildRequest>; now: Date }): CiRecord {
|
|
264
|
+
const { green, commit: c, previous, now } = input;
|
|
265
|
+
const retried = { ...(previous?.retried ?? {}) };
|
|
266
|
+
for (const r of input.retries) if (r.sha === c.sha) retried[r.run.name] = { run: r.run.id, at: iso(now) };
|
|
267
|
+
const req = input.requests.get(c.sha);
|
|
268
|
+
const extras = {
|
|
269
|
+
...(Object.keys(retried).length > 0 ? { retried } : {}),
|
|
270
|
+
...(req ? { request: { tag: req.tag, reason: req.reason, phases: req.phases, at: req.at } } : {}),
|
|
271
|
+
};
|
|
272
|
+
if (c.before === "revoked" || !c.verdict) {
|
|
273
|
+
return { schema: 1, commit: c.sha, branch: green.branch, state: "red", at: iso(now), revoked: true, phases: previous?.phases ?? {}, ...(previous?.failure ? { failure: previous.failure } : {}), ...extras };
|
|
274
|
+
}
|
|
275
|
+
const v = c.verdict;
|
|
276
|
+
// A pass whose tag an ignored tag blocks (#4031) is not green until a person clears the name.
|
|
277
|
+
const state: CiRecord["state"] =
|
|
278
|
+
c.before === "green" ? (v.verdict === "fail" ? "red" : "green") : v.verdict === "pass" ? (c.made ? "green" : "pending") : v.verdict === "fail" ? "red" : v.verdict;
|
|
279
|
+
const phases: Record<string, CiRecordPhase> = {};
|
|
280
|
+
for (const p of v.phases) {
|
|
281
|
+
phases[p.phase] = {
|
|
282
|
+
verdict: p.verdict,
|
|
283
|
+
build: buildOf(green, p.phase),
|
|
284
|
+
runs: p.runs.map(recordRun),
|
|
285
|
+
...(p.reason ? { reason: p.reason } : {}),
|
|
286
|
+
...(p.coveredBy ? { coveredBy: p.coveredBy } : {}),
|
|
287
|
+
...(p.culprit ? { culprit: p.culprit } : {}),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
schema: 1,
|
|
292
|
+
commit: c.sha,
|
|
293
|
+
branch: green.branch,
|
|
294
|
+
state,
|
|
295
|
+
at: iso(now),
|
|
296
|
+
phases,
|
|
297
|
+
...(v.failure ? { failure: { phase: v.failure.phase, run: recordRun(v.failure.run) } } : {}),
|
|
298
|
+
...extras,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** The record a commit that left the window still pending gets: each pending phase `unbuilt`, `aged-out`. */
|
|
303
|
+
export function agedOut(record: CiRecord, now: Date): CiRecord {
|
|
304
|
+
const phases = Object.fromEntries(Object.entries(record.phases).map(([k, p]) => [k, p.verdict === "pending" ? { ...p, verdict: "unbuilt" as const, reason: "aged-out" as const } : p]));
|
|
305
|
+
const { request: _request, ...rest } = record;
|
|
306
|
+
return { ...rest, state: "unbuilt", at: iso(now), phases };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Whether two records say the same thing, the time each was written aside. */
|
|
310
|
+
export function sameRecord(a: CiRecord | undefined, b: CiRecord): boolean {
|
|
311
|
+
if (!a) return false;
|
|
312
|
+
const { at: _a, ...x } = a;
|
|
313
|
+
const { at: _b, ...y } = b;
|
|
314
|
+
return JSON.stringify(x) === JSON.stringify(y);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export const recordPath = (sha: string) => `${CI_RECORDS_DIR}/${sha}.json`;
|
|
318
|
+
|
|
319
|
+
function tryGit(top: string, args: string[]): string | null {
|
|
320
|
+
try {
|
|
321
|
+
return execFileSync("git", args, { cwd: top, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 256 * 1024 * 1024 }).trimEnd();
|
|
322
|
+
} catch {
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function parseRecord(text: string | null): CiRecord | undefined {
|
|
328
|
+
if (!text) return undefined;
|
|
329
|
+
try {
|
|
330
|
+
const r = JSON.parse(text) as CiRecord;
|
|
331
|
+
return r && r.schema === 1 && typeof r.commit === "string" && typeof r.state === "string" ? r : undefined;
|
|
332
|
+
} catch {
|
|
333
|
+
return undefined;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** One commit's record, from the local `chant/lifecycle`. */
|
|
338
|
+
export function readCiRecord(top: string, sha: string): CiRecord | undefined {
|
|
339
|
+
return parseRecord(tryGit(top, ["show", `${LEDGER_REF}:${recordPath(sha)}`]));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Every record on the local `chant/lifecycle`, by commit. Empty when the branch or the directory is missing. */
|
|
343
|
+
export function readCiRecords(top: string): Map<string, CiRecord> {
|
|
344
|
+
const out = new Map<string, CiRecord>();
|
|
345
|
+
const names = tryGit(top, ["ls-tree", "--name-only", `${LEDGER_REF}:${CI_RECORDS_DIR}`]);
|
|
346
|
+
if (!names) return out;
|
|
347
|
+
for (const name of names.split("\n")) {
|
|
348
|
+
const m = /^([0-9a-f]{40,64})\.json$/.exec(name);
|
|
349
|
+
if (!m) continue;
|
|
350
|
+
const r = readCiRecord(top, m[1]);
|
|
351
|
+
if (r) out.set(m[1], r);
|
|
352
|
+
}
|
|
353
|
+
return out;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ── Requests ─────────────────────────────────────────────────────────────────
|
|
357
|
+
|
|
358
|
+
/** Every `ci/build/<sha>` tag with a record this reads, from local refs, by commit. */
|
|
359
|
+
export function readBuildRequests(top: string): Map<string, BuildRequest> {
|
|
360
|
+
const out = new Map<string, BuildRequest>();
|
|
361
|
+
const raw = tryGit(top, ["for-each-ref", "--format=%(refname:lstrip=2)%00%(objecttype)%00%(contents)%01", `refs/tags/${CI_BUILD_TAG_PREFIX}`]);
|
|
362
|
+
if (!raw) return out;
|
|
363
|
+
for (const entry of raw.split("\u0001")) {
|
|
364
|
+
const [tag, type, contents] = entry.replace(/^\n/, "").split("\u0000");
|
|
365
|
+
if (!tag || type !== "tag" || !contents?.includes("{")) continue;
|
|
366
|
+
try {
|
|
367
|
+
const r = JSON.parse(contents.slice(contents.indexOf("{"))) as BuildRequest;
|
|
368
|
+
const sha = tag.slice(CI_BUILD_TAG_PREFIX.length);
|
|
369
|
+
if (r.schema === 1 && r.commit === sha && Array.isArray(r.phases) && typeof r.at === "string") out.set(sha, { ...r, tag });
|
|
370
|
+
} catch {
|
|
371
|
+
// A tag whose record does not parse asks for nothing.
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return out;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** The annotation of a request tag: a subject line, then the record. */
|
|
378
|
+
export function buildRequestAnnotation(r: BuildRequest): string {
|
|
379
|
+
return `ci: build ${r.phases.join(", ")} at this commit (${r.reason})\n\n${JSON.stringify(r, null, 2)}\n`;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** A request for `ci run --at <sha>`: every required phase, asked for by a person. */
|
|
383
|
+
export function requestedBuild(green: CiGreen, commit: string, phases: readonly string[], now: Date): BuildRequest {
|
|
384
|
+
return request(green, commit, [...phases], "requested", now);
|
|
385
|
+
}
|