From 5603cacfdfde4f16267164cba9a92eb3a4636bb8 Mon Sep 17 00:00:00 2001 From: Wade Williams Date: Thu, 26 Mar 2026 10:21:35 -0600 Subject: [PATCH] Allow deploy command to work via cli --- SPEC.md | 12 +++---- src/flow_deploy/deploy.py | 10 +++--- src/flow_deploy/git.py | 20 ++++++----- tests/test_deploy.py | 70 +++++++++++++++++++++++++++++++++++++++ tests/test_git.py | 15 +++++++++ 5 files changed, 108 insertions(+), 19 deletions(-) diff --git a/SPEC.md b/SPEC.md index c26360c..639515a 100644 --- a/SPEC.md +++ b/SPEC.md @@ -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 ` 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 ` 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):** @@ -38,7 +38,7 @@ The deploy runs as a single transaction — `flow-deploy deploy --tag ` 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 +0d. Checkout (detached) git checkout --detach (skipped when --tag is omitted) ``` **For each service with `deploy.role=app`, in the order they appear in the compose file:** @@ -199,13 +199,13 @@ The GitHub Action (§7) reads these values by running ` 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: @@ -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 | @@ -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 diff --git a/src/flow_deploy/deploy.py b/src/flow_deploy/deploy.py index e30d54b..f1b1490 100644 --- a/src/flow_deploy/deploy.py +++ b/src/flow_deploy/deploy.py @@ -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 @@ -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 diff --git a/src/flow_deploy/git.py b/src/flow_deploy/git.py index 1c797a4..ab1e560 100644 --- a/src/flow_deploy/git.py +++ b/src/flow_deploy/git.py @@ -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 @@ -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 diff --git a/tests/test_deploy.py b/tests/test_deploy.py index 3736df4..229721b 100644 --- a/tests/test_deploy.py +++ b/tests/test_deploy.py @@ -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) @@ -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 diff --git a/tests/test_git.py b/tests/test_git.py index 4dcd5e8..f8c183e 100644 --- a/tests/test_git.py +++ b/tests/test_git.py @@ -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