GitHub Actions CI/CD Implementation Plan

Status: REMOVED — Free GitHub accounts cannot use environment variables or secrets in private repositories. See docs/deployment-guide.md for the current manual deployment approach.

Original Goal: Add production GitHub Actions CI/CD that validates pull requests to master and deploys to the existing VPS when a PR is closed as merged.

Original Architecture: A single workflow gates deploy with Bun install, backend/frontend Biome lint, backend build, and frontend build. Docker Buildx publishes SHA-tagged backend/frontend images to GHCR, then an SSH deploy step uploads the production compose file and runs the existing remote compose/migration/health sequence while keeping .env.production on the VPS.

Removal Reason: Free GitHub accounts (including this private repo) do not have access to repository environments, secrets, or variables in private repositories. The workflow relied on ${{ vars.VITE_API_URL }}, ${{ secrets.VPS_HOST }}, and other secrets/variables which are unavailable on free plans.

Current State: Use release.sh for manual deployment. See docs/deployment-guide.md for details.


Task 1: Add Production Deploy Workflow (DEPRECATED)

Files:

  • Create: .github/workflows/deploy.yml

  • Step 1: Create workflow directory

Run: mkdir -p .github/workflows

Expected: .github/workflows exists.

  • Step 2: Add deploy workflow

Create .github/workflows/deploy.yml with:

name: Deploy Production

on:
  pull_request:
    branches:
      - master
    types:
      - opened
      - synchronize
      - reopened
      - ready_for_review
      - closed

permissions:
  contents: read
  packages: write

concurrency:
  group: deploy-${{ github.event_name }}-${{ github.ref }}
  cancel-in-progress: true

env:
  REGISTRY: ghcr.io
  BACKEND_IMAGE_NAME: iot_platform-backend
  FRONTEND_IMAGE_NAME: iot_platform-frontend
  VPS_PORT: "22"
  BACKEND_HOST_PORT: "3000"
  FRONTEND_HOST_PORT: "5173"

