a release is a tag, and the lock that stops a second deploy is local

Three changes, all from the owner's call.

The trigger is a tag, deploy/<version>, and nothing else. The code word it replaces could be written by
accident — every document about deploying contains it, this repository included, and so does every
conversation planning one. Defending that needs a rule 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 names its own
version, and git ls-remote answers "is there one" without fetching.

The record of a deploy is LOCAL — data/deploys/<version>.done — not the pushed report. This is the part
that matters: making the report the marker means a failed push leaves the release looking undeployed and
the next tick rolls it out again. Pushes here fail for reasons unrelated to the rollout — gitea's public
SSH is rate-limited because every connection arrives from one proxy container IP that bots keep
penalised, which cost several retries tonight. So the lock cannot fail for a network reason, and the
report is an artefact: valuable, not load-bearing. Under data/ so a pull cannot clobber it.

And the commander skips the host it runs on ENTIRELY, naming it in the report. Restarting only officer
and leaving its own sidecar behind would work and is worse — one host in a state nobody chose, a step
behind, found later. The owner restarts it deliberately as the last step.

Tonight's deploy is backfilled: the lock file exists and deploy/2026.08.21.01 is tagged at bd41dff5, so
the mechanism has a precedent rather than starting from nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 18:21:09 +00:00
co-authored by Claude Opus 5
parent 8662f94b3b
commit 240d8adbd3
2 changed files with 65 additions and 18 deletions
+8 -2
View File
@@ -11,7 +11,13 @@ deploy/
The plan is written by hand before the release commit. The report is written by the deploy commander The plan is written by hand before the release commit. The report is written by the deploy commander
afterwards and pushed back here. afterwards and pushed back here.
**The report's presence is what marks a release deployed.** That is the whole idempotence mechanism: a **A release is a tag**, `deploy/<version>`, and nothing else triggers a deploy — not a commit message,
watcher that restarts checks for the report before acting, so it cannot roll the same release out twice. not a branch. A tag cannot be written into prose by accident, which a code word can and which every
document about deploying does.
**What marks a release deployed is LOCAL**: `$OFFICER_ROOT/data/deploys/<version>.done`. Not the report.
If the lock were the pushed report, a failed push would mean the release looked undeployed and the next
tick would roll it out again — and pushes here fail for unrelated reasons. So the report is an artefact,
valuable and not load-bearing.
See `prompts/deploy-commander.md`. See `prompts/deploy-commander.md`.
+57 -16
View File
@@ -15,30 +15,52 @@ You are not a loop. Something fires you, and each time you fire you do exactly o
there is a release to deploy that has not been deployed. there is a release to deploy that has not been deployed.
```bash ```bash
cd ~/officer-dev/platform && git fetch --quiet origin HEAD git ls-remote --tags origin 'refs/tags/deploy/*'
git log --oneline HEAD..FETCH_HEAD | grep DEPLOY_NOW
``` ```
- **Nothing matching** → say "no release pending" and stop. Do not pull, do not touch a server. A release is **a tag**, named `deploy/<version>` — `deploy/2026.08.21.01`. Nothing else triggers you: not
- **A commit whose subject begins `DEPLOY_NOW`** → that is the release. Continue. a commit message, not a branch, not a push.
`DEPLOY_NOW` must be at the **start of the subject line**, not merely present. This document contains the **Why a tag and not a code word in a commit.** A code word can be written by accident. Every document
word repeatedly and must never trigger a deploy; so must any commit that discusses one. 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.
**Already deployed?** A release is deployed when `deploy/<version>.report.md` exists on `origin`. Check - **No untagged release** → say "no release pending" and stop. Do not pull, do not touch a server.
before acting. A watcher that restarts must not redeploy, and this is the only thing standing between it - **A `deploy/*` tag with no local record** → that is the release. Continue.
and doing so.
--- ### 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.
## The release ## The release
The triggering commit carries everything: The tag names the version. The commit it points at carries the rest:
- `package.json` — `version` bumped to `yyyy.mm.dd.NN` (`2026.08.21.01`), the mobile apps' scheme. `NN` - `package.json` — `version` matching the tag, `yyyy.mm.dd.NN` (`2026.08.21.01`), the mobile apps'
increments for a second deploy on the same day. 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. - `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 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. at, and guessing is how a database gets a schema it was not ready for.
@@ -96,13 +118,32 @@ assemble at the end from memory is a report that will be wrong about the host th
If a host fails, **continue to the next one**. A half-rolled-out release that is honestly reported beats 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. one that stopped in the middle and said nothing about the four hosts it never reached.
### 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 ## The report
Commit `deploy/<version>.report.md` to the platform repository and push it. Subject line: the version and Write the local record FIRST — `$OFFICER_ROOT/data/deploys/<version>.done` — then commit
the outcome, **without the code word** — a report is not a release, and a report that triggers a deploy `deploy/<version>.report.md` and push it.
would loop forever.
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 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 processes restarted, and whether they came back. Then one line at the top saying how many hosts