name: Deploy HickLab3D Dashboard on: push: branches: - main workflow_dispatch: permissions: contents: read env: PROJECT_NAME: TechOdyssey_Designs_Dashboard DEPLOY_ROOT: /mnt/www-live NODE_VERSION: "22.21.1" REQUIRED_PHP_VERSION: "8.4.0" PHP_CMD: /opt/lhits-toolchain/bin/php COMPOSER_CMD: /opt/lhits-toolchain/bin/composer jobs: deploy-production: name: Deploy to production runs-on: [swarm, linux, arm64] if: github.ref == 'refs/heads/main' steps: - name: Install PHP, Composer, Node and deployment tools run: | if command -v apt-get >/dev/null 2>&1; then apt-get update DEBIAN_FRONTEND=noninteractive apt-get install -y \ ca-certificates curl gnupg lsb-release software-properties-common \ unzip git rsync xz-utils . /etc/os-release if [ "${ID:-}" = "ubuntu" ]; then add-apt-repository -y ppa:ondrej/php else curl -fsSL https://packages.sury.org/php/apt.gpg | gpg --dearmor --yes -o /usr/share/keyrings/deb.sury.org-php.gpg echo "deb [signed-by=/usr/share/keyrings/deb.sury.org-php.gpg] https://packages.sury.org/php/ ${VERSION_CODENAME} main" > /etc/apt/sources.list.d/php.list fi apt-get update DEBIAN_FRONTEND=noninteractive apt-get install -y \ php8.4-cli php8.4-mbstring php8.4-mysql php8.4-xml php8.4-curl \ php8.4-zip php8.4-bcmath php8.4-sqlite3 composer PHP_BIN="$(command -v php8.4)" elif command -v apk >/dev/null 2>&1; then alpine_series="v$(cut -d. -f1,2 /etc/alpine-release)" printf '%s\n' \ "https://dl-cdn.alpinelinux.org/alpine/${alpine_series}/main" \ "https://dl-cdn.alpinelinux.org/alpine/${alpine_series}/community" \ "https://dl-cdn.alpinelinux.org/alpine/edge/main" \ "https://dl-cdn.alpinelinux.org/alpine/edge/community" \ > /etc/apk/repositories apk update apk add git rsync curl ca-certificates xz libgcc apk add \ php84 php84-cli php84-mbstring php84-xml php84-curl php84-zip php84-phar php84-bcmath php84-ctype \ php84-mysqli php84-pdo php84-pdo_mysql php84-pdo_sqlite php84-sqlite3 php84-openssl php84-fileinfo php84-iconv php84-session apk add php84-dom php84-tokenizer php84-xmlwriter php84-session || true missing="" for extension in dom iconv pdo_sqlite session sqlite3 tokenizer xmlwriter; do php84 -r "exit(extension_loaded('${extension}') ? 0 : 1);" || missing="${missing} ${extension}" done if [ -n "$missing" ]; then apk add php84-dom php84-iconv php84-pdo_sqlite php84-session php84-sqlite3 php84-tokenizer php84-xmlwriter || true fi PHP_BIN="$(command -v php84)" curl -fsSL https://getcomposer.org/installer -o /tmp/composer-setup.php "$PHP_BIN" /tmp/composer-setup.php --install-dir=/usr/local/bin --filename=composer else echo "Unsupported package manager for installing PHP/Composer/Node" >&2 exit 1 fi if [ -z "${PHP_BIN:-}" ] || [ ! -x "$PHP_BIN" ]; then echo "PHP 8.4 binary was not installed or is not executable." >&2 exit 1 fi COMPOSER_BIN="$(command -v composer)" mkdir -p /opt/lhits-toolchain/bin ln -sfn "$PHP_BIN" /opt/lhits-toolchain/bin/php printf '%s\n' '#!/bin/sh' "exec \"$PHP_BIN\" \"$COMPOSER_BIN\" \"\$@\"" > /opt/lhits-toolchain/bin/composer chmod +x /opt/lhits-toolchain/bin/composer "$PHP_CMD" -v arch="$(uname -m)" case "$arch" in x86_64) node_arch="x64" ;; aarch64|arm64) node_arch="arm64" ;; *) echo "Unsupported Node architecture: $arch" >&2; exit 1 ;; esac if [ -f /etc/alpine-release ] || ldd /bin/sh 2>&1 | grep -qi musl; then node_dir="node-v${NODE_VERSION}-linux-${node_arch}-musl" node_url="https://unofficial-builds.nodejs.org/download/release/v${NODE_VERSION}/${node_dir}.tar.xz" else node_dir="node-v${NODE_VERSION}-linux-${node_arch}" node_url="https://nodejs.org/dist/v${NODE_VERSION}/${node_dir}.tar.xz" fi curl -fsSL "$node_url" -o /tmp/node.tar.xz tar -xJf /tmp/node.tar.xz -C /tmp rm -rf /usr/local/lib/nodejs mkdir -p /usr/local/lib/nodejs mv "/tmp/${node_dir}" /usr/local/lib/nodejs/node ln -sfn /usr/local/lib/nodejs/node/bin/node /usr/local/bin/node ln -sfn /usr/local/lib/nodejs/node/bin/npm /usr/local/bin/npm ln -sfn /usr/local/lib/nodejs/node/bin/npx /usr/local/bin/npx hash -r - 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 config --global credential.helper '!f() { echo "username=oauth2"; echo "password=$GITEA_TOKEN"; }; f' git 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: | "$COMPOSER_CMD" install --no-dev --no-interaction --prefer-dist --optimize-autoloader - 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