ci(#192): thin .woodpecker via #196 escape-hatch (emmett#44)
ci/woodpecker/push/container Pipeline was successful

cms-plugins is an Astro/emdash web app whose image is built by npm/astro
against an upstream package-lock.json (better-sqlite3 native build) and
cannot be expressed as Nix on emmett/amd64, so it stays on docker buildx.

Apply the cluster #196 OCI escape-hatch: move all build/tag/registry truth
into ci/local.sh, parameterized by BUILDKIT_ADDR (local docker-container
default, dry-run; CI overrides to the in-cluster native arm64 remote +
PUBLISH=1). CI now runs the same script a developer runs, so CI and local
can't drift. The three-tag Flux ImagePolicy contract (0.1.<pipeline>,
<branch>, <branch>-latest) and the arm64/kotkan targeting are preserved
verbatim; the Dockerfile is unchanged.
This commit is contained in:
Oleks
2026-06-02 09:29:38 +03:00
parent 0b1f2ebfbc
commit d8de7617fb
2 changed files with 318 additions and 36 deletions
+13 -36
View File
@@ -5,6 +5,14 @@
# repo → pod rolls), and <branch>-latest (same image; chart image.tag
# fallback). Only staging/production have an ImagePolicy, so only those
# move pods.
#
# Thin wrapper (cluster #196, emmett#44): this is an Astro/emdash WEB app
# whose image is built by npm/astro against an upstream lockfile and can NOT
# be expressed as Nix on emmett/amd64, so it stays on `docker buildx`. CI
# runs the SAME ci/local.sh a developer runs — the only CI-specific bits are
# env overrides (BUILDKIT_ADDR -> in-cluster native-arch remote, PUBLISH=1).
# All the tag/registry semantics live in ci/local.sh so CI and local can't
# drift.
labels:
# kotkan (the deploy target) is an arm64 host, so we build natively on
@@ -32,16 +40,11 @@ steps:
environment:
REGISTRY_TOKEN:
from_secret: registry_token
# In-cluster native arm64 buildkit (kotkan's arch). ci/local.sh treats
# this as a native remote builder and skips qemu emulation.
BUILDKIT_ADDR: "tcp://buildkit-rootless-arm64.infra.svc.cluster.local:1234"
PUBLISH: "1"
commands:
- BRANCH="$CI_COMMIT_BRANCH"
- SHA=$(echo "$CI_COMMIT_SHA" | cut -c1-12)
# Semver-shaped immutable tag, one per build. First two components
# stay 0.1 (no real semver discipline yet); patch is the pipeline
# number, monotonic across the whole repo.
- VERSION="0.1.$CI_PIPELINE_NUMBER"
- IMAGE="git.oleks.space/oleks/cms-plugins"
- 'echo "Building $IMAGE:$VERSION (branch=$BRANCH sha=$SHA linux/arm64)"'
# Wait for the in-cluster buildkit to be reachable (it can be cold).
- |
BUILDER_HOST="buildkit-rootless-arm64.infra.svc.cluster.local"
@@ -54,33 +57,7 @@ steps:
[ "$i" -eq 30 ] && echo "Builder not available" && exit 1
sleep 10
done
- echo "$REGISTRY_TOKEN" | docker login git.oleks.space -u oleks --password-stdin
- docker buildx create --name cms-plugins-builder --driver remote "tcp://$BUILDER_HOST:$BUILDER_PORT"
# Tagging scheme — every build pushes three refs:
# $VERSION — semver-shaped, one per build, immutable (audit).
# $BRANCH — floating channel pointer. THIS is what Flux's
# ImagePolicy tracks (filterTags `^staging$` /
# `^production$`, digestReflectionPolicy: Always);
# retagging it onto the new image is what makes
# ImageUpdateAutomation rewrite the pinned digest
# in the workloads repo and roll the pod.
# $BRANCH-latest — same image, kept only so the chart's cosmetic
# `image.tag` fallback (used when image.digest is
# unset) resolves to a real ref.
# All branches publish all three; only staging/production have an
# ImagePolicy, so only those actually move pods.
- |
TAGS="-t $IMAGE:$VERSION -t $IMAGE:$BRANCH -t $IMAGE:$BRANCH-latest"
docker buildx build \
--builder cms-plugins-builder \
--platform linux/arm64 \
$TAGS \
--push \
.
- 'echo "Pushed $IMAGE:$VERSION + floated $IMAGE:$BRANCH and $IMAGE:$BRANCH-latest"'
- ci/local.sh --arch arm64
backend_options:
kubernetes:
nodeSelector:
Executable
+305
View File
@@ -0,0 +1,305 @@
#!/usr/bin/env bash
# shellcheck shell=bash
#
# oci-image escape hatch (cluster #196) for a keep-Dockerfile repo.
#
# cms-plugins is an Astro/emdash WEB app: its production image is built by
# `npm ci` + `astro build` against an upstream package-lock.json with an
# apt-installed C toolchain (better-sqlite3 native build). It does NOT
# nixify and cannot be built on emmett/amd64 as Nix, so it stays on
# `docker buildx` instead of the parity-lib nix archetypes.
#
# This script is the single source of build truth: a developer runs it
# locally (default builder, dry-run) and CI runs the EXACT same script with
# a couple of env overrides, so CI and local can't drift (emmett#44).
#
# Builder is parameterized:
# BUILDKIT_ADDR default docker-container://local (dev: local buildkitd)
# CI overrides to the in-cluster native-arch remote, e.g.
# tcp://buildkit-rootless-arm64.infra.svc.cluster.local:1234
#
# kotkan (the deploy target) is arm64, so the default target arch is arm64.
# Foreign-arch (an arch != the host) is emulated via qemu binfmt and is
# SLOW — an explicit notice is printed and, for a remote NATIVE-arch
# builder, emulation is skipped. If a foreign arch cannot be built locally
# (no binfmt handler, no remote builder) the blocker is named + exit != 0.
#
# DRY-RUN by default. Pass --publish (or PUBLISH=1) to actually --push.
#
# Tagging contract (PRESERVED verbatim from the old .woodpecker pipeline):
# every PUBLISH pushes three refs onto the one image:
# $VERSION — semver-shaped, one per build, immutable (audit).
# Default 0.1.$CI_PIPELINE_NUMBER (override via VERSION).
# $BRANCH — floating channel pointer Flux's ImagePolicy tracks.
# Retagging it is what rolls staging/production pods.
# $BRANCH-latest — same image; chart image.tag cosmetic fallback.
# Only staging/production have an ImagePolicy, so only those move pods.
#
# Token: $REGISTRY_TOKEN, else `pass <PASS_ENTRY>`, else a named hard fail.
# The token is NEVER echoed and `set -x` is never enabled.
set -euo pipefail
# ---- configuration (override via env) ---------------------------------------
REGISTRY_HOST="${REGISTRY_HOST:-git.oleks.space}"
REGISTRY_OWNER="${REGISTRY_OWNER:-oleks}"
REGISTRY_USER="${REGISTRY_USER:-oleks}"
IMAGE_REPO="${IMAGE_REPO:-cms-plugins}"
PASS_ENTRY="${PASS_ENTRY:-infra/gitea/personal_access_token_packages_rw}"
# Local buildkitd by default; CI overrides to the cluster native-arch remote.
BUILDKIT_ADDR="${BUILDKIT_ADDR:-docker-container://local}"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# ---- argument parsing -------------------------------------------------------
PUBLISH="${PUBLISH:-0}"
TARGET_ARCH=""
usage() {
cat <<'EOF'
Usage: ci/local.sh [--arch <arch>] [--publish]
--arch <arch> Target arch: amd64 | arm64 | s390x (default: arm64,
the deploy target kotkan's arch)
--publish Actually push to the registry (default: DRY-RUN)
Environment:
BUILDKIT_ADDR buildx builder endpoint
(default docker-container://local; CI -> cluster remote)
PUBLISH=1 same as --publish
REGISTRY_TOKEN registry token (else `pass PASS_ENTRY`)
VERSION immutable semver-shaped tag (default 0.1.$CI_PIPELINE_NUMBER)
CI_COMMIT_BRANCH branch -> floating channel tag (default current git branch)
Examples:
ci/local.sh # dry-run, arm64, both via local buildkit
ci/local.sh --arch amd64 # dry-run, foreign arch via qemu (slow)
PUBLISH=1 ci/local.sh # real push of the arm64 image (3 tags)
EOF
}
while [ "$#" -gt 0 ]; do
case "$1" in
--arch)
TARGET_ARCH="${2:-}"
shift 2
;;
--publish)
PUBLISH=1
shift
;;
-h | --help)
usage
exit 0
;;
*)
echo "ERROR: unknown argument: $1" >&2
usage >&2
exit 2
;;
esac
done
# ---- arch resolution --------------------------------------------------------
host_arch() {
case "$(uname -m)" in
x86_64) echo amd64 ;;
aarch64 | arm64) echo arm64 ;;
s390x) echo s390x ;;
*) echo "unknown" ;;
esac
}
HOST_ARCH="$(host_arch)"
# kotkan is arm64; the image is only ever deployed there.
[ -n "$TARGET_ARCH" ] || TARGET_ARCH="arm64"
case "$TARGET_ARCH" in
amd64 | arm64 | s390x) ;;
*)
echo "ERROR: unsupported --arch '$TARGET_ARCH' (want amd64|arm64|s390x)" >&2
exit 2
;;
esac
# ---- version + branch derivation --------------------------------------------
# $VERSION -> $VERSION env -> 0.1.$CI_PIPELINE_NUMBER -> 0.1.0-dev.
derive_version() {
if [ -n "${VERSION:-}" ]; then
printf '%s' "$VERSION"
return
fi
if [ -n "${CI_PIPELINE_NUMBER:-}" ]; then
printf '0.1.%s' "$CI_PIPELINE_NUMBER"
return
fi
printf '0.1.0-dev'
}
# $CI_COMMIT_BRANCH -> current git branch -> "dev".
derive_branch() {
if [ -n "${CI_COMMIT_BRANCH:-}" ]; then
printf '%s' "$CI_COMMIT_BRANCH"
return
fi
local b
if b="$(git -C "$REPO_ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null)" \
&& [ -n "$b" ] && [ "$b" != "HEAD" ]; then
printf '%s' "$b"
return
fi
printf 'dev'
}
IMAGE_VERSION="$(derive_version)"
BRANCH="$(derive_branch)"
# ---- token resolution (never printed) ---------------------------------------
resolve_token() {
if [ -n "${REGISTRY_TOKEN:-}" ]; then
printf '%s' "$REGISTRY_TOKEN"
return 0
fi
if command -v pass >/dev/null 2>&1; then
local t
if t="$(pass "$PASS_ENTRY" 2>/dev/null)" && [ -n "$t" ]; then
printf '%s' "$t"
return 0
fi
fi
echo "BLOCKER: no registry token." >&2
echo " Set \$REGISTRY_TOKEN or store it at: pass $PASS_ENTRY" >&2
return 1
}
# ---- foreign-arch (qemu binfmt) gate ----------------------------------------
binfmt_handler_present() {
local q="$1"
[ -e "/proc/sys/fs/binfmt_misc/qemu-${q}" ]
}
qemu_name_for() {
case "$1" in
amd64) echo "x86_64" ;;
arm64) echo "aarch64" ;;
s390x) echo "s390x" ;;
esac
}
# Is BUILDKIT_ADDR the matching per-arch cluster builder (native, no qemu)?
builder_is_native_remote() {
case "$BUILDKIT_ADDR" in
tcp://*"buildkit-"*"${TARGET_ARCH}"*) return 0 ;;
*) return 1 ;;
esac
}
emulation_gate() {
if [ "$TARGET_ARCH" = "$HOST_ARCH" ]; then
return 0
fi
if builder_is_native_remote; then
echo "NOTE: target $TARGET_ARCH served NATIVELY by remote builder ($BUILDKIT_ADDR)."
return 0
fi
local q
q="$(qemu_name_for "$TARGET_ARCH")"
echo "================================================================"
echo " SLOW / ACCELERATOR NOTICE"
echo " Target arch '$TARGET_ARCH' != host '$HOST_ARCH'."
echo " This build runs under qemu-${q} binfmt emulation and will be"
echo " MUCH slower than a native build. For speed, point BUILDKIT_ADDR"
echo " at a native remote builder, e.g.:"
echo " BUILDKIT_ADDR=tcp://buildkit-rootless-${TARGET_ARCH}.infra.svc.cluster.local:1234"
echo "================================================================"
if ! binfmt_handler_present "$q"; then
echo "BLOCKER: cannot build '$TARGET_ARCH' locally — no qemu-${q} binfmt" >&2
echo " handler is registered with this kernel." >&2
echo " Fix one of:" >&2
echo " * register emulation: docker run --privileged --rm tonistiigi/binfmt --install ${TARGET_ARCH}" >&2
echo " * or use a native remote builder via BUILDKIT_ADDR (see notice above)." >&2
return 1
fi
return 0
}
# ---- buildx builder bootstrap ----------------------------------------------
BUILDER_NAME="cc-local-${TARGET_ARCH}"
ensure_builder() {
if docker buildx inspect "$BUILDER_NAME" >/dev/null 2>&1; then
return 0
fi
case "$BUILDKIT_ADDR" in
docker-container://*)
docker buildx create --name "$BUILDER_NAME" \
--driver docker-container >/dev/null
;;
tcp://*)
local endpoint="${BUILDKIT_ADDR#tcp://}"
docker buildx create --name "$BUILDER_NAME" \
--driver remote "tcp://${endpoint}" >/dev/null
;;
*)
echo "ERROR: unrecognized BUILDKIT_ADDR scheme: $BUILDKIT_ADDR" >&2
echo " want docker-container://... or tcp://host:port" >&2
exit 2
;;
esac
}
# ---- build / publish --------------------------------------------------------
# Builds the single repo-root Dockerfile and (on --publish) pushes the three
# contract tags onto it in one buildx invocation.
build_image() {
local image="${REGISTRY_HOST}/${REGISTRY_OWNER}/${IMAGE_REPO}"
local -a args=(
docker buildx build
--builder "$BUILDER_NAME"
--platform "linux/${TARGET_ARCH}"
-f "${REPO_ROOT}/Dockerfile"
-t "${image}:${IMAGE_VERSION}"
-t "${image}:${BRANCH}"
-t "${image}:${BRANCH}-latest"
)
if [ "$PUBLISH" = "1" ]; then
args+=(--push)
echo ">> PUBLISH $image tags: ${IMAGE_VERSION}, ${BRANCH}, ${BRANCH}-latest"
else
echo ">> DRY-RUN $image tags: ${IMAGE_VERSION}, ${BRANCH}, ${BRANCH}-latest (build-only; pass --publish to push)"
fi
args+=("$REPO_ROOT")
"${args[@]}"
}
# ---- main -------------------------------------------------------------------
main() {
echo "repo : ${REGISTRY_HOST}/${REGISTRY_OWNER}/${IMAGE_REPO}"
echo "host arch : ${HOST_ARCH}"
echo "target arch : ${TARGET_ARCH}"
echo "branch : ${BRANCH}"
echo "version tag : ${IMAGE_VERSION}"
echo "builder : ${BUILDKIT_ADDR}"
echo "mode : $([ "$PUBLISH" = "1" ] && echo PUBLISH || echo DRY-RUN)"
emulation_gate
if [ "$PUBLISH" = "1" ]; then
local token
token="$(resolve_token)"
echo "$token" | docker login "$REGISTRY_HOST" \
-u "$REGISTRY_USER" --password-stdin >/dev/null
unset token
fi
ensure_builder
build_image
echo "done."
}
main