jobs:
  quality:
    name: Lint and build
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          ref: ${{ github.event.action == 'closed' && github.event.pull_request.merge_commit_sha || github.event.pull_request.head.sha }}

      - name: Setup Bun
        uses: oven-sh/setup-bun@v2
        with:
          bun-version: 1.3.10

      - name: Install dependencies
        run: bun install --frozen-lockfile

      - name: Lint backend
        run: bun run backend:lint

      - name: Lint frontend
        run: bun run frontend:lint

      - name: Build backend
        run: bun run backend:build

      - name: Build frontend
        run: bun run frontend:build

  publish:
    name: Build and publish images
    runs-on: ubuntu-latest
    needs: quality
    if: github.event.action == 'closed' && github.event.pull_request.merged == true
    outputs:
      backend_image: ${{ steps.images.outputs.backend_image }}
      frontend_image: ${{ steps.images.outputs.frontend_image }}
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.merge_commit_sha }}

      - name: Prepare image names
        id: images
        shell: bash
        run: |
          owner_lc="$(printf '%s' "$GITHUB_REPOSITORY_OWNER" | tr '[:upper:]' '[:lower:]')"
          deploy_sha="${{ github.event.pull_request.merge_commit_sha }}"
          backend_image="${REGISTRY}/${owner_lc}/${BACKEND_IMAGE_NAME}:${deploy_sha}"
          frontend_image="${REGISTRY}/${owner_lc}/${FRONTEND_IMAGE_NAME}:${deploy_sha}"
          backend_latest="${REGISTRY}/${owner_lc}/${BACKEND_IMAGE_NAME}:latest"
          frontend_latest="${REGISTRY}/${owner_lc}/${FRONTEND_IMAGE_NAME}:latest"

          {
            echo "backend_image=${backend_image}"
            echo "frontend_image=${frontend_image}"
            echo "backend_latest=${backend_latest}"
            echo "frontend_latest=${frontend_latest}"
          } >> "$GITHUB_OUTPUT"

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Login to GHCR
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push backend
        uses: docker/build-push-action@v6
        with:
          context: .
          file: docker/backend.Dockerfile
          platforms: linux/amd64
          push: true
          tags: |
            ${{ steps.images.outputs.backend_image }}
            ${{ steps.images.outputs.backend_latest }}

      - name: Build and push frontend
        uses: docker/build-push-action@v6
        with:
          context: .
          file: docker/frontend.Dockerfile
          platforms: linux/amd64
          push: true
          build-args: |
            VITE_API_URL=${{ secrets.VITE_API_URL }}
            VITE_WS_URL=${{ secrets.VITE_WS_URL }}
            VITE_APP_NAME=${{ secrets.VITE_APP_NAME }}
          tags: |
            ${{ steps.images.outputs.frontend_image }}
            ${{ steps.images.outputs.frontend_latest }}

  deploy:
    name: Deploy to VPS
    runs-on: ubuntu-latest
    needs: publish
    if: github.event.action == 'closed' && github.event.pull_request.merged == true
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Configure SSH
        shell: bash
        run: |
          mkdir -p ~/.ssh
          printf '%s\n' "${{ secrets.VPS_SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
          chmod 600 ~/.ssh/deploy_key
          ssh-keyscan -p "$VPS_PORT" "${{ secrets.VPS_HOST }}" >> ~/.ssh/known_hosts

      - name: Prepare remote directory
        shell: bash
        run: |
          ssh -i ~/.ssh/deploy_key -p "$VPS_PORT" "${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}" \
            "mkdir -p '${{ secrets.REMOTE_APP_DIR }}/backups'"

      - name: Upload compose file
        shell: bash
        run: |
          scp -i ~/.ssh/deploy_key -P "$VPS_PORT" docker-compose.prod.yml \
            "${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}:${{ secrets.REMOTE_APP_DIR }}/docker-compose.prod.yml"

      - name: Deploy release
        shell: bash
        env:
          BACKEND_IMAGE: ${{ needs.publish.outputs.backend_image }}
          FRONTEND_IMAGE: ${{ needs.publish.outputs.frontend_image }}
          REMOTE_APP_DIR: ${{ secrets.REMOTE_APP_DIR }}
        run: |
          remote_script="$(mktemp)"
          cat > "$remote_script" <<'REMOTE_SCRIPT'
          set -euo pipefail

          cd "$REMOTE_APP_DIR"
          mkdir -p backups

          if [ ! -f .env.production ]; then
            echo "Missing .env.production in $REMOTE_APP_DIR" >&2
            exit 1
          fi

          required_env_keys=(
            JWT_SECRET
            POSTGRES_USER
            POSTGRES_PASSWORD
            POSTGRES_DB
            DATABASE_URL
          )

          for key in "${required_env_keys[@]}"; do
            if ! grep -Eq "^${key}=.+" .env.production; then
              echo "Missing required env in .env.production: ${key}" >&2
              exit 1
            fi
          done

          export BACKEND_IMAGE
          export FRONTEND_IMAGE
          export BACKEND_HOST_PORT
          export FRONTEND_HOST_PORT

          docker compose -f docker-compose.prod.yml --env-file .env.production config >/dev/null
          docker compose -f docker-compose.prod.yml --env-file .env.production pull backend frontend
          docker compose -f docker-compose.prod.yml --env-file .env.production up -d postgres redis emqx minio portainer

          echo "Waiting for postgres to be healthy..."
          until [ "$(docker inspect --format='{{.State.Health.Status}}' hmpiot_postgres 2>/dev/null)" = "healthy" ]; do
            sleep 3
          done
          echo "Postgres is healthy."

          echo "Running database migrations..."
          docker compose -f docker-compose.prod.yml --env-file .env.production run --rm backend bun run migration:run
          echo "Migrations completed."

          docker compose -f docker-compose.prod.yml --env-file .env.production up -d backend frontend

          echo "Waiting for backend/frontend containers to be running..."
          check_container_running() {
            local name="$1"
            local tries=0
            while [ "$tries" -lt 20 ]; do
              if [ "$(docker inspect --format='{{.State.Running}}' "$name" 2>/dev/null || true)" = "true" ]; then
                return 0
              fi
              tries=$((tries + 1))
              sleep 3
            done
            return 1
          }

          if ! check_container_running hmpiot_backend || ! check_container_running hmpiot_frontend; then
            echo "Error: backend/frontend container is not running after deployment." >&2
            docker compose -f docker-compose.prod.yml --env-file .env.production ps -a || true
            docker compose -f docker-compose.prod.yml --env-file .env.production logs --tail=200 backend frontend || true
            exit 1
          fi

          echo "Checking backend/frontend health status..."
          check_health() {
            local name="$1"
            local tries=0
            while [ "$tries" -lt 20 ]; do
              status="$(docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$name" 2>/dev/null || true)"
              if [ "$status" = "healthy" ] || [ "$status" = "none" ]; then
                return 0
              fi
              if [ "$status" = "unhealthy" ]; then
                return 1
              fi
              tries=$((tries + 1))
              sleep 3
            done
            return 1
          }

          if ! check_health hmpiot_backend || ! check_health hmpiot_frontend; then
            echo "Error: backend/frontend container did not become healthy." >&2
            docker compose -f docker-compose.prod.yml --env-file .env.production ps -a || true
            docker compose -f docker-compose.prod.yml --env-file .env.production logs --tail=200 backend frontend || true
            exit 1
          fi

          docker compose -f docker-compose.prod.yml --env-file .env.production ps
          REMOTE_SCRIPT

          ssh -i ~/.ssh/deploy_key -p "$VPS_PORT" "${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}" \
            "BACKEND_IMAGE='${BACKEND_IMAGE}' FRONTEND_IMAGE='${FRONTEND_IMAGE}' BACKEND_HOST_PORT='${BACKEND_HOST_PORT}' FRONTEND_HOST_PORT='${FRONTEND_HOST_PORT}' REMOTE_APP_DIR='${REMOTE_APP_DIR}' bash -s" < "$remote_script"
  • Step 3: Review workflow YAML

