toolroll 0.9.74 → 0.9.76
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 +80 -0
- package/dist/agent-fence.js +8 -0
- package/dist/approval-authority.js +6 -2
- package/dist/approval-rules-ui.d.ts +11 -0
- package/dist/approval-rules-ui.js +13 -1
- package/dist/assignment.d.ts +3 -0
- package/dist/assignment.js +27 -8
- package/dist/batch-checks.js +3 -1
- package/dist/browser/workspace.js +27 -27
- package/dist/browser-workspace.d.ts +1 -1
- package/dist/builder.js +1 -1
- package/dist/chat-acceptance.d.ts +4 -1
- package/dist/chat-acceptance.js +13 -3
- package/dist/cli.js +1 -1
- package/dist/coding-handoff.js +4 -3
- package/dist/coding-ui.js +2 -2
- package/dist/coding-workspace.js +23 -9
- package/dist/contracts/console-api.d.ts +6 -1
- package/dist/contracts/console-api.js +11 -0
- package/dist/control-ui.d.ts +3 -2
- package/dist/control-ui.js +7 -2
- package/dist/demo.js +5 -3
- package/dist/deploy-gate.d.ts +3 -0
- package/dist/deploy-gate.js +3 -2
- package/dist/desktop-update.d.ts +4 -1
- package/dist/desktop-update.js +43 -9
- package/dist/discover.js +6 -1
- package/dist/first-run.d.ts +8 -5
- package/dist/first-run.js +42 -7
- package/dist/flow-card-task.js +3 -2
- package/dist/flow-unattended.d.ts +2 -0
- package/dist/flow-unattended.js +12 -1
- package/dist/git-incident.d.ts +49 -0
- package/dist/git-incident.js +69 -0
- package/dist/git-views.d.ts +20 -0
- package/dist/git-views.js +120 -0
- package/dist/grant.js +3 -1
- package/dist/integrations.d.ts +4 -3
- package/dist/integrations.js +5 -6
- package/dist/lead-contract.d.ts +1 -1
- package/dist/lead-contract.js +2 -2
- package/dist/lead-follow.js +3 -1
- package/dist/openrouter-models.d.ts +5 -2
- package/dist/openrouter-models.js +7 -4
- package/dist/operate.js +9 -1
- package/dist/planner.js +5 -1
- package/dist/project-knowledge.js +1 -1
- package/dist/project-learning.js +65 -7
- package/dist/project-tools.js +19 -1
- package/dist/pull-request-flow.js +4 -0
- package/dist/pull-request-ui.d.ts +1 -1
- package/dist/pull-request-ui.js +5 -1
- package/dist/push.d.ts +2 -0
- package/dist/push.js +24 -19
- package/dist/read-only-request.d.ts +4 -0
- package/dist/read-only-request.js +15 -0
- package/dist/release-adapters.d.ts +24 -2
- package/dist/release-adapters.js +136 -10
- package/dist/release-authority.d.ts +46 -0
- package/dist/release-authority.js +64 -0
- package/dist/release-auto.d.ts +153 -0
- package/dist/release-auto.js +496 -0
- package/dist/release-cli.js +56 -2
- package/dist/release-coverage.js +1 -1
- package/dist/release.d.ts +105 -1
- package/dist/release.js +229 -9
- package/dist/repository-context.js +5 -2
- package/dist/result-follow-ups.js +23 -2
- package/dist/result-review.js +5 -1
- package/dist/scope.d.ts +31 -2
- package/dist/scope.js +44 -3
- package/dist/scout.js +5 -1
- package/dist/serve.js +162 -6
- package/dist/server/agents-live-filed.d.ts +17 -0
- package/dist/server/agents-live-filed.js +97 -0
- package/dist/server/agents-live-room.js +4 -1
- package/dist/server/chat.js +31 -23
- package/dist/server/http.d.ts +5 -0
- package/dist/server/last-checks.d.ts +17 -0
- package/dist/server/last-checks.js +22 -0
- package/dist/server/pages.js +18 -4
- package/dist/server/people-tokens.js +7 -4
- package/dist/server/render-pages.d.ts +2 -1
- package/dist/server/render-pages.js +28 -24
- package/dist/server/render-results.d.ts +11 -2
- package/dist/server/render-results.js +54 -12
- package/dist/server/render-settings.js +3 -3
- package/dist/server/render-tasks.d.ts +10 -0
- package/dist/server/render-tasks.js +30 -17
- package/dist/server/route-table.js +17 -1
- package/dist/server/runtime.d.ts +16 -1
- package/dist/server/settings.js +111 -31
- package/dist/server/task-views.js +8 -1
- package/dist/server/tasks.js +11 -2
- package/dist/storage-ui.d.ts +2 -0
- package/dist/storage-ui.js +1 -1
- package/dist/storage.js +2 -1
- package/dist/store.d.ts +4 -1
- package/dist/store.js +52 -13
- package/dist/subagent-work.js +3 -0
- package/dist/task-base.js +1 -1
- package/dist/task-status.d.ts +28 -1
- package/dist/task-status.js +93 -11
- package/dist/teardown-git.d.ts +59 -5
- package/dist/teardown-git.js +1022 -90
- package/dist/telegram-progress.js +4 -4
- package/dist/telegram-status.js +8 -3
- package/dist/test-infra.d.ts +26 -0
- package/dist/test-infra.js +321 -0
- package/dist/tool-secret-store.js +8 -0
- package/dist/toolroll-update-ui.d.ts +2 -0
- package/dist/toolroll-update-ui.js +4 -3
- package/dist/toolroll-update.js +10 -9
- package/dist/work-index.js +29 -12
- package/dist/workspace-ui.d.ts +5 -0
- package/dist/workspace-ui.js +38 -10
- package/dist/worktree.d.ts +4 -0
- package/dist/worktree.js +13 -7
- package/package.json +1 -1
package/dist/planner.js
CHANGED
|
@@ -20,6 +20,7 @@ import { unlinkSync } from "node:fs";
|
|
|
20
20
|
import { homedir } from "node:os";
|
|
21
21
|
import { join } from "node:path";
|
|
22
22
|
import { run } from "./exec.js";
|
|
23
|
+
import { checkoutGit } from "./teardown-git.js";
|
|
23
24
|
import { auditOf, isProviderId } from "./provider.js";
|
|
24
25
|
import { currentClaim, heartbeat } from "./claim.js";
|
|
25
26
|
import { heartbeat as runnerHeartbeat } from "./runner.js";
|
|
@@ -491,7 +492,10 @@ function draftedOutcome(root, runId, plan, repairRunId = null) {
|
|
|
491
492
|
return { ok: true, drafted: { plan, artifact, repairRunId } };
|
|
492
493
|
}
|
|
493
494
|
export async function plan(store, request) {
|
|
494
|
-
const { taskId, taskRef, runner, worktree, branch, now, agent, git = run, timeoutMs = DEFAULT_PLAN_TIMEOUT_MS, maxTurns = DEFAULT_PLAN_TURNS, } = request;
|
|
495
|
+
const { taskId, taskRef, runner, worktree, branch, now, agent, git: givenGit = run, timeoutMs = DEFAULT_PLAN_TIMEOUT_MS, maxTurns = DEFAULT_PLAN_TURNS, } = request;
|
|
496
|
+
// The plane's git on the plan's checkout (agent-written, and its repository shared with every build) runs hardened, against
|
|
497
|
+
// a frozen view of the repository's config and info (teardown-git.ts checkoutGit); an injected runner is used as given.
|
|
498
|
+
const git = givenGit === run ? checkoutGit(run, "build", { repo: store.getWorktree(worktree)?.repo ?? null }) : givenGit;
|
|
495
499
|
// An ACP agent works with full access inside the fence: it builds and repairs, and is never handed a plan (routing refuses it first).
|
|
496
500
|
if (request.provider !== undefined && isAcpProvider(request.provider)) {
|
|
497
501
|
return { ok: false, kind: "failure", reason: "provider-unsupported", message: `${ACP_AGENTS[request.provider].name} builds and repairs only — configure the planner to claude, codex or gemini` };
|
|
@@ -35,7 +35,7 @@ CREATE TRIGGER IF NOT EXISTS knowledge_snapshot_no_update BEFORE UPDATE ON knowl
|
|
|
35
35
|
CREATE TRIGGER IF NOT EXISTS knowledge_snapshot_no_delete BEFORE DELETE ON knowledge_snapshot BEGIN SELECT RAISE(ABORT,'Knowledge context is immutable'); END;
|
|
36
36
|
`;
|
|
37
37
|
const EMPTY = { instructions: '', references: [] };
|
|
38
|
-
const git = (repo, args) => execFileSync('git', ['--no-optional-locks', '-C', repo, ...args], { encoding: 'utf8', maxBuffer: 200_000, stdio: ['ignore', 'pipe', 'pipe'] }).trimEnd();
|
|
38
|
+
const git = (repo, args) => execFileSync('git', ['--no-optional-locks', '--no-lazy-fetch', '-c', 'core.fsmonitor=false', '-c', 'core.hooksPath=/dev/null', '-C', repo, ...args], { encoding: 'utf8', maxBuffer: 200_000, stdio: ['ignore', 'pipe', 'pipe'] }).trimEnd();
|
|
39
39
|
function clean(value, bytes) {
|
|
40
40
|
if (Buffer.byteLength(value) > bytes || /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f\ufffd]/u.test(value))
|
|
41
41
|
throw Error('This text is too long or is not readable text.');
|
package/dist/project-learning.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/** Optional, project-local advice. Core review and approval never depend on it. */
|
|
2
|
+
import { inReadOnlyRequest } from "./read-only-request.js";
|
|
3
|
+
import { checkoutGitSync } from './teardown-git.js';
|
|
2
4
|
import { createHash } from 'node:crypto';
|
|
3
5
|
import { execFileSync } from 'node:child_process';
|
|
4
|
-
import { realpathSync, statSync } from 'node:fs';
|
|
5
|
-
import { resolve } from 'node:path';
|
|
6
|
+
import { closeSync, constants as fsConstants, existsSync, fstatSync, lstatSync, openSync, readSync, realpathSync, statSync } from 'node:fs';
|
|
7
|
+
import { join, resolve } from 'node:path';
|
|
6
8
|
import { readVerifiedArtifact, scanForSecrets } from './evidence.js';
|
|
7
9
|
import { normalizeStructuredJson } from "./structured-output.js";
|
|
8
10
|
import { parseReviewContext, reviewContextCustodyProblem } from "./review-context.js";
|
|
@@ -50,7 +52,9 @@ const git = (repo, args, seen) => {
|
|
|
50
52
|
const known = seen?.get(key);
|
|
51
53
|
if (known !== undefined)
|
|
52
54
|
return known;
|
|
53
|
-
|
|
55
|
+
// Bounded, and no hook or fsmonitor program: it also runs from the console's background pass.
|
|
56
|
+
// No lazy fetch either: a partial clone's fetch runs whatever transport command the shared config names.
|
|
57
|
+
const out = execFileSync('git', ['--no-optional-locks', '--no-lazy-fetch', '-c', 'core.fsmonitor=false', '-c', 'core.hooksPath=/dev/null', '-C', repo, ...args], { encoding: 'utf8', maxBuffer: 2 * 1024 * 1024, timeout: 10_000, stdio: ['ignore', 'pipe', 'pipe'] }).trimEnd();
|
|
54
58
|
seen?.set(key, out);
|
|
55
59
|
return out;
|
|
56
60
|
};
|
|
@@ -62,9 +66,42 @@ export const legacyIdentityOf = (repo, common, st) => learningSha(JSON.stringify
|
|
|
62
66
|
function identityFacts(repo, seen) {
|
|
63
67
|
if (canonicalProject(repo) !== repo)
|
|
64
68
|
throw new Error('Project identity changed.');
|
|
65
|
-
|
|
69
|
+
// A page visit starts no git (docs/plans/get-side-effects.md): it reads the same answer from the folder itself.
|
|
70
|
+
const common = realpathSync(resolve(repo, inReadOnlyRequest() ? commonDirOnDisk(repo) : git(repo, ['rev-parse', '--git-common-dir'], seen)));
|
|
66
71
|
return { common, st: statSync(common) };
|
|
67
72
|
}
|
|
73
|
+
/** The shared Git folder as git finds it for a checkout: `.git` itself, or the folder a `.git` file names, through its
|
|
74
|
+
* `commondir` when it is a linked worktree. Throws when the folder holds neither. */
|
|
75
|
+
function commonDirOnDisk(repo) {
|
|
76
|
+
const dotGit = join(repo, '.git');
|
|
77
|
+
if (lstatSync(dotGit).isDirectory())
|
|
78
|
+
return dotGit;
|
|
79
|
+
const named = /^gitdir: (.+)$/m.exec(smallFile(dotGit))?.[1]?.trim();
|
|
80
|
+
if (!named)
|
|
81
|
+
throw new Error('Project identity is unavailable.');
|
|
82
|
+
const gitDir = resolve(repo, named);
|
|
83
|
+
if (!lstatSync(gitDir).isDirectory())
|
|
84
|
+
throw new Error('Project identity is unavailable.');
|
|
85
|
+
const commondir = join(gitDir, 'commondir');
|
|
86
|
+
if (!existsSync(commondir))
|
|
87
|
+
return gitDir;
|
|
88
|
+
return resolve(gitDir, smallFile(commondir).trim());
|
|
89
|
+
}
|
|
90
|
+
/** A small plain file's text, read through one descriptor that follows no link and never waits (a FIFO, a device or a
|
|
91
|
+
* file swapped in between is refused, never read), at most 4 KiB. */
|
|
92
|
+
function smallFile(path) {
|
|
93
|
+
const fd = openSync(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK);
|
|
94
|
+
try {
|
|
95
|
+
const st = fstatSync(fd);
|
|
96
|
+
if (!st.isFile() || st.size > 4096)
|
|
97
|
+
throw new Error('Project identity is unavailable.');
|
|
98
|
+
const buffer = Buffer.alloc(4096);
|
|
99
|
+
return buffer.subarray(0, readSync(fd, buffer, 0, 4096, 0)).toString('utf8');
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
closeSync(fd);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
68
105
|
export function learningIdentity(repo, seen) {
|
|
69
106
|
const { common, st } = identityFacts(repo, seen);
|
|
70
107
|
return identityOf(repo, common, st);
|
|
@@ -114,7 +151,8 @@ export function reconcileIdentity(store, repo, seen, now = new Date()) {
|
|
|
114
151
|
if (!done)
|
|
115
152
|
reconciled.set(store, done = new Set());
|
|
116
153
|
const key = `${repo}\n${legacy}`;
|
|
117
|
-
|
|
154
|
+
// Carrying saved rows to the current identity is a write: never a page visit's (the background pass does it).
|
|
155
|
+
if (legacy !== current && !done.has(key) && !inReadOnlyRequest()) {
|
|
118
156
|
if (!store.handle.prepare('SELECT 1 FROM project_identity_carry WHERE repo=? AND from_identity=? AND to_identity=?').get(repo, legacy, current)
|
|
119
157
|
&& store.handle.prepare(STORED_IDENTITY).get(repo, legacy)) {
|
|
120
158
|
// Damaged knowledge history keeps its own error; retry on the next read.
|
|
@@ -413,7 +451,9 @@ function supported(store, root, lesson, seen) {
|
|
|
413
451
|
}
|
|
414
452
|
export function learningView(store, root, repo, actor, before = 0) {
|
|
415
453
|
admission(store, repo, actor);
|
|
416
|
-
|
|
454
|
+
// Recovering learning from reviewed runs writes: the console's background pass and each run's start do it.
|
|
455
|
+
if (!inReadOnlyRequest())
|
|
456
|
+
recoverLearning(store, root, repo);
|
|
417
457
|
const identity = learningIdentity(repo), accepted = acceptedIdentities(store, repo);
|
|
418
458
|
const p = store.handle.prepare('SELECT * FROM learning_policy WHERE repo=?').get(repo);
|
|
419
459
|
const revision = Number(p?.['revision'] ?? 0), enabled = accepted.has(String(p?.['identity'])) && p?.['enabled'] === 1;
|
|
@@ -504,6 +544,24 @@ export function learningContext(store, root, runId, phase, now = new Date(), val
|
|
|
504
544
|
// advice may inform its proposal; only an approval can authorize a build.
|
|
505
545
|
const unscopedPlan = phase === 'plan' && scope === null && !run.scopeDigest;
|
|
506
546
|
const eligible = [];
|
|
547
|
+
// The checkout's status, read once for every lesson (one command on one checkout): a hardened read costs a
|
|
548
|
+
// private view and several git processes, and this runs inside the transaction. A failed read fails every lesson
|
|
549
|
+
// that reaches it, as each read alone would have.
|
|
550
|
+
let status = null;
|
|
551
|
+
const statusLines = (cwd) => {
|
|
552
|
+
status ??= (() => {
|
|
553
|
+
// A build's checkout: status runs filters, so the hardened read (teardown-git.ts checkoutGitSync).
|
|
554
|
+
try {
|
|
555
|
+
return { lines: checkoutGitSync(cwd, ['status', '--porcelain', '--untracked-files=all'], { timeoutMs: 10_000, maxBuffer: 2 * 1024 * 1024 }).trimEnd().split('\n') };
|
|
556
|
+
}
|
|
557
|
+
catch (error) {
|
|
558
|
+
return { error };
|
|
559
|
+
}
|
|
560
|
+
})();
|
|
561
|
+
if ('error' in status)
|
|
562
|
+
throw status.error;
|
|
563
|
+
return status.lines;
|
|
564
|
+
};
|
|
507
565
|
if (policy && (paths.length > 0 || unscopedPlan)) {
|
|
508
566
|
for (const row of store.handle.prepare("SELECT * FROM project_lesson WHERE repo=? AND identity IN (SELECT value FROM json_each(?)) AND status='adopted' ORDER BY id DESC LIMIT 100").all(repo, carried)) {
|
|
509
567
|
try {
|
|
@@ -514,7 +572,7 @@ export function learningContext(store, root, runId, phase, now = new Date(), val
|
|
|
514
572
|
if (l.payload.environment !== environmentFingerprint(store, repo) || learningFingerprint(repo, head, l.payload.paths, seen) !== l.payload.fingerprint)
|
|
515
573
|
continue;
|
|
516
574
|
const cwd = run.worktree ?? repo;
|
|
517
|
-
const dirty =
|
|
575
|
+
const dirty = statusLines(cwd).some(line => { const p = line.slice(3); return configPath(p) || l.payload.paths.includes(p); });
|
|
518
576
|
if (!dirty)
|
|
519
577
|
eligible.push(l);
|
|
520
578
|
}
|
package/dist/project-tools.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
* the agent. A tool connected by signing in gives a run only an access token
|
|
20
20
|
* of its own (oauth-run-token.ts).
|
|
21
21
|
*/
|
|
22
|
+
import { inReadOnlyRequest } from "./read-only-request.js";
|
|
22
23
|
import { toolRefusal } from "./policy.js";
|
|
23
24
|
import { createHash } from "node:crypto";
|
|
24
25
|
import { spawn } from "node:child_process";
|
|
@@ -319,6 +320,23 @@ function moveIn(backend, folder, repo, tool) {
|
|
|
319
320
|
export function readToolSecrets(repo, tool, state) {
|
|
320
321
|
const folder = secretFolder(state);
|
|
321
322
|
const backend = secretBackend();
|
|
323
|
+
// A page visit moves nothing (docs/plans/get-side-effects.md): it reads the store, else the file not yet moved in.
|
|
324
|
+
if (inReadOnlyRequest()) {
|
|
325
|
+
const stored = readStored(backend, folder, repo, tool);
|
|
326
|
+
if (stored !== null)
|
|
327
|
+
return stored;
|
|
328
|
+
for (const file of [legacyFile(folder, repo, tool), backend === null ? null : toolFile(folder, repo, tool, ".json")]) {
|
|
329
|
+
if (file === null || !existsSync(file))
|
|
330
|
+
continue;
|
|
331
|
+
try {
|
|
332
|
+
const values = readSecretFile(file);
|
|
333
|
+
if (values !== null)
|
|
334
|
+
return values;
|
|
335
|
+
}
|
|
336
|
+
catch { /* kept as it is, and never used */ }
|
|
337
|
+
}
|
|
338
|
+
return {};
|
|
339
|
+
}
|
|
322
340
|
moveIn(backend, folder, repo, tool);
|
|
323
341
|
return readStored(backend, folder, repo, tool) ?? {};
|
|
324
342
|
}
|
|
@@ -1067,7 +1085,7 @@ export function secretsSetFor(repo, tool, state) {
|
|
|
1067
1085
|
if (names === null) {
|
|
1068
1086
|
const values = readToolSecrets(repo, tool.name, state);
|
|
1069
1087
|
names = Object.keys(values).filter(name => values[name] !== "");
|
|
1070
|
-
if (backend !== null)
|
|
1088
|
+
if (backend !== null && !inReadOnlyRequest())
|
|
1071
1089
|
writeNames(folder, repo, tool.name, names);
|
|
1072
1090
|
}
|
|
1073
1091
|
return tool.secrets.filter(one => names.includes(one.name)).map(one => one.name);
|
|
@@ -25,6 +25,7 @@ import { authenticateApprover, personProof } from "./scope.js";
|
|
|
25
25
|
import { BRANCH_PREFIX, headWithin } from "./names.js";
|
|
26
26
|
import { NIGHTLY_CHECK_ONLY, RELEASE_MERGES_ONLY, nightlyTaskRef, releaseTaskRef } from "./store.js";
|
|
27
27
|
import { belongsToRelease } from "./release-authority.js";
|
|
28
|
+
import { autoReleaseSealed } from "./release-auto.js";
|
|
28
29
|
import { fullCheckGate, requestFollowUpChecks } from "./result-follow-ups.js";
|
|
29
30
|
export const MERGE_METHODS = ["squash", "merge", "rebase"];
|
|
30
31
|
/** CI revisions one task may file across its revisions before a person is asked instead. */
|
|
@@ -402,6 +403,9 @@ export async function mergePullRequest(store, input) {
|
|
|
402
403
|
// the merge at its lease's epoch and checks the exact pull request: never here, by hand or automatically.
|
|
403
404
|
if (mergedTask !== null && belongsToRelease(store, mergedTask))
|
|
404
405
|
return refuse("release", "This result belongs to a release; it merges only through `toolroll release`.");
|
|
406
|
+
// A release task the plane approved (or is checking) automatically (auto-approve-green.md) never merges here either.
|
|
407
|
+
if (mergedTask !== null && autoReleaseSealed(store, mergedTask))
|
|
408
|
+
return refuse("release", "This result was approved automatically for a release; it merges only through `toolroll release`.");
|
|
405
409
|
const publication = store.publicationForRun(input.runId);
|
|
406
410
|
const follow = publication === null ? null : followOf(store, publication.id);
|
|
407
411
|
if (publication === null || follow === null || publication.prNumber === null)
|
package/dist/pull-request-ui.js
CHANGED
|
@@ -22,8 +22,12 @@ export function pullRequestSettingsHtml(view) {
|
|
|
22
22
|
const check = view.check;
|
|
23
23
|
if (check === null)
|
|
24
24
|
return html `${head}<section class="card pr-settings"><p><strong>Off</strong></p><p class="meta">An approver can turn on pull requests.</p></section>`;
|
|
25
|
+
// The checks ask git and GitHub, so they run when asked, never on opening the page (docs/plans/get-side-effects.md).
|
|
26
|
+
const checkButton = (label) => postForm("/settings/pull-requests/check", html `<button type="submit"${label === "Check again" ? html ` class="secondary"` : ""}>${label}</button>`, { hidden: { repo: view.repo } });
|
|
27
|
+
if (check === "unchecked")
|
|
28
|
+
return html `${head}<section class="card pr-settings"><p><strong>Off</strong></p><p class="meta">Check this project's GitHub setup to see what turning on would do.</p>${checkButton("Check setup")}</section>`;
|
|
25
29
|
if (!check.ok) {
|
|
26
|
-
return html `${head}<section class="card pr-settings"><p><strong>Can’t turn on yet</strong></p><p>${check.message}</p
|
|
30
|
+
return html `${head}<section class="card pr-settings"><p><strong>Can’t turn on yet</strong></p><p>${check.message}</p>${checkButton("Check again")}</section>`;
|
|
27
31
|
}
|
|
28
32
|
const plan = check.plan;
|
|
29
33
|
return html `${head}${post("on", html `<p><strong>Ready to turn on</strong></p><ul class="pr-terms"><li>Pull requests open on <span class="mono">${plan.githubRepo}</span> into <span class="mono">${plan.base}</span>${plan.account === null ? "" : `, as ${plan.account}`}.</li><li>Complete offers “Complete and open a pull request”, from the exact completed commit. A flow's Pull request zone can open one too.</li><li>Merging squashes and deletes the branch. A person merges with their password; a flow merges only when its zone says so, after a person approved the card.</li></ul>${password}<button type="submit">Turn on pull requests</button>`, { github: plan.githubRepo, base: plan.base })}`;
|
package/dist/push.d.ts
CHANGED
|
@@ -31,6 +31,8 @@ export type VapidKeys = {
|
|
|
31
31
|
fingerprint: string;
|
|
32
32
|
};
|
|
33
33
|
export declare function vapidFingerprint(publicKey: string): string;
|
|
34
|
+
/** Load the saved pair only (null when none is saved yet): what a page reads (docs/plans/get-side-effects.md). */
|
|
35
|
+
export declare function loadVapidKeys(dir: string): VapidKeys | null;
|
|
34
36
|
/**
|
|
35
37
|
* Mint-or-load, atomically across processes (finding 29): exclusive
|
|
36
38
|
* creation; the loser of a race re-reads the winner's file; and a loaded
|
package/dist/push.js
CHANGED
|
@@ -35,6 +35,10 @@ const fromB64url = (text) => Buffer.from(text, "base64url");
|
|
|
35
35
|
export function vapidFingerprint(publicKey) {
|
|
36
36
|
return createHash("sha256").update(publicKey, "utf8").digest("hex").slice(0, 16);
|
|
37
37
|
}
|
|
38
|
+
/** Load the saved pair only (null when none is saved yet): what a page reads (docs/plans/get-side-effects.md). */
|
|
39
|
+
export function loadVapidKeys(dir) {
|
|
40
|
+
return readVapidKeys(join(dir, VAPID_FILE));
|
|
41
|
+
}
|
|
38
42
|
/**
|
|
39
43
|
* Mint-or-load, atomically across processes (finding 29): exclusive
|
|
40
44
|
* creation; the loser of a race re-reads the winner's file; and a loaded
|
|
@@ -43,25 +47,7 @@ export function vapidFingerprint(publicKey) {
|
|
|
43
47
|
*/
|
|
44
48
|
export function loadOrCreateVapidKeys(dir) {
|
|
45
49
|
const path = join(dir, VAPID_FILE);
|
|
46
|
-
const read = () =>
|
|
47
|
-
let raw;
|
|
48
|
-
try {
|
|
49
|
-
raw = readFileSync(path, "utf8");
|
|
50
|
-
}
|
|
51
|
-
catch {
|
|
52
|
-
return null;
|
|
53
|
-
}
|
|
54
|
-
const parsed = JSON.parse(raw);
|
|
55
|
-
if (typeof parsed.publicKey !== "string" || typeof parsed.privateKey !== "string") {
|
|
56
|
-
throw new Error(`${VAPID_FILE} is not a key file`);
|
|
57
|
-
}
|
|
58
|
-
const check = createECDH("prime256v1");
|
|
59
|
-
check.setPrivateKey(fromB64url(parsed.privateKey));
|
|
60
|
-
if (b64url(check.getPublicKey()) !== parsed.publicKey) {
|
|
61
|
-
throw new Error(`${VAPID_FILE} holds a mismatched key pair — refusing to sign with it`);
|
|
62
|
-
}
|
|
63
|
-
return { publicKey: parsed.publicKey, privateKey: parsed.privateKey, fingerprint: vapidFingerprint(parsed.publicKey) };
|
|
64
|
-
};
|
|
50
|
+
const read = () => readVapidKeys(path);
|
|
65
51
|
const existing = read();
|
|
66
52
|
if (existing !== null)
|
|
67
53
|
return existing;
|
|
@@ -87,6 +73,25 @@ export function loadOrCreateVapidKeys(dir) {
|
|
|
87
73
|
return winner;
|
|
88
74
|
}
|
|
89
75
|
}
|
|
76
|
+
function readVapidKeys(path) {
|
|
77
|
+
let raw;
|
|
78
|
+
try {
|
|
79
|
+
raw = readFileSync(path, "utf8");
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
const parsed = JSON.parse(raw);
|
|
85
|
+
if (typeof parsed.publicKey !== "string" || typeof parsed.privateKey !== "string") {
|
|
86
|
+
throw new Error(`${VAPID_FILE} is not a key file`);
|
|
87
|
+
}
|
|
88
|
+
const check = createECDH("prime256v1");
|
|
89
|
+
check.setPrivateKey(fromB64url(parsed.privateKey));
|
|
90
|
+
if (b64url(check.getPublicKey()) !== parsed.publicKey) {
|
|
91
|
+
throw new Error(`${VAPID_FILE} holds a mismatched key pair — refusing to sign with it`);
|
|
92
|
+
}
|
|
93
|
+
return { publicKey: parsed.publicKey, privateKey: parsed.privateKey, fingerprint: vapidFingerprint(parsed.publicKey) };
|
|
94
|
+
}
|
|
90
95
|
// ---- RFC 8291 encryption ----------------------------------------------------
|
|
91
96
|
/**
|
|
92
97
|
* The deterministic core, exported for the Appendix A vector test: given
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A page visit only reads (docs/plans/get-side-effects.md). The server runs every GET and HEAD inside this scope, and
|
|
3
|
+
* the few store reads that used to tidy up as they went (closing an operating mode past its expiry) check it and
|
|
4
|
+
* leave the tidying to the background pass and the paths that act.
|
|
5
|
+
*/
|
|
6
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
7
|
+
const scope = new AsyncLocalStorage();
|
|
8
|
+
/** Run a GET or HEAD request's work. */
|
|
9
|
+
export function asReadOnlyRequest(work) {
|
|
10
|
+
return scope.run(true, work);
|
|
11
|
+
}
|
|
12
|
+
/** Whether the current work answers a GET or HEAD request. */
|
|
13
|
+
export function inReadOnlyRequest() {
|
|
14
|
+
return scope.getStore() === true;
|
|
15
|
+
}
|
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
* Homebrew tap through `gh`. Each call is bounded; none asks for or stores a credential — `gh`, npm and Toolroll use
|
|
5
5
|
* the sign-ins this machine already has.
|
|
6
6
|
*/
|
|
7
|
-
import type { GateState, Installed, ReleaseAdapters } from "./release.js";
|
|
7
|
+
import type { CheckRun, GateState, Installed, ReleaseAdapters } from "./release.js";
|
|
8
8
|
import { Store } from "./store.js";
|
|
9
|
+
import { type CiObservation } from "./release-auto.js";
|
|
9
10
|
import { type BuildRunner } from "./release-carry.js";
|
|
10
11
|
import { type ReleaseRepo, type ReleaseTools } from "./release-git.js";
|
|
11
12
|
export { RELEASE_PATH, RELEASE_REMOTE, releaseConfigProblem, releaseGitArgs, releaseGitEnv, releaseRemote, releaseTools, type ReleaseRepo, type ReleaseTools } from "./release-git.js";
|
|
@@ -81,4 +82,25 @@ export declare function bumpFormula(text: string, url: string, sha256: string):
|
|
|
81
82
|
* The release's authority record through a store (release-once.md): what `toolroll release` reads and writes for its
|
|
82
83
|
* lease, its carries and its operations. `open` hands a store for each call and `done` lets it go.
|
|
83
84
|
*/
|
|
84
|
-
export declare function storeReleaseAdapter(open: () => Store, done: (store: Store) => void, now: () => Date, runner?: BuildRunner
|
|
85
|
+
export declare function storeReleaseAdapter(open: () => Store, done: (store: Store) => void, now: () => Date, runner?: BuildRunner,
|
|
86
|
+
/** Releases that approve themselves (auto-approve-green.md): the plane's own look at CI on a commit, and where the
|
|
87
|
+
* check receipts are kept. Absent: no automatic approval, every release asks a person. */
|
|
88
|
+
auto?: AutoObservers): NonNullable<ReleaseAdapters["release"]>;
|
|
89
|
+
/** What the plane itself reads for an automatic release approval. */
|
|
90
|
+
export type AutoObservers = {
|
|
91
|
+
observe: (sha: string) => Promise<CiObservation>;
|
|
92
|
+
/** GitHub's merge base of `sha` with the branch `base` of the recorded repository. */
|
|
93
|
+
mergeBase: (base: string, sha: string) => Promise<string>;
|
|
94
|
+
evidenceRoot: string;
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* Every check run GitHub holds for one commit — every attempt, not only the latest (`filter=all`; GitHub's default
|
|
98
|
+
* lists only the latest of each), every page — with its id, app, check suite and the commit it is filed under, oldest
|
|
99
|
+
* first. A row that doesn't parse stops the read: a partial answer is never taken for the whole.
|
|
100
|
+
*/
|
|
101
|
+
export declare function readCheckRuns(run: Exec, gh: string, repo: string, sha: string): Promise<CheckRun[]>;
|
|
102
|
+
/** GitHub lists check runs from at most the latest 1000 check suites of a commit: the history is whole only below that. */
|
|
103
|
+
export declare const CHECK_SUITE_CEILING = 1000;
|
|
104
|
+
export declare function readSuitesComplete(run: Exec, gh: string, repo: string, sha: string): Promise<boolean>;
|
|
105
|
+
/** GitHub's merge base of `sha` with branch `base` (the compare API), read by the plane. */
|
|
106
|
+
export declare function readMergeBase(run: Exec, gh: string, repo: string, base: string, sha: string): Promise<string>;
|
package/dist/release-adapters.js
CHANGED
|
@@ -19,7 +19,8 @@ import { BEFORE_SWAP, gateOwner } from "./release-gate.js";
|
|
|
19
19
|
import { findCliLinks, switchCliLinks } from "./release-links.js";
|
|
20
20
|
import { databasePath, finishReleaseOperation, requireFreshReleaseApproval, openStore, releaseAuthorityOfTask, releaseCarries, releaseOperations, renewReleaseLease, reserveRelease, runReleaseOperation, Store, releaseIssuerGone, takeReleaseLease, } from "./store.js";
|
|
21
21
|
import { acquireDeploymentLock } from "./deploy-lock.js";
|
|
22
|
-
import { liveReleaseOf } from "./scope.js";
|
|
22
|
+
import { autoReleaseApprovalOf, liveReleaseOf } from "./scope.js";
|
|
23
|
+
import { autoPublishHeld, holdAutoPublish, authorizeAutoCheck, giveUpAutoApproval, noteAutoCheckFailed, noteAutoRelease, releaseAutoApproves, sealAutoGreen } from "./release-auto.js";
|
|
23
24
|
import { proveCarry } from "./release-carry.js";
|
|
24
25
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
25
26
|
import { deploymentGate } from "./deploy-gate.js";
|
|
@@ -236,6 +237,14 @@ export function releaseAdapters(options) {
|
|
|
236
237
|
}
|
|
237
238
|
};
|
|
238
239
|
const refused = (answer) => `${String(answer["message"] ?? answer["reason"] ?? "refused")}`;
|
|
240
|
+
/** gh for every CI and merge-base read (auto-approve-green.md): found only in the system's own folders, never the
|
|
241
|
+
* caller's PATH (a test hands its own). */
|
|
242
|
+
const ciGh = options.tools?.gh ?? releaseTools("").gh;
|
|
243
|
+
const observeCi = async (sha) => {
|
|
244
|
+
const observedAt = (options.now ?? (() => new Date()))().toISOString();
|
|
245
|
+
const runs = (await readCheckRuns(run, ciGh, releaseRepo().slug, sha)).map(one => ({ id: one.id ?? null, name: one.name, status: one.status, conclusion: one.conclusion, app: one.app ?? null, suite: one.suite ?? null, head: one.head ?? null, workflow: one.workflow ?? null }));
|
|
246
|
+
return { head: sha, observedAt, runs, complete: await readSuitesComplete(run, ciGh, releaseRepo().slug, sha) };
|
|
247
|
+
};
|
|
239
248
|
const database = options.database ?? databasePath(process.env, homedir());
|
|
240
249
|
let recorded = options.releaseRepo ?? null;
|
|
241
250
|
/** The recorded repository, read once (chat-management.md §0a.2). */
|
|
@@ -284,7 +293,7 @@ export function releaseAdapters(options) {
|
|
|
284
293
|
return { ok: false, message: "The plane's database isn't here, so the candidate can't be proven." };
|
|
285
294
|
const db = new (sqlite().DatabaseSync)(database, { readOnly: true });
|
|
286
295
|
try {
|
|
287
|
-
return deploymentGate(new Store(db), { runId, evidenceRoot: join(dirname(database), "evidence"), now: (options.now ?? (() => new Date()))() });
|
|
296
|
+
return deploymentGate(new Store(db), { runId, evidenceRoot: join(dirname(database), "evidence"), now: (options.now ?? (() => new Date()))(), releaseFlow: true });
|
|
288
297
|
}
|
|
289
298
|
catch (error) {
|
|
290
299
|
return { ok: false, message: `The candidate can't be proven: ${error.message}` };
|
|
@@ -360,7 +369,7 @@ export function releaseAdapters(options) {
|
|
|
360
369
|
rmSync(extracted.dir, { recursive: true, force: true });
|
|
361
370
|
}
|
|
362
371
|
};
|
|
363
|
-
const release = storeReleaseAdapter(() => openStore(database), store => store.close(), options.now ?? (() => new Date()));
|
|
372
|
+
const release = storeReleaseAdapter(() => openStore(database), store => store.close(), options.now ?? (() => new Date()), undefined, { observe: sha => observeCi(sha), mergeBase: (base, sha) => readMergeBase(run, ciGh, releaseRepo().slug, base, sha), evidenceRoot: join(dirname(database), "evidence") });
|
|
364
373
|
return {
|
|
365
374
|
...(existsSync(database) ? { release } : {}),
|
|
366
375
|
now: options.now ?? (() => new Date()),
|
|
@@ -559,12 +568,9 @@ export function releaseAdapters(options) {
|
|
|
559
568
|
}));
|
|
560
569
|
return prs.find(one => one.state === "open") ?? prs.filter(one => one.state === "merged").sort((a, b) => b.number - a.number)[0] ?? null;
|
|
561
570
|
},
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
// Oldest first, so a rerun's attempt is the last one listed for its name.
|
|
566
|
-
return runs.sort((a, b) => Number(a["id"]) - Number(b["id"])).map((one) => ({ name: String(one["name"]), status: String(one["status"]), conclusion: textOr(one["conclusion"]), app: textOr(one["app"]) }));
|
|
567
|
-
},
|
|
571
|
+
// Every attempt (filter=all), with its app, check suite and commit: the release judges each job by its latest attempt.
|
|
572
|
+
async checks(repo, sha) { return readCheckRuns(run, ciGh, repo, sha); },
|
|
573
|
+
async complete(repo, sha) { return readSuitesComplete(run, ciGh, repo, sha); },
|
|
568
574
|
async merge(repo, pr, head, message) {
|
|
569
575
|
// The squash commit's message lists every commit that landed after the approval (release-once.md).
|
|
570
576
|
const merged = object(await ghJson("gh api pulls merge", "api", "-X", "PUT", `repos/${repo}/pulls/${pr}/merge`, "-f", "merge_method=squash", "-f", `sha=${head}`, ...(message === undefined ? [] : ["-f", `commit_message=${message}`])));
|
|
@@ -728,7 +734,10 @@ function personAnswerOf(value) {
|
|
|
728
734
|
* The release's authority record through a store (release-once.md): what `toolroll release` reads and writes for its
|
|
729
735
|
* lease, its carries and its operations. `open` hands a store for each call and `done` lets it go.
|
|
730
736
|
*/
|
|
731
|
-
export function storeReleaseAdapter(open, done, now, runner
|
|
737
|
+
export function storeReleaseAdapter(open, done, now, runner,
|
|
738
|
+
/** Releases that approve themselves (auto-approve-green.md): the plane's own look at CI on a commit, and where the
|
|
739
|
+
* check receipts are kept. Absent: no automatic approval, every release asks a person. */
|
|
740
|
+
auto) {
|
|
732
741
|
const withStore = (body) => { const store = open(); try {
|
|
733
742
|
return body(store);
|
|
734
743
|
}
|
|
@@ -736,6 +745,7 @@ export function storeReleaseAdapter(open, done, now, runner) {
|
|
|
736
745
|
done(store);
|
|
737
746
|
} };
|
|
738
747
|
return {
|
|
748
|
+
...(auto === undefined ? {} : { auto: autoReleaseAdapter(withStore, now, auto) }),
|
|
739
749
|
async authority(taskId) {
|
|
740
750
|
return withStore(store => {
|
|
741
751
|
const found = releaseAuthorityOfTask(store, taskId);
|
|
@@ -777,3 +787,119 @@ export function storeReleaseAdapter(open, done, now, runner) {
|
|
|
777
787
|
},
|
|
778
788
|
};
|
|
779
789
|
}
|
|
790
|
+
/** The plane's automatic release approval through a store (release-auto.ts): what `toolroll release` asks. It hands the
|
|
791
|
+
* plane ids only; the plane reads CI itself (`observe`) right before it seals. */
|
|
792
|
+
function autoReleaseAdapter(withStore, now, auto) {
|
|
793
|
+
return {
|
|
794
|
+
async setting(taskId) {
|
|
795
|
+
return withStore(store => { try {
|
|
796
|
+
const ref = store.lookupRef(taskId);
|
|
797
|
+
return ref?.repo != null && releaseAutoApproves(store, ref.repo);
|
|
798
|
+
}
|
|
799
|
+
catch {
|
|
800
|
+
return false;
|
|
801
|
+
} });
|
|
802
|
+
},
|
|
803
|
+
async authorizeCheck(input) {
|
|
804
|
+
// The comparison base for what the release changes: GitHub's own merge base of the candidate with the release's
|
|
805
|
+
// base branch (its signed terms), read by the plane — never a task branch or a build's recorded base.
|
|
806
|
+
const branch = withStore(store => store.getScope(input.taskId)?.releaseTerms?.base ?? null);
|
|
807
|
+
if (branch === null)
|
|
808
|
+
return { ok: false, why: "only a release task is approved automatically" };
|
|
809
|
+
let base;
|
|
810
|
+
try {
|
|
811
|
+
base = await auto.mergeBase(branch, input.candidate);
|
|
812
|
+
}
|
|
813
|
+
catch (error) {
|
|
814
|
+
return { ok: false, why: `GitHub couldn't say where ${input.candidate.slice(0, 7)} left ${branch} (${String(error?.message ?? error).slice(0, 160)})` };
|
|
815
|
+
}
|
|
816
|
+
return withStore(store => { const answer = authorizeAutoCheck(store, { ...input, base, now: now() }); return answer.ok ? { ok: true } : answer; });
|
|
817
|
+
},
|
|
818
|
+
async checking(taskId) {
|
|
819
|
+
return withStore(store => { const answer = autoReleaseApprovalOf(store, taskId, "auto-check"); return answer.ok ? { ok: true } : { ok: false, why: answer.why }; });
|
|
820
|
+
},
|
|
821
|
+
async approve(input) {
|
|
822
|
+
let ci = null, problem = "";
|
|
823
|
+
// A short network failure is asked again a few times; GitHub not answering is never a pass.
|
|
824
|
+
for (let attempt = 0; attempt < 3 && ci === null; attempt++) {
|
|
825
|
+
try {
|
|
826
|
+
ci = await auto.observe(input.candidate);
|
|
827
|
+
}
|
|
828
|
+
catch (error) {
|
|
829
|
+
problem = String(error?.message ?? error).slice(0, 200);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
if (ci === null) {
|
|
833
|
+
const why = `GitHub CI on ${input.candidate.slice(0, 7)} couldn't be read (${problem})`;
|
|
834
|
+
withStore(store => giveUpAutoApproval(store, { taskId: input.taskId, why, now: now() }));
|
|
835
|
+
return { ok: false, why };
|
|
836
|
+
}
|
|
837
|
+
const observed = ci;
|
|
838
|
+
return withStore(store => sealAutoGreen(store, { ...input, ci: observed, evidenceRoot: auto.evidenceRoot, now: now() }));
|
|
839
|
+
},
|
|
840
|
+
async giveUp(taskId, why) { withStore(store => giveUpAutoApproval(store, { taskId, why, now: now() })); },
|
|
841
|
+
async checkFailed(taskId, why) { withStore(store => noteAutoCheckFailed(store, { taskId, why, now: now() })); },
|
|
842
|
+
async released(taskId, tag) { withStore(store => noteAutoRelease(store, { taskId, tag, now: now() })); },
|
|
843
|
+
async holdPublish(taskId, why) { withStore(store => holdAutoPublish(store, { taskId, why, now: now() })); },
|
|
844
|
+
async publishHeld(taskId) { return withStore(store => autoPublishHeld(store, taskId)); },
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
/** The environment every CI read runs gh with: no caller PATH, host, config folder or proxy override. */
|
|
848
|
+
function ciEnv() {
|
|
849
|
+
const kept = Object.fromEntries(["HOME", "LANG", "LC_ALL", "TMPDIR", "USER", "LOGNAME", "GH_TOKEN", "GITHUB_TOKEN"].flatMap(name => process.env[name] === undefined ? [] : [[name, process.env[name]]]));
|
|
850
|
+
return { ...kept, PATH: "/usr/bin:/bin", GH_HOST: "github.com", GH_PROMPT_DISABLED: "1", GH_NO_UPDATE_NOTIFIER: "1" };
|
|
851
|
+
}
|
|
852
|
+
/**
|
|
853
|
+
* Every check run GitHub holds for one commit — every attempt, not only the latest (`filter=all`; GitHub's default
|
|
854
|
+
* lists only the latest of each), every page — with its id, app, check suite and the commit it is filed under, oldest
|
|
855
|
+
* first. A row that doesn't parse stops the read: a partial answer is never taken for the whole.
|
|
856
|
+
*/
|
|
857
|
+
export async function readCheckRuns(run, gh, repo, sha) {
|
|
858
|
+
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) || !/^[0-9a-f]{40}$/.test(sha))
|
|
859
|
+
throw new Error("not a repository and commit GitHub can be asked about");
|
|
860
|
+
const out = await must(run(gh, ["api", "--hostname", "github.com", "--paginate", `repos/${repo}/commits/${sha}/check-runs?filter=all&per_page=100`,
|
|
861
|
+
"--jq", ".check_runs[] | {id, name, status, conclusion, app: .app.slug, suite: .check_suite.id, head: .head_sha}"], { cwd: "/", env: ciEnv() }), "gh api check-runs");
|
|
862
|
+
// Which workflow each check suite is a run of (GitHub's workflow-run API): a newer run of a workflow supersedes its
|
|
863
|
+
// older runs on this commit (release.ts prChecksVerdict).
|
|
864
|
+
const flows = await must(run(gh, ["api", "--hostname", "github.com", "--paginate", `repos/${repo}/actions/runs?head_sha=${sha}&per_page=100`,
|
|
865
|
+
"--jq", ".workflow_runs[] | {suite: .check_suite_id, workflow: .workflow_id, head: .head_sha}"], { cwd: "/", env: ciEnv() }), "gh api actions/runs");
|
|
866
|
+
const workflowOf = new Map();
|
|
867
|
+
for (const line of flows.split("\n").filter(Boolean)) {
|
|
868
|
+
const one = object(JSON.parse(line));
|
|
869
|
+
if (!Number.isSafeInteger(one["suite"]) || !Number.isSafeInteger(one["workflow"]) || one["head"] !== sha)
|
|
870
|
+
throw new Error("GitHub answered a workflow run this can't read");
|
|
871
|
+
const known = workflowOf.get(Number(one["suite"]));
|
|
872
|
+
if (known !== undefined && known !== Number(one["workflow"]))
|
|
873
|
+
throw new Error("GitHub named two workflows for one check suite");
|
|
874
|
+
workflowOf.set(Number(one["suite"]), Number(one["workflow"]));
|
|
875
|
+
}
|
|
876
|
+
const runs = out.split("\n").filter(Boolean).map(line => {
|
|
877
|
+
const one = object(JSON.parse(line));
|
|
878
|
+
if (!Number.isSafeInteger(one["id"]) || typeof one["name"] !== "string" || typeof one["status"] !== "string")
|
|
879
|
+
throw new Error("GitHub answered a check run this can't read");
|
|
880
|
+
const suite = Number.isSafeInteger(one["suite"]) ? Number(one["suite"]) : null;
|
|
881
|
+
return { id: Number(one["id"]), name: one["name"], status: one["status"], conclusion: textOr(one["conclusion"]), app: textOr(one["app"]),
|
|
882
|
+
suite, head: textOr(one["head"]), workflow: suite === null ? null : workflowOf.get(suite) ?? null };
|
|
883
|
+
});
|
|
884
|
+
if (new Set(runs.map(one => one.id)).size !== runs.length)
|
|
885
|
+
throw new Error("GitHub answered the same check run twice");
|
|
886
|
+
return runs.sort((a, b) => a.id - b.id);
|
|
887
|
+
}
|
|
888
|
+
/** GitHub lists check runs from at most the latest 1000 check suites of a commit: the history is whole only below that. */
|
|
889
|
+
export const CHECK_SUITE_CEILING = 1000;
|
|
890
|
+
export async function readSuitesComplete(run, gh, repo, sha) {
|
|
891
|
+
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) || !/^[0-9a-f]{40}$/.test(sha))
|
|
892
|
+
return false;
|
|
893
|
+
const out = await run(gh, ["api", "--hostname", "github.com", `repos/${repo}/commits/${sha}/check-suites?per_page=1`, "--jq", ".total_count"], { cwd: "/", env: ciEnv() });
|
|
894
|
+
const total = Number(out.stdout.trim());
|
|
895
|
+
return out.code === 0 && out.stdout.trim() !== "" && Number.isSafeInteger(total) && total >= 0 && total < CHECK_SUITE_CEILING;
|
|
896
|
+
}
|
|
897
|
+
/** GitHub's merge base of `sha` with branch `base` (the compare API), read by the plane. */
|
|
898
|
+
export async function readMergeBase(run, gh, repo, base, sha) {
|
|
899
|
+
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) || !/^[0-9a-f]{40}$/.test(sha) || !/^[A-Za-z0-9._/-]+$/.test(base) || base.includes(".."))
|
|
900
|
+
throw new Error("not a repository, branch and commit GitHub can be asked about");
|
|
901
|
+
const out = (await must(run(gh, ["api", "--hostname", "github.com", `repos/${repo}/compare/${base}...${sha}`, "--jq", ".merge_base_commit.sha"], { cwd: "/", env: ciEnv() }), "gh api compare")).trim();
|
|
902
|
+
if (!/^[0-9a-f]{40}$/.test(out))
|
|
903
|
+
throw new Error("GitHub named no merge base");
|
|
904
|
+
return out;
|
|
905
|
+
}
|
|
@@ -116,3 +116,49 @@ export declare function outstandingReleaseOperations(store: Store, authority: nu
|
|
|
116
116
|
* superseded by the new one — but never while one of its operations is unsettled, and never once its merge started.
|
|
117
117
|
*/
|
|
118
118
|
export declare function releaseSealProblem(store: Store, terms: ReleaseTerms): string | null;
|
|
119
|
+
/** "Release approval: Ask me / Approve automatically when checks pass": a project setting, newest choice wins (a ledger
|
|
120
|
+
* row each); `ask` by default. Turning it on is a person's credential act (a password typed now); off is one click. */
|
|
121
|
+
export declare const RELEASE_APPROVAL_ACTION = "release approval changed";
|
|
122
|
+
/** The plane's record of what proved an automatic seal (`auto-check` or `auto-green`): one ledger row per seal, its
|
|
123
|
+
* id named by the seal's `mode_digest` as `auto:<id>`. Only release-auto.ts writes it. */
|
|
124
|
+
export declare const AUTO_EVIDENCE_ACTION = "release auto-approved";
|
|
125
|
+
/** An automatic seal taken back for good (the setting lowered, a refusal): its outcome names the seal's `auto:<id>`, and
|
|
126
|
+
* that evidence never stands again. */
|
|
127
|
+
export declare const AUTO_WITHDRAWN_ACTION = "release auto-approval withdrawn";
|
|
128
|
+
export type ReleaseApprovalSetting = {
|
|
129
|
+
auto: boolean;
|
|
130
|
+
setBy: string | null;
|
|
131
|
+
at: string | null;
|
|
132
|
+
};
|
|
133
|
+
/** Read live at every use. Fail closed: a missing, unreadable or unknown row is `ask`. */
|
|
134
|
+
export declare function releaseApprovalSetting(store: Store, repo: string): ReleaseApprovalSetting;
|
|
135
|
+
export declare const releaseAutoApproves: (store: Store, repo: string) => boolean;
|
|
136
|
+
/** SQL: the newest setting row for `repo` (an SQL expression) says `auto`. */
|
|
137
|
+
export declare const releaseAutoSql: (repo: string) => string;
|
|
138
|
+
/**
|
|
139
|
+
* SQL over a `task_scope` alias: its automatic seal (`auto-check` or `auto-green`) stands on its own evidence row — this
|
|
140
|
+
* task, this kind, `mode_digest = 'auto:<row id>'`, the row's digest and candidate exactly the sealed ones — on a release
|
|
141
|
+
* task (a candidate and release terms signed in), while the project's setting is on. The approver's standing is read
|
|
142
|
+
* beside it (approval-authority.ts).
|
|
143
|
+
*/
|
|
144
|
+
export declare const autoSealSql: (scope: string, repo: string) => string;
|
|
145
|
+
/** The evidence an automatic seal names, read strictly; null when the row is missing or doesn't match the seal. */
|
|
146
|
+
export type AutoEvidence = {
|
|
147
|
+
id: number;
|
|
148
|
+
kind: "auto-check" | "auto-green";
|
|
149
|
+
taskId: string;
|
|
150
|
+
digest: string;
|
|
151
|
+
candidate: string;
|
|
152
|
+
at: string;
|
|
153
|
+
by: string;
|
|
154
|
+
/** auto-green only: the build whose check passed, its receipt, the CI check runs observed green and when, the base. */
|
|
155
|
+
run: number | null;
|
|
156
|
+
receipt: string | null;
|
|
157
|
+
ci: {
|
|
158
|
+
id: number;
|
|
159
|
+
name: string;
|
|
160
|
+
}[];
|
|
161
|
+
observedAt: string | null;
|
|
162
|
+
base: string | null;
|
|
163
|
+
};
|
|
164
|
+
export declare function autoEvidenceOf(store: Store, taskId: string, modeDigest: string | null | undefined, kind: "auto-check" | "auto-green"): AutoEvidence | null;
|