@intentius/chant 0.124.37 → 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.
Files changed (41) hide show
  1. package/dist/cli/handlers/components.d.ts.map +1 -1
  2. package/dist/cli/handlers/components.js +1 -0
  3. package/dist/cli/handlers/graph.js +5 -1
  4. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  5. package/dist/cli/handlers/lifecycle.js +2 -1
  6. package/dist/cli/handlers/scenario.d.ts.map +1 -1
  7. package/dist/cli/handlers/scenario.js +1 -0
  8. package/dist/cli/handlers/search.d.ts.map +1 -1
  9. package/dist/cli/handlers/search.js +3 -1
  10. package/dist/lifecycle/observe.d.ts +3 -0
  11. package/dist/lifecycle/observe.d.ts.map +1 -1
  12. package/dist/lifecycle/observe.js +1 -1
  13. package/dist/wave-runtime.mjs +189 -189
  14. package/dist/workspace/ci-builds.d.ts +12 -1
  15. package/dist/workspace/ci-builds.d.ts.map +1 -1
  16. package/dist/workspace/ci-builds.js +26 -0
  17. package/dist/workspace/ci-green-cli.js +1 -1
  18. package/dist/workspace/ci-green-forge.d.ts +3 -1
  19. package/dist/workspace/ci-green-forge.d.ts.map +1 -1
  20. package/dist/workspace/ci-green-forge.js +20 -2
  21. package/dist/workspace/ci-green-workflow.d.ts +6 -4
  22. package/dist/workspace/ci-green-workflow.d.ts.map +1 -1
  23. package/dist/workspace/ci-green-workflow.js +26 -8
  24. package/package.json +1 -1
  25. package/src/cli/handlers/components.ts +1 -0
  26. package/src/cli/handlers/graph.test.ts +17 -0
  27. package/src/cli/handlers/graph.ts +4 -1
  28. package/src/cli/handlers/lifecycle.ts +2 -1
  29. package/src/cli/handlers/scenario.ts +1 -0
  30. package/src/cli/handlers/search.ts +3 -1
  31. package/src/lifecycle/observe.test.ts +14 -1
  32. package/src/lifecycle/observe.ts +4 -1
  33. package/src/workspace/ci-builds.e2e.test.ts +7 -2
  34. package/src/workspace/ci-builds.test.ts +46 -1
  35. package/src/workspace/ci-builds.ts +25 -1
  36. package/src/workspace/ci-green-builds.test.ts +26 -0
  37. package/src/workspace/ci-green-cli.ts +1 -1
  38. package/src/workspace/ci-green-forge.test.ts +31 -0
  39. package/src/workspace/ci-green-forge.ts +21 -3
  40. package/src/workspace/ci-green-workflow.test.ts +4 -4
  41. 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":"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"}
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. Absent on a forge chant can't ask.
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;;;OAGG;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,CA8CjE;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"}
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, so the job is re-run by it. Both need `actions: write`.
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
- await post(`${root}/repos/${opts.repo}/actions/jobs/${run.id}/rerun`, {});
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, and the group is the commit itself on the
161
- * branch, so no push cancels or replaces another's run; elsewhere, such as
162
- * a pull request, a new push cancels the run it supersedes.
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:` (for `newest`, or `every` with `compose`) and `concurrency:` blocks change,
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, and the group is the commit itself on the
253
- * branch, so no push cancels or replaces another's run; elsewhere, such as
254
- * a pull request, a new push cancels the run it supersedes.
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:` (for `newest`, or `every` with `compose`) and `concurrency:` blocks change,
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 on = isObject(doc) && isObject(doc.on) ? doc.on : undefined;
305
- const pushOn = on && isObject(on.push) ? on.push : undefined;
306
- if (compose && on && pushOn && Array.isArray(pushOn.branches) && !pushOn.branches.includes(COMPOSE_BRANCHES)) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant",
3
- "version": "0.124.37",
3
+ "version": "0.124.39",
4
4
  "description": "Declarative infrastructure-as-code toolkit — TypeScript on Node.js",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -612,6 +612,7 @@ export async function runComponentsStatus(ctx: CommandContext): Promise<number>
612
612
  if (!statusParams) return 1;
613
613
  const buildResult = await build(resolve(args.src ?? config.sourceDir ?? "."), targetSerializers, undefined, {
614
614
  buildParams: statusParams,
615
+ config: config as unknown as Record<string, unknown>,
615
616
  });
616
617
  // Which deployed stack(s) to read the change set from (behold#100).
617
618
  //
