#!/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); });