Skip to content

docs: fill the empty competitor row in the landscape map #82

docs: fill the empty competitor row in the landscape map

docs: fill the empty competitor row in the landscape map #82

Workflow file for this run

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