#!/bin/sh # SPDX-FileCopyrightText: 2026 GPForum Contributors # SPDX-License-Identifier: BSD-3-Clause set -eu # Install locked CPAN distributions in parallel through cpm under selected Perl. # Carton maintains and checks cpanfile.snapshot; cpm may resolve only from it. # This script does not install an interpreter; does not sideload application # dists with cpanm; and does not delete local/. root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) cd "$root" perl_bin=$(script/gpforum-system-perl --require) GPFORUM_PERL=$perl_bin PATH="$(dirname "$perl_bin"):$PATH" export PATH GPFORUM_PERL printf 'bootstrap-deps: selected perl -> %s\n' "$perl_bin" with_postgres=0 production=0 update=0 cached=0 rebuild_local=0 for_setup=0 dry_run=0 usage() { cat <<'EOF' Usage: script/bootstrap-deps [--postgres] [--production] [--update] [--cached] [--rebuild-local] [--for-setup] [--dry-run] --postgres include the cpanfile postgres feature (DBD::Pg, Mojo::Pg, Test::PostgreSQL). Requires pg_config / libpq. --production leave out the develop phase (Perl::Critic, Perl::Tidy, Devel::Cover, Devel::NYTProf and what they pull in): what a production host runs, per ADR 0109. --update refresh cpanfile.snapshot (development lock update). Do not use this on production hosts. --cached install from vendor/cache after `carton bundle`. --rebuild-local rename an existing local/ to local.rebuild. then reinstall (does not delete). Use when local/ is incomplete or built for another Perl. --for-setup what gpforum setup runs on a checkout without its dependencies: say so first, and end with a line of setup's kind instead of the next step, setup going on --dry-run with --for-setup, say what it would install, install nothing, and exit 1 Default production/CI path: cpm install --resolver snapshot --no-default-resolvers Parallel workers: GPFORUM_CPM_WORKERS=5 (lower it on memory-constrained hosts) HTTPS mirror: PERL_CARTON_MIRROR=https://cpan.metacpan.org/ EOF } for argument in "$@"; do case "$argument" in --postgres) with_postgres=1 ;; --production) production=1 ;; --update|--dev) update=1 ;; --cached) cached=1 ;; --rebuild-local) rebuild_local=1 ;; --for-setup) for_setup=1 ;; --dry-run) dry_run=1 ;; --help|-h) usage exit 0 ;; *) echo "bootstrap-deps: unknown option: $argument" >&2 usage >&2 exit 1 ;; esac done # A lock refresh without the develop phase would drop the maintainers' tools # from cpanfile.snapshot for everyone. if [ "$production" -eq 1 ] && [ "$update" -eq 1 ]; then echo "bootstrap-deps: --production cannot be combined with --update: the lock must keep the develop tools." >&2 exit 2 fi if [ ! -f cpanfile ]; then echo "bootstrap-deps: cpanfile is missing" >&2 exit 1 fi if [ ! -f cpanfile.snapshot ]; then if [ "$update" -eq 0 ]; then echo "bootstrap-deps: cpanfile.snapshot is required for a locked install." >&2 echo "Refresh the lock on a development host with: script/bootstrap-deps --update" >&2 exit 1 fi echo "bootstrap-deps: no snapshot yet; carton install will create cpanfile.snapshot" >&2 fi if [ "$with_postgres" -eq 1 ] && [ ! -f cpanfile.postgres ]; then echo "bootstrap-deps: cpanfile.postgres is missing" >&2 exit 1 fi # shellcheck disable=SC2016 # Config belongs to Perl. current_perl=$("$perl_bin" -MConfig -e 'print $Config{version}') if [ "$rebuild_local" -eq 1 ] && [ -e local ]; then stamp=$(date +%s) backup="local.rebuild.${stamp}" echo "bootstrap-deps: --rebuild-local moving local/ -> ${backup}" mv local "$backup" fi PERL5LIB= PERL5OPT= "$perl_bin" -I"$root/lib" -MGPForum::Infrastructure::PerlRuntime \ -e 'GPForum::Infrastructure::PerlRuntime->check(shift)' "$root" find_pg_config() { if [ -n "${GPFORUM_PG_CONFIG:-}" ] && [ -x "${GPFORUM_PG_CONFIG}" ]; then printf '%s\n' "${GPFORUM_PG_CONFIG}" return 0 fi if command -v pg_config >/dev/null 2>&1; then command -v pg_config return 0 fi for candidate in \ /opt/homebrew/opt/postgresql@18/bin/pg_config \ /opt/homebrew/opt/postgresql@17/bin/pg_config \ /opt/homebrew/opt/postgresql@16/bin/pg_config \ /usr/local/opt/postgresql@18/bin/pg_config \ /opt/homebrew/opt/libpq/bin/pg_config \ /usr/local/opt/libpq/bin/pg_config \ /Applications/Postgres.app/Contents/Versions/latest/bin/pg_config do if [ -x "$candidate" ]; then printf '%s\n' "$candidate" return 0 fi done return 1 } if [ "$with_postgres" -eq 1 ]; then pg_config_bin=$(find_pg_config) || { echo "bootstrap-deps: pg_config is required for DBD::Pg." >&2 echo "Run script/system-preflight for OS package guidance." >&2 exit 1 } PATH="$(dirname "$pg_config_bin"):$PATH" export PATH PG_CONFIG="$pg_config_bin" export PG_CONFIG printf 'bootstrap-deps: using pg_config -> %s\n' "$pg_config_bin" fi # Net::SSLeay, under IO::Socket::SSL, builds against OpenSSL's headers. # Debian and Ubuntu install them under /usr with libssl-dev (libpq-dev does # not bring it), FreeBSD has them in base; macOS has them only in Homebrew's # keg-only openssl@3, which Net::SSLeay finds when OPENSSL_PREFIX names it. if [ -z "${OPENSSL_PREFIX:-}" ] && [ ! -f /usr/include/openssl/ssl.h ]; then for candidate in /opt/homebrew/opt/openssl@3 /usr/local/opt/openssl@3; do if [ -f "$candidate/include/openssl/ssl.h" ]; then OPENSSL_PREFIX=$candidate export OPENSSL_PREFIX printf 'bootstrap-deps: using OpenSSL -> %s\n' "$candidate" break fi done fi # EV's header probe checks Perl's configured include directories, which may # omit the active macOS SDK. Ask its supported build switches for the native # backend required by GPForum::OS::Reactor instead of silently compiling only # select. The compiler still checks the platform headers normally. case $(uname -s) in Darwin) EV_KQUEUE=1; export EV_KQUEUE ;; # FreeBSD's optional libinotify headers do not provide Linux statfs. # EV's supported switch prevents that false probe; kqueue stays mandatory. FreeBSD) EV_KQUEUE=1; EV_INOTIFY=0; export EV_KQUEUE EV_INOTIFY ;; Linux) EV_EPOLL=1; export EV_EPOLL ;; esac if [ -z "${PERL_CARTON_MIRROR:-}" ]; then PERL_CARTON_MIRROR=https://cpan.metacpan.org/ fi export PERL_CARTON_MIRROR case "$PERL_CARTON_MIRROR" in https://*|file://*|/*) ;; *) echo "bootstrap-deps: PERL_CARTON_MIRROR must be HTTPS or a local path (got $PERL_CARTON_MIRROR)" >&2 exit 1 ;; esac if [ "$update" -eq 1 ]; then installer=carton set -- install if [ "$cached" -eq 1 ]; then set -- "$@" --cached fi if [ "$with_postgres" -eq 0 ]; then set -- "$@" --without postgres fi else installer=cpm workers=${GPFORUM_CPM_WORKERS:-5} case "$workers" in ''|*[!0-9]*|0*) echo "bootstrap-deps: GPFORUM_CPM_WORKERS must be a positive integer" >&2 exit 2 ;; esac mirror=$PERL_CARTON_MIRROR if [ "$cached" -eq 1 ]; then mirror="$root/vendor/cache" if [ ! -d "$mirror/authors/id" ]; then echo "bootstrap-deps: --cached requires vendor/cache from carton bundle" >&2 exit 1 fi fi phases=configure,build,test,runtime,develop if [ "$production" -eq 1 ]; then phases=configure,build,test,runtime fi # Reading a snapshot alone still allows cpm's default CPAN resolvers. # Disable them so missing or incompatible pins fail closed. Keep the # existing Carton policy: skip upstream tests, retain our test phase. set -- install --cpanfile cpanfile --snapshot cpanfile.snapshot \ --resolver snapshot --no-default-resolvers --mirror "$mirror" \ --local-lib-contained local --home local --workers "$workers" --no-test \ --final-install all \ --top-level-phase "$phases" if [ "$with_postgres" -eq 1 ]; then set -- "$@" --feature postgres fi fi snapshot_count() { if [ ! -f cpanfile.snapshot ]; then echo 0 return fi grep -c '^ [A-Za-z0-9_.-]*$' cpanfile.snapshot || echo 0 } # gpforum setup on a checkout without its dependencies runs this first # (GPForum::CLI::FrontDoor::Carton): it says what it installs, as make # install-deps-production would, in the operator's language. if [ "$for_setup" -eq 1 ] && [ -r "$root/script/lib/words.sh" ]; then # shellcheck source=script/lib/words.sh . "$root/script/lib/words.sh" if [ "$production" -eq 1 ]; then make_target=install-deps-production else make_target=install-deps-postgres fi words_say system.deps_for_setup 'gpforum setup installs the dependencies into {local} first, as make {target} does: {installer} {command}' \ "local=$root/local" "target=$make_target" "installer=$installer" "command=$*" if [ "$dry_run" -eq 1 ]; then words_say system.deps_dry_run 'Nothing was installed: --dry-run says what setup would do.' exit 1 fi fi before_count=$(snapshot_count) if [ "$update" -eq 1 ] && [ -f cpanfile.snapshot ]; then cp cpanfile.snapshot "${TMPDIR:-/tmp}/gpforum-cpanfile.snapshot.bak.$$" fi echo "bootstrap-deps: PERL_CARTON_MIRROR=$PERL_CARTON_MIRROR" echo "bootstrap-deps: running $installer $*" "script/gpforum-$installer" "$@" after_count=$(snapshot_count) if [ "$update" -eq 1 ] && [ "$before_count" -gt 20 ] && [ "$after_count" -lt "$before_count" ]; then echo "bootstrap-deps: carton install shrank cpanfile.snapshot from $before_count to $after_count distributions." >&2 echo "Restoring the previous lock. Review local/ Perl compatibility and retry." >&2 cp "${TMPDIR:-/tmp}/gpforum-cpanfile.snapshot.bak.$$" cpanfile.snapshot rm -f "${TMPDIR:-/tmp}/gpforum-cpanfile.snapshot.bak.$$" exit 1 fi rm -f "${TMPDIR:-/tmp}/gpforum-cpanfile.snapshot.bak.$$" echo "bootstrap-deps: verifying lock with carton check" script/gpforum-carton check # Record only after installation and lock verification succeeded. The # manifest follows the target architecture and contains no prefix binding. mkdir -p "$root/local" PERL5LIB= PERL5OPT= "$perl_bin" -I"$root/lib" -MGPForum::Infrastructure::PerlRuntime \ -e 'GPForum::Infrastructure::PerlRuntime->record_installation(shift)' "$root" # The interpreter shim script/gpforum-carton puts first on PATH lives in # local/, and is made the first time a command runs. Made here, by whoever # installs: the deployment guide keeps the code tree root's, and the service # user, who cannot write local/, printed "mkdir: ... Permission denied" on # every start and every command. script/gpforum-carton exec perl -e 1 # Whether this host is set up already: the environment file gpforum reads is # in place, so this install is a step of an upgrade, and gpforum upgrade # printed the ones after it. # shellcheck disable=SC2016 # Perl, not the shell, reads the variables set_up=$(script/gpforum-carton exec perl -I"$root/lib" -MGPForum::OS -e \ 'my $file = $ENV{GPFORUM_ENV_FILE} // GPForum::OS->detect->environment_file; print -e $file ? 1 : 0' \ 2>/dev/null) || set_up=0 [ -n "$set_up" ] || set_up=0 # What to type next, in the operator's language: a host not set up yet is # set up, a production one as root, by the path that works before the link, # and a checkout for development, as the README goes on. An upgrade has its # next step printed already, and a lock refresh is a maintainer's: nothing # after either. It named script/system-preflight, a step the quick start # no longer has, even in an upgrade. if [ "$update" -eq 0 ] && [ -r "$root/script/lib/words.sh" ]; then # shellcheck source=script/lib/words.sh . "$root/script/lib/words.sh" if [ "${for_setup:-0}" -eq 1 ]; then echo words_mark ok "$(words_say system.deps_installed 'dependencies: installed into {local}, for Perl {version}' \ "local=$root/local" "version=$current_perl")" elif [ "$set_up" -eq 1 ]; then : elif [ "$production" -eq 1 ]; then echo words_say system.next_setup 'Next: set this host up, with sudo {gpforum} setup' \ "gpforum=$root/bin/gpforum" else # As the README's quick start types it: through sudo, which writes # /etc/gpforum and links /usr/local/bin, but on macOS, whose are # Homebrew's and the operator's own. checkout_setup='bin/gpforum setup --environment development' [ "$(uname -s)" = Darwin ] || checkout_setup="sudo $checkout_setup" echo words_say system.next_setup_checkout 'Next: set this checkout up, with {command}' \ "command=$checkout_setup" fi fi