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

69
.github/workflows/android-build.yml vendored Normal file
View File

@@ -0,0 +1,69 @@
# .github/workflows/release_apk_github.yml
name: Build Android Release
on:
release:
types: [published]
jobs:
build:
name: Build Android Release for GitHub
runs-on: ubuntu-latest
if: github.repository_owner == 'vleeuwenmenno' && contains(github.server_url, 'github.com')
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: "temurin"
java-version: "17"
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
channel: "stable"
- name: Install dependencies
run: flutter pub get
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Build release APK
run: flutter build apk --release
- name: Extract version
id: version
run: |
APP_VERSION=$(grep 'version:' pubspec.yaml | sed 's/version: //g' | sed 's/+.*//g')
echo "APP_VERSION=$APP_VERSION" >> $GITHUB_ENV
- name: Rename APK
run: |
mv build/app/outputs/flutter-apk/app-release.apk supplements_${{ env.APP_VERSION }}_android.apk
- name: Build debug APK
run: flutter build apk --debug
- name: Rename Debug APK
run: |
mv build/app/outputs/flutter-apk/app-debug.apk supplements_${{ env.APP_VERSION }}_debug.apk
- name: Debug release information
run: |
echo "Release tag: ${{ github.event.release.tag_name }}"
echo "Release name: ${{ github.event.release.name }}"
echo "Release URL: ${{ github.event.release.html_url }}"
echo "App version: ${{ env.APP_VERSION }}"
- name: Upload APKs to GitHub Release
run: |
gh release upload "${{ github.event.release.tag_name }}" "supplements_${{ env.APP_VERSION }}_android.apk" --clobber
gh release upload "${{ github.event.release.tag_name }}" "supplements_${{ env.APP_VERSION }}_debug.apk" --clobber
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

95
.github/workflows/ios-build.yml vendored Normal file
View File

@@ -0,0 +1,95 @@
# .github/workflows/ios-build-github.yml
name: Build iOS Release
on:
release:
types: [published]
jobs:
build:
name: Build and Upload iOS Release
runs-on: macos-14
if: github.repository_owner == 'vleeuwenmenno'
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Print Release Info
run: |
echo "Release Tag: ${{ github.event.release.tag_name }}"
echo "Release Name: ${{ github.event.release.name }}"
echo "GitHub Token exists: ${{ secrets.GITHUB_TOKEN != '' }}"
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
channel: "stable"
- name: Enable iOS support
run: flutter config --enable-ios
- name: Cache CocoaPods
uses: actions/cache@v4
with:
path: |
~/.cocoapods/repos
ios/Pods
key: ${{ runner.os }}-pods-${{ hashFiles('ios/Podfile.lock') }}
restore-keys: |
${{ runner.os }}-pods-
- name: Update CocoaPods repository
run: |
echo "Updating CocoaPods repository..."
pod repo update --silent
if [ $? -eq 0 ]; then
echo "CocoaPods repository updated successfully"
else
echo "Failed to update CocoaPods repository"
exit 1
fi
- name: Get dependencies
run: flutter pub get
- name: Extract version
id: version
run: |
APP_VERSION=$(grep 'version:' pubspec.yaml | sed 's/version: //g' | sed 's/+.*//g')
echo "APP_VERSION=$APP_VERSION" >> $GITHUB_ENV
echo "App version: $APP_VERSION"
- name: Set up Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable
# Updated build steps start here
- name: Build iOS app (unsigned)
run: |
flutter build ios --release --no-codesign
echo "Built .app location: $(pwd)/build/ios/iphoneos/Runner.app"
- name: Create unsigned IPA
run: |
mkdir Payload
cp -r build/ios/iphoneos/Runner.app Payload/
zip -r supplements_${{ env.APP_VERSION }}_ios_unsigned.ipa Payload
echo "Created unsigned IPA: $(pwd)/supplements_${{ env.APP_VERSION }}_ios_unsigned.ipa"
- name: Upload IPA to GitHub Release
run: |
gh release upload "${{ github.event.release.tag_name }}" \
"supplements_${{ env.APP_VERSION }}_ios_unsigned.ipa" \
--clobber
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: ios-unsigned-ipa
path: supplements_${{ env.APP_VERSION }}_ios_unsigned.ipa
retention-days: 30

184
.github/workflows/linux-build.yml vendored Normal file
View File

