#!/bin/sh # SPDX-FileCopyrightText: 2026 GPForum Contributors # SPDX-License-Identifier: BSD-3-Clause # # Prove that a fresh checkout reaches a green `make check` from the README # quick start alone. # # Nothing else checks the quick start. Every gate runs in a checkout that is # already set up, so a step that works only because local/ is populated, a # file is present but untracked, or the shell still carries a PERL5LIB passes # there and fails for the next person to clone the repository. This clones a # commit into a temporary directory, clears what a working shell carries over, # and runs the quick start's commands there in the README's order, then every # prerequisite of `make check`, timing each one. # # It writes nothing to this checkout or to its local/: the clone installs its # own local/, cpm builds inside that local/, and the directory is # removed afterwards unless --keep is given. Uncommitted changes are not part # of the run, because a fresh checkout does not have them either. set -eu # Nothing here is meant as a file pattern: a --skip of `*` must stay `*`, not # become the names of the files in this checkout. set -f root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) cd "$root" ref=HEAD keep=0 keep_going=0 plan_only=0 skip=' ' make_command=${MAKE:-make} usage() { cat <<'USAGE' Usage: script/fresh-checkout-check [--ref REV] [--skip STEP]... [--keep-going] [--keep] [--plan] Clones REV of this repository into a temporary directory and runs the README quick start there, then every prerequisite of `make check`, reporting each step's result and duration. When GPFORUM_DATABASE_DSN is set (with GPFORUM_DATABASE_USER and GPFORUM_DATABASE_PASSWORD as the README describes), the quick start's gpforum commands run too, as bin/gpforum in the clone, reading an environment file of the run's own: gpforum setup, on a throwaway plain role and a database it owns, made on that server first and dropped afterwards; gpforum admin create; and gpforum start --foreground, until the forum answers /health/ready. The PostgreSQL integration tier (`make integration`) then runs last, as the DSN's role. That role must be allowed to create roles and databases, and the server must let the new role sign in with a password, or trust it, where the DSN connects. Without a DSN all of this is left out, and `make check` needs no database. --ref REV the commit to check out (default HEAD); uncommitted changes in this checkout are never part of the run --skip STEP leave a step out (repeatable), e.g. --skip critic; the run then does not prove a green `make check`, and says so --keep-going run every step even after one fails, and report them all; the run still fails if any step did --keep keep the temporary directory: the clone, its local/, and one log per step under logs/ (the throwaway role and database are dropped either way) --plan print the steps for REV and exit without cloning anything --help show this help The steps install every dependency from MetaCPAN into the clone's own local/, so the run needs the network and takes a while. The host prerequisites the README lists first (selected Perl, cpm, Carton, the PostgreSQL client files) must already be installed. Exit status: 0 every step passed, 1 a step failed, 2 usage error. USAGE } while [ $# -gt 0 ]; do case "$1" in --keep) keep=1 ;; --keep-going) keep_going=1 ;; --plan) plan_only=1 ;; --ref|--skip) if [ $# -lt 2 ]; then echo "fresh-checkout-check: $1 needs a value" >&2 exit 2 fi if [ "$1" = --ref ]; then ref=$2; else skip="$skip$2 "; fi shift ;; --ref=*) ref=${1#--ref=} ;; --skip=*) skip="$skip${1#--skip=} " ;; --help|-h) usage exit 0 ;; *) echo "fresh-checkout-check: unknown option $1" >&2 echo "Usage: script/fresh-checkout-check [--ref REV] [--skip STEP]... [--keep-going] [--keep] [--plan]" >&2 exit 2 ;; esac shift done if ! command -v git >/dev/null 2>&1 \ || ! git -C "$root" rev-parse --git-dir >/dev/null 2>&1; then echo "fresh-checkout-check: this is not a git checkout, so there is no commit to clone" >&2 exit 2 fi if ! commit=$(git -C "$root" rev-parse --verify --quiet "$ref^{commit}"); then echo "fresh-checkout-check: $ref is not a commit in this repository" >&2 exit 2 fi short=$(git -C "$root" rev-parse --short "$commit") # The database settings are read once, here, and handed only to the steps that # use them; everything else runs the way `make check` does, without a database. db_dsn=${GPFORUM_DATABASE_DSN:-} db_user=${GPFORUM_DATABASE_USER:-} db_password=${GPFORUM_DATABASE_PASSWORD:-} # Read a finite amount of entropy. An unbounded tr | head pipeline can keep # reading forever when its parent ignores SIGPIPE (notably BSD tr). random_password() { random_value=$(LC_ALL=C od -An -v -N24 -tx1 /dev/urandom | tr -d '[:space:]') if [ "${#random_value}" -ne 48 ]; then echo 'fresh-checkout-check: cannot read 24 random bytes' >&2 return 1 fi printf '%s' "$random_value" } # The throwaway role and database the quick start's commands create and # migrate. The README's `createuser gpforum` makes a plain role, and the # application connects as it, so the run does too: migrating as the DSN's # role, often a superuser, passes where the README's role is refused. The role # gets a generated password, because createuser asks for one only on a # terminal and a server that does not trust 127.0.0.1 wants one. fresh_name="gpforum_fresh_$(date +%s)_$$" fresh_db=$fresh_name fresh_role=$fresh_name fresh_password=$(random_password) maintenance_db='' fresh_dsn='' # What the quick start's gpforum commands read and answer: an environment # file of the run's own, never the host's; the owner's password, which admin # create asks for twice at a terminal and reads once with --password-stdin; # and the address the forum is started on, a port of the run's own. fresh_env='' # Expanded when the quick-start command string is evaluated. # shellcheck disable=SC2034 fresh_admin_password=$(random_password) fresh_listen="http://127.0.0.1:$((20000 + $$ % 10000))" if [ -n "$db_dsn" ]; then case "$db_dsn" in [dD][bB][iI]:Pg:*|[dD][bB][iI]:Pg\(*) ;; *) echo "fresh-checkout-check: GPFORUM_DATABASE_DSN is not a dbi:Pg: DSN" >&2 exit 2 ;; esac # DBD::Pg takes libpq's keywords separated by semicolons, and accepts db= # and database= for dbname=; psql, createdb and dropdb want libpq's own. dsn_pairs=$(printf '%s\n' "$db_dsn" \ | sed -E 's/^[dD][bB][iI]:Pg(\([^)]*\))?://' | tr ';' '\n' \ | sed -E 's/^[[:space:]]+//; s/^(db|database)=/dbname=/; /^$/d') maintenance_db=$(printf '%s\n' "$dsn_pairs" | paste -sd ' ' -) server_pairs=$(printf '%s\n' "$dsn_pairs" | sed '/^dbname=/d' | paste -sd ';' -) # Expanded when the quick-start command string is evaluated. # shellcheck disable=SC2034 fresh_dsn="dbi:Pg:${server_pairs:+$server_pairs;}dbname=$fresh_db" fi # The quick start's commands for a host that already has the prerequisites, # in the README's order. Each line is "name command"; a command is shown as it # is written here, so the database's connection details and password are # expanded only when it runs. Each gpforum command is bin/gpforum in the # clone, typed with the README's own arguments and what answers its # questions; setup's link onto the PATH is left out, since the clone is # removed afterwards -- not run as root, and with a Homebrew prefix of the # run's own, setup finds nowhere to make it. setup finds the throwaway role # and database the two steps before it made -- as a superuser setup would # make them itself -- and the role's password in the file it is given. quick_start_names='install-deps-postgres' quick_start_steps() { printf '%s\n' \ "install-deps-postgres $make_command install-deps-postgres" if [ -n "$db_dsn" ]; then printf '%s\n' "createuser createuser_with_password \"\$fresh_role\"" if [ -n "$db_user" ]; then printf '%s\n' "createdb createdb --maintenance-db=\"\$maintenance_db\" --username=\"\$db_user\" --owner=\"\$fresh_role\" \"\$fresh_db\"" else printf '%s\n' "createdb createdb --maintenance-db=\"\$maintenance_db\" --owner=\"\$fresh_role\" \"\$fresh_db\"" fi printf '%s\n' \ "setup write_fresh_env && HOMEBREW_PREFIX=\"\$workdir/homebrew\" script/gpforum-carton exec bin/gpforum --env-file \"\$fresh_env\" setup --environment development --yes --database \"\$fresh_dsn\" --database-user \"\$fresh_role\" --mail log" \ "admin-create printf '%s\\n' \"\$fresh_admin_password\" | script/gpforum-carton exec bin/gpforum --env-file \"\$fresh_env\" admin create --email you@example.com --username you --password-stdin" \ "start start_and_probe script/gpforum-carton exec bin/gpforum --env-file \"\$fresh_env\" start --foreground --listen \"\$fresh_listen\"" fi } # `make check` is read from the commit's own Makefile rather than restated # here, so the run cannot drift from the gate. Its prerequisites run one at a # time, which is what `make check` does, but each gets its own timing. check_prerequisites() { sed -n 's/^check:\([^#]*\).*/\1/p' | tr -s '[:blank:]' '\n' | sed '/^$/d' } # Writes one "name command" line per step. plan_steps() { makefile_text=$1 quick_start_steps targets=$(printf '%s\n' "$makefile_text" | check_prerequisites) if [ -z "$targets" ]; then echo "fresh-checkout-check: the Makefile at $short has no check target" >&2 return 1 fi for target in $targets; do case " $quick_start_names " in *" $target "*) continue ;; esac printf '%s %s %s\n' "$target" "$make_command" "$target" done if [ -n "$db_dsn" ]; then printf 'integration %s integration\n' "$make_command" fi } if ! makefile_text=$(git -C "$root" show "$commit:Makefile" 2>/dev/null); then echo "fresh-checkout-check: there is no Makefile at $short" >&2 exit 1 fi steps=$(plan_steps "$makefile_text") || exit 1 # A misspelt --skip would otherwise run the step it meant to leave out, after # the minutes the dependency install takes. The name must be a step's exactly: # matched as a pattern, `te.t` passed here and then left nothing out. for name in $skip; do if ! printf '%s\n' "$steps" | sed 's/ .*//' | grep -qxF -e "$name"; then echo "fresh-checkout-check: --skip $name names no step; the steps are:" >&2 printf '%s\n' "$steps" | sed 's/ .*//; s/^/ /' >&2 exit 2 fi done if [ "$plan_only" -eq 1 ]; then printf 'fresh-checkout-check: plan for commit %s\n' "$short" printf ' %-22s %s\n' clone "git clone --no-hardlinks, then check out $short" while read -r name command; do case "$skip" in *" $name "*) command="(skipped) $command" ;; esac printf ' %-22s %s\n' "$name" "$command" done <"$fresh_env") } # The README's `gpforum start --foreground`, then "open http://127.0.0.1:3000": # the server started in the background, asked for /health/ready until it # answers 200 or a minute has passed, then stopped. start_and_probe() { "$@" & server=$! answered=1 tries=0 while [ "$tries" -lt 60 ]; do if ! kill -0 "$server" 2>/dev/null; then break fi if curl -fsS "$fresh_listen/health/ready"; then echo answered=0 break fi tries=$((tries + 1)) sleep 1 done kill "$server" 2>/dev/null || true wait "$server" 2>/dev/null || true return "$answered" } use_dsn_password() { if [ -n "$db_password" ]; then PGPASSWORD=$db_password export PGPASSWORD fi } drop_database() { [ "$database_made" -eq 1 ] || return 0 if ( use_dsn_password if [ -n "$db_user" ]; then set -- --username="$db_user" fi dropdb --maintenance-db="$maintenance_db" "$@" --if-exists "$fresh_db" \ && dsn_psql --command="DROP ROLE IF EXISTS \"$fresh_role\"" ) >>"$logs/dropdb.log" 2>&1; then database_made=0 else printf 'fresh-checkout-check: could not drop the throwaway database and role %s; drop them by hand\n' \ "$fresh_name" >&2 fi } cleanup() { drop_database if [ "$keep" -eq 1 ]; then printf 'fresh-checkout-check: kept %s\n' "$workdir" return fi chmod -R u+w "$workdir" 2>/dev/null || true rm -rf "$workdir" } trap cleanup EXIT trap 'exit 129' HUP trap 'exit 130' INT trap 'exit 143' TERM # A working shell carries state a fresh one does not. PERL5LIB or a # local::lib can hand the clone this checkout's modules, and PERL_CARTON_PATH # would make Carton maintain another local/ altogether, so the clone # would pass on dependencies it never installed. A leftover GPFORUM_* setting # would make it pass or fail on configuration the README never mentions; only # GPFORUM_CARTON and GPFORUM_CPM, which locate the host tools, are kept. The # database settings were read above, and PGPASSWORD goes only to the steps # that sign in as the DSN's role. # # Run as `make fresh-checkout`, the script inherits make's own flags, and every # inner make would honour them: under `make -i` each would ignore its errors, # and a failing test tier would pass; a VAR=value on that command line would # reach every step. HARNESS_PERL_SWITCHES can hand prove another -I as # PERL5LIB can, and PERL_CARTON_MIRROR would install from somewhere other than # the MetaCPAN the README's commands use. unset PERL5LIB PERL5OPT PERL_LOCAL_LIB_ROOT PERL_MB_OPT PERL_MM_OPT \ PERL_CARTON_PATH PERL_CARTON_CPANFILE PERL_CARTON_MIRROR PGPASSWORD \ MAKEFLAGS MFLAGS MAKELEVEL MAKEOVERRIDES GNUMAKEFLAGS \ HARNESS_PERL_SWITCHES for variable in $(env | sed -n 's/^\(GPFORUM_[A-Za-z0-9_]*\)=.*/\1/p'); do case "$variable" in GPFORUM_CARTON|GPFORUM_CPM) ;; *) unset "$variable" ;; esac done PERL_CPANM_HOME="$workdir/cpanm" export PERL_CPANM_HOME total=0 steps_run=0 skipped='' failed='' # One line a log can be searched for: the result, and which steps failed or # were left out. finish() { printf '\nfresh-checkout-check status=%s commit=%s steps=%s failed=%s skipped=%s total=%ss\n' \ "$1" "$short" "$steps_run" \ "$(printf '%s' "${failed:- none}" | sed 's/^ //; s/ /,/g')" \ "$(printf '%s' "${skipped:- none}" | sed 's/^ //; s/ /,/g')" "$total" } # The lines worth keeping from a step's log: the harness's and the gates' own # totals, the last of what setup and admin create did (the schema, the # owner), and what /health/ready answered. summarise() { indent=' ' case "$1" in setup|admin-create) grep '^✓' "$2" 2>/dev/null | tail -n 1 | sed "s/^/$indent/" || true ;; start) grep '"check"' "$2" 2>/dev/null | tail -n 1 | sed "s/^/$indent/" || true ;; *) grep -E '^(Files=|Result: |[a-z-]+ status=)' "$2" 2>/dev/null \ | sed "s/^/$indent/" || true ;; esac } run_step() { name=$1 command=$2 case "$skip" in *" $name "*) printf ' %-22s %-6s %6s %s\n' "$name" skip - "$command" skipped="$skipped $name" return 0 ;; esac log="$logs/$name.log" # These two leave something outside the directory, so the cleanup must # know to drop it even when the step itself fails. case "$name" in createuser|createdb) database_made=1 ;; esac started=$(date +%s) # `set -e` does not apply inside an `if` condition, so a failed cd would # carry on and run the step here, in the working checkout, where # install-deps-postgres writes its local/. if ( cd "$checkout" || exit 1 case "$name" in createuser|createdb) use_dsn_password ;; integration) GPFORUM_DATABASE_DSN=$db_dsn GPFORUM_DATABASE_USER=$db_user GPFORUM_DATABASE_PASSWORD=$db_password export GPFORUM_DATABASE_DSN GPFORUM_DATABASE_USER \ GPFORUM_DATABASE_PASSWORD ;; esac eval "$command" ) >"$log" 2>&1 &2 tail -n 40 "$log" >&2 printf -- '--- end of the %s log ---\n\n' "$name" >&2 if [ "$keep_going" -eq 0 ]; then [ "$keep" -eq 1 ] || printf 'rerun with --keep to keep every log\n' >&2 finish fail exit 1 fi fi } clone_step() { started=$(date +%s) if git clone --quiet --no-hardlinks --no-checkout "$root" "$checkout" \ >"$logs/clone.log" 2>&1 \ && git -C "$checkout" checkout --quiet --detach "$commit" \ >>"$logs/clone.log" 2>&1; then result=pass else result=fail fi elapsed=$(($(date +%s) - started)) total=$((total + elapsed)) steps_run=$((steps_run + 1)) printf ' %-22s %-6s %5ss %s\n' clone "$result" "$elapsed" \ "git clone --no-hardlinks, then check out $short" if [ "$result" = fail ]; then cat "$logs/clone.log" >&2 finish fail exit 1 fi } printf 'fresh-checkout-check: commit %s (%s)\n' "$short" \ "$(git -C "$root" log -1 --format=%s "$commit")" if [ "$keep" -eq 1 ]; then printf ' working directory %s\n' "$workdir" else printf ' working directory: a temporary one, removed afterwards (--keep keeps it)\n' fi uncommitted=$(git -C "$root" --no-optional-locks status --porcelain 2>/dev/null | wc -l | tr -d ' ') if [ "$uncommitted" -gt 0 ]; then printf ' %s uncommitted paths in this checkout are not part of the run\n' "$uncommitted" fi if [ -n "$db_dsn" ]; then printf ' GPFORUM_DATABASE_DSN is set: the quick start makes and migrates the throwaway role and\n' printf ' database %s, dropped afterwards, and the integration tier runs last\n' "$fresh_name" else printf ' GPFORUM_DATABASE_DSN is not set: the database commands and the integration tier are left out\n' fi printf '\n %-22s %-6s %6s %s\n' step result time command clone_step # The README's macOS line: Homebrew's PostgreSQL is keg-only, so its client # tools are not on PATH until this puts them there. A no-op elsewhere. eval "$(cd "$checkout" && script/gpforum-homebrew-env 2>/dev/null || true)" while read -r name command; do run_step "$name" "$command" done </dev/null \ || echo unknown) printf '\n host %s, perl %s' "$(uname -sm)" "$perl_version" if command -v pg_config >/dev/null 2>&1; then printf ', %s client' "$(pg_config --version)" fi printf '\n' if [ -n "$failed" ]; then finish fail exit 1 fi if [ -n "$skipped" ]; then printf ' steps were left out, so this run does not prove a green make check\n' fi finish pass