A release is a tag: let it happen without a workstation #75

Merged
Grandiras merged 1 commit from issue-73-remote-release into main 2026-08-08 02:22:20 +00:00
Owner

Fixes #73.

A release is now a tag. Production follows :latest on the same host-side timer the dev stack already uses, so git tag vX.Y.Z && git push is the entire release — no deploy step, no workstation, and therefore doable from a phone or by an agent dispatched from an issue.

This is already live: production is armed and following :latest as of this PR.

Why the shape

An agent could always tag; it could never deploy. deploy.sh needs deploy/.env (one workstation) and an SSH route to the podman socket. Measured from a job container inside dind, which is where CI and dispatched agents run:

from host.containers.internal:22 gateway :22 public IP :22
a container on the host's podman reachable unreachable unreachable
a job container inside dind unreachable unreachable

The first row corrects the previous PR, which claimed flatly that no route exists. host.containers.internal is a route from a plain container — it only fails inside dind, where the name is the dind daemon rather than the machine. That distinction is now written down in all three places, because it looks exactly like the fix.

So the direction stays inverted and the release rides an invariant the repo already had: :latest moves for v* tags and for nothing else. Not a weaker gate — deciding to push a version tag was always the decision; only the hand that ran deploy.sh afterwards is gone.

Pinning: the part that has to be right

deploy.sh vX.Y.Z writes the version into the server's env copy, and update.sh stands down on a pinned version. Without both halves a rollback is undone within the minute, because the timer still sees :latest on the broken release.

Verified end to end on production, which was safe to use as the test rig because :latest and v2.3.0 are the same digest — no code moved at any point:

step server IMAGE_TAG container timer tick
armed latest :latest no-op, 459 ms
pinned (deploy.sh v2.3.0) v2.3.0 :v2.3.0 ponente is pinned to v2.3.0; leaving it alone, exit 0
re-armed (deploy.sh) latest :latest follows again

Exit 0 rather than 1 is deliberate: a unit that failed every minute would bury a real failure in a wall of red.

update.sh also now checks APP_PUBLIC_URL/healthz after the container reports healthy. The container's own healthcheck talks to localhost, so it cannot see whether caddy-docker-proxy followed the recreate — and it has to, because a recreated container gets a new address.

The trade, stated plainly

SERVER_STATE_DIR is now set for production too, so its secrets sit in a 0600 file in the podman user's home as well as in container environment. Production kept nothing there precisely so there was nothing to drift or steal. Something on the server has to recreate containers unattended for a workstation-free release to exist, and that means it needs the values. Unset it and everything still works; the stack simply cannot release itself.

Agent instructions

  • deploy/README.md: Releasing without a workstation, Pinning and why a rollback holds, What still needs deploy.sh, plus four new troubleshooting entries.
  • The release skill's Phase B is reordered so the config check comes before the tag — the tag is now the irreversible step, so approval belongs there and not before a deploy command that no longer exists.
  • Remote rollback in both: re-tag the last good commit with a higher version. Never force-move a tag — :latest is moved by the workflow run, not by where the tag points, so that changes nothing and destroys the record of what shipped.
  • One trap called out twice: do not "tidy" IMAGE_TAG in deploy/.env to the version just released. That pins production, and the next release is built, published and never deployed with everything looking green.

What still needs a workstation

Only what is not a code release, because the timer recreates just the app container from the compose file the server already holds: a compose or env change, a first deploy, an immediate rollback, and check-env-keys.sh (that file is not in the repo). A tag alone will not apply a compose change — the release succeeds and the feature is inert.

🤖 Generated with Claude Code

