Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Every service in `docker-compose.yml` is classified by a label:

### 2.2 Deploy Lifecycle

The deploy runs as a single transaction — `flow-deploy deploy --tag <sha>` owns the full lifecycle including git operations. The `--tag` value serves double duty: it is both the Docker image tag and the git SHA to checkout.
The deploy runs as a single transaction — `flow-deploy deploy` owns the full lifecycle including git operations. When `--tag <sha>` is provided, the value serves double duty: it is both the Docker image tag and the git SHA to checkout. When `--tag` is omitted, the tool deploys the current HEAD — the git checkout is skipped and `DEPLOY_TAG` is set to the current commit SHA.

**Pre-flight and git checkout (before any service work):**

Expand All @@ -38,7 +38,7 @@ The deploy runs as a single transaction — `flow-deploy deploy --tag <sha>` own
If non-empty → log "working tree is dirty — deploy aborted", exit 1
0b. Fetch git fetch origin
0c. Record previous SHA previous_sha = git rev-parse HEAD
0d. Checkout (detached) git checkout --detach <sha>
0d. Checkout (detached) git checkout --detach <sha> (skipped when --tag is omitted)
```

**For each service with `deploy.role=app`, in the order they appear in the compose file:**
Expand Down Expand Up @@ -199,13 +199,13 @@ The GitHub Action (§7) reads these values by running `<compose-command> config`

### 2.8 Versioning and Image Tags

The tool needs to know which image tag to deploy. By default it pulls whatever tag is declared in the compose file (typically `latest` or a pinned tag). This can be overridden at deploy time:
The tool needs to know which image tag to deploy. When `--tag` is omitted, the tool uses the current HEAD SHA — deploying whatever is currently checked out. When `--tag` is provided, the tool checks out that ref and uses it as the image tag:

```
flow-deploy deploy --tag abc123f
```

When `--tag` is provided, the tool temporarily overrides the image tag for all `deploy.role=app` services before pulling. This is implemented via the `DEPLOY_TAG` environment variable, which compose files can reference:
The `--tag` value temporarily overrides the image tag for all `deploy.role=app` services before pulling. This is implemented via the `DEPLOY_TAG` environment variable, which compose files can reference:

```yaml
services:
Expand Down Expand Up @@ -290,7 +290,7 @@ flow-deploy deploy [--tag TAG] [--service SERVICE] [--dry-run]

| Flag | Description |
|---|---|
| `--tag TAG` | Override image tag for all app services |
| `--tag TAG` | Image tag and git ref to deploy (defaults to current HEAD SHA) |
| `--service SERVICE` | Deploy only a specific service (repeatable) |
| `--dry-run` | Show what would happen without executing |

Expand Down Expand Up @@ -563,7 +563,7 @@ cd /srv/myapp
script/prod up -d postgres redis

