#!/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"