384 lines
14 KiB
PowerShell
384 lines
14 KiB
PowerShell
<#
|
|
.SYNOPSIS
|
|
Builds the SDE Meeting Toolkit image and pushes it to the Gitea container registry.
|
|
|
|
.DESCRIPTION
|
|
Does manually what .gitea/workflows/build-image.yml would do in CI, so you can
|
|
ship images without Gitea Actions or a registered act_runner.
|
|
|
|
Steps: preflight checks -> build (linux/amd64, with OCI labels so Gitea links the
|
|
package to the repo) -> push :<short-sha> and :latest -> verify the manifest landed.
|
|
|
|
Optionally mirrors postgres:16-alpine into the same registry, for prod hosts that
|
|
cannot reach Docker Hub.
|
|
|
|
.PARAMETER Tag
|
|
Image tag. Defaults to the short git SHA of HEAD.
|
|
|
|
.PARAMETER SkipBuild
|
|
Push tags that already exist locally instead of rebuilding.
|
|
|
|
.PARAMETER MirrorPostgres
|
|
Also retag and push the Postgres image into this registry.
|
|
|
|
.PARAMETER NoLatest
|
|
Push only the versioned tag, leaving :latest pointing at the previous build.
|
|
|
|
.EXAMPLE
|
|
.\scripts\push-image.ps1
|
|
Build and push :<short-sha> and :latest.
|
|
|
|
.EXAMPLE
|
|
.\scripts\push-image.ps1 -MirrorPostgres
|
|
Same, plus mirror postgres:16-alpine for an air-gapped prod host.
|
|
|
|
.EXAMPLE
|
|
.\scripts\push-image.ps1 -Tag v1.0.0 -NoLatest
|
|
Cut a named release without moving :latest.
|
|
#>
|
|
[CmdletBinding()]
|
|
param(
|
|
[string]$Registry = 'primegit.primecontrols-dev.com',
|
|
[string]$Owner = 'c.west',
|
|
[string]$Image = 'nick-sde-value-driver-website',
|
|
[string]$Tag,
|
|
[switch]$SkipBuild,
|
|
[switch]$MirrorPostgres,
|
|
[string]$PostgresImage = 'postgres:16-alpine',
|
|
[switch]$NoLatest
|
|
)
|
|
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
# --- Output helpers -------------------------------------------------------
|
|
|
|
$script:StepNum = 0
|
|
$script:WarnCount = 0
|
|
$script:Started = Get-Date
|
|
|
|
function Write-Step {
|
|
param([string]$Message)
|
|
$script:StepNum++
|
|
Write-Host ''
|
|
Write-Host ("[{0}] {1}" -f $script:StepNum, $Message) -ForegroundColor Cyan
|
|
}
|
|
|
|
function Write-Ok {
|
|
param([string]$Message)
|
|
Write-Host (" OK {0}" -f $Message) -ForegroundColor Green
|
|
}
|
|
|
|
function Write-Info {
|
|
param([string]$Message)
|
|
Write-Host (" {0}" -f $Message) -ForegroundColor DarkGray
|
|
}
|
|
|
|
function Write-Warn2 {
|
|
param([string]$Message)
|
|
$script:WarnCount++
|
|
Write-Host (" ! {0}" -f $Message) -ForegroundColor Yellow
|
|
}
|
|
|
|
function Stop-WithError {
|
|
param([string]$Message, [string[]]$Hints)
|
|
Write-Host ''
|
|
Write-Host ("ERROR: {0}" -f $Message) -ForegroundColor Red
|
|
if ($Hints) {
|
|
Write-Host ''
|
|
foreach ($h in $Hints) { Write-Host (" {0}" -f $h) -ForegroundColor Yellow }
|
|
}
|
|
Write-Host ''
|
|
exit 1
|
|
}
|
|
|
|
# Runs a native exe, echoing its output live, and throws on a non-zero exit code.
|
|
# Deliberately does NOT redirect stderr -- in PS 5.1 that wraps native stderr lines
|
|
# in ErrorRecords and falsely marks successful commands as failed.
|
|
function Invoke-Native {
|
|
param([string]$Exe, [string[]]$Arguments, [string]$FailureMessage, [string[]]$Hints)
|
|
Write-Info ("$ {0} {1}" -f $Exe, ($Arguments -join ' '))
|
|
# Docker writes build progress to stderr. If the caller redirected stderr
|
|
# (".\push-image.ps1 > build.log 2>&1"), PS 5.1 wraps each line in an ErrorRecord,
|
|
# which under 'Stop' would abort a perfectly healthy build. Exit code is the only
|
|
# trustworthy success signal for a native exe, so relax the preference here.
|
|
$prev = $ErrorActionPreference
|
|
$ErrorActionPreference = 'Continue'
|
|
try {
|
|
& $Exe @Arguments
|
|
} finally {
|
|
$ErrorActionPreference = $prev
|
|
}
|
|
if ($LASTEXITCODE -ne 0) {
|
|
Stop-WithError -Message ("{0} (exit code {1})" -f $FailureMessage, $LASTEXITCODE) -Hints $Hints
|
|
}
|
|
}
|
|
|
|
# Same stderr guard, but returns the output and exit code instead of aborting --
|
|
# for probe commands whose failure is informational rather than fatal.
|
|
function Invoke-NativeQuiet {
|
|
param([string]$Exe, [string[]]$Arguments)
|
|
$prev = $ErrorActionPreference
|
|
$ErrorActionPreference = 'Continue'
|
|
try {
|
|
$out = & $Exe @Arguments 2>$null
|
|
} finally {
|
|
$ErrorActionPreference = $prev
|
|
}
|
|
return [pscustomobject]@{ Output = $out; ExitCode = $LASTEXITCODE }
|
|
}
|
|
|
|
# --- Resolve context ------------------------------------------------------
|
|
|
|
$RepoRoot = Split-Path -Parent $PSScriptRoot
|
|
Set-Location $RepoRoot
|
|
|
|
$ImageRef = "$Registry/$Owner/$Image"
|
|
|
|
Write-Host ''
|
|
Write-Host '=======================================================' -ForegroundColor White
|
|
Write-Host ' SDE Meeting Toolkit - build and push container image' -ForegroundColor White
|
|
Write-Host '=======================================================' -ForegroundColor White
|
|
Write-Info "repo root: $RepoRoot"
|
|
Write-Info "registry: $Registry"
|
|
|
|
# --- 1. Preflight ---------------------------------------------------------
|
|
|
|
Write-Step 'Preflight checks'
|
|
|
|
$dockerCmd = Get-Command docker -ErrorAction SilentlyContinue
|
|
if (-not $dockerCmd) {
|
|
Stop-WithError -Message 'docker was not found on PATH.' -Hints @(
|
|
'Install Docker Desktop, or start it if it is already installed.'
|
|
)
|
|
}
|
|
|
|
$probe = Invoke-NativeQuiet -Exe 'docker' -Arguments @('info', '--format', '{{.ServerVersion}}')
|
|
if ($probe.ExitCode -ne 0) {
|
|
Stop-WithError -Message 'Cannot reach the Docker daemon.' -Hints @(
|
|
'Start Docker Desktop and wait until the whale icon stops animating, then retry.'
|
|
)
|
|
}
|
|
Write-Ok ("Docker daemon reachable (server {0})" -f $probe.Output)
|
|
|
|
if (-not (Test-Path (Join-Path $RepoRoot 'Dockerfile'))) {
|
|
Stop-WithError -Message "No Dockerfile at $RepoRoot." -Hints @(
|
|
'Run this script from inside the repository.'
|
|
)
|
|
}
|
|
Write-Ok 'Dockerfile present'
|
|
|
|
# --- 2. Registry credentials ---------------------------------------------
|
|
|
|
Write-Step 'Checking registry credentials'
|
|
|
|
$dockerConfigPath = Join-Path $env:USERPROFILE '.docker\config.json'
|
|
$hasAuthEntry = $false
|
|
if (Test-Path $dockerConfigPath) {
|
|
try {
|
|
$cfg = Get-Content $dockerConfigPath -Raw | ConvertFrom-Json
|
|
if ($cfg.auths -and ($cfg.auths.PSObject.Properties.Name -contains $Registry)) {
|
|
$hasAuthEntry = $true
|
|
}
|
|
} catch {
|
|
Write-Warn2 "Could not parse $dockerConfigPath - skipping the credential precheck."
|
|
}
|
|
}
|
|
|
|
$loginHints = @(
|
|
"Create a Personal Access Token with the 'write:package' scope at:",
|
|
" https://$Registry/user/settings/applications",
|
|
'',
|
|
'Then log in (paste the token at the password prompt):',
|
|
" docker login $Registry -u $Owner"
|
|
)
|
|
|
|
if (-not $hasAuthEntry) {
|
|
Stop-WithError -Message "No saved Docker credentials for $Registry." -Hints $loginHints
|
|
}
|
|
Write-Ok "Found saved credentials for $Registry"
|
|
Write-Info 'Note: this only confirms a login happened, not that the token is still valid.'
|
|
|
|
# --- 3. Determine tag -----------------------------------------------------
|
|
|
|
Write-Step 'Determining image tag'
|
|
|
|
$gitSha = $null
|
|
$gitFull = $null
|
|
$gitCmd = Get-Command git -ErrorAction SilentlyContinue
|
|
if ($gitCmd) {
|
|
$gitSha = (git rev-parse --short HEAD)
|
|
if ($LASTEXITCODE -ne 0) { $gitSha = $null }
|
|
$gitFull = (git rev-parse HEAD)
|
|
if ($LASTEXITCODE -ne 0) { $gitFull = $null }
|
|
}
|
|
|
|
if (-not $Tag) {
|
|
if (-not $gitSha) {
|
|
Stop-WithError -Message 'Could not read the git SHA and no -Tag was supplied.' -Hints @(
|
|
'Pass an explicit tag, e.g.: .\scripts\push-image.ps1 -Tag v1.0.0'
|
|
)
|
|
}
|
|
$Tag = $gitSha
|
|
Write-Ok "Tag from git HEAD: $Tag"
|
|
} else {
|
|
Write-Ok "Tag from -Tag parameter: $Tag"
|
|
}
|
|
|
|
if ($gitCmd) {
|
|
$dirty = (git status --porcelain)
|
|
if ($LASTEXITCODE -eq 0 -and $dirty) {
|
|
$changed = ($dirty -split "`n" | Where-Object { $_.Trim() }).Count
|
|
Write-Warn2 "Working tree has $changed uncommitted change(s)."
|
|
Write-Warn2 "The image will be tagged '$Tag' but will NOT match that commit."
|
|
Write-Warn2 'Commit first if you need this build to be reproducible.'
|
|
}
|
|
}
|
|
|
|
$tagsToPush = @("${ImageRef}:$Tag")
|
|
if (-not $NoLatest) { $tagsToPush += "${ImageRef}:latest" }
|
|
foreach ($t in $tagsToPush) { Write-Info "will push -> $t" }
|
|
|
|
# --- 4. Build -------------------------------------------------------------
|
|
|
|
if ($SkipBuild) {
|
|
Write-Step 'Build skipped (-SkipBuild)'
|
|
foreach ($t in $tagsToPush) {
|
|
$chk = Invoke-NativeQuiet -Exe 'docker' -Arguments @('image', 'inspect', $t)
|
|
if ($chk.ExitCode -ne 0) {
|
|
Stop-WithError -Message "-SkipBuild was set but '$t' does not exist locally." -Hints @(
|
|
'Drop -SkipBuild to build it.'
|
|
)
|
|
}
|
|
Write-Ok "Found local image $t"
|
|
}
|
|
} else {
|
|
Write-Step "Building image (linux/amd64)"
|
|
Write-Info 'First build takes a few minutes; later builds reuse cached layers.'
|
|
|
|
# --provenance/--sbom must stay false. With them on (the buildx default) BuildKit
|
|
# wraps the image in an OCI image index carrying attestation manifests, and Gitea's
|
|
# registry 404s on the manifest PUT -- after uploading every layer, so it looks like
|
|
# a permissions problem when it is really a media-type problem.
|
|
$buildArgs = @('build', '--platform', 'linux/amd64', '--provenance=false', '--sbom=false')
|
|
$buildArgs += @('--label', "org.opencontainers.image.source=https://$Registry/$Owner/$Image")
|
|
if ($gitFull) {
|
|
$buildArgs += @('--label', "org.opencontainers.image.revision=$gitFull")
|
|
}
|
|
$buildArgs += @('--label', 'org.opencontainers.image.title=sde-meeting-toolkit')
|
|
foreach ($t in $tagsToPush) { $buildArgs += @('-t', $t) }
|
|
$buildArgs += '.'
|
|
|
|
$buildStart = Get-Date
|
|
Invoke-Native -Exe 'docker' -Arguments $buildArgs `
|
|
-FailureMessage 'Docker build failed.' `
|
|
-Hints @(
|
|
'Scroll up for the failing build step.',
|
|
'A failure in "npm run build" is an application error, not a Docker one --',
|
|
'try running "npm run build" locally to see it more clearly.'
|
|
)
|
|
$buildSecs = [math]::Round(((Get-Date) - $buildStart).TotalSeconds, 1)
|
|
Write-Ok "Build finished in ${buildSecs}s"
|
|
|
|
# "docker images" reports the on-disk size; "image inspect --format {{.Size}}" can
|
|
# report a much smaller figure under the containerd image store, so prefer this.
|
|
$sz = Invoke-NativeQuiet -Exe 'docker' -Arguments @('images', "${ImageRef}:$Tag", '--format', '{{.Size}}')
|
|
if ($sz.ExitCode -eq 0 -and $sz.Output) {
|
|
Write-Info ("image size: {0}" -f $sz.Output)
|
|
}
|
|
}
|
|
|
|
# --- 5. Push --------------------------------------------------------------
|
|
|
|
Write-Step 'Pushing to the registry'
|
|
|
|
# Two very different failures look similar here, so name both. Read the docker output
|
|
# above to tell them apart:
|
|
$pushHints = @(
|
|
'Check the docker output above:',
|
|
'',
|
|
' "no basic auth credentials" / "authorization failed" / "denied"',
|
|
' -> credential problem. Log in again with a write:package token:',
|
|
" docker login $Registry -u $Owner",
|
|
" Create one at: https://$Registry/user/settings/applications",
|
|
'',
|
|
' layers all say "Pushed", then a 404 on the manifest PUT',
|
|
' -> NOT auth. BuildKit attached attestations and Gitea rejects the',
|
|
' resulting OCI image index. Rebuild without -SkipBuild so the',
|
|
' --provenance=false --sbom=false flags apply.',
|
|
'',
|
|
' "unauthorized" only on some tags',
|
|
' -> the token may be read-only; regenerate it with write:package.'
|
|
)
|
|
|
|
foreach ($t in $tagsToPush) {
|
|
Write-Info "pushing $t"
|
|
$pushStart = Get-Date
|
|
Invoke-Native -Exe 'docker' -Arguments @('push', $t) `
|
|
-FailureMessage "Failed to push $t." -Hints $pushHints
|
|
$pushSecs = [math]::Round(((Get-Date) - $pushStart).TotalSeconds, 1)
|
|
Write-Ok "Pushed $t (${pushSecs}s)"
|
|
}
|
|
|
|
# --- 6. Mirror Postgres (optional) ---------------------------------------
|
|
|
|
if ($MirrorPostgres) {
|
|
Write-Step "Mirroring $PostgresImage into $Registry"
|
|
Write-Info 'For prod hosts with no outbound access to Docker Hub.'
|
|
|
|
$pgName = ($PostgresImage -split ':')[0]
|
|
$pgTag = ($PostgresImage -split ':')[1]
|
|
if (-not $pgTag) { $pgTag = 'latest' }
|
|
$pgTarget = "$Registry/$Owner/${pgName}:$pgTag"
|
|
|
|
Invoke-Native -Exe 'docker' -Arguments @('pull', '--platform', 'linux/amd64', $PostgresImage) `
|
|
-FailureMessage "Could not pull $PostgresImage from Docker Hub." `
|
|
-Hints @('This machine needs internet access to fetch the upstream image once.')
|
|
Write-Ok "Pulled $PostgresImage"
|
|
|
|
Invoke-Native -Exe 'docker' -Arguments @('tag', $PostgresImage, $pgTarget) `
|
|
-FailureMessage 'Could not retag the Postgres image.'
|
|
Write-Ok "Tagged $pgTarget"
|
|
|
|
Invoke-Native -Exe 'docker' -Arguments @('push', $pgTarget) `
|
|
-FailureMessage "Failed to push $pgTarget." -Hints $pushHints
|
|
Write-Ok "Pushed $pgTarget"
|
|
}
|
|
|
|
# --- 7. Verify ------------------------------------------------------------
|
|
|
|
Write-Step 'Verifying the pushed manifest'
|
|
|
|
$mf = Invoke-NativeQuiet -Exe 'docker' -Arguments @('manifest', 'inspect', "${ImageRef}:$Tag")
|
|
if ($mf.ExitCode -ne 0) {
|
|
Write-Warn2 'Could not read the manifest back from the registry.'
|
|
Write-Warn2 'The push reported success, so this is most likely a transient read.'
|
|
Write-Warn2 "Confirm manually at: https://$Registry/$Owner/-/packages"
|
|
} else {
|
|
Write-Ok 'Manifest confirmed present in the registry'
|
|
}
|
|
|
|
# --- Summary --------------------------------------------------------------
|
|
|
|
$elapsed = [math]::Round(((Get-Date) - $script:Started).TotalSeconds, 1)
|
|
|
|
Write-Host ''
|
|
Write-Host '=======================================================' -ForegroundColor White
|
|
Write-Host ' DONE' -ForegroundColor Green
|
|
Write-Host '=======================================================' -ForegroundColor White
|
|
foreach ($t in $tagsToPush) { Write-Host (" pushed {0}" -f $t) -ForegroundColor Green }
|
|
if ($MirrorPostgres) {
|
|
Write-Host (" pushed {0}/{1}/{2}" -f $Registry, $Owner, $PostgresImage) -ForegroundColor Green
|
|
}
|
|
Write-Host (" elapsed {0}s" -f $elapsed) -ForegroundColor DarkGray
|
|
if ($script:WarnCount -gt 0) {
|
|
Write-Host (" {0} warning(s) above -- review before deploying." -f $script:WarnCount) -ForegroundColor Yellow
|
|
}
|
|
Write-Host ''
|
|
Write-Host ' Packages page:' -ForegroundColor White
|
|
Write-Host (" https://{0}/{1}/{2}/packages" -f $Registry, $Owner, $Image) -ForegroundColor Cyan
|
|
Write-Host ''
|
|
Write-Host ' On the prod host:' -ForegroundColor White
|
|
Write-Host (" docker login {0}" -f $Registry) -ForegroundColor DarkGray
|
|
Write-Host (" docker pull {0}:{1}" -f $ImageRef, $Tag) -ForegroundColor DarkGray
|
|
Write-Host ''
|