vibed-infra 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -68,7 +68,7 @@ See [`skills/infra-update-agent/SKILL.md`](skills/infra-update-agent/SKILL.md).
68
68
  | `templates/persist-logs/` | Shipper install + persist sidecar |
69
69
  | `templates/monitor/` | Machine-wide `monitor-vibed.sh` |
70
70
  | `lib/persistlog/` | Python append / seal / replay |
71
- | `skills/` | system-gateway, agent-vps-prep, infra-update-agent, infra-cicd, infra-packager, persist-logs, dns-configure |
71
+ | `skills/` | system-gateway, agent-vps-prep, infra-update-agent, infra-cicd, infra-e2e-cicd, infra-packager, persist-logs, dns-configure |
72
72
 
73
73
  ## Environment
74
74
 
@@ -1,4 +1,5 @@
1
1
  # Reusable GHCR docker build — one image per workflow_call.
2
+ # Keep in sync with .github/workflows/docker-build-reusable.yml (GitHub uses .github/).
2
3
  #
3
4
  # Caller (repeat per image or use a matrix in the caller workflow):
4
5
  #
@@ -37,6 +38,10 @@ on:
37
38
  required: false
38
39
  type: boolean
39
40
  default: true
41
+ build-args:
42
+ required: false
43
+ type: string
44
+ default: BUILD_PROFILE=production
40
45
  secrets:
41
46
  VIBED_WEBHOOK_SECRET:
42
47
  required: false
@@ -75,6 +80,9 @@ jobs:
75
80
  file: ${{ inputs.dockerfile }}
76
81
  push: true
77
82
  tags: ${{ steps.meta.outputs.tags }}
83
+ build-args: ${{ inputs.build-args }}
84
+ cache-from: type=gha
85
+ cache-to: type=gha,mode=max
78
86
  labels: |
79
87
  org.opencontainers.image.revision=${{ github.sha }}
80
88
  org.opencontainers.image.source=https://github.com/${{ github.repository }}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibed-infra",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Product-agnostic VPS packager: wget install, Docker Compose, TLS/nginx, auto-update",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -41,6 +41,8 @@ On the default branch, the reusable workflow mints a GitHub Actions OIDC JWT (au
41
41
  2. Build/push images to GHCR (reusable workflow notifies the VPS; requires `id-token: write`).
42
42
  3. Optional job: `test-dist.sh --profile api` on a Docker-enabled runner.
43
43
 
44
+ **Product e2e (Playwright, temp DB, human-action harnesses):** see [`infra-e2e-cicd`](../infra-e2e-cicd/SKILL.md). vibed-infra CI validates the packager; product repos add browser e2e for app critical paths.
45
+
44
46
  ## Checklist
45
47
 
46
48
  - [ ] Four YAML templates under `templates/` (`gateway.publicIp` / `tlsEmail` / `sites[]` set for production)
