<# .SYNOPSIS Baut, signiert und veröffentlicht eine neue VitosUpdater-Version auf Gitea. .DESCRIPTION 1. Prüft Git-Zustand, cargo check, cargo clippy, cargo test und svelte-check 2. Erhöht die Version in tauri.conf.json, Cargo.toml und package.json 3. Baut den NSIS-Installer samt signiertem Updater-Artefakt (.sig) 4. Erzeugt latest.json 5. Committet die Version, setzt den Tag vX.Y.Z und pusht beides 6. Erstellt das Gitea-Release vX.Y.Z und lädt Installer, .sig und latest.json hoch 7. Aktualisiert das rollende Release "latest" 8. Prüft die öffentliche latest.json und die Installer-URL Voraussetzungen: Umgebungsvariable GITEA_TOKEN (oder Datei .env.local mit GITEA_TOKEN=...), Signierschlüssel unter %USERPROFILE%\.vitosupdater\updater.key (oder TAURI_SIGNING_PRIVATE_KEY[_PATH]). .PARAMETER Bump Art der Versionserhöhung: patch (Standard), minor oder major. .PARAMETER Version Explizite Version x.y.z (überschreibt -Bump). .PARAMETER NoPublish Nur bauen und latest.json erzeugen. Es wird nichts committet, getaggt, gepusht oder hochgeladen; die Versionsänderung wird danach zurückgenommen. Die Ergebnisse liegen in release-out\. .PARAMETER SkipChecks Überspringt cargo check/clippy/test und svelte-check (nur für schnelle Wiederholungen). #> [CmdletBinding()] param( [ValidateSet('patch', 'minor', 'major')] [string]$Bump = 'patch', [string]$Version, [switch]$NoPublish, [switch]$SkipChecks ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' [Console]::OutputEncoding = [Text.Encoding]::UTF8 $OutputEncoding = [Text.Encoding]::UTF8 try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch { } $GiteaBase = 'https://gitea.davito.synology.me' $Owner = 'Davito' $Repo = 'VitosUpdater' $Branch = 'main' $Root = Split-Path -Parent $PSScriptRoot $OutDir = Join-Path $Root 'release-out' $Utf8NoBom = New-Object Text.UTF8Encoding($false) $VersionFiles = @('src-tauri/tauri.conf.json', 'src-tauri/Cargo.toml', 'src-tauri/Cargo.lock', 'package.json', 'package-lock.json') $script:Token = $null $script:Committed = $false $script:Bumped = $false function Write-Step([string]$Text) { Write-Host ''; Write-Host "==> $Text" -ForegroundColor Cyan } function Write-Ok([string]$Text) { Write-Host " [OK] $Text" -ForegroundColor Green } function Write-Info([string]$Text) { Write-Host " $Text" -ForegroundColor Gray } function Invoke-Native([string]$Description, [scriptblock]$Command) { & $Command if ($LASTEXITCODE -ne 0) { throw "$Description ist fehlgeschlagen (Exit-Code $LASTEXITCODE)." } } function Get-GitOutput([string[]]$GitArgs) { $result = & git @GitArgs if ($LASTEXITCODE -ne 0) { throw "git $($GitArgs -join ' ') ist fehlgeschlagen (Exit-Code $LASTEXITCODE)." } return $result } function Read-Token { if ($env:GITEA_TOKEN) { return $env:GITEA_TOKEN.Trim() } $file = Join-Path $Root '.env.local' if (Test-Path $file) { foreach ($line in Get-Content $file -Encoding UTF8) { if ($line -match '^\s*GITEA_TOKEN\s*=\s*(.+?)\s*$') { return $Matches[1].Trim('"', "'") } } } return $null } function Show-TokenHelp { Write-Host '' Write-Host 'Es wurde kein Gitea-Zugriffstoken gefunden.' -ForegroundColor Yellow Write-Host ' 1. In Gitea anmelden: Einstellungen -> Anwendungen -> Zugriffstoken' Write-Host ' 2. Neues Token erzeugen, Berechtigung: Repository = Lesen und Schreiben (write:repository)' Write-Host ' 3. Token hinterlegen, entweder' Write-Host ' dauerhaft: setx GITEA_TOKEN "" (danach neues Terminal öffnen)' Write-Host " oder lokal: Datei .env.local im Projektordner mit der Zeile GITEA_TOKEN=" Write-Host ' (.env.local ist in .gitignore eingetragen und wird nie eingecheckt.)' } function Get-CurrentVersion { $conf = Get-Content (Join-Path $Root 'src-tauri/tauri.conf.json') -Raw -Encoding UTF8 | ConvertFrom-Json return [string]$conf.version } function Get-NextVersion([string]$Current) { if ($Version) { if ($Version -notmatch '^\d+\.\d+\.\d+$') { throw "Ungültige Version '$Version'. Erwartet wird x.y.z." } return $Version } if ($Current -notmatch '^(\d+)\.(\d+)\.(\d+)$') { throw "Aktuelle Version '$Current' ist keine einfache SemVer-Version." } $major = [int]$Matches[1]; $minor = [int]$Matches[2]; $patch = [int]$Matches[3] switch ($Bump) { 'major' { return '{0}.0.0' -f ($major + 1) } 'minor' { return '{0}.{1}.0' -f $major, ($minor + 1) } default { return '{0}.{1}.{2}' -f $major, $minor, ($patch + 1) } } } function Set-FirstMatch([string]$Path, [string]$Pattern, [string]$Replacement) { $full = Join-Path $Root $Path $text = [IO.File]::ReadAllText($full, $Utf8NoBom) $regex = New-Object Text.RegularExpressions.Regex($Pattern, [Text.RegularExpressions.RegexOptions]::Multiline) if (-not $regex.IsMatch($text)) { throw "Versionszeile in $Path nicht gefunden." } [IO.File]::WriteAllText($full, $regex.Replace($text, $Replacement, 1), $Utf8NoBom) } function Set-ProjectVersion([string]$New) { Set-FirstMatch 'src-tauri/tauri.conf.json' '^( "version": ")[^"]+(")' ('${1}' + $New + '${2}') Set-FirstMatch 'src-tauri/Cargo.toml' '^(version = ")[^"]+(")' ('${1}' + $New + '${2}') Invoke-Native 'npm version' { npm version $New --no-git-tag-version --allow-same-version | Out-Null } } function Undo-VersionChange { if ($script:Bumped -and -not $script:Committed) { Write-Info 'Versionsänderung wird zurückgenommen ...' & git checkout -- @VersionFiles 2>$null | Out-Null $script:Bumped = $false } } function Get-ReleaseNotes([string]$NewVersion) { $lastTag = $null $found = & git describe --tags --abbrev=0 --match 'v*' 2>$null if ($LASTEXITCODE -eq 0 -and $found) { $lastTag = [string]$found } $range = 'HEAD' if ($lastTag) { $range = "$lastTag..HEAD" } $lines = & git -c core.quotepath=off log $range --no-merges --pretty=format:'- %s (%h)' --grep='^Release v' --invert-grep if ($LASTEXITCODE -ne 0) { throw 'git log ist fehlgeschlagen.' } $text = ($lines | Where-Object { $_ }) -join "`n" if (-not $text) { $text = '- Interne Verbesserungen und Fehlerbehebungen' } return $text } # ---------- Gitea-API ---------- function Invoke-Gitea([string]$Method, [string]$Path, $Body = $null) { $uri = "$GiteaBase/api/v1/repos/$Owner/$Repo$Path" $headers = @{ Authorization = "token $script:Token"; Accept = 'application/json' } if ($null -ne $Body) { $bytes = [Text.Encoding]::UTF8.GetBytes(($Body | ConvertTo-Json -Depth 6)) return Invoke-RestMethod -Method $Method -Uri $uri -Headers $headers -ContentType 'application/json; charset=utf-8' -Body $bytes } return Invoke-RestMethod -Method $Method -Uri $uri -Headers $headers } function Get-ReleaseByTag([string]$Tag) { try { return Invoke-Gitea 'GET' "/releases/tags/$Tag" } catch { $status = $null if ($_.Exception.PSObject.Properties.Name -contains 'Response' -and $_.Exception.Response) { $status = [int]$_.Exception.Response.StatusCode } if ($status -eq 404) { return $null } throw } } function Send-Asset([int]$ReleaseId, [string]$File, [string]$Name) { $url = "$GiteaBase/api/v1/repos/$Owner/$Repo/releases/$ReleaseId/assets?name=" + [uri]::EscapeDataString($Name) # Das Token wird über stdin an curl übergeben, damit es nicht in der Prozessliste erscheint. $config = 'header = "Authorization: token ' + $script:Token + '"' $response = $config | & curl.exe --silent --show-error --fail --config - -X POST -F "attachment=@$File" $url if ($LASTEXITCODE -ne 0) { throw "Upload von '$Name' ist fehlgeschlagen (curl-Exit-Code $LASTEXITCODE)." } Write-Ok "Hochgeladen: $Name ($([math]::Round((Get-Item $File).Length / 1MB, 1)) MB)" } function Clear-ReleaseAssets([int]$ReleaseId) { $release = Invoke-Gitea 'GET' "/releases/$ReleaseId" foreach ($asset in @($release.assets)) { Invoke-Gitea 'DELETE' "/releases/$ReleaseId/assets/$($asset.id)" | Out-Null Write-Info "Altes Asset entfernt: $($asset.name)" } } function Get-HttpStatus([string]$Url) { $code = & curl.exe --silent --location --head --output NUL --write-out '%{http_code}' $Url if ($code -eq '405' -or $code -eq '000') { $code = & curl.exe --silent --location --range 0-0 --output NUL --write-out '%{http_code}' $Url } return [string]$code } # ---------- Ablauf ---------- Set-Location $Root $started = Get-Date try { Write-Host 'VitosUpdater – Release-Skript' -ForegroundColor White # --- 1. Voraussetzungen und Prüfungen --- Write-Step '1/8 Voraussetzungen und Qualitätsprüfungen' if (-not $NoPublish) { $script:Token = Read-Token if (-not $script:Token) { Show-TokenHelp; throw 'GITEA_TOKEN fehlt.' } Write-Ok 'Gitea-Token gefunden (wird nicht angezeigt)' } $keyPath = Join-Path $env:USERPROFILE '.vitosupdater\updater.key' if (-not $env:TAURI_SIGNING_PRIVATE_KEY) { # Die Tauri-CLI liest nur TAURI_SIGNING_PRIVATE_KEY (Pfad oder Inhalt); _PATH wird hier darauf abgebildet. $source = $env:TAURI_SIGNING_PRIVATE_KEY_PATH if (-not $source) { $source = $keyPath } if (-not (Test-Path $source)) { throw "Signierschlüssel nicht gefunden: $source. Erzeugen mit: cargo tauri signer generate -w `"$keyPath`" (öffentlichen Schlüssel danach in tauri.conf.json eintragen)." } $env:TAURI_SIGNING_PRIVATE_KEY = (Get-Content $source -Raw -Encoding UTF8).Trim() } Write-Ok 'Signierschlüssel gefunden (wird nicht angezeigt)' $dirty = & git status --porcelain if ($LASTEXITCODE -ne 0) { throw 'git status ist fehlgeschlagen. Ist dies ein Git-Repository?' } if ($dirty) { Write-Host ($dirty -join "`n") -ForegroundColor Yellow throw 'Das Arbeitsverzeichnis ist nicht sauber. Bitte zuerst committen oder verwerfen.' } $branch = (Get-GitOutput @('rev-parse', '--abbrev-ref', 'HEAD') | Select-Object -First 1).Trim() if ($branch -ne $Branch) { throw "Es muss der Branch '$Branch' ausgecheckt sein (aktuell: '$branch')." } if (-not $NoPublish) { Invoke-Native 'git fetch' { git fetch origin --tags --force --quiet } $remoteMain = & git rev-parse --verify --quiet "refs/remotes/origin/$Branch" if ($LASTEXITCODE -eq 0 -and $remoteMain) { $behind = [int](Get-GitOutput @('rev-list', '--count', "HEAD..origin/$Branch") | Select-Object -First 1) if ($behind -gt 0) { throw "Der lokale Branch liegt $behind Commit(s) hinter origin/$Branch. Bitte zuerst git pull ausführen." } } } Write-Ok 'Git-Zustand ist sauber' if ($SkipChecks) { Write-Info 'Prüfungen werden übersprungen (-SkipChecks).' } else { Push-Location (Join-Path $Root 'src-tauri') try { Invoke-Native 'cargo check' { cargo check --all-targets } Write-Ok 'cargo check' Invoke-Native 'cargo clippy' { cargo clippy --all-targets -- -D warnings } Write-Ok 'cargo clippy (ohne Warnungen)' Invoke-Native 'cargo test' { cargo test } Write-Ok 'cargo test' } finally { Pop-Location } Invoke-Native 'svelte-check' { npm run check } Write-Ok 'svelte-check' } # --- 2. Version erhöhen --- Write-Step '2/8 Version erhöhen' $current = Get-CurrentVersion $new = Get-NextVersion $current if (-not $NoPublish) { $existingTag = & git tag --list "v$new" if ($existingTag) { throw "Der Tag v$new existiert bereits. Bitte eine höhere Version wählen." } if ([version]$new -le [version]$current) { throw "Die neue Version $new ist nicht größer als die aktuelle Version $current." } } $script:Bumped = $true Set-ProjectVersion $new Write-Ok "Version $current -> $new (tauri.conf.json, Cargo.toml, package.json)" # --- 3. Bauen --- Write-Step '3/8 Installer bauen und signieren (cargo tauri build)' $env:CI = 'true' Invoke-Native 'cargo tauri build' { cargo tauri build } $nsisDir = Join-Path $Root 'src-tauri/target/release/bundle/nsis' $installer = Get-ChildItem $nsisDir -Filter "*_${new}_*-setup.exe" | Select-Object -First 1 if (-not $installer) { throw "Installer für Version $new wurde nicht gefunden in $nsisDir." } $sigFile = "$($installer.FullName).sig" if (-not (Test-Path $sigFile)) { throw "Signaturdatei fehlt: $sigFile. Wurde createUpdaterArtifacts aktiviert und der Schlüssel gefunden?" } Write-Ok "Installer: $($installer.Name) ($([math]::Round($installer.Length / 1MB, 1)) MB)" Write-Ok 'Signatur (.sig) erzeugt' # --- 4. latest.json --- Write-Step '4/8 latest.json erzeugen' $notes = Get-ReleaseNotes $new $assetUrl = "$GiteaBase/$Owner/$Repo/releases/download/v$new/$($installer.Name)" $manifest = [ordered]@{ version = $new notes = $notes pub_date = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'") platforms = [ordered]@{ 'windows-x86_64' = [ordered]@{ signature = (Get-Content $sigFile -Raw -Encoding UTF8).Trim() url = $assetUrl size = [int64]$installer.Length } } } if (Test-Path $OutDir) { Remove-Item $OutDir -Recurse -Force } New-Item -ItemType Directory -Path $OutDir | Out-Null $latestFile = Join-Path $OutDir 'latest.json' [IO.File]::WriteAllText($latestFile, ($manifest | ConvertTo-Json -Depth 6), $Utf8NoBom) Copy-Item $installer.FullName $OutDir Copy-Item $sigFile $OutDir $outInstaller = Join-Path $OutDir $installer.Name $outSig = Join-Path $OutDir "$($installer.Name).sig" Write-Ok "latest.json -> $latestFile" if ($NoPublish) { Undo-VersionChange Write-Step 'Fertig (-NoPublish)' Write-Ok "Version $new gebaut. Dateien liegen in: $OutDir" return } # --- 5. Commit, Tag, Push --- Write-Step "5/8 Commit, Tag v$new und Push" Invoke-Native 'git add' { git add -- @VersionFiles } Invoke-Native 'git commit' { git commit --quiet -m "Release v$new" } $script:Committed = $true Invoke-Native 'git tag' { git tag -a "v$new" -m "Release v$new" } Invoke-Native 'git push (Branch)' { git push origin $Branch } Invoke-Native 'git push (Tag)' { git push origin "v$new" } Write-Ok "Commit und Tag v$new gepusht" # --- 6. Gitea-Release --- Write-Step "6/8 Gitea-Release v$new erstellen" $release = Get-ReleaseByTag "v$new" if ($release) { Write-Info 'Release existiert bereits, Assets werden ersetzt.' Clear-ReleaseAssets $release.id } else { $release = Invoke-Gitea 'POST' '/releases' @{ tag_name = "v$new" target_commitish = $Branch name = "VitosUpdater $new" body = $notes draft = $false prerelease = $false } } Write-Ok "Release angelegt: $($release.html_url)" Send-Asset $release.id $outInstaller $installer.Name Send-Asset $release.id $outSig "$($installer.Name).sig" Send-Asset $release.id $latestFile 'latest.json' # --- 7. Rollendes Release "latest" --- Write-Step '7/8 Rollendes Release "latest" aktualisieren' Invoke-Native 'git tag latest' { git tag -f latest HEAD | Out-Null } Invoke-Native 'git push latest' { git push --force origin refs/tags/latest } $rolling = Get-ReleaseByTag 'latest' $rollingBody = "Aktuelle Version: **$new**`n`n$notes" if ($rolling) { Invoke-Gitea 'PATCH' "/releases/$($rolling.id)" @{ name = "Aktuellste Version ($new)"; body = $rollingBody } | Out-Null Clear-ReleaseAssets $rolling.id } else { $rolling = Invoke-Gitea 'POST' '/releases' @{ tag_name = 'latest' target_commitish = $Branch name = "Aktuellste Version ($new)" body = $rollingBody draft = $false prerelease = $false } } Send-Asset $rolling.id $outInstaller $installer.Name Send-Asset $rolling.id $outSig "$($installer.Name).sig" Send-Asset $rolling.id $latestFile 'latest.json' Write-Ok 'Rollendes Release aktualisiert' # --- 8. Prüfung --- Write-Step '8/8 Veröffentlichung prüfen' $latestUrl = "$GiteaBase/$Owner/$Repo/releases/download/latest/latest.json" $raw = Invoke-WebRequest -Uri $latestUrl -UseBasicParsing $content = $raw.Content if ($content -is [byte[]]) { $content = [Text.Encoding]::UTF8.GetString($content) } $remote = $content | ConvertFrom-Json if ($remote.version -ne $new) { throw "Öffentliche latest.json enthält Version $($remote.version), erwartet wurde $new." } Write-Ok "latest.json öffentlich erreichbar, Version $($remote.version)" $platform = $remote.platforms.'windows-x86_64' $status = Get-HttpStatus $platform.url if ($status -ne '200' -and $status -ne '206') { throw "Installer-URL antwortet mit HTTP ${status}: $($platform.url)" } Write-Ok "Installer-URL antwortet mit HTTP $status" $rollingStatus = Get-HttpStatus "$GiteaBase/$Owner/$Repo/releases/download/latest/$($installer.Name)" if ($rollingStatus -ne '200' -and $rollingStatus -ne '206') { throw "Installer im Release 'latest' antwortet mit HTTP $rollingStatus." } Write-Ok "Installer im Release 'latest' antwortet mit HTTP $rollingStatus" $elapsed = [math]::Round(((Get-Date) - $started).TotalMinutes, 1) Write-Host '' Write-Host '==================== Zusammenfassung ====================' -ForegroundColor Green Write-Host " Version: $new (vorher $current)" Write-Host " Release: $($release.html_url)" Write-Host " Rollendes Release: $GiteaBase/$Owner/$Repo/releases/tag/latest" Write-Host " Update-Endpunkt: $latestUrl" Write-Host " Installer: $($platform.url)" Write-Host " Dauer: $elapsed Minuten" Write-Host '==========================================================' -ForegroundColor Green } catch { Write-Host '' Write-Host "FEHLER: $($_.Exception.Message)" -ForegroundColor Red if ($script:Committed) { Write-Host 'Der Versions-Commit wurde bereits lokal erstellt. Nach der Fehlerbehebung zurücknehmen mit: git tag -d v; git reset --hard HEAD~1 (bereits gepushte Teile ggf. auf Gitea prüfen), danach das Skript erneut starten.' -ForegroundColor Yellow } else { Undo-VersionChange } exit 1 }