Publish release images on v* tags; production deploys from iac-repo (#154) #159

Merged
claude-bot merged 2 commits from feat/release-image-publish into main 2026-08-01 00:22:22 +00:00
Contributor

Closes #154. Companion to Rhoving/iac-repo#220, which adds the Ansible role that consumes these images. Merge order between the two no longer matters — this workflow no longer invokes the play.

Problem

CD runs on every push to main and deploys the dev host. Nothing deployed production at all; it was a manual procedure someone had to remember. It sat six days and five alert-correctness fixes behind while four consecutive CD runs went green — because a green CD run reads as "shipped" when it only ever meant "dev is current".

Change

release.yml: a v* tag builds and pushes git.rhoving.com/rbrooks/weatherbot:<version>and that is all. Deployment is an operator step in iac-repo:

  1. git tag v1.7.1 && git push origin v1.7.1 → image published
  2. bump weatherbot_image in inventory/group_vars/weatherbot/vars.yml (PR)
  3. ansible-playbook site.yml -l docker-host (from weatherbot-ansible/)

Why no deploy job (revised from the first version of this PR): deploying from CI needs PROD_SSH_KEY + ANSIBLE_VAULT_PASSWORD in Actions secrets — root-equivalent access to docker-host, exposed to anything that can push a tag or compromise a runner. Publish-only leaves CI holding a package:write token; the worst case is a poisoned image that still has to get past the operator bumping the pin and running the play.

This also settles the open design question from the first revision (-e weatherbot_image=<tag> leaving the committed pin stale): with deploys operator-run, the committed pin in iac-repo is the deployed state. Git stays authoritative, matching the fleet convention.

Watchtower was considered and rejected — no health-gated rollback while Alembic migrations run in the startup lifespan; full rationale on #154.

Details worth reviewing

APP_VERSION is baked in at build time rather than derived from git describe on the deploy host. That is what lets production stop keeping a git checkout entirely — and removes the root-owned-git-objects problem the manual recipe had to work around.

No :latest tag. The fleet pins exact versions in Ansible group_vars (iac-repo's "never use latest" rule), and a moving tag is precisely what makes "which version is production actually running?" unanswerable.

The push gates on its secret and skips cleanly when unset, matching how cd.yml gates on DEV_SSH_KEY. Safe to merge before the secret exists. Required, one-time, in Settings → Actions:

Secret Purpose
REGISTRY_TOKEN Forgejo token with package:write

cd.yml is renamed "CD (dev only)" and says so in its header. That was the other half of #154 — the hazard was never that CD was broken, only that its name implied more than it did.

Verification

Both workflow files parse (yaml.safe_load, jobs enumerated). An un-configured run builds the image (keeping the Dockerfile honest) and skips the push with a log line rather than failing.

Note on rollback scope

The play's automatic rollback restores the previous image, not the previous schema. Migrations are forward-only, so a release whose migration ran before the app failed its health check needs a database restore, not just a re-deploy. Documented in the README alongside the pipeline table — and iac-repo#220 adds the nightly pg_dump that makes such a restore possible at all, which production did not previously have.

🤖 Generated with Claude Code

Closes #154. Companion to [Rhoving/iac-repo#220](https://git.rhoving.com/Rhoving/iac-repo/pulls/220), which adds the Ansible role that consumes these images. Merge order between the two no longer matters — this workflow no longer invokes the play. ## Problem CD runs on every push to `main` and deploys the **dev** host. Nothing deployed production at all; it was a manual procedure someone had to remember. It sat six days and five alert-correctness fixes behind while four consecutive CD runs went green — because a green CD run reads as "shipped" when it only ever meant "dev is current". ## Change `release.yml`: a `v*` tag builds and pushes `git.rhoving.com/rbrooks/weatherbot:<version>` — **and that is all**. Deployment is an operator step in iac-repo: 1. `git tag v1.7.1 && git push origin v1.7.1` → image published 2. bump `weatherbot_image` in `inventory/group_vars/weatherbot/vars.yml` (PR) 3. `ansible-playbook site.yml -l docker-host` (from `weatherbot-ansible/`) **Why no deploy job** (revised from the first version of this PR): deploying from CI needs `PROD_SSH_KEY` + `ANSIBLE_VAULT_PASSWORD` in Actions secrets — root-equivalent access to `docker-host`, exposed to anything that can push a tag or compromise a runner. Publish-only leaves CI holding a `package:write` token; the worst case is a poisoned image that still has to get past the operator bumping the pin and running the play. **This also settles the open design question** from the first revision (`-e weatherbot_image=<tag>` leaving the committed pin stale): with deploys operator-run, the committed pin in iac-repo **is** the deployed state. Git stays authoritative, matching the fleet convention. Watchtower was considered and rejected — no health-gated rollback while Alembic migrations run in the startup lifespan; full rationale on #154. ## Details worth reviewing **`APP_VERSION` is baked in at build time** rather than derived from `git describe` on the deploy host. That is what lets production stop keeping a git checkout entirely — and removes the root-owned-git-objects problem the manual recipe had to work around. **No `:latest` tag.** The fleet pins exact versions in Ansible `group_vars` (iac-repo's "never use latest" rule), and a moving tag is precisely what makes "which version is production actually running?" unanswerable. **The push gates on its secret** and skips cleanly when unset, matching how `cd.yml` gates on `DEV_SSH_KEY`. Safe to merge before the secret exists. Required, one-time, in Settings → Actions: | Secret | Purpose | |---|---| | `REGISTRY_TOKEN` | Forgejo token with `package:write` | **`cd.yml` is renamed "CD (dev only)"** and says so in its header. That was the other half of #154 — the hazard was never that CD was broken, only that its name implied more than it did. ## Verification Both workflow files parse (`yaml.safe_load`, jobs enumerated). An un-configured run builds the image (keeping the Dockerfile honest) and skips the push with a log line rather than failing. ## Note on rollback scope The play's automatic rollback restores the previous **image**, not the previous **schema**. Migrations are forward-only, so a release whose migration ran before the app failed its health check needs a database restore, not just a re-deploy. Documented in the README alongside the pipeline table — and iac-repo#220 adds the nightly `pg_dump` that makes such a restore possible at all, which production did not previously have. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Deploy production on release tags, not on merge (#154)
All checks were successful
CI / test (pull_request) Successful in 5m0s
e65a59c5b0
CD runs on every push to main and deploys the DEV host. Nothing deployed
production at all -- it was a manual procedure someone had to remember. It sat
six days and five alert-correctness fixes behind while four consecutive CD runs
went green, because a green CD run reads as "shipped" when it only ever meant
"dev is current".

Adds release.yml: a v* tag builds and pushes
git.rhoving.com/rbrooks/weatherbot:<version>, then runs the weatherbot Ansible
play (iac-repo Rhoving/iac-repo#220) against docker-host. The play pulls the
pinned tag, waits on /health, and restores the previously running image if the
new one does not come up -- which matters because Alembic migrations run inside
the app's startup lifespan, so a bad migration otherwise leaves the alerting
instance down with nothing watching.

Production therefore tracks stable release tags and never main. Watchtower was
considered and rejected: the compose file builds from source so there was no
image to pull, and Watchtower offers no health-gated rollback.

- APP_VERSION is baked in at build time rather than derived from `git describe`
  on the deploy host, which is what lets production stop keeping a git checkout
  -- and removes the root-owned-git-objects problem the manual recipe had to
  work around.
- No :latest tag. The fleet pins exact versions in Ansible group_vars, and a
  moving tag is what makes "which version is production running?" unanswerable.
- Both halves gate on their secrets independently and skip cleanly when unset,
  so this merges before any secret exists and image publishing can be turned on
  before production access is wired up.

cd.yml is renamed "CD (dev only)" and says so in its header, so the workflow
list itself cannot be misread. That was the other half of #154: the hazard was
never that CD was broken, only that its name implied more than it did.

README gains a table of which pipeline targets which host, and notes that the
automatic rollback restores the previous IMAGE, not the previous SCHEMA -- a
release whose migration ran before the health check failed still needs a
database restore.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Release workflow publishes the image only; deploys move to iac-repo
All checks were successful
CI / test (pull_request) Successful in 4m11s
42ae241bdb
Per discussion on #154/#159: a deploy-from-CI job needs PROD_SSH_KEY and
ANSIBLE_VAULT_PASSWORD in Actions secrets — root-equivalent access to
docker-host, exposed to anything that can push a tag or compromise a runner.
Dropping it leaves CI holding only a package:write registry token.

- release.yml: remove the deploy-prod job; on a v* tag, build and push the
  image, then emit a notice with the operator steps (bump weatherbot_image in
  iac-repo, run the play against docker-host).
- This also settles the open design question: with deploys operator-run from
  iac-repo, the committed weatherbot_image pin IS the deployed state — git
  stays authoritative, matching the rest of the fleet.
- README + cd.yml header: describe the publish-only pipeline and the
  three-step release procedure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
claude-bot changed title from Deploy production on release tags, not on merge (#154) to Publish release images on v* tags; production deploys from iac-repo (#154) 2026-08-01 00:12:52 +00:00
claude-bot deleted branch feat/release-image-publish 2026-08-01 00:22:22 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 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
rbrooks/WeatherBot!159
No description provided.