Skip to content

Drain-first deploys, version-tag deploys, and exact reverts - #31

Merged
wadewilliams merged 19 commits into
masterfrom
drain-first-strategy
Oct 11, 2026
Merged

wadewilliams merged 19 commits into
masterfrom
drain-first-strategy

Conversation

@wadewilliams

@wadewilliams wadewilliams commented Oct 9, 2026 •

Copy link
Copy Markdown
Member

Why

During a rolling deploy, old and new containers overlap. That's fine for web servers. Queue workers and schedulers are different: the old worker keeps running old task code through its drain, which can last up to an hour, after the new web container has already migrated the database. These services should drain first and start last.

Releases also deploy by version tag (v0.9.2), not only by commit SHA. Every consumer of the tag has to handle that: git fetch and checkout, image pull, restore, status. And a restore has to put back what was actually running, not what git HEAD or :latest says.

What

deploy.strategy: drain-first is a new per-service label. rolling remains the default. A deploy runs in three phases:

  1. Drain. Drain-first services stop in reverse deploy.order. Each pulls its new image first, so a bad tag fails before anything stops. Then each runs docker stop --time <deploy.drain>: SIGTERM (Celery's warm shutdown), then SIGKILL when the drain runs out. Nothing rolls until every drain has finished.
  2. Roll. Rolling services deploy in deploy.order, as before.
  3. Start. Drain-first services start in deploy.order, at the replica count they had before, and every replica must pass its health check.

With web (100), api (200) and worker (300), the worker drains first and starts last, after the services it depends on.

This is stop-before-start for singletons such as Celery Beat, so old and new never overlap. Closes #26, as a strategy on deploy.role: app rather than the new role the issue proposed.

Image tags: versions, SHAs, and exact restores

  • Git: git fetch --tags --force origin, so a new release tag is always present and a re-pushed tag moves. checkout --detach handles lightweight tags, annotated tags and SHAs.

  • Images: compose config is rendered with the deploy's DEPLOY_TAG. A warning is printed when an app image doesn't end in :<tag> (e.g. DOCKER_IMAGE set without :${DEPLOY_TAG:-latest}).

  • What's running is read from the containers, and named by git. Before changing anything, the deploy records each app service's running image ID (the most common one wins, with a warning if they differ) and the tag it was started from. A revert uses that tag when all of these hold:

    • it's a git ref (a release tag or a SHA)
    • it still names the running image locally
    • it isn't the tag being deployed (this deploy pulls that, which would move a re-pushed tag)

    Otherwise (:latest, a moved tag, a digest, no tag) it uses the full SHA of the commit checked out before the deploy, which CI must push every image under. No tags are created and nothing is written on the host.

  • Known limit: redeploying the commit that's already running leaves no other git name for the running build. If its image was rebuilt under the same tag, a revert restores the rebuild. The deploy warns up front. This is not a regression: master didn't roll shipped services back at all.

  • The deploy stops before changing anything when:

    • Docker can't say what's running
    • another compose project runs this deploy's services in this directory. A release that renamed the project would otherwise start a second stack beside the first. An unrelated project that merely shares the directory is fine.
  • What's deployed: .git/deploy-tag (inside .git, so the tree stays clean) records the tag on every successful deploy of all app services. After a revert it's rewritten to the restore tag. A --service deploy that leaves some app services out clears it, since services then run different tags.

The manual-ops hazard

With image: repo:${DEPLOY_TAG:-latest}, a person running docker compose up -d on the host silently moves every service to :latest. The design: flow-deploy knows what's deployed and lends it to manual commands.

  • flow-deploy tag prints the deployed tag. It comes from the running app containers when they agree, else from the record when nothing runs. Mixed tags, untagged images, or nothing to go on give an error ("set DEPLOY_TAG"), never a guess.
  • flow-deploy compose <args> runs the compose wrapper with that DEPLOY_TAG. An explicit DEPLOY_TAG wins. It refuses rather than fall back to :latest.
  • A wrapper such as script/prod can default it, which makes script/prod up -d safe too: if [ -z "${DEPLOY_TAG:-}" ] && command -v flow-deploy >/dev/null; then DEPLOY_TAG=$(flow-deploy tag 2>/dev/null) && export DEPLOY_TAG; fi. flow-deploy always sets DEPLOY_TAG when it calls the wrapper, to a placeholder while looking up the tag, so this never recurses.
  • flow-deploy status shows the deployed tag and where it came from.

Why this design: the containers are the only source that can't drift from reality. The record covers the "everything is stopped" case. Refusing on ambiguity is the safe failure, because a wrong guess recreates production on the wrong image.

A failed deploy leaves no trace

Any failure puts every app service back on the exact image it ran before the deploy. That covers a failed health check, a pull or compose error, an unexpected exception, or a signal: SIGTERM, Ctrl-C, or SIGHUP.

  • The rolling service that failed keeps its old container, as before.
  • Rolling services that already shipped are rolled back (new), in reverse order, by a rolling deploy onto their restore tag with no pull (compose pulls it only if it's missing). If a previous-image container survived its cutover (a cleanup failure), the new containers are removed instead. Stopped leftovers are removed first, since compose up --scale would restart them.
  • Every drain-first service comes back on its previous image at its replica count, in deploy.order. That includes one already healthy on the new tag. New-image containers are drained first. A service that wasn't running before stays stopped.
  • The repo goes back to the previous commit.
  • The lock is always released. Keeping it was never a guard: acquire() breaks any lock whose PID has exited.

Further signals are ignored while the revert runs, and logging survives a closed stdout or stderr. A signal that arrives while a drain-first container is stopping is held until that container has stopped and been removed, and then the deploy reverts. The drain's docker stop runs in its own session, so a terminal's Ctrl-C can't cut it short.

A cancelled CI job does not revert a remote deploy. deploy-action runs ssh with no pty. tests/integration/test_ci_cancel.py shows against a real sshd that the remote process receives no signal and runs to completion (or reverts on its own failure). With ssh -tt it would receive SIGHUP.

Exit codes: 0 success, 1 failed and fully reverted, 2 locked, and new 3 failed and not fully reverted. Exit 3 means a git restore, rollback, restore, or a failed service's own cleanup failed. Someone needs to look.

Rolling deploys: master's behavior, plus listed changes

tests/test_rolling_parity.py replays 15 rolling scenarios on a simulated Docker host (tests/fake_host.py) against tests/fixtures/rolling_master.json, recorded by running the same scenarios on master 0.3.0. Every docker, compose and git command, its env, the exit code, and the lock state must match once the listed departures are applied. Deliberate changes, each pinned by that test:

  • Pre-deploy checks: the snapshot (running image, plus restore tag via git rev-parse --verify and docker image inspect), and the project-name check.
  • git fetch --tags --force, and compose config rendered with DEPLOY_TAG.
  • A later service's failure rolls back services that already shipped. Previously they stayed on the new tag beside services on the old one.
  • Fixes a master bug: a failed health check no longer replaces the old container. Scale-back passes --no-recreate. On master it recreated the surviving old container on the new, failing image (found by the integration suite).
  • Scale-back is skipped when the container it should leave alone couldn't be stopped and removed. With two left, it removes one and might pick the wrong one. The cutover still succeeds with a warning, as on master.
  • A failed git restore exits 3 and releases the lock.
  • A signal restores the repo before exiting.

Operator-facing additions

  • Killed containers are reported. After a drain or a cutover stop, a container that exited 137 or used the whole drain timeout gets a warning: its PID 1 isn't handling SIGTERM. The deploy still succeeds.
  • Host-side run log. Every deploy is recorded in .git/flow-deploy.log (header, every line, exit code; last 20 runs; inside .git, so the tree stays clean). It's written only while the deploy lock is held. status shows how the last run ended, including "no exit recorded" for one that was killed.
  • The record holds the deployed commit. .git/deploy-tag now records the SHA as well as the tag.
  • A revert waits for what it restored. Restored services must reach their previous replica count and pass their health check. If they don't, the deploy exits 3.
  • Discovery doesn't need the app's env. If compose config fails in CI (no .env), flow-deploy config retries with --no-interpolate and warns on stderr, naming the unset variables. Stdout stays pure JSON. It always sets DEPLOY_TAG to a placeholder, so a wrapper that defaults it never asks.
  • Atomic deploy lock. Acquirers take an flock on .git/deploy-lock.guard while they check and write the lock, closing a check-then-write race (pre-existing) that let two deploys starting together both proceed.

Upgrading

Projects already on flow-deploy keep working without changes. Rolling-only behavior differs only as listed above. To use version-tag deploys and get exact reverts:

  1. Push every image under its full commit SHA, plus the version tag for releases. A revert falls back to the previous commit's full SHA whenever the running tag can't be trusted (:latest, a moved tag, a digest, no tag). :latest is optional; flow-deploy never relies on it.
    tags: |
      ${{ env.IMAGE }}:${{ github.sha }}
      ${{ steps.release.outputs.tag && format('{0}:{1}', env.IMAGE, steps.release.outputs.tag) || '' }}
  2. Deploy by the version tag, with the SHA as the fallback. The git tag must be pushed before the deploy runs:
    - uses: flowcanon/deploy-action@master
      with:
        tag: ${{ steps.release.outputs.tag || github.sha }}
  3. Name every app image with DEPLOY_TAG, directly or through .env: DOCKER_IMAGE=ghcr.io/org/app:${DEPLOY_TAG:-latest}. A bare image name always runs :latest, and the deploy warns about it.
  4. Make manual compose commands keep the deployed version. Either use flow-deploy compose up -d, or have the compose wrapper (script/prod) default DEPLOY_TAG and fail closed:
    if [ -z "${DEPLOY_TAG:-}" ]; then
      DEPLOY_TAG=$(flow-deploy tag) || {
        echo "script/prod: set DEPLOY_TAG (flow-deploy tag couldn't tell)" >&2
        exit 1
      }
      export DEPLOY_TAG
    fi
    On a fresh host with nothing running, pass DEPLOY_TAG=vX.Y.Z explicitly.
  5. Label queue workers and schedulers deploy.strategy: drain-first, with deploy.drain set to the longest job plus a margin. Any deploy.order workaround that kept workers late becomes unnecessary.
  6. Keep accessories off the app's .env. Drop env_file from postgres, redis and the like, and list the variables they need under environment: (interpolated from .env). Then app-env edits don't recreate them. Scope manual changes: flow-deploy compose up -d --no-deps <service>.
  7. Make PID 1 handle SIGTERM. Use an exec-form CMD, or exec the server in the entrypoint. Otherwise every drain waits out its timeout and kills in-flight work (flow-deploy now warns).
  8. Upgrade flow-deploy on each host before relying on the new behavior. deploy-action only updates the copy in CI, which runs config. Pass upgrade: true, or run flow-deploy upgrade on the host.

deploy-action: no changes required. It passes tag through, and flow-deploy fetches tags itself.

Docs

README (Image Tags, manual commands, revert model), SPEC (lifecycle with snapshot and revert steps, deploy order and drain-first, §2.8 image tags, §4.7 tag, §4.8 compose, exit codes, the wrapper example, the workflow example) and docs/github-actions.md (the image tag contract, version-tag deploys with a SHA fallback, release-builder created-tag, troubleshooting). site/docs.html renders the markdown, so it needs no change. CHANGELOG is generated by release-builder.

Review

Codex reviewed every round. The image-tag work took five passes on an alias-tag design. That design was then replaced, at the owner's direction, with restores named by git tags and SHAs. The replacement took four more passes, and the operator-facing additions took four more. A final adversarial review for cruft cut the accessory drift check (warn-only, sensitive to Compose versions) before merge. Fixed along the way:

  • Shipped-service rollback after a cutover cleanup failure. Rollback now goes by image ID and keeps a surviving previous-image container.
  • Scale-back is skipped while the container it should leave alone might still exist.
  • Renamed compose projects are refused. The check is narrowed to projects running this deploy's services, so a shared directory is fine.
  • A tag-plus-digest ref is checked as repo:tag, not as the digest.
  • A redeploy of the running tag restores by SHA, since the deploy's pull moves that tag.
  • Stopped leftovers are removed before a revert's compose up --scale. That includes those of the failed service itself, so the next deploy can't restart them.
  • flow-deploy tag after a revert from :latest answers with the SHA when every container runs that one image.
  • The run log is opened only under the lock, so a locked-out deploy can't clobber it.
  • A restored service with zero containers up counts as not restored.
  • The lock race (pre-existing).
  • A signal mid rolling service: the service being rolled is now reverted too, not left with old and new side by side.
  • Doc mismatches.

Declined, with reasons:

  • docker ps --filter status=exited needs --all: it doesn't. I checked on a real daemon, and the code comments it.
  • Keep recognizing old alias containers: the alias design was never released.
  • The same-SHA redeploy limit: accepted, warned and documented, since it follows from using no host-side tags.

Follow-ups

Testing

  • script/lint clean.
  • Unit: poetry run pytest gives 338 passed at 100% coverage. The tests run on FakeHost, which simulates docker, compose and git from in-memory state, so a test declares what runs and what fails rather than a call sequence.
  • Integration (real Docker): poetry run pytest -m integration --no-cov -p no:randomly gives 32 passed. New scenarios:
    • an annotated version-tag deploy
    • a SHA deploy
    • restoring a previously running version (v1 → v2 ok, then v3 fails, so everything returns to v2, shipped web included)
    • a first deploy from :latest, restored by the previous commit's SHA after :latest is re-pushed and pulled
    • a digest-pinned worker, restored by the previous commit's SHA
    • another project with different services in the directory doesn't block a deploy
    • SIGKILL warnings (drain and cutover)
    • the host-side log across a success and a failure, and status
    • a revert whose restored worker stays unhealthy exits 3
    • discovery with the image variable unset
    • flow-deploy compose up -d changes nothing after a deploy
    • flow-deploy compose refuses with nothing to go on
    • a moved and re-pushed tag
    • another compose project in the directory is refused

🤖 Generated with Claude Code

https://claude.ai/code/session_01Be6ghMqRxW6R4WS3vJY1Yv

wadewilliams and others added 11 commits October 8, 2026 22:27
Rolling deploys overlap old and new containers. For a queue worker that
means old task code keeps running, through a drain of up to an hour,
after the new web container has migrated the database.

deploy.strategy: drain-first runs a service around the rolling phase:

1. Drain: pull the new image (so a bad tag fails before anything stops),
   then docker stop --time <drain> and remove the running containers.
   SIGTERM is Celery's warm shutdown. Nothing rolls until every drain
   finishes.
2. Roll: rolling services deploy as before.
3. Start: compose up on the new tag and wait for the health check.

If the deploy fails before phase 3, drained services come back on the
image tag their old containers ran, recorded before removal, so the
rollback doesn't depend on how tags map to commits. A failed start has
no rollback, since old code would run against the new schema; the
service is left down and the deploy fails loudly. Unknown strategies
are rejected. Rolling remains the default, so existing projects are
unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Be6ghMqRxW6R4WS3vJY1Yv
- A docker stop or rm failure during the drain now aborts before
  anything rolls, restoring what was drained, instead of marking the
  service drained while old code may still run.
- Drain records keep the replica count and image; restore brings
  services back at that scale with --scale, and mixed old images are
  flagged.
- Phase 3 starts every drain-first service even if one fails. A failed
  start is restored on its previous tag when no rolling service
  deployed (e.g. --service worker); otherwise it stays down.
- A failed restore is reported and sets the retained-lock message, as a
  failed git restore does.
- --service that rolls services while leaving drain-first ones out
  warns that they keep running old code.
- _image_tag ignores a digest suffix: repo@sha256:... has no tag (it
  returned the digest hex as one), and name:tag@sha256:... keeps the tag.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Be6ghMqRxW6R4WS3vJY1Yv
From the second review:

- A rolling-phase failure restored drained services even when an
  earlier rolling service had already deployed, putting the old worker
  back beside the new web. Every abort now follows one rule: restore
  drained services on their old tags only while nothing has shipped;
  after that, leave them down with a message naming what shipped.
- A failed start left its unhealthy container running, so the service
  wasn't actually down, and a restore on an unchanged config could
  reuse it. It's now stopped (with the service's drain) and removed.
- abort() builds the FAILED footer from a reason, so suffixes read
  "FAILED (worker failed to start; worker down)".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Be6ghMqRxW6R4WS3vJY1Yv
A failed deploy should leave no trace and need no manual cleanup:

- Drained services are always restored on the image tag and replica
  count they ran, whatever failed, including after a rolling service
  shipped and when a drain-first service fails its own start. The
  "leave it down" rule is gone: expand-then-contract migrations,
  which rolling deploys already rely on, keep old code compatible with
  the new schema.
- The lock is always released. Retaining it was never a guard anyway:
  acquire() breaks any lock whose PID is gone.
- New exit code 3 when something can't be put back (git restore
  failed, a restore's compose up failed, or the old image had no tag),
  so CI can tell "failed and reverted" (1) from "needs a person" (3).

Ordering follows dependencies: drain-first services drain in reverse
deploy.order, rolling services roll in deploy.order, and drain-first
services start (and restore) in deploy.order. So with web 100, api 200,
worker 300, the worker drains first and starts last.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Be6ghMqRxW6R4WS3vJY1Yv
From the third review:

- A signal (cancelled CI job, Ctrl-C) or unexpected exception now
  reverts like any other failure instead of exiting with drained
  services down. The handler raises Interrupted; deploy() wraps the
  run, ignores further signals while reverting, and still releases
  the lock. Drain-first services already healthy on the new tag stay.
- Cleanup failures are reported. _deploy_service and _start_service
  return OK / FAILED / DIRTY; a stop, rm, or scale-back that fails
  during a rolling abort, a cutover, or a failed start makes the deploy
  exit 3 instead of claiming a clean revert.
- Phase 3 starts drain-first services at their drained replica count
  and health-checks every replica.
- _image_tag treats a bare image ID (sha256:...) as untagged.
- README states the DEPLOY_TAG contract that restores rely on.

Coverage is now required at 100% (pyproject fail_under), with tests for
the previously uncovered CLI status command, version fallback, lock,
log, process, and container paths, plus a test module for deploy
failure paths. The __main__ guard is excluded.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Be6ghMqRxW6R4WS3vJY1Yv
From self-review:

- _drain_service records what's running before stopping anything, so a
  signal during a long drain (up to the longest task) still restores
  the service instead of leaving it down.
- SIGHUP reverts too. deploy-action runs flow-deploy over ssh; a
  cancelled CI job kills the ssh client and the remote process gets
  SIGHUP, which previously killed it with no revert.
- Logging never raises: once the ssh session is gone, stdout/stderr
  writes fail with OSError, which would have crashed the revert.
- Signals are ignored inside _Run.revert itself, so a second signal
  during a normal failure's revert can't cut it short or start another.
- Dry run returns EXIT_* constants.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Be6ghMqRxW6R4WS3vJY1Yv
From the fourth review:

- Signal handlers are installed before the lock is taken, and the lock
  is acquired inside the guarded block, so a signal can't land between
  the two and skip cleanup. The finally only releases a lock this
  process actually holds.
- The finally ignores signals before releasing the lock and restoring
  the original handlers, so a signal there can't skip the rest.
- Phase 3 starting a drain-first service that wasn't running at one
  replica is deliberate (newly added or crashed services come up with
  the deploy); the comment and test now say so.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Be6ghMqRxW6R4WS3vJY1Yv
Reliability review: a project that doesn't use drain-first must see
exactly master's behavior.

- Scale-up failure no longer issues a scale-back; as on master it fails
  without further compose commands.
- A successful cutover whose old-container stop/rm/scale-back fails
  succeeds again with a warning, as on master. The usual cause is the
  old container having exited on its own; failing would revert the repo
  underneath a healthy new container.
- tests/test_rolling_parity.py replays nine rolling scenarios (success,
  health abort, pull/scale-up/compose-config failures, container-count
  mismatch, cutover cleanup failure, dirty tree, restore failure)
  against fixtures/rolling_master.json, recorded by running them on
  master 0.3.0: every docker/compose command, its env, the exit code,
  and the lock. All match; the one agreed departure (failed git restore
  exits 3 and releases the lock) is listed in DELIBERATE_CHANGES.

Drain-first:

- A docker ps failure during a drain aborts before anything rolls
  instead of reading as "nothing running" (new
  containers.list_containers_for_service returns None on failure; the
  rolling helper is unchanged). Restarting containers are drained too.
- --service skipping drain-first services is a plain warning line, not
  an ::error:: annotation on a deploy that succeeds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Be6ghMqRxW6R4WS3vJY1Yv
From the confirmation review:

- _start_service lists containers with the failure-aware helper
  (running and restarting). If docker ps fails after compose up, it
  can't see or remove what started, so it reports SVC_DIRTY (exit 3)
  instead of treating "couldn't list" as "nothing to clean up" and
  restoring old workers beside new ones.
- The parity scenarios move to tests/rolling_scenarios.py, shared by the
  test and a recorder that runs them against a master checkout.
  fixtures/rolling_master.json is re-recorded from master 0.3.0 with 15
  scenarios, adding: second-service health abort, unidentifiable
  containers, same-tag redeploy, healthcheck.skip, --service, and
  deploy without --tag. All match master.
- The signal change for rolling-only projects (restore git, then exit 1)
  is now documented as deliberate and pinned by a rolling-only test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Be6ghMqRxW6R4WS3vJY1Yv
tests/integration runs flow-deploy as a subprocess against real
containers: a local registry:2, an env-driven stub service image, and a
throwaway git repo + bare origin + compose project per test. A new
`integration` CI job runs it; the unit job deselects it
(-m 'not integration'), so the 100% coverage gate is unchanged.

Scenarios: happy path (replica counts, drain/roll/start order); first
deploy of a new drain-first service; failed healthcheck on the rolling
service, on a rolling-only project, and on a drain-first service in
phase 3; bad tag failing at pull with nothing stopped; a drain that
hits its timeout and is SIGKILLed; SIGINT, SIGTERM, and a process-group
SIGINT mid-drain; lock contention (exit 2); an untagged old image (exit
3); a crash-looping worker; a healthcheck that passes late.

Bugs found and fixed:

- A signal mid-drain left the drain-first service down while exiting 1
  as "fully reverted": interrupting the docker CLI doesn't interrupt the
  daemon's stop, so the restore saw the container still running, left
  it, and it exited afterwards. Signals are now held until that
  container has stopped and been removed (_signals_deferred), and the
  drain's docker stop runs in its own session so a terminal's
  process-group Ctrl-C can't cut it short either.
- On master, a failed rolling healthcheck's scale-back ran
  `compose up --scale svc=1` with the new tag, recreating the surviving
  OLD container on the NEW failing image. Scale-back now passes
  --no-recreate; the parity test records it as the one deliberate
  command change.

CI cancel: tests/integration/test_ci_cancel.py runs a stand-in for
flow-deploy over real ssh to an sshd container and kills the client.
Without a pty (how deploy-action runs it) the remote process gets no
signal and runs to completion; with -tt it gets SIGHUP. README, SPEC,
and code comments now say a cancelled CI job does not revert a remote
deploy. SPEC section 5 no longer claims the lock is retained.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Be6ghMqRxW6R4WS3vJY1Yv
DEPLOY_TAG is normally a release version now (v0.9.2), with commit SHAs
still supported, so every consumer of the tag has to handle both:

- git fetch --tags --force, so release tags arrive and a re-pushed tag
  moves; checkout handles lightweight and annotated tags and SHAs.
- compose config is rendered with DEPLOY_TAG, and a warning is printed
  when an app image ignores it.

What a failed deploy restores is read from the containers, not from git
HEAD or :latest. Before changing anything, each app service's running
image ID is recorded and aliased locally as <repo>:flow-deploy-<image id>.
Any failure or signal then reverts every app service onto that alias:
rolling services that already shipped are rolled back, and drain-first
services are restored. The restore is exact whether a service ran a
version, a SHA, a moved :latest, a digest, or no tag at all. The deploy
stops before touching anything if an image can't be aliased or if
another compose project runs in the directory.

The revert also handles cleanup failures, which master didn't:

- A rollback keeps a previous-image container that survived its
  cutover.
- Scale-back waits until the other container is really gone.
- Stopped leftovers are removed before compose up --scale can restart
  them.

The manual-ops hazard is fixed. `flow-deploy tag` prints the deployed
tag, taken from the running containers, or from .git/deploy-tag when
nothing runs, and refuses on ambiguity. `flow-deploy compose ARGS` runs
the compose wrapper with that tag, so `up -d` on the host never silently
moves services to :latest. A wrapper can default DEPLOY_TAG the same
way, and a lookup placeholder keeps that from recursing. `status` shows
the deployed tag.

Unit tests now run on a FakeHost simulator rather than call-sequence
mocks. The rolling parity test replays master's recorded commands on the
same scenarios, with each deliberate change listed. New integration
scenarios cover:

- a version-tag deploy and a SHA deploy
- restoring a previously running version
- a first deploy from :latest, restored after :latest is re-pushed
- manual compose
- a moved tag
- a renamed compose project

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Be6ghMqRxW6R4WS3vJY1Yv
@wadewilliams wadewilliams changed the title Add a drain-first deploy strategy; revert every failed deploy cleanly Drain-first deploys, version-tag deploys, and exact reverts Oct 11, 2026
wadewilliams and others added 8 commits October 11, 2026 00:10
Under GITHUB_ACTIONS, log.error also writes an ::error:: annotation to
stdout. A failing `flow-deploy tag` therefore printed that annotation
where scripts read the tag. `tag` and `compose` now report "can't tell"
on stderr only.

The unit suite assumed GITHUB_ACTIONS was unset, so it failed in CI
(test_warning, test_tag_unknown_exits_1). An autouse fixture now clears
it; tests that need annotations set it explicitly.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Be6ghMqRxW6R4WS3vJY1Yv
A revert no longer creates flow-deploy-<image id> tags on the host.
Restores are named by git, so nothing is stashed on the server.

- The deploy records the tag each service was started from, and a
  revert uses it when all of these hold:
  - it's a git ref (git rev-parse --verify)
  - it still names the running image locally
  - it isn't the tag being deployed, which this deploy pulls
- Otherwise (:latest, a moved tag, a digest, no tag, a redeploy of the
  same tag) a revert uses the full SHA of the commit checked out before
  the deploy, which CI pushes every image under.
- A ref like repo:v1@sha256:... is checked as repo:v1, the name a
  revert renders.
- Redeploying the commit that's already running leaves no other git
  name for its build. If that image was rebuilt under the same tag, a
  revert restores the rebuild. The deploy warns up front. Master didn't
  roll shipped services back at all.

Fixes two compatibility problems in the previous commits:

- App services whose image can't be tagged (e.g. build-only) no longer
  abort every deploy, since the snapshot needs no tag to proceed.
- The renamed-project guard only refuses when another compose project
  runs this deploy's services in the directory. An unrelated project
  sharing the directory no longer blocks deploys.

flow-deploy tag treats one image running under :latest plus one other
tag, as after a revert from :latest, as that other tag.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Be6ghMqRxW6R4WS3vJY1Yv
Fixes from a host rehearsal, so an operator can see what a deploy did
and what it left pending:

- A container that had to be killed is reported. After a drain or a
  cutover stop, a container that exited 137 or used the whole timeout
  gets a warning that its PID 1 isn't handling SIGTERM. The deploy
  still succeeds.
- Accessory drift is reported, never acted on. A deploy and status warn
  when an accessory's `compose config --hash` no longer matches its
  running containers' config-hash label, meaning the next manual
  `up -d` will recreate it. One-off containers are ignored.
- Every run is recorded in .git/flow-deploy.log (header, output, exit
  code; last 20 runs). It's written only while the lock is held, so a
  locked-out deploy can't touch it. status shows how the last run
  ended, including one that never recorded an exit.
- .git/deploy-tag records the deployed commit.
- A revert waits for what it restored. Each restored service must reach
  its previous replica count and pass its health check, or the deploy
  exits 3.
- `flow-deploy config` doesn't need the app's env. If compose config
  fails (CI has no .env), it retries with --no-interpolate and warns on
  stderr, naming the unset variables. It keeps stdout pure JSON for
  deploy-action and always sets DEPLOY_TAG, so a wrapper that defaults
  it never asks.

Two failure paths found in review are fixed:

- A signal in the middle of a rolling service (its health wait, the
  cutover) now reverts that service too, instead of leaving old and
  new side by side.
- lock.acquire() no longer has a check-then-write race between deploys
  started together. Acquirers take an flock on .git/deploy-lock.guard,
  which the kernel releases if the holder dies.

Docs:

- the fail-closed script/prod snippet
- keep accessories off the app's .env (list their variables under
  environment:), and scope manual changes with
  `flow-deploy compose up -d --no-deps <service>`
