param( [string]$Version = "", [switch]$ValidateOnly ) $ErrorActionPreference = "Stop" Add-Type -AssemblyName System.Net.Http $Root = Split-Path -Parent $MyInvocation.MyCommand.Path $BaseUrl = "https://gitea.davito.synology.me" $Owner = "Davito" $Repository = "SyncVito" $LocalAppData = [Environment]::GetFolderPath("LocalApplicationData") $TokenPaths = @( (Join-Path $LocalAppData "SyncVito\developer\gitea-token.dpapi"), (Join-Path $LocalAppData "MusicBrain\developer\gitea-token.dpapi") ) if ([string]::IsNullOrWhiteSpace($Version)) { $Version = (Get-Content -LiteralPath (Join-Path $Root "version.txt") -Raw -Encoding UTF8).Trim() } if ($Version -notmatch '^\d+\.\d+\.\d+$') { throw "Ungültige Release-Version: $Version" } $Tag = "v$Version" $ReleaseDir = Join-Path $Root "release" $InstallerName = "SyncVitoSetup-$Version.exe" $ChecksumName = "$InstallerName.sha256" $ChangelogName = "CHANGELOG-$Version.md" $AssetPaths = @( (Join-Path $ReleaseDir $InstallerName), (Join-Path $ReleaseDir $ChecksumName), (Join-Path $ReleaseDir $ChangelogName) ) foreach ($assetPath in $AssetPaths) { if (-not (Test-Path -LiteralPath $assetPath -PathType Leaf)) { throw "Release-Datei fehlt: $assetPath" } } $actualHash = (Get-FileHash -LiteralPath $AssetPaths[0] -Algorithm SHA256).Hash.ToLowerInvariant() $checksumText = (Get-Content -LiteralPath $AssetPaths[1] -Raw -Encoding UTF8).Trim() $checksumPattern = '^([0-9a-fA-F]{64})\s+\*?' + [regex]::Escape($InstallerName) + '$' if ($checksumText -notmatch $checksumPattern -or $Matches[1].ToLowerInvariant() -ne $actualHash) { throw "SHA-256-Prüfung fehlgeschlagen. Der Release wird nicht hochgeladen." } $plainToken = $null $tokenPointer = [IntPtr]::Zero if (-not [string]::IsNullOrWhiteSpace($env:GITEA_TOKEN)) { $plainToken = $env:GITEA_TOKEN.Trim() } else { $tokenPath = $TokenPaths | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1 if (-not $tokenPath) { throw "Gitea-Token fehlt. Erwarte GITEA_TOKEN oder eine lokale DPAPI-Token-Datei." } try { $secureToken = (Get-Content -LiteralPath $tokenPath -Raw -Encoding UTF8).Trim() | ConvertTo-SecureString $tokenPointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureToken) $plainToken = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($tokenPointer) } catch { throw "Der lokal gespeicherte Gitea-Token konnte nicht entschlüsselt werden: $($_.Exception.Message)" } } $client = $null function Read-ResponseJson { param([System.Net.Http.HttpResponseMessage]$Response, [string]$Action) $text = $Response.Content.ReadAsStringAsync().GetAwaiter().GetResult() if (-not $Response.IsSuccessStatusCode) { $detail = $text try { $errorPayload = $text | ConvertFrom-Json if ($errorPayload.message) { $detail = $errorPayload.message } } catch { } throw "$Action fehlgeschlagen (HTTP $([int]$Response.StatusCode)): $detail" } if ([string]::IsNullOrWhiteSpace($text)) { return $null } return $text | ConvertFrom-Json } function Send-GiteaJson { param([string]$Method, [string]$Path, [object]$Body, [string]$Action) $request = [System.Net.Http.HttpRequestMessage]::new( [System.Net.Http.HttpMethod]::new($Method), "$BaseUrl/api/v1$Path" ) try { if ($null -ne $Body) { $json = $Body | ConvertTo-Json -Depth 8 -Compress $request.Content = [System.Net.Http.StringContent]::new( $json, [System.Text.Encoding]::UTF8, "application/json" ) } $response = $client.SendAsync($request).GetAwaiter().GetResult() try { return Read-ResponseJson -Response $response -Action $Action } finally { $response.Dispose() } } finally { $request.Dispose() } } try { if ([string]::IsNullOrWhiteSpace($plainToken)) { throw "Der gespeicherte Gitea-Token ist leer." } $client = [System.Net.Http.HttpClient]::new() $client.Timeout = [TimeSpan]::FromMinutes(20) $client.DefaultRequestHeaders.Authorization = [System.Net.Http.Headers.AuthenticationHeaderValue]::new("token", $plainToken) $client.DefaultRequestHeaders.UserAgent.ParseAdd("SyncVito-Release-Publisher/$Version") $client.DefaultRequestHeaders.Accept.ParseAdd("application/json") $repositoryInfo = Send-GiteaJson -Method "GET" -Path "/repos/$Owner/$Repository" -Body $null -Action "Repository-Zugriff prüfen" if (-not $repositoryInfo.permissions.push) { throw "Der Token besitzt keine Schreibrechte für $Owner/$Repository." } if ($ValidateOnly) { Write-Host "Gitea-Publisher ist bereit: $($repositoryInfo.full_name), Version $Version" return } $encodedTag = [Uri]::EscapeDataString($Tag) $release = $null $lookup = [System.Net.Http.HttpRequestMessage]::new( [System.Net.Http.HttpMethod]::Get, "$BaseUrl/api/v1/repos/$Owner/$Repository/releases/tags/$encodedTag" ) try { $lookupResponse = $client.SendAsync($lookup).GetAwaiter().GetResult() try { if ($lookupResponse.IsSuccessStatusCode) { $release = Read-ResponseJson -Response $lookupResponse -Action "Release lesen" } elseif ([int]$lookupResponse.StatusCode -ne 404) { Read-ResponseJson -Response $lookupResponse -Action "Release prüfen" | Out-Null } } finally { $lookupResponse.Dispose() } } finally { $lookup.Dispose() } $releaseBody = (Get-Content -LiteralPath $AssetPaths[2] -Raw -Encoding UTF8).Trim() $metadata = @{ tag_name = $Tag target_commitish = "main" name = "SyncVito $Version" body = $releaseBody draft = $false prerelease = $false } if ($null -eq $release) { Write-Host "Lege Gitea-Release $Tag an …" $release = Send-GiteaJson -Method "POST" -Path "/repos/$Owner/$Repository/releases" -Body $metadata -Action "Release anlegen" } else { Write-Host "Aktualisiere Gitea-Release $Tag …" $release = Send-GiteaJson -Method "PATCH" -Path "/repos/$Owner/$Repository/releases/$($release.id)" -Body $metadata -Action "Release aktualisieren" } if (-not $release.id) { throw "Gitea hat keine Release-ID zurückgegeben." } $localNames = @($AssetPaths | ForEach-Object { [System.IO.Path]::GetFileName($_) }) foreach ($existing in @($release.assets | Where-Object { $_.name -in $localNames })) { Send-GiteaJson -Method "DELETE" -Path "/repos/$Owner/$Repository/releases/$($release.id)/assets/$($existing.id)" -Body $null -Action "Altes Asset entfernen" | Out-Null } foreach ($assetPath in $AssetPaths) { $assetName = [System.IO.Path]::GetFileName($assetPath) Write-Host "Lade hoch: $assetName" $uploadUrl = "$BaseUrl/api/v1/repos/$Owner/$Repository/releases/$($release.id)/assets?name=$([Uri]::EscapeDataString($assetName))" $request = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::Post, $uploadUrl) $multipart = [System.Net.Http.MultipartFormDataContent]::new() $stream = [System.IO.File]::OpenRead($assetPath) $content = [System.Net.Http.StreamContent]::new($stream) $content.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::new("application/octet-stream") $multipart.Add($content, "attachment", $assetName) $request.Content = $multipart try { $response = $client.SendAsync($request).GetAwaiter().GetResult() try { Read-ResponseJson -Response $response -Action "Upload von $assetName" | Out-Null } finally { $response.Dispose() } } finally { $request.Dispose() $multipart.Dispose() $content.Dispose() $stream.Dispose() } } $published = Send-GiteaJson -Method "GET" -Path "/repos/$Owner/$Repository/releases/tags/$encodedTag" -Body $null -Action "Release verifizieren" $publishedNames = @($published.assets | ForEach-Object { $_.name }) foreach ($required in $localNames) { if ($required -notin $publishedNames) { throw "Veröffentlichtes Asset fehlt: $required" } } Write-Host "Gitea-Release erfolgreich veröffentlicht: $($published.html_url)" Write-Host "SHA256: $actualHash" } finally { if ($client) { $client.Dispose() } if ($tokenPointer -ne [IntPtr]::Zero) { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($tokenPointer) } $plainToken = $null Remove-Variable plainToken -ErrorAction SilentlyContinue }