#requires -Version 5.1 [CmdletBinding()] param( [Parameter(Mandatory)] [ValidatePattern('^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$')] [string]$Version, [string]$Notes = "Decky $Version", [string]$ForgejoBaseUrl = 'https://git.elijahkuntz.com', [string]$Repository = 'Elijah/Decky', [string]$Token = $(if ($env:FORGEJO_TOKEN) { $env:FORGEJO_TOKEN } else { $env:FORGEJO_PAT }) ) $ErrorActionPreference = 'Stop' $repositoryRoot = Split-Path -Parent $PSScriptRoot $releaseDirectory = Join-Path $repositoryRoot "artifacts\release\v$Version" $latestJsonPath = Join-Path $releaseDirectory 'latest.json' if (-not (Test-Path -LiteralPath $latestJsonPath)) { throw "Run Prepare-Release.ps1 first: $latestJsonPath is missing." } function Invoke-GitCommand { param([Parameter(Mandatory)][string[]]$ArgumentList) $output = @(& git -C $repositoryRoot @ArgumentList 2>&1) if ($LASTEXITCODE) { throw "git $($ArgumentList -join ' ') failed: $($output -join [Environment]::NewLine)" } return $output } $branch = ((Invoke-GitCommand @('rev-parse', '--abbrev-ref', 'HEAD')) | Select-Object -Last 1).Trim() if ($branch -ne 'main') { throw "Releases must be published from main; the current branch is '$branch'." } $workingChanges = @(Invoke-GitCommand @('status', '--porcelain')) if ($workingChanges.Count) { throw "Commit or discard all source changes before publishing:`n$($workingChanges -join [Environment]::NewLine)" } Invoke-GitCommand @('fetch', 'origin', 'main') | Out-Null $localHead = ((Invoke-GitCommand @('rev-parse', 'HEAD')) | Select-Object -Last 1).Trim() $remoteHead = ((Invoke-GitCommand @('rev-parse', 'origin/main')) | Select-Object -Last 1).Trim() if ($localHead -ne $remoteHead) { throw 'Local main does not exactly match origin/main. Pull/rebase or push the source before publishing.' } if (-not $Token) { Write-Host 'A Forgejo personal access token is required to create the release.' -ForegroundColor Cyan Write-Host 'The token is used only by this process and is not saved by Decky.' -ForegroundColor DarkGray $secureToken = Read-Host 'Forgejo personal access token' -AsSecureString $Token = [Net.NetworkCredential]::new('', $secureToken).Password } if (-not $Token) { throw 'No Forgejo personal access token was entered.' } $headers = @{ Authorization = "token $Token"; Accept = 'application/json' } $apiRoot = "$($ForgejoBaseUrl.TrimEnd('/'))/api/v1/repos/$Repository" $tag = "v$Version" $releaseBody = @{ tag_name = $tag target_commitish = 'main' name = "Decky $Version" body = $Notes draft = $false prerelease = $Version.Contains('-') } | ConvertTo-Json try { $release = Invoke-RestMethod -Method Get -Uri "$apiRoot/releases/tags/$tag" -Headers $headers } catch { if ($_.Exception.Response.StatusCode.value__ -ne 404) { throw } $release = Invoke-RestMethod -Method Post -Uri "$apiRoot/releases" -Headers $headers -ContentType 'application/json' -Body $releaseBody } $assets = Get-ChildItem -LiteralPath $releaseDirectory -File foreach ($asset in $assets) { $existing = @($release.assets | Where-Object name -eq $asset.Name) if ($existing.Count) { Write-Host "Release asset already exists, skipping: $($asset.Name)" continue } Add-Type -AssemblyName System.Net.Http $client = New-Object Net.Http.HttpClient try { $client.DefaultRequestHeaders.Authorization = New-Object Net.Http.Headers.AuthenticationHeaderValue('token', $Token) $form = New-Object Net.Http.MultipartFormDataContent $fileContent = New-Object Net.Http.ByteArrayContent(,[IO.File]::ReadAllBytes($asset.FullName)) $fileContent.Headers.ContentType = New-Object Net.Http.Headers.MediaTypeHeaderValue('application/octet-stream') $form.Add($fileContent, 'attachment', $asset.Name) $response = $client.PostAsync("$apiRoot/releases/$($release.id)/assets?name=$([Uri]::EscapeDataString($asset.Name))", $form).GetAwaiter().GetResult() if (-not $response.IsSuccessStatusCode) { throw "Asset upload failed ($([int]$response.StatusCode)): $($response.Content.ReadAsStringAsync().GetAwaiter().GetResult())" } } finally { if ($form) { $form.Dispose() } $client.Dispose() } Write-Host "Uploaded $($asset.Name)" } $encodedMetadata = [Convert]::ToBase64String([IO.File]::ReadAllBytes($latestJsonPath)) $contentsUri = "$apiRoot/contents/releases/latest.json?ref=main" $sha = $null try { $sha = (Invoke-RestMethod -Method Get -Uri $contentsUri -Headers $headers).sha } catch { if ($_.Exception.Response.StatusCode.value__ -ne 404) { throw } } $contentBody = @{ branch = 'main' message = "chore(release): publish Decky $Version updater metadata" content = $encodedMetadata } if ($sha) { $contentBody.sha = $sha } Invoke-RestMethod -Method Put -Uri "$apiRoot/contents/releases/latest.json" -Headers $headers -ContentType 'application/json' -Body ($contentBody | ConvertTo-Json) | Out-Null Write-Host "Published Decky $Version and updated releases/latest.json." Invoke-GitCommand @('fetch', 'origin', 'main') | Out-Null $publishedHead = ((Invoke-GitCommand @('rev-parse', 'origin/main')) | Select-Object -Last 1).Trim() if ($publishedHead -ne $localHead) { $mergeBase = ((Invoke-GitCommand @('merge-base', $localHead, $publishedHead)) | Select-Object -Last 1).Trim() if ($mergeBase -ne $localHead) { throw 'The release was published, but origin/main also changed independently. Fetch and reconcile it manually.' } Invoke-GitCommand @('merge', '--ff-only', 'origin/main') | Out-Null } Write-Host 'Local main is synchronized with the Forgejo release metadata commit.'