This repository has been archived on 2026-05-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
app/.github/workflows/ci.yml
T
Flea Flicker 20ae61c5e6 feat(ci): add cd job to update groombook/infra image tags on main merge
Adds a `cd` job to ci.yml that runs after the docker job on main
branch pushes. It clones groombook/infra, updates image tags in
api.yaml, web.yaml, migrate-job.yaml, and seed-job.yaml, then opens
a PR with auto-merge enabled.

Trade-off documented: deploy-dev uses kubectl set image directly,
creating drift from the GitOps source of truth. The GitOps path via
this PR is the correct approach for production.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-28 20:56:23 +00:00

406 lines
13 KiB
YAML

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 <<EOF | kubectl apply -n groombook-dev -f -
apiVersion: batch/v1
kind: Job
metadata:
name: migrate-pr-$PR_NUM
spec:
ttlSecondsAfterFinished: 3600
backoffLimit: 2
template:
spec:
restartPolicy: Never
containers:
- name: migrate
image: ghcr.io/groombook/migrate:$TAG
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: groombook-postgres-credentials-dev
key: uri
EOF
kubectl wait --for=condition=complete "job/migrate-pr-$PR_NUM" \
-n groombook-dev --timeout=120s
# Update deployments
kubectl set image deployment/api api=ghcr.io/groombook/api:$TAG -n groombook-dev
kubectl set image deployment/web web=ghcr.io/groombook/web:$TAG -n groombook-dev
# Wait for rollout
kubectl rollout status deployment/api -n groombook-dev --timeout=120s
kubectl rollout status deployment/web -n groombook-dev --timeout=120s
echo "Deployment complete."
- name: Comment on PR
uses: actions/github-script@v7
with:
script: |
const pr = context.issue.number;
const tag = `pr-${pr}`;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr,
body: [
'## Deployed to groombook-dev',
'',
`**Images:** \`${tag}\``,
'**URL:** https://dev.groombook.farh.net',
'',
'Ready for UAT validation.'
].join('\n')
});
cd:
name: Update Infra Repo with New Image Tags
runs-on: ubuntu-latest
needs: [docker]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
permissions:
contents: write
pull-requests: write
steps:
- name: Generate image tag
id: version
run: |
TAG="$(date -u +%Y.%m.%d)-${GITHUB_SHA::7}"
echo "tag=$TAG" >> "$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}`);