diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 48541de..9b9c8e7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -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 diff --git a/src/flow_deploy/upgrade.py b/src/flow_deploy/upgrade.py index 38642c6..9978bed 100644 --- a/src/flow_deploy/upgrade.py +++ b/src/flow_deploy/upgrade.py @@ -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}" @@ -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}...") diff --git a/tests/test_upgrade.py b/tests/test_upgrade.py index f5a2a3b..aaf3066 100644 --- a/tests/test_upgrade.py +++ b/tests/test_upgrade.py @@ -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() @@ -100,15 +121,29 @@ 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() @@ -116,17 +151,19 @@ def test_upgrade_download_failure(mock_libc, mock_path, mock_dl, tmp_path): 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 @@ -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"])