@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
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant release compose` (#4168 4.3, mode b): a release of chosen changes
|
|
3
|
+
* on a base, when trunk carries work that is not ready to ship.
|
|
4
|
+
*
|
|
5
|
+
* The changes are named by pull request (`--pr <n>`) or work item
|
|
6
|
+
* (`--work <id>`) and found on trunk's first-parent line after the base:
|
|
7
|
+
* a pull request by the merge record `_merges/<sha>.json` on
|
|
8
|
+
* `chant/lifecycle` (./merge-record.ts) or the squash subject `... (#n)`,
|
|
9
|
+
* a work item by the commit's `Chant-Record: work:<id>` trailers or a merge
|
|
10
|
+
* record that names it. Each commit is cherry-picked in trunk order onto the
|
|
11
|
+
* base, in a temporary detached worktree, with a `Chant-Composed-From: <sha>`
|
|
12
|
+
* trailer, and the result goes on the branch `chant/compose/<name>`, pushed
|
|
13
|
+
* so the forge's CI builds it (decision 6).
|
|
14
|
+
*
|
|
15
|
+
* It only appends. A branch that exists is composed onto from its tip, with
|
|
16
|
+
* the base it already has; a change it already carries is skipped. On a
|
|
17
|
+
* conflict it stops, names the change and the files, and writes nothing: a
|
|
18
|
+
* person resolves it in a pull request to the compose branch.
|
|
19
|
+
*
|
|
20
|
+
* Without `yes` it is a dry run: the picks are made in the temporary
|
|
21
|
+
* worktree, so a conflict shows now, and no ref is moved or pushed.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
25
|
+
import { mkdtempSync, realpathSync, rmSync } from "node:fs";
|
|
26
|
+
import { tmpdir } from "node:os";
|
|
27
|
+
import { join } from "node:path";
|
|
28
|
+
import { COMPOSE_BRANCH_PREFIX, COMPOSED_FROM_TRAILER, compositionOnTrunk } from "./compose-refs";
|
|
29
|
+
|
|
30
|
+
export type ComposeCode =
|
|
31
|
+
| "not-a-git-repository"
|
|
32
|
+
| "compose-name-invalid"
|
|
33
|
+
| "compose-nothing-chosen"
|
|
34
|
+
| "compose-base-unknown"
|
|
35
|
+
| "compose-base-differs"
|
|
36
|
+
| "compose-branch-diverged"
|
|
37
|
+
| "compose-source-unknown"
|
|
38
|
+
| "compose-conflict"
|
|
39
|
+
| "compose-push-refused";
|
|
40
|
+
|
|
41
|
+
export class ComposeError extends Error {
|
|
42
|
+
constructor(
|
|
43
|
+
readonly code: ComposeCode,
|
|
44
|
+
message: string,
|
|
45
|
+
readonly fix?: string,
|
|
46
|
+
/** With `compose-conflict`: the change that did not apply and the files it conflicts in. */
|
|
47
|
+
readonly conflict?: { commit: string; subject: string; files: string[] },
|
|
48
|
+
) {
|
|
49
|
+
super(message);
|
|
50
|
+
this.name = "ComposeError";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ComposeRequest {
|
|
55
|
+
cwd: string;
|
|
56
|
+
name: string;
|
|
57
|
+
/** A release label or digest, or a commit. Required when the branch does not exist yet. */
|
|
58
|
+
base?: string;
|
|
59
|
+
prs: readonly number[];
|
|
60
|
+
works: readonly string[];
|
|
61
|
+
/** Move the branch and push it. Otherwise a dry run. */
|
|
62
|
+
yes: boolean;
|
|
63
|
+
/** The trunk branch, `ci.green.branch` as the cut resolves it. */
|
|
64
|
+
trunk: { remote: string; branch: string };
|
|
65
|
+
/** Push the branch to the trunk's remote with `yes`. Default true. */
|
|
66
|
+
push?: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** One trunk commit a compose picks, and why. */
|
|
70
|
+
export interface ComposeSource {
|
|
71
|
+
commit: string;
|
|
72
|
+
subject: string;
|
|
73
|
+
pr: number | null;
|
|
74
|
+
work: string[];
|
|
75
|
+
/** The `--pr` and `--work` values that chose it. */
|
|
76
|
+
chosenBy: string[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface ComposeResult {
|
|
80
|
+
name: string;
|
|
81
|
+
branch: string;
|
|
82
|
+
base: string;
|
|
83
|
+
/** The branch's tip before the compose, or null when it is new. */
|
|
84
|
+
from: string | null;
|
|
85
|
+
/** The commits picked now, in trunk order. */
|
|
86
|
+
picked: ComposeSource[];
|
|
87
|
+
/** Chosen commits the branch already carries. */
|
|
88
|
+
already: ComposeSource[];
|
|
89
|
+
/** The branch's tip after the picks. Under a dry run, the tip it would have. */
|
|
90
|
+
tip: string;
|
|
91
|
+
/** Whether the branch was moved here. False under a dry run. */
|
|
92
|
+
written: boolean;
|
|
93
|
+
/** Whether it was pushed. */
|
|
94
|
+
pushed: boolean;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
98
|
+
const PR_IN_SUBJECT = /\(#(\d+)\)\s*$/;
|
|
99
|
+
|
|
100
|
+
function git(cwd: string, args: string[], env?: NodeJS.ProcessEnv): string {
|
|
101
|
+
return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 64 * 1024 * 1024, ...(env ? { env } : {}) }).trim();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function tryGit(cwd: string, args: string[]): string | null {
|
|
105
|
+
try {
|
|
106
|
+
return git(cwd, args);
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const isAncestor = (top: string, a: string, b: string): boolean => spawnSync("git", ["merge-base", "--is-ancestor", a, b], { cwd: top, stdio: "ignore" }).status === 0;
|
|
113
|
+
|
|
114
|
+
/** The branch a compose name lives on. */
|
|
115
|
+
export function composeBranchName(name: string): string {
|
|
116
|
+
return `${COMPOSE_BRANCH_PREFIX}${name}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function resolveBase(top: string, cwd: string, ref: string): Promise<string> {
|
|
120
|
+
const { resolveRelease } = await import("./release-store");
|
|
121
|
+
const found = await resolveRelease(ref, { cwd }).catch(() => null);
|
|
122
|
+
if (found) return found.release.commit;
|
|
123
|
+
const sha = tryGit(top, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
|
|
124
|
+
if (!sha) {
|
|
125
|
+
throw new ComposeError("compose-base-unknown", `--base ${ref} names no release of this member and no commit this clone has`, "Name a release label (chant release list) or a commit, after fetching it.");
|
|
126
|
+
}
|
|
127
|
+
return sha;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The merge record of `sha`, as stored, or null. */
|
|
131
|
+
async function mergeRecordOf(cwd: string, sha: string): Promise<Record<string, unknown> | null> {
|
|
132
|
+
const { readBlobFromPath } = await import("./git");
|
|
133
|
+
const text = await readBlobFromPath("_merges", `${sha}.json`, { cwd }).catch(() => null);
|
|
134
|
+
if (text === null) return null;
|
|
135
|
+
try {
|
|
136
|
+
const parsed = JSON.parse(text) as unknown;
|
|
137
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : null;
|
|
138
|
+
} catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The pull request and work items a merge record names. */
|
|
144
|
+
function fromMergeRecord(merge: Record<string, unknown> | null): { pr: number | null; work: string[] } {
|
|
145
|
+
if (!merge) return { pr: null, work: [] };
|
|
146
|
+
const source = merge.source as { kind?: unknown; pr?: unknown; records?: unknown } | undefined;
|
|
147
|
+
const pr = source?.kind === "pull-request" && typeof source.pr === "number" ? source.pr : typeof merge.pr === "number" ? merge.pr : null;
|
|
148
|
+
const work =
|
|
149
|
+
source?.kind === "work-item" && Array.isArray(source.records)
|
|
150
|
+
? source.records.filter((r): r is { kind: string; id: string } => !!r && typeof r === "object" && (r as { kind?: unknown }).kind === "work").map((r) => r.id)
|
|
151
|
+
: [];
|
|
152
|
+
return { pr, work };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Compose the chosen changes onto the base, on `chant/compose/<name>`.
|
|
157
|
+
* Throws {@link ComposeError} before any ref moves.
|
|
158
|
+
*/
|
|
159
|
+
export async function composeRelease(req: ComposeRequest): Promise<ComposeResult> {
|
|
160
|
+
const top = tryGit(req.cwd, ["rev-parse", "--show-toplevel"]);
|
|
161
|
+
if (!top) throw new ComposeError("not-a-git-repository", "a compose picks commits, and this directory is not in a git repository");
|
|
162
|
+
if (!NAME.test(req.name)) {
|
|
163
|
+
throw new ComposeError("compose-name-invalid", `"${req.name}" can't name a compose branch: use letters, digits, dots, dashes and underscores, starting with a letter or digit`);
|
|
164
|
+
}
|
|
165
|
+
if (req.prs.length === 0 && req.works.length === 0) {
|
|
166
|
+
throw new ComposeError("compose-nothing-chosen", "a compose needs the changes it carries", "Name them: --pr <n> or --work <id>, each as often as needed.");
|
|
167
|
+
}
|
|
168
|
+
const { remote } = req.trunk;
|
|
169
|
+
const branch = composeBranchName(req.name);
|
|
170
|
+
const remoteRef = `refs/remotes/${remote}/${branch}`;
|
|
171
|
+
const localRef = `refs/heads/${branch}`;
|
|
172
|
+
// The branch as the remote has it, when the remote is reachable; a new branch is absent there.
|
|
173
|
+
if (tryGit(top, ["remote", "get-url", remote]) !== null) tryGit(top, ["fetch", "--quiet", "--no-tags", remote, `+${localRef}:${remoteRef}`]);
|
|
174
|
+
const remoteTip = tryGit(top, ["rev-parse", "--verify", "--quiet", `${remoteRef}^{commit}`]);
|
|
175
|
+
const localTip = tryGit(top, ["rev-parse", "--verify", "--quiet", `${localRef}^{commit}`]);
|
|
176
|
+
if (remoteTip && localTip && !isAncestor(top, localTip, remoteTip) && !isAncestor(top, remoteTip, localTip)) {
|
|
177
|
+
throw new ComposeError(
|
|
178
|
+
"compose-branch-diverged",
|
|
179
|
+
`${branch} here and on ${remote} have diverged, and a compose only appends`,
|
|
180
|
+
`Keep the remote's: git branch -f ${branch} ${remote}/${branch}`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
const from = remoteTip && localTip ? (isAncestor(top, localTip, remoteTip) ? remoteTip : localTip) : (remoteTip ?? localTip);
|
|
184
|
+
const trunkRef =
|
|
185
|
+
tryGit(top, ["rev-parse", "--verify", "--quiet", `refs/remotes/${remote}/${req.trunk.branch}^{commit}`]) ??
|
|
186
|
+
tryGit(top, ["rev-parse", "--verify", "--quiet", `refs/heads/${req.trunk.branch}^{commit}`]);
|
|
187
|
+
if (!trunkRef) throw new ComposeError("compose-source-unknown", `this clone has neither ${remote}/${req.trunk.branch} nor ${req.trunk.branch}, so there is no trunk to pick from`);
|
|
188
|
+
// What the branch carries: back to trunk, past a commit a person made on it, such as a resolved conflict.
|
|
189
|
+
const existing = from ? compositionOnTrunk(top, from, trunkRef, branch) : null;
|
|
190
|
+
|
|
191
|
+
let base: string;
|
|
192
|
+
if (from) {
|
|
193
|
+
base = existing?.base ?? from;
|
|
194
|
+
if (req.base !== undefined) {
|
|
195
|
+
const asked = await resolveBase(top, req.cwd, req.base);
|
|
196
|
+
if (asked !== base) {
|
|
197
|
+
throw new ComposeError(
|
|
198
|
+
"compose-base-differs",
|
|
199
|
+
`${branch} is composed on ${base.slice(0, 12)}, and --base names ${asked.slice(0, 12)}; a compose only appends to its branch`,
|
|
200
|
+
`Drop --base to add to ${branch}, or compose under another name.`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
} else {
|
|
205
|
+
if (req.base === undefined) throw new ComposeError("compose-base-unknown", `${branch} does not exist yet, so the compose needs --base`, "Name the release or commit to compose on: --base <label|sha>");
|
|
206
|
+
base = await resolveBase(top, req.cwd, req.base);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Trunk's first-parent commits after the base, oldest first.
|
|
210
|
+
const line = (tryGit(top, ["rev-list", "--first-parent", "--reverse", trunkRef, `^${base}`]) ?? "").split("\n").filter(Boolean);
|
|
211
|
+
const { commitDetails } = await import("../workspace/intent");
|
|
212
|
+
const { recordTrailers } = await import("../workspace/release-candidates");
|
|
213
|
+
const details = commitDetails(top, line);
|
|
214
|
+
|
|
215
|
+
const chosen = new Map<string, ComposeSource>();
|
|
216
|
+
const choose = (sha: string, by: string, pr: number | null, work: string[]) => {
|
|
217
|
+
const entry = chosen.get(sha) ?? { commit: sha, subject: details.get(sha)?.subject ?? "", pr, work, chosenBy: [] };
|
|
218
|
+
if (!entry.chosenBy.includes(by)) entry.chosenBy.push(by);
|
|
219
|
+
chosen.set(sha, entry);
|
|
220
|
+
};
|
|
221
|
+
const unmatched = new Set<string>([...req.prs.map((n) => `--pr ${n}`), ...req.works.map((w) => `--work ${w}`)]);
|
|
222
|
+
for (const sha of line) {
|
|
223
|
+
const d = details.get(sha);
|
|
224
|
+
const merge = fromMergeRecord(await mergeRecordOf(req.cwd, sha));
|
|
225
|
+
const subjectPr = PR_IN_SUBJECT.exec(d?.subject ?? "");
|
|
226
|
+
const pr = merge.pr ?? (subjectPr ? Number(subjectPr[1]) : null);
|
|
227
|
+
const work = [...new Set([...recordTrailers(d?.trailers ?? {}).work, ...merge.work])];
|
|
228
|
+
for (const n of req.prs) {
|
|
229
|
+
if (pr === n) {
|
|
230
|
+
choose(sha, `--pr ${n}`, pr, work);
|
|
231
|
+
unmatched.delete(`--pr ${n}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
for (const w of req.works) {
|
|
235
|
+
if (work.includes(w)) {
|
|
236
|
+
choose(sha, `--work ${w}`, pr, work);
|
|
237
|
+
unmatched.delete(`--work ${w}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (unmatched.size > 0) {
|
|
242
|
+
throw new ComposeError(
|
|
243
|
+
"compose-source-unknown",
|
|
244
|
+
`${[...unmatched].join(", ")} names no commit on ${remote}/${req.trunk.branch} after ${base.slice(0, 12)}`,
|
|
245
|
+
"A pull request is found by its merge record or its squash subject (#n), and a work item by a Chant-Record: work:<id> trailer. Fetch trunk and chant/lifecycle if it merged elsewhere.",
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Trunk order, and what the branch already carries.
|
|
250
|
+
const carried = new Set(existing?.sources ?? []);
|
|
251
|
+
const ordered = line.filter((sha) => chosen.has(sha)).map((sha) => chosen.get(sha)!);
|
|
252
|
+
const already = ordered.filter((s) => carried.has(s.commit));
|
|
253
|
+
const picked = ordered.filter((s) => !carried.has(s.commit));
|
|
254
|
+
|
|
255
|
+
const tip = picked.length === 0 ? (from ?? base) : pickOnto(top, from ?? base, picked);
|
|
256
|
+
let written = false;
|
|
257
|
+
let pushed = false;
|
|
258
|
+
if (req.yes && picked.length > 0) {
|
|
259
|
+
git(top, ["update-ref", "-m", `chant release compose ${req.name}`, localRef, tip, localTip ?? ""]);
|
|
260
|
+
written = true;
|
|
261
|
+
}
|
|
262
|
+
if (req.yes && (req.push ?? true) && tryGit(top, ["remote", "get-url", remote]) !== null && tip !== remoteTip) {
|
|
263
|
+
try {
|
|
264
|
+
git(top, ["push", "--quiet", remote, `${tip}:${localRef}`]);
|
|
265
|
+
tryGit(top, ["update-ref", remoteRef, tip]);
|
|
266
|
+
pushed = true;
|
|
267
|
+
} catch (err) {
|
|
268
|
+
const stderr = (err as { stderr?: Buffer | string }).stderr?.toString().trim() ?? String(err);
|
|
269
|
+
throw new ComposeError("compose-push-refused", `${branch} was composed here at ${tip.slice(0, 12)} and ${remote} refused the push: ${stderr}`, `Push it by hand once the remote allows it: git push ${remote} ${branch}`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return { name: req.name, branch, base, from, picked, already, tip, written, pushed };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Cherry-pick `sources` in order onto `start` in a temporary detached
|
|
277
|
+
* worktree, each with its trailer, and return the last commit. Throws
|
|
278
|
+
* `compose-conflict` naming the change and its files when one does not
|
|
279
|
+
* apply. The worktree is removed either way.
|
|
280
|
+
*/
|
|
281
|
+
function pickOnto(top: string, start: string, sources: readonly ComposeSource[]): string {
|
|
282
|
+
const realTop = realpathSync(top);
|
|
283
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-compose-"));
|
|
284
|
+
git(realTop, ["worktree", "add", "--detach", "--quiet", dir, start]);
|
|
285
|
+
try {
|
|
286
|
+
for (const s of sources) {
|
|
287
|
+
const parents = (tryGit(dir, ["rev-list", "--parents", "-n", "1", s.commit]) ?? "").split(" ").length - 1;
|
|
288
|
+
const args = ["cherry-pick", "--allow-empty", "--keep-redundant-commits", ...(parents > 1 ? ["-m", "1"] : []), s.commit];
|
|
289
|
+
const run = spawnSync("git", args, { cwd: dir, encoding: "utf-8" });
|
|
290
|
+
if (run.status !== 0) {
|
|
291
|
+
const files = (tryGit(dir, ["diff", "--name-only", "--diff-filter=U"]) ?? "").split("\n").filter(Boolean);
|
|
292
|
+
tryGit(dir, ["cherry-pick", "--abort"]);
|
|
293
|
+
const what = [s.pr !== null ? `#${s.pr}` : null, ...s.work.map((w) => `work:${w}`)].filter(Boolean).join(", ");
|
|
294
|
+
throw new ComposeError(
|
|
295
|
+
"compose-conflict",
|
|
296
|
+
`${s.commit.slice(0, 12)} (${s.subject}${what ? `; ${what}` : ""}) does not apply on the composed commits${files.length > 0 ? `: it conflicts in ${files.join(", ")}` : `: ${(run.stderr || run.stdout).trim()}`}. Nothing was written`,
|
|
297
|
+
"Resolve it by hand in a pull request to the compose branch, or compose without that change.",
|
|
298
|
+
{ commit: s.commit, subject: s.subject, files },
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
git(dir, ["commit", "--quiet", "--amend", "--no-edit", "--allow-empty", "--no-verify", "--trailer", `${COMPOSED_FROM_TRAILER}: ${s.commit}`]);
|
|
302
|
+
}
|
|
303
|
+
return git(dir, ["rev-parse", "HEAD"]);
|
|
304
|
+
} finally {
|
|
305
|
+
try {
|
|
306
|
+
git(realTop, ["worktree", "remove", "--force", dir]);
|
|
307
|
+
} catch {
|
|
308
|
+
rmSync(dir, { recursive: true, force: true });
|
|
309
|
+
tryGit(realTop, ["worktree", "prune"]);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
@@ -102,6 +102,10 @@ export interface CutRequest {
|
|
|
102
102
|
* must be the principal the signers file lists the key for.
|
|
103
103
|
*/
|
|
104
104
|
sign?: string | true;
|
|
105
|
+
/** The composition of a composed commit (#4168 4.3), recorded on the cut. */
|
|
106
|
+
composed?: { branch: string; base: string; sources: string[] };
|
|
107
|
+
/** The publish channel recorded on the cut (#4168 4.3), in place of `latest`. */
|
|
108
|
+
channel?: string;
|
|
105
109
|
}
|
|
106
110
|
|
|
107
111
|
export interface CutResult {
|
|
@@ -501,6 +505,8 @@ export async function cutRelease(req: CutRequest): Promise<CutResult> {
|
|
|
501
505
|
runId: ci?.runId ?? `local-${Date.parse(timestamp)}`,
|
|
502
506
|
runOrigin: ci?.runOrigin ?? { forge: "local" },
|
|
503
507
|
signature,
|
|
508
|
+
...(req.composed ? { composed: req.composed } : {}),
|
|
509
|
+
...(req.channel ? { channel: req.channel } : {}),
|
|
504
510
|
},
|
|
505
511
|
{ cwd },
|
|
506
512
|
);
|
|
@@ -278,6 +278,8 @@ export interface PublishPlan {
|
|
|
278
278
|
redactor: Redactor;
|
|
279
279
|
/** Whether a target with no SBOM source is refused (`publish-sbom-missing`): the workspace opts into `supply-chain` (#4168). Otherwise it publishes without one. */
|
|
280
280
|
requireSbom: boolean;
|
|
281
|
+
/** The channel a cut of the release names (#4168 4.3), the tag moved in place of `latest`, or null. */
|
|
282
|
+
channel: string | null;
|
|
281
283
|
}
|
|
282
284
|
|
|
283
285
|
/**
|
|
@@ -399,7 +401,8 @@ export async function preparePublish(req: {
|
|
|
399
401
|
const member = ledger.members.length > 0 ? ledger.members.join("/") : null;
|
|
400
402
|
const releaseTag = label !== null ? releaseTagName(member, label) : null;
|
|
401
403
|
const redactor = createRedactor(env, targets.flatMap((t) => t.credentialEnv));
|
|
402
|
-
|
|
404
|
+
const channel = [...cuts].reverse().find((c) => c.channel)?.channel ?? null;
|
|
405
|
+
return { release, label, signature, provenance, warning, signatureWarning, cut, releaseTag, targets, redactor, requireSbom: groups.has("supply-chain"), channel };
|
|
403
406
|
}
|
|
404
407
|
|
|
405
408
|
/**
|
|
@@ -149,6 +149,17 @@ export interface ReleaseCut {
|
|
|
149
149
|
* unsigned cut, and on every cut written before 0.124.28.
|
|
150
150
|
*/
|
|
151
151
|
signature: string | null;
|
|
152
|
+
/**
|
|
153
|
+
* A cut of a composed commit (#4168 4.3): its compose branch, the base it
|
|
154
|
+
* was composed on, and the trunk commits picked onto the base, oldest
|
|
155
|
+
* first. Absent on a cut of a trunk commit.
|
|
156
|
+
*/
|
|
157
|
+
composed?: { branch: string; base: string; sources: string[] };
|
|
158
|
+
/**
|
|
159
|
+
* The channel the release publishes to (#4168 4.3): the npm dist-tag or
|
|
160
|
+
* image tag a publish moves in place of `latest`. Absent for `latest`.
|
|
161
|
+
*/
|
|
162
|
+
channel?: string;
|
|
152
163
|
}
|
|
153
164
|
|
|
154
165
|
/** The parts of a release a digest is computed from. */
|
|
@@ -364,7 +375,9 @@ export function parseCuts(text: string | null): { cuts: ReleaseCut[]; malformed:
|
|
|
364
375
|
typeof c.timestamp === "string" &&
|
|
365
376
|
(c.runOrigin === null || (typeof c.runOrigin === "object" && typeof c.runOrigin?.forge === "string")) &&
|
|
366
377
|
(c.signature === null || c.signature === undefined || typeof c.signature === "string") &&
|
|
367
|
-
(c.runId === null || c.runId === undefined || typeof c.runId === "string")
|
|
378
|
+
(c.runId === null || c.runId === undefined || typeof c.runId === "string") &&
|
|
379
|
+
(c.channel === undefined || typeof c.channel === "string") &&
|
|
380
|
+
(c.composed === undefined || (typeof c.composed === "object" && c.composed !== null && typeof c.composed.base === "string" && Array.isArray(c.composed.sources)));
|
|
368
381
|
if (!ok) {
|
|
369
382
|
malformed++;
|
|
370
383
|
continue;
|
|
@@ -444,6 +457,8 @@ export async function appendCut(
|
|
|
444
457
|
runId: input.runId ?? null,
|
|
445
458
|
runOrigin: input.runOrigin,
|
|
446
459
|
signature: input.signature ?? null,
|
|
460
|
+
...(input.composed ? { composed: input.composed } : {}),
|
|
461
|
+
...(input.channel ? { channel: input.channel } : {}),
|
|
447
462
|
};
|
|
448
463
|
const content = `${existing ? `${existing.replace(/\n$/, "")}\n` : ""}${JSON.stringify(cut, sortedJsonReplacer)}\n`;
|
|
449
464
|
try {
|
|
@@ -47,6 +47,8 @@ const PIPELINE = [
|
|
|
47
47
|
" - run: chant ci run record --in-place --env prod --gate pr-apply --forge github",
|
|
48
48
|
"",
|
|
49
49
|
].join("\n");
|
|
50
|
+
/** #4168 step 9: under apply on merge a merge is recorded, and the apply applies the newest green commit, with no push base. */
|
|
51
|
+
const APPLY_PIPELINE = `${PIPELINE} apply:\n steps:\n - run: chant ci run apply --in-place --env prod --gate pr-apply --forge github\n`;
|
|
50
52
|
const TICK = "jobs:\n tick:\n steps:\n - run: npx --yes @intentius/chant@0.124.33 ci tick\n";
|
|
51
53
|
const PUBLISH = "jobs:\n publish:\n steps:\n - run: npx chant release publish \"$LABEL\"\n";
|
|
52
54
|
|
|
@@ -100,14 +102,22 @@ describe("ci.workflow (WSP151)", () => {
|
|
|
100
102
|
});
|
|
101
103
|
|
|
102
104
|
test("trunk-curated with an apply job on merge differs", async () => {
|
|
103
|
-
const root = repo(complete({ ".github/workflows/chant-pr.yml":
|
|
105
|
+
const root = repo(complete({ ".github/workflows/chant-pr.yml": APPLY_PIPELINE }));
|
|
104
106
|
expect(await found(root)).toEqual([["ci-workflow-pipeline-differs", null]]);
|
|
105
107
|
});
|
|
106
108
|
|
|
107
109
|
test("trunk-curated+apply-on-merge with only a record job differs, and with an apply job passes", async () => {
|
|
108
110
|
const decl = declaration({ ci: { workflow: "trunk-curated+apply-on-merge", green: green() } });
|
|
109
111
|
expect(await found(repo(complete({ "chant.workspace.json": decl })))).toEqual([["ci-workflow-pipeline-differs", null]]);
|
|
110
|
-
expect(await found(repo(complete({ "chant.workspace.json": decl, ".github/workflows/chant-pr.yml":
|
|
112
|
+
expect(await found(repo(complete({ "chant.workspace.json": decl, ".github/workflows/chant-pr.yml": APPLY_PIPELINE })))).toEqual([]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("trunk-curated+apply-on-merge: an apply of each merge differs, and a pipeline with no record job is missing it (#4168 step 9)", async () => {
|
|
116
|
+
const decl = declaration({ ci: { workflow: "trunk-curated+apply-on-merge", green: green() } });
|
|
117
|
+
const perMerge = APPLY_PIPELINE.replace("ci run apply --in-place", 'ci run apply --in-place --base "$BASE_SHA"');
|
|
118
|
+
expect(await found(repo(complete({ "chant.workspace.json": decl, ".github/workflows/chant-pr.yml": perMerge })))).toEqual([["ci-workflow-pipeline-differs", null]]);
|
|
119
|
+
const noRecord = PIPELINE.replace("ci run record", "ci run apply");
|
|
120
|
+
expect(await found(repo(complete({ "chant.workspace.json": decl, ".github/workflows/chant-pr.yml": noRecord })))).toEqual([["ci-workflow-pipeline-missing", null]]);
|
|
111
121
|
});
|
|
112
122
|
|
|
113
123
|
test("a member's own workflow wins, and a top-level workflow skips members that don't build with chant", async () => {
|
|
@@ -106,7 +106,8 @@ export const CI_WORKFLOW_CHECKS: readonly WorkspaceCheck[] = [
|
|
|
106
106
|
const applyOnMerge = under.some((u) => appliesOnMerge(u.workflow)) || (d.ci?.workflow ? appliesOnMerge(d.ci.workflow) : false);
|
|
107
107
|
const missing = [
|
|
108
108
|
...(has(PLAN) ? [] : ["a plan job (ci run plan)"]),
|
|
109
|
-
|
|
109
|
+
// #4168 step 9: every merge is recorded, apply on merge or not, since the apply reads the merge records.
|
|
110
|
+
...(has(RECORD) ? [] : ["a record job (ci run record)"]),
|
|
110
111
|
...(has(TICK) ? [] : ["chant ci tick"]),
|
|
111
112
|
];
|
|
112
113
|
if (missing.length > 0) {
|
|
@@ -119,6 +120,11 @@ export const CI_WORKFLOW_CHECKS: readonly WorkspaceCheck[] = [
|
|
|
119
120
|
} else if (!has(APPLY) && has(RECORD)) {
|
|
120
121
|
const l = commandLines(ctx, RECORD)[0]!;
|
|
121
122
|
finding("ci-workflow-pipeline-differs", `${l.path} records on merge, and the workflow is trunk-curated+apply-on-merge; regenerate with --apply-on-merge`, { treeFile: l.path, line: l.line });
|
|
123
|
+
} else {
|
|
124
|
+
// #4168 step 9: an apply measured from a push's base applies each merge; the workflow applies green commits.
|
|
125
|
+
for (const l of commandLines(ctx, APPLY).filter((c) => /--base\b/.test(c.text) || (/\bcomponents\s+pr-apply\b/.test(c.text) && !/--green\b/.test(c.text)))) {
|
|
126
|
+
finding("ci-workflow-pipeline-differs", `${l.path} applies each merge, and under trunk-curated+apply-on-merge the apply applies the newest green commit; regenerate with --apply-on-merge`, { treeFile: l.path, line: l.line });
|
|
127
|
+
}
|
|
122
128
|
}
|
|
123
129
|
if (!has(PUBLISH)) {
|
|
124
130
|
finding("ci-workflow-publish-missing", "no pipeline file runs chant release publish, so a cut release is never published; generate one with chant release workflow");
|
|
@@ -154,7 +154,9 @@ export async function runCiWorkflow(ctx: CommandContext): Promise<number> {
|
|
|
154
154
|
const sources = readWorkflowSources(repoRoot, fromRepo);
|
|
155
155
|
const scan = workflowsHolding(green, sources);
|
|
156
156
|
// #4169: the triggers and concurrency of the workflows holding a phase that sets build. Refused before anything is written.
|
|
157
|
-
|
|
157
|
+
// #4220: under a ci.workflow, composed releases' branches run the phases and tick too.
|
|
158
|
+
const compose = declaration.ci?.workflow !== undefined || declaration.members.some((m) => m.ciWorkflow !== null);
|
|
159
|
+
const edits = buildTriggerEdits(green, sources, { compose });
|
|
158
160
|
const extra = ctx.args.ciWorkflows ?? [];
|
|
159
161
|
const workflows = [...new Set([...scan.workflows, ...extra])].sort();
|
|
160
162
|
for (const f of scan.unreadable) console.error(`warning: ${f} is not valid YAML, so its jobs were not read`);
|
|
@@ -180,10 +182,10 @@ export async function runCiWorkflow(ctx: CommandContext): Promise<number> {
|
|
|
180
182
|
const workspaceRoot = relative(repoRoot, root).split(sep).join("/") || ".";
|
|
181
183
|
// The install runs in the workspace root, so its lockfile there keys setup-node's npm cache.
|
|
182
184
|
const npmLock = ctx.args.ciInstall && existsSync(join(root, "package-lock.json")) ? (workspaceRoot === "." ? "package-lock.json" : `${workspaceRoot}/package-lock.json`) : undefined;
|
|
183
|
-
const yaml = renderCiGreenWorkflow({ green, workflows, chantVersion: readerVersion(), workspaceRoot, chant: ctx.args.ciChant, install: ctx.args.ciInstall, npmLock, push, ...(runnerKeySecret ? { runnerKeySecret } : {}) });
|
|
185
|
+
const yaml = renderCiGreenWorkflow({ green, workflows, chantVersion: readerVersion(), workspaceRoot, chant: ctx.args.ciChant, install: ctx.args.ciInstall, npmLock, push, ...(runnerKeySecret ? { runnerKeySecret } : {}), ...(compose ? { compose } : {}) });
|
|
184
186
|
const written = writeWorkflowFile(target, ciGreenHeader(command, member ? "member" : "root") + yaml);
|
|
185
187
|
if (member) recordGeneratedFiles(member.memberRoot, [{ path: fromRepo, command }]);
|
|
186
|
-
console.log(`Wrote ${written}: ticks when ${workflows.join(", ")} complete${workflows.length === 1 ? "s" : ""} on ${green.branch}, and every 15 minutes.`);
|
|
188
|
+
console.log(`Wrote ${written}: ticks when ${workflows.join(", ")} complete${workflows.length === 1 ? "s" : ""} on ${green.branch}${compose ? " or a chant/compose/ branch" : ""}, and every 15 minutes.`);
|
|
187
189
|
for (const e of edits) {
|
|
188
190
|
if (!e.changed) continue;
|
|
189
191
|
const path = writeWorkflowFile(join(repoRoot, e.path), e.text);
|
|
@@ -381,6 +381,58 @@ describe("the triggers of a phase that sets build (#4169)", () => {
|
|
|
381
381
|
expect(before.on.workflow_run.branches).toEqual(["main"]);
|
|
382
382
|
});
|
|
383
383
|
|
|
384
|
+
test("under a ci.workflow, both builds run on chant/compose/** pushes, a compose branch's every runs are keyed by commit, and the tick ticks on them (#4220)", () => {
|
|
385
|
+
const sources = [
|
|
386
|
+
{ path: ".github/workflows/ci.yml", text: LINT_WF },
|
|
387
|
+
{ path: ".github/workflows/suites.yml", text: SUITES_WF },
|
|
388
|
+
];
|
|
389
|
+
const edits = buildTriggerEdits(green(), sources, { compose: true });
|
|
390
|
+
const ci = parseYAMLDocument(edits[0].text) as Yaml;
|
|
391
|
+
expect(ci.on).toEqual({ push: { branches: ["main", "chant/compose/**"] }, pull_request: null });
|
|
392
|
+
expect(ci.concurrency).toEqual({
|
|
393
|
+
group: "chant-build-lint-${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/chant/compose/')) && github.sha || github.ref }}",
|
|
394
|
+
"cancel-in-progress": "${{ !(github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/chant/compose/')) }}",
|
|
395
|
+
});
|
|
396
|
+
const suites = parseYAMLDocument(edits[1].text) as Yaml;
|
|
397
|
+
expect(suites.on).toEqual({ push: { branches: ["main", "chant/compose/**"] }, workflow_dispatch: {} });
|
|
398
|
+
// Writing again changes nothing.
|
|
399
|
+
expect(buildTriggerEdits(green(), edits.map((e) => ({ path: e.path, text: e.text })), { compose: true }).map((e) => e.changed)).toEqual([false, false]);
|
|
400
|
+
// A push with no branch list already runs on every branch, so its triggers stay.
|
|
401
|
+
const anyBranch = LINT_WF.replace(" push:\n branches: [main]\n", " push:\n");
|
|
402
|
+
expect((parseYAMLDocument(buildTriggerEdits(green(), [{ path: ".github/workflows/ci.yml", text: anyBranch }], { compose: true })[0].text) as Yaml).on).toEqual({ push: null, pull_request: null });
|
|
403
|
+
// Without compose the bytes are what #4169 wrote.
|
|
404
|
+
expect(buildTriggerEdits(green(), sources).map((e) => e.text)).toEqual(buildTriggerEdits(green(), sources, { compose: false }).map((e) => e.text));
|
|
405
|
+
const tick = parseYAMLDocument(renderCiGreenWorkflow({ green: green(), workflows: ["ci"], chantVersion: "0.124.33", workspaceRoot: ".", compose: true })) as Yaml;
|
|
406
|
+
expect(tick.on.workflow_run.branches).toEqual(["main", "ci/build/**", "chant/compose/**"]);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test("chant ci workflow writes the compose triggers only when the declaration names a ci.workflow (#4220)", async () => {
|
|
410
|
+
const run = async (ci: Record<string, unknown>) => {
|
|
411
|
+
const root = scratchDir();
|
|
412
|
+
git(root, "init", "-q");
|
|
413
|
+
writeFiles(root, {
|
|
414
|
+
"chant.workspace.json": declaration([{ name: "root", dir: ".", kind: "other", because: "x" }], { ci }),
|
|
415
|
+
".github/workflows/ci.yml": LINT_WF,
|
|
416
|
+
});
|
|
417
|
+
const cwd = vi.spyOn(process, "cwd").mockReturnValue(root);
|
|
418
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
419
|
+
try {
|
|
420
|
+
expect(await runCiWorkflow({ args: {} } as unknown as CommandContext)).toBe(0);
|
|
421
|
+
return { tick: readFileSync(join(root, CI_GREEN_WORKFLOW_FILE), "utf-8"), ci: readFileSync(join(root, ".github/workflows/ci.yml"), "utf-8") };
|
|
422
|
+
} finally {
|
|
423
|
+
cwd.mockRestore();
|
|
424
|
+
log.mockRestore();
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
const lint = { branch: "main", phases: { lint: { runs: ["lint"], build: "every" } }, require: ["lint"] };
|
|
428
|
+
const without = await run({ green: lint });
|
|
429
|
+
expect(without.tick).not.toContain("chant/compose");
|
|
430
|
+
expect(without.ci).not.toContain("chant/compose");
|
|
431
|
+
const curated = await run({ green: lint, workflow: "trunk-curated" });
|
|
432
|
+
expect(curated.tick).toContain("chant/compose/**");
|
|
433
|
+
expect(curated.ci).toContain("chant/compose/**");
|
|
434
|
+
});
|
|
435
|
+
|
|
384
436
|
test("the workflows a build request can start are the ones holding its phases with workflow_dispatch", () => {
|
|
385
437
|
expect(dispatchableWorkflows(green(), ["lint", "suites"], [{ path: ".github/workflows/ci.yml", text: LINT_WF }, { path: ".github/workflows/suites.yml", text: SUITES_WF }])).toEqual({ dispatchable: ["suites.yml"], without: ["ci.yml"] });
|
|
386
438
|
});
|
|
@@ -52,6 +52,7 @@ import { emitYAMLEntry, parseYAMLDocument } from "../yaml";
|
|
|
52
52
|
import type { CiBuild, CiGreen } from "./declaration";
|
|
53
53
|
import { matchesCheckRun } from "./ci-green";
|
|
54
54
|
import { githubSecretOrJobToken } from "../ci-jobs";
|
|
55
|
+
import { COMPOSE_BRANCH_PREFIX } from "../lifecycle/compose-refs";
|
|
55
56
|
|
|
56
57
|
/** Where the workflow goes, relative to the repository root. */
|
|
57
58
|
export const CI_GREEN_WORKFLOW_FILE = ".github/workflows/chant-ci-green.yml";
|
|
@@ -194,8 +195,17 @@ export interface WorkflowInput {
|
|
|
194
195
|
* The key's principal needs the `runner` role in `.chant/trust.json`.
|
|
195
196
|
*/
|
|
196
197
|
runnerKeySecret?: string;
|
|
198
|
+
/**
|
|
199
|
+
* The workspace declares a `ci.workflow` (#4220), so composed releases
|
|
200
|
+
* live on `chant/compose/<name>` branches (#4168 4.3): the tick also runs
|
|
201
|
+
* when a phase workflow completes on one of them.
|
|
202
|
+
*/
|
|
203
|
+
compose?: boolean;
|
|
197
204
|
}
|
|
198
205
|
|
|
206
|
+
/** The branch filter for composed releases' branches (#4220). */
|
|
207
|
+
export const COMPOSE_BRANCHES = `${COMPOSE_BRANCH_PREFIX}**`;
|
|
208
|
+
|
|
199
209
|
/**
|
|
200
210
|
* A token the checkout persists for the tick's `git push`: a repository
|
|
201
211
|
* secret, or a GitHub App's installation token minted from its id (an
|
|
@@ -240,6 +250,8 @@ export function renderCiGreenWorkflow(input: WorkflowInput): string {
|
|
|
240
250
|
` - ${q(green.branch)}`,
|
|
241
251
|
// #4169: a build the tick asked for runs at a ci/build/<sha> tag, and its completion ticks too.
|
|
242
252
|
...(newestPhases(green) ? [` - ${q("ci/build/**")}`] : []),
|
|
253
|
+
// #4220: a composed commit's phases run on its chant/compose/<name> branch, and their completion ticks too.
|
|
254
|
+
...(input.compose ? [` - ${q(COMPOSE_BRANCHES)}`] : []),
|
|
243
255
|
" schedule:",
|
|
244
256
|
" - cron: \"*/15 * * * *\"",
|
|
245
257
|
" workflow_dispatch: {}",
|
|
@@ -319,12 +331,19 @@ export interface BuildTriggerEdit {
|
|
|
319
331
|
* branch, so no push cancels or replaces another's run; elsewhere, such as
|
|
320
332
|
* a pull request, a new push cancels the run it supersedes.
|
|
321
333
|
*
|
|
322
|
-
*
|
|
334
|
+
* With `compose` (#4220, a workspace declaring `ci.workflow`), both builds
|
|
335
|
+
* also run on pushes to `chant/compose/**`: `newest` adds the filter to the
|
|
336
|
+
* branches it writes, `every` adds it to an existing `push.branches` list,
|
|
337
|
+
* and `every`'s group keys a compose branch's runs by commit, as the trunk
|
|
338
|
+
* branch's.
|
|
339
|
+
*
|
|
340
|
+
* Only the top-level `on:` (for `newest`, or `every` with `compose`) and `concurrency:` blocks change,
|
|
323
341
|
* each under {@link BUILD_BLOCK_MARKER}; the rest of the file is kept as it
|
|
324
342
|
* is. Throws when one workflow holds a phase of each build, which no one
|
|
325
343
|
* concurrency group can serve.
|
|
326
344
|
*/
|
|
327
|
-
export function buildTriggerEdits(green: CiGreen, sources: readonly WorkflowSource[]): BuildTriggerEdit[] {
|
|
345
|
+
export function buildTriggerEdits(green: CiGreen, sources: readonly WorkflowSource[], options: { compose?: boolean } = {}): BuildTriggerEdit[] {
|
|
346
|
+
const compose = options.compose === true;
|
|
328
347
|
const out: BuildTriggerEdit[] = [];
|
|
329
348
|
for (const s of sources) {
|
|
330
349
|
let doc: unknown;
|
|
@@ -349,17 +368,27 @@ export function buildTriggerEdits(green: CiGreen, sources: readonly WorkflowSour
|
|
|
349
368
|
delete on.schedule;
|
|
350
369
|
const push = isObject(on.push) ? { ...on.push } : {};
|
|
351
370
|
const branches = Array.isArray(push.branches) ? push.branches.filter((b): b is string => typeof b === "string") : [];
|
|
352
|
-
|
|
371
|
+
const withBranch = branches.includes(green.branch) ? branches : [...branches, green.branch];
|
|
372
|
+
push.branches = compose && !withBranch.includes(COMPOSE_BRANCHES) ? [...withBranch, COMPOSE_BRANCHES] : withBranch;
|
|
353
373
|
const { push: _push, workflow_dispatch: dispatch, ...rest } = on;
|
|
354
374
|
text = writeTopLevel(text, "on", { push, workflow_dispatch: dispatch ?? {}, ...rest });
|
|
355
375
|
text = writeTopLevel(text, "concurrency", { group: `chant-build-${tag}-\${{ github.ref }}`, "cancel-in-progress": false });
|
|
356
376
|
} else {
|
|
357
377
|
const main = `refs/heads/${green.branch}`;
|
|
378
|
+
// A push filter that lists branches gains the compose branches; one with no list already runs on every branch.
|
|
379
|
+
const on = isObject(doc) && isObject(doc.on) ? doc.on : undefined;
|
|
380
|
+
const pushOn = on && isObject(on.push) ? on.push : undefined;
|
|
381
|
+
if (compose && on && pushOn && Array.isArray(pushOn.branches) && !pushOn.branches.includes(COMPOSE_BRANCHES)) {
|
|
382
|
+
text = writeTopLevel(text, "on", { ...on, push: { ...pushOn, branches: [...pushOn.branches, COMPOSE_BRANCHES] } });
|
|
383
|
+
}
|
|
384
|
+
// #4220: a compose branch's commits each get a verdict, so its runs are keyed by commit, as the trunk branch's are.
|
|
385
|
+
const keyed = compose ? `(github.ref == '${main}' || startsWith(github.ref, 'refs/heads/${COMPOSE_BRANCH_PREFIX}'))` : `github.ref == '${main}'`;
|
|
386
|
+
const unkeyed = compose ? `!${keyed}` : `github.ref != '${main}'`;
|
|
358
387
|
// Written as lines: the emitter would quote the expression, and GitHub reads it the same either way.
|
|
359
388
|
text = writeTopLevel(text, "concurrency", [
|
|
360
389
|
"concurrency:",
|
|
361
|
-
` group: chant-build-${tag}-\${{
|
|
362
|
-
` cancel-in-progress: \${{
|
|
390
|
+
` group: chant-build-${tag}-\${{ ${keyed} && github.sha || github.ref }}`,
|
|
391
|
+
` cancel-in-progress: \${{ ${unkeyed} }}`,
|
|
363
392
|
]);
|
|
364
393
|
}
|
|
365
394
|
out.push({ path: s.path, build, phases, text, changed: text !== s.text });
|