Documentation
Continuous deployment — factory self-deploy via a containerized runner
The factory manages its own repo (aif) as a project: the agent makes changes on
per-issue branches and merges them to main. Deployment is a separate, standard
CD concern handled by GitHub Actions — not by the factory pipeline — so the
agent never holds any infra credentials.
On every push to main, .github/workflows/deploy.yml
first confirms on a GitHub-hosted runner that the commit passed the green-gate
check (the verify-gate job — it queries the check-run rather than re-running the
suite, so it costs seconds and refuses to deploy an ungated commit). Only then does
the self-hosted runner that itself is a container on the docker host (mimi)
safely redeploy the factory:
verify green-gate # ungated commit => the deploy stops here, nothing is touched
docker compose build # build the new image first — the running factory stays up if this fails
tag current image :prev # for rollback
docker compose up -d # swap
health-check the factory # retry ~60s (over the shared network, by DNS)
rollback to :prev if the new container is unhealthyWhy a container runner (docker-out-of-docker)
The runner container mounts the host's docker socket, so when the deploy job
runs factory.sh redeploy it builds and swaps the factory as a sibling
container on the same host daemon — exactly as a manual factory.sh up would,
on the host's ports. This is docker-out-of-docker (sharing the host daemon via
its socket), not docker-in-docker (a nested daemon). It works cleanly here because
the factory's compose uses a named volume (factory-data:/data), not host bind
mounts, and the build context is streamed to the daemon — so nothing depends on
paths inside the runner container.
The runner also joins the factory's shared network (FACTORY_NETWORK), so its
health-check reaches the factory by its DNS alias on the container port. No SSH
key and no deploy secret is ever exposed to GitHub, and no deploy credential ever
lands in an agent-operated container.
One-time setup
1. Provide a GitHub registration credential
The runner registers itself with GitHub on first boot. Give it one of:
RUNNER_PAT— a repo-scoped GitHub personal access token (fine-grained: theaifrepo with Administration: read & write, or a classic token withrepo). The runner image uses it to mint registration tokens itself, and can re-register across restarts. Recommended.RUNNER_TOKEN— a one-time registration token from repo → Settings → Actions → Runners → New self-hosted runner (valid ~1 hour, single use).
Create this token yourself in GitHub — never paste a token into chat, and never commit it.
Where to put it. Not in the main .env — that file is stored verbatim as the
FACTORY_ENV deploy secret and shipped to the deploy job, which has no need for
the runner's registration credential. Instead, put it in .env.runner
(gitignored; copy .env.runner.example), which
factory.sh runner sources on its own:
cp .env.runner.example .env.runner
# set RUNNER_PAT=... (recommended) — or RUNNER_TOKEN=... for a one-time tokenOr skip the file and pass it inline: RUNNER_PAT=... ./scripts/factory.sh runner up.
A short-lived RUNNER_TOKEN is best passed inline rather than saved.
2. Run everything from your operator machine — no shell on mimi
factory.sh drives mimi's docker daemon over DOCKER_HOST=ssh://$DEPLOY_SSH, so
every runner command runs from the same machine you already deploy the
factory from. runner up creates the container on mimi over that SSH tunnel;
you never open a shell on mimi. (Set FACTORY_DEPLOY_LOCAL=1 only if you actually
run these on the host itself.)
The one thing that isn't a CLI knob is a Docker Desktop GUI setting on mimi — but it's on by default, so usually there's nothing to do:
- The runner mounts
/var/run/docker.sock. On Docker Desktop for Mac that path is a symlink to~/.docker/run/docker.sock, and bind-mounting it into a container works by default (verified: a sibling container reaches the daemon through it).runner upruns a preflight that confirms this from your machine and fails with a clear message if it can't. If that preflight fails, enable Settings → Advanced → "Allow the default Docker socket to be used" on mimi, or set
DOCKER_SOCKto the socket path as it exists on mimi (an absolute path — not$HOME, which would expand on your machine, not mimi):DOCKER_SOCK=/Users/<mimi-user>/.docker/run/docker.sock \ RUNNER_PAT=<pat> ./scripts/factory.sh runner upfactory.sh runnerhonorsDOCKER_SOCK(default/var/run/docker.sock).
3. Bring the runner up (from your operator machine)
With your .env present (it supplies DEPLOY_SSH, FACTORY_NAME,
FACTORY_NETWORK, FACTORY_HOSTNAME, DASHBOARD_CONTAINER_PORT):
# repo-scoped PAT (recommended):
RUNNER_PAT=<your-github-pat> ./scripts/factory.sh runner up
# ...or a one-time registration token:
RUNNER_TOKEN=<registration-token> ./scripts/factory.sh runner up
./scripts/factory.sh runner logs # watch it register ("Listening for Jobs")
./scripts/factory.sh runner psrunner up preflights the socket mount, then starts the runner container on
mimi on FACTORY_NETWORK, labeled mimi (the workflow targets
runs-on: [self-hosted, mimi]), with the docker socket mounted. Manage it — all
from your machine — with:
./scripts/factory.sh runner up|down|rm|logs|ps
# down = stop (keep the work volume); rm = stop + delete the volumeConfirm it appears Idle under repo → Settings → Actions → Runners.
4. Add the factory runtime env as a repo secret
The redeployed container must come up with the same runtime env it was first
deployed with (same OAuth client, SERVICE_TOKEN, ports, FACTORY_NAME,
network …) — otherwise the credentials/ports change out from under you.
factory.sh redeploy refuses to run if GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET or SERVICE_TOKEN is missing from that env, because with
no shared URL token left there would be no way back into the dashboard.
Store your factory .env verbatim as a single repo secret:
- GitHub: repo → Settings → Secrets and variables → Actions → New repository secret
- Name:
FACTORY_ENV - Value: the full contents of the
.envyou deploy the factory with (it containsFACTORY_NAME,DASHBOARD_PORT,DASHBOARD_CONTAINER_PORT,GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,FACTORY_PUBLIC_URL,SERVICE_TOKEN,FACTORY_NETWORK,FACTORY_HOSTNAME,GH_TOKEN,GIT_AUTHOR_*, etc.).
The workflow writes it to .env at deploy time and removes it after. DEPLOY_SSH
in that env is ignored — the workflow sets FACTORY_DEPLOY_LOCAL=1 so factory.sh
redeploy uses the docker daemon reached through the mounted socket.
The health-check target is supplied by the runner container's environment
(FACTORY_HEALTH_HOST = FACTORY_HOSTNAME, FACTORY_HEALTH_PORT =
DASHBOARD_CONTAINER_PORT, set in runner/compose.yaml),
so the deploy job reaches the factory by DNS over the shared network. The probe
hits /health — the only unauthenticated route left; / now redirects an
anonymous request to /login.
5. (Optional) Let the deploy notice a dead runner — RUNNER_STATUS_PAT
A wedged runner does not make a deploy fail; it makes it wait. The job sits
queued until GitHub cancels it at its 24-hour ceiling, and because the running
factory is untouched the whole time, it reads as "deploy pending" rather than as
a problem. timeout-minutes does not help — that bounds a job which is running,
not one waiting to be picked up (measured: a job with timeout-minutes: 2 on a
label no runner had was still queued at 2m25s).
The runner-online job in deploy.yml closes that gap by asking GitHub whether
any online runner carries the mimi label before the deploy is queued. That
list needs repo Administration: read, which GITHUB_TOKEN cannot be
granted, so it takes a PAT:
- GitHub: repo → Settings → Secrets and variables → Actions → New repository secret
- Name:
RUNNER_STATUS_PAT - Value: a fine-grained token for this repo with Administration: Read
(a classic
repotoken also works). TheRUNNER_PATin.env.runneralready has the access — reuse it, or mint a read-only one.
It is optional by design. With no secret set, or with a token that cannot
read the list, the job prints a warning and passes, so a factory that has not set
one deploys exactly as it did before. Only a token that can read the list and
finds no online mimi runner blocks the deploy — with the fix in the log:
./scripts/factory.sh runner down && ./scripts/factory.sh runner upNote that is a recreate, not a restart. docker restart cannot fix a wedged
runner — it is what causes it (see Notes / options).
6. The stage factory (AIF-653)
A merge to main deploys a stage factory; production changes only when
someone promotes a commit. Stage is a second factory on the same host, made by
the same factory.sh redeploy with a different env file, so everything that
follows from FACTORY_NAME — container, named volume, image, DNS alias — is its
own. It starts with fresh data and loads no project (the repo's projects ship as
.yaml.txt), so it never drives a real repo.
- Write
.env.stage(git-ignored) from your production.env, changing:FACTORY_NAME=aif-stage,DASHBOARD_PORT,DEV_SSH_PORTand theSTAGE_PROXY_PORT/STAGE_PROXY_PORT_ENDrange to ports free on mimi, andFACTORY_PUBLIC_URL(if set) to stage's own address. KeepFACTORY_NETWORK(stage joins production's network under its own alias). Leave out credentials stage has no business holding —LINEAR_API_KEY,FLY_API_TOKEN,GH_TOKEN.FACTORY_NAMEis the one that matters most: it is what keeps stage off production's container and data volume, and it is also stage's alias on the shared network — so it must be whatever stage's tunnel route points at. Left at production's value, the stage deploy rebuilds PRODUCTION with stage's env and its health check passes anyway.scripts/check-stage-env.shrefuses that (and any shared alias or host port); both the deploy and the next step run it. Give stage its ownSESSION_COOKIE_NAME(e.g.fsid_stage), and keep itsSESSION_COOKIE_DOMAINto its own host. Production scopes itsfsidcookie to the parent domain, so the browser sends it to stage as well; under the same name stage reads production's session first, finds no such session in its own database, and every sign-in lands back on/login. The check refuses that too. Leave production on the default — the iOS and desktop apps look forfsidby name. ./scripts/push-env.sh --stage— sets theFACTORY_STAGE_ENVsecret.- To sign in to stage in a browser, give it a tunnel route, and add
<stage url>/auth/google/callbackto the Google OAuth client's redirect URIs. - The production factory's
GH_TOKENneeds Actions: read & write on this repo, so the Deployments tab can dispatch the promotion and follow the run.
Until FACTORY_STAGE_ENV exists, a push to main fails at the stage redeploy
with a message saying so. Production is untouched and can still be promoted.
Deploying merges straight to production (AIF-655). Where a merge goes is
one repository variable, DEPLOY_ON_MERGE — flipped without a commit, in the
repo's Settings → Secrets and variables → Actions → Variables, or:
gh variable set DEPLOY_ON_MERGE --body production # merges deploy production
gh variable delete DEPLOY_ON_MERGE # back to stage (the default)Or from the project's Deployments tab: the Merges deploy to switch
(AIF-660) does exactly those two calls, because .factory/deployments.yaml
names the variable under merge_target. It needs GH_TOKEN to have
Variables: write on the repo, and it records who flipped it in the tab's
recent actions.
Only production (any case) opts in; unset, stage or anything else means
stage, so a typo never ships to production. A dispatch (Promote, or Run
workflow) goes to its target input — production unless it says stage. A merge going to production takes the
production path unchanged — verify-gate still has to pass, FACTORY_ENV is
used, it queues with promotions, and a production deployment is recorded.
The flip takes effect from the next run. While it is set, stage gets no merges,
so the Deployments tab will show stage falling behind production. Each run's
summary says which target it picked and the variable value behind it.
How a change ships
- You submit an aif issue; the agent works it (branch → build/typecheck → PR
pushed). You review and approve; the merge stage lands it on
main. - The push to
maintriggersdeploy-factory, which deploys stage (or production, whenDEPLOY_ON_MERGE=production— see §6).verify-gateconfirms the commit'sgreen-gatecheck passed (following the commit back to its PR, since the check-run lives on the PR head). Then the mimi runner container checks out that commit, materialises.envfromFACTORY_STAGE_ENV, and runs./scripts/factory.sh redeployagainst the host daemon via the mounted socket. AstagingGitHub deployment is recorded. - Promote — the project's Deployments tab lists what is on stage but not
on production; its Promote button dispatches
deploy-factorywith that exactsha. The run verifies the gate on that sha, checks it out, redeploys production fromFACTORY_ENV, and records aproductiondeployment — which is also when the factory's issues get their deployed mark (AIF-459). The tab also has Deploy latest → stage / production buttons (AIF-662): they dispatchdeploy-factorywithmain's head andtarget=stage|production— for re-shipping after a failed merge deploy, or shippingmainstraight to production. Declared underdeploy_latestin.factory/deployments.yaml. - Build fails → the running factory is untouched. New container unhealthy → it auto-rolls-back to the previous image. Healthy → you're on the new version.
Stage and production each have their own concurrency group, so a merge never
queues behind a promotion. The contract the tab reads is .factory/deployments.yaml
(format in factory/server/src/deployconfig.ts): it takes each environment's
commit from the latest successful GitHub deployment in staging /
production — the ones step 2 and 3 record — rather than calling either
factory's /health.
Watch a deploy under the repo's Actions tab. You can also run
deploy-factory by hand there (Run workflow): it deploys production at
sha, or main's head when sha is blank. Roll back by promoting an older
sha. The button takes a skip_gate_check input for the emergency case where you
must redeploy a commit the gate never blessed.
Notes / options
- A wedged runner is RECREATED, never restarted. A restart reuses the
container's writable layer, so the registration from its first boot is still
there — and the image's entrypoint clears
.runnerbut not.credentials. That half-configured pair fails both ways (config.shrefuses with *"already configured"; removal fails with "Value cannot be null (configuredSettings)"*), the container exits 2, andrestart: unless-stoppedrepeats it forever. Sodocker restartcannot fix this state — it is what produces it. Use./scripts/factory.sh runner down && ./scripts/factory.sh runner up, which gives a fresh layer.runner/compose.yamlnow clears both files in an entrypoint wrapper, so a host reboot re-registers on its own; the recreate is for a container created before that fix, or for any other stuck state. - Host runner instead of a container: if you'd rather run the runner as a raw
host service, register it the usual way (GitHub's shown
config.sh/svc.shsteps) with the labelmimiand add its user to thedockergroup. The same workflow then health-checks the factory at127.0.0.1:${DASHBOARD_PORT}(the published host port) —factory.sh redeploydefaults there when the container'sFACTORY_HEALTH_*vars are absent. - Extra approval gate: add
environment: productionto the job and require reviewers on that environment for a second human click before each deploy. (The factory already gates via issue approval before merge, so this is optional.) - Config-only changes (tools/workflows YAML under
config/) don't need a full redeploy — they take effect viafactory.sh config-push/reload. The redeploy rebuilds the whole image, which also covers them. - Multiple factories on one host: give each its own runner (unique
FACTORY_NAME→ its own runner project/label) +FACTORY_ENVsecret (or use GitHub Environments) and a workflow per instance.