Files
pig/.gitea/workflows/ci.yml
T
karti 516685526c Add Motion: the go-to-market operating system on top of the ledger
The ledger answers which contracted capacity is sold, to whom, at what
margin. It says nothing about the motion — the repeatable practice that
turns a customer conversation into a scoped deployment, and turns that
deployment into something the next one reuses.

Motion is deliberately not a parallel entity tree. DEMAND_STAGES already
is the motion, so Motion binds reusable artefacts to the stages of a
demand deal that already exists: an engagement hangs off one deal,
cascade deleted, one per deal by unique constraint.

Nine closed kinds, each declaring which stages it serves, and a starter
library of twelve templates covering all eight open stages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 18:27:03 -07:00

493 lines
23 KiB
YAML

# Continuous integration.
#
# Runs on every push and pull request. The job is deliberately one sequence
# rather than a fan-out: this is a small project, the whole thing takes a
# couple of minutes, and a single log is easier to read than five.
#
# What it actually proves, in order of how likely each is to catch something:
#
# 1. Every package typechecks.
# 2. The migration chain applies to a REAL, empty Postgres. This has already
# caught one migration that Drizzle generated but Postgres refused
# (a jsonb -> integer cast with no USING clause).
# 3. The seed is idempotent — running it twice leaves the same row counts.
# This caught a seed that silently duplicated 27 contacts.
# 4. The unit tests pass.
# 5. The server boots against that database and answers.
# 6. Piggy boots against that same database, answers /internal/health, and
# the API — wired to it through the environment, not through a stub —
# reports it enabled. The relay's own tests inject a resolver, so they
# stay green whether or not the real wiring exists; only this step reads
# it. A crash on boot and an unset PIGGY_INTERNAL_URL look identical from
# the browser: the dock simply never appears.
# 7. The front end builds, and the CSP hash for the inline theme script still
# matches what the proxy is configured to allow. Editing that script
# changes its hash, and the failure mode is a silent white flash for
# dark-mode users rather than an error.
# 8. docker-compose.yml renders, and the piggy service is passed every
# environment key the worker's schema requires. That is the one failure
# nothing else here can see, because it lives between two files that are
# each individually correct.
#
# THE CSP HASH IS DUPLICATED IN THREE PLACES: the `expected` constant below,
# `deploy/Caddyfile.example`, and the LIVE Caddyfile on cloud-2. Only the first
# two are checked by anything. The live one is the copy that actually decides
# whether a browser runs the script, and nothing in this repository can see it,
# so changing the script means editing all three by hand — see deploy/README.md.
#
# Shipping is a two-step, and the second step is a human:
#
# push to main -> `verify` only. Nothing is published, nothing deploys.
# tag release-* -> `verify`, then `publish` pushes the image to the Gitea
# registry. The production host notices it and deploys.
#
# So the tag IS the ship decision. No credential on this runner can reach
# cloud-2; the host pulls, the runner never pushes to it.
name: CI
on:
push:
branches: [main]
# A tag push runs the same verification and then, and only then, publishes.
tags: ['release-*']
pull_request:
jobs:
verify:
runs-on: ubuntu-latest
# Postgres is started as a step rather than through `services:`, because
# this runner is configured with `container.network: host`.
#
# That single setting explains three failed attempts, and is worth writing
# down so nobody repeats them:
#
# `services:` Service containers are not resolvable by
# name from a host-networked job, giving
# "getaddrinfo EAI_AGAIN postgres".
# `--network container:$HOSTNAME` /etc/hostname reports the HOST's name,
# not a container id, so the namespace
# join finds no such container.
# default-gateway addressing The wrong idea entirely: with host
# networking the default route is the
# real router, not a docker bridge.
#
# Because the job shares the host's network namespace, a published port is
# simply on 127.0.0.1. The port is derived from the run id so concurrent
# runs cannot collide.
env:
PG_CONTAINER: pig-ci-pg-${{ github.run_id }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
# Corepack ships with Node and installs the exact pnpm pinned by
# `packageManager` in package.json, so CI, the image and a laptop all run
# the same version. `--activate` puts it on PATH; the download prompt is
# disabled because a non-interactive runner cannot answer it and would
# otherwise hang until the job times out.
- name: Enable pnpm
env:
COREPACK_ENABLE_DOWNLOAD_PROMPT: '0'
run: |
corepack enable
corepack prepare --activate
pnpm --version
- name: Start Postgres
run: |
PG_PORT=$(( 45000 + (${{ github.run_id }} % 15000) ))
echo "Publishing Postgres on 127.0.0.1:${PG_PORT}"
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
docker run -d --name "$PG_CONTAINER" \
-p "127.0.0.1:${PG_PORT}:5432" \
-e POSTGRES_USER=pig -e POSTGRES_PASSWORD=pig -e POSTGRES_DB=pig \
postgres:16-alpine
# pg_isready inside the container only proves the server started.
# What matters is that THIS job can reach it through the published
# port, so the readiness check is made from here, over TCP.
for i in $(seq 1 60); do
if node -e "
const net=require('net');
const s=net.connect(${PG_PORT},'127.0.0.1');
s.on('connect',()=>{s.end();process.exit(0)});
s.on('error',()=>process.exit(1));
" 2>/dev/null; then
echo "Reachable after ${i}s"
echo "DATABASE_URL=postgres://pig:pig@127.0.0.1:${PG_PORT}/pig" >> "$GITHUB_ENV"
exit 0
fi
sleep 1
done
echo "Postgres never became reachable on 127.0.0.1:${PG_PORT}"
docker logs "$PG_CONTAINER" 2>&1 | tail -30
exit 1
- name: Install
# --frozen-lockfile fails rather than quietly resolving a different
# tree when the lockfile and manifests disagree. That is the whole
# point of committing a lockfile, and it is the default in CI anyway —
# stated here so it survives someone running this locally.
run: pnpm install --frozen-lockfile
- name: Typecheck every package
run: pnpm run typecheck
- name: Unit tests
run: pnpm run test
- name: Migrations apply to a real Postgres
run: pnpm exec tsx packages/db/src/migrate.ts
- name: Migrations are re-runnable
run: pnpm exec tsx packages/db/src/migrate.ts
- name: Seed is idempotent
# A seed that duplicates on a second run corrupts any database it is
# pointed at twice, and nobody notices until the counts look odd.
#
# Two tables are counted, not one. This gate only ever watched
# `contacts`, and `contacts` is idempotent by an explicit existence
# check — so it could not see the class of regression it exists to
# catch, which is `onConflictDoNothing` firing at a constraint that is
# no longer there. The Motion starter library relies on exactly that
# clause against `motion_templates_slug_version_key`, so it is counted
# here too. Any table whose idempotency rests on a conflict target
# belongs in this list.
run: |
pnpm exec tsx packages/db/src/seed/index.ts > /dev/null
count() { docker exec "$PG_CONTAINER" psql -U pig -d pig -tAc "select count(*) from $1"; }
CONTACTS_BEFORE=$(count contacts)
TEMPLATES_BEFORE=$(count motion_templates)
pnpm exec tsx packages/db/src/seed/index.ts > /dev/null
CONTACTS_AFTER=$(count contacts)
TEMPLATES_AFTER=$(count motion_templates)
echo "contacts: $CONTACTS_BEFORE -> $CONTACTS_AFTER"
echo "motion_templates: $TEMPLATES_BEFORE -> $TEMPLATES_AFTER"
test "$CONTACTS_BEFORE" = "$CONTACTS_AFTER" || { echo "SEED IS NOT IDEMPOTENT"; exit 1; }
test "$TEMPLATES_BEFORE" = "$TEMPLATES_AFTER" || { echo "SEED IS NOT IDEMPOTENT"; exit 1; }
- name: Critical path E2E against Postgres and Hono
run: pnpm run test:e2e
- name: Server boots and answers
run: |
NODE_ENV=development PIG_PORT=8930 pnpm exec tsx apps/api/src/server.ts &
for i in $(seq 1 30); do
curl -sf http://127.0.0.1:8930/api/health && break
sleep 1
done
curl -sf http://127.0.0.1:8930/api/health | grep -q '"ok":true'
- name: Piggy boots, and the API reports it enabled
# apps/api's own comment admits the gap this closes: its tests inject a
# resolver, so they pass whether or not the process is really wired to a
# Piggy. Here the relay is given nothing but environment variables and
# has to reach a Piggy that actually booted.
#
# It runs after the seed on purpose: with no identity provider every
# request is the development user, and that user is a seeded row.
run: |
LOGS=$(mktemp -d)
# Derived from the run id for the same reason Postgres's port is: this
# job shares the host's network namespace, so a fixed port belongs to
# the whole machine and two concurrent runs would fight over it.
PIGGY_PORT=$(( 30000 + (${{ github.run_id }} % 5000) ))
API_PORT=$(( 36000 + (${{ github.run_id }} % 5000) ))
# Worthless, and long enough for the schema's 32-character minimum.
INTERNAL_TOKEN='piggy-ci-internal-token-0123456789'
PIGGY_PID=''
API_PID=''
# There are two processes between the job's pid and the server that
# holds the port — pnpm launches tsx, tsx launches node — so the whole
# descendant tree has to go. Verified by watching a plain `kill` leave
# a Piggy behind, still holding its Postgres connections.
#
# SIGKILL, not the polite signal: nothing here needs a clean shutdown,
# and a server still listening when the next step runs is worse than
# an abrupt one.
stop() {
for pid in "$@"; do
[ -n "$pid" ] || continue
for child in $(pgrep -P "$pid" 2>/dev/null); do stop "$child"; done
kill -9 "$pid" 2>/dev/null || true
done
}
trap 'stop "$PIGGY_PID" "$API_PID"' EXIT
# Nothing here calls a model: the task queue is empty and the status
# route never reaches one. The inference base points at the discard
# port so that a future version which DID call out would fail loudly
# rather than quietly billing somebody's real endpoint.
PIGGY_INFERENCE_API_KEY=ci-stub-key \
PIGGY_INFERENCE_BASE=http://127.0.0.1:9/v1 \
PIGGY_INTERNAL_TOKEN="$INTERNAL_TOKEN" \
PIGGY_CHAT_HOST=127.0.0.1 \
PIGGY_CHAT_PORT="$PIGGY_PORT" \
pnpm exec tsx apps/piggy/src/main.ts > "$LOGS/piggy.log" 2>&1 &
PIGGY_PID=$!
for i in $(seq 1 30); do
curl -sf "http://127.0.0.1:${PIGGY_PORT}/internal/health" >/dev/null && break
sleep 1
done
HEALTH=$(curl -sf "http://127.0.0.1:${PIGGY_PORT}/internal/health" || true)
echo "GET /internal/health -> ${HEALTH:-<no response>}"
case "$HEALTH" in
*'"ok":true'*) ;;
*)
echo 'Piggy never answered. Its configuration schema rejects an incomplete environment on start, so the reason is usually the last line here:'
tail -30 "$LOGS/piggy.log"
exit 1
;;
esac
NODE_ENV=development PIG_PORT="$API_PORT" \
PIGGY_ENABLED=true \
PIGGY_INTERNAL_URL="http://127.0.0.1:${PIGGY_PORT}" \
PIGGY_INTERNAL_TOKEN="$INTERNAL_TOKEN" \
pnpm exec tsx apps/api/src/server.ts > "$LOGS/api.log" 2>&1 &
API_PID=$!
for i in $(seq 1 30); do
curl -sf "http://127.0.0.1:${API_PORT}/api/health" >/dev/null && break
sleep 1
done
# The stored admin toggle is the inner gate, and an earlier step in
# this job has already created the settings row with Piggy off — the
# insert is ON CONFLICT DO NOTHING, so booting with PIGGY_ENABLED=true
# cannot correct it. Flip it here: what is under test is the wiring,
# not the switch.
docker exec "$PG_CONTAINER" psql -U pig -d pig \
-c 'update platform_settings set piggy_enabled = true' >/dev/null
STATUS=$(curl -sf "http://127.0.0.1:${API_PORT}/api/piggy/status" || true)
echo "GET /api/piggy/status -> ${STATUS:-<no response>}"
case "$STATUS" in
*'"enabled":true'*) ;;
*)
echo 'The API does not consider Piggy available, which is what the browser sees as a dock that never appears. PIGGY_ENABLED, PIGGY_INTERNAL_URL and PIGGY_INTERNAL_TOKEN are all read where the routes are composed; one of them is no longer reaching them.'
tail -30 "$LOGS/api.log"
exit 1
;;
esac
- name: Front end builds
run: pnpm -F @pig/web run build
- name: Inline theme script still matches the deployed CSP hash
# The proxy allows exactly one inline script by hash. If the script
# changes and the CSP is not updated, dark-mode users get a white flash
# on every load and nothing anywhere reports an error.
run: |
node -e "
const fs=require('fs'), crypto=require('crypto');
const html=fs.readFileSync('apps/web/dist/index.html','utf8');
const m=html.match(/<script>([\s\S]*?)<\/script>/);
if(!m){ console.error('No inline script found in index.html'); process.exit(1); }
const hash='sha256-'+crypto.createHash('sha256').update(m[1]).digest('base64');
const expected='sha256-1tTDwCq+TCEyPDSZeYqW5HbmP+unUg8hrgRiZBiH/IU=';
if(hash!==expected){
console.error('Inline script hash changed.');
console.error(' now: '+hash);
console.error(' expected: '+expected);
console.error('Update the CSP in deploy/Caddyfile.example AND on the server,');
console.error('then update the expected hash in this workflow.');
process.exit(1);
}
console.log('CSP hash unchanged: '+hash);
"
- name: Compose file renders, and Piggy is passed every key it requires
# `docker compose config` is the only thing that reads docker-compose.yml
# in this repository. Without it, a typo in that file is discovered by
# the production host, at deploy time, as a container that restarts for
# ever with a message only `docker logs` shows.
run: |
WORK=$(mktemp -d)
# A dummy environment file rather than a real .env: these values are
# never used, they exist only because compose refuses to render while
# a `${VAR:?}` is unset. Passing --env-file also means a stray .env on
# the runner cannot supply a key and hide its absence from the check.
cat > "$WORK/dummy.env" <<'ENVEOF'
POSTGRES_PASSWORD=ci-dummy
PIG_PUBLIC_URL=http://localhost:8920
SUPABASE_URL=http://localhost:54321
SUPABASE_ANON_KEY=ci-dummy
ENVEOF
# --profile piggy, because a profiled service is otherwise omitted
# from the rendered output entirely — and it is the service under test.
docker compose --env-file "$WORK/dummy.env" --profile piggy config -q || {
echo 'If that complained about a missing variable, add it to the dummy environment above: a `${VAR:?}` in docker-compose.yml needs a value here, not the right value.'
exit 1
}
docker compose --env-file "$WORK/dummy.env" --profile piggy config --format json \
> "$WORK/compose.json"
# .mts, not .ts: this file lives outside the workspace, so tsx has no
# package.json to tell it the module system and would treat a .ts file
# as CommonJS, where top-level await is a syntax error.
cat > "$WORK/piggy-env-keys.mts" <<'CHECKEOF'
/**
* Every key the Piggy configuration schema requires must be handed to
* the piggy service by docker-compose.yml. One that is missing is not
* a failure anywhere else in this repository: both files are
* individually valid, and the gap only appears as a container exiting
* on boot with "Invalid Piggy configuration".
*
* Both sides are read at run time — the required keys by asking the
* schema itself what an empty environment is missing, the provided
* keys from the compose file as Compose renders it. A list copied
* into this workflow would be right today and wrong by the next key.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
interface RenderedCompose {
services?: Record<string, { environment?: Record<string, string | null> }>;
}
const composeJsonPath = process.argv[2];
if (!composeJsonPath) {
console.error('Usage: piggy-env-keys.mts <rendered-compose.json>');
process.exit(1);
}
const configModule = (await import(
pathToFileURL(resolve('apps/piggy/src/config.ts')).href
)) as { loadPiggyConfig: (env: NodeJS.ProcessEnv) => unknown };
/**
* An empty environment fails on exactly the keys that have neither a
* default nor `.optional()`, and loadPiggyConfig reports one indented
* "KEY: message" line per failure.
*/
function keysWithNoDefault(): string[] {
try {
configModule.loadPiggyConfig({});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return [...message.matchAll(/^\s+([A-Z][A-Z0-9_]*):/gm)].flatMap(([, key]) =>
key ? [key] : [],
);
}
throw new Error(
'The Piggy config schema accepted an empty environment, so this check can no longer tell which keys are required.',
);
}
const rendered = JSON.parse(readFileSync(composeJsonPath, 'utf8')) as RenderedCompose;
const piggy = rendered.services?.piggy;
if (!piggy) {
console.error('The rendered compose file has no `piggy` service.');
process.exit(1);
}
const provided = new Set(Object.keys(piggy.environment ?? {}));
const required = keysWithNoDefault();
console.log(`piggy requires ${required.length} key(s) with no default: ${required.join(', ')}`);
const missing = required.filter((key) => !provided.has(key));
if (missing.length > 0) {
console.error(`docker-compose.yml never passes: ${missing.join(', ')}`);
console.error('The piggy container would exit on boot and restart for ever.');
console.error("Add each key to the piggy service's environment: block, and to .env.example.");
process.exit(1);
}
console.log('Every required Piggy key is present in the piggy service.');
CHECKEOF
pnpm exec tsx "$WORK/piggy-env-keys.mts" "$WORK/compose.json"
- name: Docker image builds
run: docker build -t pig:ci .
- name: Stop Postgres
if: always()
run: docker rm -f "$PG_CONTAINER" 2>/dev/null || true
# Publish the image that production will run.
#
# Only on a `release-*` tag. A push to main proves the commit is sound and
# stops there; tagging is the deliberate, human act that says "ship this".
# The production host polls the registry for the newest release tag and
# deploys it (scripts/autodeploy.sh) — which is how this gets automated
# WITHOUT the thing scripts/deploy.sh refuses to do. Nothing here holds a
# credential for cloud-2, and nothing here can execute anything on cloud-2.
#
# `gitea.ref` and `github.ref` are the same object in Gitea Actions; the
# gitea-prefixed spelling is used for the ref test because that is the one
# documented for tag conditions, and github.* elsewhere to match the job
# above.
publish:
needs: verify
if: startsWith(gitea.ref, 'refs/tags/release-')
runs-on: ubuntu-latest
env:
REGISTRY: git.karti.ai
# Gitea namespaces packages under the lowercased owner, so PIG/pig is
# published as pig/pig.
IMAGE: git.karti.ai/pig/pig
# THE POINT OF THIS VARIABLE: the shared act_runner on cloud-1 runs with
# `container.network: host`, and its docker config is visible to jobs
# from every other repository on that host. A plain `docker login` would
# leave a credential in ~/.docker/config.json that any of them could
# read. Pointing DOCKER_CONFIG at a per-run directory keeps the token out
# of the shared file entirely; the logout step below is the second belt.
DOCKER_CONFIG: /tmp/pig-docker-${{ github.run_id }}
steps:
- uses: actions/checkout@v4
- name: Log in to the Gitea registry
# The per-run Actions token, not a long-lived secret: it is minted for
# this run and dies with it. --password-stdin because an argument is
# visible in the runner's process list to anything else on that host.
run: |
mkdir -p "$DOCKER_CONFIG"
printf '%s' '${{ secrets.GITHUB_TOKEN }}' \
| docker login "$REGISTRY" -u '${{ github.actor }}' --password-stdin
- name: Build and push
# Both cloud-1 and cloud-2 are aarch64, so this is a native build and
# needs no --platform. The layer cache from the `verify` job's
# `docker build` is warm on this same daemon, so the rebuild is cheap.
#
# Two tags, always pushed together: the tag is what a human asked for,
# the short sha is what is unambiguous a year later when tags have been
# moved or deleted.
run: |
TAG="${GITHUB_REF#refs/tags/}"
SHORT_SHA=$(printf '%s' "${{ github.sha }}" | cut -c1-7)
echo "Publishing $IMAGE:$TAG and $IMAGE:$SHORT_SHA"
docker build -t "$IMAGE:$TAG" -t "$IMAGE:$SHORT_SHA" .
docker push "$IMAGE:$TAG"
docker push "$IMAGE:$SHORT_SHA"
# Print the digest: it is what the host poller compares against, and
# the only identifier that cannot be reassigned.
docker image inspect "$IMAGE:$TAG" \
--format '{{range .RepoDigests}}{{println .}}{{end}}'
- name: Log out
if: always()
# Runs even when the build failed, because a failed job that left a
# credential behind is exactly the leak this is guarding against.
run: |
docker logout "$REGISTRY" || true
rm -rf "$DOCKER_CONFIG"