Files
alrahma_sunday_school/build_production.sh
T
root f8f00c70c8
Tests / PHPUnit (push) Failing after 35s
update php
2026-07-29 19:03:57 -04:00

710 lines
19 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# build_production.sh
#
# Build a deployable CodeIgniter 4 production directory from a complete
# project checkout. The script deliberately refuses incomplete projects,
# development Composer dependencies, and non-production environment files.
#
# Usage:
# ./build_production.sh
#
# Optional:
# --skip-composer Use the existing vendor directory. The script rejects
# known development packages such as PHPUnit.
# --skip-lint Skip PHP syntax checks.
# --no-archive Do not create alrahma.zip.
# --no-force Refuse to replace an existing output directory.
# --help Show help.
#
# The web-server document root must be <production-folder>/public.
#
set -Eeuo pipefail
IFS=$'\n\t'
umask 027
SCRIPT_NAME="$(basename "$0")"
SOURCE_DIR="$(pwd)"
OUTPUT_DIR="$(pwd)/build"
ENV_FILE="$(pwd)/env.production"
SKIP_COMPOSER=0
SKIP_LINT=0
MAKE_ARCHIVE=1
FORCE=1
STAGE_ROOT=""
log() {
printf '[%s] %s\n' "$SCRIPT_NAME" "$*"
}
warn() {
printf '[%s] WARNING: %s\n' "$SCRIPT_NAME" "$*" >&2
}
die() {
printf '[%s] ERROR: %s\n' "$SCRIPT_NAME" "$*" >&2
exit 1
}
usage() {
cat <<'USAGE'
Build a CodeIgniter 4 production directory.
Default:
./build_production.sh builds the current project into ./build, copies
./env.production as .env, replaces any existing ./build directory, and
creates ./alrahma.zip.
Options:
--source DIR Complete project root. Default: current directory.
--output DIR Destination. Default: ./build.
--env FILE Production environment file. Default: ./env.production.
--skip-composer Keep the existing vendor directory instead of running
Composer. Known development dependencies are rejected.
--skip-lint Skip PHP syntax checks.
--archive Create ./alrahma.zip after building. Enabled by
default. The archive contains .env and must be
protected.
--no-archive Do not create ./alrahma.zip.
--force Replace an existing output directory. Enabled by
default.
--no-force Refuse to replace an existing output directory.
-h, --help Show this help.
Example:
./build_production.sh
USAGE
}
cleanup() {
if [[ -n "${STAGE_ROOT:-}" && -d "$STAGE_ROOT" ]]; then
rm -rf -- "$STAGE_ROOT"
fi
}
trap cleanup EXIT
trap 'die "Build failed near line $LINENO."' ERR
while [[ $# -gt 0 ]]; do
case "$1" in
--source)
[[ $# -ge 2 ]] || die "--source requires a directory."
SOURCE_DIR="$2"
shift 2
;;
--output)
[[ $# -ge 2 ]] || die "--output requires a directory."
OUTPUT_DIR="$2"
shift 2
;;
--env)
[[ $# -ge 2 ]] || die "--env requires a file."
ENV_FILE="$2"
shift 2
;;
--skip-composer)
SKIP_COMPOSER=1
shift
;;
--skip-lint)
SKIP_LINT=1
shift
;;
--archive)
MAKE_ARCHIVE=1
shift
;;
--no-archive)
MAKE_ARCHIVE=0
shift
;;
--force)
FORCE=1
shift
;;
--no-force)
FORCE=0
shift
;;
-h|--help)
usage
exit 0
;;
*)
die "Unknown option: $1"
;;
esac
done
for command_name in php rsync find awk sed grep sort xargs sha256sum zip; do
command -v "$command_name" >/dev/null 2>&1 \
|| die "Required command is missing: $command_name"
done
[[ -d "$SOURCE_DIR" ]] || die "Source directory does not exist: $SOURCE_DIR"
SOURCE_DIR="$(cd "$SOURCE_DIR" && pwd -P)"
OUTPUT_PARENT="$(dirname "$OUTPUT_DIR")"
mkdir -p -- "$OUTPUT_PARENT"
OUTPUT_PARENT="$(cd "$OUTPUT_PARENT" && pwd -P)"
OUTPUT_DIR="$OUTPUT_PARENT/$(basename "$OUTPUT_DIR")"
[[ "$OUTPUT_DIR" != "/" ]] || die "Refusing to use / as the output directory."
[[ "$OUTPUT_DIR" != "$SOURCE_DIR" ]] \
|| die "Output directory must not be the project root."
[[ -n "$ENV_FILE" ]] \
|| die "Pass a production environment file with --env."
[[ -f "$ENV_FILE" ]] \
|| die "Environment file does not exist: $ENV_FILE"
ENV_FILE="$(cd "$(dirname "$ENV_FILE")" && pwd -P)/$(basename "$ENV_FILE")"
required_paths=(
"app"
"app/Config/Paths.php"
"public"
"public/index.php"
"writable"
"spark"
"composer.json"
"composer.lock"
)
missing_paths=()
for required_path in "${required_paths[@]}"; do
if [[ ! -e "$SOURCE_DIR/$required_path" ]]; then
missing_paths+=("$required_path")
fi
done
if (( ${#missing_paths[@]} > 0 )); then
printf '[%s] Missing required project items:\n' "$SCRIPT_NAME" >&2
printf ' - %s\n' "${missing_paths[@]}" >&2
die "The source is not a complete deployable CodeIgniter project."
fi
[[ -f "$SOURCE_DIR/vendor/autoload.php" || "$SKIP_COMPOSER" -eq 0 ]] \
|| die "--skip-composer requires an existing vendor/autoload.php."
if [[ -e "$OUTPUT_DIR" ]]; then
(( FORCE == 1 )) \
|| die "Output already exists. Use --force to replace it: $OUTPUT_DIR"
log "Removing existing output: $OUTPUT_DIR"
rm -rf -- "$OUTPUT_DIR"
fi
env_value() {
local key="$1"
local file="$2"
awk -F '=' -v wanted="$key" '
/^[[:space:]]*[#;]/ { next }
NF < 2 { next }
{
key = $1
gsub(/^[[:space:]]+|[[:space:]]+$/, "", key)
if (key != wanted) {
next
}
value = substr($0, index($0, "=") + 1)
sub(/[[:space:]]+[;#].*$/, "", value)
gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)
gsub(/^["'\'']|["'\'']$/, "", value)
print value
exit
}
' "$file"
}
CI_ENVIRONMENT_VALUE="$(env_value "CI_ENVIRONMENT" "$ENV_FILE" || true)"
CI_ENVIRONMENT_VALUE="$(printf '%s' "$CI_ENVIRONMENT_VALUE" | tr '[:upper:]' '[:lower:]')"
[[ "$CI_ENVIRONMENT_VALUE" == "production" ]] \
|| die "The supplied environment file must set CI_ENVIRONMENT = production."
for recommended_key in \
"app.baseURL" \
"database.default.hostname" \
"database.default.database" \
"database.default.username"
do
if [[ -z "$(env_value "$recommended_key" "$ENV_FILE" || true)" ]]; then
warn "Production environment does not define '$recommended_key'. Verify that it is injected another way."
fi
done
STAGE_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/ci4-production-build.XXXXXXXX")"
STAGE_DIR="$STAGE_ROOT/release"
mkdir -p -- "$STAGE_DIR"
log "Copying application files into a clean staging directory."
copy_production_path() {
local relative_path="$1"
local source_path="$SOURCE_DIR/$relative_path"
local destination_path="$STAGE_DIR/$relative_path"
[[ -e "$source_path" ]] || return 0
mkdir -p -- "$(dirname "$destination_path")"
if [[ -d "$source_path" ]]; then
mkdir -p -- "$destination_path"
rsync -a \
--exclude='.DS_Store' \
--exclude='__MACOSX/' \
"$source_path/" "$destination_path/"
else
install -m 640 "$source_path" "$destination_path"
fi
}
copy_public_files() {
mkdir -p -- "$STAGE_DIR/public"
rsync -a \
--exclude='.DS_Store' \
--exclude='__MACOSX/' \
--exclude='/docs/***' \
--exclude='/gitlab/***' \
--exclude='/jenkins/***' \
--exclude='/uploads/***' \
"$SOURCE_DIR/public/" "$STAGE_DIR/public/"
}
production_paths=(
"app"
"bootstrap"
"routes"
"composer.json"
"composer.lock"
"spark"
"preload.php"
"LICENSE"
)
for production_path in "${production_paths[@]}"; do
copy_production_path "$production_path"
done
copy_public_files
mkdir -p -- "$STAGE_DIR/writable"
copy_production_path "writable/.htaccess"
copy_production_path "writable/index.html"
copy_production_path "writable/.gitkeep"
if (( SKIP_COMPOSER == 1 )); then
copy_production_path "vendor"
fi
install -m 600 "$ENV_FILE" "$STAGE_DIR/.env"
# Remove platform debris even when it was nested in an unexpected directory.
find "$STAGE_DIR" -name '.DS_Store' -type f -delete
find "$STAGE_DIR" -name '__MACOSX' -type d -prune -exec rm -rf {} +
runtime_directories=(
"writable/cache"
"writable/debugbar"
"writable/logs"
"writable/session"
"writable/tmp"
"writable/uploads"
"writable/uploads/class_material"
"writable/uploads/early_dismissal_signatures"
"writable/uploads/exams"
"writable/uploads/print_requests"
"writable/uploads/receipts"
"writable/uploads/reimbursements"
"writable/reports"
"writable/report_assets"
"public/uploads"
"public/uploads/event_flyers"
"public/uploads/print_requests"
)
for runtime_directory in "${runtime_directories[@]}"; do
mkdir -p -- "$STAGE_DIR/$runtime_directory"
: > "$STAGE_DIR/$runtime_directory/.gitkeep"
done
chmod 750 "$STAGE_DIR/spark"
chmod 600 "$STAGE_DIR/.env"
if (( SKIP_COMPOSER == 0 )); then
command -v composer >/dev/null 2>&1 \
|| die "Composer is required. Install Composer or use --skip-composer with an already production-only vendor directory."
log "Installing production Composer dependencies."
(
cd "$STAGE_DIR"
COMPOSER_ALLOW_SUPERUSER=1 composer install \
--no-dev \
--prefer-dist \
--optimize-autoloader \
--classmap-authoritative \
--no-interaction \
--no-progress
COMPOSER_ALLOW_SUPERUSER=1 composer check-platform-reqs --no-dev
)
else
log "Using the existing vendor directory."
[[ -f "$STAGE_DIR/vendor/autoload.php" ]] \
|| die "Existing vendor/autoload.php is missing."
forbidden_vendor_paths=(
"vendor/phpunit"
"vendor/fakerphp"
"vendor/mockery"
"vendor/myclabs/deep-copy"
"vendor/phar-io"
"vendor/sebastian"
"vendor/theseer"
)
found_dev_dependencies=()
for vendor_path in "${forbidden_vendor_paths[@]}"; do
if [[ -e "$STAGE_DIR/$vendor_path" ]]; then
found_dev_dependencies+=("$vendor_path")
fi
done
if (( ${#found_dev_dependencies[@]} > 0 )); then
printf '[%s] Development packages remain in vendor:\n' "$SCRIPT_NAME" >&2
printf ' - %s\n' "${found_dev_dependencies[@]}" >&2
die "Run Composer with --no-dev instead of using --skip-composer."
fi
fi
[[ -f "$STAGE_DIR/vendor/autoload.php" ]] \
|| die "Production vendor/autoload.php was not created."
php -r '
require $argv[1];
fwrite(STDOUT, "Composer autoload check passed.\n");
' "$STAGE_DIR/vendor/autoload.php"
if (( SKIP_LINT == 0 )); then
log "Linting application PHP files."
lint_failure_file="$STAGE_ROOT/php-lint-failures.txt"
: > "$lint_failure_file"
while IFS= read -r -d '' php_file; do
if ! php -l "$php_file" >/dev/null 2>&1; then
printf '%s\n' "$php_file" >> "$lint_failure_file"
fi
done < <(
find \
"$STAGE_DIR/app" \
"$STAGE_DIR/public" \
-type f -name '*.php' -print0
)
if [[ -s "$lint_failure_file" ]]; then
cat "$lint_failure_file" >&2
die "PHP lint failed."
fi
fi
# Apply conservative baseline permissions. The deployment user or PHP-FPM
# group can be supplied later using chown/chgrp on the server.
find "$STAGE_DIR" -type d -exec chmod 750 {} +
find "$STAGE_DIR" -type f -exec chmod 640 {} +
chmod 750 "$STAGE_DIR/spark"
chmod 600 "$STAGE_DIR/.env"
for runtime_directory in "${runtime_directories[@]}"; do
chmod 770 "$STAGE_DIR/$runtime_directory"
done
mkdir -p "$STAGE_DIR/.deployment"
cat > "$STAGE_DIR/.deployment/release-check.sh" <<'RELEASE_CHECK'
#!/usr/bin/env bash
set -Eeuo pipefail
IFS=$'\n\t'
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
errors=0
fail() {
printf 'FAIL: %s\n' "$*" >&2
errors=$((errors + 1))
}
pass() {
printf 'PASS: %s\n' "$*"
}
[[ -f "$ROOT/.env" ]] \
&& pass ".env exists" \
|| fail ".env is missing"
if grep -Eq '^[[:space:]]*CI_ENVIRONMENT[[:space:]]*=[[:space:]]*["'\'']?production["'\'']?[[:space:]]*$' "$ROOT/.env"; then
pass "CI_ENVIRONMENT is production"
else
fail "CI_ENVIRONMENT is not production"
fi
for required in \
"app/Config/Paths.php" \
"public/index.php" \
"vendor/autoload.php" \
"spark"
do
[[ -e "$ROOT/$required" ]] \
&& pass "$required exists" \
|| fail "$required is missing"
done
for writable_path in \
"writable/cache" \
"writable/logs" \
"writable/session" \
"writable/tmp" \
"writable/uploads"
do
if [[ -d "$ROOT/$writable_path" && -w "$ROOT/$writable_path" ]]; then
pass "$writable_path is writable"
else
fail "$writable_path is not writable"
fi
done
for forbidden in \
".git" \
"tests" \
"node_modules" \
"vendor/phpunit" \
"phpunit.xml"
do
[[ ! -e "$ROOT/$forbidden" ]] \
&& pass "$forbidden is absent" \
|| fail "$forbidden must not be deployed"
done
php -r 'require $argv[1];' "$ROOT/vendor/autoload.php" \
&& pass "Composer autoload works" \
|| fail "Composer autoload failed"
printf '\nRequired web document root:\n %s/public\n' "$ROOT"
printf '\nBefore traffic is enabled, run database migration status and the application financial smoke tests.\n'
if (( errors > 0 )); then
printf '\nRelease check failed with %d error(s).\n' "$errors" >&2
exit 1
fi
printf '\nRelease check passed.\n'
RELEASE_CHECK
chmod 750 "$STAGE_DIR/.deployment/release-check.sh"
cat > "$STAGE_DIR/.deployment/nginx.conf.example" <<'NGINX'
server {
listen 80;
server_name __DOMAIN__;
# Point the web root only at public. Exposing the project root is unsafe.
root __APP_ROOT__/public;
index index.php;
client_max_body_size 25m;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
try_files $uri =404;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param HTTP_PROXY "";
fastcgi_pass __PHP_FPM_SOCKET__;
}
location ~ /\.(?!well-known).* {
deny all;
}
location ~* \.(?:css|js|jpg|jpeg|gif|png|svg|ico|webp|woff|woff2|ttf)$ {
expires 7d;
access_log off;
try_files $uri =404;
}
}
NGINX
cat > "$STAGE_DIR/.deployment/apache-vhost.conf.example" <<'APACHE'
<VirtualHost *:80>
ServerName __DOMAIN__
DocumentRoot "__APP_ROOT__/public"
<Directory "__APP_ROOT__/public">
AllowOverride All
Options FollowSymLinks
Require all granted
</Directory>
ErrorLog ${APACHE_LOG_DIR}/school-app-error.log
CustomLog ${APACHE_LOG_DIR}/school-app-access.log combined
</VirtualHost>
APACHE
cat > "$STAGE_DIR/.deployment/DEPLOYMENT.md" <<'DEPLOYMENT'
# Production Deployment Notes
## Required document root
Configure the web server document root as:
```text
<release-directory>/public
```
Never expose the project root, `app`, `vendor`, `writable`, or `.env`.
## Server preparation
1. Install the PHP version and extensions accepted by:
```bash
composer check-platform-reqs --no-dev
```
2. Set the release owner and PHP-FPM group:
```bash
sudo chown -R DEPLOY_USER:PHP_FPM_GROUP /path/to/release
```
3. Keep application code read-only for PHP-FPM. Grant write access only to runtime locations:
```bash
sudo find /path/to/release -type d -exec chmod 750 {} \;
sudo find /path/to/release -type f -exec chmod 640 {} \;
sudo chmod 600 /path/to/release/.env
sudo chmod 750 /path/to/release/spark
sudo chmod -R 770 /path/to/release/writable
sudo chmod -R 770 /path/to/release/public/uploads
```
4. Run the packaged release check:
```bash
/path/to/release/.deployment/release-check.sh
```
## Database deployment
Back up the production database first.
Review migration status before executing migrations:
```bash
cd /path/to/release
php spark migrate:status
php spark migrate --all
```
Financial hardening migrations must fail if required constraints cannot be installed. Do not accept warning-only deployment of uniqueness or check constraints.
## Persistent data
Do not overwrite persistent production data during release replacement:
- `.env`
- `writable/uploads`
- `writable/reports`
- `writable/report_assets`
- `public/uploads`
Mount or copy these from controlled persistent storage.
## Traffic activation
Before enabling traffic:
1. Run migrations.
2. Run release checks.
3. Run financial smoke tests for invoice, payment, refund, discount, expense, reimbursement, and purchase-order workflows.
4. Confirm scheduled jobs and workers point at the new release.
5. Confirm writable ownership.
6. Confirm the web server points to `public`.
7. Confirm `CI_ENVIRONMENT = production`.
8. Confirm debug output is disabled.
9. Confirm database backups and rollback instructions exist.
Use an atomic `current` symlink switch where possible rather than copying files over a live release.
DEPLOYMENT
SOURCE_COMMIT="not-a-git-checkout"
if command -v git >/dev/null 2>&1 && git -C "$SOURCE_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
SOURCE_COMMIT="$(git -C "$SOURCE_DIR" rev-parse HEAD)"
fi
cat > "$STAGE_DIR/.deployment/BUILD_INFO" <<BUILD_INFO
built_at_utc=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
source_directory=$SOURCE_DIR
source_commit=$SOURCE_COMMIT
php_version=$(php -r 'echo PHP_VERSION;')
composer_mode=$([[ "$SKIP_COMPOSER" -eq 1 ]] && printf 'existing-vendor' || printf 'composer-no-dev')
BUILD_INFO
(
cd "$STAGE_DIR"
find . -type f \
! -path './.env' \
! -path './.deployment/MANIFEST.sha256' \
-print0 \
| sort -z \
| xargs -0 sha256sum \
> .deployment/MANIFEST.sha256
)
log "Running packaged release verification."
"$STAGE_DIR/.deployment/release-check.sh"
log "Publishing production directory: $OUTPUT_DIR"
mkdir -p -- "$(dirname "$OUTPUT_DIR")"
mv -- "$STAGE_DIR" "$OUTPUT_DIR"
if (( MAKE_ARCHIVE == 1 )); then
ARCHIVE_PATH="$(dirname "$OUTPUT_DIR")/alrahma.zip"
rm -f -- "$ARCHIVE_PATH"
warn "The archive contains the production .env. Store and transfer it as a secret."
(
cd "$(dirname "$OUTPUT_DIR")"
zip -qr "$ARCHIVE_PATH" "$(basename "$OUTPUT_DIR")"
)
chmod 600 "$ARCHIVE_PATH"
log "Created protected archive: $ARCHIVE_PATH"
fi
cat <<SUMMARY
Production build completed.
Directory:
$OUTPUT_DIR
Web document root:
$OUTPUT_DIR/public
Verification:
$OUTPUT_DIR/.deployment/release-check.sh
Deployment notes:
$OUTPUT_DIR/.deployment/DEPLOYMENT.md
The build excludes runtime logs, sessions, caches, existing uploads, tests,
development metadata, and local environment files. The supplied production
environment file was copied as .env with mode 600.
SUMMARY