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
5 changes: 3 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ jobs:
if: inputs.force_deploy || needs.detect_release.outputs.created_tag
name: Build & Deploy
runs-on: ubuntu-latest
needs: detect_release
environment: coruscant
needs: [detect_release, build_release]
environment: coruscant
permissions:
contents: read
packages: write
Expand Down Expand Up @@ -87,6 +87,7 @@ jobs:
user: ${{ vars.HOST_USER }}
ssh-port: ${{ vars.SSH_PORT }}
registry-token: ${{ secrets.GITHUB_TOKEN }}
upgrade: true

build_release:
if: inputs.force_release || needs.detect_release.outputs.created_tag
Expand Down
30 changes: 28 additions & 2 deletions src/flow_deploy/upgrade.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,38 @@ def _download(url: str, dest: str) -> None:
raise RuntimeError("curl or wget required")


def _latest_version() -> str | None:
"""Fetch the latest release tag from GitHub."""
import json

url = f"https://api.github.com/repos/{REPO}/releases/latest"
try:
fd, tmp = tempfile.mkstemp()
os.close(fd)
_download(url, tmp)
with open(tmp) as f:
data = json.load(f)
os.unlink(tmp)
tag = data.get("tag_name", "")
return tag.lstrip("v") if tag else None
except Exception:
return None


def upgrade() -> int:
"""Download and replace the current binary with the latest release.

Returns 0 on success, 1 on failure.
Returns 0 on success, 1 on failure. Skips if already up to date.
"""
from flow_deploy import __version__, log

log.info(f"Current version: {__version__}")

latest = _latest_version()
if latest and latest == __version__:
log.success(f"Already up to date ({__version__}).")
return 0

libc = _detect_libc()
url = f"https://github.com/{REPO}/releases/latest/download/flow-deploy-linux-{libc}"

Expand All @@ -59,7 +84,8 @@ def upgrade() -> int:
log.error(str(e))
return 1

log.info(f"Current version: {__version__}")
if latest:
log.info(f"Latest version: {latest}")
log.info(f"Binary: {current_path}")
log.info(f"Detected libc: {libc}")
log.info(f"Downloading latest from {url}...")
Expand Down
46 changes: 42 additions & 4 deletions tests/test_upgrade.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,16 +82,37 @@ def test_download_no_tools():
upgrade._download("https://example.com/bin", "/tmp/bin")


@patch("flow_deploy.upgrade._download")
def test_latest_version(mock_dl, tmp_path):
"""Parses version from GitHub API response."""
import json

def write_response(url, dest):
with open(dest, "w") as f:
json.dump({"tag_name": "v1.2.3"}, f)

mock_dl.side_effect = write_response
assert upgrade._latest_version() == "1.2.3"


@patch("flow_deploy.upgrade._download", side_effect=RuntimeError("network"))
def test_latest_version_failure(mock_dl):
"""Returns None when API request fails."""
assert upgrade._latest_version() is None


@patch("flow_deploy.upgrade._latest_version")
@patch("flow_deploy.upgrade._download")
@patch("flow_deploy.upgrade._binary_path")
@patch("flow_deploy.upgrade._detect_libc")
def test_upgrade_success(mock_libc, mock_path, mock_dl, tmp_path):
def test_upgrade_success(mock_libc, mock_path, mock_dl, mock_latest, tmp_path):
"""Full upgrade succeeds: downloads, replaces binary."""
binary = tmp_path / "flow-deploy"
binary.write_text("old")
mock_libc.return_value = "glibc"
mock_path.return_value = str(binary)
mock_dl.side_effect = lambda url, dest: open(dest, "w").write("new")
mock_latest.return_value = "99.99.99"

result = upgrade.upgrade()

Expand All @@ -100,33 +121,49 @@ def test_upgrade_success(mock_libc, mock_path, mock_dl, tmp_path):
assert "latest/download/flow-deploy-linux-glibc" in mock_dl.call_args[0][0]


@patch("flow_deploy.upgrade._latest_version")
def test_upgrade_already_current(mock_latest):
"""Upgrade is a no-op when already at the latest version."""
from flow_deploy import __version__

mock_latest.return_value = __version__

result = upgrade.upgrade()

assert result == 0


@patch("flow_deploy.upgrade._latest_version")
@patch("flow_deploy.upgrade._download", side_effect=RuntimeError("network error"))
@patch("flow_deploy.upgrade._binary_path")
@patch("flow_deploy.upgrade._detect_libc")
def test_upgrade_download_failure(mock_libc, mock_path, mock_dl, tmp_path):
def test_upgrade_download_failure(mock_libc, mock_path, mock_dl, mock_latest, tmp_path):
"""Upgrade returns 1 on download failure and cleans up temp file."""
binary = tmp_path / "flow-deploy"
binary.write_text("old")
mock_libc.return_value = "glibc"
mock_path.return_value = str(binary)
mock_latest.return_value = "99.99.99"

result = upgrade.upgrade()

assert result == 1
assert binary.read_text() == "old" # original untouched


@patch("flow_deploy.upgrade._latest_version", return_value="99.99.99")
@patch("flow_deploy.upgrade._binary_path", side_effect=RuntimeError("not found"))
def test_upgrade_no_binary(mock_path):
def test_upgrade_no_binary(mock_path, mock_latest):
"""Upgrade returns 1 when binary path cannot be determined."""
result = upgrade.upgrade()
assert result == 1


@patch("flow_deploy.upgrade._latest_version")
@patch("flow_deploy.upgrade._download")
@patch("flow_deploy.upgrade._binary_path")
@patch("flow_deploy.upgrade._detect_libc")
def test_upgrade_cli(mock_libc, mock_path, mock_dl, tmp_path):
def test_upgrade_cli(mock_libc, mock_path, mock_dl, mock_latest, tmp_path):
"""Upgrade command via CLI."""
from click.testing import CliRunner

Expand All @@ -137,6 +174,7 @@ def test_upgrade_cli(mock_libc, mock_path, mock_dl, tmp_path):
mock_libc.return_value = "glibc"
mock_path.return_value = str(binary)
mock_dl.side_effect = lambda url, dest: open(dest, "w").write("new")
mock_latest.return_value = "99.99.99"

runner = CliRunner()
result = runner.invoke(main, ["upgrade"])
Expand Down