[CmdletBinding()] param( [Parameter(Mandatory = $true)] [ValidatePattern('^v\d+\.\d+\.\d+.*$')] [string]$Tag, [string]$NotesPath, [string]$BaseUrl, [string]$Owner, [string]$Repo, [string]$ApiToken, [string]$DistRoot = (Join-Path (Split-Path -Parent $PSScriptRoot) 'dist') ) $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest function Invoke-GitCapture { param( [Parameter(Mandatory = $true)] [string[]]$ArgumentList ) $output = & git @ArgumentList 2>&1 if ($LASTEXITCODE -ne 0) { throw "git $($ArgumentList -join ' ') failed: $($output -join "`n")" } return (($output | ForEach-Object { [string]$_ }) -join "`n").Trim() } function Resolve-GiteaRepositoryContext { param( [string]$RemoteUrl ) if ([string]::IsNullOrWhiteSpace($RemoteUrl)) { $RemoteUrl = Invoke-GitCapture -ArgumentList @('remote', 'get-url', 'origin') } $normalized = $RemoteUrl.Trim() if ($normalized -match '^(https?://[^/]+)/([^/]+)/([^/]+?)(?:\.git)?$') { return [pscustomobject]@{ BaseUrl = $Matches[1] Owner = $Matches[2] Repo = $Matches[3] } } if ($normalized -match '^ssh://git@([^/:]+)(?::(\d+))?/([^/]+)/([^/]+?)(?:\.git)?$') { $scheme = 'https' $giteaHost = $Matches[1] $port = if ([string]::IsNullOrWhiteSpace($Matches[2])) { '' } else { ":$($Matches[2])" } return [pscustomobject]@{ BaseUrl = "${scheme}://$giteaHost$port" Owner = $Matches[3] Repo = $Matches[4] } } throw "Could not parse Gitea origin remote URL: $normalized" } function Resolve-ApiToken { param( [string]$BaseUrl, [string]$Owner, [string]$Repo, [string]$ExplicitToken ) if (-not [string]::IsNullOrWhiteSpace($ExplicitToken)) { return $ExplicitToken } foreach ($name in @('GITEA_API_TOKEN', 'GITEA_TOKEN', 'GITHUB_TOKEN')) { $value = [Environment]::GetEnvironmentVariable($name) if (-not [string]::IsNullOrWhiteSpace($value)) { return $value } } $uri = [Uri]$BaseUrl $lines = @( 'protocol=https' "host=$($uri.Authority)" "path=$Owner/$Repo.git" '' ) | git credential fill $passwordLine = $lines | Where-Object { $_ -like 'password=*' } | Select-Object -First 1 if ($null -eq $passwordLine) { throw 'Could not resolve a Gitea API token from the current environment or git credential manager.' } return $passwordLine.Substring(9) } function Invoke-GiteaApi { param( [Parameter(Mandatory = $true)] [string]$Method, [Parameter(Mandatory = $true)] [string]$Uri, [string]$Token, [object]$Body ) $headers = @{ Authorization = "token $Token" } $invokeArgs = @{ Method = $Method Uri = $Uri Headers = $headers ErrorAction = 'Stop' } if ($PSBoundParameters.ContainsKey('Body')) { $invokeArgs.ContentType = 'application/json' $invokeArgs.Body = ($Body | ConvertTo-Json -Depth 10) } try { return Invoke-RestMethod @invokeArgs } catch { $response = $_.Exception.Response if ($null -eq $response) { throw } $statusCode = [int]$response.StatusCode $payload = '' if ($response -is [System.Net.Http.HttpResponseMessage]) { if ($null -ne $_.ErrorDetails -and -not [string]::IsNullOrWhiteSpace($_.ErrorDetails.Message)) { $payload = $_.ErrorDetails.Message } elseif ($null -ne $response.Content) { try { $payload = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult() } catch { $payload = '' } } } elseif ($response.PSObject.Methods.Name -contains 'GetResponseStream') { $stream = $response.GetResponseStream() if ($null -ne $stream) { $reader = [System.IO.StreamReader]::new($stream) try { $payload = $reader.ReadToEnd() } finally { $reader.Dispose() $stream.Dispose() } } } throw "Gitea API $Method $Uri failed with HTTP ${statusCode}: $payload" } } function Find-ReleaseArtifacts { param( [Parameter(Mandatory = $true)] [string]$RootPath ) if (-not (Test-Path -LiteralPath $RootPath)) { throw "dist root not found: $RootPath" } $zip = Get-ChildItem -LiteralPath $RootPath -File -Filter 'MercuryToolbox-*-ReleaseFast.zip' | Sort-Object LastWriteTimeUtc -Descending | Select-Object -First 1 if ($null -eq $zip) { throw "No packaged archive found under $RootPath. Run just package first." } $packageRoot = Join-Path $RootPath $zip.BaseName if (-not (Test-Path -LiteralPath $packageRoot -PathType Container)) { throw "Package directory matching archive was not found: $packageRoot" } $hashPath = Join-Path $packageRoot 'SHA256SUMS.txt' $manifestPath = Join-Path $packageRoot 'mercury-toolbox-package.json' foreach ($requiredPath in @($hashPath, $manifestPath)) { if (-not (Test-Path -LiteralPath $requiredPath -PathType Leaf)) { throw "Expected packaged artifact not found: $requiredPath" } } return @($zip.FullName, $hashPath, $manifestPath) } $workspaceRoot = Split-Path -Parent $PSScriptRoot Push-Location -LiteralPath $workspaceRoot try { $context = Resolve-GiteaRepositoryContext if ([string]::IsNullOrWhiteSpace($BaseUrl)) { $BaseUrl = $context.BaseUrl } if ([string]::IsNullOrWhiteSpace($Owner)) { $Owner = $context.Owner } if ([string]::IsNullOrWhiteSpace($Repo)) { $Repo = $context.Repo } if ([string]::IsNullOrWhiteSpace($NotesPath)) { $NotesPath = Join-Path $workspaceRoot (Join-Path 'docs\releases' "$Tag.md") } $ApiToken = Resolve-ApiToken -BaseUrl $BaseUrl -Owner $Owner -Repo $Repo -ExplicitToken $ApiToken if (-not (Test-Path -LiteralPath $NotesPath -PathType Leaf)) { throw "Release notes not found: $NotesPath" } $notes = Get-Content -Raw -LiteralPath $NotesPath $releaseName = "Mercury Toolbox $Tag" $apiBase = "$BaseUrl/api/v1/repos/$Owner/$Repo" $targetCommitish = Invoke-GitCapture -ArgumentList @('rev-parse', 'HEAD') $release = $null try { $release = Invoke-GiteaApi -Method 'GET' -Uri "$apiBase/releases/tags/$Tag" -Token $ApiToken } catch { if (-not $_.Exception.Message.Contains('HTTP 404')) { throw } } if ($null -eq $release) { $release = Invoke-GiteaApi -Method 'POST' -Uri "$apiBase/releases" -Token $ApiToken -Body @{ tag_name = $Tag target_commitish = $targetCommitish name = $releaseName body = $notes draft = $false prerelease = $false } } else { $release = Invoke-GiteaApi -Method 'PATCH' -Uri "$apiBase/releases/$($release.id)" -Token $ApiToken -Body @{ tag_name = $Tag target_commitish = $targetCommitish name = $releaseName body = $notes draft = $false prerelease = $false } } $assets = @() if ($release.PSObject.Properties.Name -contains 'assets' -and $null -ne $release.assets) { $assets = @($release.assets) } foreach ($assetPath in (Find-ReleaseArtifacts -RootPath $DistRoot)) { $assetName = [System.IO.Path]::GetFileName($assetPath) $existing = $assets | Where-Object { $_.name -eq $assetName } | Select-Object -First 1 if ($null -ne $existing) { Invoke-GiteaApi -Method 'DELETE' -Uri "$apiBase/releases/$($release.id)/assets/$($existing.id)" -Token $ApiToken | Out-Null } $uploadHeaders = @{ Authorization = "token $ApiToken" } $uploadUri = "$apiBase/releases/$($release.id)/assets?name=$([Uri]::EscapeDataString($assetName))" Invoke-RestMethod -Method 'POST' -Uri $uploadUri -Headers $uploadHeaders -Form @{ attachment = Get-Item -LiteralPath $assetPath } -ErrorAction Stop | Out-Null Write-Host "Uploaded release asset: $assetName" } Write-Host "Release published: $($release.html_url)" } finally { Pop-Location }