- troubleshooting for each new warning

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Be6ghMqRxW6R4WS3vJY1Yv
On a real host the accessory drift warning was a false positive for any
service with an env_file. It compared `compose config --hash=<svc>`
with the running container's config-hash label, but on Compose v5
`config --hash` hashes env_file services differently from `up`. The
warning fired right after a fresh `up -d` that compose itself reported
as Running.

Ask compose instead. `up -d --dry-run --no-deps <svc>` changes nothing
and reports `Recreate` exactly when the next `up -d` would recreate the
container. It runs with COMPOSE_PROGRESS=plain and COMPOSE_ANSI=never,
because an operator's progress settings change or hide those lines. If
compose can't answer (an older compose without --dry-run), no warning
is given. The config-hash helpers are removed.

The new integration test uses an env_file accessory. It gets no
warning when unchanged and a warning after an env edit, and is never
recreated. It fails on the previous code. SPEC states the compose
wrapper's contract: pass arguments through, do nothing else.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Be6ghMqRxW6R4WS3vJY1Yv
Compose v2 prefixes every dry-run line with " DRY-RUN MODE - ", and
would_recreate anchored its match at the start of the line. On the
GitHub runner (Compose v2), drift was never detected and the drift
integration tests failed.

The fix searches within each line. Tests now cover both formats,
including "Recreated" and "Running", which must not match. Checked end
to end against Compose v2.29.7 and v5.3.1: an unchanged env_file
accessory is not flagged, and an edited one is.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Be6ghMqRxW6R4WS3vJY1Yv
A fail-closed compose wrapper (script/prod exits 1 when DEPLOY_TAG is
unset and `flow-deploy tag` can't tell) must never run its own lookup
from inside flow-deploy, or a check built on that call silently gives
no answer. Three paths could hit it:

- status's accessory drift check passed DEPLOY_TAG="" when the deployed
  tag was unknown, so no drift warning would show. It now passes the
  lookup placeholder.
- exec and logs passed no DEPLOY_TAG. They now pass an explicit
  DEPLOY_TAG if one is set, else the placeholder.

The FakeHost now behaves like a fail-closed wrapper by default: any
compose call without DEPLOY_TAG fails. Every unit test enforces the
contract, and no other path lacked it. A new integration test commits a
wrapper that refuses outright without DEPLOY_TAG and drives deploy,
status (with drift), config, exec and logs through it.

SPEC §3.1 now states the wrapper contract. Environment setup is
allowed. The wrapper must pass its arguments through unchanged, print
nothing extra on stdout, exit with compose's status, and never prompt
or change files. SPEC also states that flow-deploy always passes
DEPLOY_TAG. The README matches.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Be6ghMqRxW6R4WS3vJY1Yv
The next release is the first real `flow-deploy upgrade` on a
production host, and this is the code every later upgrade runs:

- A directory the user can't write is reported with what to do. 0.3.0
  failed there with an UnboundLocalError traceback, because tmp_path
  was unset in its except block.
- The downloaded binary must run (`--version` printing "flow-deploy")
  before it replaces the old one. A wrong-libc build or a bad download
  can no longer leave the host without a working flow-deploy. The
  replace itself is still an atomic rename beside the old binary.

README gains an Upgrading section:

- installs are per user, so deploy-action's `upgrade: true` upgrades
  only the binary of the user it deploys as
- 0.3.0's upgrade risks: no check on the download, and a traceback on
  an unwritable directory, either of which stops the deploy
- the first deploy after upgrading needs no migration

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Be6ghMqRxW6R4WS3vJY1Yv
From the adversarial pre-merge review. The drift warning was warn-only,
its output parsing depended on the Compose version (v2's "DRY-RUN MODE -"
prefix versus v5), it added a dry-run call per accessory on every deploy,
and an earlier form had false positives. The root cause it targeted,
.env edits recreating accessories, is handled by the documented pattern:
accessories drop env_file and list their variables under environment:.

Removed: compose.would_recreate, the deploy-time warning, the status
line, the FakeHost dry-run model, and their unit and integration tests.

Kept, deliberately: the "one image under :latest plus one other tag"
case in deployed.current(). Without it, after a failed first deploy from
an all-:latest host, the failed service keeps its :latest container
while restored services run the previous SHA. `flow-deploy tag` would
then refuse, and a project's fail-closed script/prod would refuse every
manual and scripted compose call until the next deploy. A new test pins
that scenario end to end.

Docs no longer promise more than the code does:
- no drift warning
- files are written in .git
- a failure restores or exits 3
- a build: service is rebuilt when its full-SHA image is missing

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Be6ghMqRxW6R4WS3vJY1Yv
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support singleton deploy role for services like Celery Beat

1 participant