From fb1422f56f57fcc11048751a5213324d17791895 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Sat, 4 Jul 2026 19:57:16 +0200 Subject: [PATCH] Add CD workflow: build-push, deploy Test, smoke suite, promote Int On every push to main: build both images once (SHA + moving `test` tag), push to the Gitea registry, SSH-deploy the Test stage, wait for readiness, run the new Playwright smoke suite (SPA shell, web liveness, api healthz/readyz) against https://test.dorfteich.cloud, and on green retag the identical SHA images as `int` and deploy Int. The CI image-build job becomes PR-only to avoid double builds on main. Part of #8 Co-Authored-By: Claude Fable 5 --- .gitea/workflows/cd.yml | 124 ++++++++++++++++++++++++++++++++++ .gitea/workflows/ci.yml | 3 + apps/web/e2e/smoke.spec.ts | 25 +++++++ apps/web/package.json | 4 +- apps/web/playwright.config.ts | 13 ++++ pnpm-lock.yaml | 38 +++++++++++ 6 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 .gitea/workflows/cd.yml create mode 100644 apps/web/e2e/smoke.spec.ts create mode 100644 apps/web/playwright.config.ts diff --git a/.gitea/workflows/cd.yml b/.gitea/workflows/cd.yml new file mode 100644 index 0000000..41af8c9 --- /dev/null +++ b/.gitea/workflows/cd.yml @@ -0,0 +1,124 @@ +# CD: every push to main builds images once, deploys them to Test, runs +# the smoke suite against the live Test stage, and promotes the identical +# images to Int on success (ADR 0014). Prod deploys are a separate, +# manually gated release workflow (issue #89). + +name: CD + +on: + push: + branches: [main] + +env: + IMAGE_BASE: gitea.101010.cloud/stwaidele/dorfteich + DEPLOY_HOST: 188.245.116.44 + +jobs: + build-push: + name: Build and push images + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Log in to the Gitea registry + run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.101010.cloud -u fable-5 --password-stdin + + # Tags: the immutable SHA (promotion + rollback target) and the + # moving `test` tag that the Test stage's compose file pulls. + - name: Build and push web image + run: | + docker build -f apps/web/Dockerfile --build-arg APP_VERSION=${{ github.sha }} \ + -t $IMAGE_BASE-web:${{ github.sha }} -t $IMAGE_BASE-web:test . + docker push $IMAGE_BASE-web:${{ github.sha }} + docker push $IMAGE_BASE-web:test + + - name: Build and push api image + run: | + docker build -f apps/api/Dockerfile --build-arg APP_VERSION=${{ github.sha }} \ + -t $IMAGE_BASE-api:${{ github.sha }} -t $IMAGE_BASE-api:test . + docker push $IMAGE_BASE-api:${{ github.sha }} + docker push $IMAGE_BASE-api:test + + deploy-test: + name: Deploy to Test + needs: build-push + runs-on: ubuntu-latest + steps: + - name: Set up SSH + run: | + mkdir -p ~/.ssh && chmod 700 ~/.ssh + printf '%s\n' "${{ secrets.DEPLOY_SSH_KEY_TEST }}" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + printf '%s\n' "${{ secrets.DEPLOY_HOST_KEY }}" > ~/.ssh/known_hosts + + - name: Pull and restart the Test stack + run: | + ssh deploy@$DEPLOY_HOST 'cd /home/DOCKER/dorfteich-test \ + && docker compose pull --quiet && docker compose up -d --remove-orphans \ + && docker compose ps' + + smoke-test: + name: Smoke tests against Test + needs: deploy-test + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + version: 11 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Install Playwright browser + run: pnpm --filter @dorfteich/web exec playwright install --with-deps chromium + + - name: Wait for the Test stage to be ready + run: | + for i in $(seq 1 30); do + code=$(curl -s -o /dev/null -w '%{http_code}' https://test.dorfteich.cloud/api/v1/readyz || true) + [ "$code" = "200" ] && exit 0 + sleep 5 + done + echo "Test stage did not become ready" >&2; exit 1 + + - name: Run smoke suite + run: E2E_BASE_URL=https://test.dorfteich.cloud pnpm --filter @dorfteich/web exec playwright test + + promote-int: + name: Promote to Int + needs: smoke-test + runs-on: ubuntu-latest + steps: + - name: Log in to the Gitea registry + run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.101010.cloud -u fable-5 --password-stdin + + # Registry-side retag of the SHA images that just passed on Test — + # Int always runs bit-identical images, never a rebuild. + - name: Retag SHA images as :int + run: | + docker buildx imagetools create -t $IMAGE_BASE-web:int $IMAGE_BASE-web:${{ github.sha }} + docker buildx imagetools create -t $IMAGE_BASE-api:int $IMAGE_BASE-api:${{ github.sha }} + + - name: Set up SSH + run: | + mkdir -p ~/.ssh && chmod 700 ~/.ssh + printf '%s\n' "${{ secrets.DEPLOY_SSH_KEY_INT }}" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + printf '%s\n' "${{ secrets.DEPLOY_HOST_KEY }}" > ~/.ssh/known_hosts + + - name: Pull and restart the Int stack + run: | + ssh deploy@$DEPLOY_HOST 'cd /home/DOCKER/dorfteich-int \ + && docker compose pull --quiet && docker compose up -d --remove-orphans \ + && docker compose ps' diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index d513071..7082dcd 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -53,6 +53,9 @@ jobs: images: name: Build container images + # PR-only: on main the CD workflow builds and pushes the same images — + # building twice would waste the runner (ADR 0014: build once, promote). + if: github.event_name == 'pull_request' runs-on: ubuntu-latest steps: - name: Check out repository diff --git a/apps/web/e2e/smoke.spec.ts b/apps/web/e2e/smoke.spec.ts new file mode 100644 index 0000000..12571a7 --- /dev/null +++ b/apps/web/e2e/smoke.spec.ts @@ -0,0 +1,25 @@ +import { expect, test } from '@playwright/test'; + +// Walking-skeleton smoke suite: proves the deployed stage serves the SPA, +// the reverse proxy routes /api, and the api is ready (db + migrations). +// Feature e2e packs (issues #20, #32, …) build on this file's patterns. + +test('SPA shell loads', async ({ page }) => { + await page.goto('/'); + await expect(page).toHaveTitle(/Dorfteich/); + await expect(page.getByRole('heading', { level: 1 })).toBeVisible(); +}); + +test('web liveness endpoint responds', async ({ request }) => { + const res = await request.get('/healthz'); + expect(res.ok()).toBeTruthy(); +}); + +test('api is live and ready', async ({ request }) => { + const healthz = await request.get('/api/v1/healthz'); + expect(healthz.ok()).toBeTruthy(); + expect((await healthz.json()).status).toBe('ok'); + + const readyz = await request.get('/api/v1/readyz'); + expect(readyz.ok(), `readyz: ${await readyz.text()}`).toBeTruthy(); +}); diff --git a/apps/web/package.json b/apps/web/package.json index e84d7aa..4fafb82 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,7 +10,8 @@ "build": "vite build", "preview": "vite preview", "typecheck": "tsc --noEmit", - "test": "vitest run --passWithNoTests" + "test": "vitest run --passWithNoTests", + "e2e": "playwright test" }, "dependencies": { "@dorfteich/shared": "workspace:*", @@ -23,6 +24,7 @@ "react-router-dom": "^7.1.0" }, "devDependencies": { + "@playwright/test": "^1.61.1", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.0", diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts new file mode 100644 index 0000000..c23b9f1 --- /dev/null +++ b/apps/web/playwright.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from '@playwright/test'; + +// Smoke/e2e tests run against a deployed stage (CD pipeline) or a local +// stack. The target comes from E2E_BASE_URL; no dev server is started here. +export default defineConfig({ + testDir: './e2e', + timeout: 30_000, + retries: 1, + reporter: [['list']], + use: { + baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:5173', + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ba30b4f..1f6953e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -130,6 +130,9 @@ importers: specifier: ^7.1.0 version: 7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) devDependencies: + '@playwright/test': + specifier: ^1.61.1 + version: 1.61.1 '@types/react': specifier: ^19.0.0 version: 19.2.17 @@ -1063,6 +1066,11 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@playwright/test@1.61.1': + resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==} + engines: {node: '>=18'} + hasBin: true + '@prisma/client@6.19.3': resolution: {integrity: sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==} engines: {node: '>=18.18'} @@ -2211,6 +2219,11 @@ packages: fs-monkey@1.1.0: resolution: {integrity: sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2719,6 +2732,16 @@ packages: pkg-types@2.3.1: resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.1: + resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} + engines: {node: '>=18'} + hasBin: true + pluralize@8.0.0: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} @@ -4215,6 +4238,10 @@ snapshots: '@pinojs/redact@0.4.0': {} + '@playwright/test@1.61.1': + dependencies: + playwright: 1.61.1 + '@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3)': optionalDependencies: prisma: 6.19.3(typescript@5.9.3) @@ -5453,6 +5480,9 @@ snapshots: fs-monkey@1.1.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -5917,6 +5947,14 @@ snapshots: exsolve: 1.1.0 pathe: 2.0.3 + playwright-core@1.61.1: {} + + playwright@1.61.1: + dependencies: + playwright-core: 1.61.1 + optionalDependencies: + fsevents: 2.3.2 + pluralize@8.0.0: {} postcss-load-config@6.0.1(jiti@2.7.0)(postcss@8.5.16)(tsx@4.23.0):