diff --git a/backend/bot/app/web/webapp/assets.py b/backend/bot/app/web/webapp/assets.py index b4cd115..f7c0391 100644 --- a/backend/bot/app/web/webapp/assets.py +++ b/backend/bot/app/web/webapp/assets.py @@ -834,6 +834,45 @@ def _run_git_command(*args: str) -> str: return result.stdout.strip() +def _normalize_version_branch(raw_branch: str) -> str: + branch = str(raw_branch or "").strip() + for prefix in ("refs/heads/", "refs/remotes/origin/", "origin/"): + if branch.startswith(prefix): + branch = branch[len(prefix) :] + break + if branch == "HEAD": + return "" + return re.sub(r"[^A-Za-z0-9._-]+", "-", branch).strip("-")[:48] + + +def _resolve_version_branch() -> str: + for env_name in ( + "REMNAWAVE_MINISHOP_BRANCH", + "GIT_BRANCH", + "BRANCH_NAME", + "GITHUB_REF_NAME", + "CI_COMMIT_REF_NAME", + ): + branch = _normalize_version_branch(os.getenv(env_name, "")) + if branch: + return branch + return _normalize_version_branch( + _run_git_command("branch", "--show-current") + or _run_git_command("symbolic-ref", "--quiet", "--short", "HEAD") + ) + + +def _format_app_version(tag: str, sha: str, branch: str) -> str: + branch_suffix = "" if not branch or branch == "main" else f"-{branch}" + if tag and sha: + return f"{tag}{branch_suffix}+g{sha}" + if sha: + return f"dev{branch_suffix}+g{sha}" + if tag: + return f"{tag}{branch_suffix}" + return f"dev{branch_suffix}+unknown" + + def _resolve_app_version() -> str: global _APP_VERSION_CACHE if _APP_VERSION_CACHE: @@ -855,21 +894,8 @@ def _resolve_app_version() -> str: tag = _run_git_command("describe", "--tags", "--abbrev=0") sha = _run_git_command("rev-parse", "--short", "HEAD") - dirty = bool(_run_git_command("status", "--porcelain")) - - if tag and sha: - commits_since_tag = _run_git_command("rev-list", f"{tag}..HEAD", "--count") - if commits_since_tag and commits_since_tag != "0": - version = f"{tag}+{commits_since_tag}.g{sha}" - else: - version = tag - elif sha: - version = f"dev+g{sha}" - else: - version = "dev+unknown" - - if dirty: - version = f"{version}-dirty" + branch = _resolve_version_branch() + version = _format_app_version(tag, sha, branch) _APP_VERSION_CACHE = version return version diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile index b2868f4..e3151ac 100644 --- a/deploy/docker/Dockerfile +++ b/deploy/docker/Dockerfile @@ -1,32 +1,45 @@ -# Resolve the application version from .git at build time and emit a single -# tiny ``.build-version`` file. The .git tree is consumed in this throwaway -# stage and never copied into the runtime image — only the resulting one-line -# version string ships. This matches the runtime fallback chain in -# ``_resolve_app_version`` (REMNAWAVE_MINISHOP_VERSION env > .build-version -# file > live git > "dev+unknown") so the admin sidebar always shows a tag / -# sha even though the runtime images have no git tooling and no .git tree. +# Resolve the application version from .git at build time and emit a tiny +# .build-version file. The .git tree is consumed in this throwaway stage and +# never copied into the runtime image; only the tag + commit version string +# ships. Non-main builds include the branch name so they are visibly distinct +# from release builds. This matches the runtime fallback chain in _resolve_app_version +# (REMNAWAVE_MINISHOP_VERSION env > .build-version file > live git > +# "dev+unknown") so the admin sidebar always shows a tag / sha even though the +# runtime images have no git tooling and no .git tree. FROM alpine:3.20 AS version-builder RUN apk add --no-cache git WORKDIR /repo +ARG REMNAWAVE_MINISHOP_BRANCH="" +ARG GIT_BRANCH="" +ARG BRANCH_NAME="" +ARG GITHUB_REF_NAME="" +ARG CI_COMMIT_REF_NAME="" COPY .git ./.git RUN set -eu; \ git config --global --add safe.directory /repo; \ tag=$(git describe --tags --abbrev=0 2>/dev/null || true); \ sha=$(git rev-parse --short HEAD 2>/dev/null || true); \ - dirty=$(git status --porcelain 2>/dev/null | head -c1 || true); \ + branch="${REMNAWAVE_MINISHOP_BRANCH:-${GIT_BRANCH:-${BRANCH_NAME:-${GITHUB_REF_NAME:-${CI_COMMIT_REF_NAME:-}}}}}"; \ + if [ -z "$branch" ]; then branch=$(git branch --show-current 2>/dev/null || true); fi; \ + if [ -z "$branch" ]; then branch=$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true); fi; \ + case "$branch" in \ + refs/heads/*) branch="${branch#refs/heads/}" ;; \ + refs/remotes/origin/*) branch="${branch#refs/remotes/origin/}" ;; \ + origin/*) branch="${branch#origin/}" ;; \ + HEAD) branch="" ;; \ + esac; \ + branch_slug=$(printf '%s' "$branch" | sed -E 's/[^A-Za-z0-9._-]+/-/g; s/^-+//; s/-+$//' | cut -c1-48); \ + branch_suffix=""; \ + if [ -n "$branch_slug" ] && [ "$branch_slug" != "main" ]; then branch_suffix="-$branch_slug"; fi; \ if [ -n "$tag" ] && [ -n "$sha" ]; then \ - commits_since_tag=$(git rev-list "$tag..HEAD" --count 2>/dev/null || true); \ - if [ -n "$commits_since_tag" ] && [ "$commits_since_tag" != "0" ]; then \ - version="${tag}+${commits_since_tag}.g${sha}"; \ - else \ - version="$tag"; \ - fi; \ + version="${tag}${branch_suffix}+g${sha}"; \ elif [ -n "$sha" ]; then \ - version="dev+g${sha}"; \ + version="dev${branch_suffix}+g${sha}"; \ + elif [ -n "$tag" ]; then \ + version="${tag}${branch_suffix}"; \ else \ - version="dev+unknown"; \ + version="dev${branch_suffix}+unknown"; \ fi; \ - if [ -n "$dirty" ]; then version="${version}-dirty"; fi; \ printf '%s' "$version" > /build-version; \ printf '%s' "${tag:-unknown}" > /build-tag; \ printf '%s' "${sha:-unknown}" > /build-commit diff --git a/tests/test_app_version_resolution.py b/tests/test_app_version_resolution.py index 098792e..defb55d 100644 --- a/tests/test_app_version_resolution.py +++ b/tests/test_app_version_resolution.py @@ -1,20 +1,21 @@ -"""Tests for ``_resolve_app_version`` — the source of the admin sidebar footer. +"""Tests for ``_resolve_app_version``: the admin sidebar footer source. The admin sidebar shows ``{appVersion}`` next to a "remnawave-minishop" GitHub -link. That string is rendered by -``backend/bot/app/web/webapp/assets.py::_resolve_app_version`` through the -following precedence chain: +link. Release builds from ``main`` should render the latest reachable tag and +commit sha, without a dirty suffix. Builds from other branches include the +branch name. That string is rendered by +``backend/bot/app/web/webapp/assets.py::_resolve_app_version`` through this +precedence chain: - 1. ``REMNAWAVE_MINISHOP_VERSION`` env var — manual override; - 2. ``$APP_ROOT/.build-version`` file — baked at Docker build time by the - ``version-builder`` stage in ``deploy/docker/Dockerfile`` (consumes .git - in a throwaway stage and ships only this one tiny file); - 3. live ``git describe`` / ``rev-parse`` — works in local dev where .git - is present; + 1. ``REMNAWAVE_MINISHOP_VERSION`` env var: manual override; + 2. ``$APP_ROOT/.build-version`` file: baked at Docker build time by the + ``version-builder`` stage in ``deploy/docker/Dockerfile``; + 3. live ``git describe`` / ``rev-parse``: local dev fallback where .git is + present; 4. ``"dev+unknown"`` as the ultimate fallback. -Before the build-time bake, the admin footer in production silently fell back -to step 4 because the Docker image carries no .git tree and no git binary. +The Docker image carries no git binary and no .git tree at runtime, so the +baked ``.build-version`` file is the production source for the sidebar. """ import importlib @@ -28,11 +29,20 @@ from unittest.mock import patch import bot.app.web.subscription_webapp # noqa: F401 from bot.app.web.webapp import assets as assets_module +_VERSION_ENV_NAMES = ( + "REMNAWAVE_MINISHOP_VERSION", + "REMNAWAVE_MINISHOP_BRANCH", + "GIT_BRANCH", + "BRANCH_NAME", + "GITHUB_REF_NAME", + "CI_COMMIT_REF_NAME", +) + def _reset_cache() -> None: # The resolver memoizes the first result in a module-level global. assets_module._APP_VERSION_CACHE = None # type: ignore[attr-defined] - # Some callers reach through the facade re-export — clear that too. + # Some callers reach through the facade re-export; clear that too. runtime = importlib.import_module("bot.app.web.webapp._runtime") runtime._APP_VERSION_CACHE = None # type: ignore[attr-defined] @@ -41,6 +51,13 @@ def _resolve(): return assets_module._resolve_app_version() +def _clean_version_env() -> dict: + env = dict(os.environ) + for name in _VERSION_ENV_NAMES: + env.pop(name, None) + return env + + class EnvOverrideTests(unittest.TestCase): def setUp(self) -> None: _reset_cache() @@ -62,12 +79,13 @@ class EnvOverrideTests(unittest.TestCase): self.assertEqual(called["git"], 0) def test_blank_env_var_falls_through(self): - env = {"REMNAWAVE_MINISHOP_VERSION": " "} + env = _clean_version_env() + env["REMNAWAVE_MINISHOP_VERSION"] = " " with ( - patch.dict(os.environ, env), + patch.dict(os.environ, env, clear=True), patch.object(assets_module, "_run_git_command", lambda *a: ""), ): - # No env, no file, no git → fallback string. + # No env, no file, no git: fallback string. self.assertEqual(_resolve(), "dev+unknown") @@ -80,24 +98,22 @@ class BuildVersionFileTests(unittest.TestCase): def test_reads_baked_version_file(self): with tempfile.TemporaryDirectory() as tmp: - (Path(tmp) / ".build-version").write_text("v3.4.5+12.gabcdef1", encoding="utf-8") - env = dict(os.environ) - env.pop("REMNAWAVE_MINISHOP_VERSION", None) + (Path(tmp) / ".build-version").write_text("v3.4.5+gabcdef1", encoding="utf-8") + env = _clean_version_env() with ( patch.dict(os.environ, env, clear=True), patch.object(assets_module, "APP_ROOT", Path(tmp)), - # ensure live git doesn't accidentally win if file read fails: + # Ensure live git does not accidentally win if file read fails. patch.object(assets_module, "_run_git_command", lambda *a: ""), ): - self.assertEqual(_resolve(), "v3.4.5+12.gabcdef1") + self.assertEqual(_resolve(), "v3.4.5+gabcdef1") def test_strips_trailing_whitespace_and_newlines_in_file(self): - # Shell ``printf '%s'`` writes no newline, but earlier helpers used - # ``echo`` which appends one. Both must produce the same result. + # Shell ``printf '%s'`` writes no newline, but older helpers may have + # used ``echo``. Both must produce the same result. with tempfile.TemporaryDirectory() as tmp: (Path(tmp) / ".build-version").write_text("v1.2.3\n\n", encoding="utf-8") - env = dict(os.environ) - env.pop("REMNAWAVE_MINISHOP_VERSION", None) + env = _clean_version_env() with ( patch.dict(os.environ, env, clear=True), patch.object(assets_module, "APP_ROOT", Path(tmp)), @@ -108,14 +124,13 @@ class BuildVersionFileTests(unittest.TestCase): def test_empty_file_falls_through_to_git_then_unknown(self): with tempfile.TemporaryDirectory() as tmp: (Path(tmp) / ".build-version").write_text("", encoding="utf-8") - env = dict(os.environ) - env.pop("REMNAWAVE_MINISHOP_VERSION", None) + env = _clean_version_env() with ( patch.dict(os.environ, env, clear=True), patch.object(assets_module, "APP_ROOT", Path(tmp)), patch.object(assets_module, "_run_git_command", lambda *a: ""), ): - # No env, empty file, no live git → ultimate fallback. + # No env, empty file, no live git: ultimate fallback. self.assertEqual(_resolve(), "dev+unknown") @@ -126,56 +141,83 @@ class LiveGitFallbackTests(unittest.TestCase): _reset_cache() self.addCleanup(_reset_cache) - def _run_with_git(self, replies: dict, *, dirty: bool = False) -> str: - # Map (subcommand, *args) tuples to canned stdout values. - def fake_git(*args): - return replies.get(args, "") - + def _run_with_git(self, replies: dict) -> str: with tempfile.TemporaryDirectory() as tmp: # ``.build-version`` deliberately absent so we fall through. - env = dict(os.environ) - env.pop("REMNAWAVE_MINISHOP_VERSION", None) + env = _clean_version_env() base = { ("describe", "--tags", "--abbrev=0"): replies.get("tag", ""), ("rev-parse", "--short", "HEAD"): replies.get("sha", ""), - ("status", "--porcelain"): "M file\n" if dirty else "", - ("rev-list", f"{replies.get('tag', '')}..HEAD", "--count"): replies.get( - "commits_since_tag", "0" - ), + ("branch", "--show-current"): replies.get("branch", ""), } - def real_fake_git(*args): + def fake_git(*args): return base.get(args, "") with ( patch.dict(os.environ, env, clear=True), patch.object(assets_module, "APP_ROOT", Path(tmp)), - patch.object(assets_module, "_run_git_command", real_fake_git), + patch.object(assets_module, "_run_git_command", fake_git), ): return _resolve() - def test_tag_with_zero_commits_since_returns_bare_tag(self): - result = self._run_with_git({"tag": "v2.0.0", "sha": "abcdef1", "commits_since_tag": "0"}) - self.assertEqual(result, "v2.0.0") + def test_tag_with_sha_returns_tag_plus_sha(self): + result = self._run_with_git({"tag": "v2.0.0", "sha": "abcdef1"}) + self.assertEqual(result, "v2.0.0+gabcdef1") - def test_tag_plus_distance_plus_sha_format(self): + def test_main_branch_does_not_add_branch_suffix(self): + result = self._run_with_git({"tag": "v2.0.0", "sha": "abcdef1", "branch": "main"}) + self.assertEqual(result, "v2.0.0+gabcdef1") + + def test_non_main_branch_adds_branch_suffix(self): + result = self._run_with_git({"tag": "v2.0.0", "sha": "abcdef1", "branch": "dev"}) + self.assertEqual(result, "v2.0.0-dev+gabcdef1") + + def test_branch_name_is_sanitized_for_version(self): + result = self._run_with_git( + {"tag": "v2.0.0", "sha": "abcdef1", "branch": "feature/cool build"} + ) + self.assertEqual(result, "v2.0.0-feature-cool-build+gabcdef1") + + def test_commit_distance_is_not_included(self): result = self._run_with_git({"tag": "v2.0.0", "sha": "abcdef1", "commits_since_tag": "7"}) - self.assertEqual(result, "v2.0.0+7.gabcdef1") + self.assertEqual(result, "v2.0.0+gabcdef1") def test_sha_only_when_no_tag(self): result = self._run_with_git({"sha": "abcdef1"}) self.assertEqual(result, "dev+gabcdef1") + def test_tag_only_when_no_sha(self): + result = self._run_with_git({"tag": "v2.0.0"}) + self.assertEqual(result, "v2.0.0") + def test_neither_tag_nor_sha_is_unknown(self): result = self._run_with_git({}) self.assertEqual(result, "dev+unknown") - def test_dirty_suffix_is_appended(self): - result = self._run_with_git( - {"tag": "v2.0.0", "sha": "abcdef1", "commits_since_tag": "0"}, - dirty=True, - ) - self.assertEqual(result, "v2.0.0-dirty") + def test_dirty_suffix_is_not_appended_or_queried(self): + calls = [] + + def fake_git(*args): + calls.append(args) + replies = { + ("describe", "--tags", "--abbrev=0"): "v2.0.0", + ("rev-parse", "--short", "HEAD"): "abcdef1", + ("status", "--porcelain"): "M file\n", + } + return replies.get(args, "") + + with tempfile.TemporaryDirectory() as tmp: + env = _clean_version_env() + with ( + patch.dict(os.environ, env, clear=True), + patch.object(assets_module, "APP_ROOT", Path(tmp)), + patch.object(assets_module, "_run_git_command", fake_git), + ): + result = _resolve() + + self.assertEqual(result, "v2.0.0+gabcdef1") + self.assertNotIn(("status", "--porcelain"), calls) class CacheBehaviourTests(unittest.TestCase): @@ -191,8 +233,7 @@ class CacheBehaviourTests(unittest.TestCase): return "abcdef1" if args == ("rev-parse", "--short", "HEAD") else "" with tempfile.TemporaryDirectory() as tmp: - env = dict(os.environ) - env.pop("REMNAWAVE_MINISHOP_VERSION", None) + env = _clean_version_env() with ( patch.dict(os.environ, env, clear=True), patch.object(assets_module, "APP_ROOT", Path(tmp)),