@intentius/chant 0.124.29 → 0.124.31
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/build.d.ts.map +1 -1
- package/dist/cli/handlers/build.js +4 -1
- package/dist/cli/handlers/fan-out.d.ts.map +1 -1
- package/dist/cli/handlers/fan-out.js +27 -5
- package/dist/cli/handlers/pr.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +64 -0
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +99 -0
- package/dist/cli/registry.d.ts +35 -1
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/pr-pipeline.d.ts +24 -0
- package/dist/components/pr-pipeline.d.ts.map +1 -1
- package/dist/components/pr-pipeline.js +16 -0
- package/dist/components/pr-run.d.ts +20 -2
- package/dist/components/pr-run.d.ts.map +1 -1
- package/dist/components/pr-run.js +26 -13
- package/dist/components/wave-plan.d.ts +11 -0
- package/dist/components/wave-plan.d.ts.map +1 -1
- package/dist/components/wave-plan.js +26 -0
- package/dist/lifecycle/release-ledger.d.ts +8 -3
- package/dist/lifecycle/release-ledger.d.ts.map +1 -1
- package/dist/lifecycle/release-ledger.js +2 -0
- package/dist/lifecycle/run-ledger.d.ts +1 -1
- package/dist/lifecycle/run-ledger.d.ts.map +1 -1
- package/dist/lifecycle/run-ledger.js +9 -0
- package/dist/op/activities/activity-contracts.d.ts +1 -0
- package/dist/op/activities/activity-contracts.d.ts.map +1 -1
- package/dist/op/activities/activity-contracts.js +1 -0
- package/dist/op/activities/shell.d.ts +6 -0
- package/dist/op/activities/shell.d.ts.map +1 -1
- package/dist/op/activities/shell.js +1 -1
- package/dist/op/local-executor.d.ts +35 -1
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-executor.js +31 -6
- package/dist/op/redact.d.ts +34 -0
- package/dist/op/redact.d.ts.map +1 -0
- package/dist/op/redact.js +94 -0
- package/dist/op/runtime.d.ts +27 -0
- package/dist/op/runtime.d.ts.map +1 -1
- package/dist/op/runtime.js +20 -0
- package/dist/op/step-console.d.ts +2 -0
- package/dist/op/step-console.d.ts.map +1 -1
- package/dist/op/step-console.js +5 -2
- package/dist/pr-forge.d.ts +40 -0
- package/dist/pr-forge.d.ts.map +1 -1
- package/dist/pr-forge.js +56 -6
- package/dist/pr-loop.d.ts +10 -0
- package/dist/pr-loop.d.ts.map +1 -1
- package/dist/pr-loop.js +7 -0
- package/dist/pr-report-url.d.ts +8 -0
- package/dist/pr-report-url.d.ts.map +1 -1
- package/dist/pr-report-url.js +47 -1
- package/dist/run-event-stream.d.ts +2 -2
- package/dist/run-event-stream.d.ts.map +1 -1
- package/dist/run-event-stream.js +2 -2
- package/dist/run-event.d.ts +15 -0
- package/dist/run-event.d.ts.map +1 -1
- package/dist/run-event.js +2 -0
- package/dist/wave-runtime.mjs +171 -171
- package/dist/workspace/ci-green-forge.d.ts +2 -0
- package/dist/workspace/ci-green-forge.d.ts.map +1 -1
- package/dist/workspace/ci-green.d.ts +2 -0
- package/dist/workspace/ci-green.d.ts.map +1 -1
- package/dist/workspace/ci-green.js +4 -2
- package/dist/workspace/ci-run-index.d.ts +96 -0
- package/dist/workspace/ci-run-index.d.ts.map +1 -0
- package/dist/workspace/ci-run-index.js +162 -0
- package/dist/workspace/ci-run-index.schema.json +50 -0
- package/dist/workspace/ci-run.d.ts +299 -0
- package/dist/workspace/ci-run.d.ts.map +1 -0
- package/dist/workspace/ci-run.js +691 -0
- package/dist/workspace/ci-runner-key.d.ts +120 -0
- package/dist/workspace/ci-runner-key.d.ts.map +1 -0
- package/dist/workspace/ci-runner-key.js +217 -0
- package/dist/workspace/ci-secrets.d.ts +65 -0
- package/dist/workspace/ci-secrets.d.ts.map +1 -0
- package/dist/workspace/ci-secrets.js +211 -0
- package/dist/workspace/ci-serve.d.ts +227 -0
- package/dist/workspace/ci-serve.d.ts.map +1 -0
- package/dist/workspace/ci-serve.js +468 -0
- package/dist/workspace/pr-report.schema.json +5 -0
- package/dist/workspace/reason-codes.d.ts +6 -2
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/reason-codes.js +7 -2
- package/dist/workspace/run-event.schema.json +14 -3
- package/dist/workspace/status-stewards.d.ts +2 -0
- package/dist/workspace/status-stewards.d.ts.map +1 -1
- package/dist/workspace/status-stewards.js +1 -0
- package/dist/workspace/status.schema.json +12 -1
- package/dist/workspace/trust/trust-write.d.ts +109 -0
- package/dist/workspace/trust/trust-write.d.ts.map +1 -0
- package/dist/workspace/trust/trust-write.js +320 -0
- package/dist/workspace/trust-write.schema.json +265 -0
- package/package.json +1 -1
- package/src/cli/handlers/build.test.ts +15 -0
- package/src/cli/handlers/build.ts +4 -1
- package/src/cli/handlers/fan-out.test.ts +82 -0
- package/src/cli/handlers/fan-out.ts +34 -5
- package/src/cli/handlers/pr.ts +5 -0
- package/src/cli/main.test.ts +1 -0
- package/src/cli/main.ts +64 -0
- package/src/cli/parse-args.ts +71 -0
- package/src/cli/registry.ts +35 -1
- package/src/components/pr-pipeline.ts +32 -0
- package/src/components/pr-run.test.ts +127 -0
- package/src/components/pr-run.ts +32 -12
- package/src/components/wave-plan.ts +30 -0
- package/src/lifecycle/release-ledger.ts +9 -3
- package/src/lifecycle/run-ledger.ts +7 -1
- package/src/op/activities/activity-contracts.ts +1 -0
- package/src/op/activities/shell.ts +7 -1
- package/src/op/local-executor.ts +58 -7
- package/src/op/redact.ts +93 -0
- package/src/op/runtime.ts +42 -0
- package/src/op/step-console.ts +7 -2
- package/src/pr-forge.test.ts +54 -0
- package/src/pr-forge.ts +92 -6
- package/src/pr-loop.test.ts +13 -1
- package/src/pr-loop.ts +16 -0
- package/src/pr-report-url.ts +53 -1
- package/src/run-event-stream.ts +4 -3
- package/src/run-event.ts +17 -0
- package/src/workspace/ci-green-forge.ts +2 -0
- package/src/workspace/ci-green.ts +6 -2
- package/src/workspace/ci-run-index.schema.json +50 -0
- package/src/workspace/ci-run-index.test.ts +353 -0
- package/src/workspace/ci-run-index.ts +192 -0
- package/src/workspace/ci-run.test.ts +716 -0
- package/src/workspace/ci-run.ts +833 -0
- package/src/workspace/ci-runner-key.test.ts +166 -0
- package/src/workspace/ci-runner-key.ts +250 -0
- package/src/workspace/ci-secrets.test.ts +106 -0
- package/src/workspace/ci-secrets.ts +213 -0
- package/src/workspace/ci-serve.test.ts +367 -0
- package/src/workspace/ci-serve.ts +574 -0
- package/src/workspace/pr-report.schema.json +5 -0
- package/src/workspace/reason-codes.test.ts +2 -0
- package/src/workspace/reason-codes.ts +7 -2
- package/src/workspace/run-event.schema.json +14 -3
- package/src/workspace/status-stewards.ts +3 -0
- package/src/workspace/status.schema.json +12 -1
- package/src/workspace/trust/trust-write.test.ts +179 -0
- package/src/workspace/trust/trust-write.ts +377 -0
- package/src/workspace/trust-write.schema.json +265 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The chant runner's secrets file (#4096, epic #4101): the values a job gets
|
|
3
|
+
* from the host, such as cloud credentials and the endpoint, which a forge
|
|
4
|
+
* job would get from the forge's secrets.
|
|
5
|
+
*
|
|
6
|
+
* `chant ci run` and `chant ci serve` read it from `--secrets <file>`, or
|
|
7
|
+
* the file `CHANT_CI_SECRETS` names. It is a dotenv file of `NAME=value`
|
|
8
|
+
* lines, or a JSON object:
|
|
9
|
+
*
|
|
10
|
+
* - a map of name to value, `{ "AWS_SECRET_ACCESS_KEY": "..." }`;
|
|
11
|
+
* - or `{ "secrets": { ... }, "jobs": { "plan": [...], "apply": [...] } }`,
|
|
12
|
+
* where `jobs` lists, for a job, the names it gets. A job `jobs` doesn't
|
|
13
|
+
* name gets every secret.
|
|
14
|
+
*
|
|
15
|
+
* The allowlist lives in this file on the host, not in the repository, so a
|
|
16
|
+
* pull request can't widen what its plan gets.
|
|
17
|
+
*
|
|
18
|
+
* The file must belong to the user reading it and give group and others no
|
|
19
|
+
* permission at all, as ssh asks of a private key (`chmod 600`). A file
|
|
20
|
+
* that does is refused before any job runs.
|
|
21
|
+
*
|
|
22
|
+
* Every value in the file, and every value of the job's environment whose
|
|
23
|
+
* name looks secret ({@link SECRET_NAME}), is redacted from what the run
|
|
24
|
+
* writes about itself (../op/redact.ts).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { readFileSync, statSync } from "node:fs";
|
|
28
|
+
import { redactorOf, type Redact } from "../op/redact";
|
|
29
|
+
import { RUNNER_KEY_ENV } from "./ci-green";
|
|
30
|
+
import type { CiJobName } from "./ci-run";
|
|
31
|
+
|
|
32
|
+
/** The variable that names the secrets file when `--secrets` is not given. */
|
|
33
|
+
export const CI_SECRETS_ENV = "CHANT_CI_SECRETS";
|
|
34
|
+
|
|
35
|
+
/** A variable whose name matches this is taken as secret, and its value redacted. */
|
|
36
|
+
export const SECRET_NAME = /TOKEN|SECRET|KEY|PASSWORD/i;
|
|
37
|
+
|
|
38
|
+
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
39
|
+
|
|
40
|
+
const JOBS: readonly CiJobName[] = ["plan", "apply"];
|
|
41
|
+
|
|
42
|
+
/** The secrets file, read. */
|
|
43
|
+
export interface CiSecrets {
|
|
44
|
+
/** Where it was read from. */
|
|
45
|
+
file: string;
|
|
46
|
+
/** Every secret, by name. */
|
|
47
|
+
values: Record<string, string>;
|
|
48
|
+
/** For a job the file names, the secrets it gets. A job missing here gets every one. */
|
|
49
|
+
jobs: Partial<Record<CiJobName, string[]>>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class CiSecretsError extends Error {
|
|
53
|
+
constructor(message: string) {
|
|
54
|
+
super(message);
|
|
55
|
+
this.name = "CiSecretsError";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Refuse a secrets file anyone but its owner can read or write, or that
|
|
61
|
+
* belongs to another user, as ssh refuses a loose key. Not checked on
|
|
62
|
+
* Windows, whose files carry no such mode.
|
|
63
|
+
*/
|
|
64
|
+
export function checkCiSecretsMode(file: string): void {
|
|
65
|
+
let st: ReturnType<typeof statSync>;
|
|
66
|
+
try {
|
|
67
|
+
st = statSync(file);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
throw new CiSecretsError(`the secrets file ${file} can't be read: ${(err as NodeJS.ErrnoException).code ?? String(err)}`);
|
|
70
|
+
}
|
|
71
|
+
if (!st.isFile()) throw new CiSecretsError(`the secrets file ${file} is not a file`);
|
|
72
|
+
if (process.platform === "win32") return;
|
|
73
|
+
const loose = Number(st.mode) & 0o077;
|
|
74
|
+
if (loose !== 0) {
|
|
75
|
+
const mode = (Number(st.mode) & 0o777).toString(8).padStart(4, "0");
|
|
76
|
+
throw new CiSecretsError(
|
|
77
|
+
`the secrets file ${file} has mode ${mode}, so its group or other users can ${loose & 0o044 ? "read" : "change"} it; a secrets file must be readable only by its owner, as ssh asks of a key: chmod 600 ${file}`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
|
|
81
|
+
if (uid !== undefined && Number(st.uid) !== uid) {
|
|
82
|
+
throw new CiSecretsError(`the secrets file ${file} belongs to uid ${st.uid}, not to this user (uid ${uid}); a secrets file must be its reader's own`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A dotenv text's `NAME=value` pairs. Errors name the line, never a value. */
|
|
87
|
+
export function parseDotenv(text: string, file: string): Record<string, string> {
|
|
88
|
+
const out: Record<string, string> = {};
|
|
89
|
+
let i = 0;
|
|
90
|
+
let line = 1;
|
|
91
|
+
const fail = (why: string): never => {
|
|
92
|
+
throw new CiSecretsError(`${file}:${line}: ${why}`);
|
|
93
|
+
};
|
|
94
|
+
while (i < text.length) {
|
|
95
|
+
// One line: blank, a comment, or NAME=value.
|
|
96
|
+
const eol = text.indexOf("\n", i);
|
|
97
|
+
const rest = text.slice(i, eol === -1 ? text.length : eol);
|
|
98
|
+
const trimmed = rest.trim();
|
|
99
|
+
if (trimmed === "" || trimmed.startsWith("#")) {
|
|
100
|
+
i = eol === -1 ? text.length : eol + 1;
|
|
101
|
+
line++;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*/.exec(rest);
|
|
105
|
+
if (!m) fail("expected NAME=value");
|
|
106
|
+
const name = m![1]!;
|
|
107
|
+
let j = i + m![0].length;
|
|
108
|
+
const startLine = line;
|
|
109
|
+
let value = "";
|
|
110
|
+
const q = text[j];
|
|
111
|
+
if (q === '"' || q === "'") {
|
|
112
|
+
j++;
|
|
113
|
+
for (;;) {
|
|
114
|
+
if (j >= text.length) {
|
|
115
|
+
line = startLine;
|
|
116
|
+
fail(`the value of ${name} has no closing ${q}`);
|
|
117
|
+
}
|
|
118
|
+
const c = text[j]!;
|
|
119
|
+
if (c === q) break;
|
|
120
|
+
if (c === "\n") line++;
|
|
121
|
+
if (q === '"' && c === "\\" && j + 1 < text.length) {
|
|
122
|
+
const n = text[j + 1]!;
|
|
123
|
+
value += n === "n" ? "\n" : n === "r" ? "\r" : n === "t" ? "\t" : n;
|
|
124
|
+
j += 2;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
value += c;
|
|
128
|
+
j++;
|
|
129
|
+
}
|
|
130
|
+
j++;
|
|
131
|
+
const end = text.indexOf("\n", j);
|
|
132
|
+
const after = text.slice(j, end === -1 ? text.length : end).trim();
|
|
133
|
+
if (after !== "" && !after.startsWith("#")) fail(`unexpected text after the quoted value of ${name}`);
|
|
134
|
+
i = end === -1 ? text.length : end + 1;
|
|
135
|
+
} else {
|
|
136
|
+
const end = text.indexOf("\n", j);
|
|
137
|
+
value = text.slice(j, end === -1 ? text.length : end).replace(/\s+#.*$/, "").trim();
|
|
138
|
+
i = end === -1 ? text.length : end + 1;
|
|
139
|
+
}
|
|
140
|
+
if (Object.prototype.hasOwnProperty.call(out, name)) fail(`${name} is set twice`);
|
|
141
|
+
out[name] = value;
|
|
142
|
+
line++;
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function checkName(name: string, file: string): void {
|
|
148
|
+
if (!ENV_NAME.test(name)) throw new CiSecretsError(`${file} names a secret ${JSON.stringify(name)}, which is not a variable name`);
|
|
149
|
+
if (name === RUNNER_KEY_ENV) throw new CiSecretsError(`${file} sets ${RUNNER_KEY_ENV}, and the runner key is never passed to a job; give it to ci run or ci serve with --key or ${RUNNER_KEY_ENV}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The secrets in a file's text: JSON when it starts with `{`, else dotenv. */
|
|
153
|
+
export function parseCiSecrets(text: string, file: string): Omit<CiSecrets, "file"> {
|
|
154
|
+
if (!text.trimStart().startsWith("{")) {
|
|
155
|
+
const values = parseDotenv(text, file);
|
|
156
|
+
for (const name of Object.keys(values)) checkName(name, file);
|
|
157
|
+
return { values, jobs: {} };
|
|
158
|
+
}
|
|
159
|
+
let raw: unknown;
|
|
160
|
+
try {
|
|
161
|
+
raw = JSON.parse(text);
|
|
162
|
+
} catch {
|
|
163
|
+
// The parser's message quotes the text near the error, which may be a secret.
|
|
164
|
+
throw new CiSecretsError(`${file} starts with {, and is not valid JSON`);
|
|
165
|
+
}
|
|
166
|
+
const doc = raw as Record<string, unknown>;
|
|
167
|
+
const structured = doc.secrets !== null && typeof doc.secrets === "object" && !Array.isArray(doc.secrets);
|
|
168
|
+
const map = (structured ? doc.secrets : doc) as Record<string, unknown>;
|
|
169
|
+
const values: Record<string, string> = {};
|
|
170
|
+
for (const [name, value] of Object.entries(map)) {
|
|
171
|
+
checkName(name, file);
|
|
172
|
+
if (typeof value !== "string") throw new CiSecretsError(`${file}: the secret ${name} must be a string`);
|
|
173
|
+
values[name] = value;
|
|
174
|
+
}
|
|
175
|
+
const jobs: CiSecrets["jobs"] = {};
|
|
176
|
+
if (structured) {
|
|
177
|
+
for (const key of Object.keys(doc)) if (key !== "secrets" && key !== "jobs") throw new CiSecretsError(`${file} has ${JSON.stringify(key)} beside secrets and jobs`);
|
|
178
|
+
const given = doc.jobs ?? {};
|
|
179
|
+
if (given === null || typeof given !== "object" || Array.isArray(given)) throw new CiSecretsError(`${file}: jobs must map plan and apply to lists of secret names`);
|
|
180
|
+
for (const [job, names] of Object.entries(given as Record<string, unknown>)) {
|
|
181
|
+
if (!(JOBS as readonly string[]).includes(job)) throw new CiSecretsError(`${file}: jobs names ${JSON.stringify(job)}, and the jobs are plan and apply`);
|
|
182
|
+
if (!Array.isArray(names) || names.some((n) => typeof n !== "string")) throw new CiSecretsError(`${file}: jobs.${job} must be a list of secret names`);
|
|
183
|
+
for (const n of names as string[]) if (!Object.prototype.hasOwnProperty.call(values, n)) throw new CiSecretsError(`${file}: jobs.${job} names ${n}, which the file does not set`);
|
|
184
|
+
jobs[job as CiJobName] = [...(names as string[])];
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return { values, jobs };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Read the secrets file, refusing one others can read. */
|
|
191
|
+
export function readCiSecrets(file: string): CiSecrets {
|
|
192
|
+
checkCiSecretsMode(file);
|
|
193
|
+
return { file, ...parseCiSecrets(readFileSync(file, "utf-8"), file) };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The secrets `job` gets: those `jobs` lists for it, else every one. */
|
|
197
|
+
export function ciSecretsFor(secrets: CiSecrets | undefined, job: CiJobName): Record<string, string> {
|
|
198
|
+
if (!secrets) return {};
|
|
199
|
+
const names = secrets.jobs[job] ?? Object.keys(secrets.values);
|
|
200
|
+
return Object.fromEntries(names.map((n) => [n, secrets.values[n]!]));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The redactor for a job: every value in the secrets file, whether the job
|
|
205
|
+
* gets it or not, and the value of each variable in the job's environment
|
|
206
|
+
* whose name looks secret, such as `GITHUB_TOKEN` or a `--pass-env
|
|
207
|
+
* AWS_SECRET_ACCESS_KEY`. Undefined when there is nothing to redact.
|
|
208
|
+
*/
|
|
209
|
+
export function ciRedactor(jobEnv: Record<string, string>, secrets?: CiSecrets): Redact | undefined {
|
|
210
|
+
const values = Object.values(secrets?.values ?? {});
|
|
211
|
+
for (const [name, value] of Object.entries(jobEnv)) if (SECRET_NAME.test(name)) values.push(value);
|
|
212
|
+
return redactorOf(values);
|
|
213
|
+
}
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #4095: `chant ci serve` polls the forge and runs `ci run` plan for each new
|
|
3
|
+
* pull request head and apply for each new target-branch commit, deduped in
|
|
4
|
+
* a state file, one apply per group through a git-ref lease, with each
|
|
5
|
+
* apply's resume record kept on the host, and at most `maxJobs` at once.
|
|
6
|
+
*
|
|
7
|
+
* Every test works in its own temp repository with a bare origin. The forge
|
|
8
|
+
* is a fake client, and the jobs' `chant` is a stub script outside the
|
|
9
|
+
* repository. Each test runs single `--once` passes in-process.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { execFileSync } from "node:child_process";
|
|
13
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
16
|
+
import { cleanScratch, declaration, git, repoFixture, scratchDir, writeFiles } from "./__fixtures__/contract-repo";
|
|
17
|
+
import { ciServeInterval, ciServeKey, ciServePass, formatCiServeEvent, readCiServeState, type CiServeEvent, type CiServeForge, type CiServeOptions } from "./ci-serve";
|
|
18
|
+
import { ciRun } from "./ci-run";
|
|
19
|
+
import { acquireLease, releaseLease } from "../lifecycle/lease";
|
|
20
|
+
import { prApplyGroup, prApplyRecordKey } from "../components/pr-pipeline";
|
|
21
|
+
import { parseArgs } from "../cli/parse-args";
|
|
22
|
+
import { forgejoForge, githubForge, type ForgeFetch, type OpenPullRequest } from "../pr-forge";
|
|
23
|
+
|
|
24
|
+
afterAll(cleanScratch);
|
|
25
|
+
|
|
26
|
+
function commit(root: string, file: string): string {
|
|
27
|
+
writeFiles(root, { [file]: `${file} ${Math.random()}\n` });
|
|
28
|
+
for (const args of [["add", "-A"], ["commit", "-q", "-m", file]]) {
|
|
29
|
+
execFileSync("git", ["-c", "user.name=t", "-c", "user.email=t@example.com", "-c", "commit.gpgsign=false", ...args], { cwd: root, stdio: "ignore" });
|
|
30
|
+
}
|
|
31
|
+
return git(root, "rev-parse", "HEAD");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The repository every test starts from, built once: a checkout on main with a bare origin and member `app`. */
|
|
35
|
+
const workspaceTemplate = repoFixture(() => {
|
|
36
|
+
const top = scratchDir("chant-ci-serve-template-");
|
|
37
|
+
const origin = join(top, "origin");
|
|
38
|
+
const root = join(top, "root");
|
|
39
|
+
mkdirSync(origin);
|
|
40
|
+
mkdirSync(root);
|
|
41
|
+
git(origin, "init", "-q", "--bare");
|
|
42
|
+
git(root, "init", "-q");
|
|
43
|
+
git(root, "symbolic-ref", "HEAD", "refs/heads/main");
|
|
44
|
+
writeFiles(root, {
|
|
45
|
+
"chant.workspace.json": declaration([{ name: "app", dir: "app", kind: "other", because: "the app" }]),
|
|
46
|
+
".gitignore": ".chant/pr/\n.chant/pr-resume/\n",
|
|
47
|
+
"app/a.txt": "a\n",
|
|
48
|
+
});
|
|
49
|
+
git(root, "remote", "add", "origin", origin);
|
|
50
|
+
commit(root, "app/init.txt");
|
|
51
|
+
git(root, "push", "-q", "origin", "main");
|
|
52
|
+
return top;
|
|
53
|
+
}, "chant-ci-serve-");
|
|
54
|
+
|
|
55
|
+
/** A copy of the template, its origin pointed at the copy of the bare one. */
|
|
56
|
+
function workspace(): { root: string; app: string; main: string } {
|
|
57
|
+
const top = workspaceTemplate();
|
|
58
|
+
const root = join(top, "root");
|
|
59
|
+
git(root, "remote", "set-url", "origin", join(top, "origin"));
|
|
60
|
+
return { root, app: join(root, "app"), main: git(root, "rev-parse", "HEAD") };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A pull request's head commit, on a branch off main, pushed to the forge's refs/pull/<n>/head. */
|
|
64
|
+
function pullHead(root: string, n: number): string {
|
|
65
|
+
git(root, "checkout", "-q", "-b", `pr-${n}`);
|
|
66
|
+
const sha = commit(root, `app/pr-${n}.txt`);
|
|
67
|
+
git(root, "push", "-q", "origin", `${sha}:refs/pull/${n}/head`);
|
|
68
|
+
git(root, "checkout", "-q", "main");
|
|
69
|
+
return sha;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A stand-in for `chant` that keeps its arguments under `.chant/pr` and exits
|
|
74
|
+
* `$FAKE_EXIT`. As `pr-apply` it copies the resume record it was given to
|
|
75
|
+
* `.chant/pr/resume-in.json` and writes a new one naming `$FAKE_ATTEMPT`.
|
|
76
|
+
*/
|
|
77
|
+
function fakeChant(): string {
|
|
78
|
+
const file = join(scratchDir("chant-ci-serve-bin-"), "chant");
|
|
79
|
+
writeFileSync(
|
|
80
|
+
file,
|
|
81
|
+
[
|
|
82
|
+
"#!/bin/sh",
|
|
83
|
+
'stage="$2"',
|
|
84
|
+
"mkdir -p .chant/pr",
|
|
85
|
+
'printf "%s\\n" "$@" > ".chant/pr/$stage.args"',
|
|
86
|
+
'if [ "$stage" = pr-apply ]; then',
|
|
87
|
+
" if [ -f .chant/pr-resume/pr-apply.json ]; then cp .chant/pr-resume/pr-apply.json .chant/pr/resume-in.json; fi",
|
|
88
|
+
" mkdir -p .chant/pr-resume",
|
|
89
|
+
' echo "{\\"attempt\\": \\"${FAKE_ATTEMPT:-1}\\"}" > .chant/pr-resume/pr-apply.json',
|
|
90
|
+
"fi",
|
|
91
|
+
'if [ -n "$FAKE_SLEEP" ]; then sleep "$FAKE_SLEEP"; fi',
|
|
92
|
+
'exit "${FAKE_EXIT:-0}"',
|
|
93
|
+
"",
|
|
94
|
+
].join("\n"),
|
|
95
|
+
);
|
|
96
|
+
chmodSync(file, 0o755);
|
|
97
|
+
return file;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** A forge whose open pull requests and branch head the test sets. */
|
|
101
|
+
function fakeForge(head: string): CiServeForge & { head: string; prs: OpenPullRequest[]; polls: number; merges: Map<string, number | "throw">; asked: string[] } {
|
|
102
|
+
const f = {
|
|
103
|
+
head,
|
|
104
|
+
merges: new Map<string, number | "throw">(),
|
|
105
|
+
asked: [] as string[],
|
|
106
|
+
async pullRequestFor(sha: string) {
|
|
107
|
+
f.asked.push(sha);
|
|
108
|
+
const n = f.merges.get(sha);
|
|
109
|
+
if (n === "throw") throw new Error("forge unavailable");
|
|
110
|
+
return n ?? null;
|
|
111
|
+
},
|
|
112
|
+
prs: [] as OpenPullRequest[],
|
|
113
|
+
polls: 0,
|
|
114
|
+
async openPullRequests(base: string) {
|
|
115
|
+
f.polls++;
|
|
116
|
+
return f.prs.filter((p) => p.base === base);
|
|
117
|
+
},
|
|
118
|
+
async branchHead(branch: string) {
|
|
119
|
+
return branch === "main" ? f.head : undefined;
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
return f;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const pr = (number: number, headSha: string, baseSha: string, extra: Partial<OpenPullRequest> = {}): OpenPullRequest => ({ number, head: `pr-${number}`, headSha, base: "main", baseSha, author: "driver", fork: false, ...extra });
|
|
126
|
+
|
|
127
|
+
const HOST = { PATH: process.env.PATH, HOME: process.env.HOME, GITHUB_TOKEN: "forge-token", GITHUB_SERVER_URL: "http://forgejo.localhost", GITHUB_API_URL: "http://forgejo.localhost/api/v1" };
|
|
128
|
+
|
|
129
|
+
function serve(app: string, client: CiServeForge, extra: Partial<CiServeOptions> & { hostEnv?: Record<string, string | undefined> } = {}): { opts: CiServeOptions; events: CiServeEvent[]; state: string } {
|
|
130
|
+
const state = extra.stateDir ?? scratchDir("chant-ci-serve-state-");
|
|
131
|
+
const events: CiServeEvent[] = [];
|
|
132
|
+
const { hostEnv, ...rest } = extra;
|
|
133
|
+
const env = { ...HOST, ...hostEnv };
|
|
134
|
+
return {
|
|
135
|
+
state,
|
|
136
|
+
events,
|
|
137
|
+
opts: {
|
|
138
|
+
cwd: app,
|
|
139
|
+
forge: "forgejo",
|
|
140
|
+
repo: "demo/estate",
|
|
141
|
+
env: "prod",
|
|
142
|
+
client,
|
|
143
|
+
stateDir: state,
|
|
144
|
+
holder: "serve-test",
|
|
145
|
+
// A job image of its own, so no setup line downloads OpenTofu.
|
|
146
|
+
job: { image: "chant-ci-serve-test:local", chant: fakeChant(), hostEnv: env, passEnv: Object.keys(hostEnv ?? {}) },
|
|
147
|
+
log: (e) => events.push(e),
|
|
148
|
+
...rest,
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const argsOf = (report: string, stage: string): string[] => readFileSync(join(report, `${stage}.args`), "utf-8").trimEnd().split("\n");
|
|
154
|
+
const ofType = <T extends CiServeEvent["type"]>(events: CiServeEvent[], type: T) => events.filter((e): e is Extract<CiServeEvent, { type: T }> => e.type === type);
|
|
155
|
+
|
|
156
|
+
describe("chant ci serve (#4095)", () => {
|
|
157
|
+
test("one poll plans a new pull request head once, and skips forks and authors not given", async () => {
|
|
158
|
+
const { root, app, main } = workspace();
|
|
159
|
+
const head = pullHead(root, 1);
|
|
160
|
+
const forge = fakeForge(main);
|
|
161
|
+
forge.prs = [pr(1, head, main), pr(2, head, main, { fork: true }), pr(3, head, main, { author: "stranger" })];
|
|
162
|
+
const { opts, events, state } = serve(app, forge, { authors: ["Driver"] });
|
|
163
|
+
|
|
164
|
+
const first = await ciServePass(opts);
|
|
165
|
+
expect(first.jobs).toHaveLength(1);
|
|
166
|
+
expect(first.jobs[0]).toMatchObject({ job: "plan", pr: 1, commit: head, base: main, status: "ok", attempts: 1 });
|
|
167
|
+
expect(ofType(events, "baseline")).toEqual([{ type: "baseline", commit: main }]);
|
|
168
|
+
expect(ofType(events, "skipped").map((e) => [e.pr, e.reason])).toEqual([[2, "fork"], [3, "author"]]);
|
|
169
|
+
const argv = argsOf(first.jobs[0]!.report!, "pr-plan");
|
|
170
|
+
expect(argv.slice(0, 5)).toEqual(["components", "pr-plan", "--base", main, "--pr"]);
|
|
171
|
+
expect(argv[5]).toBe("1");
|
|
172
|
+
expect(argv).toContain("--member");
|
|
173
|
+
|
|
174
|
+
const group = prApplyGroup("prod", "app");
|
|
175
|
+
const file = join(state, `${group}.json`);
|
|
176
|
+
const saved = readCiServeState(file, { forge: "forgejo", repo: "demo/estate", env: "prod", branch: "main" });
|
|
177
|
+
expect(saved.target).toBe(main);
|
|
178
|
+
expect(saved.jobs[ciServeKey({ job: "plan", pr: 1, commit: head })]).toMatchObject({ status: "ok" });
|
|
179
|
+
|
|
180
|
+
// The same head again: nothing runs.
|
|
181
|
+
const second = await ciServePass(opts);
|
|
182
|
+
expect(second.jobs).toEqual([]);
|
|
183
|
+
expect(ofType(second.events, "started")).toEqual([]);
|
|
184
|
+
expect(forge.polls).toBe(2);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("a merge to the target branch applies the new commit, measured from the head before it", async () => {
|
|
188
|
+
const { root, app, main } = workspace();
|
|
189
|
+
const forge = fakeForge(main);
|
|
190
|
+
const { opts, events } = serve(app, forge);
|
|
191
|
+
expect((await ciServePass(opts)).jobs).toEqual([]);
|
|
192
|
+
expect(ofType(events, "baseline")).toHaveLength(1);
|
|
193
|
+
|
|
194
|
+
const merged = commit(root, "app/merged.txt");
|
|
195
|
+
git(root, "push", "-q", "origin", "main");
|
|
196
|
+
forge.head = merged;
|
|
197
|
+
const pass = await ciServePass(opts);
|
|
198
|
+
expect(pass.jobs).toEqual([expect.objectContaining({ job: "apply", commit: merged, base: main, status: "ok" })]);
|
|
199
|
+
const argv = argsOf(pass.jobs[0]!.report!, "pr-apply");
|
|
200
|
+
expect(argv.slice(0, 4)).toEqual(["components", "pr-apply", "--base", main]);
|
|
201
|
+
expect(argv.slice(-2)).toEqual(["--resume", ".chant/pr-resume/pr-apply.json"]);
|
|
202
|
+
// The run was recorded on chant/lifecycle and pushed.
|
|
203
|
+
expect(git(root, "ls-remote", "origin", "chant/lifecycle")).toContain(git(root, "rev-parse", "chant/lifecycle"));
|
|
204
|
+
|
|
205
|
+
expect((await ciServePass(opts)).jobs).toEqual([]);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("an apply gets the number of the pull request whose merge commit it is (#4112)", async () => {
|
|
209
|
+
const { root, app, main } = workspace();
|
|
210
|
+
const forge = fakeForge(main);
|
|
211
|
+
const seen: Array<number | undefined> = [];
|
|
212
|
+
const { opts } = serve(app, forge, { run: (req) => (seen.push(req.pr), ciRun(req)) });
|
|
213
|
+
await ciServePass(opts);
|
|
214
|
+
const merged = commit(root, "app/merged.txt");
|
|
215
|
+
git(root, "push", "-q", "origin", "main");
|
|
216
|
+
forge.head = merged;
|
|
217
|
+
forge.merges.set(merged, 17);
|
|
218
|
+
|
|
219
|
+
const pass = await ciServePass(opts);
|
|
220
|
+
expect(forge.asked).toEqual([merged]);
|
|
221
|
+
expect(pass.jobs).toEqual([expect.objectContaining({ job: "apply", commit: merged, pr: 17, status: "ok" })]);
|
|
222
|
+
expect(seen).toEqual([17]);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
/** A serve over a repository whose baseline pass is done, with the `pr` each run was asked for in `seen`. */
|
|
226
|
+
async function mergeWithoutPr() {
|
|
227
|
+
const { root, app, main } = workspace();
|
|
228
|
+
const forge = fakeForge(main);
|
|
229
|
+
const seen: Array<number | undefined> = [];
|
|
230
|
+
const { opts } = serve(app, forge, { run: (req) => (seen.push(req.pr), ciRun(req)) });
|
|
231
|
+
await ciServePass(opts);
|
|
232
|
+
return { root, forge, seen, opts };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
test("an apply with no pull request found runs without --pr (#4112)", async () => {
|
|
236
|
+
const { root, forge, seen, opts } = await mergeWithoutPr();
|
|
237
|
+
const direct = commit(root, "app/direct.txt");
|
|
238
|
+
git(root, "push", "-q", "origin", "main");
|
|
239
|
+
forge.head = direct;
|
|
240
|
+
const pass = await ciServePass(opts);
|
|
241
|
+
expect(pass.jobs).toEqual([expect.objectContaining({ job: "apply", commit: direct, status: "ok" })]);
|
|
242
|
+
expect(pass.jobs[0]!.pr).toBeUndefined();
|
|
243
|
+
expect(seen).toEqual([undefined]);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("an apply whose pull request lookup fails runs without --pr (#4112)", async () => {
|
|
247
|
+
const { root, forge, seen, opts } = await mergeWithoutPr();
|
|
248
|
+
const broken = commit(root, "app/broken.txt");
|
|
249
|
+
git(root, "push", "-q", "origin", "main");
|
|
250
|
+
forge.head = broken;
|
|
251
|
+
forge.merges.set(broken, "throw");
|
|
252
|
+
const pass = await ciServePass(opts);
|
|
253
|
+
expect(pass.jobs).toEqual([expect.objectContaining({ job: "apply", commit: broken, status: "ok" })]);
|
|
254
|
+
expect(pass.jobs[0]!.pr).toBeUndefined();
|
|
255
|
+
expect(seen).toEqual([undefined]);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("the forge clients look a merge commit up with one GET each (#4112)", async () => {
|
|
259
|
+
const sha = "c".repeat(40);
|
|
260
|
+
const calls: string[] = [];
|
|
261
|
+
const fetch = (body: unknown): ForgeFetch => async (url, init) => {
|
|
262
|
+
calls.push(`${init.method} ${new URL(url).pathname}`);
|
|
263
|
+
return { ok: true, status: 200, text: async () => JSON.stringify(body) };
|
|
264
|
+
};
|
|
265
|
+
const gh: CiServeForge = githubForge({ apiBase: "https://api.github.com", repo: "demo/estate", token: "t", fetch: fetch([{ number: 5, merged_at: "2026-10-01T00:00:00Z", merge_commit_sha: sha }]) }) as unknown as CiServeForge;
|
|
266
|
+
expect(await gh.pullRequestFor!(sha)).toBe(5);
|
|
267
|
+
const fj: CiServeForge = forgejoForge({ apiBase: "http://forgejo.localhost/api/v1", host: "forgejo.localhost", repo: "demo/estate", token: "t", fetch: fetch({ number: 6 }) }) as unknown as CiServeForge;
|
|
268
|
+
expect(await fj.pullRequestFor!(sha)).toBe(6);
|
|
269
|
+
expect(calls).toEqual([`GET /repos/demo/estate/commits/${sha}/pulls`, `GET /api/v1/repos/demo/estate/commits/${sha}/pull`]);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("the group's lease held elsewhere holds the apply until it is released", async () => {
|
|
273
|
+
const { root, app, main } = workspace();
|
|
274
|
+
const forge = fakeForge(main);
|
|
275
|
+
const { opts, events } = serve(app, forge);
|
|
276
|
+
await ciServePass(opts);
|
|
277
|
+
const merged = commit(root, "app/merged.txt");
|
|
278
|
+
git(root, "push", "-q", "origin", "main");
|
|
279
|
+
forge.head = merged;
|
|
280
|
+
|
|
281
|
+
const group = prApplyGroup("prod", "app");
|
|
282
|
+
const other = await acquireLease(group, "other-host", { cwd: app, mode: "claim", requirePush: true });
|
|
283
|
+
expect(other.acquired).toBe(true);
|
|
284
|
+
const held = await ciServePass(opts);
|
|
285
|
+
expect(held.jobs).toEqual([]);
|
|
286
|
+
expect(ofType(events, "held")).toEqual([{ type: "held", job: "apply", commit: merged, reason: "lease", heldBy: "other-host" }]);
|
|
287
|
+
expect(formatCiServeEvent(ofType(events, "held")[0]!)).toContain("the apply lease is held by other-host");
|
|
288
|
+
|
|
289
|
+
expect(await releaseLease(group, "other-host", other.lease!.token, { cwd: app })).toBe(true);
|
|
290
|
+
const ran = await ciServePass(opts);
|
|
291
|
+
expect(ran.jobs).toEqual([expect.objectContaining({ job: "apply", commit: merged, base: main, status: "ok" })]);
|
|
292
|
+
// The serve released its own lease after the apply.
|
|
293
|
+
expect((await acquireLease(group, "after", { cwd: app, mode: "claim" })).acquired).toBe(true);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
describe("a failed apply", () => {
|
|
297
|
+
// The failing apply runs once in beforeAll, and the two tests read what it left, so no test
|
|
298
|
+
// runs two jobs. A file's beforeAll is not counted in the unit budget (#2817).
|
|
299
|
+
let w: ReturnType<typeof workspace>;
|
|
300
|
+
let forge: ReturnType<typeof fakeForge>;
|
|
301
|
+
let failing: ReturnType<typeof serve>;
|
|
302
|
+
let merged: string;
|
|
303
|
+
let failed: Awaited<ReturnType<typeof ciServePass>>;
|
|
304
|
+
let record: string;
|
|
305
|
+
|
|
306
|
+
beforeAll(async () => {
|
|
307
|
+
w = workspace();
|
|
308
|
+
forge = fakeForge(w.main);
|
|
309
|
+
failing = serve(w.app, forge, { hostEnv: { FAKE_EXIT: "3", FAKE_ATTEMPT: "1" } });
|
|
310
|
+
await ciServePass(failing.opts);
|
|
311
|
+
merged = commit(w.root, "app/merged.txt");
|
|
312
|
+
git(w.root, "push", "-q", "origin", "main");
|
|
313
|
+
forge.head = merged;
|
|
314
|
+
failed = await ciServePass(failing.opts);
|
|
315
|
+
record = join(failing.state, "resume", `${prApplyRecordKey("prod", merged, "app")}.json`);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("stays failed, and keeps its resume record", async () => {
|
|
319
|
+
expect(failed.jobs).toEqual([expect.objectContaining({ job: "apply", status: "fail", attempts: 1, failure: expect.stringContaining("command-failed") })]);
|
|
320
|
+
expect(JSON.parse(readFileSync(record, "utf-8"))).toEqual({ attempt: "1" });
|
|
321
|
+
expect(existsSync(join(failed.jobs[0]!.report!, "resume-in.json"))).toBe(false);
|
|
322
|
+
|
|
323
|
+
// Without --rerun-failed the failed apply is not run again.
|
|
324
|
+
expect((await ciServePass(failing.opts)).jobs).toEqual([]);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("--rerun-failed runs it again with its resume record", async () => {
|
|
328
|
+
const retry = serve(w.app, forge, { stateDir: failing.state, rerunFailed: true, hostEnv: { FAKE_EXIT: "0", FAKE_ATTEMPT: "2" } });
|
|
329
|
+
const again = await ciServePass(retry.opts);
|
|
330
|
+
expect(again.jobs).toEqual([expect.objectContaining({ job: "apply", commit: merged, base: w.main, status: "ok", attempts: 2 })]);
|
|
331
|
+
expect(ofType(retry.events, "started")[0]).toMatchObject({ attempt: 2, resume: record });
|
|
332
|
+
// The job read the first attempt's record, and left its own.
|
|
333
|
+
expect(JSON.parse(readFileSync(join(again.jobs[0]!.report!, "resume-in.json"), "utf-8"))).toEqual({ attempt: "1" });
|
|
334
|
+
expect(JSON.parse(readFileSync(record, "utf-8"))).toEqual({ attempt: "2" });
|
|
335
|
+
// A passed apply is not run again, --rerun-failed or not.
|
|
336
|
+
expect((await ciServePass(retry.opts)).jobs).toEqual([]);
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test("--max-jobs holds the jobs over the limit for the next poll", async () => {
|
|
341
|
+
const { root, app, main } = workspace();
|
|
342
|
+
const one = pullHead(root, 1);
|
|
343
|
+
const two = pullHead(root, 2);
|
|
344
|
+
const forge = fakeForge(main);
|
|
345
|
+
forge.prs = [pr(1, one, main), pr(2, two, main)];
|
|
346
|
+
const { opts, events } = serve(app, forge, { maxJobs: 1 });
|
|
347
|
+
|
|
348
|
+
const first = await ciServePass(opts);
|
|
349
|
+
expect(first.jobs.map((j) => [j.pr, j.status])).toEqual([[1, "ok"]]);
|
|
350
|
+
expect(ofType(events, "held")).toEqual([{ type: "held", job: "plan", commit: two, pr: 2, reason: "capacity" }]);
|
|
351
|
+
|
|
352
|
+
const second = await ciServePass(opts);
|
|
353
|
+
expect(second.jobs.map((j) => [j.pr, j.status])).toEqual([[2, "ok"]]);
|
|
354
|
+
expect((await ciServePass(opts)).jobs).toEqual([]);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test("the command line takes the repository, the limit, the authors and the interval", () => {
|
|
358
|
+
const args = parseArgs(["ci", "serve", "--forge", "forgejo", "--repo", "demo/estate", "--env", "local", "--max-jobs", "2", "--author", "a", "--author", "b", "--state", "/tmp/s", "--once", "--rerun-failed", "--interval", "45"]);
|
|
359
|
+
expect(args).toMatchObject({ command: "ci", path: "serve", forge: "forgejo", ciRepo: "demo/estate", env: "local", maxJobs: 2, ciAuthors: ["a", "b"], statePath: "/tmp/s", once: true, rerunFailed: true, interval: "45" });
|
|
360
|
+
expect(() => parseArgs(["ci", "serve", "--repo", "estate"])).toThrow(/owner\/name/);
|
|
361
|
+
expect(() => parseArgs(["ci", "serve", "--max-jobs", "0"])).toThrow(/1 or more/);
|
|
362
|
+
expect(ciServeInterval(undefined)).toBe(30_000);
|
|
363
|
+
expect(ciServeInterval("45")).toBe(45_000);
|
|
364
|
+
expect(ciServeInterval("2m")).toBe(120_000);
|
|
365
|
+
expect(() => ciServeInterval("0")).toThrow(/a second or more/);
|
|
366
|
+
});
|
|
367
|
+
});
|