@critical-labs/qa-conductor 0.0.0-stage → 0.3.0

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.
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env node
2
+ // qa-conductor-expose: ensure the conductor's tailscale serve mounts, or with
3
+ // --check only report drift. An operator and debug tool: the conductor's own
4
+ // reconcile loop owns the mounts, and no deploy runs this.
5
+ //
6
+ // It loads cfg as the conductor does (loadConfig, or the platform's own
7
+ // loader named by --config, so a .env.qa that leans on a platform's defaults
8
+ // loads too), builds the built-in tailscale adapter, and runs runExpose:
9
+ // mountsFor then reconcileExposure, the conductor loop's own pass. The pass
10
+ // and what it prints are in lib/exposure.mjs; this file parses flags, loads
11
+ // cfg and sets the exit code.
12
+
13
+ import path from 'node:path'
14
+ import { pathToFileURL } from 'node:url'
15
+ import { parseArgs } from 'node:util'
16
+
17
+ import { createTailscaleExposure } from '../lib/adapters/exposure-tailscale.mjs'
18
+ import { loadConfig } from '../lib/config.mjs'
19
+ import { makeExecFileFn } from '../lib/exec.mjs'
20
+ import { runExpose } from '../lib/exposure.mjs'
21
+
22
+ const USAGE = `Usage: qa-conductor-expose [--check] [--env FILE] [--config MODULE[#export]] [--tailscale BIN] [--socket PATH] [--help|-h]
23
+
24
+ Ensures the conductor's tailscale serve mounts (the harness under /qa at the
25
+ harness origin's port, each pane at / at its own origin's port), or with
26
+ --check only reports drift. It never removes a handler. The conductor's own
27
+ reconcile loop owns these mounts: this is for operators and debugging, and no
28
+ deploy needs to run it.
29
+
30
+ Run it without --check only while the conductor is running. This CLI never
31
+ removes a mount, and self-QA removes its own only as it stops, so a mount
32
+ written with no conductor behind it stays, and publishes whatever listens on
33
+ its loopback port next with no identity gate. Otherwise use --check.
34
+
35
+ --check report drift; change nothing
36
+ --env FILE the conductor's env file
37
+ (default: $QA_ENV_FILE, else ./.env.qa)
38
+ --config MODULE[#export] load the config by calling MODULE's export (default:
39
+ its default export) with the env file's path. MODULE
40
+ is a file path, from the working directory. Without
41
+ it, the core loadConfig reads the env file, which then
42
+ needs GITHUB_QA_TOKEN and QA_REPO like the conductor's
43
+ --tailscale BIN the tailscale CLI (default: QA_TAILSCALE_BIN in the env
44
+ file, else tailscale on PATH). On macOS, use the app's
45
+ /Applications/Tailscale.app/Contents/MacOS/Tailscale
46
+ when the one on PATH is older than the daemon
47
+ --socket PATH tailscaled's socket, passed as --socket=PATH
48
+ -h, --help print this and exit
49
+
50
+ Exit status: 0 every mount is in place (or QA_EXPOSURE=none), 1 drift remains
51
+ or tailscale failed, 2 a usage, config or mount layout error.
52
+ `
53
+
54
+ const OPTIONS = {
55
+ check: { type: 'boolean' },
56
+ env: { type: 'string' },
57
+ config: { type: 'string' },
58
+ tailscale: { type: 'string' },
59
+ socket: { type: 'string' },
60
+ help: { type: 'boolean', short: 'h' },
61
+ }
62
+
63
+ // A usage or config error: what went wrong, then the usage, on stderr.
64
+ function refuse(message) {
65
+ process.stderr.write(`qa-conductor-expose: ${message}\n\n${USAGE}`)
66
+ return 2
67
+ }
68
+
69
+ // MODULE[#export]: the export is what follows the last #, when that is a name.
70
+ async function loadCfg(spec, envFile) {
71
+ if (spec === undefined) return loadConfig(envFile)
72
+ const [, file, name] = /^(.*)#([A-Za-z_$][\w$]*)$/.exec(spec) ?? [spec, spec, 'default']
73
+ const mod = await import(pathToFileURL(path.resolve(file)).href)
74
+ if (typeof mod[name] !== 'function') throw new Error(`--config ${spec}: ${file} has no function export named ${name}`)
75
+ const cfg = await mod[name](envFile)
76
+ if (cfg === null || typeof cfg !== 'object') throw new Error(`--config ${spec}: ${name}(${JSON.stringify(envFile)}) returned no config object`)
77
+ return cfg
78
+ }
79
+
80
+ async function main(argv) {
81
+ let opts
82
+ try {
83
+ opts = parseArgs({ args: argv, options: OPTIONS, strict: true, allowPositionals: false }).values
84
+ } catch (err) {
85
+ return refuse(err.message)
86
+ }
87
+ if (opts.help) {
88
+ process.stdout.write(USAGE)
89
+ return 0
90
+ }
91
+ const envFile = opts.env ?? (process.env.QA_ENV_FILE || '.env.qa')
92
+ let cfg, exposure
93
+ try {
94
+ cfg = await loadCfg(opts.config, envFile)
95
+ exposure = createTailscaleExposure({
96
+ execFileFn: makeExecFileFn(),
97
+ bin: opts.tailscale ?? (cfg.env?.QA_TAILSCALE_BIN || 'tailscale'),
98
+ socket: opts.socket ?? null,
99
+ })
100
+ } catch (err) {
101
+ return refuse(err?.message ?? String(err))
102
+ }
103
+ return runExpose({ cfg, exposure, checkOnly: opts.check === true })
104
+ }
105
+
106
+ // exitCode, not exit(): stdout and stderr drain first.
107
+ main(process.argv.slice(2)).then(
108
+ code => { process.exitCode = code },
109
+ err => {
110
+ process.stderr.write(`qa-conductor-expose: ${err?.stack ?? err}\n`)
111
+ process.exitCode = 1
112
+ },
113
+ )
@@ -0,0 +1,532 @@
1
+ // git-worktree BuildConvention: the built-in "build" for consumers that run a
2
+ // PR straight from its source instead of from CI-built images.
3
+ //
4
+ // For a PR it fetches the base branch and the PR head into a bare repository
5
+ // under `cacheDir`, checks each SHA out into its own worktree
6
+ // (`worktrees/<sha>`), optionally installs dependencies there, and records a
7
+ // marker outside the checkout (`built/<sha>.json`) so the next boot of the same
8
+ // SHA is instant. `servicesFor(dir, { role, sha })` turns a worktree into the
9
+ // Provisioner's service refs.
10
+ //
11
+ // This checks out and installs a PR's code on the reviewer's machine, so the
12
+ // trust gate (`trustDecision`) is the only real boundary: ensureBuilt refuses
13
+ // an untrusted PR before any git call, and only the exact SHA that passed the
14
+ // gate is ever checked out or installed. The installer's scrubbed env
15
+ // (PATH, HOME and install.env only) is defence in depth, not a sandbox.
16
+ // A marker vouches for a checkout, so the cache must be the reviewer's alone:
17
+ // a build refuses a cache that is a symlink or that others could write to.
18
+ //
19
+ // Builds run one at a time per instance. Effects (execFileFn, fsx, baseEnv,
20
+ // nowFn) are injected, so it is unit-tested with no real git or processes.
21
+
22
+ import { createHash } from 'node:crypto'
23
+ import fsp from 'node:fs/promises'
24
+ import path from 'node:path'
25
+
26
+ import { makeExecFileFn } from '../exec.mjs'
27
+
28
+ const SHA_RE = /^[0-9a-f]{40}([0-9a-f]{24})?$/
29
+ const REPO_RE = /^[\w.-]+\/[\w.-]+$/
30
+ const DEFAULT_TRUST = { logins: [], associations: ['OWNER', 'MEMBER', 'COLLABORATOR'], requirePush: true, allowForks: true }
31
+ const PUSH_PERMISSIONS = ['admin', 'write']
32
+ const PERMISSION_TTL_MS = 5 * 60_000
33
+ const TAIL_LINES = 40
34
+ const TAIL_BYTES = 8192
35
+
36
+ const lower = s => String(s ?? '').toLowerCase()
37
+ const sha7 = sha => sha.slice(0, 7)
38
+ const abortError = () => Object.assign(new Error('build aborted'), { name: 'AbortError' })
39
+ const throwIfAborted = signal => { if (signal?.aborted) throw abortError() }
40
+
41
+ // A string is iterable, so `logins: 'alice'` would spread into one-character
42
+ // logins and widen the allowlist. Anything but a list of non-empty strings
43
+ // throws: at construction, and inside trustDecision it fails closed.
44
+ function stringList(value, key, what) {
45
+ if (value == null) return []
46
+ const list = value instanceof Set ? [...value] : value
47
+ if (!Array.isArray(list) || !list.every(s => typeof s === 'string' && s.trim() !== '')) {
48
+ throw new Error(`trust.${key} must be an array of ${what}`)
49
+ }
50
+ return list.map(s => s.trim())
51
+ }
52
+
53
+ function flag(value, key) {
54
+ if (value == null) return DEFAULT_TRUST[key]
55
+ if (typeof value !== 'boolean') throw new Error(`trust.${key} must be true or false`)
56
+ return value
57
+ }
58
+
59
+ // A partial `trust` must never switch a check off by omission: missing keys
60
+ // take the defaults, and only an explicit `false` disables a check.
61
+ function normalizeTrust(trust) {
62
+ if (trust != null && (typeof trust !== 'object' || Array.isArray(trust))) throw new Error('trust must be an object')
63
+ const t = { ...DEFAULT_TRUST, ...trust }
64
+ return {
65
+ logins: stringList(t.logins, 'logins', 'GitHub logins').map(lower),
66
+ associations: stringList(t.associations, 'associations', 'author associations').map(s => s.toUpperCase()),
67
+ requirePush: flag(t.requirePush, 'requirePush'),
68
+ allowForks: flag(t.allowForks, 'allowForks'),
69
+ }
70
+ }
71
+
72
+ function isAllowlisted(info, t) {
73
+ return !!info?.author && t.logins.includes(lower(info.author))
74
+ }
75
+
76
+ // Whether trustDecision will need the author's repo permission.
77
+ function permissionNeeded(info, t) {
78
+ return t.requirePush && !isAllowlisted(info, t) && t.associations.includes(String(info?.authorAssociation).toUpperCase())
79
+ }
80
+
81
+ // Is this PR's code safe to check out and run on the reviewer's machine?
82
+ // `info` is a github.prInfo result (or a listOpenPrs item). `permission` is the
83
+ // author's repo permission, needed only when an association has to be backed
84
+ // by push access: author_association alone is not an access check, since
85
+ // COLLABORATOR includes read-only outside collaborators and MEMBER includes
86
+ // org members with no push access. Any error fails closed.
87
+ //
88
+ // It vouches for the author and where the head lives, not for every commit on
89
+ // the head branch: anyone who can push to the author's fork can move it.
90
+ // `allowForks: false` requires a head in the repo itself.
91
+ export function trustDecision(info, { repo, trust, permission } = {}) {
92
+ try {
93
+ const t = normalizeTrust(trust)
94
+ const author = info.author
95
+ if (!isAllowlisted(info, t)) {
96
+ const association = String(info.authorAssociation ?? 'NONE').toUpperCase()
97
+ if (!t.associations.includes(association)) return { ok: false, reason: `author association ${association} is not trusted` }
98
+ if (t.requirePush && !PUSH_PERMISSIONS.includes(permission)) {
99
+ const has = permission === 'none' ? 'no' : permission || 'unknown'
100
+ return { ok: false, reason: `@${author} has ${has} access; push access is required` }
101
+ }
102
+ }
103
+ if (info.headRepo == null) return { ok: false, reason: 'head repository was deleted' }
104
+ if (lower(info.headRepo) !== lower(repo)) {
105
+ const authorsFork = !!author && lower(info.headOwner) === lower(author)
106
+ if (!authorsFork) return { ok: false, reason: `head branch is in @${info.headOwner}'s fork (${info.headRepo}), not the author's` }
107
+ if (!t.allowForks) return { ok: false, reason: `head branch is in a fork (${info.headRepo}) and trust.allowForks is off` }
108
+ }
109
+ return { ok: true }
110
+ } catch (err) {
111
+ return { ok: false, reason: `trust check failed: ${err?.message ?? err}` }
112
+ }
113
+ }
114
+
115
+ function validSha(value, what) {
116
+ const sha = typeof value === 'string' ? value.trim() : ''
117
+ if (!SHA_RE.test(sha)) throw new Error(`invalid SHA for ${what}: ${JSON.stringify(String(value)).slice(0, 80)}`)
118
+ return sha
119
+ }
120
+
121
+ function assertPrNumber(pr) {
122
+ if (!Number.isSafeInteger(pr) || pr <= 0) throw new Error(`invalid PR number: ${typeof pr === 'string' ? JSON.stringify(pr) : String(pr)}`)
123
+ }
124
+
125
+ // Tokens belong in a git credential helper, never in the URL: the URL lands in
126
+ // argv (visible in `ps`) and in git's own error messages. The message never
127
+ // echoes the URL.
128
+ function assertCloneUrl(url) {
129
+ if (typeof url !== 'string' || !url.trim() || url.startsWith('-')) throw new Error('cloneUrl must be a URL or a path')
130
+ const refuse = () => { throw new Error('cloneUrl must not contain credentials; use a git credential helper') }
131
+ let parsed = null
132
+ try { parsed = new URL(url) } catch { /* scp-like (git@host:path) or a local path */ }
133
+ if (parsed && url.slice(parsed.protocol.length).startsWith('//')) {
134
+ const sshUser = parsed.protocol === 'ssh:' && !parsed.password
135
+ if (parsed.password || (parsed.username && !sshUser)) refuse()
136
+ } else if (/^[^/@:]+:[^/@]*@/.test(url)) {
137
+ refuse() // user:secret@host:path
138
+ }
139
+ }
140
+
141
+ function assertBaseRef(ref) {
142
+ const ok = typeof ref === 'string' && /^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(ref) &&
143
+ !ref.includes('..') && !ref.includes('//') && !ref.endsWith('/') && !ref.endsWith('.lock')
144
+ if (!ok) throw new Error(`invalid baseRef: ${JSON.stringify(ref)}`)
145
+ }
146
+
147
+ function assertInstall(install) {
148
+ if (install === null) return
149
+ if (typeof install?.cmd !== 'string' || !install.cmd) throw new Error('install.cmd must be a non-empty string')
150
+ if (install.args !== undefined && !(Array.isArray(install.args) && install.args.every(a => typeof a === 'string'))) {
151
+ throw new Error('install.args must be an array of strings')
152
+ }
153
+ if (install.env !== undefined && (typeof install.env !== 'object' || install.env === null)) throw new Error('install.env must be an object')
154
+ }
155
+
156
+ // Markers are keyed on what shaped the install. Env values are left out so a
157
+ // rotated secret in install.env doesn't invalidate every build.
158
+ function installFingerprint(install) {
159
+ const shape = install ? { cmd: install.cmd, args: install.args ?? [], envKeys: Object.keys(install.env ?? {}).sort() } : null
160
+ return createHash('sha256').update(JSON.stringify(shape)).digest('hex')
161
+ }
162
+
163
+ // The last TAIL_LINES lines, at most TAIL_BYTES, of a failed exec's output.
164
+ // U1's exec errors carry stdout/stderr; older ones only a message.
165
+ //
166
+ // Output can run to tens of megabytes, so it is cut to a window before any
167
+ // line work, and trimmed with trimEnd: a /\s+$/ regex backtracks
168
+ // quadratically over a long run of inner whitespace, blocking the event loop.
169
+ // The window (TAIL_BYTES * 8 UTF-16 units) always encodes to more than
170
+ // TAIL_BYTES, so its possibly split first character never survives the cap.
171
+ function logTailOf(err) {
172
+ const stdout = err?.stdout == null ? '' : String(err.stdout)
173
+ const stderr = err?.stderr == null ? '' : String(err.stderr)
174
+ let text = (stdout && stderr && !stdout.endsWith('\n') ? `${stdout}\n${stderr}` : stdout + stderr).trimEnd()
175
+ if (!text) text = String(err?.message ?? err ?? '').trimEnd()
176
+ const tail = text.slice(-TAIL_BYTES * 8).split('\n').slice(-TAIL_LINES).join('\n')
177
+ let buf = Buffer.from(tail, 'utf8')
178
+ if (buf.length <= TAIL_BYTES) return tail
179
+ buf = buf.subarray(buf.length - TAIL_BYTES)
180
+ let i = 0
181
+ while (i < buf.length && (buf[i] & 0xc0) === 0x80) i++ // don't start mid-character
182
+ return buf.subarray(i).toString('utf8')
183
+ }
184
+
185
+ export function createWorktreeBuild({
186
+ repo,
187
+ cloneUrl = `https://github.com/${repo}.git`,
188
+ cacheDir,
189
+ github,
190
+ servicesFor,
191
+ install = null,
192
+ baseRef = 'main',
193
+ trust = DEFAULT_TRUST,
194
+ keep = 6,
195
+ migrationStrategy = 'on-boot',
196
+ execFileFn = makeExecFileFn(),
197
+ fsx = fsp,
198
+ baseEnv = process.env,
199
+ nowFn = Date.now,
200
+ } = {}) {
201
+ if (typeof repo !== 'string' || !REPO_RE.test(repo)) throw new Error(`repo must be 'owner/name', got ${JSON.stringify(repo)}`)
202
+ if (typeof cacheDir !== 'string' || !cacheDir) throw new Error('cacheDir is required')
203
+ if (typeof github?.prInfo !== 'function') throw new Error('github must provide prInfo(num)')
204
+ if (typeof github?.authorPermission !== 'function') throw new Error('github must provide authorPermission(login)')
205
+ if (typeof servicesFor !== 'function') throw new Error('servicesFor(worktreeDir, { role, sha }) is required')
206
+ if (!Number.isSafeInteger(keep) || keep < 0) throw new Error('keep must be a non-negative integer')
207
+ assertCloneUrl(cloneUrl)
208
+ assertBaseRef(baseRef)
209
+ assertInstall(install)
210
+
211
+ const trustCfg = normalizeTrust(trust)
212
+ const root = path.resolve(cacheDir)
213
+ const bare = path.join(root, 'repo.git')
214
+ const worktreesRoot = path.join(root, 'worktrees')
215
+ const builtDir = path.join(root, 'built')
216
+ const fingerprint = installFingerprint(install)
217
+ const worktreeDir = sha => path.join(worktreesRoot, sha)
218
+ const markerPath = sha => path.join(builtDir, `${sha}.json`)
219
+
220
+ // The SHAs the last live ensureBuilt resolved, for resolve*.
221
+ const resolved = { base: null, prs: new Map() }
222
+ // SHAs whose build holds the lock (describePrs reports them as building).
223
+ let building = new Set()
224
+ let listener = null
225
+ const permissionCache = new Map()
226
+
227
+ // Progress belongs to the call that made it. The core resubscribes on every
228
+ // boot, so a call keeps the subscriber it started with, and an aborted
229
+ // call's late messages (a failing prune, say) never reach the next boot.
230
+ function progressFor(signal) {
231
+ const cb = listener
232
+ return message => {
233
+ if (!cb || signal?.aborted) return
234
+ try {
235
+ const out = cb({ message })
236
+ if (typeof out?.then === 'function') out.then(undefined, () => {})
237
+ } catch { /* a broken subscriber must never fail a build */ }
238
+ }
239
+ }
240
+
241
+ // --- the lock: one build at a time -----------------------------------------
242
+ // Callers queue on a promise chain that never rejects. A waiter never
243
+ // shares the holder's promise, because an aborted holder's AbortError must
244
+ // not reach a live boot: it waits, then runs the whole build itself.
245
+ let lockTail = Promise.resolve()
246
+ let lockUsers = 0
247
+ async function withLock(fn, progress) {
248
+ const prev = lockTail
249
+ let release
250
+ lockTail = new Promise(resolve => { release = resolve })
251
+ if (lockUsers++ > 0) progress('waiting for another build to finish…')
252
+ try {
253
+ await prev
254
+ return await fn()
255
+ } finally {
256
+ lockUsers--
257
+ release()
258
+ }
259
+ }
260
+
261
+ // --- effects ---------------------------------------------------------------
262
+
263
+ // No prompts (a missing credential fails instead of hanging) and no hooks:
264
+ // `worktree add` runs post-checkout and ref updates run
265
+ // reference-transaction, so a user-level core.hooksPath or init.templateDir
266
+ // hook would otherwise run over PR code with this full env. Passed as env
267
+ // config (GIT_CONFIG_COUNT), appended to any the operator already set.
268
+ function gitEnv() {
269
+ const env = { ...baseEnv, GIT_TERMINAL_PROMPT: '0' }
270
+ const n = Number.parseInt(env.GIT_CONFIG_COUNT ?? '0', 10) || 0
271
+ env.GIT_CONFIG_COUNT = String(n + 1)
272
+ env[`GIT_CONFIG_KEY_${n}`] = 'core.hooksPath'
273
+ env[`GIT_CONFIG_VALUE_${n}`] = '/dev/null'
274
+ return env
275
+ }
276
+ const git = (args, signal) => execFileFn('git', ['--git-dir', bare, ...args], { signal, env: gitEnv() })
277
+ const exists = p => fsx.lstat(p).then(() => true, () => false)
278
+
279
+ // The only recursive delete: refuses anything outside worktrees/.
280
+ async function rmrf(target) {
281
+ const abs = path.resolve(target)
282
+ if (!abs.startsWith(worktreesRoot + path.sep)) throw new Error(`refusing to delete ${abs}: outside ${worktreesRoot}`)
283
+ await fsx.rm(abs, { recursive: true, force: true })
284
+ }
285
+
286
+ async function revParse(ref, signal) {
287
+ const out = await git(['rev-parse', '--verify', ref], signal)
288
+ return validSha(String(out?.stdout ?? ''), ref)
289
+ }
290
+
291
+ // Whoever can write to the cache can plant a marker and a tree for the
292
+ // public, predictable base SHA, which the next boot would run as the
293
+ // reviewer. So the cache and its marker and checkout directories must each
294
+ // be a real directory (not a symlink), ours, and not group- or
295
+ // world-writable. Owners and modes are skipped where there are none (Windows).
296
+ async function assertPrivateDir(dir) {
297
+ const st = await fsx.lstat(dir)
298
+ const refuse = why => { throw new Error(`refusing to build in ${dir}: ${why}`) }
299
+ if (st.isSymbolicLink()) refuse('it is a symlink; pass the real path as cacheDir')
300
+ if (!st.isDirectory()) refuse('it is not a directory')
301
+ const uid = process.getuid?.()
302
+ if (uid === undefined) return
303
+ if (st.uid !== uid) refuse(`it is owned by uid ${st.uid}, not ${uid}`)
304
+ if (st.mode & 0o022) refuse(`it is writable by group or others (mode ${(st.mode & 0o777).toString(8)}); chmod 700 it`)
305
+ }
306
+
307
+ async function ensureBareRepo(signal) {
308
+ await fsx.mkdir(root, { recursive: true, mode: 0o700 })
309
+ await assertPrivateDir(root)
310
+ for (const dir of [worktreesRoot, builtDir]) {
311
+ await fsx.mkdir(dir, { recursive: true, mode: 0o700 })
312
+ await assertPrivateDir(dir)
313
+ }
314
+ if (!(await exists(path.join(bare, 'HEAD')))) {
315
+ await execFileFn('git', ['init', '--bare', bare], { signal, env: gitEnv() })
316
+ }
317
+ }
318
+
319
+ async function readMarker(sha) {
320
+ return JSON.parse(String(await fsx.readFile(markerPath(sha), 'utf8')))
321
+ }
322
+
323
+ // Built = a marker for this SHA and install shape, and its checkout present.
324
+ async function isBuilt(sha) {
325
+ try {
326
+ const m = await readMarker(sha)
327
+ return m?.sha === sha && m.installFingerprint === fingerprint && (await exists(worktreeDir(sha)))
328
+ } catch {
329
+ return false
330
+ }
331
+ }
332
+
333
+ // --- trust ---------------------------------------------------------------------
334
+
335
+ async function gate(info, getPermission) {
336
+ let permission = null
337
+ try {
338
+ if (permissionNeeded(info, trustCfg)) permission = await getPermission(info.author)
339
+ } catch (err) {
340
+ return { ok: false, reason: `could not check @${info?.author}'s permission: ${err?.message ?? err}` }
341
+ }
342
+ return trustDecision(info, { repo, trust: trustCfg, permission })
343
+ }
344
+
345
+ const freshPermission = login => github.authorPermission(login)
346
+
347
+ // The picker polls describePrs, so permission lookups are memoized per login
348
+ // for five minutes. Failures aren't cached (they fail closed until retried).
349
+ function cachedPermission(login) {
350
+ const key = lower(login)
351
+ const hit = permissionCache.get(key)
352
+ if (hit && nowFn() - hit.at < PERMISSION_TTL_MS) return hit.promise
353
+ const promise = Promise.resolve().then(() => github.authorPermission(login))
354
+ permissionCache.set(key, { at: nowFn(), promise })
355
+ promise.catch(() => { if (permissionCache.get(key)?.promise === promise) permissionCache.delete(key) })
356
+ return promise
357
+ }
358
+
359
+ // The fetched head differs from the one we gated: accept it only if GitHub
360
+ // now reports exactly that SHA and it passes the gate again.
361
+ async function regate(pr, fetchedSha) {
362
+ const moved = cause => new Error(`PR #${pr} head moved during fetch; retry`, cause ? { cause } : undefined)
363
+ let again
364
+ try { again = await github.prInfo(pr) } catch (err) { throw moved(err) }
365
+ let sha = null
366
+ try { sha = validSha(again?.headSha, `PR #${pr} head`) } catch { /* treated as moved */ }
367
+ if (sha !== fetchedSha || !(await gate(again, freshPermission)).ok) throw moved()
368
+ }
369
+
370
+ // --- building ----------------------------------------------------------------
371
+
372
+ // `run` is one ensureBuilt call: { signal, progress }.
373
+ async function runInstall(dir, tag, { signal, progress }) {
374
+ if (!install) return
375
+ progress(`installing dependencies for ${tag}…`)
376
+ // Nothing else from the conductor's env reaches PR code (no tokens).
377
+ const env = {}
378
+ for (const key of ['PATH', 'HOME']) if (baseEnv[key] !== undefined) env[key] = baseEnv[key]
379
+ try {
380
+ await execFileFn(install.cmd, install.args ?? [], { cwd: dir, signal, env: { ...env, ...install.env } })
381
+ } catch (err) {
382
+ if (signal?.aborted || err?.name === 'AbortError') throw err
383
+ const status = err?.code ?? err?.signal ?? 'abnormally'
384
+ throw Object.assign(new Error(`install failed for ${tag}: ${install.cmd} exited ${status}`, { cause: err }), { logTail: logTailOf(err) })
385
+ }
386
+ }
387
+
388
+ async function buildTree(sha, who, run) {
389
+ const { signal, progress } = run
390
+ const tag = `${who} (${sha7(sha)})`
391
+ if (await isBuilt(sha)) {
392
+ progress(`${tag} already built`)
393
+ return
394
+ }
395
+ throwIfAborted(signal)
396
+ // A leftover marker (install shape changed, or its checkout was deleted)
397
+ // must not outlive the rebuild: drop it before touching the directory.
398
+ await fsx.rm(markerPath(sha), { force: true })
399
+ const dir = worktreeDir(sha)
400
+ await git(['worktree', 'prune'], signal) // drop registrations whose directory is gone
401
+ if (await exists(dir)) {
402
+ // A previous build died part-way: start from a clean checkout.
403
+ progress(`cleaning up a partial checkout of ${tag}…`)
404
+ await git(['worktree', 'remove', '-f', '-f', dir], signal).catch(() => {})
405
+ await rmrf(dir).catch(() => {})
406
+ await git(['worktree', 'prune'], signal).catch(() => {})
407
+ }
408
+ progress(`checking out ${tag}…`)
409
+ await git(['worktree', 'add', '-f', '-f', '--detach', dir, sha], signal)
410
+ await runInstall(dir, tag, run)
411
+ // Last, so a marker always means a complete checkout and install.
412
+ await fsx.writeFile(markerPath(sha), `${JSON.stringify({ sha, builtAt: nowFn(), installFingerprint: fingerprint })}\n`)
413
+ }
414
+
415
+ // Best-effort: keep the newest `keep` markers and the SHAs just built, and
416
+ // clear everything else, including checkouts whose build never finished.
417
+ async function prune(current, { signal, progress }) {
418
+ const report = (what, err) => progress(`could not prune ${what}: ${err?.message ?? err}`)
419
+ try {
420
+ const markers = []
421
+ for (const name of await fsx.readdir(builtDir)) {
422
+ const m = /^(.+)\.json$/.exec(name)
423
+ if (!m || !SHA_RE.test(m[1])) continue
424
+ let builtAt = 0
425
+ try { builtAt = Number((await readMarker(m[1])).builtAt) || 0 } catch { /* unreadable: oldest */ }
426
+ markers.push({ sha: m[1], builtAt })
427
+ }
428
+ markers.sort((a, b) => b.builtAt - a.builtAt || a.sha.localeCompare(b.sha))
429
+ const retain = new Set([...markers.slice(0, keep).map(m => m.sha), ...current])
430
+ const candidates = new Set(markers.map(m => m.sha))
431
+ for (const name of await fsx.readdir(worktreesRoot).catch(() => [])) if (SHA_RE.test(name)) candidates.add(name)
432
+
433
+ const stale = [...candidates].filter(sha => !retain.has(sha))
434
+ for (const sha of stale) {
435
+ try {
436
+ // Marker first, so a half-removed tree can never count as built.
437
+ await fsx.rm(markerPath(sha), { force: true })
438
+ const dir = worktreeDir(sha)
439
+ await git(['worktree', 'remove', '-f', '-f', dir], signal).catch(() => rmrf(dir))
440
+ } catch (err) {
441
+ report(sha7(sha), err)
442
+ }
443
+ }
444
+ if (stale.length) await git(['worktree', 'prune'], signal).catch(err => report('stale worktrees', err))
445
+ } catch (err) {
446
+ report('the build cache', err)
447
+ }
448
+ }
449
+
450
+ async function buildLocked(pr, headSha, run) {
451
+ const { signal, progress } = run
452
+ throwIfAborted(signal)
453
+ building = new Set([headSha])
454
+ try {
455
+ await ensureBareRepo(signal)
456
+ progress(`fetching ${repo}…`)
457
+ await git([
458
+ 'fetch', '--no-tags', cloneUrl,
459
+ `+refs/heads/${baseRef}:refs/qa/base`,
460
+ `+refs/pull/${pr}/head:refs/qa/pr-${pr}-incoming`,
461
+ ], signal)
462
+ const baseSha = await revParse('refs/qa/base', signal)
463
+ const prSha = await revParse(`refs/qa/pr-${pr}-incoming`, signal)
464
+ if (prSha !== headSha) await regate(pr, prSha)
465
+ // From here on prSha is the gated SHA: it is the only PR code that gets
466
+ // pinned, checked out or installed.
467
+ await git(['update-ref', `refs/qa/pr-${pr}`, prSha], signal)
468
+ if (!signal?.aborted) {
469
+ resolved.base = baseSha
470
+ resolved.prs.set(pr, prSha)
471
+ }
472
+ building = new Set([baseSha, prSha])
473
+ await buildTree(baseSha, 'base', run)
474
+ await buildTree(prSha, `#${pr}`, run)
475
+ // An aborted signal fails every further exec, so there's nothing to gain.
476
+ if (!signal?.aborted) await prune([baseSha, prSha], run)
477
+ } finally {
478
+ building = new Set()
479
+ }
480
+ }
481
+
482
+ return {
483
+ migrationStrategy,
484
+
485
+ // One subscriber, the latest: the core subscribes on every boot, just
486
+ // before its ensureBuilt, and runs one session at a time.
487
+ subscribeBuild(cb) { listener = cb },
488
+
489
+ async ensureBuilt(pr, { signal } = {}) {
490
+ // Bound before the first await, while it is still this call's subscriber.
491
+ const run = { signal, progress: progressFor(signal) }
492
+ assertPrNumber(pr)
493
+ // The gate runs outside the lock and before any git call.
494
+ const info = await github.prInfo(pr)
495
+ const decision = await gate(info, freshPermission)
496
+ if (!decision.ok) throw new Error(`PR #${pr} by @${info?.author ?? 'unknown'} is not from a trusted source: ${decision.reason}`)
497
+ const headSha = validSha(info.headSha, `PR #${pr} head`)
498
+ await withLock(() => buildLocked(pr, headSha, run), run.progress)
499
+ },
500
+
501
+ async resolvePrImages(pr) {
502
+ assertPrNumber(pr)
503
+ const sha = resolved.prs.get(pr)
504
+ if (!sha) throw new Error(`PR #${pr} has not been built; call ensureBuilt first`)
505
+ return { services: await servicesFor(worktreeDir(sha), { role: 'pr', sha }), migrate: null, label: `#${pr}@${sha7(sha)}` }
506
+ },
507
+
508
+ async resolveBaseImages() {
509
+ const sha = resolved.base
510
+ if (!sha) throw new Error('base has not been built; call ensureBuilt first')
511
+ return { services: await servicesFor(worktreeDir(sha), { role: 'base', sha }), migrate: null, label: `${baseRef}@${sha7(sha)}` }
512
+ },
513
+
514
+ // Picker readiness for listOpenPrs items. Items without authorAssociation
515
+ // (a bare { number, headSha }) aren't judged on trust; ensureBuilt still is.
516
+ async describePrs(prs) {
517
+ return Promise.all((prs ?? []).map(async item => {
518
+ const row = { number: item?.number, status: 'none', runUrl: null }
519
+ try {
520
+ if (item.authorAssociation != null) {
521
+ const decision = await gate(item, cachedPermission)
522
+ if (!decision.ok) return { ...row, status: 'blocked', reason: decision.reason }
523
+ }
524
+ const sha = validSha(item.headSha, `PR #${item.number} head`)
525
+ if (building.has(sha)) row.status = 'building'
526
+ else if (await isBuilt(sha)) row.status = 'built'
527
+ } catch { /* unknown readiness shows as none */ }
528
+ return row
529
+ }))
530
+ },
531
+ }
532
+ }