docs: fill the empty competitor row in the landscape map #82
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: cloud | |
| # CI for the Ghost Cloud SaaS (cloud/). Scoped to cloud/** so it never runs on | |
| # Rust-only changes and never collides with rust.yml. | |
| # | |
| # Postgres and Redis are real services rather than placeholders. Roughly 90 of | |
| # the suite's tests are gated on `Boolean(process.env.DATABASE_URL)`, so a | |
| # DATABASE_URL pointing at nothing is the worst of both worlds: the guard reads | |
| # "a database is available", the tests run, and they fail on connection refused. | |
| # Without the variable at all they silently skip, which is how a fully green | |
| # check can cover none of the engine. | |
| # | |
| # Path scoping lives in the `changes` job, not in `on:` — see the long comment | |
| # in rust.yml. Short version: a workflow skipped by a path filter never creates | |
| # its check runs, so requiring `build` in branch protection would leave every | |
| # Rust-only PR blocked on a check that can never report. A job skipped by an | |
| # `if:` condition does create its check run, concludes "skipped", and satisfies | |
| # branch protection. | |
| on: | |
| push: | |
| branches: [master, develop] | |
| pull_request: | |
| concurrency: | |
| group: cloud-ci-${{ github.ref }} | |
| cancel-in-progress: true | |
| permissions: | |
| contents: read | |
| pull-requests: read | |
| defaults: | |
| run: | |
| working-directory: cloud | |
| jobs: | |
| changes: | |
| name: Detect changed paths | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| # This job never checks out, so the workflow-level `working-directory: | |
| # cloud` would point bash at a directory that does not exist yet. | |
| defaults: | |
| run: | |
| working-directory: . | |
| outputs: | |
| cloud: ${{ steps.filter.outputs.cloud }} | |
| steps: | |
| - id: filter | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| set -euo pipefail | |
| if [ "${{ github.event_name }}" != "pull_request" ]; then | |
| echo "cloud=true" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| files=$(gh api \ | |
| "repos/${{ github.repository }}/pulls/${{ github.event.number }}/files" \ | |
| --paginate --jq '.[].filename') | |
| if printf '%s\n' "$files" \ | |
| | grep -qE '^cloud/|^\.github/workflows/cloud\.yml$'; then | |
| echo "cloud=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "cloud=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| build: | |
| needs: changes | |
| # Fail closed. If detection itself breaks, `needs.changes.outputs.cloud` is | |
| # empty and a bare equality test would skip this job — which branch | |
| # protection then accepts as satisfied, merging cloud changes with no CI at | |
| # all. Run on failure instead. `!cancelled()` is required for the job to be | |
| # considered at all once a dependency has failed. | |
| if: ${{ !cancelled() && (needs.changes.result == 'failure' || needs.changes.outputs.cloud == 'true') }} | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| services: | |
| postgres: | |
| image: postgres:16-alpine | |
| env: | |
| POSTGRES_USER: ghost | |
| POSTGRES_PASSWORD: ghost | |
| POSTGRES_DB: ghost | |
| ports: | |
| - 5432:5432 | |
| options: >- | |
| --health-cmd "pg_isready -U ghost" | |
| --health-interval 10s | |
| --health-timeout 5s | |
| --health-retries 10 | |
| redis: | |
| image: redis:7-alpine | |
| ports: | |
| - 6379:6379 | |
| options: >- | |
| --health-cmd "redis-cli ping" | |
| --health-interval 10s | |
| --health-timeout 5s | |
| --health-retries 10 | |
| env: | |
| # The user must be `ghost`, not `postgres`: the wrong one fails with | |
| # "User was denied access on the database", which reads like a missing | |
| # migration and sends you looking in the wrong place. | |
| DATABASE_URL: postgresql://ghost:ghost@localhost:5432/ghost?schema=public | |
| REDIS_URL: redis://localhost:6379 | |
| AUTH_SECRET: ci-placeholder-secret-value-0000000000000000 | |
| APP_URL: http://localhost:3000 | |
| GHOST_ARTIFACT_DIR: ${{ github.workspace }}/cloud/.artifacts | |
| # Required, not optional. Without it the worker skips session capture at | |
| # an approval gate, so `sessionUrl` stays null and every gated run refuses | |
| # to resume — `planRestore` returns `unsafe` and the run ends INCIDENT. | |
| # That is correct fail-safe behaviour in production and a broken suite | |
| # here: 16 tests in compensateRun/runWorkflow/slots assert a run reaches | |
| # SUCCEEDED or COMPENSATED, and they fail on the status rather than on | |
| # anything naming the key. A CI-only throwaway value; never a real secret. | |
| GHOST_SESSION_KEY: Z2hvc3QtY2ktdGhyb3dhd2F5LXNlc3Npb24ta2V5LTA= | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: pnpm/action-setup@v4 | |
| with: | |
| version: 10.33.0 | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version: 22 | |
| cache: pnpm | |
| cache-dependency-path: cloud/pnpm-lock.yaml | |
| - name: Install dependencies | |
| run: pnpm install --frozen-lockfile | |
| - name: Install Playwright Chromium | |
| # The driver and engine tests drive real Chromium. Runners have none | |
| # preinstalled, so install it here. PLAYWRIGHT_BROWSERS_PATH is left | |
| # unset, which makes `discoverChromium` no-op and lets Playwright use | |
| # its own cache — the revision then matches by construction. | |
| run: pnpm --filter @ghost/worker exec playwright install --with-deps chromium | |
| - name: Prisma validate | |
| run: pnpm db:validate | |
| - name: Apply migrations | |
| # `migrate deploy`, not `migrate dev` — the latter is interactive and | |
| # will offer to reset the database. | |
| run: pnpm db:migrate:deploy | |
| - name: Typecheck | |
| run: pnpm typecheck | |
| - name: Tests | |
| run: pnpm test | |
| - name: Build | |
| run: pnpm build | |
| - name: Build worker container image | |
| # `pnpm build` above only bundles the worker with tsup — it never | |
| # exercises the Dockerfile's own multi-stage COPY list, and it never | |
| # runs the resulting dist/index.js in an isolated, workspace-filtered | |
| # node_modules the way the image's `pnpm install --filter | |
| # @ghost/worker...` does. Both gaps let a genuinely broken image ship | |
| # silently before: a missing `packages/core/prisma` COPY failed | |
| # `pnpm install` inside the image, and bundling @prisma/client's CJS | |
| # runtime into the ESM output crashed the container on boot with | |
| # "Dynamic require of 'fs' is not supported". Build it here so a | |
| # regression in either fails CI instead of surfacing at deploy time. | |
| run: docker build -f apps/worker/Dockerfile -t ghost-worker-ci . | |
| - name: Smoke-test worker container boots | |
| # A build succeeding is not the same as the entrypoint running. Boot | |
| # it against the same Postgres/Redis service containers this job | |
| # already has and require the worker's own startup log line — not just | |
| # "container still alive", which a crash-loop with a restart policy | |
| # would also satisfy. | |
| run: | | |
| set -euo pipefail | |
| container_id=$(docker run -d --network host \ | |
| -e DATABASE_URL="$DATABASE_URL" \ | |
| -e REDIS_URL="$REDIS_URL" \ | |
| -e GHOST_SESSION_KEY="$GHOST_SESSION_KEY" \ | |
| ghost-worker-ci) | |
| trap 'docker logs "$container_id" || true; docker rm -f "$container_id" >/dev/null 2>&1 || true' EXIT | |
| ok="" | |
| for _ in $(seq 1 15); do | |
| if docker logs "$container_id" 2>&1 | grep -q "Ghost worker started"; then | |
| ok=1 | |
| break | |
| fi | |
| if [ "$(docker inspect -f '{{.State.Running}}' "$container_id")" != "true" ]; then | |
| break | |
| fi | |
| sleep 1 | |
| done | |
| if [ -z "$ok" ]; then | |
| echo "worker container did not report startup within 15s" >&2 | |
| exit 1 | |
| fi |