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:
@@ -3,15 +3,23 @@ LDFLAGS := -s -w -X main.version=$(VERSION)
|
|||||||
BIN := docker-migrate
|
BIN := docker-migrate
|
||||||
OUT := dist
|
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:
|
help:
|
||||||
@echo "make ui build the web interface into internal/webui/dist"
|
@echo "make ui build the web interface into internal/webui/dist"
|
||||||
@echo "make build build the binary for this platform (implies ui)"
|
@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 run build and start the server on 127.0.0.1:8080"
|
||||||
@echo "make test run the Go tests"
|
@echo "make test run the Go tests"
|
||||||
@echo "make docker build the container image"
|
@echo "make docker build the container image"
|
||||||
@echo "make release cross-compile linux/amd64, linux/arm64, darwin/arm64, windows/amd64"
|
@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
|
all: build
|
||||||
|
|
||||||
@@ -52,6 +60,16 @@ release: ui
|
|||||||
done
|
done
|
||||||
@ls -lh $(OUT)
|
@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:
|
clean:
|
||||||
rm -rf $(BIN) $(BIN).exe $(OUT) internal/webui/dist/assets
|
rm -rf $(BIN) $(BIN).exe $(OUT) internal/webui/dist/assets
|
||||||
rm -rf web/node_modules
|
rm -rf web/node_modules
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 121 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.8 KiB |
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user