Fixes #73. **A release is now a tag.** Production follows `:latest` on the same host-side timer the dev stack already uses, so `git tag vX.Y.Z && git push` is the entire release — no deploy step, no workstation, and therefore doable from a phone or by an agent dispatched from an issue. This is **already live**: production is armed and following `:latest` as of this PR. ## Why the shape An agent could always tag; it could never deploy. `deploy.sh` needs `deploy/.env` (one workstation) and an SSH route to the podman socket. Measured from a job container inside dind, which is where CI and dispatched agents run: | from | `host.containers.internal:22` | gateway `:22` | public IP `:22` | |---|---|---|---| | a container on the host's podman | reachable | unreachable | unreachable | | **a job container inside dind** | **unreachable** | — | **unreachable** | The first row **corrects the previous PR**, which claimed flatly that no route exists. `host.containers.internal` *is* a route from a plain container — it only fails inside dind, where the name is the dind daemon rather than the machine. That distinction is now written down in all three places, because it looks exactly like the fix. So the direction stays inverted and the release rides an invariant the repo already had: `:latest` moves for `v*` tags and for nothing else. Not a weaker gate — deciding to push a version tag was always the decision; only the hand that ran `deploy.sh` afterwards is gone. ## Pinning: the part that has to be right `deploy.sh vX.Y.Z` writes the version into the server's env copy, and `update.sh` stands down on a pinned version. Without **both** halves a rollback is undone within the minute, because the timer still sees `:latest` on the broken release. Verified end to end **on production**, which was safe to use as the test rig because `:latest` and `v2.3.0` are the same digest — no code moved at any point: | step | server `IMAGE_TAG` | container | timer tick | |---|---|---|---| | armed | `latest` | `:latest` | no-op, 459 ms | | pinned (`deploy.sh v2.3.0`) | `v2.3.0` | `:v2.3.0` | `ponente is pinned to v2.3.0; leaving it alone`, exit **0** | | re-armed (`deploy.sh`) | `latest` | `:latest` | follows again | Exit 0 rather than 1 is deliberate: a unit that failed every minute would bury a real failure in a wall of red. `update.sh` also now checks `APP_PUBLIC_URL/healthz` after the container reports healthy. The container's own healthcheck talks to localhost, so it cannot see whether caddy-docker-proxy followed the recreate — and it has to, because a recreated container gets a new address. ## The trade, stated plainly `SERVER_STATE_DIR` is now set for production too, so its secrets sit in a `0600` file in the podman user's home as well as in container environment. Production kept nothing there precisely so there was nothing to drift or steal. Something on the server has to recreate containers unattended for a workstation-free release to exist, and that means it needs the values. Unset it and everything still works; the stack simply cannot release itself. ## Agent instructions - `deploy/README.md`: *Releasing without a workstation*, *Pinning and why a rollback holds*, *What still needs deploy.sh*, plus four new troubleshooting entries. - The release skill's Phase B is **reordered so the config check comes before the tag** — the tag is now the irreversible step, so approval belongs there and not before a deploy command that no longer exists. - Remote rollback in both: re-tag the last good commit with a **higher** version. Never force-move a tag — `:latest` is moved by the workflow *run*, not by where the tag points, so that changes nothing and destroys the record of what shipped. - One trap called out twice: **do not "tidy" `IMAGE_TAG` in `deploy/.env` to the version just released.** That pins production, and the next release is built, published and never deployed with everything looking green. ## What still needs a workstation Only what is not a code release, because the timer recreates just the app container from the compose file the server already holds: a compose or env change, a first deploy, an immediate rollback, and `check-env-keys.sh` (that file is not in the repo). A tag alone will not apply a compose change — the release succeeds and the feature is inert. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
A release is a tag: let it happen without a workstation
All checks were successful
CI / build (pull_request) Successful in 40s
e9bdb16730
Production now follows `:latest` on the same host-side timer the dev stack
uses, so `git tag vX.Y.Z && git push` is the entire release. There is no
deploy step. That is what lets a release be cut from a phone, or by an agent
dispatched from an issue.

## Why it had to be this shape

An agent could always tag; it could never deploy. `deploy.sh` needs
`deploy/.env`, which lives on one workstation, and an SSH route to the
server's podman socket. Measured from a job container inside dind, which is
where CI and dispatched agents run:

| from                             | host.containers.internal:22 | gateway:22  | public IP:22 |
|----------------------------------|-----------------------------|-------------|--------------|
| a container on the host's podman | reachable                   | unreachable | unreachable  |
| a job container inside dind      | unreachable                 | —           | unreachable  |

The first row corrects what the previous commit claimed. `host.containers.internal`
*is* a route to the host from a plain container — it is only inside dind that
the name resolves to the dind daemon instead of the machine. Worth stating
precisely, because it looks like the fix and is not; the docs said "no route
exists", which would have sent the next person down it.

So the direction stays inverted, and the release rides the mechanism that
already works: `:latest` is moved by the Publish workflow for `v*` tags and by
nothing else — an invariant this repo already had — and the host watches it.
This is not a weaker gate. A person deciding to push a version tag was always
the decision; only the hand that ran deploy.sh afterwards is gone.

## Pinning, which is the part that has to be right

`deploy.sh vX.Y.Z` now writes that version into the server's copy of the env
file, and `update.sh` stands down on a pinned version. Without both halves a
rollback would be undone within the minute — the timer would see `:latest`
still on the broken release and restore it. Deploying without a tag re-arms.

Verified end to end on production, which was safe to use as the test because
`:latest` and `v2.3.0` are the same digest, so no code moved:

- armed → `IMAGE_TAG=latest`, container on `:latest`, ticks are no-ops
- pinned → `IMAGE_TAG=v2.3.0`, container on `:v2.3.0`, tick logs
  "ponente is pinned to v2.3.0; leaving it alone" and exits **0**, not 1 — a
  unit that failed every minute would bury a real failure in red
