name: Deploy HickLab3D Dashboard on: push: branches: - main workflow_dispatch: permissions: contents: read env: PROJECT_NAME: TechOdyssey_Designs_Dashboard DEPLOY_ROOT: /mnt/www-live REQUIRED_PHP_VERSION: "8.4.0" PHP_CMD: /opt/hicklab3d-toolchain/bin/php COMPOSER_CMD: /opt/hicklab3d-toolchain/bin/composer jobs: deploy-production: name: Deploy to production runs-on: [swarm, linux, arm64] if: github.ref == 'refs/heads/main' steps: - name: Prepare runner toolchain run: | PHP_BIN="$(command -v php84)" test -x "$PHP_BIN" command -v git command -v rsync command -v curl command -v node command -v npm mkdir -p /opt/hicklab3d-toolchain/bin ln -sfn "$PHP_BIN" "$PHP_CMD" curl -fsSL https://getcomposer.org/installer -o /tmp/hicklab3d-composer-setup.php "$PHP_CMD" /tmp/hicklab3d-composer-setup.php --install-dir=/opt/hicklab3d-toolchain/bin --filename=composer.phar printf '%s\n' '#!/bin/sh' 'exec /opt/hicklab3d-toolchain/bin/php /opt/hicklab3d-toolchain/bin/composer.phar "$@"' > "$COMPOSER_CMD" chmod +x "$COMPOSER_CMD" - name: Verify runner toolchain run: | "$PHP_CMD" -v "$PHP_CMD" -r ' $required = getenv("REQUIRED_PHP_VERSION"); if (version_compare(PHP_VERSION, $required, "<")) { fwrite(STDERR, "PHP ".PHP_VERSION." is too old. Required: ".$required." or newer.\n"); exit(1); } $extensions = ["bcmath", "ctype", "curl", "dom", "fileinfo", "iconv", "mbstring", "openssl", "pdo", "pdo_mysql", "pdo_sqlite", "session", "sqlite3", "tokenizer", "xml", "xmlwriter", "zip"]; $missing = array_values(array_filter($extensions, fn ($extension) => ! extension_loaded($extension))); if ($missing !== []) { fwrite(STDERR, "Missing required PHP extensions: ".implode(", ", $missing)."\n"); exit(1); } ' node -e "const [major, minor] = process.versions.node.split('.').map(Number); const ok = (major === 20 && minor >= 19) || (major === 22 && minor >= 12) || major > 22; if (!ok) { console.error('Node '+process.versions.node+' is not supported. Vite requires ^20.19.0 or >=22.12.0.'); process.exit(1); }" "$COMPOSER_CMD" --version npm --version - name: Clone main env: GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} REPOSITORY: ${{ github.repository }} run: | test -n "$GITEA_TOKEN" git -c credential.helper='!f() { echo "username=oauth2"; echo "password=$GITEA_TOKEN"; }; f' clone --depth 1 --single-branch --branch main "https://git.hickmeister.uk/${REPOSITORY}.git" src - name: Install dependencies and verify app working-directory: src run: | "$COMPOSER_CMD" install --no-interaction --prefer-dist --no-progress npm ci cp .env.example .env "$PHP_CMD" artisan key:generate --ansi npm run build test -f public/build/manifest.json "$PHP_CMD" vendor/bin/phpunit ls -la public/build - name: Check existing production environment env: DEPLOY_PATH: ${{ env.DEPLOY_ROOT }}/${{ env.PROJECT_NAME }} run: | test -d "$DEPLOY_PATH" test -f "$DEPLOY_PATH/.env" test -f "$DEPLOY_PATH/.htaccess" grep -q '^APP_KEY=base64:' "$DEPLOY_PATH/.env" - name: Deploy application to live directory env: DEPLOY_PATH: ${{ env.DEPLOY_ROOT }}/${{ env.PROJECT_NAME }} run: | set +e rsync -rlz --delete --no-perms --no-owner --no-group --omit-dir-times --no-times --inplace \ --exclude '.env' \ --exclude '.htaccess' \ --exclude '.git/' \ --exclude '.gitea/' \ --exclude 'node_modules/' \ --exclude 'vendor/' \ --exclude 'tests/' \ --exclude '.phpunit.cache/' \ --exclude '.phpunit.result.cache' \ --exclude 'storage/' \ --exclude 'bootstrap/cache/' \ --exclude 'logs/' \ --exclude 'public/storage' \ src/ "$DEPLOY_PATH/" status=$? set -e if [ "$status" -ne 0 ]; then if [ "$status" -eq 23 ] || [ "$status" -eq 24 ]; then echo "rsync reported partial transfer (likely timestamps/attrs not permitted); continuing." else exit "$status" fi fi - name: Prepare Laravel directories env: DEPLOY_PATH: ${{ env.DEPLOY_ROOT }}/${{ env.PROJECT_NAME }} RUNTIME_PATH: /tmp/${{ env.PROJECT_NAME }} run: | mkdir -p \ "$DEPLOY_PATH/storage/app/public" \ "$DEPLOY_PATH/storage/framework/cache/data" \ "$DEPLOY_PATH/storage/framework/sessions" \ "$DEPLOY_PATH/storage/framework/testing" \ "$DEPLOY_PATH/storage/framework/views" \ "$DEPLOY_PATH/storage/logs" \ "$DEPLOY_PATH/bootstrap/cache" \ "$RUNTIME_PATH/views" \ "$RUNTIME_PATH/logs" \ "$RUNTIME_PATH/cache" - name: Verify deployed Laravel files working-directory: ${{ env.DEPLOY_ROOT }}/${{ env.PROJECT_NAME }} run: | for path in \ artisan \ public/index.php \ public/build/manifest.json \ routes/web.php \ resources/views/dashboard.blade.php \ app/Services/Etsy.php \ app/Services/Bambuddy.php \ app/Services/Parcel2Go.php \ app/Services/DryerClient.php do if [ ! -f "$path" ]; then echo "Missing deployed Laravel file: $(pwd)/$path" >&2 exit 1 fi done - name: Require Laravel environment file working-directory: ${{ env.DEPLOY_ROOT }}/${{ env.PROJECT_NAME }} run: | if [ ! -f ".env" ]; then echo "Missing .env at $(pwd)/.env. Create the server-side Laravel environment file before deploying." >&2 exit 1 fi - name: Ensure writable runtime paths working-directory: ${{ env.DEPLOY_ROOT }}/${{ env.PROJECT_NAME }} env: RUNTIME_PATH: /tmp/${{ env.PROJECT_NAME }} run: | mkdir -p "$RUNTIME_PATH/views" "$RUNTIME_PATH/logs" "$RUNTIME_PATH/cache" chmod 0777 "$RUNTIME_PATH" "$RUNTIME_PATH/views" "$RUNTIME_PATH/logs" "$RUNTIME_PATH/cache" if grep -q '^VIEW_COMPILED_PATH=' .env; then sed -i "s|^VIEW_COMPILED_PATH=.*|VIEW_COMPILED_PATH=/tmp/TechOdyssey_Designs_Dashboard.views|" .env else echo "VIEW_COMPILED_PATH=/tmp/TechOdyssey_Designs_Dashboard.views" >> .env fi if grep -q '^LOG_PATH=' .env; then sed -i "s|^LOG_PATH=.*|LOG_PATH=/tmp/TechOdyssey_Designs_Dashboard.laravel.log|" .env else echo "LOG_PATH=/tmp/TechOdyssey_Designs_Dashboard.laravel.log" >> .env fi for key_path in \ "APP_SERVICES_CACHE=/tmp/TechOdyssey_Designs_Dashboard.services.php" \ "APP_PACKAGES_CACHE=/tmp/TechOdyssey_Designs_Dashboard.packages.php" \ "APP_CONFIG_CACHE=/tmp/TechOdyssey_Designs_Dashboard.config.php" \ "APP_ROUTES_CACHE=/tmp/TechOdyssey_Designs_Dashboard.routes.php" \ "APP_EVENTS_CACHE=/tmp/TechOdyssey_Designs_Dashboard.events.php" do key="${key_path%%=*}" value="${key_path#*=}" if grep -q "^${key}=" .env; then sed -i "s|^${key}=.*|${key}=${value}|" .env else echo "${key}=${value}" >> .env fi done - name: Install production PHP dependencies working-directory: ${{ env.DEPLOY_ROOT }}/${{ env.PROJECT_NAME }} run: | rm -f \ /tmp/TechOdyssey_Designs_Dashboard.packages.php \ /tmp/TechOdyssey_Designs_Dashboard.services.php "$COMPOSER_CMD" install --no-dev --no-interaction --prefer-dist --optimize-autoloader rm -f \ /tmp/TechOdyssey_Designs_Dashboard.packages.php \ /tmp/TechOdyssey_Designs_Dashboard.services.php "$PHP_CMD" artisan package:discover --ansi --no-interaction - name: Clear compiled Laravel files working-directory: ${{ env.DEPLOY_ROOT }}/${{ env.PROJECT_NAME }} run: | rm -f bootstrap/cache/config.php bootstrap/cache/routes-*.php bootstrap/cache/events.php bootstrap/cache/packages.php bootstrap/cache/services.php "$PHP_CMD" artisan config:clear "$PHP_CMD" artisan route:clear "$PHP_CMD" artisan event:clear "$PHP_CMD" artisan view:clear - name: Ensure application key exists working-directory: ${{ env.DEPLOY_ROOT }}/${{ env.PROJECT_NAME }} run: | grep -q '^APP_KEY=base64:' .env - name: Verify database target working-directory: ${{ env.DEPLOY_ROOT }}/${{ env.PROJECT_NAME }} run: | "$PHP_CMD" <<'PHP' $env = []; foreach (file(".env", FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) { $line = trim($line); if ($line === "" || str_starts_with($line, "#") || ! str_contains($line, "=")) { continue; } [$key, $value] = explode("=", $line, 2); $env[$key] = trim($value, "\"'"); } $connection = $env["DB_CONNECTION"] ?? ""; if (! in_array($connection, ["mysql", "mariadb"], true)) { fwrite(STDERR, "DB_CONNECTION must be mysql or mariadb for this deployment. Current value: ".$connection."\n"); exit(1); } $host = $env["DB_HOST"] ?? ""; $port = (int) ($env["DB_PORT"] ?? 3306); $database = $env["DB_DATABASE"] ?? ""; if ($host === "") { fwrite(STDERR, "DB_HOST is missing from .env. Set DB_HOST to the MySQL server IP or hostname.\n"); exit(1); } if ($database === "") { fwrite(STDERR, "DB_DATABASE is missing from .env.\n"); exit(1); } $socket = @fsockopen($host, $port, $errno, $errstr, 5); if (! $socket) { fwrite(STDERR, "Cannot reach MySQL at DB_HOST=".$host." DB_PORT=".$port.": ".$errstr." (".$errno.")\n"); exit(1); } fclose($socket); echo "Database target reachable at DB_HOST=".$host." DB_PORT=".$port." DB_DATABASE=".$database."\n"; PHP - name: Run Laravel migrations working-directory: ${{ env.DEPLOY_ROOT }}/${{ env.PROJECT_NAME }} run: | "$PHP_CMD" artisan migrate --force - name: Optimise Laravel working-directory: ${{ env.DEPLOY_ROOT }}/${{ env.PROJECT_NAME }} run: | rm -f public/hot rm -rf build cp -a public/build build rm -rf public/storage ln -s ../storage/app/public public/storage "$PHP_CMD" artisan config:clear "$PHP_CMD" artisan route:clear "$PHP_CMD" artisan view:clear