diff --git a/.circleci/AGENTS.md b/.circleci/AGENTS.md index e54b11cf..fab5d225 100644 --- a/.circleci/AGENTS.md +++ b/.circleci/AGENTS.md @@ -15,14 +15,15 @@ unauthenticated rate limit. With it, the limit is 5,000 req/hour per token. [mise-tokens]: https://mise.en.dev/dev-tools/github-tokens.html -| Workflow | Job | Requires `github` context? | -|---------------|--------------------|----------------------------| -| `ci` | `check` | yes | -| `ci` | `main-pipeline` | yes | -| `release` | `release` | yes | -| `release-cli` | `build-cli` | yes | -| `release-cli` | `publish-platform` | yes | -| `release-cli` | `finalize-cli` | yes | +| Workflow | Job | Requires `github` context? | +|---------------|--------------------------|----------------------------| +| `ci` | `check` | yes | +| `ci` | `registry-compatibility` | yes | +| `ci` | `main-pipeline` | yes | +| `release` | `release` | yes | +| `release-cli` | `build-cli` | yes | +| `release-cli` | `publish-platform` | yes | +| `release-cli` | `finalize-cli` | yes | If a future job adds `setup-mise` without attaching the `github` context, it will fail loudly on the `Install tools` step with `mise WARN GitHub rate limit exceeded` once CircleCI's IP @@ -94,7 +95,16 @@ Two packed CircleCI configs, one per pipeline dir. ### `development/` — CI -PR-time checks. Workflows: `ci` (runs `check` on non-main branches, runs `main-pipeline` on main). +PR-time checks. Workflows: `ci` (runs `check` and `registry-compatibility` on non-main branches, runs `main-pipeline` on main). + +`registry-compatibility` is the live-registry type-compatibility job: it +fetches the deployed registry's OpenAPI spec (network dependency), regenerates +the engine's registry types in the ephemeral checkout, and runs +`bun turbo types` across the whole monorepo (hence the `turbo-cache` context +in addition to `github`). It fails when the live schema is unevaluable +(fetch/validate/codegen error) or when any type check fails — never on +snapshot age or diffs against the committed generated files, which are +discarded with the checkout. ### `release/` — CD diff --git a/.circleci/development.yml b/.circleci/development.yml index b10427a1..d2158310 100644 --- a/.circleci/development.yml +++ b/.circleci/development.yml @@ -90,15 +90,18 @@ jobs: command: bun scripts/release/tag.ts name: Tag Release - notify-failure - openapi-freshness: + registry-compatibility: docker: - image: cimg/base:current - resource_class: small + resource_class: medium steps: - setup-mise - run: - command: bun run --cwd packages/engine codegen:registry --check --strict - name: OpenAPI snapshot freshness + command: bun run --cwd packages/engine codegen:registry + name: Sync live registry OpenAPI spec + - run: + command: bun turbo types + name: Type-check against live registry types release: docker: - image: cimg/base:current @@ -129,8 +132,9 @@ workflows: branches: ignore: - main - - openapi-freshness: + - registry-compatibility: context: + - turbo-cache - github filters: branches: diff --git a/.circleci/development/jobs/openapi-freshness.yml b/.circleci/development/jobs/openapi-freshness.yml deleted file mode 100644 index 1d29de2c..00000000 --- a/.circleci/development/jobs/openapi-freshness.yml +++ /dev/null @@ -1,17 +0,0 @@ -docker: - - image: cimg/base:current -resource_class: small -steps: - - setup-mise - # Pure offline read of the on-disk OpenAPI snapshot's `Generated-At` - # header. Exits non-zero (via --strict) if the snapshot is older than - # STALENESS_THRESHOLD_DAYS (default 7). - # - # The non-zero exit produces a failed CircleCI job → failed GitHub - # status check → red X on the PR. Branch protection decides whether - # that red X blocks merge; the default is "advisory" (red X visible, - # merge unblocked) per design D4. To enforce blocking, add this job - # to GitHub's required-checks list in repo settings. - - run: - name: OpenAPI snapshot freshness - command: bun run --cwd packages/engine codegen:registry --check --strict diff --git a/.circleci/development/jobs/registry-compatibility.yml b/.circleci/development/jobs/registry-compatibility.yml new file mode 100644 index 00000000..50357d6c --- /dev/null +++ b/.circleci/development/jobs/registry-compatibility.yml @@ -0,0 +1,21 @@ +docker: + - image: cimg/base:current +resource_class: medium +steps: + - setup-mise + # Live-registry compatibility check. Fetches the registry's current + # OpenAPI spec, regenerates the engine's registry types in this + # ephemeral checkout, and type-checks the full monorepo against + # them. Fails when the live schema can't be fetched/validated/ + # generated (unevaluable) or when any type check fails. + # + # Snapshot age and working-tree diffs are deliberately NOT failure + # conditions — the regenerated files are thrown away with the job's + # checkout. The committed snapshot stays authoritative for + # deterministic offline builds. + - run: + name: Sync live registry OpenAPI spec + command: bun run --cwd packages/engine codegen:registry + - run: + name: Type-check against live registry types + command: bun turbo types diff --git a/.circleci/development/workflows/ci.yml b/.circleci/development/workflows/ci.yml index 612d3dd0..c69fe326 100644 --- a/.circleci/development/workflows/ci.yml +++ b/.circleci/development/workflows/ci.yml @@ -7,12 +7,14 @@ jobs: branches: ignore: - main - - openapi-freshness: - # Advisory job: exits 1 when the vendored OpenAPI snapshot is - # older than STALENESS_THRESHOLD_DAYS (default 7). Produces a - # failed CircleCI status → red X on the PR. Not a blocker - # unless added to GitHub's required-checks list. + - registry-compatibility: + # Compatibility job: regenerates registry types from the LIVE + # registry OpenAPI spec and type-checks the monorepo against + # them. Fails only when the live schema is unevaluable or the + # type check fails — never on snapshot age or generated diffs. + # Not a blocker unless added to GitHub's required-checks list. context: + - turbo-cache - github filters: branches: diff --git a/packages/engine/AGENTS.md b/packages/engine/AGENTS.md index 852ca26c..27d8d498 100644 --- a/packages/engine/AGENTS.md +++ b/packages/engine/AGENTS.md @@ -152,13 +152,18 @@ Wire errors become structured `RegistryError` values via `NOT_FOUND`, `NETWORK_ERROR` (with `attempts` count), `REGISTRY_NOT_AVAILABLE`, `UNEXPECTED_ERROR`. -A CircleCI advisory job (`openapi-snapshot-freshness` in -`.circleci/development/jobs/`) verifies the snapshot's -`Generated-At` is no more than `STALENESS_THRESHOLD_DAYS` (default -`7`) old. Stale snapshots produce a failed CircleCI status and a -red X on the PR; the check is advisory and does not block merge by -default. Add the job to GitHub branch protection if you want -hard-block behavior. +A CircleCI job (`registry-compatibility` in +`.circleci/development/jobs/`) checks compatibility against the +**live** registry: it runs `codegen:registry` against the deployed +registry's OpenAPI spec (a network dependency), regenerates the +types in the ephemeral CI checkout, and runs `bun turbo types` +across the full monorepo. It fails when the live schema can't be +fetched/validated/generated or when any type check fails. Snapshot +age and diffs against the committed generated files are not failure +conditions — the regenerated output is discarded with the checkout, +and the committed snapshot remains authoritative for deterministic +offline builds. The check does not block merge by default; add the +job to GitHub branch protection if you want hard-block behavior. ## Bun runtime diff --git a/packages/engine/scripts/sync-registry-openapi.ts b/packages/engine/scripts/sync-registry-openapi.ts index 70cbe236..ce4d3e35 100644 --- a/packages/engine/scripts/sync-registry-openapi.ts +++ b/packages/engine/scripts/sync-registry-openapi.ts @@ -3,12 +3,12 @@ * package and regenerate the TypeScript types the registry client * imports from it. * - * Two modes: + * Usage: * * bun run codegen:registry - * Sync mode. Fetches the OpenAPI YAML from - * `FACET_REGISTRY_OPENAPI_URL` (default: the live cafe registry), - * validates it parses as OpenAPI 3.x, atomically writes it to + * Fetches the OpenAPI YAML from `FACET_REGISTRY_OPENAPI_URL` + * (default: the live cafe registry), validates it parses as + * OpenAPI 3.x, atomically writes it to * `src/registry/openapi.snapshot.yaml` with a leading metadata * header, then runs `openapi-typescript` to emit * `src/registry/generated/registry-api.ts`. Idempotent: re-running @@ -16,13 +16,6 @@ * (network, parse, codegen), exits non-zero with a clear message * and leaves on-disk state untouched. * - * bun run codegen:registry --check [--strict] - * Check mode. Reads `Generated-At` from the on-disk snapshot, - * compares to `now`, prints a one-line freshness report. Pure - * offline read; never touches the network. Threshold from - * `STALENESS_THRESHOLD_DAYS` (default: 7). Exits 0 by default; - * with `--strict`, exits 1 when stale. - * * The script lives outside `src/` because it is build/dev tooling, * not engine runtime code. It is the only writer of * `src/registry/openapi.snapshot.yaml` and `src/registry/generated/*`; @@ -30,37 +23,20 @@ */ import { spawnSync } from 'node:child_process' -import { existsSync, readFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { atomicWriteFileSync } from '@agent-facets/common' import { parse as parseYaml } from 'yaml' const DEFAULT_OPENAPI_URL = 'https://api.agentfacets.io/v0/openapi.yaml' -const DEFAULT_STALENESS_THRESHOLD_DAYS = 7 const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) const ENGINE_ROOT = resolve(SCRIPT_DIR, '..') const SNAPSHOT_PATH = resolve(ENGINE_ROOT, 'src/registry/openapi.snapshot.yaml') const GENERATED_PATH = resolve(ENGINE_ROOT, 'src/registry/generated/registry-api.ts') -const GENERATED_AT_HEADER_PATTERN = /^# Generated-At:\s*(\S+)/m - -/** - * Two-mode entry point. Argument parsing is intentionally trivial — - * this script is invoked from `package.json`, not from a user shell, - * so we don't need a full flags library. - */ -async function main(argv: ReadonlyArray): Promise { - const args = new Set(argv) - if (args.has('--check')) { - return runCheck({ strict: args.has('--strict') }) - } - return runSync() -} - /** - * Sync mode. Fetch → validate → atomically write snapshot → invoke + * Fetch → validate → atomically write snapshot → invoke * codegen → atomically write generated module → print summary. * * Every failure path leaves on-disk state untouched; we only commit @@ -127,45 +103,11 @@ async function runSync(): Promise { return 0 } -/** - * Check mode. Pure offline read of the on-disk snapshot's - * `Generated-At` header; compares to `now`; prints freshness. - */ -function runCheck({ strict }: { strict: boolean }): number { - if (!existsSync(SNAPSHOT_PATH)) { - process.stderr.write(`error: snapshot missing at ${SNAPSHOT_PATH}\n`) - process.stderr.write(`run \`bun run codegen:registry\` from packages/engine to generate it.\n`) - return 1 - } - - const contents = readFileSync(SNAPSHOT_PATH, 'utf8') - const match = GENERATED_AT_HEADER_PATTERN.exec(contents) - if (match === null) { - process.stderr.write( - `error: snapshot is missing the Generated-At header — likely corrupt; regenerate with \`bun run codegen:registry\`.\n`, - ) - return 1 - } - const generatedAt = new Date(match[1]) - if (Number.isNaN(generatedAt.getTime())) { - process.stderr.write(`error: snapshot Generated-At header is not a valid ISO 8601 timestamp: ${match[1]}\n`) - return 1 - } - - const thresholdDays = parseThreshold(process.env.STALENESS_THRESHOLD_DAYS) - const ageDays = (Date.now() - generatedAt.getTime()) / (1000 * 60 * 60 * 24) - const ageDaysRounded = Math.round(ageDays * 10) / 10 - const stale = ageDays > thresholdDays - const verdict = stale ? 'STALE' : 'fresh' - process.stdout.write(`snapshot is ${ageDaysRounded} days old (threshold: ${thresholdDays}d) — ${verdict}\n`) - return stale && strict ? 1 : 0 -} - /** * Compose the on-disk snapshot: leading metadata comment block, then * upstream YAML body verbatim. The header is intentionally one - * `# Key: value` line per piece of metadata so the staleness check - * can target `Generated-At` with a one-line regex regardless of what + * `# Key: value` line per piece of metadata so any tooling can + * target a single line with a one-line regex regardless of what * other lines we add later. */ function composeSnapshot({ url, generatedAt, body }: { url: string; generatedAt: string; body: string }): string { @@ -182,21 +124,9 @@ function composeSnapshot({ url, generatedAt, body }: { url: string; generatedAt: return header + body.replace(/^\n+/, '') } -/** - * Parse `STALENESS_THRESHOLD_DAYS`. Falls back to the default on - * absent/invalid values rather than failing — the staleness check is - * advisory, not load-bearing, and a typo'd env shouldn't break CI. - */ -function parseThreshold(raw: string | undefined): number { - if (raw === undefined) return DEFAULT_STALENESS_THRESHOLD_DAYS - const parsed = Number(raw) - if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_STALENESS_THRESHOLD_DAYS - return parsed -} - function isObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } -const exitCode = await main(process.argv.slice(2)) +const exitCode = await runSync() process.exit(exitCode)