feat: add pages-deploy action for static site containerization and TrueNAS Traefik deployment
Update README Actions Table / update-readme (push) Successful in 10s

This commit is contained in:
2026-10-13 11:25:09 -07:00
parent d2a3b7e655
commit 393301c0b6
3 changed files with 368 additions and 0 deletions
+92
View File
@@ -0,0 +1,92 @@
# Deploy Static Pages Action
Custom Gitea Action for building and deploying static web applications (Vite, Lit, React, Flutter Web, Astro, vanilla HTML) as Docker containers with Traefik ingress on TrueNAS SCALE.
## Features
- **Zero-Boilerplate Containerization**: Dynamically creates a multi-stage Nginx container with SPA routing, gzip, PWA cache policies, and security headers.
- **Auto-Configured Ingress**: Generates Traefik routing labels using target domain/subdomain on the `apps-net` Docker network.
- **Direct Registry Publishing**: Authenticates and pushes images directly to Gitea Container Registry (`git.rodydavis.dev`).
- **Seamless TrueNAS Deployment**: Securely transfers `docker-compose.yml` to TrueNAS SCALE and pulls/restarts the service.
## Inputs
| Name | Description | Default | Required |
| :--- | :--- | :--- | :---: |
| `path` | Path to static build output directory (`./dist`, `./build/web`, `./public`) | `./dist` | No |
| `domain` | Target URL/domain for Traefik router (e.g. `myapp.rodydavis.dev`, `pocketpiano.app`) | `<repo-name>.rodydavis.dev` | No |
| `app_name` | Container and directory name | `<repo-name>` | No |
| `git_token` | Gitea token with package write permissions | `""` | No |
| `ssh_key` | SSH private key for TrueNAS SCALE | `""` | No |
| `registry` | Container registry host | `git.rodydavis.dev` | No |
| `host` | TrueNAS host IP/domain | `10.0.0.218` | No |
| `user` | TrueNAS SSH user | `root` | No |
| `network` | Docker external network for Traefik | `apps-net` | No |
| `spa` | Enable SPA fallback (`try_files $uri $uri/ /index.html`) | `true` | No |
| `push_image` | Push built image to container registry | `true` | No |
| `deploy` | Deploy to TrueNAS | `true` | No |
## Usage Examples
### 1. Node / Vite / Lit / TypeScript App
```yaml
name: Build and Deploy
on:
push:
branches: ["main", "master"]
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
- run: npm ci && npm run build
- name: Deploy Static Pages
uses: https://git.rodydavis.dev/rodydavis/shared-actions/pages-deploy@main
with:
path: './dist'
domain: 'bullmastiff.rodydavis.dev'
git_token: ${{ secrets.GIT_TOKEN }}
ssh_key: ${{ secrets.TRUENAS_SSH_KEY }}
```
### 2. Flutter Web App
```yaml
name: Build and Deploy
on:
push:
branches: ["main", "master"]
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: https://git.rodydavis.dev/rodydavis/shared-actions/setup-flutter@main
- run: |
flutter pub get
flutter build web --release
- name: Deploy Static Pages
uses: https://git.rodydavis.dev/rodydavis/shared-actions/pages-deploy@main
with:
path: './build/web'
domain: 'flow-image-editor.rodydavis.dev'
git_token: ${{ secrets.GIT_TOKEN }}
ssh_key: ${{ secrets.TRUENAS_SSH_KEY }}
```
+275
View File
@@ -0,0 +1,275 @@
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