Repository navigation
Drain-first deploys, version-tag deploys, and exact reverts - #31
Merged
Merged
Conversation
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
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
This was referenced Oct 11, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:latestsays.What
deploy.strategy: drain-firstis a new per-service label.rollingremains the default. A deploy runs in three phases:deploy.order. Each pulls its new image first, so a bad tag fails before anything stops. Then each runsdocker stop --time <deploy.drain>: SIGTERM (Celery's warm shutdown), then SIGKILL when the drain runs out. Nothing rolls until every drain has finished.deploy.order, as before.deploy.order, at the replica count they had before, and every replica must pass its health check.With
web(100),api(200) andworker(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: apprather 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 --detachhandles lightweight tags, annotated tags and SHAs.Images:
compose configis rendered with the deploy'sDEPLOY_TAG. A warning is printed when an app image doesn't end in:<tag>(e.g.DOCKER_IMAGEset 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:
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:
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--servicedeploy 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 runningdocker compose up -don the host silently moves every service to:latest. The design: flow-deploy knows what's deployed and lends it to manual commands.flow-deploy tagprints 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 thatDEPLOY_TAG. An explicitDEPLOY_TAGwins. It refuses rather than fall back to:latest.script/prodcan default it, which makesscript/prod up -dsafe 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 setsDEPLOY_TAGwhen it calls the wrapper, to a placeholder while looking up the tag, so this never recurses.flow-deploy statusshows 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.
compose up --scalewould restart them.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.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 stopruns 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-actionruns ssh with no pty.tests/integration/test_ci_cancel.pyshows against a real sshd that the remote process receives no signal and runs to completion (or reverts on its own failure). Withssh -ttit would receive SIGHUP.Exit codes:
0success,1failed and fully reverted,2locked, and new3failed and not fully reverted. Exit3means 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.pyreplays 15 rolling scenarios on a simulated Docker host (tests/fake_host.py) againsttests/fixtures/rolling_master.json, recorded by running the same scenarios onmaster0.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:git rev-parse --verifyanddocker image inspect), and the project-name check.git fetch --tags --force, andcompose configrendered withDEPLOY_TAG.--no-recreate. On master it recreated the surviving old container on the new, failing image (found by the integration suite).3and releases the lock.Operator-facing additions
.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.statusshows how the last run ended, including "no exit recorded" for one that was killed..git/deploy-tagnow records the SHA as well as the tag.compose configfails in CI (no.env),flow-deploy configretries with--no-interpolateand warns on stderr, naming the unset variables. Stdout stays pure JSON. It always setsDEPLOY_TAGto a placeholder, so a wrapper that defaults it never asks.flockon.git/deploy-lock.guardwhile 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:
:latest, a moved tag, a digest, no tag).:latestis optional; flow-deploy never relies on it.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.flow-deploy compose up -d, or have the compose wrapper (script/prod) defaultDEPLOY_TAGand fail closed:DEPLOY_TAG=vX.Y.Zexplicitly.deploy.strategy: drain-first, withdeploy.drainset to the longest job plus a margin. Anydeploy.orderworkaround that kept workers late becomes unnecessary..env. Dropenv_filefrom postgres, redis and the like, and list the variables they need underenvironment:(interpolated from.env). Then app-env edits don't recreate them. Scope manual changes:flow-deploy compose up -d --no-deps <service>.CMD, orexecthe server in the entrypoint. Otherwise every drain waits out its timeout and kills in-flight work (flow-deploy now warns).deploy-actiononly updates the copy in CI, which runsconfig. Passupgrade: true, or runflow-deploy upgradeon the host.deploy-action: no changes required. It passes
tagthrough, 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.8compose, exit codes, the wrapper example, the workflow example) anddocs/github-actions.md(the image tag contract, version-tag deploys with a SHA fallback, release-buildercreated-tag, troubleshooting).site/docs.htmlrenders 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:
repo:tag, not as the digest.compose up --scale. That includes those of the failed service itself, so the next deploy can't restart them.flow-deploy tagafter a revert from:latestanswers with the SHA when every container runs that one image.Declined, with reasons:
docker ps --filter status=exitedneeds--all: it doesn't. I checked on a real daemon, and the code comments it.Follow-ups
script/prodto the fail-closedDEPLOY_TAGdefault once hosts are past 0.3.0Testing
script/lintclean.poetry run pytestgives 338 passed at 100% coverage. The tests run onFakeHost, which simulates docker, compose and git from in-memory state, so a test declares what runs and what fails rather than a call sequence.poetry run pytest -m integration --no-cov -p no:randomlygives 32 passed. New scenarios::latest, restored by the previous commit's SHA after:latestis re-pushed and pulledstatusflow-deploy compose up -dchanges nothing after a deployflow-deploy composerefuses with nothing to go on🤖 Generated with Claude Code
https://claude.ai/code/session_01Be6ghMqRxW6R4WS3vJY1Yv