#!/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 /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' ServerName __DOMAIN__ DocumentRoot "__APP_ROOT__/public" AllowOverride All Options FollowSymLinks Require all granted ErrorLog ${APACHE_LOG_DIR}/school-app-error.log CustomLog ${APACHE_LOG_DIR}/school-app-access.log combined APACHE cat > "$STAGE_DIR/.deployment/DEPLOYMENT.md" <<'DEPLOYMENT' # Production Deployment Notes ## Required document root Configure the web server document root as: ```text /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" < .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 <