Files
summercms/scripts/check-phase14.3.sh
Jakub Zych 612d0c6dcb chore(14.3-02): add the D-20 container acceptance stages to the phase 14.3 gate
- --acceptance: in a bare node:24-bookworm container install jq, the Docker
  CLI and compose plugin (Docker apt repo) and Go 1.27.0 (sha256 from go.dev),
  pipe the committed new.sh into bash with SUMMERCMS_REPO=/fw and
  SUMMERCMS_REF=HEAD, then run the D-20 chain and require the starter route
- refuses when modules/sunrise/new.sh differs from HEAD; unique compose
  project with host-side cleanup of containers, volumes and networks
- --acceptance-production: same chain against https://summercms.io/new
2026-10-08 11:55:27 +02:00

308 lines
12 KiB
Bash
Executable File

#!/usr/bin/env bash
# Phase 14.3 gate (one-command app scaffolding).
#
# --generator proves D-12 end to end: `summer new demo` run next to a
# symlinked framework checkout, with no --framework flag and no git
# identity, writes an app that builds, migrates against PostgreSQL 16 from
# its own compose.yaml and serves the starter route with no edits. It ends
# with "Phase 14.3 generator check passed".
#
# --acceptance proves D-20 before the release tag exists: in a bare
# node:24-bookworm container it installs jq, the Docker CLI and compose
# plugin from Docker's signed apt repository and Go 1.27.0 from go.dev
# (sha256 checked), pipes the committed modules/sunrise/new.sh into
# `bash -s -- demo` with SUMMERCMS_REPO=<this checkout> and SUMMERCMS_REF=HEAD,
# then runs `docker compose up -d --wait && summer build && summer migrate &&
# summer serve` in sm-demo-app and requires the starter route. The container
# drives the host Docker daemon through its socket, so it runs with
# --network host under a unique compose project that is removed afterwards.
# It ends with "Phase 14.3 acceptance passed".
#
# --acceptance-production runs the same chain with
# `curl -fsSL https://summercms.io/new | bash -s -- demo` and no SUMMERCMS_
# overrides, after the release is tagged and the site redeployed. It ends
# with "Phase 14.3 production acceptance passed".
#
# Needs Docker, the postgres:16 image (pulled when missing) and free host
# ports 54320 and 8080. Every failure exits non-zero.
#
# Overrides: PHASE143_ROOT (framework checkout), PHASE143_KEEP=1 (keep the
# temporary directory), PHASE143_TIMEOUT (seconds to wait for serve, 60).
set -euo pipefail
unset FORCE_COLOR
ROOT="${PHASE143_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
SERVE_TIMEOUT="${PHASE143_TIMEOUT:-60}"
fail() {
echo "FAIL: $*" >&2
exit 1
}
port_free() {
# bash /dev/tcp connects when something listens; free means it fails.
if (exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null; then
return 1
fi
return 0
}
stage_generator() {
command -v docker >/dev/null || fail "docker is not installed"
docker info >/dev/null 2>&1 || fail "the Docker daemon is not reachable (docker info failed)"
docker compose version >/dev/null 2>&1 || fail "docker compose is not available"
command -v git >/dev/null || fail "git is not installed"
command -v curl >/dev/null || fail "curl is not installed"
port_free 54320 || fail "127.0.0.1:54320 is in use; the generated compose.yaml publishes PostgreSQL there"
port_free 8080 || fail "127.0.0.1:8080 is in use; summer serve listens there"
# Globals, not locals: the EXIT trap runs after this function returns.
tmp="$(mktemp -d)"
app="$tmp/parent/sm-demo-app"
project="phase143-$$-$(date +%s)"
server_pid=""
cleanup() {
local rc=$?
if [ -n "$server_pid" ]; then
kill "$server_pid" 2>/dev/null || true
wait "$server_pid" 2>/dev/null || true
fi
if [ -f "$app/compose.yaml" ]; then
(cd "$app" && COMPOSE_PROJECT_NAME="$project" docker compose down -v --remove-orphans >/dev/null 2>&1) || true
fi
if [ "${PHASE143_KEEP:-0}" = "1" ]; then
echo "kept $tmp" >&2
else
chmod -R u+w "$tmp" 2>/dev/null || true
rm -rf "$tmp"
fi
exit "$rc"
}
trap cleanup EXIT
echo "== build the summer CLI"
mkdir -p "$tmp/bin" "$tmp/parent" "$tmp/home"
go -C "$ROOT" build -o "$tmp/bin/summer" ./cmd/summer
export PATH="$tmp/bin:$PATH"
# Reuse the module and build caches under the empty HOME below.
GOMODCACHE="$(go env GOMODCACHE)"
GOCACHE="$(go env GOCACHE)"
GOPATH="$(go env GOPATH)"
export GOMODCACHE GOCACHE GOPATH
echo "== summer new demo (sibling detection, no git identity)"
ln -s "$ROOT" "$tmp/parent/summercms"
(
cd "$tmp/parent"
env -u GIT_AUTHOR_NAME -u GIT_AUTHOR_EMAIL -u GIT_COMMITTER_NAME -u GIT_COMMITTER_EMAIL \
HOME="$tmp/home" XDG_CONFIG_HOME="$tmp/home/.config" GIT_CONFIG_NOSYSTEM=1 \
summer new demo
) | tee "$tmp/new.out"
grep -q "SummerCMS <summer@localhost>" "$tmp/new.out" || fail "summer new did not announce the fallback git identity"
grep -q "docker compose up -d --wait" "$tmp/new.out" || fail "next steps lack docker compose up -d --wait"
for step in "summer build" "summer migrate" "summer serve"; do
grep -q "$step" "$tmp/new.out" || fail "next steps lack $step"
done
echo "== layout checks"
[ -d "$app" ] || fail "sm-demo-app was not created"
grep -q "replace git.golem15.com/golem15/summercms => ../summercms" "$app/go.mod" ||
fail "go.mod lacks the relative sibling replace"
[ "$(stat -c %a "$app/.env")" = "600" ] || fail ".env mode is $(stat -c %a "$app/.env"), want 600"
grep -q '^SUMMER_APP__KEY=.\{44\}$' "$app/.env" || fail ".env lacks a 44-character SUMMER_APP__KEY"
[ "$(git -C "$app" rev-list --count HEAD)" = "1" ] || fail "want exactly one commit"
[ "$(git -C "$app" rev-parse --abbrev-ref HEAD)" = "main" ] || fail "want branch main"
[ "$(git -C "$app" log -1 --format='%an <%ae>')" = "SummerCMS <summer@localhost>" ] ||
fail "first commit author is not the fallback identity"
[ -z "$(git -C "$app" ls-files .env)" ] || fail ".env is committed"
for f in .env.example go.work main.go plugins.gen.go .gitignore compose.yaml CLAUDE.md AGENTS.md summer.yaml go.mod; do
[ -n "$(git -C "$app" ls-files "$f")" ] || fail "$f is not committed"
done
[ -L "$app/AGENTS.md" ] && [ "$(readlink "$app/AGENTS.md")" = "CLAUDE.md" ] || fail "AGENTS.md is not a symlink to CLAUDE.md"
[ ! -e "$app/go.sum" ] || fail "the app root has a go.sum"
if grep -Eq '^[[:space:]]*/' "$app/go.work"; then
fail "go.work has an absolute use path"
fi
git -C "$app" log -1 --format=%B | grep -qi "co-authored-by" && fail "first commit carries a trailer"
echo "== docker compose up -d --wait, summer build, summer migrate"
cd "$app"
export COMPOSE_PROJECT_NAME="$project"
docker compose up -d --wait
summer build
summer migrate
echo "== summer serve"
summer serve >"$tmp/serve.log" 2>&1 &
server_pid=$!
local body="" i
for ((i = 0; i < SERVE_TIMEOUT; i++)); do
if body="$(curl -fsS http://127.0.0.1:8080/ 2>/dev/null)"; then
break
fi
kill -0 "$server_pid" 2>/dev/null || { cat "$tmp/serve.log" >&2; fail "summer serve exited"; }
sleep 1
done
[ -n "$body" ] || { cat "$tmp/serve.log" >&2; fail "GET / did not answer within ${SERVE_TIMEOUT}s"; }
echo "GET / -> $body"
case "$body" in
*'"plugin":"golem15.demo"'*) ;;
*) fail "GET / body lacks \"plugin\":\"golem15.demo\"" ;;
esac
echo "Phase 14.3 generator check passed"
}
# acceptance_inner runs inside node:24-bookworm as root. ACC_MODE is local
# or production; ACC_REF is the framework commit for local runs.
read -r -d '' acceptance_inner <<'INNER' || true
export DEBIAN_FRONTEND=noninteractive
run() {
echo "+ $*"
"$@"
}
echo "== apt: ca-certificates curl gnupg jq"
apt-get update -qq
apt-get install -y -qq ca-certificates curl gnupg jq >/dev/null
echo "== Docker CLI and compose plugin from Docker's apt repository"
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian bookworm stable" >/etc/apt/sources.list.d/docker.list
apt-get update -qq
apt-get install -y -qq docker-ce-cli docker-compose-plugin >/dev/null
docker --version
docker compose version
echo "== Go 1.27.0 from go.dev, sha256 checked"
tarball=go1.27.0.linux-amd64.tar.gz
sha="$(curl -fsSL 'https://go.dev/dl/?mode=json&include=all' |
jq -r --arg f "$tarball" '.[] | select(.version == "go1.27.0") | .files[] | select(.filename == $f) | .sha256')"
[[ $sha =~ ^[0-9a-f]{64}$ ]] || { echo "no sha256 for $tarball on go.dev" >&2; exit 1; }
curl -fsSL -o "/tmp/$tarball" "https://go.dev/dl/$tarball"
(cd /tmp && echo "$sha $tarball" | sha256sum -c -)
tar -C /usr/local -xzf "/tmp/$tarball"
export PATH="/usr/local/go/bin:$PATH"
go version
# Throwaway root container cloning a host-owned checkout.
git config --global --add safe.directory '*'
mkdir /work
cd /work
server_pid=""
cleanup() {
local rc=$?
if [ -n "$server_pid" ]; then
kill "$server_pid" 2>/dev/null || true
wait "$server_pid" 2>/dev/null || true
fi
if [ -f /work/sm-demo-app/compose.yaml ]; then
(cd /work/sm-demo-app && docker compose down -v --remove-orphans >/dev/null 2>&1) || true
fi
exit "$rc"
}
trap cleanup EXIT
if [ "$ACC_MODE" = production ]; then
echo "+ curl -fsSL https://summercms.io/new | bash -s -- demo"
curl -fsSL https://summercms.io/new | bash -s -- demo 2>&1 | tee /tmp/new.out
else
echo "+ curl -fsSL file:///fw/modules/sunrise/new.sh | SUMMERCMS_REPO=/fw SUMMERCMS_REF=$ACC_REF bash -s -- demo"
curl -fsSL file:///fw/modules/sunrise/new.sh | SUMMERCMS_REPO=/fw SUMMERCMS_REF="$ACC_REF" bash -s -- demo 2>&1 | tee /tmp/new.out
fi
grep -qx ' cd /work/sm-demo-app' /tmp/new.out || { echo "the script did not print cd /work/sm-demo-app" >&2; exit 1; }
grep -qF 'export PATH="/root/go/bin:$PATH"' /tmp/new.out || { echo "the script did not print the PATH hint" >&2; exit 1; }
export PATH="$(go env GOPATH)/bin:$PATH"
echo "== D-20 chain"
cd sm-demo-app
run docker compose up -d --wait
run summer build
run summer migrate
echo "+ summer serve &"
summer serve >/tmp/serve.log 2>&1 &
server_pid=$!
body=""
for ((i = 0; i < 60; i++)); do
if body="$(curl -fsS http://127.0.0.1:8080/ 2>/dev/null)"; then
break
fi
kill -0 "$server_pid" 2>/dev/null || { cat /tmp/serve.log >&2; echo "summer serve exited" >&2; exit 1; }
sleep 1
done
[ -n "$body" ] || { cat /tmp/serve.log >&2; echo "GET / did not answer within 60s" >&2; exit 1; }
echo "GET http://127.0.0.1:8080/ -> $body"
case "$body" in
*'"plugin":"golem15.demo"'*) ;;
*) echo "GET / body lacks \"plugin\":\"golem15.demo\"" >&2; exit 1 ;;
esac
INNER
# stage_acceptance runs acceptance_inner in node:24-bookworm. $1 is local
# (committed script and repository at HEAD) or production.
stage_acceptance() {
local mode="$1"
command -v docker >/dev/null || fail "docker is not installed"
docker info >/dev/null 2>&1 || fail "the Docker daemon is not reachable (docker info failed)"
port_free 54320 || fail "127.0.0.1:54320 is in use; the generated compose.yaml publishes PostgreSQL there"
port_free 8080 || fail "127.0.0.1:8080 is in use; summer serve listens there"
local ref="" mounts=()
if [ "$mode" = local ]; then
git -C "$ROOT" ls-files --error-unmatch modules/sunrise/new.sh >/dev/null 2>&1 ||
fail "modules/sunrise/new.sh is not committed; the acceptance run clones HEAD"
git -C "$ROOT" diff --quiet HEAD -- modules/sunrise/new.sh ||
fail "modules/sunrise/new.sh differs from HEAD; commit it first (what is tested must be committed)"
ref="$(git -C "$ROOT" rev-parse HEAD)"
mounts=(-v "$ROOT:/fw:ro")
fi
echo "== docker pull node:24-bookworm"
docker pull -q node:24-bookworm >/dev/null
# Globals: the EXIT trap runs after this function returns.
acc_project="phase143acc-$$-$(date +%s)"
acc_runner="$acc_project-runner"
acc_cleanup() {
local rc=$?
docker rm -f "$acc_runner" >/dev/null 2>&1 || true
local ids
ids="$(docker ps -aq --filter "label=com.docker.compose.project=$acc_project")"
[ -z "$ids" ] || docker rm -f $ids >/dev/null 2>&1 || true
ids="$(docker volume ls -q --filter "label=com.docker.compose.project=$acc_project")"
[ -z "$ids" ] || docker volume rm -f $ids >/dev/null 2>&1 || true
ids="$(docker network ls -q --filter "label=com.docker.compose.project=$acc_project")"
[ -z "$ids" ] || docker network rm $ids >/dev/null 2>&1 || true
exit "$rc"
}
trap acc_cleanup EXIT
echo "== node:24-bookworm ($mode, compose project $acc_project)"
docker run --rm --name "$acc_runner" --network host \
-v /var/run/docker.sock:/var/run/docker.sock "${mounts[@]}" \
-e COMPOSE_PROJECT_NAME="$acc_project" -e ACC_MODE="$mode" -e ACC_REF="$ref" \
node:24-bookworm bash -euo pipefail -c "$acceptance_inner"
if [ "$mode" = production ]; then
echo "Phase 14.3 production acceptance passed"
else
echo "Phase 14.3 acceptance passed"
fi
}
case "${1:-}" in
--generator) stage_generator ;;
--acceptance) stage_acceptance local ;;
--acceptance-production) stage_acceptance production ;;
*)
echo "usage: $0 --generator | --acceptance | --acceptance-production" >&2
exit 2
;;
esac