<# .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 ''