@intentius/chant 0.124.34 → 0.124.35
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/cli/handlers/graph.js +2 -2
- package/dist/cli/handlers/pr.d.ts.map +1 -1
- package/dist/cli/handlers/pr.js +147 -51
- package/dist/cli/handlers/promote.d.ts.map +1 -1
- package/dist/cli/handlers/promote.js +23 -8
- package/dist/cli/handlers/release.d.ts +12 -0
- package/dist/cli/handlers/release.d.ts.map +1 -1
- package/dist/cli/handlers/release.js +125 -10
- package/dist/cli/handlers/run.d.ts +3 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/handlers/run.js +12 -2
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +19 -1
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +17 -0
- package/dist/cli/registry.d.ts +14 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/auto-release.d.ts +8 -1
- package/dist/components/auto-release.d.ts.map +1 -1
- package/dist/components/auto-release.js +3 -2
- package/dist/components/green-cover.d.ts +77 -0
- package/dist/components/green-cover.d.ts.map +1 -0
- package/dist/components/green-cover.js +153 -0
- package/dist/components/pr-pipeline.d.ts +34 -0
- package/dist/components/pr-pipeline.d.ts.map +1 -1
- package/dist/components/pr-pipeline.js +39 -5
- package/dist/components/promote.d.ts +4 -1
- package/dist/components/promote.d.ts.map +1 -1
- package/dist/components/promote.js +6 -3
- package/dist/components/release-promote.d.ts +9 -0
- package/dist/components/release-promote.d.ts.map +1 -1
- package/dist/components/release-promote.js +15 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/deep-observation.d.ts +11 -0
- package/dist/deep-observation.d.ts.map +1 -1
- package/dist/graph-declared.d.ts +5 -1
- package/dist/graph-declared.d.ts.map +1 -1
- package/dist/graph-declared.js +7 -2
- package/dist/graph-detail.d.ts.map +1 -1
- package/dist/graph-detail.js +30 -1
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/graph-ir.js +13 -2
- package/dist/lifecycle/__fixtures__/publish-fakes.d.ts.map +1 -1
- package/dist/lifecycle/__fixtures__/publish-fakes.js +5 -1
- package/dist/lifecycle/compose-refs.d.ts +52 -0
- package/dist/lifecycle/compose-refs.d.ts.map +1 -0
- package/dist/lifecycle/compose-refs.js +115 -0
- package/dist/lifecycle/deep-observe.d.ts.map +1 -1
- package/dist/lifecycle/deep-observe.js +3 -2
- package/dist/lifecycle/green-apply.d.ts +39 -0
- package/dist/lifecycle/green-apply.d.ts.map +1 -0
- package/dist/lifecycle/green-apply.js +51 -0
- package/dist/lifecycle/publish-declaration.d.ts +4 -0
- package/dist/lifecycle/publish-declaration.d.ts.map +1 -1
- package/dist/lifecycle/publish-declaration.js +15 -0
- package/dist/lifecycle/publish-npm.d.ts +7 -4
- package/dist/lifecycle/publish-npm.d.ts.map +1 -1
- package/dist/lifecycle/publish-npm.js +60 -13
- package/dist/lifecycle/release-compose.d.ts +91 -0
- package/dist/lifecycle/release-compose.d.ts.map +1 -0
- package/dist/lifecycle/release-compose.js +235 -0
- package/dist/lifecycle/release-cut.d.ts +8 -0
- package/dist/lifecycle/release-cut.d.ts.map +1 -1
- package/dist/lifecycle/release-cut.js +2 -0
- package/dist/lifecycle/release-publish.d.ts +2 -0
- package/dist/lifecycle/release-publish.d.ts.map +1 -1
- package/dist/lifecycle/release-publish.js +2 -1
- package/dist/lifecycle/release-store.d.ts +15 -0
- package/dist/lifecycle/release-store.d.ts.map +1 -1
- package/dist/lifecycle/release-store.js +5 -1
- package/dist/wave-runtime.mjs +225 -223
- package/dist/workspace/checks/ci-workflow.d.ts.map +1 -1
- package/dist/workspace/checks/ci-workflow.js +8 -1
- package/dist/workspace/ci-green-cli.d.ts.map +1 -1
- package/dist/workspace/ci-green-cli.js +5 -3
- package/dist/workspace/ci-green-workflow.d.ts +18 -2
- package/dist/workspace/ci-green-workflow.d.ts.map +1 -1
- package/dist/workspace/ci-green-workflow.js +27 -5
- package/dist/workspace/ci-green.d.ts +31 -0
- package/dist/workspace/ci-green.d.ts.map +1 -1
- package/dist/workspace/ci-green.js +78 -4
- package/dist/workspace/ci-run.d.ts +6 -0
- package/dist/workspace/ci-run.d.ts.map +1 -1
- package/dist/workspace/ci-run.js +2 -1
- package/dist/workspace/ci-serve.d.ts +94 -13
- package/dist/workspace/ci-serve.d.ts.map +1 -1
- package/dist/workspace/ci-serve.js +248 -30
- package/dist/workspace/ci-workflow.d.ts +4 -4
- package/dist/workspace/ci-workflow.d.ts.map +1 -1
- package/dist/workspace/ci-workflow.js +2 -2
- package/dist/workspace/delivery-metrics.d.ts +3 -1
- package/dist/workspace/delivery-metrics.d.ts.map +1 -1
- package/dist/workspace/delivery-metrics.js +12 -3
- package/dist/workspace/member-commands.d.ts.map +1 -1
- package/dist/workspace/member-commands.js +4 -0
- package/dist/workspace/nested-graph.d.ts.map +1 -1
- package/dist/workspace/nested-graph.js +1 -0
- package/dist/workspace/release-candidates.d.ts +28 -0
- package/dist/workspace/release-candidates.d.ts.map +1 -1
- package/dist/workspace/release-candidates.js +38 -2
- package/dist/workspace/release-candidates.schema.json +51 -0
- package/dist/workspace/releases.schema.json +30 -0
- package/package.json +1 -1
- package/src/cli/handlers/graph.ts +2 -2
- package/src/cli/handlers/pr-green.test.ts +230 -0
- package/src/cli/handlers/pr.ts +148 -66
- package/src/cli/handlers/promote.test.ts +34 -0
- package/src/cli/handlers/promote.ts +22 -8
- package/src/cli/handlers/release-compose.e2e.test.ts +133 -0
- package/src/cli/handlers/release.ts +112 -10
- package/src/cli/handlers/run.ts +12 -1
- package/src/cli/main.ts +19 -1
- package/src/cli/parse-args.ts +13 -0
- package/src/cli/registry.ts +14 -0
- package/src/codegen/release-wiring.test.ts +56 -9
- package/src/components/auto-release.test.ts +22 -0
- package/src/components/auto-release.ts +9 -2
- package/src/components/green-cover.test.ts +170 -0
- package/src/components/green-cover.ts +186 -0
- package/src/components/pr-pipeline.test.ts +14 -4
- package/src/components/pr-pipeline.ts +56 -5
- package/src/components/promote.test.ts +7 -0
- package/src/components/promote.ts +9 -4
- package/src/components/release-promote.test.ts +24 -1
- package/src/components/release-promote.ts +15 -0
- package/src/deep-observation.ts +8 -0
- package/src/graph-declared.test.ts +18 -0
- package/src/graph-declared.ts +8 -1
- package/src/graph-detail.test.ts +68 -1
- package/src/graph-detail.ts +29 -1
- package/src/graph-ir.ts +12 -4
- package/src/lifecycle/__fixtures__/publish-fakes.ts +5 -1
- package/src/lifecycle/compose-refs.ts +125 -0
- package/src/lifecycle/deep-observe.test.ts +20 -0
- package/src/lifecycle/deep-observe.ts +4 -2
- package/src/lifecycle/green-apply.ts +71 -0
- package/src/lifecycle/publish-declaration.ts +15 -0
- package/src/lifecycle/publish-npm.ts +55 -13
- package/src/lifecycle/publish-targets.test.ts +45 -0
- package/src/lifecycle/release-compose.test.ts +125 -0
- package/src/lifecycle/release-compose.ts +312 -0
- package/src/lifecycle/release-cut.ts +6 -0
- package/src/lifecycle/release-publish.ts +4 -1
- package/src/lifecycle/release-store.ts +16 -1
- package/src/workspace/checks/ci-workflow.test.ts +12 -2
- package/src/workspace/checks/ci-workflow.ts +7 -1
- package/src/workspace/ci-green-cli.ts +5 -3
- package/src/workspace/ci-green-workflow.test.ts +52 -0
- package/src/workspace/ci-green-workflow.ts +34 -5
- package/src/workspace/ci-green.test.ts +33 -0
- package/src/workspace/ci-green.ts +85 -4
- package/src/workspace/ci-run.test.ts +18 -13
- package/src/workspace/ci-run.ts +8 -1
- package/src/workspace/ci-serve-land.e2e.test.ts +138 -0
- package/src/workspace/ci-serve-land.test.ts +176 -0
- package/src/workspace/ci-serve.test.ts +77 -43
- package/src/workspace/ci-serve.ts +268 -34
- package/src/workspace/ci-workflow.ts +4 -4
- package/src/workspace/delivery-metrics.ts +12 -3
- package/src/workspace/graph-contract.test.ts +3 -2
- package/src/workspace/member-commands.test.ts +11 -4
- package/src/workspace/member-commands.ts +3 -0
- package/src/workspace/nested-graph.test.ts +4 -0
- package/src/workspace/nested-graph.ts +1 -0
- package/src/workspace/release-candidates.schema.json +51 -0
- package/src/workspace/release-candidates.test.ts +30 -3
- package/src/workspace/release-candidates.ts +54 -2
- package/src/workspace/releases.schema.json +30 -0
|
@@ -5,10 +5,13 @@
|
|
|
5
5
|
* {@link ciRun} (./ci-run.ts): `plan` for each new pull request head, and
|
|
6
6
|
* `record` for each new commit on the target branch (#4168), measured from
|
|
7
7
|
* the head the previous poll saw (the push's `before`). The record applies
|
|
8
|
-
* nothing
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* nothing. With `applyOnMerge` (`--apply-on-merge`) the newest green commit
|
|
9
|
+
* of the branch, once no apply ran for it, gets `apply`: `pr-apply --green`
|
|
10
|
+
* at that commit, measured from the one last applied (#4168 step 9). After
|
|
11
|
+
* each job settles it runs `ci tick` from the runs the jobs recorded, when
|
|
12
|
+
* the declaration has `ci.green`, so a green commit follows the record that
|
|
13
|
+
* made its runs. What the list below says of the apply holds for it under
|
|
14
|
+
* `applyOnMerge`.
|
|
12
15
|
*
|
|
13
16
|
* - Dedupe. A state file in the state directory keeps every job it started,
|
|
14
17
|
* keyed by job and commit (`plan:<pr>:<sha>`, `record:<sha>`, `apply:<sha>`), with how it
|
|
@@ -19,10 +22,11 @@
|
|
|
19
22
|
* the one `chant operator` holds) named for `prApplyGroup`, renews it while
|
|
20
23
|
* it runs and releases it after. An apply that finds it held waits for a
|
|
21
24
|
* later poll, as a forge's concurrency group queues it.
|
|
22
|
-
* - Merged pull request.
|
|
23
|
-
* merge commit the new head is (`pullRequestFor`, as pr-
|
|
25
|
+
* - Merged pull request. A record asks the forge for the pull request whose
|
|
26
|
+
* merge commit the new head is (`pullRequestFor`, as pr-record does) and
|
|
24
27
|
* passes it as `--pr`, so its commit status link and run index use
|
|
25
|
-
* `<pr>/<commit>`. With none found it runs without `--pr`.
|
|
28
|
+
* `<pr>/<commit>`. With none found it runs without `--pr`. An apply covers
|
|
29
|
+
* every merge since the last applied green commit, so it passes none.
|
|
26
30
|
* - Resume records. Each apply gets `<state>/resume/<prApplyRecordKey>.json`
|
|
27
31
|
* as its `--resume` record, so a re-run of a failed apply at the same
|
|
28
32
|
* commit finishes under the approval it has. This replaces the forge's CI
|
|
@@ -56,6 +60,24 @@
|
|
|
56
60
|
* forge's keeps its facts, and the job's push sends them. Nothing here
|
|
57
61
|
* pushes the ledger.
|
|
58
62
|
*
|
|
63
|
+
* - Land branches (#4168 4.4). With `land`, each `chant/land/<name>` branch
|
|
64
|
+
* pushed to the remote is a change asking to land without a pull request.
|
|
65
|
+
* A head that does not descend from the target's is refused and left for
|
|
66
|
+
* its author to rebase. Otherwise `buildCommand` runs on it with
|
|
67
|
+
* `CHANT_BUILD_PHASES` set to `ci.green.preMerge` and `CHANT_BUILD_REASON`
|
|
68
|
+
* `land`, and the phases are judged from the Op run records on its head
|
|
69
|
+
* ({@link judgePreMerge}). When the command exits 0 and every phase
|
|
70
|
+
* passes, the target is pushed to the head, a fast-forward the remote
|
|
71
|
+
* refuses if the target moved, and the land branch is deleted. The next
|
|
72
|
+
* poll sees the new head and runs the record job, whose merge record
|
|
73
|
+
* names a work item or a direct push. A refused land stays in the state
|
|
74
|
+
* file under `land:<name>:<sha>`, so a new push to the branch is tried
|
|
75
|
+
* again and the same head is not.
|
|
76
|
+
* - The `git` forge kind (#4016 gap 6). With `forge: "git"` the serve reads
|
|
77
|
+
* only refs, through `git ls-remote` against `origin`, so any git server
|
|
78
|
+
* that takes pushes will do. It lists no pull requests and sets no commit
|
|
79
|
+
* status, and the jobs run without `--forge`.
|
|
80
|
+
*
|
|
59
81
|
* Host mode only: each job runs in a fresh worktree on this host as this
|
|
60
82
|
* user, with the clean environment `ciRun` gives it. A pull request from a
|
|
61
83
|
* fork is never planned, and with `authors` only those people's pull requests
|
|
@@ -70,7 +92,7 @@ import { execFileSync, spawn, spawnSync } from "node:child_process";
|
|
|
70
92
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
71
93
|
import { tmpdir } from "node:os";
|
|
72
94
|
import { join, resolve } from "node:path";
|
|
73
|
-
import { CI_BUILD_TAG_PREFIX, readBuildRequests, type
|
|
95
|
+
import { CI_BUILD_TAG_PREFIX, readBuildRequests, type BuildRequestReason } from "./ci-builds";
|
|
74
96
|
import { prApplyGroup, prApplyRecordKey } from "../components/pr-pipeline";
|
|
75
97
|
import { forgeFromEnv, type OpenPullRequest } from "../pr-forge";
|
|
76
98
|
import type { CommandContext } from "../cli/registry";
|
|
@@ -79,12 +101,14 @@ import { acquireLease, currentHolderId, releaseLease, DEFAULT_LEASE_TTL_MS, type
|
|
|
79
101
|
import { parseDuration } from "../op/duration";
|
|
80
102
|
import { reportUrlOf } from "./ci-report-url";
|
|
81
103
|
import { CI_JOB_OPS, ciJobPlace, ciLoopOptions, ciSecretsOf, ciRun, ciStatusFromEnv, CiRunError, type CiJobName, type CiRunRequest, type CiRunResult } from "./ci-run";
|
|
82
|
-
import { CiGreenError, ciTick, RUNNER_KEY_ENV, signedRunsRequired } from "./ci-green";
|
|
104
|
+
import { CiGreenError, ciTick, judgePreMerge, lastGreen, RUNNER_KEY_ENV, signedRunsRequired, type CommitVerdict } from "./ci-green";
|
|
83
105
|
import { gitTop } from "./tree";
|
|
106
|
+
import { readDeclaration } from "./declaration";
|
|
107
|
+
import { locateWorkspace } from "./which-chant";
|
|
84
108
|
import { ciServeRunnerKey, formatRunnerKeySetup, setupRunnerKey } from "./ci-runner-key";
|
|
85
109
|
|
|
86
|
-
/** The forges `ci serve` polls. GitLab is later. */
|
|
87
|
-
export type CiServeForgeKind = "github" | "forgejo";
|
|
110
|
+
/** The forges `ci serve` polls. GitLab is later. `git` is any git server, read through refs alone (#4016 gap 6). */
|
|
111
|
+
export type CiServeForgeKind = "github" | "forgejo" | "git";
|
|
88
112
|
|
|
89
113
|
/** What `ci serve` reads from the forge: the two calls GitHub's and Forgejo's clients have (../pr-forge.ts). */
|
|
90
114
|
export interface CiServeForge {
|
|
@@ -96,13 +120,51 @@ export interface CiServeForge {
|
|
|
96
120
|
|
|
97
121
|
/**
|
|
98
122
|
* A job the forge's state calls for: the plan of a pull request's head, or
|
|
99
|
-
* for a target-branch commit its record (#4168) or
|
|
100
|
-
*
|
|
123
|
+
* for a target-branch commit its record (#4168), or with `applyOnMerge` the
|
|
124
|
+
* apply of the newest green commit (#4168 step 9).
|
|
101
125
|
*/
|
|
102
126
|
export type CiServeTrigger =
|
|
103
127
|
| { job: "plan"; commit: string; base: string; pr: number; rerun?: boolean }
|
|
104
128
|
| { job: "apply" | "record"; commit: string; base: string; rerun?: boolean };
|
|
105
129
|
|
|
130
|
+
/**
|
|
131
|
+
* The `git` forge kind (#4016 gap 6): the branch head from `git ls-remote`
|
|
132
|
+
* against `remote`, and no pull requests. A remote that can't be reached is
|
|
133
|
+
* an error for the pass, as a forge API's would be.
|
|
134
|
+
*/
|
|
135
|
+
export function gitCiServeForge(top: string, remote = "origin"): CiServeForge {
|
|
136
|
+
return {
|
|
137
|
+
openPullRequests: async () => [],
|
|
138
|
+
async branchHead(branch) {
|
|
139
|
+
const r = gitStatus(top, ["ls-remote", "--exit-code", remote, `refs/heads/${branch}`]);
|
|
140
|
+
if (r.code === 2) return undefined;
|
|
141
|
+
if (r.code !== 0) throw new CiRunError(`git ls-remote ${remote} could not read refs/heads/${branch}`);
|
|
142
|
+
return r.out.split(/\s+/)[0] || undefined;
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** What a build runs: a `ci/build/<sha>` request (#4169), or a land branch's pre-merge phases (#4168 4.4). */
|
|
148
|
+
export interface CiServeBuildInput {
|
|
149
|
+
commit: string;
|
|
150
|
+
phases: string[];
|
|
151
|
+
reason: BuildRequestReason | "land";
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Where pushed land branches live on the remote (#4168 4.4). */
|
|
155
|
+
export const LAND_BRANCH_PREFIX = "chant/land/";
|
|
156
|
+
|
|
157
|
+
/** One land branch head in the state file, by `land:<name>:<sha>`. */
|
|
158
|
+
export interface CiServeLandState {
|
|
159
|
+
branch: string;
|
|
160
|
+
commit: string;
|
|
161
|
+
status: "running" | "landed" | "refused";
|
|
162
|
+
/** Why it was refused, in a line. */
|
|
163
|
+
reason?: string;
|
|
164
|
+
startedAt: string;
|
|
165
|
+
settledAt?: string;
|
|
166
|
+
}
|
|
167
|
+
|
|
106
168
|
/** One job in the state file. */
|
|
107
169
|
export interface CiServeJobState {
|
|
108
170
|
job: CiJobName;
|
|
@@ -136,6 +198,8 @@ export interface CiServeState {
|
|
|
136
198
|
skipped?: Record<string, "skip-ci">;
|
|
137
199
|
/** The build requests served (#4169), by `build:<sha>`, with how each ended. */
|
|
138
200
|
builds?: Record<string, { tag: string; status: "running" | "ok" | "fail"; startedAt: string; settledAt?: string }>;
|
|
201
|
+
/** The land branch heads seen (#4168 4.4), by `land:<name>:<sha>`. */
|
|
202
|
+
lands?: Record<string, CiServeLandState>;
|
|
139
203
|
}
|
|
140
204
|
|
|
141
205
|
/** What a pass did, in order, for the log. */
|
|
@@ -149,6 +213,8 @@ export type CiServeEvent =
|
|
|
149
213
|
| { type: "not-run"; job: CiJobName; commit: string; pr?: number; reason: "not-fast-forward" | "skip-ci"; branch?: string; from?: string }
|
|
150
214
|
| { type: "ledger"; action: "deleted" | "reset"; local: string; forge?: string }
|
|
151
215
|
| { type: "build"; commit: string; phases: string[]; status: "started" | "ok" | "fail" | "held"; exitCode?: number }
|
|
216
|
+
/** A land branch (#4168 4.4): its pre-merge phases started, the target moved to it, or it was refused and left. */
|
|
217
|
+
| { type: "land"; branch: string; commit: string; status: "started" | "landed" | "refused" | "held"; reason?: string; warning?: string }
|
|
152
218
|
/** `ci tick` after the pass (#4168): the tags it made from the runs. */
|
|
153
219
|
| { type: "tick"; made: string[] }
|
|
154
220
|
| { type: "error"; message: string };
|
|
@@ -199,7 +265,14 @@ export interface CiServeOptions {
|
|
|
199
265
|
*/
|
|
200
266
|
buildCommand?: string;
|
|
201
267
|
/** Runs a build. Default: `sh -c <buildCommand>` in a detached worktree of the commit. */
|
|
202
|
-
build?: (input: { top: string; request:
|
|
268
|
+
build?: (input: { top: string; request: CiServeBuildInput; command: string }) => Promise<number>;
|
|
269
|
+
/**
|
|
270
|
+
* Land `chant/land/<name>` branches (#4168 4.4): the `ci.green.preMerge`
|
|
271
|
+
* phases each must pass first. Needs `buildCommand`, which runs them.
|
|
272
|
+
*/
|
|
273
|
+
land?: { phases: string[] };
|
|
274
|
+
/** Judges a land head's phases. Default {@link judgePreMerge} from the Op run records. */
|
|
275
|
+
judgeLand?: (input: { commit: string; base: string }) => Promise<CommitVerdict>;
|
|
203
276
|
}
|
|
204
277
|
|
|
205
278
|
/** The jobs this process runs now, by state key, shared by every pass of one loop. */
|
|
@@ -367,8 +440,8 @@ export async function ciServeObserve(opts: CiServeOptions, rt: CiServeRuntime =
|
|
|
367
440
|
const triggers: CiServeTrigger[] = [];
|
|
368
441
|
const events: CiServeEvent[] = [];
|
|
369
442
|
const [head, prs] = await Promise.all([opts.client.branchHead(place.branch), opts.client.openPullRequests(place.branch)]);
|
|
370
|
-
// #4168: a new target commit is recorded
|
|
371
|
-
const onMerge
|
|
443
|
+
// #4168: a new target commit is recorded. With applyOnMerge the newest green commit is applied, below.
|
|
444
|
+
const onMerge = "record" as const;
|
|
372
445
|
|
|
373
446
|
if (head === undefined) {
|
|
374
447
|
events.push({ type: "no-branch", branch: place.branch });
|
|
@@ -398,6 +471,16 @@ export async function ciServeObserve(opts: CiServeOptions, rt: CiServeRuntime =
|
|
|
398
471
|
if (opts.rerunFailed && failedEntry(entry, key, rt)) triggers.push({ job: onMerge, commit: head, base: entry.base, rerun: true });
|
|
399
472
|
}
|
|
400
473
|
|
|
474
|
+
if (opts.applyOnMerge && head !== undefined) {
|
|
475
|
+
const green = newestGreen(place.top, opts.cwd, place.branch);
|
|
476
|
+
if (green) {
|
|
477
|
+
const key = ciServeKey({ job: "apply", commit: green });
|
|
478
|
+
const entry = state.jobs[key];
|
|
479
|
+
if (!entry) triggers.push({ job: "apply", commit: green, base: green });
|
|
480
|
+
else if (opts.rerunFailed && failedEntry(entry, key, rt)) triggers.push({ job: "apply", commit: green, base: entry.base, rerun: true });
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
401
484
|
const authors = opts.authors?.length ? new Set(opts.authors.map((a) => a.toLowerCase())) : undefined;
|
|
402
485
|
for (const pr of prs) {
|
|
403
486
|
if (pr.fork) {
|
|
@@ -498,7 +581,7 @@ export async function ciServeSchedule(opts: CiServeOptions, triggers: readonly C
|
|
|
498
581
|
const entry: CiServeJobState = { job: t.job, commit: t.commit, base: t.base, ...pr, status: "running", runId, attempts: attempt, startedAt: now().toISOString() };
|
|
499
582
|
update((s) => {
|
|
500
583
|
s.jobs[key] = entry;
|
|
501
|
-
if (t.job
|
|
584
|
+
if (t.job === "record" && !t.rerun) s.target = t.commit;
|
|
502
585
|
});
|
|
503
586
|
emit({ type: "started", job: t.job, commit: t.commit, ...pr, base: t.base, runId, attempt, ...(resume && existsSync(resume) ? { resume } : {}) });
|
|
504
587
|
|
|
@@ -541,7 +624,7 @@ async function runJob(
|
|
|
541
624
|
tryGit(place.top, ["fetch", "--quiet", "--no-tags", "origin", t.job === "plan" ? `refs/pull/${t.pr}/head` : place.branch]);
|
|
542
625
|
const hostEnv = { ...(opts.job?.hostEnv ?? process.env), GITHUB_REPOSITORY: opts.repo };
|
|
543
626
|
// An apply is for a merge: pass the pull request whose merge commit it is, so the commit status link and the run index use `<pr>/<commit>`. None found, or a forge that errs, is today's behaviour.
|
|
544
|
-
if (t.job
|
|
627
|
+
if (t.job === "record" && opts.client.pullRequestFor) merged = (await opts.client.pullRequestFor(t.commit).catch(() => null)) ?? undefined;
|
|
545
628
|
const run = opts.run ?? ciRun;
|
|
546
629
|
const r = await run({
|
|
547
630
|
...opts.job,
|
|
@@ -549,7 +632,8 @@ async function runJob(
|
|
|
549
632
|
cwd: opts.cwd,
|
|
550
633
|
job: t.job,
|
|
551
634
|
env: opts.env,
|
|
552
|
-
|
|
635
|
+
// #4016 gap 6: a plain git server has no API, so the job runs without --forge, shaped as a GitHub job.
|
|
636
|
+
...(opts.forge === "git" ? { forge: "github" as const, noForge: true } : { forge: opts.forge }),
|
|
553
637
|
base: t.base,
|
|
554
638
|
...(t.job === "plan" ? { pr: t.pr } : merged !== undefined ? { pr: merged } : {}),
|
|
555
639
|
commit: t.commit,
|
|
@@ -567,15 +651,31 @@ async function runJob(
|
|
|
567
651
|
}
|
|
568
652
|
}
|
|
569
653
|
|
|
654
|
+
/**
|
|
655
|
+
* The newest green commit of the target branch (#4168 step 9), after
|
|
656
|
+
* fetching the ci tags, or undefined when none is green or the workspace
|
|
657
|
+
* declares no `ci.green`.
|
|
658
|
+
*/
|
|
659
|
+
function newestGreen(top: string, cwd: string, branch: string): string | undefined {
|
|
660
|
+
tryGit(top, ["fetch", "--quiet", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`, "+refs/tags/ci/*:refs/tags/ci/*"]);
|
|
661
|
+
try {
|
|
662
|
+
const doc = lastGreen({ cwd });
|
|
663
|
+
return "commit" in doc && doc.commit ? doc.commit.sha : undefined;
|
|
664
|
+
} catch {
|
|
665
|
+
return undefined;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
570
669
|
/** One pass: observe, schedule, serve build requests, and with `wait` wait for the jobs it started. */
|
|
571
670
|
export async function ciServePass(opts: CiServeOptions, rt: CiServeRuntime = newCiServeRuntime(), wait = true): Promise<{ events: CiServeEvent[]; jobs: CiServeJobState[] }> {
|
|
572
671
|
const observed = await ciServeObserve(opts, rt);
|
|
573
672
|
for (const e of observed.events) opts.log?.(e);
|
|
574
673
|
const scheduled = await ciServeSchedule(opts, observed.triggers, rt);
|
|
575
674
|
const builds = opts.buildCommand ? ciServeBuilds(opts, rt) : { started: [], events: [] };
|
|
675
|
+
const lands = opts.land && opts.buildCommand ? await ciServeLands(opts, rt) : { started: [], events: [] };
|
|
576
676
|
const jobs = wait ? await Promise.all(scheduled.started) : [];
|
|
577
|
-
if (wait) await Promise.all(builds.started);
|
|
578
|
-
return { events: [...observed.events, ...scheduled.events, ...builds.events], jobs };
|
|
677
|
+
if (wait) await Promise.all([...builds.started, ...lands.started]);
|
|
678
|
+
return { events: [...observed.events, ...scheduled.events, ...builds.events, ...lands.events], jobs };
|
|
579
679
|
}
|
|
580
680
|
|
|
581
681
|
/**
|
|
@@ -630,8 +730,107 @@ export function ciServeBuilds(opts: CiServeOptions, rt: CiServeRuntime): { start
|
|
|
630
730
|
return { started, events };
|
|
631
731
|
}
|
|
632
732
|
|
|
733
|
+
/** The land branches on `origin`, by name, with their heads (#4168 4.4). */
|
|
734
|
+
function landBranches(top: string): { name: string; head: string }[] {
|
|
735
|
+
const r = gitStatus(top, ["ls-remote", "origin", `refs/heads/${LAND_BRANCH_PREFIX}*`]);
|
|
736
|
+
if (r.code !== 0) return [];
|
|
737
|
+
return r.out
|
|
738
|
+
.split("\n")
|
|
739
|
+
.map((line) => line.split(/\s+/))
|
|
740
|
+
.filter(([sha, ref]) => sha && ref?.startsWith(`refs/heads/${LAND_BRANCH_PREFIX}`))
|
|
741
|
+
.map(([head, ref]) => ({ name: ref!.slice(`refs/heads/${LAND_BRANCH_PREFIX}`.length), head: head! }))
|
|
742
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/** Why a land's phases don't let it land, or undefined when every one passed. */
|
|
746
|
+
function landRefusal(verdict: CommitVerdict): string | undefined {
|
|
747
|
+
if (verdict.verdict === "pass") return undefined;
|
|
748
|
+
const failed = verdict.phases.filter((p) => p.verdict === "fail").map((p) => p.phase);
|
|
749
|
+
if (failed.length > 0) return `the pre-merge phase${failed.length > 1 ? "s" : ""} ${failed.join(", ")} failed`;
|
|
750
|
+
const open = verdict.phases.filter((p) => p.verdict !== "pass").map((p) => p.phase);
|
|
751
|
+
return `the build recorded no finished run for ${open.join(", ")}`;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Serve the land branches (#4168 4.4): for each `chant/land/<name>` head not
|
|
756
|
+
* in the state file, refuse it unless it descends from the target's head,
|
|
757
|
+
* else run `buildCommand` on it with the pre-merge phases, judge them, and
|
|
758
|
+
* when they pass push the target to it and delete the branch. At most
|
|
759
|
+
* `maxJobs` builds and jobs run at once.
|
|
760
|
+
*/
|
|
761
|
+
export async function ciServeLands(opts: CiServeOptions, rt: CiServeRuntime): Promise<{ started: Promise<void>[]; events: CiServeEvent[] }> {
|
|
762
|
+
const place = servePlace(opts);
|
|
763
|
+
const fresh = { forge: opts.forge, repo: opts.repo, env: opts.env, branch: place.branch, ...(place.member ? { member: place.member } : {}) };
|
|
764
|
+
const update = (fn: (s: CiServeState) => void): void => {
|
|
765
|
+
const st = readCiServeState(place.stateFile, fresh);
|
|
766
|
+
fn(st);
|
|
767
|
+
writeState(place.stateFile, st);
|
|
768
|
+
};
|
|
769
|
+
const events: CiServeEvent[] = [];
|
|
770
|
+
const started: Promise<void>[] = [];
|
|
771
|
+
const now = opts.now ?? (() => new Date());
|
|
772
|
+
const log = (e: CiServeEvent): void => {
|
|
773
|
+
events.push(e);
|
|
774
|
+
opts.log?.(e);
|
|
775
|
+
};
|
|
776
|
+
const phases = opts.land!.phases;
|
|
777
|
+
const seen = readCiServeState(place.stateFile, fresh).lands ?? {};
|
|
778
|
+
for (const { name, head } of landBranches(place.top)) {
|
|
779
|
+
const key = `land:${name}:${head}`;
|
|
780
|
+
const branch = `${LAND_BRANCH_PREFIX}${name}`;
|
|
781
|
+
if (seen[key] || rt.inflight.has(key)) continue;
|
|
782
|
+
if (rt.inflight.size >= (opts.maxJobs ?? 1)) {
|
|
783
|
+
log({ type: "land", branch, commit: head, status: "held", reason: "--max-jobs jobs run" });
|
|
784
|
+
continue;
|
|
785
|
+
}
|
|
786
|
+
const settle = (status: "landed" | "refused", reason?: string, warning?: string): void => {
|
|
787
|
+
update((st) => {
|
|
788
|
+
st.lands = { ...st.lands, [key]: { branch, commit: head, status, ...(reason ? { reason } : {}), startedAt: st.lands?.[key]?.startedAt ?? now().toISOString(), settledAt: now().toISOString() } };
|
|
789
|
+
});
|
|
790
|
+
log({ type: "land", branch, commit: head, status, ...(reason ? { reason } : {}), ...(warning ? { warning } : {}) });
|
|
791
|
+
};
|
|
792
|
+
const target = await opts.client.branchHead(place.branch);
|
|
793
|
+
if (target === undefined) {
|
|
794
|
+
log({ type: "error", message: `${branch} waits: the remote has no branch ${place.branch} to land it on` });
|
|
795
|
+
continue;
|
|
796
|
+
}
|
|
797
|
+
tryGit(place.top, ["fetch", "--quiet", "--no-tags", "origin", place.branch, `+refs/heads/${branch}:refs/chant/ci-serve/land/${name}`]);
|
|
798
|
+
// #4168 4.4: a land only ever fast-forwards the target, so its head must descend from the target's.
|
|
799
|
+
if (gitStatus(place.top, ["merge-base", "--is-ancestor", target, head]).code !== 0) {
|
|
800
|
+
settle("refused", `${head.slice(0, 12)} does not descend from ${place.branch} at ${target.slice(0, 12)}; rebase ${branch} onto it and push again`);
|
|
801
|
+
continue;
|
|
802
|
+
}
|
|
803
|
+
update((st) => {
|
|
804
|
+
st.lands = { ...st.lands, [key]: { branch, commit: head, status: "running", startedAt: now().toISOString() } };
|
|
805
|
+
});
|
|
806
|
+
log({ type: "land", branch, commit: head, status: "started" });
|
|
807
|
+
const build = opts.build ?? runBuild;
|
|
808
|
+
const judge = opts.judgeLand ?? ((input: { commit: string; base: string }) => judgePreMerge({ cwd: opts.cwd, ...input }));
|
|
809
|
+
const job = (async () => {
|
|
810
|
+
const code = await build({ top: place.top, request: { commit: head, phases, reason: "land" }, command: opts.buildCommand! }).catch(() => 1);
|
|
811
|
+
if (code !== 0) return settle("refused", `the build of ${phases.join(", ")} exited ${code}`);
|
|
812
|
+
let refusal: string | undefined;
|
|
813
|
+
try {
|
|
814
|
+
refusal = landRefusal(await judge({ commit: head, base: target }));
|
|
815
|
+
} catch (err) {
|
|
816
|
+
refusal = `the pre-merge phases could not be judged: ${message(err)}`;
|
|
817
|
+
}
|
|
818
|
+
if (refusal) return settle("refused", refusal);
|
|
819
|
+
// A plain push: the remote refuses it unless it fast-forwards the target, so a target that moved meanwhile refuses the land.
|
|
820
|
+
const pushed = spawnSync("git", ["push", "--quiet", "origin", `${head}:refs/heads/${place.branch}`], { cwd: place.top, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] });
|
|
821
|
+
if (pushed.status !== 0) return settle("refused", `the push to ${place.branch} was refused: ${(pushed.stderr ?? "").trim().split("\n").pop() ?? ""}`);
|
|
822
|
+
const deleted = gitStatus(place.top, ["push", "--quiet", "origin", "--delete", `refs/heads/${branch}`]);
|
|
823
|
+
tryGit(place.top, ["update-ref", "-d", `refs/chant/ci-serve/land/${name}`]);
|
|
824
|
+
settle("landed", undefined, deleted.code === 0 ? undefined : `${branch} could not be deleted on the remote; delete it by hand`);
|
|
825
|
+
})().finally(() => rt.inflight.delete(key));
|
|
826
|
+
rt.inflight.set(key, job);
|
|
827
|
+
started.push(job);
|
|
828
|
+
}
|
|
829
|
+
return { started, events };
|
|
830
|
+
}
|
|
831
|
+
|
|
633
832
|
/** `sh -c <command>` in a fresh detached worktree of the request's commit, removed after. Resolves to the exit code. */
|
|
634
|
-
async function runBuild(input: { top: string; request:
|
|
833
|
+
async function runBuild(input: { top: string; request: CiServeBuildInput; command: string }): Promise<number> {
|
|
635
834
|
const dir = mkdtempSync(join(tmpdir(), "chant-ci-build-"));
|
|
636
835
|
try {
|
|
637
836
|
execFileSync("git", ["worktree", "add", "--quiet", "--detach", dir, input.request.commit], { cwd: input.top, stdio: ["ignore", "pipe", "pipe"] });
|
|
@@ -717,6 +916,13 @@ export function formatCiServeEvent(e: CiServeEvent): string {
|
|
|
717
916
|
if (e.status === "held") return `${what} waits: --max-jobs jobs run`;
|
|
718
917
|
return e.status === "started" ? `${what} started` : e.status === "ok" ? `${what} passed` : `${what} failed${e.exitCode !== undefined ? ` with exit code ${e.exitCode}` : ""}`;
|
|
719
918
|
}
|
|
919
|
+
case "land": {
|
|
920
|
+
const what = `${e.branch} at ${e.commit.slice(0, 12)}`;
|
|
921
|
+
if (e.status === "started") return `${what}: the pre-merge phases started`;
|
|
922
|
+
if (e.status === "held") return `${what} waits: ${e.reason ?? "--max-jobs jobs run"}`;
|
|
923
|
+
if (e.status === "refused") return `${what} was refused and left on the remote: ${e.reason ?? "no reason given"}`;
|
|
924
|
+
return `${what} landed${e.warning ? `; ${e.warning}` : ", and the land branch was deleted"}`;
|
|
925
|
+
}
|
|
720
926
|
case "tick":
|
|
721
927
|
return e.made.length > 0 ? `ci tick made ${e.made.join(", ")}` : "ci tick: no tag due";
|
|
722
928
|
case "error":
|
|
@@ -754,8 +960,8 @@ export function runnerKeySetupCommand(ctx: CommandContext): number {
|
|
|
754
960
|
|
|
755
961
|
const USAGE =
|
|
756
962
|
"chant ci serve --setup-key [--trust [--principal <name>]] [--state <dir>] [--branch <name>] | " +
|
|
757
|
-
"chant ci serve --forge forgejo|github --repo <owner/repo> --env <env> [--interval <s>] [--max-jobs <n>] [--state <dir>] " +
|
|
758
|
-
"[--author <login>]... [--once] [--rerun-failed] [--build-command <line>] [--gate <name>] [--wave-gate <name>] [--branch <name>] [--events-json] [--report-url <url>] [--no-status] [--status-url <url template>] " +
|
|
963
|
+
"chant ci serve --forge forgejo|github|git --repo <owner/repo> --env <env> [--interval <s>] [--max-jobs <n>] [--state <dir>] " +
|
|
964
|
+
"[--author <login>]... [--once] [--rerun-failed] [--build-command <line>] [--land] [--gate <name>] [--wave-gate <name>] [--branch <name>] [--events-json] [--report-url <url>] [--no-status] [--status-url <url template>] " +
|
|
759
965
|
"[--image <image>] [--before-script <line>]... [--pr-build] [--pr-build-command <line>] [--chant <command>] [--pass-env <NAME>]... [--secrets <file>] [--key <runner key.pem>] [--unsigned]";
|
|
760
966
|
|
|
761
967
|
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
@@ -768,14 +974,29 @@ export function ciServeInterval(value: string | undefined): number {
|
|
|
768
974
|
return ms;
|
|
769
975
|
}
|
|
770
976
|
|
|
977
|
+
/** `ci.green.preMerge` where `cwd` is, for `--land` (#4168 4.4). Throws when the declaration names none. */
|
|
978
|
+
function preMergeOf(cwd: string): string[] {
|
|
979
|
+
let pre: string[] | undefined;
|
|
980
|
+
try {
|
|
981
|
+
pre = readDeclaration(locateWorkspace(cwd).tree).ci?.green?.preMerge;
|
|
982
|
+
} catch (err) {
|
|
983
|
+
throw new CiRunError(`--land reads ci.green.preMerge from the workspace declaration, which could not be read: ${message(err)}`);
|
|
984
|
+
}
|
|
985
|
+
if (!pre || pre.length === 0) throw new CiRunError("--land runs the ci.green.preMerge phases on each land branch, and the declaration names none; add them to ci.green");
|
|
986
|
+
return pre;
|
|
987
|
+
}
|
|
988
|
+
|
|
771
989
|
/** `chant ci serve` (#4095). */
|
|
772
990
|
export async function runCiServe(ctx: CommandContext): Promise<number> {
|
|
773
991
|
const { args } = ctx;
|
|
774
992
|
try {
|
|
775
993
|
if (args.setupKey) return runnerKeySetupCommand(ctx);
|
|
776
994
|
const forge = args.forge;
|
|
777
|
-
if (forge !== "forgejo" && forge !== "github") throw new CiRunError(`--forge names the forge ci serve polls: --forge forgejo|github${forge ? `, not ${JSON.stringify(forge)}` : ""}`);
|
|
778
|
-
|
|
995
|
+
if (forge !== "forgejo" && forge !== "github" && forge !== "git") throw new CiRunError(`--forge names the forge ci serve polls: --forge forgejo|github|git${forge ? `, not ${JSON.stringify(forge)}` : ""}`);
|
|
996
|
+
// #4016 gap 6: a plain git server has no repository name to ask an API for, so --repo only labels the state file.
|
|
997
|
+
if (!args.ciRepo && forge !== "git") throw new CiRunError("--repo names the repository whose pull requests ci serve polls: --repo <owner/repo>");
|
|
998
|
+
// #4168 4.4: landing runs the pre-merge phases through the build command.
|
|
999
|
+
if (args.ciLand && !args.ciBuildCommand) throw new CiRunError("--land runs each land branch's pre-merge phases with --build-command, so it needs one: --build-command '<line>'");
|
|
779
1000
|
if (!args.env) throw new CiRunError("--env names the environment the pipeline is for: --env <env>");
|
|
780
1001
|
for (const name of args.passEnv ?? []) if (!ENV_NAME.test(name)) throw new CiRunError(`--pass-env takes a variable's name, not ${JSON.stringify(name)}`);
|
|
781
1002
|
// #4168: a gate per wave gates the apply on merge, which a merge runs only when opted in.
|
|
@@ -786,6 +1007,8 @@ export async function runCiServe(ctx: CommandContext): Promise<number> {
|
|
|
786
1007
|
if (!top) throw new CiRunError("ci serve runs in a clone of the repository it serves, and this directory is not in a git repository");
|
|
787
1008
|
const stateDir = args.statePath ? resolve(args.statePath) : defaultCiServeStateDir(cwd);
|
|
788
1009
|
const branch = args.branch ?? "main";
|
|
1010
|
+
const repo = args.ciRepo ?? (tryGit(top, ["remote", "get-url", "origin"]) || "origin");
|
|
1011
|
+
const landPhases = args.ciLand ? preMergeOf(cwd) : undefined;
|
|
789
1012
|
// #4099: the host's key, checked against .chant/trust.json at the target's head before the first poll.
|
|
790
1013
|
// #4156: signing is opt-in. Without ci.green.signedSince no key is quiet, and an untrusted one warns once and serves unsigned.
|
|
791
1014
|
const keyed = ciServeRunnerKey({
|
|
@@ -807,25 +1030,36 @@ export async function runCiServe(ctx: CommandContext): Promise<number> {
|
|
|
807
1030
|
// #4160: without --report-url, the declaration's ci.reportUrl.
|
|
808
1031
|
const reportUrl = await reportUrlOf(args.reportUrl, cwd);
|
|
809
1032
|
if (reportUrl && !args.reportUrl) console.error(`ci serve: reports and the run index go to ${reportUrl}, the declaration's ci.reportUrl`);
|
|
810
|
-
const hostEnv = { ...process.env, GITHUB_REPOSITORY:
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
if (
|
|
814
|
-
|
|
1033
|
+
const hostEnv = { ...process.env, GITHUB_REPOSITORY: repo };
|
|
1034
|
+
let client: CiServeForge;
|
|
1035
|
+
let status: ReturnType<typeof ciStatusFromEnv>["status"];
|
|
1036
|
+
if (forge === "git") {
|
|
1037
|
+
// #4016 gap 6: refs only, and no commit status to set.
|
|
1038
|
+
client = gitCiServeForge(top);
|
|
1039
|
+
if (!args.noStatus) console.error(formatWarning({ message: "the git forge kind has no API, so no commit status is set; each job's result is in the state file and its report" }));
|
|
1040
|
+
} else {
|
|
1041
|
+
const api = forgeFromEnv(forge, hostEnv);
|
|
1042
|
+
const fromEnv = ciStatusFromEnv({ forge, hostEnv, noStatus: args.noStatus, statusUrl: args.statusUrl });
|
|
1043
|
+
if (fromEnv.warning) console.error(formatWarning({ message: fromEnv.warning }));
|
|
1044
|
+
status = fromEnv.status;
|
|
1045
|
+
if (!api.openPullRequests || !api.branchHead) throw new CiRunError(`the ${forge} client cannot list pull requests`);
|
|
1046
|
+
client = { openPullRequests: api.openPullRequests.bind(api), branchHead: api.branchHead.bind(api), pullRequestFor: api.pullRequestFor.bind(api) };
|
|
1047
|
+
}
|
|
815
1048
|
// #4168: a host that applied each merge before the upgrade hears that it no longer does.
|
|
816
|
-
if (!args.applyOnMerge) console.error("ci serve: each new commit on the target branch gets the record job, which applies nothing (#4168); --apply-on-merge applies
|
|
1049
|
+
if (!args.applyOnMerge) console.error("ci serve: each new commit on the target branch gets the record job, which applies nothing (#4168); --apply-on-merge also applies each newest green commit");
|
|
817
1050
|
const opts: CiServeOptions = {
|
|
818
1051
|
cwd,
|
|
819
1052
|
forge,
|
|
820
|
-
repo
|
|
1053
|
+
repo,
|
|
821
1054
|
env: args.env,
|
|
822
|
-
client
|
|
1055
|
+
client,
|
|
823
1056
|
stateDir,
|
|
824
1057
|
...(args.maxJobs ? { maxJobs: args.maxJobs } : {}),
|
|
825
1058
|
...(args.ciAuthors?.length ? { authors: args.ciAuthors } : {}),
|
|
826
1059
|
...(args.rerunFailed ? { rerunFailed: true } : {}),
|
|
827
1060
|
...(args.ciBuildCommand ? { buildCommand: args.ciBuildCommand } : {}),
|
|
828
1061
|
...(args.applyOnMerge ? { applyOnMerge: true } : {}),
|
|
1062
|
+
...(landPhases ? { land: { phases: landPhases } } : {}),
|
|
829
1063
|
// #4168: the tick from the runs the jobs recorded, signed with the runs' key. No ci.green, no tick.
|
|
830
1064
|
tick: async () => {
|
|
831
1065
|
try {
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* `trunk-curated`: a merge records the plan and deploys nothing (#4168 4.1),
|
|
6
6
|
* the quick checks run before the branch moves (4.4), and a release is cut
|
|
7
7
|
* from a green commit and promoted through the member's environments, gated
|
|
8
|
-
* in the last (4.2). `trunk-curated+apply-on-merge` is the same
|
|
9
|
-
*
|
|
8
|
+
* in the last (4.2). `trunk-curated+apply-on-merge` is the same, and each
|
|
9
|
+
* newest green commit is also applied (section 5, step 9).
|
|
10
10
|
*
|
|
11
11
|
* A workflow requires no security group: those are opt-in on their own
|
|
12
12
|
* (./security.ts). `chant workspace check` reports what a member under a
|
|
@@ -55,8 +55,8 @@ export const CI_WORKFLOW_REQUIREMENTS = [
|
|
|
55
55
|
{ code: "ci-workflow-no-components", requirement: "the member declares a component (`*.component.ts`)", fix: "`chant ci init` writes a stub", perMember: true },
|
|
56
56
|
{ code: "ci-workflow-no-environments", requirement: "the member's `chant.config.ts` declares `environments`, the promotion order", fix: "`environments` in `chant.config.ts`", perMember: true },
|
|
57
57
|
{ code: "ci-workflow-ungated-env", requirement: "each component has a gate, so a promote to the last environment waits on an approval", fix: "a `gate()` step in the component", perMember: true },
|
|
58
|
-
{ code: "ci-workflow-pipeline-missing", requirement: "a pipeline file runs the plan job, the
|
|
59
|
-
{ code: "ci-workflow-pipeline-differs", requirement: "the
|
|
58
|
+
{ code: "ci-workflow-pipeline-missing", requirement: "a pipeline file runs the plan job, the record job (`ci run record`) and `chant ci tick`", fix: "`chant build --components --generate <forge> --pr-loop` and `chant ci workflow`", perMember: false },
|
|
59
|
+
{ code: "ci-workflow-pipeline-differs", requirement: "the pipeline matches the workflow: no apply job under `trunk-curated`, and under `trunk-curated+apply-on-merge` an apply job that applies the newest green commit, not each merge", fix: "regenerate the pull-request pipeline, with `--apply-on-merge` only under apply on merge", perMember: false },
|
|
60
60
|
{ code: "ci-workflow-publish-missing", requirement: "a pipeline file runs `chant release publish`", fix: "`chant release workflow`", perMember: false },
|
|
61
61
|
] as const satisfies readonly CiWorkflowRequirement[];
|
|
62
62
|
|
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
* resolutions, for a deploy that names its release only through its
|
|
16
16
|
* admission (`releaseOfDeploy`, #4043);
|
|
17
17
|
* - the counted `ci/revoked/<sha>` tags, judged as `release cut` judges them
|
|
18
|
-
* (`readRevokedCommits`, #4033), and each commit's committer time from git
|
|
18
|
+
* (`readRevokedCommits`, #4033), and each commit's committer time from git;
|
|
19
|
+
* a composed commit (#4168 4.3) takes the earliest time of the trunk
|
|
20
|
+
* commits it carries.
|
|
19
21
|
*
|
|
20
22
|
* Outside a workspace the project's own ledger is read as one member, `null`.
|
|
21
23
|
* It reads the local branch and the local tags, and never fetches.
|
|
@@ -139,11 +141,18 @@ export async function deliveryMetrics(query: MetricsQuery): Promise<DeliveryMetr
|
|
|
139
141
|
const { readRevokedCommits } = await import("../lifecycle/release-revocation");
|
|
140
142
|
const revoked = await readRevokedCommits(root);
|
|
141
143
|
|
|
144
|
+
const { compositionOf } = await import("../lifecycle/compose-refs");
|
|
142
145
|
const commitTimes = new Map<string, number | null>();
|
|
146
|
+
const ownTime = (sha: string): number | null => {
|
|
147
|
+
const iso = tryGit(top, ["show", "-s", "--format=%cI", `${sha}^{commit}`]);
|
|
148
|
+
return iso ? Date.parse(iso) : null;
|
|
149
|
+
};
|
|
143
150
|
const commitTime = (sha: string): number | null => {
|
|
144
151
|
if (!commitTimes.has(sha)) {
|
|
145
|
-
|
|
146
|
-
|
|
152
|
+
// #4168 4.3: a composed commit's lead time runs from the earliest trunk commit it carries.
|
|
153
|
+
const sources = compositionOf(top, sha)?.sources ?? [];
|
|
154
|
+
const times = sources.map(ownTime).filter((t): t is number => t !== null);
|
|
155
|
+
commitTimes.set(sha, times.length > 0 ? Math.min(...times) : ownTime(sha));
|
|
147
156
|
}
|
|
148
157
|
return commitTimes.get(sha)!;
|
|
149
158
|
};
|
|
@@ -221,10 +221,11 @@ describe("chant workspace graph on the chant repo (#2557)", () => {
|
|
|
221
221
|
// reference workspace runs its own chant workspace graph (#2551);
|
|
222
222
|
// read-contract.test.ts covers that. Here both are left out, so nothing runs.
|
|
223
223
|
const notRun = (JSON.parse(readFileSync(join(REPO, "chant.workspace.json"), "utf-8")) as { members: { name: string; kind: string }[] }).members
|
|
224
|
-
|
|
224
|
+
// npm (release/, #4040) is the one kind chant member; leaving it out keeps this a read with nothing to build.
|
|
225
|
+
.filter((m) => m.kind !== "terraform" && m.kind !== "choudoufu" && m.kind !== "workspace" && m.kind !== "chant")
|
|
225
226
|
.map((m) => m.name);
|
|
226
227
|
|
|
227
|
-
test("validates, and lists every member but the terraform one as skipped:
|
|
228
|
+
test("validates, and lists every member but the terraform one and npm as skipped: no other is kind chant", async () => {
|
|
228
229
|
const { doc, failed } = await workspaceGraph({ cwd: join(REPO, "packages", "core"), members: notRun });
|
|
229
230
|
const g = result(doc);
|
|
230
231
|
expectValid(g);
|
|
@@ -112,22 +112,22 @@ describe("resolveToolchain", () => {
|
|
|
112
112
|
});
|
|
113
113
|
|
|
114
114
|
describe("the chant repo's own declaration (#2557)", () => {
|
|
115
|
-
test("
|
|
115
|
+
test("build runs the one chant member, npm (release/, #4040), and the example groups under one toolchain", () => {
|
|
116
116
|
const plan = planMembers("build", REPO);
|
|
117
117
|
expect(plan.workspace.name).toBe("chant");
|
|
118
118
|
expect(plan.unreadable).toEqual([]);
|
|
119
119
|
expect(plan.groups).toHaveLength(1);
|
|
120
120
|
const units = plan.groups[0].units;
|
|
121
|
-
expect(units.
|
|
121
|
+
expect(units.filter((u) => !u.group).map((u) => u.id)).toEqual(["npm"]);
|
|
122
122
|
expect(units.map((u) => u.id)).toContain("fixtures:test/leftness");
|
|
123
123
|
expect(plan.skipped.every((s) => s.reason.code === "kind-not-run")).toBe(true);
|
|
124
124
|
expect(plan.skipped.map((s) => s.name)).toContain("core");
|
|
125
125
|
});
|
|
126
126
|
|
|
127
|
-
test("graph and audit run
|
|
127
|
+
test("graph and audit run only npm there: every other member is kind other", () => {
|
|
128
128
|
for (const verb of ["graph", "audit"] as const) {
|
|
129
129
|
const plan = planMembers(verb, REPO);
|
|
130
|
-
expect(plan.groups, verb).toEqual([]);
|
|
130
|
+
expect(plan.groups.flatMap((g) => g.units.map((u) => u.id)), verb).toEqual(["npm"]);
|
|
131
131
|
}
|
|
132
132
|
});
|
|
133
133
|
|
|
@@ -164,6 +164,13 @@ describe("member command lines", () => {
|
|
|
164
164
|
expect(memberArgv("graph", unit, args({ env: "prod" }))).toEqual(["graph", ".", "--format", "ir", "--env", "prod"]);
|
|
165
165
|
});
|
|
166
166
|
|
|
167
|
+
test("graph hands each member the detail tier (#4228)", () => {
|
|
168
|
+
expect(memberArgv("graph", unit, args({ detail: 1, live: true, overlay: true, env: "local" }))).toEqual([
|
|
169
|
+
"graph", ".", "--format", "ir", "--env", "local", "--live", "--overlay", "--detail", "1",
|
|
170
|
+
]);
|
|
171
|
+
expect(memberArgv("graph", unit, args({ detail: 0 }))).toEqual(["graph", ".", "--format", "ir", "--detail", "0"]);
|
|
172
|
+
});
|
|
173
|
+
|
|
167
174
|
test("a reader unit takes --env only with --live, which needs one (#2874, #2875)", () => {
|
|
168
175
|
const reader = { ...unit, reader: { lexicon: "terraform", config: {}, packageDir: "/p" } } as typeof unit;
|
|
169
176
|
expect(memberArgv("graph", reader, args({ env: "prod" }))).toEqual(["graph", ".", "--format", "ir"]);
|
|
@@ -305,6 +305,9 @@ export function memberArgv(verb: WorkspaceVerb, unit: RunUnit, args: ParsedArgs)
|
|
|
305
305
|
if (args.live) argv.push("--live");
|
|
306
306
|
if (args.overlay) argv.push("--overlay");
|
|
307
307
|
if (args.traffic) argv.push("--traffic", args.traffic);
|
|
308
|
+
// The detail tier too, so `workspace graph --detail 1` composes collapsed
|
|
309
|
+
// composites with their rolled-up status (#4228).
|
|
310
|
+
if (args.detail !== undefined) argv.push("--detail", String(args.detail));
|
|
308
311
|
}
|
|
309
312
|
return argv;
|
|
310
313
|
}
|
|
@@ -69,6 +69,10 @@ describe("readNestedDocument", () => {
|
|
|
69
69
|
"workspace", "graph", "/w/inner", "--at", "abc", "--env", "dev", "--live", "--overlay", "--traffic", "10 rps", "--no-cache",
|
|
70
70
|
]);
|
|
71
71
|
});
|
|
72
|
+
|
|
73
|
+
test("passes the detail tier on (#4228)", () => {
|
|
74
|
+
expect(nestedArgv("/w/inner", { detail: 1 }, null)).toEqual(["workspace", "graph", "/w/inner", "--detail", "1"]);
|
|
75
|
+
});
|
|
72
76
|
});
|
|
73
77
|
|
|
74
78
|
describe("foldNested", () => {
|
|
@@ -43,6 +43,7 @@ export function nestedArgv(dir: string, args: Partial<ParsedArgs>, at: string |
|
|
|
43
43
|
...(args.live ? ["--live"] : []),
|
|
44
44
|
...(args.overlay ? ["--overlay"] : []),
|
|
45
45
|
...(args.traffic !== undefined ? ["--traffic", String(args.traffic)] : []),
|
|
46
|
+
...(args.detail !== undefined ? ["--detail", String(args.detail)] : []),
|
|
46
47
|
...(args.noCache ? ["--no-cache"] : []),
|
|
47
48
|
];
|
|
48
49
|
}
|