Add CI, a test suite, and a deploy script
CI / verify (push) Failing after 34s

`npm test` did nothing until now. CI that runs no tests is theatre, so the
tests came first — 39 of them, over the two places where an error would be
silent and expensive.

packages/core: the margin arithmetic. Every dashboard figure, idle-capacity
alert and agent answer resolves through it, and wrong numbers still look like
numbers. The cases pin decisions rather than implementation: cost is charged
against the full commitment (a naive version reports the opposite sign on a
loss-making block), aggregation sums cents rather than averaging percentages
(averaging reports +22% on a book that is losing money), break-even prices the
remaining hours and returns null rather than Infinity when there are none, and
internal research burn counts as cost with no revenue.

packages/prime: the upstream mapping. Rounding rather than truncating cents,
because 2.43 is 2.4299999 in binary and a lost cent compounds across millions
of GPU-hours. And interconnect normalisation, where an unrecognised fabric maps
to Unknown rather than Ethernet — guessing low loses a deal, guessing high
sells a training customer a cluster that cannot train.

CI runs on push and pull request: typecheck all six packages, unit tests,
migrations applied twice to a real Postgres, a seed-idempotency assertion that
fails the build if row counts move on a second run, a server boot, the front-end
build, and a Docker build.

It also asserts the inline theme script's hash still matches the CSP the proxy
allows. That script prevents a white flash for dark-mode users; if it changes
without the CSP being updated, the browser silently blocks it and nothing
anywhere reports an error.

