npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@critical-labs/qa-conductor

v0.3.1

Published

Side-by-side pull request QA: runs your app's base and PR builds on cloned data, mirrors both panes, posts a verdict.

Readme

qa-conductor

A side-by-side PR-QA harness. For a pull request it boots two copies of your app: base (what's live now) and PR (the branch). Each runs against its own clone of real data, behind proxies that mirror scrolling and navigation between the two panes. A reviewer drives both at once and posts a verdict (a comment plus a label) back to the PR.

The conductor owns the choreography: session state, cancellation, the harness UI and API, the pane proxies and the verdict. Everything about your app and infrastructure comes from five adapters you supply, plus an optional sixth, Exposure, through which the conductor publishes itself on a front door such as tailscale serve.

Status: 0.x. The interface may still change: a 0.x minor version may break it, and so may a patch release when a fix needs to. Read the migration notes in CHANGELOG.md before upgrading, and to choose when that happens, install with --save-exact: the ^ range npm saves by default takes patch releases.

Contents

Try it

git clone https://github.com/critical-labs/qa-conductor
cd qa-conductor
npm run demo        # then open http://127.0.0.1:4100/

The demo runs the real conductor against fixture PRs and fake adapters, so it needs nothing but Node: no GitHub token, containers or databases. demo/ is in the repository, not in the npm package. More in Demo.

Requirements

  • Node 22 or later. The package has no runtime dependencies.
  • Linux or macOS. Windows isn't supported: the process Provisioner signals process groups and reads ps, and the Docker one runs sh.
  • The tools each built-in adapter runs, on the conductor's PATH:
    • adapters/build-worktree: git, with worktree support;
    • adapters/provisioner-process: ps, and to tell one boot of the machine from the next, /proc/sys/kernel/random/boot_id on Linux or sysctl on macOS;
    • adapters/provisioner-docker and ./docker: the docker CLI and sh, and a Docker daemon the conductor's user may use;
    • adapters/exposure-tailscale and the expose CLI: a tailscale CLI no older than the daemon (on macOS, the app's bundled one), run by a user the daemon lets change tailscale serve (on Linux, root or the user tailscale set --operator names).
  • No TypeScript declarations yet. The package is plain JavaScript (ES modules); the Types below describe what the adapters exchange.

Install

npm install @critical-labs/qa-conductor

To pin a git tag instead: npm install github:critical-labs/qa-conductor#v0.3.1.

Quickstart: QA your own app

This script is a whole conductor for an app that runs from a checkout with one node command and needs no database. It QAs acme/widget's pull requests on this machine: each pane is a git worktree, main and the PR head, running node server.js on a port of its own. It is this repository's own self-QA, qa/self.mjs, cut down.

  1. Create .env.qa beside it, readable by you alone, since it holds a token, and keep it out of git:
    (umask 077 && touch .env.qa)
    echo .env.qa >> .gitignore
    Then put in it a GitHub token for the repository (see Tokens) and the repository's name:
    GITHUB_QA_TOKEN=<token>
    QA_REPO=acme/widget
  2. Save this as qa.mjs:
    import fs from 'node:fs'
    import os from 'node:os'
    import path from 'node:path'
    import { startConductor } from '@critical-labs/qa-conductor'
    import { loadConfig } from '@critical-labs/qa-conductor/config'
    import { createGithub } from '@critical-labs/qa-conductor/github'
    import { createWorktreeBuild } from '@critical-labs/qa-conductor/adapters/build-worktree'
    import { createProcessProvisioner } from '@critical-labs/qa-conductor/adapters/provisioner-process'
    
    // Panes on loopback: nothing outside this machine reaches them, so no identity gate.
    const cfg = loadConfig('.env.qa', {
      defaults: { QA_BASE_ORIGIN: 'http://127.0.0.1:3101', QA_PR_ORIGIN: 'http://127.0.0.1:3102' },
    })
    const github = createGithub({ token: cfg.githubToken, repo: cfg.repo, qaLabels: [cfg.verdictLabels.accept, cfg.verdictLabels.reject] })
    const cacheDir = path.join(os.homedir(), '.cache/qa-conductor', cfg.repo.replace('/', '-'))
    
    const conductor = startConductor({
      cfg,
      github,
      fsx: { readFile: p => fs.promises.readFile(p) },
      adapters: {
        // main and each PR head in a git worktree, a PR only once it passes the trust gate
        build: createWorktreeBuild({
          repo: cfg.repo,
          cacheDir: path.join(cacheDir, 'build'),
          github,
          servicesFor: dir => ({ app: dir }),
          install: { cmd: 'npm', args: ['ci', '--ignore-scripts'] },
        }),
        // `node server.js` in each worktree, as a process group on 127.0.0.1
        provisioner: createProcessProvisioner({
          stateDir: path.join(cacheDir, 'state'),
          command: ({ ref }) => ({ cmd: process.execPath, args: ['server.js'], cwd: ref }),
        }),
        seed: { databases: [], seedPane: async () => {} },
        envTransform: { derivePaneEnv: ({ pane }) => ({ app: { PORT: String(pane.services.app.port) } }) },
        auth: { requiresDb: false, establishSession: async ({ pane }) => ({ landingUrl: `${pane.publicOrigin}/` }) },
      },
    })
    
    for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
      process.on(sig, () => conductor.shutdown().then(() => process.exit(0)))
    }
  3. Run npm install @critical-labs/qa-conductor beside it, then node qa.mjs, open http://127.0.0.1:3100/qa/, and open a PR. Ctrl-C tears both panes down.

What the pieces do, and what to change for your app:

  • The trust gate. A PR boots only when its author has write access to the repository and its head is in the repository or the author's own fork, since booting it runs its code as you. See adapters/build-worktree.
  • Install and launch. The install runs with --ignore-scripts. The server must be started directly, not through npm run or npx (the launch contract), and must listen on the PORT the EnvTransform gives it, on 127.0.0.1. It counts as up once / answers below 500.
  • No data, no sign-in. The Seed clones nothing and the AuthBootstrap lands each pane on /. To give each pane its own copy of a database, pass the Provisioner a database and write a seedPane; to sign the reviewer in, return a sign-in URL from establishSession. The seams has the contracts.
  • Other devices. To open the harness from another machine, read Security, then Configuration's tailnet sample.

Use

import fs from 'node:fs'
import { startConductor } from '@critical-labs/qa-conductor'
import { loadConfig } from '@critical-labs/qa-conductor/config'
import { createGithub } from '@critical-labs/qa-conductor/github'

const cfg = loadConfig('/path/to/.env.qa', { defaults: { QA_REPO: 'acme/widget' } })
const github = createGithub({ token: cfg.githubToken, repo: cfg.repo, qaLabels: [cfg.verdictLabels.accept, cfg.verdictLabels.reject] })

const conductor = startConductor({
  cfg,
  github,
  fsx: { readFile: p => fs.promises.readFile(p) },   // serves the harness UI files
  adapters: { provisioner, build, seed, envTransform, auth },   // and optionally exposure
  readBaseEnv: async () => ({ /* the env the pane env is derived from */ }),
})

for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
  process.on(sig, () => conductor.shutdown().then(() => process.exit(0)))
}

