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
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/actions/setup-fern/action.yml
Original file line number Diff line number Diff line change
@@ -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') }}-
5 changes: 2 additions & 3 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
11 changes: 5 additions & 6 deletions .github/workflows/preview-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,21 @@ 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
env:
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
filePath: preview_url.txt
5 changes: 2 additions & 3 deletions .github/workflows/publish-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
run: node scripts/fern/run.cjs generate --docs --log-level debug
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
73 changes: 73 additions & 0 deletions scripts/fern/README.md
Original file line number Diff line number Diff line change
@@ -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.
113 changes: 113 additions & 0 deletions scripts/fern/example-cache.cjs
Original file line number Diff line number Diff line change
@@ -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 };
106 changes: 106 additions & 0 deletions scripts/fern/example-cache.test.cjs
Original file line number Diff line number Diff line change
@@ -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);
});
4 changes: 4 additions & 0 deletions scripts/fern/register.cjs
Original file line number Diff line number Diff line change
@@ -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)));
Loading
Loading