@intentius/chant 0.107.1 → 0.108.0
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 +6 -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 +16 -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 +82 -0
- package/dist/workspace/ci-green-workflow.d.ts.map +1 -0
- package/dist/workspace/ci-green.d.ts +185 -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 +39 -0
- package/src/cli/plugins.ts +16 -2
- package/src/cli/registry.ts +6 -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 +101 -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 +198 -0
- package/src/workspace/ci-green-workflow.ts +215 -0
- package/src/workspace/ci-green.test.ts +375 -0
- package/src/workspace/ci-green.ts +408 -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,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #3573 — the root CI file that runs `chant ci tick`: which workflows trigger
|
|
3
|
+
* it, what it says, and `chant ci workflow` writing and recording it under
|
|
4
|
+
* the generated-file rules (ws-042, #2524 D14).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { afterAll, describe, expect, test, vi } from "vitest";
|
|
10
|
+
import { GENERATED_MARKER } from "../discovery/files";
|
|
11
|
+
import type { CommandContext } from "../cli/registry";
|
|
12
|
+
import { parseYAMLDocument } from "../yaml";
|
|
13
|
+
import { runCiWorkflow } from "./ci-green-cli";
|
|
14
|
+
import { CI_GREEN_WORKFLOW_FILE, jobCheckRuns, renderCiGreenWorkflow, workflowsHolding } from "./ci-green-workflow";
|
|
15
|
+
import { parseDeclaration } from "./declaration";
|
|
16
|
+
import { cleanScratch, declaration, git, scratchDir, writeFiles } from "./__fixtures__/contract-repo";
|
|
17
|
+
|
|
18
|
+
afterAll(cleanScratch);
|
|
19
|
+
|
|
20
|
+
/** A parsed workflow, read by key path in the assertions. */
|
|
21
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
22
|
+
type Yaml = Record<string, any>;
|
|
23
|
+
|
|
24
|
+
const GREEN = {
|
|
25
|
+
branch: "main",
|
|
26
|
+
phases: { lint: ["lint"], test: ["test", "e2e (*)"], macos: { runs: ["macos (test)"], skipped: "pass" }, docs: ["docs"] },
|
|
27
|
+
require: ["lint", "test", "macos"],
|
|
28
|
+
};
|
|
29
|
+
const green = parseDeclaration(JSON.stringify({ name: "w", schema: 1, members: [], ci: { green: GREEN } }), "chant.workspace.json").ci!.green!;
|
|
30
|
+
|
|
31
|
+
const CI = `name: ci
|
|
32
|
+
on: push
|
|
33
|
+
jobs:
|
|
34
|
+
lint:
|
|
35
|
+
runs-on: ubuntu-latest
|
|
36
|
+
test:
|
|
37
|
+
runs-on: ubuntu-latest
|
|
38
|
+
e2e:
|
|
39
|
+
strategy:
|
|
40
|
+
matrix:
|
|
41
|
+
browser: [chromium, webkit]
|
|
42
|
+
`;
|
|
43
|
+
const MACOS = `on: push
|
|
44
|
+
jobs:
|
|
45
|
+
mac:
|
|
46
|
+
name: macos (\${{ matrix.target }})
|
|
47
|
+
strategy:
|
|
48
|
+
matrix:
|
|
49
|
+
target: [test, webkit]
|
|
50
|
+
`;
|
|
51
|
+
const DOCS = `name: docs
|
|
52
|
+
jobs:
|
|
53
|
+
docs:
|
|
54
|
+
runs-on: ubuntu-latest
|
|
55
|
+
`;
|
|
56
|
+
|
|
57
|
+
describe("the workflows that hold required check runs (#3573)", () => {
|
|
58
|
+
test("a job reports its name or id, with matrix values or a called workflow's job after it", () => {
|
|
59
|
+
expect(jobCheckRuns(parseYAMLDocument(CI))).toEqual(["lint", "test", "e2e", "e2e (*)"]);
|
|
60
|
+
expect(jobCheckRuns(parseYAMLDocument(MACOS))).toEqual(["macos (*)"]);
|
|
61
|
+
expect(jobCheckRuns({ jobs: { deploy: { uses: "./.github/workflows/deploy.yml" } } })).toEqual(["deploy", "deploy / *"]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("keeps the workflows whose jobs meet a required phase's pattern, by name or by path", () => {
|
|
65
|
+
const scan = workflowsHolding(green, [
|
|
66
|
+
{ path: ".github/workflows/ci.yml", text: CI },
|
|
67
|
+
{ path: ".github/workflows/macos.yml", text: MACOS },
|
|
68
|
+
{ path: ".github/workflows/docs.yml", text: DOCS },
|
|
69
|
+
{ path: ".github/workflows/broken.yml", text: "jobs: [" },
|
|
70
|
+
]);
|
|
71
|
+
// docs is a declared phase but not a required one, so its workflow does not trigger a tick.
|
|
72
|
+
expect(scan).toEqual({ workflows: [".github/workflows/macos.yml", "ci"], unmatched: [], unreadable: [".github/workflows/broken.yml"] });
|
|
73
|
+
expect(workflowsHolding(green, [{ path: ".github/workflows/ci.yml", text: CI }]).unmatched).toEqual([{ phase: "macos", pattern: "macos (test)" }]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("runs the tick on completion of each, on a 15-minute cron, one at a time, with the rights it needs", () => {
|
|
77
|
+
const text = renderCiGreenWorkflow({ green, workflows: ["ci", "macos"], chantVersion: "0.108.0", workspaceRoot: "." });
|
|
78
|
+
const doc = parseYAMLDocument(text) as Yaml;
|
|
79
|
+
expect(doc.name).toBe("chant-ci-green");
|
|
80
|
+
expect(doc.on.workflow_run).toEqual({ workflows: ["ci", "macos"], types: ["completed"], branches: ["main"] });
|
|
81
|
+
expect(doc.on.schedule).toEqual([{ cron: "*/15 * * * *" }]);
|
|
82
|
+
expect(doc.concurrency).toEqual({ group: "chant-ci-green", "cancel-in-progress": false });
|
|
83
|
+
expect(doc.permissions).toEqual({ contents: "write", checks: "read" });
|
|
84
|
+
const steps = doc.jobs.tick.steps;
|
|
85
|
+
expect(steps[0]).toEqual({ uses: "actions/checkout@v6", with: { ref: "main", "fetch-depth": 0 } });
|
|
86
|
+
expect(steps[2].run).toBe("npx --yes @intentius/chant@0.108.0 ci tick");
|
|
87
|
+
expect(steps[2]["working-directory"]).toBeUndefined();
|
|
88
|
+
expect(steps[2].env.GITHUB_TOKEN).toBe("${{ github.token }}");
|
|
89
|
+
const nested = parseYAMLDocument(renderCiGreenWorkflow({ green, workflows: ["ci"], chantVersion: "0.108.0", workspaceRoot: "infra" })) as Yaml;
|
|
90
|
+
expect(nested.jobs.tick.steps[2]["working-directory"]).toBe("infra");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("--chant replaces the npx pin, and --install adds a cached install step before the tick", () => {
|
|
94
|
+
const own = parseYAMLDocument(
|
|
95
|
+
renderCiGreenWorkflow({ green, workflows: ["ci"], chantVersion: "0.108.0", workspaceRoot: ".", chant: "npx tsx packages/core/src/cli/main.ts", install: "npm install --ignore-scripts", npmLock: "package-lock.json" }),
|
|
96
|
+
) as Yaml;
|
|
97
|
+
const steps = own.jobs.tick.steps;
|
|
98
|
+
expect(steps[1].with).toEqual({ "node-version": "24", cache: "npm" });
|
|
99
|
+
expect(steps[2]).toEqual({ name: "Install what the tick runs", run: "npm install --ignore-scripts" });
|
|
100
|
+
expect(steps[3].run).toBe("npx tsx packages/core/src/cli/main.ts ci tick");
|
|
101
|
+
|
|
102
|
+
// In a nested workspace root both steps run there, and the cache keys on that root's lockfile.
|
|
103
|
+
const nested = parseYAMLDocument(
|
|
104
|
+
renderCiGreenWorkflow({ green, workflows: ["ci"], chantVersion: "0.108.0", workspaceRoot: "infra", chant: "npx chant", install: "npm ci", npmLock: "infra/package-lock.json" }),
|
|
105
|
+
) as Yaml;
|
|
106
|
+
expect(nested.jobs.tick.steps[1].with).toEqual({ "node-version": "24", cache: "npm", "cache-dependency-path": "infra/package-lock.json" });
|
|
107
|
+
expect(nested.jobs.tick.steps[2]["working-directory"]).toBe("infra");
|
|
108
|
+
expect(nested.jobs.tick.steps[3]["working-directory"]).toBe("infra");
|
|
109
|
+
|
|
110
|
+
// A command YAML would misread as a mapping or a comment stays one string.
|
|
111
|
+
const odd = parseYAMLDocument(renderCiGreenWorkflow({ green, workflows: ["ci"], chantVersion: "0.108.0", workspaceRoot: ".", chant: "env A=b: c # x chant" })) as Yaml;
|
|
112
|
+
expect(odd.jobs.tick.steps).toHaveLength(3);
|
|
113
|
+
expect(odd.jobs.tick.steps[1].with).toEqual({ "node-version": "24" });
|
|
114
|
+
expect(odd.jobs.tick.steps[2].run).toBe("env A=b: c # x chant ci tick");
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("chant ci workflow (#3573)", () => {
|
|
119
|
+
test("writes the file with its header, records it on the member it ran in, and writes the same bytes twice", async () => {
|
|
120
|
+
const root = scratchDir("chant-ci-workflow-");
|
|
121
|
+
git(root, "init", "-q");
|
|
122
|
+
writeFiles(root, {
|
|
123
|
+
"chant.workspace.json": declaration([{ name: "root", dir: ".", kind: "other", because: "the repository" }], { ci: { green: GREEN } }),
|
|
124
|
+
".github/workflows/ci.yml": CI,
|
|
125
|
+
".github/workflows/macos.yml": MACOS,
|
|
126
|
+
});
|
|
127
|
+
const cwd = vi.spyOn(process, "cwd").mockReturnValue(root);
|
|
128
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
129
|
+
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
130
|
+
try {
|
|
131
|
+
const ctx = { args: { ciWorkflows: ["release"] } } as unknown as CommandContext;
|
|
132
|
+
expect(await runCiWorkflow(ctx)).toBe(0);
|
|
133
|
+
const file = join(root, CI_GREEN_WORKFLOW_FILE);
|
|
134
|
+
const first = readFileSync(file, "utf-8");
|
|
135
|
+
expect(first.split("\n")[0]).toBe(`# ${GENERATED_MARKER}. Regenerate with: chant ci workflow --workflow release (in the member's directory)`);
|
|
136
|
+
expect((parseYAMLDocument(first) as Yaml).on.workflow_run.workflows).toEqual([".github/workflows/macos.yml", "ci", "release"]);
|
|
137
|
+
const record = JSON.parse(readFileSync(join(root, ".chant", "generated.json"), "utf-8"));
|
|
138
|
+
expect(record.files).toEqual([{ path: CI_GREEN_WORKFLOW_FILE, command: "chant ci workflow --workflow release" }]);
|
|
139
|
+
// The scan skips the file it writes, so regenerating is byte-identical.
|
|
140
|
+
expect(await runCiWorkflow(ctx)).toBe(0);
|
|
141
|
+
expect(readFileSync(file, "utf-8")).toBe(first);
|
|
142
|
+
} finally {
|
|
143
|
+
cwd.mockRestore();
|
|
144
|
+
log.mockRestore();
|
|
145
|
+
error.mockRestore();
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("--chant and --install go into the file and its header, and regenerate byte-identically", async () => {
|
|
150
|
+
const root = scratchDir("chant-ci-workflow-");
|
|
151
|
+
git(root, "init", "-q");
|
|
152
|
+
writeFiles(root, {
|
|
153
|
+
"chant.workspace.json": declaration([], { ci: { green: GREEN } }),
|
|
154
|
+
"package-lock.json": "{}",
|
|
155
|
+
".github/workflows/ci.yml": CI,
|
|
156
|
+
".github/workflows/macos.yml": MACOS,
|
|
157
|
+
});
|
|
158
|
+
const cwd = vi.spyOn(process, "cwd").mockReturnValue(root);
|
|
159
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
160
|
+
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
161
|
+
try {
|
|
162
|
+
const ctx = { args: { ciChant: "npx tsx packages/core/src/cli/main.ts", ciInstall: "npm install --ignore-scripts" } } as unknown as CommandContext;
|
|
163
|
+
expect(await runCiWorkflow(ctx)).toBe(0);
|
|
164
|
+
const file = join(root, CI_GREEN_WORKFLOW_FILE);
|
|
165
|
+
const first = readFileSync(file, "utf-8");
|
|
166
|
+
expect(first.split("\n")[0]).toBe(
|
|
167
|
+
`# ${GENERATED_MARKER}. Regenerate with: chant ci workflow --chant 'npx tsx packages/core/src/cli/main.ts' --install 'npm install --ignore-scripts' (in the workspace root)`,
|
|
168
|
+
);
|
|
169
|
+
const steps = (parseYAMLDocument(first) as Yaml).jobs.tick.steps;
|
|
170
|
+
expect(steps[1].with.cache).toBe("npm");
|
|
171
|
+
expect(steps[2].run).toBe("npm install --ignore-scripts");
|
|
172
|
+
expect(steps[3].run).toBe("npx tsx packages/core/src/cli/main.ts ci tick");
|
|
173
|
+
expect(await runCiWorkflow(ctx)).toBe(0);
|
|
174
|
+
expect(readFileSync(file, "utf-8")).toBe(first);
|
|
175
|
+
} finally {
|
|
176
|
+
cwd.mockRestore();
|
|
177
|
+
log.mockRestore();
|
|
178
|
+
error.mockRestore();
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("refuses to write a workflow nothing but the schedule would trigger", async () => {
|
|
183
|
+
const root = scratchDir("chant-ci-workflow-");
|
|
184
|
+
git(root, "init", "-q");
|
|
185
|
+
writeFiles(root, { "chant.workspace.json": declaration([{ name: "root", dir: ".", kind: "other", because: "x" }], { ci: { green: GREEN } }) });
|
|
186
|
+
const cwd = vi.spyOn(process, "cwd").mockReturnValue(root);
|
|
187
|
+
const errors: string[] = [];
|
|
188
|
+
const error = vi.spyOn(console, "error").mockImplementation((s: unknown) => void errors.push(String(s)));
|
|
189
|
+
try {
|
|
190
|
+
expect(await runCiWorkflow({ args: {} } as unknown as CommandContext)).toBe(1);
|
|
191
|
+
expect(errors.join("\n")).toMatch(/--workflow <name>/);
|
|
192
|
+
expect(existsSync(join(root, CI_GREEN_WORKFLOW_FILE))).toBe(false);
|
|
193
|
+
} finally {
|
|
194
|
+
cwd.mockRestore();
|
|
195
|
+
error.mockRestore();
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
});
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The root CI file that runs `chant ci tick` (#3573, ws-103; ws-042 for its
|
|
3
|
+
* place at the repository root, #2524 D14 for its header and record).
|
|
4
|
+
*
|
|
5
|
+
* `chant ci workflow` writes `.github/workflows/chant-ci-green.yml`. It runs
|
|
6
|
+
* the tick when a workflow that holds a required check run completes on the
|
|
7
|
+
* branch, so a result or a re-run is acted on within seconds, and every 15
|
|
8
|
+
* minutes as a backstop for a dropped or late event. One concurrency group
|
|
9
|
+
* that never cancels keeps ticks one at a time.
|
|
10
|
+
*
|
|
11
|
+
* GitHub's `workflow_run` names workflows, and `ci.green` names check runs,
|
|
12
|
+
* so the generator reads the repository's other workflow files and keeps
|
|
13
|
+
* each workflow with a job whose check-run name meets one of a required
|
|
14
|
+
* phase's patterns. A job's check run is its `name`, or its id, with
|
|
15
|
+
* ` (<matrix values>)` after it for a matrix job and ` / <job>` after it for
|
|
16
|
+
* a job that calls a reusable workflow; a `${{ }}` expression stands for
|
|
17
|
+
* anything. `--workflow <name>` adds a workflow the scan can't see.
|
|
18
|
+
*
|
|
19
|
+
* The tick runs the published chant that wrote the file, through npx.
|
|
20
|
+
* `--chant <command>` runs another one instead, and `--install <command>`
|
|
21
|
+
* adds a step that installs it, with setup-node caching npm's downloads when
|
|
22
|
+
* the workspace root has a `package-lock.json`. chant's own repository uses
|
|
23
|
+
* both to run its source at the commit it checks out.
|
|
24
|
+
*
|
|
25
|
+
* Like a member's pipelines (./member-pipeline.ts), the file starts with the
|
|
26
|
+
* generated-file header naming the command, and the member whose directory
|
|
27
|
+
* the command runs in records it in its generated-file record, so `WSP081`
|
|
28
|
+
* allows one declarer.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
32
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
33
|
+
import { GENERATED_MARKER } from "../discovery/files";
|
|
34
|
+
import { parseYAMLDocument } from "../yaml";
|
|
35
|
+
import type { CiGreen } from "./declaration";
|
|
36
|
+
import { matchesCheckRun } from "./ci-green";
|
|
37
|
+
|
|
38
|
+
/** Where the workflow goes, relative to the repository root. */
|
|
39
|
+
export const CI_GREEN_WORKFLOW_FILE = ".github/workflows/chant-ci-green.yml";
|
|
40
|
+
export const CI_GREEN_WORKFLOW_NAME = "chant-ci-green";
|
|
41
|
+
|
|
42
|
+
/** One workflow file as the scan reads it. */
|
|
43
|
+
export interface WorkflowSource {
|
|
44
|
+
/** Relative to the repository root, such as `.github/workflows/ci.yml`. */
|
|
45
|
+
path: string;
|
|
46
|
+
text: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
50
|
+
const EXPRESSION = /\$\{\{[^}]*\}\}/g;
|
|
51
|
+
|
|
52
|
+
/** The check-run names a workflow's jobs report, as patterns: `*` stands for what an expression or a matrix fills in. */
|
|
53
|
+
export function jobCheckRuns(doc: unknown): string[] {
|
|
54
|
+
if (!isObject(doc) || !isObject(doc.jobs)) return [];
|
|
55
|
+
const out: string[] = [];
|
|
56
|
+
for (const [id, job] of Object.entries(doc.jobs)) {
|
|
57
|
+
if (!isObject(job)) continue;
|
|
58
|
+
const named = typeof job.name === "string" ? job.name : id;
|
|
59
|
+
const base = named.replace(EXPRESSION, "*");
|
|
60
|
+
out.push(base);
|
|
61
|
+
if (isObject(job.strategy) && job.strategy.matrix !== undefined && base === named) out.push(`${base} (*)`);
|
|
62
|
+
if (typeof job.uses === "string") out.push(`${base} / *`);
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Whether two patterns can name the same check run: either matches the other with its `*` filled in. */
|
|
68
|
+
function meet(a: string, b: string): boolean {
|
|
69
|
+
return matchesCheckRun(a, b.replace(/\*/g, "x")) || matchesCheckRun(b, a.replace(/\*/g, "x"));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The name `workflow_run` knows a workflow by: its `name`, or its path when it has none or the name is an expression. */
|
|
73
|
+
function workflowName(doc: unknown, path: string): string {
|
|
74
|
+
return isObject(doc) && typeof doc.name === "string" && !doc.name.includes("${{") ? doc.name : path;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface WorkflowScan {
|
|
78
|
+
/** The workflows holding a required check run, sorted. */
|
|
79
|
+
workflows: string[];
|
|
80
|
+
/** Required patterns no job of any workflow meets. */
|
|
81
|
+
unmatched: { phase: string; pattern: string }[];
|
|
82
|
+
/** Workflow files that could not be parsed. */
|
|
83
|
+
unreadable: string[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Find the workflows that hold the required phases' check runs. */
|
|
87
|
+
export function workflowsHolding(green: CiGreen, sources: readonly WorkflowSource[]): WorkflowScan {
|
|
88
|
+
const patterns = green.require.flatMap((name) => green.phases.find((p) => p.name === name)!.runs.map((pattern) => ({ phase: name, pattern })));
|
|
89
|
+
const workflows = new Set<string>();
|
|
90
|
+
const met = new Set<string>();
|
|
91
|
+
const unreadable: string[] = [];
|
|
92
|
+
for (const s of sources) {
|
|
93
|
+
let doc: unknown;
|
|
94
|
+
try {
|
|
95
|
+
doc = parseYAMLDocument(s.text);
|
|
96
|
+
} catch {
|
|
97
|
+
unreadable.push(s.path);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const runs = jobCheckRuns(doc);
|
|
101
|
+
for (const p of patterns) {
|
|
102
|
+
if (!runs.some((r) => meet(p.pattern, r))) continue;
|
|
103
|
+
workflows.add(workflowName(doc, s.path));
|
|
104
|
+
met.add(`${p.phase}\0${p.pattern}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
workflows: [...workflows].sort(),
|
|
109
|
+
unmatched: patterns.filter((p) => !met.has(`${p.phase}\0${p.pattern}`)),
|
|
110
|
+
unreadable,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The repository's GitHub workflow files, other than `skip`. */
|
|
115
|
+
export function readWorkflowSources(repoRoot: string, skip: string): WorkflowSource[] {
|
|
116
|
+
const dir = join(repoRoot, ".github", "workflows");
|
|
117
|
+
if (!existsSync(dir)) return [];
|
|
118
|
+
return readdirSync(dir)
|
|
119
|
+
.filter((f) => /\.ya?ml$/.test(f))
|
|
120
|
+
.sort()
|
|
121
|
+
.map((f) => `.github/workflows/${f}`)
|
|
122
|
+
.filter((p) => p !== skip)
|
|
123
|
+
.map((path) => ({ path, text: readFileSync(join(repoRoot, path), "utf-8") }));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const q = (s: string) => JSON.stringify(s);
|
|
127
|
+
|
|
128
|
+
export interface WorkflowInput {
|
|
129
|
+
green: CiGreen;
|
|
130
|
+
/** The workflows whose completion triggers a tick. */
|
|
131
|
+
workflows: readonly string[];
|
|
132
|
+
/** The chant the tick runs, `@intentius/chant` at this version. */
|
|
133
|
+
chantVersion: string;
|
|
134
|
+
/**
|
|
135
|
+
* The command that runs chant, with `ci tick` after it, in place of
|
|
136
|
+
* `npx --yes @intentius/chant@<chantVersion>`. chant's own repository runs
|
|
137
|
+
* its source this way, so the tick tests the commit it checks out.
|
|
138
|
+
*/
|
|
139
|
+
chant?: string;
|
|
140
|
+
/** A step before the tick that installs what `chant` needs, run where the tick runs. */
|
|
141
|
+
install?: string;
|
|
142
|
+
/** With `install`, the `package-lock.json` setup-node caches npm's downloads by, from the repository root. */
|
|
143
|
+
npmLock?: string;
|
|
144
|
+
/** The workspace root relative to the repository root, `.` for the root itself. */
|
|
145
|
+
workspaceRoot: string;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The workflow's YAML, without the generated-file header. Deterministic: the same input writes the same bytes. */
|
|
149
|
+
export function renderCiGreenWorkflow(input: WorkflowInput): string {
|
|
150
|
+
const { green } = input;
|
|
151
|
+
const where = input.workspaceRoot === "." ? [] : [` working-directory: ${q(input.workspaceRoot)}`];
|
|
152
|
+
const cache = input.install && input.npmLock ? [" cache: npm", ...(input.npmLock === "package-lock.json" ? [] : [` cache-dependency-path: ${q(input.npmLock)}`])] : [];
|
|
153
|
+
const tick = input.chant ? q(`${input.chant} ci tick`) : `npx --yes @intentius/chant@${input.chantVersion} ci tick`;
|
|
154
|
+
const lines = [
|
|
155
|
+
`name: ${CI_GREEN_WORKFLOW_NAME}`,
|
|
156
|
+
"",
|
|
157
|
+
"# Tags each commit on the branch that passed its required phases ci/green/<sha>,",
|
|
158
|
+
"# and a green commit that later fails one ci/revoked/<sha> (#3573).",
|
|
159
|
+
"on:",
|
|
160
|
+
" workflow_run:",
|
|
161
|
+
" workflows:",
|
|
162
|
+
...input.workflows.map((w) => ` - ${q(w)}`),
|
|
163
|
+
" types:",
|
|
164
|
+
" - completed",
|
|
165
|
+
" branches:",
|
|
166
|
+
` - ${q(green.branch)}`,
|
|
167
|
+
" schedule:",
|
|
168
|
+
" - cron: \"*/15 * * * *\"",
|
|
169
|
+
" workflow_dispatch: {}",
|
|
170
|
+
"",
|
|
171
|
+
"# One tick at a time, and a tick in progress is never cancelled.",
|
|
172
|
+
"concurrency:",
|
|
173
|
+
` group: ${CI_GREEN_WORKFLOW_NAME}`,
|
|
174
|
+
" cancel-in-progress: false",
|
|
175
|
+
"",
|
|
176
|
+
"permissions:",
|
|
177
|
+
" contents: write",
|
|
178
|
+
" checks: read",
|
|
179
|
+
"",
|
|
180
|
+
"jobs:",
|
|
181
|
+
" tick:",
|
|
182
|
+
" runs-on: ubuntu-latest",
|
|
183
|
+
" timeout-minutes: 15",
|
|
184
|
+
" steps:",
|
|
185
|
+
" - uses: actions/checkout@v6",
|
|
186
|
+
" with:",
|
|
187
|
+
` ref: ${q(green.branch)}`,
|
|
188
|
+
" fetch-depth: 0",
|
|
189
|
+
" - uses: actions/setup-node@v6",
|
|
190
|
+
" with:",
|
|
191
|
+
" node-version: \"24\"",
|
|
192
|
+
...cache,
|
|
193
|
+
...(input.install ? [" - name: Install what the tick runs", ...where, ` run: ${q(input.install)}`] : []),
|
|
194
|
+
" - name: Tag the commits that turned green, revoke the ones that turned red",
|
|
195
|
+
...where,
|
|
196
|
+
" env:",
|
|
197
|
+
" GITHUB_TOKEN: ${{ github.token }}",
|
|
198
|
+
" GIT_COMMITTER_NAME: github-actions[bot]",
|
|
199
|
+
" GIT_COMMITTER_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com",
|
|
200
|
+
` run: ${tick}`,
|
|
201
|
+
];
|
|
202
|
+
return lines.join("\n") + "\n";
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The header line naming the command that regenerates the file, and where to run it. */
|
|
206
|
+
export function ciGreenHeader(command: string, where: "member" | "root"): string {
|
|
207
|
+
return `# ${GENERATED_MARKER}. Regenerate with: ${command} (in the ${where === "member" ? "member's directory" : "workspace root"})\n`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Write the file, creating its directory. Returns the path relative to the current directory. */
|
|
211
|
+
export function writeWorkflowFile(target: string, text: string): string {
|
|
212
|
+
mkdirSync(dirname(resolve(target)), { recursive: true });
|
|
213
|
+
writeFileSync(target, text);
|
|
214
|
+
return relative(process.cwd(), target).split(sep).join("/") || target;
|
|
215
|
+
}
|