startConductor takes:

| Option | Default | | |---|---|---| | cfg | (required) | the configuration, from loadConfig or built in code (see Configuration) | | github | (required) | what the core calls on GitHub: createGithub(...), or anything with the members the core uses | | fsx | (required) | { readFile(path) }, which serves the harness UI files from publicDir | | adapters | (required) | { provisioner, build, seed, envTransform, auth }, plus exposure if you want one (The seams) | | readBaseEnv | async () => ({}) | the env the panes' env is derived from, such as production's: passed to derivePaneEnv as prodEnv at each boot | | publicDir | the package's public/ | the directory with the harness UI's index.html and harness.js and the panes' bridge.js | | log | console | where the [qa] lines go: log.log and log.error |

It serves the harness on cfg.ports.harness and one proxy per pane on cfg.ports.base / cfg.ports.pr, all on cfg.host (127.0.0.1 by default). It returns { servers, stop(), shutdown(), exposure }:

  • shutdown() is the graceful exit. It stops the idle reaper and the exposure loop, refuses harness writes from then on (503, so no new boot can start), tears the session down (aborting an in-flight boot and tearing down both panes), ends the progress streams, closes every connection and resolves once all three servers are closed. Call it from your signal handlers; calling it again is a no-op.
  • stop() only stops the reaper and the exposure loop, and asks the servers to close.
  • exposure is the exposure loop's handle, { ready, state(), reconcile() }. ready resolves with the state after the first pass, or as it stands when stop() or shutdown() begins first (at once with no Exposure adapter). state() is the last pass, as GET /api/exposure reports it. reconcile() runs a pass now, or joins the one in flight; before all three servers listen there are no targets to mount yet, so it returns ready.

cfg.paneOrigins must be the URLs viewers actually reach the panes at, and cfg.harnessOrigin the one they open the harness at (see Configuration). To reach the harness from anywhere but the machine it runs on, read Security first, then either:

  • put tailscale serve --https on the same host in front of these ports, in tailscale mode (QA_EXPOSURE=tailscale, the default for any layout that isn't loopback throughout): every server then answers only the Tailscale logins in QA_ALLOWED_LOGINS, and listens on loopback. Pass an Exposure adapter and the conductor sets up and keeps those mounts itself; or
  • put another TLS front door that authenticates viewers in front of them, and set QA_EXPOSURE=none.

Entry points

| Import | Exports | | |---|---|---| | @critical-labs/qa-conductor | startConductor | the conductor: harness, pane proxies, sessions, the exposure loop (Use) | | @critical-labs/qa-conductor/config | loadConfig, parseEnvFile, defaultExposure, defaultHarnessOrigin, isExposureInterval, EXPOSURE_MODES, EXPOSURE_INTERVAL_RULE, MAX_EXPOSURE_INTERVAL_MINUTES, HARNESS_PATH | reading .env.qa into cfg (Configuration), and the rules it checks | | @critical-labs/qa-conductor/session | bootSession, teardownSession, createSession, reduce, touch, isIdle, ROLES, PANE_STAGES, parseEnv, renderEnv, migrateImageFor | the boot sequence and session state the conductor runs, and helpers for adapters: parsing and rendering env file text, and the migrate-<tag> image beside an app image | | @critical-labs/qa-conductor/github | createGithub | the GitHub effect wrapper | | @critical-labs/qa-conductor/docker | createDocker | the Docker effect wrapper | | @critical-labs/qa-conductor/exec | makeExecFileFn | the execFile effect wrapper | | @critical-labs/qa-conductor/identity | normalizeLogins, refusalReason, isAllowed, identityGate | the Tailscale identity gate | | @critical-labs/qa-conductor/exposure | mountsFor, reconcileExposure, runExpose | one exposure pass, outside a conductor | | @critical-labs/qa-conductor/proxy | createPaneProxy, panePolicy, parseSetCookie, isAllowedHost, requestHostname, misdirected | the pane proxy, and the Host allowlist check every server runs | | @critical-labs/qa-conductor/verdict | formatVerdict, postVerdict | the verdict comment and label | | @critical-labs/qa-conductor/adapters/provisioner-docker | createDockerProvisioner | a Provisioner that runs each pane as containers on the host's Docker daemon | | @critical-labs/qa-conductor/adapters/provisioner-process | createProcessProvisioner | a Provisioner for local process groups | | @critical-labs/qa-conductor/adapters/build-worktree | createWorktreeBuild, trustDecision | a BuildConvention that runs PRs from git worktrees, behind a trust gate | | @critical-labs/qa-conductor/adapters/exposure-tailscale | createTailscaleExposure | an Exposure adapter on tailscale serve | | @critical-labs/qa-conductor/package.json | (the manifest) | |

Each row lists every name its entry point exports, and no other entry point is exported. The package also installs one bin, qa-conductor-expose, the expose CLI, for operators.

Using the harness

Open the harness at its origin, under /qa/, as startup logs it ([qa] harness at <origin>/qa/); anywhere else it shows a banner that links there. Then:

  • Pull requests. The picker lists the open PRs, at most the 50 most recently opened, with each one's build status: ready — opens in seconds, building…, needs build or can't boot: <reason>. ↻ reloads the list and Open QA boots a PR. One session runs at a time: opening another PR offers Resume for the running one, or End #N and open #M to take over.
  • Boot. The four steps, Building, Preparing data, Migrating and Starting, each timed, with the BuildConvention's messages under the first. Cancel boot tears the boot down. A failed boot shows the error and the failing pane's log tail, with Retry boot.
  • Mirror (m) replays each click, key and form value from one pane in the other. Its ⌄ menu turns scroll mirroring off and on (s).
  • Re-sync → (r) reloads the PR pane at the base pane's path, and its ⌄ menu the base pane at the PR pane's. A dot on it says the panes are at different paths.
  • 375, 768 and Full (1, 2, 3) set both panes' width.
  • auto-end counts down to the idle teardown, QA_IDLE_MINUTES after the last interaction, and turns amber for the last five minutes.
  • End session (e) tears the session down once you click it again within three seconds.
  • Each pane's bar has a dot (amber while signing in, green while the pane's bridge reports, grey after 10 seconds of silence), the pane's path, a count of interactions the other pane couldn't replay (click it to clear), and buttons to copy the path, reload the pane and open it in a new tab.
  • Verdict (v) opens the drawer: notes, kept in the browser as a draft for each PR, then Accept or Reject, a preview of the comment and the label change, and Post to PR #N. After posting: End session, Keep session open or Back to pull requests.
  • ? lists the keyboard shortcuts, which work while the harness, not a pane, has the focus. Esc closes the drawer or the list, or cancels an end-session click. Below 900 pixels wide, the panes become BASE and PR tabs.

Security

