Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
79 changes: 71 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,27 @@ jobs:
exit 1
fi

test:
name: Test
test_suites:
name: Tests (${{ matrix.suite }})
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
include:
- suite: API 1/2
command: bun run --cwd apps/api test --shard=1/2
- suite: API 2/2
command: bun run --cwd apps/api test --shard=2/2
- suite: Database
command: bun run --cwd packages/db test
# Both suites rebuild the same native bundle. Keep them sequential on
# their own runner; the other packages do not share that filesystem.
- suite: SDK and CLI
command: bun run test --filter=@repo/sdk --filter=@repo/cli --log-order=stream
# Exclusions keep new workspace test scripts included automatically.
- suite: Other packages
command: bun run test --filter=!@repo/api --filter=!@repo/db --filter=!@repo/sdk --filter=!@repo/cli --log-order=stream
steps:
- name: Checkout
uses: actions/checkout@v7
Expand All @@ -89,13 +107,58 @@ jobs:
- name: Install dependencies
run: bun install --frozen-lockfile

# Runs `turbo run test` → vitest across every package that defines a test
# script (@repo/core, @repo/adapters, @repo/db [PGlite — no external DB],
# apps/api, apps/dashboard). Packages resolve to src, so no build needed.
# apps/api excludes test/e2e/** here — those need a daemon and run in the
# e2e-docker job in release-gate.yml, where they gate the publish.
# Run every workspace test, with the large API suite split across runners.
# Direct Vitest runs and Turbo's stream mode expose progress immediately.
# API test/e2e/** still runs in release-gate.yml against a real daemon.
- name: Run tests
run: bun run test
run: ${{ matrix.command }}

# Preserve the existing required "Test" check. A failed, skipped, or canceled
# matrix must fail this check; no individual shard can make the PR green.
test:
name: Test
needs: test_suites
if: ${{ always() }}
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- name: Require every test suite to pass
env:
TEST_SUITES_RESULT: ${{ needs.test_suites.result }}
run: |
if [ "$TEST_SUITES_RESULT" != "success" ]; then
echo "::error::Test suites finished with status: $TEST_SUITES_RESULT"
exit 1
fi

docs:
name: Documentation
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7

- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: "22"

- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- name: Install dependencies
run: bun install --frozen-lockfile

- name: Build public SDK and CLI
run: bun run build:sdk

- name: Check documentation and public examples
run: bun run docs:check

- name: Build documentation website
run: bun run --cwd apps/web build

# The webmail server's own suite, which nothing else runs.
#
Expand Down
25 changes: 25 additions & 0 deletions .github/workflows/release-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@ jobs:
- name: Typecheck apps/api
run: bun run --cwd apps/api lint

- name: Typecheck SDK and shared platform
run: |
bun run --cwd packages/contracts lint
bun run --cwd packages/platform lint
bun run --cwd packages/sdk lint
bun run --cwd apps/cli lint

# `turbo run test` across every package with a test script. Includes the two
# migration suites in packages/db: migrate-chain (the chain applied to a
# POPULATED database, plus a self-check proving it can fail) and
Expand All @@ -71,6 +78,24 @@ jobs:
- name: Run tests
run: bun run test

sdk-package:
name: Installed SDK (Node ${{ matrix.node }})
runs-on: ubuntu-latest
strategy:
matrix:
node: ['22', '24']
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
- uses: oven-sh/setup-bun@v2
with:
bun-version-file: .bun-version
- run: bun install --frozen-lockfile
- run: bun run --cwd packages/openship build
- run: bun run --cwd packages/openship test:package

# Rollback and restore against a REAL Docker daemon. This is the only job that proves
# those paths work at all; every other test in the repo mocks the runtime. It lived in
# CI, where it ran alongside the release it claimed to gate — it is here now so a
Expand Down
41 changes: 11 additions & 30 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ on:
push:
tags:
- 'v*.*.*'
# Manual run publishes ONLY the CLI to npm (current version, no tag / installers).
# Manual run publishes ONLY the SDK/CLI package to npm (current version, no tag / installers).
workflow_dispatch: {}

concurrency:
Expand Down Expand Up @@ -542,7 +542,7 @@ jobs:
retention-days: 7

publish-npm:
name: Publish CLI to npm
name: Publish Openship to npm
runs-on: ubuntu-24.04
# npm is the ONE immutable artifact — a published version can never be
# replaced. So it must publish LAST, only after every other job in the
Expand Down Expand Up @@ -603,8 +603,8 @@ jobs:
- name: Install dependencies
run: bun install --frozen-lockfile

- name: Build CLI
run: bun run --cwd apps/cli build
- name: Build SDK and CLI package
run: bun run --cwd packages/openship build

