Two independent GitHub Actions pipelines — one per repo — each gating on tests before a Docker image reaches GHCR, with the frontend pipeline going one step further into an SSH deploy + healthcheck.

# amigos_unite_api / .github/workflows/backend-ci.yml

name: Backend CI

on:
  pull_request:
    branches: [main]
    paths:
      - "app/**"
      - "config/**"
      - "db/**"
      - "spec/**"
      - "Gemfile"
      - "Gemfile.lock"

jobs:
  test:
    name: backend-ci / test
    runs-on: ubuntu-24.04

    services:
      postgres:
        image: postgres:17
        env:
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: postgres
          POSTGRES_DB: amigos_unite_api_test
        ports:
          - "5432:5432"
        options: >-
          --health-cmd="pg_isready -U postgres -d amigos_unite_api_test"
          --health-interval=10s
          --health-timeout=5s
          --health-retries=10

    env:
      RAILS_ENV: test
      DATABASE_URL: postgres://postgres:postgres@localhost:5432/amigos_unite_api_test
      RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY_TEST }}

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Install OS deps
        run: |
          sudo apt-get update -qq
          sudo apt-get install -y --no-install-recommends \
            libpq-dev postgresql-client pkg-config \
            libvips libvips-dev libvips-tools

      - name: Set up Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: "3.2.2"
          bundler: "2.5.17"
          bundler-cache: true

      - name: Materialize test credentials key (validate shape)
        run: |
          KEY="$(printf '%s' "${RAILS_MASTER_KEY:-}" | tr -d '\r\n')"
          if [[ ! "$KEY" =~ ^[0-9A-Fa-f]{32}$ ]]; then
            echo "ERROR: RAILS_MASTER_KEY_TEST must be exactly 32 hex characters." >&2
            exit 1
          fi
          mkdir -p config/credentials
          printf '%s' "$KEY" > config/credentials/test.key
          chmod 0400 config/credentials/test.key

      - name: Prepare database (CI-safe - no seeds)
        run: bin/rails db:drop db:create db:schema:load

      - name: Run RSpec suite
        run: bundle exec rspec

Path-filtered so it only runs on backend changes; validates the test credentials key's shape before Rails ever touches it, to fail with a clear message instead of a cryptic decrypt error.

# amigos_unite_app / .github/workflows/ghcr.yml

# lines 1–80 of 140 (test → build → push)

name: Build, Push, Deploy (GHCR) - WEB

on:
  push:
    branches: ["main"]
  pull_request:
    branches: ["main"]
  workflow_dispatch: {}

permissions:
  contents: read
  packages: write

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: sagacic-tim/amigos_unite_app

jobs:
  test:
    name: Lint, typecheck & build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "20.x"
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      - name: Lint and typecheck
        run: npm test

      - name: Build
        run: npm run build
        env:
          VITE_GOOGLE_MAPS_API_KEY: ${{ secrets.VITE_GOOGLE_MAPS_API_KEY }}

  build_and_push:
    name: Build & push Docker image
    needs: test
    runs-on: ubuntu-latest
    if: github.event_name != 'pull_request'
    steps:
      - uses: actions/checkout@v4

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

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

      - name: Docker metadata (tags/labels)
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=raw,value=main
            type=sha,format=short

      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          build-args: |
            VITE_GOOGLE_MAPS_API_KEY=${{ secrets.VITE_GOOGLE_MAPS_API_KEY }}

Deploy stage (not shown — SSH + healthcheck loop against the VPS) only runs after both this test job and the image push succeed.