@intentius/chant 0.124.38 → 0.124.40

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 (93) hide show
  1. package/dist/cli/component-endpoint.d.ts +20 -0
  2. package/dist/cli/component-endpoint.d.ts.map +1 -0
  3. package/dist/cli/component-endpoint.js +36 -0
  4. package/dist/cli/handlers/fan-out.d.ts.map +1 -1
  5. package/dist/cli/handlers/fan-out.js +10 -1
  6. package/dist/cli/handlers/pr.d.ts.map +1 -1
  7. package/dist/cli/handlers/pr.js +11 -5
  8. package/dist/cli/handlers/promote.d.ts.map +1 -1
  9. package/dist/cli/handlers/promote.js +7 -2
  10. package/dist/cli/handlers/run.d.ts +4 -1
  11. package/dist/cli/handlers/run.d.ts.map +1 -1
  12. package/dist/cli/handlers/run.js +17 -6
  13. package/dist/components/auto-release.d.ts +35 -3
  14. package/dist/components/auto-release.d.ts.map +1 -1
  15. package/dist/components/auto-release.js +29 -2
  16. package/dist/components/cli-support.d.ts +2 -0
  17. package/dist/components/cli-support.d.ts.map +1 -1
  18. package/dist/components/cli-support.js +2 -0
  19. package/dist/components/deploy-units.d.ts.map +1 -1
  20. package/dist/components/deploy-units.js +5 -0
  21. package/dist/components/promote.d.ts.map +1 -1
  22. package/dist/components/promote.js +39 -16
  23. package/dist/lifecycle/__fixtures__/publish-fakes.d.ts.map +1 -1
  24. package/dist/lifecycle/__fixtures__/publish-fakes.js +6 -1
  25. package/dist/lifecycle/publish-declaration.d.ts.map +1 -1
  26. package/dist/lifecycle/publish-declaration.js +4 -3
  27. package/dist/lifecycle/publish-npm.d.ts +2 -2
  28. package/dist/lifecycle/publish-npm.d.ts.map +1 -1
  29. package/dist/lifecycle/publish-npm.js +31 -8
  30. package/dist/lifecycle/release-ledger.d.ts +31 -0
  31. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  32. package/dist/lifecycle/release-ledger.js +19 -1
  33. package/dist/lifecycle/status.d.ts +5 -1
  34. package/dist/lifecycle/status.d.ts.map +1 -1
  35. package/dist/lifecycle/status.js +10 -5
  36. package/dist/live-endpoint.d.ts +18 -0
  37. package/dist/live-endpoint.d.ts.map +1 -1
  38. package/dist/live-endpoint.js +27 -0
  39. package/dist/wave-runtime.mjs +201 -201
  40. package/dist/workspace/ci-builds.d.ts +12 -1
  41. package/dist/workspace/ci-builds.d.ts.map +1 -1
  42. package/dist/workspace/ci-builds.js +26 -0
  43. package/dist/workspace/ci-green-cli.js +1 -1
  44. package/dist/workspace/ci-green-forge.d.ts +3 -1
  45. package/dist/workspace/ci-green-forge.d.ts.map +1 -1
  46. package/dist/workspace/ci-green-forge.js +20 -2
  47. package/dist/workspace/ci-green-workflow.d.ts +6 -4
  48. package/dist/workspace/ci-green-workflow.d.ts.map +1 -1
  49. package/dist/workspace/ci-green-workflow.js +26 -8
  50. package/dist/workspace/ci-run.d.ts +7 -0
  51. package/dist/workspace/ci-run.d.ts.map +1 -1
  52. package/dist/workspace/ci-run.js +21 -3
  53. package/dist/workspace/status.d.ts +3 -1
  54. package/dist/workspace/status.d.ts.map +1 -1
  55. package/dist/workspace/status.js +3 -2
  56. package/dist/workspace/status.schema.json +1 -0
  57. package/package.json +1 -1
  58. package/src/cli/component-endpoint.ts +43 -0
  59. package/src/cli/handlers/fan-out.test.ts +10 -0
  60. package/src/cli/handlers/fan-out.ts +12 -0
  61. package/src/cli/handlers/pr-endpoint.test.ts +169 -0
  62. package/src/cli/handlers/pr.ts +12 -5
  63. package/src/cli/handlers/promote.ts +16 -2
  64. package/src/cli/handlers/run.test.ts +2 -1
  65. package/src/cli/handlers/run.ts +16 -5
  66. package/src/components/auto-release-infra.test.ts +131 -0
  67. package/src/components/auto-release.test.ts +40 -1
  68. package/src/components/auto-release.ts +50 -4
  69. package/src/components/cli-support.ts +4 -0
  70. package/src/components/deploy-units.ts +5 -0
  71. package/src/components/promote.test.ts +39 -0
  72. package/src/components/promote.ts +38 -14
  73. package/src/lifecycle/__fixtures__/publish-fakes.ts +6 -1
  74. package/src/lifecycle/publish-declaration.ts +4 -3
  75. package/src/lifecycle/publish-npm.ts +33 -8
  76. package/src/lifecycle/publish-targets.test.ts +36 -3
  77. package/src/lifecycle/release-ledger.ts +42 -1
  78. package/src/lifecycle/status.test.ts +19 -0
  79. package/src/lifecycle/status.ts +14 -6
  80. package/src/live-endpoint.ts +31 -0
  81. package/src/workspace/ci-builds.e2e.test.ts +7 -2
  82. package/src/workspace/ci-builds.test.ts +46 -1
  83. package/src/workspace/ci-builds.ts +25 -1
  84. package/src/workspace/ci-green-builds.test.ts +26 -0
  85. package/src/workspace/ci-green-cli.ts +1 -1
  86. package/src/workspace/ci-green-forge.test.ts +31 -0
  87. package/src/workspace/ci-green-forge.ts +21 -3
  88. package/src/workspace/ci-green-workflow.test.ts +4 -4
  89. package/src/workspace/ci-green-workflow.ts +22 -8
  90. package/src/workspace/ci-run.test.ts +26 -0
  91. package/src/workspace/ci-run.ts +20 -3
  92. package/src/workspace/status.schema.json +1 -0
  93. package/src/workspace/status.ts +7 -3