# First deploy
flow-deploy deploy --tag latest
flow-deploy deploy
```

### 8.3 Upgrading Across a Fleet
Expand Down
10 changes: 5 additions & 5 deletions src/flow_deploy/deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ def deploy(
"""Perform a rolling deploy. Returns exit code (0=success, 1=failure, 2=locked)."""
compose_cmd = cmd or compose.resolve_command()

# Determine tag
if tag is None:
tag = "latest"

# Determine tag: if not provided, deploy whatever is currently checked out
tag_provided = tag is not None
if not tag_provided:
tag = git.current_sha()

if dry_run:
# Dry run: just parse config from current checkout, no git or lock
Expand Down Expand Up @@ -58,7 +58,7 @@ def _cleanup_handler(signum, frame):
# Protected by the lock so concurrent deploys can't race on checkout.
# Must happen before config parsing so we read the compose file
# from the target commit, not whatever is currently checked out.
git_code, previous_sha = git.preflight_and_checkout(tag)
git_code, previous_sha = git.preflight_and_checkout(tag if tag_provided else None)
if git_code != 0:
return 1

Expand Down
20 changes: 12 additions & 8 deletions src/flow_deploy/git.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,14 @@ def checkout_detached(sha: str) -> process.Result:
return process.run(["git", "checkout", "--detach", sha])


def preflight_and_checkout(tag: str) -> tuple[int, str | None]:
"""Run git pre-flight checks and checkout the deploy SHA.
def preflight_and_checkout(tag: str | None = None) -> tuple[int, str | None]:
"""Run git pre-flight checks and optionally checkout the deploy SHA.

When tag is provided, checks out that ref in detached HEAD mode.
When tag is None, runs preflight only (dirty check + fetch) without checkout.

Returns (exit_code, previous_sha).
- (0, previous_sha) on success — repo is now at `tag` in detached HEAD.
- (0, previous_sha) on success.
- (1, None) on error — git operation failed or working tree is dirty.
"""
# 1. Dirty check
Expand All @@ -46,11 +49,12 @@ def preflight_and_checkout(tag: str) -> tuple[int, str | None]:
# 3. Record previous SHA
previous_sha = current_sha()

# 4. Checkout new SHA (detached HEAD)
result = checkout_detached(tag)
if result.returncode != 0:
log.error(f"git checkout failed: {result.stderr.strip()}")
return 1, None
# 4. Checkout new SHA (detached HEAD) — skip when deploying current HEAD
if tag is not None:
result = checkout_detached(tag)
if result.returncode != 0:
log.error(f"git checkout failed: {result.stderr.strip()}")
return 1, None

return 0, previous_sha

Expand Down
70 changes: 70 additions & 0 deletions tests/test_deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,18 @@ def _git_preflight():
]


HEAD_SHA = "head456def"


def _git_preflight_no_checkout():
"""Return the 3 mock responses for preflight when no tag is provided (no checkout)."""
return [
_ok(""), # git status --porcelain (clean)
_ok(), # git fetch origin
_ok(HEAD_SHA + "\n"), # git rev-parse HEAD
]


def _chdir(monkeypatch, tmp_path):
"""Set working directory and ensure .git/ exists for lock file."""
(tmp_path / ".git").mkdir(exist_ok=True)
Expand Down Expand Up @@ -379,3 +391,61 @@ def test_deploy_healthcheck_skip(mock_process, monkeypatch, tmp_path):
)
result = deploy(tag="abc123", cmd=COMPOSE_CMD)
assert result == 0


def test_deploy_no_tag_uses_head(mock_process, monkeypatch, tmp_path):
"""Deploy without --tag uses current HEAD SHA and skips git checkout."""
_chdir(monkeypatch, tmp_path)
single_svc_config = """\
services:
web:
image: app:latest
labels:
deploy.role: app
healthcheck:
test: ["CMD", "true"]
"""
mock_process.responses.extend(
[
# git rev-parse HEAD for tag resolution
_ok(HEAD_SHA + "\n"),
# git preflight (no checkout)
*_git_preflight_no_checkout(),
# compose config
_ok(single_svc_config),
# web: pull
_ok(),
# web: scale to 2
_ok(),
# web: docker ps
_ok(WEB_CONTAINER_OLD + "\n" + WEB_CONTAINER_NEW + "\n"),
# web: health check
_ok("healthy\n"),
# web: docker stop old
_ok(),
# web: docker rm old
_ok(),
# web: scale back to 1
_ok(),
]
)
result = deploy(cmd=COMPOSE_CMD)
assert result == 0


def test_deploy_no_tag_dry_run(mock_process, monkeypatch, tmp_path, capsys):
"""Dry run without --tag resolves tag from HEAD."""
_chdir(monkeypatch, tmp_path)
mock_process.responses.extend(
[
# git rev-parse HEAD for tag resolution
_ok(HEAD_SHA + "\n"),
# compose config
_ok(COMPOSE_CONFIG_YAML),
]
)
result = deploy(dry_run=True, cmd=COMPOSE_CMD)
assert result == 0
out = capsys.readouterr().out
assert "dry-run" in out
assert HEAD_SHA in out
15 changes: 15 additions & 0 deletions tests/test_git.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,21 @@ def test_preflight_checkout_failure(mock_process):
assert prev is None


def test_preflight_no_tag_skips_checkout(mock_process):
"""When tag is None, preflight runs dirty check + fetch but skips checkout."""
mock_process.responses.extend(
[
_ok(""), # git status --porcelain (clean)
_ok(), # git fetch origin
_ok("head123\n"), # git rev-parse HEAD
]
)
code, prev = preflight_and_checkout(None)
assert code == 0
assert prev == "head123"
assert len(mock_process.calls) == 3 # no checkout call


def test_restore_success(mock_process, capsys):
mock_process.responses.append(_ok())
assert restore("prev123") is True
Expand Down
Loading