name: CI on: push: branches: [main] pull_request: branches: [main] jobs: lint-typecheck: name: Lint & Typecheck runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile - name: Typecheck run: pnpm typecheck - name: Lint run: pnpm lint test: name: Test runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile - name: Run tests run: pnpm test e2e: name: E2E Tests runs-on: ubuntu-latest needs: [lint-typecheck, test] steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile - name: Install Playwright browsers run: pnpm --filter @groombook/e2e exec playwright install --with-deps chromium - name: Start Docker Compose stack run: docker compose up -d --wait timeout-minutes: 5 - name: Run E2E tests run: pnpm --filter @groombook/e2e test - name: Upload Playwright report if: failure() uses: actions/upload-artifact@v4 with: name: playwright-report path: apps/e2e/playwright-report/ retention-days: 7 - name: Stop Docker Compose stack if: always() run: docker compose down build: name: Build runs-on: ubuntu-latest needs: [lint-typecheck, test] steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile - name: Build all packages run: pnpm build docker: name: Build & Push Docker Images runs-on: ubuntu-latest needs: [build, e2e] permissions: contents: read packages: write steps: - uses: actions/checkout@v4 - name: Generate image tag id: version run: | if [ "${{ github.event_name }}" = "pull_request" ]; then TAG="pr-${{ github.event.pull_request.number }}" else TAG="$(date -u +%Y.%m.%d)-${GITHUB_SHA::7}" fi echo "tag=$TAG" >> "$GITHUB_OUTPUT" echo "Image tag: $TAG" - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to GitHub Container Registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push API image uses: docker/build-push-action@v6 with: context: . file: apps/api/Dockerfile target: runner push: true tags: | ghcr.io/groombook/api:${{ steps.version.outputs.tag }} ${{ github.ref == 'refs/heads/main' && 'ghcr.io/groombook/api:latest' || '' }} cache-from: type=gha cache-to: type=gha,mode=max - name: Build and push Migrate image uses: docker/build-push-action@v6 with: context: . file: apps/api/Dockerfile target: migrate push: true tags: | ghcr.io/groombook/migrate:${{ steps.version.outputs.tag }} ${{ github.ref == 'refs/heads/main' && 'ghcr.io/groombook/migrate:latest' || '' }} cache-from: type=gha cache-to: type=gha,mode=max - name: Build and push Seed image uses: docker/build-push-action@v6 with: context: . file: apps/api/Dockerfile target: seed push: true tags: | ghcr.io/groombook/seed:${{ steps.version.outputs.tag }} ${{ github.ref == 'refs/heads/main' && 'ghcr.io/groombook/seed:latest' || '' }} cache-from: type=gha cache-to: type=gha,mode=max - name: Build and push Web image uses: docker/build-push-action@v6 with: context: . file: apps/web/Dockerfile push: true tags: | ghcr.io/groombook/web:${{ steps.version.outputs.tag }} ${{ github.ref == 'refs/heads/main' && 'ghcr.io/groombook/web:latest' || '' }} cache-from: type=gha cache-to: type=gha,mode=max deploy-dev: name: Deploy PR to groombook-dev runs-on: runners-groombook needs: [docker] if: github.event_name == 'pull_request' permissions: contents: read pull-requests: write steps: - name: Install kubectl run: | curl -sLO "https://dl.k8s.io/release/$(curl -sL https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" chmod +x kubectl sudo mv kubectl /usr/local/bin/ kubectl version --client - name: Deploy to groombook-dev env: TAG: pr-${{ github.event.pull_request.number }} PR_NUM: ${{ github.event.pull_request.number }} run: | echo "Deploying images tagged $TAG to groombook-dev..." # Run migration with PR image kubectl delete job migrate-schema -n groombook-dev --ignore-not-found kubectl delete job "migrate-pr-$PR_NUM" -n groombook-dev --ignore-not-found cat <> "$GITHUB_OUTPUT" echo "Image tag: $TAG" - name: Checkout groombook/infra uses: actions/checkout@v4 with: repository: groombook/infra token: ${{ secrets.GITHUB_TOKEN }} path: infra ref: main - name: Update image tags in infra repo run: | TAG="${{ steps.version.outputs.tag }}" # Update api.yaml — annotation + container image sed -i "s|groombook.dev/image-version: \".*\"|groombook.dev/image-version: \"$TAG\"|" infra/apps/groombook/base/api.yaml sed -i "s|ghcr.io/groombook/api:.*|ghcr.io/groombook/api:$TAG|" infra/apps/groombook/base/api.yaml # Update web.yaml — annotation + container image sed -i "s|groombook.dev/image-version: \".*\"|groombook.dev/image-version: \"$TAG\"|" infra/apps/groombook/base/web.yaml sed -i "s|ghcr.io/groombook/web:.*|ghcr.io/groombook/web:$TAG|" infra/apps/groombook/base/web.yaml # Update migrate-job.yaml — annotation + container image sed -i "s|groombook.app/deploy-version: \".*\"|groombook.app/deploy-version: \"$TAG\"|" infra/apps/groombook/base/migrate-job.yaml sed -i "s|ghcr.io/groombook/migrate:.*|ghcr.io/groombook/migrate:$TAG|" infra/apps/groombook/base/migrate-job.yaml # Update seed-job.yaml — annotation + container image sed -i "s|groombook.app/deploy-version: \".*\"|groombook.app/deploy-version: \"$TAG\"|" infra/apps/groombook/base/seed-job.yaml sed -i "s|ghcr.io/groombook/seed:.*|ghcr.io/groombook/seed:$TAG|" infra/apps/groombook/base/seed-job.yaml echo "Updated image tags to $TAG in:" git -C infra diff --stat - name: Create PR on groombook/infra uses: actions/github-script@v7 with: script: | const tag = process.env.TAG; const sha = process.env.GITHUB_SHA; const shortSha = sha.slice(0, 7); const branchName = `cd/update-images-${tag}`; // Create a new branch await github.rest.git.createRef({ owner: 'groombook', repo: 'infra', ref: `refs/heads/${branchName}`, sha: sha }); // Commit the changes const fs = require('fs'); const path = require('path'); const files = [ 'apps/groombook/base/api.yaml', 'apps/groombook/base/web.yaml', 'apps/groombook/base/migrate-job.yaml', 'apps/groombook/base/seed-job.yaml' ]; const commits = []; for (const file of files) { const filePath = path.join(process.env.GITHUB_WORKSPACE, 'infra', file); const content = fs.readFileSync(filePath, 'utf8'); const encoded = Buffer.from(content).toString('base64'); await github.rest.repos.createOrUpdateFileContents({ owner: 'groombook', repo: 'infra', path: file, message: `chore: update ${path.basename(file)} image tag to ${tag}`, content: encoded, branch: branchName }); commits.push(`Updated ${path.basename(file)}`); } // Create PR const pr = await github.rest.pulls.create({ owner: 'groombook', repo: 'infra', title: `chore: deploy images ${tag}`, body: [ `## Image Update`, ``, `Updating image tags to \`${tag}\` after main merge.`, ``, `Files changed:`, ...commits.map(c => `- ${c}`), ``, `**Build:** ${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`, ``, `⚠️ **Note on deploy-dev:** The \`deploy-dev\` job currently uses \`kubectl set image\` directly against the groombook-dev cluster, bypassing GitOps. This creates drift between committed infra manifests and actual cluster state.`, ``, `Trade-off to consider:`, `- **Current (kubectl):** Fast PR previews, no infra PR overhead`, `- **GitOps route:** Consistent history, validated manifests, but slower preview loops`, ``, `For production, the GitOps path via this PR is the correct approach.` ].join('\n'), head: branchName, base: 'main' }); // Enable auto-merge try { await github.rest.pulls.merge({ owner: 'groombook', repo: 'infra', pull_number: pr.data.number, merge_method: 'squash' }); console.log(`Auto-merge enabled for PR #${pr.data.number}`); } catch (e) { console.log(`Could not enable auto-merge: ${e.message}`); console.log(`PR #${pr.data.number} created — manual approval required.`); } // Output PR URL console.log(`PR_URL=${pr.data.html_url}`);