feat: Add GitHub Actions workflows for building and releasing Android, iOS, Linux, and Windows applications

This commit is contained in:
2025-08-27 11:06:04 +02:00
parent 8cb163d969
commit fe1acd8669
5 changed files with 1127 additions and 0 deletions

573
bin/release.py Executable file
View File

@@ -0,0 +1,573 @@
#!/usr/bin/env python3
import subprocess
import os
import sys
import re
import fileinput
from datetime import datetime
def get_project_root():
"""Finds the project root directory containing .git"""
current_dir = os.path.dirname(os.path.abspath(__file__))
# Go up one level from bin/
project_root = os.path.dirname(current_dir)
if os.path.isdir(os.path.join(project_root, '.git')):
return project_root
else:
print("Error: Could not find project root (.git directory).", file=sys.stderr)
sys.exit(1)
def get_version(project_root):
"""Reads the version from pubspec.yaml"""
pubspec_path = os.path.join(project_root, 'pubspec.yaml')
try:
with open(pubspec_path, 'r') as f:
for line in f:
if line.strip().startswith('version:'):
# Extract version string after 'version:'
version_match = re.search(r'version:\s*([\w\.\+\-]+)', line)
if version_match:
return version_match.group(1).strip()
print(f"Error: Could not find 'version:' line in {pubspec_path}", file=sys.stderr)
sys.exit(1)
except FileNotFoundError:
print(f"Error: {pubspec_path} not found.", file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"Error reading {pubspec_path}: {e}", file=sys.stderr)
sys.exit(1)
def check_tag_exists(tag_name, project_root):
"""Checks if a git tag already exists"""
try:
# Use cwd=project_root to run git in the correct directory
result = subprocess.run(['git', 'rev-parse', '--verify', '--quiet', tag_name],
cwd=project_root,
check=False, # Don't raise exception on non-zero exit
capture_output=True)
return result.returncode == 0
except FileNotFoundError:
print("Error: 'git' command not found. Make sure Git is installed and in your PATH.", file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"Error checking git tag: {e}", file=sys.stderr)
sys.exit(1)
def check_git_dirty(project_root):
"""Checks if the git working directory is dirty"""
try:
result = subprocess.run(['git', 'status', '--porcelain'],
cwd=project_root,
check=True,
capture_output=True,
text=True)
if result.stdout:
print("Warning: Git working directory is dirty:", file=sys.stderr)
print(result.stdout.strip(), file=sys.stderr)
return True # Dirty
return False # Clean
except FileNotFoundError:
print("Error: 'git' command not found. Make sure Git is installed and in your PATH.", file=sys.stderr)
sys.exit(1) # Exit if git isn't found
except subprocess.CalledProcessError as e:
print(f"Error checking git status: {e}", file=sys.stderr)
# Decide if we should exit or just warn and continue? Let's exit for safety.
sys.exit(1)
except Exception as e:
print(f"An unexpected error occurred checking git status: {e}", file=sys.stderr)
sys.exit(1)
def update_pubspec_version(project_root, new_version):
"""Updates the version in pubspec.yaml"""
pubspec_path = os.path.join(project_root, 'pubspec.yaml')
print(f"Updating {pubspec_path} to version: {new_version}")
try:
# Read lines, update the version line, write back
updated = False
lines = []
with open(pubspec_path, 'r') as f:
lines = f.readlines()
with open(pubspec_path, 'w') as f:
for line in lines:
if line.strip().startswith('version:'):
f.write(f"version: {new_version}\n")
updated = True
else:
f.write(line)
if not updated:
print(f"Warning: 'version:' line not found in {pubspec_path}. File not updated.", file=sys.stderr)
return updated
except Exception as e:
print(f"Error updating {pubspec_path}: {e}", file=sys.stderr)
return False
def run_command(command_list, cwd, error_message_prefix):
"""Runs a command and handles errors"""
print(f"Running: {' '.join(command_list)}")
try:
result = subprocess.run(command_list, cwd=cwd, check=True, capture_output=True, text=True)
print(result.stdout)
if result.stderr:
print(result.stderr, file=sys.stderr) # Show stderr even on success if present
return True
except FileNotFoundError:
print(f"\nError: '{command_list[0]}' command not found. Make sure it's installed and in your PATH.", file=sys.stderr)
return False
except subprocess.CalledProcessError as e:
print(f"\n{error_message_prefix}:", file=sys.stderr)
print(f" Command: {' '.join(e.cmd)}", file=sys.stderr)
print(f" Return code: {e.returncode}", file=sys.stderr)
if e.stdout:
print(f" Stdout:\n{e.stdout}", file=sys.stderr)
if e.stderr:
print(f" Stderr:\n{e.stderr}", file=sys.stderr)
return False
except Exception as e:
print(f"\nAn unexpected error occurred while running {' '.join(command_list)}: {e}", file=sys.stderr)
return False
def get_release_notes():
"""Prompts the user for multi-line release notes"""
print("Enter release notes (press Ctrl+D on a new line when finished):")
lines = []
try:
while True:
line = sys.stdin.readline()
if not line: # EOF detected (Ctrl+D)
break
lines.append(line)
except KeyboardInterrupt:
print("\nAborted note entry.")
return None # Indicate abortion if needed, or handle differently
return "".join(lines).strip()
def get_git_remotes(project_root):
"""Gets all git remotes"""
try:
result = subprocess.run(['git', 'remote', '-v'],
cwd=project_root,
check=True,
capture_output=True,
text=True)
remotes = {}
for line in result.stdout.strip().split('\n'):
if line and '(fetch)' in line:
parts = line.split('\t')
if len(parts) >= 2:
remote_name = parts[0]
remote_url = parts[1].replace(' (fetch)', '')
remotes[remote_name] = remote_url
return remotes
except FileNotFoundError:
print("Error: 'git' command not found. Make sure Git is installed and in your PATH.", file=sys.stderr)
sys.exit(1)
except subprocess.CalledProcessError as e:
print(f"Error getting git remotes: {e}", file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"An unexpected error occurred getting git remotes: {e}", file=sys.stderr)
sys.exit(1)
def is_gitea_remote(remote_url):
"""Checks if a remote URL appears to be a Gitea instance"""
# This is a heuristic - you might want to adjust this based on your setup
gitea_indicators = ['gitea', 'tea', 'git.mvl.sh']
return any(indicator in remote_url.lower() for indicator in gitea_indicators)
def is_github_remote(remote_url):
"""Checks if a remote URL is GitHub"""
return 'github.com' in remote_url.lower()
def select_remotes_for_push(remotes):
"""Allows user to select which remotes to push to"""
if not remotes:
print("No remotes found.")
return []
print("\nAvailable remotes:")
remote_list = list(remotes.keys())
for i, remote in enumerate(remote_list, 1):
print(f" {i}. {remote} ({remotes[remote]})")
while True:
selection = input(f"\nSelect remotes to push to (comma-separated numbers, 'all', or 'none') [all]: ").strip()
if not selection or selection.lower() == 'all':
return remote_list
if selection.lower() == 'none':
return []
try:
selected_indices = [int(x.strip()) for x in selection.split(',')]
selected_remotes = []
for idx in selected_indices:
if 1 <= idx <= len(remote_list):
selected_remotes.append(remote_list[idx - 1])
else:
print(f"Invalid selection: {idx}. Please try again.")
break
else:
return selected_remotes
except ValueError:
print("Invalid input. Please enter numbers separated by commas, 'all', or 'none'.")
def select_remote_for_release(remotes):
"""Allows user to select which remote to create the release on"""
if not remotes:
print("No remotes found.")
return None
# Filter remotes that might support releases (Gitea or GitHub)
release_capable_remotes = {}
for name, url in remotes.items():
if is_gitea_remote(url) or is_github_remote(url):
release_capable_remotes[name] = url
if not release_capable_remotes:
print("No remotes found that appear to support releases (Gitea or GitHub).")
return None
if len(release_capable_remotes) == 1:
remote_name = list(release_capable_remotes.keys())[0]
print(f"\nUsing {remote_name} for release creation ({release_capable_remotes[remote_name]})")
return remote_name
print("\nAvailable remotes for release creation:")
remote_list = list(release_capable_remotes.keys())
for i, remote in enumerate(remote_list, 1):
remote_type = "Gitea" if is_gitea_remote(release_capable_remotes[remote]) else "GitHub"
print(f" {i}. {remote} ({remote_type}: {release_capable_remotes[remote]})")
while True:
try:
selection = input(f"\nSelect remote for release creation [1]: ").strip()
if not selection:
return remote_list[0]
idx = int(selection)
if 1 <= idx <= len(remote_list):
return remote_list[idx - 1]
else:
print(f"Invalid selection: {idx}. Please try again.")
except ValueError:
print("Invalid input. Please enter a number.")
def create_github_release(tag_name, title, notes, is_prerelease, project_root, remote_name, remotes):
"""Creates a GitHub release using gh CLI"""
# Get the repository URL from the remote
remote_url = remotes[remote_name]
# Extract owner/repo from GitHub URL
repo_info = None
if 'github.com' in remote_url:
# Handle both SSH and HTTPS URLs
if remote_url.startswith('git@github.com:'):
# SSH format: git@github.com:owner/repo.git
repo_part = remote_url.replace('git@github.com:', '').replace('.git', '')
repo_info = repo_part
elif 'github.com/' in remote_url:
# HTTPS format: https://github.com/owner/repo.git
repo_part = remote_url.split('github.com/')[-1].replace('.git', '')
repo_info = repo_part
gh_command = ['gh', 'release', 'create', tag_name, '--title', title]
if repo_info:
gh_command.extend(['--repo', repo_info])
if is_prerelease:
gh_command.append('--prerelease')
if notes:
gh_command.extend(['--notes', notes])
else:
gh_command.append('--generate-notes')
print(f"\nCreating GitHub release on {remote_name}...")
if repo_info:
print(f"Repository: {repo_info}")
print(f"Running: {' '.join(gh_command)}")
try:
result = subprocess.run(gh_command, cwd=project_root, check=True, capture_output=True, text=True)
print("\nGitHub release created successfully!")
print(result.stdout)
return True
except FileNotFoundError:
print("\nError: 'gh' command not found. Make sure GitHub CLI is installed and authenticated.", file=sys.stderr)
return False
except subprocess.CalledProcessError as e:
print("\nError creating GitHub release:", file=sys.stderr)
print(f" Return code: {e.returncode}", file=sys.stderr)
if e.stdout:
print(f" Stdout:\n{e.stdout}", file=sys.stderr)
if e.stderr:
print(f" Stderr:\n{e.stderr}", file=sys.stderr)
return False
except Exception as e:
print(f"\nAn unexpected error occurred creating GitHub release: {e}", file=sys.stderr)
return False
def create_gitea_release(tag_name, title, notes, is_prerelease, project_root, remote_name):
"""Creates a Gitea release using tea CLI"""
tea_command = ['tea', 'release', 'create', '--tag', tag_name, '--title', title, '--remote', remote_name]
if is_prerelease:
tea_command.append('--prerelease')
if notes:
tea_command.extend(['--note', notes])
print(f"\nCreating Gitea release on {remote_name}...")
print(f"Running: {' '.join(tea_command)}")
try:
result = subprocess.run(tea_command, cwd=project_root, check=True, capture_output=True, text=True)
print("\nGitea release created successfully!")
print(result.stdout)
return True
except FileNotFoundError:
print("\nError: 'tea' command not found. Make sure Gitea CLI is installed and configured.", file=sys.stderr)
return False
except subprocess.CalledProcessError as e:
print("\nError creating Gitea release:", file=sys.stderr)
print(f" Return code: {e.returncode}", file=sys.stderr)
if e.stdout:
print(f" Stdout:\n{e.stdout}", file=sys.stderr)
if e.stderr:
print(f" Stderr:\n{e.stderr}", file=sys.stderr)
return False
except Exception as e:
print(f"\nAn unexpected error occurred creating Gitea release: {e}", file=sys.stderr)
return False
def main():
project_root = get_project_root()
# --- Get remotes information ---
remotes = get_git_remotes(project_root)
print(f"Found {len(remotes)} remote(s):")
for name, url in remotes.items():
print(f" {name}: {url}")
# --- Check Git Status ---
if check_git_dirty(project_root):
dirty_confirm = input("Working directory is dirty. Continue anyway? [y/N]: ").strip().lower()
if dirty_confirm != 'y':
print("Aborted due to dirty working directory.")
sys.exit(0)
print("Continuing with dirty working directory...")
# --- End Check Git Status ---
original_version = get_version(project_root)
tag_name = f"v{original_version}"
version_to_use = original_version # This might change if tag exists
print(f"Detected version: {original_version}")
print(f"Tag to create based on pubspec: {tag_name}")
# Ask if user wants to use detected version or specify a new one
use_detected = input(f"\nUse detected version '{original_version}'? [Y/n]: ").strip().lower()
if use_detected == 'n':
while True:
new_base_version_input = input("Enter a new base version tag (e.g., v1.2.0): ").strip()
# Validate the base version input (starts with v, numbers, dots)
if not re.match(r'^v\d+(\.\d+)*$', new_base_version_input):
print("Invalid format. Please use 'v' followed by numbers and dots (e.g., v1.2.0). Try again.", file=sys.stderr)
continue # Ask again
# Get current date in ddMMyyyy format
current_date = datetime.now().strftime('%d%m%Y')
# Construct the full version string (without leading 'v' for pubspec)
version_to_use = f"{new_base_version_input[1:]}+{current_date}" # Remove leading 'v'
tag_name = f"v{version_to_use}" # Add 'v' back for the tag
print(f"New version string: {version_to_use}")
print(f"New tag to check/create: {tag_name}")
# Check if this new tag already exists
if check_tag_exists(tag_name, project_root):
print(f"\nWarning: Tag '{tag_name}' already exists.")
continue # Ask for a different version
else:
break # Tag doesn't exist, we can use this version
else:
# User wants to use detected version, but check if tag already exists
if check_tag_exists(tag_name, project_root):
print(f"\nWarning: Tag '{tag_name}' already exists.")
while True:
new_base_version_input = input("Enter a new base version tag (e.g., v1.2.0): ").strip()
# Validate the base version input (starts with v, numbers, dots)
if not re.match(r'^v\d+(\.\d+)*$', new_base_version_input):
print("Invalid format. Please use 'v' followed by numbers and dots (e.g., v1.2.0). Try again.", file=sys.stderr)
continue # Ask again
# Get current date in ddMMyyyy format
current_date = datetime.now().strftime('%d%m%Y')
# Construct the full version string (without leading 'v' for pubspec)
version_to_use = f"{new_base_version_input[1:]}+{current_date}" # Remove leading 'v'
tag_name = f"v{version_to_use}" # Add 'v' back for the tag
print(f"New version string: {version_to_use}")
print(f"New tag to check/create: {tag_name}")
# Check if this new tag already exists
if check_tag_exists(tag_name, project_root):
print(f"\nWarning: Tag '{tag_name}' already exists.")
continue # Ask for a different version
else:
break # Tag doesn't exist, we can use this version
print(f"\nFinal version: {version_to_use}")
print(f"Final tag: {tag_name}")
# --- Optional: Update pubspec, pub get, commit ---
commit_changes_input = input(f"\nUpdate pubspec to {version_to_use}, run 'flutter pub get', commit, and push? [y/N]: ").strip().lower()
if commit_changes_input == 'y':
print("\n--- Starting update, commit, and push process ---")
# 1. Update pubspec.yaml
if not update_pubspec_version(project_root, version_to_use):
print("Aborting due to error updating pubspec.yaml.", file=sys.stderr)
sys.exit(1)
# 2. Run flutter pub get
if not run_command(['flutter', 'pub', 'get'], project_root, "Error running 'flutter pub get'"):
print("Aborting due to error running 'flutter pub get'.", file=sys.stderr)
sys.exit(1)
# 3. Git add
if not run_command(['git', 'add', 'pubspec.yaml', 'pubspec.lock'], project_root, "Error running 'git add'"):
print("Aborting due to error running 'git add'.", file=sys.stderr)
sys.exit(1)
# 4. Git commit
commit_message = f"chore: bump version to {version_to_use}"
if not run_command(['git', 'commit', '-m', commit_message], project_root, "Error running 'git commit'"):
print("Aborting due to error running 'git commit'.", file=sys.stderr)
sys.exit(1)
# 5. Select remotes to push to
selected_remotes = select_remotes_for_push(remotes)
if not selected_remotes:
print("No remotes selected for push. Skipping push step.")
else:
# Push to selected remotes
for remote in selected_remotes:
print(f"\nPushing to {remote}...")
if not run_command(['git', 'push', remote], project_root, f"Error pushing to {remote}"):
print(f"Failed to push to {remote}. Continuing with other remotes...", file=sys.stderr)
else:
print(f"Successfully pushed to {remote}")
print("--- Update, commit, and push process finished ---")
else:
print("Skipping pubspec update, pub get, commit, and push.")
# --- End optional update ---
# Ask about pre-release
prerelease_input = input("\nMark as pre-release? [Y/n]: ").strip().lower()
is_prerelease = prerelease_input == '' or prerelease_input == 'y'
# Ask about release notes
notes = ""
notes_input = input("Add release notes? [y/N]: ").strip().lower()
if notes_input == 'y':
notes = get_release_notes()
if notes is None: # Handle potential abortion during note entry
print("Release aborted during note entry.")
sys.exit(1)
# Select remote for release creation
release_remote = select_remote_for_release(remotes)
if not release_remote:
print("No suitable remote found for release creation. Exiting.")
sys.exit(1)
remote_url = remotes[release_remote]
is_github = is_github_remote(remote_url)
is_gitea = is_gitea_remote(remote_url)
# Create the git tag locally first
print(f"\nCreating git tag '{tag_name}'...")
if not run_command(['git', 'tag', tag_name], project_root, f"Error creating tag {tag_name}"):
print(f"Failed to create tag {tag_name}.", file=sys.stderr)
sys.exit(1)
# Push the tag to the release remote
print(f"Pushing tag '{tag_name}' to {release_remote}...")
if not run_command(['git', 'push', release_remote, tag_name], project_root, f"Error pushing tag to {release_remote}"):
print(f"Failed to push tag to {release_remote}. Cannot create release without the tag.", file=sys.stderr)
sys.exit(1)
# Ask for confirmation
platform = "GitHub" if is_github else "Gitea" if is_gitea else "Unknown platform"
confirm_input = input(f"\nProceed with creating the release on {release_remote} ({platform})? [Y/n]: ").strip().lower()
if confirm_input != '' and confirm_input != 'y':
print("Aborted by user.")
sys.exit(0)
# Create the release
success = False
if is_github:
success = create_github_release(tag_name, tag_name, notes, is_prerelease, project_root, release_remote, remotes)
elif is_gitea:
success = create_gitea_release(tag_name, tag_name, notes, is_prerelease, project_root, release_remote)
else:
print(f"Unsupported remote type for {release_remote}. Only GitHub and Gitea are supported.", file=sys.stderr)
sys.exit(1)
if not success:
print("Release creation failed.", file=sys.stderr)
sys.exit(1)
# Ask about creating releases on other platforms
release_capable_remotes = {}
for name, url in remotes.items():
if is_gitea_remote(url) or is_github_remote(url):
release_capable_remotes[name] = url
print(f"\nAll release-capable remotes found: {release_capable_remotes}")
# Remove the already used remote
remaining_remotes = {k: v for k, v in release_capable_remotes.items() if k != release_remote}
print(f"Remaining remotes after removing {release_remote}: {remaining_remotes}")
if remaining_remotes:
print(f"\nOther release-capable remotes available:")
for name, url in remaining_remotes.items():
remote_type = "GitHub" if is_github_remote(url) else "Gitea"
print(f" {name} ({remote_type}: {url})")
create_more = input(f"\nCreate release on other platforms? [y/N]: ").strip().lower()
if create_more == 'y':
for remote_name, remote_url in remaining_remotes.items():
remote_is_github = is_github_remote(remote_url)
remote_is_gitea = is_gitea_remote(remote_url)
remote_platform = "GitHub" if remote_is_github else "Gitea" if remote_is_gitea else "Unknown"
create_on_remote = input(f"\nCreate release on {remote_name} ({remote_platform})? [Y/n]: ").strip().lower()
if create_on_remote == '' or create_on_remote == 'y':
# Push tag to this remote too
print(f"Pushing tag '{tag_name}' to {remote_name}...")
if not run_command(['git', 'push', remote_name, tag_name], project_root, f"Error pushing tag to {remote_name}"):
print(f"Failed to push tag to {remote_name}. Skipping release creation.", file=sys.stderr)
continue
# Create release on this remote
if remote_is_github:
create_github_release(tag_name, tag_name, notes, is_prerelease, project_root, remote_name, remotes)
elif remote_is_gitea:
create_gitea_release(tag_name, tag_name, notes, is_prerelease, project_root, remote_name)
else:
print(f"Skipping release creation on {remote_name}")
else:
print("\nNo other release-capable remotes found.")
print("\nRelease process completed!")
if __name__ == "__main__":
main()