Add publishing scripts and release assets

- Add publish.ps1 and build-release.ps1 for publishing to Gitea
- Add dockmv logo assets (full, small, very small variants)
- Update Makefile with publish and publish-binaries targets
- Support PowerShell-based release pipeline with Gitea integration

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 12:25:26 +02:00
co-authored by Claude Haiku 4.5
parent 537e345d89
commit 7a1c5433a2
6 changed files with 694 additions and 7 deletions
+228
View File
@@ -0,0 +1,228 @@
#requires -Version 5.1
<#
.SYNOPSIS
Build the docker-migrate web UI and cross-compile release binaries.
.DESCRIPTION
Produces dist/docker-migrate-<os>-<arch>[.exe] for every target, one archive
per binary (.zip for Windows, .tar.gz elsewhere) and dist/SHA256SUMS.txt.
The PowerShell equivalent of `make release`, so a Windows host without make
can cut the same artifacts. The web UI is built first: vite writes into
internal/webui/dist, which the Go binary embeds, so the UI must be current
before the compile step — that directory is committed, so -SkipUi is safe for
a backend-only iteration.
Runs anywhere: the Go toolchain cross-compiles with CGO_ENABLED=0, and no
target needs a platform-specific linker.
.EXAMPLE
./scripts/build-release.ps1
Build every default target, tagging archives with `git describe`.
.EXAMPLE
./scripts/build-release.ps1 -Tag v1.2.0 -Targets windows/amd64, linux/amd64
Build two targets only, named v1.2.0.
.EXAMPLE
./scripts/build-release.ps1 -SkipUi
Reuse the committed internal/webui/dist instead of running npm.
#>
[CmdletBinding()]
param(
# os/arch pairs to build. Anything `go tool dist list` accepts works.
[string[]]$Targets = @("linux/amd64", "linux/arm64", "darwin/arm64", "darwin/amd64", "windows/amd64"),
# Version stamped into the binary and used in archive names. Defaults to
# `git describe --tags --always --dirty`, matching the Makefile.
[string]$Tag,
# Go toolchain to build with.
[string]$Go = "go",
# Skip the `npm ci && npm run build` UI step.
[switch]$SkipUi,
# Remove dist/ before building.
[switch]$Clean,
# Build the binaries but skip archives and checksums.
[switch]$NoArchive
)
$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 Invoke-GoBuild {
<#
One cross-compile. GOOS/GOARCH/CGO_ENABLED are process-wide env vars, so
they are saved and restored: leaving GOOS=windows set would silently
poison every later `go` call in the same session.
#>
param(
[Parameter(Mandatory)][string]$Go,
[Parameter(Mandatory)][string]$Goos,
[Parameter(Mandatory)][string]$Goarch,
[Parameter(Mandatory)][string]$Output,
[Parameter(Mandatory)][string]$Ldflags
)
$saved = @{
GOOS = $env:GOOS
GOARCH = $env:GOARCH
CGO_ENABLED = $env:CGO_ENABLED
}
try {
$env:GOOS = $Goos
$env:GOARCH = $Goarch
$env:CGO_ENABLED = "0"
Invoke-Checked $Go @("build", "-trimpath", "-ldflags", $Ldflags, "-o", $Output, ".")
}
finally {
foreach ($k in $saved.Keys) {
if ($null -eq $saved[$k]) { Remove-Item "env:$k" -ErrorAction SilentlyContinue }
else { Set-Item "env:$k" $saved[$k] }
}
}
}
function New-Archive {
<#
Archive one binary. .zip for Windows consumers, .tar.gz for the unix
targets — a tar preserves the executable bit, which a zip made by
Compress-Archive does not, so a downloaded linux binary stays runnable
without a chmod.
#>
param(
[Parameter(Mandatory)][string]$BinaryPath,
[Parameter(Mandatory)][string]$ArchivePath
)
if (Test-Path $ArchivePath) { Remove-Item -Force $ArchivePath }
$dir = Split-Path -Parent $BinaryPath
$name = Split-Path -Leaf $BinaryPath
if ($ArchivePath.EndsWith(".zip")) {
Compress-Archive -Path $BinaryPath -DestinationPath $ArchivePath
}
else {
# bsdtar ships with Windows 10 1803+; -C keeps the archive flat so it
# extracts as a bare binary rather than a dist/ tree.
$tar = (Get-Command tar -ErrorAction SilentlyContinue).Source
if (-not $tar) { throw "tar not found — needed for .tar.gz archives (pass -NoArchive to skip)." }
Invoke-Checked $tar @("-czf", $ArchivePath, "-C", $dir, $name)
}
}
$repoRoot = Split-Path -Parent $PSScriptRoot
Push-Location $repoRoot
try {
if (-not $Tag) {
try { $Tag = (git describe --tags --always --dirty 2>$null).Trim() } catch { }
if (-not $Tag) { $Tag = "dev" }
}
$bin = "docker-migrate"
$distDir = Join-Path $repoRoot "dist"
$ldflags = "-s -w -X main.version=$Tag"
Write-Host "docker-migrate release build" -ForegroundColor Cyan
Write-Host " go : $Go"
Write-Host " tag : $Tag"
Write-Host " targets : $($Targets -join ', ')"
Write-Host " output : $distDir"
Write-Host ""
if ($Clean) {
Write-Host "Cleaning dist/..." -ForegroundColor Cyan
if (Test-Path $distDir) { Remove-Item -Recurse -Force $distDir }
}
New-Item -ItemType Directory -Force -Path $distDir | Out-Null
# --- Web UI ---------------------------------------------------------------
# vite's outDir is internal/webui/dist, which embed.go embeds, so this has to
# run before the compile — not after, and not in parallel with it.
if (-not $SkipUi) {
Write-Host "Building web UI..." -ForegroundColor Cyan
if (-not (Get-Command npm -ErrorAction SilentlyContinue)) {
throw "npm not found — install Node 22+, or pass -SkipUi to reuse the committed internal/webui/dist."
}
Push-Location (Join-Path $repoRoot "web")
try {
Invoke-Checked npm @("ci", "--no-audit", "--no-fund")
Invoke-Checked npm @("run", "build")
}
finally { Pop-Location }
Write-Host ""
}
$embedded = Join-Path $repoRoot "internal/webui/dist/index.html"
if (-not (Test-Path $embedded)) {
throw "internal/webui/dist/index.html is missing — the binary would serve an empty UI. Run without -SkipUi."
}
# --- Compile --------------------------------------------------------------
Write-Host "Cross-compiling..." -ForegroundColor Cyan
$built = @()
foreach ($target in $Targets) {
$parts = $target -split "/"
if ($parts.Count -ne 2) { throw "target '$target' is not in os/arch form." }
$goos, $goarch = $parts
$ext = if ($goos -eq "windows") { ".exe" } else { "" }
$binaryPath = Join-Path $distDir "$bin-$goos-$goarch$ext"
Write-Host " $goos/$goarch" -ForegroundColor DarkGray
Invoke-GoBuild -Go $Go -Goos $goos -Goarch $goarch -Output $binaryPath -Ldflags $ldflags
if (-not (Test-Path $binaryPath)) { throw "go build reported success but $binaryPath is missing." }
$archivePath = $null
if (-not $NoArchive) {
$archiveExt = if ($goos -eq "windows") { "zip" } else { "tar.gz" }
$archivePath = Join-Path $distDir "$bin-$Tag-$goos-$goarch.$archiveExt"
New-Archive -BinaryPath $binaryPath -ArchivePath $archivePath
}
$built += [pscustomobject]@{
Target = $target
Binary = $binaryPath
Archive = $archivePath
SizeMb = [math]::Round((Get-Item $binaryPath).Length / 1MB, 1)
}
}
Write-Host ""
# --- Checksums ------------------------------------------------------------
# Over the archives, since those are what a release serves. Written in the
# `sha256sum -c` format so it verifies on a linux host as-is.
$sumsPath = $null
if (-not $NoArchive) {
Write-Host "Writing checksums..." -ForegroundColor Cyan
$sumsPath = Join-Path $distDir "SHA256SUMS.txt"
$lines = foreach ($b in $built) {
$hash = (Get-FileHash -Algorithm SHA256 $b.Archive).Hash.ToLower()
"$hash $(Split-Path -Leaf $b.Archive)"
}
# ASCII with LF: a BOM or CRLF makes `sha256sum -c` reject the first line.
[System.IO.File]::WriteAllText($sumsPath, ($lines -join "`n") + "`n", [System.Text.ASCIIEncoding]::new())
Write-Host ""
}
Write-Host "Done. Built:" -ForegroundColor Green
foreach ($b in $built) {
Write-Host " $($b.Binary) ($($b.SizeMb) MB)" -ForegroundColor Green
if ($b.Archive) { Write-Host " $($b.Archive)" -ForegroundColor Green }
}
if ($sumsPath) { Write-Host " $sumsPath" -ForegroundColor Green }
}
finally {
Pop-Location
}
+441
View File
@@ -0,0 +1,441 @@
#requires -Version 5.1
<#
.SYNOPSIS
Build the docker-migrate 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 -PublishRelease attaches them to the Gitea
release for that tag instead (creating the release if it does not exist).
-BinariesOnly ships just the binaries: no docker build, no docker login, no
image push, and the release upload is implied.
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
Build and push :latest plus the git-describe tag; build dist/ binaries locally.
.EXAMPLE
./scripts/publish.ps1 -Tag v1.2.0 -PublishRelease
Full release: push the image and attach every dist/ archive to release v1.2.0.
.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.
.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 = "docker-migrate",
# 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. Implies
# -PublishRelease, since building alone is what build-release.ps1 already does.
[switch]$BinariesOnly,
# Attach the release archives to the Gitea release for $Tag, creating the
# release if it is missing.
[switch]$PublishRelease,
# owner/repo holding the release. Defaults to $Owner/$Repo.
[string]$ReleaseRepo,
# Gitea base URL for the API. Defaults to https://<Registry>.
[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 <temp> -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 = "docker-migrate $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) {
# 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
$PublishRelease = $true
}
$pushImage = -not $BinariesOnly
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 "docker-migrate 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 "docker-migrate-$Tag-*" -File |
Sort-Object LastWriteTime | Select-Object -First 1)
if (-not $oldest) {
throw "no archives named docker-migrate-$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 "docker-migrate-$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 "-PublishRelease has nothing to upload (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
}