# Gate: run the ACTUAL built bundle across node/bun/shebang/no-node and
# boot the real server. This is the last check before the IMMUTABLE npm
Expand All @@ -614,8 +614,11 @@ jobs:
- name: Release smoke — built CLI launches + server boots
run: SMOKE_SKIP_BUILD=1 bash apps/cli/scripts/release-smoke.sh

- name: Verify packed SDK outside the workspace
run: bun run --cwd packages/openship test:package

- name: Publish to npm (OIDC trusted publishing — no token)
working-directory: apps/cli
working-directory: packages/openship
run: |
set -euo pipefail
TAG="${GITHUB_REF_NAME}"
Expand All @@ -630,31 +633,9 @@ jobs:
echo "::notice::${NAME}@${VERSION} already on npm — skipping publish (re-run of an existing release)."
exit 0
fi
# tsup bundles the @repo/* workspace packages into dist/, so they must
# NOT appear in the published manifest — a `workspace:*` specifier is
# uninstallable off the monorepo (bun/npm: "@repo/… failed to resolve").
# Strip EVERY workspace dependency generically: hard-coding the list
# once shipped a broken 0.4.1 that kept @repo/adapters. This can never
# silently miss a newly-added @repo/* again.
node -e '
const fs = require("fs");
const p = JSON.parse(fs.readFileSync("package.json", "utf8"));
const stripped = [];
for (const k of Object.keys(p.dependencies || {})) {
if (String(p.dependencies[k]).startsWith("workspace:")) {
delete p.dependencies[k];
stripped.push(k);
}
}
fs.writeFileSync("package.json", JSON.stringify(p, null, 2) + "\n");
console.log("stripped workspace deps:", stripped.join(", ") || "(none)");
'
# Fail loudly if any workspace: specifier somehow survives — never ship
# an uninstallable package.
if grep -q '"workspace:' package.json; then
echo "::error::a workspace:* dependency survived the strip — refusing to publish an uninstallable package."
exit 1
fi
# The distribution owns an installable manifest. Never rewrite a
# source workspace manifest during publication.
bun run check
# Prereleases (v1.2.3-rc.1) publish to the `next` dist-tag, not `latest`.
if [[ "${TAG}" == *-* ]]; then
npm publish --access public --tag next
Expand Down
3 changes: 3 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,10 @@
"@hono/node-ws": "^1.3.1",
"@hono/typebox-validator": "^0.3.0",
"@repo/adapters": "workspace:*",
"@repo/contracts": "workspace:*",
"@repo/core": "workspace:*",
"@repo/db": "workspace:*",
"@repo/platform": "workspace:*",
"@sinclair/typebox": "^0.34.48",
"better-auth": "^1.5.4",
"bullmq": "^5.70.4",
Expand All @@ -39,6 +41,7 @@
"zod": "^4.3.6"
},
"devDependencies": {
"@repo/sdk": "workspace:*",
"@types/node": "^22.13.0",
"@types/nodemailer": "^8.0.1",
"tsup": "^8.5.1",
Expand Down
4 changes: 3 additions & 1 deletion apps/api/scripts/build-release.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@
* core/ ← @repo/core source (workspace dep)
* db/ ← @repo/db source + drizzle/ migrations
* adapters/ ← @repo/adapters source (workspace dep)
* contracts/ ← shared SDK/API contracts
* platform/ ← shared authorization/application operations
*
* Workspace packages are copied verbatim and referenced via `file:`
* paths in api/package.json. They are not on npm — shipping the
Expand Down Expand Up @@ -66,7 +68,7 @@ const PACKAGES_DIR = join(REPO_ROOT, "packages");
* (db-email, ui, onboarding) aren't included — they're either dashboard-
* only or webmail-only.
*/
const API_WORKSPACE_DEPS = ["core", "db", "adapters"] as const;
const API_WORKSPACE_DEPS = ["core", "db", "adapters", "contracts", "platform"] as const;

/**
* Output directory. Defaults to `apps/api/release-dist/` (the canonical
Expand Down
67 changes: 67 additions & 0 deletions apps/api/scripts/cloud-readiness.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/** Read-only release checks. Does not create tokens, checkouts, or resources. */
import { runtimeTarget } from "@repo/core";
import { OblienBillingApi } from "@repo/platform/engine/lib/oblien-billing-api";
import { OBLIEN_WEBHOOK_EVENTS, oblienWebhookUrl } from "@repo/platform/engine/lib/oblien-webhook-config";

const results: Array<{ check: string; ok: boolean; detail?: string }> = [];
const record = (check: string, ok: boolean, detail?: string) => results.push({ check, ok, ...(detail ? { detail } : {}) });
const clientId = process.env.OBLIEN_CLIENT_ID;
const clientSecret = process.env.OBLIEN_CLIENT_SECRET;
const apiBase = process.env.OBLIEN_API_URL ?? "https://api.oblien.com";
record("Cloud mode", process.env.CLOUD_MODE === "true");
record("Oblien credentials configured", Boolean(clientId && clientSecret));
record("Webhook secret configured", Boolean(process.env.OBLIEN_WEBHOOK_SECRET));
record("Subscription purchases enabled", process.env.BILLING_ENABLED === "true");
record("Credit purchases enabled", process.env.BILLING_TOPUPS_ENABLED === "true");

const billing = new OblienBillingApi({ clientId, clientSecret, baseUrl: apiBase });
const checks = await Promise.allSettled([
(async () => {
const catalog = await billing.getCatalog();
const plans = catalog.plans.filter((plan) => plan.priceMonthly !== null);
record("Provider catalog", plans.length > 0 && [...catalog.plans, ...catalog.creditPacks].every((item) => item.currency.toUpperCase() === "USD"),
`${plans.length} priced plans, ${catalog.creditPacks.length} credit packs`);
})(),
(async () => {
const defaults = await billing.getDefaults();
record("Finite automatic namespace policy", defaults.autoApply && defaults.quotaLimit !== null && defaults.onOverdraftAction === "stop_workspaces",
`autoApply=${defaults.autoApply}, quotaLimit=${defaults.quotaLimit}, action=${defaults.onOverdraftAction}`);
})(),
(async () => {
const callback = oblienWebhookUrl(process.env.OBLIEN_WEBHOOK_URL, runtimeTarget.api);
if (!clientId || !clientSecret) throw new Error("Oblien credentials are missing");
const response = await fetch(`${apiBase.replace(/\/+$/, "")}/webhooks`, {
headers: { "X-Client-ID": clientId, "X-Client-Secret": clientSecret },
redirect: "error", signal: AbortSignal.timeout(15_000),
});
if (!response.ok) throw new Error(`Webhook registry HTTP ${response.status}`);
const body = await response.json() as { success: boolean; webhooks?: Array<{ url: string; active: boolean; namespace?: string | null; events: string[]; secret?: string | null }> };
const webhook = body.success && body.webhooks?.find((item) => item.url === callback && !item.namespace && item.active);
const missing = OBLIEN_WEBHOOK_EVENTS.filter((event) => !webhook || !webhook.events.includes(event));
record("Account-wide signed billing webhook", Boolean(webhook && webhook.secret && missing.length === 0),
webhook ? `Missing events: ${missing.join(", ") || "none"}` : "No active account-wide webhook matches the configured callback");
})(),
(async () => {
if (!clientId || !clientSecret) throw new Error("Oblien credentials are missing");
const response = await fetch(`${apiBase.replace(/\/+$/, "")}/namespaces?limit=1`, {
headers: { "X-Client-ID": clientId, "X-Client-Secret": clientSecret },
redirect: "error", signal: AbortSignal.timeout(15_000),
});
if (!response.ok) throw new Error(`Namespace registry HTTP ${response.status}`);
const body = await response.json() as { success: boolean; data?: Array<{ slug: string }> };
// The probe is read-only even on a new account with no namespace yet.
const namespace = body.success && body.data?.[0]?.slug || "openship-billing-readiness";
await billing.getSubscription(namespace);
record("Namespace subscription API (SDK 2.3)", true, "Authenticated namespace-bound response verified");
})(),
]);
checks.forEach((result, index) => {
if (result.status === "rejected") {
// Never serialize provider bodies, headers, credentials, or webhook secrets.
const error = result.reason;
record(["Provider catalog", "Namespace default policy", "Webhook registration", "Namespace subscription API (SDK 2.3)"][index]!, false,
error instanceof Error ? error.message : "Read failed");
}
});
console.log(JSON.stringify({ readOnly: true, checks: results, passed: results.every((result) => result.ok) }, null, 2));
process.exitCode = results.every((result) => result.ok) ? 0 : 1;
2 changes: 1 addition & 1 deletion apps/api/scripts/promo-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@

import type Stripe from "stripe";
import { PLAN_IDS, PRICING, resolveStripePriceId, type PlanTierId } from "@repo/core";
import { stripe } from "../src/lib/stripe-client";
import { stripe } from "@repo/platform/engine/lib/stripe-client";

const INVOCATION = "bun --cwd apps/api scripts/promo-code.ts";
/** Stamped on everything this CLI creates, so hand-made codes stay tellable. */
Expand Down
Loading