diff --git a/scripts/push-image.ps1 b/scripts/push-image.ps1 new file mode 100644 index 0000000..4a98c2e --- /dev/null +++ b/scripts/push-image.ps1 @@ -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 : 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 : 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 ''