@intentius/chant 0.124.38 → 0.124.39
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/wave-runtime.mjs +189 -189
- package/dist/workspace/ci-builds.d.ts +12 -1
- package/dist/workspace/ci-builds.d.ts.map +1 -1
- package/dist/workspace/ci-builds.js +26 -0
- package/dist/workspace/ci-green-cli.js +1 -1
- package/dist/workspace/ci-green-forge.d.ts +3 -1
- package/dist/workspace/ci-green-forge.d.ts.map +1 -1
- package/dist/workspace/ci-green-forge.js +20 -2
- package/dist/workspace/ci-green-workflow.d.ts +6 -4
- package/dist/workspace/ci-green-workflow.d.ts.map +1 -1
- package/dist/workspace/ci-green-workflow.js +26 -8
- package/package.json +1 -1
- package/src/workspace/ci-builds.e2e.test.ts +7 -2
- package/src/workspace/ci-builds.test.ts +46 -1
- package/src/workspace/ci-builds.ts +25 -1
- package/src/workspace/ci-green-builds.test.ts +26 -0
- package/src/workspace/ci-green-cli.ts +1 -1
- package/src/workspace/ci-green-forge.test.ts +31 -0
- package/src/workspace/ci-green-forge.ts +21 -3
- package/src/workspace/ci-green-workflow.test.ts +4 -4
- package/src/workspace/ci-green-workflow.ts +22 -8
|
@@ -26,6 +26,10 @@
|
|
|
26
26
|
* waits on is `pending`. A request no runner served in six hours leaves
|
|
27
27
|
* the phase `unbuilt`, and once its phases are decided the tick deletes
|
|
28
28
|
* the tag.
|
|
29
|
+
* - Missing. A phase that declares `build: "every"` and has no run on a
|
|
30
|
+
* commit half an hour after it landed (the forge skipped the push run, as
|
|
31
|
+
* on acc9b9dbd) gets one build request, `missing`, for those phases
|
|
32
|
+
* (#4216). The record keeps `missing`, so a commit is asked for once.
|
|
29
33
|
* - Records. Every first-parent commit the tick judged gets
|
|
30
34
|
* `_ci/<sha>.json` on `chant/lifecycle`, rewritten only when what it says
|
|
31
35
|
* changes: `green`, `red`, `unbuilt` or `pending`, with each required
|
|
@@ -42,11 +46,13 @@ export declare const CI_BUILD_TAG_PREFIX = "ci/build/";
|
|
|
42
46
|
export declare const CI_RECORDS_DIR = "_ci";
|
|
43
47
|
/** How long a build request may wait for a runner before its phases count as unbuilt. */
|
|
44
48
|
export declare const BUILD_REQUEST_TTL_MS: number;
|
|
49
|
+
/** How long an `every` phase may have no run on a commit before the tick asks for one (#4216). */
|
|
50
|
+
export declare const MISSING_RUN_GRACE_MS: number;
|
|
45
51
|
/** How long a retry may take to start before the failure it was counts. */
|
|
46
52
|
export declare const RETRY_TTL_MS = 3600000;
|
|
47
53
|
/** Above this many unbuilt commits a bisect halves the range; at or below it, it steps back one commit at a time. */
|
|
48
54
|
export declare const BISECT_HALVE_ABOVE = 4;
|
|
49
|
-
export type BuildRequestReason = "bisect" | "retry" | "requested";
|
|
55
|
+
export type BuildRequestReason = "bisect" | "retry" | "requested" | "missing";
|
|
50
56
|
/** A `ci/build/<sha>` tag's record: the phases to build at the commit, and why. */
|
|
51
57
|
export interface BuildRequest {
|
|
52
58
|
schema: 1;
|
|
@@ -88,6 +94,11 @@ export interface CiRecord {
|
|
|
88
94
|
at: string;
|
|
89
95
|
dispatched?: true;
|
|
90
96
|
};
|
|
97
|
+
/** The `every` phases a `missing` request asked for, and when (#4216). A commit is asked for once. */
|
|
98
|
+
missing?: {
|
|
99
|
+
phases: string[];
|
|
100
|
+
at: string;
|
|
101
|
+
};
|
|
91
102
|
}
|
|
92
103
|
export interface CiRecordRun {
|
|
93
104
|
name: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ci-builds.d.ts","sourceRoot":"","sources":["../../src/workspace/ci-builds.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"ci-builds.d.ts","sourceRoot":"","sources":["../../src/workspace/ci-builds.ts"],"names":[],"mappings":"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"}
|
|
@@ -26,6 +26,10 @@
|
|
|
26
26
|
* waits on is `pending`. A request no runner served in six hours leaves
|
|
27
27
|
* the phase `unbuilt`, and once its phases are decided the tick deletes
|
|
28
28
|
* the tag.
|
|
29
|
+
* - Missing. A phase that declares `build: "every"` and has no run on a
|
|
30
|
+
* commit half an hour after it landed (the forge skipped the push run, as
|
|
31
|
+
* on acc9b9dbd) gets one build request, `missing`, for those phases
|
|
32
|
+
* (#4216). The record keeps `missing`, so a commit is asked for once.
|
|
29
33
|
* - Records. Every first-parent commit the tick judged gets
|
|
30
34
|
* `_ci/<sha>.json` on `chant/lifecycle`, rewritten only when what it says
|
|
31
35
|
* changes: `green`, `red`, `unbuilt` or `pending`, with each required
|
|
@@ -42,6 +46,8 @@ export const CI_RECORDS_DIR = "_ci";
|
|
|
42
46
|
const LEDGER_REF = "refs/heads/chant/lifecycle";
|
|
43
47
|
/** How long a build request may wait for a runner before its phases count as unbuilt. */
|
|
44
48
|
export const BUILD_REQUEST_TTL_MS = 6 * 3600000;
|
|
49
|
+
/** How long an `every` phase may have no run on a commit before the tick asks for one (#4216). */
|
|
50
|
+
export const MISSING_RUN_GRACE_MS = 30 * 60000;
|
|
45
51
|
/** How long a retry may take to start before the failure it was counts. */
|
|
46
52
|
export const RETRY_TTL_MS = 3600000;
|
|
47
53
|
/** Above this many unbuilt commits a bisect halves the range; at or below it, it steps back one commit at a time. */
|
|
@@ -83,6 +89,24 @@ export function planBuilds(input) {
|
|
|
83
89
|
setPhase(c, name, (q) => (live(c.sha, name) ? { ...q, verdict: "pending", reason: "requested" } : { ...q, verdict: "unbuilt", reason: "request-unserved" }));
|
|
84
90
|
}
|
|
85
91
|
}
|
|
92
|
+
// Missing (#4216): an `every` phase the forge never ran on a commit, asked for once per commit.
|
|
93
|
+
for (const c of commits) {
|
|
94
|
+
if (c.before !== "none" || !c.verdict || age(c.committed, now) < MISSING_RUN_GRACE_MS)
|
|
95
|
+
continue;
|
|
96
|
+
if (input.records.get(c.sha)?.missing || input.requests.has(c.sha) || plan.requests.some((q) => q.commit === c.sha))
|
|
97
|
+
continue;
|
|
98
|
+
const phases = green.require.filter((name) => {
|
|
99
|
+
if (phaseOf(green, name).build !== "every")
|
|
100
|
+
return false;
|
|
101
|
+
const p = phaseIn(c, name);
|
|
102
|
+
return p !== undefined && p.verdict === "pending" && p.runs.length === 0 && p.reason === undefined;
|
|
103
|
+
});
|
|
104
|
+
if (phases.length === 0)
|
|
105
|
+
continue;
|
|
106
|
+
plan.requests.push(request(green, c.sha, phases, "missing", now));
|
|
107
|
+
for (const name of phases)
|
|
108
|
+
setPhase(c, name, (q) => ({ ...q, verdict: "pending", reason: "requested" }));
|
|
109
|
+
}
|
|
86
110
|
// Superseded: newest first, a newest phase with no run below a commit with one.
|
|
87
111
|
for (const name of green.require) {
|
|
88
112
|
if (buildOf(green, name) !== "newest")
|
|
@@ -196,9 +220,11 @@ export function recordOf(input) {
|
|
|
196
220
|
if (r.sha === c.sha)
|
|
197
221
|
retried[r.run.name] = { run: r.run.id, at: iso(now) };
|
|
198
222
|
const req = input.requests.get(c.sha);
|
|
223
|
+
const missing = req?.reason === "missing" ? { phases: req.phases, at: req.at } : previous?.missing;
|
|
199
224
|
const extras = {
|
|
200
225
|
...(Object.keys(retried).length > 0 ? { retried } : {}),
|
|
201
226
|
...(req ? { request: { tag: req.tag, reason: req.reason, phases: req.phases, at: req.at } } : {}),
|
|
227
|
+
...(missing ? { missing } : {}),
|
|
202
228
|
};
|
|
203
229
|
if (c.before === "revoked" || !c.verdict) {
|
|
204
230
|
return { schema: 1, commit: c.sha, branch: green.branch, state: "red", at: iso(now), revoked: true, phases: previous?.phases ?? {}, ...(previous?.failure ? { failure: previous.failure } : {}), ...extras };
|
|
@@ -199,7 +199,7 @@ export async function runCiWorkflow(ctx) {
|
|
|
199
199
|
const path = writeWorkflowFile(join(repoRoot, e.path), e.text);
|
|
200
200
|
console.log(e.build === "newest"
|
|
201
201
|
? `Wrote ${path}'s triggers and concurrency: ${e.phases.join(", ")} build${e.phases.length === 1 ? "s" : ""} the newest commit on ${green.branch} when a run ends, and a ci/build/<sha> tag by hand.`
|
|
202
|
-
: `Wrote ${path}'s concurrency: ${e.phases.join(", ")} build${e.phases.length === 1 ? "s" : ""} every commit on ${green.branch}, none cancelled.`);
|
|
202
|
+
: `Wrote ${path}'s triggers and concurrency: ${e.phases.join(", ")} build${e.phases.length === 1 ? "s" : ""} every commit on ${green.branch}, none cancelled, and a ci/build/<sha> tag by hand when the forge ran nothing.`);
|
|
203
203
|
}
|
|
204
204
|
return 0;
|
|
205
205
|
}
|
|
@@ -53,7 +53,9 @@ export interface CiForge {
|
|
|
53
53
|
checkRuns(sha: string): Promise<CheckRun[]>;
|
|
54
54
|
/**
|
|
55
55
|
* Run a failed check run's job again (#4169), so a flake costs one more
|
|
56
|
-
* run before the commit counts red
|
|
56
|
+
* run before the commit counts red: on GitHub, every failed job of its
|
|
57
|
+
* workflow run, so the jobs an aggregate needs go again with it (#4219).
|
|
58
|
+
* Absent on a forge chant can't ask.
|
|
57
59
|
*/
|
|
58
60
|
rerun?(run: CheckRun): Promise<void>;
|
|
59
61
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ci-green-forge.d.ts","sourceRoot":"","sources":["../../src/workspace/ci-green-forge.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,EAAwC,KAAK,UAAU,EAAE,KAAK,SAAS,EAAE,MAAM,aAAa,CAAC;AACpG,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAEjD,gDAAgD;AAChD,MAAM,WAAW,QAAQ;IACvB,0EAA0E;IAC1E,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,6GAA6G;IAC7G,MAAM,EAAE,MAAM,CAAC;IACf,8HAA8H;IAC9H,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACnB;;;;;OAKG;IACH,KAAK,CAAC,EAAE,QAAQ,CAAC;IACjB,6GAA6G;IAC7G,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,wDAAwD;AACxD,MAAM,WAAW,QAAQ;IACvB,EAAE,EAAE,MAAM,CAAC;IACX,GAAG,EAAE,MAAM,CAAC;IACZ,gJAAgJ;IAChJ,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,4CAA4C;IAC5C,SAAS,CAAC,EAAE,WAAW,CAAC,WAAW,CAAC,WAAW,CAAC,CAAC,CAAC;CACnD;AAED,MAAM,WAAW,OAAO;IACtB,IAAI,EAAE,SAAS,CAAC;IAChB,+CAA+C;IAC/C,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,EAAE,CAAC,CAAC;IAC5C
|
|
1
|
+
{"version":3,"file":"ci-green-forge.d.ts","sourceRoot":"","sources":["../../src/workspace/ci-green-forge.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,EAAwC,KAAK,UAAU,EAAE,KAAK,SAAS,EAAE,MAAM,aAAa,CAAC;AACpG,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAEjD,gDAAgD;AAChD,MAAM,WAAW,QAAQ;IACvB,0EAA0E;IAC1E,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,6GAA6G;IAC7G,MAAM,EAAE,MAAM,CAAC;IACf,8HAA8H;IAC9H,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACnB;;;;;OAKG;IACH,KAAK,CAAC,EAAE,QAAQ,CAAC;IACjB,6GAA6G;IAC7G,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,wDAAwD;AACxD,MAAM,WAAW,QAAQ;IACvB,EAAE,EAAE,MAAM,CAAC;IACX,GAAG,EAAE,MAAM,CAAC;IACZ,gJAAgJ;IAChJ,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,4CAA4C;IAC5C,SAAS,CAAC,EAAE,WAAW,CAAC,WAAW,CAAC,WAAW,CAAC,CAAC,CAAC;CACnD;AAED,MAAM,WAAW,OAAO;IACtB,IAAI,EAAE,SAAS,CAAC;IAChB,+CAA+C;IAC/C,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,EAAE,CAAC,CAAC;IAC5C;;;;;OAKG;IACH,KAAK,CAAC,CAAC,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACrC;;;;;OAKG;IACH,QAAQ,CAAC,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACzD;AAED,MAAM,WAAW,oBAAoB;IACnC,sEAAsE;IACtE,OAAO,EAAE,MAAM,CAAC;IAChB,oBAAoB;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,UAAU,CAAC;CACpB;AAKD,+GAA+G;AAC/G,wBAAgB,aAAa,CAAC,IAAI,EAAE,oBAAoB,GAAG,OAAO,CA8DjE;AAED,0EAA0E;AAC1E,qBAAa,uBAAwB,SAAQ,KAAK;IAChD,YAAY,IAAI,EAAE,MAAM,EAKvB;CACF;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,IAAI,GAAE,MAAiB,EAAE,GAAG,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAe,EAAE,SAAS,CAAC,EAAE,UAAU,GAAG,OAAO,CAW9I"}
|
|
@@ -23,6 +23,7 @@ export function githubCiForge(opts) {
|
|
|
23
23
|
const root = opts.apiBase.replace(/\/+$/, "");
|
|
24
24
|
const fetchImpl = opts.fetch ?? globalThis.fetch;
|
|
25
25
|
const headers = { Authorization: `Bearer ${opts.token}`, Accept: "application/vnd.github+json" };
|
|
26
|
+
const rerunRuns = new Set();
|
|
26
27
|
return {
|
|
27
28
|
kind: "github",
|
|
28
29
|
async checkRuns(sha) {
|
|
@@ -55,9 +56,26 @@ export function githubCiForge(opts) {
|
|
|
55
56
|
}
|
|
56
57
|
return out;
|
|
57
58
|
},
|
|
58
|
-
// An Actions job's check run has the job's id
|
|
59
|
+
// An Actions job's check run has the job's id. Re-running only that job
|
|
60
|
+
// would re-run an aggregate such as `suites` against the shards' old
|
|
61
|
+
// results, so the failed jobs of its workflow run go again together,
|
|
62
|
+
// each run once per tick (#4219). Both calls need `actions: write`.
|
|
59
63
|
async rerun(run) {
|
|
60
|
-
|
|
64
|
+
const jobUrl = `${root}/repos/${opts.repo}/actions/jobs/${run.id}`;
|
|
65
|
+
const res = await fetchImpl(jobUrl, { method: "GET", headers });
|
|
66
|
+
const text = await res.text();
|
|
67
|
+
if (!res.ok)
|
|
68
|
+
throw new ForgeApiError("GET", jobUrl, res.status, text);
|
|
69
|
+
const job = text ? JSON.parse(text) : null;
|
|
70
|
+
const runId = isObject(job) && typeof job.run_id === "number" ? job.run_id : null;
|
|
71
|
+
if (runId === null) {
|
|
72
|
+
await post(`${jobUrl}/rerun`, {});
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (rerunRuns.has(runId))
|
|
76
|
+
return;
|
|
77
|
+
rerunRuns.add(runId);
|
|
78
|
+
await post(`${root}/repos/${opts.repo}/actions/runs/${runId}/rerun-failed-jobs`, {});
|
|
61
79
|
},
|
|
62
80
|
async dispatch(workflow, ref) {
|
|
63
81
|
await post(`${root}/repos/${opts.repo}/actions/workflows/${encodeURIComponent(workflow)}/dispatches`, { ref });
|
|
@@ -157,9 +157,11 @@ export interface BuildTriggerEdit {
|
|
|
157
157
|
* ref that never cancels a run in progress, so GitHub keeps the newest
|
|
158
158
|
* push waiting and drops the ones between, and each tag's build runs in a
|
|
159
159
|
* group of its own.
|
|
160
|
-
* - `every`: the triggers stay,
|
|
161
|
-
*
|
|
162
|
-
*
|
|
160
|
+
* - `every`: the triggers stay, with `workflow_dispatch` added so the tick
|
|
161
|
+
* can start a run the forge never started on a commit (#4216), and the
|
|
162
|
+
* group is the commit itself on the branch, so no push cancels or replaces
|
|
163
|
+
* another's run; elsewhere, such as a pull request or a `ci/build/<sha>`
|
|
164
|
+
* tag, a new push cancels the run it supersedes.
|
|
163
165
|
*
|
|
164
166
|
* With `compose` (#4220, a workspace declaring `ci.workflow`), both builds
|
|
165
167
|
* also run on pushes to `chant/compose/**`: `newest` adds the filter to the
|
|
@@ -167,7 +169,7 @@ export interface BuildTriggerEdit {
|
|
|
167
169
|
* and `every`'s group keys a compose branch's runs by commit, as the trunk
|
|
168
170
|
* branch's.
|
|
169
171
|
*
|
|
170
|
-
* Only the top-level `on:`
|
|
172
|
+
* Only the top-level `on:` and `concurrency:` blocks change,
|
|
171
173
|
* each under {@link BUILD_BLOCK_MARKER}; the rest of the file is kept as it
|
|
172
174
|
* is. Throws when one workflow holds a phase of each build, which no one
|
|
173
175
|
* concurrency group can serve.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ci-green-workflow.d.ts","sourceRoot":"","sources":["../../src/workspace/ci-green-workflow.ts"],"names":[],"mappings":"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
|
|
1
|
+
{"version":3,"file":"ci-green-workflow.d.ts","sourceRoot":"","sources":["../../src/workspace/ci-green-workflow.ts"],"names":[],"mappings":"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"}
|
|
@@ -249,9 +249,11 @@ export const BUILD_BLOCK_MARKER = "# Written by chant ci workflow from ci.green'
|
|
|
249
249
|
* ref that never cancels a run in progress, so GitHub keeps the newest
|
|
250
250
|
* push waiting and drops the ones between, and each tag's build runs in a
|
|
251
251
|
* group of its own.
|
|
252
|
-
* - `every`: the triggers stay,
|
|
253
|
-
*
|
|
254
|
-
*
|
|
252
|
+
* - `every`: the triggers stay, with `workflow_dispatch` added so the tick
|
|
253
|
+
* can start a run the forge never started on a commit (#4216), and the
|
|
254
|
+
* group is the commit itself on the branch, so no push cancels or replaces
|
|
255
|
+
* another's run; elsewhere, such as a pull request or a `ci/build/<sha>`
|
|
256
|
+
* tag, a new push cancels the run it supersedes.
|
|
255
257
|
*
|
|
256
258
|
* With `compose` (#4220, a workspace declaring `ci.workflow`), both builds
|
|
257
259
|
* also run on pushes to `chant/compose/**`: `newest` adds the filter to the
|
|
@@ -259,7 +261,7 @@ export const BUILD_BLOCK_MARKER = "# Written by chant ci workflow from ci.green'
|
|
|
259
261
|
* and `every`'s group keys a compose branch's runs by commit, as the trunk
|
|
260
262
|
* branch's.
|
|
261
263
|
*
|
|
262
|
-
* Only the top-level `on:`
|
|
264
|
+
* Only the top-level `on:` and `concurrency:` blocks change,
|
|
263
265
|
* each under {@link BUILD_BLOCK_MARKER}; the rest of the file is kept as it
|
|
264
266
|
* is. Throws when one workflow holds a phase of each build, which no one
|
|
265
267
|
* concurrency group can serve.
|
|
@@ -300,12 +302,18 @@ export function buildTriggerEdits(green, sources, options = {}) {
|
|
|
300
302
|
}
|
|
301
303
|
else {
|
|
302
304
|
const main = `refs/heads/${green.branch}`;
|
|
305
|
+
const on = triggersOf(isObject(doc) ? doc.on : undefined);
|
|
306
|
+
let next = on;
|
|
303
307
|
// A push filter that lists branches gains the compose branches; one with no list already runs on every branch.
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
text = writeTopLevel(text, "on", { ...on, push: { ...pushOn, branches: [...pushOn.branches, COMPOSE_BRANCHES] } });
|
|
308
|
+
const pushOn = isObject(on.push) ? on.push : undefined;
|
|
309
|
+
if (compose && pushOn && Array.isArray(pushOn.branches) && !pushOn.branches.includes(COMPOSE_BRANCHES)) {
|
|
310
|
+
next = { ...next, push: { ...pushOn, branches: [...pushOn.branches, COMPOSE_BRANCHES] } };
|
|
308
311
|
}
|
|
312
|
+
// #4216: the tick starts the workflow by hand at a ci/build/<sha> tag when the forge ran nothing on a commit.
|
|
313
|
+
if (!("workflow_dispatch" in next))
|
|
314
|
+
next = { ...next, workflow_dispatch: {} };
|
|
315
|
+
if (next !== on)
|
|
316
|
+
text = writeTopLevel(text, "on", next);
|
|
309
317
|
// #4220: a compose branch's commits each get a verdict, so its runs are keyed by commit, as the trunk branch's are.
|
|
310
318
|
const keyed = compose ? `(github.ref == '${main}' || startsWith(github.ref, 'refs/heads/${COMPOSE_BRANCH_PREFIX}'))` : `github.ref == '${main}'`;
|
|
311
319
|
const unkeyed = compose ? `!${keyed}` : `github.ref != '${main}'`;
|
|
@@ -320,6 +328,16 @@ export function buildTriggerEdits(green, sources, options = {}) {
|
|
|
320
328
|
}
|
|
321
329
|
return out;
|
|
322
330
|
}
|
|
331
|
+
/** A workflow's `on:` as a map: `on: push` and `on: [push, pull_request]` name triggers with no settings. */
|
|
332
|
+
function triggersOf(on) {
|
|
333
|
+
if (isObject(on))
|
|
334
|
+
return on;
|
|
335
|
+
if (typeof on === "string")
|
|
336
|
+
return { [on]: null };
|
|
337
|
+
if (Array.isArray(on))
|
|
338
|
+
return Object.fromEntries(on.filter((t) => typeof t === "string").map((t) => [t, null]));
|
|
339
|
+
return {};
|
|
340
|
+
}
|
|
323
341
|
/**
|
|
324
342
|
* Replace the top-level `key:` block of a YAML file with `value` (or with
|
|
325
343
|
* `value`'s lines, the key's own line first), under
|
package/package.json
CHANGED
|
@@ -37,6 +37,8 @@ interface Run {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
/** The fake GitHub: runs per commit, and what dispatching the suites at a commit concludes. */
|
|
40
|
+
const RUN_BASE = 100_000;
|
|
41
|
+
|
|
40
42
|
class FakeGithub {
|
|
41
43
|
runs = new Map<string, Run[]>();
|
|
42
44
|
dispatched: string[] = [];
|
|
@@ -74,9 +76,12 @@ class FakeGithub {
|
|
|
74
76
|
const runs = this.runs.get(m[1]) ?? [];
|
|
75
77
|
return send(200, { total_count: runs.length, check_runs: runs.map((r) => ({ ...r, completed_at: null, html_url: null })) });
|
|
76
78
|
}
|
|
77
|
-
|
|
79
|
+
// Each job is its own workflow run here, numbered RUN_BASE above the job's id.
|
|
80
|
+
m = /^\/repos\/acme\/app\/actions\/jobs\/(\d+)$/.exec(url.pathname);
|
|
81
|
+
if (req.method === "GET" && m) return send(200, { id: Number(m[1]), run_id: RUN_BASE + Number(m[1]) });
|
|
82
|
+
m = /^\/repos\/acme\/app\/actions\/runs\/(\d+)\/rerun-failed-jobs$/.exec(url.pathname);
|
|
78
83
|
if (req.method === "POST" && m) {
|
|
79
|
-
const id = Number(m[1]);
|
|
84
|
+
const id = Number(m[1]) - RUN_BASE;
|
|
80
85
|
this.reruns.push(id);
|
|
81
86
|
// The job runs again and fails the same way.
|
|
82
87
|
for (const [sha, runs] of this.runs) {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { describe, expect, test } from "vitest";
|
|
7
|
-
import { agedOut, BUILD_REQUEST_TTL_MS, planBuilds, recordOf, RETRY_TTL_MS, sameRecord, type BuildRequest, type CiRecord } from "./ci-builds";
|
|
7
|
+
import { agedOut, BUILD_REQUEST_TTL_MS, MISSING_RUN_GRACE_MS, planBuilds, recordOf, RETRY_TTL_MS, sameRecord, type BuildRequest, type CiRecord } from "./ci-builds";
|
|
8
8
|
import { evaluateCommit, type TickCommit } from "./ci-green";
|
|
9
9
|
import type { CheckRun } from "./ci-green-forge";
|
|
10
10
|
import type { CiGreen } from "./declaration";
|
|
@@ -172,3 +172,48 @@ describe("records (#4169)", () => {
|
|
|
172
172
|
expect(agedOut(pending, NOW)).toMatchObject({ state: "unbuilt", phases: { suites: { verdict: "unbuilt", reason: "aged-out" } } });
|
|
173
173
|
});
|
|
174
174
|
});
|
|
175
|
+
|
|
176
|
+
describe("a missing every phase (#4216)", () => {
|
|
177
|
+
// The forge ran suites on the commit but never ran check: its push run was skipped.
|
|
178
|
+
const skipped = (committed: string) => {
|
|
179
|
+
const { commits, runs } = judged([[run("suites", "success")]]);
|
|
180
|
+
commits[0].committed = committed;
|
|
181
|
+
return { commits, runs };
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
test("half an hour after the commit landed, the tick asks for the every phase once, at a ci/build tag", () => {
|
|
185
|
+
const { commits, runs } = skipped(ago(MISSING_RUN_GRACE_MS + 60_000));
|
|
186
|
+
const p = plan(commits, runs);
|
|
187
|
+
expect(p.requests).toEqual([expect.objectContaining({ commit: commits[0].sha, phases: ["check"], reason: "missing", tag: `ci/build/${commits[0].sha}` })]);
|
|
188
|
+
expect(commits[0].verdict!.phases[0]).toMatchObject({ phase: "check", verdict: "pending", reason: "requested" });
|
|
189
|
+
// The record keeps it, so the next tick doesn't ask again.
|
|
190
|
+
const req = new Map(p.requests.map((r) => [r.commit, r] as const));
|
|
191
|
+
const record = recordOf({ green: GREEN, commit: commits[0], retries: [], requests: req, now: NOW });
|
|
192
|
+
expect(record.missing).toEqual({ phases: ["check"], at: p.requests[0].at });
|
|
193
|
+
const again = skipped(ago(MISSING_RUN_GRACE_MS + 60_000));
|
|
194
|
+
expect(plan(again.commits, again.runs, { records: new Map([[commits[0].sha, record]]) }).requests).toEqual([]);
|
|
195
|
+
const { request: _request, ...afterTagDeleted } = record;
|
|
196
|
+
const later = recordOf({ green: GREEN, commit: again.commits[0], previous: afterTagDeleted as CiRecord, retries: [], requests: new Map(), now: NOW });
|
|
197
|
+
expect(later.missing).toEqual(record.missing);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("nothing is asked for inside the grace, for a newest phase, for a tagged commit, or while a request waits", () => {
|
|
201
|
+
expect(plan(...Object.values(skipped(ago(MISSING_RUN_GRACE_MS - 60_000))) as [TickCommit[], Map<string, CheckRun[]>]).requests).toEqual([]);
|
|
202
|
+
// suites missing is the newest phase's business (superseded or bisected), not a missing request.
|
|
203
|
+
const { commits, runs } = judged([[run("check", "success")]]);
|
|
204
|
+
commits[0].committed = ago(MISSING_RUN_GRACE_MS + 60_000);
|
|
205
|
+
expect(plan(commits, runs).requests).toEqual([]);
|
|
206
|
+
const tagged = skipped(ago(MISSING_RUN_GRACE_MS + 60_000));
|
|
207
|
+
tagged.commits[0].before = "green";
|
|
208
|
+
expect(plan(tagged.commits, tagged.runs).requests).toEqual([]);
|
|
209
|
+
const waiting = skipped(ago(MISSING_RUN_GRACE_MS + 60_000));
|
|
210
|
+
const r: BuildRequest = { schema: 1, tag: `ci/build/${waiting.commits[0].sha}`, commit: waiting.commits[0].sha, branch: "main", phases: ["suites"], reason: "requested", at: ago(60_000) };
|
|
211
|
+
expect(plan(waiting.commits, waiting.runs, { requests: new Map([[r.commit, r]]) }).requests).toEqual([]);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("a phase that sets no build is never asked for", () => {
|
|
215
|
+
const green: CiGreen = { ...GREEN, phases: GREEN.phases.map((ph) => (ph.name === "check" ? { ...ph, build: undefined } : ph)) };
|
|
216
|
+
const { commits, runs } = skipped(ago(MISSING_RUN_GRACE_MS + 60_000));
|
|
217
|
+
expect(planBuilds({ green, commits, runs, requests: new Map(), records: new Map(), now: NOW, retryVia: null }).requests).toEqual([]);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
@@ -26,6 +26,10 @@
|
|
|
26
26
|
* waits on is `pending`. A request no runner served in six hours leaves
|
|
27
27
|
* the phase `unbuilt`, and once its phases are decided the tick deletes
|
|
28
28
|
* the tag.
|
|
29
|
+
* - Missing. A phase that declares `build: "every"` and has no run on a
|
|
30
|
+
* commit half an hour after it landed (the forge skipped the push run, as
|
|
31
|
+
* on acc9b9dbd) gets one build request, `missing`, for those phases
|
|
32
|
+
* (#4216). The record keeps `missing`, so a commit is asked for once.
|
|
29
33
|
* - Records. Every first-parent commit the tick judged gets
|
|
30
34
|
* `_ci/<sha>.json` on `chant/lifecycle`, rewritten only when what it says
|
|
31
35
|
* changes: `green`, `red`, `unbuilt` or `pending`, with each required
|
|
@@ -47,12 +51,14 @@ const LEDGER_REF = "refs/heads/chant/lifecycle";
|
|
|
47
51
|
|
|
48
52
|
/** How long a build request may wait for a runner before its phases count as unbuilt. */
|
|
49
53
|
export const BUILD_REQUEST_TTL_MS = 6 * 3_600_000;
|
|
54
|
+
/** How long an `every` phase may have no run on a commit before the tick asks for one (#4216). */
|
|
55
|
+
export const MISSING_RUN_GRACE_MS = 30 * 60_000;
|
|
50
56
|
/** How long a retry may take to start before the failure it was counts. */
|
|
51
57
|
export const RETRY_TTL_MS = 3_600_000;
|
|
52
58
|
/** Above this many unbuilt commits a bisect halves the range; at or below it, it steps back one commit at a time. */
|
|
53
59
|
export const BISECT_HALVE_ABOVE = 4;
|
|
54
60
|
|
|
55
|
-
export type BuildRequestReason = "bisect" | "retry" | "requested";
|
|
61
|
+
export type BuildRequestReason = "bisect" | "retry" | "requested" | "missing";
|
|
56
62
|
|
|
57
63
|
/** A `ci/build/<sha>` tag's record: the phases to build at the commit, and why. */
|
|
58
64
|
export interface BuildRequest {
|
|
@@ -84,6 +90,8 @@ export interface CiRecord {
|
|
|
84
90
|
retried?: Record<string, { run: number; at: string }>;
|
|
85
91
|
/** The build request waiting on this commit, when there is one. */
|
|
86
92
|
request?: { tag: string; reason: BuildRequestReason; phases: string[]; at: string; dispatched?: true };
|
|
93
|
+
/** The `every` phases a `missing` request asked for, and when (#4216). A commit is asked for once. */
|
|
94
|
+
missing?: { phases: string[]; at: string };
|
|
87
95
|
}
|
|
88
96
|
|
|
89
97
|
export interface CiRecordRun {
|
|
@@ -166,6 +174,20 @@ export function planBuilds(input: BuildsInput): BuildsPlan {
|
|
|
166
174
|
}
|
|
167
175
|
}
|
|
168
176
|
|
|
177
|
+
// Missing (#4216): an `every` phase the forge never ran on a commit, asked for once per commit.
|
|
178
|
+
for (const c of commits) {
|
|
179
|
+
if (c.before !== "none" || !c.verdict || age(c.committed, now) < MISSING_RUN_GRACE_MS) continue;
|
|
180
|
+
if (input.records.get(c.sha)?.missing || input.requests.has(c.sha) || plan.requests.some((q) => q.commit === c.sha)) continue;
|
|
181
|
+
const phases = green.require.filter((name) => {
|
|
182
|
+
if (phaseOf(green, name).build !== "every") return false;
|
|
183
|
+
const p = phaseIn(c, name);
|
|
184
|
+
return p !== undefined && p.verdict === "pending" && p.runs.length === 0 && p.reason === undefined;
|
|
185
|
+
});
|
|
186
|
+
if (phases.length === 0) continue;
|
|
187
|
+
plan.requests.push(request(green, c.sha, phases, "missing", now));
|
|
188
|
+
for (const name of phases) setPhase(c, name, (q) => ({ ...q, verdict: "pending", reason: "requested" }));
|
|
189
|
+
}
|
|
190
|
+
|
|
169
191
|
// Superseded: newest first, a newest phase with no run below a commit with one.
|
|
170
192
|
for (const name of green.require) {
|
|
171
193
|
if (buildOf(green, name) !== "newest") continue;
|
|
@@ -265,9 +287,11 @@ export function recordOf(input: { green: CiGreen; commit: TickCommit; previous?:
|
|
|
265
287
|
const retried = { ...(previous?.retried ?? {}) };
|
|
266
288
|
for (const r of input.retries) if (r.sha === c.sha) retried[r.run.name] = { run: r.run.id, at: iso(now) };
|
|
267
289
|
const req = input.requests.get(c.sha);
|
|
290
|
+
const missing = req?.reason === "missing" ? { phases: req.phases, at: req.at } : previous?.missing;
|
|
268
291
|
const extras = {
|
|
269
292
|
...(Object.keys(retried).length > 0 ? { retried } : {}),
|
|
270
293
|
...(req ? { request: { tag: req.tag, reason: req.reason, phases: req.phases, at: req.at } } : {}),
|
|
294
|
+
...(missing ? { missing } : {}),
|
|
271
295
|
};
|
|
272
296
|
if (c.before === "revoked" || !c.verdict) {
|
|
273
297
|
return { schema: 1, commit: c.sha, branch: green.branch, state: "red", at: iso(now), revoked: true, phases: previous?.phases ?? {}, ...(previous?.failure ? { failure: previous.failure } : {}), ...extras };
|
|
@@ -158,6 +158,32 @@ describe("the tick for a newest phase (#4169)", () => {
|
|
|
158
158
|
});
|
|
159
159
|
});
|
|
160
160
|
|
|
161
|
+
describe("the tick for a missing every phase (#4216)", () => {
|
|
162
|
+
const CHECK_WORKFLOW = "name: ci\non:\n push:\n branches: [main]\n workflow_dispatch: {}\njobs:\n check:\n runs-on: ubuntu-latest\n steps:\n - run: true\n";
|
|
163
|
+
|
|
164
|
+
test("a commit the forge never ran check on gets one request, started at its ci/build tag, and the record keeps it", async () => {
|
|
165
|
+
const { root, origin } = workspace();
|
|
166
|
+
writeFiles(root, { ".github/workflows/ci.yml": CHECK_WORKFLOW });
|
|
167
|
+
const a = commitAt(root, 2, "app/a.txt");
|
|
168
|
+
git(root, "push", "-q", "origin", "main");
|
|
169
|
+
// GitHub skipped a's push run of ci.yml; release-suites ran.
|
|
170
|
+
const runs = new Map([[a, [run("suites", "success")]]]);
|
|
171
|
+
const forge = fakeForge(runs);
|
|
172
|
+
const tick = await ciTick({ cwd: root, forge, now: NOW });
|
|
173
|
+
expect(tick.requested.map((r) => [r.commit, r.phases, r.reason])).toEqual([[a, ["check"], "missing"]]);
|
|
174
|
+
expect(forge.dispatched).toEqual([`ci.yml@ci/build/${a}`]);
|
|
175
|
+
expect(remoteTags(origin)).toEqual([`ci/build/${a}`]);
|
|
176
|
+
expect(remoteRecord(origin, a)).toMatchObject({ state: "pending", missing: { phases: ["check"] }, request: { reason: "missing", dispatched: true }, phases: { check: { reason: "requested" } } });
|
|
177
|
+
|
|
178
|
+
// The started run passes: the commit goes green and the request is done.
|
|
179
|
+
runs.set(a, [run("suites", "success"), run("check", "success")]);
|
|
180
|
+
const second = await ciTick({ cwd: root, forge, now: NOW });
|
|
181
|
+
expect(second.made).toEqual([`ci/green/${a}`]);
|
|
182
|
+
expect(second.requested).toEqual([]);
|
|
183
|
+
expect(forge.dispatched).toHaveLength(1);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
161
187
|
describe("chant ci run --at (#4169)", () => {
|
|
162
188
|
test("pushes a request for every required phase and starts the workflows holding them", async () => {
|
|
163
189
|
const { root, origin } = workspace();
|
|
@@ -192,7 +192,7 @@ export async function runCiWorkflow(ctx: CommandContext): Promise<number> {
|
|
|
192
192
|
console.log(
|
|
193
193
|
e.build === "newest"
|
|
194
194
|
? `Wrote ${path}'s triggers and concurrency: ${e.phases.join(", ")} build${e.phases.length === 1 ? "s" : ""} the newest commit on ${green.branch} when a run ends, and a ci/build/<sha> tag by hand.`
|
|
195
|
-
: `Wrote ${path}'s concurrency: ${e.phases.join(", ")} build${e.phases.length === 1 ? "s" : ""} every commit on ${green.branch}, none cancelled.`,
|
|
195
|
+
: `Wrote ${path}'s triggers and concurrency: ${e.phases.join(", ")} build${e.phases.length === 1 ? "s" : ""} every commit on ${green.branch}, none cancelled, and a ci/build/<sha> tag by hand when the forge ran nothing.`,
|
|
196
196
|
);
|
|
197
197
|
}
|
|
198
198
|
return 0;
|
|
@@ -64,3 +64,34 @@ describe("the GitHub check-run client (#3573)", () => {
|
|
|
64
64
|
expect(() => ciForgeFromEnv("forgejo", {})).toThrow(/GitHub only/);
|
|
65
65
|
});
|
|
66
66
|
});
|
|
67
|
+
|
|
68
|
+
describe("re-running a failed check run (#4219)", () => {
|
|
69
|
+
const run = (id: number) => ({ id, name: "suites", status: "completed", conclusion: "failure", completedAt: null, url: null });
|
|
70
|
+
const forgeWith = (jobs: Record<number, unknown>) => {
|
|
71
|
+
const calls: string[] = [];
|
|
72
|
+
const fetch: ForgeFetch = async (url, init) => {
|
|
73
|
+
calls.push(`${init.method} ${new URL(url).pathname}`);
|
|
74
|
+
const m = /\/actions\/jobs\/(\d+)$/.exec(new URL(url).pathname);
|
|
75
|
+
if (init.method === "GET" && m) return { ok: true, status: 200, text: async () => JSON.stringify(jobs[Number(m[1])] ?? {}) };
|
|
76
|
+
return { ok: true, status: 201, text: async () => "" };
|
|
77
|
+
};
|
|
78
|
+
return { forge: githubCiForge({ apiBase: "https://api.github.com", repo: "acme/app", token: "t", fetch }), calls };
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
test("re-runs the failed jobs of the job's workflow run, so an aggregate's shards go again with it, once per run", async () => {
|
|
82
|
+
const { forge, calls } = forgeWith({ 7: { id: 7, run_id: 500 }, 8: { id: 8, run_id: 500 } });
|
|
83
|
+
await forge.rerun!(run(7));
|
|
84
|
+
await forge.rerun!(run(8));
|
|
85
|
+
expect(calls).toEqual([
|
|
86
|
+
"GET /repos/acme/app/actions/jobs/7",
|
|
87
|
+
"POST /repos/acme/app/actions/runs/500/rerun-failed-jobs",
|
|
88
|
+
"GET /repos/acme/app/actions/jobs/8",
|
|
89
|
+
]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("falls back to the job's own re-run when the job names no workflow run", async () => {
|
|
93
|
+
const { forge, calls } = forgeWith({ 9: { id: 9 } });
|
|
94
|
+
await forge.rerun!(run(9));
|
|
95
|
+
expect(calls).toEqual(["GET /repos/acme/app/actions/jobs/9", "POST /repos/acme/app/actions/jobs/9/rerun"]);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -57,7 +57,9 @@ export interface CiForge {
|
|
|
57
57
|
checkRuns(sha: string): Promise<CheckRun[]>;
|
|
58
58
|
/**
|
|
59
59
|
* Run a failed check run's job again (#4169), so a flake costs one more
|
|
60
|
-
* run before the commit counts red
|
|
60
|
+
* run before the commit counts red: on GitHub, every failed job of its
|
|
61
|
+
* workflow run, so the jobs an aggregate needs go again with it (#4219).
|
|
62
|
+
* Absent on a forge chant can't ask.
|
|
61
63
|
*/
|
|
62
64
|
rerun?(run: CheckRun): Promise<void>;
|
|
63
65
|
/**
|
|
@@ -86,6 +88,7 @@ export function githubCiForge(opts: GithubCiForgeOptions): CiForge {
|
|
|
86
88
|
const root = opts.apiBase.replace(/\/+$/, "");
|
|
87
89
|
const fetchImpl = opts.fetch ?? (globalThis.fetch as unknown as ForgeFetch);
|
|
88
90
|
const headers = { Authorization: `Bearer ${opts.token}`, Accept: "application/vnd.github+json" };
|
|
91
|
+
const rerunRuns = new Set<number>();
|
|
89
92
|
return {
|
|
90
93
|
kind: "github",
|
|
91
94
|
async checkRuns(sha) {
|
|
@@ -115,9 +118,24 @@ export function githubCiForge(opts: GithubCiForgeOptions): CiForge {
|
|
|
115
118
|
}
|
|
116
119
|
return out;
|
|
117
120
|
},
|
|
118
|
-
// An Actions job's check run has the job's id
|
|
121
|
+
// An Actions job's check run has the job's id. Re-running only that job
|
|
122
|
+
// would re-run an aggregate such as `suites` against the shards' old
|
|
123
|
+
// results, so the failed jobs of its workflow run go again together,
|
|
124
|
+
// each run once per tick (#4219). Both calls need `actions: write`.
|
|
119
125
|
async rerun(run) {
|
|
120
|
-
|
|
126
|
+
const jobUrl = `${root}/repos/${opts.repo}/actions/jobs/${run.id}`;
|
|
127
|
+
const res = await fetchImpl(jobUrl, { method: "GET", headers });
|
|
128
|
+
const text = await res.text();
|
|
129
|
+
if (!res.ok) throw new ForgeApiError("GET", jobUrl, res.status, text);
|
|
130
|
+
const job: unknown = text ? JSON.parse(text) : null;
|
|
131
|
+
const runId = isObject(job) && typeof job.run_id === "number" ? job.run_id : null;
|
|
132
|
+
if (runId === null) {
|
|
133
|
+
await post(`${jobUrl}/rerun`, {});
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (rerunRuns.has(runId)) return;
|
|
137
|
+
rerunRuns.add(runId);
|
|
138
|
+
await post(`${root}/repos/${opts.repo}/actions/runs/${runId}/rerun-failed-jobs`, {});
|
|
121
139
|
},
|
|
122
140
|
async dispatch(workflow, ref) {
|
|
123
141
|
await post(`${root}/repos/${opts.repo}/actions/workflows/${encodeURIComponent(workflow)}/dispatches`, { ref });
|
|
@@ -346,8 +346,8 @@ describe("the triggers of a phase that sets build (#4169)", () => {
|
|
|
346
346
|
[".github/workflows/suites.yml", "newest", ["suites"], true],
|
|
347
347
|
]);
|
|
348
348
|
const ci = parseYAMLDocument(edits[0].text) as Yaml;
|
|
349
|
-
// The triggers of an every phase stay, pull requests included.
|
|
350
|
-
expect(ci.on).toEqual({ push: { branches: ["main"] }, pull_request: null });
|
|
349
|
+
// The triggers of an every phase stay, pull requests included, and it can be started by hand (#4216).
|
|
350
|
+
expect(ci.on).toEqual({ push: { branches: ["main"] }, pull_request: null, workflow_dispatch: {} });
|
|
351
351
|
expect(ci.concurrency).toEqual({ group: "chant-build-lint-${{ github.ref == 'refs/heads/main' && github.sha || github.ref }}", "cancel-in-progress": "${{ github.ref != 'refs/heads/main' }}" });
|
|
352
352
|
expect(edits[0].text).toContain(`# Comments above a block stay.\n${BUILD_BLOCK_MARKER}\nconcurrency:`);
|
|
353
353
|
const suites = parseYAMLDocument(edits[1].text) as Yaml;
|
|
@@ -388,7 +388,7 @@ describe("the triggers of a phase that sets build (#4169)", () => {
|
|
|
388
388
|
];
|
|
389
389
|
const edits = buildTriggerEdits(green(), sources, { compose: true });
|
|
390
390
|
const ci = parseYAMLDocument(edits[0].text) as Yaml;
|
|
391
|
-
expect(ci.on).toEqual({ push: { branches: ["main", "chant/compose/**"] }, pull_request: null });
|
|
391
|
+
expect(ci.on).toEqual({ push: { branches: ["main", "chant/compose/**"] }, pull_request: null, workflow_dispatch: {} });
|
|
392
392
|
expect(ci.concurrency).toEqual({
|
|
393
393
|
group: "chant-build-lint-${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/chant/compose/')) && github.sha || github.ref }}",
|
|
394
394
|
"cancel-in-progress": "${{ !(github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/chant/compose/')) }}",
|
|
@@ -399,7 +399,7 @@ describe("the triggers of a phase that sets build (#4169)", () => {
|
|
|
399
399
|
expect(buildTriggerEdits(green(), edits.map((e) => ({ path: e.path, text: e.text })), { compose: true }).map((e) => e.changed)).toEqual([false, false]);
|
|
400
400
|
// A push with no branch list already runs on every branch, so its triggers stay.
|
|
401
401
|
const anyBranch = LINT_WF.replace(" push:\n branches: [main]\n", " push:\n");
|
|
402
|
-
expect((parseYAMLDocument(buildTriggerEdits(green(), [{ path: ".github/workflows/ci.yml", text: anyBranch }], { compose: true })[0].text) as Yaml).on).toEqual({ push: null, pull_request: null });
|
|
402
|
+
expect((parseYAMLDocument(buildTriggerEdits(green(), [{ path: ".github/workflows/ci.yml", text: anyBranch }], { compose: true })[0].text) as Yaml).on).toEqual({ push: null, pull_request: null, workflow_dispatch: {} });
|
|
403
403
|
// Without compose the bytes are what #4169 wrote.
|
|
404
404
|
expect(buildTriggerEdits(green(), sources).map((e) => e.text)).toEqual(buildTriggerEdits(green(), sources, { compose: false }).map((e) => e.text));
|
|
405
405
|
const tick = parseYAMLDocument(renderCiGreenWorkflow({ green: green(), workflows: ["ci"], chantVersion: "0.124.33", workspaceRoot: ".", compose: true })) as Yaml;
|