fuzzyworld

tower / commit

ci(pages): put every branch on Pages, with a list for a branch picker

342ab851a436cb716dc06e0d22a32310f94c52d8 · Pavel Safronov · 2026-09-27 17:04:39 -0700 · parents b53509f4553b

ci(pages): put every branch on Pages, with a list for a branch picker

GitLab's parallel deployments are Premium-only, and this project is on the
Free plan. So the default branch's pages job now builds every branch into one
site (tools/build-pages.sh): the default branch at the root, each other branch
at /<branch>/, and a branches.json in every folder listing them. A push to any
other branch triggers a default-branch pipeline, so its preview follows it. A
branch that fails to build is left out rather than failing the deploy.

Usage: 922 in / 410.7K out / 111.6M cached (session tally)
rtk:   906.1K saved, 15.2% over 2685 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 .gitlab-ci.yml       | 18 ++++++++++---
 tools/build-pages.sh | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 91 insertions(+), 3 deletions(-)

diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 529dfa6..011dab1 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -11,10 +11,11 @@ test:
 
 pages:
   stage: deploy
+  variables:
+    GIT_DEPTH: "0"
   script:
-    - npm ci
-    - npm run build
-    - mv dist public
+    # The default branch at the root, every other branch at /<branch>/ (build-pages.sh).
+    - sh tools/build-pages.sh public
     # Fail loudly rather than publishing a 404. `mv` silently nests when the
     # destination exists, which is exactly how this broke the first time.
     - test -f public/index.html || (echo "no index.html at artifact root" && exit 1)
@@ -25,3 +26,14 @@ pages:
     paths: [public]
   rules:
     - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
+
+# A push to any other branch rebuilds the site from the default branch, which is what
+# builds that branch's preview at /<branch>/. Parallel deployments would do this
+# natively, but they are Premium-only and this project is on the Free plan.
+preview:
+  stage: deploy
+  trigger:
+    project: PavelSafronov/tower
+    branch: main
+  rules:
+    - if: $CI_COMMIT_BRANCH && $CI_COMMIT_BRANCH != $CI_DEFAULT_BRANCH
diff --git a/tools/build-pages.sh b/tools/build-pages.sh
new file mode 100644
index 0000000..475d717
--- /dev/null
+++ b/tools/build-pages.sh
@@ -0,0 +1,76 @@
+#!/bin/sh
+# Build the GitLab Pages site: the default branch at the root, and every other branch
+# at /<branch>/, so any branch can be looked at in a browser without a dev server.
+#
+# GitLab's own way to do this (parallel deployments) is Premium-only, and this project
+# is on the Free plan. So the default branch's pipeline builds every branch into one
+# site, and a push to any other branch triggers a default-branch pipeline to rebuild it
+# (.gitlab-ci.yml). Assets are relative (vite.config.ts), so a build works in a folder.
+#
+# Every folder also gets a branches.json listing what was built, with links relative to
+# that folder, which is what the app's branch picker reads (src/ui/app.ts).
+#
+#   sh tools/build-pages.sh [out]    run from the repository root; out defaults to public
+set -eu
+
+DEFAULT="${CI_DEFAULT_BRANCH:-main}"
+OUT="${1:-public}"
+WORK="$(mktemp -d)"
+trap 'rm -rf "$WORK"' EXIT
+
+# The default branch, as checked out.
+npm ci
+npm run build
+rm -rf "$OUT"
+mv dist "$OUT"
+# What was built, as branch:folder pairs; the default branch's folder is the root.
+# Neither a git branch name nor a folder name can hold a colon or a space.
+built="$DEFAULT:"
+
+# Every other branch GitLab has, each into its own folder. CI clones shallowly and
+# fetches only the branch it is building, so fetch the rest first.
+git fetch --quiet --prune origin '+refs/heads/*:refs/remotes/origin/*'
+for branch in $(git for-each-ref --format='%(refname:lstrip=3)' refs/remotes/origin); do
+  case "$branch" in "$DEFAULT"|HEAD) continue ;; esac
+  # A folder name a URL can carry: fix/worker-loading becomes fix-worker-loading.
+  name="$(printf '%s' "$branch" | tr 'A-Z' 'a-z' | tr -c 'a-z0-9.\n-' '-')"
+  if [ -e "$OUT/$name" ]; then
+    echo "skipping $branch: /$name/ is already part of the site"
+    continue
+  fi
+  echo "building $branch into /$name/"
+  git worktree add --quiet --detach "$WORK/$name" "origin/$branch"
+  # A branch that does not build is left out rather than failing the whole site.
+  if (cd "$WORK/$name" && npm ci && npm run build); then
+    mv "$WORK/$name/dist" "$OUT/$name"
+    built="$built $branch:$name"
+  else
+    echo "WARNING: $branch did not build; it has no preview this time"
+  fi
+  git worktree remove --force "$WORK/$name"
+done
+
+# branches.json in every folder: each branch, a link to it from here, and which is this.
+write_branches() {
+  here="$1"
+  up=""
+  [ -n "$here" ] && up="../"
+  sep=""
+  {
+    printf '['
+    for pair in $built; do
+      b="${pair%%:*}"
+      f="${pair#*:}"
+      href="$up${f:+$f/}"
+      [ -n "$href" ] || href="./"
+      cur=false
+      [ "$f" = "$here" ] && cur=true
+      printf '%s\n  {"branch": "%s", "href": "%s", "current": %s}' "$sep" "$b" "$href" "$cur"
+      sep=","
+    done
+    printf '\n]\n'
+  } > "$OUT/${here:+$here/}branches.json"
+}
+for pair in $built; do
+  write_branches "${pair#*:}"
+done