toolroll 0.9.92 → 0.9.93
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 +19 -0
- package/dist/assignment-status.d.ts +1 -1
- package/dist/assignment-ui.js +2 -2
- package/dist/assignment.js +11 -3
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +30 -30
- package/dist/browser-crew.js +2 -2
- package/dist/browser-workspace.d.ts +3 -5
- package/dist/builder.js +21 -2
- package/dist/check-facts.d.ts +124 -0
- package/dist/check-facts.js +272 -0
- package/dist/check-report.d.ts +5 -0
- package/dist/check-report.js +4 -3
- package/dist/cli.js +5 -0
- package/dist/contracts/console-api.d.ts +2 -2
- package/dist/contracts/console-api.js +2 -2
- package/dist/contracts/release-check.d.ts +25 -1
- package/dist/contracts/release-check.js +19 -0
- package/dist/desktop-update.d.ts +2 -2
- package/dist/desktop-update.js +13 -9
- package/dist/eval-hooks.d.ts +55 -0
- package/dist/eval-hooks.js +227 -4
- package/dist/flaky.d.ts +17 -0
- package/dist/flaky.js +9 -0
- package/dist/needs-you.d.ts +7 -7
- package/dist/needs-you.js +12 -10
- package/dist/operate.js +138 -18
- package/dist/project-delete.js +5 -1
- package/dist/project-knowledge.d.ts +6 -6
- package/dist/push.d.ts +10 -3
- package/dist/push.js +22 -6
- package/dist/release-adapters.js +11 -1
- package/dist/release-approval.d.ts +191 -0
- package/dist/release-approval.js +382 -0
- package/dist/release-auto.js +13 -4
- package/dist/release-card.d.ts +73 -4
- package/dist/release-card.js +168 -23
- package/dist/release-cli.js +9 -1
- package/dist/release.d.ts +30 -0
- package/dist/release.js +42 -3
- package/dist/result-acts.d.ts +3 -3
- package/dist/result-acts.js +1 -1
- package/dist/server/guards.js +1 -0
- package/dist/server/pages-assets.js +1 -1
- package/dist/server/render-release.d.ts +17 -0
- package/dist/server/render-release.js +84 -0
- package/dist/server/render-results.d.ts +0 -2
- package/dist/server/render-results.js +4 -6
- package/dist/server/render-tasks.js +3 -3
- package/dist/server/route-table.d.ts +1 -1
- package/dist/server/route-table.js +7 -1
- package/dist/server/tasks.d.ts +15 -0
- package/dist/server/tasks.js +102 -20
- package/dist/setup-guide.d.ts +2 -2
- package/dist/stop-proof.d.ts +67 -0
- package/dist/stop-proof.js +293 -0
- package/dist/store.d.ts +70 -31
- package/dist/store.js +335 -95
- package/dist/surface.js +1 -1
- package/dist/task-outcome-cli.js +19 -0
- package/dist/toolroll-update-ui.js +2 -2
- package/dist/toolroll-update.js +1 -0
- package/dist/work-index.js +7 -9
- package/dist/work-summary.d.ts +2 -2
- package/package.json +1 -1
package/dist/release-card.js
CHANGED
|
@@ -22,7 +22,9 @@ import { join } from "node:path";
|
|
|
22
22
|
import { checkAccessWords } from "./check-access.js";
|
|
23
23
|
import { CHECK_LEVEL_WORDS, effectiveCheckLevel } from "./check-levels.js";
|
|
24
24
|
import { verifiedRange } from "./commit-extract.js";
|
|
25
|
-
import { releaseTermsSchema } from "./contracts/release-terms.js";
|
|
25
|
+
import { releaseCheckPurposeSchema, releaseTermsSchema } from "./contracts/release-terms.js";
|
|
26
|
+
import { readVerifiedCommit } from "./commit-extract.js";
|
|
27
|
+
import { cleanTerminalLine } from "./terminal-text.js";
|
|
26
28
|
import { routeFromJson } from "./phase-routing.js";
|
|
27
29
|
import { projectName } from "./project.js";
|
|
28
30
|
import { RELEASE_CI_RUNNING_WORDS, releaseCiRunningOf } from "./release-ci-hold.js";
|
|
@@ -91,6 +93,30 @@ export const RELEASE_TERM_LABELS = {
|
|
|
91
93
|
"policy.unsandboxedCarry": "Carry when checks aren't sandboxed",
|
|
92
94
|
"grant.id": "Release check grant", "grant.digest": "Release check grant digest", access: "Release check access", expiresAt: "Expires",
|
|
93
95
|
};
|
|
96
|
+
/** Every field of a release check's purpose (contracts/release-terms.ts releaseCheckPurposeSchema): the sealed form
|
|
97
|
+
* Toolroll's own releases take while their checks run outside the sandbox. A contract test checks every leaf has one. */
|
|
98
|
+
export const RELEASE_CHECK_LABELS = {
|
|
99
|
+
version: "Release check version", purpose: "Approved as", repo: "Repository", packageVersion: "Package version", branch: "Branch", pr: "Pull request",
|
|
100
|
+
base: "Base", approvedSha: "Approved commit", approvedTree: "Approved tree", "grant.id": "Release check grant", "grant.digest": "Release check grant digest",
|
|
101
|
+
access: "Release check access",
|
|
102
|
+
};
|
|
103
|
+
export function releaseSealOf(scope) {
|
|
104
|
+
if (scope === null)
|
|
105
|
+
return null;
|
|
106
|
+
if (scope.releaseTerms != null)
|
|
107
|
+
return { kind: "terms", terms: scope.releaseTerms, identity: scope.releaseTerms };
|
|
108
|
+
if (scope.releaseCheck != null)
|
|
109
|
+
return { kind: "check", purpose: scope.releaseCheck, identity: scope.releaseCheck };
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
/** What an unsandboxed (or sign-ins) release check means, said before anyone approves it. Null in the sandbox. */
|
|
113
|
+
export function releaseAccessWords(access) {
|
|
114
|
+
if (access === "unsandboxed")
|
|
115
|
+
return "The release check runs this commit's code with full access to this computer.";
|
|
116
|
+
if (access === "sign-ins")
|
|
117
|
+
return "The release check runs this commit's code with your sign-ins on this computer.";
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
94
120
|
/** The leaf paths of a zod object schema (nested objects joined with dots). */
|
|
95
121
|
export function schemaLeaves(schema, prefix = "") {
|
|
96
122
|
const shape = schema.shape;
|
|
@@ -140,6 +166,15 @@ export function approvalTermsOf(store, scope, words) {
|
|
|
140
166
|
lines.push({ key: leaf.path, label: `Phase route ${leaf.path.slice("route.".length)}`, value: leaf.value });
|
|
141
167
|
}
|
|
142
168
|
const terms = scope.releaseTerms ?? null;
|
|
169
|
+
// A release check's purpose (the form Toolroll's own releases seal): every one of its fields, never "none".
|
|
170
|
+
if (terms === null && scope.releaseCheck != null) {
|
|
171
|
+
const purpose = scope.releaseCheck;
|
|
172
|
+
for (const path of schemaLeaves(releaseCheckPurposeSchema)) {
|
|
173
|
+
const value = path === "pr" ? `#${purpose.pr}` : path === "purpose" ? "a release check: every later commit asks again" : said(valueAt(purpose, path));
|
|
174
|
+
lines.push({ key: `releaseCheck.${path}`, label: RELEASE_CHECK_LABELS[path] ?? path, value });
|
|
175
|
+
}
|
|
176
|
+
return lines;
|
|
177
|
+
}
|
|
143
178
|
for (const path of schemaLeaves(releaseTermsSchema)) {
|
|
144
179
|
const label = RELEASE_TERM_LABELS[path] ?? path;
|
|
145
180
|
const value = terms === null ? NONE : path === "pr" ? `#${terms.pr}`
|
|
@@ -195,20 +230,120 @@ export function commitsBetween(repo, from, to) {
|
|
|
195
230
|
const range = verifiedRange(gitDir, from, to, { git: tools.git, env });
|
|
196
231
|
if (!range.ok)
|
|
197
232
|
return null;
|
|
198
|
-
return range.value.map(one => ({ sha: one.id, subject: one.message
|
|
233
|
+
return range.value.map(one => ({ sha: one.id, subject: subjectText(one.message) }));
|
|
199
234
|
}
|
|
200
|
-
/**
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
235
|
+
/** GitHub lists at most this many of a pull request's commits; a longer one is never shown as complete. */
|
|
236
|
+
export const PULL_REQUEST_COMMITS_MAX = 250;
|
|
237
|
+
/** A commit subject is someone else's text: one line, control-free (src/terminal-text.ts), bounded. The card digest
|
|
238
|
+
* binds this cleaned text, so every surface shows exactly what was bound (Codex round 2, finding 7). */
|
|
239
|
+
export const subjectText = (subject) => cleanTerminalLine(subject.split("\n")[0] ?? "").trim().slice(0, 200);
|
|
240
|
+
const ghRead = args => {
|
|
241
|
+
const read = spawnSync(releaseTools().gh, args, { cwd: "/", env: releaseGitEnv(), encoding: "utf8", timeout: 30_000, maxBuffer: 16 * 1024 * 1024 });
|
|
242
|
+
return { status: read.status, stdout: String(read.stdout ?? "") };
|
|
243
|
+
};
|
|
244
|
+
/**
|
|
245
|
+
* A pull request's own commits from GitHub, for the recorded owner/name, complete and bound to the candidate (Codex
|
|
246
|
+
* round 2, finding 6): every page (`--paginate`), accepted only when the pull request's head is `head`, the newest
|
|
247
|
+
* listed commit is `head` and the count is the pull request's own count. Otherwise why, never a shortened list.
|
|
248
|
+
*/
|
|
249
|
+
export function pullRequestCommits(slug, pr, head, read = ghRead) {
|
|
250
|
+
const about = read(["api", `repos/${slug}/pulls/${pr}`, "--jq", '[.head.sha, (.commits|tostring)] | join("\\t")']);
|
|
251
|
+
if (about.status !== 0)
|
|
252
|
+
return { ok: false, why: "GitHub couldn't be read" };
|
|
253
|
+
const [prHead, countText] = about.stdout.trim().split("\t");
|
|
254
|
+
const total = Number(countText);
|
|
255
|
+
if (prHead !== head)
|
|
256
|
+
return { ok: false, why: `the pull request is at ${SHA.test(prHead ?? "") ? prHead.slice(0, 7) : "another commit"}, not ${head.slice(0, 7)}` };
|
|
257
|
+
if (!Number.isSafeInteger(total) || total < 1)
|
|
258
|
+
return { ok: false, why: "GitHub didn't say how many commits the pull request has" };
|
|
259
|
+
if (total > PULL_REQUEST_COMMITS_MAX)
|
|
260
|
+
return { ok: false, why: `incomplete: the pull request has ${total} commits and GitHub lists only ${PULL_REQUEST_COMMITS_MAX}` };
|
|
261
|
+
const listed = read(["api", "--paginate", `repos/${slug}/pulls/${pr}/commits?per_page=100`, "--jq", '.[] | .sha + "\\t" + ((.commit.message | split("\\n")[0]) | @json)']);
|
|
262
|
+
if (listed.status !== 0)
|
|
263
|
+
return { ok: false, why: "GitHub couldn't be read" };
|
|
264
|
+
const commits = [];
|
|
265
|
+
for (const line of listed.stdout.split("\n")) {
|
|
266
|
+
if (line === "")
|
|
267
|
+
continue;
|
|
268
|
+
const tab = line.indexOf("\t");
|
|
269
|
+
const sha = line.slice(0, tab);
|
|
270
|
+
let subject;
|
|
271
|
+
try {
|
|
272
|
+
subject = JSON.parse(line.slice(tab + 1));
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
return { ok: false, why: "GitHub's answer didn't parse" };
|
|
276
|
+
}
|
|
277
|
+
if (tab < 0 || !SHA.test(sha) || typeof subject !== "string")
|
|
278
|
+
return { ok: false, why: "GitHub's answer didn't parse" };
|
|
279
|
+
commits.push({ sha, subject: subjectText(subject) });
|
|
280
|
+
}
|
|
281
|
+
if (commits.length !== total)
|
|
282
|
+
return { ok: false, why: `incomplete: GitHub listed ${commits.length} of ${total} commits` };
|
|
283
|
+
if (commits[commits.length - 1].sha !== head)
|
|
284
|
+
return { ok: false, why: `the newest commit GitHub listed isn't ${head.slice(0, 7)}` };
|
|
285
|
+
if (new Set(commits.map(one => one.sha)).size !== commits.length)
|
|
286
|
+
return { ok: false, why: "GitHub listed a commit twice" };
|
|
287
|
+
return { ok: true, commits: commits.reverse() };
|
|
288
|
+
}
|
|
289
|
+
/** The ledger act that records a verified release (the merged tree proved equal to the approved one). */
|
|
290
|
+
export const RELEASE_VERIFIED_ACTION = "release verified";
|
|
291
|
+
/** The project's own Git directory: `<realpath(repo)>/.git`, a real directory. */
|
|
292
|
+
function gitDirOf(repo) {
|
|
293
|
+
try {
|
|
294
|
+
const named = join(realpathSync(repo), ".git");
|
|
295
|
+
return lstatSync(named).isDirectory() ? realpathSync(named) : null;
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
205
298
|
return null;
|
|
206
|
-
|
|
207
|
-
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Record a verified release (Codex round 2, finding 1: verified release history apart from carrying). The coordinator
|
|
303
|
+
* calls it once main's merge commit was proved to hold the approved tree; the plane proves it again here, from its own
|
|
304
|
+
* records only: the task is a release scope of a project, and `merge` read as a verified Git object from the project's
|
|
305
|
+
* recorded Git directory has exactly the sealed approved tree. False when anything doesn't hold (nothing written).
|
|
306
|
+
*/
|
|
307
|
+
export function recordVerifiedRelease(store, input) {
|
|
308
|
+
if (!SHA.test(input.merge))
|
|
309
|
+
return false;
|
|
310
|
+
const repo = store.lookupRef(input.taskId)?.repo ?? null;
|
|
311
|
+
const seal = releaseSealOf(store.getScope(input.taskId));
|
|
312
|
+
if (repo === null || seal === null || seal.identity.repo !== repo)
|
|
313
|
+
return false;
|
|
314
|
+
const gitDir = gitDirOf(repo);
|
|
315
|
+
if (gitDir === null)
|
|
316
|
+
return false;
|
|
317
|
+
const commit = readVerifiedCommit(gitDir, input.merge);
|
|
318
|
+
if (!commit.ok || commit.value.tree !== seal.identity.approvedTree)
|
|
319
|
+
return false;
|
|
320
|
+
if (store.handle.prepare("SELECT 1 FROM action_ledger WHERE task_id = ? AND action = ? AND detail = ?").get(input.taskId, RELEASE_VERIFIED_ACTION, input.merge) !== undefined)
|
|
321
|
+
return true;
|
|
322
|
+
store.recordAction({ at: input.now.toISOString(), actor: "toolroll", repo, taskId: input.taskId, runId: null, action: RELEASE_VERIFIED_ACTION, outcome: seal.identity.packageVersion,
|
|
323
|
+
source: "work", detail: input.merge });
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* The last verified release of `repo`, from either record: a merged release authority's succeeded merge, or a verified
|
|
328
|
+
* release row (recordVerifiedRelease). The newer stands. Shaped as releaseBaselineRecord.
|
|
329
|
+
*/
|
|
330
|
+
export function verifiedReleaseBaseline(store, repo) {
|
|
331
|
+
const authority = store.handle.prepare(`SELECT o.external_ref, COALESCE(o.finished_at, '') AS at FROM release_authority a LEFT JOIN release_operation o
|
|
332
|
+
ON o.authority = a.id AND o.kind = 'merge' AND o.state = 'succeeded'
|
|
333
|
+
WHERE a.repo = ? AND a.state = 'merged' ORDER BY a.id DESC, o.finished_at DESC LIMIT 1`).get(repo);
|
|
334
|
+
const verified = store.handle.prepare("SELECT detail, at FROM action_ledger WHERE repo = ? AND action = ? ORDER BY id DESC LIMIT 1").get(repo, RELEASE_VERIFIED_ACTION);
|
|
335
|
+
const verifiedSha = typeof verified?.["detail"] === "string" && SHA.test(verified["detail"]) ? verified["detail"] : null;
|
|
336
|
+
if (verified !== undefined && verifiedSha !== null && (authority === undefined || String(verified["at"]) >= String(authority["at"])))
|
|
337
|
+
return { recorded: true, sha: verifiedSha };
|
|
338
|
+
if (authority === undefined)
|
|
339
|
+
return verified === undefined ? { recorded: false } : { recorded: true, sha: null };
|
|
340
|
+
const ref = authority["external_ref"];
|
|
341
|
+
return { recorded: true, sha: typeof ref === "string" && SHA.test(ref) ? ref : null };
|
|
208
342
|
}
|
|
209
|
-
/** What changed since the last verified release, for a release's terms
|
|
210
|
-
|
|
211
|
-
|
|
343
|
+
/** What changed since the last verified release, for a release's sealed identity (terms or a release check's purpose),
|
|
344
|
+
* from Toolroll's records only. */
|
|
345
|
+
export function releaseChangeList(store, terms, read) {
|
|
346
|
+
const record = verifiedReleaseBaseline(store, terms.repo);
|
|
212
347
|
// A release recorded with a merge commit that can't be read: the list couldn't be read (eval-harness review 2,
|
|
213
348
|
// finding 4) — never the pull request's commits, as if there were no previous release.
|
|
214
349
|
if (record.recorded && record.sha === null)
|
|
@@ -216,6 +351,7 @@ export function releaseChangeList(store, terms) {
|
|
|
216
351
|
const baseline = record.recorded ? record.sha : null;
|
|
217
352
|
let commits;
|
|
218
353
|
let source;
|
|
354
|
+
let why;
|
|
219
355
|
if (baseline !== null) {
|
|
220
356
|
commits = commitsBetween(terms.repo, baseline, terms.approvedSha);
|
|
221
357
|
source = commits === null ? "unread" : "recorded";
|
|
@@ -223,10 +359,14 @@ export function releaseChangeList(store, terms) {
|
|
|
223
359
|
else {
|
|
224
360
|
const remote = store.projectRemote(terms.repo);
|
|
225
361
|
const parsed = remote === null ? null : releaseRemote(remote);
|
|
226
|
-
|
|
362
|
+
const listed = parsed === null ? { ok: false, why: "the project has no GitHub remote on record" } : pullRequestCommits(parsed.slug, terms.pr, terms.approvedSha, read);
|
|
363
|
+
commits = listed.ok ? listed.commits : null;
|
|
364
|
+
if (!listed.ok)
|
|
365
|
+
why = listed.why;
|
|
227
366
|
source = commits === null ? "unread" : "pull-request";
|
|
228
367
|
}
|
|
229
|
-
return { baseline, commits, source,
|
|
368
|
+
return { baseline, commits, source, ...(why === undefined ? {} : { why }),
|
|
369
|
+
digest: sha256(`release-changes:${JSON.stringify(why === undefined ? { baseline, source, commits } : { baseline, source, commits, why })}`) };
|
|
230
370
|
}
|
|
231
371
|
/** The card digest: everything the person is shown that can change under them. */
|
|
232
372
|
export function cardDigestOf(scopeDigest, formDigest, changes) {
|
|
@@ -236,25 +376,30 @@ function changeWords(changes, terms, cap) {
|
|
|
236
376
|
const head = changes.baseline === null ? `No earlier verified release is recorded. ${changes.source === "pull-request" ? `Pull request #${terms?.pr ?? "?"}'s commits:` : ""}`.trim()
|
|
237
377
|
: `Changes since the last verified release (${changes.baseline.slice(0, 7)}):`;
|
|
238
378
|
if (changes.commits === null)
|
|
239
|
-
return [head, changes.baseline === null && changes.baselineUnreadable !== true ? `The pull request's commits couldn't be read; see #${terms?.pr ?? "?"} on GitHub.` : "The change list couldn't be read."];
|
|
379
|
+
return [head, changes.baseline === null && changes.baselineUnreadable !== true ? `The pull request's commits couldn't be read${changes.why === undefined ? "" : ` (${changes.why})`}; see #${terms?.pr ?? "?"} on GitHub.` : "The change list couldn't be read."];
|
|
240
380
|
if (changes.commits.length === 0)
|
|
241
381
|
return [head, "No commits."];
|
|
242
382
|
return [head, ...changes.commits.slice(0, cap).map(one => `• ${one.sha.slice(0, 7)} ${one.subject.slice(0, 100)}`), ...(changes.commits.length > cap ? [`• and ${changes.commits.length - cap} more`] : [])];
|
|
243
383
|
}
|
|
244
384
|
/** The release card for a deploy gate, against the live records now. */
|
|
245
|
-
export function releaseCardOf(store, scope, formDigest, words) {
|
|
385
|
+
export function releaseCardOf(store, scope, formDigest, words, read) {
|
|
246
386
|
const terms = scope.releaseTerms ?? null;
|
|
387
|
+
// Either sealed form (Codex round 2, finding 1): the identity both share, for the head lines and the change list.
|
|
388
|
+
const identity = releaseSealOf(scope)?.identity ?? null;
|
|
247
389
|
const lines = approvalTermsOf(store, scope, words);
|
|
248
390
|
const value = (key) => lines.find(line => line.key === key)?.value ?? NONE;
|
|
249
|
-
const changes =
|
|
391
|
+
const changes = identity === null
|
|
250
392
|
? { baseline: null, commits: null, source: "unread", digest: sha256("release-changes:none") }
|
|
251
|
-
: releaseChangeList(store,
|
|
252
|
-
const version =
|
|
393
|
+
: releaseChangeList(store, identity, read);
|
|
394
|
+
const version = identity?.packageVersion ?? null;
|
|
253
395
|
const fileName = `release-${version ?? scope.taskId.replace(/[^A-Za-z0-9._-]/g, "-")}-terms.txt`;
|
|
254
396
|
const short = [
|
|
255
397
|
version === null ? `Deploy gate: ${value("title")}` : `Release ${version}: ${value("title")}`,
|
|
256
|
-
|
|
257
|
-
terms === null ? `Checks: ${value("checks")} · check access: ${value("checkAccess")}` : `Carry later commits:
|
|
398
|
+
identity === null ? `Candidate ${said(scope.candidate).slice(0, 12)} · ${value("project")}` : `${projectName(identity.repo)} · ${identity.branch} · pull request #${identity.pr} · commit ${identity.approvedSha.slice(0, 7)}`,
|
|
399
|
+
terms === null ? identity === null ? `Checks: ${value("checks")} · check access: ${value("checkAccess")}` : `Carry later commits: no, every later commit asks again · checks: ${value("checks")}`
|
|
400
|
+
: `Carry later commits: ${terms.policy.carry ? "yes" : "no"} · checks: ${value("checks")} · expires ${terms.expiresAt}`,
|
|
401
|
+
// What an unsandboxed check means, before consent (Codex round 2, finding 1).
|
|
402
|
+
...(identity === null || releaseAccessWords(identity.access) === null ? [] : [releaseAccessWords(identity.access)]),
|
|
258
403
|
// Option A's disclosure, on the card as on the approval sheet: never approved from chat without it.
|
|
259
404
|
...(terms !== null && terms.policy.carry && terms.access !== "sandboxed" ? ["This project's checks don't run in the sandbox: later test-only commits will run outside the sandbox without being shown to you first."] : []),
|
|
260
405
|
// Filed while CI runs (Alex, 2026-10-29): a person may approve now, and is told plainly what still waits.
|
|
@@ -262,10 +407,10 @@ export function releaseCardOf(store, scope, formDigest, words) {
|
|
|
262
407
|
// GitHub's nightly (nightly-github.ts): what holds this release, or why it can't be read. Not a signed term.
|
|
263
408
|
...(terms === null ? [] : nightlyReleaseLines(store, { repo: terms.repo, taskId: scope.taskId, candidate: scope.candidate ?? null, now: new Date() })),
|
|
264
409
|
"",
|
|
265
|
-
...changeWords(changes,
|
|
410
|
+
...changeWords(changes, identity, 5),
|
|
266
411
|
"",
|
|
267
412
|
`Every term is in ${fileName}.`,
|
|
268
413
|
].join("\n");
|
|
269
|
-
const full = [`${version === null ? "Deploy gate" : `Release ${version}`}: every term`, "", termsText(lines), "", ...changeWords(changes,
|
|
414
|
+
const full = [`${version === null ? "Deploy gate" : `Release ${version}`}: every term`, "", termsText(lines), "", ...changeWords(changes, identity, 200)].join("\n");
|
|
270
415
|
return { digest: cardDigestOf(scope.digest, formDigest, changes), short, full, fileName, changes };
|
|
271
416
|
}
|
package/dist/release-cli.js
CHANGED
|
@@ -36,9 +36,14 @@ export const RELEASE_HELP = `toolroll release <branch> — release Toolroll itse
|
|
|
36
36
|
|
|
37
37
|
Run the same command to resume after a stop. Interrupted deployments recover their saved stage before retrying.
|
|
38
38
|
|
|
39
|
-
A branch whose name is one of this command's own words (status, nightly, carry…) is named with --branch <name>
|
|
39
|
+
A branch whose name is one of this command's own words (approve, status, nightly, carry…) is named with --branch <name>
|
|
40
40
|
or after --: \`toolroll release --branch status\`.
|
|
41
41
|
|
|
42
|
+
toolroll release approve [<branch>] [--repo <path>] — approve the release waiting for you: shows the release, GitHub CI
|
|
43
|
+
now, what approving does, what changed and every term, then asks for your password (or uses your remembered login
|
|
44
|
+
where Passwordless allows it). Names the waiting releases when more than one could be meant. With --json it never
|
|
45
|
+
prompts: --as <you> --token <password> --digest <card digest>
|
|
46
|
+
|
|
42
47
|
toolroll release status [<task>] — the steps of a release (the newest one recorded, or the release task named): each
|
|
43
48
|
step's state and how long it took, and where it stopped
|
|
44
49
|
|
|
@@ -233,6 +238,9 @@ export async function runReleaseCommand(args, write, deps = {}) {
|
|
|
233
238
|
return refuse("usage", "`toolroll release status [<task>]` takes only --json.", 2);
|
|
234
239
|
return runStatusCommand(positional[1], write, json, deps);
|
|
235
240
|
}
|
|
241
|
+
// `release approve` is the toolroll command's own (cli.ts routes it to the operate commands); never a branch here.
|
|
242
|
+
if (!named && positional[0] === "approve")
|
|
243
|
+
return refuse("usage", "Run `toolroll release approve [<branch>]`. A branch named approve is released with `--branch approve`.", 2);
|
|
236
244
|
if (!named && positional[0] === "nightly" && positional.length === 1)
|
|
237
245
|
return runNightlyCommand({ repo: repo === undefined ? undefined : resolve(deps.cwd ?? process.cwd(), repo), as, token, schedule: scheduleFlag, start: bare.has("--start") }, write, json, deps);
|
|
238
246
|
if (!named && ["carry", "revoke", "ask-every-commit", "unsandboxed-carry", "auto-approve", "publish-ok"].includes(positional[0] ?? "") && positional.length === 2) {
|
package/dist/release.d.ts
CHANGED
|
@@ -332,6 +332,34 @@ export type ReleaseAuthorityAdapter = {
|
|
|
332
332
|
why: string;
|
|
333
333
|
warning: string | null;
|
|
334
334
|
}>;
|
|
335
|
+
/** One-tap release approval (docs/plans/trusted-release-reports.md, slice A): what the plane tells a person and
|
|
336
|
+
* keeps for the approval sheet. Best effort: a failure here never changes the release. Absent on an older plane. */
|
|
337
|
+
ready?: ReleaseReadyAdapter;
|
|
338
|
+
};
|
|
339
|
+
/** What the coordinator tells the plane for a person's release (release-approval.ts). Ids and its own readings only. */
|
|
340
|
+
export type ReleaseReadyAdapter = {
|
|
341
|
+
/** The approval step was entered (a fresh filing or a resumed one): one notification per task and scope digest. */
|
|
342
|
+
asked(taskId: string): Promise<void>;
|
|
343
|
+
/** The pull request's required checks on the candidate, as this coordinator just read them. */
|
|
344
|
+
ci(input: {
|
|
345
|
+
taskId: string;
|
|
346
|
+
candidate: string;
|
|
347
|
+
state: "passed" | "running" | "failed";
|
|
348
|
+
failed: readonly string[];
|
|
349
|
+
runs: readonly CheckRun[];
|
|
350
|
+
}): Promise<void>;
|
|
351
|
+
/** A person's release stopped: one attention notification per task, step and reason. */
|
|
352
|
+
stopped(input: {
|
|
353
|
+
taskId: string;
|
|
354
|
+
step: string;
|
|
355
|
+
reason: string;
|
|
356
|
+
message: string;
|
|
357
|
+
}): Promise<void>;
|
|
358
|
+
/** Main's merge commit was proved to hold the approved tree: the plane checks it again and keeps the release's record. */
|
|
359
|
+
verified(input: {
|
|
360
|
+
taskId: string;
|
|
361
|
+
merge: string;
|
|
362
|
+
}): Promise<void>;
|
|
335
363
|
};
|
|
336
364
|
/** The plane's automatic release approval, as the coordinator asks it. Every answer is the plane's own: the coordinator
|
|
337
365
|
* hands it ids, never evidence (the plane reads GitHub CI itself for `approve`). */
|
|
@@ -451,11 +479,13 @@ export type ReleaseAdapters = {
|
|
|
451
479
|
}): Promise<{
|
|
452
480
|
scopeDigest: string;
|
|
453
481
|
}>;
|
|
482
|
+
/** `reason` "stopping": Toolroll is still proving the check's build stopped (stop-proof.ts); it clears on its own. */
|
|
454
483
|
complete(taskId: string, receipt: string): Promise<{
|
|
455
484
|
ok: true;
|
|
456
485
|
} | {
|
|
457
486
|
ok: false;
|
|
458
487
|
message: string;
|
|
488
|
+
reason?: string;
|
|
459
489
|
}>;
|
|
460
490
|
busy(): Promise<{
|
|
461
491
|
running: number;
|
package/dist/release.js
CHANGED
|
@@ -453,6 +453,20 @@ export async function runRelease(options, adapters) {
|
|
|
453
453
|
};
|
|
454
454
|
// ---- one approval per release (release-once.md) -------------------------------------------------------------
|
|
455
455
|
const rel = adapters.release;
|
|
456
|
+
/** One-tap release approval: tell the plane, never stopping or changing the release whatever it answers. */
|
|
457
|
+
const tell = async (act) => { if (rel?.ready === undefined)
|
|
458
|
+
return; try {
|
|
459
|
+
await act(rel.ready);
|
|
460
|
+
}
|
|
461
|
+
catch { /* best effort */ } };
|
|
462
|
+
/** The last pull request checks this run read on the candidate (the gate reads them before the task exists). */
|
|
463
|
+
let lastCi = null;
|
|
464
|
+
const noteCi = async (runs, verdict) => {
|
|
465
|
+
lastCi = { candidate: j.candidate.sha, state: verdict.state === "pending" ? "running" : verdict.state, failed: verdict.failed ?? [], runs: runs.filter(run => REQUIRED_PR_CHECKS.includes(run.name)) };
|
|
466
|
+
const seen = lastCi;
|
|
467
|
+
await tell(ready => ready.ci({ taskId: j.task.id, ...seen }));
|
|
468
|
+
};
|
|
469
|
+
const currentCi = () => lastCi;
|
|
456
470
|
let epoch = null;
|
|
457
471
|
// This invocation's own lease owner (review round 1): two runs of the same journal never share an epoch.
|
|
458
472
|
const owner = `${j.id}:${randomUUID()}`;
|
|
@@ -693,6 +707,7 @@ export async function runRelease(options, adapters) {
|
|
|
693
707
|
throw error;
|
|
694
708
|
}
|
|
695
709
|
const verdict = prChecksVerdict(runs, j.candidate.sha, REQUIRED_PR_CHECKS, { workflow: REQUIRED_PR_CHECKS_WORKFLOW });
|
|
710
|
+
await noteCi(runs, verdict);
|
|
696
711
|
if (verdict.state === "failed") {
|
|
697
712
|
throw new Stop("ci-failed", `GitHub's checks failed on ${j.candidate.sha.slice(0, 7)}: ${verdict.failed.join(", ")}. ${nothing}. ${j.approval === null ? "" : `The approval covers ${j.candidate.sha.slice(0, 7)} only. `}Rerun the failed job on GitHub and run \`${resume}\` again, or fix the branch and release the new commit with \`toolroll release ${releaseBranchArg(j.branch)} --repo ${j.checkout} --new\`.`);
|
|
698
713
|
}
|
|
@@ -810,7 +825,7 @@ export async function runRelease(options, adapters) {
|
|
|
810
825
|
// The gate only stops on a check that already failed: the task is filed and a person asked while CI runs, and
|
|
811
826
|
// the release check runs beside it (pipeline-speed.md, D2 and "Decision: a person's release asks during
|
|
812
827
|
// CI"). The automatic approval waits for CI (the check step); nothing deploys before it passes (the deploy).
|
|
813
|
-
const settled = await earlyChecks(adapters, j, (what, probe) => waitFor("gate", what, probe), { pendingIsEnough: true });
|
|
828
|
+
const settled = await earlyChecks(adapters, j, (what, probe) => waitFor("gate", what, probe), { pendingIsEnough: true, seen: (runs, verdict) => noteCi(runs, verdict) });
|
|
814
829
|
// Recorded before the task exists, so its approval card says CI still runs from the first moment.
|
|
815
830
|
if (!settled)
|
|
816
831
|
await rel?.auto?.ciRunning?.(j.task.id, j.candidate.sha);
|
|
@@ -830,6 +845,10 @@ export async function runRelease(options, adapters) {
|
|
|
830
845
|
...(rel === undefined ? {} : { release: { version: j.packageVersion, pr: j.pr, branch: j.branch, noCarry: options.noCarry === true } }),
|
|
831
846
|
});
|
|
832
847
|
j.task.scopeDigest = filed.scopeDigest;
|
|
848
|
+
// The checks the gate read before the task existed, kept for its approval sheet now that it does.
|
|
849
|
+
const seen = currentCi();
|
|
850
|
+
if (seen !== null)
|
|
851
|
+
await tell(ready => ready.ci({ taskId: j.task.id, ...seen }));
|
|
833
852
|
}
|
|
834
853
|
advance("approval");
|
|
835
854
|
break;
|
|
@@ -862,7 +881,11 @@ export async function runRelease(options, adapters) {
|
|
|
862
881
|
// A check that already failed asks nobody (as at the gate).
|
|
863
882
|
if (j.ciAhead === true)
|
|
864
883
|
await ciWhileRunning("Nothing was deployed or merged", true);
|
|
884
|
+
// One push, on every entry to this step (a fresh filing or a resumed one): the plane files it once per task and
|
|
885
|
+
// scope digest, so a crash between filing and here is healed by the next run.
|
|
886
|
+
await tell(ready => ready.asked(j.task.id));
|
|
865
887
|
say(` The release scope needs your approval: \`toolroll task approve ${j.task.id} --digest ${j.task.scopeDigest}\`, or open the task in the console.`);
|
|
888
|
+
say(` Or approve it from here: \`toolroll release approve ${releaseBranchArg(j.branch)}\`.`);
|
|
866
889
|
if (j.ciAhead === true)
|
|
867
890
|
say(` ${CI_RUNNING_WORDS}`);
|
|
868
891
|
const approved = await waitFor("approval", `a person to approve task ${j.task.id}`, async () => {
|
|
@@ -1007,7 +1030,12 @@ export async function runRelease(options, adapters) {
|
|
|
1007
1030
|
await checked(gate, "complete");
|
|
1008
1031
|
if (gate.completion?.digest !== check.receipt) {
|
|
1009
1032
|
intend("complete");
|
|
1010
|
-
|
|
1033
|
+
// Toolroll proves the check's build stopped by itself (stop-proof.ts) a short while after it finished: the
|
|
1034
|
+
// release waits for that, never for a person to confirm it.
|
|
1035
|
+
const done = await waitFor("quiet", "Toolroll to make sure the release check stopped", async () => {
|
|
1036
|
+
const tried = await adapters.toolroll.complete(j.task.id, check.receipt);
|
|
1037
|
+
return !tried.ok && tried.reason === "stopping" ? null : tried;
|
|
1038
|
+
});
|
|
1011
1039
|
if (!done.ok)
|
|
1012
1040
|
throw new Stop("complete", `Couldn't mark task ${j.task.id} complete: ${done.message} Mark that exact result complete in the console, then rerun.`);
|
|
1013
1041
|
}
|
|
@@ -1202,6 +1230,8 @@ export async function runRelease(options, adapters) {
|
|
|
1202
1230
|
merge.tree = tree;
|
|
1203
1231
|
if (tree !== j.candidate.tree)
|
|
1204
1232
|
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.`);
|
|
1233
|
+
// The release's verified record (trusted-release-reports.md A1): the next release's change list starts here.
|
|
1234
|
+
await tell(ready => ready.verified({ taskId: j.task.id, merge: merge.sha }));
|
|
1205
1235
|
advance("tag");
|
|
1206
1236
|
break;
|
|
1207
1237
|
}
|
|
@@ -1288,6 +1318,14 @@ export async function runRelease(options, adapters) {
|
|
|
1288
1318
|
}
|
|
1289
1319
|
catch { /* the stop is still reported */ }
|
|
1290
1320
|
mark(journal.step, "failed", stop.message);
|
|
1321
|
+
// A person's release that stops tells them (trusted-release-reports.md A5); the automatic road keeps its own notices.
|
|
1322
|
+
const ready = adapters.release?.ready;
|
|
1323
|
+
if (ready !== undefined && journal.auto?.state !== "checking" && journal.auto?.state !== "approved") {
|
|
1324
|
+
try {
|
|
1325
|
+
await ready.stopped({ taskId: journal.task.id, step: journal.step, reason: stop.reason, message: stop.message });
|
|
1326
|
+
}
|
|
1327
|
+
catch { /* best effort */ }
|
|
1328
|
+
}
|
|
1291
1329
|
}
|
|
1292
1330
|
return { ok: false, reason: stop.reason, message: stop.message, step: journal?.step ?? step, journal, file, resume };
|
|
1293
1331
|
}
|
|
@@ -1428,6 +1466,7 @@ async function earlyChecks(adapters, j, wait, options = {}) {
|
|
|
1428
1466
|
return null;
|
|
1429
1467
|
}
|
|
1430
1468
|
const verdict = prChecksVerdict(runs.filter(run => required.includes(run.name)), j.candidate.sha, required, { workflow: REQUIRED_PR_CHECKS_WORKFLOW });
|
|
1469
|
+
await options.seen?.(runs, verdict);
|
|
1431
1470
|
if (verdict.state === "failed") {
|
|
1432
1471
|
throw new Stop("ci-failed", `Pull request checks already failed on ${sha}: ${verdict.failed.join(", ")}. ${nothing}. Fix the branch, then release the new commit with \`toolroll release ${j.branch} --repo ${j.checkout} --new\` (or rerun the failed job on GitHub and run this command again).`);
|
|
1433
1472
|
}
|
|
@@ -1616,6 +1655,6 @@ export function workflowIs(path, file) {
|
|
|
1616
1655
|
return /^[A-Za-z0-9._+/-]+$/.test(ref) && !/\.ya?ml$/i.test(ref);
|
|
1617
1656
|
}
|
|
1618
1657
|
/** The words `toolroll release` reads as its own (release-cli.ts), never as a branch: such a branch is named with --branch. */
|
|
1619
|
-
const RELEASE_WORDS = new Set(["status", "nightly", "carry", "revoke", "ask-every-commit", "unsandboxed-carry", "auto-approve", "publish-ok"]);
|
|
1658
|
+
const RELEASE_WORDS = new Set(["status", "nightly", "carry", "revoke", "ask-every-commit", "unsandboxed-carry", "auto-approve", "publish-ok", "approve"]);
|
|
1620
1659
|
/** How a person names this branch to `toolroll release` again. */
|
|
1621
1660
|
export function releaseBranchArg(branch) { return RELEASE_WORDS.has(branch) ? `--branch ${branch}` : branch; }
|
package/dist/result-acts.d.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* report and changes disagree,
|
|
12
12
|
* or the proof is refuted → Request changes, then Accept (as allowed);
|
|
13
13
|
* never Accept as ink, even with no form here
|
|
14
|
-
* a Needs you form resolves it → that form (Build again,
|
|
14
|
+
* a Needs you form resolves it → that form (Build again, Check again)
|
|
15
15
|
* saved notes or a failing PR → Revise / Draft a repair task
|
|
16
16
|
* a failed build → Retry (its note starts with what to change), then Run checks when they
|
|
17
17
|
* didn't run; never Accept: a failed build is not a result to accept
|
|
@@ -37,7 +37,7 @@ export declare function evidenceProblemOf(verdict: ProofVerdict | null, reasons:
|
|
|
37
37
|
* when nothing refutes it (a person's acceptance settles it). The result page
|
|
38
38
|
* shows it before Request changes, its one ink act. */
|
|
39
39
|
export declare function cantAcceptYetOf(verdict: ProofVerdict | null, reasons: readonly string[], accepted: boolean): string | null;
|
|
40
|
-
export type ResultActKind = "retry" | "accept" | "accept-anyway" | "next-check" | "checks-running" | "run-checks" | "request-changes" | "rebuild" | "
|
|
40
|
+
export type ResultActKind = "retry" | "accept" | "accept-anyway" | "next-check" | "checks-running" | "run-checks" | "request-changes" | "rebuild" | "recheck-stopped" | "revise" | "draft-repair";
|
|
41
41
|
export type ResultActFacts = {
|
|
42
42
|
/** An Accept this page can post (Accept and finish, or the person's acceptance alone); `ready`: every requirement met and checks passed. */
|
|
43
43
|
accept: {
|
|
@@ -52,7 +52,7 @@ export type ResultActFacts = {
|
|
|
52
52
|
/** The feedback form is on this page. */
|
|
53
53
|
canRequest: boolean;
|
|
54
54
|
/** A Needs you form that resolves it here. */
|
|
55
|
-
need: "rebuild" | "
|
|
55
|
+
need: "rebuild" | "recheck-stopped" | null;
|
|
56
56
|
/** Saved notes ready to become a revision, or a failing pull request's repair. */
|
|
57
57
|
next: "revise" | "draft-repair" | null;
|
|
58
58
|
/** A failed build: `retry` when the task can be retried from here; `acceptAnyway` when it delivered a result that
|
package/dist/result-acts.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* report and changes disagree,
|
|
12
12
|
* or the proof is refuted → Request changes, then Accept (as allowed);
|
|
13
13
|
* never Accept as ink, even with no form here
|
|
14
|
-
* a Needs you form resolves it → that form (Build again,
|
|
14
|
+
* a Needs you form resolves it → that form (Build again, Check again)
|
|
15
15
|
* saved notes or a failing PR → Revise / Draft a repair task
|
|
16
16
|
* a failed build → Retry (its note starts with what to change), then Run checks when they
|
|
17
17
|
* didn't run; never Accept: a failed build is not a result to accept
|
package/dist/server/guards.js
CHANGED
|
@@ -176,6 +176,7 @@ export function createSharedGuards(runtime) {
|
|
|
176
176
|
const task = matchTaskPath(url.pathname, '(?:/[a-z-]+)?$');
|
|
177
177
|
return task === null ? null : runtime.store.lookupRef(task.taskId)?.repo ?? null;
|
|
178
178
|
}
|
|
179
|
+
case 'task-ref': return Number.isSafeInteger(id) && id > 0 ? runtime.store.refForId(id)?.repo ?? null : null;
|
|
179
180
|
case 'run': return runRepo(id);
|
|
180
181
|
case 'decision': return runRepo(runtime.store.getDecision(id)?.run);
|
|
181
182
|
case 'incident': return runRepo(runtime.store.openIncidents().find(one => one.id === id)?.run);
|
|
@@ -39,7 +39,7 @@ const PWA_MANIFEST = JSON.stringify({
|
|
|
39
39
|
// paths — the payload URL is data, revalidated, never handed raw to the
|
|
40
40
|
// browser (arc 3 finding 5).
|
|
41
41
|
const PWA_WORKER = `// Toolroll — push only; deliberately NO fetch handler (no offline cache of an authenticated console).
|
|
42
|
-
const SHAPES = [/^\\/next$/, /^\\/review$/, /^\\/system$/, /^\\/routines$/, /^\\/routines\\/[0-9]+$/, /^\\/d\\/[0-9]+$/, /^\\/r\\/[0-9]+$/];
|
|
42
|
+
const SHAPES = [/^\\/next$/, /^\\/review$/, /^\\/system$/, /^\\/routines$/, /^\\/routines\\/[0-9]+$/, /^\\/d\\/[0-9]+$/, /^\\/r\\/[0-9]+$/, /^\\/approve\\/[0-9]+$/];
|
|
43
43
|
self.addEventListener("push", function (event) {
|
|
44
44
|
var data = {};
|
|
45
45
|
try { data = event.data ? event.data.json() : {}; } catch (e) {}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ApprovalFacts } from "../design/kit.js";
|
|
2
|
+
import { type ReleaseApprovalCard } from "../release-approval.js";
|
|
3
|
+
import { type Chrome, type Screen } from "./chrome.js";
|
|
4
|
+
export type ReleaseSheetData = {
|
|
5
|
+
card: ReleaseApprovalCard;
|
|
6
|
+
/** The single-use approval nonce for this person, task and card digest ("" when nothing can be approved here). */
|
|
7
|
+
nonce: string;
|
|
8
|
+
facts: ApprovalFacts;
|
|
9
|
+
/** This computer's local owner: releases need a full sign-in first (scope.ts localApprovalEligible). */
|
|
10
|
+
local: {
|
|
11
|
+
message: string;
|
|
12
|
+
action: string;
|
|
13
|
+
href: string;
|
|
14
|
+
} | null;
|
|
15
|
+
problem: string | null;
|
|
16
|
+
};
|
|
17
|
+
export declare function releaseSheetPage(chrome: Chrome, data: ReleaseSheetData): Screen;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The release approval sheet (docs/plans/trusted-release-reports.md, slice A, A7): one screen with what the person is
|
|
3
|
+
* approving, GitHub CI now, what approving does, what changed and every term, then one Approve release button. The
|
|
4
|
+
* page only shows: the approval POST (`/t/<id>/approve`, `sheet=release`) proves the person, the card and the scope
|
|
5
|
+
* again (release-approval.ts approveReleaseCard).
|
|
6
|
+
*/
|
|
7
|
+
import { html, joinHtml } from "../html.js";
|
|
8
|
+
import { ciWords, releaseConsequence } from "../release-approval.js";
|
|
9
|
+
import { checkAccessWords } from "../check-access.js";
|
|
10
|
+
import { projectName } from "../project.js";
|
|
11
|
+
import { screen } from "./chrome.js";
|
|
12
|
+
import { approvalTerms, approveForm, chip, disclose, doc, docHeader, facts, infoLine, note, notice, slots, btn } from "./kit.js";
|
|
13
|
+
import { taskHref } from "./http.js";
|
|
14
|
+
/** The change list, folded: every commit when it is whole, or why it isn't. */
|
|
15
|
+
function changesHtml(card) {
|
|
16
|
+
const changes = card.card.changes;
|
|
17
|
+
const id = card.seal.identity;
|
|
18
|
+
if (changes.commits === null) {
|
|
19
|
+
return note(changes.baselineUnreadable === true ? "What changed couldn't be read: the last verified release's commit can't be read."
|
|
20
|
+
: `What changed couldn't be read${changes.why === undefined ? "" : ` (${changes.why})`}. See pull request #${id.pr} on GitHub.`);
|
|
21
|
+
}
|
|
22
|
+
const head = changes.baseline === null ? `Pull request #${id.pr}'s commits` : `Since the last verified release (${changes.baseline.slice(0, 7)})`;
|
|
23
|
+
const list = changes.commits.length === 0 ? note("No commits.")
|
|
24
|
+
: html `<ul class="k-list">${changes.commits.map(one => html `<li><code class="k-mono">${one.sha.slice(0, 7)}</code> ${one.subject}</li>`)}</ul>`;
|
|
25
|
+
return disclose(`What changed: ${changes.commits.length} commit${changes.commits.length === 1 ? "" : "s"}`, html `<p class="k-note">${head}</p>${list}`);
|
|
26
|
+
}
|
|
27
|
+
/** Every signed term, before the button. */
|
|
28
|
+
function termsHtml(card) {
|
|
29
|
+
return disclose("Every term", facts(card.terms.map(line => [line.label, html `<span class="k-mono">${line.value}</span>`])), true);
|
|
30
|
+
}
|
|
31
|
+
export function releaseSheetPage(chrome, data) {
|
|
32
|
+
const card = data.card;
|
|
33
|
+
const id = card.seal.identity;
|
|
34
|
+
const title = `Release ${id.packageVersion}`;
|
|
35
|
+
const summary = facts([
|
|
36
|
+
["Pull request", `#${id.pr}`],
|
|
37
|
+
["Branch", html `<code class="k-mono">${id.branch}</code>`],
|
|
38
|
+
["Commit", html `<code class="k-mono">${id.approvedSha}</code>`],
|
|
39
|
+
["Project", projectName(id.repo)],
|
|
40
|
+
["Checks", checkAccessWords(id.access)],
|
|
41
|
+
]);
|
|
42
|
+
const consequence = releaseConsequence(card);
|
|
43
|
+
const ci = ciWords(card.ci, id.approvedSha);
|
|
44
|
+
const notRunning = card.stopped === null ? "" : infoLine(html `The release isn't running (it stopped at ${card.stopped.step}). After you approve, run <code class="k-mono">toolroll release ${id.branch}</code>.`);
|
|
45
|
+
let status, text, acts, terms, wait = false;
|
|
46
|
+
if (card.approved !== null) {
|
|
47
|
+
status = html `${chip("Approved", "done")}<h2>Release approved</h2>`;
|
|
48
|
+
text = `Approved by ${card.approved.by}. ${ci}`;
|
|
49
|
+
acts = btn("Open the task", { variant: "outline", href: taskHref(card.taskId) });
|
|
50
|
+
terms = "";
|
|
51
|
+
}
|
|
52
|
+
else if (card.refusal !== null) {
|
|
53
|
+
status = html `${chip("Can't approve", "failed")}<h2>Can't approve this release now</h2>`;
|
|
54
|
+
text = card.refusal;
|
|
55
|
+
acts = btn("Open the task", { variant: "outline", href: taskHref(card.taskId) });
|
|
56
|
+
terms = "";
|
|
57
|
+
}
|
|
58
|
+
else if (data.local !== null) {
|
|
59
|
+
wait = true;
|
|
60
|
+
status = html `${chip("Needs you", "needs")}<h2>Ready for your approval</h2>`;
|
|
61
|
+
text = `${ci} Releases need a full sign-in on this computer.`;
|
|
62
|
+
acts = btn(data.local.action, { variant: "primary", href: data.local.href });
|
|
63
|
+
terms = data.local.message;
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
wait = true;
|
|
67
|
+
status = html `${chip("Needs you", "needs")}<h2>Ready for your approval</h2>`;
|
|
68
|
+
text = ci;
|
|
69
|
+
acts = data.nonce === "" ? "" : approveForm({
|
|
70
|
+
action: `${taskHref(card.taskId)}/approve`, kind: "standard", facts: data.facts, label: "Approve release", primary: true,
|
|
71
|
+
before: html `<section class="k-sec"><h3>What approving does</h3>${joinHtml(consequence.map(line => html `<p class="k-note">${line}</p>`))}${termsHtml(card)}</section>`,
|
|
72
|
+
form: { hidden: { nonce: data.nonce, digest: card.digest, sheet: "release" }, attrs: { class: "k-form", "data-release-approve": card.digest } },
|
|
73
|
+
});
|
|
74
|
+
terms = approvalTerms("standard", data.facts);
|
|
75
|
+
}
|
|
76
|
+
// What, then what changed, then the one decision: its status, CI now, what approving does, every term, the button.
|
|
77
|
+
const formShown = acts !== "" && data.nonce !== "" && card.approved === null && card.refusal === null && data.local === null;
|
|
78
|
+
const body = doc(docHeader({ title, back: [taskHref(card.taskId), "Task"], meta: `${projectName(id.repo)} · pull request #${id.pr}` }),
|
|
79
|
+
// A refused approval says why first, above the card it now shows.
|
|
80
|
+
data.problem === null ? "" : notice(data.problem), summary, changesHtml(card), slots({ wait, attrs: { "data-release-sheet": card.taskId }, status, text, acts, terms }), notRunning,
|
|
81
|
+
// When the form isn't shown, the consequence and the terms are still on the page.
|
|
82
|
+
formShown ? "" : html `<section class="k-sec"><h3>What approving does</h3>${joinHtml(consequence.map(line => html `<p class="k-note">${line}</p>`))}</section>${termsHtml(card)}`);
|
|
83
|
+
return screen(title, body, { chrome });
|
|
84
|
+
}
|