@intentius/chant 0.107.1 → 0.108.1
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/generate.d.ts +17 -0
- package/dist/cli/handlers/generate.d.ts.map +1 -0
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/plugins.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +12 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/codegen/json-schema-to-ts.d.ts +61 -0
- package/dist/codegen/json-schema-to-ts.d.ts.map +1 -0
- package/dist/config.d.ts +15 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/lexicon.d.ts +13 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/project-codegen.d.ts +145 -0
- package/dist/project-codegen.d.ts.map +1 -0
- package/dist/workspace/ci-green-cli.d.ts +17 -0
- package/dist/workspace/ci-green-cli.d.ts.map +1 -0
- package/dist/workspace/ci-green-forge.d.ts +54 -0
- package/dist/workspace/ci-green-forge.d.ts.map +1 -0
- package/dist/workspace/ci-green-workflow.d.ts +107 -0
- package/dist/workspace/ci-green-workflow.d.ts.map +1 -0
- package/dist/workspace/ci-green.d.ts +191 -0
- package/dist/workspace/ci-green.d.ts.map +1 -0
- package/dist/workspace/declaration.d.ts +31 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.schema.json +102 -0
- package/dist/workspace/ls.d.ts +15 -0
- package/dist/workspace/ls.d.ts.map +1 -1
- package/dist/workspace/reason-codes.d.ts +2 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/generate.ts +90 -0
- package/src/cli/main.ts +53 -0
- package/src/cli/plugins.ts +16 -2
- package/src/cli/registry.ts +12 -0
- package/src/codegen/json-schema-to-ts.test.ts +256 -0
- package/src/codegen/json-schema-to-ts.ts +373 -0
- package/src/config.ts +16 -0
- package/src/lexicon.ts +20 -0
- package/src/project-codegen.test.ts +208 -0
- package/src/project-codegen.ts +296 -0
- package/src/workspace/ci-green-cli.ts +118 -0
- package/src/workspace/ci-green-forge.test.ts +66 -0
- package/src/workspace/ci-green-forge.ts +111 -0
- package/src/workspace/ci-green-workflow.test.ts +288 -0
- package/src/workspace/ci-green-workflow.ts +252 -0
- package/src/workspace/ci-green.test.ts +401 -0
- package/src/workspace/ci-green.ts +424 -0
- package/src/workspace/ci-last-green.schema.json +158 -0
- package/src/workspace/declaration.schema.json +102 -0
- package/src/workspace/declaration.ts +64 -0
- package/src/workspace/ls.schema.json +34 -0
- package/src/workspace/ls.ts +22 -0
- package/src/workspace/read-contract.test.ts +2 -1
- package/src/workspace/reason-codes.test.ts +2 -0
- package/src/workspace/reason-codes.ts +3 -0
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #3573 (ws-103) — which commits passed CI: the `ci.green` block, judging a
|
|
3
|
+
* phase from check runs, the tick against a real git remote with a fake
|
|
4
|
+
* forge, and `ci last-green` with its read-contract document.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
import { chmodSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { afterAll, describe, expect, test, vi } from "vitest";
|
|
11
|
+
import { parseDeclaration, type CiPhase } from "./declaration";
|
|
12
|
+
import { cleanScratch, contract, declaration, git, scratchDir, writeFiles } from "./__fixtures__/contract-repo";
|
|
13
|
+
import { ciTick, pushRefusalCause, evaluateCommit, evaluatePhase, latestAttempts, lastGreen, matchesCheckRun, windowMs, type LastGreenDocument } from "./ci-green";
|
|
14
|
+
import type { CheckRun, CiForge } from "./ci-green-forge";
|
|
15
|
+
import { runCiLastGreen } from "./ci-green-cli";
|
|
16
|
+
import lastGreenSchema from "./ci-last-green.schema.json";
|
|
17
|
+
import lsSchema from "./ls.schema.json";
|
|
18
|
+
import { listWorkspace, type LsDocument } from "./ls";
|
|
19
|
+
import type { CommandContext } from "../cli/registry";
|
|
20
|
+
|
|
21
|
+
afterAll(cleanScratch);
|
|
22
|
+
|
|
23
|
+
const GREEN = {
|
|
24
|
+
branch: "main",
|
|
25
|
+
phases: {
|
|
26
|
+
lint: ["lint"],
|
|
27
|
+
test: ["test", "e2e (*)"],
|
|
28
|
+
macos: { runs: ["macos (test)"], skipped: "pass" },
|
|
29
|
+
},
|
|
30
|
+
require: ["lint", "test", "macos"],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const decl = (ci: unknown) => JSON.stringify({ name: "w", schema: 1, members: [{ name: "app", dir: "app", kind: "other", because: "x" }], ci });
|
|
34
|
+
|
|
35
|
+
describe("the ci.green block (#3573)", () => {
|
|
36
|
+
test("parses both phase forms with their defaults, in file order", () => {
|
|
37
|
+
const d = parseDeclaration(decl({ green: GREEN }), "chant.workspace.json");
|
|
38
|
+
expect(d.ci!.green).toEqual({
|
|
39
|
+
branch: "main",
|
|
40
|
+
window: "24h",
|
|
41
|
+
phases: [
|
|
42
|
+
{ name: "lint", runs: ["lint"], skipped: "fail", pointer: "/ci/green/phases/lint" },
|
|
43
|
+
{ name: "test", runs: ["test", "e2e (*)"], skipped: "fail", pointer: "/ci/green/phases/test" },
|
|
44
|
+
{ name: "macos", runs: ["macos (test)"], skipped: "pass", pointer: "/ci/green/phases/macos" },
|
|
45
|
+
],
|
|
46
|
+
require: ["lint", "test", "macos"],
|
|
47
|
+
pointer: "/ci/green",
|
|
48
|
+
});
|
|
49
|
+
expect(parseDeclaration(decl({ green: { ...GREEN, window: "90m" } }), "chant.workspace.json").ci!.green!.window).toBe("90m");
|
|
50
|
+
expect(parseDeclaration(decl({}), "chant.workspace.json").ci).toEqual({ green: null });
|
|
51
|
+
expect(parseDeclaration(JSON.stringify({ name: "w", schema: 1, members: [] }), "chant.workspace.json").ci).toBeNull();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("refuses a required phase it does not declare, and what the schema doesn't allow", () => {
|
|
55
|
+
expect(() => parseDeclaration(decl({ green: { ...GREEN, require: ["lint", "docs"] } }), "chant.workspace.json")).toThrow(/require names the phase "docs"/);
|
|
56
|
+
for (const bad of [
|
|
57
|
+
{ ...GREEN, window: "soon" },
|
|
58
|
+
{ ...GREEN, window: "10s" },
|
|
59
|
+
{ ...GREEN, phases: { lint: [] } },
|
|
60
|
+
{ ...GREEN, phases: { lint: { runs: ["lint"], skipped: "maybe" } } },
|
|
61
|
+
{ ...GREEN, require: [] },
|
|
62
|
+
{ phases: GREEN.phases, require: GREEN.require },
|
|
63
|
+
{ ...GREEN, branch: "has space" },
|
|
64
|
+
]) {
|
|
65
|
+
expect(() => parseDeclaration(decl({ green: bad }), "chant.workspace.json"), JSON.stringify(bad)).toThrow();
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// ── Judging check runs ───────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
const run = (id: number, name: string, conclusion: string | null, status = "completed"): CheckRun => ({
|
|
73
|
+
id,
|
|
74
|
+
name,
|
|
75
|
+
status,
|
|
76
|
+
conclusion: status === "completed" ? conclusion : null,
|
|
77
|
+
completedAt: null,
|
|
78
|
+
url: `https://github.com/acme/app/runs/${id}`,
|
|
79
|
+
});
|
|
80
|
+
const phase = (runs: string[], skipped: "pass" | "fail" = "fail"): CiPhase => ({ name: "p", runs, skipped, pointer: "/p" });
|
|
81
|
+
|
|
82
|
+
describe("judging a phase from check runs (#3573)", () => {
|
|
83
|
+
test("a pattern matches a whole name, * standing for any run of characters", () => {
|
|
84
|
+
expect(matchesCheckRun("e2e (*)", "e2e (chromium)")).toBe(true);
|
|
85
|
+
expect(matchesCheckRun("e2e (*)", "e2e")).toBe(false);
|
|
86
|
+
expect(matchesCheckRun("test", "test-shard (1/4)")).toBe(false);
|
|
87
|
+
expect(matchesCheckRun("test*", "test-shard (1/4)")).toBe(true);
|
|
88
|
+
expect(matchesCheckRun("a.b", "axb")).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("each run is judged by its latest attempt", () => {
|
|
92
|
+
expect(latestAttempts([run(1, "test", "failure"), run(5, "test", "success"), run(3, "test", "failure")]).map((r) => r.id)).toEqual([5]);
|
|
93
|
+
expect(evaluatePhase(phase(["test"]), latestAttempts([run(1, "test", "failure"), run(2, "test", "success")])).verdict).toBe("pass");
|
|
94
|
+
expect(evaluatePhase(phase(["test"]), latestAttempts([run(1, "test", "success"), run(2, "test", "failure")])).verdict).toBe("fail");
|
|
95
|
+
// A re-run still going leaves the phase undecided, whatever the attempt before it said.
|
|
96
|
+
expect(evaluatePhase(phase(["test"]), latestAttempts([run(1, "test", "success"), run(2, "test", null, "in_progress")])).verdict).toBe("pending");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("every run a pattern matches must pass", () => {
|
|
100
|
+
const runs = [run(1, "e2e (chromium)", "success"), run(2, "e2e (webkit)", "failure")];
|
|
101
|
+
const v = evaluatePhase(phase(["e2e (*)"]), runs);
|
|
102
|
+
expect(v.verdict).toBe("fail");
|
|
103
|
+
expect(v.runs.map((r) => [r.name, r.verdict])).toEqual([
|
|
104
|
+
["e2e (chromium)", "pass"],
|
|
105
|
+
["e2e (webkit)", "fail"],
|
|
106
|
+
]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("a skipped run passes only under skipped: pass", () => {
|
|
110
|
+
const runs = [run(1, "macos (test)", "skipped")];
|
|
111
|
+
expect(evaluatePhase(phase(["macos (test)"]), runs).verdict).toBe("fail");
|
|
112
|
+
expect(evaluatePhase(phase(["macos (test)"], "pass"), runs).verdict).toBe("pass");
|
|
113
|
+
// Neither counts any other conclusion as a pass.
|
|
114
|
+
for (const c of ["neutral", "cancelled", "timed_out", "action_required", "stale"]) {
|
|
115
|
+
expect(evaluatePhase(phase(["x"], "pass"), [run(1, "x", c)]).verdict, c).toBe("fail");
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("a run in progress, or a pattern nothing matches yet, leaves the phase undecided; a failure decides it", () => {
|
|
120
|
+
expect(evaluatePhase(phase(["lint"]), [run(1, "lint", null, "queued")]).verdict).toBe("pending");
|
|
121
|
+
const missing = evaluatePhase(phase(["lint", "build"]), [run(1, "lint", "success")]);
|
|
122
|
+
expect(missing).toMatchObject({ verdict: "pending", unmatched: ["build"] });
|
|
123
|
+
expect(evaluatePhase(phase(["lint", "build"]), [run(1, "lint", "failure")]).verdict).toBe("fail");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("a commit fails on its first failing required phase, and passes when every one passes", () => {
|
|
127
|
+
const green = parseDeclaration(decl({ green: GREEN }), "chant.workspace.json").ci!.green!;
|
|
128
|
+
const all = [run(1, "lint", "success"), run(2, "test", "success"), run(3, "e2e (a)", "success"), run(4, "macos (test)", "skipped")];
|
|
129
|
+
expect(evaluateCommit(green, all).verdict).toBe("pass");
|
|
130
|
+
const red = evaluateCommit(green, [...all, run(9, "test", "failure")]);
|
|
131
|
+
expect(red.verdict).toBe("fail");
|
|
132
|
+
expect(red.failure).toMatchObject({ phase: "test", run: { name: "test", id: 9, conclusion: "failure" } });
|
|
133
|
+
expect(evaluateCommit(green, all.slice(1)).verdict).toBe("pending");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("the window is minutes, hours or days", () => {
|
|
137
|
+
expect(windowMs("24h")).toBe(86_400_000);
|
|
138
|
+
expect(windowMs("90m")).toBe(5_400_000);
|
|
139
|
+
expect(windowMs("7d")).toBe(604_800_000);
|
|
140
|
+
expect(() => windowMs("1w")).toThrow();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// ── The tick, against a real remote ──────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
const NOW = new Date("2026-10-06T12:00:00Z");
|
|
147
|
+
const hoursAgo = (h: number) => new Date(NOW.getTime() - h * 3_600_000).toISOString();
|
|
148
|
+
|
|
149
|
+
/** git with a fixed author and committer time. */
|
|
150
|
+
function gitAt(cwd: string, date: string, ...args: string[]): string {
|
|
151
|
+
return execFileSync("git", ["-c", "user.name=t", "-c", "user.email=t@example.com", "-c", "commit.gpgsign=false", ...args], {
|
|
152
|
+
cwd,
|
|
153
|
+
encoding: "utf-8",
|
|
154
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
155
|
+
env: { ...process.env, GIT_AUTHOR_DATE: date, GIT_COMMITTER_DATE: date },
|
|
156
|
+
}).trim();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function commitAt(root: string, hours: number, file: string): string {
|
|
160
|
+
writeFiles(root, { [file]: `${file} ${hours}\n` });
|
|
161
|
+
gitAt(root, hoursAgo(hours), "add", "-A");
|
|
162
|
+
gitAt(root, hoursAgo(hours), "commit", "-q", "-m", file);
|
|
163
|
+
return git(root, "rev-parse", "HEAD");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** A checkout on main whose origin is a bare repository, declaring `ci.green`. */
|
|
167
|
+
function workspace(green: unknown = GREEN): { root: string; origin: string } {
|
|
168
|
+
const origin = scratchDir("chant-ci-origin-");
|
|
169
|
+
git(origin, "init", "-q", "--bare");
|
|
170
|
+
const root = scratchDir("chant-ci-work-");
|
|
171
|
+
git(root, "init", "-q");
|
|
172
|
+
git(root, "symbolic-ref", "HEAD", "refs/heads/main");
|
|
173
|
+
writeFiles(root, { "chant.workspace.json": declaration([{ name: "app", dir: "app", kind: "other", because: "the app" }], { ci: { green } }), "app/a.txt": "a\n" });
|
|
174
|
+
git(root, "remote", "add", "origin", origin);
|
|
175
|
+
return { root, origin };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const push = (root: string) => git(root, "push", "-q", "origin", "main");
|
|
179
|
+
const remoteTags = (origin: string) => git(origin, "for-each-ref", "--format=%(refname:lstrip=2)", "refs/tags/").split("\n").filter(Boolean).sort();
|
|
180
|
+
|
|
181
|
+
const PASSING = [run(1, "lint", "success"), run(2, "test", "success"), run(3, "e2e (chromium)", "success"), run(4, "macos (test)", "skipped")];
|
|
182
|
+
|
|
183
|
+
/** A forge whose check runs a test sets per commit, counting what it is asked. */
|
|
184
|
+
function fakeForge(runs: Map<string, CheckRun[]>): CiForge & { asked: string[] } {
|
|
185
|
+
const asked: string[] = [];
|
|
186
|
+
return { kind: "github", asked, checkRuns: async (sha) => (asked.push(sha), runs.get(sha) ?? []) };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
describe("chant ci tick (#3573)", () => {
|
|
190
|
+
test("tags green once, revokes once, and a second tick changes nothing", async () => {
|
|
191
|
+
const { root, origin } = workspace();
|
|
192
|
+
const a = commitAt(root, 5, "app/a.txt");
|
|
193
|
+
const b = commitAt(root, 3, "app/b.txt");
|
|
194
|
+
const c = commitAt(root, 1, "app/c.txt");
|
|
195
|
+
push(root);
|
|
196
|
+
const runs = new Map([
|
|
197
|
+
[a, PASSING],
|
|
198
|
+
[b, [...PASSING.slice(0, 3), run(7, "test", null, "in_progress")]],
|
|
199
|
+
[c, [run(1, "lint", "failure")]],
|
|
200
|
+
]);
|
|
201
|
+
const forge = fakeForge(runs);
|
|
202
|
+
|
|
203
|
+
const first = await ciTick({ cwd: root, forge, now: NOW });
|
|
204
|
+
expect(first.made).toEqual([`ci/green/${a}`]);
|
|
205
|
+
expect(first.commits.map((x) => [x.sha, x.verdict?.verdict])).toEqual([
|
|
206
|
+
[c, "fail"],
|
|
207
|
+
[b, "pending"],
|
|
208
|
+
[a, "pass"],
|
|
209
|
+
]);
|
|
210
|
+
expect(remoteTags(origin)).toEqual([`ci/green/${a}`]);
|
|
211
|
+
// Annotated, with the time and each required phase's runs.
|
|
212
|
+
expect(git(origin, "cat-file", "-t", `refs/tags/ci/green/${a}`)).toBe("tag");
|
|
213
|
+
const note = git(origin, "tag", "-l", "--format=%(contents)", `ci/green/${a}`);
|
|
214
|
+
const record = JSON.parse(note.slice(note.indexOf("{")));
|
|
215
|
+
expect(record).toMatchObject({ tag: "green", commit: a, branch: "main", at: "2026-10-06T12:00:00Z" });
|
|
216
|
+
expect(Object.keys(record.phases)).toEqual(["lint", "test", "macos"]);
|
|
217
|
+
expect(record.phases.test.map((r: { name: string }) => r.name)).toEqual(["test", "e2e (chromium)"]);
|
|
218
|
+
|
|
219
|
+
const again = await ciTick({ cwd: root, forge, now: NOW });
|
|
220
|
+
expect(again.made).toEqual([]);
|
|
221
|
+
expect(remoteTags(origin)).toEqual([`ci/green/${a}`]);
|
|
222
|
+
|
|
223
|
+
// A re-run of a's tests goes red: a is revoked, once.
|
|
224
|
+
runs.set(a, [...PASSING, run(9, "test", "failure")]);
|
|
225
|
+
const revoke = await ciTick({ cwd: root, forge, now: NOW });
|
|
226
|
+
expect(revoke.made).toEqual([`ci/revoked/${a}`]);
|
|
227
|
+
const why = git(origin, "tag", "-l", "--format=%(contents)", `ci/revoked/${a}`);
|
|
228
|
+
expect(JSON.parse(why.slice(why.indexOf("{")))).toMatchObject({ tag: "revoked", commit: a, phase: "test", run: { name: "test", id: 9, conclusion: "failure" } });
|
|
229
|
+
expect((await ciTick({ cwd: root, forge, now: NOW })).made).toEqual([]);
|
|
230
|
+
|
|
231
|
+
// Passing again does not make it green again by itself, and the forge is not asked about it.
|
|
232
|
+
runs.set(a, [...PASSING, run(9, "test", "failure"), run(10, "test", "success")]);
|
|
233
|
+
forge.asked.length = 0;
|
|
234
|
+
expect((await ciTick({ cwd: root, forge, now: NOW })).made).toEqual([]);
|
|
235
|
+
expect(forge.asked).not.toContain(a);
|
|
236
|
+
expect(remoteTags(origin)).toEqual([`ci/green/${a}`, `ci/revoked/${a}`]);
|
|
237
|
+
expect((lastGreen({ cwd: root }) as { commit: unknown }).commit).toBeNull();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("deleting the revoked tag by hand makes the commit count as green again", async () => {
|
|
241
|
+
const { root, origin } = workspace();
|
|
242
|
+
const a = commitAt(root, 2, "app/a.txt");
|
|
243
|
+
push(root);
|
|
244
|
+
const runs = new Map([[a, PASSING]]);
|
|
245
|
+
await ciTick({ cwd: root, forge: fakeForge(runs), now: NOW });
|
|
246
|
+
runs.set(a, [...PASSING, run(9, "lint", "failure")]);
|
|
247
|
+
await ciTick({ cwd: root, forge: fakeForge(runs), now: NOW });
|
|
248
|
+
expect(remoteTags(origin)).toEqual([`ci/green/${a}`, `ci/revoked/${a}`]);
|
|
249
|
+
|
|
250
|
+
// Someone fixes the flake and deletes the revoked tag on the remote.
|
|
251
|
+
runs.set(a, [...PASSING, run(9, "lint", "failure"), run(11, "lint", "success")]);
|
|
252
|
+
git(root, "push", "-q", "origin", `:refs/tags/ci/revoked/${a}`);
|
|
253
|
+
const tick = await ciTick({ cwd: root, forge: fakeForge(runs), now: NOW });
|
|
254
|
+
expect(tick.made).toEqual([]);
|
|
255
|
+
expect(tick.commits[0]).toMatchObject({ sha: a, before: "green" });
|
|
256
|
+
// The fetch pruned the local copy, so the read sees it green again.
|
|
257
|
+
expect((lastGreen({ cwd: root }) as { commit: { sha: string } }).commit.sha).toBe(a);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("looks only at first-parent commits within the window", async () => {
|
|
261
|
+
const { root, origin } = workspace({ ...GREEN, window: "6h" });
|
|
262
|
+
const old = commitAt(root, 30, "app/old.txt");
|
|
263
|
+
const base = commitAt(root, 4, "app/base.txt");
|
|
264
|
+
git(root, "checkout", "-q", "-b", "side");
|
|
265
|
+
const side = commitAt(root, 3, "app/side.txt");
|
|
266
|
+
git(root, "checkout", "-q", "main");
|
|
267
|
+
gitAt(root, hoursAgo(2), "merge", "-q", "--no-ff", "-m", "merge side", "side");
|
|
268
|
+
const merge = git(root, "rev-parse", "HEAD");
|
|
269
|
+
push(root);
|
|
270
|
+
const forge = fakeForge(new Map([old, base, side, merge].map((s) => [s, PASSING])));
|
|
271
|
+
const tick = await ciTick({ cwd: root, forge, now: NOW });
|
|
272
|
+
expect(tick.commits.map((c) => c.sha)).toEqual([merge, base]);
|
|
273
|
+
expect(forge.asked.sort()).toEqual([merge, base].sort());
|
|
274
|
+
expect(remoteTags(origin)).toEqual([`ci/green/${base}`, `ci/green/${merge}`].sort());
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("a dry run decides and makes no tag", async () => {
|
|
278
|
+
const { root, origin } = workspace();
|
|
279
|
+
const a = commitAt(root, 1, "app/a.txt");
|
|
280
|
+
push(root);
|
|
281
|
+
const tick = await ciTick({ cwd: root, forge: fakeForge(new Map([[a, PASSING]])), now: NOW, dryRun: true });
|
|
282
|
+
expect(tick.made).toEqual([`ci/green/${a}`]);
|
|
283
|
+
expect(remoteTags(origin)).toEqual([]);
|
|
284
|
+
expect(git(root, "tag", "-l")).toBe("");
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("a push GitHub refuses for lack of the workflows permission names the cause and the flags that fix it", async () => {
|
|
288
|
+
const { root, origin } = workspace();
|
|
289
|
+
const a = commitAt(root, 1, "app/a.txt");
|
|
290
|
+
push(root);
|
|
291
|
+
// The bare origin refuses tags the way GitHub does when the tagged commit changes a workflow file.
|
|
292
|
+
const hook = join(origin, "hooks", "pre-receive");
|
|
293
|
+
writeFileSync(
|
|
294
|
+
hook,
|
|
295
|
+
"#!/bin/sh\necho 'refusing to allow a GitHub App to create or update workflow `.github/workflows/check.yml` without `workflows` permission' >&2\nexit 1\n",
|
|
296
|
+
);
|
|
297
|
+
chmodSync(hook, 0o755);
|
|
298
|
+
const tick = ciTick({ cwd: root, forge: fakeForge(new Map([[a, PASSING]])), now: NOW });
|
|
299
|
+
await expect(tick).rejects.toThrow(/could not push ci\/green\/[0-9a-f]+ to origin: GitHub refused the tag push because the tagged commit changes workflow files, and GITHUB_TOKEN can't update workflows: regenerate the workflow with --token-secret <NAME>/);
|
|
300
|
+
await expect(ciTick({ cwd: root, forge: fakeForge(new Map([[a, PASSING]])), now: NOW })).rejects.toThrow(/without `workflows` permission/);
|
|
301
|
+
expect(remoteTags(origin)).toEqual([]);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("other push refusals get no workflows hint", () => {
|
|
305
|
+
expect(pushRefusalCause("! [remote rejected] ci/green/abc -> ci/green/abc (refusing to allow a GitHub App to create or update workflow .github/workflows/check.yml without workflows permission)")).toMatch(/--token-secret/);
|
|
306
|
+
expect(pushRefusalCause("refusing to allow an OAuth App to create or update workflow `.github/workflows/x.yml` without `workflow` scope")).toMatch(/--app-id-var/);
|
|
307
|
+
expect(pushRefusalCause("! [rejected] ci/green/abc -> ci/green/abc (already exists)")).toBeNull();
|
|
308
|
+
expect(pushRefusalCause("remote: Permission to o/r.git denied to github-actions[bot].")).toBeNull();
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// ── last-green ───────────────────────────────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
const read = contract(lastGreenSchema);
|
|
315
|
+
|
|
316
|
+
function captured(fn: () => Promise<number>): Promise<{ code: number; out: string; err: string }> {
|
|
317
|
+
const out: string[] = [];
|
|
318
|
+
const err: string[] = [];
|
|
319
|
+
const log = vi.spyOn(console, "log").mockImplementation((s: unknown) => void out.push(String(s)));
|
|
320
|
+
const error = vi.spyOn(console, "error").mockImplementation((s: unknown) => void err.push(String(s)));
|
|
321
|
+
return fn()
|
|
322
|
+
.then((code) => ({ code, out: out.join("\n"), err: err.join("\n") }))
|
|
323
|
+
.finally(() => {
|
|
324
|
+
log.mockRestore();
|
|
325
|
+
error.mockRestore();
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const ctx = (json: boolean) => ({ args: { json } }) as unknown as CommandContext;
|
|
330
|
+
|
|
331
|
+
describe("chant ci last-green (#3573)", () => {
|
|
332
|
+
test("prints the newest green commit that is not revoked, as text and as the read contract's document", async () => {
|
|
333
|
+
const { root } = workspace();
|
|
334
|
+
const a = commitAt(root, 4, "app/a.txt");
|
|
335
|
+
const b = commitAt(root, 3, "app/b.txt");
|
|
336
|
+
const c = commitAt(root, 2, "app/c.txt");
|
|
337
|
+
commitAt(root, 1, "app/d.txt");
|
|
338
|
+
push(root);
|
|
339
|
+
const runs = new Map([
|
|
340
|
+
[a, PASSING],
|
|
341
|
+
[b, PASSING],
|
|
342
|
+
[c, PASSING],
|
|
343
|
+
]);
|
|
344
|
+
await ciTick({ cwd: root, forge: fakeForge(runs), now: NOW });
|
|
345
|
+
runs.set(c, [run(9, "lint", "failure")]);
|
|
346
|
+
await ciTick({ cwd: root, forge: fakeForge(runs), now: NOW });
|
|
347
|
+
|
|
348
|
+
const doc = lastGreen({ cwd: root }) as Extract<LastGreenDocument, { branch: string }>;
|
|
349
|
+
read.expectValid(doc);
|
|
350
|
+
expect(doc).toMatchObject({ contract: 1, workspace: { name: "acme", root: ".", file: "chant.workspace.json" }, branch: "main", ref: "refs/remotes/origin/main" });
|
|
351
|
+
expect(doc.commit).toMatchObject({ sha: b, tag: `ci/green/${b}` });
|
|
352
|
+
expect(doc.commit!.tagged).toMatch(/^\d{4}-\d\d-\d\dT/);
|
|
353
|
+
|
|
354
|
+
const cwd = vi.spyOn(process, "cwd").mockReturnValue(root);
|
|
355
|
+
try {
|
|
356
|
+
expect(await captured(() => runCiLastGreen(ctx(false)))).toMatchObject({ code: 0, out: b });
|
|
357
|
+
const json = await captured(() => runCiLastGreen(ctx(true)));
|
|
358
|
+
expect(json.code).toBe(0);
|
|
359
|
+
expect(JSON.parse(json.out)).toEqual(doc);
|
|
360
|
+
} finally {
|
|
361
|
+
cwd.mockRestore();
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test("no green commit is a result with a null commit; no ci.green is a failure", async () => {
|
|
366
|
+
const { root } = workspace();
|
|
367
|
+
commitAt(root, 1, "app/a.txt");
|
|
368
|
+
const none = lastGreen({ cwd: root });
|
|
369
|
+
read.expectValid(none);
|
|
370
|
+
expect(none).toMatchObject({ ref: "refs/heads/main", commit: null });
|
|
371
|
+
|
|
372
|
+
const plain = scratchDir("chant-ci-plain-");
|
|
373
|
+
git(plain, "init", "-q");
|
|
374
|
+
writeFiles(plain, { "chant.workspace.json": declaration([{ name: "app", dir: "app", kind: "other", because: "x" }]), "app/a.txt": "a\n" });
|
|
375
|
+
const undeclared = lastGreen({ cwd: plain });
|
|
376
|
+
read.expectValid(undeclared);
|
|
377
|
+
expect(undeclared).toMatchObject({ error: { code: "ci-green-undeclared" } });
|
|
378
|
+
const cwd = vi.spyOn(process, "cwd").mockReturnValue(plain);
|
|
379
|
+
try {
|
|
380
|
+
expect((await captured(() => runCiLastGreen(ctx(true)))).code).toBe(1);
|
|
381
|
+
} finally {
|
|
382
|
+
cwd.mockRestore();
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("ls --json serves the block with its defaults", () => {
|
|
387
|
+
const { root } = workspace();
|
|
388
|
+
const doc = listWorkspace({ cwd: root });
|
|
389
|
+
contract(lsSchema).expectValid(doc);
|
|
390
|
+
expect((doc as Extract<LsDocument, { ci: unknown }>).ci!.green).toEqual({
|
|
391
|
+
branch: "main",
|
|
392
|
+
window: "24h",
|
|
393
|
+
phases: [
|
|
394
|
+
{ name: "lint", runs: ["lint"], skipped: "fail" },
|
|
395
|
+
{ name: "test", runs: ["test", "e2e (*)"], skipped: "fail" },
|
|
396
|
+
{ name: "macos", runs: ["macos (test)"], skipped: "pass" },
|
|
397
|
+
],
|
|
398
|
+
require: ["lint", "test", "macos"],
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
});
|