toolroll 0.9.71 → 0.9.72
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/CHANGELOG.md +79 -0
- package/dist/agent-fence.d.ts +5 -2
- package/dist/agent-fence.js +5 -2
- package/dist/approval-authority.d.ts +20 -0
- package/dist/approval-authority.js +67 -0
- package/dist/assignment.js +26 -4
- package/dist/browser/workspace.css +1 -1
- package/dist/builder.js +48 -6
- package/dist/chat-decide.js +17 -2
- package/dist/check-access.js +2 -2
- package/dist/check-report.d.ts +109 -0
- package/dist/check-report.js +204 -0
- package/dist/claim.js +1 -1
- package/dist/commit-extract.d.ts +94 -0
- package/dist/commit-extract.js +436 -0
- package/dist/contracts/console-api.d.ts +1 -1
- package/dist/contracts/console-api.js +1 -0
- package/dist/contracts/flow.d.ts +16 -0
- package/dist/contracts/flow.js +3 -0
- package/dist/contracts/release-check.d.ts +335 -0
- package/dist/contracts/release-check.js +159 -0
- package/dist/contracts/release-terms.d.ts +60 -0
- package/dist/contracts/release-terms.js +70 -0
- package/dist/contracts/verification-receipt.d.ts +42 -8
- package/dist/contracts/verification-receipt.js +24 -7
- package/dist/contracts/verification-view.d.ts +1 -0
- package/dist/deploy-gate.d.ts +17 -0
- package/dist/deploy-gate.js +68 -0
- package/dist/dispatch.js +3 -10
- package/dist/dispose.js +4 -1
- package/dist/evidence-pack.d.ts +1 -1
- package/dist/evidence-pack.js +20 -2
- package/dist/evidence.d.ts +1 -0
- package/dist/evidence.js +2 -1
- package/dist/flow-engine.js +5 -1
- package/dist/flow-pull-request.d.ts +5 -2
- package/dist/flow-pull-request.js +14 -5
- package/dist/flow-schedule.js +25 -6
- package/dist/flows-ui.js +7 -1
- package/dist/invoke.d.ts +17 -6
- package/dist/invoke.js +48 -18
- package/dist/lead-status.js +5 -2
- package/dist/live-terminal.d.ts +10 -6
- package/dist/live-terminal.js +55 -14
- package/dist/nightly-check.d.ts +104 -0
- package/dist/nightly-check.js +362 -0
- package/dist/operate.js +39 -2
- package/dist/person-proof.d.ts +2 -0
- package/dist/person-proof.js +14 -0
- package/dist/plan-auto.js +4 -4
- package/dist/planner-source.d.ts +1 -1
- package/dist/project-delete-ui.js +1 -1
- package/dist/proof.js +2 -0
- package/dist/publish.js +156 -144
- package/dist/pull-request-flow.d.ts +2 -0
- package/dist/pull-request-flow.js +24 -2
- package/dist/release-adapters.d.ts +8 -0
- package/dist/release-adapters.js +212 -15
- package/dist/release-authority.d.ts +112 -0
- package/dist/release-authority.js +126 -0
- package/dist/release-carry.d.ts +233 -0
- package/dist/release-carry.js +645 -0
- package/dist/release-cli.d.ts +3 -0
- package/dist/release-cli.js +124 -5
- package/dist/release-coverage.js +3 -0
- package/dist/release-recheck.d.ts +146 -0
- package/dist/release-recheck.js +657 -0
- package/dist/release-view.d.ts +34 -0
- package/dist/release-view.js +27 -0
- package/dist/release.d.ts +131 -1
- package/dist/release.js +562 -244
- package/dist/remote-main.d.ts +2 -0
- package/dist/remote-main.js +1 -1
- package/dist/result-completion.d.ts +6 -0
- package/dist/result-completion.js +15 -1
- package/dist/result-follow-ups.js +29 -1
- package/dist/sandbox-runtime.d.ts +16 -5
- package/dist/sandbox-runtime.js +19 -6
- package/dist/scope.d.ts +115 -5
- package/dist/scope.js +219 -7
- package/dist/server/render-tasks.d.ts +8 -0
- package/dist/server/render-tasks.js +50 -9
- package/dist/server/route-table.js +1 -0
- package/dist/server/task-views.js +4 -0
- package/dist/server/tasks.js +12 -0
- package/dist/store.d.ts +227 -5
- package/dist/store.js +727 -37
- package/dist/task-outcome-cli.js +7 -3
- package/dist/toolroll-update.js +2 -2
- package/dist/verification-evidence.d.ts +25 -1
- package/dist/verification-evidence.js +57 -2
- package/dist/work-index.js +3 -6
- package/package.json +1 -1
package/dist/release.js
CHANGED
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
32
32
|
import { randomUUID } from "node:crypto";
|
|
33
33
|
import { dirname, join } from "node:path";
|
|
34
|
+
import { personProof } from "./person-proof.js";
|
|
34
35
|
/** The pull-request checks a release waits for, by exact job name (.github/workflows/ci.yml). Every one must have
|
|
35
36
|
* concluded `success` on the gated commit; src/ci-workflow.test.ts holds the workflow to this list. */
|
|
36
37
|
export const REQUIRED_PR_CHECKS = [
|
|
@@ -54,6 +55,21 @@ class Stop extends Error {
|
|
|
54
55
|
this.reason = reason;
|
|
55
56
|
}
|
|
56
57
|
}
|
|
58
|
+
/** The branch moved before the deploy reservation: carry the approval to the new head, or file it for a person. */
|
|
59
|
+
class Restart extends Error {
|
|
60
|
+
head;
|
|
61
|
+
constructor(head) {
|
|
62
|
+
super(`restart at ${head}`);
|
|
63
|
+
this.head = head;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** Every commit that landed after the approval, in words: the summary and the merge message list them all. */
|
|
67
|
+
export function landedWords(landed) {
|
|
68
|
+
if (landed.length === 0)
|
|
69
|
+
return [];
|
|
70
|
+
return [`Landed after the approval (${landed.length} commit${landed.length === 1 ? "" : "s"}):`,
|
|
71
|
+
...landed.map(one => `- ${one.sha.slice(0, 7)}: ${one.files.map(file => `${file.path} (+${file.added} −${file.deleted})`).join(", ")}`)];
|
|
72
|
+
}
|
|
57
73
|
const branchFile = (branch) => branch.replace(/[^A-Za-z0-9._-]+/g, "_");
|
|
58
74
|
export const journalFile = (stateDir, branch) => join(stateDir, "releases", `${branchFile(branch)}.json`);
|
|
59
75
|
export const resumeCommand = (branch, checkout) => `toolroll release ${branch} --repo ${checkout}`;
|
|
@@ -98,12 +114,22 @@ export function ownerApproval(gate, digest) {
|
|
|
98
114
|
return { ok: false, why: "not approved yet" };
|
|
99
115
|
if (digest === null || approval.digest !== digest)
|
|
100
116
|
return { ok: false, why: "the approval is for different scope text" };
|
|
117
|
+
if (approval.basis === "lead")
|
|
118
|
+
return { ok: false, why: "approved by the lead, not by a person" };
|
|
101
119
|
if (approval.basis !== "password" && approval.basis !== "chat")
|
|
102
120
|
return { ok: false, why: `approved by ${approval.basis ?? "an unknown road"}, not by a person` };
|
|
103
121
|
if (/ \(AI\)$/.test(approval.by) || /^mode /.test(approval.by) || /^lead\b/.test(approval.by) || approval.by === "routine")
|
|
104
122
|
return { ok: false, why: `approved as ${approval.by}, not by a person` };
|
|
105
123
|
if (approval.role !== "approver" || !approval.active)
|
|
106
124
|
return { ok: false, why: `approved by ${approval.by}, who is not an active approver` };
|
|
125
|
+
// A person's proof, recorded for these exact bytes (approval policy §1.5): a lead token or a remote API token sealed
|
|
126
|
+
// through the person's road before the fix said `password` too. A plane that can't say is not a yes.
|
|
127
|
+
if (approval.person === undefined)
|
|
128
|
+
return { ok: false, why: "the plane can't show a person gave this approval; update it first" };
|
|
129
|
+
if (!approval.person.ok)
|
|
130
|
+
return { ok: false, why: approval.person.why };
|
|
131
|
+
if (!personProof(approval.person.method))
|
|
132
|
+
return { ok: false, why: "approved with a machine credential, not by a person" };
|
|
107
133
|
return { ok: true, by: approval.by, at: approval.at };
|
|
108
134
|
}
|
|
109
135
|
/** Every required check present, and every reported check's newest attempt completed successfully. */
|
|
@@ -133,6 +159,8 @@ export async function runRelease(options, adapters) {
|
|
|
133
159
|
const say = adapters.say;
|
|
134
160
|
let journal = null;
|
|
135
161
|
let step = "preflight";
|
|
162
|
+
let freshApproval = false;
|
|
163
|
+
let supersededTask = null;
|
|
136
164
|
const save = () => { journal.updatedAt = adapters.now().toISOString(); writeJournal(file, journal); };
|
|
137
165
|
const advance = (next) => { journal.step = next; journal.stopped = null; save(); };
|
|
138
166
|
const intend = (at) => { journal.intents[at] = adapters.now().toISOString(); save(); };
|
|
@@ -175,6 +203,8 @@ export async function runRelease(options, adapters) {
|
|
|
175
203
|
const aside = file.replace(/\.json$/, `.superseded-${adapters.now().toISOString().replace(/[:.]/g, "-")}.json`);
|
|
176
204
|
renameSync(file, aside);
|
|
177
205
|
say(`Kept the earlier release journal at ${aside}.`);
|
|
206
|
+
freshApproval = journal.needsApproval === true;
|
|
207
|
+
supersededTask = journal.task.id;
|
|
178
208
|
journal = null;
|
|
179
209
|
}
|
|
180
210
|
if (journal?.step === "done") {
|
|
@@ -183,8 +213,28 @@ export async function runRelease(options, adapters) {
|
|
|
183
213
|
}
|
|
184
214
|
if (!await adapters.github.authenticated())
|
|
185
215
|
throw new Stop("github-auth", "GitHub CLI is not signed in: run `gh auth login`, then rerun.");
|
|
186
|
-
if (journal === null)
|
|
216
|
+
if (journal === null) {
|
|
187
217
|
journal = await begin(options, adapters, adapters.now());
|
|
218
|
+
// The earlier attempt needs a person's fresh approval (a CI failure after its deploy): this one files a task of its
|
|
219
|
+
// own for the same commit, so the old approval can't carry it.
|
|
220
|
+
// Only an id that has never existed, numbered after the superseded one: never a task whose approval still stands.
|
|
221
|
+
if (freshApproval) {
|
|
222
|
+
const base = journal.task.id;
|
|
223
|
+
const suffix = supersededTask === null ? null : supersededTask === base ? 1 : supersededTask.startsWith(`${base}-`) ? Number(supersededTask.slice(base.length + 1)) : null;
|
|
224
|
+
let picked = null;
|
|
225
|
+
for (let n = suffix !== null && Number.isInteger(suffix) && suffix > 0 ? suffix + 1 : 2; n < 1000; n++) {
|
|
226
|
+
const id = `${base}-${n}`;
|
|
227
|
+
if (!(await adapters.toolroll.gate(id)).exists) {
|
|
228
|
+
picked = id;
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (picked === null)
|
|
233
|
+
throw new Stop("approval", `No unused task id is left for a fresh approval of ${base}.`);
|
|
234
|
+
journal.task = { id: picked, scopeDigest: null };
|
|
235
|
+
journal.freshTask = true;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
188
238
|
else
|
|
189
239
|
say(`Resuming the release of ${journal.tag} (${journal.candidate.sha.slice(0, 7)}) at ${journal.step}.`);
|
|
190
240
|
const j = journal;
|
|
@@ -201,9 +251,169 @@ export async function runRelease(options, adapters) {
|
|
|
201
251
|
throw new Stop("drift", `Pull request #${j.pr} no longer has ${j.candidate.sha.slice(0, 7)} at its head.`);
|
|
202
252
|
return pr;
|
|
203
253
|
};
|
|
204
|
-
|
|
254
|
+
// ---- one approval per release (release-once.md) -------------------------------------------------------------
|
|
255
|
+
const rel = adapters.release;
|
|
256
|
+
let epoch = null;
|
|
257
|
+
// This invocation's own lease owner (review round 1): two runs of the same journal never share an epoch.
|
|
258
|
+
const owner = `${j.id}:${randomUUID()}`;
|
|
259
|
+
/** Take the release's lease (a takeover raises its epoch), then settle every operation still open before any new work. */
|
|
260
|
+
const hold = async () => {
|
|
261
|
+
if (rel === undefined || !j.authority)
|
|
262
|
+
return;
|
|
263
|
+
const lease = await rel.lease(j.authority.id, owner);
|
|
264
|
+
if (!lease.ok)
|
|
265
|
+
throw new Stop("lease", lease.why);
|
|
266
|
+
epoch = lease.epoch;
|
|
267
|
+
await reconcile();
|
|
268
|
+
};
|
|
269
|
+
const reconcile = async () => {
|
|
270
|
+
if (rel === undefined || !j.authority)
|
|
271
|
+
return;
|
|
272
|
+
for (const op of (await rel.operations(j.authority.id)).filter(one => one.state === "reserved" || one.state === "running" || one.state === "unknown")) {
|
|
273
|
+
// A started call whose issuer may still be alive may still land (review round 2): nothing is settled for it on
|
|
274
|
+
// elapsed time. It stays as it is, and a person looks.
|
|
275
|
+
const inFlight = () => new Stop("operation-in-flight", `An earlier ${op.kind} (operation ${op.id}) was started by a process that may still be running. Nothing was settled: check that process, then rerun.`);
|
|
276
|
+
if (op.kind === "deploy") {
|
|
277
|
+
// Reserved and never started: nothing to undo (a start after this takeover is refused: its epoch moved).
|
|
278
|
+
if (op.state === "reserved") {
|
|
279
|
+
await rel.finish(op.id, "failed", { detail: "the call never started" }, epoch);
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
if (await served(adapters, builtHead()) !== null) {
|
|
283
|
+
await rel.finish(op.id, "succeeded", { detail: "reconciled: the services run the candidate" }, epoch);
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if (!op.issuerGone)
|
|
287
|
+
throw inFlight();
|
|
288
|
+
if (op.direct) {
|
|
289
|
+
// A person's own deploy: its process is gone; its lock, update pause and journal must say nothing is half done.
|
|
290
|
+
if (!await adapters.toolroll.deploymentIdle(op.stage))
|
|
291
|
+
throw new Stop("deploy-recovery", `A person's deploy of this release (operation ${op.id}) ended without finishing${op.stage === null ? "" : `; its journal is in ${op.stage}`}. Recover it, then rerun.`);
|
|
292
|
+
await rel.finish(op.id, "failed", { detail: "reconciled: the deploy's process ended, and nothing is deployed or half done" }, epoch);
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
await recoverDeploy();
|
|
296
|
+
await rel.finish(op.id, "failed", { detail: "reconciled: recovered the previous build" }, epoch);
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
// The pull request decides an unknown merge: merged from the gated head is a success, whoever issued it.
|
|
300
|
+
const pr = await adapters.github.pullRequest(j.github, j.branch);
|
|
301
|
+
if (pr?.state === "merged" && pr.number === j.pr && pr.head === op.candidate && pr.mergeCommit !== null)
|
|
302
|
+
await rel.finish(op.id, "succeeded", { externalRef: pr.mergeCommit, detail: "reconciled: the pull request is merged from the gated head" }, epoch);
|
|
303
|
+
else if (op.state === "reserved")
|
|
304
|
+
await rel.finish(op.id, "failed", { detail: "reconciled: the merge never started" }, epoch);
|
|
305
|
+
else if (!op.issuerGone)
|
|
306
|
+
throw inFlight();
|
|
307
|
+
else if (pr?.state === "open" && pr.number === j.pr && pr.head === op.candidate)
|
|
308
|
+
await rel.finish(op.id, "failed", { detail: "reconciled: the merge's process ended and the pull request is still open at the gated head" }, epoch);
|
|
309
|
+
else
|
|
310
|
+
throw new Stop("merge-unknown", `The merge of pull request #${j.pr} can't be reconciled: it is ${pr?.state ?? "gone"}. Check it on GitHub before rerunning.`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
/**
|
|
315
|
+
* Who and what the release is, read again: the repository, the pull request (open, its number, base main) and its
|
|
316
|
+
* head, and the live-release predicate for the coming action. Before the deploy reservation a moved head restarts
|
|
317
|
+
* the release for the new commit; after it, it stops, and the new commit needs a person.
|
|
318
|
+
*/
|
|
319
|
+
const identity = async (action, beforeReservation) => {
|
|
320
|
+
if (rel === undefined) {
|
|
321
|
+
await pinned();
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (await adapters.repo.github() !== j.github)
|
|
325
|
+
throw new Stop("drift", `The release's repository is no longer ${j.github}.`);
|
|
326
|
+
const head = await adapters.repo.remoteHead(j.branch);
|
|
327
|
+
const pr = await adapters.github.pullRequest(j.github, j.branch);
|
|
328
|
+
if (pr === null || pr.number !== j.pr || pr.state !== "open")
|
|
329
|
+
throw new Stop("drift", `Pull request #${j.pr} is no longer the open pull request of ${j.branch}; the release stops.`);
|
|
330
|
+
if (pr.base !== "main")
|
|
331
|
+
throw new Stop("drift", `Pull request #${j.pr} now merges into ${pr.base}, not main; the release stops.`);
|
|
332
|
+
if (head !== j.candidate.sha || pr.head !== j.candidate.sha) {
|
|
333
|
+
if (beforeReservation && head !== null && pr.head === head)
|
|
334
|
+
throw new Restart(head);
|
|
335
|
+
throw new Stop("drift", `${j.branch} moved from ${j.candidate.sha.slice(0, 7)} to ${head?.slice(0, 7) ?? "nothing"} after the deploy began. The new commit needs a person's approval: run \`toolroll release ${j.branch} --repo ${j.checkout} --new\`.`);
|
|
336
|
+
}
|
|
337
|
+
if (j.authority) {
|
|
338
|
+
const live = await rel.live(j.task.id, action);
|
|
339
|
+
// Fail closed: once the release has an authority, its task must be one the predicate applies to AND accepts.
|
|
340
|
+
if (!live.applies || !live.ok)
|
|
341
|
+
throw new Stop("release", `Task ${j.task.id} can't ${action === "dispatch" ? "go on" : action}: ${live.applies ? live.why ?? "its release approval doesn't stand" : "it belongs to no release approval this plane can find"}.`);
|
|
342
|
+
if (epoch !== null && !await rel.renew(j.authority.id, owner, epoch))
|
|
343
|
+
throw new Stop("lease", "Another release coordinator took over this release.");
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
/** The head moved before the deploy reservation: carry the approval to it, or file it for a person. Nothing kept from
|
|
347
|
+
* the old step is used afterwards: the journal is rewritten and the state machine runs again. */
|
|
348
|
+
/**
|
|
349
|
+
* A pull request check failed after the deploy reservation (Alex, 2026-10-25): the candidate froze at deploy, so the
|
|
350
|
+
* release needs a person's fresh approval, even for the same commit. It is persisted (the authority stops; the
|
|
351
|
+
* journal says so) before the release stops; `--new` files the commit for a person again.
|
|
352
|
+
*/
|
|
353
|
+
const ciFailedAfterDeploy = async (what) => {
|
|
354
|
+
j.needsApproval = true;
|
|
355
|
+
save();
|
|
356
|
+
// Fail closed: the authority must have stopped; otherwise its approval could still carry the commit.
|
|
357
|
+
if (rel !== undefined && j.authority && (epoch === null || !await rel.needsApproval(j.authority.id, "a pull request check failed after the deploy", epoch))) {
|
|
358
|
+
throw new Stop("lease", `${what}. Nothing was merged, but this release's approval couldn't be stopped (another coordinator holds it, or a merge is reserved). Check it with \`toolroll release ${j.branch} --repo ${j.checkout}\` before going on.`);
|
|
359
|
+
}
|
|
360
|
+
throw new Stop("ci-failed", `${what}. Nothing was merged. The deploy had already started, so this commit needs a person's approval again: run \`toolroll release ${j.branch} --repo ${j.checkout} --new\`.`);
|
|
361
|
+
};
|
|
362
|
+
/** identity(), on a plane that keeps release authority; a legacy plane keeps the old rules. */
|
|
363
|
+
const watch = async (action) => { if (rel !== undefined)
|
|
364
|
+
await identity(action, true); };
|
|
365
|
+
const restart = async (head) => {
|
|
366
|
+
const tree = await adapters.repo.tree(head);
|
|
367
|
+
if (rel !== undefined && j.authority && epoch !== null) {
|
|
368
|
+
say(` ${j.branch} moved to ${head.slice(0, 7)}: checking whether the approval carries`);
|
|
369
|
+
const carried = await rel.carry(j.authority.id, head, randomUUID(), epoch);
|
|
370
|
+
if (carried.ok) {
|
|
371
|
+
const view = await rel.authority(carried.taskId);
|
|
372
|
+
j.candidate = { sha: head, tree };
|
|
373
|
+
j.task = { id: carried.taskId, scopeDigest: (await adapters.toolroll.gate(carried.taskId)).scopeDigest };
|
|
374
|
+
j.check = null;
|
|
375
|
+
j.completion = null;
|
|
376
|
+
j.landed = view?.landed ?? j.landed ?? [];
|
|
377
|
+
delete j.intents.check;
|
|
378
|
+
delete j.intents.complete;
|
|
379
|
+
j.step = "check";
|
|
380
|
+
j.stopped = null;
|
|
381
|
+
save();
|
|
382
|
+
say(` carried the approval to ${head.slice(0, 7)} (task ${carried.taskId})`);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
say(` ${head.slice(0, 7)} needs a person's approval: ${carried.message}`);
|
|
386
|
+
}
|
|
387
|
+
else
|
|
388
|
+
say(` ${j.branch} moved to ${head.slice(0, 7)}: filing it for approval`);
|
|
389
|
+
j.candidate = { sha: head, tree };
|
|
390
|
+
j.task = { id: gateTaskId(j.packageVersion, head), scopeDigest: null };
|
|
391
|
+
j.approval = null;
|
|
392
|
+
j.check = null;
|
|
393
|
+
j.completion = null;
|
|
394
|
+
j.authority = null;
|
|
395
|
+
epoch = null;
|
|
396
|
+
delete j.intents.gate;
|
|
397
|
+
delete j.intents.check;
|
|
398
|
+
delete j.intents.complete;
|
|
399
|
+
j.step = "gate";
|
|
400
|
+
j.stopped = null;
|
|
401
|
+
save();
|
|
402
|
+
};
|
|
403
|
+
/**
|
|
404
|
+
* The task is approved for `action`: a release task (a carried commit, or any task once the release has an
|
|
405
|
+
* authority) only when the live-release predicate applies to it AND accepts it — fail closed, whatever its own seal
|
|
406
|
+
* says. Anything else needs the owner's own approval of these exact words (which a carried seal never is).
|
|
407
|
+
*/
|
|
408
|
+
const approved = async (gate, action = "dispatch") => {
|
|
205
409
|
if (gate.candidate !== j.candidate.sha || gate.scopeDigest !== j.task.scopeDigest)
|
|
206
410
|
throw new Stop("scope-changed", `Task ${j.task.id}'s candidate or scope changed after approval.`);
|
|
411
|
+
if (rel !== undefined && (j.authority || gate.approval?.basis === "carried")) {
|
|
412
|
+
const live = await rel.live(j.task.id, action);
|
|
413
|
+
if (live.applies && live.ok)
|
|
414
|
+
return;
|
|
415
|
+
throw new Stop("release", `Task ${j.task.id} can't ${action === "dispatch" ? "go on" : action}: ${live.applies ? live.why ?? "its release approval doesn't stand" : "it belongs to no release approval this plane can find"}.`);
|
|
416
|
+
}
|
|
207
417
|
const owner = ownerApproval(gate, j.task.scopeDigest);
|
|
208
418
|
if (!owner.ok)
|
|
209
419
|
throw new Stop("approval", `Task ${j.task.id}: ${owner.why}. An active approver must approve the release scope.`);
|
|
@@ -216,277 +426,385 @@ export async function runRelease(options, adapters) {
|
|
|
216
426
|
};
|
|
217
427
|
/** The commit the passing build recorded: what the deployed runtime is stamped with. */
|
|
218
428
|
const builtHead = () => j.check?.head ?? j.candidate.sha;
|
|
219
|
-
const checked = (gate) => {
|
|
220
|
-
approved(gate);
|
|
429
|
+
const checked = async (gate, action = "dispatch") => {
|
|
430
|
+
await approved(gate, action);
|
|
221
431
|
if (gate.result?.head !== builtHead() || gate.result?.receipt !== j.check?.receipt)
|
|
222
432
|
throw new Stop("drift", `Task ${j.task.id}'s result changed since its check passed.`);
|
|
223
433
|
fullCheck(gate);
|
|
224
434
|
};
|
|
435
|
+
if (j.needsApproval === true)
|
|
436
|
+
throw new Stop("ci-failed", `A pull request check failed after the deploy, so ${j.candidate.sha.slice(0, 7)} needs a person's approval again: run \`toolroll release ${j.branch} --repo ${j.checkout} --new\`.`);
|
|
437
|
+
// A resumed release that already has an authority takes its lease and settles every open operation first.
|
|
438
|
+
await hold();
|
|
225
439
|
while (j.step !== "done") {
|
|
226
440
|
step = j.step;
|
|
227
441
|
say(`• ${j.step}`);
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
442
|
+
try {
|
|
443
|
+
switch (j.step) {
|
|
444
|
+
case "gate": {
|
|
445
|
+
await pinned();
|
|
446
|
+
// The same commit's earlier gate belongs to a release that no longer stands (a CI failure after its deploy, a
|
|
447
|
+
// revoke): a fresh task asks a person again, and their yes opens a new generation.
|
|
448
|
+
if (rel !== undefined && j.freshTask !== true) {
|
|
449
|
+
const base = gateTaskId(j.packageVersion, j.candidate.sha);
|
|
450
|
+
for (let n = 1; n < 50; n++) {
|
|
451
|
+
const id = n === 1 ? base : `${base}-${n}`;
|
|
452
|
+
const live = await rel.live(id, "dispatch");
|
|
453
|
+
if (!live.applies || live.ok) {
|
|
454
|
+
if (j.task.id !== id) {
|
|
455
|
+
j.task = { id, scopeDigest: null };
|
|
456
|
+
save();
|
|
457
|
+
}
|
|
458
|
+
break;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
const existing = await adapters.toolroll.gate(j.task.id);
|
|
463
|
+
// A fresh task this journal never filed has appeared under its id: it isn't ours, and isn't a fresh approval.
|
|
464
|
+
if (existing.exists && j.freshTask === true && j.intents.gate === undefined)
|
|
465
|
+
throw new Stop("approval", `Task ${j.task.id} appeared before this release filed it; rerun with --new.`);
|
|
466
|
+
if (existing.exists) {
|
|
467
|
+
if (existing.candidate !== j.candidate.sha)
|
|
468
|
+
throw new Stop("conflict", `Task ${j.task.id} exists for a different commit (${existing.candidate?.slice(0, 7) ?? "none"}).`);
|
|
469
|
+
j.task.scopeDigest = existing.scopeDigest;
|
|
470
|
+
}
|
|
471
|
+
else {
|
|
472
|
+
intend("gate");
|
|
473
|
+
await adapters.repo.sync(j.branch);
|
|
474
|
+
const filed = await adapters.toolroll.fileGate({
|
|
475
|
+
taskId: j.task.id, title: `Release ${j.packageVersion}`, checkout: j.checkout, candidate: j.candidate.sha,
|
|
476
|
+
goal: `Verify ${j.packageName} ${j.packageVersion} (${j.branch} at ${j.candidate.sha}) passes the approved release check before it is deployed, merged, tagged and published.`,
|
|
477
|
+
acceptance: "The approved release check passes on the candidate commit|check",
|
|
478
|
+
...(rel === undefined ? {} : { release: { version: j.packageVersion, pr: j.pr, branch: j.branch, noCarry: options.noCarry === true } }),
|
|
479
|
+
});
|
|
480
|
+
j.task.scopeDigest = filed.scopeDigest;
|
|
481
|
+
}
|
|
482
|
+
advance("approval");
|
|
483
|
+
break;
|
|
236
484
|
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
await
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
485
|
+
case "approval": {
|
|
486
|
+
say(` The release scope needs your approval: \`toolroll task approve ${j.task.id} --digest ${j.task.scopeDigest}\`, or open the task in the console.`);
|
|
487
|
+
const approved = await waitFor("approval", `a person to approve task ${j.task.id}`, async () => {
|
|
488
|
+
await watch("dispatch");
|
|
489
|
+
const gate = await adapters.toolroll.gate(j.task.id);
|
|
490
|
+
if (gate.scopeDigest !== j.task.scopeDigest)
|
|
491
|
+
throw new Stop("scope-changed", `The scope of ${j.task.id} changed after it was filed; the release only runs on the scope it filed.`);
|
|
492
|
+
const owner = ownerApproval(gate, j.task.scopeDigest);
|
|
493
|
+
if (!owner.ok && gate.approval !== null && owner.why !== "the approval is for different scope text")
|
|
494
|
+
throw new Stop("approval", `Task ${j.task.id} was ${owner.why}; the release needs a person to approve it.`);
|
|
495
|
+
return owner.ok ? owner : null;
|
|
244
496
|
});
|
|
245
|
-
j.
|
|
497
|
+
j.approval = { by: approved.by, at: approved.at };
|
|
498
|
+
say(` approved by ${approved.by}`);
|
|
499
|
+
// The person's yes to release terms opened the release's authority: hold its lease from here on.
|
|
500
|
+
if (rel !== undefined) {
|
|
501
|
+
const view = await rel.authority(j.task.id);
|
|
502
|
+
if (view !== null) {
|
|
503
|
+
j.authority = { id: view.id, generation: view.generation };
|
|
504
|
+
j.landed = view.landed;
|
|
505
|
+
save();
|
|
506
|
+
await hold();
|
|
507
|
+
say(` later test-only commits on #${j.pr} carry this approval (release ${view.id})`);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
advance("check");
|
|
511
|
+
break;
|
|
246
512
|
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
513
|
+
case "check": {
|
|
514
|
+
const result = await waitFor("check", `the release check of ${j.candidate.sha.slice(0, 7)}`, async () => {
|
|
515
|
+
// Before acting on an old candidate's failure: a moved head restarts the release first.
|
|
516
|
+
await watch("dispatch");
|
|
517
|
+
const gate = await adapters.toolroll.gate(j.task.id);
|
|
518
|
+
await approved(gate);
|
|
519
|
+
if (gate.failed !== null)
|
|
520
|
+
throw new Stop("check-failed", `The release build of ${j.task.id} failed: ${gate.failed}. Fix it and release the new commit with --new.`);
|
|
521
|
+
if (gate.result === null || gate.result.check === "running" || gate.result.check === "pending")
|
|
522
|
+
return null;
|
|
523
|
+
// The build records a prepared candidate as a commit of its own (its message names the task): it must hold
|
|
524
|
+
// exactly the candidate's tree, or it isn't the release.
|
|
525
|
+
const head = gate.result.head;
|
|
526
|
+
if (head === null || (head !== j.candidate.sha && await adapters.repo.tree(head) !== await adapters.repo.tree(j.candidate.sha))) {
|
|
527
|
+
throw new Stop("drift", `The release result is for ${head?.slice(0, 7) ?? "no commit"}, which doesn't hold exactly ${j.candidate.sha.slice(0, 7)}'s files.`);
|
|
528
|
+
}
|
|
529
|
+
fullCheck(gate);
|
|
530
|
+
if (gate.result.worktree === null)
|
|
531
|
+
throw new Stop("worktree", `Run ${gate.result.run} names no checkout to deploy from.`);
|
|
532
|
+
return { run: gate.result.run, receipt: gate.result.receipt, worktree: gate.result.worktree, head };
|
|
533
|
+
});
|
|
534
|
+
j.check = result;
|
|
535
|
+
say(` run ${result.run} passed on ${j.candidate.sha.slice(0, 7)}`);
|
|
536
|
+
advance("complete");
|
|
537
|
+
break;
|
|
538
|
+
}
|
|
539
|
+
case "complete": {
|
|
540
|
+
await watch("complete");
|
|
541
|
+
const check = j.check;
|
|
268
542
|
const gate = await adapters.toolroll.gate(j.task.id);
|
|
269
|
-
|
|
270
|
-
if (gate.
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
// exactly the candidate's tree, or it isn't the release.
|
|
276
|
-
const head = gate.result.head;
|
|
277
|
-
if (head === null || (head !== j.candidate.sha && await adapters.repo.tree(head) !== await adapters.repo.tree(j.candidate.sha))) {
|
|
278
|
-
throw new Stop("drift", `The release result is for ${head?.slice(0, 7) ?? "no commit"}, which doesn't hold exactly ${j.candidate.sha.slice(0, 7)}'s files.`);
|
|
543
|
+
await checked(gate, "complete");
|
|
544
|
+
if (gate.completion?.digest !== check.receipt) {
|
|
545
|
+
intend("complete");
|
|
546
|
+
const done = await adapters.toolroll.complete(j.task.id, check.receipt);
|
|
547
|
+
if (!done.ok)
|
|
548
|
+
throw new Stop("complete", `Couldn't mark task ${j.task.id} complete: ${done.message} Mark that exact result complete in the console, then rerun.`);
|
|
279
549
|
}
|
|
280
|
-
|
|
281
|
-
if (
|
|
282
|
-
throw new Stop("
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
say(` run ${result.run} passed on ${j.candidate.sha.slice(0, 7)}`);
|
|
287
|
-
advance("complete");
|
|
288
|
-
break;
|
|
289
|
-
}
|
|
290
|
-
case "complete": {
|
|
291
|
-
const check = j.check;
|
|
292
|
-
const gate = await adapters.toolroll.gate(j.task.id);
|
|
293
|
-
checked(gate);
|
|
294
|
-
if (gate.completion?.digest !== check.receipt) {
|
|
295
|
-
intend("complete");
|
|
296
|
-
const done = await adapters.toolroll.complete(j.task.id, check.receipt);
|
|
297
|
-
if (!done.ok)
|
|
298
|
-
throw new Stop("complete", `Couldn't mark task ${j.task.id} complete: ${done.message} Mark that exact result complete in the console, then rerun.`);
|
|
550
|
+
const after = await adapters.toolroll.gate(j.task.id);
|
|
551
|
+
if (after.completion?.digest !== check.receipt)
|
|
552
|
+
throw new Stop("complete", `Task ${j.task.id} is not marked complete for result ${check.receipt.slice(0, 12)}.`);
|
|
553
|
+
j.completion = { actor: after.completion.actor, at: after.completion.at };
|
|
554
|
+
advance("quiet");
|
|
555
|
+
break;
|
|
299
556
|
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
await waitFor("quiet", "running work to finish", async () => {
|
|
309
|
-
const busy = await adapters.toolroll.busy();
|
|
310
|
-
return busy.running === 0 ? true : null;
|
|
311
|
-
});
|
|
312
|
-
advance("deploy");
|
|
313
|
-
break;
|
|
314
|
-
}
|
|
315
|
-
case "deploy": {
|
|
316
|
-
const check = j.check;
|
|
317
|
-
await recoverDeploy();
|
|
318
|
-
checked(await adapters.toolroll.gate(j.task.id));
|
|
319
|
-
// A deployment that finished but whose answer was lost: the services already run a runtime of this commit.
|
|
320
|
-
let runtime = await served(adapters, builtHead());
|
|
321
|
-
if (runtime === null) {
|
|
322
|
-
await pinned();
|
|
323
|
-
j.deployAttempt = { stage: join(options.stateDir, "staged-upgrades", `browser-${j.candidate.sha.slice(0, 7)}-${randomUUID()}`) };
|
|
324
|
-
intend("deploy");
|
|
325
|
-
const result = await adapters.toolroll.deploy({ run: check.run, worktree: check.worktree, stage: j.deployAttempt.stage });
|
|
326
|
-
if (!result.ok)
|
|
327
|
-
throw deployStop("deploy", `The deployment of run ${check.run} stopped: ${result.message}`, result);
|
|
328
|
-
if (!/[\\/]dist$/.test(result.runtime))
|
|
329
|
-
throw new Stop("deploy", `The deployment named ${result.runtime} as its runtime, which is not a dist folder.`);
|
|
330
|
-
runtime = result.runtime;
|
|
331
|
-
// Saved at once: everything after this (relinking, a rerun) uses the runtime the deployer named.
|
|
332
|
-
j.deploy = { runtime, at: adapters.now().toISOString() };
|
|
333
|
-
save();
|
|
557
|
+
case "quiet": {
|
|
558
|
+
await waitFor("quiet", "running work to finish", async () => {
|
|
559
|
+
await watch("complete");
|
|
560
|
+
const busy = await adapters.toolroll.busy();
|
|
561
|
+
return busy.running === 0 ? true : null;
|
|
562
|
+
});
|
|
563
|
+
advance("deploy");
|
|
564
|
+
break;
|
|
334
565
|
}
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
566
|
+
case "deploy": {
|
|
567
|
+
const check = j.check;
|
|
568
|
+
await recoverDeploy();
|
|
569
|
+
await checked(await adapters.toolroll.gate(j.task.id), "deploy");
|
|
570
|
+
// A deployment that finished but whose answer was lost: the services already run a runtime of this commit.
|
|
571
|
+
let runtime = await served(adapters, builtHead());
|
|
572
|
+
// Already running this candidate (a new generation after a CI failure, or a lost answer): the release still
|
|
573
|
+
// reserves its deploy, so its merge has a deploy to stand on.
|
|
574
|
+
if (runtime !== null && rel !== undefined && j.authority && epoch !== null) {
|
|
575
|
+
const authority = await rel.authority(j.task.id);
|
|
576
|
+
if (authority !== null && authority.state === "open") {
|
|
577
|
+
await watch("deploy");
|
|
578
|
+
const reserved = await rel.reserve({ authority: j.authority.id, kind: "deploy", taskId: j.task.id, candidate: j.candidate.sha, epoch });
|
|
579
|
+
if (!reserved.ok)
|
|
580
|
+
throw new Stop("release", `The deploy couldn't be reserved: ${reserved.why}`);
|
|
581
|
+
if (!await rel.start(reserved.operation, epoch))
|
|
582
|
+
throw new Stop("lease", "Another release coordinator took over this release.");
|
|
583
|
+
await rel.finish(reserved.operation, "succeeded", { detail: "the services already run this candidate" }, epoch);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
if (runtime === null) {
|
|
587
|
+
// The last moment a moved head restarts; then the deploy reservation freezes the candidate in the store.
|
|
588
|
+
await watch("deploy");
|
|
589
|
+
await pinned();
|
|
590
|
+
let operation, secret = "";
|
|
591
|
+
if (rel !== undefined && j.authority && epoch !== null) {
|
|
592
|
+
const reserved = await rel.reserve({ authority: j.authority.id, kind: "deploy", taskId: j.task.id, candidate: j.candidate.sha, epoch });
|
|
593
|
+
if (!reserved.ok)
|
|
594
|
+
throw new Stop("release", `The deploy couldn't be reserved: ${reserved.why}`);
|
|
595
|
+
operation = reserved.operation;
|
|
596
|
+
secret = reserved.secret ?? "";
|
|
597
|
+
j.operations = { ...j.operations, deploy: operation };
|
|
598
|
+
save();
|
|
599
|
+
}
|
|
600
|
+
j.deployAttempt = { stage: join(options.stateDir, "staged-upgrades", `browser-${j.candidate.sha.slice(0, 7)}-${randomUUID()}`) };
|
|
601
|
+
intend("deploy");
|
|
602
|
+
// The issuing fence, immediately before the deploy is spawned (review round 2): lease, epoch and the live
|
|
603
|
+
// release again, in one transaction that marks the operation as issued by this process.
|
|
604
|
+
if (operation !== undefined && !await rel.start(operation, epoch))
|
|
605
|
+
throw new Stop("lease", "Another release coordinator took over this release; the deploy didn't start.");
|
|
606
|
+
const result = await adapters.toolroll.deploy({ run: check.run, worktree: check.worktree, stage: j.deployAttempt.stage, ...(operation === undefined ? {} : { operation: { id: operation, secret } }) });
|
|
607
|
+
if (operation !== undefined)
|
|
608
|
+
await rel.finish(operation, result.ok ? "succeeded" : "failed", { detail: result.ok ? result.runtime : result.message.slice(0, 500) }, epoch);
|
|
609
|
+
if (!result.ok)
|
|
610
|
+
throw deployStop("deploy", `The deployment of run ${check.run} stopped: ${result.message}`, result);
|
|
611
|
+
if (!/[\\/]dist$/.test(result.runtime))
|
|
612
|
+
throw new Stop("deploy", `The deployment named ${result.runtime} as its runtime, which is not a dist folder.`);
|
|
613
|
+
runtime = result.runtime;
|
|
614
|
+
// Saved at once: everything after this (relinking, a rerun) uses the runtime the deployer named.
|
|
615
|
+
j.deploy = { runtime, at: adapters.now().toISOString() };
|
|
616
|
+
save();
|
|
617
|
+
}
|
|
618
|
+
const want = runtime;
|
|
619
|
+
await waitFor("deploy", `the console and worker to run the gated commit from ${want}`, async () => (await served(adapters, builtHead())) === want ? true : null);
|
|
620
|
+
j.deploy = { runtime: want, at: j.deploy?.runtime === want ? j.deploy.at : adapters.now().toISOString() };
|
|
621
|
+
j.deployAttempt = null;
|
|
622
|
+
delete j.intents.deploy;
|
|
623
|
+
say(` the service runs ${j.packageVersion} from ${want}`);
|
|
624
|
+
advance("link");
|
|
625
|
+
break;
|
|
351
626
|
}
|
|
352
|
-
|
|
353
|
-
|
|
627
|
+
case "link": {
|
|
628
|
+
const runtime = j.deploy.runtime;
|
|
629
|
+
if (await served(adapters, builtHead()) !== runtime)
|
|
630
|
+
throw new Stop("drift", `The console and worker no longer run ${runtime}; the CLI names were not moved.`);
|
|
631
|
+
if (j.links === undefined || j.links === null) {
|
|
632
|
+
j.links = await adapters.toolroll.cliLinks();
|
|
633
|
+
intend("link");
|
|
634
|
+
}
|
|
635
|
+
try {
|
|
636
|
+
await adapters.toolroll.switchLinks(j.links, join(runtime, "bin.js"), j.packageVersion);
|
|
637
|
+
}
|
|
638
|
+
catch (error) {
|
|
639
|
+
throw new Stop("link", `The CLI names were not moved to ${runtime} (they point where they did): ${error.message}. The service already runs the new build; rerun to try again.`);
|
|
640
|
+
}
|
|
641
|
+
const everywhere = await deployed(adapters, builtHead());
|
|
642
|
+
if (everywhere !== runtime)
|
|
643
|
+
throw new Stop("mixed-runtime", `After relinking, the console, worker and both CLI names don't all run ${runtime} at ${j.candidate.sha.slice(0, 7)}.`);
|
|
644
|
+
say(` toolroll and standing-orders run ${join(runtime, "bin.js")}`);
|
|
645
|
+
advance("ci");
|
|
646
|
+
break;
|
|
354
647
|
}
|
|
355
|
-
|
|
356
|
-
|
|
648
|
+
case "ci": {
|
|
649
|
+
await waitFor("ci", `the pull request checks on ${j.candidate.sha.slice(0, 7)}`, async () => {
|
|
650
|
+
await pinned();
|
|
651
|
+
const verdict = checksVerdict(await adapters.github.checks(j.github, j.candidate.sha), REQUIRED_PR_CHECKS);
|
|
652
|
+
if (verdict.state === "failed")
|
|
653
|
+
await ciFailedAfterDeploy(`Pull request checks failed on ${j.candidate.sha.slice(0, 7)}: ${verdict.failed.join(", ")}`);
|
|
654
|
+
return verdict.state === "passed" ? true : null;
|
|
655
|
+
});
|
|
656
|
+
j.ci = { checks: [...REQUIRED_PR_CHECKS], at: adapters.now().toISOString() };
|
|
657
|
+
advance("merge");
|
|
658
|
+
break;
|
|
357
659
|
}
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
660
|
+
case "merge": {
|
|
661
|
+
const pr = await adapters.github.pullRequest(j.github, j.branch);
|
|
662
|
+
if (pr?.state === "merged" && pr.number === j.pr && pr.mergeCommit !== null) {
|
|
663
|
+
if (pr.head !== j.candidate.sha || pr.base !== "main")
|
|
664
|
+
throw new Stop("drift", `Merged pull request #${j.pr} does not match the gated commit and base.`);
|
|
665
|
+
if (j.intents.merge === undefined)
|
|
666
|
+
intend("merge");
|
|
667
|
+
j.merge = { sha: pr.mergeCommit, tree: null };
|
|
668
|
+
}
|
|
669
|
+
else {
|
|
670
|
+
// After the deploy reservation a moved head, a changed pull request or a stopped release stops it here.
|
|
671
|
+
if (rel !== undefined && j.authority)
|
|
672
|
+
await identity("merge", false);
|
|
673
|
+
await pinned();
|
|
674
|
+
// A squash merge only reproduces the gated tree when main hasn't moved past the branch's base.
|
|
675
|
+
await adapters.repo.sync(j.branch);
|
|
676
|
+
const main = await adapters.repo.mainHead();
|
|
677
|
+
if (!await adapters.repo.contains(j.candidate.sha, main))
|
|
678
|
+
throw new Stop("base-moved", `main moved to ${main.slice(0, 7)}, which ${j.branch} doesn't contain, so the merge wouldn't be the gated tree. Nothing was merged: update the branch and release the new commit with --new.`);
|
|
679
|
+
// The checks again, at the moment of merging: a rerun that failed since must not merge.
|
|
680
|
+
const verdict = checksVerdict(await adapters.github.checks(j.github, j.candidate.sha), REQUIRED_PR_CHECKS);
|
|
681
|
+
if (verdict.state !== "passed")
|
|
682
|
+
await ciFailedAfterDeploy(`Pull request checks are no longer all passing on ${j.candidate.sha.slice(0, 7)}`);
|
|
683
|
+
let operation;
|
|
684
|
+
if (rel !== undefined && j.authority && epoch !== null) {
|
|
685
|
+
const reserved = await rel.reserve({ authority: j.authority.id, kind: "merge", taskId: j.task.id, candidate: j.candidate.sha, epoch });
|
|
686
|
+
if (!reserved.ok)
|
|
687
|
+
throw new Stop("release", `The merge couldn't be reserved: ${reserved.why}`);
|
|
688
|
+
operation = reserved.operation;
|
|
689
|
+
j.operations = { ...j.operations, merge: operation };
|
|
690
|
+
save();
|
|
691
|
+
}
|
|
383
692
|
intend("merge");
|
|
384
|
-
|
|
693
|
+
const message = landedWords(j.landed ?? []).join("\n");
|
|
694
|
+
// The issuing fence, immediately before gh is spawned (review round 2).
|
|
695
|
+
if (operation !== undefined && !await rel.start(operation, epoch))
|
|
696
|
+
throw new Stop("lease", "Another release coordinator took over this release; the merge didn't start.");
|
|
697
|
+
let merged;
|
|
698
|
+
try {
|
|
699
|
+
merged = await adapters.github.merge(j.github, j.pr, j.candidate.sha, ...(message === "" ? [] : [message]));
|
|
700
|
+
}
|
|
701
|
+
catch (error) {
|
|
702
|
+
if (operation !== undefined)
|
|
703
|
+
await rel.finish(operation, "unknown", { detail: String(error?.message ?? error).slice(0, 500) }, epoch);
|
|
704
|
+
throw error;
|
|
705
|
+
}
|
|
706
|
+
if (operation !== undefined)
|
|
707
|
+
await rel.finish(operation, "succeeded", { externalRef: merged }, epoch);
|
|
708
|
+
j.merge = { sha: merged, tree: null };
|
|
709
|
+
}
|
|
710
|
+
save();
|
|
711
|
+
// This also runs after discovering a merge whose response was lost. Never delete a reused branch.
|
|
712
|
+
await adapters.repo.deleteBranch(j.branch, j.candidate.sha);
|
|
713
|
+
advance("tree");
|
|
714
|
+
break;
|
|
385
715
|
}
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
// A squash merge only reproduces the gated tree when main hasn't moved past the branch's base.
|
|
716
|
+
case "tree": {
|
|
717
|
+
const merge = j.merge;
|
|
389
718
|
await adapters.repo.sync(j.branch);
|
|
390
|
-
const
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
throw new Stop("ci-failed", `Pull request checks are no longer all passing on ${j.candidate.sha.slice(0, 7)}; nothing was merged.`);
|
|
397
|
-
intend("merge");
|
|
398
|
-
j.merge = { sha: await adapters.github.merge(j.github, j.pr, j.candidate.sha), tree: null };
|
|
399
|
-
}
|
|
400
|
-
save();
|
|
401
|
-
// This also runs after discovering a merge whose response was lost. Never delete a reused branch.
|
|
402
|
-
await adapters.repo.deleteBranch(j.branch, j.candidate.sha);
|
|
403
|
-
advance("tree");
|
|
404
|
-
break;
|
|
405
|
-
}
|
|
406
|
-
case "tree": {
|
|
407
|
-
const merge = j.merge;
|
|
408
|
-
await adapters.repo.sync(j.branch);
|
|
409
|
-
const tree = await adapters.repo.tree(merge.sha);
|
|
410
|
-
merge.tree = tree;
|
|
411
|
-
if (tree !== j.candidate.tree)
|
|
412
|
-
throw new Stop("tree-mismatch", `Main's merge commit ${merge.sha.slice(0, 7)} has tree ${tree.slice(0, 12)}, not the gated ${j.candidate.tree.slice(0, 12)}: main changed what was checked. Nothing was tagged; the deployed build stays as it is.`);
|
|
413
|
-
advance("tag");
|
|
414
|
-
break;
|
|
415
|
-
}
|
|
416
|
-
case "tag": {
|
|
417
|
-
const merge = j.merge;
|
|
418
|
-
const at = await adapters.github.tag(j.github, j.tag);
|
|
419
|
-
if (at !== null && at !== merge.sha)
|
|
420
|
-
throw new Stop("conflict", `${j.tag} already points at ${at.slice(0, 7)}, not the merge ${merge.sha.slice(0, 7)}.`);
|
|
421
|
-
if (at === null) {
|
|
422
|
-
intend("tag");
|
|
423
|
-
await adapters.github.createTag(j.github, j.tag, merge.sha);
|
|
719
|
+
const tree = await adapters.repo.tree(merge.sha);
|
|
720
|
+
merge.tree = tree;
|
|
721
|
+
if (tree !== j.candidate.tree)
|
|
722
|
+
throw new Stop("tree-mismatch", `Main's merge commit ${merge.sha.slice(0, 7)} has tree ${tree.slice(0, 12)}, not the gated ${j.candidate.tree.slice(0, 12)}: main changed what was checked. Nothing was tagged; the deployed build stays as it is.`);
|
|
723
|
+
advance("tag");
|
|
724
|
+
break;
|
|
424
725
|
}
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
throw new Stop("publish-failed", `The release tag's checks failed: ${verdict.failed.join(", ")}. Homebrew was not updated.`);
|
|
438
|
-
if (verdict.state === "pending")
|
|
439
|
-
return null;
|
|
440
|
-
return await adapters.registry.published(j.packageName, j.packageVersion) && await adapters.github.release(j.github, j.tag) ? true : null;
|
|
441
|
-
});
|
|
442
|
-
j.published = { at: adapters.now().toISOString() };
|
|
443
|
-
advance("homebrew");
|
|
444
|
-
break;
|
|
445
|
-
}
|
|
446
|
-
case "homebrew": {
|
|
447
|
-
const brew = adapters.homebrew;
|
|
448
|
-
let state = await brew.state(j.packageVersion);
|
|
449
|
-
if (state.kind === "missing") {
|
|
450
|
-
intend("homebrew");
|
|
451
|
-
const proposed = await brew.propose(j.packageVersion);
|
|
452
|
-
state = { kind: "proposed", ...proposed };
|
|
726
|
+
case "tag": {
|
|
727
|
+
const merge = j.merge;
|
|
728
|
+
const at = await adapters.github.tag(j.github, j.tag);
|
|
729
|
+
if (at !== null && at !== merge.sha)
|
|
730
|
+
throw new Stop("conflict", `${j.tag} already points at ${at.slice(0, 7)}, not the merge ${merge.sha.slice(0, 7)}.`);
|
|
731
|
+
if (at === null) {
|
|
732
|
+
intend("tag");
|
|
733
|
+
await adapters.github.createTag(j.github, j.tag, merge.sha);
|
|
734
|
+
}
|
|
735
|
+
j.tagged = { sha: merge.sha };
|
|
736
|
+
advance("publish");
|
|
737
|
+
break;
|
|
453
738
|
}
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
// Two apps may name a check alike; each app's attempts stand for themselves.
|
|
465
|
-
const runs = (await adapters.github.checks(brew.repo, proposed.head)).map(run => ({ ...run, name: `${run.app ?? "unknown app"}: ${run.name}` }));
|
|
466
|
-
const statuses = await brew.statuses(proposed.head);
|
|
467
|
-
const verdicts = [runs, statuses].filter(one => one.length > 0).map(one => checksVerdict(one, [...new Set(one.map(run => run.name))]));
|
|
468
|
-
const failed = verdicts.flatMap(one => one.state === "failed" ? one.failed : []);
|
|
469
|
-
if (failed.length > 0)
|
|
470
|
-
throw new Stop("homebrew-failed", `The Homebrew change's checks failed: ${failed.join(", ")}.`);
|
|
471
|
-
if (verdicts.some(one => one.state === "pending"))
|
|
472
|
-
return null;
|
|
473
|
-
// GitHub registers checks asynchronously; an empty set says nothing about success.
|
|
474
|
-
// A workflow is owed: only its own run counts, not another app's green check.
|
|
475
|
-
if (checked && !runs.some(run => run.app === "github-actions"))
|
|
739
|
+
case "publish": {
|
|
740
|
+
if (j.intents.publish === undefined)
|
|
741
|
+
intend("publish");
|
|
742
|
+
await waitFor("publish", `npm, the GitHub release and the tag's checks for ${j.tag}`, async () => {
|
|
743
|
+
if (await adapters.github.tag(j.github, j.tag) !== j.tagged.sha)
|
|
744
|
+
throw new Stop("conflict", `${j.tag} no longer points at the verified merge commit. Homebrew was not updated.`);
|
|
745
|
+
const verdict = checksVerdict(await adapters.github.checks(j.github, j.tagged.sha), RELEASE_TAG_CHECKS);
|
|
746
|
+
if (verdict.state === "failed")
|
|
747
|
+
throw new Stop("publish-failed", `The release tag's checks failed: ${verdict.failed.join(", ")}. Homebrew was not updated.`);
|
|
748
|
+
if (verdict.state === "pending")
|
|
476
749
|
return null;
|
|
477
|
-
|
|
478
|
-
return ++emptyLooks >= 2 ? true : null;
|
|
479
|
-
return true;
|
|
750
|
+
return await adapters.registry.published(j.packageName, j.packageVersion) && await adapters.github.release(j.github, j.tag) ? true : null;
|
|
480
751
|
});
|
|
481
|
-
|
|
752
|
+
j.published = { at: adapters.now().toISOString() };
|
|
753
|
+
advance("homebrew");
|
|
754
|
+
break;
|
|
755
|
+
}
|
|
756
|
+
case "homebrew": {
|
|
757
|
+
const brew = adapters.homebrew;
|
|
758
|
+
let state = await brew.state(j.packageVersion);
|
|
759
|
+
if (state.kind === "missing") {
|
|
760
|
+
intend("homebrew");
|
|
761
|
+
const proposed = await brew.propose(j.packageVersion);
|
|
762
|
+
state = { kind: "proposed", ...proposed };
|
|
763
|
+
}
|
|
764
|
+
if (state.kind === "proposed") {
|
|
765
|
+
const proposed = state;
|
|
766
|
+
j.homebrew = { pr: proposed.pr, head: proposed.head, merged: false };
|
|
767
|
+
save();
|
|
768
|
+
// Whatever checks do register are always judged; an empty set passes only for a tap whose workflows never run
|
|
769
|
+
// on a change, and only when it's still empty a poll later.
|
|
770
|
+
const checked = await brew.checked(proposed.head);
|
|
771
|
+
let emptyLooks = 0;
|
|
772
|
+
await waitFor("homebrew", `the Homebrew change's checks (${brew.repo} #${proposed.pr})`, async () => {
|
|
773
|
+
// Check runs and commit statuses are judged apart, so neither stands in for the other or hides its failure.
|
|
774
|
+
// Two apps may name a check alike; each app's attempts stand for themselves.
|
|
775
|
+
const runs = (await adapters.github.checks(brew.repo, proposed.head)).map(run => ({ ...run, name: `${run.app ?? "unknown app"}: ${run.name}` }));
|
|
776
|
+
const statuses = await brew.statuses(proposed.head);
|
|
777
|
+
const verdicts = [runs, statuses].filter(one => one.length > 0).map(one => checksVerdict(one, [...new Set(one.map(run => run.name))]));
|
|
778
|
+
const failed = verdicts.flatMap(one => one.state === "failed" ? one.failed : []);
|
|
779
|
+
if (failed.length > 0)
|
|
780
|
+
throw new Stop("homebrew-failed", `The Homebrew change's checks failed: ${failed.join(", ")}.`);
|
|
781
|
+
if (verdicts.some(one => one.state === "pending"))
|
|
782
|
+
return null;
|
|
783
|
+
// GitHub registers checks asynchronously; an empty set says nothing about success.
|
|
784
|
+
// A workflow is owed: only its own run counts, not another app's green check.
|
|
785
|
+
if (checked && !runs.some(run => run.app === "github-actions"))
|
|
786
|
+
return null;
|
|
787
|
+
if (runs.length === 0 && statuses.length === 0)
|
|
788
|
+
return ++emptyLooks >= 2 ? true : null;
|
|
789
|
+
return true;
|
|
790
|
+
});
|
|
791
|
+
await brew.merge(proposed.pr, proposed.head);
|
|
792
|
+
}
|
|
793
|
+
j.homebrew = { pr: j.homebrew?.pr ?? null, head: j.homebrew?.head ?? null, merged: true };
|
|
794
|
+
advance("done");
|
|
795
|
+
break;
|
|
482
796
|
}
|
|
483
|
-
j.homebrew = { pr: j.homebrew?.pr ?? null, head: j.homebrew?.head ?? null, merged: true };
|
|
484
|
-
advance("done");
|
|
485
|
-
break;
|
|
486
797
|
}
|
|
487
798
|
}
|
|
799
|
+
catch (error) {
|
|
800
|
+
if (!(error instanceof Restart))
|
|
801
|
+
throw error;
|
|
802
|
+
await restart(error.head);
|
|
803
|
+
}
|
|
488
804
|
}
|
|
489
805
|
say(`Released ${j.tag}: deployed, merged as ${j.merge.sha.slice(0, 7)}, tagged, published and on Homebrew.`);
|
|
806
|
+
for (const line of landedWords(j.landed ?? []))
|
|
807
|
+
say(line);
|
|
490
808
|
return { ok: true, journal: j, file };
|
|
491
809
|
}
|
|
492
810
|
catch (error) {
|