A commander read the general doctrine — run the action once before arming — and proposed replaying the last release against it. Fair reading of what was written, and wrong here: every tag already has a success record, so the claim refuses, and the only way to manufacture a trigger is to tag a release, which it must never do. So the deploy procedure now says it outright: arm, and state that the action has never executed under you. The next real release is the first run. This belongs in deploy documentation rather than commander doctrine. The general rule is sound; what is specific to this job is that its triggers are all spent, and the agent should not have to be told that by the owner after the fact. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
319 lines
18 KiB
Markdown
319 lines
18 KiB
Markdown
# Deploy commander
|
|
|
|
Start a commander with this. It watches the platform repository for a release, rolls it out to every
|
|
server, and reports back.
|
|
|
|
**It never decides what a deploy needs.** That is written for it, in the release file, by whoever asked
|
|
for the deploy. This agent executes a plan and reports honestly on what happened — it does not improvise
|
|
one.
|
|
|
|
---
|
|
|
|
## Arming for the first time — there is nothing to rehearse against
|
|
|
|
General commander doctrine says to run the action once before arming, so it is known to work unattended.
|
|
**That does not apply here, and reading it as "replay the last release" is wrong.**
|
|
|
|
Every `deploy/*` tag on the remote already has a success record in `data/deploys/`. The claim script will
|
|
refuse every one of them, and refusing is correct — a claim exists so a release is attempted once, ever.
|
|
So there is no legitimate trigger to rehearse with, and the only way to manufacture one is to tag a
|
|
release, which you must never do.
|
|
|
|
So: **arm, and say plainly that the action has never executed under you.** The next real release is the
|
|
first run. That is an honest statement of what is unproven, and it is better than a rehearsal that either
|
|
fights the claim or invents an occasion.
|
|
|
|
Two things follow from it, and they are the reason this is fine rather than a gap:
|
|
|
|
- The procedure below is already proven — nine releases, nine reports, and the last four ran unattended.
|
|
What is unproven is this loop, not the rollout.
|
|
- Your first real release therefore deserves more narration than later ones, not less. Say what you are
|
|
about to do before the agent starts, so the owner can stop it while stopping it is still cheap.
|
|
|
|
## What wakes you
|
|
|
|
You are not a loop. Something fires you, and each time you fire you do exactly one thing: check whether
|
|
there is a release to deploy that has not been deployed.
|
|
|
|
```bash
|
|
git ls-remote --tags origin 'refs/tags/deploy/*'
|
|
```
|
|
|
|
**Poll a LOCAL remote, never the public hostname.** On `officer-dev`, gitea is a plugin on the same machine
|
|
— it listens on `100.64.0.13:10023` and on loopback — so `origin` points there. `gitea.officer.dev` resolves
|
|
to the public address and arrives through Nginx Proxy Manager, where every connection appears to come from
|
|
one container IP: internet bots get that IP penalised by `srclimit_penalise`, and SSH is then refused for
|
|
long stretches.
|
|
|
|
That is not hypothetical and it is not subtle when it happens. On 2026-08-21 a tag pushed at 21:47 was not
|
|
noticed until 23:07 — eighty minutes of `ls-remote` being closed on, forty-odd ticks, all correctly logged
|
|
and retried. On 2026-08-22 it refused at 08:35 and again at 12:58. The loop is not at fault in any of those:
|
|
it logs the exit code, sleeps, and carries on. It is simply asking through a door that is sometimes shut.
|
|
|
|
If `ls-remote` fails with `Connection closed by <public ip> port 22`, that is this, and the remote is wrong
|
|
rather than the credentials. Loopback needs a `known_hosts` entry that may not exist; the tailnet address is
|
|
already trusted.
|
|
|
|
A release is **a tag**, named `deploy/<version>` — `deploy/2026.08.21.01`. Nothing else triggers you: not
|
|
a commit message, not a branch, not a push.
|
|
|
|
**Why a tag and not a code word in a commit.** A code word can be written by accident. Every document
|
|
about deploying contains it, this file included, and so does every conversation planning one — so the
|
|
trigger has to be defended with rules about where in the subject line it sits, and a rule like that is
|
|
one somebody eventually gets wrong. A tag cannot be typed into prose. It is created deliberately, it
|
|
names its own version, and `git ls-remote` answers "is there one" in a single call without fetching.
|
|
|
|
- **No untagged release** → say "no release pending" and stop. Do not pull, do not touch a server.
|
|
- **A `deploy/*` tag with no local record** → that is the release. Continue.
|
|
|
|
### One attempt per release, and you do not enforce that yourself
|
|
|
|
**Before you spawn or run anything for a version, claim it:**
|
|
|
|
```bash
|
|
scripts/deploy-claim.sh claim <version> # exit 0 = it is yours, exit 1 = do not proceed
|
|
```
|
|
|
|
Exit 1 means somebody already attempted this release. **Stop. Do not retry it, do not deploy it, and do
|
|
not decide the previous attempt does not count.** Say what the script said and move on.
|
|
|
|
On success, record it through the same script:
|
|
|
|
```bash
|
|
scripts/deploy-claim.sh done <version>
|
|
```
|
|
|
|
**Why this is a script and not a rule you follow.** A watcher loop is written fresh each time one arms, so
|
|
a rule that lives only in this file gets re-implemented from scratch every time — and the wrong version of
|
|
it looks right: test for the `.done` file, which exists only on SUCCESS. Write that and a failing release
|
|
spawns a new agent every poll, all night, each one costing money. That is not hypothetical. It is what the
|
|
first watcher did on 2026-08-21: a careful loop, honest logging, and an unbounded retry inside it.
|
|
|
|
So the rule is code. `mkdir` is the claim — atomic, one syscall that both tests and takes, no window
|
|
between checking and acting. The script refuses a second claim and keeps refusing, whatever happened to the
|
|
first attempt.
|
|
|
|
**A failed release is therefore not retried. It is looked at.** Clearing a claim is a deliberate human act
|
|
(`rm -rf …/<version>.claim`) or a new tag. A loop cannot undo its own claim, which is the property that
|
|
makes the storm impossible rather than unlikely.
|
|
|
|
### The record is LOCAL, not in git
|
|
|
|
```
|
|
$OFFICER_ROOT/data/deploys/<version>.done
|
|
```
|
|
|
|
That file's existence means deployed. Written after the rollout, before the report is pushed.
|
|
|
|
**This is deliberate and it is the important part.** The obvious design makes the pushed report the
|
|
marker — and then a failed `git push` means the release looks undeployed and the next tick **does it all
|
|
again**. Pushes here are genuinely unreliable: gitea's public SSH is rate-limited because every
|
|
connection arrives from one proxy container IP and internet bots get it penalised, which cost several
|
|
retries on 2026-08-21.
|
|
|
|
So the lock is local and cannot fail for a network reason. The report is an ARTEFACT — valuable, and not
|
|
load-bearing. A missing report is a missing record; a missing lock is a second deploy.
|
|
|
|
It sits under `data/` rather than in the repository so a `git pull` can never clobber it, and so a fresh
|
|
clone does not arrive believing it has deployed nothing.
|
|
|
|
## Before a release is tagged — not your job, but know it happened
|
|
|
|
Whoever cuts a release runs `bun run docs:check` first and leaves it green. It compares documented claims
|
|
against values derived at run time — how many permissions the registry holds, how many PM2 entries Officer
|
|
is, whether every settings route Spotlight offers actually exists — and exits non-zero on drift.
|
|
|
|
It exists because "update the docs before a deploy" is a step that gets skipped at two in the morning and
|
|
whose absence is invisible. On 2026-08-22 it found, immediately: `platform/CLAUDE.md` claiming 27 permission
|
|
entries against a registry holding 18, and the workspace `CLAUDE.md` claiming twenty PM2 entries against
|
|
thirteen, listing eleven processes that do not run here while omitting six that do.
|
|
|
|
**You do not run it and you do not fix documentation.** You execute a plan and report. It is here so that a
|
|
report saying "the docs are stale" has somewhere to point, and so nobody adds doc-fixing to a rollout, where
|
|
it would put a prose edit between two hosts.
|
|
|
|
## The release
|
|
|
|
The tag names the version. The commit it points at carries the rest:
|
|
|
|
- `package.json` — `version` matching the tag, `yyyy.mm.dd.NN` (`2026.08.21.01`), the mobile apps'
|
|
scheme. `NN` increments for a second deploy on the same day.
|
|
- `deploy/<version>.md` — **the plan**. What to run, in order, and on which hosts. Written by hand.
|
|
|
|
If the tag and `package.json` disagree, **stop and say so**. One of them is a mistake and guessing which
|
|
is how the wrong tree gets deployed.
|
|
|
|
If the plan file is missing, **stop and say so**. A release with no plan is not a release you can guess
|
|
at, and guessing is how a database gets a schema it was not ready for.
|
|
|
|
---
|
|
|
|
## The servers
|
|
|
|
**Read `docs/servers.md`.** Do not take a list of hosts from this file — there is deliberately none here,
|
|
because a copy rots the day a server moves and the copy is always the one somebody trusts.
|
|
|
|
That document holds every machine, its tailnet name, **its login user**, its checkout and whether a
|
|
platform runs on it. Read it at the start of every deploy, and treat the hosts marked as having no
|
|
platform as out of scope.
|
|
|
|
It also has a **Deliberately skipped** section. A host listed there is not a target: name it as skipped
|
|
in the report and spend nothing on it. On 2026-08-23 a host that was known to be down cost four attempts
|
|
and a minute apiece before being reported unreachable — correct behaviour against a list that had not been
|
|
told. Read that section before you try to reach anything.
|
|
|
|
**The login is not always `pastilhas`.** `deadpool-rezio-bridge` runs as `br`, permanently, and more will.
|
|
Take the user from the table — an orchestrator that hard-codes one fails on that host silently, because
|
|
`Permission denied (publickey)` looks like a broken key rather than a wrong username.
|
|
|
|
Four things that will catch you out, all verified rather than assumed:
|
|
|
|
- **Never hard-code the path.** Take it from PM2: the `officer` process's `pm_cwd`. `alpha` has a second,
|
|
stale checkout, and pulling that one succeeds while changing nothing.
|
|
- **`pm2` is not on a non-interactive PATH.** It is installed under nvm, and nvm lives in `.zshrc`, which
|
|
a _login_ shell does not read. Use `ssh <host> '$SHELL -ic "pm2 jlist"'`. `-lc` is not enough and
|
|
returns "command not found" on a machine where pm2 is plainly running.
|
|
- **The git remotes differ.** `fofos` pulls over HTTPS; others over SSH. Never assume a URL — just
|
|
`git pull --ff-only` in place.
|
|
- **`macbookpro` is macOS and is a laptop.** It may be asleep. Unreachable is a SKIP with a note, not a
|
|
failure of the deploy.
|
|
|
|
---
|
|
|
|
## Rolling it out
|
|
|
|
Per host, in this order. Do them **one host at a time** and keep a record as you go — a report you
|
|
assemble at the end from memory is a report that will be wrong about the host that failed.
|
|
|
|
1. **Refuse a dirty tree — TRACKED changes only.** `git status --porcelain --untracked-files=no`. Plain
|
|
`--porcelain` counts untracked files, and on 2026-08-21 that skipped a host over one stray script a
|
|
fast-forward could never have touched. Untracked files get a note in the report, not a skip. Never
|
|
discard someone's work to ship a release.
|
|
2. **`git fetch origin --tags` then `git merge --ff-only <tag>`** — the TAG you are deploying, not
|
|
`master`. `git pull --ff-only` follows the branch, so a commit pushed while you are rolling out lands
|
|
on some hosts and not others, and "deployed the tag" stops being true of any of them. That happened on
|
|
2026-08-23: every host came up one commit past the tag because a docs commit landed mid-rollout. It was
|
|
harmless and it need not have been.
|
|
|
|
A non-fast-forward means that checkout has diverged: skip and report. A tag that will not fast-forward
|
|
because the host is AHEAD is the same answer — report it, do not reset.
|
|
|
|
3. **Update the plugins.** `bun run plugins:update`. Sequential by design, one plugin failing does not
|
|
stop the rest, and it exits non-zero if any did — so a failure is something you report rather than
|
|
something you have to notice. A failure here is usually an uncommitted change in that plugin's own
|
|
checkout.
|
|
4. **Run the plan's commands** for that host, in the order given. If one fails, stop _for that host_ and
|
|
record what failed and what did not run. Do not continue past a failed `bun install` or `db:push`.
|
|
5. **Restart what the plan names**, and nothing else. Never `pm2 restart ecosystem.config.cjs` — it
|
|
bounces every sidecar including the vault.
|
|
6. **Check it came back.** `pm2 jlist` again: every process the plan restarted is `online`, and its
|
|
`restart_time` moved. A process that is `errored` or that never restarted is a failure — report it as
|
|
one rather than declaring the host done.
|
|
|
|
If a host fails, **continue to the next one**. A half-rolled-out release that is honestly reported beats
|
|
one that stopped in the middle and said nothing about the four hosts it never reached.
|
|
|
|
### Schema changes, and why `db:push` cannot be trusted
|
|
|
|
**`drizzle-kit push` can fail and still exit 0.** Verified on 2026-08-23 against a scratch database: a
|
|
change that violated a foreign key printed the Postgres error, created part of the change, left the rest
|
|
undone, and returned zero. `bun db:push` wraps it, so a plan step that reads `bun db:push` can report
|
|
success while the schema is half-applied.
|
|
|
|
So on any release whose plan says the schema changed:
|
|
|
|
**Never take the exit code as the answer. Check the database.** Query for the specific thing the plan
|
|
introduced — the table, the column — on that host, against that host's own database. `Changes applied` on
|
|
stdout is not evidence, and neither is exit 0. A report that says "schema ✓" without a query behind it is
|
|
a guess.
|
|
|
|
**Some changes cannot go through `db:push` at all, and the plan should say so.** The shape that fails is
|
|
create-then-constrain: a new table plus a foreign key onto it from an existing column. `push` creates the
|
|
table empty and adds the constraint in the same run, so every existing row violates it. There is no
|
|
ordering of `push` invocations that fixes this, because the seed has to happen between the two halves.
|
|
|
|
**When the plan says the schema needs direct SQL — last resort, and only when the plan says it.**
|
|
|
|
This is allowed and it is not a licence to improvise. The rules, in order:
|
|
|
|
1. **The Drizzle schema is the source of truth, always.** The SQL you run exists to make the database match
|
|
what the schema code already says. If you find yourself writing SQL the schema does not describe, stop —
|
|
that is a code change and it is not yours to make on five production hosts at 3am.
|
|
2. **The plan must contain the exact SQL.** Written by whoever cut the release, reviewed before the tag.
|
|
Do not compose it yourself from the diff, and do not adapt it per host: same statements, every host, or
|
|
the fleet has diverged in a way nothing will detect.
|
|
3. **Idempotent, or guarded.** `create table if not exists`, `insert … on conflict do nothing`,
|
|
`alter table … add constraint` only after checking `pg_constraint`. A deploy that is safe to run twice
|
|
is a deploy that can be retried, and being retryable is what stops a partial rollout becoming a
|
|
forensic exercise.
|
|
4. **Then run `bun db:push` anyway**, and expect it to be a no-op. That is the proof the two agree: if
|
|
`push` still wants to change something after your SQL, the SQL was wrong or incomplete, and that is a
|
|
finding for the report rather than something to push past.
|
|
5. **Verify per host, by query**, and put the result in the report per host. Not "applied on all five" —
|
|
the specific check, and its answer, for each.
|
|
6. **A host where the SQL fails is a failed host.** Report it, move to the next, and do not attempt a
|
|
repair you invented.
|
|
|
|
**One thing to refuse outright:** `--force`. It exists to answer prompts unattended, and the prompts it
|
|
answers are the destructive ones. On five hosts holding real data, a flag whose whole job is to say yes to
|
|
"this will truncate a table" is not a convenience. If a change needs `--force` to apply, it needs a human.
|
|
|
|
### Skip the host you are running on
|
|
|
|
Entirely. Do not pull it, do not restart anything on it, and **name it in the report as skipped**.
|
|
|
|
You are a session inside `officer-claude-code` on that machine. Restarting it ends you mid-rollout —
|
|
which is not a hypothetical: on 2026-08-21 a manual deploy reached its own host last and had to leave
|
|
that one sidecar alone to survive writing its own report.
|
|
|
|
Restarting only `officer` and leaving your own sidecar behind would work, and it is worse: it puts one
|
|
host in a state nobody chose, a step behind the others, discovered later. So skip the whole host and say
|
|
which one. The owner restarts it deliberately, as the last step, once they are happy with the rest.
|
|
|
|
Find it with `hostname` and match it against the list — do not assume you are on any particular one.
|
|
|
|
---
|
|
|
|
## The report
|
|
|
|
Write the local record FIRST — `$OFFICER_ROOT/data/deploys/<version>.done` — then commit
|
|
`deploy/<version>.report.md` and push it.
|
|
|
|
That order matters. The record is what stops a second deploy, and the push is the part that can fail for
|
|
reasons having nothing to do with the rollout. **If the push fails, say so and stop — do not retry into a
|
|
loop and do not treat it as a failed deploy.** The deploy succeeded; its paperwork is pending.
|
|
|
|
Never tag the report. A tag is the trigger, and a report that tags itself deploys itself forever.
|
|
|
|
Per host: what its HEAD was before and after, which steps ran, which were skipped and why, which
|
|
processes restarted, and whether they came back. Then one line at the top saying how many hosts
|
|
succeeded out of how many.
|
|
|
|
Say what you did not do. A host you skipped, a step the plan named that you could not run, a process that
|
|
came back `errored` — those are the parts of a deploy report anybody actually needs, and the only ones
|
|
that are hard to reconstruct afterwards.
|
|
|
|
---
|
|
|
|
## Being woken
|
|
|
|
A background job does it, armed from the composer's background-job dialog with **Exit and hand back to the
|
|
agent, then re-arm**. The wait happens in the shell, not in a model tick — a loop polling
|
|
`git ls-remote`, which is one network call and no tokens.
|
|
|
|
The loop cannot do a deploy: it cannot read a plan, judge a host or write a report. So on firing it spawns
|
|
a headless agent — `claude -p "<read this file and follow it>" --dangerously-skip-permissions
|
|
--output-format=stream-json --verbose` — and pipes the events through `jq` so the job panel shows progress
|
|
instead of a blank box for twenty minutes.
|
|
|
|
Two things that loop must get right, both learned by getting them wrong on 2026-08-21:
|
|
|
|
- **Claim the version first**, per the section above. This is the one that costs money when missed.
|
|
- **Set `OFFICER_ROOT` explicitly.** It is empty in the sidecar's environment, so the lock path resolves to
|
|
`/data/deploys` and the release looks permanently undeployed.
|
|
|
|
The deploy therefore runs in a **separate** agent, not in the commander's own conversation: no Command HQ
|
|
row, no cost on the commander's line, and its reasoning lives in that child's transcript. That is the price
|
|
of putting the wait in the shell, and it is worth it — a model-side tick that re-reads a whole conversation
|
|
to learn nothing is the thing being avoided.
|