diff --git a/README.md b/README.md index 1a25d7f..907ae95 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ This repository contains reusable composite actions for CI/CD workflows across o | :--- | :--- | :--- | | [Deploy to Coolify](./coolify-deploy) | Triggers a Coolify deployment using a webhook and authorization token. | `rodydavis/shared-actions/coolify-deploy@main` | | [Enforce Flutter Version Constraint](./enforce-flutter-version) | Scans all pubspec.yaml files to ensure they use the required Flutter SDK constraint. | `rodydavis/shared-actions/enforce-flutter-version@main` | +| [Deploy Static Pages](./pages-deploy) | Packages static web assets into a hardened Nginx container, pushes to Gitea registry, and deploys to TrueNAS with Traefik ingress. | `rodydavis/shared-actions/pages-deploy@main` | | [Run Buildifier](./run-buildifier) | Downloads and runs buildifier on Starlark files. | `rodydavis/shared-actions/run-buildifier@main` | | [Setup Bazel (Bazelisk)](./setup-bazel) | Installs Node, Bazelisk, and configures Git for private SSH dependencies. | `rodydavis/shared-actions/setup-bazel@main` | | [Setup Flutter](./setup-flutter) | Cross-platform setup for Flutter with local mirror and multi-runner concurrency support. | `rodydavis/shared-actions/setup-flutter@main` | diff --git a/pages-deploy/README.md b/pages-deploy/README.md new file mode 100644 index 0000000..70817d8 --- /dev/null +++ b/pages-deploy/README.md @@ -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`) | `.rodydavis.dev` | No | +| `app_name` | Container and directory 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 }} +``` diff --git a/pages-deploy/action.yml b/pages-deploy/action.yml new file mode 100644 index 0000000..787997c --- /dev/null +++ b/pages-deploy/action.yml @@ -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 < "${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