name: Validate and deploy site on: pull_request: push: branches: - main workflow_dispatch: permissions: contents: read concurrency: group: production-site cancel-in-progress: false jobs: validate: runs-on: ubuntu-24.04 services: postgres: image: postgres:16-alpine env: POSTGRES_PASSWORD: postgres ports: - 5432:5432 options: >- --health-cmd "pg_isready -U postgres" --health-interval 5s --health-timeout 5s --health-retries 10 steps: - name: Check out repository uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: 22 - name: Check JavaScript syntax shell: bash run: find . -type f -name '*.js' -not -path './.git/*' -print0 | xargs -0 -n1 node --check - name: Install validation tools run: npm ci --ignore-scripts - name: Validate site assets run: node scripts/validate-site.mjs - name: Test site, publishing, backup transactions, and integrations run: npm test - name: Build offline validation site run: node scripts/build-site.mjs --input scripts/fixtures/blog-posts.json - name: Validate generated pages and assets run: node scripts/validate-site.mjs --root .site-build - name: Install Chromium for unpacked extension checks run: npx playwright install --with-deps chromium - name: Check browser behavior and accessibility env: BITS_BROWSER_CHANNEL: chrome run: | npm run test:browser node scripts/resume-browser.mjs --root .site-build node scripts/career-dates-browser.mjs --root .site-build node scripts/social-links-browser.mjs --root .site-build node scripts/accessibility-preferences-browser.mjs --root .site-build node scripts/keyboard-startup-browser.mjs --root .site-build node scripts/creators-browser.mjs --root .site-build node scripts/media-player-browser.mjs --root .site-build node scripts/nowplaying-browser.mjs --root .site-build node scripts/youtube-extension-browser.mjs node scripts/aero-variants-browser.mjs --root .site-build node scripts/theme-collection-browser.mjs --root .site-build node scripts/theme-picker-browser.mjs --root .site-build node scripts/html-theme-browser.mjs --root .site-build node scripts/pride-themes-browser.mjs --root .site-build node scripts/desktop-workspaces-browser.mjs --root .site-build node scripts/messages-browser.mjs --root .site-build node scripts/critical-css-browser.mjs --root .site-build - name: Enforce pre-deployment performance budgets id: performance run: npm run audit - name: Profile a failing startup performance check if: failure() && steps.performance.outcome == 'failure' env: BITS_BROWSER_CHANNEL: chrome run: node scripts/profile-startup.mjs - name: Upload pre-deployment performance reports if: always() uses: actions/upload-artifact@v4 with: name: validation-lighthouse-reports path: lighthouse-reports if-no-files-found: ignore retention-days: 14 - name: Test NAS ingestion and desktop compatibility env: PGPASSWORD: postgres run: psql -h localhost -U postgres -d postgres -f agents/unraid/database.test.sql - name: Build Unraid collector image run: docker build --tag shortbits-unraid:test agents/unraid - name: Scan committed content for secrets uses: gitleaks/gitleaks-action@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} supabase: if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' needs: validate runs-on: ubuntu-latest environment: Supabase env: SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }} SUPABASE_DB_PASSWORD: ${{ secrets.SUPABASE_DB_PASSWORD }} SUPABASE_PROJECT_REF: arjwrycitgobvlodmkeo steps: - name: Check Supabase credentials id: credentials shell: bash run: | if [ -n "$SUPABASE_ACCESS_TOKEN" ] && [ -n "$SUPABASE_DB_PASSWORD" ]; then echo "ready=true" >> "$GITHUB_OUTPUT" else echo "ready=false" >> "$GITHUB_OUTPUT" echo "::warning::SUPABASE_ACCESS_TOKEN or SUPABASE_DB_PASSWORD is not set; skipping database migrations and Edge Function deploys." fi - name: Check out repository if: steps.credentials.outputs.ready == 'true' uses: actions/checkout@v4 - name: Set up Supabase CLI if: steps.credentials.outputs.ready == 'true' uses: supabase/setup-cli@v1 with: version: latest - name: Sync Edge Function secrets if: steps.credentials.outputs.ready == 'true' shell: bash env: TURNSTILE_SECRET_KEY: ${{ secrets.TURNSTILE_SECRET_KEY }} RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }} CONTACT_EMAIL_FROM: ${{ secrets.CONTACT_EMAIL_FROM }} CONTACT_EMAIL_TO: ${{ secrets.CONTACT_EMAIL_TO }} TWITCH_CLIENT_ID: ${{ secrets.TWITCH_CLIENT_ID }} TWITCH_CLIENT_SECRET: ${{ secrets.TWITCH_CLIENT_SECRET }} run: | if [ -n "$TURNSTILE_SECRET_KEY" ]; then supabase secrets set --project-ref "$SUPABASE_PROJECT_REF" TURNSTILE_SECRET_KEY="$TURNSTILE_SECRET_KEY" else echo "TURNSTILE_SECRET_KEY is not set; the contact function will skip the Turnstile check." fi if [ -n "$RESEND_API_KEY" ] && [ -n "$CONTACT_EMAIL_FROM" ] && [ -n "$CONTACT_EMAIL_TO" ]; then supabase secrets set --project-ref "$SUPABASE_PROJECT_REF" \ RESEND_API_KEY="$RESEND_API_KEY" \ CONTACT_EMAIL_FROM="$CONTACT_EMAIL_FROM" \ CONTACT_EMAIL_TO="$CONTACT_EMAIL_TO" else echo "::warning::Resend secrets are incomplete; existing Supabase email secrets are unchanged. Configure all three secrets to enable contact emails." fi if [ -n "$TWITCH_CLIENT_ID" ] && [ -n "$TWITCH_CLIENT_SECRET" ]; then supabase secrets set --project-ref "$SUPABASE_PROJECT_REF" \ TWITCH_CLIENT_ID="$TWITCH_CLIENT_ID" \ TWITCH_CLIENT_SECRET="$TWITCH_CLIENT_SECRET" else echo "Twitch secrets are not configured in GitHub; existing Supabase Twitch secrets are unchanged." fi # The function must exist before the migration revokes direct inserts into messages. - name: Deploy contact Edge Function if: steps.credentials.outputs.ready == 'true' run: >- supabase functions deploy contact-message --project-ref "$SUPABASE_PROJECT_REF" --no-verify-jwt --use-api - name: Deploy creator videos Edge Function if: steps.credentials.outputs.ready == 'true' run: >- supabase functions deploy creator-videos --project-ref "$SUPABASE_PROJECT_REF" --no-verify-jwt --use-api - name: Deploy Twitch creator Edge Function if: steps.credentials.outputs.ready == 'true' run: >- supabase functions deploy creator-twitch --project-ref "$SUPABASE_PROJECT_REF" --no-verify-jwt --use-api - name: Deploy Spotify playback Edge Function if: steps.credentials.outputs.ready == 'true' run: >- supabase functions deploy spotify-now-playing --project-ref "$SUPABASE_PROJECT_REF" --no-verify-jwt --use-api - name: Deploy YouTube presence Edge Function if: steps.credentials.outputs.ready == 'true' run: >- supabase functions deploy youtube-presence --project-ref "$SUPABASE_PROJECT_REF" --no-verify-jwt --use-api # The hardware agent authenticates with its own device token, not a Supabase JWT. - name: Deploy hardware sync Edge Function if: steps.credentials.outputs.ready == 'true' run: >- supabase functions deploy hardware-sync --project-ref "$SUPABASE_PROJECT_REF" --no-verify-jwt --use-api - name: Apply database migrations if: steps.credentials.outputs.ready == 'true' shell: bash run: | supabase link --project-ref "$SUPABASE_PROJECT_REF" --password "$SUPABASE_DB_PASSWORD" supabase db push --linked --password "$SUPABASE_DB_PASSWORD" deploy: # Front-end features degrade gracefully, so the site still ships if the Supabase job fails. if: >- always() && needs.validate.result == 'success' && github.event_name != 'pull_request' && github.ref == 'refs/heads/main' needs: [validate, supabase] runs-on: ubuntu-latest environment: production steps: - name: Check out repository uses: actions/checkout@v4 - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: 22 - name: Install build tools run: npm ci --ignore-scripts - name: Generate build metadata shell: bash run: | cat > build-info.json < ~/.ssh/id_ed25519 chmod 600 ~/.ssh/id_ed25519 printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts chmod 600 ~/.ssh/known_hosts - name: Build published content and versioned assets run: node scripts/build-site.mjs - name: Inject Google ownership verification env: GOOGLE_SITE_VERIFICATION: ${{ secrets.GOOGLE_SITE_VERIFICATION }} run: node scripts/write-site-verification.mjs --root .site-build - name: Validate production build run: node scripts/validate-site.mjs --root .site-build - name: Deploy static site shell: bash env: DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} DEPLOY_USER: ${{ secrets.DEPLOY_USER }} DEPLOY_PORT: ${{ vars.DEPLOY_PORT || '22' }} run: | rsync \ --archive \ --checksum \ --compress \ --chmod=D755,F644 \ -e "ssh -p ${DEPLOY_PORT}" \ .site-build/ \ "${DEPLOY_USER}@${DEPLOY_HOST}:/srv/bitsofbits/" # This generated-only directory must mirror the published set so removed # or unpublished posts do not leave stale public HTML behind. rsync \ --archive --checksum --compress --delete --chmod=D755,F644 \ -e "ssh -p ${DEPLOY_PORT}" \ .site-build/blog/articles/ \ "${DEPLOY_USER}@${DEPLOY_HOST}:/srv/bitsofbits/blog/articles/" - name: Configure integrations, canonical host, and versioned asset caching shell: bash env: DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} DEPLOY_USER: ${{ secrets.DEPLOY_USER }} DEPLOY_PORT: ${{ vars.DEPLOY_PORT || '22' }} CADDYFILE_PATH: /etc/caddy/Caddyfile run: | set -euo pipefail scp -P "${DEPLOY_PORT}" \ "${DEPLOY_USER}@${DEPLOY_HOST}:${CADDYFILE_PATH}" \ /tmp/Caddyfile.original cp /tmp/Caddyfile.original /tmp/Caddyfile.candidate node scripts/update-caddy-csp.mjs /tmp/Caddyfile.candidate scp -P "${DEPLOY_PORT}" \ /tmp/Caddyfile.original \ /tmp/Caddyfile.candidate \ "${DEPLOY_USER}@${DEPLOY_HOST}:/tmp/" ssh -p "${DEPLOY_PORT}" "${DEPLOY_USER}@${DEPLOY_HOST}" \ "set -eu; \ caddy validate --config /tmp/Caddyfile.candidate --adapter caddyfile; \ persisted=false; \ if cp /tmp/Caddyfile.candidate '${CADDYFILE_PATH}' 2>/dev/null; then \ persisted=true; \ else \ echo 'Caddyfile is not writable; applying through the Caddy admin API.'; \ fi; \ if ! caddy reload --config /tmp/Caddyfile.candidate --adapter caddyfile; then \ if [ \"\$persisted\" = true ]; then \ cp /tmp/Caddyfile.original '${CADDYFILE_PATH}'; \ fi; \ caddy reload --config /tmp/Caddyfile.original --adapter caddyfile; \ exit 1; \ fi; \ rm -f /tmp/Caddyfile.original /tmp/Caddyfile.candidate" health: if: always() && needs.deploy.result == 'success' needs: deploy permissions: contents: read issues: write uses: ./.github/workflows/monitor.yml secrets: DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }} DEPLOY_USER: ${{ secrets.DEPLOY_USER }} DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} with: expected-commit: ${{ github.sha }} lighthouse: if: always() && needs.deploy.result == 'success' needs: deploy runs-on: ubuntu-24.04 steps: - name: Check out repository uses: actions/checkout@v4 - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: 22 - name: Install pinned audit tools run: npm ci --ignore-scripts - name: Audit live pages run: node scripts/audit-site.mjs --url https://www.shortbits.net --enforce --median-lcp - name: Upload Lighthouse reports if: always() uses: actions/upload-artifact@v4 with: name: lighthouse-reports path: lighthouse-reports retention-days: 14