From fd69996c53712141262e50feb36e74a2b49536ed Mon Sep 17 00:00:00 2001 From: claudi Date: Tue, 10 Mar 2026 16:18:28 +0100 Subject: [PATCH] feat: Implement brand-aware release creation for Agravity - Added support for multiple brands in release scripts, allowing for branded artifacts. - Introduced brand configuration management with JSON files for each brand. - Created a new `brand_config.py` script to handle brand-specific logic and asset resolution. - Updated `create_release.ps1` and `create_release.sh` scripts to utilize brand configurations and generate release manifests. - Added unit tests for brand configuration loading and release manifest generation. - Introduced `agravity` brand with its specific configuration in `agravity.json`. --- build/WebDropBridge.wxs | 40 +++--- build/brands/agravity.json | 18 +++ build/scripts/brand_config.py | 236 ++++++++++++++++++++++++++++++ build/scripts/build_macos.sh | 25 +++- build/scripts/build_windows.py | 158 +++++++------------- build/scripts/create_release.ps1 | 239 +++++++------------------------ build/scripts/create_release.sh | 168 +++++++++------------- tests/unit/test_brand_config.py | 77 ++++++++++ 8 files changed, 552 insertions(+), 409 deletions(-) create mode 100644 build/brands/agravity.json create mode 100644 build/scripts/brand_config.py create mode 100644 tests/unit/test_brand_config.py diff --git a/build/WebDropBridge.wxs b/build/WebDropBridge.wxs index 6ce5b67..38af7a1 100644 --- a/build/WebDropBridge.wxs +++ b/build/WebDropBridge.wxs @@ -2,23 +2,23 @@ - + - + - + - - - + + + @@ -26,12 +26,12 @@ - + @@ -39,10 +39,10 @@ - + - + @@ -50,16 +50,16 @@ str: + return f"{self.asset_prefix}-{version}-win-x64.msi" + + def macos_installer_name(self, version: str) -> str: + return f"{self.asset_prefix}-{version}-macos-universal.dmg" + + @property + def app_bundle_name(self) -> str: + return f"{self.asset_prefix}.app" + + +DEFAULT_BRAND_VALUES: dict[str, Any] = { + "brand_id": "webdrop_bridge", + "display_name": "WebDrop Bridge", + "asset_prefix": "WebDropBridge", + "exe_name": "WebDropBridge", + "manufacturer": "HIM-Tools", + "install_dir_name": "WebDrop Bridge", + "shortcut_description": "Web Drag-and-Drop Bridge", + "bundle_identifier": "de.him_tools.webdrop-bridge", + "config_dir_name": "webdrop_bridge", + "msi_upgrade_code": "12345678-1234-1234-1234-123456789012", + "update_channel": "stable", + "icon_ico": "resources/icons/app.ico", + "icon_icns": "resources/icons/app.icns", + "dialog_bmp": "resources/icons/background.bmp", + "banner_bmp": "resources/icons/banner.bmp", + "license_rtf": "resources/license.rtf", +} + + +def project_root() -> Path: + return Path(__file__).resolve().parents[2] + + +def brands_dir(root: Path | None = None) -> Path: + base = root or project_root() + return base / "build" / "brands" + + +def load_brand_config( + brand: str | None = None, + *, + root: Path | None = None, + manifest_path: Path | None = None, +) -> BrandConfig: + """Load a brand manifest with defaults and asset fallbacks.""" + base = root or project_root() + values = dict(DEFAULT_BRAND_VALUES) + + if manifest_path is None and brand: + manifest_path = brands_dir(base) / f"{brand}.json" + + if manifest_path and manifest_path.exists(): + values.update(json.loads(manifest_path.read_text(encoding="utf-8"))) + elif manifest_path and not manifest_path.exists(): + raise FileNotFoundError(f"Brand manifest not found: {manifest_path}") + + def resolve_asset(key: str) -> Path: + candidate = base / str(values.get(key, DEFAULT_BRAND_VALUES[key])) + if candidate.exists(): + return candidate + return base / str(DEFAULT_BRAND_VALUES[key]) + + return BrandConfig( + brand_id=str(values["brand_id"]), + display_name=str(values["display_name"]), + asset_prefix=str(values["asset_prefix"]), + exe_name=str(values["exe_name"]), + manufacturer=str(values["manufacturer"]), + install_dir_name=str(values["install_dir_name"]), + shortcut_description=str(values["shortcut_description"]), + bundle_identifier=str(values["bundle_identifier"]), + config_dir_name=str(values["config_dir_name"]), + msi_upgrade_code=str(values["msi_upgrade_code"]), + update_channel=str(values.get("update_channel", "stable")), + icon_ico=resolve_asset("icon_ico"), + icon_icns=resolve_asset("icon_icns"), + dialog_bmp=resolve_asset("dialog_bmp"), + banner_bmp=resolve_asset("banner_bmp"), + license_rtf=resolve_asset("license_rtf"), + ) + + +def generate_release_manifest( + version: str, + brands: list[str], + *, + output_path: Path, + root: Path | None = None, +) -> Path: + """Generate a shared release-manifest.json from local build outputs.""" + base = root or project_root() + manifest: dict[str, Any] = { + "version": version, + "channel": "stable", + "brands": {}, + } + + for brand_name in brands: + brand = load_brand_config(brand_name, root=base) + manifest["channel"] = brand.update_channel + entries: dict[str, dict[str, str]] = {} + + windows_dir = base / "build" / "dist" / "windows" / brand.brand_id + windows_installer = windows_dir / brand.windows_installer_name(version) + windows_checksum = windows_dir / f"{windows_installer.name}.sha256" + if windows_installer.exists(): + entries["windows-x64"] = { + "installer": windows_installer.name, + "checksum": windows_checksum.name if windows_checksum.exists() else "", + } + + macos_dir = base / "build" / "dist" / "macos" / brand.brand_id + macos_installer = macos_dir / brand.macos_installer_name(version) + macos_checksum = macos_dir / f"{macos_installer.name}.sha256" + if macos_installer.exists(): + entries["macos-universal"] = { + "installer": macos_installer.name, + "checksum": macos_checksum.name if macos_checksum.exists() else "", + } + + if entries: + manifest["brands"][brand.brand_id] = entries + + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8") + return output_path + + +def cli_env(args: argparse.Namespace) -> int: + brand = load_brand_config(args.brand) + assignments = { + "WEBDROP_BRAND_ID": brand.brand_id, + "WEBDROP_APP_DISPLAY_NAME": brand.display_name, + "WEBDROP_ASSET_PREFIX": brand.asset_prefix, + "WEBDROP_EXE_NAME": brand.exe_name, + "WEBDROP_BUNDLE_ID": brand.bundle_identifier, + "WEBDROP_CONFIG_DIR_NAME": brand.config_dir_name, + "WEBDROP_ICON_ICO": str(brand.icon_ico), + "WEBDROP_ICON_ICNS": str(brand.icon_icns), + } + for key, value in assignments.items(): + print(f'export {key}="{value}"') + return 0 + + +def cli_manifest(args: argparse.Namespace) -> int: + output = generate_release_manifest( + args.version, + args.brands, + output_path=Path(args.output).resolve(), + ) + print(output) + return 0 + + +def cli_show(args: argparse.Namespace) -> int: + brand = load_brand_config(args.brand) + print( + json.dumps( + { + "brand_id": brand.brand_id, + "display_name": brand.display_name, + "asset_prefix": brand.asset_prefix, + "exe_name": brand.exe_name, + "manufacturer": brand.manufacturer, + "install_dir_name": brand.install_dir_name, + "shortcut_description": brand.shortcut_description, + "bundle_identifier": brand.bundle_identifier, + "config_dir_name": brand.config_dir_name, + "msi_upgrade_code": brand.msi_upgrade_code, + "update_channel": brand.update_channel, + }, + indent=2, + ) + ) + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description="Brand-aware build configuration") + subparsers = parser.add_subparsers(dest="command", required=True) + + env_parser = subparsers.add_parser("env") + env_parser.add_argument("--brand", required=True) + env_parser.set_defaults(func=cli_env) + + manifest_parser = subparsers.add_parser("release-manifest") + manifest_parser.add_argument("--version", required=True) + manifest_parser.add_argument("--output", required=True) + manifest_parser.add_argument("--brands", nargs="+", required=True) + manifest_parser.set_defaults(func=cli_manifest) + + show_parser = subparsers.add_parser("show") + show_parser.add_argument("--brand", required=True) + show_parser.set_defaults(func=cli_show) + + args = parser.parse_args() + return args.func(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/build/scripts/build_macos.sh b/build/scripts/build_macos.sh index 661df12..432f8fe 100644 --- a/build/scripts/build_macos.sh +++ b/build/scripts/build_macos.sh @@ -28,10 +28,13 @@ DIST_DIR="$BUILD_DIR/dist/macos" TEMP_BUILD="$BUILD_DIR/temp/macos" SPECS_DIR="$BUILD_DIR/specs" SPEC_FILE="$BUILD_DIR/webdrop_bridge.spec" +BRAND_HELPER="$BUILD_DIR/scripts/brand_config.py" +BRAND="" APP_NAME="WebDropBridge" DMG_VOLUME_NAME="WebDrop Bridge" -VERSION="1.0.0" +BUNDLE_IDENTIFIER="de.him_tools.webdrop-bridge" +VERSION="" # Default .env file ENV_FILE="$PROJECT_ROOT/.env" @@ -54,6 +57,10 @@ while [[ $# -gt 0 ]]; do ENV_FILE="$2" shift 2 ;; + --brand) + BRAND="$2" + shift 2 + ;; *) echo "Unknown option: $1" exit 1 @@ -70,6 +77,18 @@ fi echo "📋 Using configuration: $ENV_FILE" +if [ -n "$BRAND" ]; then + eval "$(python3 "$BRAND_HELPER" env --brand "$BRAND")" + APP_NAME="$WEBDROP_ASSET_PREFIX" + DMG_VOLUME_NAME="$WEBDROP_APP_DISPLAY_NAME" + BUNDLE_IDENTIFIER="$WEBDROP_BUNDLE_ID" + DIST_DIR="$BUILD_DIR/dist/macos/$WEBDROP_BRAND_ID" + TEMP_BUILD="$BUILD_DIR/temp/macos/$WEBDROP_BRAND_ID" + echo "🏷️ Building brand: $WEBDROP_APP_DISPLAY_NAME ($WEBDROP_BRAND_ID)" +fi + +VERSION="$(python3 -c "from pathlib import Path; import sys; sys.path.insert(0, str(Path(r'$BUILD_DIR/scripts').resolve())); from version_utils import get_current_version; print(get_current_version())")" + # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' @@ -178,6 +197,8 @@ build_executable() { # Export env file for spec file to pick up export WEBDROP_ENV_FILE="$ENV_FILE" + export WEBDROP_VERSION="$VERSION" + export WEBDROP_BUNDLE_ID="$BUNDLE_IDENTIFIER" python3 -m PyInstaller \ --distpath="$DIST_DIR" \ @@ -252,6 +273,8 @@ create_dmg() { SIZE=$(du -h "$DMG_FILE" | cut -f1) log_success "DMG created successfully" log_info "Output: $DMG_FILE (Size: $SIZE)" + shasum -a 256 "$DMG_FILE" | awk '{print $1}' > "$DMG_FILE.sha256" + log_info "Checksum: $DMG_FILE.sha256" echo "" } diff --git a/build/scripts/build_windows.py b/build/scripts/build_windows.py index 42d68fa..a507531 100644 --- a/build/scripts/build_windows.py +++ b/build/scripts/build_windows.py @@ -39,13 +39,14 @@ from pathlib import Path from datetime import datetime # Import shared version utilities +from brand_config import load_brand_config from sync_version import get_current_version, do_sync_version class WindowsBuilder: """Build Windows installer using PyInstaller.""" - def __init__(self, env_file: Path | None = None): + def __init__(self, env_file: Path | None = None, brand: str | None = None): """Initialize builder paths. Args: @@ -53,10 +54,12 @@ class WindowsBuilder: If that doesn't exist, raises error. """ self.project_root = Path(__file__).parent.parent.parent + self.brand = load_brand_config(brand, root=self.project_root) self.build_dir = self.project_root / "build" - self.dist_dir = self.build_dir / "dist" / "windows" - self.temp_dir = self.build_dir / "temp" / "windows" + self.dist_dir = self.build_dir / "dist" / "windows" / self.brand.brand_id + self.temp_dir = self.build_dir / "temp" / "windows" / self.brand.brand_id self.spec_file = self.build_dir / "webdrop_bridge.spec" + self.wix_template = self.build_dir / "WebDropBridge.wxs" self.version = get_current_version() # Validate and set env file @@ -74,6 +77,7 @@ class WindowsBuilder: self.env_file = env_file print(f"📋 Using configuration: {self.env_file}") + print(f"🏷️ Building brand: {self.brand.display_name} ({self.brand.brand_id})") def _get_version(self) -> str: """Get version from __init__.py. @@ -116,6 +120,15 @@ class WindowsBuilder: # Set environment variable for spec file to use env = os.environ.copy() env["WEBDROP_ENV_FILE"] = str(self.env_file) + env["WEBDROP_BRAND_ID"] = self.brand.brand_id + env["WEBDROP_APP_DISPLAY_NAME"] = self.brand.display_name + env["WEBDROP_ASSET_PREFIX"] = self.brand.asset_prefix + env["WEBDROP_EXE_NAME"] = self.brand.exe_name + env["WEBDROP_BUNDLE_ID"] = self.brand.bundle_identifier + env["WEBDROP_CONFIG_DIR_NAME"] = self.brand.config_dir_name + env["WEBDROP_ICON_ICO"] = str(self.brand.icon_ico) + env["WEBDROP_ICON_ICNS"] = str(self.brand.icon_icns) + env["WEBDROP_VERSION"] = self.version result = subprocess.run(cmd, cwd=str(self.project_root), text=True, env=env) @@ -123,8 +136,8 @@ class WindowsBuilder: print("❌ PyInstaller build failed") return False - # Check if executable exists (now in WebDropBridge/WebDropBridge.exe due to COLLECT) - exe_path = self.dist_dir / "WebDropBridge" / "WebDropBridge.exe" + # Check if executable exists (inside the COLLECT directory) + exe_path = self.dist_dir / self.brand.exe_name / f"{self.brand.exe_name}.exe" if not exe_path.exists(): print(f"❌ Executable not found at {exe_path}") return False @@ -134,7 +147,9 @@ class WindowsBuilder: # Calculate total dist size total_size = sum( - f.stat().st_size for f in self.dist_dir.glob("WebDropBridge/**/*") if f.is_file() + f.stat().st_size + for f in self.dist_dir.glob(f"{self.brand.exe_name}/**/*") + if f.is_file() ) if total_size > 0: print(f" Total size: {total_size / 1024 / 1024:.1f} MB") @@ -249,7 +264,7 @@ class WindowsBuilder: # Harvest application files using Heat print(f" Harvesting application files...") - dist_folder = self.dist_dir / "WebDropBridge" + dist_folder = self.dist_dir / self.brand.exe_name if not dist_folder.exists(): print(f"❌ Distribution folder not found: {dist_folder}") return False @@ -291,7 +306,7 @@ class WindowsBuilder: # Compile both WiX files wix_obj = self.build_dir / "WebDropBridge.wixobj" wix_files_obj = self.build_dir / "WebDropBridge_Files.wixobj" - msi_output = self.dist_dir / f"WebDropBridge-{self.version}-Setup.msi" + msi_output = self.dist_dir / self.brand.windows_installer_name(self.version) # Run candle compiler - make sure to use correct source directory candle_cmd = [ @@ -301,11 +316,11 @@ class WindowsBuilder: "-ext", "WixUtilExtension", f"-dDistDir={self.dist_dir}", - f"-dSourceDir={self.dist_dir}\\WebDropBridge", # Set SourceDir for Heat-generated files + f"-dSourceDir={self.dist_dir}\{self.brand.exe_name}", # Set SourceDir for Heat-generated files f"-dResourcesDir={self.project_root}\\resources", # Set ResourcesDir for branding assets "-o", str(self.build_dir) + "\\", - str(self.build_dir / "WebDropBridge.wxs"), + str(self.build_dir / "WebDropBridge.generated.wxs"), ] if harvest_file.exists(): @@ -325,7 +340,7 @@ class WindowsBuilder: "-ext", "WixUtilExtension", "-b", - str(self.dist_dir / "WebDropBridge"), # Base path for source files + str(self.dist_dir / self.brand.exe_name), # Base path for source files "-o", str(msi_output), str(wix_obj), @@ -353,6 +368,7 @@ class WindowsBuilder: print("✅ MSI installer created successfully") print(f"📦 Output: {msi_output}") print(f" Size: {msi_output.stat().st_size / 1024 / 1024:.1f} MB") + self.generate_checksum(msi_output) return True @@ -363,7 +379,7 @@ class WindowsBuilder: even if a previous PyInstaller run omitted them. """ src_icons_dir = self.project_root / "resources" / "icons" - bundle_icons_dir = self.dist_dir / "WebDropBridge" / "_internal" / "resources" / "icons" + bundle_icons_dir = self.dist_dir / self.brand.exe_name / "_internal" / "resources" / "icons" required_icons = ["home.ico", "reload.ico", "open.ico", "openwith.ico"] try: @@ -392,97 +408,23 @@ class WindowsBuilder: Creates per-machine installation (Program Files). Installation requires admin rights, but the app does not. """ - wix_content = f""" - - + wix_template = self.wix_template.read_text(encoding="utf-8") + wix_content = wix_template.format( + product_name=self.brand.display_name, + version=self.version, + manufacturer=self.brand.manufacturer, + upgrade_code=self.brand.msi_upgrade_code, + asset_prefix=self.brand.asset_prefix, + icon_ico=str(self.brand.icon_ico), + dialog_bmp=str(self.brand.dialog_bmp), + banner_bmp=str(self.brand.banner_bmp), + license_rtf=str(self.brand.license_rtf), + exe_name=self.brand.exe_name, + install_dir_name=self.brand.install_dir_name, + shortcut_description=self.brand.shortcut_description, + ) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -""" - - wix_file = self.build_dir / "WebDropBridge.wxs" + wix_file = self.build_dir / "WebDropBridge.generated.wxs" wix_file.write_text(wix_content) print(f" Created WiX source: {wix_file}") return True @@ -573,7 +515,7 @@ class WindowsBuilder: print(" Skipping code signing") return True - exe_path = self.dist_dir / "WebDropBridge.exe" + exe_path = self.dist_dir / self.brand.exe_name / f"{self.brand.exe_name}.exe" cmd = [ signtool, "sign", @@ -606,7 +548,7 @@ class WindowsBuilder: """ start_time = datetime.now() print("=" * 60) - print("🚀 WebDrop Bridge Windows Build") + print(f"🚀 {self.brand.display_name} Windows Build") print("=" * 60) self.clean() @@ -650,6 +592,12 @@ def main() -> int: default=None, help="Path to .env file to bundle (default: project root .env)", ) + parser.add_argument( + "--brand", + type=str, + default=None, + help="Brand manifest name from build/brands (e.g. agravity)", + ) args = parser.parse_args() @@ -657,7 +605,7 @@ def main() -> int: do_sync_version() try: - builder = WindowsBuilder(env_file=args.env_file) + builder = WindowsBuilder(env_file=args.env_file, brand=args.brand) except FileNotFoundError as e: print(f"❌ Build failed: {e}") return 1 diff --git a/build/scripts/create_release.ps1 b/build/scripts/create_release.ps1 index e23159b..82702e0 100644 --- a/build/scripts/create_release.ps1 +++ b/build/scripts/create_release.ps1 @@ -1,70 +1,36 @@ -# Create Forgejo Release with Binary Assets -# Usage: .\create_release.ps1 [-Version 1.0.0] -# If -Version is not provided, it will be read from src/webdrop_bridge/__init__.py -# Uses your Forgejo credentials (same as git) -# First run will prompt for credentials and save them to this session - param( - [Parameter(Mandatory=$false)] + [Parameter(Mandatory = $false)] [string]$Version, - - [Parameter(Mandatory=$false)] + + [Parameter(Mandatory = $false)] + [string[]]$Brands = @("agravity"), + + [Parameter(Mandatory = $false)] [string]$ForgejoUser, - - [Parameter(Mandatory=$false)] + + [Parameter(Mandatory = $false)] [string]$ForgejoPW, - + [switch]$ClearCredentials, - - [switch]$SkipExe, - + [string]$ForgejoUrl = "https://git.him-tools.de", - [string]$Repo = "HIM-public/webdrop-bridge", - [string]$ExePath = "build\dist\windows\WebDropBridge\WebDropBridge.exe", - [string]$ChecksumPath = "build\dist\windows\WebDropBridge\WebDropBridge.exe.sha256" + [string]$Repo = "HIM-public/webdrop-bridge" ) $ErrorActionPreference = "Stop" - -# Get project root (PSScriptRoot is build/scripts, go up to project root with ..\..) $projectRoot = Resolve-Path (Join-Path $PSScriptRoot "..\..") - -# Resolve file paths relative to project root -$ExePath = Join-Path $projectRoot $ExePath -$ChecksumPath = Join-Path $projectRoot $ChecksumPath -$MsiPath = Join-Path $projectRoot $MsiPath - -# Function to read version from .env or .env.example -function Get-VersionFromEnv { - # Use already resolved project root - - # Try .env first (runtime config), then .env.example (template) - $envFile = Join-Path $projectRoot ".env" - $envExampleFile = Join-Path $projectRoot ".env.example" - - # Check .env first - if (Test-Path $envFile) { - $content = Get-Content $envFile -Raw - if ($content -match 'APP_VERSION=([^\r\n]+)') { - Write-Host "Version read from .env" -ForegroundColor Gray - return $matches[1].Trim() - } - } - - # Fall back to .env.example - if (Test-Path $envExampleFile) { - $content = Get-Content $envExampleFile -Raw - if ($content -match 'APP_VERSION=([^\r\n]+)') { - Write-Host "Version read from .env.example" -ForegroundColor Gray - return $matches[1].Trim() - } - } - - Write-Host "ERROR: Could not find APP_VERSION in .env or .env.example" -ForegroundColor Red - exit 1 +$pythonExe = Join-Path $projectRoot ".venv\Scripts\python.exe" +if (-not (Test-Path $pythonExe)) { + $pythonExe = "python" +} + +$brandHelper = Join-Path $projectRoot "build\scripts\brand_config.py" +$manifestOutput = Join-Path $projectRoot "build\dist\release-manifest.json" + +function Get-CurrentVersion { + return (& $pythonExe -c "from pathlib import Path; import sys; sys.path.insert(0, str(Path(r'$projectRoot/build/scripts').resolve())); from version_utils import get_current_version; print(get_current_version())").Trim() } -# Handle --ClearCredentials flag if ($ClearCredentials) { Remove-Item env:FORGEJO_USER -ErrorAction SilentlyContinue Remove-Item env:FORGEJO_PASS -ErrorAction SilentlyContinue @@ -72,190 +38,95 @@ if ($ClearCredentials) { exit 0 } -# Get credentials from sources (in order of priority) if (-not $ForgejoUser) { $ForgejoUser = $env:FORGEJO_USER } - if (-not $ForgejoPW) { $ForgejoPW = $env:FORGEJO_PASS } -# If still no credentials, prompt user interactively if (-not $ForgejoUser -or -not $ForgejoPW) { Write-Host "Forgejo credentials not found. Enter your credentials:" -ForegroundColor Yellow - if (-not $ForgejoUser) { $ForgejoUser = Read-Host "Username" } - if (-not $ForgejoPW) { $securePass = Read-Host "Password" -AsSecureString $ForgejoPW = [Runtime.InteropServices.Marshal]::PtrToStringAuto([Runtime.InteropServices.Marshal]::SecureStringToCoTaskMemUnicode($securePass)) } - - # Save credentials to environment for this session $env:FORGEJO_USER = $ForgejoUser $env:FORGEJO_PASS = $ForgejoPW - Write-Host "[OK] Credentials saved to this PowerShell session" -ForegroundColor Green - Write-Host "Tip: Credentials will persist until you close PowerShell or run: .\create_release.ps1 -ClearCredentials" -ForegroundColor Gray } -# Verify Version parameter - if not provided, read from .env.example if (-not $Version) { - Write-Host "Version not provided, reading from .env.example..." -ForegroundColor Cyan - $Version = Get-VersionFromEnv - Write-Host "Using version: $Version" -ForegroundColor Green + $Version = Get-CurrentVersion } -# Define MSI path with resolved version -$MsiPath = Join-Path $projectRoot "build\dist\windows\WebDropBridge-$Version-Setup.msi" +$artifactPaths = New-Object System.Collections.Generic.List[string] +foreach ($brand in $Brands) { + $brandJson = & $pythonExe $brandHelper show --brand $brand | ConvertFrom-Json + $msiPath = Join-Path $projectRoot "build\dist\windows\$($brandJson.brand_id)\$($brandJson.asset_prefix)-$Version-win-x64.msi" + $checksumPath = "$msiPath.sha256" -# Verify files exist (exe/checksum optional, MSI required) -if (-not $SkipExe) { - if (-not (Test-Path $ExePath)) { - Write-Host "WARNING: Executable not found at $ExePath" -ForegroundColor Yellow - Write-Host " Use -SkipExe flag to skip exe upload" -ForegroundColor Gray - $SkipExe = $true - } - - if (-not $SkipExe -and -not (Test-Path $ChecksumPath)) { - Write-Host "WARNING: Checksum file not found at $ChecksumPath" -ForegroundColor Yellow - Write-Host " Exe will not be uploaded" -ForegroundColor Gray - $SkipExe = $true + if (Test-Path $msiPath) { + $artifactPaths.Add($msiPath) + if (Test-Path $checksumPath) { + $artifactPaths.Add($checksumPath) + } + $msiSize = (Get-Item $msiPath).Length / 1MB + Write-Host "Windows artifact: $([System.IO.Path]::GetFileName($msiPath)) ($([math]::Round($msiSize, 2)) MB)" } } -# MSI is the primary release artifact -if (-not (Test-Path $MsiPath)) { - Write-Host "ERROR: MSI installer not found at $MsiPath" -ForegroundColor Red - Write-Host "Please build with MSI support:" -ForegroundColor Yellow - Write-Host " python build\scripts\build_windows.py --msi" -ForegroundColor Cyan +& $pythonExe $brandHelper release-manifest --version $Version --output $manifestOutput --brands $Brands | Out-Null +if (Test-Path $manifestOutput) { + $artifactPaths.Add($manifestOutput) +} + +if ($artifactPaths.Count -eq 0) { + Write-Host "ERROR: No Windows artifacts found for the requested brands" -ForegroundColor Red exit 1 } -Write-Host "Creating WebDropBridge $Version release on Forgejo..." -ForegroundColor Cyan - -# Get file info -$msiSize = (Get-Item $MsiPath).Length / 1MB -Write-Host "Primary Artifact: WebDropBridge-$Version-Setup.msi ($([math]::Round($msiSize, 2)) MB)" - -if (-not $SkipExe) { - $exeSize = (Get-Item $ExePath).Length / 1MB - $checksum = Get-Content $ChecksumPath -Raw - Write-Host "Optional Artifact: WebDropBridge.exe ($([math]::Round($exeSize, 2)) MB)" - Write-Host " Checksum: $($checksum.Substring(0, 16))..." -} - -# Create basic auth header $auth = [System.Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes("${ForgejoUser}:${ForgejoPW}")) - $headers = @{ "Authorization" = "Basic $auth" "Content-Type" = "application/json" } -# Step 1: Create release -Write-Host "`nCreating release v$Version..." -ForegroundColor Yellow +$releaseLookupUrl = "$ForgejoUrl/api/v1/repos/$Repo/releases/tags/v$Version" $releaseUrl = "$ForgejoUrl/api/v1/repos/$Repo/releases" - -# Build release body with checksum info if exe is being uploaded -$releaseBody = "WebDropBridge v$Version`n`n**Release Artifacts:**`n- MSI Installer (Windows Setup)`n" -if (-not $SkipExe) { - $checksum = Get-Content $ChecksumPath -Raw - $releaseBody += "- Portable Executable`n`n**Checksum:**`n$checksum`n" -} - $releaseData = @{ tag_name = "v$Version" name = "WebDropBridge v$Version" - body = $releaseBody + body = "Shared branded release for WebDrop Bridge v$Version" draft = $false prerelease = $false } | ConvertTo-Json try { - $response = Invoke-WebRequest -Uri $releaseUrl ` - -Method POST ` - -Headers $headers ` - -Body $releaseData ` - -TimeoutSec 30 ` - -UseBasicParsing ` - -ErrorAction Stop - + $lookupResponse = Invoke-WebRequest -Uri $releaseLookupUrl -Method GET -Headers $headers -TimeoutSec 30 -UseBasicParsing -ErrorAction Stop + $releaseInfo = $lookupResponse.Content | ConvertFrom-Json + $releaseId = $releaseInfo.id + Write-Host "[OK] Using existing release (ID: $releaseId)" -ForegroundColor Green +} +catch { + $response = Invoke-WebRequest -Uri $releaseUrl -Method POST -Headers $headers -Body $releaseData -TimeoutSec 30 -UseBasicParsing -ErrorAction Stop $releaseInfo = $response.Content | ConvertFrom-Json $releaseId = $releaseInfo.id Write-Host "[OK] Release created (ID: $releaseId)" -ForegroundColor Green } -catch { - Write-Host "ERROR creating release: $_" -ForegroundColor Red - exit 1 -} -# Setup curl authentication $curlAuth = "$ForgejoUser`:$ForgejoPW" $uploadUrl = "$ForgejoUrl/api/v1/repos/$Repo/releases/$releaseId/assets" -# Step 2: Upload MSI installer as primary artifact -Write-Host "`nUploading MSI installer (primary artifact)..." -ForegroundColor Yellow - -try { - $response = curl.exe -s -X POST ` - -u $curlAuth ` - -F "attachment=@$MsiPath" ` - $uploadUrl - +foreach ($artifact in $artifactPaths) { + $response = curl.exe -s -X POST -u $curlAuth -F "attachment=@$artifact" $uploadUrl if ($response -like "*error*" -or $response -like "*404*") { - Write-Host "ERROR uploading MSI: $response" -ForegroundColor Red - exit 1 + Write-Host "WARNING: Could not upload $artifact : $response" -ForegroundColor Yellow } - - Write-Host "[OK] MSI installer uploaded" -ForegroundColor Green -} -catch { - Write-Host "ERROR uploading MSI: $_" -ForegroundColor Red - exit 1 -} - -# Step 3: Upload executable as optional artifact (if available) -if (-not $SkipExe) { - Write-Host "`nUploading executable (optional portable version)..." -ForegroundColor Yellow - - try { - $response = curl.exe -s -X POST ` - -u $curlAuth ` - -F "attachment=@$ExePath" ` - $uploadUrl - - if ($response -like "*error*" -or $response -like "*404*") { - Write-Host "WARNING: Could not upload executable: $response" -ForegroundColor Yellow - } - else { - Write-Host "[OK] Executable uploaded" -ForegroundColor Green - } - } - catch { - Write-Host "WARNING: Could not upload executable: $_" -ForegroundColor Yellow - } - - # Step 4: Upload checksum as asset - Write-Host "Uploading checksum..." -ForegroundColor Yellow - - try { - $response = curl.exe -s -X POST ` - -u $curlAuth ` - -F "attachment=@$ChecksumPath" ` - $uploadUrl - - if ($response -like "*error*" -or $response -like "*404*") { - Write-Host "WARNING: Could not upload checksum: $response" -ForegroundColor Yellow - } - else { - Write-Host "[OK] Checksum uploaded" -ForegroundColor Green - } - } - catch { - Write-Host "WARNING: Could not upload checksum: $_" -ForegroundColor Yellow + else { + Write-Host "[OK] Uploaded $([System.IO.Path]::GetFileName($artifact))" -ForegroundColor Green } } diff --git a/build/scripts/create_release.sh b/build/scripts/create_release.sh index 1c1838f..ea71bd1 100644 --- a/build/scripts/create_release.sh +++ b/build/scripts/create_release.sh @@ -1,31 +1,33 @@ #!/bin/bash -# Create Forgejo Release with Binary Assets -# Usage: ./create_release.sh -v 1.0.0 -# Uses your Forgejo credentials (same as git) -# First run will prompt for credentials and save them to this session +# Create or update a shared Forgejo release with branded macOS assets. set -e -# Parse arguments VERSION="" -FORGEJO_USER="" -FORGEJO_PASS="" +BRANDS=("agravity") +FORGEJO_USER="${FORGEJO_USER}" +FORGEJO_PASS="${FORGEJO_PASS}" FORGEJO_URL="https://git.him-tools.de" REPO="HIM-public/webdrop-bridge" -DMG_PATH="build/dist/macos/WebDropBridge.dmg" -CHECKSUM_PATH="build/dist/macos/WebDropBridge.dmg.sha256" CLEAR_CREDS=false +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +BRAND_HELPER="$PROJECT_ROOT/build/scripts/brand_config.py" +MANIFEST_OUTPUT="$PROJECT_ROOT/build/dist/release-manifest.json" while [[ $# -gt 0 ]]; do case $1 in - -v|--version) VERSION="$2"; shift 2;; - -u|--url) FORGEJO_URL="$2"; shift 2;; - --clear-credentials) CLEAR_CREDS=true; shift;; - *) echo "Unknown option: $1"; exit 1;; + -v|--version) VERSION="$2"; shift 2 ;; + -u|--url) FORGEJO_URL="$2"; shift 2 ;; + --brand) BRANDS+=("$2"); shift 2 ;; + --clear-credentials) CLEAR_CREDS=true; shift ;; + *) echo "Unknown option: $1"; exit 1 ;; esac done -# Handle --clear-credentials flag +if [ ${#BRANDS[@]} -gt 1 ] && [ "${BRANDS[0]}" = "agravity" ]; then + BRANDS=("${BRANDS[@]:1}") +fi + if [ "$CLEAR_CREDS" = true ]; then unset FORGEJO_USER unset FORGEJO_PASS @@ -33,127 +35,95 @@ if [ "$CLEAR_CREDS" = true ]; then exit 0 fi -# Load credentials from environment -FORGEJO_USER="${FORGEJO_USER}" -FORGEJO_PASS="${FORGEJO_PASS}" - -# Verify required parameters if [ -z "$VERSION" ]; then - echo "ERROR: Version parameter required" >&2 - echo "Usage: $0 -v VERSION [-u FORGEJO_URL]" >&2 - echo "Example: $0 -v 1.0.0" >&2 - exit 1 + VERSION="$(python3 -c "from pathlib import Path; import sys; sys.path.insert(0, str(Path(r'$PROJECT_ROOT/build/scripts').resolve())); from version_utils import get_current_version; print(get_current_version())")" fi -# If no credentials, prompt user interactively if [ -z "$FORGEJO_USER" ] || [ -z "$FORGEJO_PASS" ]; then echo "Forgejo credentials not found. Enter your credentials:" - if [ -z "$FORGEJO_USER" ]; then - read -p "Username: " FORGEJO_USER + read -r -p "Username: " FORGEJO_USER fi - if [ -z "$FORGEJO_PASS" ]; then - read -sp "Password: " FORGEJO_PASS + read -r -s -p "Password: " FORGEJO_PASS echo "" fi - - # Export for this session export FORGEJO_USER export FORGEJO_PASS - echo "[OK] Credentials saved to this shell session" - echo "Tip: Credentials will persist until you close the terminal or run: $0 --clear-credentials" fi -# Verify files exist -if [ ! -f "$DMG_PATH" ]; then - echo "ERROR: DMG file not found at $DMG_PATH" +ARTIFACTS=() +for BRAND in "${BRANDS[@]}"; do + BRAND_JSON=$(python3 "$BRAND_HELPER" show --brand "$BRAND") + BRAND_ID=$(printf '%s' "$BRAND_JSON" | python3 -c 'import json,sys; print(json.load(sys.stdin)["brand_id"])') + ASSET_PREFIX=$(printf '%s' "$BRAND_JSON" | python3 -c 'import json,sys; print(json.load(sys.stdin)["asset_prefix"])') + DMG_PATH="$PROJECT_ROOT/build/dist/macos/$BRAND_ID/${ASSET_PREFIX}-${VERSION}-macos-universal.dmg" + CHECKSUM_PATH="$DMG_PATH.sha256" + + if [ -f "$DMG_PATH" ]; then + ARTIFACTS+=("$DMG_PATH") + [ -f "$CHECKSUM_PATH" ] && ARTIFACTS+=("$CHECKSUM_PATH") + DMG_SIZE=$(du -m "$DMG_PATH" | cut -f1) + echo "macOS artifact: $(basename "$DMG_PATH") ($DMG_SIZE MB)" + fi +done + +python3 "$BRAND_HELPER" release-manifest --version "$VERSION" --output "$MANIFEST_OUTPUT" --brands "${BRANDS[@]}" >/dev/null +[ -f "$MANIFEST_OUTPUT" ] && ARTIFACTS+=("$MANIFEST_OUTPUT") + +if [ ${#ARTIFACTS[@]} -eq 0 ]; then + echo "ERROR: No macOS artifacts found" exit 1 fi -if [ ! -f "$CHECKSUM_PATH" ]; then - echo "ERROR: Checksum file not found at $CHECKSUM_PATH" - exit 1 -fi - -echo "Creating WebDropBridge $VERSION release on Forgejo..." - -# Get file info -DMG_SIZE=$(du -m "$DMG_PATH" | cut -f1) -CHECKSUM=$(cat "$CHECKSUM_PATH") - -echo "File: WebDropBridge.dmg ($DMG_SIZE MB)" -echo "Checksum: ${CHECKSUM:0:16}..." - -# Create basic auth BASIC_AUTH=$(echo -n "${FORGEJO_USER}:${FORGEJO_PASS}" | base64) - -# Step 1: Create release -echo "" -echo "Creating release v$VERSION..." RELEASE_URL="$FORGEJO_URL/api/v1/repos/$REPO/releases" +RELEASE_LOOKUP_URL="$FORGEJO_URL/api/v1/repos/$REPO/releases/tags/v$VERSION" -RELEASE_DATA=$(cat <