name: 'Deploy Static Pages' description: 'Packages static web assets into a hardened Nginx container, pushes to Gitea registry, and deploys to TrueNAS with Traefik ingress.' inputs: path: description: 'Path to static files directory (e.g. ./dist, ./build/web, ./public)' required: false default: './dist' domain: description: 'Target URL or domain for Traefik router (e.g. "my-app.rodydavis.dev" or "https://my-app.rodydavis.dev")' required: false default: '' app_name: description: 'Application / container name (defaults to repository name)' required: false default: '' registry: description: 'Container registry host' required: false default: 'git.rodydavis.dev' image_name: description: 'Container image tag (defaults to registry/repository:latest)' required: false default: '' git_token: description: 'Token with package write permissions for container registry' required: false default: '' ssh_key: description: 'Private SSH key for authenticating to TrueNAS SCALE' required: false default: '' host: description: 'TrueNAS SSH host IP or domain' required: false default: '10.0.0.218' user: description: 'TrueNAS SSH user' required: false default: 'root' network: description: 'Docker external network name for Traefik' required: false default: 'apps-net' spa: description: 'Enable SPA router fallback in Nginx (try_files $uri $uri/ /index.html)' required: false default: 'true' traefik_entrypoints: description: 'Traefik entrypoint (e.g. web, websecure)' required: false default: 'web' push_image: description: 'Whether to push the built Docker image to registry' required: false default: 'true' deploy: description: 'Whether to deploy to TrueNAS' required: false default: 'true' runs: using: 'composite' steps: - name: Package and Deploy Static Pages shell: bash env: INPUT_PATH: ${{ inputs.path }} INPUT_DOMAIN: ${{ inputs.domain }} INPUT_APP_NAME: ${{ inputs.app_name }} INPUT_REGISTRY: ${{ inputs.registry }} INPUT_IMAGE_NAME: ${{ inputs.image_name }} INPUT_GIT_TOKEN: ${{ inputs.git_token }} INPUT_SSH_KEY: ${{ inputs.ssh_key }} INPUT_HOST: ${{ inputs.host }} INPUT_USER: ${{ inputs.user }} INPUT_NETWORK: ${{ inputs.network }} INPUT_SPA: ${{ inputs.spa }} INPUT_ENTRYPOINTS: ${{ inputs.traefik_entrypoints }} INPUT_PUSH_IMAGE: ${{ inputs.push_image }} INPUT_DEPLOY: ${{ inputs.deploy }} REPO_NAME: ${{ github.event.repository.name }} GITHUB_REPOSITORY: ${{ github.repository }} GITHUB_ACTOR: ${{ github.actor }} run: | set -euo pipefail echo "🚀 Starting Static Pages Deployment..." # 1. Resolve repository name fallback if [ -z "${REPO_NAME:-}" ]; then REPO_NAME=$(basename "${GITHUB_REPOSITORY:-app}") fi # 2. Locate static build assets ASSET_DIR="$INPUT_PATH" if [ ! -d "$ASSET_DIR" ]; then echo "Specified path '$ASSET_DIR' not found. Searching common output directories..." if [ -d "./dist" ]; then ASSET_DIR="./dist" elif [ -d "./build/web" ]; then ASSET_DIR="./build/web" elif [ -d "./web/dist" ]; then ASSET_DIR="./web/dist" elif [ -d "./public" ]; then ASSET_DIR="./public" elif [ -d "./build" ]; then ASSET_DIR="./build" elif [ -d "./out" ]; then ASSET_DIR="./out" else echo "::error::Could not find static asset directory! Checked: '$INPUT_PATH', ./dist, ./build/web, ./public, ./build, ./out" exit 1 fi fi echo "📁 Using static assets from: $ASSET_DIR" # 3. Resolve target domain & router name DOMAIN="$INPUT_DOMAIN" if [ -n "$DOMAIN" ]; then DOMAIN=$(echo "$DOMAIN" | sed -E 's#^https?://##' | sed 's#/.*$##') else CLEAN_REPO=$(echo "$REPO_NAME" | tr '_' '-' | tr '[:upper:]' '[:lower:]') DOMAIN="${CLEAN_REPO}.rodydavis.dev" fi APP_NAME="${INPUT_APP_NAME:-$REPO_NAME}" APP_NAME=$(echo "$APP_NAME" | tr '_' '-' | tr '[:upper:]' '[:lower:]') ROUTER_NAME=$(echo "$APP_NAME" | tr -cd '[:alnum:]') # 4. Resolve container image tag REGISTRY="${INPUT_REGISTRY:-git.rodydavis.dev}" if [ -n "$INPUT_IMAGE_NAME" ]; then IMAGE_NAME="$INPUT_IMAGE_NAME" else REPO_LOWER=$(echo "$GITHUB_REPOSITORY" | tr '[:upper:]' '[:lower:]') IMAGE_NAME="${REGISTRY}/${REPO_LOWER}:latest" fi echo "🌐 Routing Target: https://${DOMAIN}" echo "📦 Container Name: ${APP_NAME}-web" echo "🏷️ Image Tag: ${IMAGE_NAME}" # 5. Prepare build bundle in temporary staging directory BUILD_STAGING=$(mktemp -d) trap 'rm -rf "$BUILD_STAGING"' EXIT mkdir -p "${BUILD_STAGING}/html" cp -R "${ASSET_DIR}/." "${BUILD_STAGING}/html/" # Configure SPA fallback rule if [ "$INPUT_SPA" = "true" ]; then SPA_DIRECTIVE='try_files $uri $uri/ /index.html;' else SPA_DIRECTIVE='try_files $uri $uri/ =404;' fi cat < "${BUILD_STAGING}/nginx.conf" server { listen 80; listen [::]:80; server_name localhost; root /usr/share/nginx/html; index index.html index.htm; gzip on; gzip_vary on; gzip_min_length 1024; gzip_proxied expired no-cache no-store private auth; gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/javascript application/json application/manifest+json image/svg+xml; location = /sw.js { add_header Cache-Control "no-cache, no-store, must-revalidate"; expires 0; } location = /manifest.json { add_header Cache-Control "no-cache, must-revalidate"; expires 0; } location / { ${SPA_DIRECTIVE} add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; } location ~* \.(?:css|js|jpg|jpeg|gif|png|ico|cur|gz|svg|svgz|mp4|ogg|ogv|webm|htc|woff|woff2)$ { expires 1y; add_header Cache-Control "public, immutable"; } error_page 500 502 503 504 /50x.html; location = /50x.html { root /usr/share/nginx/html; } } EOF cat < "${BUILD_STAGING}/Dockerfile" FROM nginx:alpine WORKDIR /usr/share/nginx/html RUN rm -rf ./* COPY nginx.conf /etc/nginx/conf.d/default.conf COPY html/ ./ EXPOSE 80 CMD ["nginx", "-g", "daemon off;"] EOF cat < "${BUILD_STAGING}/docker-compose.yml" version: '3.8' services: ${APP_NAME}-web: image: ${IMAGE_NAME} container_name: ${APP_NAME}-web restart: unless-stopped networks: - ${INPUT_NETWORK} labels: - "traefik.enable=true" - "traefik.http.routers.${ROUTER_NAME}.rule=Host(\`${DOMAIN}\`)" - "traefik.http.routers.${ROUTER_NAME}.entrypoints=${INPUT_ENTRYPOINTS}" - "traefik.http.services.${ROUTER_NAME}.loadbalancer.server.port=80" networks: ${INPUT_NETWORK}: external: true EOF # 6. Docker Build echo "🔨 Building Docker image..." docker build -t "$IMAGE_NAME" -f "${BUILD_STAGING}/Dockerfile" "$BUILD_STAGING" # 7. Push image to registry if [ "$INPUT_PUSH_IMAGE" = "true" ]; then if [ -n "$INPUT_GIT_TOKEN" ]; then echo "🔐 Logging into ${REGISTRY}..." echo "$INPUT_GIT_TOKEN" | docker login "$REGISTRY" -u "${GITHUB_ACTOR}" --password-stdin fi echo "📤 Pushing image to ${IMAGE_NAME}..." docker push "$IMAGE_NAME" else echo "⏭️ Skipping image push (push_image=false)" fi # 8. Deploy to TrueNAS if [ "$INPUT_DEPLOY" = "true" ]; then if [ -z "$INPUT_SSH_KEY" ]; then echo "⚠️ No ssh_key provided for TrueNAS. Skipping remote deployment step." else echo "🚢 Deploying to TrueNAS SCALE (${INPUT_HOST})..." mkdir -p ~/.ssh echo "$INPUT_SSH_KEY" > ~/.ssh/truenas_deploy_key chmod 600 ~/.ssh/truenas_deploy_key ssh-keyscan -H "$INPUT_HOST" >> ~/.ssh/known_hosts 2>/dev/null REMOTE_DIR="/mnt/jupiter/apps/${APP_NAME}" echo "Ensuring remote directory ${REMOTE_DIR} on ${INPUT_HOST}..." ssh -i ~/.ssh/truenas_deploy_key -o StrictHostKeyChecking=no "${INPUT_USER}@${INPUT_HOST}" "mkdir -p ${REMOTE_DIR}" echo "Syncing docker-compose.yml to ${REMOTE_DIR}..." scp -i ~/.ssh/truenas_deploy_key -o StrictHostKeyChecking=no "${BUILD_STAGING}/docker-compose.yml" "${INPUT_USER}@${INPUT_HOST}:${REMOTE_DIR}/docker-compose.yml" echo "Pulling latest image and restarting stack..." ssh -i ~/.ssh/truenas_deploy_key -o StrictHostKeyChecking=no "${INPUT_USER}@${INPUT_HOST}" "cd ${REMOTE_DIR} && docker compose pull && docker compose up -d --remove-orphans" rm -f ~/.ssh/truenas_deploy_key echo "✅ Deployment complete! Accessible at: https://${DOMAIN}" fi else echo "⏭️ Skipping deployment step (deploy=false)" fi