Files
rmancinasandClaude Opus 5 7e3b530174
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m46s
Build and Push Images / Build jorgecuadros-api (push) Successful in 1m58s
fix(deploy): pull images explicitly, and detect api/web drift by commit
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>
2026-07-30 14:57:44 -07:00

105 lines
3.5 KiB
JavaScript

#!/usr/bin/env node
/**
* Pull the api + web images onto the target host before the stack is applied.
*
* This exists because the deploy action's `pull: true` does NOT reliably
* refresh an already-cached tag on a standalone endpoint. Observed on galactus
* 2026-07-30: the registry held web:latest built from 3ff56e6, the host still
* had a web:latest cached from an earlier commit, the deploy reported success,
* and the running container served the OLD build. A moving tag like `latest`
* makes this silent — the stack file names the same string either way, so
* nothing downstream notices.
*
* Pulling explicitly, and failing the deploy if a pull fails, makes "the image
* the host runs" a thing the workflow controls rather than hopes for.
*
* Required env:
* PORTAINER_URL, PORTAINER_API_KEY, PORTAINER_ENDPOINT_ID
* REGISTRY, REGISTRY_USERNAME, REGISTRY_PASSWORD
* IMAGES comma-separated repositories, e.g. "owner/api,owner/web"
* TAG the tag to pull
*
* TLS: Portainer here is self-signed; the caller sets
* NODE_TLS_REJECT_UNAUTHORIZED=0 for this step.
*/
function required(name) {
const v = process.env[name];
if (!v) {
console.error(`missing required env: ${name}`);
process.exit(1);
}
return v;
}
const PORTAINER_URL = required("PORTAINER_URL").replace(/\/+$/, "");
const API_KEY = required("PORTAINER_API_KEY");
const ENDPOINT_ID = required("PORTAINER_ENDPOINT_ID");
const REGISTRY = required("REGISTRY");
const USERNAME = required("REGISTRY_USERNAME");
const PASSWORD = required("REGISTRY_PASSWORD");
const IMAGES = required("IMAGES").split(",").map((s) => s.trim()).filter(Boolean);
const TAG = required("TAG");
const DOCKER = `${PORTAINER_URL}/api/endpoints/${ENDPOINT_ID}/docker`;
// Docker wants the credentials as base64url'd JSON in a header. Node's
// "base64url" encoding omits the `=` padding, which Portainer's Go decoder
// rejects outright ("Illegal base64 data at input byte N"), so build the
// URL-safe alphabet by hand and KEEP the padding.
const REGISTRY_AUTH = Buffer.from(
JSON.stringify({
username: USERNAME,
password: PASSWORD,
serveraddress: REGISTRY,
}),
)
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_");
async function pull(repository) {
const image = `${REGISTRY}/${repository}`;
const url =
`${DOCKER}/images/create` +
`?fromImage=${encodeURIComponent(image)}&tag=${encodeURIComponent(TAG)}`;
const res = await fetch(url, {
method: "POST",
headers: { "X-API-Key": API_KEY, "X-Registry-Auth": REGISTRY_AUTH },
});
const body = await res.text();
if (!res.ok) {
throw new Error(`pull ${image}:${TAG} -> HTTP ${res.status} ${body.slice(0, 300)}`);
}
// The endpoint streams newline-delimited JSON and answers 200 even when the
// pull itself failed — the failure only shows up as an {"error": ...} object
// in the stream, so the status code alone proves nothing.
const lines = body.split("\n").filter((l) => l.trim());
for (const line of lines) {
let obj;
try {
obj = JSON.parse(line);
} catch {
continue;
}
if (obj.error) {
throw new Error(`pull ${image}:${TAG} failed: ${obj.error}`);
}
}
const last = lines.length ? JSON.parse(lines[lines.length - 1]) : {};
console.log(`${image}:${TAG}${last.status ?? "pulled"}`);
}
async function main() {
for (const repository of IMAGES) {
await pull(repository);
}
console.log("all images pulled");
}
main().catch((err) => {
console.error(`image pull FAILED: ${err.message}`);
process.exit(1);
});