What changed, and why it matters
This commit is mostly a routine code cleanup and test expansion for how SeedSigner reports its software version. It renames a decorator, adds more tests, improves error handling when files are missing, and fixes a timestamp formatting bug when checking GitHub releases. There is no direct evidence in the commit that this fixes an active security vulnerability, but it does reduce risky behavior such as silently swallowing errors and making unverified shell calls in some contexts.
No urgent action required. Review the version reporting logic in a normal release cycle and ensure the new tests pass. Monitor whether the GitHub API call and shell git invocations are appropriately restricted in production SeedSigner OS builds.
Security signals we found
Decorator rename and broader application to prevent shell/git operations in SeedSigner OS runtime environment
Replacement of bare 'except Exception: return None' with explicit FileNotFoundError handling and error logging
Fix for GitHub API timestamp parsing ('Z' to '+00:00')
Change from 'git describe --tags --abbrev=0' to 'git tag --points-at' for exact tag matching
Addition of unit tests covering error paths and OS-restriction decorator
Evidence from the diff
The commit refactors version.py: renames fail_if_running_seedsigner_os to not_allowed_in_seedsigner_os, adds DOT_GIT_DIR_NAME constant for testability, replaces broad exception swallowing with explicit FileNotFoundError handling plus traceback logging, renames _fetch_latest_release_version to _fetch_latest_seedsigner_release_tag, fixes published_at timestamp parsing by replacing trailing ‘Z’ with ‘+00:00’ before datetime.fromisoformat, changes git tag lookup to use ‘git tag –points-at’, and adds extensive unit tests. tools/write_versionfile.py is simplified and updated to document version_fork and version_commit_hash. The screenshot generator is updated to call the renamed method. No cryptographic, input validation, or privilege changes are present.
Changed components
src/seedsigner/helpers/version.pytools/write_versionfile.pytests/test_version.pytests/screenshot_generator/generator.pyInspect captured patch +648 / −97
diff --git a/src/seedsigner/helpers/version.py b/src/seedsigner/helpers/version.py
index 4365f15..cb6644d 100644
--- a/src/seedsigner/helpers/version.py
+++ b/src/seedsigner/helpers/version.py
@@ -1,6 +1,8 @@
import json
import logging
import os
+import traceback
+
from datetime import datetime, timezone
from seedsigner.models.settings import Settings
@@ -17,7 +19,7 @@ class NotAllowedInSeedSignerOS(Exception):
pass
-def fail_if_running_seedsigner_os(func: callable):
+def not_allowed_in_seedsigner_os(func: callable):
""" Simple decorator to enforce SeedSigner OS restrictions. """
def wrapper_func(*args, **kwargs):
if Settings.HOSTNAME == Settings.SEEDSIGNER_OS:
@@ -34,14 +36,21 @@ class Version(Singleton):
"""
Utility class to report the current version and the last edit time of the source code.
+ Implemented as a Singleton for convenient access (no need to keep passing an instance
+ around in the main code) and so that the version data is only determined once per
+ runtime.
+
On SeedSigner OS:
* Version data is written to `src/seedsigner/version.json` during the build
process via tools/write_versionfile.py.
* version_name: copied from the command used to build the SeedSigner OS image:
--app-branch (the target git branch OR tag) or
--app-commit-id (target commit hash)
+ * version_fork: the git repo owner/fork name (e.g. "SeedSigner") targeted by the
+ build process.
* version_timestamp: the last git commit time for the target branch, tag, or
commit hash.
+ * commit_hash: the short commit hash for the specified build target.
In local dev:
* version_name: read dynamically from a few possible sources. In order:
@@ -50,16 +59,28 @@ class Version(Singleton):
* Directly parsing the .git/HEAD file and possibly .git/refs/tags.
* Note: we avoid reading from the version.json file as it may be out of date
and could lead to confusion.
+ * version_fork: read dynamically from:
+ * Shell `git` call to check the remote "origin" URL.
+ * Parse the .git/config for the remote "origin" URL.
* version_timestamp: determined by scanning the src/ directory for the most
recently modified python file.
+ * commit_hash: read dynamically from:
+ * Shell `git` call.
+ * Parse the .git/HEAD file and possibly .git/refs/heads/<branch_name>.
In Github Actions CI:
* version_name: read from GITHUB_REF_NAME or GITHUB_SHA env vars.
+ * version_fork: TODO
* version_timestamp: TODO
+ * commit_hash: TODO
+
+ This class defines the limited methods that are meant to be publicly accessible
+ across the SeedSigner codebase.
- This `Version` class defines the limited methods that are meant to be used elsewhere
- in the SeedSigner codebase. The misc utility functions in `VersionUtils` were
- explicitly isolated because they should not be used elsewhere in the codebase.
+ The misc utility functions in `VersionUtils` were explicitly isolated because they
+ should NOT be used elsewhere in the codebase.
+ TODO: Should `VersionUtils` be an internal class within `Version` to further signal
+ that it is not to be used externally?
"""
_version_name: str = None
_version_fork: str = None
@@ -97,14 +118,14 @@ class Version(Singleton):
@classmethod
- @fail_if_running_seedsigner_os
+ @not_allowed_in_seedsigner_os
def reset_instance(cls):
""" Resets the singleton instance. Only used by the test suite between tests. """
cls._instance = None
@classmethod
- @fail_if_running_seedsigner_os
+ @not_allowed_in_seedsigner_os
def override_data(cls, version_name, version_fork, version_timestamp, version_commit_hash):
"""
Only used by the test suite to manually change version data between tests.
@@ -144,16 +165,31 @@ class VersionUtils:
Methods are separated out here to a rather extreme degree in order to enable all the
mocking that is required for testing.
+
+ Summary of functions:
+ * Top-level "get" calls that manage all the possible ways of getting the version
+ data in an environment-aware manner (SeedSigner OS, local dev, Github Actions
+ CI).
+ * Reading data from the version.json file (SeedSigner OS).
+ * Getting data from SeedSigner OS build env vars.
+ * Detecting Github Actions CI environment and getting data from its env vars.
+ * Parsing local filesystem .git/ files.
+ * Making shell `git` calls.
+ * One external http GET to github to get the most recent release tag.
********************************************************************************* """
ENV_VAR__SEEDSIGNER_VERSION_NAME = "SEEDSIGNER_VERSION_NAME"
VERSION_FILENAME = "version.json"
+ DOT_GIT_DIR_NAME = ".git" # defined to facilitate mocking in tests
ATTR__VERSION_NAME = "name"
ATTR__VERSION_FORK = "fork"
ATTR__VERSION_TIMESTAMP = "timestamp"
ATTR__VERSION_COMMIT_HASH = "commit_hash"
+ """ *************************************************************************************
+ Top-level "get" calls. These are the only functions meant to be called externally.
+ ************************************************************************************* """
@classmethod
def get_version_name(cls) -> str:
"""
@@ -281,6 +317,9 @@ class VersionUtils:
return version_name
+ """ *************************************************************************************
+ Reading data from the version.json file.
+ ************************************************************************************* """
@classmethod
def _get_version_file_path(cls) -> str:
# Have to back out of this file's location in the "helpers" dir to the main
@@ -296,9 +335,11 @@ class VersionUtils:
try:
with open(cls._get_version_file_path(), "r") as f:
return json.load(f)
- except Exception:
- # In local dev we don't expect/want this file to exist
- return None
+ except FileNotFoundError as e:
+ logger.debug(f"Ignoring {e}")
+ except Exception as e:
+ # Something unexpected happened. Flag it but keep going.
+ logger.error(traceback.format_exc())
@classmethod
@@ -341,6 +382,10 @@ class VersionUtils:
return version_data.get(cls.ATTR__VERSION_COMMIT_HASH)
+
+ """ *************************************************************************************
+ Getting data from SeedSigner OS build env vars or Github Actions CI env vars.
+ ************************************************************************************* """
@classmethod
def _get_version_name_from_env_var(cls) -> str | None:
"""
@@ -365,12 +410,13 @@ class VersionUtils:
""" *************************************************************************************
- These functions attempt to read directly from local .git/ or src/ files. These operations
- aren't dangerous but aren't necessary when we're running in SeedSigner OS, so we restrict
- them here.
+ Reading directly from local .git/ or src/ files.
+
+ These operations aren't dangerous but aren't necessary when we're running in
+ SeedSigner OS, so we apply the decorator restriction here.
************************************************************************************* """
@classmethod
- @fail_if_running_seedsigner_os
+ @not_allowed_in_seedsigner_os
def _get_dot_git_dir(cls) -> str:
# If it exists, the .git dir will be in the project root
path = os.path.dirname(os.path.abspath(__file__))
@@ -378,11 +424,11 @@ class VersionUtils:
# Have to back out of this file's location in "helpers" and "seedsigner" and "src" dirs
project_root = os.path.join(path, "..", "..", "..")
- return os.path.normpath(os.path.join(project_root, ".git"))
+ return os.path.normpath(os.path.join(project_root, cls.DOT_GIT_DIR_NAME))
@classmethod
- @fail_if_running_seedsigner_os
+ @not_allowed_in_seedsigner_os
def _read_git_HEAD_file(cls) -> tuple[str | None, str | None]:
"""
Reads the .git/HEAD file and returns a tuple of (branch_name, commit_hash) where
@@ -394,7 +440,7 @@ class VersionUtils:
branch_name = None
commit_hash = None
- if os.path.exists(git_HEAD_path):
+ try:
with open(git_HEAD_path, "r") as f:
git_ref = f.read().strip()
if git_ref.startswith("ref:"):
@@ -404,17 +450,22 @@ class VersionUtils:
# If we're on a detached HEAD, the contents will just be the current
# commit hash.
commit_hash = git_ref
+ except FileNotFoundError as e:
+ logger.debug(f"Ignoring {e}")
+ except Exception as e:
+ # Something unexpected happened. Flag it but keep going.
+ logger.error(traceback.format_exc())
return (branch_name, commit_hash)
@classmethod
- @fail_if_running_seedsigner_os
- def _get_matching_tag(cls, commit_hash: str) -> str | None:
+ @not_allowed_in_seedsigner_os
+ def _get_matching_tag_from_git_refs_tags(cls, commit_hash: str) -> str | None:
"""
Checks the .git/refs/tags dir for a tag that matches the provided commit hash.
"""
- git_refs_tags_dir = os.path.join(cls._get_dot_git_dir(), "refs", "tags")
- if os.path.exists(git_refs_tags_dir):
+ try:
+ git_refs_tags_dir = os.path.join(cls._get_dot_git_dir(), "refs", "tags")
for tag_filename in os.listdir(git_refs_tags_dir):
tag_path = os.path.join(git_refs_tags_dir, tag_filename)
with open(tag_path, "r") as tag_file:
@@ -423,11 +474,15 @@ class VersionUtils:
if tag_commit_hash == commit_hash:
# Filename is the tag name
return tag_filename
- return None
+ except FileNotFoundError as e:
+ logger.debug(f"Ignoring {e}")
+ except Exception as e:
+ # Something unexpected happened. Flag it but keep going.
+ logger.error(traceback.format_exc())
@classmethod
- @fail_if_running_seedsigner_os
+ @not_allowed_in_seedsigner_os
def _get_version_name_from_git_HEAD(cls) -> str | None:
"""
Reads the .git/HEAD file and, depending on the local dev git state, returns
@@ -438,7 +493,7 @@ class VersionUtils:
return branch_name
elif commit_hash:
# See if this commit_hash matches a tag
- matching_tag = VersionUtils._get_matching_tag(commit_hash)
+ matching_tag = VersionUtils._get_matching_tag_from_git_refs_tags(commit_hash)
if matching_tag:
return matching_tag
else:
@@ -446,7 +501,7 @@ class VersionUtils:
@classmethod
- @fail_if_running_seedsigner_os
+ @not_allowed_in_seedsigner_os
def _get_commit_hash_from_git_HEAD(cls) -> str | None:
"""
Reads the .git/HEAD file and, depending on the local dev git state, returns
@@ -458,17 +513,22 @@ class VersionUtils:
@classmethod
- @fail_if_running_seedsigner_os
+ @not_allowed_in_seedsigner_os
def _get_commit_hash_from_git_refs_heads(cls, branch_name: str) -> str | None:
"""
Reads the .git/refs/heads/<branch_name> file to get the current commit hash
for the given branch.
"""
- git_ref_path = os.path.join(cls._get_dot_git_dir(), "refs", "heads", branch_name)
- if os.path.exists(git_ref_path):
+ try:
+ git_ref_path = os.path.join(cls._get_dot_git_dir(), "refs", "heads", branch_name)
with open(git_ref_path, "r") as f:
commit_hash = f.read().strip()
return commit_hash
+ except FileNotFoundError as e:
+ logger.debug(f"Ignoring {e}")
+ except Exception as e:
+ # Something unexpected happened. Flag it but keep going.
+ logger.error(traceback.format_exc())
@classmethod
@@ -492,7 +552,7 @@ class VersionUtils:
@classmethod
- @fail_if_running_seedsigner_os
+ @not_allowed_in_seedsigner_os
def _get_version_fork_from_git_config(cls) -> str | None:
"""
Attempts to read the .git/config file to determine the remote "origin" URL
@@ -507,8 +567,8 @@ class VersionUtils:
[next_section]
...
"""
- git_config_path = os.path.join(cls._get_dot_git_dir(), "config")
- if os.path.exists(git_config_path):
+ try:
+ git_config_path = os.path.join(cls._get_dot_git_dir(), "config")
with open(git_config_path, "r") as f:
lines = f.readlines()
in_origin_section = False
@@ -523,11 +583,15 @@ class VersionUtils:
elif in_origin_section and line.startswith("["):
# We reached the next section without finding the url
raise Exception("Didn't find 'url' entry in 'origin' section of .git/config")
- return None
+ except FileNotFoundError as e:
+ logger.debug(f"Ignoring {e}")
+ except Exception as e:
+ # Something unexpected happened. Flag it but keep going.
+ logger.error(traceback.format_exc())
@classmethod
- @fail_if_running_seedsigner_os
+ @not_allowed_in_seedsigner_os
def _get_version_timestamp_from_src_files(cls) -> datetime | None:
"""
Recursively scan the src/ directory for the most recent python file edit time.
@@ -560,39 +624,41 @@ class VersionUtils:
except Exception as e:
# Catch and log any unexpected errors but this isn't a mission-critical
# function so return gracefully.
- import traceback
logger.error(traceback.format_exc())
- return None
""" *************************************************************************************
- These functions use shell `git` commands which shouldn't be dangerous, but we don't want
- them being run in SeedSigner OS regardless.
+ Get data via shell `git` commands.
+
+ These calls shouldn't be dangerous, but we definitely don't want them being run in
+ SeedSigner OS regardless so we apply the decorator restriction.
************************************************************************************* """
@classmethod
- @fail_if_running_seedsigner_os
+ @not_allowed_in_seedsigner_os
def _get_version_name_from_git_shell_branch(cls) -> str | None:
branch_name = os.popen("git branch --show-current 2> /dev/null").read()
return branch_name.strip() if branch_name else None
@classmethod
- @fail_if_running_seedsigner_os
+ @not_allowed_in_seedsigner_os
def _get_version_name_from_git_shell_tag(cls) -> str | None:
- tag_name = os.popen("git describe --tags --abbrev=0 2> /dev/null").read()
+ # Only return a value if the current commit exactly corresponds with a tag.
+ # (`--points-at` defaults to the current HEAD)
+ tag_name = os.popen(f"git tag --points-at 2> /dev/null").read()
return tag_name.strip() if tag_name else None
@classmethod
- @fail_if_running_seedsigner_os
+ @not_allowed_in_seedsigner_os
def _get_version_name_from_git_shell_commit_hash(cls) -> str | None:
commit_hash = os.popen("git rev-parse --short HEAD 2> /dev/null").read()
return commit_hash.strip() if commit_hash else None
@classmethod
- @fail_if_running_seedsigner_os
+ @not_allowed_in_seedsigner_os
def _get_version_name_from_git_shell(cls) -> str | None:
"""
Attempts to get the version name via shell `git` commands.
@@ -605,7 +671,7 @@ class VersionUtils:
@classmethod
- @fail_if_running_seedsigner_os
+ @not_allowed_in_seedsigner_os
def _get_version_fork_from_git_shell(cls) -> str | None:
"""
Attempts to get the fork owner name via shell `git` commands.
@@ -616,7 +682,7 @@ class VersionUtils:
@classmethod
- @fail_if_running_seedsigner_os
+ @not_allowed_in_seedsigner_os
def _get_version_timestamp_from_git_shell(cls) -> datetime | None:
version_timestamp = os.popen("git log -1 --format=%cI").read().strip()
# Parse the timestamp, ensure that it's in UTC, and omit tz info
@@ -624,7 +690,7 @@ class VersionUtils:
@classmethod
- @fail_if_running_seedsigner_os
+ @not_allowed_in_seedsigner_os
def _get_version_commit_hash_from_git_shell(cls) -> str | None:
"""
Attempts to get the current git commit hash via shell `git` commands.
@@ -633,12 +699,16 @@ class VersionUtils:
return commit_hash.strip() if commit_hash else None
+
+ """ *************************************************************************************
+ External http GET call to github.
+ ************************************************************************************* """
@classmethod
- @fail_if_running_seedsigner_os
- def _fetch_latest_release_version(cls) -> tuple[str, datetime] | tuple[None, None]:
+ @not_allowed_in_seedsigner_os
+ def _fetch_latest_seedsigner_release_tag(cls) -> tuple[str, datetime] | tuple[None, None]:
"""
- Fetches the latest release version from the SeedSigner GitHub repo via the
- GitHub API. Then attempts to resolve the tag name locally to get its associated
+ Fetches the latest release version from the SeedSigner github repo via the
+ github API. Then attempts to resolve the tag name locally to get its associated
commit timestamp. If local git data is not available, falls back to using the
release published_at time from the API.
@@ -648,6 +718,17 @@ class VersionUtils:
from http.client import HTTPResponse
try:
+ """
+ Excerpted example response from the github API:
+ {
+ "url": "https://api.github.com/repos/SeedSigner/seedsigner/releases/228915183",
+ "tag_name": "0.8.6",
+ "prerelease": false,
+ "created_at": "2025-06-22T01:38:41Z",
+ "updated_at": "2025-06-30T20:49:22Z",
+ "published_at": "2025-06-30T20:45:05Z"
+ }
+ """
req = urllib.request.Request(
"https://api.github.com/repos/SeedSigner/seedsigner/releases/latest"
)
@@ -660,12 +741,12 @@ class VersionUtils:
version_timestamp = os.popen(f"git show {version_name} --format=%cI").read().strip()
if not version_timestamp:
# Fallback: use the release published_at time from the API
- version_timestamp = release_data.get("published_at")
+ version_timestamp = release_data.get("published_at").replace("Z", "+00:00")
version_timestamp = datetime.fromisoformat(version_timestamp).astimezone(timezone.utc).replace(tzinfo=None)
return (VersionUtils._prefix_version_name(version_name), version_timestamp)
else:
- logger.warning(f"GitHub API returned status code {response.status}")
+ logger.error(f"GitHub API returned status code {response.status}: {response}")
return (None, None)
except Exception as e:
- logger.error(f"Error fetching latest release version: {e}")
+ logger.error(f"Error fetching latest release version:\n{traceback.format_exc()}")
return (None, None)
\ No newline at end of file
diff --git a/tests/screenshot_generator/generator.py b/tests/screenshot_generator/generator.py
index e041b95..3bce441 100644
--- a/tests/screenshot_generator/generator.py
+++ b/tests/screenshot_generator/generator.py
@@ -198,7 +198,7 @@ def generate_screenshots(locale):
)
# Initialize the Version data to the most recent release
- (version_name, version_timestamp) = VersionUtils._fetch_latest_release_version()
+ (version_name, version_timestamp) = VersionUtils._fetch_latest_seedsigner_release_tag()
if not version_name or not version_timestamp:
raise Exception("Could not fetch latest release version from GitHub")
Version.override_data(
diff --git a/tests/test_version.py b/tests/test_version.py
index 5eb4438..bdfc621 100644
--- a/tests/test_version.py
+++ b/tests/test_version.py
@@ -1,16 +1,153 @@
+import json
import os
import pytest
-from datetime import datetime
+from datetime import datetime, timezone
from unittest import mock
+from unittest.mock import Mock, patch
# Must import this before any SeedSigner imports
from base import BaseTest
-from seedsigner.helpers.version import Version
+from seedsigner.helpers.version import Version, VersionUtils, NotAllowedInSeedSignerOS, not_allowed_in_seedsigner_os
+from seedsigner.models.settings import Settings
-class TestVersion(BaseTest):
+# overrides
+TEST__VERSION_FILE_NAME = "version-test.json"
+TEST__DOT_GIT_DIR_NAME = f"dot-git-test"
+
+# Reusable test data
+TEST__VERSION_NAME = "1.2.3" # Will require VersionUtils._prefix_version_name when verifying results
+TEST__VERSION_FORK = "some_repo_owner"
+TEST__VERSION_TIMESTAMP = datetime.now()
+TEST__VERSION_COMMIT_HASH = "abcd123"
+TEST__VERSION_DICT = {
+ VersionUtils.ATTR__VERSION_NAME: TEST__VERSION_NAME,
+ VersionUtils.ATTR__VERSION_FORK: TEST__VERSION_FORK,
+ VersionUtils.ATTR__VERSION_TIMESTAMP: TEST__VERSION_TIMESTAMP.isoformat(),
+ VersionUtils.ATTR__VERSION_COMMIT_HASH: TEST__VERSION_COMMIT_HASH,
+}
+
+# Mimic result of reading from version.json
+TEST__VERSION_FILE_CONTENTS = str(TEST__VERSION_DICT).replace("'", '"') # JSON uses double quotes
+
+
+
+class VersionBaseTest(BaseTest):
+ """ Sets up test-specific overrides and reusable methods and fixtures. """
+
+ @pytest.fixture(autouse=True, scope="class")
+ def mock_version_file_name(self):
+ """
+ Every test in this class (and subclasses) will automatically run with this patch
+ applied (autouse=True), but the patch will not persist beyond the test class.
+ """
+ with patch.object(VersionUtils, 'VERSION_FILENAME', TEST__VERSION_FILE_NAME):
+ yield
+
+
+ @pytest.fixture(autouse=True)
+ def mock_DOT_GIT_DIR_NAME(self):
+ """
+ Patch out the DOT_GIT_DIR_NAME to facilitate testing git-related methods
+ without touching the real filesystem.
+ """
+ with patch.object(VersionUtils, 'DOT_GIT_DIR_NAME', TEST__DOT_GIT_DIR_NAME):
+ yield
+
+
+ @pytest.fixture(autouse=True)
+ def mock_popen(self):
+ """
+ Prevent any os.popen calls from actually executing during tests.
+ """
+ with patch("os.popen", autospec=True) as mock_popen:
+ # Default to returning an empty string for `read()`
+ mock_popen.return_value.read.return_value = ""
+ yield mock_popen
+
+
+ @classmethod
+ def write_test_version_file(cls):
+ """
+ Write the test version file to disk.
+ """
+ assert VersionUtils.VERSION_FILENAME == TEST__VERSION_FILE_NAME
+ with open(VersionUtils._get_version_file_path(), "w") as f:
+ f.write(TEST__VERSION_FILE_CONTENTS)
+
+
+ @classmethod
+ def delete_test_version_file(cls):
+ """
+ Delete the test version file from disk.
+ """
+ assert VersionUtils.VERSION_FILENAME == TEST__VERSION_FILE_NAME
+ try:
+ os.remove(VersionUtils._get_version_file_path())
+ except FileNotFoundError:
+ pass
+
+
+ def setup_method(self):
+ super().setup_method()
+
+
+ def teardown_method(self):
+ super().teardown_method()
+ # Clean up any test version file created
+ self.delete_test_version_file()
+
+
+
+class TestVersionBaseTest(VersionBaseTest):
+ def test_setup_and_teardown(self):
+ """
+ Ensure that the setup and teardown methods work as expected.
+ """
+ assert VersionUtils.VERSION_FILENAME == TEST__VERSION_FILE_NAME
+
+ # During setup, the test version file should not exist
+ assert not os.path.exists(VersionUtils._get_version_file_path())
+
+ # Write the test version file
+ self.write_test_version_file()
+ assert os.path.exists(VersionUtils._get_version_file_path())
+
+ # Delete should remove it
+ self.delete_test_version_file()
+ assert not os.path.exists(VersionUtils._get_version_file_path())
+
+ # Teardown should also delete the test version file
+ self.write_test_version_file()
+ self.teardown_method()
+ assert not os.path.exists(VersionUtils._get_version_file_path())
+
+
+ def test_mock_popen(self):
+ """ All os.popen calls should be automatically/invisibly mocked out. """
+ result = os.popen("echo 'Hello, World!'")
+ assert isinstance(result, mock.MagicMock)
+
+
+
+class TestVersion(VersionBaseTest):
+ def test_seedsigner_os_reads_from_version_file(self):
+ """
+ When running on SeedSigner OS, the version data should be read from the
+ version.json file.
+ """
+ self.write_test_version_file()
+
+ # Simulate running on SeedSigner OS
+ with patch("seedsigner.models.settings.Settings.HOSTNAME", Settings.SEEDSIGNER_OS):
+ assert Version.get_version_name() == VersionUtils._prefix_version_name(TEST__VERSION_NAME)
+ assert Version.get_version_fork() == TEST__VERSION_FORK
+ assert Version.get_version_timestamp() == TEST__VERSION_TIMESTAMP
+ assert Version.get_version_commit_hash() == TEST__VERSION_COMMIT_HASH
+
+
# def test_version_with_no_git_head(self):
# """
# If there is no .git/HEAD file, the hard-coded VERSION constant should be returned.
@@ -40,32 +177,32 @@ class TestVersion(BaseTest):
# pytest.skip(f"No .git dir found at {git_dot_dir}, skipping test.")
- def test_version_with_mocked_git_head(self):
- """
- If there is a .git/HEAD file, the version should report the current git branch
- name, commit hash, or a matching tag.
- """
- with mock.patch("os.path.exists", return_value=True):
- # Mock the HEAD file read in _read_HEAD_file to return our fake branch name
- branch_name = "my_feature_branch"
- git_HEAD_content = f"ref: refs/heads/{branch_name}"
- with mock.patch("builtins.open", mock.mock_open(read_data=git_HEAD_content)):
- version = Version.get_version_name()
- assert version == branch_name
+ # def test_version_with_mocked_git_head(self):
+ # """
+ # If there is a .git/HEAD file, the version should report the current git branch
+ # name, commit hash, or a matching tag.
+ # """
+ # with mock.patch("os.path.exists", return_value=True):
+ # # Mock the HEAD file read in _read_HEAD_file to return our fake branch name
+ # branch_name = "my_feature_branch"
+ # git_HEAD_content = f"ref: refs/heads/{branch_name}"
+ # with mock.patch("builtins.open", mock.mock_open(read_data=git_HEAD_content)):
+ # version = VersionUtils._get_version_name_from_git_HEAD()
+ # assert version == branch_name
- # Mock the HEAD file read in _read_HEAD_file to return a fake commit hash
- commit_hash = "abcdef1234567890"
- with mock.patch("builtins.open", mock.mock_open(read_data=commit_hash)):
- # Mock that there are no matching tags for this commit hash
- with mock.patch.object(Version, '_get_matching_tag', return_value=None):
- version = Version.get_version_name()
- assert version == commit_hash[:7] # short commit hash
+ # # Mock the HEAD file read in _read_HEAD_file to return a fake commit hash
+ # commit_hash = "abcdef1234567890"
+ # with mock.patch("builtins.open", mock.mock_open(read_data=commit_hash)):
+ # # Mock that there are no matching tags for this commit hash
+ # with mock.patch.object(Version, '_get_matching_tag', return_value=None):
+ # version = Version.get_version_name()
+ # assert version == commit_hash[:7] # short commit hash
- # Now mock that there is a matching tag for this commit hash
- tag_name = "v1.2.3"
- with mock.patch.object(Version, '_get_matching_tag', return_value=tag_name):
- version = Version.get_version_name()
- assert version == tag_name
+ # # Now mock that there is a matching tag for this commit hash
+ # tag_name = "v1.2.3"
+ # with mock.patch.object(Version, '_get_matching_tag', return_value=tag_name):
+ # version = Version.get_version_name()
+ # assert version == tag_name
def test_get_last_edit(self):
@@ -82,4 +219,334 @@ class TestVersion(BaseTest):
# Could not have happened tomorrow
known_future = datetime.now().replace(year=datetime.now().year + 1)
- assert last_edit < known_future
\ No newline at end of file
+ assert last_edit < known_future
+
+
+class TestVersionUtils(VersionBaseTest):
+ def test__prefix_version_name(self):
+ """
+ Semantic versions should be prefixed with 'v' but others should be left as-is.
+ """
+ # Expected to end up starting with "v":
+ for version_name in [
+ "1.2.3",
+ "v1.2.3",
+ "1.2.3-rc1",
+ "v1.2.3-rc1",
+ "21.known.slight.flaw",
+ ]:
+ result = VersionUtils._prefix_version_name(version_name)
+ assert result.startswith("v"), f"Expected '{result}' to start with 'v'"
+ if version_name.startswith("v"):
+ assert result == version_name, f"Expected '{result}' to equal input '{version_name}'"
+
+ # Expect no change:
+ for version_name in [
+ "release-branch",
+ "feature/foo",
+ "hotfix-123",
+ "foo.bar.not.semantic",
+ "1234abcd",
+ "1234",
+ ]:
+ result = VersionUtils._prefix_version_name(version_name)
+ assert result == version_name, f"Expected '{result}' to equal input '{version_name}'"
+
+
+ def test__read_version_file(self):
+ """
+ Low-level test for reading the version.json file.
+ """
+ self.write_test_version_file()
+
+ version_data = VersionUtils._read_version_file()
+ assert version_data is not None
+ assert version_data[VersionUtils.ATTR__VERSION_NAME] == TEST__VERSION_NAME
+ assert version_data[VersionUtils.ATTR__VERSION_FORK] == TEST__VERSION_FORK
+ assert version_data[VersionUtils.ATTR__VERSION_TIMESTAMP] == TEST__VERSION_TIMESTAMP.isoformat()
+ assert version_data[VersionUtils.ATTR__VERSION_COMMIT_HASH] == TEST__VERSION_COMMIT_HASH
+
+
+ def test__read_version_file__missing(self):
+ """ _read_version_file should return None if the version file is missing. """
+ assert os.path.exists(VersionUtils._get_version_file_path()) is False
+ assert VersionUtils._read_version_file() is None
+
+
+ def test__get_dot_git_dir(self):
+ """
+ _get_dot_git_dir should return the expected .git directory path.
+ """
+ # The mocked DOT_GIT_DIR_NAME should be in the result
+ result = VersionUtils._get_dot_git_dir()
+ assert TEST__DOT_GIT_DIR_NAME in result
+
+
+ def test__read_git_HEAD_file(self):
+ """
+ _read_git_HEAD_file should parse the git HEAD file to extract the current
+ branch name or commit hash. Or gracefully return None if the HEAD file is missing.
+ """
+ # Initially our test setup has no .git dir
+ assert VersionUtils._read_git_HEAD_file() == (None, None)
+
+ # If we're on a branch...
+ expected_branch = "my_test_branch"
+ git_HEAD_content = f"ref: refs/heads/{expected_branch}"
+ with patch("builtins.open", mock.mock_open(read_data=git_HEAD_content)):
+ branch_name, commit_hash = VersionUtils._read_git_HEAD_file()
+ assert branch_name == expected_branch
+ assert commit_hash is None
+
+ # If we're in a detached HEAD state at a specific commit hash...
+ expected_commit_hash = "47212c98f1bf948e9918b672c4bb88b1c965aff4"
+ with patch("builtins.open", mock.mock_open(read_data=expected_commit_hash)):
+ branch_name, commit_hash = VersionUtils._read_git_HEAD_file()
+ assert branch_name is None
+ assert commit_hash == expected_commit_hash
+
+ # Gracefully handle a read error
+ with patch("builtins.open", side_effect=FileNotFoundError):
+ assert VersionUtils._read_git_HEAD_file() == (None, None)
+
+ # Or any other kind of exception
+ with patch("builtins.open", side_effect=Exception("Unexpected error")):
+ assert VersionUtils._read_git_HEAD_file() == (None, None)
+
+
+ def test__get_matching_tag_from_git_refs_tags(self):
+ """
+ _get_matching_tag_from_git_refs_tags should return the expected tag name
+ if a matching tag is found for the given commit hash.
+ """
+ # No .git dir initially
+ assert VersionUtils._get_matching_tag_from_git_refs_tags("anyhash") is None
+
+ # Mock out the .git/refs/tags file read to return some fake tags
+ fake_tags_content = """v1.0.0:abcd1234567890
+v1.2.3:deadbeefcafebabe
+v2.0.0-rc1:47212c98f1bf948e9918b672c4bb88b1c965aff4
+"""
+ with patch("builtins.open", mock.mock_open(read_data=fake_tags_content)):
+ # Existing tag
+ tag_name = VersionUtils._get_matching_tag_from_git_refs_tags("deadbeefcafebabe")
+ assert tag_name == "v1.2.3"
+
+ # Another existing tag
+ tag_name = VersionUtils._get_matching_tag_from_git_refs_tags("47212c98f1bf948e9918b672c4bb88b1c965aff4")
+ assert tag_name == "v2.0.0-rc1"
+
+ # Non-existing tag
+ tag_name = VersionUtils._get_matching_tag_from_git_refs_tags("nonexistenthash")
+ assert tag_name is None
+
+
+ def test__get_version_timestamp_from_src_files(self):
+ """
+ _get_version_timestamp_from_src_files should return the most recent file
+ modification timestamp from the SeedSigner python files.
+ """
+ # Do the real filesystem scan
+ timestamp = VersionUtils._get_version_timestamp_from_src_files()
+ assert timestamp < datetime.now()
+ assert timestamp > datetime(2020, 12, 13) # after first SeedSigner release
+
+ # Now mock out os.path.getmtime to force all files to have a known timestamp
+ expected_timestamp = datetime(2025, 12, 23, 0, 0, 0)
+ with mock.patch("os.path.getmtime", return_value=expected_timestamp.timestamp()):
+ assert VersionUtils._get_version_timestamp_from_src_files() == expected_timestamp
+
+ # Mock out os.walk() to simulate no .py files found
+ with mock.patch("os.walk", return_value=[]):
+ assert VersionUtils._get_version_timestamp_from_src_files() is None
+
+
+
+
+ def test__get_version_name_from_git_shell(self):
+ """
+ Test that _get_version_name_from_git_shell returns the expected name depending on
+ the current git state
+ """
+ branch_name = "my_test_branch"
+ tag_name = "my_test_tag"
+ commit_hash = "abcd123"
+
+ # Default mock_popen return empty string; simulates no `git` shell command available
+ # or no local git data.
+ result = VersionUtils._get_version_name_from_git_shell()
+ assert result is None
+
+ # If we're on a branch, should return the branch name
+ with mock.patch.multiple(
+ "seedsigner.helpers.version.VersionUtils",
+ _get_version_name_from_git_shell_branch=Mock(return_value=branch_name),
+ _get_version_name_from_git_shell_tag=Mock(return_value=tag_name),
+ _get_version_name_from_git_shell_commit_hash=Mock(return_value=commit_hash),
+ ):
+ result = VersionUtils._get_version_name_from_git_shell()
+ assert result == branch_name
+
+ # If we're on a tag, the detached HEAD state wipes out the branch name
+ with mock.patch.multiple(
+ "seedsigner.helpers.version.VersionUtils",
+ _get_version_name_from_git_shell_branch=Mock(return_value=None),
+ _get_version_name_from_git_shell_tag=Mock(return_value=tag_name),
+ _get_version_name_from_git_shell_commit_hash=Mock(return_value=commit_hash),
+ ):
+ result = VersionUtils._get_version_name_from_git_shell()
+ assert result == tag_name
+
+ # Similarly, if we're detached at a specific commit hash
+ with mock.patch.multiple(
+ "seedsigner.helpers.version.VersionUtils",
+ _get_version_name_from_git_shell_branch=Mock(return_value=None),
+ _get_version_name_from_git_shell_tag=Mock(return_value=None),
+ _get_version_name_from_git_shell_commit_hash=Mock(return_value=commit_hash),
+ ):
+ result = VersionUtils._get_version_name_from_git_shell()
+ assert result == commit_hash[:7] # short hash
+
+
+ def test__get_version_fork_from_git_shell(self, mock_popen: Mock):
+ """
+ Test that _get_version_fork_from_git_shell returns the expected repo owner from
+ the remote url.
+ """
+ remote_url = "https://github.com/SeedSigner/seedsigner.git"
+ expected_fork = "SeedSigner"
+
+ mock_popen.return_value.read.return_value = remote_url
+ result = VersionUtils._get_version_fork_from_git_shell()
+ assert result == expected_fork
+
+
+ def test__get_version_timestamp_from_git_shell(self, mock_popen: Mock):
+ """
+ Test that _get_version_timestamp_from_git_shell returns the expected datetime.
+ """
+ # Initial timestamp has timezone info
+ hour = 14
+ tz_offset = 1
+ test_local_isoformat = f"2025-12-20T{hour:02}:00:00-{tz_offset:02}:00"
+ mock_popen.return_value.read.return_value = test_local_isoformat
+
+ # But the final result will be UTC
+ expected_datetime = datetime.fromisoformat(f"2025-12-20T{hour + tz_offset:02}:00:00")
+ assert VersionUtils._get_version_timestamp_from_git_shell() == expected_datetime
+
+ # And UTC-to-UTC should be unchanged
+ mock_popen.return_value.read.return_value = expected_datetime.isoformat() + "+00:00"
+ assert VersionUtils._get_version_timestamp_from_git_shell() == expected_datetime
+
+
+ def test__get_version_commit_hash_from_git_shell(self, mock_popen: Mock):
+ """
+ Test that _get_version_commit_hash_from_git_shell returns the expected short commit hash.
+ """
+ commit_hash = "abcd123"
+ mock_popen.return_value.read.return_value = commit_hash
+
+ result = VersionUtils._get_version_commit_hash_from_git_shell()
+ assert result == commit_hash
+
+
+ def test__fetch_latest_seedsigner_release_tag(self, mock_popen: Mock):
+ """
+ Test that _fetch_latest_seedsigner_release_tag returns the expected version string.
+ """
+ latest_releases_response_dict = {
+ "url": "https://api.github.com/repos/SeedSigner/seedsigner/releases/228915183",
+ "tag_name": "0.8.6",
+ "prerelease": False,
+ "created_at": "2025-06-22T01:38:41Z",
+ "updated_at": "2025-06-30T20:49:22Z",
+ "published_at": "2025-06-30T20:45:05Z"
+ }
+
+ fake_response = Mock()
+ fake_response.status = 200
+ fake_response.read.return_value = json.dumps(latest_releases_response_dict).encode('utf-8')
+
+ with mock.patch("urllib.request.urlopen", return_value=fake_response):
+ release_tag, release_timestamp = VersionUtils._fetch_latest_seedsigner_release_tag()
+
+ # mock_popen returns empty string, which mimics not having local git data to
+ # retrieve the commit timestamp.
+ assert mock_popen.called is True
+
+ # So then we expect to fall back to using the published_at time from the API
+ expected_timestamp = datetime.fromisoformat(latest_releases_response_dict["published_at"].replace("Z", "+00:00")).replace(tzinfo=None)
+
+ assert release_tag == VersionUtils._prefix_version_name(latest_releases_response_dict["tag_name"])
+ assert release_timestamp == expected_timestamp
+
+ # Update mock_popen so that we CAN get the (simulated) commit timestamp from
+ # local git data.
+ git_timestamp = "2025-06-21T21:38:41-04:00"
+ mock_popen.return_value.read.return_value = f"{git_timestamp}\n"
+ expected_timestamp = datetime.fromisoformat(git_timestamp).astimezone(timezone.utc).replace(tzinfo=None)
+
+ release_tag, release_timestamp = VersionUtils._fetch_latest_seedsigner_release_tag()
+ assert release_tag == VersionUtils._prefix_version_name(latest_releases_response_dict["tag_name"])
+ assert release_timestamp == expected_timestamp
+
+ # Should gracefully handle HTTP errors
+ fake_error_response = Mock()
+ fake_error_response.status = 404
+ with mock.patch("urllib.request.urlopen", return_value=fake_error_response):
+ release_tag, release_timestamp = VersionUtils._fetch_latest_seedsigner_release_tag()
+ assert release_tag is None
+ assert release_timestamp is None
+
+ # And other exceptions
+ with mock.patch("urllib.request.urlopen", side_effect=Exception("Network error")):
+ release_tag, release_timestamp = VersionUtils._fetch_latest_seedsigner_release_tag()
+ assert release_tag is None
+ assert release_timestamp is None
+
+
+
+class TestNotAllowedInSeedSignerOSDecorator(BaseTest):
+ SUCCESS = "success"
+
+ @not_allowed_in_seedsigner_os
+ def dummy_function(self):
+ return self.SUCCESS
+
+
+ def test_not_allowed_in_seedsigner_os(self):
+ """
+ The not_allowed_in_seedsigner_os decorator should raise its associated exception
+ if we run a decorated function while in SeedSigner OS.
+ """
+ # Patch over the Settings.HOSTNAME value to simulate running in SeedSigner OS
+ with mock.patch("seedsigner.models.settings.Settings.HOSTNAME", Settings.SEEDSIGNER_OS):
+ with pytest.raises(NotAllowedInSeedSignerOS):
+ self.dummy_function()
+
+
+ def test_allowed_outside_seedsigner_os(self):
+ # Now try with any other HOSTNAME
+ with mock.patch("seedsigner.models.settings.Settings.HOSTNAME", "my_dev_machine"):
+ assert self.dummy_function() == self.SUCCESS
+
+
+
+class TestNotVersionBaseTest(BaseTest):
+ def test_version_file_name__not_patched(self):
+ """
+ Ensure that outside of VersionBaseTest, the VERSION_FILENAME patch does not
+ persist.
+ """
+ assert VersionUtils.VERSION_FILENAME != TEST__VERSION_FILE_NAME
+
+
+ def test_mock_popen__not_patched(self):
+ """
+ Ensure that outside of VersionBaseTest, os.popen is not patched.
+ """
+ # Call os.popen
+ result = os.popen("echo 'Hello, World!'")
+ # The result should not be a MagicMock instance
+ assert not isinstance(result, mock.MagicMock)
diff --git a/tools/write_versionfile.py b/tools/write_versionfile.py
index 8e556e5..89f736e 100644
--- a/tools/write_versionfile.py
+++ b/tools/write_versionfile.py
@@ -1,14 +1,10 @@
import json
-import os
-from datetime import datetime, timezone
from seedsigner.helpers.version import VersionUtils
-from seedsigner.models.settings import Settings
-
"""
-CLI utility to extract the current version and last edit time and write to
+CLI utility to extract the current version data and write to
`src/seedsigner/version.json`. Primarily used by the SeedSigner OS build process.
SeedSigner OS lifecycle:
@@ -16,21 +12,28 @@ SeedSigner OS lifecycle:
* version.json is included in the SeedSigner OS image.
* SeedSigner OS reads version.json at runtime.
-This script can also be run in local dev but `git` shell commands are required.
+Notes:
+ * The SeedSigner OS build environment already relies on `git` being installed.
+ * This script can also be run in local dev but `git` shell commands are required.
+
+Version data:
+ * version_name:
+ * Check for the SEEDSIGNER_VERSION_NAME env var (provided in SeedSigner OS build
+ env).
+ * Will be the branch, tag, or commit hash being built.
+ * In local dev will fall back to using `git` shell commands:
+ * Current git branch name
+ * Current git tag name
+ * Current git commit hash
-version_name:
- * Check for the SEEDSIGNER_VERSION_NAME env var (provided in SeedSigner OS build env).
- * Will be the branch, tag, or commit hash being built.
- * In local dev will fall back to (via `git` shell commands):
- * Current git branch name
- * Current git tag name
- * Current git commit hash
+ * version_fork:
+ * Pulls the current repo owner from the `git remote` shell command.
-version_timestamp:
- * Pulls last git commit time from `git log`.
+ * version_timestamp:
+ * Pulls last git commit time from `git log`.
-version_commit_hash:
- * Pulls current git commit hash from `git` shell command.
+ * version_commit_hash:
+ * Pulls current git commit hash from `git` shell command.
"""
if __name__ == "__main__":
Why this scored 15/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.