diff --git a/Makefile b/Makefile index afa0f02..3f26789 100644 --- a/Makefile +++ b/Makefile @@ -3,15 +3,23 @@ LDFLAGS := -s -w -X main.version=$(VERSION) BIN := docker-migrate OUT := dist -.PHONY: all ui build run test vet fmt docker clean release help +# PowerShell 7+. The publish path lives in scripts/*.ps1 because uploading a +# Gitea release asset needs an HTTP client, not a shell loop. +PWSH ?= pwsh +# Extra flags forwarded to publish.ps1, e.g. `make publish ARGS=-Force`. +ARGS ?= + +.PHONY: all ui build run test vet fmt docker clean release publish publish-binaries help help: - @echo "make ui build the web interface into internal/webui/dist" - @echo "make build build the binary for this platform (implies ui)" - @echo "make run build and start the server on 127.0.0.1:8080" - @echo "make test run the Go tests" - @echo "make docker build the container image" - @echo "make release cross-compile linux/amd64, linux/arm64, darwin/arm64, windows/amd64" + @echo "make ui build the web interface into internal/webui/dist" + @echo "make build build the binary for this platform (implies ui)" + @echo "make run build and start the server on 127.0.0.1:8080" + @echo "make test run the Go tests" + @echo "make docker build the container image" + @echo "make release cross-compile linux/amd64, linux/arm64, darwin/arm64, windows/amd64" + @echo "make publish push the image to git.azuze.fr and attach binaries to the release" + @echo "make publish-binaries binaries only: no docker build, login or push" all: build @@ -52,6 +60,16 @@ release: ui done @ls -lh $(OUT) +# Needs GITEA_USER + GITEA_TOKEN in the environment, or it prompts. The token +# needs package read/write for the push and write:repository for the release. +publish: + $(PWSH) -NoProfile -ExecutionPolicy Bypass -File scripts/publish.ps1 \ + -Tag $(VERSION) -PublishRelease $(ARGS) + +publish-binaries: + $(PWSH) -NoProfile -ExecutionPolicy Bypass -File scripts/publish.ps1 \ + -Tag $(VERSION) -BinariesOnly $(ARGS) + clean: rm -rf $(BIN) $(BIN).exe $(OUT) internal/webui/dist/assets rm -rf web/node_modules diff --git a/assets/dockmv-logo-full.png b/assets/dockmv-logo-full.png new file mode 100644 index 0000000..0c05ece Binary files /dev/null and b/assets/dockmv-logo-full.png differ diff --git a/assets/dockmv-logo-smol.png b/assets/dockmv-logo-smol.png new file mode 100644 index 0000000..be6ad77 Binary files /dev/null and b/assets/dockmv-logo-smol.png differ diff --git a/assets/dockmv-logo-vsmol.png b/assets/dockmv-logo-vsmol.png new file mode 100644 index 0000000..34c2004 Binary files /dev/null and b/assets/dockmv-logo-vsmol.png differ diff --git a/scripts/build-release.ps1 b/scripts/build-release.ps1 new file mode 100644 index 0000000..df80c34 --- /dev/null +++ b/scripts/build-release.ps1 @@ -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--[.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 +} diff --git a/scripts/publish.ps1 b/scripts/publish.ps1 new file mode 100644 index 0000000..51b5e22 --- /dev/null +++ b/scripts/publish.ps1 @@ -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://. + [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 = "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 +}