Files
shared-actions/pages-deploy/action.yml
T
2026-10-13 11:25:09 -07:00

276 lines
9.4 KiB
YAML

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 <<EOF > "${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 <<EOF > "${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 <<EOF > "${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