@@ -0,0 +1,184 @@
# .github/workflows/linux-build-github.yml
name: Build Linux Release
on:
release:
types: [published]
jobs:
build:
name: Build and Upload Linux Release
runs-on: ubuntu-22.04
if: github.repository_owner == 'vleeuwenmenno' && contains(github.server_url, 'github.com')
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
channel: "stable"
- name: Enable Linux desktop support
run: flutter config --enable-linux-desktop
- name: Install Linux dependencies
run: |
# Fix GPG key issues
sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 871920D1991BC93C
sudo apt-get update -y
# Install dependencies needed for Flutter Linux builds and appimage-builder
sudo apt-get install -y clang cmake git ninja-build pkg-config libgtk-3-dev liblzma-dev libstdc++-12-dev \
python3-pip python3-setuptools patchelf desktop-file-utils libgdk-pixbuf2.0-dev fakeroot strace pipx squashfs-tools zsync \
python3-venv python3-dev appstream
- name: Get dependencies
run: flutter pub get
- name: Build Linux release
run: flutter build linux --release
- name: Extract version
id: version
run: |
APP_VERSION=$(grep 'version:' pubspec.yaml | sed 's/version: //g' | sed 's/+.*//g')
echo "APP_VERSION=$APP_VERSION" >> $GITHUB_ENV
- name: Zip bundle directory
run: |
cd build/linux/x64/release
zip -r ../../../../supplements_${{ env.APP_VERSION }}_amd64_linux.zip bundle/*
cd ../../../../ # Go back to the root directory
- name: Download and extract AppImageTool
run: |
wget -O appimagetool-x86_64.AppImage https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage
chmod +x appimagetool-x86_64.AppImage
# Extract AppImageTool to avoid FUSE dependency issues in containers
./appimagetool-x86_64.AppImage --appimage-extract
# The extracted tool will be in squashfs-root/AppRun
- name: Create AppDir structure using Flutter bundle
run: |
# Verify Flutter build exists
echo "Verifying Flutter build..."
if [[ ! -d "build/linux/x64/release/bundle" ]]; then
echo "Error: Flutter build directory not found!"
ls -la build/linux/x64/release/ || echo "Release directory doesn't exist"
exit 1
fi
if [[ ! -f "build/linux/x64/release/bundle/supplements" ]]; then
echo "Error: supplements executable not found!"
ls -la build/linux/x64/release/bundle/
exit 1
fi
# Create AppDir and copy the entire Flutter bundle
echo "Creating AppDir with Flutter bundle..."
mkdir -p AppDir
cp -r build/linux/x64/release/bundle AppDir/
# Create required directories for AppImage structure
mkdir -p AppDir/usr/share/applications
mkdir -p AppDir/usr/share/icons/hicolor/64x64/apps
# Copy icon
echo "Copying application icon..."
if [[ -f "android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png" ]]; then
# Copy to standard location
cp android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png AppDir/usr/share/icons/hicolor/64x64/apps/
# Also copy to AppDir root with the name referenced in desktop file
cp android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png AppDir/ic_launcher.png
else
echo "Warning: Icon not found, trying other locations..."
ICON_FILE=$(find android/app/src/main/res/ -name "ic_launcher.png" | head -1)
if [[ -n "$ICON_FILE" ]]; then
cp "$ICON_FILE" AppDir/usr/share/icons/hicolor/64x64/apps/
cp "$ICON_FILE" AppDir/ic_launcher.png
else
echo "No icon found"
fi
fi
# Create desktop file (AppImageTool looks for it in the root of AppDir)
echo "Creating desktop file..."
cat > AppDir/supplements.desktop << 'EOF'
[Desktop Entry]
Name=supplements
Exec=AppRun
Icon=ic_launcher
Type=Application
Categories=Utility;
EOF
# Also create it in the standard location for completeness
cp AppDir/supplements.desktop AppDir/usr/share/applications/supplements.desktop
# Create AppRun script that uses the Flutter bundle directly
echo "Creating AppRun script..."
cat > AppDir/AppRun << 'EOF'
#!/bin/bash
HERE="$(dirname "$(readlink -f "${0}")")"
cd "${HERE}/bundle"
exec "./supplements" "$@"
EOF
chmod +x AppDir/AppRun
echo "AppDir structure created successfully!"
echo "AppDir contents:"
ls -la AppDir/
echo "Bundle contents:"
ls -la AppDir/bundle/
echo "Desktop file verification:"
ls -la AppDir/*.desktop
echo "Icon verification:"
ls -la AppDir/usr/share/icons/hicolor/64x64/apps/
- name: Build AppImage manually
run: |
# Verify AppDir structure before building
echo "Verifying AppDir structure..."
if [[ ! -f "AppDir/AppRun" ]]; then
echo "Error: AppRun not found!"
exit 1
fi
if [[ ! -f "AppDir/bundle/supplements" ]]; then
echo "Error: supplements executable not found in AppDir/bundle/!"
exit 1
fi
# Build AppImage using extracted appimagetool (avoids FUSE issues)
echo "Building AppImage..."
ARCH=amd64 ./squashfs-root/AppRun AppDir supplements-latest-amd64.AppImage
# Check if AppImage was created successfully
if [[ -f "supplements-latest-amd64.AppImage" ]]; then
echo "AppImage created successfully!"
ls -la supplements-latest-amd64.AppImage
else
echo "Error: AppImage creation failed!"
exit 1
fi
- name: Debug release information
run: |
echo "Release tag: ${{ github.event.release.tag_name }}"
echo "Release name: ${{ github.event.release.name }}"
echo "Release URL: ${{ github.event.release.html_url }}"
echo "App version: ${{ env.APP_VERSION }}"
- name: Upload Linux Zip to GitHub Release
run: |
gh release upload "${{ github.event.release.tag_name }}" "supplements_${{ env.APP_VERSION }}_amd64_linux.zip" --clobber
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload Linux AppImage to GitHub Release
run: |
gh release upload "${{ github.event.release.tag_name }}" "supplements-latest-amd64.AppImage" --clobber
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

206
.github/workflows/windows-build.yml vendored Normal file
View File

@@ -0,0 +1,206 @@
# .github/workflows/windows-build-github.yml
name: Build Windows Release
on:
release:
types: [published]
jobs:
build:
name: Build Windows Release
runs-on: windows-latest
if: github.repository_owner == 'vleeuwenmenno' && contains(github.server_url, 'github.com')
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
channel: "stable"
- name: Enable Windows desktop support
run: flutter config --enable-windows-desktop
- name: Install dependencies
run: flutter pub get
- name: Build Windows release
run: flutter build windows --release
- name: Extract version
id: version
shell: pwsh
run: |
$version = (Get-Content pubspec.yaml | Select-String "version:" | ForEach-Object { $_.ToString().Split(' ')[1] }).Split('+')[0]
echo "APP_VERSION=$version" >> $env:GITHUB_ENV
echo "Version extracted: $version"
- name: Create Windows installer directory structure
shell: pwsh
run: |
# Create installer directory
New-Item -ItemType Directory -Force -Path "installer"
# Copy the built app
Copy-Item -Recurse "build\windows\x64\runner\Release\*" "installer\"
# Verify the executable exists
if (Test-Path "installer\supplements.exe") {
Write-Host "✓ supplements.exe found in installer directory"
} else {
Write-Host "✗ supplements.exe not found!"
Get-ChildItem "installer" -Recurse
exit 1
}
- name: Create portable zip
shell: pwsh
run: |
Compress-Archive -Path "installer\*" -DestinationPath "supplements_${{ env.APP_VERSION }}_windows_x64_portable.zip"
# Verify zip was created
if (Test-Path "supplements_${{ env.APP_VERSION }}_windows_x64_portable.zip") {
$size = (Get-Item "supplements_${{ env.APP_VERSION }}_windows_x64_portable.zip").Length / 1MB
Write-Host "✓ Portable zip created successfully ($([math]::Round($size, 2)) MB)"
} else {
Write-Host "✗ Failed to create portable zip"
exit 1
}
- name: Create NSIS installer script
shell: pwsh
run: |
$nsisScript = @"
!define APP_NAME "supplements"
!define APP_VERSION "${{ env.APP_VERSION }}"
!define APP_PUBLISHER "vleeuwenmenno"
!define APP_DESCRIPTION "A ComfyUI client with sync capabilities"
!define APP_EXECUTABLE "supplements.exe"
!include "MUI2.nsh"
Name "`${APP_NAME} `${APP_VERSION}"
OutFile "supplements_`${APP_VERSION}_windows_x64_installer.exe"
InstallDir "`$PROGRAMFILES64\`${APP_NAME}"
InstallDirRegKey HKLM "Software\`${APP_NAME}" "InstallDir"
RequestExecutionLevel admin
!define MUI_ABORTWARNING
!define MUI_ICON "`${NSISDIR}\Contrib\Graphics\Icons\modern-install.ico"
!define MUI_UNICON "`${NSISDIR}\Contrib\Graphics\Icons\modern-uninstall.ico"
!insertmacro MUI_PAGE_WELCOME
!insertmacro MUI_PAGE_LICENSE "installer\LICENSE"
!insertmacro MUI_PAGE_DIRECTORY
!insertmacro MUI_PAGE_INSTFILES
!insertmacro MUI_PAGE_FINISH
!insertmacro MUI_UNPAGE_WELCOME
!insertmacro MUI_UNPAGE_CONFIRM
!insertmacro MUI_UNPAGE_INSTFILES
!insertmacro MUI_UNPAGE_FINISH
!insertmacro MUI_LANGUAGE "English"
Section "Install"
SetOutPath "`$INSTDIR"
File /r "installer\*"
WriteRegStr HKLM "Software\`${APP_NAME}" "InstallDir" "`$INSTDIR"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\`${APP_NAME}" "DisplayName" "`${APP_NAME}"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\`${APP_NAME}" "UninstallString" "`$INSTDIR\Uninstall.exe"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\`${APP_NAME}" "DisplayVersion" "`${APP_VERSION}"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\`${APP_NAME}" "Publisher" "`${APP_PUBLISHER}"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\`${APP_NAME}" "DisplayIcon" "`$INSTDIR\`${APP_EXECUTABLE}"
WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\`${APP_NAME}" "NoModify" 1
WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\`${APP_NAME}" "NoRepair" 1
CreateDirectory "`$SMPROGRAMS\`${APP_NAME}"
CreateShortcut "`$SMPROGRAMS\`${APP_NAME}\`${APP_NAME}.lnk" "`$INSTDIR\`${APP_EXECUTABLE}"
CreateShortcut "`$SMPROGRAMS\`${APP_NAME}\Uninstall.lnk" "`$INSTDIR\Uninstall.exe"
CreateShortcut "`$DESKTOP\`${APP_NAME}.lnk" "`$INSTDIR\`${APP_EXECUTABLE}"
WriteUninstaller "`$INSTDIR\Uninstall.exe"
SectionEnd
Section "Uninstall"
Delete "`$INSTDIR\Uninstall.exe"
RMDir /r "`$INSTDIR"
RMDir /r "`$SMPROGRAMS\`${APP_NAME}"
Delete "`$DESKTOP\`${APP_NAME}.lnk"
DeleteRegKey HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\`${APP_NAME}"
DeleteRegKey HKLM "Software\`${APP_NAME}"
SectionEnd
"@
$nsisScript | Out-File -FilePath "installer.nsi" -Encoding UTF8
Write-Host "✓ NSIS script created"
- name: Create dummy LICENSE file
shell: pwsh
run: |
$license = @"
MIT License
Copyright (c) 2025 vleeuwenmenno
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"@
$license | Out-File -FilePath "installer\LICENSE" -Encoding UTF8
Write-Host "✓ LICENSE file created"
- name: Build NSIS installer
uses: joncloud/makensis-action@v4.1
with:
script-file: "installer.nsi"
- name: Verify installer creation
shell: pwsh
run: |
# Verify installer was created
if (Test-Path "supplements_${{ env.APP_VERSION }}_windows_x64_installer.exe") {
$size = (Get-Item "supplements_${{ env.APP_VERSION }}_windows_x64_installer.exe").Length / 1MB
Write-Host "✓ NSIS installer created successfully ($([math]::Round($size, 2)) MB)"
} else {
Write-Host "✗ Failed to create NSIS installer"
Get-ChildItem . -Filter "*.exe" | ForEach-Object { Write-Host "Found: $($_.Name)" }
exit 1
}
- name: Debug release information
run: |
echo "Release tag: ${{ github.event.release.tag_name }}"
echo "Release name: ${{ github.event.release.name }}"
echo "Release URL: ${{ github.event.release.html_url }}"
echo "App version: ${{ env.APP_VERSION }}"
- name: Upload Windows builds to GitHub Release
shell: pwsh
run: |
gh release upload "${{ github.event.release.tag_name }}" "supplements_${{ env.APP_VERSION }}_windows_x64_portable.zip" --clobber
gh release upload "${{ github.event.release.tag_name }}" "supplements_${{ env.APP_VERSION }}_windows_x64_installer.exe" --clobber
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

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()