Run: sed -n '1,260p' .github/workflows/deploy.yml

Expected: workflow includes quality, publish, and deploy jobs.

Task 2: Update Deployment Documentation

Files:

  • Modify: README.md

  • Modify: docs/deployment-guide.md

  • Step 1: Update README release section

Replace the release section with text that says PRs to master run lint/build and production deploy happens when a PR is closed as merged into master; list required secrets and keep release.sh as manual fallback.

  • Step 2: Update deployment guide

Update docs/deployment-guide.md with GitHub Actions source of truth, required secrets, VPS .env.production ownership, deploy sequence, and local fallback notes.

  • Step 3: Review docs

Run: sed -n '1,220p' README.md and sed -n '1,260p' docs/deployment-guide.md

Expected: docs match the workflow and do not say .env.production is uploaded by GitHub Actions.

Task 3: Verify and Commit

Files:

  • Verify: .github/workflows/deploy.yml

  • Verify: README.md

  • Verify: docs/deployment-guide.md

  • Verify: docs/superpowers/plans/2026-04-18-github-actions-cicd.md

  • Step 1: Run backend lint

Run: bun run backend:lint

Expected: command exits 0.

  • Step 2: Run frontend lint

Run: bun run frontend:lint

Expected: command exits 0.

  • Step 3: Run backend build

Run: bun run backend:build

Expected: command exits 0.

  • Step 4: Run frontend build

Run: bun run frontend:build

Expected: command exits 0.

  • Step 5: Check git diff

Run: git diff --stat

Expected: only workflow, docs, and plan files changed.

  • Step 6: Commit

Run:

git add .github/workflows/deploy.yml README.md docs/deployment-guide.md docs/superpowers/plans/2026-04-18-github-actions-cicd.md
git commit -m "ci: add github actions production deploy"

Expected: commit succeeds.