feat: Allow OTA update to beta releases

This commit is contained in:
Joel Goguen
2026-05-07 23:29:15 -04:00
parent 0b93445450
commit 3a612df4b5
14 changed files with 398 additions and 103 deletions
+115 -12
View File
@@ -1,13 +1,18 @@
"""
PlatformIO pre-build script: inject git branch into CROSSPOINT_VERSION for
the default (dev) environment.
PlatformIO pre-build script: inject Git metadata into preprocessor defines.
Results in a version string like: 1.1.0-dev+feat-koysnc-xpath
Release environments are unaffected; they set CROSSPOINT_VERSION in the ini.
- The default (dev) environment gets CROSSPOINT_VERSION with a branch suffix like:
1.1.0-dev+feat-koysnc-xpath
- The gh_release_rc environment gets CROSSPOINT_VERSION with an RC tag from CI metadata
when available, or a local fallback like: 1.1.0-rc+local
- All environments get CROSSPOINT_GIT_REPOSITORY, resolved from CI metadata
or local Git remotes. A safe fallback is defined in src/network/OtaUpdater.h in case
resolution here fails.
"""
import configparser
import os
import re
import subprocess
import sys
@@ -16,18 +21,26 @@ def warn(msg):
print(f'WARNING [git_branch.py]: {msg}', file=sys.stderr)
def get_git_branch(project_dir):
def run_git_command(*args: str, project_dir: str) -> str:
try:
branch = subprocess.check_output(
['git', 'rev-parse', '--abbrev-ref', 'HEAD'],
return subprocess.check_output(
['git', *args],
text=True, stderr=subprocess.PIPE, cwd=project_dir
).strip()
except FileNotFoundError:
warn('git not found on PATH')
raise
except subprocess.CalledProcessError as e:
warn(f'git command "git {" ".join(args)}" failed (exit {e.returncode}): {e.stderr.strip()}')
raise
def get_git_branch(project_dir):
try:
branch = run_git_command('rev-parse', '--abbrev-ref', 'HEAD', project_dir=project_dir)
# Detached HEAD — show the short SHA instead
if branch == 'HEAD':
branch = subprocess.check_output(
['git', 'rev-parse', '--short', 'HEAD'],
text=True, stderr=subprocess.PIPE, cwd=project_dir
).strip()
branch = run_git_command('rev-parse', '--short', 'HEAD', project_dir=project_dir)
# Strip characters that would break a C string literal
return ''.join(c for c in branch if c not in '"\\')
except FileNotFoundError:
@@ -41,6 +54,74 @@ def get_git_branch(project_dir):
return 'unknown'
def get_all_remotes(project_dir: str) -> list[str]:
try:
remotes = run_git_command('remote', project_dir=project_dir)
return remotes.splitlines()
except FileNotFoundError:
warn('git not found on PATH; cannot read git remotes')
return []
except subprocess.CalledProcessError as e:
warn(f'git command failed (exit {e.returncode}): {e.stderr.strip()}; cannot read git remotes')
return []
except Exception as e:
warn(f'Unexpected error reading git remotes: {e}; cannot read git remotes')
return []
def parse_git_repository(remote_url: str) -> str | None:
# Match strings like:
# - https://github.com/owner/repo.git
# - https://code.example.com/owner/repo
# - git+ssh://vcs.example.org:owner/repo.git
# - codeberg.org:owner/repo.git
match = re.search(r'^(?:.+)?(?:://)?[^:/]+[:/]([^/]+)/([^/]+?)(?:\.git)?$', remote_url.strip())
if not match:
return None
owner = match.group(1)
repo = match.group(2)
if not owner or not repo:
return None
return f'{owner}/{repo}'
def get_git_remote_url(project_dir, remote_name):
try:
return run_git_command('remote', 'get-url', remote_name, project_dir=project_dir)
except (FileNotFoundError, subprocess.CalledProcessError):
return None
def get_git_repository(project_dir):
# Other CI systems (Forgejo, Codeberg) may set GITHUB_REPOSITORY for compatibility
# with GHA. We could also check for other CI-specific env vars to expand support
# later, such as:
# - FORGEJO_REPOSITORY
# - CI_REPOSITORY_URL (this one is a full URL, likely will work with parse_git_repository)
# - BITBUCKET_REPO_FULL_NAME
ci_repository = os.environ.get('GITHUB_REPOSITORY')
if ci_repository:
return ci_repository
remotes = get_all_remotes(project_dir)
# 'origin' is most likely to be the primary remote, so always check for it first
if 'origin' in remotes:
remotes = ['origin'] + [r for r in remotes if r != 'origin']
for remote_name in remotes:
remote_url = get_git_remote_url(project_dir, remote_name)
if not remote_url:
continue
repository = parse_git_repository(remote_url)
if repository:
return repository
warn(
'Could not resolve a repository from CI metadata or git remotes; '
'falling back to compile-time default.'
)
return None
def get_base_version(project_dir):
ini_path = os.path.join(project_dir, 'platformio.ini')
if not os.path.isfile(ini_path):
@@ -54,13 +135,35 @@ def get_base_version(project_dir):
return config.get('crosspoint', 'version')
def normalize_semver_patch(version: str) -> str:
version = version.strip()
if version.count('.') == 1:
return f'{version}.0'
return version
def inject_version(env):
project_dir = env['PROJECT_DIR']
git_repository = get_git_repository(project_dir)
if git_repository:
env.Append(CPPDEFINES=[('CROSSPOINT_GIT_REPOSITORY', f'\\"{git_repository}\\"')])
print(f'CrossPoint Git repository: {git_repository}')
# Release candidate builds use the CI-provided RC tag when available, but
# keep local gh_release_rc builds identifiable instead of leaving the
# firmware version empty.
if env['PIOENV'] == 'gh_release_rc':
base_version = normalize_semver_patch(get_base_version(project_dir))
version_string = os.environ.get('CROSSPOINT_RC_VERSION') or f'{base_version}-rc.0+local'
env.Append(CPPDEFINES=[('CROSSPOINT_VERSION', f'\\"{version_string}\\"')])
print(f'CrossPoint build version: {version_string}')
return
# Only applies to the dev (default) environment; release envs set the
# version via build_flags in platformio.ini and are unaffected.
if env['PIOENV'] != 'default':
return
project_dir = env['PROJECT_DIR']
base_version = get_base_version(project_dir)
branch = get_git_branch(project_dir)
version_string = f'{base_version}-dev+{branch}'