What changed, and why it matters
This is a large code refactor that changes how SeedSigner detects and displays its software version. It replaces a simple version reader with a more complex singleton system that reads git state, environment variables, and a JSON file. The changes are mostly about version reporting and do not appear to alter how private keys, seeds, or Bitcoin transactions are handled. There is no clear security bug visible in the diff, but the refactor introduces new code paths that touch files and shell commands, which always carry some risk of mistakes.
Treat this as a routine refactor that needs normal code review. Verify that `fail_if_running_seedsigner_os` correctly guards all local-dev paths in the shipped OS image, that shell commands are not injectable (they use fixed git arguments), and that the GitHub API fetch in `_fetch_latest_release_version` is only used in the screenshot renderer and not in the device runtime. No immediate security patch is indicated by the diff alone.
Security signals we found
Large refactor (+728/-153) with interim commit message, increasing review surface
New shell command execution via os.popen for git metadata
New file/network access: .git/config, .git/HEAD, .git/refs/heads/*, GitHub API over HTTPS
New singleton state with test-only override and reset methods
Decorator introduced to block certain operations when running in SeedSigner OS
No changes to seed storage, key derivation, transaction signing, or PSBT parsing
Evidence from the diff
The commit refactors src/seedsigner/helpers/version.py from a small utility class into a Version singleton backed by VersionUtils. It adds fork detection, commit-hash display, GitHub API release lookup, and a new tools/write_versionfile.py CLI for SeedSigner OS builds. The UI VersionScreen is updated to show version name, fork, commit hash, and timestamp. Several methods are decorated with fail_if_running_seedsigner_os to prevent local-git/shell operations in the OS image. The refactor also uses os.popen for git shell calls and reads .git/HEAD, .git/config, and .git/refs/heads/*. No cryptographic, PSBT parsing, or seed-handling code is modified.
Changed components
src/seedsigner/helpers/version.pysrc/seedsigner/gui/screens/settings_screens.pysrc/seedsigner/views/settings_views.pysrc/seedsigner/views/screensaver.pytools/write_versionfile.pytests/test_version.pytests/screenshot_generator/generator.pyInspect captured patch +728 / −153
diff --git a/src/seedsigner/gui/screens/settings_screens.py b/src/seedsigner/gui/screens/settings_screens.py
index 2c1ed82..67835fa 100644
--- a/src/seedsigner/gui/screens/settings_screens.py
+++ b/src/seedsigner/gui/screens/settings_screens.py
@@ -313,53 +313,105 @@ class DonateScreen(BaseTopNavScreen):
@dataclass
class VersionScreen(BaseTopNavScreen):
- version: str = None
- last_edit: datetime = None
+ version_name: str = None
+ version_fork: str = None
+ version_timestamp: datetime = None
+ version_commit_hash: str = None
def __post_init__(self):
self.title = _("Version")
super().__post_init__()
- font_name = GUIConstants.FIXED_WIDTH_FONT_NAME
- font_size = GUIConstants.get_top_nav_title_font_size() + 6
- font = Fonts.get_font(font_name, font_size)
- (left, char_height, char_width, bottom) = font.getbbox("X", anchor="ls")
+ version_name_font_name = GUIConstants.FIXED_WIDTH_FONT_NAME
+ version_name_font_size = GUIConstants.get_top_nav_title_font_size() + 6
+ version_name_font = Fonts.get_font(version_name_font_name, version_name_font_size)
+ (left, version_name_char_height, version_name_char_width, bottom) = version_name_font.getbbox("X", anchor="ls")
- num_version_lines = 1
- if len(self.version) * char_width > self.canvas_width - 2*GUIConstants.EDGE_PADDING:
- max_chars_width = int((self.canvas_width - 2*GUIConstants.EDGE_PADDING) / char_width)
+ if len(self.version_name) * version_name_char_width > self.canvas_width - 2*GUIConstants.EDGE_PADDING:
+ max_chars_width = int((self.canvas_width - 2*GUIConstants.EDGE_PADDING) / version_name_char_width)
# Add as many line breaks as needed for the version string to fit
wrapped_version = []
- for i in range(0, len(self.version), max_chars_width):
- if i + max_chars_width < len(self.version):
- wrapped_version.append(self.version[i:i+max_chars_width])
+ for i in range(0, len(self.version_name), max_chars_width):
+ if i + max_chars_width < len(self.version_name):
+ wrapped_version.append(self.version_name[i:i+max_chars_width])
else:
- wrapped_version.append(self.version[i:])
- self.version = "\n".join(wrapped_version)
- num_version_lines = len(wrapped_version)
-
- # Center the version vertically
- char_height *= -1 # due to the "ls" (baseline) anchor, height is negative
+ wrapped_version.append(self.version_name[i:])
+ self.version_name = "\n".join(wrapped_version)
+
+ timestamp_font_name = GUIConstants.get_body_font_name()
+ timestamp_font_size = GUIConstants.get_body_font_size()
+ timestamp_font = Fonts.get_font(timestamp_font_name, timestamp_font_size)
+ (left, timestamp_char_height, timestamp_char_width, bottom) = timestamp_font.getbbox("UTC", anchor="ls")
- # Note: we roughly account for the last edit display's vertical space by adding to
- # num_version_lines.
- screen_y = int((self.canvas_height - self.top_nav.height) / 2) + self.top_nav.height - char_height*(num_version_lines + 1)
+ screen_y = self.top_nav.height + GUIConstants.COMPONENT_PADDING * 2
+ if not self.version_fork and not self.version_commit_hash:
+ # Center the version name if there's no fork/commit info
+ screen_y = int(self.canvas_height / 2) - (-1 * version_name_char_height) # char_height is negative
self.components.append(TextArea(
- text=self.version,
- font_name=font_name,
- font_size=font_size,
+ text=self.version_name,
+ font_name=version_name_font_name,
+ font_size=version_name_font_size,
font_color=GUIConstants.ACCENT_COLOR,
screen_y=screen_y,
))
- if self.last_edit:
- last_edit_str = self.last_edit.strftime("%Y-%m-%d\n%H:%M:%S UTC")
+ screen_y = self.components[-1].screen_y + self.components[-1].height + 3*GUIConstants.COMPONENT_PADDING
+
+ label_font_name = GUIConstants.get_body_font_name()
+ label_font_size = GUIConstants.get_body_font_size()
+ label_font = Fonts.get_font(label_font_name, label_font_size)
+ (left, label_height, fork_label_width, bottom) = label_font.getbbox("fork: ", anchor="ls")
+ (left, label_height, commit_label_width, bottom) = label_font.getbbox("commit: ", anchor="ls")
+
+ if self.version_fork:
+ screen_x = 0
+ if self.version_commit_hash:
+ # right-align the labels
+ screen_x = commit_label_width - fork_label_width
self.components.append(TextArea(
- text=last_edit_str,
- is_text_centered=True,
- screen_y=self.components[-1].screen_y + self.components[-1].height + 2*GUIConstants.COMPONENT_PADDING,
+ text=f"fork: ",
+ is_text_centered=False,
+ font_color=GUIConstants.LABEL_FONT_COLOR,
+ screen_x=screen_x,
+ screen_y=screen_y,
+ ))
+ self.components.append(TextArea(
+ text=self.version_fork,
+ is_text_centered=False,
+ font_name=GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME,
+ font_size=GUIConstants.get_top_nav_title_font_size(),
+ edge_padding=0,
+ screen_x=screen_x + fork_label_width + GUIConstants.COMPONENT_PADDING,
+ screen_y=screen_y,
))
+ screen_y = self.components[-1].screen_y + self.components[-1].height + GUIConstants.COMPONENT_PADDING
+
+ if self.version_commit_hash:
+ self.components.append(TextArea(
+ text=f"commit: ",
+ is_text_centered=False,
+ font_color=GUIConstants.LABEL_FONT_COLOR,
+ screen_y=screen_y,
+ ))
+ self.components.append(TextArea(
+ text=self.version_commit_hash,
+ is_text_centered=False,
+ font_name=GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME,
+ font_size=GUIConstants.get_top_nav_title_font_size(),
+ edge_padding=0,
+ screen_x=commit_label_width + GUIConstants.COMPONENT_PADDING,
+ screen_y=screen_y,
+ ))
+
+ # Pin the timestamp centered to the bottom of the screen
+ last_edit_str = self.version_timestamp.strftime("%Y-%m-%d %H:%M:%S UTC")
+ self.components.append(TextArea(
+ text=last_edit_str,
+ font_name=timestamp_font_name,
+ font_size=timestamp_font_size,
+ screen_y=self.canvas_height - (-1 * timestamp_char_height) - GUIConstants.EDGE_PADDING,
+ ))
diff --git a/src/seedsigner/helpers/version.py b/src/seedsigner/helpers/version.py
index 0f1b356..4365f15 100644
--- a/src/seedsigner/helpers/version.py
+++ b/src/seedsigner/helpers/version.py
@@ -4,37 +4,392 @@ import os
from datetime import datetime, timezone
from seedsigner.models.settings import Settings
+from seedsigner.models.singleton import Singleton
logger = logging.getLogger(__name__)
-class Version:
- """
- Utility class to detect the current version and the last edit time of the source code.
+# Note: If this exception and its associated decorator end up being useful elsewhere, move
+# them to a more general location (e.g. create a helpers/exceptions.py).
+class NotAllowedInSeedSignerOS(Exception):
+ pass
+
+
+def fail_if_running_seedsigner_os(func: callable):
+ """ Simple decorator to enforce SeedSigner OS restrictions. """
+ def wrapper_func(*args, **kwargs):
+ if Settings.HOSTNAME == Settings.SEEDSIGNER_OS:
+ raise NotAllowedInSeedSignerOS(f"Cannot run `{func.__name__}` in SeedSigner OS.")
+
+ # Now run the target function and return its results
+ return func(*args, **kwargs)
+ return wrapper_func
+
+
- Version detection attempts to read the current git branch, commit hash, or tag but
- will fall back to the hard-coded VERSION constant if no git info is available.
- Internal utility functions are separated out as class methods for easier mocking in tests.
+class Version(Singleton):
"""
+ Utility class to report the current version and the last edit time of the source code.
+
+ 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_timestamp: the last git commit time for the target branch, tag, or
+ commit hash.
+
+ In local dev:
+ * version_name: read dynamically from a few possible sources. In order:
+ * SEEDSIGNER_VERSION_NAME env var, if available.
+ * Shell `git` calls (e.g. `git branch --show-current`).
+ * 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_timestamp: determined by scanning the src/ directory for the most
+ recently modified python file.
+
+ In Github Actions CI:
+ * version_name: read from GITHUB_REF_NAME or GITHUB_SHA env vars.
+ * version_timestamp: TODO
+
+ 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.
+ """
+ _version_name: str = None
+ _version_fork: str = None
+ _version_timestamp: datetime = None
+ _version_commit_hash: str = None
+
+
+ @classmethod
+ def get_instance(cls):
+ # This is the only way to access the one and only instance
+ if cls._instance:
+ return cls._instance
+ else:
+ # Instantiate the one and only Version instance
+ return cls.configure_instance()
+
+
+ @classmethod
+ def configure_instance(cls):
+ # Must be called before the first get_instance() call
+ if cls._instance:
+ raise Exception("Instance already configured")
+
+ # Create the one and only Version instance
+ version = cls.__new__(cls)
+ cls._instance = version
+
+ # Populate version data
+ version._version_name = VersionUtils.get_version_name()
+ version._version_fork = VersionUtils.get_version_fork()
+ version._version_timestamp = VersionUtils.get_version_timestamp()
+ version._version_commit_hash = VersionUtils.get_version_commit_hash()
+
+ return cls._instance
+
+
+ @classmethod
+ @fail_if_running_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
+ 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.
+ """
+ instance = cls.get_instance()
+ instance._version_name = version_name
+ instance._version_fork = version_fork
+ instance._version_timestamp = version_timestamp
+ instance._version_commit_hash = version_commit_hash
+
+
+ @classmethod
+ def get_version_name(cls) -> str | None:
+ return cls.get_instance()._version_name
+
+
+ @classmethod
+ def get_version_fork(cls) -> str | None:
+ return cls.get_instance()._version_fork
+
+
+ @classmethod
+ def get_version_timestamp(cls) -> datetime | None:
+ return cls.get_instance()._version_timestamp
+
+
+ @classmethod
+ def get_version_commit_hash(cls) -> str | None:
+ return cls.get_instance()._version_commit_hash
+
+
+
+class VersionUtils:
+ """ *********************************************************************************
+ Not meant to be used elsewhere in the SeedSigner codebase (aside from
+ tools/write_versionfile.py).
+
+ Methods are separated out here to a rather extreme degree in order to enable all the
+ mocking that is required for testing.
+ ********************************************************************************* """
+
+ ENV_VAR__SEEDSIGNER_VERSION_NAME = "SEEDSIGNER_VERSION_NAME"
VERSION_FILENAME = "version.json"
+ ATTR__VERSION_NAME = "name"
+ ATTR__VERSION_FORK = "fork"
+ ATTR__VERSION_TIMESTAMP = "timestamp"
+ ATTR__VERSION_COMMIT_HASH = "commit_hash"
+
+
+ @classmethod
+ def get_version_name(cls) -> str:
+ """
+ Will prefix the version name "v" if it looks like a semantic version.
+ """
+ if Settings.HOSTNAME == Settings.SEEDSIGNER_OS:
+ # The SeedSigner OS build process generates the version.json file for the tag,
+ # branch, or commit hash the image is targeting.
+ version_name = VersionUtils._get_version_name_from_version_file()
+ if version_name is None:
+ # Shouldn't be possible. Raise an exception to alert testers before this
+ # image goes out.
+ raise Exception("Could not read the version from the version.json file.")
+ return VersionUtils._prefix_version_name(version_name)
+
+ elif VersionUtils.is_github_actions_ci():
+ # In Github Actions CI, try to get the version name from env vars
+ version_name = VersionUtils.get_version_name_from_github_actions_env_vars()
+ if version_name is not None:
+ return VersionUtils._prefix_version_name(version_name)
+ else:
+ raise Exception("Could not determine version from Github Actions env vars.")
+
+ else:
+ # In local dev, we try the following methods in order:
+ for get_version_name_method in [
+ VersionUtils._get_version_name_from_env_var,
+ VersionUtils._get_version_name_from_git_shell,
+ VersionUtils._get_version_name_from_git_HEAD,
+ ]:
+ version_name = get_version_name_method()
+ if version_name is not None:
+ return VersionUtils._prefix_version_name(version_name)
+
+ # If we reach here, none of the methods worked
+ # TODO: What do we want to do in this case?
+ # Intentionally not marking this for translation; end users should never see it.
+ return "version not detected"
+
+
+ @classmethod
+ def get_version_fork(cls) -> str | None:
+ """
+ Returns the fork owner or None if it cannot be determined.
+
+ e.g. https://github.com/SeedSigner/seedsigner -> "SeedSigner"
+ """
+ if Settings.HOSTNAME == Settings.SEEDSIGNER_OS:
+ # The SeedSigner OS build process generates the version.json file which will
+ # already contain the fork name.
+ return VersionUtils._get_version_fork_from_version_file()
+ else:
+ # In local dev we try to access the current git state via:
+ for get_version_fork_method in [
+ VersionUtils._get_version_fork_from_git_shell,
+ VersionUtils._get_version_fork_from_git_config,
+ ]:
+ version_fork = get_version_fork_method()
+ if version_fork is not None:
+ return version_fork
+ return None
+
+
+ @classmethod
+ def get_version_timestamp(cls) -> datetime:
+ """
+ Returns a datetime object representing the last edit time of the source code via
+ the most recent git commit time (SeedSigner OS, as written in version.json) or
+ the most recently modified python source file (local dev).
+ """
+ if Settings.HOSTNAME == Settings.SEEDSIGNER_OS:
+ # The SeedSigner OS build process generates the version.json file which will
+ # already contain the last edit time.
+ version_timestamp = VersionUtils._get_version_timestamp_from_version_file()
+ if version_timestamp is None:
+ # Shouldn't be possible. Raise an exception to alert testers before this
+ # image goes out.
+ raise Exception("Could not read the version timestamp from the version.json file.")
+ return datetime.fromisoformat(version_timestamp)
+
+ else:
+ # In local dev we use the last modified time of the source python files
+ return VersionUtils._get_version_timestamp_from_src_files()
+
+
+ @classmethod
+ def get_version_commit_hash(cls) -> str | None:
+ """
+ Returns the short commit hash string.
+
+ Will be None if the local dev system has no git state available or if it only has
+ the .git/HEAD but is currently on a branch (not a tag or specific commit).
+ """
+ if Settings.HOSTNAME == Settings.SEEDSIGNER_OS:
+ return VersionUtils._get_version_commit_hash_from_version_file()
+ else:
+ # In local dev we try to access the current git state via:
+ for get_commit_hash_method in [
+ VersionUtils._get_version_commit_hash_from_git_shell,
+ VersionUtils._get_commit_hash_from_git_HEAD,
+ ]:
+ commit_hash = get_commit_hash_method()
+ if commit_hash is not None:
+ return commit_hash[:7]
+
+ # If we're still here, we can try looking up based on the branch name
+ branch_name = VersionUtils._get_version_name_from_git_shell_branch()
+ if not branch_name:
+ branch_name, commit_hash = VersionUtils._read_git_HEAD_file()
+ if branch_name:
+ commit_hash = VersionUtils._get_commit_hash_from_git_refs_heads(branch_name)
+ if commit_hash is not None:
+ return commit_hash[:7]
+
+
+ @classmethod
+ def _prefix_version_name(cls, version_name: str) -> str:
+ """
+ Ensure version strings are prefixed with 'v' if they look like semantic
+ versions. Only checks that the first part is numeric in order to be compatible
+ with non-numeric minor versions (e.g. "0.8.5-rc1").
+ """
+ if not version_name.startswith("v") and version_name.count(".") >= 1 and version_name.split(".")[0].isnumeric():
+ return f"v{version_name}"
+ return version_name
+
+
+ @classmethod
+ def _get_version_file_path(cls) -> str:
+ # Have to back out of this file's location in the "helpers" dir to the main
+ # "seedsigner" dir.
+ return os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", cls.VERSION_FILENAME))
+
+
+ @classmethod
+ def _read_version_file(cls) -> dict | None:
+ """
+ Attempts to read version.json and return its contents as a dict.
+ """
+ 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
+
+
+ @classmethod
+ def _get_version_name_from_version_file(cls) -> str | None:
+ """
+ Attempts to read the version.json and return the version name.
+ """
+ version_data = cls._read_version_file()
+ if version_data:
+ return version_data.get(cls.ATTR__VERSION_NAME)
+
+
+ @classmethod
+ def _get_version_fork_from_version_file(cls) -> str | None:
+ """
+ Attempts to read the version.json and return the version fork name.
+ """
+ version_data = cls._read_version_file()
+ if version_data:
+ return version_data.get(cls.ATTR__VERSION_FORK)
@classmethod
+ def _get_version_timestamp_from_version_file(cls) -> str | None:
+ """
+ Attempts to read the version.json and return the version timestamp.
+ """
+ version_data = cls._read_version_file()
+ if version_data:
+ return version_data.get(cls.ATTR__VERSION_TIMESTAMP)
+
+
+ @classmethod
+ def _get_version_commit_hash_from_version_file(cls) -> str | None:
+ """
+ Attempts to read the version.json and return the version commit hash.
+ """
+ version_data = cls._read_version_file()
+ if version_data:
+ return version_data.get(cls.ATTR__VERSION_COMMIT_HASH)
+
+
+ @classmethod
+ def _get_version_name_from_env_var(cls) -> str | None:
+ """
+ Primarily used during the SeedSigner OS build process to set the version name via env var.
+
+ This env var can also be set manually in local dev when needed.
+ e.g. SEEDSIGNER_VERSION_NAME=some_name python main.py
+ """
+ return os.getenv(cls.ENV_VAR__SEEDSIGNER_VERSION_NAME)
+
+
+ @classmethod
+ def is_github_actions_ci(cls) -> bool:
+ return os.getenv("CI") == "true"
+
+
+ @classmethod
+ def get_version_name_from_github_actions_env_vars(cls) -> str | None:
+ # REF_NAME will be the branch or tag name; SHA is the full commit hash
+ return os.getenv("GITHUB_REF_NAME") or os.getenv("GITHUB_SHA")[:7]
+
+
+
+ """ *************************************************************************************
+ 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.
+ ************************************************************************************* """
+ @classmethod
+ @fail_if_running_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__))
- # Have to back out of "helpers" and "seedsigner" and "src" dirs
+ # 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.join(project_root, ".git")
+ return os.path.normpath(os.path.join(project_root, ".git"))
@classmethod
- def _read_HEAD_file(cls) -> tuple[str,str]:
+ @fail_if_running_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
+ only one will have a value.
+
+ If there is no .git/HEAD file detected, both values will be None.
+ """
git_HEAD_path = os.path.join(cls._get_dot_git_dir(), "HEAD")
branch_name = None
@@ -53,8 +408,11 @@ class Version:
@classmethod
- def _get_matching_tag(cls, commit_hash: str) -> str:
- # Check the .git/refs/tags dir for a tag matching this commit hash
+ @fail_if_running_seedsigner_os
+ def _get_matching_tag(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):
for tag_filename in os.listdir(git_refs_tags_dir):
@@ -69,95 +427,111 @@ class Version:
@classmethod
- def _get_version_file_path(cls) -> str:
- # Have to back out of "helpers" dir to the main "seedsigner" dir
- return os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", cls.VERSION_FILENAME))
+ @fail_if_running_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
+ either the branch name, tag name, or short commit hash.
+ """
+ branch_name, commit_hash = VersionUtils._read_git_HEAD_file()
+ if branch_name:
+ return branch_name
+ elif commit_hash:
+ # See if this commit_hash matches a tag
+ matching_tag = VersionUtils._get_matching_tag(commit_hash)
+ if matching_tag:
+ return matching_tag
+ else:
+ return commit_hash[:7] # short commit hash
@classmethod
- def _get_version_file(cls) -> dict | None:
+ @fail_if_running_seedsigner_os
+ def _get_commit_hash_from_git_HEAD(cls) -> str | None:
"""
- Attempts to read the VERSION_FILENAME and return its contents as a dict.
+ Reads the .git/HEAD file and, depending on the local dev git state, returns
+ the short commit hash. Will only return a value if HEAD is detached (on a tag or
+ on a specific commit).
"""
- version_file_path = cls._get_version_file_path()
- try:
- with open(version_file_path, "r") as f:
- return json.load(f)
- except Exception as e:
- # In local dev we don't expect/want this file to exist
- pass
- return None
+ branch_name, commit_hash = VersionUtils._read_git_HEAD_file()
+ return commit_hash
@classmethod
- def get_version(cls) -> str:
+ @fail_if_running_seedsigner_os
+ def _get_commit_hash_from_git_refs_heads(cls, branch_name: str) -> str | None:
"""
- Will attempt to read the current git branch name or commit hash from
- .git/HEAD. But if there's no git info available, it will fall back to the
- hard-coded VERSION constant.
+ Reads the .git/refs/heads/<branch_name> file to get the current commit hash
+ for the given branch.
"""
- name = None
+ git_ref_path = os.path.join(cls._get_dot_git_dir(), "refs", "heads", branch_name)
+ if os.path.exists(git_ref_path):
+ with open(git_ref_path, "r") as f:
+ commit_hash = f.read().strip()
+ return commit_hash
- def _prefix_version_name(version: str) -> str:
- """
- Ensure version strings are prefixed with 'v' if they look like semantic
- versions. Only checks that the first part is numeric in order to be compatible
- with minor versions like an "rc1".
- """
- if not version.startswith("v") and version.count(".") >= 1 and version.split(".")[0].isnumeric():
- return f"v{version}"
- return version
- if Settings.HOSTNAME == Settings.SEEDSIGNER_OS:
- # The SeedSigner OS build process generates the version.json file for the tag,
- # branch, or commit hash the image is targeting.
- try:
- name = cls._get_version_file()["version"]
- except Exception:
- # Shouldn't be possible. Raise an exception to alert testers before this
- # image goes out.
- raise Exception("Could not read the version from the version.json file.")
- return _prefix_version_name(name)
+ @classmethod
+ def _parse_git_remote_url(cls, remote_url: str) -> str | None:
+ """
+ Parses a git remote URL to extract the fork owner name.
- # In local dev we dynamically read from the .git/HEAD file
- branch_name, commit_hash = cls._read_HEAD_file()
- if branch_name:
- name = branch_name
- elif commit_hash:
- # See if this commit_hash matches a tag
- matching_tag = cls._get_matching_tag(commit_hash)
- if matching_tag:
- name = matching_tag
- else:
- name = commit_hash[:7] # short commit hash
+ Formats:
+ * https://github.com/repo_owner/repo_name.git
+ * git@github.com:repo_owner/repo_name.git
+ """
+ if not remote_url:
+ return None
+ if remote_url.startswith("https"):
+ parts = remote_url.rsplit("/", 2)
+ return parts[-2]
+ elif remote_url.startswith("git@"):
+ parts = remote_url.split(":")
+ owner_repo = parts[1] # repo_owner/repo_name.git
+ return owner_repo.split("/")[0]
- if name is None:
- # If we're running in the Github Actions CI, we'll have env vars we can use
- if os.getenv("CI") == "true":
- name = os.getenv("GITHUB_REF_NAME") or os.getenv("GITHUB_SHA")[:7]
-
- if name is None:
- raise Exception("Could not determine version from git info nor CI env vars.")
- return _prefix_version_name(name)
+ @classmethod
+ @fail_if_running_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
+ and extract the fork owner name from it.
+
+ Format:
+ [some_section]
+ some_key = some_value
+ [remote "origin"]
+ url = git@github.com:SeedSigner/seedsigner.git
+ fetch = +refs/heads/*:refs/remotes/origin/*
+ [next_section]
+ ...
+ """
+ git_config_path = os.path.join(cls._get_dot_git_dir(), "config")
+ if os.path.exists(git_config_path):
+ with open(git_config_path, "r") as f:
+ lines = f.readlines()
+ in_origin_section = False
+ for line in lines:
+ if line.startswith("[remote \"origin\"]"):
+ # Next lines fetched will be the ones we care about
+ in_origin_section = True
+ elif in_origin_section and line.strip().startswith("url ="):
+ # Found our "origin" url
+ remote_url = line.split("=", 1)[1].strip()
+ return cls._parse_git_remote_url(remote_url)
+ 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
@classmethod
- def get_last_edit_timestamp(cls) -> datetime:
+ @fail_if_running_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.
"""
- if Settings.HOSTNAME == Settings.SEEDSIGNER_OS:
- # The SeedSigner OS build process generates the version.json file which will
- # already contain the last edit time.
- try:
- last_src_edit_str = cls._get_version_file()["last_src_edit"]
- return datetime.fromisoformat(last_src_edit_str)
- except Exception as e:
- # Shouldn't be possible. Raise an exception to alert testers before this
- # image goes out.
- raise Exception("Could not read the last_src_edit from the version.json file.")
-
try:
path = os.path.dirname(os.path.abspath(__file__))
@@ -177,7 +551,6 @@ class Version:
# getmtime returns the file's last modified time
file_mtime = os.path.getmtime(filepath)
last_modified = max(file_mtime, last_modified)
-
if num_files == 0:
# Shouldn't be possible
raise Exception("No python source files found in src/ directory")
@@ -192,50 +565,107 @@ class Version:
return None
-if __name__ == "__main__":
- """
- CLI to extract the current version and last edit time and write to `src/seedsigner/version.json`.
- Used by the SeedSigner OS build process to generate the version.json file.
+ """ *************************************************************************************
+ These functions use shell `git` commands which shouldn't be dangerous, but we don't want
+ them being run in SeedSigner OS regardless.
+ ************************************************************************************* """
+ @classmethod
+ @fail_if_running_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
- Uses the last git commit time (via `git log`) as the last edit time.
- """
- version_info = dict()
- # Run `git log` in the shell to get the last commit time
- try:
- last_commit = os.popen("git log -1 --format=%cI").read().strip()
- # Parse the timestamp, ensure that it's in UTC, and omit tz info
- version_info["last_src_edit"] = datetime.fromisoformat(last_commit).astimezone(timezone.utc).replace(tzinfo=None).isoformat()
- except Exception as e:
- raise Exception("Could not get last commit time from git log.") from e
+ @classmethod
+ @fail_if_running_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()
+ return tag_name.strip() if tag_name else None
- try:
- # If we're currently building SeedSigner OS, check the env var
- version_name = os.getenv("SEEDSIGNER_VERSION_NAME")
- if not version_name:
- version_name = os.popen("git branch --show-current").read().strip()
+ @classmethod
+ @fail_if_running_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
- if not version_name:
- # If we're on a tag, there won't be a current branch. Instead, try to get the
- # current tag.
- version_name = os.popen("git describe --tags --abbrev=0").read().strip()
-
- if not version_name:
- # Fallback to commit hash
- version_name = os.popen("git rev-parse --short HEAD").read().strip()
- if not version_name:
- raise Exception("No git info found for version name.")
+ @classmethod
+ @fail_if_running_seedsigner_os
+ def _get_version_name_from_git_shell(cls) -> str | None:
+ """
+ Attempts to get the version name via shell `git` commands.
+ """
+ return (
+ cls._get_version_name_from_git_shell_branch() or
+ cls._get_version_name_from_git_shell_tag() or
+ cls._get_version_name_from_git_shell_commit_hash()
+ )
+
+
+ @classmethod
+ @fail_if_running_seedsigner_os
+ def _get_version_fork_from_git_shell(cls) -> str | None:
+ """
+ Attempts to get the fork owner name via shell `git` commands.
+ """
+ # We expect to at least have a remote named "origin"
+ remote_url = os.popen("git remote get-url origin 2> /dev/null").read().strip()
+ return cls._parse_git_remote_url(remote_url)
+
+
+ @classmethod
+ @fail_if_running_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
+ return datetime.fromisoformat(version_timestamp).astimezone(timezone.utc).replace(tzinfo=None)
+
- version_info["version"] = version_name
- except Exception as e:
- raise Exception("Could not get version name from SeedSigner OS env var nor git.") from e
+ @classmethod
+ @fail_if_running_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.
+ """
+ commit_hash = os.popen("git rev-parse --short HEAD").read()
+ return commit_hash.strip() if commit_hash else None
- version_file_path = Version._get_version_file_path()
- with open(version_file_path, "w") as f:
- json.dump(version_info, f, indent=4)
- print(f"Wrote version info to: {version_file_path}")
- print(json.dumps(version_info, indent=4))
\ No newline at end of file
+ @classmethod
+ @fail_if_running_seedsigner_os
+ def _fetch_latest_release_version(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
+ commit timestamp. If local git data is not available, falls back to using the
+ release published_at time from the API.
+
+ This is only used by the screenshot renderer.
+ """
+ import urllib.request
+ from http.client import HTTPResponse
+
+ try:
+ req = urllib.request.Request(
+ "https://api.github.com/repos/SeedSigner/seedsigner/releases/latest"
+ )
+ response: HTTPResponse = urllib.request.urlopen(req, timeout=5)
+ if response.status == 200:
+ release_data = json.loads(response.read().decode('utf-8'))
+ version_name = release_data.get("tag_name")
+
+ # Try to find the commit timestamp for this tag from local git data
+ 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 = 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}")
+ return (None, None)
+ except Exception as e:
+ logger.error(f"Error fetching latest release version: {e}")
+ return (None, None)
\ No newline at end of file
diff --git a/src/seedsigner/views/screensaver.py b/src/seedsigner/views/screensaver.py
index 3160626..0b0847b 100644
--- a/src/seedsigner/views/screensaver.py
+++ b/src/seedsigner/views/screensaver.py
@@ -98,7 +98,7 @@ class OpeningSplashScreen(LogoScreen):
# Display version num below SeedSigner logo
font = Fonts.get_font(GUIConstants.get_body_font_name(), GUIConstants.get_top_nav_title_font_size())
- version = Version.get_version()
+ version = Version.get_version_name()
# The logo png is 240x240, but the actual logo is 70px tall, vertically centered
logo_height = 70
diff --git a/src/seedsigner/views/settings_views.py b/src/seedsigner/views/settings_views.py
index 03b5f17..ee14ce1 100644
--- a/src/seedsigner/views/settings_views.py
+++ b/src/seedsigner/views/settings_views.py
@@ -330,10 +330,22 @@ class VersionView(View):
def run(self):
from seedsigner.helpers.version import Version
+ version_fork = Version.get_version_fork()
+ version_commit_hash = Version.get_version_commit_hash()
+
+ print(f"{version_fork=}, {version_commit_hash=}")
+
+ if version_fork and version_fork.lower() == "seedsigner":
+ # Don't display fork name or commit hash for the main repo
+ version_fork = None
+ version_commit_hash = None
+
self.run_screen(
settings_screens.VersionScreen,
- version=Version.get_version(),
- last_edit=Version.get_last_edit_timestamp(),
+ version_name=Version.get_version_name(),
+ version_fork=version_fork,
+ version_timestamp=Version.get_version_timestamp(),
+ version_commit_hash=version_commit_hash,
)
return Destination(SettingsMenuView)
\ No newline at end of file
diff --git a/tests/screenshot_generator/generator.py b/tests/screenshot_generator/generator.py
index 8ac7f53..e041b95 100644
--- a/tests/screenshot_generator/generator.py
+++ b/tests/screenshot_generator/generator.py
@@ -13,6 +13,8 @@ from embit import compact
from embit.psbt import PSBT, OutputScope
from embit.script import Script
+from seedsigner.helpers.version import Version, VersionUtils
+
# Prevent importing modules w/Raspi hardware dependencies.
# These must precede any SeedSigner imports.
sys.modules['seedsigner.hardware.displays.st7789_mpy'] = MagicMock()
@@ -195,6 +197,17 @@ def generate_screenshots(locale):
value=SettingsConstants.OPTION__ENABLED
)
+ # Initialize the Version data to the most recent release
+ (version_name, version_timestamp) = VersionUtils._fetch_latest_release_version()
+ if not version_name or not version_timestamp:
+ raise Exception("Could not fetch latest release version from GitHub")
+ Version.override_data(
+ version_name=version_name,
+ version_fork="SeedSigner", # main repo; screenshot should hide fork and commit hash
+ version_timestamp=version_timestamp,
+ version_commit_hash="abcd1234" # dummy value should be ignored
+ )
+
# Automatically populate all Settings options Views
settings_views_list = []
def add_settings_entries(visibility = SettingsConstants.VISIBILITY__GENERAL):
@@ -297,6 +310,10 @@ def generate_screenshots(locale):
decoder.add_data(BASE64_PSBT_WITH_OP_RETURN_RAW_BYTES)
controller.psbt = decoder.get_psbt()
controller.psbt_parser = PSBTParser(p=controller.psbt, seed=seed_12b)
+
+
+ def reset_version_to_local_git_state_cb():
+ Version.reset_instance()
screenshot_sections = {
@@ -429,6 +446,7 @@ def generate_screenshots(locale):
ScreenshotConfig(settings_views.IOTestView),
ScreenshotConfig(settings_views.DonateView),
ScreenshotConfig(settings_views.VersionView),
+ ScreenshotConfig(settings_views.VersionView, run_before=reset_version_to_local_git_state_cb, screenshot_name="VersionView_current_git_state"),
ScreenshotConfig(settings_views.SettingsIngestSettingsQRView, dict(data=settingsqr_data_persistent), screenshot_name="SettingsIngestSettingsQRView_persistent"),
ScreenshotConfig(settings_views.SettingsIngestSettingsQRView, dict(data=settingsqr_data_not_persistent), screenshot_name="SettingsIngestSettingsQRView_not_persistent"),
],
diff --git a/tests/test_version.py b/tests/test_version.py
index f398d2d..5eb4438 100644
--- a/tests/test_version.py
+++ b/tests/test_version.py
@@ -50,7 +50,7 @@ class TestVersion(BaseTest):
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()
+ version = Version.get_version_name()
assert version == branch_name
# Mock the HEAD file read in _read_HEAD_file to return a fake commit hash
@@ -58,13 +58,13 @@ class TestVersion(BaseTest):
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()
+ 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()
+ version = Version.get_version_name()
assert version == tag_name
@@ -73,7 +73,7 @@ class TestVersion(BaseTest):
Test that get_last_src_edit returns a sane datetime object. Assumes the system
running this test has a reasonably correct system time.
"""
- last_edit = Version.get_last_edit_timestamp()
+ last_edit = Version.get_version_timestamp()
assert isinstance(last_edit, datetime)
# Has to be more recent than the first SeedSigner v0.0.1 release
diff --git a/tools/write_versionfile.py b/tools/write_versionfile.py
new file mode 100644
index 0000000..8e556e5
--- /dev/null
+++ b/tools/write_versionfile.py
@@ -0,0 +1,63 @@
+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
+`src/seedsigner/version.json`. Primarily used by the SeedSigner OS build process.
+
+SeedSigner OS lifecycle:
+ * Build process runs this script to generate version.json.
+ * 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.
+
+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_timestamp:
+ * Pulls last git commit time from `git log`.
+
+version_commit_hash:
+ * Pulls current git commit hash from `git` shell command.
+"""
+
+if __name__ == "__main__":
+ version_info = dict()
+
+ for get_version_name_method in [
+ VersionUtils._get_version_name_from_env_var,
+ VersionUtils._get_version_name_from_git_shell,
+ ]:
+ version_name = get_version_name_method()
+ if version_name:
+ break
+
+ version_timestamp = VersionUtils._get_version_timestamp_from_git_shell()
+ version_commit_hash = VersionUtils._get_version_commit_hash_from_git_shell()
+
+ if not version_name or not version_timestamp or not version_commit_hash:
+ raise Exception("Could not determine version information from git.")
+
+ version_info[VersionUtils.ATTR__VERSION_NAME] = version_name
+ version_info[VersionUtils.ATTR__VERSION_FORK] = VersionUtils._get_version_fork_from_git_shell()
+ version_info[VersionUtils.ATTR__VERSION_TIMESTAMP] = version_timestamp.isoformat()
+ version_info[VersionUtils.ATTR__VERSION_COMMIT_HASH] = version_commit_hash[:7] # short hash
+
+ version_file_path = VersionUtils._get_version_file_path()
+ with open(version_file_path, "w") as f:
+ json.dump(version_info, f, indent=4)
+
+ print(f"Wrote version info to: {version_file_path}")
+ print(json.dumps(version_info, indent=4))
Why this scored 16/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.