fix(deploy): pull images explicitly, and detect api/web drift by commit
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m46s
Build and Push Images / Build jorgecuadros-api (push) Successful in 1m58s

The first successful galactus deploy came up all-green while the web tier was
running a build from two commits earlier. The registry held web:latest from
3ff56e6; the host still had a web:latest cached from 4ee7ec7; the deploy
reported success and served the old one. The API was only current because it
had been pulled by hand during earlier debugging.

Two independent failures, both fixed here.

1. Images are not pulled. The deploy action's `pull: true` does not reliably
   refresh an already-cached moving tag on a standalone endpoint. Added a
   Pull images step (deploy/scripts/pull-images.mjs) that pulls each image
   through Portainer's Docker API with registry credentials and fails the
   deploy if a pull fails — note the endpoint answers 200 even when the pull
   errored, so the stream body has to be inspected, not just the status.

2. The drift check could not see it. Both the verify step and the web footer
   compared APP_VERSION, but on a branch build BOTH tiers report "master", so
   equality proved nothing. They now compare gitSha, which is the only field
   that differs between two builds of the same branch. api and web come from
   one matrix run, so a difference can only mean an image was not replaced.

   This needed a /version on the web tier too — previously its build identity
   was only readable by scraping window.__APP_BUILD__ out of the HTML.

pull-images.mjs builds the X-Registry-Auth header as URL-safe base64 WITH
padding: Node's "base64url" omits the padding and Portainer's Go decoder
rejects it with "Illegal base64 data at input byte N".

Verified against galactus: pulls both images, and exits non-zero on a
nonexistent tag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 14:57:44 -07:00
co-authored by Claude Opus 5
parent 1cba9bfc32
commit 7e3b530174
5 changed files with 236 additions and 36 deletions
+18
View File
@@ -0,0 +1,18 @@
import { NextResponse } from "next/server";
import { readBuildInfoFromEnv } from "@/lib/build-info";
// Read per request, never prerendered — the whole point is to report what THIS
// running container is, and a baked answer would defeat that.
export const dynamic = "force-dynamic";
/**
* The web tier's counterpart to the API's GET /version.
*
* Without this, the only way to see what the web container is running was to
* scrape window.__APP_BUILD__ out of the HTML. The deploy workflow compares the
* two tiers' gitSha to catch a half-applied release, so it needs a stable,
* parseable answer from both sides.
*/
export function GET() {
return NextResponse.json({ service: "web", ...readBuildInfoFromEnv() });
}
+11 -6
View File
@@ -3,7 +3,7 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { usePathname, useRouter } from "next/navigation";
import Link from "next/link";
import { getApiVersion, logout, me, updateUiScale } from "@/lib/api";
import { getApiVersion, logout, me, updateUiScale, type ServiceVersion } from "@/lib/api";
import { shortSha, webBuildInfo } from "@/lib/build-info";
import { AuthContext, can } from "@/lib/abilities";
import { ROLE_LABEL } from "@/lib/labels";
@@ -190,13 +190,13 @@ function NavMenu({
*/
function BuildFooter() {
const web = webBuildInfo();
const [api, setApi] = useState<string | null>(null);
const [api, setApi] = useState<ServiceVersion | null>(null);
useEffect(() => {
let alive = true;
getApiVersion()
.then((v) => {
if (alive) setApi(v.version);
if (alive) setApi(v);
})
.catch(() => {
// The shell already redirects to /login when the API is unreachable;
@@ -207,7 +207,12 @@ function BuildFooter() {
};
}, []);
const mismatch = api !== null && api !== web.version;
// Compare the COMMIT, not the version string. On a branch build both tiers
// report APP_VERSION "master", so comparing versions cannot see drift — which
// is exactly how a stale web image once sat next to a current API with this
// footer showing nothing wrong. The sha is the only field that actually
// differs between two builds of the same branch.
const mismatch = api !== null && api.gitSha !== web.gitSha;
return (
<footer className="shell-footer">
@@ -216,8 +221,8 @@ function BuildFooter() {
className="shell-footer-build"
title={`web ${web.version} (${shortSha(web.gitSha)}) — ${web.buildDate}`}
>
v{web.version}
{api !== null && (mismatch ? ` · API v${api}` : "")}
v{web.version} · {shortSha(web.gitSha)}
{mismatch && api ? ` · API ${shortSha(api.gitSha)}` : ""}
</span>
{mismatch && (
<span className="shell-footer-warn" role="status">