diff --git a/.github/actions/setup-fern/action.yml b/.github/actions/setup-fern/action.yml index 1f1ba7aa8..89ed43717 100644 --- a/.github/actions/setup-fern/action.yml +++ b/.github/actions/setup-fern/action.yml @@ -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') }}- diff --git a/scripts/fern/README.md b/scripts/fern/README.md index 8a2fffded..39d896e70 100644 --- a/scripts/fern/README.md +++ b/scripts/fern/README.md @@ -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 @@ -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. diff --git a/scripts/fern/example-cache.cjs b/scripts/fern/example-cache.cjs index a478b86f9..00939b87e 100644 --- a/scripts/fern/example-cache.cjs +++ b/scripts/fern/example-cache.cjs @@ -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 }) { @@ -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, @@ -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(); @@ -110,4 +139,4 @@ function createExampleCache({ directory, namespace }) { return { run, stats }; } -module.exports = { createExampleCache, digest, fingerprint }; +module.exports = { createExampleCache, digest, fingerprint, dependencyFingerprint }; diff --git a/scripts/fern/example-cache.test.cjs b/scripts/fern/example-cache.test.cjs index 42af2484c..2e42f3c4b 100644 --- a/scripts/fern/example-cache.test.cjs +++ b/scripts/fern/example-cache.test.cjs @@ -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"}'); @@ -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'); +}); diff --git a/scripts/fern/run.cjs b/scripts/fern/run.cjs index aadd1e006..621c02855 100644 --- a/scripts/fern/run.cjs +++ b/scripts/fern/run.cjs @@ -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')))]);