content split + copy audit + css polish: content (news/pages/uploads) moves to the ctao/content repo (build overlays it — two-repo build.sh, link-content.sh for local dev); portal copy trimmed to source-documents-only (dashboard empty states, minimal privacy/disclaimer, no mock badges); css: inverted selection on navy bands, featured-card text-track floor, 44px toc target, mobile search dates on own line, footer link row gap; DESIGN.md rewritten as a short handoff

This commit is contained in:
2026-09-03 19:35:22 +02:00
parent 2e6d374b4b
commit 13f945fbf3
457 changed files with 192 additions and 8142 deletions
+15 -8
View File
@@ -4,15 +4,20 @@ Everything runs rootless as user `strapi`. Footprint on the machine:
ONE directory (`~/ctao-portal-demo/`) + 4 unit files in `~/.config/`.
Uninstall restores the machine exactly (see bottom). No secrets in any file.
Two Gitea repos by design — `ctao/portal` (code, developed by the team) and
`ctao/content` (Markdown + uploads, committed by the CMS). The build overlays
content onto code; a push to EITHER republishes the site.
```
~/ctao-portal-demo/
├── gitea-data/ # Gitea state (repos, SQLite, accounts) ← the ONLY thing worth backing up
├── gitea-config/ # Gitea app.ini (generated from env)
├── repo/ # clone of the portal repo (created by first build)
├── releases/ # <sha>/ dirs + `current` symlink (what nginx serves)
├── state/ # last-built SHA
├── repo/ # clone of the code repo (created by first build)
├── content/ # clone of the content repo (created by first build)
├── releases/ # <code>-<content>/ dirs + `current` symlink (what nginx serves)
├── state/ # last-built release id
├── npm-cache/ # npm cache for the build container (created by build.sh)
├── bin/build.sh # copied from deploy/ (source of truth stays in the repo)
├── bin/build.sh # copied from deploy/ (source of truth stays in the code repo)
└── config/nginx.conf
```
@@ -47,10 +52,11 @@ missing). Everything else runs inside containers.
6. **[W]** Create the Gitea admin — run interactively in a terminal so the
password never lands in a file or shell history:
`podman exec -it ctao-demo-gitea gitea admin user create --admin --username <you> --email <you@…> --random-password`
7. **[W]** In the Gitea UI: create org `ctao`, repo `portal` (public read).
Then, from your workstation, push the portal repo over the vhost with a
repo-scoped token:
7. **[W]** In the Gitea UI: create org `ctao` with repos `portal` and
`content` (both public read). Then, from your workstation, push both over
the vhost with a repo-scoped token:
`git push https://<user>:<token>@astro-git.isl-dev.grid.cyfronet.pl/ctao/portal.git main`
`git push https://<user>:<token>@astro-git.isl-dev.grid.cyfronet.pl/ctao/content.git main`
8. **[W]** `systemctl --user enable --now ctao-portal-build.timer` — first run
clones + `npm ci` + builds (minutes); later runs are seconds. Wait until
`journalctl --user -u ctao-portal-build -n 5` shows `published <sha>`
@@ -88,7 +94,8 @@ loginctl disable-linger $USER # only if nothing else of yours should survive l
## Notes
- **Only site content auto-deploys.** Changes to `deploy/*` need a manual
- **Only pushes to the machine's Gitea auto-deploy** (code and content repos
alike). Changes to `deploy/*` need a manual
re-copy: `build.sh` → `bin/`, `nginx.conf` → `config/` +
`systemctl --user restart ctao-demo-web`, unit files →
`~/.config/…` + `systemctl --user daemon-reload` (+ restart). This is
+60 -32
View File
@@ -1,15 +1,22 @@
#!/usr/bin/env bash
# CTAO portal demo — poll the content repo, rebuild, publish atomically.
# Triggered every 10 s by ctao-portal-build.timer; systemd oneshot semantics
# guarantee runs never overlap. The 99.9% case is one local curl and exit 0.
# No secrets anywhere: the portal repo is public-read on the local Gitea.
# 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
REPO="${REPO:-ctao/portal}" # owner/repo in 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
@@ -17,9 +24,10 @@ BUILD_IMAGE="${BUILD_IMAGE:-localhost/ctao-portal-build:1}"
# (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}"
REPO_INTERNAL="${REPO_INTERNAL:-http://localhost:3000/$REPO.git}"
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/releases" "$BASE/state" "$BASE/npm-cache"
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.
@@ -27,43 +35,63 @@ for tool in curl jq podman; do
command -v "$tool" >/dev/null || { echo "missing host tool: $tool"; exit 1; }
done
# --- 1. Cheap poll: branch head via the local Gitea API (host curl + jq) ---
sha=$(curl -fsS --max-time 5 "$GITEA_URL/api/v1/repos/$REPO/branches/$BRANCH" \
| jq -r '.commit.id' || true)
# --- 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.
if [[ ! "$sha" =~ ^[0-9a-f]{40}$ ]]; then
echo "poll failed (gitea unreachable?) — skipping this tick"
exit 0
fi
# Skip only if this sha is both recorded AND still present in releases/
for sha in "$code_sha" "$content_sha"; do
if [[ ! "$sha" =~ ^[0-9a-f]{40}$ ]]; then
echo "poll failed (gitea unreachable?) — skipping this tick"
exit 0
fi
done
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).
[[ "$sha" == "$(cat "$BASE/state/last-built" 2>/dev/null)" \
&& -d "$BASE/releases/$sha" ]] && exit 0
[[ "$release" == "$(cat "$BASE/state/last-built" 2>/dev/null)" \
&& -d "$BASE/releases/$release" ]] && exit 0
echo "building $sha"
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 (code-server). Never use --network=host here.
# 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 SHA="$sha" -e REPO_URL="$REPO_INTERNAL" -e BRANCH="$BRANCH" \
-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
[ -d repo/.git ] || git clone --branch "$BRANCH" "$REPO_URL" repo
git -C repo remote set-url origin "$REPO_URL" # self-heal if the URL changes
git -C repo fetch --quiet origin "$BRANCH"
# --force: the working copy is disposable; a stray tracked-file edit must
# not wedge every future build.
git -C repo checkout --quiet --force "$SHA"
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)
rm -rf repo/src/content/news repo/src/content/pages repo/public/uploads
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
@@ -76,17 +104,17 @@ podman run --rm --network="$BUILD_NETNS" --memory=1g \
echo "$lock" > .deps-hash
fi
npm run build
rm -rf "../releases/$SHA"
cp -a dist "../releases/$SHA"
rm -rf "../releases/$RELEASE"
cp -a dist "../releases/$RELEASE"
'
# --- 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 "$sha" "$BASE/releases/.current.$$"
ln -s "$release" "$BASE/releases/.current.$$"
mv -Tf "$BASE/releases/.current.$$" "$BASE/releases/current"
echo "$sha" > "$BASE/state/last-built"
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.
@@ -98,4 +126,4 @@ ls -1t | grep -vx current | grep -vx -- "$cur" | tail -n +"$((KEEP + 1))" | whil
rm -rf -- "$old"
done || true
echo "published $sha in $(( $(date +%s) - t0 ))s"
echo "published $release in $(( $(date +%s) - t0 ))s"