toolroll 0.9.77 → 0.9.78
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 +81 -0
- package/dist/agent-fence.js +18 -2
- package/dist/backup-ui.d.ts +9 -1
- package/dist/backup-ui.js +28 -3
- package/dist/backup.js +4 -3
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +18 -18
- package/dist/browser-workspace.d.ts +2 -0
- package/dist/builder.d.ts +4 -3
- package/dist/builder.js +14 -8
- package/dist/check-failure.js +31 -3
- package/dist/check-report.d.ts +18 -3
- package/dist/check-report.js +67 -7
- package/dist/claim.d.ts +9 -0
- package/dist/claim.js +9 -1
- package/dist/contracts/console-api.d.ts +1 -1
- package/dist/contracts/console-api.js +1 -1
- package/dist/contracts/release-terms.d.ts +41 -0
- package/dist/contracts/release-terms.js +53 -0
- package/dist/controller-service.js +4 -1
- package/dist/deploy-rescue.d.ts +41 -0
- package/dist/deploy-rescue.js +94 -0
- package/dist/desktop-update.d.ts +1 -1
- package/dist/dispose.js +4 -3
- package/dist/evidence.d.ts +9 -0
- package/dist/evidence.js +15 -3
- package/dist/exec.d.ts +2 -0
- package/dist/exec.js +15 -1
- package/dist/guides.js +18 -8
- package/dist/latency-timeline.d.ts +46 -0
- package/dist/latency-timeline.js +120 -0
- package/dist/live-backup.d.ts +39 -0
- package/dist/live-backup.js +271 -0
- package/dist/operate.js +203 -16
- package/dist/planner.js +2 -2
- package/dist/release-adapters.d.ts +32 -1
- package/dist/release-adapters.js +99 -7
- package/dist/release-carry.d.ts +21 -1
- package/dist/release-carry.js +22 -0
- package/dist/release-cli.d.ts +3 -0
- package/dist/release-cli.js +74 -5
- package/dist/release-gate.d.ts +5 -0
- package/dist/release-gate.js +1 -1
- package/dist/release-recheck.js +3 -2
- package/dist/release.d.ts +37 -4
- package/dist/release.js +128 -43
- package/dist/runner-process.d.ts +67 -0
- package/dist/runner-process.js +219 -0
- package/dist/runner.d.ts +48 -6
- package/dist/runner.js +71 -8
- package/dist/scope.d.ts +19 -1
- package/dist/scope.js +19 -2
- package/dist/scout.js +2 -2
- package/dist/serve.js +11 -0
- package/dist/server/http.d.ts +2 -0
- package/dist/server/pages.js +7 -3
- package/dist/server/remote-hooks.d.ts +1 -1
- package/dist/server/remote-hooks.js +5 -3
- package/dist/server/render-results.d.ts +2 -0
- package/dist/server/render-results.js +1 -0
- package/dist/server/request-context.d.ts +2 -0
- package/dist/server/settings.js +22 -2
- package/dist/server/tasks.js +4 -1
- package/dist/storage-sweep.js +1 -1
- package/dist/store.d.ts +125 -2
- package/dist/store.js +250 -12
- package/dist/surface.js +3 -1
- package/dist/teardown-git.js +5 -5
- package/dist/toolroll-update.js +2 -2
- package/dist/up-role-child.js +6 -2
- package/dist/workspace-revision.js +3 -1
- package/package.json +1 -1
package/dist/release-adapters.js
CHANGED
|
@@ -575,7 +575,7 @@ export function releaseAdapters(options) {
|
|
|
575
575
|
return prs.find(one => one.state === "open") ?? prs.filter(one => one.state === "merged").sort((a, b) => b.number - a.number)[0] ?? null;
|
|
576
576
|
},
|
|
577
577
|
// Every attempt (filter=all), with its app, check suite and commit: the release judges each job by its latest attempt.
|
|
578
|
-
async checks(repo, sha) { return readCheckRuns(run, ciGh, repo, sha); },
|
|
578
|
+
async checks(repo, sha, options) { return readCheckRuns(run, ciGh, repo, sha, options); },
|
|
579
579
|
async complete(repo, sha) { return readSuitesComplete(run, ciGh, repo, sha); },
|
|
580
580
|
async mainHead(repo) { return readMainHead(run, ciGh, repo); },
|
|
581
581
|
async contains(repo, sha, ancestor) { return readContains(run, ciGh, repo, sha, ancestor); },
|
|
@@ -653,8 +653,16 @@ export function releaseAdapters(options) {
|
|
|
653
653
|
const current = await formulaAt(branch);
|
|
654
654
|
if (current.text.includes(`-${version}.tgz`))
|
|
655
655
|
return { kind: "current" };
|
|
656
|
-
|
|
657
|
-
const
|
|
656
|
+
// Ordering (pipeline-speed.md, P1): a tap already on a later version is never moved back by an older release.
|
|
657
|
+
const named = formulaVersion(current.text);
|
|
658
|
+
if (named !== null && versionAfter(named, version))
|
|
659
|
+
return { kind: "newer", version: named };
|
|
660
|
+
// Only a change from the tap's own branch counts: a fork's pull request of the same branch name never does.
|
|
661
|
+
const open = (await ghJson("gh pr list", "pr", "list", "--repo", tap, "--head", brewBranch(version), "--state", "open", "--json", "number,headRefOid,headRepository,headRepositoryOwner,isCrossRepository"));
|
|
662
|
+
const own = open.filter(one => tapOwnHead(one, tap));
|
|
663
|
+
if (own.length > 1)
|
|
664
|
+
throw new Error(`${tap} has ${own.length} open changes for ${brewBranch(version)}`);
|
|
665
|
+
const pr = own[0];
|
|
658
666
|
return pr === undefined ? { kind: "missing" } : { kind: "proposed", pr: Number(pr["number"]), head: String(pr["headRefOid"]) };
|
|
659
667
|
},
|
|
660
668
|
async propose(version) {
|
|
@@ -664,14 +672,20 @@ export function releaseAdapters(options) {
|
|
|
664
672
|
throw new Error(`downloading ${tarball} answered ${response.status}`);
|
|
665
673
|
const sha256 = createHash("sha256").update(Buffer.from(await response.arrayBuffer())).digest("hex");
|
|
666
674
|
const base = await defaultBranch();
|
|
667
|
-
const
|
|
675
|
+
const baseSha = String(object(object(await ghJson("gh api git/ref", "api", `repos/${tap}/git/ref/heads/${base}`))["object"])["sha"]);
|
|
676
|
+
const current = await formulaAt(baseSha);
|
|
677
|
+
const baseVersion = formulaVersion(current.text);
|
|
678
|
+
if (baseVersion === null || versionAfter(baseVersion, version))
|
|
679
|
+
throw new Error("The tap current version is unreadable or newer; no downgrade was proposed.");
|
|
668
680
|
const next = bumpFormula(current.text, tarball, sha256);
|
|
669
681
|
const branch = brewBranch(version);
|
|
670
|
-
const baseSha = String(object(object(await ghJson("gh api git/ref", "api", `repos/${tap}/git/ref/heads/${base}`))["object"])["sha"]);
|
|
671
682
|
// A branch left by an earlier attempt is reused.
|
|
672
683
|
const made = await gh("api", "-X", "POST", `repos/${tap}/git/refs`, "-f", `ref=refs/heads/${branch}`, "-f", `sha=${baseSha}`);
|
|
673
684
|
if (made.code !== 0 && !/Reference already exists/.test(made.stderr + made.stdout))
|
|
674
685
|
throw new Error(`creating ${tap} ${branch}: ${made.stderr.trim()}`);
|
|
686
|
+
// A branch left by an earlier attempt starts again from the base (security scan): none of its history is kept.
|
|
687
|
+
if (made.code !== 0)
|
|
688
|
+
await must(gh("api", "-X", "PATCH", `repos/${tap}/git/refs/heads/${branch}`, "-f", `sha=${baseSha}`, "-F", "force=true"), `resetting ${tap} ${branch}`);
|
|
675
689
|
const onBranch = await formulaAt(branch);
|
|
676
690
|
if (onBranch.text !== next) {
|
|
677
691
|
await must(gh("api", "-X", "PUT", `repos/${tap}/contents/${formula}`, "-f", `message=toolroll ${version}`, "-f", `branch=${branch}`, "-f", `sha=${onBranch.sha}`, "-f", `content=${Buffer.from(next).toString("base64")}`), `updating ${formula}`);
|
|
@@ -680,6 +694,24 @@ export function releaseAdapters(options) {
|
|
|
680
694
|
const opened = (await ghJson("gh pr view", "pr", "view", branch, "--repo", tap, "--json", "number,headRefOid"));
|
|
681
695
|
return { pr: Number(opened["number"]), head: String(opened["headRefOid"]) };
|
|
682
696
|
},
|
|
697
|
+
async verified(pr, head, version) {
|
|
698
|
+
// The change at exactly `head` is the tap's own, and its formula is exactly today's formula with npm's published
|
|
699
|
+
// tarball and that tarball's own sha256: nothing else is ever merged.
|
|
700
|
+
const view = object(await ghJson("gh pr view", "pr", "view", String(pr), "--repo", tap, "--json", "number,headRefOid,headRefName,baseRefName,headRepository,headRepositoryOwner,isCrossRepository,state"));
|
|
701
|
+
const base = await defaultBranch();
|
|
702
|
+
const baseHead = String(object(object(await ghJson("gh api git/ref", "api", `repos/${tap}/git/ref/heads/${base}`))["object"])["sha"]);
|
|
703
|
+
// The whole change against the tap's default branch head: its merge base, its commits and every file it touches.
|
|
704
|
+
const compare = object(await ghJson("gh api compare", "api", `repos/${tap}/compare/${encodeURIComponent(baseHead)}...${encodeURIComponent(head)}`));
|
|
705
|
+
const tarball = `https://registry.npmjs.org/toolroll/-/toolroll-${version}.tgz`;
|
|
706
|
+
const response = await get(tarball, { signal: AbortSignal.timeout(60_000) });
|
|
707
|
+
if (!response.ok)
|
|
708
|
+
return { ok: false, why: `downloading ${tarball} answered ${response.status}` };
|
|
709
|
+
const sha256 = createHash("sha256").update(Buffer.from(await response.arrayBuffer())).digest("hex");
|
|
710
|
+
const current = (await formulaAt(baseHead)).text;
|
|
711
|
+
const expected = bumpFormula(current, tarball, sha256);
|
|
712
|
+
const problem = tapChangeProblem({ tap, pr, head, version, formula, defaultBranch: base, baseHead, baseVersion: formulaVersion(current), view, compare, expected, atHead: (await formulaAt(head)).text, sha256 });
|
|
713
|
+
return problem === null ? { ok: true } : { ok: false, why: problem };
|
|
714
|
+
},
|
|
683
715
|
async merge(pr, head) {
|
|
684
716
|
const merged = object(await ghJson(`merging ${tap} #${pr}`, "api", "-X", "PUT", `repos/${tap}/pulls/${pr}/merge`, "-f", "merge_method=squash", "-f", `sha=${head}`));
|
|
685
717
|
if (merged["merged"] !== true || typeof merged["sha"] !== "string")
|
|
@@ -696,6 +728,58 @@ export function releaseAdapters(options) {
|
|
|
696
728
|
}
|
|
697
729
|
}
|
|
698
730
|
const brewBranch = (version) => `toolroll-${version}`;
|
|
731
|
+
/**
|
|
732
|
+
* Why a tap change isn't exactly the formula bump this release proposed (security scan), or null when it is: the tap's
|
|
733
|
+
* own open pull request number `pr` at exactly `head`, from its version branch into the default branch; compared with
|
|
734
|
+
* the default branch's head it is one commit on top of exactly that head, changing exactly one file, the formula; and
|
|
735
|
+
* the formula there is exactly today's with npm's published tarball and that tarball's own sha256.
|
|
736
|
+
*/
|
|
737
|
+
export function tapChangeProblem(input) {
|
|
738
|
+
const { tap, pr, head, version, view, compare } = input;
|
|
739
|
+
// State may have changed since the coordinator checked it. Judge the immutable base used for this comparison.
|
|
740
|
+
if (input.baseVersion === null)
|
|
741
|
+
return `${tap} current version cannot be read`;
|
|
742
|
+
if (versionAfter(input.baseVersion, version))
|
|
743
|
+
return `${tap} already names newer ${input.baseVersion}; refusing ${version}`;
|
|
744
|
+
if (Number(view["number"]) !== pr || view["headRefOid"] !== head || view["headRefName"] !== brewBranch(version) || view["state"] !== "OPEN" || !tapOwnHead(view, tap)) {
|
|
745
|
+
return `${tap} #${pr} isn't the tap's own open change at ${head.slice(0, 7)}`;
|
|
746
|
+
}
|
|
747
|
+
if (view["baseRefName"] !== input.defaultBranch)
|
|
748
|
+
return `${tap} #${pr} merges into ${String(view["baseRefName"])}, not ${input.defaultBranch}`;
|
|
749
|
+
const mergeBase = compare["merge_base_commit"] !== null && typeof compare["merge_base_commit"] === "object" ? compare["merge_base_commit"]["sha"] : undefined;
|
|
750
|
+
if (mergeBase !== input.baseHead)
|
|
751
|
+
return `${tap} #${pr} isn't on ${input.defaultBranch}'s head ${input.baseHead.slice(0, 7)}`;
|
|
752
|
+
if (compare["status"] !== "ahead" || compare["ahead_by"] !== 1 || compare["behind_by"] !== 0)
|
|
753
|
+
return `${tap} #${pr} isn't exactly one commit on ${input.defaultBranch}'s head`;
|
|
754
|
+
const files = Array.isArray(compare["files"]) ? compare["files"] : null;
|
|
755
|
+
if (files === null || files.length !== 1 || files[0]["filename"] !== input.formula || files[0]["status"] !== "modified")
|
|
756
|
+
return `${tap} #${pr} changes more than ${input.formula}`;
|
|
757
|
+
if (input.atHead !== input.expected)
|
|
758
|
+
return `${tap} #${pr}'s formula isn't npm's ${version} tarball with its sha256 ${input.sha256.slice(0, 12)}`;
|
|
759
|
+
return null;
|
|
760
|
+
}
|
|
761
|
+
/** Whether a pull request's head is a branch of the tap itself (never a fork's). */
|
|
762
|
+
export function tapOwnHead(pr, tap) {
|
|
763
|
+
const [owner, name] = tap.split("/");
|
|
764
|
+
const repo = pr["headRepository"], who = pr["headRepositoryOwner"];
|
|
765
|
+
const repoName = repo !== null && typeof repo === "object" ? repo["name"] : undefined;
|
|
766
|
+
const ownerLogin = who !== null && typeof who === "object" ? who["login"] : undefined;
|
|
767
|
+
return pr["isCrossRepository"] === false && typeof repoName === "string" && typeof ownerLogin === "string"
|
|
768
|
+
&& repoName.toLowerCase() === (name ?? "").toLowerCase() && ownerLogin.toLowerCase() === (owner ?? "").toLowerCase();
|
|
769
|
+
}
|
|
770
|
+
/** The version a formula's tarball url names (`…/toolroll-<x.y.z>.tgz`), or null. */
|
|
771
|
+
export function formulaVersion(text) {
|
|
772
|
+
const found = /^\s*url "[^"]*-(\d+\.\d+\.\d+)\.tgz"/m.exec(text);
|
|
773
|
+
return found === null ? null : found[1];
|
|
774
|
+
}
|
|
775
|
+
/** Whether version `a` comes after `b` (both x.y.z). */
|
|
776
|
+
export function versionAfter(a, b) {
|
|
777
|
+
const pa = a.split(".").map(Number), pb = b.split(".").map(Number);
|
|
778
|
+
for (let i = 0; i < 3; i++)
|
|
779
|
+
if ((pa[i] ?? 0) !== (pb[i] ?? 0))
|
|
780
|
+
return (pa[i] ?? 0) > (pb[i] ?? 0);
|
|
781
|
+
return false;
|
|
782
|
+
}
|
|
699
783
|
/** The events that put checks on a change. */
|
|
700
784
|
const CHANGE_EVENTS = new Set(["pull_request", "pull_request_target", "push", "merge_group"]);
|
|
701
785
|
/**
|
|
@@ -918,7 +1002,11 @@ function ciEnv() {
|
|
|
918
1002
|
* lists only the latest of each), every page — with its id, app, check suite and the commit it is filed under, oldest
|
|
919
1003
|
* first. A row that doesn't parse stops the read: a partial answer is never taken for the whole.
|
|
920
1004
|
*/
|
|
921
|
-
export async function readCheckRuns(run, gh, repo, sha
|
|
1005
|
+
export async function readCheckRuns(run, gh, repo, sha,
|
|
1006
|
+
/** A release tag (pipeline-speed.md, item 3): a push of exactly this tag binds its workflow file too. */
|
|
1007
|
+
options = {}) {
|
|
1008
|
+
if (options.tag !== undefined && !/^v\d+\.\d+\.\d+$/.test(options.tag))
|
|
1009
|
+
throw new Error("not a release tag");
|
|
922
1010
|
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) || !/^[0-9a-f]{40}$/.test(sha))
|
|
923
1011
|
throw new Error("not a repository and commit GitHub can be asked about");
|
|
924
1012
|
const out = await must(run(gh, ["api", "--hostname", "github.com", "--paginate", `repos/${repo}/commits/${sha}/check-runs?filter=all&per_page=100`,
|
|
@@ -964,7 +1052,7 @@ export async function readCheckRuns(run, gh, repo, sha) {
|
|
|
964
1052
|
const prs = (Array.isArray(one["prs"]) ? one["prs"] : []).map(pr => (pr !== null && typeof pr === "object" ? pr : {}));
|
|
965
1053
|
const ours = prs.length > 0 && prs.every(pr => Number.isSafeInteger(pr["number"]) && pr["repo"] === `https://api.github.com/repos/${repo}`);
|
|
966
1054
|
const untouched = one["event"] !== "pull_request" || (ours && !(await Promise.all(prs.map(pr => everRetargeted(Number(pr["number"]))))).some(Boolean));
|
|
967
|
-
if (mainTriggered(one) && untouched)
|
|
1055
|
+
if ((options.tag === undefined ? mainTriggered(one) : tagTriggered(one, options.tag)) && untouched)
|
|
968
1056
|
pathOf.set(Number(one["suite"]), String(one["path"]));
|
|
969
1057
|
}
|
|
970
1058
|
const runs = out.split("\n").filter(Boolean).map(line => {
|
|
@@ -1227,6 +1315,10 @@ export async function readUpToDateRequired(run, gh, repo, branch = "main") {
|
|
|
1227
1315
|
const protection = await readMainProtection(run, gh, repo, branch);
|
|
1228
1316
|
return protection === null ? null : protection.effective;
|
|
1229
1317
|
}
|
|
1318
|
+
/** Whether a workflow run is the push of exactly this release tag (its workflow file is the tagged commit's own, on main). */
|
|
1319
|
+
function tagTriggered(run, tag) {
|
|
1320
|
+
return tag !== undefined && run["event"] === "push" && run["branch"] === tag;
|
|
1321
|
+
}
|
|
1230
1322
|
/** Whether a workflow run was triggered on main: a push to main, or a pull request whose every base is main. */
|
|
1231
1323
|
function mainTriggered(run) {
|
|
1232
1324
|
const bases = Array.isArray(run["bases"]) ? run["bases"] : [];
|
package/dist/release-carry.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type TreeChange, type VerifiedCommit } from "./commit-extract.js";
|
|
2
|
-
import { type ReleasePolicy, type ReleaseTerms } from "./contracts/release-terms.js";
|
|
2
|
+
import { type ReleaseCheckPurpose, type ReleasePolicy, type ReleaseTerms } from "./contracts/release-terms.js";
|
|
3
3
|
import { type ExecResult } from "./exec.js";
|
|
4
4
|
import { type ReleaseAuthority, type ReleaseCarry, type Store } from "./store.js";
|
|
5
5
|
/** The journey scripts: tests, by Alex's decision (they carry). */
|
|
@@ -66,6 +66,26 @@ export declare function releaseTermsFor(store: Store, input: {
|
|
|
66
66
|
ok: false;
|
|
67
67
|
why: string;
|
|
68
68
|
};
|
|
69
|
+
/**
|
|
70
|
+
* A release check's purpose (docs/plans/pipeline-speed.md, P0), for a release whose terms can't be made (its checks run
|
|
71
|
+
* outside the sandbox, or carrying is off): from the plane's own records — the project's repository, the approved commit
|
|
72
|
+
* and its verified tree, the live release check grant and the check access. It never carries a later commit; it marks
|
|
73
|
+
* this exact candidate as a release's check (strict report rules, no CI stand-in, the plane's own rerun after confirmed
|
|
74
|
+
* infrastructure on the same candidate under the same approval). Null with why when even that can't be read.
|
|
75
|
+
*/
|
|
76
|
+
export declare function releaseCheckPurposeFor(store: Store, input: {
|
|
77
|
+
repo: string;
|
|
78
|
+
version: string;
|
|
79
|
+
branch: string;
|
|
80
|
+
pr: number;
|
|
81
|
+
candidate: string;
|
|
82
|
+
}): {
|
|
83
|
+
ok: true;
|
|
84
|
+
purpose: ReleaseCheckPurpose;
|
|
85
|
+
} | {
|
|
86
|
+
ok: false;
|
|
87
|
+
why: string;
|
|
88
|
+
};
|
|
69
89
|
/** One carried commit, as the summary and the merge message list it. */
|
|
70
90
|
export type LandedCommit = {
|
|
71
91
|
sha: string;
|
package/dist/release-carry.js
CHANGED
|
@@ -165,6 +165,28 @@ export function releaseTermsFor(store, input) {
|
|
|
165
165
|
expiresAt: new Date(input.now.getTime() + 3 * 24 * 60 * 60_000).toISOString(),
|
|
166
166
|
} };
|
|
167
167
|
}
|
|
168
|
+
/**
|
|
169
|
+
* A release check's purpose (docs/plans/pipeline-speed.md, P0), for a release whose terms can't be made (its checks run
|
|
170
|
+
* outside the sandbox, or carrying is off): from the plane's own records — the project's repository, the approved commit
|
|
171
|
+
* and its verified tree, the live release check grant and the check access. It never carries a later commit; it marks
|
|
172
|
+
* this exact candidate as a release's check (strict report rules, no CI stand-in, the plane's own rerun after confirmed
|
|
173
|
+
* infrastructure on the same candidate under the same approval). Null with why when even that can't be read.
|
|
174
|
+
*/
|
|
175
|
+
export function releaseCheckPurposeFor(store, input) {
|
|
176
|
+
const gitDir = projectGitDir(input.repo);
|
|
177
|
+
if (gitDir === null)
|
|
178
|
+
return { ok: false, why: "the project's Git directory can't be read" };
|
|
179
|
+
const commit = readVerifiedCommit(gitDir, input.candidate);
|
|
180
|
+
if (!commit.ok)
|
|
181
|
+
return { ok: false, why: commit.message };
|
|
182
|
+
const grant = store.liveVerifyCommand(input.repo);
|
|
183
|
+
if (grant === null)
|
|
184
|
+
return { ok: false, why: "the project has no approved release check" };
|
|
185
|
+
return { ok: true, purpose: {
|
|
186
|
+
version: 1, purpose: "release-check", repo: input.repo, packageVersion: input.version, branch: input.branch, pr: input.pr, base: "main",
|
|
187
|
+
approvedSha: input.candidate, approvedTree: commit.value.tree, grant: { id: grant.id, digest: grant.digest }, access: store.projectCheckAccess(input.repo),
|
|
188
|
+
} };
|
|
189
|
+
}
|
|
168
190
|
/**
|
|
169
191
|
* Step 2, read-only: nothing from the candidate is installed, built or run here. Objects come from the project's own
|
|
170
192
|
* Git directory as Toolroll records it, hashed here; the authority is read live.
|
package/dist/release-cli.d.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { type ReleaseAdapters } from "./release.js";
|
|
9
9
|
import { type GateReleaseWorld } from "./release-gate.js";
|
|
10
|
+
import { type RescueWorld } from "./deploy-rescue.js";
|
|
10
11
|
export declare const RELEASE_HELP: string;
|
|
11
12
|
/** A runner lease id (claim.ts mints randomUUID()). */
|
|
12
13
|
export declare const LEASE_SHAPED: RegExp;
|
|
@@ -20,6 +21,8 @@ export type ReleaseCliDeps = {
|
|
|
20
21
|
warn?: (line: string) => void;
|
|
21
22
|
/** What --release-gate reads and changes (release-gate.ts); the installation's own by default. */
|
|
22
23
|
gateWorld?: GateReleaseWorld;
|
|
24
|
+
/** What --recover-deploy reads and runs (deploy-rescue.ts); the installation's own by default. */
|
|
25
|
+
rescueWorld?: RescueWorld;
|
|
23
26
|
/** The plane's database for the authority commands (default: this installation's). */
|
|
24
27
|
database?: string;
|
|
25
28
|
now?: () => Date;
|
package/dist/release-cli.js
CHANGED
|
@@ -14,7 +14,8 @@ const sqlite = () => createRequire(import.meta.url)("node:sqlite");
|
|
|
14
14
|
import { checkedEnvelopeJson } from "./contracts/cli.js";
|
|
15
15
|
import { RELEASE_LIMITS, runRelease } from "./release.js";
|
|
16
16
|
import { processes, releaseAdapters } from "./release-adapters.js";
|
|
17
|
-
import { releaseGate } from "./release-gate.js";
|
|
17
|
+
import { journals, releaseGate } from "./release-gate.js";
|
|
18
|
+
import { rescueDeployment } from "./deploy-rescue.js";
|
|
18
19
|
import { databasePath, openStore, revokeRelease } from "./store.js";
|
|
19
20
|
import { carryRefusalWords, proveCarry, setAskEveryReleaseCommit, setUnsandboxedCarry } from "./release-carry.js";
|
|
20
21
|
import { authenticateApprover, machineActing, personProof, proveCredential } from "./scope.js";
|
|
@@ -39,6 +40,13 @@ toolroll release --release-gate <id> [--yes] — lift the update pause a deploym
|
|
|
39
40
|
database, service definition and running service are all still the previous build's. --yes lifts that one pause.
|
|
40
41
|
Any missing or contradicting evidence refuses and leaves the pause in place.
|
|
41
42
|
|
|
43
|
+
toolroll release --recover-deploy [--stage <dir>] [--yes] — put back a deployment that stopped half way
|
|
44
|
+
|
|
45
|
+
Finds the one unfinished deployment (or the stage named) and shows what its recovery will do; --yes does it. When
|
|
46
|
+
the old service isn't proved stopped, it boots the service out and stops what it left running, but only processes
|
|
47
|
+
proved, by exact runtime path and arguments, to be that deployment's own; then it starts the previous service
|
|
48
|
+
again, waits for it, and lifts the update pause. Anything it can't prove refuses. Safe to run again.
|
|
49
|
+
|
|
42
50
|
toolroll release carry <release> --to <commit> [--request <id>] — carry a person's release approval to a later commit
|
|
43
51
|
|
|
44
52
|
Only tests, docs and the changelog may change after the approval, within the limits, with an identical production
|
|
@@ -74,15 +82,17 @@ Options
|
|
|
74
82
|
--limit <step>=<min> change one step's time limit (${Object.entries(RELEASE_LIMITS).map(([step, minutes]) => `${step} ${minutes}`).join(", ")})
|
|
75
83
|
--tap <owner/name> the Homebrew tap (default ap9000/homebrew-toolroll)
|
|
76
84
|
--release-gate <id> check (and with --yes, lift) one update pause left before a deployment's swap
|
|
77
|
-
--yes with --release-gate: lift the pause once it is proved safe
|
|
85
|
+
--yes with --release-gate: lift the pause once it is proved safe; with --recover-deploy: recover
|
|
86
|
+
--recover-deploy recover the unfinished deployment (see above)
|
|
87
|
+
--stage <dir> with --recover-deploy: the deployment's staging folder, when more than one is unfinished
|
|
78
88
|
--json answer with one machine envelope
|
|
79
89
|
|
|
80
90
|
A runner's lease is returned with \`toolroll worker release <lease>\`.`;
|
|
81
91
|
/** A runner lease id (claim.ts mints randomUUID()). */
|
|
82
92
|
export const LEASE_SHAPED = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
83
93
|
export const LEASE_DEPRECATION = "`toolroll release <lease>` is now `toolroll worker release <lease>`; the old form goes in the next minor version.";
|
|
84
|
-
const VALUE_FLAGS = new Set(["--repo", "--limit", "--tap", "--release-gate", "--to", "--request", "--as", "--token", "--schedule"]);
|
|
85
|
-
const BARE_FLAGS = new Set(["--new", "--json", "--help", "--yes", "--no-carry", "--start"]);
|
|
94
|
+
const VALUE_FLAGS = new Set(["--repo", "--limit", "--tap", "--release-gate", "--to", "--request", "--as", "--token", "--schedule", "--stage"]);
|
|
95
|
+
const BARE_FLAGS = new Set(["--new", "--json", "--help", "--yes", "--no-carry", "--start", "--recover-deploy"]);
|
|
86
96
|
export async function runReleaseCommand(args, write, deps = {}) {
|
|
87
97
|
// The flag, not a secret's value that happens to read "--json".
|
|
88
98
|
// A secret is never exactly one of these names (the loop below refuses that as a missing value).
|
|
@@ -94,6 +104,7 @@ export async function runReleaseCommand(args, write, deps = {}) {
|
|
|
94
104
|
};
|
|
95
105
|
const positional = [];
|
|
96
106
|
const limits = {};
|
|
107
|
+
let stage;
|
|
97
108
|
let repo, tap, gate, to, request, as, token, scheduleFlag;
|
|
98
109
|
for (let i = 0; i < args.length; i += 1) {
|
|
99
110
|
const raw = args[i];
|
|
@@ -126,6 +137,8 @@ export async function runReleaseCommand(args, write, deps = {}) {
|
|
|
126
137
|
tap = value;
|
|
127
138
|
else if (arg === "--release-gate")
|
|
128
139
|
gate = value;
|
|
140
|
+
else if (arg === "--stage")
|
|
141
|
+
stage = value;
|
|
129
142
|
else {
|
|
130
143
|
const match = /^([a-z]+)=([0-9]+)$/.exec(value);
|
|
131
144
|
if (match === null || !(match[1] in RELEASE_LIMITS) || Number(match[2]) < 1)
|
|
@@ -147,8 +160,15 @@ export async function runReleaseCommand(args, write, deps = {}) {
|
|
|
147
160
|
return refuse("usage", "--release-gate <id> takes no branch or release options.", 2);
|
|
148
161
|
return runGateRelease(gate, bare.has("--yes"), write, json, deps.gateWorld ?? installedGateWorld());
|
|
149
162
|
}
|
|
163
|
+
if (bare.has("--recover-deploy")) {
|
|
164
|
+
if (positional.length > 0 || args.some(arg => ["--repo", "--limit", "--tap", "--new", "--release-gate"].includes(arg.split("=")[0])))
|
|
165
|
+
return refuse("usage", "--recover-deploy takes only --stage <dir>, --yes and --json.", 2);
|
|
166
|
+
return runRescue(stage, bare.has("--yes"), write, json, deps.rescueWorld ?? installedRescueWorld(deps.cwd ?? process.cwd()));
|
|
167
|
+
}
|
|
168
|
+
if (stage !== undefined)
|
|
169
|
+
return refuse("usage", "--stage goes with --recover-deploy.", 2);
|
|
150
170
|
if (bare.has("--yes"))
|
|
151
|
-
return refuse("usage", "--yes goes with --release-gate <id
|
|
171
|
+
return refuse("usage", "--yes goes with --release-gate <id> or --recover-deploy; a release needs no confirmation beyond the owner's approval.", 2);
|
|
152
172
|
if (positional[0] === "nightly" && positional.length === 1)
|
|
153
173
|
return runNightlyCommand({ repo: repo === undefined ? undefined : resolve(deps.cwd ?? process.cwd(), repo), as, token, schedule: scheduleFlag, start: bare.has("--start") }, write, json, deps);
|
|
154
174
|
if (["carry", "revoke", "ask-every-commit", "unsandboxed-carry", "auto-approve", "publish-ok"].includes(positional[0] ?? "") && positional.length === 2) {
|
|
@@ -217,6 +237,55 @@ function installedGateWorld() {
|
|
|
217
237
|
.filter(path => existsSync(path)).map(path => ({ path, text: readFileSync(path, "utf8") })),
|
|
218
238
|
};
|
|
219
239
|
}
|
|
240
|
+
/** The installation's deployments, its plane's records and the release's own recovery road, for --recover-deploy. */
|
|
241
|
+
function installedRescueWorld(cwd) {
|
|
242
|
+
const database = databasePath(process.env, homedir());
|
|
243
|
+
const stateDir = dirname(database);
|
|
244
|
+
const adapters = releaseAdapters({ checkout: cwd, say: line => process.stderr.write(`${line}\n`) });
|
|
245
|
+
return {
|
|
246
|
+
database, stateDir,
|
|
247
|
+
journals: () => journals(stateDir),
|
|
248
|
+
runWorktree: run => {
|
|
249
|
+
if (!existsSync(database))
|
|
250
|
+
return null;
|
|
251
|
+
const db = new (sqlite().DatabaseSync)(database, { readOnly: true });
|
|
252
|
+
try {
|
|
253
|
+
const row = db.prepare("SELECT worktree FROM run WHERE id = ?").get(run);
|
|
254
|
+
return typeof row?.["worktree"] === "string" && row["worktree"] !== "" ? row["worktree"] : null;
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
finally {
|
|
260
|
+
db.close();
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
recover: async (input) => {
|
|
264
|
+
const answer = await adapters.toolroll.recoverDeploy(input);
|
|
265
|
+
return answer.ok ? { ok: true } : { ok: false, message: answer.message };
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
async function runRescue(stage, yes, write, json, world) {
|
|
270
|
+
let outcome;
|
|
271
|
+
try {
|
|
272
|
+
outcome = await rescueDeployment(stage, yes, world);
|
|
273
|
+
}
|
|
274
|
+
catch (error) {
|
|
275
|
+
outcome = { ok: false, reason: "failed", message: `${error.message} Run \`toolroll release --recover-deploy --yes\` again once the cause is fixed.` };
|
|
276
|
+
}
|
|
277
|
+
if (!outcome.ok) {
|
|
278
|
+
write(json ? checkedEnvelopeJson({ ok: false, command: "release", reason: outcome.reason, message: outcome.message }) : `✗ ${outcome.message}`);
|
|
279
|
+
return outcome.reason === "usage" ? 2 : 3;
|
|
280
|
+
}
|
|
281
|
+
if (json) {
|
|
282
|
+
write(checkedEnvelopeJson({ ok: true, command: "release", recovery: { acted: outcome.acted, stage: outcome.stage, phase: outcome.phase, said: outcome.words } }));
|
|
283
|
+
return 0;
|
|
284
|
+
}
|
|
285
|
+
for (const line of outcome.words)
|
|
286
|
+
write(outcome.acted ? `✓ ${line}` : line);
|
|
287
|
+
return 0;
|
|
288
|
+
}
|
|
220
289
|
function runGateRelease(id, yes, write, json, world) {
|
|
221
290
|
let outcome;
|
|
222
291
|
try {
|
package/dist/release-gate.d.ts
CHANGED
|
@@ -61,6 +61,11 @@ export type GateProof = {
|
|
|
61
61
|
};
|
|
62
62
|
/** The owner id written into the live pause's triggers, when there is one pause and it names one id. */
|
|
63
63
|
export declare function gateOwner(db: Database): string | null;
|
|
64
|
+
/** Every deployment journal under staged-upgrades/: the stage it is in and what it holds, or why it can't be read. */
|
|
65
|
+
export declare function journals(stateDir: string): {
|
|
66
|
+
stage: string;
|
|
67
|
+
journal: Record<string, unknown> | null;
|
|
68
|
+
}[];
|
|
64
69
|
/** Whether the pause owned by `id` was left by a deployment that never began its swap, and the evidence that says so. */
|
|
65
70
|
export declare function proveBeforeSwap(id: string, world: GateReleaseWorld): GateProof;
|
|
66
71
|
export type GateReleaseOutcome = {
|
package/dist/release-gate.js
CHANGED
|
@@ -36,7 +36,7 @@ export function gateOwner(db) {
|
|
|
36
36
|
return ids.size === 1 ? [...ids][0] : null;
|
|
37
37
|
}
|
|
38
38
|
/** Every deployment journal under staged-upgrades/: the stage it is in and what it holds, or why it can't be read. */
|
|
39
|
-
function journals(stateDir) {
|
|
39
|
+
export function journals(stateDir) {
|
|
40
40
|
const root = join(stateDir, "staged-upgrades");
|
|
41
41
|
let stages = [];
|
|
42
42
|
try {
|
package/dist/release-recheck.js
CHANGED
|
@@ -25,7 +25,7 @@ import { readVerifiedCommit, verifiedTreeDiff } from "./commit-extract.js";
|
|
|
25
25
|
import { checkContextSchema, fileIdentity, groupIdentity, journeyIdentity, obligationSnapshotSchema, readCheckReport, sealedInventorySchema, targetedPlanSchema, unitIdentity, versionIdentity, } from "./contracts/release-check.js";
|
|
26
26
|
import { parseContract } from "./contracts/contract.js";
|
|
27
27
|
import { releaseAuthority, releaseAuthorityOfTask, releaseCarries } from "./release-authority.js";
|
|
28
|
-
import { liveReleaseOf } from "./scope.js";
|
|
28
|
+
import { liveReleaseOf, releaseCheckScope } from "./scope.js";
|
|
29
29
|
import { checkAttemptOf } from "./store.js";
|
|
30
30
|
/** The sanity pass every targeted check runs beside its plan: a few fast tests of the core rules, when the tree has them. */
|
|
31
31
|
export const SANITY_UNITS = Object.freeze(["src/scope.test.ts", "src/contract.test.ts", "src/check-report.test.ts", "src/update-safe-migrations.test.ts"]);
|
|
@@ -485,7 +485,8 @@ export function settleCoverage(store, root, input, gateOf) {
|
|
|
485
485
|
}
|
|
486
486
|
// A release's Full check (its task carries signed release terms, before or after its authority opens — an automatic
|
|
487
487
|
// approval's check runs before) counts only with a complete, valid result (check-report.ts): anything less is no result.
|
|
488
|
-
|
|
488
|
+
// A release check purpose (pipeline-speed.md, P0) counts too: a termsless release is held to the same rules.
|
|
489
|
+
const release = b.attempt.authority !== null || releaseCheckScope(store.getScope(input.taskId));
|
|
489
490
|
if (release && fullCheckProblem({ report: b.report, inventory: b.inventory, exitCode: input.exitCode }) !== null)
|
|
490
491
|
return { coverage: "no-result", composed: null };
|
|
491
492
|
return { coverage: "full", composed: null };
|
package/dist/release.d.ts
CHANGED
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
*/
|
|
35
35
|
/** The pull-request checks a release waits for, by exact job name (.github/workflows/ci.yml). Every one must have
|
|
36
36
|
* concluded `success` on the gated commit; src/ci-workflow.test.ts holds the workflow to this list. */
|
|
37
|
-
export declare const REQUIRED_PR_CHECKS: readonly ["node 22 · ubuntu-latest", "node 24 · ubuntu-latest", "linux native containment · delegated cgroup v2", "linux fence · sandbox-runtime", "Windows Job Object containment", "git 2.41 · debian trixie", "git 2.47 · debian trixie"];
|
|
37
|
+
export declare const REQUIRED_PR_CHECKS: readonly ["node 22 · ubuntu-latest", "node 24 · ubuntu-latest", "linux native containment · delegated cgroup v2", "linux fence · sandbox-runtime", "Windows Job Object containment", "git 2.41 · debian trixie", "git 2.47 · debian trixie", "node 22 · macos-latest", "node 24 · macos-latest"];
|
|
38
38
|
/** The workflow file every REQUIRED_PR_CHECKS job belongs to: a job of that name from any other workflow (a lookalike
|
|
39
39
|
* added beside it) never stands for it (Codex review round 1, inherited CI limitation). */
|
|
40
40
|
export declare const REQUIRED_PR_CHECKS_WORKFLOW = ".github/workflows/ci.yml";
|
|
@@ -57,8 +57,11 @@ export type MainProtection = {
|
|
|
57
57
|
missing: string[];
|
|
58
58
|
notYetRequired: string[];
|
|
59
59
|
};
|
|
60
|
-
/** The checks a release tag
|
|
61
|
-
|
|
60
|
+
/** The checks a release's publication waits for on its tag (.github/workflows/publish.yml, pipeline-speed.md item 3):
|
|
61
|
+
* the publish job and the macOS legs it needs, each named. Nothing else reported on the merge commit (main's own CI push
|
|
62
|
+
* run) holds publication. */
|
|
63
|
+
export declare const RELEASE_TAG_CHECKS: readonly ["publish", "tag macOS · node 22", "tag macOS · node 24"];
|
|
64
|
+
export declare const RELEASE_TAG_WORKFLOW = ".github/workflows/publish.yml";
|
|
62
65
|
export declare const RELEASE_STEPS: readonly ["gate", "approval", "check", "complete", "quiet", "deploy", "link", "ci", "merge", "tree", "tag", "publish", "homebrew", "done"];
|
|
63
66
|
export type ReleaseStep = typeof RELEASE_STEPS[number];
|
|
64
67
|
type WaitingStep = "gate" | "approval" | "check" | "quiet" | "deploy" | "ci" | "publish" | "homebrew";
|
|
@@ -170,6 +173,8 @@ export type DeployStop = {
|
|
|
170
173
|
phase: string;
|
|
171
174
|
};
|
|
172
175
|
};
|
|
176
|
+
/** The tap's formula for a version: current, a proposal open, missing — or `newer`: the tap's default branch already
|
|
177
|
+
* names a later version, which an older release must never overwrite. */
|
|
173
178
|
export type HomebrewState = {
|
|
174
179
|
kind: "current";
|
|
175
180
|
} | {
|
|
@@ -178,7 +183,14 @@ export type HomebrewState = {
|
|
|
178
183
|
head: string;
|
|
179
184
|
} | {
|
|
180
185
|
kind: "missing";
|
|
186
|
+
} | {
|
|
187
|
+
kind: "newer";
|
|
188
|
+
version: string;
|
|
181
189
|
};
|
|
190
|
+
/** Homebrew after publication (pipeline-speed.md, P1): its own durable state, finished by `toolroll release <branch>`.
|
|
191
|
+
* `pending`: a change proposed or its checks still running; `failed`: its checks failed (a later run reads them again);
|
|
192
|
+
* `current`: merged, or already on the tap; `superseded`: the tap already names a newer version, so nothing was changed. */
|
|
193
|
+
export type HomebrewProgress = "pending" | "failed" | "current" | "superseded";
|
|
182
194
|
/** One commit that landed after the approval, with its files and line counts (release_carry). */
|
|
183
195
|
export type LandedCommit = {
|
|
184
196
|
sha: string;
|
|
@@ -442,7 +454,10 @@ export type ReleaseAdapters = {
|
|
|
442
454
|
github: {
|
|
443
455
|
authenticated(): Promise<boolean>;
|
|
444
456
|
pullRequest(repo: string, branch: string): Promise<PullRequest | null>;
|
|
445
|
-
|
|
457
|
+
/** `tag`: a release tag's push binds its workflow file too (only that exact tag's push). */
|
|
458
|
+
checks(repo: string, sha: string, options?: {
|
|
459
|
+
tag?: string;
|
|
460
|
+
}): Promise<CheckRun[]>;
|
|
446
461
|
/** Whether GitHub's check history of this commit can be read whole (fewer check suites than the check-runs
|
|
447
462
|
* endpoint's ceiling). Absent or false: an automatic release fails closed; a person's is unaffected. */
|
|
448
463
|
complete?(repo: string, sha: string): Promise<boolean>;
|
|
@@ -472,6 +487,14 @@ export type ReleaseAdapters = {
|
|
|
472
487
|
pr: number;
|
|
473
488
|
head: string;
|
|
474
489
|
}>;
|
|
490
|
+
/** Right before the merge: the change is the tap's own open pull request at exactly `head`, and its formula is
|
|
491
|
+
* exactly the tap's formula with npm's published tarball for `version` and that tarball's own sha256. */
|
|
492
|
+
verified(pr: number, head: string, version: string): Promise<{
|
|
493
|
+
ok: true;
|
|
494
|
+
} | {
|
|
495
|
+
ok: false;
|
|
496
|
+
why: string;
|
|
497
|
+
}>;
|
|
475
498
|
merge(pr: number, head: string): Promise<void>;
|
|
476
499
|
};
|
|
477
500
|
};
|
|
@@ -538,6 +561,9 @@ export type ReleaseJournal = {
|
|
|
538
561
|
pr: number | null;
|
|
539
562
|
head: string | null;
|
|
540
563
|
merged: boolean;
|
|
564
|
+
/** Absent in a journal from before Homebrew was finished apart: read from `merged`. */
|
|
565
|
+
state?: HomebrewProgress;
|
|
566
|
+
why?: string | null;
|
|
541
567
|
} | null;
|
|
542
568
|
/** The release's authority record (release-once.md), once a person approved terms: the id and generation. */
|
|
543
569
|
authority?: {
|
|
@@ -635,6 +661,13 @@ export declare function checksVerdict(runs: readonly CheckRun[], required: reado
|
|
|
635
661
|
state: "failed";
|
|
636
662
|
failed: string[];
|
|
637
663
|
};
|
|
664
|
+
/**
|
|
665
|
+
* Publication's verdict on the tag commit (pipeline-speed.md, item 3): only RELEASE_TAG_CHECKS, from GitHub Actions
|
|
666
|
+
* runs of publish.yml bound to the tag's push, each by its newest attempt. Every other check on the merge commit — main's
|
|
667
|
+
* own CI run — is not waited for: the candidate's checks already passed on its exact head before the merge, and the
|
|
668
|
+
* merged tree was proved equal to it.
|
|
669
|
+
*/
|
|
670
|
+
export declare function tagChecksVerdict(runs: readonly CheckRun[]): ReturnType<typeof checksVerdict>;
|
|
638
671
|
/** Run the release from wherever its journal stopped. */
|
|
639
672
|
export declare function runRelease(options: ReleaseOptions, adapters: ReleaseAdapters): Promise<ReleaseOutcome>;
|
|
640
673
|
/** A probe's failure that is only the network to GitHub being slow, never a decision (a Stop) or another error. */
|