#requires -Version 5.1 <# .SYNOPSIS Build the dockmv container image + release binaries; push the image to the Gitea registry and attach the binaries to a Gitea release. .DESCRIPTION Builds the Docker image from the repo Dockerfile, tags it for the Gitea registry (git.azuze.fr by default), logs in, and pushes one or more tags. It also cross-compiles the release binaries (scripts/build-release.ps1) from the same commit, so both artifacts carry the same -Tag. Archives cannot live in a container registry, so they're attached to the Gitea release for that tag instead (creating the release if it does not exist) — on by default, since a run that builds binaries and doesn't publish them is the unusual case. Pass -NoPublishRelease to build locally without uploading. -BinariesOnly ships just the binaries: no docker build, no docker login, no image push. Credentials are read, in order of precedence: 1. -Username / -Password parameters 2. $env:GITEA_USER / $env:GITEA_TOKEN 3. Interactive prompt (token is read as a SecureString) Use a Gitea access token (Settings -> Applications) as the password, not your account password. The image push needs package read/write scope; the release upload needs repository write scope (`write:repository`). .EXAMPLE ./scripts/publish.ps1 Full release: push the image (:latest plus the git-describe tag) and attach every dist/ archive to the matching Gitea release. .EXAMPLE ./scripts/publish.ps1 -Tag v1.2.0 -NoPublishRelease Push the image and build dist/ binaries locally, but don't upload them. .EXAMPLE ./scripts/publish.ps1 -BinariesOnly -Tag v1.2.0 Binaries only — build them and attach them to release v1.2.0. Docker is never invoked, so this works with Docker Desktop stopped. .EXAMPLE ./scripts/publish.ps1 -BinariesOnly -NoBinaryBuild -Tag v1.2.0 Retry a failed upload: attach the archives already in dist/ without rebuilding. .EXAMPLE ./scripts/publish.ps1 -NoBinaries Container only — no cross-compile, nothing to publish as a release. .EXAMPLE $env:GITEA_USER = "kawa"; $env:GITEA_TOKEN = "xxxx"; ./scripts/publish.ps1 -SkipLogin:$false #> [CmdletBinding()] param( # Registry host (Gitea instance). [string]$Registry = "git.azuze.fr", # Owner / organisation that holds the package and the repo. [string]$Owner = "kawa", # Image name. [string]$Image = "dockmv", # Repository name holding the releases. The image and the repo are not named # the same here, so this is separate from -Image. [string]$Repo = "DockMV", # Primary tag. Defaults to `git describe --tags --always --dirty`. [string]$Tag, # Also push :latest. On by default. [switch]$NoLatest, # Registry username. Falls back to $env:GITEA_USER then a prompt. [string]$Username, # Registry token/password. Falls back to $env:GITEA_TOKEN then a prompt. [string]$Password, # Skip the image build and only push existing local tags. [switch]$NoBuild, # Skip docker login (assume already authenticated). [switch]$SkipLogin, # Skip cross-compiling the release binaries. [switch]$NoBinaries, # os/arch pairs to cross-compile (passed to build-release.ps1). [string[]]$Targets = @("linux/amd64", "linux/arm64", "darwin/arm64", "darwin/amd64", "windows/amd64"), # Skip the npm UI build inside build-release.ps1 and reuse the committed # internal/webui/dist. [switch]$SkipUi, # Reuse the archives already in dist/ instead of re-running the build. For # retrying a failed upload without paying for the build again. [switch]$NoBinaryBuild, # Ship only the binaries: no docker build, login or push. [switch]$BinariesOnly, # Skip attaching the release archives to the Gitea release for $Tag. The # upload happens by default whenever binaries are built. [switch]$NoPublishRelease, # owner/repo holding the release. Defaults to $Owner/$Repo. [string]$ReleaseRepo, # Gitea base URL for the API. Defaults to https://. [string]$ApiBase, # Replace release attachments that already exist under the same name. [switch]$Force ) $ErrorActionPreference = "Stop" function Invoke-Checked { param([Parameter(Mandatory)][string]$Exe, [Parameter(Mandatory)][string[]]$Args) Write-Host " > $Exe $($Args -join ' ')" -ForegroundColor DarkGray & $Exe @Args if ($LASTEXITCODE -ne 0) { throw "'$Exe $($Args -join ' ')' failed with exit code $LASTEXITCODE." } } function Resolve-Token { <# The token for both the registry push and the release API: parameter, then env, then an interactive SecureString prompt. Read once and reused, so a run that does both does not prompt twice. #> param([string]$Provided, [Parameter(Mandatory)][string]$Purpose) if ($Provided) { return $Provided } if ($env:GITEA_TOKEN) { return $env:GITEA_TOKEN } $secure = Read-Host "Gitea token ($Purpose)" -AsSecureString return [System.Net.NetworkCredential]::new("", $secure).Password } function Invoke-GiteaApi { <# JSON call against the Gitea API. Returns $null on 404 instead of throwing, because "does this release exist yet?" is a 404 in the normal case and Invoke-RestMethod treats any 4xx as terminating. #> param( [Parameter(Mandatory)][string]$Method, [Parameter(Mandatory)][string]$Uri, [Parameter(Mandatory)][string]$Token, $Body ) $params = @{ Method = $Method Uri = $Uri Headers = @{ Authorization = "token $Token"; Accept = "application/json" } } if ($null -ne $Body) { $params.Body = ($Body | ConvertTo-Json -Depth 5) $params.ContentType = "application/json" } try { return Invoke-RestMethod @params } catch { $status = $_.Exception.Response.StatusCode.value__ if ($status -eq 404) { return $null } if ($status -eq 401) { throw "Gitea API $Method $Uri returned 401 — the token was rejected. Check GITEA_TOKEN (a registry-only token works for docker push but not for the API)." } if ($status -eq 403) { throw "Gitea API $Method $Uri returned 403 — the token is valid but lacks repository write scope (write:repository)." } throw "Gitea API $Method $Uri failed: $($_.Exception.Message)" } } function Send-ReleaseAsset { <# Upload one file as a release attachment. curl.exe rather than Invoke-RestMethod -Form: -Form needs PowerShell 6+, and hand-rolling a multipart body in 5.1 means loading the whole binary into a string. The token goes in a --config file, never in the argument list, so it stays out of the process table and the shell history. #> param( [Parameter(Mandatory)][string]$Uri, [Parameter(Mandatory)][string]$Token, [Parameter(Mandatory)][string]$Path ) $curl = (Get-Command curl.exe -ErrorAction SilentlyContinue).Source if (-not $curl) { throw "curl.exe not found — needed to upload release attachments." } $configFile = [System.IO.Path]::GetTempFileName() try { # curl --config syntax: one option per line, `name = "value"`, and a value # may not span lines. Only the header belongs here — everything else goes # on the command line, where a stray escape can't silently split a line. Set-Content -Path $configFile -Encoding ASCII -Value @( "header = `"Authorization: token $Token`"", "silent", "show-error", "fail-with-body" ) Write-Host " > curl --config -F attachment=@$(Split-Path -Leaf $Path) `"$Uri`"" -ForegroundColor DarkGray # Single-quoted: the \n is curl's own escape in -w, not PowerShell's. & $curl "--config" $configFile ` "--write-out" ' http %{http_code}, %{size_upload} bytes uploaded\n' ` "-F" "attachment=@$Path" $Uri if ($LASTEXITCODE -ne 0) { throw "upload of '$Path' failed (curl exit $LASTEXITCODE)." } } finally { Remove-Item -Force $configFile -ErrorAction SilentlyContinue } } function Publish-BinaryRelease { <# Attach the release archives to the release for $Tag, creating that release if it does not exist yet. Re-uploading the same file name is a delete + upload, which needs -Force: overwriting an asset someone may already have linked is not something to do silently. #> param( [Parameter(Mandatory)][string]$ApiRoot, [Parameter(Mandatory)][string]$RepoPath, [Parameter(Mandatory)][string]$Tag, [Parameter(Mandatory)][string]$Token, [Parameter(Mandatory)][string[]]$Artifacts, [switch]$Force ) $releasesUri = "$ApiRoot/repos/$RepoPath/releases" $release = Invoke-GiteaApi -Method GET -Uri "$releasesUri/tags/$Tag" -Token $Token if (-not $release) { Write-Host " creating release $Tag in $RepoPath..." -ForegroundColor DarkGray $release = Invoke-GiteaApi -Method POST -Uri $releasesUri -Token $Token -Body @{ tag_name = $Tag name = "dockmv $Tag" body = "Standalone binaries for linux, macOS and Windows. Container image: see the package registry." draft = $false } if (-not $release) { throw "could not create release $Tag in $RepoPath (does the repo exist?)." } } else { Write-Host " reusing release $Tag (id $($release.id))" -ForegroundColor DarkGray } foreach ($path in $Artifacts) { $name = Split-Path -Leaf $path $existing = $release.assets | Where-Object { $_.name -eq $name } if ($existing) { if (-not $Force) { throw "release $Tag already has an attachment named '$name' — pass -Force to replace it." } Write-Host " replacing existing attachment '$name'..." -ForegroundColor DarkGray Invoke-GiteaApi -Method DELETE -Token $Token ` -Uri "$releasesUri/$($release.id)/assets/$($existing.id)" | Out-Null } $encoded = [System.Uri]::EscapeDataString($name) Send-ReleaseAsset -Token $Token -Path $path ` -Uri "$releasesUri/$($release.id)/assets?name=$encoded" } return "$ApiRoot/repos/$RepoPath/releases/tags/$Tag" } # Resolve repo root (parent of this script's folder) so the script works from anywhere. $repoRoot = Split-Path -Parent $PSScriptRoot Push-Location $repoRoot try { # --- Mode resolution ------------------------------------------------------ if ($BinariesOnly -and $NoBinaries) { throw "-BinariesOnly and -NoBinaries cancel each other out — pick one." } if ($NoBinaryBuild -and $NoBinaries) { throw "-NoBinaryBuild reuses the build that -NoBinaries skips entirely — pick one." } if ($BinariesOnly -and $NoPublishRelease) { throw "-BinariesOnly with -NoPublishRelease leaves nothing to do — pick one." } if ($BinariesOnly) { # Nothing to build, log into or push on the container side, and uploading # is the whole point (build-release.ps1 alone covers "just build them"). $NoBuild = $true $SkipLogin = $true } $pushImage = -not $BinariesOnly # On by default: a run that builds binaries and doesn't publish them is the # unusual case. -NoBinaries means there is nothing to publish either way. $PublishRelease = (-not $NoPublishRelease) -and (-not $NoBinaries) if (-not $ReleaseRepo) { $ReleaseRepo = "$Owner/$Repo" } if (-not $ApiBase) { $ApiBase = "https://$Registry" } $apiRoot = "$($ApiBase.TrimEnd('/'))/api/v1" # --- Tag resolution ------------------------------------------------------- # `git describe` to match the Makefile and the version the binary reports. if (-not $Tag) { try { $Tag = (git describe --tags --always --dirty 2>$null).Trim() } catch { } if (-not $Tag) { $Tag = "latest" } } # A dirty worktree is fine for a local image, but a published tag that nobody # can check out again is worth naming out loud. if ($Tag -like "*-dirty") { Write-Warning "tag '$Tag' comes from a dirty worktree — the published artifacts won't match any commit. Commit first, or pass -Tag explicitly." } $base = "$Registry/$Owner/$Image" $tags = @("$base`:$Tag") if (-not $NoLatest -and $Tag -ne "latest") { $tags += "$base`:latest" } Write-Host "dockmv publish" -ForegroundColor Cyan Write-Host " registry : $Registry" if ($pushImage) { Write-Host " image : $base" Write-Host " tags : $($tags -join ', ')" } else { Write-Host " image : skipped (-BinariesOnly)" } Write-Host " binaries : $(if ($NoBinaries) { 'skipped' } elseif ($NoBinaryBuild) { 'dist/ (reused, not rebuilt)' } else { $Targets -join ', ' })" Write-Host " release : $(if ($PublishRelease) { "$ReleaseRepo @ $Tag" } else { 'not uploaded' })" Write-Host "" # PowerShell 5.1 still defaults to TLS 1.0 on some hosts, which every current # Gitea rejects — the API call would fail with an opaque connection error. if ($PublishRelease -and [Net.ServicePointManager]::SecurityProtocol -notmatch 'Tls12') { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 } # --- Build ---------------------------------------------------------------- if (-not $NoBuild) { Write-Host "Building image..." -ForegroundColor Cyan # The Dockerfile builds the UI in its own node stage, so the image does # not depend on the local dist/ or on -SkipUi. $buildArgs = @("build", "--build-arg", "VERSION=$Tag") foreach ($t in $tags) { $buildArgs += @("-t", $t) } $buildArgs += "." Invoke-Checked docker $buildArgs Write-Host "" } # --- Release binaries ----------------------------------------------------- # Built before the push so a failing cross-compile doesn't leave a pushed # image with no matching binaries for the same tag. $artifacts = @() if (-not $NoBinaries) { $distDir = Join-Path $repoRoot "dist" if ($NoBinaryBuild) { Write-Host "Reusing existing binary build..." -ForegroundColor Cyan if (-not (Test-Path $distDir)) { throw "-NoBinaryBuild was set but $distDir does not exist — build first (drop the flag, or run scripts/build-release.ps1)." } # Uploading a binary older than the code it claims to be is the one way # this flag can quietly go wrong, so say so rather than assume. $oldest = (Get-ChildItem $distDir -Filter "dockmv-$Tag-*" -File | Sort-Object LastWriteTime | Select-Object -First 1) if (-not $oldest) { throw "no archives named dockmv-$Tag-* in $distDir — the exe on disk was built under a different tag. Drop -NoBinaryBuild." } $newer = Get-ChildItem $repoRoot -Recurse -Include *.go, *.ts, *.tsx, *.css, *.html -File | Where-Object { $_.FullName -notlike "$distDir*" -and $_.FullName -notlike "*\node_modules\*" -and $_.LastWriteTime -gt $oldest.LastWriteTime } if ($newer) { Write-Warning "$($oldest.Name) predates $($newer.Count) source file(s) — the archives may not contain your latest changes (newest: $(($newer | Sort-Object LastWriteTime -Descending)[0].Name))." } } else { Write-Host "Building release binaries..." -ForegroundColor Cyan # build-release.ps1 throws on any failure and $ErrorActionPreference=Stop # propagates it, so there is nothing to test an exit code against — # `& script.ps1` leaves $LASTEXITCODE untouched, and with -NoBuild no # docker command has reset it, so checking it would rethrow whatever the # caller's shell last failed at. & (Join-Path $PSScriptRoot "build-release.ps1") -Tag $Tag -Targets $Targets -SkipUi:$SkipUi } $artifacts = @(Get-ChildItem $distDir -Filter "dockmv-$Tag-*" -File | ForEach-Object FullName) if (-not $artifacts.Count) { throw "no release archives for $Tag found in $distDir." } $sums = Join-Path $distDir "SHA256SUMS.txt" if (Test-Path $sums) { $artifacts += $sums } Write-Host "" } # --- Login ---------------------------------------------------------------- if (-not $SkipLogin) { if (-not $Username) { $Username = $env:GITEA_USER } if (-not $Username) { $Username = Read-Host "Gitea username for $Registry" } $Password = Resolve-Token -Provided $Password -Purpose "registry push as $Username" Write-Host "Logging in to $Registry as $Username..." -ForegroundColor Cyan # Pass the token via stdin so it never lands in process args or history. $Password | docker login $Registry --username $Username --password-stdin if ($LASTEXITCODE -ne 0) { throw "docker login failed (exit $LASTEXITCODE)." } Write-Host "" } # --- Push ----------------------------------------------------------------- if ($pushImage) { Write-Host "Pushing image..." -ForegroundColor Cyan foreach ($t in $tags) { Invoke-Checked docker @("push", $t) } Write-Host "" } # --- Release attachments -------------------------------------------------- $releaseUrl = $null if ($PublishRelease) { if (-not $artifacts.Count) { throw "nothing to upload — no release archives were built (was -NoBinaries set?)." } Write-Host "Uploading binaries to release $Tag..." -ForegroundColor Cyan $Password = Resolve-Token -Provided $Password -Purpose "release upload to $ReleaseRepo" $releaseUrl = Publish-BinaryRelease -ApiRoot $apiRoot -RepoPath $ReleaseRepo -Tag $Tag ` -Token $Password -Artifacts $artifacts -Force:$Force Write-Host "" } Write-Host "Done." -ForegroundColor Green if ($pushImage) { Write-Host "Pushed:" -ForegroundColor Green foreach ($t in $tags) { Write-Host " $t" -ForegroundColor Green } } if ($artifacts.Count) { $where = if ($PublishRelease) { "attached to release $Tag" } else { "built locally — attach to a release manually" } Write-Host "Binaries ($where):" -ForegroundColor Green foreach ($a in $artifacts) { Write-Host " $a" -ForegroundColor Green } if ($releaseUrl) { Write-Host " $ApiBase/$ReleaseRepo/releases/tag/$Tag" -ForegroundColor Green } } } finally { Pop-Location }