From fe1acd866904935ed5caf3acbaf62c9e294effc7 Mon Sep 17 00:00:00 2001 From: Menno van Leeuwen Date: Wed, 27 Aug 2025 11:06:04 +0200 Subject: [PATCH] feat: Add GitHub Actions workflows for building and releasing Android, iOS, Linux, and Windows applications --- .github/workflows/android-build.yml | 69 ++++ .github/workflows/ios-build.yml | 95 +++++ .github/workflows/linux-build.yml | 184 +++++++++ .github/workflows/windows-build.yml | 206 ++++++++++ bin/release.py | 573 ++++++++++++++++++++++++++++ 5 files changed, 1127 insertions(+) create mode 100644 .github/workflows/android-build.yml create mode 100644 .github/workflows/ios-build.yml create mode 100644 .github/workflows/linux-build.yml create mode 100644 .github/workflows/windows-build.yml create mode 100755 bin/release.py diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml new file mode 100644 index 0000000..bc9506e --- /dev/null +++ b/.github/workflows/android-build.yml @@ -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 }} diff --git a/.github/workflows/ios-build.yml b/.github/workflows/ios-build.yml new file mode 100644 index 0000000..8d3cd71 --- /dev/null +++ b/.github/workflows/ios-build.yml @@ -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 diff --git a/.github/workflows/linux-build.yml b/.github/workflows/linux-build.yml new file mode 100644 index 0000000..081eeac --- /dev/null +++ b/.github/workflows/linux-build.yml @@ -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 }} diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml new file mode 100644 index 0000000..55abe91 --- /dev/null +++ b/.github/workflows/windows-build.yml @@ -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 }} diff --git a/bin/release.py b/bin/release.py new file mode 100755 index 0000000..778ef60 --- /dev/null +++ b/bin/release.py @@ -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()