@@ -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. Absent on a forge chant can't ask.
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, so the job is re-run by it. Both need `actions: write`.
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
- await post(`${root}/repos/${opts.repo}/actions/jobs/${run.id}/rerun`, {});
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;
@@ -327,9 +327,11 @@ export interface BuildTriggerEdit {
327
327
  * ref that never cancels a run in progress, so GitHub keeps the newest
328
328
  * push waiting and drops the ones between, and each tag's build runs in a
329
329
  * group of its own.
330
- * - `every`: the triggers stay, and the group is the commit itself on the
331
- * branch, so no push cancels or replaces another's run; elsewhere, such as
332
- * a pull request, a new push cancels the run it supersedes.
330
+ * - `every`: the triggers stay, with `workflow_dispatch` added so the tick
331
+ * can start a run the forge never started on a commit (#4216), and the
332
+ * group is the commit itself on the branch, so no push cancels or replaces
333
+ * another's run; elsewhere, such as a pull request or a `ci/build/<sha>`
334
+ * tag, a new push cancels the run it supersedes.
333
335
  *
334
336
  * With `compose` (#4220, a workspace declaring `ci.workflow`), both builds
335
337
  * also run on pushes to `chant/compose/**`: `newest` adds the filter to the
@@ -337,7 +339,7 @@ export interface BuildTriggerEdit {
337
339
  * and `every`'s group keys a compose branch's runs by commit, as the trunk
338
340
  * branch's.
339
341
  *
340
- * Only the top-level `on:` (for `newest`, or `every` with `compose`) and `concurrency:` blocks change,
342
+ * Only the top-level `on:` and `concurrency:` blocks change,
341
343
  * each under {@link BUILD_BLOCK_MARKER}; the rest of the file is kept as it
342
344
  * is. Throws when one workflow holds a phase of each build, which no one
343
345
  * concurrency group can serve.
@@ -375,12 +377,16 @@ export function buildTriggerEdits(green: CiGreen, sources: readonly WorkflowSour
375
377
  text = writeTopLevel(text, "concurrency", { group: `chant-build-${tag}-\${{ github.ref }}`, "cancel-in-progress": false });
376
378
  } else {
377
379
  const main = `refs/heads/${green.branch}`;
380
+ const on = triggersOf(isObject(doc) ? doc.on : undefined);
381
+ let next = on;
378
382
  // A push filter that lists branches gains the compose branches; one with no list already runs on every branch.
379
- const on = isObject(doc) && isObject(doc.on) ? doc.on : undefined;
380
- const pushOn = on && isObject(on.push) ? on.push : undefined;
381
- if (compose && on && pushOn && Array.isArray(pushOn.branches) && !pushOn.branches.includes(COMPOSE_BRANCHES)) {
382
- text = writeTopLevel(text, "on", { ...on, push: { ...pushOn, branches: [...pushOn.branches, COMPOSE_BRANCHES] } });
383
+ const pushOn = isObject(on.push) ? on.push : undefined;
384
+ if (compose && pushOn && Array.isArray(pushOn.branches) && !pushOn.branches.includes(COMPOSE_BRANCHES)) {
385
+ next = { ...next, push: { ...pushOn, branches: [...pushOn.branches, COMPOSE_BRANCHES] } };
383
386
  }
387
+ // #4216: the tick starts the workflow by hand at a ci/build/<sha> tag when the forge ran nothing on a commit.
388
+ if (!("workflow_dispatch" in next)) next = { ...next, workflow_dispatch: {} };
389
+ if (next !== on) text = writeTopLevel(text, "on", next);
384
390
  // #4220: a compose branch's commits each get a verdict, so its runs are keyed by commit, as the trunk branch's are.
385
391
  const keyed = compose ? `(github.ref == '${main}' || startsWith(github.ref, 'refs/heads/${COMPOSE_BRANCH_PREFIX}'))` : `github.ref == '${main}'`;
386
392
  const unkeyed = compose ? `!${keyed}` : `github.ref != '${main}'`;
@@ -396,6 +402,14 @@ export function buildTriggerEdits(green: CiGreen, sources: readonly WorkflowSour
396
402
  return out;
397
403
  }
398
404
 
405
+ /** A workflow's `on:` as a map: `on: push` and `on: [push, pull_request]` name triggers with no settings. */
406
+ function triggersOf(on: unknown): Record<string, unknown> {
407
+ if (isObject(on)) return on;
408
+ if (typeof on === "string") return { [on]: null };
409
+ if (Array.isArray(on)) return Object.fromEntries(on.filter((t): t is string => typeof t === "string").map((t) => [t, null]));
410
+ return {};
411
+ }
412
+
399
413
  /**
400
414
  * Replace the top-level `key:` block of a YAML file with `value` (or with
401
415
  * `value`'s lines, the key's own line first), under
@@ -24,6 +24,7 @@ import type { StepRecord } from "../op/local-executor";
24
24
  import { sshFromEd25519 } from "./trust/dsse";
25
25
  import { verifyOpRunStatement } from "./trust/op-run-statement";
26
26
  import { readRunLedger } from "../lifecycle/run-ledger";
27
+ import { resolveRunId } from "../lifecycle/release-ledger";
27
28
  import { prLoopJobs, PR_LOOP_SETUP } from "../components/pr-pipeline";
28
29
  import { parseArgs } from "../cli/parse-args";
29
30
  import { ciSecretsFor, readCiSecrets } from "./ci-secrets";
@@ -837,6 +838,31 @@ describe("a generated forge job runs ci run --in-place (#4156)", () => {
837
838
  expect(index.runs[0]).toMatchObject({ job: "plan", runId: "forge-plan-1", signer: null, runOrigin: { forge: "github", jobId: "plan", attempt: 2 } });
838
839
  });
839
840
 
841
+ test("a release record written in a runner job names the run the run index lists, and a forge job's never does (#4275)", async () => {
842
+ const { app, base } = workspace();
843
+ const reports = scratchDir("chant-ci-run-index-");
844
+ const output = join(scratchDir("chant-ci-run-out-"), "pr");
845
+ // The host's forge run id, even passed on by name, does not reach a runner job's steps.
846
+ const r = await ciRun({ image: JOB_IMAGE, cwd: app, job: "plan", env: "prod", forge: "forgejo", base, pr: 7, chant: fakeChant(), hostEnv: { ...HOST, ...FORGE }, passEnv: ["GITHUB_RUN_ID", "GITHUB_JOB"], push: false, output, loop: { reportUrl: `file://${reports}` } });
847
+ expect(r.status).toBe("ok");
848
+ const step = envFile(output, "pr-plan");
849
+ expect(step).not.toHaveProperty("GITHUB_RUN_ID");
850
+ const index = JSON.parse(readFileSync(join(reports, "runs", "index.json"), "utf-8")) as { runs: { runId: string; runOrigin: unknown }[] };
851
+ expect(index.runs[0]!.runId).toMatch(/^chant-\d+$/);
852
+ expect(resolveRunId(undefined, step)).toEqual({ runId: index.runs[0]!.runId, runOrigin: index.runs[0]!.runOrigin });
853
+ expect(index.runs[0]!.runOrigin).toEqual({ forge: "chant" });
854
+
855
+ // In place in a forge job, the runner's variables from the host are dropped, and the record names the forge's run.
856
+ const forge = await ciRun({ cwd: app, job: "plan", env: "prod", forge: "forgejo", base, pr: 7, chant: fakeChant(), hostEnv: { ...HOST, ...FORGE, CHANT_CI_RUN_ID: "chant-1", CHANT_CI_RUN_FORGE: "chant" }, inPlace: true, push: false, runId: "forge-plan-2" });
857
+ expect(forge.status).toBe("ok");
858
+ const inPlace = envFile(join(app, ".chant/pr"), "pr-plan");
859
+ expect(inPlace).not.toHaveProperty("CHANT_CI_RUN_ID");
860
+ expect(resolveRunId(undefined, inPlace).runOrigin).toMatchObject({ forge: "github", repo: "acme/estate" });
861
+ // And a forge's run id wins over the runner's variables wherever both appear.
862
+ expect(resolveRunId(undefined, { GITHUB_RUN_ID: "4242", CHANT_CI_RUN_ID: "chant-1", CHANT_CI_RUN_FORGE: "chant" }).runId).toBe("4242");
863
+ expect(resolveRunId(undefined, { CHANT_CI_RUN_ID: "chant-1" }).runOrigin).toEqual({ forge: "local" });
864
+ });
865
+
840
866
  test("a failed command's exit code is the job's, and --wave reaches pr-apply", async () => {
841
867
  const { app, base } = workspace();
842
868
  const r = await ciRun({ cwd: app, job: "apply", env: "prod", forge: "github", base, chant: fakeChant(), hostEnv: { ...HOST, ...FORGE, FAKE_EXIT: "3" }, inPlace: true, push: false, loop: { waveGate: "release" }, wave: 2 });
@@ -79,7 +79,7 @@ import { detectForge, ForgeEnvironmentError, forgeFromEnv, FORGE_KINDS, type Com
79
79
  import { PR_APPLY_GATE } from "../pr-loop";
80
80
  import type { CommandContext } from "../cli/registry";
81
81
  import { formatError, formatWarning } from "../cli/format";
82
- import { resolveCiJob, type RunOrigin } from "../lifecycle/release-ledger";
82
+ import { CHANT_CI_RUN_FORGE_ENV, CHANT_CI_RUN_ID_ENV, FORGE_RUN_ENV, resolveCiJob, type RunOrigin } from "../lifecycle/release-ledger";
83
83
  import { fetchLifecycleStatus, pushLifecycleReport } from "../lifecycle/git";
84
84
  import { ACTIVITY_PROFILES } from "../op/activity-profiles";
85
85
  import type { ActivityFn, ActivityProfile } from "../op/activity-registry";
@@ -761,6 +761,23 @@ function ciRunSigner(req: CiRunRequest, at: { cwd: string; top: string; commit:
761
761
  }
762
762
  }
763
763
 
764
+ /**
765
+ * The steps' environment (#4275). A job the chant runner ran gets its run's id
766
+ * and origin, so a release record written inside it names the run the run
767
+ * index lists, and none of the forge's run variables. Any other job, in place
768
+ * in a forge job or on this machine, never carries the runner's.
769
+ */
770
+ export function ciRunStepEnv(jobEnv: Record<string, string>, origin: RunOrigin, runId: string): Record<string, string> {
771
+ const out = { ...jobEnv };
772
+ delete out[CHANT_CI_RUN_ID_ENV];
773
+ delete out[CHANT_CI_RUN_FORGE_ENV];
774
+ if (origin.forge !== "chant") return out;
775
+ for (const name of FORGE_RUN_ENV) delete out[name];
776
+ out[CHANT_CI_RUN_ID_ENV] = runId;
777
+ out[CHANT_CI_RUN_FORGE_ENV] = "chant";
778
+ return out;
779
+ }
780
+
764
781
  /** Where the job runs: a fresh worktree of the commit, or with `inPlace` the checkout itself, which is left as it is. */
765
782
  function jobCheckout(top: string, commit: string, inPlace?: boolean): { dir: string; remove(): void } {
766
783
  return inPlace ? { dir: top, remove() {} } : freshCheckout(top, commit);
@@ -788,16 +805,16 @@ async function runCiJob(
788
805
  const checkout = jobCheckout(top, commit, req.inPlace);
789
806
  try {
790
807
  const jobDir = place.dir === "." ? checkout.dir : join(checkout.dir, place.dir);
808
+ const runId = req.runId ?? `chant-${Date.now()}`;
791
809
  const op = ciJobOp({
792
810
  job,
793
811
  env: req.env,
794
812
  cwd: jobDir,
795
- jobEnv,
813
+ jobEnv: ciRunStepEnv(jobEnv, origin, runId),
796
814
  setup: req.inPlace ? [] : prLoopSetup(req.image ?? PR_LOOP_IMAGE),
797
815
  build: [...(req.beforeScript ?? []), ...(job.build ?? [])],
798
816
  ...(req.chant ? { chant: req.chant } : {}),
799
817
  });
800
- const runId = req.runId ?? `chant-${Date.now()}`;
801
818
  const warnings: string[] = [...early];
802
819
  const resume = req.job === "apply" && req.resumeRecord ? { host: resolve(req.resumeRecord), job: join(jobDir, PR_APPLY_RECORD) } : undefined;
803
820
  if (resume && existsSync(resume.host)) {
@@ -1149,6 +1149,7 @@
1149
1149
  "releaseDigest": { "type": ["string", "null"], "pattern": "^(?:jcs1-)?sha256:[0-9a-f]{64}$", "description": "Added in contract 1 by #4043, written by chant 0.124.29 and newer. The release this deploy was made from: the releaseDigest of _releases/<releaseDigest>.json. A promote with --release writes it, a run --components with --release writes it, and a rollback or redeploy copies it from the record it restores. Null for a deploy that named no release. With the item's environment it names the release target." },
1150
1150
  "releaseLabel": { "type": ["string", "null"], "description": "Added in contract 1 by #4043, written by chant 0.124.29 and newer. The label that release was cut with, from _releases/cuts.jsonl. Null when releaseDigest is null, when the release has no label, and when the cuts can't be read." },
1151
1151
  "flags": { "type": "array", "items": { "enum": ["legacy-digest"] } },
1152
+ "digestKind": { "enum": ["plan", "deploy-input", "templates"], "description": "Added in contract 1 by #4274, written by chant 0.124.35 and newer. What digest names when it is not an artifact digest: plan, the plan digest a pr-apply of a component that published nothing applied under its approval; deploy-input, that component's deploy-input digest when nothing planned; templates, the digest of the templates a release records for the component (#4222). Absent for an artifact digest. Compare joins a plan or deploy-input record on gitSha." },
1152
1153
  "plan": {
1153
1154
  "type": ["object", "null"],
1154
1155
  "description": "The release plan digest names, read from _plans/<digest>.json on chant/lifecycle (ws-055, #2733): the work items and evidence this release shipped. Null when this checkout has no plan stored under that digest — most releases carry no plan, and a plan not yet fetched also reads null."
@@ -38,7 +38,7 @@ import { formatError } from "../cli/format";
38
38
  import type { CommandContext } from "../cli/registry";
39
39
  import { GATES_DIR } from "../lifecycle/gate-ledger";
40
40
  import { readPathSha } from "../lifecycle/git";
41
- import { latestPerComponent, readReleaseLedger, type ReleaseRecord } from "../lifecycle/release-ledger";
41
+ import { latestPerComponent, readReleaseLedger, type ReleaseDigestKind, type ReleaseRecord } from "../lifecycle/release-ledger";
42
42
  import { describeRevocation, revokedCommitsAt, revokedReleaseLookup, type ReleaseRevocation, type RevokedRelease } from "../lifecycle/release-revocation";
43
43
  import { readReleasePlan, type ReleasePlan } from "../lifecycle/plan-ledger";
44
44
  import { readDeployOutcomes, type DeployOutcome } from "../lifecycle/deploy-outcome";
@@ -138,6 +138,8 @@ export interface StatusRelease {
138
138
  releaseLabel: string | null;
139
139
  /** Read flags, such as `legacy-digest` (#2514). */
140
140
  flags: string[];
141
+ /** What `digest` names when it is not an artifact digest (#4274): `plan`, `deploy-input` or `templates`. Absent for an artifact digest. */
142
+ digestKind?: ReleaseDigestKind;
141
143
  /**
142
144
  * The release plan `digest` names, read from `_plans/<digest>.json` on
143
145
  * chant/lifecycle (ws-055, #2733) — the work items and evidence this
@@ -611,6 +613,7 @@ async function readEnvironment(
611
613
  releaseDigest: r.releaseDigest ?? null,
612
614
  releaseLabel: (r.releaseDigest !== undefined ? labels.get(r.releaseDigest) : undefined) ?? null,
613
615
  flags: [...(r.flags ?? [])],
616
+ ...(r.digestKind ? { digestKind: r.digestKind } : {}),
614
617
  plan: await readReleasePlan(r.digest, { cwd, prefix: planPrefix }),
615
618
  ...(await revokedItem(r, env, revokedOf, back)),
616
619
  })),
@@ -665,8 +668,9 @@ async function supersedeRevokedGates(gates: StatusGate[], revokedOf: RevokedOf):
665
668
  );
666
669
  }
667
670
 
668
- /** The digest two environments are compared on: the input side when there is one. */
669
- const compareKey = (r: StatusRelease): string => r.inputDigest ?? r.digest;
671
+ /** The digest two environments are compared on: the input side when there is one, the commit for an apply (#4274). */
672
+ const compareKey = (r: StatusRelease): string =>
673
+ r.digestKind === "plan" || r.digestKind === "deploy-input" ? `git:${r.gitSha}` : r.inputDigest ?? r.digest;
670
674
 
671
675
  export function compareEnvironments(a: StatusEnvironment, b: StatusEnvironment): StatusCompare {
672
676
  const left = new Map(a.releases.map((r) => [r.component, r]));