param( [Parameter(Mandatory = $false)] [string]$Version, [Parameter(Mandatory = $false)] [string[]]$Brands, [Parameter(Mandatory = $false)] [string]$ForgejoUser, [Parameter(Mandatory = $false)] [string]$ForgejoPW, [switch]$ClearCredentials, [switch]$DryRun, [string]$ForgejoUrl = "https://git.him-tools.de", [string]$Repo = "HIM-public/webdrop-bridge" ) $ErrorActionPreference = "Stop" $projectRoot = Resolve-Path (Join-Path $PSScriptRoot "..\..") $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" $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 Write-Host "[OK] Credentials cleared from this session" -ForegroundColor Green 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 } $localManifestJson = $localData.manifest | ConvertTo-Json -Depth 10 [System.IO.File]::WriteAllText($localManifestPath, $localManifestJson, (New-Object System.Text.UTF8Encoding($false))) 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 } if (-not $ForgejoPW) { $ForgejoPW = $env:FORGEJO_PASS } 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)) } $env:FORGEJO_USER = $ForgejoUser $env:FORGEJO_PASS = $ForgejoPW } $auth = [System.Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes("${ForgejoUser}:${ForgejoPW}")) $headers = @{ "Authorization" = "Basic $auth" "Content-Type" = "application/json" } $releaseLookupUrl = "$ForgejoUrl/api/v1/repos/$Repo/releases/tags/v$Version" $releaseUrl = "$ForgejoUrl/api/v1/repos/$Repo/releases" $releaseData = @{ tag_name = "v$Version" name = "WebDropBridge v$Version" body = "Shared branded release for WebDrop Bridge v$Version" draft = $false prerelease = $false } | ConvertTo-Json try { $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 { $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 } # Ensure uploaded manifest is UTF-8 without BOM (for strict JSON parsers) if (Test-Path $manifestOutput) { $manifestText = Get-Content -Raw -Path $manifestOutput [System.IO.File]::WriteAllText($manifestOutput, $manifestText, (New-Object System.Text.UTF8Encoding($false))) } $artifactPaths.Add($manifestOutput) $assetMap = Get-AssetMap -Assets $releaseInfo.assets $artifactsToUpload = New-Object System.Collections.Generic.List[string] foreach ($artifact in $artifactPaths) { $assetName = [System.IO.Path]::GetFileName($artifact) $extension = [System.IO.Path]::GetExtension($artifact).ToLowerInvariant() if ($extension -eq ".msi" -and $assetMap.ContainsKey($assetName)) { $localSize = (Get-Item $artifact).Length $remoteSize = [int64]$assetMap[$assetName].size if ($localSize -eq $remoteSize) { Write-Host "[OK] Skipping already uploaded MSI $assetName ($([math]::Round($localSize / 1MB, 2)) MB)" -ForegroundColor Cyan continue } } $artifactsToUpload.Add($artifact) } if ($artifactsToUpload.Count -eq 0) { Write-Host "[OK] All release assets already uploaded." -ForegroundColor Green Write-Host "View at: $ForgejoUrl/$Repo/releases/tag/v$Version" -ForegroundColor Cyan exit 0 } # Use Python requests library for more reliable large file uploads $pythonUploadScript = @" import sys import requests from requests.auth import HTTPBasicAuth from pathlib import Path import time upload_url = sys.argv[1] artifacts = sys.argv[2:] username = '$ForgejoUser' password = '$ForgejoPW' delete_url_template = '${ForgejoUrl}/api/v1/repos/${Repo}/releases/$releaseId/assets/{}' release_info_url = '${ForgejoUrl}/api/v1/repos/${Repo}/releases/$releaseId' session = requests.Session() session.auth = HTTPBasicAuth(username, password) session.headers.update({'Connection': 'close'}) def upload_with_retry(artifact_path, max_retries=3): asset_name = Path(artifact_path).name # Check if asset already exists and delete it try: release_response = session.get(release_info_url, timeout=30) release_response.raise_for_status() for asset in release_response.json().get('assets', []): if asset['name'] == asset_name: delete_resp = session.delete(delete_url_template.format(asset['id']), timeout=30) delete_resp.raise_for_status() print(f'[OK] Replaced existing asset {asset_name}', file=sys.stderr) break except Exception as e: print(f'Warning checking existing assets: {e}', file=sys.stderr) # Upload file with streaming and retries retryable_status_codes = {429, 502, 503, 504} for attempt in range(max_retries): try: if attempt > 0: print(f' Retry {attempt} of {max_retries}...', file=sys.stderr) time.sleep(min(10, 2 * attempt)) with open(artifact_path, 'rb') as f: files = {'attachment': (asset_name, f)} response = session.post( upload_url, files=files, timeout=900, # 15 minute timeout stream=False ) if response.status_code in [200, 201]: print(f'[OK] Uploaded {asset_name}') return True if response.status_code in retryable_status_codes: if attempt >= max_retries - 1: print(f'ERROR uploading {asset_name} (HTTP {response.status_code} after {max_retries} retries)') print(response.text) sys.exit(1) continue print(f'ERROR uploading {asset_name} (HTTP {response.status_code})') print(response.text) sys.exit(1) except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as e: if attempt >= max_retries - 1: print(f'ERROR uploading {asset_name}: Connection failed after {max_retries} retries') print(str(e)) sys.exit(1) time.sleep(min(10, 2 * (attempt + 1))) except Exception as e: print(f'ERROR uploading {asset_name}: {e}') sys.exit(1) for artifact_path in artifacts: upload_with_retry(artifact_path) print(f'[OK] All files uploaded successfully') "@ $uploadScriptPath = ([System.IO.Path]::GetTempFileName() -replace 'tmp$', 'py') Set-Content -Path $uploadScriptPath -Value $pythonUploadScript -Encoding UTF8 try { $uploadUrl = "$ForgejoUrl/api/v1/repos/$Repo/releases/$releaseId/assets" & $pythonExe $uploadScriptPath $uploadUrl @artifactsToUpload if ($LASTEXITCODE -ne 0) { exit 1 } } finally { Remove-Item $uploadScriptPath -ErrorAction SilentlyContinue } Write-Host "`n[OK] Release complete!" -ForegroundColor Green Write-Host "View at: $ForgejoUrl/$Repo/releases/tag/v$Version" -ForegroundColor Cyan