Skip to content
Open
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
4 changes: 2 additions & 2 deletions .github/actions/setup-fern/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,6 @@ runs:
- 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 }}
key: fern-examples-v2-${{ runner.os }}-${{ runner.arch }}-node22.22.2-${{ hashFiles('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') }}-
fern-examples-v2-${{ runner.os }}-${{ runner.arch }}-node22.22.2-${{ hashFiles('fern/fern.config.json', 'scripts/fern/run.cjs', 'scripts/fern/example-cache.cjs', 'scripts/fern/register.cjs') }}-
31 changes: 18 additions & 13 deletions scripts/fern/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,20 @@ snippets, or change the API playground. Changes are confined to this docs repo.
- 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.
- The cache namespace includes the Fern config, adapter implementation, exact CLI
checksum, Node/V8 versions, OS, and CPU architecture. API source edits and
Markdown changes do not invalidate the namespace.
- Each entry includes the example inputs, parser and example settings, reference
base directory, and the complete contents of every transitively referenced
schema or example. Editing an unrelated endpoint or type preserves the entry.
Editing a shared type invalidates every example that depends on it. Added or
removed reference targets also invalidate affected entries, including cycles.
- Reference traversal matches the pinned Fern resolver. Non-local references
bypass caching because their contents cannot be proven unchanged from the
resolved local specification. Diagnostics and exceptions retain normal behavior.
- GitHub Actions restores caches with the same tooling fingerprint and saves a
new snapshot on each successful run. New or changed examples extend the cache.
The first build with a new tooling fingerprint fills a fresh cache.

## Maintenance and escape hatch

Expand Down Expand Up @@ -69,5 +72,7 @@ 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.
A first build after tooling changes still pays the original generation cost.
API edits regenerate only entries whose inputs or transitive references changed.
The measurements above are from the original whole-spec cache. Granular-cache
measurements are recorded separately when verified.
37 changes: 33 additions & 4 deletions scripts/fern/example-cache.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,35 @@ function fingerprint(value) {
return digest(JSON.stringify(encode(value)));
}

// Match the pinned Fern resolver, including its ~1-only pointer decoding.
// Walk every reference in the inputs and their transitive dependencies. Keep
// missing targets in the key so adding a formerly missing type invalidates it.
function dependencyFingerprint(spec, inputs) {
const references = new Map();
const visited = new WeakSet();
function visit(value) {
if (value === null || typeof value !== 'object' || visited.has(value)) return;
visited.add(value);
if (Object.hasOwn(value, '$ref')) {
const ref = value.$ref;
if (typeof ref !== 'string' || !ref.startsWith('#/')) {
throw new Error('Non-local reference requires uncached generation');
}
if (!references.has(ref)) {
let target = spec;
for (const key of ref.slice(2).split('/').map((part) => part.replace(/~1/g, '/'))) {
target = target != null && typeof target === 'object' ? target[key] : undefined;
}
references.set(ref, target);
visit(target);
}
}
for (const child of Object.values(value)) visit(child);
}
visit(inputs);
return fingerprint([...references].sort(([a], [b]) => a.localeCompare(b)));
}

