@intentius/chant 0.124.33 → 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 +284 -81
- 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 +37 -2
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +24 -0
- package/dist/cli/registry.d.ts +17 -1
- 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-rounds.d.ts +102 -0
- package/dist/components/promote-rounds.d.ts.map +1 -0
- package/dist/components/promote-rounds.js +174 -0
- package/dist/components/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-gate.d.ts +30 -0
- package/dist/components/release-gate.d.ts.map +1 -1
- package/dist/components/release-gate.js +92 -42
- package/dist/components/release-promote.d.ts +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 +9 -1
- package/dist/graph-declared.d.ts.map +1 -1
- package/dist/graph-declared.js +18 -3
- 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 +226 -224
- package/dist/workspace/__fixtures__/contract-repo.d.ts.map +1 -1
- package/dist/workspace/__fixtures__/contract-repo.js +13 -2
- package/dist/workspace/check.schema.json +20 -2
- package/dist/workspace/checks/ci-workflow.d.ts +17 -0
- package/dist/workspace/checks/ci-workflow.d.ts.map +1 -0
- package/dist/workspace/checks/ci-workflow.js +130 -0
- package/dist/workspace/checks/security.d.ts +16 -1
- package/dist/workspace/checks/security.d.ts.map +1 -1
- package/dist/workspace/checks/security.js +4 -4
- package/dist/workspace/checks.d.ts +1 -1
- package/dist/workspace/checks.d.ts.map +1 -1
- package/dist/workspace/checks.js +4 -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-init.d.ts +100 -0
- package/dist/workspace/ci-init.d.ts.map +1 -0
- package/dist/workspace/ci-init.js +434 -0
- package/dist/workspace/ci-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 +91 -0
- package/dist/workspace/ci-workflow.d.ts.map +1 -0
- package/dist/workspace/ci-workflow.js +33 -0
- package/dist/workspace/declaration.d.ts +18 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.js +9 -2
- package/dist/workspace/declaration.schema.json +28 -0
- 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/init.js +2 -2
- 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/reason-codes.d.ts +9 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/reason-codes.js +10 -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/release-workflow.d.ts +31 -0
- package/dist/workspace/release-workflow.d.ts.map +1 -0
- package/dist/workspace/release-workflow.js +150 -0
- package/dist/workspace/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 +302 -91
- 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 +37 -2
- package/src/cli/parse-args.ts +18 -0
- package/src/cli/registry.ts +17 -1
- 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-rounds.test.ts +220 -0
- package/src/components/promote-rounds.ts +232 -0
- package/src/components/promote.test.ts +7 -0
- package/src/components/promote.ts +9 -4
- package/src/components/release-gate.test.ts +47 -0
- package/src/components/release-gate.ts +111 -46
- 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-exports.test.ts +40 -0
- package/src/graph-declared.test.ts +18 -0
- package/src/graph-declared.ts +17 -2
- 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/__fixtures__/contract-repo.ts +11 -2
- package/src/workspace/check-contract.test.ts +3 -2
- package/src/workspace/check.schema.json +20 -2
- package/src/workspace/checks/ci-workflow.test.ts +160 -0
- package/src/workspace/checks/ci-workflow.ts +135 -0
- package/src/workspace/checks/security.ts +4 -4
- package/src/workspace/checks.ts +4 -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-init.test.ts +159 -0
- package/src/workspace/ci-init.ts +445 -0
- package/src/workspace/ci-report-url.test.ts +6 -1
- 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 +71 -0
- package/src/workspace/declaration.schema.json +28 -0
- package/src/workspace/declaration.ts +32 -3
- package/src/workspace/delivery-metrics.ts +12 -3
- package/src/workspace/graph-contract.test.ts +3 -2
- package/src/workspace/init.ts +2 -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/reason-codes.test.ts +2 -0
- package/src/workspace/reason-codes.ts +10 -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/release-workflow.ts +167 -0
- 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 {
|
|
@@ -27,7 +27,7 @@ export function scratchDir(prefix = "chant-contract-"): string {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
export function git(cwd: string, ...args: string[]): string {
|
|
30
|
-
return execFileSync("git", ["-c", "user.name=t", "-c", "user.email=t@example.com", "-c", "commit.gpgsign=false", ...args], {
|
|
30
|
+
return execFileSync("git", ["-c", "user.name=t", "-c", "user.email=t@example.com", "-c", "commit.gpgsign=false", "-c", "gc.auto=0", "-c", "maintenance.auto=false", ...args], {
|
|
31
31
|
cwd,
|
|
32
32
|
encoding: "utf-8",
|
|
33
33
|
stdio: ["ignore", "pipe", "pipe"],
|
|
@@ -67,7 +67,16 @@ export function repoFixture(build: () => string, prefix = "chant-contract-copy-"
|
|
|
67
67
|
return () => {
|
|
68
68
|
if (template === undefined || !existsSync(template)) template = build();
|
|
69
69
|
const root = scratchDir(prefix);
|
|
70
|
-
|
|
70
|
+
// A commit can leave a detached `git gc --auto` or maintenance run moving
|
|
71
|
+
// the template's objects, and a copy that meets it fails with ENOENT on
|
|
72
|
+
// .git/objects. Copy once more after it settles.
|
|
73
|
+
try {
|
|
74
|
+
cpSync(template, root, { recursive: true });
|
|
75
|
+
} catch (err) {
|
|
76
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
|
77
|
+
rmSync(root, { recursive: true, force: true });
|
|
78
|
+
cpSync(template, root, { recursive: true });
|
|
79
|
+
}
|
|
71
80
|
return root;
|
|
72
81
|
};
|
|
73
82
|
}
|
|
@@ -14,6 +14,7 @@ import { BOX_FINDING_CODES } from "./checks/boxes";
|
|
|
14
14
|
import { RECORD_FINDING_CODES } from "./checks/records";
|
|
15
15
|
import { DIAGRAM_FINDING_CODES } from "./checks/diagrams";
|
|
16
16
|
import { SECURITY_FINDING_CODES } from "./checks/security";
|
|
17
|
+
import { CI_WORKFLOW_FINDING_CODES } from "./ci-workflow";
|
|
17
18
|
import { WORKSPACE_ERROR_CODES } from "./declaration";
|
|
18
19
|
import { CHECK_CODES, CHECK_CONTRACT_VERSION, CHECK_ERROR_CODES, CHECK_OUTPUT_SCHEMA_ID, runChecks, type CheckDocument } from "./lineage-check";
|
|
19
20
|
|
|
@@ -39,8 +40,8 @@ describe("check output schema", () => {
|
|
|
39
40
|
test("lists exactly the codes the code can return", () => {
|
|
40
41
|
expect(schema.$defs.lockFinding.properties.code.enum).toEqual([...CHECK_CODES]);
|
|
41
42
|
expect(schema.$defs.failure.properties.error.properties.code.enum).toEqual([...CHECK_ERROR_CODES]);
|
|
42
|
-
expect(schema.$defs.diagnostic.properties.code.enum).toEqual([...WORKSPACE_ERROR_CODES, ...BOX_FINDING_CODES, ...RECORD_FINDING_CODES, ...DIAGRAM_FINDING_CODES, ...SECURITY_FINDING_CODES]);
|
|
43
|
-
expect(schema.$defs.suppressed.properties.code.enum).toEqual([...WORKSPACE_ERROR_CODES, ...BOX_FINDING_CODES, ...RECORD_FINDING_CODES, ...DIAGRAM_FINDING_CODES, ...SECURITY_FINDING_CODES]);
|
|
43
|
+
expect(schema.$defs.diagnostic.properties.code.enum).toEqual([...WORKSPACE_ERROR_CODES, ...BOX_FINDING_CODES, ...RECORD_FINDING_CODES, ...DIAGRAM_FINDING_CODES, ...SECURITY_FINDING_CODES, ...CI_WORKFLOW_FINDING_CODES]);
|
|
44
|
+
expect(schema.$defs.suppressed.properties.code.enum).toEqual([...WORKSPACE_ERROR_CODES, ...BOX_FINDING_CODES, ...RECORD_FINDING_CODES, ...DIAGRAM_FINDING_CODES, ...SECURITY_FINDING_CODES, ...CI_WORKFLOW_FINDING_CODES]);
|
|
44
45
|
});
|
|
45
46
|
});
|
|
46
47
|
|
|
@@ -332,7 +332,16 @@
|
|
|
332
332
|
|
|
333
333
|
"security-runner-start-no-role",
|
|
334
334
|
|
|
335
|
-
"security-trust-unreadable"
|
|
335
|
+
"security-trust-unreadable",
|
|
336
|
+
"ci-workflow-green-undeclared",
|
|
337
|
+
"ci-workflow-no-pre-merge",
|
|
338
|
+
"ci-workflow-apply-in-green",
|
|
339
|
+
"ci-workflow-no-components",
|
|
340
|
+
"ci-workflow-no-environments",
|
|
341
|
+
"ci-workflow-ungated-env",
|
|
342
|
+
"ci-workflow-pipeline-missing",
|
|
343
|
+
"ci-workflow-pipeline-differs",
|
|
344
|
+
"ci-workflow-publish-missing"
|
|
336
345
|
],
|
|
337
346
|
"description": "For WSP001: why the declaration can't be read. For WSP121 to WSP127, the box finding (#2726, #2727, #2780, #2850). For WSP117, work-acceptance-unmet (#2772). For WSP118, flag-stale (#4024). For WSP131 to WSP133, the diagram finding (#2764)."
|
|
338
347
|
}
|
|
@@ -445,7 +454,16 @@
|
|
|
445
454
|
|
|
446
455
|
"security-runner-start-no-role",
|
|
447
456
|
|
|
448
|
-
"security-trust-unreadable"
|
|
457
|
+
"security-trust-unreadable",
|
|
458
|
+
"ci-workflow-green-undeclared",
|
|
459
|
+
"ci-workflow-no-pre-merge",
|
|
460
|
+
"ci-workflow-apply-in-green",
|
|
461
|
+
"ci-workflow-no-components",
|
|
462
|
+
"ci-workflow-no-environments",
|
|
463
|
+
"ci-workflow-ungated-env",
|
|
464
|
+
"ci-workflow-pipeline-missing",
|
|
465
|
+
"ci-workflow-pipeline-differs",
|
|
466
|
+
"ci-workflow-publish-missing"
|
|
449
467
|
],
|
|
450
468
|
"description": "For WSP001: why the declaration can't be read. For WSP121 to WSP127, the box finding (#2726, #2727, #2780, #2850). For WSP117, work-acceptance-unmet (#2772). For WSP118, flag-stale (#4024). For WSP131 to WSP133, the diagram finding (#2764)."
|
|
451
469
|
},
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WSP151, what a member under ci.workflow still lacks (#4168 step 6), and the
|
|
3
|
+
* reference page printed from CI_WORKFLOW_REQUIREMENTS.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { afterAll, describe, expect, test } from "vitest";
|
|
12
|
+
import { runDeclarationChecks } from "../checks";
|
|
13
|
+
import { ciWorkflowTable, CI_WORKFLOW_FINDING_CODES, workflowOf } from "../ci-workflow";
|
|
14
|
+
import { REASON_CODES } from "../reason-codes";
|
|
15
|
+
|
|
16
|
+
const DOCS = fileURLToPath(new URL("../../../../../docs/src/content/docs/", import.meta.url));
|
|
17
|
+
|
|
18
|
+
const scratch: string[] = [];
|
|
19
|
+
afterAll(() => {
|
|
20
|
+
for (const d of scratch) rmSync(d, { recursive: true, force: true });
|
|
21
|
+
});
|
|
22
|
+
function repo(files: Record<string, string>): string {
|
|
23
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), "chant-ci-workflow-")));
|
|
24
|
+
scratch.push(root);
|
|
25
|
+
execFileSync("git", ["init", "-q"], { cwd: root });
|
|
26
|
+
for (const [path, text] of Object.entries(files)) {
|
|
27
|
+
mkdirSync(dirname(join(root, path)), { recursive: true });
|
|
28
|
+
writeFileSync(join(root, path), text);
|
|
29
|
+
}
|
|
30
|
+
return root;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const member = (extra: Record<string, unknown> = {}) => ({ name: "app", dir: "app", kind: "other", because: "the app", ...extra });
|
|
34
|
+
const declaration = (extra: Record<string, unknown> = {}, members = [member()]) => JSON.stringify({ name: "acme", schema: 1, members, ...extra }, null, 2);
|
|
35
|
+
const green = (extra: Record<string, unknown> = {}) => ({ branch: "main", phases: { check: ["check"], suites: ["suites"] }, require: ["check", "suites"], preMerge: ["check"], ...extra });
|
|
36
|
+
|
|
37
|
+
const CONFIG = 'export default { lexicons: ["aws"], environments: ["staging", "prod"] };\n';
|
|
38
|
+
const COMPONENT = 'import { gate, phase } from "@intentius/chant/components/component";\nexport const app = { name: "app", deploy: [phase("Apply", [gate("prod")])] };\n';
|
|
39
|
+
const PIPELINE = [
|
|
40
|
+
"on: [pull_request, push]",
|
|
41
|
+
"jobs:",
|
|
42
|
+
" plan:",
|
|
43
|
+
" steps:",
|
|
44
|
+
" - run: chant ci run plan --in-place --no-push --env prod --gate pr-apply --forge github",
|
|
45
|
+
" record:",
|
|
46
|
+
" steps:",
|
|
47
|
+
" - run: chant ci run record --in-place --env prod --gate pr-apply --forge github",
|
|
48
|
+
"",
|
|
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`;
|
|
52
|
+
const TICK = "jobs:\n tick:\n steps:\n - run: npx --yes @intentius/chant@0.124.33 ci tick\n";
|
|
53
|
+
const PUBLISH = "jobs:\n publish:\n steps:\n - run: npx chant release publish \"$LABEL\"\n";
|
|
54
|
+
|
|
55
|
+
/** A member under trunk-curated with everything the workflow needs. */
|
|
56
|
+
const complete = (extra: Record<string, string> = {}) => ({
|
|
57
|
+
"chant.workspace.json": declaration({ ci: { workflow: "trunk-curated", green: green() } }),
|
|
58
|
+
"app/chant.config.ts": CONFIG,
|
|
59
|
+
"app/app.component.ts": COMPONENT,
|
|
60
|
+
".github/workflows/chant-pr.yml": PIPELINE,
|
|
61
|
+
".github/workflows/chant-ci-green.yml": TICK,
|
|
62
|
+
".github/workflows/chant-release.yml": PUBLISH,
|
|
63
|
+
...extra,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
/** The WSP151 findings as [code, entity]. */
|
|
67
|
+
const found = async (root: string) =>
|
|
68
|
+
(await runDeclarationChecks(root, (f) => f, { gather: false })).diagnostics.filter((d) => d.ruleId === "WSP151").map((d) => [d.code, d.entity ?? null]);
|
|
69
|
+
|
|
70
|
+
describe("ci.workflow (WSP151)", () => {
|
|
71
|
+
test("a declaration that names no workflow gets no finding", async () => {
|
|
72
|
+
const root = repo({ "chant.workspace.json": declaration(), "app/chant.config.ts": "export default {};\n" });
|
|
73
|
+
expect(await found(root)).toEqual([]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("a workspace with everything trunk-curated needs passes", async () => {
|
|
77
|
+
expect(await found(repo(complete()))).toEqual([]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("an empty workspace under trunk-curated reports each missing piece", async () => {
|
|
81
|
+
const root = repo({ "chant.workspace.json": declaration({ ci: { workflow: "trunk-curated" } }), "app/chant.config.ts": "export default {};\n" });
|
|
82
|
+
expect(await found(root)).toEqual([
|
|
83
|
+
["ci-workflow-no-environments", "app"],
|
|
84
|
+
["ci-workflow-no-components", "app"],
|
|
85
|
+
["ci-workflow-green-undeclared", null],
|
|
86
|
+
["ci-workflow-pipeline-missing", null],
|
|
87
|
+
["ci-workflow-publish-missing", null],
|
|
88
|
+
]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("no preMerge phases, and a required phase that matches the apply job", async () => {
|
|
92
|
+
const root = repo(complete({ "chant.workspace.json": declaration({ ci: { workflow: "trunk-curated", green: green({ phases: { check: ["check"], deploy: ["*apply"] }, require: ["check", "deploy"], preMerge: [] }) } }) }));
|
|
93
|
+
expect(await found(root)).toEqual([
|
|
94
|
+
["ci-workflow-no-pre-merge", null],
|
|
95
|
+
["ci-workflow-apply-in-green", null],
|
|
96
|
+
]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("a component with no gate", async () => {
|
|
100
|
+
const root = repo(complete({ "app/app.component.ts": 'export const app = { name: "app", deploy: [] };\n' }));
|
|
101
|
+
expect(await found(root)).toEqual([["ci-workflow-ungated-env", "app"]]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("trunk-curated with an apply job on merge differs", async () => {
|
|
105
|
+
const root = repo(complete({ ".github/workflows/chant-pr.yml": APPLY_PIPELINE }));
|
|
106
|
+
expect(await found(root)).toEqual([["ci-workflow-pipeline-differs", null]]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("trunk-curated+apply-on-merge with only a record job differs, and with an apply job passes", async () => {
|
|
110
|
+
const decl = declaration({ ci: { workflow: "trunk-curated+apply-on-merge", green: green() } });
|
|
111
|
+
expect(await found(repo(complete({ "chant.workspace.json": decl })))).toEqual([["ci-workflow-pipeline-differs", null]]);
|
|
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]]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("a member's own workflow wins, and a top-level workflow skips members that don't build with chant", async () => {
|
|
124
|
+
const root = repo({
|
|
125
|
+
...complete(),
|
|
126
|
+
"chant.workspace.json": declaration({ ci: { green: green() } }, [member({ ci: { workflow: "trunk-curated" } }), member({ name: "docs", dir: "docs" })]),
|
|
127
|
+
"docs/README.md": "",
|
|
128
|
+
});
|
|
129
|
+
expect(await found(root)).toEqual([]);
|
|
130
|
+
const top = repo({ ...complete(), "docs/README.md": "", "chant.workspace.json": declaration({ ci: { workflow: "trunk-curated", green: green() } }, [member(), member({ name: "docs", dir: "docs" })]) });
|
|
131
|
+
expect(await found(top)).toEqual([]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("ci.green.preMerge must name declared phases", async () => {
|
|
135
|
+
const root = repo({ "chant.workspace.json": declaration({ ci: { green: green({ preMerge: ["lint"] }) } }) });
|
|
136
|
+
const report = await runDeclarationChecks(root, (f) => f, { gather: false });
|
|
137
|
+
expect(report.diagnostics.find((d) => d.ruleId === "WSP001")?.message).toMatch(/ci\.green\.preMerge names the phase "lint"/);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("workflowOf", () => {
|
|
142
|
+
test("the member's own wins over the top level's", () => {
|
|
143
|
+
const d = { ci: { green: null, workflow: "trunk-curated" as const, reportUrl: null } };
|
|
144
|
+
expect(workflowOf(d, { ciWorkflow: null })).toBe("trunk-curated");
|
|
145
|
+
expect(workflowOf(d, { ciWorkflow: "trunk-curated+apply-on-merge" })).toBe("trunk-curated+apply-on-merge");
|
|
146
|
+
expect(workflowOf({ ci: null }, null)).toBeNull();
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("the ci.workflow codes and reference page", () => {
|
|
151
|
+
test("every code is a read-contract reason code", () => {
|
|
152
|
+
for (const code of CI_WORKFLOW_FINDING_CODES) expect(REASON_CODES, code).toContain(code);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("the CI workflows page carries the table CI_WORKFLOW_REQUIREMENTS prints", () => {
|
|
156
|
+
const page = readFileSync(join(DOCS, "reference", "ci-workflows.mdx"), "utf-8");
|
|
157
|
+
const table = /^\| Code \| Requirement \|[^\n]*\n(?:\|[^\n]*\n)*/m.exec(page)?.[0].trimEnd();
|
|
158
|
+
expect(table, "regenerate the table under Requirements from ciWorkflowTable()").toBe(ciWorkflowTable());
|
|
159
|
+
});
|
|
160
|
+
});
|