@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,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
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #4169 — the tick against a real remote with a fake forge, for a phase that
|
|
3
|
+
* builds only the newest commit: the records it writes on chant/lifecycle,
|
|
4
|
+
* the build requests it pushes and starts, the retries it asks for, and what
|
|
5
|
+
* `ci list`, `ci last-green` and `ci run --at` read and do.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { execFileSync } from "node:child_process";
|
|
9
|
+
import { afterAll, describe, expect, test } from "vitest";
|
|
10
|
+
import { cleanScratch, declaration, git, scratchDir, writeFiles } from "./__fixtures__/contract-repo";
|
|
11
|
+
import { ciList, ciTick, lastGreen, requestBuild } from "./ci-green";
|
|
12
|
+
import type { CheckRun, CiForge } from "./ci-green-forge";
|
|
13
|
+
import { readBuildRequests, readCiRecord } from "./ci-builds";
|
|
14
|
+
|
|
15
|
+
afterAll(cleanScratch);
|
|
16
|
+
|
|
17
|
+
const NOW = new Date("2026-10-20T12:00:00Z");
|
|
18
|
+
const hoursAgo = (h: number) => new Date(NOW.getTime() - h * 3_600_000).toISOString();
|
|
19
|
+
|
|
20
|
+
const GREEN = {
|
|
21
|
+
branch: "main",
|
|
22
|
+
phases: { check: { runs: ["check"], build: "every" }, suites: { runs: ["suites"], build: "newest" } },
|
|
23
|
+
require: ["check", "suites"],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const SUITES_WORKFLOW = "name: release-suites\non:\n push:\n branches: [main]\n workflow_dispatch: {}\njobs:\n suites:\n runs-on: ubuntu-latest\n steps:\n - run: true\n";
|
|
27
|
+
|
|
28
|
+
function gitAt(cwd: string, date: string, ...args: string[]): string {
|
|
29
|
+
return execFileSync("git", ["-c", "user.name=t", "-c", "user.email=t@example.com", "-c", "commit.gpgsign=false", ...args], {
|
|
30
|
+
cwd,
|
|
31
|
+
encoding: "utf-8",
|
|
32
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
33
|
+
env: { ...process.env, GIT_AUTHOR_DATE: date, GIT_COMMITTER_DATE: date },
|
|
34
|
+
}).trim();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function commitAt(root: string, hours: number, file: string): string {
|
|
38
|
+
writeFiles(root, { [file]: `${file} ${hours}\n` });
|
|
39
|
+
gitAt(root, hoursAgo(hours), "add", "-A");
|
|
40
|
+
gitAt(root, hoursAgo(hours), "commit", "-q", "-m", file);
|
|
41
|
+
return git(root, "rev-parse", "HEAD");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function workspace(): { root: string; origin: string } {
|
|
45
|
+
const origin = scratchDir("chant-builds-origin-");
|
|
46
|
+
git(origin, "init", "-q", "--bare");
|
|
47
|
+
const root = scratchDir("chant-builds-work-");
|
|
48
|
+
git(root, "init", "-q");
|
|
49
|
+
git(root, "symbolic-ref", "HEAD", "refs/heads/main");
|
|
50
|
+
writeFiles(root, {
|
|
51
|
+
"chant.workspace.json": declaration([{ name: "app", dir: "app", kind: "other", because: "the app" }], { ci: { green: GREEN } }),
|
|
52
|
+
"app/a.txt": "a\n",
|
|
53
|
+
".github/workflows/release-suites.yml": SUITES_WORKFLOW,
|
|
54
|
+
});
|
|
55
|
+
git(root, "remote", "add", "origin", origin);
|
|
56
|
+
return { root, origin };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let id = 1;
|
|
60
|
+
const run = (name: string, conclusion: string | null, status = "completed"): CheckRun => ({ id: id++, name, status, conclusion: status === "completed" ? conclusion : null, completedAt: null, url: null });
|
|
61
|
+
|
|
62
|
+
/** A forge whose runs the test sets per commit, recording the re-runs and the workflows started. */
|
|
63
|
+
function fakeForge(runs: Map<string, CheckRun[]>): CiForge & { reruns: number[]; dispatched: string[] } {
|
|
64
|
+
const reruns: number[] = [];
|
|
65
|
+
const dispatched: string[] = [];
|
|
66
|
+
return {
|
|
67
|
+
kind: "github",
|
|
68
|
+
reruns,
|
|
69
|
+
dispatched,
|
|
70
|
+
checkRuns: async (sha) => runs.get(sha) ?? [],
|
|
71
|
+
rerun: async (r) => void reruns.push(r.id),
|
|
72
|
+
dispatch: async (w, ref) => void dispatched.push(`${w}@${ref}`),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const remoteRecord = (origin: string, sha: string) => JSON.parse(git(origin, "show", `refs/heads/chant/lifecycle:_ci/${sha}.json`));
|
|
77
|
+
const remoteTags = (origin: string) => git(origin, "for-each-ref", "--format=%(refname:lstrip=2)", "refs/tags/").split("\n").filter(Boolean).sort();
|
|
78
|
+
|
|
79
|
+
describe("the tick for a newest phase (#4169)", () => {
|
|
80
|
+
test("records each commit, and asks for a bisect step at a ci/build tag and starts the workflow there once", async () => {
|
|
81
|
+
const { root, origin } = workspace();
|
|
82
|
+
const base = commitAt(root, 6, "app/base.txt");
|
|
83
|
+
const a = commitAt(root, 5, "app/a.txt");
|
|
84
|
+
const b = commitAt(root, 4, "app/b.txt");
|
|
85
|
+
const c = commitAt(root, 3, "app/c.txt");
|
|
86
|
+
git(root, "push", "-q", "origin", "main");
|
|
87
|
+
const runs = new Map<string, CheckRun[]>([
|
|
88
|
+
[base, [run("check", "success"), run("suites", "success")]],
|
|
89
|
+
[a, [run("check", "success")]],
|
|
90
|
+
[b, [run("check", "success")]],
|
|
91
|
+
[c, [run("check", "success"), run("suites", "failure"), run("suites", "failure")]],
|
|
92
|
+
]);
|
|
93
|
+
const forge = fakeForge(runs);
|
|
94
|
+
|
|
95
|
+
const first = await ciTick({ cwd: root, forge, now: NOW });
|
|
96
|
+
expect(first.made).toEqual([`ci/green/${base}`]);
|
|
97
|
+
// The newest red asks for the commit just below it.
|
|
98
|
+
expect(first.requested.map((r) => [r.commit, r.reason])).toEqual([[b, "bisect"]]);
|
|
99
|
+
expect(remoteTags(origin)).toEqual([`ci/build/${b}`, `ci/green/${base}`].sort());
|
|
100
|
+
expect(forge.dispatched).toEqual([`release-suites.yml@ci/build/${b}`]);
|
|
101
|
+
expect(remoteRecord(origin, a)).toMatchObject({ state: "unbuilt", phases: { suites: { verdict: "unbuilt", reason: "superseded", coveredBy: c } } });
|
|
102
|
+
expect(remoteRecord(origin, b)).toMatchObject({ state: "pending", request: { tag: `ci/build/${b}`, reason: "bisect", dispatched: true }, phases: { suites: { reason: "requested" } } });
|
|
103
|
+
expect(remoteRecord(origin, c)).toMatchObject({ state: "red", failure: { phase: "suites" } });
|
|
104
|
+
|
|
105
|
+
// A second tick asks for nothing more and starts nothing again while b's build waits.
|
|
106
|
+
const second = await ciTick({ cwd: root, forge, now: NOW });
|
|
107
|
+
expect(second.requested).toEqual([]);
|
|
108
|
+
expect(forge.dispatched).toHaveLength(1);
|
|
109
|
+
|
|
110
|
+
// ci list and ci last-green read the records. (The walk to the culprit is ci-builds.e2e.test.ts.)
|
|
111
|
+
const list = await ciList({ cwd: root, now: NOW });
|
|
112
|
+
if ("error" in list) throw new Error(list.error.message);
|
|
113
|
+
expect(list.commits.find((x) => x.sha === a)!.record).toEqual({ state: "unbuilt", reason: "superseded", coveredBy: c });
|
|
114
|
+
const last = lastGreen({ cwd: root });
|
|
115
|
+
if ("error" in last) throw new Error(last.error.message);
|
|
116
|
+
expect(last.commit!.sha).toBe(base);
|
|
117
|
+
expect(last.newer.map((n) => [n.sha, n.record?.state])).toEqual([
|
|
118
|
+
[c, "red"],
|
|
119
|
+
[b, "pending"],
|
|
120
|
+
[a, "unbuilt"],
|
|
121
|
+
]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("runs a failed job again once before the commit counts red", async () => {
|
|
125
|
+
const { root, origin } = workspace();
|
|
126
|
+
const a = commitAt(root, 2, "app/a.txt");
|
|
127
|
+
git(root, "push", "-q", "origin", "main");
|
|
128
|
+
const failed = run("check", "failure");
|
|
129
|
+
const runs = new Map([[a, [failed, run("suites", "success")]]]);
|
|
130
|
+
const forge = fakeForge(runs);
|
|
131
|
+
const tick = await ciTick({ cwd: root, forge, now: NOW });
|
|
132
|
+
expect(tick.retried).toEqual([{ sha: a, run: "check" }]);
|
|
133
|
+
expect(forge.reruns).toEqual([failed.id]);
|
|
134
|
+
expect(remoteRecord(origin, a)).toMatchObject({ state: "pending", retried: { check: { run: failed.id } } });
|
|
135
|
+
// Asked once: the next tick waits for the attempt and does not ask again.
|
|
136
|
+
await ciTick({ cwd: root, forge, now: NOW });
|
|
137
|
+
expect(forge.reruns).toEqual([failed.id]);
|
|
138
|
+
runs.set(a, [failed, run("check", "success"), run("suites", "success")]);
|
|
139
|
+
expect((await ciTick({ cwd: root, forge, now: NOW })).made).toEqual([`ci/green/${a}`]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("a dry run writes no record and pushes no request", async () => {
|
|
143
|
+
const { root, origin } = workspace();
|
|
144
|
+
const base = commitAt(root, 4, "app/base.txt");
|
|
145
|
+
const a = commitAt(root, 3, "app/a.txt");
|
|
146
|
+
const b = commitAt(root, 2, "app/b.txt");
|
|
147
|
+
git(root, "push", "-q", "origin", "main");
|
|
148
|
+
const runs = new Map([
|
|
149
|
+
[base, [run("check", "success"), run("suites", "success")]],
|
|
150
|
+
[a, [run("check", "success")]],
|
|
151
|
+
[b, [run("check", "success"), run("suites", "failure"), run("suites", "failure")]],
|
|
152
|
+
]);
|
|
153
|
+
const tick = await ciTick({ cwd: root, forge: fakeForge(runs), now: NOW, dryRun: true });
|
|
154
|
+
expect(tick.requested.map((r) => r.commit)).toEqual([a]);
|
|
155
|
+
expect(tick.recorded).toEqual([]);
|
|
156
|
+
expect(remoteTags(origin)).toEqual([]);
|
|
157
|
+
expect(() => git(origin, "rev-parse", "--verify", "refs/heads/chant/lifecycle")).toThrow();
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("chant ci run --at (#4169)", () => {
|
|
162
|
+
test("pushes a request for every required phase and starts the workflows holding them", async () => {
|
|
163
|
+
const { root, origin } = workspace();
|
|
164
|
+
const a = commitAt(root, 2, "app/a.txt");
|
|
165
|
+
commitAt(root, 1, "app/b.txt");
|
|
166
|
+
git(root, "push", "-q", "origin", "main");
|
|
167
|
+
const forge = fakeForge(new Map());
|
|
168
|
+
const r = await requestBuild({ cwd: root, at: a, forge, now: NOW });
|
|
169
|
+
expect(r.request).toMatchObject({ commit: a, phases: ["check", "suites"], reason: "requested", tag: `ci/build/${a}` });
|
|
170
|
+
expect(r.existed).toBe(false);
|
|
171
|
+
expect(r.dispatched).toEqual(["release-suites.yml"]);
|
|
172
|
+
expect(remoteTags(origin)).toEqual([`ci/build/${a}`]);
|
|
173
|
+
expect(readBuildRequests(root).get(a)).toMatchObject({ reason: "requested" });
|
|
174
|
+
expect((await requestBuild({ cwd: root, at: a, now: NOW })).existed).toBe(true);
|
|
175
|
+
await expect(requestBuild({ cwd: root, at: "nope", now: NOW })).rejects.toThrow(/names no commit/);
|
|
176
|
+
// The tick takes the run already going at the commit as started, and records the request.
|
|
177
|
+
await ciTick({ cwd: root, forge: fakeForge(new Map([[a, [run("check", "success"), run("suites", null, "in_progress")]]])), now: NOW });
|
|
178
|
+
expect(readCiRecord(root, a)).toMatchObject({ state: "pending", request: { reason: "requested", dispatched: true } });
|
|
179
|
+
});
|
|
180
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant ci tick` under the who-ran-it security group (#4168): no key, no tick.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { afterAll, afterEach, describe, expect, test, vi } from "vitest";
|
|
10
|
+
import { runCiTick } from "./ci-green-cli";
|
|
11
|
+
import { RUNNER_KEY_ENV } from "./ci-green";
|
|
12
|
+
|
|
13
|
+
const scratch: string[] = [];
|
|
14
|
+
afterAll(() => {
|
|
15
|
+
for (const d of scratch) rmSync(d, { recursive: true, force: true });
|
|
16
|
+
});
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
vi.restoreAllMocks();
|
|
19
|
+
vi.unstubAllEnvs();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
function repo(extra: Record<string, unknown>): string {
|
|
23
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), "chant-ci-tick-")));
|
|
24
|
+
scratch.push(root);
|
|
25
|
+
execFileSync("git", ["init", "-q"], { cwd: root });
|
|
26
|
+
writeFileSync(
|
|
27
|
+
join(root, "chant.workspace.json"),
|
|
28
|
+
JSON.stringify({ name: "acme", schema: 1, members: [], ci: { green: { branch: "main", phases: { test: ["test"] }, require: ["test"], source: "runs" } }, ...extra }),
|
|
29
|
+
);
|
|
30
|
+
return root;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const ctx = (args: Record<string, unknown> = {}) => ({ args: { command: "ci", path: "tick", ...args }, plugins: [] }) as never;
|
|
34
|
+
|
|
35
|
+
describe("ci tick and who-ran-it", () => {
|
|
36
|
+
test("under who-ran-it a tick with no runner key is refused before it reads anything", async () => {
|
|
37
|
+
vi.stubEnv(RUNNER_KEY_ENV, "");
|
|
38
|
+
vi.spyOn(process, "cwd").mockReturnValue(repo({ security: ["who-ran-it"] }));
|
|
39
|
+
const errors: string[] = [];
|
|
40
|
+
vi.spyOn(console, "error").mockImplementation((...m: unknown[]) => void errors.push(m.map(String).join(" ")));
|
|
41
|
+
expect(await runCiTick(ctx())).toBe(1);
|
|
42
|
+
expect(errors.join("\n")).toContain("the workspace opts into who-ran-it, so the tick signs every tag it makes and needs a runner key");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("without the group the same tick is not refused for its key", async () => {
|
|
46
|
+
vi.stubEnv(RUNNER_KEY_ENV, "");
|
|
47
|
+
vi.spyOn(process, "cwd").mockReturnValue(repo({}));
|
|
48
|
+
const errors: string[] = [];
|
|
49
|
+
vi.spyOn(console, "error").mockImplementation((...m: unknown[]) => void errors.push(m.map(String).join(" ")));
|
|
50
|
+
vi.spyOn(console, "log").mockImplementation(() => {});
|
|
51
|
+
await runCiTick(ctx());
|
|
52
|
+
expect(errors.join("\n")).not.toContain("who-ran-it");
|
|
53
|
+
});
|
|
54
|
+
});
|