diff --git a/README.md b/README.md index a9daf8a..9388501 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,8 @@ wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.0 For more installation options and details, see [QUICKSTART.md](QUICKSTART.md#installing-from-release-wget) and [PACKAGE_MANAGER_SUPPORT.md](docs/PACKAGE_MANAGER_SUPPORT.md) +For multi-brand packaging and release workflows, see [BRANDING_AND_RELEASES.md](docs/BRANDING_AND_RELEASES.md). + ### Installation from Source ```bash diff --git a/build/brands/template.jsonc b/build/brands/template.jsonc new file mode 100644 index 0000000..b089411 --- /dev/null +++ b/build/brands/template.jsonc @@ -0,0 +1,20 @@ +{ + // Copy this file to build/brands/.json (without comments) + // and replace values. + "brand_id": "your_brand_id", + "display_name": "Your Brand Bridge", + "asset_prefix": "YourBrandBridge", + "exe_name": "YourBrandBridge", + "manufacturer": "Your Company", + "install_dir_name": "Your Brand Bridge", + "shortcut_description": "Your brand drag-and-drop bridge", + "bundle_identifier": "com.yourcompany.bridge", + "config_dir_name": "your_brand_bridge", + "msi_upgrade_code": "00000000-0000-0000-0000-000000000000", + "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" +} \ No newline at end of file diff --git a/build/scripts/brand_config.py b/build/scripts/brand_config.py index ee20a00..8ccc4da 100644 --- a/build/scripts/brand_config.py +++ b/build/scripts/brand_config.py @@ -60,6 +60,8 @@ DEFAULT_BRAND_VALUES: dict[str, Any] = { "license_rtf": "resources/license.rtf", } +DEFAULT_BRAND_ID = str(DEFAULT_BRAND_VALUES["brand_id"]) + def project_root() -> Path: return Path(__file__).resolve().parents[2] @@ -70,6 +72,18 @@ def brands_dir(root: Path | None = None) -> Path: return base / "build" / "brands" +def available_brand_names(root: Path | None = None) -> list[str]: + """Return all supported brand names, including the default build.""" + base = root or project_root() + names = [DEFAULT_BRAND_ID] + manifest_dir = brands_dir(base) + if manifest_dir.exists(): + for manifest in sorted(manifest_dir.glob("*.json")): + if manifest.stem not in names: + names.append(manifest.stem) + return names + + def load_brand_config( brand: str | None = None, *, @@ -80,7 +94,7 @@ def load_brand_config( base = root or project_root() values = dict(DEFAULT_BRAND_VALUES) - if manifest_path is None and brand: + if manifest_path is None and brand and brand != DEFAULT_BRAND_ID: manifest_path = brands_dir(base) / f"{brand}.json" if manifest_path and manifest_path.exists(): @@ -160,6 +174,92 @@ def generate_release_manifest( return output_path +def merge_release_manifests( + base_manifest: dict[str, Any], overlay_manifest: dict[str, Any] +) -> dict[str, Any]: + """Merge two release manifests, preserving previously uploaded platforms.""" + merged: dict[str, Any] = { + "version": overlay_manifest.get("version") or base_manifest.get("version", ""), + "channel": overlay_manifest.get("channel") or base_manifest.get("channel", "stable"), + "brands": dict(base_manifest.get("brands", {})), + } + + for brand_id, entries in overlay_manifest.get("brands", {}).items(): + brand_entry = dict(merged["brands"].get(brand_id, {})) + for platform_key, platform_value in entries.items(): + if platform_value: + brand_entry[platform_key] = platform_value + merged["brands"][brand_id] = brand_entry + + return merged + + +def collect_local_release_data( + version: str, + *, + platform: str, + root: Path | None = None, + brands: list[str] | None = None, +) -> dict[str, Any]: + """Collect local artifacts and manifest entries for the requested platform.""" + base = root or project_root() + selected_brands = brands or available_brand_names(base) + release_manifest: dict[str, Any] = { + "version": version, + "channel": "stable", + "brands": {}, + } + artifacts: list[str] = [] + found_brands: list[str] = [] + + for brand_name in selected_brands: + brand = load_brand_config(brand_name, root=base) + release_manifest["channel"] = brand.update_channel + + if platform == "windows": + artifact_dir = base / "build" / "dist" / "windows" / brand.brand_id + installer = artifact_dir / brand.windows_installer_name(version) + checksum = artifact_dir / f"{installer.name}.sha256" + platform_key = "windows-x64" + elif platform == "macos": + artifact_dir = base / "build" / "dist" / "macos" / brand.brand_id + installer = artifact_dir / brand.macos_installer_name(version) + checksum = artifact_dir / f"{installer.name}.sha256" + platform_key = "macos-universal" + + if not installer.exists() and brand.brand_id == DEFAULT_BRAND_ID: + legacy_installer = (base / "build" / "dist" / "macos") / brand.macos_installer_name( + version + ) + legacy_checksum = legacy_installer.parent / f"{legacy_installer.name}.sha256" + if legacy_installer.exists(): + installer = legacy_installer + checksum = legacy_checksum + else: + raise ValueError(f"Unsupported platform: {platform}") + + if not installer.exists(): + continue + + found_brands.append(brand.brand_id) + artifacts.append(str(installer)) + if checksum.exists(): + artifacts.append(str(checksum)) + + release_manifest["brands"].setdefault(brand.brand_id, {})[platform_key] = { + "installer": installer.name, + "checksum": checksum.name if checksum.exists() else "", + } + + return { + "version": version, + "platform": platform, + "brands": found_brands, + "artifacts": artifacts, + "manifest": release_manifest, + } + + def cli_env(args: argparse.Namespace) -> int: brand = load_brand_config(args.brand) assignments = { @@ -187,6 +287,26 @@ def cli_manifest(args: argparse.Namespace) -> int: return 0 +def cli_local_release_data(args: argparse.Namespace) -> int: + data = collect_local_release_data( + args.version, + platform=args.platform, + brands=args.brands, + ) + print(json.dumps(data, indent=2)) + return 0 + + +def cli_merge_manifests(args: argparse.Namespace) -> int: + base_manifest = json.loads(Path(args.base).read_text(encoding="utf-8")) + overlay_manifest = json.loads(Path(args.overlay).read_text(encoding="utf-8")) + merged = merge_release_manifests(base_manifest, overlay_manifest) + output_path = Path(args.output) + output_path.write_text(json.dumps(merged, indent=2), encoding="utf-8") + print(output_path) + return 0 + + def cli_show(args: argparse.Namespace) -> int: brand = load_brand_config(args.brand) print( @@ -224,6 +344,18 @@ def main() -> int: manifest_parser.add_argument("--brands", nargs="+", required=True) manifest_parser.set_defaults(func=cli_manifest) + local_parser = subparsers.add_parser("local-release-data") + local_parser.add_argument("--version", required=True) + local_parser.add_argument("--platform", choices=["windows", "macos"], required=True) + local_parser.add_argument("--brands", nargs="+") + local_parser.set_defaults(func=cli_local_release_data) + + merge_parser = subparsers.add_parser("merge-manifests") + merge_parser.add_argument("--base", required=True) + merge_parser.add_argument("--overlay", required=True) + merge_parser.add_argument("--output", required=True) + merge_parser.set_defaults(func=cli_merge_manifests) + show_parser = subparsers.add_parser("show") show_parser.add_argument("--brand", required=True) show_parser.set_defaults(func=cli_show) diff --git a/build/scripts/build_macos.sh b/build/scripts/build_macos.sh index 432f8fe..bfd41cd 100644 --- a/build/scripts/build_macos.sh +++ b/build/scripts/build_macos.sh @@ -77,13 +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" +if [ -z "$BRAND" ]; then + BRAND="webdrop_bridge" +fi + +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" + +if [ -n "$WEBDROP_APP_DISPLAY_NAME" ]; then echo "🏷️ Building brand: $WEBDROP_APP_DISPLAY_NAME ($WEBDROP_BRAND_ID)" fi @@ -220,7 +225,7 @@ create_dmg() { log_info "Creating DMG package..." echo "" - DMG_FILE="$DIST_DIR/${APP_NAME}-${VERSION}.dmg" + DMG_FILE="$DIST_DIR/${APP_NAME}-${VERSION}-macos-universal.dmg" # Remove existing DMG if [ -f "$DMG_FILE" ]; then diff --git a/build/scripts/create_release.ps1 b/build/scripts/create_release.ps1 index 82702e0..49de30d 100644 --- a/build/scripts/create_release.ps1 +++ b/build/scripts/create_release.ps1 @@ -3,7 +3,7 @@ param( [string]$Version, [Parameter(Mandatory = $false)] - [string[]]$Brands = @("agravity"), + [string[]]$Brands, [Parameter(Mandatory = $false)] [string]$ForgejoUser, @@ -12,6 +12,7 @@ param( [string]$ForgejoPW, [switch]$ClearCredentials, + [switch]$DryRun, [string]$ForgejoUrl = "https://git.him-tools.de", [string]$Repo = "HIM-public/webdrop-bridge" @@ -26,11 +27,32 @@ if (-not (Test-Path $pythonExe)) { $brandHelper = Join-Path $projectRoot "build\scripts\brand_config.py" $manifestOutput = Join-Path $projectRoot "build\dist\release-manifest.json" +$localManifestPath = Join-Path $projectRoot "build\dist\release-manifest.local.json" +$existingManifestPath = Join-Path $projectRoot "build\dist\release-manifest.existing.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() } +function Get-LocalReleaseData { + $arguments = @($brandHelper, "local-release-data", "--platform", "windows", "--version", $Version) + if ($Brands) { + $arguments += "--brands" + $arguments += $Brands + } + return (& $pythonExe @arguments | ConvertFrom-Json) +} + +function Get-AssetMap { + param([object[]]$Assets) + + $map = @{} + foreach ($asset in ($Assets | Where-Object { $_ })) { + $map[$asset.name] = $asset + } + return $map +} + if ($ClearCredentials) { Remove-Item env:FORGEJO_USER -ErrorAction SilentlyContinue Remove-Item env:FORGEJO_PASS -ErrorAction SilentlyContinue @@ -38,6 +60,44 @@ if ($ClearCredentials) { exit 0 } +if (-not $Version) { + $Version = Get-CurrentVersion +} + +$localData = Get-LocalReleaseData +$artifactPaths = New-Object System.Collections.Generic.List[string] + +foreach ($artifact in $localData.artifacts) { + $artifactPaths.Add([string]$artifact) + if ((Test-Path $artifact) -and ((Get-Item $artifact).Extension -eq ".msi")) { + $msiSize = (Get-Item $artifact).Length / 1MB + Write-Host "Windows artifact: $([System.IO.Path]::GetFileName($artifact)) ($([math]::Round($msiSize, 2)) MB)" + } +} + +if ($artifactPaths.Count -eq 0) { + Write-Host "ERROR: No local Windows artifacts found" -ForegroundColor Red + exit 1 +} + +$localData.manifest | ConvertTo-Json -Depth 10 | Set-Content -Path $localManifestPath -Encoding utf8 + +if ($DryRun) { + Copy-Item $localManifestPath $manifestOutput -Force + $brandsText = if ($localData.brands.Count -gt 0) { $localData.brands -join ", " } else { "" } + + Write-Host "[DRY RUN] No network requests or uploads will be performed." -ForegroundColor Yellow + Write-Host "[DRY RUN] Release tag: v$Version" + Write-Host "[DRY RUN] Release URL: $ForgejoUrl/$Repo/releases/tag/v$Version" + Write-Host "[DRY RUN] Discovered brands: $brandsText" + Write-Host "[DRY RUN] Artifacts that would be uploaded:" + foreach ($artifact in $artifactPaths) { + Write-Host " - $artifact" + } + Write-Host "[DRY RUN] Local manifest preview: $manifestOutput" + exit 0 +} + if (-not $ForgejoUser) { $ForgejoUser = $env:FORGEJO_USER } @@ -58,36 +118,6 @@ if (-not $ForgejoUser -or -not $ForgejoPW) { $env:FORGEJO_PASS = $ForgejoPW } -if (-not $Version) { - $Version = Get-CurrentVersion -} - -$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" - - 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)" - } -} - -& $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 -} - $auth = [System.Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes("${ForgejoUser}:${ForgejoPW}")) $headers = @{ "Authorization" = "Basic $auth" @@ -105,28 +135,46 @@ $releaseData = @{ } | ConvertTo-Json try { - $lookupResponse = Invoke-WebRequest -Uri $releaseLookupUrl -Method GET -Headers $headers -TimeoutSec 30 -UseBasicParsing -ErrorAction Stop - $releaseInfo = $lookupResponse.Content | ConvertFrom-Json + $releaseInfo = Invoke-RestMethod -Uri $releaseLookupUrl -Method GET -Headers $headers -TimeoutSec 30 -ErrorAction Stop $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 + $releaseInfo = Invoke-RestMethod -Uri $releaseUrl -Method POST -Headers $headers -Body $releaseData -TimeoutSec 30 -ErrorAction Stop $releaseId = $releaseInfo.id Write-Host "[OK] Release created (ID: $releaseId)" -ForegroundColor Green } +$assetMap = Get-AssetMap -Assets $releaseInfo.assets +if ($assetMap.ContainsKey("release-manifest.json")) { + Invoke-WebRequest -Uri $assetMap["release-manifest.json"].browser_download_url -Method GET -Headers $headers -TimeoutSec 30 -OutFile $existingManifestPath | Out-Null + + & $pythonExe $brandHelper merge-manifests --base $existingManifestPath --overlay $localManifestPath --output $manifestOutput | Out-Null +} +else { + Copy-Item $localManifestPath $manifestOutput -Force +} + +$artifactPaths.Add($manifestOutput) +$assetMap = Get-AssetMap -Assets $releaseInfo.assets + $curlAuth = "$ForgejoUser`:$ForgejoPW" $uploadUrl = "$ForgejoUrl/api/v1/repos/$Repo/releases/$releaseId/assets" foreach ($artifact in $artifactPaths) { + $assetName = [System.IO.Path]::GetFileName($artifact) + if ($assetMap.ContainsKey($assetName)) { + $existingAsset = $assetMap[$assetName] + Invoke-RestMethod -Uri "$ForgejoUrl/api/v1/repos/$Repo/releases/$releaseId/assets/$($existingAsset.id)" -Method DELETE -Headers $headers -TimeoutSec 30 | Out-Null + Write-Host "[OK] Replaced existing asset $assetName" -ForegroundColor Yellow + } + $response = curl.exe -s -X POST -u $curlAuth -F "attachment=@$artifact" $uploadUrl if ($response -like "*error*" -or $response -like "*404*") { Write-Host "WARNING: Could not upload $artifact : $response" -ForegroundColor Yellow } else { - Write-Host "[OK] Uploaded $([System.IO.Path]::GetFileName($artifact))" -ForegroundColor Green + Write-Host "[OK] Uploaded $assetName" -ForegroundColor Green } } diff --git a/build/scripts/create_release.sh b/build/scripts/create_release.sh index ea71bd1..04b3885 100644 --- a/build/scripts/create_release.sh +++ b/build/scripts/create_release.sh @@ -4,15 +4,19 @@ set -e VERSION="" -BRANDS=("agravity") +BRANDS=() FORGEJO_USER="${FORGEJO_USER}" FORGEJO_PASS="${FORGEJO_PASS}" FORGEJO_URL="https://git.him-tools.de" REPO="HIM-public/webdrop-bridge" CLEAR_CREDS=false +DRY_RUN=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" +LOCAL_MANIFEST_OUTPUT="$PROJECT_ROOT/build/dist/release-manifest.local.json" +EXISTING_MANIFEST_OUTPUT="$PROJECT_ROOT/build/dist/release-manifest.existing.json" +LOCAL_DATA_OUTPUT="$PROJECT_ROOT/build/dist/release-data.local.json" while [[ $# -gt 0 ]]; do case $1 in @@ -20,14 +24,11 @@ while [[ $# -gt 0 ]]; do -u|--url) FORGEJO_URL="$2"; shift 2 ;; --brand) BRANDS+=("$2"); shift 2 ;; --clear-credentials) CLEAR_CREDS=true; shift ;; + --dry-run) DRY_RUN=true; shift ;; *) echo "Unknown option: $1"; exit 1 ;; esac done -if [ ${#BRANDS[@]} -gt 1 ] && [ "${BRANDS[0]}" = "agravity" ]; then - BRANDS=("${BRANDS[@]:1}") -fi - if [ "$CLEAR_CREDS" = true ]; then unset FORGEJO_USER unset FORGEJO_PASS @@ -39,6 +40,61 @@ if [ -z "$VERSION" ]; then 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 +LOCAL_ARGS=("$BRAND_HELPER" "local-release-data" "--platform" "macos" "--version" "$VERSION") +if [ ${#BRANDS[@]} -gt 0 ]; then + LOCAL_ARGS+=("--brands" "${BRANDS[@]}") +fi + +python3 "${LOCAL_ARGS[@]}" > "$LOCAL_DATA_OUTPUT" + +mapfile -t ARTIFACTS < <(python3 - "$LOCAL_DATA_OUTPUT" "$LOCAL_MANIFEST_OUTPUT" <<'PY' +import json +import sys +from pathlib import Path + +data = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) +Path(sys.argv[2]).write_text(json.dumps(data["manifest"], indent=2), encoding="utf-8") +for artifact in data["artifacts"]: + print(artifact) +PY +) + +for ARTIFACT in "${ARTIFACTS[@]}"; do + if [ -f "$ARTIFACT" ] && [ "${ARTIFACT##*.}" = "dmg" ]; then + DMG_SIZE=$(du -m "$ARTIFACT" | cut -f1) + echo "macOS artifact: $(basename "$ARTIFACT") ($DMG_SIZE MB)" + fi +done + +if [ ${#ARTIFACTS[@]} -eq 0 ]; then + echo "ERROR: No local macOS artifacts found" + exit 1 +fi + +if [ "$DRY_RUN" = true ]; then + cp "$LOCAL_MANIFEST_OUTPUT" "$MANIFEST_OUTPUT" + DISCOVERED_BRANDS=$(python3 - "$LOCAL_DATA_OUTPUT" <<'PY' +import json +import sys +from pathlib import Path + +data = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) +print(", ".join(data.get("brands", [])) or "") +PY +) + + echo "[DRY RUN] No network requests or uploads will be performed." + echo "[DRY RUN] Release tag: v$VERSION" + echo "[DRY RUN] Release URL: $FORGEJO_URL/$REPO/releases/tag/v$VERSION" + echo "[DRY RUN] Discovered brands: $DISCOVERED_BRANDS" + echo "[DRY RUN] Artifacts that would be uploaded:" + for ARTIFACT in "${ARTIFACTS[@]}"; do + echo " - $ARTIFACT" + done + echo "[DRY RUN] Local manifest preview: $MANIFEST_OUTPUT" + exit 0 +fi + if [ -z "$FORGEJO_USER" ] || [ -z "$FORGEJO_PASS" ]; then echo "Forgejo credentials not found. Enter your credentials:" if [ -z "$FORGEJO_USER" ]; then @@ -52,36 +108,25 @@ if [ -z "$FORGEJO_USER" ] || [ -z "$FORGEJO_PASS" ]; then export FORGEJO_PASS fi -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 - BASIC_AUTH=$(echo -n "${FORGEJO_USER}:${FORGEJO_PASS}" | base64) RELEASE_URL="$FORGEJO_URL/api/v1/repos/$REPO/releases" RELEASE_LOOKUP_URL="$FORGEJO_URL/api/v1/repos/$REPO/releases/tags/v$VERSION" -RESPONSE=$(curl -s -H "Authorization: Basic $BASIC_AUTH" "$RELEASE_LOOKUP_URL") -RELEASE_ID=$(echo "$RESPONSE" | grep -o '"id":[0-9]*' | head -1 | cut -d':' -f2) +RELEASE_RESPONSE_FILE=$(mktemp) +HTTP_CODE=$(curl -s -o "$RELEASE_RESPONSE_FILE" -w "%{http_code}" -H "Authorization: Basic $BASIC_AUTH" "$RELEASE_LOOKUP_URL") +if [ "$HTTP_CODE" = "200" ]; then + RELEASE_ID=$(python3 - "$RELEASE_RESPONSE_FILE" <<'PY' +import json +import sys +from pathlib import Path + +payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) +print(payload.get("id", "")) +PY +) +else + RELEASE_ID="" +fi if [ -z "$RELEASE_ID" ]; then RELEASE_DATA=$(cat </dev/null +else + cp "$LOCAL_MANIFEST_OUTPUT" "$MANIFEST_OUTPUT" +fi + +ARTIFACTS+=("$MANIFEST_OUTPUT") + UPLOAD_URL="$FORGEJO_URL/api/v1/repos/$REPO/releases/$RELEASE_ID/assets" for ARTIFACT in "${ARTIFACTS[@]}"; do + ASSET_NAME="$(basename "$ARTIFACT")" + EXISTING_ASSET_ID=$(python3 - "$RELEASE_RESPONSE_FILE" "$ASSET_NAME" <<'PY' +import json +import sys +from pathlib import Path + +payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) +asset_name = sys.argv[2] +for asset in payload.get("assets", []): + if asset.get("name") == asset_name: + print(asset.get("id", "")) + break +PY +) + + if [ -n "$EXISTING_ASSET_ID" ]; then + curl -s -X DELETE \ + -H "Authorization: Basic $BASIC_AUTH" \ + "$FORGEJO_URL/api/v1/repos/$REPO/releases/$RELEASE_ID/assets/$EXISTING_ASSET_ID" >/dev/null + echo "[OK] Replaced existing asset $ASSET_NAME" + fi + HTTP_CODE=$(curl -s -w "%{http_code}" -X POST \ -H "Authorization: Basic $BASIC_AUTH" \ -F "attachment=@$ARTIFACT" \ @@ -117,9 +216,9 @@ for ARTIFACT in "${ARTIFACTS[@]}"; do -o /tmp/curl_response.txt) if [ "$HTTP_CODE" -eq 201 ] || [ "$HTTP_CODE" -eq 200 ]; then - echo "[OK] Uploaded $(basename "$ARTIFACT")" + echo "[OK] Uploaded $ASSET_NAME" else - echo "ERROR uploading $(basename "$ARTIFACT") (HTTP $HTTP_CODE)" + echo "ERROR uploading $ASSET_NAME (HTTP $HTTP_CODE)" cat /tmp/curl_response.txt exit 1 fi diff --git a/docs/BRANDING_AND_RELEASES.md b/docs/BRANDING_AND_RELEASES.md new file mode 100644 index 0000000..4e46d26 --- /dev/null +++ b/docs/BRANDING_AND_RELEASES.md @@ -0,0 +1,469 @@ +# Branding, Builds, and Releases + +This document describes how branded builds work in this repository, how to add or edit a brand, how to build the default and branded variants, and how to publish releases. + +## Overview + +The project supports one default product and any number of branded variants from the same codebase. + +- The default product is defined by built-in defaults in `build/scripts/brand_config.py`. +- The default product identifier is `webdrop_bridge`. +- Additional brands are defined by JSON manifests in `build/brands/`. +- Runtime behavior can also be branded through application config values such as `brand_id`, `config_dir_name`, `app_name`, and update settings. +- Windows and macOS installers are built as separate artifacts per brand. +- Releases are shared by version. A single Forgejo release can contain installers for the default product and multiple brands. + +## Branding Model + +There are two layers to branding: + +1. Packaging identity + Controls installer name, executable/app bundle name, product display name, bundle identifier, MSI upgrade code, installer artwork, and related metadata. + +2. Runtime configuration + Controls app name shown in the UI, config directory name, update feed settings, URL mappings, allowed roots, and similar application behavior. + +Packaging identity lives in `build/brands/.json`. + +Runtime configuration lives in app config files loaded by the application. See `config.example.json` for the current branded example. + +## Important Files + +- `build/scripts/brand_config.py`: central helper for brand metadata, artifact naming, and release manifest generation +- `build/brands/agravity.json`: example branded manifest +- `build/scripts/build_windows.py`: Windows build entrypoint +- `build/scripts/build_macos.sh`: macOS build entrypoint +- `build/scripts/create_release.ps1`: Windows release uploader +- `build/scripts/create_release.sh`: macOS release uploader +- `config.example.json`: example runtime branding config + +## Create a New Brand + +To create a new brand, add a new manifest file under `build/brands/`. + +Example: + +1. Copy `build/brands/template.jsonc` to `build/brands/.json`. +2. Update the values for the new brand. +3. Add any brand-specific assets if you do not want to reuse the default icons/license assets. + +Minimal example: + +```json +{ + "brand_id": "customerx", + "display_name": "Customer X Bridge", + "asset_prefix": "CustomerXBridge", + "exe_name": "CustomerXBridge", + "manufacturer": "Customer X", + "install_dir_name": "Customer X Bridge", + "shortcut_description": "Customer X drag-and-drop bridge", + "bundle_identifier": "com.customerx.bridge", + "config_dir_name": "customerx_bridge", + "msi_upgrade_code": "PUT-A-NEW-GUID-HERE", + "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" +} +``` + +### Required Fields + +- `brand_id`: internal identifier used for build output folders and release manifest entries +- `display_name`: user-facing product name +- `asset_prefix`: base name for installer artifacts and app bundle name +- `exe_name`: executable name for Windows and app bundle name base for macOS +- `manufacturer`: MSI manufacturer string +- `install_dir_name`: installation directory name shown to the OS +- `shortcut_description`: Windows shortcut description +- `bundle_identifier`: macOS bundle identifier +- `config_dir_name`: local app config/log/cache directory name +- `msi_upgrade_code`: stable GUID for Windows upgrades +- `update_channel`: currently typically `stable` + +Generate a new `msi_upgrade_code` for a new brand once and keep it stable afterwards. + +Examples: + +```powershell +New-Guid +``` + +```bash +uuidgen +``` + +### Asset Fields + +These can point at brand-specific files or default shared files: + +- `icon_ico` +- `icon_icns` +- `dialog_bmp` +- `banner_bmp` +- `license_rtf` + +If a referenced asset path does not exist, the helper falls back to the default asset defined in `build/scripts/brand_config.py`. + +### Identity Rules + +Treat these values as long-lived product identity once a brand has shipped: + +- `brand_id` +- `asset_prefix` +- `exe_name` +- `bundle_identifier` +- `config_dir_name` +- `msi_upgrade_code` + +Changing them later can break one or more of the following: + +- Windows upgrade behavior +- macOS app identity +- auto-update asset selection +- local config/log/cache continuity +- installer and artifact naming consistency + +If the product is already in use, only change these values deliberately and with migration planning. + +## Edit an Existing Brand + +To edit a shipped or in-progress brand: + +1. Update the brand manifest in `build/brands/.json`. +2. If needed, update brand-specific assets referenced by that manifest. +3. If runtime behavior should also change, update the relevant application config values. +4. Rebuild the affected platform artifacts. +5. Validate the result with a dry-run release before publishing. + +Safe edits after release usually include: + +- `display_name` +- `shortcut_description` +- artwork paths +- license text +- update channel, if release policy changes + +High-risk edits after release are the identity fields listed above. + +## Runtime Branding Configuration + +Packaging branding alone is not enough if the app should also present a different name, use different local storage, or point to different update settings. + +Relevant runtime config keys include: + +- `brand_id` +- `config_dir_name` +- `app_name` +- `update_base_url` +- `update_repo` +- `update_channel` +- `update_manifest_name` + +The current example in `config.example.json` shows the Agravity runtime setup. + +When adding a new brand, make sure the runtime config matches the packaging manifest at least for: + +- `brand_id` +- `config_dir_name` +- `app_name` + +## Build the Default Product + +### Windows + +Build the default executable only: + +```powershell +python .\build\scripts\build_windows.py +``` + +Build the default Windows MSI: + +```powershell +python .\build\scripts\build_windows.py --msi +``` + +Build with a specific `.env` file: + +```powershell +python .\build\scripts\build_windows.py --msi --env-file .\.env +``` + +### macOS + +Build the default macOS app and DMG: + +```bash +bash build/scripts/build_macos.sh +``` + +Build with a specific `.env` file: + +```bash +bash build/scripts/build_macos.sh --env-file .env +``` + +## Build a Brand + +### Windows + +Build a branded executable only: + +```powershell +python .\build\scripts\build_windows.py --brand agravity +``` + +Build a branded MSI: + +```powershell +python .\build\scripts\build_windows.py --brand agravity --msi +``` + +### macOS + +Build a branded macOS app and DMG: + +```bash +bash build/scripts/build_macos.sh --brand agravity +``` + +## Build Output Locations + +Windows artifacts are written to: + +- `build/dist/windows/webdrop_bridge/` for the default product +- `build/dist/windows//` for branded products + +macOS artifacts are written to: + +- `build/dist/macos/webdrop_bridge/` for the default product +- `build/dist/macos//` for branded products + +Typical artifact names: + +- Windows MSI: `--win-x64.msi` +- Windows checksum: `--win-x64.msi.sha256` +- macOS DMG: `--macos-universal.dmg` +- macOS checksum: `--macos-universal.dmg.sha256` + +## Create a Release + +Releases are shared by version. The release scripts scan local build outputs on the current machine and upload every artifact they find for that platform. + +This means: + +- a Windows machine can upload all locally built MSIs for the current version +- a macOS machine can later upload all locally built DMGs for the same version +- both runs contribute to the same Forgejo release tag +- `release-manifest.json` is merged so later runs do not wipe earlier platform entries + +### Windows Release + +Dry run first: + +```powershell +.\build\scripts\create_release.ps1 -DryRun +``` + +Publish all locally built Windows variants for the current version: + +```powershell +.\build\scripts\create_release.ps1 +``` + +Publish only selected brands: + +```powershell +.\build\scripts\create_release.ps1 -Brands agravity +``` + +Publish only the default product: + +```powershell +.\build\scripts\create_release.ps1 -Brands webdrop_bridge +``` + +Publish a specific version: + +```powershell +.\build\scripts\create_release.ps1 -Version 0.8.4 +``` + +### macOS Release + +Dry run first: + +```bash +bash build/scripts/create_release.sh --dry-run +``` + +Publish all locally built macOS variants for the current version: + +```bash +bash build/scripts/create_release.sh +``` + +Publish only selected brands: + +```bash +bash build/scripts/create_release.sh --brand agravity +``` + +Publish only the default product: + +```bash +bash build/scripts/create_release.sh --brand webdrop_bridge +``` + +Publish a specific version: + +```bash +bash build/scripts/create_release.sh --version 0.8.4 +``` + +### Credentials + +Both release scripts use Forgejo credentials from environment variables when available: + +- `FORGEJO_USER` +- `FORGEJO_PASS` + +If they are not set and you are not in dry-run mode, the script prompts for them. + +Both scripts also support clearing credentials from the current shell session: + +- Windows: `-ClearCredentials` +- macOS: `--clear-credentials` + +## Dry Run Behavior + +Dry-run mode is the preferred validation step before publishing. + +Dry-run mode: + +- discovers the local artifacts exactly like a real release run +- prints the release tag and target release URL +- prints the brands that were discovered locally +- prints the artifact paths that would be uploaded +- writes a local manifest preview to `build/dist/release-manifest.json` +- does not prompt for credentials +- does not perform network requests +- does not delete or upload assets + +## Release Manifest + +The release scripts generate and upload `release-manifest.json`. + +This file is used by the updater to select the correct installer and checksum for a given brand and platform. + +Current platform keys are: + +- `windows-x64` +- `macos-universal` + +The manifest is built from local artifacts and merged with any existing manifest already attached to the release. + +## First Manual Download (Before Auto-Update) + +After creating a release, a user can manually download the first installer directly from Forgejo. Once installed, auto-update handles later versions. + +Base repository URL: + +- `https://git.him-tools.de/HIM-public/webdrop-bridge` + +Release page pattern: + +- `https://git.him-tools.de/HIM-public/webdrop-bridge/releases/tag/v` + +Direct asset download pattern: + +- `https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v/` + +Example asset names: + +- `WebDropBridge-0.8.4-win-x64.msi` +- `WebDropBridge-0.8.4-macos-universal.dmg` +- `AgravityBridge-0.8.4-win-x64.msi` +- `AgravityBridge-0.8.4-macos-universal.dmg` + +### wget Examples + +```bash +# Default Windows installer +wget "https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.4/WebDropBridge-0.8.4-win-x64.msi" + +# Agravity macOS installer +wget "https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.4/AgravityBridge-0.8.4-macos-universal.dmg" +``` + +### curl Examples + +```bash +# Default macOS installer +curl -L -o WebDropBridge-0.8.4-macos-universal.dmg \ + "https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.4/WebDropBridge-0.8.4-macos-universal.dmg" + +# Agravity Windows installer +curl -L -o AgravityBridge-0.8.4-win-x64.msi \ + "https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.4/AgravityBridge-0.8.4-win-x64.msi" +``` + +### PowerShell Example + +```powershell +Invoke-WebRequest ` + -Uri "https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.4/WebDropBridge-0.8.4-win-x64.msi" ` + -OutFile "WebDropBridge-0.8.4-win-x64.msi" +``` + +You can inspect `release-manifest.json` on the release to see the exact file names for each brand and platform. + +## Recommended Workflow for a New Brand + +1. Create `build/brands/.json`. +2. Add or update brand-specific assets if needed. +3. Prepare matching runtime config values. +4. Build the brand on Windows and/or macOS. +5. Run the release script in dry-run mode. +6. Verify artifact names, discovered brands, and manifest contents. +7. Run the actual release script. +8. Validate update behavior against the shared release. + +## Troubleshooting Notes + +### Brand not discovered by release script + +Check that: + +- the build completed successfully +- the artifact is under the expected platform folder +- the artifact name matches the `asset_prefix` and current version +- the version used by the release script matches the built artifact version + +### Windows upgrade behavior is wrong + +Check that the brand has its own stable `msi_upgrade_code`. Reusing or changing it incorrectly will break expected MSI upgrade semantics. + +### App uses the wrong local config folder + +Check that runtime config uses the intended `config_dir_name`, and that it matches the packaging brand you expect. + +### Auto-update downloads the wrong installer + +Check that: + +- the release contains the correct installer files +- `release-manifest.json` includes the correct brand and platform entry +- runtime update settings point to the expected repo/channel/manifest + +## Current Example Brand + +The first branded variant currently in the repository is: + +- `build/brands/agravity.json` + +Use it as the template for future branded variants. \ No newline at end of file diff --git a/tests/unit/test_brand_config.py b/tests/unit/test_brand_config.py index 2f3d48b..fa5dd5e 100644 --- a/tests/unit/test_brand_config.py +++ b/tests/unit/test_brand_config.py @@ -8,7 +8,13 @@ BUILD_SCRIPTS_DIR = Path(__file__).resolve().parents[2] / "build" / "scripts" if str(BUILD_SCRIPTS_DIR) not in sys.path: sys.path.insert(0, str(BUILD_SCRIPTS_DIR)) -from brand_config import generate_release_manifest, load_brand_config +from brand_config import ( + DEFAULT_BRAND_ID, + collect_local_release_data, + generate_release_manifest, + load_brand_config, + merge_release_manifests, +) def test_load_agravity_brand_config(): @@ -75,3 +81,63 @@ def test_generate_release_manifest_for_agravity(tmp_path): manifest["brands"]["agravity"]["macos-universal"]["installer"] == "AgravityBridge-0.8.4-macos-universal.dmg" ) + + +def test_collect_local_release_data_includes_default_brand(tmp_path): + """Test discovering local artifacts for the default Windows build.""" + project_root = tmp_path + installer_dir = project_root / "build" / "dist" / "windows" / DEFAULT_BRAND_ID + installer_dir.mkdir(parents=True) + + installer = installer_dir / "WebDropBridge-0.8.4-win-x64.msi" + installer.write_bytes(b"msi") + checksum = installer_dir / f"{installer.name}.sha256" + checksum.write_text("abc", encoding="utf-8") + + data = collect_local_release_data("0.8.4", platform="windows", root=project_root) + + assert data["brands"] == [DEFAULT_BRAND_ID] + assert str(installer) in data["artifacts"] + assert str(checksum) in data["artifacts"] + assert ( + data["manifest"]["brands"][DEFAULT_BRAND_ID]["windows-x64"]["installer"] == installer.name + ) + + +def test_merge_release_manifests_preserves_existing_platforms(): + """Test merging platform-specific manifest entries from separate upload runs.""" + base_manifest = { + "version": "0.8.4", + "channel": "stable", + "brands": { + "agravity": { + "windows-x64": { + "installer": "AgravityBridge-0.8.4-win-x64.msi", + "checksum": "AgravityBridge-0.8.4-win-x64.msi.sha256", + } + } + }, + } + overlay_manifest = { + "version": "0.8.4", + "channel": "stable", + "brands": { + "agravity": { + "macos-universal": { + "installer": "AgravityBridge-0.8.4-macos-universal.dmg", + "checksum": "AgravityBridge-0.8.4-macos-universal.dmg.sha256", + } + } + }, + } + + merged = merge_release_manifests(base_manifest, overlay_manifest) + + assert ( + merged["brands"]["agravity"]["windows-x64"]["installer"] + == "AgravityBridge-0.8.4-win-x64.msi" + ) + assert ( + merged["brands"]["agravity"]["macos-universal"]["installer"] + == "AgravityBridge-0.8.4-macos-universal.dmg" + )