Deployment stays a script rather than push-to-deploy. Automating it would put
an SSH key with production write access on the CI runner — a real escalation
for a project this size. The script takes a database dump before migrating and
refuses to finish if an unauthenticated request returns anything but 401.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-12 20:27:47 -07:00
parent 7719850fc5
commit 73231a8944
9 changed files with 530 additions and 12 deletions
+124
View File
@@ -0,0 +1,124 @@
# 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. 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.
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
verify:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: pig
POSTGRES_PASSWORD: pig
POSTGRES_DB: pig
options: >-
--health-cmd "pg_isready -U pig"
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
DATABASE_URL: postgres://pig:pig@postgres:5432/pig
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install
run: npm install --no-audit --no-fund
- name: Typecheck every package
run: |
npx tsc --noEmit -p packages/core/tsconfig.json
npx tsc --noEmit -p packages/db/tsconfig.json
npx tsc --noEmit -p packages/prime/tsconfig.json
npx tsc --noEmit -p apps/api/tsconfig.json
npx tsc --noEmit -p apps/web/tsconfig.json
npx tsc --noEmit -p apps/mcp/tsconfig.json
- name: Unit tests
run: npm test --workspaces --if-present
- name: Migrations apply to a real Postgres
run: npx tsx packages/db/src/migrate.ts
- name: Migrations are re-runnable
run: npx 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.
run: |
npx tsx packages/db/src/seed/index.ts > /dev/null
BEFORE=$(psql "$DATABASE_URL" -tAc "select count(*) from contacts")
npx tsx packages/db/src/seed/index.ts > /dev/null
AFTER=$(psql "$DATABASE_URL" -tAc "select count(*) from contacts")
echo "contacts: $BEFORE -> $AFTER"
test "$BEFORE" = "$AFTER" || { echo "SEED IS NOT IDEMPOTENT"; exit 1; }
- name: Server boots and answers
run: |
NODE_ENV=development PIG_PORT=8930 npx 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: Front end builds
run: npm run build -w @pig/web
- 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: Docker image builds
run: docker build -t pig:ci .
+1
View File
@@ -18,3 +18,4 @@ coverage/
# Postgres volume mounts used by local compose
deploy/pgdata/
backups/
+2 -1
View File
@@ -10,7 +10,8 @@
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test": "node --test --import tsx test/*.test.ts"
},
"dependencies": {
"zod": "^3.24.1"
+184
View File
@@ -0,0 +1,184 @@
/**
* Tests for the margin arithmetic.
*
* This is the most load-bearing pure code in the project: every dashboard
* figure, every idle-capacity alert and every answer an agent gives resolves
* through these functions. An error here is an error everywhere, and it would
* be a quiet one — wrong numbers still look like numbers.
*
* The cases below are chosen to pin the *decisions*, not merely the
* implementation. Several would pass under a naive version that is wrong in a
* way that flatters the business, which is precisely why they exist.
*/
import { strict as assert } from 'node:assert';
import { describe, it } from 'node:test';
import {
aggregateMargin,
breakEvenPricePerGpuHourCents,
computeMargin,
formatCents,
} from '../src/margin';
describe('computeMargin', () => {
it('charges cost against the FULL commitment, not only the hours sold', () => {
// The decision the whole product rests on. A naive implementation charges
// only the allocated share and reports this block as profitable, when in
// fact it loses money: the unsold hours were already paid for.
const result = computeMargin(
{ gpuHours: 1000, costPerGpuHourCents: 100 },
[{ gpuHours: 500, pricePerGpuHourCents: 150 }],
);
assert.equal(result.costCents, 100_000, 'cost must cover all 1000 hours');
assert.equal(result.revenueCents, 75_000);
assert.equal(result.grossMarginCents, -25_000, 'this block is underwater');
// Under the naive treatment: cost 500×100 = 50,000 against revenue 75,000,
// reporting +25,000 — a sign error on the thing that matters most.
assert.ok(result.grossMarginCents < 0);
});
it('reports utilisation and idle hours', () => {
const r = computeMargin({ gpuHours: 1000, costPerGpuHourCents: 100 }, [
{ gpuHours: 700, pricePerGpuHourCents: 200 },
]);
assert.equal(r.allocatedGpuHours, 700);
assert.equal(r.idleGpuHours, 300);
assert.equal(r.utilisation, 0.7);
});
it('sums several allocations against one commitment', () => {
const r = computeMargin({ gpuHours: 1000, costPerGpuHourCents: 100 }, [
{ gpuHours: 400, pricePerGpuHourCents: 200 },
{ gpuHours: 300, pricePerGpuHourCents: 250 },
]);
assert.equal(r.allocatedGpuHours, 700);
assert.equal(r.revenueCents, 400 * 200 + 300 * 250);
});
it('counts internal consumption as cost with no revenue', () => {
// Research burn is a zero-price allocation. Leaving it out would overstate
// available capacity; pricing it at anything but zero would invent revenue.
const r = computeMargin({ gpuHours: 1000, costPerGpuHourCents: 100 }, [
{ gpuHours: 600, pricePerGpuHourCents: 200 },
{ gpuHours: 200, pricePerGpuHourCents: 0 },
]);
assert.equal(r.allocatedGpuHours, 800);
assert.equal(r.revenueCents, 120_000, 'the internal 200 hours earn nothing');
assert.equal(r.idleGpuHours, 200);
});
it('clamps idle at zero when capacity is deliberately oversubscribed', () => {
// Selling beyond 100% is a real policy, not a data-entry error. Negative
// idle hours would be nonsense to display.
const r = computeMargin({ gpuHours: 1000, costPerGpuHourCents: 100 }, [
{ gpuHours: 1200, pricePerGpuHourCents: 150 },
]);
assert.equal(r.idleGpuHours, 0);
assert.equal(r.utilisation, 1.2, 'utilisation itself is not clamped — it is the signal');
});
it('returns null rather than dividing by zero on an empty book', () => {
const r = computeMargin({ gpuHours: 1000, costPerGpuHourCents: 100 }, []);
assert.equal(r.revenueCents, 0);
assert.equal(r.grossMarginCents, -100_000);
assert.equal(r.grossMarginPct, null, 'no revenue means no percentage, not Infinity');
assert.equal(r.marginPerAllocatedGpuHourCents, null);
});
it('handles a zero-hour commitment without producing NaN', () => {
const r = computeMargin({ gpuHours: 0, costPerGpuHourCents: 100 }, []);
assert.equal(r.utilisation, 0);
assert.ok(!Number.isNaN(r.utilisation));
});
it('keeps money in whole cents', () => {
// Fractional hours are normal; fractional cents are not. A dashboard that
// renders 1234.9999999 has lost the plot.
const r = computeMargin({ gpuHours: 0.1, costPerGpuHourCents: 3 }, [
{ gpuHours: 0.1, pricePerGpuHourCents: 7 },
]);
assert.ok(Number.isInteger(r.costCents));
assert.ok(Number.isInteger(r.revenueCents));
});
});
describe('breakEvenPricePerGpuHourCents', () => {
it('prices the REMAINING hours, not the whole block', () => {
// Half a 1000-hour block at 100c cost is sold at 120c: 60,000 of 100,000
// recovered, 40,000 outstanding across 500 remaining hours = 80c.
const price = breakEvenPricePerGpuHourCents({ gpuHours: 1000, costPerGpuHourCents: 100 }, [
{ gpuHours: 500, pricePerGpuHourCents: 120 },
]);
assert.equal(price, 80);
});
it('falls as more of the block sells', () => {
const commitment = { gpuHours: 1000, costPerGpuHourCents: 100 };
const early = breakEvenPricePerGpuHourCents(commitment, [
{ gpuHours: 200, pricePerGpuHourCents: 120 },
])!;
const later = breakEvenPricePerGpuHourCents(commitment, [
{ gpuHours: 800, pricePerGpuHourCents: 120 },
])!;
assert.ok(later < early, 'a mostly-sold block is cheaper to break even on');
});
it('returns zero once cost is covered — further sales are pure upside', () => {
const price = breakEvenPricePerGpuHourCents({ gpuHours: 1000, costPerGpuHourCents: 100 }, [
{ gpuHours: 500, pricePerGpuHourCents: 300 },
]);
// Not a negative price, which would be meaningless to show a seller.
assert.equal(price, 0);
});
it('returns null when nothing is left to sell', () => {
const price = breakEvenPricePerGpuHourCents({ gpuHours: 1000, costPerGpuHourCents: 100 }, [
{ gpuHours: 1000, pricePerGpuHourCents: 150 },
]);
assert.equal(price, null, 'no remaining hours means no break-even price, not Infinity');
});
});
describe('aggregateMargin', () => {
it('sums cents rather than averaging percentages', () => {
// A tiny block at a wonderful margin next to a huge one at a terrible
// margin. Averaging the two percentages would report roughly break-even;
// the truth is a large loss dominated by the big block.
const result = aggregateMargin([
{
commitment: { gpuHours: 10, costPerGpuHourCents: 100 },
allocations: [{ gpuHours: 10, pricePerGpuHourCents: 1000 }],
},
{
commitment: { gpuHours: 10_000, costPerGpuHourCents: 100 },
allocations: [{ gpuHours: 5_000, pricePerGpuHourCents: 110 }],
},
]);
assert.equal(result.costCents, 10 * 100 + 10_000 * 100);
assert.equal(result.revenueCents, 10 * 1000 + 5_000 * 110);
assert.ok(result.grossMarginCents < 0, 'the big block dominates');
// The naive mean of +90% and -45% is about +22%, which is the wrong sign.
assert.ok(result.grossMarginPct! < 0);
});
it('is empty-safe', () => {
const r = aggregateMargin([]);
assert.equal(r.costCents, 0);
assert.equal(r.grossMarginPct, null);
assert.equal(r.utilisation, 0);
});
});
describe('formatCents', () => {
it('renders whole currency from integer cents', () => {
assert.equal(formatCents(123_456), '$1,234.56');
assert.equal(formatCents(0), '$0.00');
});
it('renders negatives, which is the case that matters', () => {
assert.ok(formatCents(-5000).includes('50'));
});
});
+2 -6
View File
@@ -1,9 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"noEmit": true
},
"include": ["src/**/*.ts"]
"compilerOptions": { "noEmit": true, "types": ["node"] },
"include": ["src/**/*.ts", "test/**/*.ts"]
}
+10 -3
View File
@@ -6,7 +6,14 @@
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": { ".": "./src/index.ts" },
"scripts": { "typecheck": "tsc --noEmit" },
"dependencies": { "@pig/core": "*" }
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"test": "node --test --import tsx test/*.test.ts"
},
"dependencies": {
"@pig/core": "*"
}
}
+136
View File
@@ -0,0 +1,136 @@
/**
* Tests for the upstream-to-PIG mapping.
*
* Two things here are worth defending with tests, because getting either wrong
* is expensive and neither would throw:
*
* **Money.** Upstream sends floating-point dollars; PIG stores integer
* cents. Truncating instead of rounding loses a cent on values that cannot
* be represented exactly in binary, and a cent compounds across millions of
* GPU-hours.
*
* **Interconnect.** The field that decides whether capacity can train or
* only serve. Guessing wrong in either direction loses a deal or sells a
* customer a cluster that cannot do the job.
*/
import { strict as assert } from 'node:assert';
import { describe, it } from 'node:test';
import { mapListing, toCents } from '../src/map';
import type { PrimeGpuListing } from '../src/client';
const listing = (over: Partial<PrimeGpuListing> = {}): PrimeGpuListing => ({
gpuType: 'H100_80GB',
gpuCount: 8,
raw: {},
...over,
});
describe('toCents', () => {
it('rounds rather than truncating', () => {
// 2.43 is 2.4299999... in binary. Truncating gives 242 and loses a cent
// on every single listing.
assert.equal(toCents(2.43), 243);
assert.equal(toCents(0.47), 47);
assert.equal(toCents(12.005), 1201);
});
it('passes through null and rejects nonsense', () => {
assert.equal(toCents(null), null);
assert.equal(toCents(undefined), null);
assert.equal(toCents(Number.NaN), null);
assert.equal(toCents(Number.POSITIVE_INFINITY), null);
});
it('handles zero, which is a real price and not a missing one', () => {
assert.equal(toCents(0), 0);
});
});
describe('mapListing — interconnect', () => {
const cases: [string, string][] = [
['Infiniband', 'Infiniband'],
['InfiniBand', 'Infiniband'],
['INFINIBAND', 'Infiniband'],
['infini_band', 'Infiniband'],
['IB', 'Infiniband'],
['RoCE', 'RoCE'],
['roce v2', 'RoCE'],
['NVLink', 'NVLink'],
['nvl', 'NVLink'],
['Ethernet', 'Ethernet'],
['eth', 'Ethernet'],
];
for (const [input, expected] of cases) {
it(`normalises "${input}" to ${expected}`, () => {
assert.equal(mapListing(listing({ interconnectType: input }))!.interconnectType, expected);
});
}
it('maps an unrecognised fabric to Unknown, never to Ethernet', () => {
// Assuming Ethernet would understate real capacity; assuming InfiniBand
// would sell a training customer a cluster that cannot train. Neither
// error is acceptable, so it stays explicitly unknown.
assert.equal(mapListing(listing({ interconnectType: 'Omni-Path' }))!.interconnectType, 'Unknown');
assert.equal(mapListing(listing({ interconnectType: '' }))!.interconnectType, 'Unknown');
assert.equal(mapListing(listing({ interconnectType: undefined }))!.interconnectType, 'Unknown');
});
});
describe('mapListing — stock', () => {
it('treats an unrecognised stock signal as Unavailable', () => {
// Under-promising inventory is recoverable. A seller offering capacity
// that turns out not to exist is not.
assert.equal(mapListing(listing({ stockStatus: 'wat' }))!.stockStatus, 'Unavailable');
assert.equal(mapListing(listing({ stockStatus: undefined }))!.stockStatus, 'Unavailable');
});
it('passes known values through, case-insensitively', () => {
assert.equal(mapListing(listing({ stockStatus: 'available' }))!.stockStatus, 'Available');
assert.equal(mapListing(listing({ stockStatus: 'HIGH' }))!.stockStatus, 'High');
});
});
describe('mapListing — general', () => {
it('drops a listing with no GPU type or count — there is nothing sellable', () => {
assert.equal(mapListing(listing({ gpuType: undefined })), null);
assert.equal(mapListing(listing({ gpuCount: undefined })), null);
assert.equal(mapListing(listing({ gpuCount: 0 })), null);
});
it('converts prices to integer cents', () => {
const m = mapListing(listing({ prices: { onDemand: 2.43, communityPrice: 0.94 } }))!;
assert.equal(m.onDemandPriceCents, 243);
assert.equal(m.communityPriceCents, 94);
});
it('defaults to the secure tier unless community is stated', () => {
// Mislabelling community capacity as secure would let it be sold against a
// requirement it cannot meet.
assert.equal(mapListing(listing({}))!.securityTier, 'secure_cloud');
assert.equal(
mapListing(listing({ security: 'community_cloud' }))!.securityTier,
'community_cloud',
);
});
it('normalises sockets and drops unknown ones rather than inventing a value', () => {
assert.equal(mapListing(listing({ socket: 'sxm5' }))!.socket, 'SXM5');
assert.equal(mapListing(listing({ socket: 'pcie' }))!.socket, 'PCIe');
assert.equal(mapListing(listing({ socket: 'SXM_5' }))!.socket, 'SXM5');
// The column is an enum; an unmapped value would fail the insert.
assert.equal(mapListing(listing({ socket: 'weird' }))!.socket, null);
});
it('unwraps counts sent as objects', () => {
const m = mapListing(listing({ vcpu: { defaultCount: 96 }, memory: 1024 }))!;
assert.equal(m.vcpu, 96);
assert.equal(m.memoryGb, 1024);
});
it('preserves the raw payload so a new upstream field is not lost', () => {
const raw = { gpuType: 'H100_80GB', gpuCount: 8, somethingNew: 'value' };
const m = mapListing({ ...listing(), raw })!;
assert.equal((m.raw as Record<string, unknown>).somethingNew, 'value');
});
});
+2 -2
View File
@@ -1,5 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "noEmit": true },
"include": ["src/**/*.ts"]
"compilerOptions": { "noEmit": true, "types": ["node"] },
"include": ["src/**/*.ts", "test/**/*.ts"]
}
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
#
# Deploy PIG. Run on the host that serves it.
#
# ./scripts/deploy.sh
#
# Deliberately a script rather than automated push-to-deploy. Automating it
# would mean putting an SSH key with write access to the production host onto
# the CI runner, which is a meaningful escalation for a project this size. CI
# proves the commit is sound; a human decides when it ships.
#
# Safe to re-run. Migrations are additive and tracked.
set -euo pipefail
cd "$(dirname "$0")/.."
echo "==> Fetching"
git fetch -q origin
BEFORE=$(git rev-parse --short HEAD)
git reset --hard -q origin/main
AFTER=$(git rev-parse --short HEAD)
if [ "$BEFORE" = "$AFTER" ]; then
echo " Already at $AFTER"
else
echo " $BEFORE -> $AFTER"
git --no-pager log --oneline "$BEFORE..$AFTER" | sed 's/^/ /'
fi
echo "==> Backing up the database first"
# Cheap insurance. A migration that goes wrong on a database holding real deal
# data is not something to discover without a dump in hand.
mkdir -p backups
BACKUP="backups/pig-$(date +%Y%m%d-%H%M%S).sql.gz"
sudo docker compose -p pig exec -T db pg_dump -U pig pig | gzip > "$BACKUP"
echo " $BACKUP ($(du -h "$BACKUP" | cut -f1))"
echo "==> Building and starting"
sudo docker compose -p pig up -d --build
echo "==> Waiting for health"
for _ in $(seq 1 60); do
if curl -sf http://127.0.0.1:8920/api/health > /dev/null; then break; fi
sleep 1
done
echo "==> Migrating"
sudo docker compose -p pig exec -T app npx tsx packages/db/src/migrate.ts
echo "==> Verifying"
if curl -sf http://127.0.0.1:8920/api/health | grep -q '"ok":true'; then
echo " health ok"
else
echo " HEALTH CHECK FAILED"
sudo docker compose -p pig logs app --tail 40
exit 1
fi
# Authentication must be enforced. A deploy that accidentally serves the CRM
# unauthenticated is the one failure worth blocking on.
CODE=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8920/api/dashboard)
if [ "$CODE" != "401" ]; then
echo " UNAUTHENTICATED REQUEST RETURNED $CODE, EXPECTED 401"
exit 1
fi
echo " auth enforced"
echo "==> Deployed $AFTER"