added deployment script
Some checks failed
build-image / build-and-push (push) Has been cancelled

This commit is contained in:
C-West8
2026-07-28 22:30:11 -05:00
parent c317480897
commit 3c4414dea9

383
scripts/push-image.ps1 Normal file
View File

@@ -0,0 +1,383 @@
<#
.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 ''