@@ -0,0 +1,107 @@
1
+ ---
2
+ name: infra-e2e-cicd
3
+ description: >-
4
+ E2E CI/CD for vibed-infra products: GHCR build/tag (UI/API/nodes), OIDC
5
+ auto-update, Docker pull-size optimization, Playwright critical paths, temp DB,
6
+ CI-only human-action harnesses. Use when setting up product GitHub Actions
7
+ or browser e2e tests.
8
+ ---
9
+
10
+ # E2E CI/CD for vibed-infra products
11
+
12
+ Best practices for product repos that use the infra packager. An agent should read this skill after [`infra-packager`](../infra-packager/SKILL.md) and apply the reference templates to the product repo.
13
+
14
+ ## Separation of concerns
15
+
16
+ | Layer | What it proves | Where |
17
+ |-------|----------------|-------|
18
+ | **vibed-infra CI** | Install scripts, named volumes, OIDC webhook, multi-app gateway | This repo — `npm test`, `test:dist`, `test:e2e-multi` |
19
+ | **Product CI** | App critical paths, Playwright UI flows, temp DB, human-action harnesses | Product repo — see [reference/product-ci-workflow.yml](reference/product-ci-workflow.yml) |
20
+
21
+ vibed-infra CI does **not** run Playwright. Products add browser e2e for their own critical paths.
22
+
23
+ ## Product CI job graph
24
+
25
+ ```
26
+ validate (package drift + unit tests)
27
+ → build-api / build-ui / build-nodes (GHCR push + OIDC notify)
28
+ → e2e-playwright (temp DB, VIBED_E2E_HARNESS=1)
29
+ → harness-absence check (production-profile image returns 404 on /__e2e__/*)
30
+ ```
31
+
32
+ On default-branch push, each GHCR build job notifies the VPS via OIDC (see [`infra-update-agent`](../infra-update-agent/SKILL.md)). Tags: `:main`, `main-<sha>`, semver on release tag.
33
+
34
+ ## Critical paths
35
+
36
+ 1. **Identify** user journeys that must never break: sign-in, core CRUD, checkout, wallet sign, etc.
37
+ 2. **One Playwright spec per journey** — keep specs focused; use smoke vs full suite tags (`@smoke`).
38
+ 3. **Map selectors** to stable `data-testid` attributes (see [reference/playwright-ui.md](reference/playwright-ui.md)).
39
+ 4. **Never point e2e at prod or VPS DB** — always use a temp database per CI job.
40
+
41
+ ## Temp DB
42
+
43
+ - **Postgres**: GitHub Actions `services:` block or `docker compose` service; unique database name per job (`e2e_${{ github.run_id }}`).
44
+ - **SQLite**: tmpfile path in env; delete on job end.
45
+ - Run migrations **before** Playwright starts (`npx prisma migrate`, `alembic upgrade`, etc.).
46
+ - Seed minimal fixtures in the e2e setup hook, not in production migrations.
47
+
48
+ ## VPS vs CI images
49
+
50
+ | | VPS (production) | CI (e2e) |
51
+ |---|------------------|----------|
52
+ | Tag | `:main` from GHCR | `:local` or `:main-<sha>` built in job |
53
+ | `BUILD_PROFILE` | `production` (default in reusable workflow) | `e2e` only in e2e compose |
54
+ | `VIBED_E2E_HARNESS` | unset | `1` |
55
+ | Harness routes | **404 / absent** | available for simulators |
56
+
57
+ Production images pushed to GHCR use `BUILD_PROFILE=production` by default (reusable workflow). Harness code lives under `e2e/` and is never imported by the production server entrypoint.
58
+
59
+ ## Reference docs
60
+
61
+ | Topic | File |
62
+ |-------|------|
63
+ | Full product CI workflow | [reference/product-ci-workflow.yml](reference/product-ci-workflow.yml) |
64
+ | Docker pull-size optimization | [reference/docker-optimization.md](reference/docker-optimization.md) |
65
+ | Playwright-friendly UI + config | [reference/playwright-ui.md](reference/playwright-ui.md) |
66
+ | Passkey / wallet / captcha harnesses | [reference/human-harnesses.md](reference/human-harnesses.md) |
67
+
68
+ ## GHCR build (one job per image)
69
+
70
+ Use the reusable workflow from vibed-infra (see [`infra-cicd`](../infra-cicd/SKILL.md)):
71
+
72
+ ```yaml
73
+ build-api:
74
+ uses: naiemk/vibed-infra/.github/workflows/docker-build-reusable.yml@main
75
+ permissions:
76
+ contents: read
77
+ packages: write
78
+ id-token: write
79
+ with:
80
+ dockerfile: app/api/Dockerfile
81
+ image: ghcr.io/${{ github.repository_owner }}/my-api
82
+ build-args: BUILD_PROFILE=production
83
+ ```
84
+
85
+ Repeat for UI and nodes/worker images. Image names must match `*-config.yaml` and include `ghcr.io/{owner}/…` so OIDC notify matches the update-agent registry.
86
+
87
+ ## Checklist
88
+
89
+ - [ ] Critical paths listed and covered by Playwright specs
90
+ - [ ] `./package.sh` + `git diff --exit-code dist` in CI validate job
91
+ - [ ] GHCR jobs for api, ui, nodes with `id-token: write`
92
+ - [ ] Dockerfiles follow [docker-optimization.md](reference/docker-optimization.md) (pull size first)
93
+ - [ ] UI uses `data-testid` on interactive elements
94
+ - [ ] E2e uses temp DB; migrations run before tests
95
+ - [ ] Harness code only under `e2e/`; production build excludes it
96
+ - [ ] CI asserts `/__e2e__/health` returns 404 on production-profile image
97
+ - [ ] `VIBED_E2E_HARNESS` never set in VPS `.env` or `dist/` templates
98
+
99
+ ## Agent workflow
100
+
101
+ 1. Read `infra-packager` → wire templates + commit `dist/`.
102
+ 2. Apply [product-ci-workflow.yml](reference/product-ci-workflow.yml) to `.github/workflows/ci.yml`.
103
+ 3. Optimize Dockerfiles per [docker-optimization.md](reference/docker-optimization.md).
104
+ 4. Add Playwright config + specs per [playwright-ui.md](reference/playwright-ui.md).
105
+ 5. Add harness stubs from [human-harnesses.md](reference/human-harnesses.md) under `e2e/` only.
106
+ 6. Add prod-image harness-absence check to CI.
107
+ 7. Wire GHCR matrix with `id-token: write` per `infra-update-agent`.
@@ -0,0 +1,124 @@
1
+ # Docker build optimization (pull size first, then build speed)
2
+
3
+ Every push to `main` triggers a digest-gated `docker pull` on the VPS. Optimize layers so **most pulls transfer minimal bytes**, then speed up CI builds.
4
+
5
+ ## Priority rules
6
+
7
+ | Priority | Rule |
8
+ |----------|------|
9
+ | 1 | **Multi-stage**: slim runtime (`alpine`, `distroless`); compilers and devDeps stay in builder stage |
10
+ | 2 | **Layer order**: base → OS packages → lockfiles → install deps → **source last** |
11
+ | 3 | **`.dockerignore`**: exclude `node_modules`, `.git`, `e2e/`, `tests/`, `*.md`, `.env*` |
12
+ | 4 | **Pin base image** by digest in production Dockerfiles |
13
+ | 5 | **Avoid busting dep layers**: copy `package-lock.json` / `requirements.txt` before `COPY .` |
14
+ | 6 | **CI-only** (secondary): BuildKit cache mounts + GHA cache in reusable workflow |
15
+
16
+ GHA cache (`cache-from` / `cache-to: type=gha`) speeds CI rebuilds. It does **not** change what the VPS pulls — layer ordering and multi-stage builds do.
17
+
18
+ ## `.dockerignore` (product root)
19
+
20
+ ```
21
+ .git
22
+ node_modules
23
+ e2e
24
+ tests
25
+ **/*.md
26
+ .env*
27
+ dist
28
+ .github
29
+ ```
30
+
31
+ ## API — before (pull-unfriendly)
32
+
33
+ ```dockerfile
34
+ FROM python:3.12-alpine
35
+ WORKDIR /app
36
+ COPY . .
37
+ RUN pip install -r requirements.txt
38
+ CMD ["python", "server.py"]
39
+ ```
40
+
41
+ Every source change busts the dependency layer; full tree copied early.
42
+
43
+ ## API — after (pull-friendly)
44
+
45
+ ```dockerfile
46
+ FROM python:3.12-alpine@sha256:… AS runtime
47
+ RUN apk add --no-cache wget \
48
+ && adduser -D -u 1000 app
49
+ WORKDIR /app
50
+ COPY requirements.txt .
51
+ RUN pip install --no-cache-dir -r requirements.txt
52
+ COPY server.py .
53
+ ARG BUILD_PROFILE=production
54
+ ENV BUILD_PROFILE=$BUILD_PROFILE
55
+ RUN mkdir -p /data /config && chown -R app:app /data /config /app
56
+ USER 1000
57
+ EXPOSE 8080
58
+ CMD ["python3", "server.py"]
59
+ ```
60
+
61
+ Lockfile copied first → dependency layer cached across commits. Only `server.py` (and siblings) bust on app changes.
62
+
63
+ ## UI — static site with nginx (multi-stage)
64
+
65
+ ```dockerfile
66
+ # builder — not shipped to VPS
67
+ FROM node:22-alpine@sha256:… AS builder
68
+ WORKDIR /src
69
+ COPY package.json package-lock.json ./
70
+ RUN npm ci
71
+ COPY . .
72
+ ARG BUILD_PROFILE=production
73
+ ENV VITE_BUILD_PROFILE=$BUILD_PROFILE
74
+ RUN npm run build
75
+
76
+ # runtime — minimal pull
77
+ FROM nginx:1.27-alpine@sha256:…
78
+ COPY --from=builder /src/dist /usr/share/nginx/html
79
+ COPY nginx.conf /etc/nginx/conf.d/default.conf
80
+ EXPOSE 80
81
+ ```
82
+
83
+ The VPS pulls only the nginx layer plus changed static assets — not Node, `node_modules`, or source.
84
+
85
+ ## Worker / nodes
86
+
87
+ Same rules as API. Keep worker images separate from API so a UI change does not force an API pull (and vice versa).
88
+
89
+ ## `BUILD_PROFILE` in Dockerfile
90
+
91
+ Products gate harness imports on build arg:
92
+
93
+ ```dockerfile
94
+ ARG BUILD_PROFILE=production
95
+ ENV BUILD_PROFILE=$BUILD_PROFILE
96
+ ```
97
+
98
+ - GHCR pushes (VPS): `BUILD_PROFILE=production` (reusable workflow default).
99
+ - Local e2e compose: `BUILD_PROFILE=e2e` only in `docker-compose.e2e.yml`.
100
+
101
+ Never set `BUILD_PROFILE=e2e` in VPS install `.env` or production GHCR workflow.
102
+
103
+ ## Reusable workflow (CI build speed)
104
+
105
+ The vibed-infra reusable workflow passes:
106
+
107
+ ```yaml
108
+ build-args: BUILD_PROFILE=production
109
+ cache-from: type=gha
110
+ cache-to: type=gha,mode=max
111
+ ```
112
+
113
+ Products can override `build-args` per job if needed.
114
+
115
+ ## Verify pull efficiency
116
+
117
+ On the VPS after a small code change:
118
+
119
+ ```bash
120
+ docker pull ghcr.io/owner/my-api:main
121
+ # Should report "Already exists" for most layers; only changed layers download
122
+ ```
123
+
124
+ Use `docker history ghcr.io/owner/my-api:main` to confirm large layers (deps, base) are stable across commits.
@@ -0,0 +1,183 @@
1
+ # CI human-action harnesses (passkeys, wallet, captcha)
2
+
3
+ Simulate human interactions in Playwright CI only. **Harness code must be completely unavailable on VPS production builds.**
4
+
5
+ ## Three-layer VPS exclusion contract
6
+
7
+ 1. **Source layout** — all harness code under `e2e/` or `test/harness/`; never in `app/` production paths imported by the server entrypoint.
8
+ 2. **Build gate** — `BUILD_PROFILE=production` excludes harness modules; GHCR images use production profile only (reusable workflow default).
9
+ 3. **Runtime gate** — `VIBED_E2E_HARNESS` unset on VPS; CI compose sets `VIBED_E2E_HARNESS=1`.
10
+
11
+ ## Verification (product CI)
12
+
13
+ After building a production-profile image, assert harness routes are absent:
14
+
15
+ ```bash
16
+ cid=$(docker run -d -p 18080:8080 prod-api:ci)
17
+ trap "docker rm -f $cid" EXIT
18
+ sleep 2
19
+ code=$(curl -sf -o /dev/null -w "%{http_code}" http://localhost:18080/__e2e__/health || echo "000")
20
+ test "$code" = "404" || test "$code" = "000" || (echo "harness leaked: HTTP $code" && exit 1)
21
+ ```
22
+
23
+ Never add `VIBED_E2E_HARNESS`, `E2E_CAPTCHA_SECRET`, or harness URLs to VPS `.env` templates or committed `dist/`.
24
+
25
+ ---
26
+
27
+ ## Passkeys / WebAuthn
28
+
29
+ **CI approach:** Playwright CDP virtual authenticator; optional test-only registration endpoint when harness is on.
30
+
31
+ **VPS exclusion:** Route module in `e2e/harness/webauthn_routes.py` (or equivalent); imported only when `BUILD_PROFILE=e2e` or dynamic import gated on `VIBED_E2E_HARNESS`.
32
+
33
+ ### Playwright virtual authenticator stub (`e2e/harness/webauthn.ts`)
34
+
35
+ ```typescript
36
+ import { type BrowserContext, type CDPSession } from "@playwright/test";
37
+
38
+ export async function enableVirtualAuthenticator(context: BrowserContext) {
39
+ const page = context.pages()[0] ?? await context.newPage();
40
+ const client = await context.newCDPSession(page);
41
+ await client.send("WebAuthn.enable");
42
+ const { authenticatorId } = await client.send("WebAuthn.addVirtualAuthenticator", {
43
+ options: {
44
+ protocol: "ctap2",
45
+ transport: "internal",
46
+ hasResidentKey: true,
47
+ hasUserVerification: true,
48
+ isUserVerified: true,
49
+ },
50
+ });
51
+ return { client, authenticatorId };
52
+ }
53
+ ```
54
+
55
+ ### Test-only server route stub (`e2e/harness/webauthn_routes.py`)
56
+
57
+ ```python
58
+ # Import ONLY from e2e entrypoint when VIBED_E2E_HARNESS=1 — never from prod server.py
59
+ import os
60
+ from flask import Blueprint, jsonify
61
+
62
+ bp = Blueprint("e2e_webauthn", __name__, url_prefix="/__e2e__/webauthn")
63
+
64
+ @bp.route("/register", methods=["POST"])
65
+ def register_credential():
66
+ if os.environ.get("VIBED_E2E_HARNESS") != "1":
67
+ return jsonify(error="not found"), 404
68
+ # Return a fixed test credential payload for the app's WebAuthn verify step
69
+ return jsonify({"credentialId": "e2e-test-credential", "publicKey": "…"})
70
+ ```
71
+
72
+ Wire `bp` in an `e2e/server_extensions.py` that production `server.py` never imports.
73
+
74
+ ---
75
+
76
+ ## MetaMask / wallet signing
77
+
78
+ **CI approach:** Inject `window.ethereum` mock via Playwright `addInitScript`; optionally use [Synpress](https://github.com/Synthetixio/synpress) for extension-based flows in CI only.
79
+
80
+ **VPS exclusion:** Mock script lives in `e2e/harness/`; never bundled in production UI build.
81
+
82
+ ### Wallet mock stub (`e2e/harness/ethereum-mock.ts`)
83
+
84
+ ```typescript
85
+ import { type Page } from "@playwright/test";
86
+
87
+ const TEST_ADDRESS = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8";
88
+
89
+ export async function injectEthereumMock(page: Page) {
90
+ await page.addInitScript(({ address }) => {
91
+ (window as unknown as { ethereum?: unknown }).ethereum = {
92
+ isMetaMask: true,
93
+ selectedAddress: address,
94
+ request: async ({ method }: { method: string }) => {
95
+ if (method === "eth_requestAccounts") return [address];
96
+ if (method === "eth_accounts") return [address];
97
+ if (method === "personal_sign") return "0x" + "ab".repeat(32);
98
+ if (method === "eth_chainId") return "0x1";
99
+ throw new Error(`unsupported: ${method}`);
100
+ },
101
+ };
102
+ }, { address: TEST_ADDRESS });
103
+ }
104
+ ```
105
+
106
+ ### Example spec usage
107
+
108
+ ```typescript
109
+ test("wallet connect and sign @smoke", async ({ page }) => {
110
+ await injectEthereumMock(page);
111
+ await page.goto("/connect");
112
+ await page.getByTestId("connect-wallet").click();
113
+ await expect(page.getByTestId("wallet-address")).toContainText("0x7099");
114
+ });
115
+ ```
116
+
117
+ Do not ship MetaMask extension or mock provider in production Docker images.
118
+
119
+ ---
120
+
121
+ ## Captcha
122
+
123
+ **CI approach:** Server accepts `X-E2E-Captcha-Bypass: $E2E_CAPTCHA_SECRET` when harness env is set; Playwright sets header globally or per request.
124
+
125
+ **VPS exclusion:** Bypass handler in separate module; eliminated from production build via `BUILD_PROFILE=production`.
126
+
127
+ ### Server bypass stub (`e2e/harness/captcha_bypass.py`)
128
+
129
+ ```python
130
+ import os
131
+
132
+ def captcha_verified(request) -> bool:
133
+ if os.environ.get("VIBED_E2E_HARNESS") != "1":
134
+ return False # production path uses real captcha provider only
135
+ secret = os.environ.get("E2E_CAPTCHA_SECRET", "")
136
+ return secret and request.headers.get("X-E2E-Captcha-Bypass") == secret
137
+ ```
138
+
139
+ Use in your captcha middleware:
140
+
141
+ ```python
142
+ if captcha_verified(request):
143
+ return # skip provider verify in CI only
144
+ # … real hCaptcha / Turnstile verify …
145
+ ```
146
+
147
+ ### Playwright global header (`playwright.config.ts`)
148
+
149
+ ```typescript
150
+ use: {
151
+ extraHTTPHeaders: process.env.VIBED_E2E_HARNESS === "1"
152
+ ? { "X-E2E-Captcha-Bypass": process.env.E2E_CAPTCHA_SECRET ?? "" }
153
+ : {},
154
+ },
155
+ ```
156
+
157
+ Set `E2E_CAPTCHA_SECRET` only in CI secrets or e2e compose — never on VPS.
158
+
159
+ ---
160
+
161
+ ## Harness health endpoint (CI only)
162
+
163
+ Optional liveness for e2e compose debugging:
164
+
165
+ ```python
166
+ @bp.route("/health")
167
+ def harness_health():
168
+ if os.environ.get("VIBED_E2E_HARNESS") != "1":
169
+ return "", 404
170
+ return jsonify(ok=True)
171
+ ```
172
+
173
+ Production profile must return **404** for `/__e2e__/health` — verified in the `harness-absence` CI job.
174
+
175
+ ## Summary
176
+
177
+ | Harness | CI simulator | VPS must not have |
178
+ |---------|--------------|-------------------|
179
+ | Passkeys | CDP virtual authenticator + optional `/__e2e__/webauthn/*` | Virtual auth routes, test credentials |
180
+ | Wallet | `window.ethereum` mock in Playwright | Extension, mock provider in bundle |
181
+ | Captcha | `X-E2E-Captcha-Bypass` header + env secret | Bypass env vars, bypass middleware in prod build |
182
+
183
+ Products wire app-specific flows on top of these stubs; vibed-infra documents the pattern, not a shared npm package.
@@ -0,0 +1,122 @@
1
+ # Playwright-friendly UI and CI setup
2
+
3
+ Design the product UI so Playwright tests are stable, fast, and maintainable.
4
+
5
+ ## UI design rules
6
+
7
+ 1. **`data-testid` on interactive elements** — prefer over CSS classes, auto-generated IDs, or visible text that changes with i18n.
8
+
9
+ ```html
10
+ <form id="form" data-testid="note-form">
11
+ <input data-testid="note-input" … />
12
+ <button type="submit" data-testid="note-submit">Post</button>
13
+ </form>
14
+ <ul id="list" data-testid="note-list"></ul>
15
+ <p id="status" data-testid="status"></p>
16
+ ```
17
+
18
+ 2. **Stable routes** — use predictable URLs for auth and core flows (`/login`, `/dashboard`). Avoid hash-only navigation for critical paths.
19
+
20
+ 3. **Reduce flakiness**
21
+ - Set `aria-busy="true"` while loading; tests wait for `[aria-busy="false"]`.
22
+ - Disable animations in e2e: `prefers-reduced-motion: reduce` via test env CSS, or `VIBED_E2E_HARNESS=1` body class.
23
+ - Avoid `setTimeout`-driven UI; use explicit ready states.
24
+
25
+ 4. **Auth state** — expose login via API or cookie the test can set; do not rely only on `localStorage` without a test hook.
26
+
27
+ 5. **Error surfaces** — use `data-testid="error-message"` so failures are assertable without parsing console logs.
28
+
29
+ ## Playwright config stub (`playwright.config.ts`)
30
+
31
+ ```typescript
32
+ import { defineConfig, devices } from "@playwright/test";
33
+
34
+ export default defineConfig({
35
+ testDir: "e2e/specs",
36
+ fullyParallel: true,
37
+ forbidOnly: !!process.env.CI,
38
+ retries: process.env.CI ? 2 : 0,
39
+ workers: process.env.CI ? 1 : undefined,
40
+ reporter: process.env.CI ? [["github"], ["html"]] : "list",
41
+ use: {
42
+ baseURL: process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:3000",
43
+ trace: "on-first-retry",
44
+ screenshot: "only-on-failure",
45
+ },
46
+ projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
47
+ });
48
+ ```
49
+
50
+ Tag smoke tests: `test("user can post a note @smoke", …)`.
51
+
52
+ ## Example spec stub
53
+
54
+ ```typescript
55
+ import { test, expect } from "@playwright/test";
56
+
57
+ test("user can post a note @smoke", async ({ page }) => {
58
+ await page.goto("/");
59
+ await expect(page.getByTestId("status")).toContainText("Live");
60
+ await page.getByTestId("note-input").fill("hello e2e");
61
+ await page.getByTestId("note-submit").click();
62
+ await expect(page.getByTestId("note-list")).toContainText("hello e2e");
63
+ });
64
+ ```
65
+
66
+ ## E2e stack (`docker-compose.e2e.yml` stub)
67
+
68
+ ```yaml
69
+ services:
70
+ api:
71
+ image: my-api:local
72
+ build:
73
+ context: app/api
74
+ args:
75
+ BUILD_PROFILE: e2e
76
+ environment:
77
+ VIBED_E2E_HARNESS: "1"
78
+ E2E_CAPTCHA_SECRET: ci-test-secret
79
+ DATABASE_URL: postgresql://e2e:e2e@postgres:5432/e2e
80
+ ports:
81
+ - "8080:8080"
82
+ depends_on:
83
+ postgres:
84
+ condition: service_healthy
85
+
86
+ ui:
87
+ image: my-ui:local
88
+ ports:
89
+ - "3000:80"
90
+ depends_on:
91
+ - api
92
+
93
+ postgres:
94
+ image: postgres:16-alpine
95
+ environment:
96
+ POSTGRES_USER: e2e
97
+ POSTGRES_PASSWORD: e2e
98
+ POSTGRES_DB: e2e
99
+ healthcheck:
100
+ test: ["CMD", "pg_isready", "-U", "e2e"]
101
+ interval: 5s
102
+ retries: 5
103
+ ```
104
+
105
+ Set `PLAYWRIGHT_BASE_URL=http://localhost:3000` in the Playwright job.
106
+
107
+ ## CI job essentials
108
+
109
+ ```yaml
110
+ - run: npx playwright install --with-deps chromium
111
+ - run: npx playwright test --grep @smoke
112
+ env:
113
+ PLAYWRIGHT_BASE_URL: http://localhost:3000
114
+ ```
115
+
116
+ Upload `playwright-report/` as a CI artifact on failure.
117
+
118
+ ## What not to do
119
+
120
+ - Do not use production GHCR `:main` images for e2e with harness enabled — build `:local` or use `BUILD_PROFILE=e2e`.
121
+ - Do not add test-only routes to production UI bundles without build-profile gating.
122
+ - Do not run Playwright against the VPS or production database.
@@ -0,0 +1,131 @@
1
+ # Product CI workflow (copy to `.github/workflows/ci.yml`)
2
+
3
+ Example for a vibed-infra-packaged product with API, UI, and worker images.
4
+ Adjust paths, image names, and e2e commands to match the product repo.
5
+
6
+ ```yaml
7
+ name: CI
8
+
9
+ on:
10
+ push:
11
+ branches: [main]
12
+ pull_request:
13
+ branches: [main]
14
+
15
+ jobs:
16
+ validate:
17
+ runs-on: ubuntu-latest
18
+ steps:
19
+ - uses: actions/checkout@v4
20
+ - uses: actions/setup-node@v4
21
+ with:
22
+ node-version: "22"
23
+ - run: npm ci
24
+ - run: npm test
25
+ - name: Package drift check
26
+ run: |
27
+ ./package.sh
28
+ git diff --exit-code dist/
29
+
30
+ build-api:
31
+ needs: validate
32
+ uses: naiemk/vibed-infra/.github/workflows/docker-build-reusable.yml@main
33
+ permissions:
34
+ contents: read
35
+ packages: write
36
+ id-token: write
37
+ with:
38
+ dockerfile: app/api/Dockerfile
39
+ image: ghcr.io/${{ github.repository_owner }}/my-api
40
+ build-args: BUILD_PROFILE=production
41
+ secrets: inherit
42
+
43
+ build-ui:
44
+ needs: validate
45
+ uses: naiemk/vibed-infra/.github/workflows/docker-build-reusable.yml@main
46
+ permissions:
47
+ contents: read
48
+ packages: write
49
+ id-token: write
50
+ with:
51
+ dockerfile: app/ui/Dockerfile
52
+ image: ghcr.io/${{ github.repository_owner }}/my-ui
53
+ build-args: BUILD_PROFILE=production
54
+ secrets: inherit
55
+
56
+ build-nodes:
57
+ needs: validate
58
+ uses: naiemk/vibed-infra/.github/workflows/docker-build-reusable.yml@main
59
+ permissions:
60
+ contents: read
61
+ packages: write
62
+ id-token: write
63
+ with:
64
+ dockerfile: app/worker/Dockerfile
65
+ image: ghcr.io/${{ github.repository_owner }}/my-worker
66
+ build-args: BUILD_PROFILE=production
67
+ secrets: inherit
68
+
69
+ e2e-playwright:
70
+ needs: [build-api, build-ui, build-nodes]
71
+ runs-on: ubuntu-latest
72
+ services:
73
+ postgres:
74
+ image: postgres:16-alpine
75
+ env:
76
+ POSTGRES_USER: e2e
77
+ POSTGRES_PASSWORD: e2e
78
+ POSTGRES_DB: e2e
79
+ ports:
80
+ - 5432:5432
81
+ options: >-
82
+ --health-cmd "pg_isready -U e2e"
83
+ --health-interval 5s
84
+ --health-timeout 5s
85
+ --health-retries 5
86
+ env:
87
+ DATABASE_URL: postgresql://e2e:e2e@localhost:5432/e2e
88
+ VIBED_E2E_HARNESS: "1"
89
+ E2E_CAPTCHA_SECRET: ci-test-secret
90
+ steps:
91
+ - uses: actions/checkout@v4
92
+ - uses: actions/setup-node@v4
93
+ with:
94
+ node-version: "22"
95
+ - run: npm ci
96
+ - name: Build local e2e images
97
+ run: bash scripts/build-images.sh
98
+ - name: Start e2e stack
99
+ run: docker compose -f docker-compose.e2e.yml up -d --wait
100
+ - name: Run DB migrations
101
+ run: npm run db:migrate
102
+ - name: Install Playwright browsers
103
+ run: npx playwright install --with-deps chromium
104
+ - name: Playwright e2e (critical paths)
105
+ run: npx playwright test --grep @smoke
106
+ - name: Teardown
107
+ if: always()
108
+ run: docker compose -f docker-compose.e2e.yml down -v
109
+
110
+ harness-absence:
111
+ needs: validate
112
+ runs-on: ubuntu-latest
113
+ steps:
114
+ - uses: actions/checkout@v4
115
+ - name: Build production-profile API image
116
+ run: docker build --build-arg BUILD_PROFILE=production -t prod-api:ci app/api
117
+ - name: Assert harness routes absent
118
+ run: |
119
+ cid=$(docker run -d -p 18080:8080 prod-api:ci)
120
+ trap "docker rm -f $cid" EXIT
121
+ sleep 2
122
+ code=$(curl -sf -o /dev/null -w "%{http_code}" http://localhost:18080/__e2e__/health || echo "000")
123
+ test "$code" = "404" || test "$code" = "000" || (echo "harness leaked: HTTP $code" && exit 1)
124
+ ```
125
+
126
+ ## Notes
127
+
128
+ - **PR builds**: GHCR push still runs; OIDC notify only fires on default branch (handled inside reusable workflow).
129
+ - **Disable notify on PR-only builds**: pass `notify: false` to the reusable workflow when `github.event_name == 'pull_request'`.
130
+ - **Matrix alternative**: if GitHub limits reusable workflow calls, inline the build-push steps from [`docker-build-reusable.yml`](../../../.github/workflows/docker-build-reusable.yml) into a matrix job.
131
+ - **Infra dist e2e** (optional): add `bash test-dist.sh --profile api` on a Docker runner to prove install scripts — separate from Playwright.
@@ -36,7 +36,7 @@ exec bash "$PACKAGER/package.sh" --product "$ROOT" --out "$ROOT/dist"
36
36
  git add dist && git commit && git push
37
37
  ```
38
38
 
39
- 6. **CI** — product repo: package drift check + GHCR build (notifies VPS). Packager repo: `npm test`, `oidc-webhook-e2e`, `test-dist.sh`, `npm run test:e2e-multi`.
39
+ 6. **CI** — product repo: package drift check + GHCR build (notifies VPS) + Playwright e2e for critical paths ([`infra-e2e-cicd`](../infra-e2e-cicd/SKILL.md)). Packager repo: `npm test`, `oidc-webhook-e2e`, `test-dist.sh`, `npm run test:e2e-multi`.
40
40
 
41
41
  7. **VPS** — DNS first (paste `dist/DNS-SKILL.md` into AU agent), then wget. Install registers the app with the update-agent so the next GHCR push can pull immediately:
42
42
 
@@ -63,6 +63,8 @@ jobs:
63
63
 
64
64
  URLs come from `gateway.publicIp` + `gateway.sites[].host` already in config (`dist/packageconfig.yaml` `webhook.url` / `fallbackUrl`). Disable notify with `notify: false`.
65
65
 
66
+ For Playwright e2e, temp DB, and CI-only harnesses on the product side, see [`infra-e2e-cicd`](../infra-e2e-cicd/SKILL.md).
67
+
66
68
  Local curl can send `X-Vibed-Secret` from `tokens/{app}` (compiled hash). Optional `WEBHOOK_SECRET` still works as an override.
67
69
 
68
70
  ## Tests