@@ -1138,6 +1138,7 @@ describe("runGraph", () => {
1138
1138
  expect(exit).toBe(0);
1139
1139
  expect(observeMock).toHaveBeenCalledWith("prod", expect.anything(), expect.anything(), {
1140
1140
  owned: true,
1141
+ config: {},
1141
1142
  stacks: [],
1142
1143
  });
1143
1144
  });
@@ -1260,6 +1261,7 @@ describe("runGraph", () => {
1260
1261
  expect(exit).toBe(0);
1261
1262
  expect(observeMock).toHaveBeenCalledWith("prod", expect.anything(), expect.anything(), {
1262
1263
  owned: true,
1264
+ config: {},
1263
1265
  stacks: [],
1264
1266
  });
1265
1267
  expect(stderrBuf.join("\n")).toMatch(/component discovery failed/i);
@@ -1449,6 +1451,21 @@ describe("runGraph", () => {
1449
1451
  expect(options.buildRoots).toHaveLength(1);
1450
1452
  });
1451
1453
 
1454
+ test("--live hands the project config to build() and observeResources() (#4252)", async () => {
1455
+ // gcp.project reaches the live read only as the annotation the serializer
1456
+ // writes from config, so a build without config left every read with no project.
1457
+ resolveLexMock.mockResolvedValue(["k8s"]);
1458
+ loadPluginsMock.mockResolvedValue([k8sPluginStub()]);
1459
+ loadChantConfigMock.mockResolvedValue({ config: { lexicons: ["gcp"], gcp: { project: "three-cloud" } } });
1460
+ observeMock.mockResolvedValue({ observations: [], errors: [], warnings: [] });
1461
+ const exit = await runGraph({ args: makeArgs({ format: "ir", live: true, env: "kcheck" }), plugins: [], serializers: [] });
1462
+ expect(exit).toBe(0);
1463
+ const [, , , options] = buildMock.mock.calls[0] as [string, unknown, unknown, { config?: { gcp?: { project?: string } } }];
1464
+ expect(options.config?.gcp?.project).toBe("three-cloud");
1465
+ const [, , , observeOpts] = observeMock.mock.calls[0] as [string, unknown, unknown, { config?: { gcp?: { project?: string } } }];
1466
+ expect(observeOpts.config?.gcp?.project).toBe("three-cloud");
1467
+ });
1468
+
1452
1469
  test("--live --overlay classifies an observed rendered node as managed, not foreign", async () => {
1453
1470
  resolveLexMock.mockResolvedValue(["k8s"]);
1454
1471
  loadPluginsMock.mockResolvedValue([k8sPluginStub()]);
@@ -270,7 +270,9 @@ async function runGraphLive(
270
270
  resolve(args.src ?? config.sourceDir ?? "."),
271
271
  plugins.map((p) => p.serializer),
272
272
  undefined,
273
- { buildParams: liveBuildParams, buildRoots },
273
+ // #4252: the serializers read lexicon settings from config (gcp.project
274
+ // becomes each resource's project annotation, which the live read takes).
275
+ { buildParams: liveBuildParams, buildRoots, config: config as unknown as Record<string, unknown> },
274
276
  );
275
277
  if (buildResult.errors.length > 0) {
276
278
  console.error(formatError({ message: "Build failed — fix errors before graphing live state" }));
@@ -342,6 +344,7 @@ async function runGraphLive(
342
344
  } else {
343
345
  const observeResult = await observeResources(environment, observing, buildResult, {
344
346
  owned: true,
347
+ config: config as unknown as Record<string, unknown>,
345
348
  stacks: [...stacks],
346
349
  // `--namespace <ns>` (#1629): where to read entities that declare no
347
350
  // namespace. One project declaring the GitOps binding and another
@@ -164,7 +164,7 @@ export async function runLifecycleSnapshot(ctx: CommandContext): Promise<number>
164
164
  const buildRoots = collectBuildRootContributors(targetPlugins, config as unknown as Record<string, unknown>, projectPath);
165
165
  for (const target of targets) {
166
166
  const label = target.stack ? `stack "${target.stack}"` : "project";
167
- const buildResult = await build(target.root, targetSerializers, undefined, { buildParams: declaredParams, buildRoots });
167
+ const buildResult = await build(target.root, targetSerializers, undefined, { buildParams: declaredParams, buildRoots, config: config as unknown as Record<string, unknown> });
168
168
  if (buildResult.errors.length > 0) {
169
169
  console.error(formatError({ message: `Build failed for ${label} — fix errors before taking a snapshot` }));
170
170
  anyHardError = true;
@@ -1257,6 +1257,7 @@ export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
1257
1257
  const buildResult = await build(resolveBuildRoot(args, config), targetSerializers, undefined, {
1258
1258
  buildParams: declaredParams,
1259
1259
  buildRoots: collectBuildRootContributors(plugins, config as unknown as Record<string, unknown>, resolve(".")),
1260
+ config: config as unknown as Record<string, unknown>,
1260
1261
  });
1261
1262
  if (buildResult.errors.length > 0) {
1262
1263
  console.error(formatError({ message: "Build failed — fix errors before planning" }));
@@ -70,6 +70,7 @@ export async function runScenarioCheck(ctx: CommandContext): Promise<number> {
70
70
  const buildResult = await build(resolveBuildRoot(args, config), serializers, undefined, {
71
71
  buildParams: declaredParams,
72
72
  buildRoots: collectBuildRootContributors(plugins, config as unknown as Record<string, unknown>, resolve(".")),
73
+ config: config as unknown as Record<string, unknown>,
73
74
  });
74
75
  if (buildResult.errors.length > 0) {
75
76
  console.error(formatError({ message: "Build failed — fix errors before checking scenarios" }));
@@ -134,7 +134,9 @@ export async function runSearch(ctx: CommandContext): Promise<number> {
134
134
  return 1;
135
135
  }
136
136
  const plugins = ctx.plugins.length > 0 ? ctx.plugins : await loadPlugins(await resolveProjectLexicons(projectPath));
137
- const buildResult = await build(resolve(args.src ?? config.sourceDir ?? "."), plugins.map((p) => p.serializer));
137
+ const buildResult = await build(resolve(args.src ?? config.sourceDir ?? "."), plugins.map((p) => p.serializer), undefined, {
138
+ config: config as unknown as Record<string, unknown>,
139
+ });
138
140
  if (buildResult.errors.length > 0) {
139
141
  console.error(formatError({ message: "Build failed — fix errors before searching live state" }));
140
142
  return 1;
@@ -9,8 +9,10 @@ import type { BuildResult } from "../build";
9
9
  // whose src is scoped reports BARE entity names, matching the deployed
10
10
  // LogicalResourceIds, not the whole-project build's disambiguated names.
11
11
  const scopedBuilds: Record<string, string[]> = {};
12
+ const scopedBuildOptions: Array<{ src: string; options: unknown }> = [];
12
13
  vi.mock("../build", () => ({
13
- build: async (src: string): Promise<BuildResult> => {
14
+ build: async (src: string, _serializers?: unknown, _x?: unknown, options?: unknown): Promise<BuildResult> => {
15
+ scopedBuildOptions.push({ src, options });
14
16
  const names = scopedBuilds[src] ?? [];
15
17
  return {
16
18
  outputs: new Map<string, string>([["aws", "{}"]]),
@@ -284,6 +286,17 @@ describe("observeResources", () => {
284
286
  expect(observations[0].resources["west::server"].physicalId).toBe("i-west");
285
287
  });
286
288
 
289
+ it("hands the project config to each stack's scoped build (#4252)", async () => {
290
+ scopedBuildOptions.length = 0;
291
+ const plugins = [awsPlugin(() => ({}))];
292
+ await observeResources("floci", plugins, mockBuild(), {
293
+ stacks: [{ name: "gcp-net", src: "gcp-net/src" }],
294
+ config: { gcp: { project: "three-cloud" } },
295
+ });
296
+ const call = scopedBuildOptions.find((c) => c.src.endsWith("gcp-net/src"));
297
+ expect(call?.options).toEqual({ config: { gcp: { project: "three-cloud" } } });
298
+ });
299
+
287
300
  it("with an empty stacks array — falls back to the single unstacked call", async () => {
288
301
  const calls: Array<{ stack?: string }> = [];
289
302
  const plugins = [
@@ -104,6 +104,9 @@ export async function observeResources(
104
104
  * Passed through to every lexicon's `describeResources`; one that has no
105
105
  * namespace-like scope ignores it. */
106
106
  namespace?: string;
107
+ /** The project configuration, handed to a stack's scoped build so its
108
+ * serializers see lexicon settings such as gcp's `gcp.project` (#4252). */
109
+ config?: Record<string, unknown>;
107
110
  },
108
111
  ): Promise<ObserveResult> {
109
112
  const owned = opts?.owned ?? true;
@@ -119,7 +122,7 @@ export async function observeResources(
119
122
  const key = resolvePath(src);
120
123
  let r = scopedBuildCache.get(key);
121
124
  if (!r) {
122
- r = await buildProject(key, serializers);
125
+ r = await buildProject(key, serializers, undefined, opts?.config ? { config: opts.config } : undefined);
123
126
  scopedBuildCache.set(key, r);
124
127
  }
125
128
  return r;
@@ -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
- m = /^\/repos\/acme\/app\/actions\/jobs\/(\d+)\/rerun$/.exec(url.pathname);
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 };