- re-armed → back to `:latest`, following again, both public health checks pass

`update.sh` also now checks `APP_PUBLIC_URL/healthz` after the container
reports healthy. The container's own healthcheck talks to localhost, so it
cannot see whether caddy-docker-proxy followed the recreate — and it has to,
because a recreated container gets a new address.

## The trade

`SERVER_STATE_DIR` is now set for production too, so its secrets sit in a 0600
file in the podman user's home as well as in container environment. Production
kept nothing there precisely so there was nothing to drift or steal. Something
on the server has to be able to recreate containers unattended for a release to
happen without a workstation, and that means it needs the values. Unset it and
everything still works — the stack simply cannot release itself.

## Instructions

`deploy/README.md` gains "Releasing without a workstation" and "Pinning, and
why a rollback holds"; the release skill's Phase B is reordered so the config
check comes **before** the tag, since the tag is now the irreversible step.
Both spell out the remote rollback: re-tag the last good commit with a higher
version. Never force-move a tag — `:latest` is moved by the workflow run, not
by where the tag points, so that changes nothing and destroys the record.

One trap called out explicitly in both: do not "tidy" `IMAGE_TAG` in
`deploy/.env` to the version just released. That pins production, and the next
release is then built, published, and never deployed, with everything green.

Fixes #73

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Collaborator

🤖 Reviewing this PR — follow along in the action run; the reply will link the full session transcript.

📜 Live transcript — updating every ~5 minutes while the task runs.

🤖 Reviewing this PR — follow along in [the action run](https://git.grandiras.net/Grandiras/claude-bot/actions/runs/176); the reply will link the full session transcript. 📜 [Live transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/Ponente/pr-75/run-176.md) — updating every ~5 minutes while the task runs.
claude-reviewer left a comment

Verdict: approve — no blocking issues found.

Large, mostly-documentation PR with two real functional changes: deploy/autoupdate/update.sh (pinned-tag handling now exits 0 instead of 1, plus a new public /healthz check after the container reports healthy) and deploy/deploy.sh (writing the deployed tag into the env file that's synced to the server, so a rollback pin actually sticks). I traced both scripts end-to-end against the diff and the resulting file content on the PR branch: the pinning logic, the envval/IMAGE_TAG precedence, the SERVER_STATE_DIR instance-name check, and the --help line range (sed -n '2,11p') are all internally consistent, and APP_PUBLIC_URL is already a required var in both .env.example files so the new outward healthcheck has what it needs for both stacks. The extensive prose updates (README, CLAUDE.md, deploy/README.md, SKILL.md, .env.example) accurately reflect what the scripts do — the reordering of the release skill's Phase B (config check before tag) matches the stated rationale that the tag is now the irreversible step. No correctness bugs or security issues found; the SERVER_STATE_DIR tradeoff for production (secrets now also live in a 0600 file on the server) is clearly called out rather than silently introduced.


⏱ 1m 8s · 11 turns · tokens: 75.8k in / 5.0k out (+948.8k cache-read)
📜 Full session transcript

✅ **Verdict: approve** — no blocking issues found. Large, mostly-documentation PR with two real functional changes: `deploy/autoupdate/update.sh` (pinned-tag handling now exits 0 instead of 1, plus a new public `/healthz` check after the container reports healthy) and `deploy/deploy.sh` (writing the deployed tag into the env file that's synced to the server, so a rollback pin actually sticks). I traced both scripts end-to-end against the diff and the resulting file content on the PR branch: the pinning logic, the `envval`/`IMAGE_TAG` precedence, the `SERVER_STATE_DIR` instance-name check, and the `--help` line range (`sed -n '2,11p'`) are all internally consistent, and `APP_PUBLIC_URL` is already a required var in both `.env.example` files so the new outward healthcheck has what it needs for both stacks. The extensive prose updates (README, CLAUDE.md, deploy/README.md, SKILL.md, `.env.example`) accurately reflect what the scripts do — the reordering of the release skill's Phase B (config check before tag) matches the stated rationale that the tag is now the irreversible step. No correctness bugs or security issues found; the `SERVER_STATE_DIR` tradeoff for production (secrets now also live in a 0600 file on the server) is clearly called out rather than silently introduced. --- ⏱ 1m 8s · 11 turns · tokens: 75.8k in / 5.0k out (+948.8k cache-read) 📜 [Full session transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/Ponente/pr-75/run-176.md) <!-- claude-verdict:approve --> <!-- claude-reviewed-head:e9bdb167301ddb076a47b71bf30b3bf37acaff53 -->
Grandiras deleted branch issue-73-remote-release 2026-08-08 02:22:44 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
3 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Grandiras/Ponente!75
No description provided.