The trust gate is the only real boundary between a PR's code and the reviewer's machine. Booting a PR runs its code. A BuildConvention that checks out and installs PRs must refuse untrusted ones in ensureBuilt, before any git call. Env scrubbing and loopback binding are defence in depth, not a boundary.

The conductor's own defences in depth:

  • Tailscale identity gate. In tailscale mode (QA_EXPOSURE=tailscale), the harness and both pane proxies answer 403 to any request whose Tailscale-User-Login isn't in QA_ALLOWED_LOGINS (matched ignoring case), before anything else runs. The gate wraps each whole server, so no route, present or future, runs for such a request, upgrades included, and the 403 still carries the harness frame lock or the pane's frame policy. tailscale serve sets that header from the device the request came from, strips any copy the client sent, and sets none for tagged devices, which are refused. An empty allowlist refuses everyone, so loadConfig refuses one in tailscale mode (startConductor only logs an error). The pane proxies drop every Tailscale-* header before the pane app sees the request. Other front-door headers pass through, such as X-Forwarded-For, which tailscale serve sets to the viewer's tailnet address: the pane app, PR code included, still sees which device is viewing.

    QA_EXPOSURE defaults to tailscale when anything the conductor answers to or listens on is off loopback: the harness origin, either pane origin, QA_PUBLIC_HOST, a QA_ALLOWED_HOSTS entry or QA_BIND_HOST (see Configuration). Loopback layouts, such as npm run qa and the demo, stay ungated with no config.

    The gate trusts the header from anything that can reach loopback on this host. That is why tailscale mode requires a loopback bind: on any other address, a client could send its own login. It also means the gate keeps out other devices, not code on this one. With provisioner-process, the pane processes run PR code on the same host, as the same user, so they can reach loopback and send any login they like: in tailscale mode the gate does not protect against PR code, and only the BuildConvention's trust gate does. Containers don't change that everywhere. On native Linux Docker, a container on a bridge network can't reach the host's loopback, but Docker Desktop (through host.docker.internal) and some rootless runtimes forward to it, and there a container pane can send any login too.

    The gate works only behind tailscale serve --https, which sets Tailscale-User-Login and drops any copy the client sent. Never put Funnel in front of the conductor, which opens it to the internet, nor tailscale serve --tcp or --tls-terminated-tcp, which pass the client's bytes through unchanged, so a header it writes itself reaches the gate. And never put tailscale serve in front of a conductor in none mode (QA_EXPOSURE=none): with no gate, every device on the tailnet gets in.

  • Loopback by default. All three servers listen on QA_BIND_HOST, default 127.0.0.1, and tailscale mode refuses any other address (loadConfig and startConductor throw). In none mode, anything other than loopback exposes an unauthenticated API that returns pane login URLs and posts verdicts with GITHUB_QA_TOKEN. A pane proxy is an authenticated pane session, because the proxy holds the pane's cookie jar. Widen the bind only in none mode, behind a firewall or an authenticating front door, and have viewers reach the harness and the panes over https (a plain-http origin must be loopback, see the pane request guard below).

  • Host allowlist. Every server checks the Host header before routing (the harness does so before it even parses the request target) and answers 421 unless its hostname (port ignored, [] stripped) is 127.0.0.1, localhost, ::1, QA_PUBLIC_HOST, the hostname of the harness origin or of either pane origin, or an entry in QA_ALLOWED_HOSTS. This defeats DNS rebinding. A front door must pass the viewer's Host through, or rewrite it to a loopback Host. A rewritten Host that is allowed but not loopback (qa-conductor:3100 behind nginx, say) clears this check, but every browser /api/* call then gets 403 not the harness origin (see Same-origin API).

  • Pane request guard. The proxy holds each pane's cookie jar, so every request that reaches a pane acts as the reviewer, and the reviewer's browser sends requests for any page they have open. Loopback and the Host allowlist don't stop that. So a pane answers 403 to:

    • a write, a preflight, a CORS read or a WebSocket that doesn't come from the pane's own pages (sec-fetch-site same-origin or none, else an Origin whose host is the request's Host);
    • a subresource load (<img>, <script>, a no-cors fetch) from another site;
    • a navigation from another site, into a frame or top-level, unless its Referer is the harness origin, as it is for the harness's own iframes and "Open in new tab" links. A redirect the app answers such a navigation with keeps that Referer for the next hop, unless the redirect's own Referrer-Policy drops it, so the proxy removes that header from a redirect that stays on the pane.

    same-site counts as another site: on loopback every other port is same-site, and so is every host in a tailnet. A request the guard admits gets the whole jar.

    The guard works from Fetch Metadata (sec-fetch-site, sec-fetch-mode, sec-fetch-dest), which browsers send only to https and loopback origins. A request without it (curl, an older browser) is judged by its Origin alone, and another page's <img>, <iframe> or link sends no Origin. Over plain http on any other host, the guard couldn't tell those from curl, so loadConfig and startConductor refuse an http: harness or pane origin whose host isn't loopback (127.0.0.0/8, ::1, localhost).

    The guard keeps out other pages, not the panes' own apps. A pane's app sees the harness's navigations, and when it answers one with a redirect, the next hop keeps the harness Referer wherever it goes. So the PR pane's app can redirect the harness's frame, or a new tab, to any URL of the base pane, which serves it with the reviewer's session. The trust gate is the boundary against PR code.

  • The pane apps get only their jar's cookies. A pane proxy keeps the app's cookies in its jar and never passes the app's Set-Cookie on to the browser. Nor does it pass the browser's own Cookie header on to the app. Cookies ignore ports, so that header carries every cookie for the pane's hostname: another app's on another port, its session among them, and any the other pane's scripts set with document.cookie. And the app runs PR code. An app that reads a cookie its own scripts set (a locale, say) gets it only when QA_FORWARD_CLIENT_COOKIES names it. The jar's value wins on a name both have, and any page on the hostname, the other pane included, can set that cookie. So such a cookie passes only when its value is an RFC 6265 cookie-value. A browser sends back whatever a page wrote, so a value with a space, a comma, a backslash or a stray double quote could hide another cookie (locale=fr, sid=evil) for a lenient parser to read, and the proxy drops it. The jar's cookies come first in the header the app gets. And each jar holds one session's cookies: the conductor gives both panes fresh jars when a session is torn down, when the next one starts booting and when it is ready, and detaches both panes while a session boots, so no PR's app gets the cookies an earlier session's app set.

    The panes' pages still share the browser's cookies with every app on their hostname. A pane's scripts (PR code) run there, so they can read that hostname's cookies that aren't HttpOnly, and set cookies the browser then sends to every app on it. They can also send those apps requests that carry their cookies, HttpOnly and SameSite=Strict ones included (a form post, a no-cors fetch), because every port of the hostname, and every host in a tailnet, is same-site: PR code can act on the reviewer's session in another app without reading it. Serve the panes on a hostname no other app uses. That stops the cookie sharing, not those requests, so another app the reviewer is signed in to must not rely on SameSite alone against the panes.

  • Pane frame lock. Every pane response, the proxy's own 403, 421, 502 and 503 included, carries Content-Security-Policy: frame-ancestors 'self' <harness origin> <QA_FRAME_ANCESTORS…> and X-Content-Type-Options: nosniff. The policy replaces the app's own frame-ancestors directive (its other directives are kept), and the app's X-Frame-Options is dropped. A value that isn't an http(s) origin is left out, and so is an IPv6 literal, which a CSP source can't express.

  • The mirror bridge talks only to the harness. The proxy puts the harness origin on the script tag it injects (data-harness). The bridge posts only to its parent frame at that origin, applies replays only from its parent at that origin, and does nothing without one. So a page that frames a pane, the PR pane included, can neither hear nor drive its bridge. The PR pane's code can still drive the base pane through the harness, as mirroring does: while the mirror is on, the harness replays in the base pane every interaction the PR pane posts to it (a click, a key or a value on any selector), and PR code can post those itself.

  • Harness frame lock. Every harness response, errors and refusals included, carries frame-ancestors 'self', X-Frame-Options: SAMEORIGIN and Referrer-Policy: strict-origin-when-cross-origin. No other page can frame the harness to trick a click that starts a session or posts a verdict, and the harness's frame loads and new tabs always send its origin as the Referer the panes check.

  • Open the harness at its origin. The panes trust exactly one harness origin, QA_HARNESS_ORIGIN, which startup logs as [qa] harness at <origin>/qa/. Opened anywhere else that reaches it (localhost for 127.0.0.1, or another front-door port), the harness shows a banner linking to that origin, and the panes answer 403. The harness and the panes may still use different hosts: only the URL the harness itself is opened at must match. An IPv6 literal can't be that origin, since a CSP source can't name one: with a ::1 bind, set QA_HARNESS_ORIGIN=http://localhost:<port> (startup logs an error until you do).

  • Same-origin API. Every harness /api/* request, reads and the progress stream included, gets 403 cross-site request refused when sec-fetch-site is present and isn't same-origin or none, or, absent that, when Origin is present and its host isn't the request's Host. Another page can't read the answers, but its writes would run, and each read of /api/prs spends GitHub API calls. A browser (a request with either header) must also reach the API at the harness origin's host and port, else it gets 403 not the harness origin (its body names harnessOrigin, for the banner): a page served by another front-door handler that proxies to the harness, such as a stale tailscale serve mount on another port, is same-origin with that handler. Loopback Hosts are exempt from that second check, and non-browser clients, which send neither header, from both. Like the pane guard, this needs Fetch Metadata: over plain http off loopback, another page's <script src> GET sends neither header and passes as a non-browser client.

  • JSON bodies. POST /api/session, /api/verdict and /api/teardown require content-type: application/json (else 415). A cross-site form can't send that type, and a cross-site fetch() with it needs a CORS preflight the harness never grants.

  • Inert rendering. The harness UI renders PR titles, build messages, blocked reasons, errors and log tails as text, and links a build run only when its URL is https://.

The seams

A boot runs these five seams in order: ensureBuilt → per pane (provisionDatabase → seedPane → reserveServices) → derivePaneEnv (+ runMigrate) → launchServices → waitHealthy → establishSession. A sixth, Exposure, is optional and outside the boot: it publishes the conductor itself. The shapes they pass each other are under Types.

| Seam | Members | Owns | |---|---|---| | Provisioner | provisionDatabase({paneRef, databases, signal}) → {dsn, db}, reserveServices({paneRef, services, signal}) → {name: {url, port}}, launchServices({paneRef, services, env, reserved, signal}), waitHealthy({services, signal}), teardown({paneRef}); optional runMigrate({paneRef, migrate, env, signal}), sweep(), logs({paneRef, stage, lines}) → string (or a promise of one) | Where panes run: databases, processes or containers, ports, env at rest, cleanup | | BuildConvention | migrationStrategy ('one-shot-image' \| 'on-boot' \| 'none'), ensureBuilt(pr, {signal}), resolvePrImages(pr), resolveBaseImages() → {services: {name: ref}, migrate?, label?}; optional subscribeBuild(cb) with cb({runUrl?, runStatus?, message?}), describePrs(prs) → [{number, status: 'built'\|'building'\|'none'\|'blocked', runUrl, reason?}] | What gets run for base and PR, whether it's ready, and whether it may run at all | | Seed | databases, seedPane({paneRef, db, databases}) | Where each pane's data comes from and how it moves | | EnvTransform | derivePaneEnv({prodEnv, pane}) → {service: env} (pure) | Pointing a pane at its own DB and origin, and neutralizing side effects (email, payments, storage) | | AuthBootstrap | requiresDb, establishSession({pane, operator, db?}) → {landingUrl}; optional envContributions() → {service: env} | Getting the reviewer logged in to each pane |

The optional members degrade gracefully when absent:

  • With no sweep, nothing is cleaned up at startup.
  • With no logs, failures show no log tail.
  • With no describePrs, every PR shows as none.

The one exception: a build that declares one-shot-image migrations with a Provisioner that can't runMigrate fails the boot with a clear error.

Types

What the seams pass each other. Any method above may return its result or a promise of it: the core awaits each one.

  • PaneRef: { role, slug, publicOrigin } during a boot. role is 'base' or 'pr', slug is qa-<pr>-<role>, and publicOrigin is the pane's origin from cfg.paneOrigins. teardown and logs get only { role }, since they also run outside a boot.
  • ImageSet: { services: { name: ref }, migrate?: { image }, label? }, from resolveBaseImages() and resolvePrImages(pr). A ref is whatever the Provisioner launches, such as an image name or a checkout directory.
  • Reserved: { name: { url, port } }, from reserveServices: where each service will listen. Only the primary service is proxied, app when there is one, else the first: the pane proxy forwards to 127.0.0.1:<port> of that service, so it must listen there. Any other service is the app's own business.
  • Pane: { ref, dsn, db, services, publicOrigin, env }. ref is the PaneRef, dsn and db come from provisionDatabase, services is the Reserved map, and env is the pane's env. derivePaneEnv gets the pane before it has an env; establishSession gets it whole.
  • PaneEnv: { service: { KEY: value } }, from derivePaneEnv. EnvContributions, the same shape from auth.envContributions(), is merged over it service by service. runMigrate and launchServices get the result as env.
  • SessionResult: { landingUrl }, from establishSession: the URL the harness loads in the pane's frame. The core reads nothing else from it, so a session cookie must reach the pane's jar as a Set-Cookie the landing URL answers with (see below).
  • BuildEvent: { runUrl?, runStatus?, message? }, what subscribeBuild's callback gets.
  • Readiness: { number, status, runUrl, reason? }, one per PR from describePrs, with status one of 'built', 'building', 'none' and 'blocked'.

Contracts between the seams

  • db is opaque to the core. Whatever provisionDatabase returns as db is passed unchanged to seedPane and (when requiresDb) to establishSession. Its shape is a contract among a consumer's own adapters.
  • Cancellation (signal). Teardown and takeover abort the in-flight boot. The core checks the signal between stages, at the top of each pane's provisioning, before each launchServices and before the waitHealthy loop. It also passes the signal to ensureBuilt and to every Provisioner call above, so a long wait can stop early. Provisioners may ignore it (the Docker one does). An aborted boot never tears anything down, even when the abort lands while its failure log tail is being read: whoever aborted it already did.
  • Startup sweep. The conductor calls sweep() once, at startup. A boot started before it settles waits for it before ensureBuilt, so the sweep can't remove the new session's panes. While it waits, the harness shows waiting for startup cleanup… under the first boot step, then startup cleanup done, and counts the wait in that step's time and the boot's. There is no timeout. A failed sweep is logged and doesn't block boots, even one that throws synchronously or rejects with something other than an Error. A teardown or takeover during the wait cancels the waiting boot, as it would at any other point.
  • Build progress. subscribeBuild(cb) payloads are {runUrl?, runStatus?, message?}. The harness shows message (plain text, e.g. installing dependencies for #12 (abc1234)…) under the first boot step, else a summary of the run's status, and links the run when runUrl is https://. /api/state returns the latest as buildRun: {url, status, message}.
  • blocked. describePrs may report a PR as blocked, with a plain-text reason (for example, an untrusted author or a head branch in someone else's fork). The picker shows it as can't boot: <reason>. Opening the PR is still allowed, because ensureBuilt is the real gate. describePrs receives listOpenPrs() items, or for /api/build-status an item built from github.prInfo(pr) ({number, headSha, author, authorAssociation, headRepo, headOwner}), falling back to {number, headSha} from prHead when github has no prInfo.
  • Display label. resolveBaseImages / resolvePrImages may return a label string, used as the pane's tag instead of the primary service's ref (app, else the first service). The label appears in the harness header and in the public verdict comment. Consumers whose service refs are local paths or objects must set one, so no path or object leaks into the PR. It must not contain :.
  • Failure log tails. When a boot fails at a pane stage (cloning, migrating, starting), the core calls logs({paneRef: {role}, stage, lines: 40}) for the failing pane before tearing the panes down, and attaches the result to the error as err.logTail (with the pane's role as err.failedRole). An error that already carries a string logTail keeps it, so a BuildConvention can attach its own tail to an ensuring-image failure (for example, installer output). The harness shows the tail under the error.
  • Landing flows stay on the pane origin (AuthBootstrap). The harness loads each landingUrl as given; the bridge learns the harness origin from the proxy, not from the URL. A pane serves a navigation from another site only when its Referer is the harness origin, so a sign-in step on another site, such as an external identity provider's form, comes back with that site's Referer and is refused. Keep landing flows on the pane origin. Redirects within it are fine, whatever their Referrer-Policy: the proxy drops that header from a redirect that stays on the pane, so the next hop still carries the harness Referer.
  • Sessions live in the jar (AuthBootstrap). The pane app gets the cookies its own responses set this session, which the proxy keeps, and not the browser's. So the session must reach the jar in a Set-Cookie: have the landingUrl, or a redirect it makes, set it server-side. A session cookie the app's scripts write can't be kept apart for two panes on one hostname, QA_FORWARD_CLIENT_COOKIES or not. Cookies ignore ports, so both panes' pages write the same browser cookie, whichever pane signs in last overwrites it, and both proxies would pass that one value on: one pane's app would get the other pane's session (see Security).

Reference consumers: this repository's self-QA (qa/self.mjs: build-worktree and provisioner-process, described in CONTRIBUTING.md), the demo's fake adapters (demo/fake-adapters.mjs), and agent-identity's packages/qa, a 0.2-era consumer that seeds each pane's DynamoDB Local from a redacted snapshot.

Built in: adapters/build-worktree (git-worktree BuildConvention)

A BuildConvention for apps that run a PR from its source rather than from CI-built images. It fetches the base branch and the PR head into a bare repository, checks each SHA out into its own git worktree, optionally installs dependencies, and hands each worktree directory to your Provisioner through servicesFor.

import os from 'node:os'
import path from 'node:path'
import { createWorktreeBuild } from '@critical-labs/qa-conductor/adapters/build-worktree'

const build = createWorktreeBuild({
  repo: 'acme/widget',
  cacheDir: path.join(os.homedir(), '.cache/qa-conductor/acme-widget'),
  github,                                          // needs prInfo(num) and authorPermission(login)
  servicesFor: (dir, { role, sha }) => ({ app: dir }),
  install: { cmd: 'pnpm', args: ['install', '--frozen-lockfile', '--ignore-scripts', '--ignore-pnpmfile'] },
})

| Option | Default | | |---|---|---| | cloneUrl | https://github.com/<repo>.git | must not contain credentials (construction throws); for a private repo, configure a git credential helper | | install | null (no install) | { cmd, args, env? }, run in the worktree | | baseRef | 'main' | the branch the base pane runs | | trust | { logins: [], associations: ['OWNER', 'MEMBER', 'COLLABORATOR'], requirePush: true, allowForks: true } | see below; omitted keys keep their defaults. logins and associations must be arrays of strings (split a comma-separated env value first); anything else throws at construction | | keep | 6 | the newest keep builds survive pruning; the two SHAs just built are never removed, even when they fall outside that number | | migrationStrategy | 'on-boot' | |

How it works:

  • Layout under cacheDir. The bare repository is repo.git, created on first use, and each checkout is worktrees/<sha>. Markers live at built/<sha>.json, outside the checkouts, so a PR's committed files can't supply one. A marker records a fingerprint of the install command, args and env keys. If it doesn't match, or the checkout is gone, the marker is dropped and the SHA is rebuilt from a clean checkout.
  • The cache must be yours alone. cacheDir, built/ and worktrees/ are created 0700. Each build refuses to run unless each of them is a real directory (not a symlink), owned by the current user, and not writable by group or others: anyone who can write there can plant a marker and a tree for the base SHA.
  • One build at a time per instance. A second caller waits, then runs its own build, re-checking the markers.
  • Progress goes to subscribeBuild as { message } (fetching acme/widget…, installing dependencies for #12 (1a2b3c4)…, #12 (1a2b3c4) already built). Each ensureBuilt reports to the subscriber that was current when it was called, and says nothing more once its signal aborts. An install failure's error carries a logTail: the last 40 lines of its output, at most 8 KB.
  • resolvePrImages / resolveBaseImages return servicesFor(dir, …), migrate: null, and a label such as #12@1a2b3c4 or main@9f8e7d6, which never contains a path.
  • describePrs reports blocked (with a reason) for a PR that fails the trust gate, then building, built or none.
  • Pruning runs after each build and is best-effort. The newest keep builds are kept, and the two SHAs just built are never removed, even when they fall outside that number. Every other checkout is removed, including ones whose build never finished.

Security. This adapter checks out and installs a PR's code on the reviewer's machine, as the reviewer, and your Provisioner then runs it. The trust gate is the only real boundary between a PR's code and the reviewer's machine. Env scrubbing and loopback binding are defence in depth.

  • The trust gate. ensureBuilt refuses a PR, before any git call, unless all of these hold:
    • its author is in trust.logins, or has an association in trust.associations and, when requirePush is set (the default), write or admin permission on the repo;
    • its head repository still exists;
    • its head branch is in the repo itself or in the author's own fork, not in someone else's fork. With allowForks: false, only the repo itself.
  • Association isn't access. author_association alone is not an access check. COLLABORATOR includes read-only outside collaborators, and MEMBER includes org members with no push access. That's why requirePush defaults on.
  • The gate vouches for the author, not for every commit. It checks who opened the PR and where its head lives, not who pushed the head commit. Anyone with push access to the author's fork (collaborators they added, bots or Actions with write access to it) can move the head, and the moved head passes as the author's. Set allowForks: false to require heads in the repo itself, where everyone who can push has write access to the repo.
  • Only the gated SHA runs. Only the exact SHA that passed the gate is ever checked out or installed. If the head moves between the check and the fetch, the new SHA is gated again, or the build fails with head moved during fetch; retry.
  • It fails closed. Any error while checking (a GitHub error, a missing field) refuses the PR, and a malformed trust config throws at construction.
  • Defence in depth, not a sandbox.
    • The installer's environment is exactly PATH, HOME and install.env, so credentials in environment variables (such as GITHUB_QA_TOKEN) don't reach it. Files under HOME do: it can read ~/.npmrc tokens, ~/.config/gh, ~/.git-credentials and ~/.ssh.
    • git runs with hooks disabled and prompts off.
    • A PR that passes the gate still runs with the reviewer's full user access. Code it runs (install scripts, a package manager it selects, the server your Provisioner launches) can rewrite this cache, including markers and other checkouts such as the base, like anything else the reviewer can write.
  • Skip install scripts. Install with --ignore-scripts (with pnpm, also --ignore-pnpmfile) so dependency lifecycle scripts and pnpmfiles don't run.

Built in: adapters/provisioner-process (process Provisioner)

A Provisioner for local processes. It runs each pane's services, and optionally a per-pane database, as process groups on 127.0.0.1, with a scrubbed env, a pidfile and a start-time-checked orphan sweep. Its launch contract is below.

import net from 'node:net'
import os from 'node:os'
import path from 'node:path'
import { setTimeout as sleep } from 'node:timers/promises'
import { createProcessProvisioner } from '@critical-labs/qa-conductor/adapters/provisioner-process'

const ddbDir = '/opt/dynamodb-local'   // where DynamoDBLocal.jar is unpacked

// Resolves once something accepts connections on the port.
async function waitForPort(port, { signal }) {
  while (!signal.aborted) {
    const open = await new Promise(resolve => {
      const socket = net.connect(port, '127.0.0.1', () => { socket.end(); resolve(true) })
      socket.on('error', () => resolve(false))
    })
    if (open) return
    await sleep(250)
  }
}

const provisioner = createProcessProvisioner({
  stateDir: path.join(os.homedir(), '.cache/qa-conductor/acme-widget'),  // holds pids.json
  // `ref` is the service's entry from the BuildConvention, e.g. a checkout directory
  command: ({ name, ref, port, env, paneRef }) => ({
    cmd: path.join(ref, 'node_modules/.bin/tsx'),
    args: ['packages/api/src/dev.ts'],
    cwd: ref,
    env: { HOST: '127.0.0.1', PORT: String(port) },
  }),
  database: {                                  // optional: one per pane
    command: ({ paneRef, port }) => ({ cmd: '/usr/bin/java', args: ['-jar', 'DynamoDBLocal.jar', '-inMemory', '-port', String(port)], cwd: ddbDir }),
    ready: ({ port, signal }) => waitForPort(port, { signal }),
    handle: ({ paneRef, port }) => ({ dsn: `http://127.0.0.1:${port}`, db: { endpoint: `http://127.0.0.1:${port}` } }),
  },
  healthPath: '/ui/',                          // or (serviceName) => path; default '/'
  healthy: status => status === 200,           // default: status < 500
})

The other options are healthTimeoutMs (60000, per service), host (127.0.0.1: the address in reserved urls, health checks and the free-port lookup), graceMs (5000, from SIGTERM to SIGKILL), logLines (200 per pane) and log (console). Every effect is injectable: spawnFn, killFn, freePortFn, fetchFn, fsx, psFn, pgroupFn, bootIdFn, sleepFn, nowFn, onExitFn and baseEnv.

Launch contract. command and database.command must start the server binary directly, never through a package manager (pnpm dev, pnpm exec, npm run, npx). The child has no TTY and only PATH plus the env you declare, and the package manager on that PATH may not be the one that ran install: pnpm 10 and later verify dependencies before run/exec and, with no TTY, fail or reinstall. So use, for example, { cmd: join(dir, 'node_modules/.bin/tsx'), args: ['packages/api/src/dev.ts'], cwd: dir } or { cmd: process.execPath, args: ['demo/server.mjs'], cwd: dir }. The process you start is the server: if it exits, the pane has failed.

  • Env. A service gets exactly { PATH, ...env[name], ...spec.env }: the conductor's PATH, the pane env from your EnvTransform, then the command's own env. The database gets { PATH, ...spec.env }. Nothing else is inherited: no credentials, and no HOME unless you declare it.
  • Ports. reserveServices picks a free port per service on host, and never hands out a port another pane or service still holds. Nothing starts until launchServices. Your command must make the server listen on that port, bound to host.
  • Health. waitHealthy polls <url><healthPath> until healthy(status), for up to healthTimeoutMs. A process that exits first fails the boot at once, with its log tail. healthy(status) gets the service's own response status: redirects aren't followed (an app often redirects to its public origin, the pane proxy, which answers 503 until the boot is done), so under the default status < 500 a 3xx counts as up. A strict predicate such as status === 200 needs a healthPath that doesn't redirect.
  • Logs. Each pane keeps its last logLines lines of stdout and stderr, prefixed [name], and logs() returns the tail. The buffer survives teardown, and resets when the pane's next boot starts.
  • Teardown signals process groups, never bare pids, because a wrapper may exit at once while its server lives on. Services go first, then the database: SIGTERM, then SIGKILL after graceMs. A group that survives SIGKILL is logged and left for the next sweep. Once a leader has exited, its pid can be reissued, so teardown checks it with ps first: a pid held by another process means our group is gone, and it isn't signalled.
  • Crash cleanup. Each process is recorded in <stateDir>/pids.json with its start time from ps and the boot it ran in. The directory must be a real directory owned by you and not writable by group or others (it's created 0700; one that others could write to is refused, since its contents may be planted), and pids.json must be a regular file owned by you (written 0600). sweep(), which the conductor runs at startup, kills the groups a previous run left behind. It skips any pid that now belongs to another process, drops entries from an earlier boot without signalling anything, and leaves in place entries it can't check (no start time). Give each conductor its own stateDir. A process.on('exit') hook SIGKILLs every live group; signals don't run it, so call the conductor's shutdown() from your signal handlers.
  • There's no runMigrate: process consumers migrate on boot.

Security. A PR's code runs as your user, on your machine. The trust gate is the only real boundary between a PR's code and the reviewer's machine: the BuildConvention must refuse to build a PR it doesn't trust. Env scrubbing and loopback binding are defence in depth. The Tailscale identity gate is no boundary against PR code either: a pane process can reach the conductor on loopback and send any Tailscale-User-Login it likes (see Security).

Built in: adapters/provisioner-docker (Docker Provisioner)

A Provisioner that runs each pane as two containers on the host's Docker daemon, a postgres one and an app one, on a network both panes share. It writes each pane's env to an owner-only file for docker run --env-file, logs in to the registry, runs one-shot migrations, and at startup removes whatever an earlier run left behind.

import fs from 'node:fs'
import { createDocker } from '@critical-labs/qa-conductor/docker'
import { makeExecFileFn } from '@critical-labs/qa-conductor/exec'
import { createDockerProvisioner } from '@critical-labs/qa-conductor/adapters/provisioner-docker'

const provisioner = createDockerProvisioner({
  docker: createDocker({ execFileFn: makeExecFileFn() }),
  fsx: fs.promises,
  workDir: '/var/lib/qa-conductor',
  registry: { user: 'acme-bot', token: cfg.ghcrToken },
})

| Option | Default | | |---|---|---| | docker | (required) | a createDocker(...) (Effect wrappers) | | fsx | (required) | { writeFile, unlink }, such as fs.promises: writes the env files, mode 0600, and removes them at teardown | | workDir | (required) | the directory the env files go in, as .env.qa-base and .env.qa-pr. The docker CLI reads them by path, so it must see the same directory | | network | 'qa-session' | the Docker network both panes' containers join | | postgres | { user: 'qa', password: 'qa', db: 'postgres' } | the credentials and admin database in the dsn it returns: match createDocker's postgres | | hostPorts | { base: 3111, pr: 3112 } | the loopback host port each pane's app container is published on | | registry | null | { user, token }: log in to ghcr.io before pulling, and again before each retry |

For each pane:

  • provisionDatabase starts qa-pg-<role> on the network, waits until it answers twice in a row, creates each of the Seed's databases, and returns the dsn postgresql://<user>:<password>@qa-pg-<role>:5432 and a db of { dsn, query(sql, { database }) }, which runs psql in the container.
  • runMigrate runs migrate.image once on the network, with the pane's app env (else its first service's), and removes the container after.
  • launchServices pulls each image that's missing (three tries), then runs it as qa-app-<role>, with its port 3000 published on 127.0.0.1:<hostPorts[role]>.
  • waitHealthy polls http://127.0.0.1:<port>/api/health until it answers 200, for up to a minute.
  • logs tails the app container at starting, else the postgres one.
  • teardown removes both containers and their volumes, the network once no pane uses it, and the env file. sweep removes every container with createDocker's label, and the network.

Its limits:

  • One service per pane. Every service would run as qa-app-<role> with the same env file.
  • Fixed names and ports. The containers, the network and the host ports are the same on every run, so run one conductor per Docker host.
  • The app's side: listen on port 3000 in the container, and answer GET /api/health with 200.
  • The registry login is to ghcr.io only.
  • It ignores signal, so a teardown waits for a Docker call in flight.

Effect wrappers

github. The core calls only these, so anything that has them will do:

  • listOpenPrs() → [{ number, title, headSha, headRef, author, authorAssociation, headRepo, headOwner }], for the picker;
  • prInfo(num) → { number, headSha, author, authorAssociation, isDraft, headRepo, headOwner }, or without it prHead(num) → headSha, for /api/build-status;
  • postComment(num, body) → the comment's URL, and setQaLabel(num, label), for the verdict.

adapters/build-worktree also needs prInfo and authorPermission(login). createGithub(options) makes one on GitHub's REST API:

| Option | Default | | |---|---|---| | token | (required) | every call but the package listing (Tokens) | | repo | (required) | owner/name | | qaLabels | ['qa-approved', 'qa-changes-requested'] | the accept and reject labels, [cfg.verdictLabels.accept, cfg.verdictLabels.reject]: setQaLabel adds one and removes the other | | packagesToken | token | the GHCR package version listing only (cfg.ghcrToken) | | packageName | null | the container package the GHCR helpers read; they throw without it | | rcTagPattern | /-rc\.\d+$/ | which tags latestRcTag() counts as release candidates | | previewWorkflow | 'pr-preview.yml' | the workflow dispatchPreviewBuild(num) runs, with the input pr, and findPreviewRun(num) reads | | previewRef | 'main' | the ref that workflow is dispatched on | | fetchFn | fetch | |

  • listOpenPrs() returns at most the 50 most recently opened PRs: it reads one page.
  • headRepo (owner/name) and headOwner are null when the head repository was deleted.
  • authorPermission(login) returns the login's admin, write, read or none permission on the repo, and throws on a non-2xx response. author_association alone is no access check: COLLABORATOR includes read-only outside collaborators.
  • The GHCR and preview-build helpers are for a BuildConvention that runs CI-built images: ghcrTagExists(tag), latestRcTag(), listPrImageTags(), awaitPreviewImage(num, sha, { timeoutMs, pollMs, signal }), which waits for the tag pr-<num>-<the sha's first 12 characters>, dispatchPreviewBuild(num) and findPreviewRun(num). They read a package that the token's own user owns, not an organization's.

docker. createDocker({ execFileFn, label, postgres }) runs the docker CLI for the Docker Provisioner, and for a Seed that copies databases between containers:

| Option | Default | | |---|---|---| | execFileFn | (required) | makeExecFileFn() from ./exec | | label | 'qa-conductor-session' | put on every container and network it creates; sweepQaContainers() removes whatever carries it | | postgres | { image: 'postgres:16', user: 'qa', password: 'qa', db: 'postgres' } | the pane database containers; keys you leave out keep these values |

Its members are run(argv), login(user, token) (to ghcr.io, with the token in the environment, never in argv), imagePresent, ensureImage(image, { retries, relogin }), runPg, waitHealthyPg, createDatabase, pipeDump(from, to, db) and cloneDb(from, to, db) (a host-side pg_dump | psql between two containers), runMigrate, runApp, waitHealthyApp, psql, rmForce, createNetwork, rmNetwork, sweepQaContainers, inspectImageOf and logsTail. login, createDatabase, pipeDump, cloneDb and logsTail throw on a name that isn't letters, digits, _ and -.

exec. makeExecFileFn({ maxBuffer }) returns an execFileFn(cmd, args, opts) that runs execFile with maxBuffer (default 64 MB, which opts can override) and resolves { stdout }. It rejects with an Error whose message is <cmd> <first arg>: <execFile's message>, then, on the next line, up to 2000 characters of stderr. The original error is its cause, and it also carries the whole stdout and stderr and the exit code.

Exposure (optional)

An Exposure adapter publishes the conductor's three servers on a front door, such as tailscale serve, and reports when they drift. It is optional, and the core never constructs one: a platform builds it and passes it as adapters.exposure, as it does the other adapters. Only a gated conductor may have one: in none mode, startConductor throws adapters.exposure needs QA_EXPOSURE=tailscale: an ungated conductor must not publish itself.

import { createTailscaleExposure } from '@critical-labs/qa-conductor/adapters/exposure-tailscale'
import { makeExecFileFn } from '@critical-labs/qa-conductor/exec'

const conductor = startConductor({
  cfg,                                                  // in tailscale mode
  github, fsx, readBaseEnv,
  adapters: { provisioner, build, seed, envTransform, auth, exposure: createTailscaleExposure({ execFileFn: makeExecFileFn() }) },
})
const state = await conductor.exposure.ready          // { mode, managed, ok, checkedAt, drift, added, error }

The reconcile loop. With an adapter, the conductor owns its mounts:

  • It reconciles once all three servers listen, so the targets are the bound ports, then every QA_EXPOSURE_INTERVAL_MINUTES (cfg.exposureIntervalMinutes, default 5) on an unref()ed timer. Each pass derives the mounts afresh with mountsFor, since cfg.paneOrigins may be assigned after start, then runs reconcileExposure.
  • One pass runs at a time. A tick while a pass is still running, on a hung CLI say, starts nothing, so CLI processes never stack.
  • Once stop() or shutdown() begins, no pass runs and the timer is cleared. Neither waits for a pass in flight. Nothing ever removes a mount, so after a stop the front door answers 502 until the conductor is back.
  • Nothing a pass does takes the conductor down. Each mount it writes is logged as [qa] exposure mounted <port><path> -> <target>, or restored when this conductor had it in place before. [qa] exposure failed: …, [qa] exposure drift remains: <port><path>, … and [qa] exposure ok are logged only when they change.
  • GET /api/exposure and conductor.exposure.state() report the last pass as { mode, managed, ok, checkedAt, drift, added, error }. Neither calls the front door. The mounts in drift and added are copied from what the adapter returned, keeping only the Mount type's values: a field that isn't a string (for port, an integer), such as a URL object as the target, is reported as null.
  • So a deploy needn't touch the mounts: the restarted conductor's first pass restores any that are missing or wrong.

In tailscale mode without an adapter, the mounts are someone else's, such as a deploy script's: startup logs [qa] exposure: tailscale serve mounts are managed outside the conductor, and /api/exposure reports managed: false. In none mode it reports managed: false too.

To run a pass yourself, outside a conductor, use the expose CLI from a shell, or from code:

import { mountsFor, reconcileExposure } from '@critical-labs/qa-conductor/exposure'

const exposure = createTailscaleExposure({ execFileFn: makeExecFileFn() })
const result = await reconcileExposure(exposure, mountsFor(cfg), { checkOnly: true })   // { ok, checkedAt, drift, added, error }

The contract:

  • Mount = { name, host, port, path, target }, one each for harness, base and pr.
    • host and port are the mount's public side: the hostname and port of its own origin (cfg.harnessOrigin, or the pane's in cfg.paneOrigins). A port-less https origin means 443.
    • host is not cfg.host. cfg.host is the address the conductor binds, and only target uses it.
    • path is /qa for the harness and / for each pane.
    • target is http://<cfg.host>:<listen port>, with brackets for IPv6.
  • Drift = { mount, actual }: actual is the proxy target the front door serves at that mount over https now, or null when there is none: nothing at that path, no https on the port, or a handler that isn't a proxy. A mount also drifts once for each other handler that takes some of its requests, and actual then names that handler, as <path> -> <target> or <path> (not a proxy).
  • ensure(mounts) → Promise<{ added: Mount[], ok: Mount[] }> creates the missing or mismatched mounts, and only those: added lists the mounts it wrote, ok the ones already in place. check(mounts) → Promise<{ ok: boolean, drift: Drift[] }> changes nothing. Either rejects on failure. An ensure that fails part way may list the mounts it did write on its error, as added.
  • An adapter owns exactly the (port, path) pairs it is given, and never touches another handler.

mountsFor(cfg, { ports = cfg.ports }) derives the mounts from the origins viewers open, so the URL a viewer sees and the mount behind it can't disagree. Pass the bound ports when cfg.ports holds 0. It throws on a layout no front door can publish:

  • no harness origin;
  • a missing or unparseable pane origin;
  • an origin that isn't https, or is on port 0;
  • two mounts on one port, which would share an origin;
  • a listen port that isn't an integer from 1 to 65535.

reconcileExposure(exposure, mounts, { checkOnly }) runs ensure then check, or only check with checkOnly. It resolves { ok, checkedAt, drift, added, error } and never rejects, whatever the adapter throws: a failure comes back as ok: false with its message in error, and added still lists what ensure wrote.

runExpose({ cfg, exposure, checkOnly = false, log = console }) is the expose CLI's pass: mountsFor(cfg) then reconcileExposure, printed through log, resolving the CLI's exit code (0, 1 or 2). A cfg without exposure or harnessOrigin resolves both as startConductor does.

Built in: adapters/exposure-tailscale. createTailscaleExposure({ execFileFn, bin = 'tailscale', socket = null, timeoutMs = 30000 }) drives tailscale serve on the host it runs on:

  • check runs tailscale serve status --json. A mount is in place when its port serves https and its path proxies to its target (one trailing / ignored). It reads the status entry for the mount's host:port, else the first entry on that port.
  • tailscaled hands a request to the deepest handler path that holds it, so a handler under a mount's path takes some of its requests: /qa/ or /qa/api beside the harness's /qa, or any other path on a pane's port. check reports each one that doesn't proxy to the mount's target as drift.
  • ensure reads the same status, then runs tailscale serve --bg --https=<port> [--set-path=<path>] <target> for each mount whose own handler is missing or points elsewhere, and only those. --set-path is left out for /. A handler that shadows a mount stays drift until you remove it: ensure never writes or removes it. A mount that fails doesn't stop the others, and ensure then rejects, naming each failure, with the mounts it did write as the error's added.
  • Every call goes through execFileFn (makeExecFileFn() from ./exec) with a timeoutMs timeout. With socket set, --socket=<socket> comes before the subcommand, for a CLI whose daemon's socket is somewhere else, such as one mounted into a container.
  • bin is the CLI to run, and it should be no older than the daemon. On macOS, the tailscale on PATH may lag behind the app's daemon: use the app's bundled CLI, /Applications/Tailscale.app/Contents/MacOS/Tailscale.

What it never touches. It never runs tailscale serve reset or off, so it never removes a handler, and it never changes one at a (port, path) it wasn't given:

  • Another app's / handler on the harness's port stays beside the harness's /qa, since tailscale serve keeps a port's other paths. Give the harness a port of its own all the same: that app's pages would share the harness origin, so the panes and the harness API would take them for the harness.
  • The flip side: a declared (port, path) is the conductor's. A pane origin on a port where another app serves / replaces that app's handler.
  • **A changed origin or port leaves the old handler