// 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 }) {
Expand All @@ -39,13 +68,13 @@ function createExampleCache({ directory, namespace }) {
let key = contexts.get(context);
if (key !== undefined) return key;
const {
spec, settings, generationLanguage, smartCasing, namespace: apiNamespace,
settings, generationLanguage, smartCasing, namespace: apiNamespace,
exampleGenerationArgs, authOverrides, environmentOverrides,
globalHeaderOverrides, enableUniqueErrorsPerEndpoint, generateV1Examples,
documentBaseDir,
} = context;
key = fingerprint({
spec, settings, generationLanguage, smartCasing, namespace: apiNamespace,
settings, generationLanguage, smartCasing, namespace: apiNamespace,
exampleGenerationArgs, authOverrides, environmentOverrides,
globalHeaderOverrides, enableUniqueErrorsPerEndpoint, generateV1Examples,
documentBaseDir,
Expand All @@ -58,7 +87,7 @@ function createExampleCache({ directory, namespace }) {
const { context, ...inputs } = args;
let file;
try {
file = path.join(directory, namespace, contextKey(context), `${fingerprint(inputs)}.bin`);
file = path.join(directory, namespace, contextKey(context), `${fingerprint(inputs)}-${dependencyFingerprint(context.spec, inputs)}.bin`);
} catch {
stats.skipped++;
return generate();
Expand Down Expand Up @@ -110,4 +139,4 @@ function createExampleCache({ directory, namespace }) {
return { run, stats };
}

module.exports = { createExampleCache, digest, fingerprint };
module.exports = { createExampleCache, digest, fingerprint, dependencyFingerprint };
68 changes: 67 additions & 1 deletion scripts/fern/example-cache.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ test('corrupt entries fall back to original generation', (t) => {
assert.equal(cache.run(args, () => 'regenerated'), 'regenerated');
});

test('all local API inputs invalidate the cache but prose does not', (t) => {
test('API and prose edits preserve the namespace while tooling changes invalidate it', (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"}');
Expand All @@ -102,5 +102,71 @@ test('all local API inputs invalidate the cache but prose does not', (t) => {
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.equal(inputDigest(directory), before);
fs.writeFileSync(path.join(directory, 'fern/fern.config.json'), '{"version":"different"}');
assert.notEqual(inputDigest(directory), before);
});

test('unrelated API edits reuse entries across fresh processes', (t) => {
const { directory, args, cache } = fixture(t);
cache.run(args, () => 'original');
const spec = structuredClone(args.context.spec);
spec.components.schemas.Unrelated = { type: 'number' };
spec.paths = { '/unrelated': { get: { summary: 'New endpoint' } } };
const next = createExampleCache({ directory, namespace: 'test' });
assert.equal(next.run({ ...args, context: { ...args.context, spec } }, () => assert.fail('unrelated edit missed')), 'original');
assert.equal(next.stats.hits, 1);
});

test('transitive dependencies, cycles, and same-context mutations invalidate affected entries', (t) => {
const { args, cache } = fixture(t);
const schemas = args.context.spec.components.schemas;
schemas.Voice = { type: 'object', properties: { style: { $ref: '#/components/schemas/Style' } } };
schemas.Style = { enum: ['warm'], parent: { $ref: '#/components/schemas/Voice' } };
cache.run(args, () => 'warm');
assert.equal(cache.run(args, () => assert.fail()), 'warm');
schemas.Style.enum = ['bright'];
assert.equal(cache.run(args, () => 'bright'), 'bright');
delete schemas.Style;
assert.equal(cache.run(args, () => 'missing'), 'missing');
schemas.Style = { enum: ['soft'] };
assert.equal(cache.run(args, () => 'soft'), 'soft');
});

test('referenced examples and all union branches contribute dependencies', (t) => {
const { args, cache } = fixture(t);
const spec = args.context.spec;
spec.components.examples = { greeting: { value: 'hello' } };
spec.components.schemas.Voice = { oneOf: [{ $ref: '#/components/schemas/Other' }], example: { $ref: '#/components/examples/greeting' } };
spec.components.schemas.Other = { type: 'string' };
cache.run(args, () => 'first');
spec.components.examples.greeting.value = 'hi';
assert.equal(cache.run(args, () => 'second'), 'second');
spec.components.schemas.Other.type = 'number';
assert.equal(cache.run(args, () => 'third'), 'third');
});

test('pointer escaping matches the pinned Fern resolver', (t) => {
const { args, cache } = fixture(t);
args.propertySchema = { $ref: '#/components/schemas/a~1b~0c' };
args.context.spec.components.schemas['a/b~0c'] = { type: 'string' };
cache.run(args, () => 'first');
args.context.spec.components.schemas['a/b~0c'].type = 'number';
assert.equal(cache.run(args, () => 'second'), 'second');
});

test('external references bypass caching instead of risking stale content', (t) => {
const { args, cache } = fixture(t);
args.context.spec.components.schemas.Voice = { $ref: 'https://example.com/types.json#/Voice' };
assert.equal(cache.run(args, () => 'first'), 'first');
assert.equal(cache.run(args, () => 'second'), 'second');
assert.equal(cache.stats.skipped, 2);
});

test('media request and response examples include their referenced schemas', (t) => {
const { args, cache } = fixture(t);
const media = { context: args.context, breadcrumbs: ['response'], mediaExampleArgs: { schema: args.propertySchema, example: undefined } };
cache.run(media, () => 'marin');
args.context.spec.components.schemas.Voice.enum = ['cedar'];
assert.equal(cache.run(media, () => 'cedar'), 'cedar');
});
13 changes: 0 additions & 13 deletions scripts/fern/run.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,6 @@ const MEDIA_MARKER = 'generateOrValidateExample({schema:t,example:r,generateOpti

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')))]);
Expand Down
Loading