diff --git a/.github/actions/setup-fern/action.yml b/.github/actions/setup-fern/action.yml new file mode 100644 index 000000000..1f1ba7aa8 --- /dev/null +++ b/.github/actions/setup-fern/action.yml @@ -0,0 +1,22 @@ +name: Set up Fern with an example cache +description: Install the pinned CLI and restore generated API examples without skipping validation. +runs: + using: composite + steps: + - uses: actions/setup-node@v4 + with: + node-version: 22.22.2 + - name: Install pinned Fern + shell: bash + run: | + npm install --prefix "$RUNNER_TEMP/vapi-fern" --no-save --no-audit --no-fund fern-api@5.112.0 + echo "FERN_CLI_PATH=$RUNNER_TEMP/vapi-fern/node_modules/fern-api/cli.cjs" >> "$GITHUB_ENV" + - name: Test cache safeguards + shell: bash + run: node --test scripts/fern/example-cache.test.cjs + - uses: actions/cache@v4 + with: + path: .cache/fern-examples + key: fern-examples-v1-${{ runner.os }}-${{ runner.arch }}-node22.22.2-${{ hashFiles('fern/apis/**', 'fern/fern.config.json', 'scripts/fern/run.cjs', 'scripts/fern/example-cache.cjs', 'scripts/fern/register.cjs') }}-${{ github.run_id }}-${{ github.run_attempt }} + restore-keys: | + fern-examples-v1-${{ runner.os }}-${{ runner.arch }}-node22.22.2-${{ hashFiles('fern/apis/**', 'fern/fern.config.json', 'scripts/fern/run.cjs', 'scripts/fern/example-cache.cjs', 'scripts/fern/register.cjs') }}- diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 65a6b2a55..b8f63ce4b 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -13,8 +13,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - name: Install Fern - run: npm install -g fern-api + - uses: ./.github/actions/setup-fern - name: Check API is valid - run: fern check + run: node scripts/fern/run.cjs check diff --git a/.github/workflows/preview-docs.yml b/.github/workflows/preview-docs.yml index ce6eadf4f..d7b61f5d6 100644 --- a/.github/workflows/preview-docs.yml +++ b/.github/workflows/preview-docs.yml @@ -10,8 +10,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - name: Install Fern - run: npm install -g fern-api + - uses: ./.github/actions/setup-fern - name: Generate preview URL id: generate-docs @@ -19,13 +18,13 @@ jobs: FERN_TOKEN: ${{ secrets.FERN_TOKEN }} POSTHOG_PROJECT_API_KEY: ${{ secrets.POSTHOG_PROJECT_API_KEY }} run: | - OUTPUT=$(fern generate --docs --preview --log-level debug 2>&1) || true - echo "$OUTPUT" - URL=$(echo "$OUTPUT" | grep -oP 'Published docs to \K.*(?= \()') + set -euo pipefail + node scripts/fern/run.cjs generate --docs --preview --log-level debug 2>&1 | tee "$RUNNER_TEMP/fern-preview.log" + URL=$(grep -oP 'Published docs to \K.*(?= \()' "$RUNNER_TEMP/fern-preview.log") echo "Preview URL: $URL" echo "🌿 Preview your docs: $URL" > preview_url.txt - name: Comment URL in PR uses: thollander/actions-comment-pull-request@v2.4.3 with: - filePath: preview_url.txt \ No newline at end of file + filePath: preview_url.txt diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index 21735321b..afce822fe 100644 --- a/.github/workflows/publish-docs.yml +++ b/.github/workflows/publish-docs.yml @@ -13,11 +13,10 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - name: Install Fern - run: npm install -g fern-api + - uses: ./.github/actions/setup-fern - name: Publish Docs env: FERN_TOKEN: ${{ secrets.FERN_TOKEN }} POSTHOG_PROJECT_API_KEY: ${{ secrets.POSTHOG_PROJECT_API_KEY }} - run: fern generate --docs --log-level debug \ No newline at end of file + run: node scripts/fern/run.cjs generate --docs --log-level debug diff --git a/.gitignore b/.gitignore index 587625b32..d63663f59 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ dist/ .env .DS_Store .tool-versions +.cache/ # Fern AI-generated examples (regenerated on each build; not committed) fern/apis/api/ai_examples_override.yml diff --git a/scripts/fern/README.md b/scripts/fern/README.md new file mode 100644 index 000000000..8a2fffded --- /dev/null +++ b/scripts/fern/README.md @@ -0,0 +1,73 @@ +# Fern example cache + +Fern 5.112.0 repeatedly generates examples for nested API properties during +validation and publication. The GPT-Live docs publish spent about 24 minutes +preparing validation and another 25 minutes building the API navigation. Its +final deployment took six seconds. + +This adapter caches the exact return values of Fern's property and request/response +example generators. +It does not remove fields, shorten examples, skip schema checks, disable SDK +snippets, or change the API playground. Changes are confined to this docs repo. + +## How it works + +- A cold miss invokes the unmodified property-example generator. +- Successful values are stored with V8 serialization, preserving `undefined` + values that JSON would discard. Each entry has a checksum and is written + atomically. Every read returns an independent object. +- Any call that reports a diagnostic through Fern's error collector is not + cached. Exceptions still fail the build. Unreadable or corrupt entries are + regenerated normally. +- The cache namespace includes API source files under `fern/apis`, the Fern config, + adapter implementation, exact CLI checksum, Node/V8 versions, OS, and CPU + architecture. Each entry also includes the resolved API spec, parser settings, + example settings, schema, property ID, and breadcrumbs. Markdown changes do + not invalidate API examples. Fern's generated `.definition` directories and + `ai_examples_override.yml` files do not invalidate the source fingerprint. + Actual resolved examples and overrides remain part of each entry key. +- GitHub Actions restores only caches with the same API/tooling fingerprint. + Each successful run saves a new snapshot, so additional examples generated + during publishing can extend the validation cache. It never falls back to + different API inputs. The first build for new inputs fills the cache. + +## Maintenance and escape hatch + +This is a small, version-specific adapter to Fern's bundled CLI, not a public +Fern extension API. `run.cjs` verifies the complete official CLI SHA-256 and the +two expected function signatures before adding the wrappers. An unexpected +version or bundle fails before publishing. Upgrading Fern requires reviewing the +adapter and repeating the output and timing checks. + +To bypass the cache, invoke the installed Fern CLI directly. No source schema or +published content needs to change. Removing the setup action and restoring the +original Fern commands also removes this optimization. + +## Local verification + +```sh +npm install --prefix /tmp/vapi-fern --no-save fern-api@5.112.0 +export FERN_CLI_PATH=/tmp/vapi-fern/node_modules/fern-api/cli.cjs +node --test scripts/fern/example-cache.test.cjs +node scripts/fern/run.cjs check --local --log-level debug +node scripts/fern/run.cjs generate --docs --preview --log-level debug +``` + +Only use `--preview` for verification. Production publishing remains in the +existing main-branch workflow. Logs print cache hits, misses, and uncached +diagnostic-producing calls at exit. + +## Measured validation results + +On the same machine and API inputs, the original generators took 697 seconds +in docs validation setup. A fresh process using the cache took 6.9 seconds, +with 2,266 hits and zero misses. Both complete local checks reported zero errors +and 11 warnings, with identical warning text. The cached full check took about +15 seconds. This measures validation, not end-to-end publication. + +Two complete preview publications took approximately 47 and 49 seconds, with +4,532 cache hits and zero misses each. Both used the normal publishing flags, +including dynamic SDK snippets. Browser checks covered the guide, API reference, +request examples, and API Explorer form. These are local timings, not CI timings. +A first build after API or tooling changes still pays the original generation +cost before populating the cache. diff --git a/scripts/fern/example-cache.cjs b/scripts/fern/example-cache.cjs new file mode 100644 index 000000000..a478b86f9 --- /dev/null +++ b/scripts/fern/example-cache.cjs @@ -0,0 +1,113 @@ +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); +const v8 = require('node:v8'); + +const digest = (value) => crypto.createHash('sha256').update(value).digest('hex'); + +// V8 serialization preserves values but is not a canonical encoding for keys. +// Tag every value so undefined, null, NaN, and user-authored objects cannot collide. +function fingerprint(value) { + const ancestors = new Set(); + function encode(value) { + if (value === null) return ['null']; + if (value === undefined) return ['undefined']; + if (typeof value === 'string' || typeof value === 'boolean') return [typeof value, value]; + if (typeof value === 'number') return ['number', Object.is(value, -0) ? '-0' : String(value)]; + if (typeof value !== 'object') throw new Error('Unsupported cache input'); + if (value instanceof Date) return ['date', value.toISOString()]; + if (ancestors.has(value)) throw new Error('Cyclic cache input'); + ancestors.add(value); + try { + if (Array.isArray(value)) return ['array', Array.from({ length: value.length }, (_, i) => Object.hasOwn(value, i) ? encode(value[i]) : ['hole'])]; + if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) throw new Error('Unsupported cache input prototype'); + return ['object', Object.keys(value).sort().map((key) => [key, encode(value[key])])]; + } finally { + ancestors.delete(value); + } + } + return digest(JSON.stringify(encode(value))); +} + +// Cache only return values of Fern's property and request/response example generators. API parsing, +// schema validation, markdown validation, and deployment still run normally. +function createExampleCache({ directory, namespace }) { + const contexts = new WeakMap(); + const stats = { hits: 0, misses: 0, skipped: 0 }; + + function contextKey(context) { + let key = contexts.get(context); + if (key !== undefined) return key; + const { + spec, settings, generationLanguage, smartCasing, namespace: apiNamespace, + exampleGenerationArgs, authOverrides, environmentOverrides, + globalHeaderOverrides, enableUniqueErrorsPerEndpoint, generateV1Examples, + documentBaseDir, + } = context; + key = fingerprint({ + spec, settings, generationLanguage, smartCasing, namespace: apiNamespace, + exampleGenerationArgs, authOverrides, environmentOverrides, + globalHeaderOverrides, enableUniqueErrorsPerEndpoint, generateV1Examples, + documentBaseDir, + }); + contexts.set(context, key); + return key; + } + + function run(args, generate) { + const { context, ...inputs } = args; + let file; + try { + file = path.join(directory, namespace, contextKey(context), `${fingerprint(inputs)}.bin`); + } catch { + stats.skipped++; + return generate(); + } + try { + const entry = fs.readFileSync(file); + const payload = entry.subarray(65); + if (entry[64] !== 10 || entry.subarray(0, 64).toString() !== digest(payload)) throw new Error('Invalid cache checksum'); + const result = v8.deserialize(payload); + stats.hits++; + return result; + } catch { + // A missing, stale, or corrupt cache is a normal cold build. + } + stats.misses++; + const collector = context.errorCollector; + const owned = Object.hasOwn(collector, 'collect'); + const collect = collector.collect; + let clean = true; + collector.collect = function (...values) { + clean = false; + return collect.apply(this, values); + }; + let result; + try { + result = generate(); + } finally { + if (owned) collector.collect = collect; + else delete collector.collect; + } + // Never suppress diagnostics on subsequent builds. + if (!clean) { + stats.skipped++; + return result; + } + const temporary = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`; + try { + const payload = v8.serialize(result); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(temporary, Buffer.concat([Buffer.from(`${digest(payload)}\n`), payload])); + fs.renameSync(temporary, file); + } catch { + // Cache storage must not make an otherwise valid build fail. + try { fs.unlinkSync(temporary); } catch {} + } + return result; + } + + return { run, stats }; +} + +module.exports = { createExampleCache, digest, fingerprint }; diff --git a/scripts/fern/example-cache.test.cjs b/scripts/fern/example-cache.test.cjs new file mode 100644 index 000000000..42af2484c --- /dev/null +++ b/scripts/fern/example-cache.test.cjs @@ -0,0 +1,106 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { createExampleCache, fingerprint } = require('./example-cache.cjs'); +const { inputDigest } = require('./run.cjs'); + +function fixture(t) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'fern-cache-test-')); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + const errors = []; + const context = { + spec: { components: { schemas: { Voice: { type: 'string', enum: ['marin'] } } } }, + settings: {}, + errorCollector: { collect(value) { errors.push(value); } }, + }; + const args = { context, propertySchema: { $ref: '#/components/schemas/Voice' }, breadcrumbs: ['voice'], propertyId: 'voice' }; + return { directory, context, args, errors, cache: createExampleCache({ directory, namespace: 'test' }) }; +} + +test('a new process can reuse an exact result without sharing mutable objects', (t) => { + const { directory, args, cache } = fixture(t); + const expected = { userSpecifiedExamples: {}, autogeneratedExamples: { voice: { value: 'marin', missing: undefined } } }; + assert.deepEqual(cache.run(args, () => expected), expected); + expected.autogeneratedExamples.voice.value = 'changed after conversion'; + const next = createExampleCache({ directory, namespace: 'test' }); + const hit = next.run(args, () => { throw new Error('must be a cache hit'); }); + assert.equal(hit.autogeneratedExamples.voice.value, 'marin'); + assert.ok(Object.hasOwn(hit.autogeneratedExamples.voice, 'missing')); + hit.autogeneratedExamples.voice.value = 'changed after cache read'; + assert.equal(next.run(args, () => assert.fail()).autogeneratedExamples.voice.value, 'marin'); +}); + +test('schemas, parser settings, property inputs, and namespaces invalidate entries', (t) => { + const { directory, args, cache } = fixture(t); + cache.run(args, () => 'old'); + for (const context of [ + { ...args.context, spec: { changed: true } }, + { ...args.context, settings: { changed: true } }, + { ...args.context, generationLanguage: 'python' }, + { ...args.context, smartCasing: true }, + { ...args.context, documentBaseDir: '/different-reference-base' }, + ]) assert.equal(cache.run({ ...args, context }, () => 'new'), 'new'); + assert.equal(cache.run({ ...args, propertySchema: { type: 'number' } }, () => 'new'), 'new'); + assert.equal(createExampleCache({ directory, namespace: 'new-version' }).run(args, () => 'new'), 'new'); +}); + +test('diagnostics are emitted on every run and the collector is restored', (t) => { + const { args, context, errors, cache } = fixture(t); + const original = context.errorCollector.collect; + for (let i = 0; i < 2; i++) { + cache.run(args, () => { context.errorCollector.collect('invalid example'); return {}; }); + assert.equal(context.errorCollector.collect, original); + } + assert.deepEqual(errors, ['invalid example', 'invalid example']); + assert.equal(cache.stats.hits, 0); +}); + +test('generation errors propagate and do not poison the cache', (t) => { + const { args, context, cache } = fixture(t); + const original = context.errorCollector.collect; + assert.throws(() => cache.run(args, () => { throw new Error('invalid schema'); }), /invalid schema/); + assert.equal(context.errorCollector.collect, original); + assert.equal(cache.run(args, () => 'recovered'), 'recovered'); +}); + +test('cache keys preserve null, undefined, and non-JSON numeric values', (t) => { + const { args, cache } = fixture(t); + for (const example of [null, undefined, NaN, Infinity]) { + const inputs = { ...args, mediaExampleArgs: { example } }; + assert.deepEqual(cache.run(inputs, () => ({ example })), { example }); + assert.deepEqual(cache.run(inputs, () => assert.fail()), { example }); + } + assert.equal(cache.stats.misses, 4); + assert.equal(cache.stats.hits, 4); +}); + +test('fingerprints are stable across key order and shared object identities', () => { + const shared = { text: 'hello', number: 3 }; + assert.equal(fingerprint({ a: shared, b: shared }), fingerprint({ b: { number: 3, text: 'hello' }, a: { text: 'hello', number: 3 } })); + assert.notEqual(fingerprint([undefined]), fingerprint(Array(1))); + assert.notEqual(fingerprint(NaN), fingerprint(['number', 'NaN'])); +}); + +test('corrupt entries fall back to original generation', (t) => { + const { directory, args, cache } = fixture(t); + cache.run(args, () => 'old'); + const contextDirectory = path.join(directory, 'test', fs.readdirSync(path.join(directory, 'test'))[0]); + fs.writeFileSync(path.join(contextDirectory, fs.readdirSync(contextDirectory)[0]), 'corrupt'); + assert.equal(cache.run(args, () => 'regenerated'), 'regenerated'); +}); + +test('all local API inputs invalidate the cache but prose does not', (t) => { + const { directory } = fixture(t); + fs.mkdirSync(path.join(directory, 'fern/apis/api'), { recursive: true }); + fs.writeFileSync(path.join(directory, 'fern/fern.config.json'), '{"version":"5.112.0"}'); + fs.writeFileSync(path.join(directory, 'fern/apis/api/openapi.json'), '{}'); + const before = inputDigest(directory); + fs.writeFileSync(path.join(directory, 'fern/overview.mdx'), 'new prose'); + assert.equal(inputDigest(directory), before); + fs.writeFileSync(path.join(directory, 'fern/apis/api/ai_examples_override.yml'), 'generated output'); + assert.equal(inputDigest(directory), before); + fs.writeFileSync(path.join(directory, 'fern/apis/api/overrides.yml'), 'new schema override'); + assert.notEqual(inputDigest(directory), before); +}); diff --git a/scripts/fern/register.cjs b/scripts/fern/register.cjs new file mode 100644 index 000000000..434916106 --- /dev/null +++ b/scripts/fern/register.cjs @@ -0,0 +1,4 @@ +const { createExampleCache } = require('./example-cache.cjs'); +const cache = createExampleCache({ directory: process.env.VAPI_FERN_CACHE_DIR, namespace: process.env.VAPI_FERN_CACHE_NAMESPACE }); +globalThis.__vapiFernExamples = cache.run; +process.on('exit', () => console.error('[fern-example-cache]', JSON.stringify(cache.stats))); diff --git a/scripts/fern/run.cjs b/scripts/fern/run.cjs new file mode 100644 index 000000000..aadd1e006 --- /dev/null +++ b/scripts/fern/run.cjs @@ -0,0 +1,61 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { spawnSync } = require('node:child_process'); +const { digest } = require('./example-cache.cjs'); + +const VERSION = '5.112.0'; +const CLI_SHA256 = '9932a459d6e109b40a1dcf4551317e9737779c5c3750f0c78701c0c4d58d6305'; +const MARKER = 'function u8d({propertySchema:e,breadcrumbs:t,context:r,propertyId:n})'; +const MEDIA_MARKER = 'generateOrValidateExample({schema:t,example:r,generateOptionalProperties:n,exampleGenerationStrategy:i})'; + +function inputDigest(root) { + const inputs = []; + function visit(relative) { + const absolute = path.join(root, relative); + for (const entry of fs.readdirSync(absolute, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + // Fern writes these build outputs itself. The resolved spec is also in + // each entry key, including any examples actually applied as overrides. + if (entry.name === '.definition' || entry.name === 'ai_examples_override.yml') continue; + const child = path.join(relative, entry.name); + if (entry.isDirectory()) visit(child); + else if (entry.isFile()) inputs.push([child, digest(fs.readFileSync(path.join(root, child)))]); + else throw new Error(`Unsupported API input: ${child}`); + } + } + visit('fern/apis'); + inputs.push(['fern/fern.config.json', digest(fs.readFileSync(path.join(root, 'fern/fern.config.json')))]); + inputs.push(['runtime', VERSION, CLI_SHA256, process.versions.node, process.versions.v8, process.platform, process.arch]); + inputs.push(['cache-code', digest(fs.readFileSync(path.join(__dirname, 'example-cache.cjs')))]); + inputs.push(['adapter-code', digest(fs.readFileSync(__filename))]); + return digest(JSON.stringify(inputs)); +} + +function main() { + const root = path.resolve(__dirname, '../..'); + const config = JSON.parse(fs.readFileSync(path.join(root, 'fern/fern.config.json'), 'utf8')); + if (config.version !== VERSION) throw new Error('Fern version changed. Review and revalidate the example-cache adapter before publishing.'); + if (!process.env.FERN_CLI_PATH) throw new Error('Set FERN_CLI_PATH to the installed fern-api/cli.cjs (see scripts/fern/README.md).'); + const source = fs.readFileSync(process.env.FERN_CLI_PATH, 'utf8'); + if (digest(source) !== CLI_SHA256 || source.split(MARKER).length !== 2 || source.split(MEDIA_MARKER).length !== 2) throw new Error('Unexpected Fern CLI contents. Refusing to apply the cache adapter.'); + const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'vapi-fern-')); + const patched = source + .replace(MARKER, 'function u8d(args){return globalThis.__vapiFernExamples(args,()=>vapiOriginalExamples(args))}function vapiOriginalExamples({propertySchema:e,breadcrumbs:t,context:r,propertyId:n})') + .replace(MEDIA_MARKER, 'generateOrValidateExample(args){return globalThis.__vapiFernExamples({context:this.context,breadcrumbs:this.breadcrumbs,mediaExampleArgs:args},()=>this.vapiOriginalMediaExample(args))}vapiOriginalMediaExample({schema:t,example:r,generateOptionalProperties:n,exampleGenerationStrategy:i})'); + try { + const cli = path.join(temporary, 'fern.cjs'); + fs.writeFileSync(cli, patched); + const result = spawnSync(process.execPath, ['--require', path.join(__dirname, 'register.cjs'), cli, ...process.argv.slice(2)], { + cwd: root, + stdio: 'inherit', + env: { ...process.env, VAPI_FERN_CACHE_NAMESPACE: inputDigest(root), VAPI_FERN_CACHE_DIR: path.join(root, '.cache/fern-examples') }, + }); + if (result.error) throw result.error; + process.exitCode = result.status ?? 1; + } finally { + fs.rmSync(temporary, { recursive: true, force: true }); + } +} + +if (require.main === module) main(); +module.exports = { inputDigest };