Files
portal/deploy/build.sh
T

137 lines
7.2 KiB
Bash
Executable File

#!/usr/bin/env bash
# CTAO portal — poll the code AND content repos, rebuild on any change,
# publish atomically. Triggered every 10 s by ctao-portal-build.timer; systemd
# oneshot semantics guarantee runs never overlap. The 99.9% case is two local
# curls and exit 0. No secrets anywhere: both repos are public-read on the
# local Gitea.
#
# Two repositories by design: code (templates/CSS, developed by the team) and
# content (Markdown + uploads, committed by the CMS). The build overlays
# content onto code, so an editor publishing an article and a developer
# shipping CSS never mix histories — either change republishes the site.
set -euo pipefail
# Defaults match the machine; every var is env-overridable so the whole
# pipeline can be tested locally against a sandbox dir + local Gitea.
BASE="${BASE:-$HOME/ctao-portal-demo}"
GITEA_URL="${GITEA_URL:-http://localhost:3000}" # published by ctao-demo-gitea
CODE_REPO="${CODE_REPO:-ctao/portal}" # owner/repo in Gitea
CONTENT_REPO="${CONTENT_REPO:-ctao/content}"
BRANCH="${BRANCH:-main}"
BUILD_IMAGE="${BUILD_IMAGE:-localhost/ctao-portal-build:1}"
# The build joins the Gitea container's network namespace: localhost inside
# the build = Gitea's loopback (port 3000), host loopback stays unreachable.
# (A netavark bridge would be equivalent, but rootless bridges need the
# ip_tables kernel module, absent on the machine — pasta needs nothing.)
BUILD_NETNS="${BUILD_NETNS:-container:ctao-demo-gitea}"
CODE_URL="${CODE_URL:-http://localhost:3000/$CODE_REPO.git}"
CONTENT_URL="${CONTENT_URL:-http://localhost:3000/$CONTENT_REPO.git}"
KEEP="${KEEP:-3}" # released builds to retain
mkdir -p "$BASE/repo" "$BASE/content" "$BASE/releases" "$BASE/state" "$BASE/npm-cache"
# Host prerequisites (everything else runs inside containers). Fail loud —
# a missing tool is permanent, unlike a Gitea hiccup below.
for tool in curl jq podman; do
command -v "$tool" >/dev/null || { echo "missing host tool: $tool"; exit 1; }
done
# --- 1. Cheap poll: both branch heads via the local Gitea API ---
head_of() {
curl -fsS --max-time 5 "$GITEA_URL/api/v1/repos/$1/branches/$BRANCH" \
| jq -r '.commit.id' || true
}
code_sha=$(head_of "$CODE_REPO")
content_sha=$(head_of "$CONTENT_REPO")
# Gitea down/unreachable is a transient, not a unit failure — exit 0 quietly
# instead of painting the journal red every 10 s. Name the repo: a 404 here
# also means "repo/branch missing or renamed", not just "Gitea down".
[[ "$code_sha" =~ ^[0-9a-f]{40}$ ]] || { echo "poll failed for $CODE_REPO@$BRANCH (gitea down, or repo/branch missing) — skipping"; exit 0; }
[[ "$content_sha" =~ ^[0-9a-f]{40}$ ]] || { echo "poll failed for $CONTENT_REPO@$BRANCH (gitea down, or repo/branch missing) — skipping"; exit 0; }
release="${code_sha:0:12}-${content_sha:0:12}" # code+content pin the release
# Skip only if this pair is both recorded AND still present in releases/
# (a deleted release dir must trigger a rebuild, not an eternal skip).
[[ "$release" == "$(cat "$BASE/state/last-built" 2>/dev/null)" \
&& -d "$BASE/releases/$release" ]] && exit 0
# A release that already failed is not retried until either repo moves —
# otherwise one bad commit (e.g. broken frontmatter) turns into a full
# rebuild every 10 s on a shared VM. The failure is loud once, then quiet.
if [[ "$release" == "$(cat "$BASE/state/last-failed" 2>/dev/null)" ]]; then
echo "skipping $release — build failed before; push a fix to retry"
exit 0
fi
echo "building code=$code_sha content=$content_sha"
t0=$(date +%s)
# --- 2. Build in the ephemeral container (git + pinned node live there).
# SECURITY: the container runs npm lifecycle scripts from the repo, so it is
# confined to Gitea's netns — it reaches Gitea on localhost:3000 and the
# internet (for `npm ci` when the lockfile changed), but NOT the host's
# loopback services. Never use --network=host here.
# node_modules and .deps-hash are untracked, so they survive checkouts.
podman run --rm --network="$BUILD_NETNS" --memory=1g \
-e ASTRO_TELEMETRY_DISABLED=1 \
-e CODE_SHA="$code_sha" -e CODE_URL="$CODE_URL" \
-e CONTENT_SHA="$content_sha" -e CONTENT_URL="$CONTENT_URL" \
-e RELEASE="$release" -e BRANCH="$BRANCH" \
-v "$BASE/repo:/work/repo:z" \
-v "$BASE/content:/work/content:z" \
-v "$BASE/releases:/work/releases:z" \
-v "$BASE/npm-cache:/root/.npm:z" \
-w /work "$BUILD_IMAGE" sh -ec '
git config --global safe.directory "/work/repo"
git config --global --add safe.directory "/work/content"
sync_clone() { # $1 dir $2 url $3 sha
[ -d "$1/.git" ] || git clone --branch "$BRANCH" "$2" "$1"
git -C "$1" remote set-url origin "$2" # self-heal if the URL changes
git -C "$1" fetch --quiet origin "$BRANCH"
# --force: the working copy is disposable; a stray tracked-file edit
# must not wedge every future build.
git -C "$1" checkout --quiet --force "$3"
}
sync_clone repo "$CODE_URL" "$CODE_SHA"
sync_clone content "$CONTENT_URL" "$CONTENT_SHA"
# Overlay content onto code (these paths are gitignored in the code repo).
# mkdir -p: checkout prunes the emptied parent dirs, cp needs them back.
rm -rf repo/src/content/news repo/src/content/pages repo/public/uploads
mkdir -p repo/src/content repo/public
cp -a content/news repo/src/content/news
cp -a content/pages repo/src/content/pages
cp -a content/uploads repo/public/uploads
cd repo
lock=$(sha256sum package-lock.json | cut -d" " -f1)
if [ ! -d node_modules ] || [ "$lock" != "$(cat .deps-hash 2>/dev/null)" ]; then
# --ignore-scripts: (1) removes the install-time postinstall vector from
# npm deps (build-time repo code still runs `npm run build` below — the
# netns confinement is the control for that), (2) avoids the esbuild
# ETXTBSY postinstall race in rootless containers. esbuild ships its
# binary as an optional dep, so nothing here needs lifecycle scripts.
npm ci --ignore-scripts --no-audit --no-fund
echo "$lock" > .deps-hash
fi
npm run build
rm -rf "../releases/$RELEASE"
cp -a dist "../releases/$RELEASE"
' || { echo "$release" > "$BASE/state/last-failed"; echo "BUILD FAILED for $release (see above) — will not retry until a new commit"; exit 1; }
rm -f "$BASE/state/last-failed"
# --- 3. Atomic publish: symlink flip via rename(2) — no half-published moment.
# mv -T is GNU (the target host is Rocky); when testing on macOS put a
# coreutils `mv` (gmv) first in PATH.
rm -f "$BASE/releases/".current.* # stale temps from a crash mid-flip
ln -s "$release" "$BASE/releases/.current.$$"
mv -Tf "$BASE/releases/.current.$$" "$BASE/releases/current"
echo "$release" > "$BASE/state/last-built"
# --- 4. Prune old releases. `current`'s target is excluded explicitly —
# mtime ordering makes it newest today, but nothing should depend on that.
# `|| true`: an empty match must not fail the unit after a successful publish
# (grep exits 1 under pipefail when there is nothing to prune).
cd "$BASE/releases"
cur=$(readlink current || true)
ls -1t | grep -vx current | grep -vx -- "$cur" | tail -n +"$((KEEP + 1))" | while read -r old; do
rm -rf -- "$old"
done || true
echo "published $release in $(( $(date +%s) - t0 ))s"