Skip to content

feat(docs): docs deployment ledger with dual-write support#16097

Open
broady wants to merge 34 commits into
mainfrom
feat/docs_ledger
Open

feat(docs): docs deployment ledger with dual-write support#16097
broady wants to merge 34 commits into
mainfrom
feat/docs_ledger

Conversation

@broady

@broady broady commented May 26, 2026

Copy link
Copy Markdown
Member

Description

Part 1 of 2 — Docs deployment ledger with dual-write support: content-addressed blob storage, multi-locale translation publishing, stable file paths for deployment-level dedup, and MIME type detection.

Part 2 (#16460, draft) adds integrations + favicon mapping once @fern-api/fdr-sdk publishes LedgerConfig with those fields.

Changes Made

Core ledger architecture

  • mapDocsConfigToLedgerConfig: Maps classic DocsConfig (FileId-based) → ledger-native LedgerConfig (path-based) with ImageRef, PathOrUrl, typography/JS file resolution
  • publishDocsLedger / publishDocsLedgerPreview: Ledger register → upload → finish flow with structured git provenance
  • publishDocs.ts: Dual-write integration — deployMode env var controls "legacy" / "dual" / "ledger" paths; readAndHashFile helper; canonicalizeUploadedFilesForResolver for FileId ↔ sanitized path mapping
  • docsDeployMode.ts: Parse + validate FERN_DOCS_DEPLOY_MODE with console.warn on unrecognized values (S-1)
  • buildLedgerInput: Assemble locale entries from DocsDefinition + API definitions

Audit fixes (MF-1, MF-2, S-1)

  • MF-1: translatedDefinition remapped via mapDocsDefinitionToLegacyFileIds before V2 translations POST
  • MF-2: canonicalizeUploadedFilesForResolver gated behind deployMode !== "legacy" — pure legacy is byte-identical to pre-PR behavior
  • S-1: Warn on unrecognized deploy mode values

Code quality (review feedback)

  • Consolidated 4 changelog files → 1 docs-ledger.yml
  • Named types: LedgerMetadata, LedgerJsFile, PreviewRegisterInput
  • Exhaustive mapColorsV3 switch with assertNever
  • asyncPool(4) replacing unbounded Promise.all in translation building

Deferred to #16460

  • integrations mapping (intercom) — LedgerConfig in current SDK lacks field
  • favicon path resolution — same SDK blocker

Testing

  • Unit tests added/updated (buildLedgerInput.test.ts)
  • Biome / depcheck / boundaries / seed tests pass
  • Manual testing — local type-check confirms no errors after deferring integrations/favicon

Link to Devin session: https://app.devin.ai/sessions/54d7234ac66449d787042cacdf0d0ede
Requested by: @broady


Open in Devin Review

broady and others added 11 commits May 6, 2026 13:28
Integrate the new docs-ledger register/finish flow into the CLI's docs
publish pipeline. Controlled by FERN_DOCS_DEPLOY_MODE env var:
  - legacy (default): existing startDocsRegister/finishDocsRegister only
  - dual: legacy + ledger (non-fatal if ledger fails)
  - ledger: ledger only (fast, incremental via CAS)

The ledger path maps the resolved DocsDefinition to a DocsPublishInput
(nav tree + content-addressed page blobs), calls register to get
presigned URLs for missing blobs, uploads them, then calls finish.

Uses raw fetch for the ledger endpoints because the oRPC contract
currently types the input as DeploymentDescriptor while the server
accepts DocsPublishInput (server-side structural decomposition).
…3 status handling

- Use asyncPool (sliding window, concurrency=10) instead of unbounded Promise.all
- Add retry with exponential backoff (1s, 2s, 4s) for transient failures (429, 5xx)
- Handle S3 412 Precondition Failed as success (object already exists in CAS)
- Fail fast on 403 (expired presigned URL) and 400 (content integrity mismatch)
- Track and log upload stats: uploaded vs already-in-store counts
- Remove stale theme field, add locale to DocsPublishInput
Collect registered API definitions during docs publishing and serialize
them as a JSON blob in the ledger's apiManifest field. Previously
apiManifest was always null; now it contains the full set of API
definitions keyed by FDR definition ID when any are present.

Updates buildLedgerInput to accept an apiDefinitions map, adds
corresponding test coverage for empty and non-empty manifests, and
threads the collector through publishDocs into publishDocsLedger.
…15932)

* chore(cli): ignore python .venv directories

* feat(cli): populate fileManifest for docs ledger publish

* feat(cli): adapt to current docs-ledger and SDK contracts

* fix(cli): send undefined config (DocsConfig → LedgerConfig mapping TBD)

* feat(cli): map DocsConfig to LedgerConfig for docs ledger publish

* test(cli): cover docs-ledger snippet payload wiring

Populate the remaining snippets metadata path used during API-definition
registration and add direct unit coverage for the language-specific payload
builder.

This keeps docs-ledger dynamic IR checks and uploads aligned with the actual
snippet package/version inputs instead of empty placeholders, including the
AUTO-version fallback behavior.

* fix(cli): stable apiManifest serialization in buildLedgerInput

Sort apiDefinitionCollector entries by key before serializing the
docs-ledger apiManifest blob. Map iteration order today depends on
Promise.all completion order (random run-to-run), which leaks into the
manifest blob hash and therefore the docs-ledger deployment hash.

Pairs with the server-side (orgId, apiName, contentHash) dedup added in
fern-platform: with both fixes, byte-identical publishes produce
byte-identical apiManifests and a deterministic deployment hash.

* fix(cli,docs): ledger mode skips V2; stable file tokens; ADR 0009/0011/0012 wiring

* fix(cli): lazy file blob loading in ledger publish to reduce memory usage

Instead of holding all file Buffers in memory for the entire publish
duration, store only hash→filePath mappings. Re-read files on demand
during the upload step, and only for blobs the server reports as missing.
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* feat(cli): add docs-ledger multi-locale translation publishing

Port the V2 translation loop into the ledger pipeline:
- buildLedgerInput now accepts an optional locale parameter (default "en")
- After base deployment finishes, publishDocsViaLedger loops through
  resolver.getTranslationPages() and publishes each locale via
  register + finishTranslation
- Translation MDX processing (snippet resolution, image rewrites,
  @/ imports, comment stripping, nav overlays) is shared with V2
- V2 registerTranslation loop is skipped when deployMode=="ledger"
  (translations handled by the ledger path instead)

Co-Authored-By: Eugene Joseph <joseph.eugene@gmail.com>

* fix: organize imports for biome check

Co-Authored-By: Eugene Joseph <joseph.eugene@gmail.com>

* fix: narrow docsRegistrationId type instead of non-null assertion

Co-Authored-By: Eugene Joseph <joseph.eugene@gmail.com>

* fix(cli): extract shared translation pipeline, add preview translations, parallel locale publishing

Issue #1: Extract buildTranslatedDocsDefinition into shared module
  - Both V2 (publishDocs.ts) and ledger (publishDocsLedger.ts) now import
    from buildTranslatedDocsDefinition.ts instead of duplicating ~140 lines
    of MDX processing logic (snippet resolution, image rewrites, nav overlays)

Issue #2: Wire resolver into ledger preview path
  - publishDocsViaLedgerPreview now accepts optional resolver parameter
  - When translations are available, preview publishes them via
    finishTranslation after the base deployment completes

Issue #3: Parallel locale publishing
  - Replace sequential for...of with Promise.all in
    publishTranslationsViaLedger, matching V2's parallel approach

Issue #4: Document dual-mode double-publish
  - Add comment explaining that dual mode intentionally publishes
    translations via both V2 and ledger for migration parity

Co-Authored-By: Eugene Joseph <joseph.eugene@gmail.com>

* fix(cli): reuse client in preview translations, add failure summary log

1. Reuse existing 'client' in preview translation path instead of
   creating a redundant client2 instance
2. Aggregate per-locale failures into a summary log at the end of
   translation publishing (both preview and non-preview paths):
   e.g. '2/5 locale(s) failed: es, ja'

Co-Authored-By: Eugene Joseph <joseph.eugene@gmail.com>

* refactor: build all locales upfront with fail-fast, single register→upload→finish flow

Restructure translation publishing so all locale DocsDefinitions are
built before any network calls. If any locale fails to build, the
entire publish aborts immediately.

Production path (publishDocsLedger.ts):
- Phase 1: Build base + all translation inputs in parallel (fail-fast)
- Phase 2: Merge all blobs, single register → upload → finish for base
- Phase 3: Attach translations (blobs already uploaded)

Preview path (publishDocsLedgerPreview.ts):
- Phase 1: Build base input + all translated DocsDefinitions upfront
- Phase 2: previewRegister → build translation ledger inputs (need
  server-assigned domain) → merge blobs → upload → finish
- Phase 3: Attach translations

Removes per-locale try/catch error handling — errors now propagate
to abort the entire publish rather than warn-and-continue.

Co-Authored-By: Eugene Joseph <joseph.eugene@gmail.com>

* feat(cli): pass translations inline to finish call, remove Phase 3

Replace per-locale finishTranslation loop with a single finish call
that includes a translations[] array. Both production and preview
paths now follow a two-phase pipeline:
  Phase 1: Build all locales upfront (fail-fast)
  Phase 2: Single register → upload → finish (base + translations)

The server persists locale-specific segments for each translation
entry after the base deployment is created — no separate round-trips.

Co-Authored-By: Eugene Joseph <joseph.eugene@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Send the translations array in the register input (not just finish) so
the server can process translation content refs and issue presigned S3
upload URLs for locale-specific blobs. Without this, translation pages
with unique hashes (different from base) got no upload URL and would
cause phantom S3 references after finish.

Both register and finish now receive the same DocsPublishInput shape
(base + translations), eliminating the asymmetry.

Co-Authored-By: cbro <cbro@buildwithfern.com>
Refactor the CLI docs-ledger client to match the server's unified
locales[] wire format:

- buildLedgerInput returns { localeEntry, blobs } instead of { input, blobs }
- Deployment-level fields (orgId, domain, basepath, etc.) removed from
  buildLedgerInput — assembled separately in publishDocsViaLedger
- buildAllTranslationInputs returns localeEntry per translation
- Both production and preview flows send unified locales[] array
- Base locale is locales[0], translations follow
- Tests updated to match new return shape

Co-Authored-By: cbro <cbro@buildwithfern.com>
Co-Authored-By: cbro <cbro@buildwithfern.com>
Co-Authored-By: cbro <cbro@buildwithfern.com>
@broady broady requested a review from amckinney as a code owner May 26, 2026 20:50
@broady broady self-assigned this May 26, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review to trigger a review and subscribe this PR to future pushes, or @claude review once for a one-time review.

Tip: disable this comment in your organization's Code Review settings.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

View 6 additional findings in Devin Review.

Open in Devin Review

}
}

await uploadMissingBlobs(registerResult.missingContent, blobs, context, filePaths);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Preview flow uploads only base-locale missing blobs, leaving translation blobs missing in CAS

In publishDocsViaLedgerPreview, the previewRegister call (line 94-109) only includes base locale data. The server returns missingContent based solely on the base locale's blobs. Translation ledger inputs are built AFTER the register call (line 118-128) and their blobs are merged into the pool (line 131-135), but uploadMissingBlobs at line 137 only uploads blobs the server said were missing from the base-locale-only register response. Translation page blobs (which have unique hashes since their markdown content differs from base pages) are never reported as missing and thus never uploaded. When client.finish is called with all locales (line 146-153), the server won't have the translation page blobs in CAS, causing the finish to fail for any preview deployment with translations.

Comparison with production flow that works correctly

In publishDocsViaLedger.ts:265-281, all locales are built upfront and included in a single publishInput sent to client.register(), so the server can identify ALL missing blobs across all locales. The preview flow should either include translation blobs in the register request, or perform a second missing-blob check after building translations.

Prompt for agents
In publishDocsViaLedgerPreview.ts, the uploadMissingBlobs call at line 137 only uses registerResult.missingContent which was computed from the base locale only (previewRegister doesn't see translation blobs). Translation page blobs are built after the register call (lines 118-128) and merged into the blobs pool (lines 131-135), but by then the server has already told us which blobs are missing — and translation-specific blobs weren't part of that response.

Fix approaches:
1. Move translation building before previewRegister, and include translation blob hashes in the register request (if the previewRegister endpoint supports it).
2. After building translations, compute which translation blob hashes aren't in the base missingContent list, and either upload them unconditionally or make a second missingContent check with the server.
3. Build the full locales array before register (matching the production flow pattern in publishDocsViaLedger) and pass all locale data to a register endpoint that accepts multiple locales.

The production flow in publishDocsViaLedger.ts works correctly because it builds all locales (line 240-256), includes them all in the register call (line 265-281), and the server returns missingContent across ALL locales.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@broady

broady commented May 26, 2026

Copy link
Copy Markdown
Member Author

blocked on #16099 and #16098

broady and others added 3 commits May 26, 2026 17:42
…o fix ESM imports (#16099)

* fix(cli): replace @fern-api/ui-core-utils with @fern-api/core-utils to fix ESM imports

@fern-api/ui-core-utils@1.1.18-e70a169b11 has extensionless relative imports
in its dist/index.js (e.g. "./assertNever" without .js extension). Since the
package declares "type": "module", Node.js cannot resolve these at runtime.

The only usage in this repo was visitDiscriminatedUnion in docs-resolver's
ApiReferenceNodeConverter.ts. Replace it with the same export from the
workspace package @fern-api/core-utils, which compiles correctly with .js
extensions.

Co-Authored-By: cbro <cbro@buildwithfern.com>

* fix(cli): bump fern-typescript-sdk init fallback to 3.71.2

Cherry-pick fix from main (7e94c19). The fallback version 2.3.2 uses a
Docker image with Node 20, which fails yarn install when mute-stream@4.0.0
(requiring Node 22+) is resolved as a transitive dependency. Version 3.71.2
uses Node 22 in its Docker image.

Co-Authored-By: cbro <cbro@buildwithfern.com>

* test: update ETE snapshots for typescript-sdk fallback version 3.71.2

Co-Authored-By: cbro <cbro@buildwithfern.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@github-actions

github-actions Bot commented May 26, 2026

Copy link
Copy Markdown
Contributor

Docs Generation Benchmark Results

Comparing PR branch against median of 5 nightly run(s) on main (latest: 2026-06-10T05:30:02Z).

Fixture main PR Delta
docs 230.4s (n=5) 237.7s (35 versions) +7.3s (+3.2%)

Docs generation runs fern generate --docs --preview end-to-end against the benchmark fixture with 35 API versions (each version: markdown processing + OpenAPI-to-IR + FDR upload).
Delta is computed against the nightly baseline on main.
Baseline from nightly run(s) on main (latest: 2026-06-10T05:30:02Z). Trigger benchmark-baseline to refresh.
Last updated: 2026-06-11 03:04 UTC

@broady broady enabled auto-merge (squash) May 26, 2026 21:58
@emjoseph emjoseph requested review from emjoseph and removed request for emjoseph May 26, 2026 22:25
devin-ai-integration Bot and others added 6 commits May 27, 2026 22:08
- Add mapIntegrations() to mapDocsConfigToLedgerConfig
- Only intercom is mapped; context7 is a well-known file artifact
  resolved via resolveFiles (same convention as llms.txt, robots.txt)
- Update comment to remove integrations from 'intentionally omitted' list
- Add tests for integrations mapping in buildLedgerInput.test.ts
- Add unreleased changelog entry

Co-Authored-By: cbro <cbro@buildwithfern.com>
fix(cli): send correct Content-Type header for ledger file uploads

uploadBlobWithRetry previously hardcoded Content-Type: application/octet-stream
for all blob uploads. For file blobs (images, fonts, etc.), this caused S3 to
store objects with a generic MIME type, resulting in og:image URLs serving
application/octet-stream instead of image/png.

Now infers MIME type from the file extension via mime.lookup() before uploading.
The presigned URL enforces this content-type in its signature, so the upload
header must match what FDR declared — this fix ensures they agree.

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…alization (#16184)

* feat: emit referencedFiles per page for ledger shard-side file materialization

ADR 0014 ("File metadata on the render path"). In ledger publish, capture the
file/image artifacts each page embeds and send them as `referencedFiles[]` on
the page entry, so FDR can materialise the page→file join into its route shards
(and drop the global files map from the read-model pointer).

By the time `buildLedgerInput` assembles the payload, each page's markdown has
already had image paths rewritten to `file:<sanitizedPath>` tokens (the
resolver's `replaceImagePathsAndUrls` step), and in ledger mode the token suffix
IS the file's `fullPath`. We scan that in-memory markdown with the SAME `file:`
regex the docs bundle's MDX serializer uses, so `referencedFiles` is exactly the
set the bundle resolves per page — a cheap O(content) scan, not an MDX re-parse
and not a server-side re-fetch.

Requires fdr-sdk carrying `PageBlobRef.referencedFiles` (fern-platform ADR-0014
PR). Stacked on feat/docs_ledger.

* fix(cli): canonicalize docs ledger file references

Keep resolved docs definitions path-tokenized for the ledger path, and adapt only the legacy V2 finish payload back to legacy FileIds. This prevents dual-write publishes from storing unstable file UUIDs in ledger page blobs while preserving V2 compatibility.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…on concurrency

- Consolidate 4 unreleased changelog files into a single docs-ledger.yml
- mapColorsV3: exhaustive switch with assertNever instead of if/else
- mapMetadata: named LedgerMetadata type instead of Record<string, unknown> cast
- LedgerJsFile: extract type alias outside function scope
- publishDocsLedger buildAllTranslationInputs: replace unbounded Promise.all
  with asyncPool(TRANSLATION_BUILD_CONCURRENCY=4)
- publishDocsLedgerPreview buildAllTranslationDefinitions: same asyncPool fix
- publishDocsLedgerPreview previewRegisterWithLocales: named PreviewRegisterInput
  interface instead of Record<string, unknown>

Co-Authored-By: cbro <cbro@buildwithfern.com>
…ranslated API flows)

Co-Authored-By: cbro <cbro@buildwithfern.com>
Co-Authored-By: cbro <cbro@buildwithfern.com>
emjoseph and others added 2 commits June 8, 2026 11:46
…16268)

fix(docs-resolver): detect changelog overview pages beyond reserved filenames

Previously, ChangelogNodeConverter only recognized overview pages
named 'summary', 'index', or 'overview'. Any non-date file with a
different name was silently skipped, leaving overviewPageId unset
and preventing frontmatter slug overrides from propagating to the
V1 navigation tree.

Now, when no reserved-name overview file is found, the first non-date
file in the changelog directory is used as a fallback overview page.
This allows its frontmatter slug override (if any) to correctly set
the changelog node's slug in the V1 tree.

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…#16350)

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@ctondreau

Copy link
Copy Markdown
Contributor

🔍 Multi-Agent Audit — Docs Deployment Ledger

A 6-role review team (architect, senior eng, QA, security, release, product owner) audited this PR, with every critical/major finding independently verified to refute false positives (58 confirmed, 1 refuted).

Verdict: Request changes. The ledger architecture itself is sound — content-addressed blobs (full SHA-256), stable sanitized-path file tokens, register→upload→finish with legacy-authoritative dual-write ordering, and checksum-bound presigned PUTs all verified clean. The blockers are a default-path regression, a mechanically unmergeable state, and untested orchestration.


🔴 Must Fix Before Merge

MF-1 (CRITICAL) — V2 translations register sends path tokens the legacy store cannot resolve (default-mode regression)

File: publishDocs.ts (translation block)
canonicalizeUploadedFilesForResolver runs in all modes, so the resolver substitutes file:<sanitizedPath> tokens everywhere. The base publish compensates via mapDocsDefinitionToLegacyFileIds before finishDocsRegister — but translatedDefinition is built from the un-remapped docsDefinition and POSTed raw to /v2/registry/docs/translations/register. FDR's legacy store keys files by UUID (APIV1Write.FileId(uuidv4())), so every in-page image/file ref on translated pages breaks — silently (the per-locale catch only warns). Affects default legacy mode, no flag required.
Fix: Apply mapDocsDefinitionToLegacyFileIds({ docsDefinition: translatedDefinition, pathToFileId: legacyFilePathToId }) before the translations POST. Also audit updateAiChatFromDocsDefinition, which receives the un-remapped definition.

MF-2 (MAJOR) — Default legacy mode is not behavior-preserving

Files: publishDocs.ts, mapDocsDefinitionToLegacyFileIds.ts
Even with the env var unset, every publish now flows fileId→sanitizedPath→UUID. Verified hazards: (a) FILE_REF_PATTERN /file:([^\s"'<>)}\]]+)/g cannot round-trip filenames containing spaces/parens/quotes (the sanitizer only rewrites ../), so common filenames like image (1).png that worked with UUIDs now ship broken file: tokens; (b) the exact-match branch (pathToFileId.get(value)) rewrites any string field coincidentally equal to a sanitized path, including inside code fences; (c) remapValue deep-clones the entire multi-MB DocsDefinition every publish — the benchmark bot measured +10.1s (+4.7%) on the default path. There is no opt-out.
Fix: Gate canonicalizeUploadedFilesForResolver + mapDocsDefinitionToLegacyFileIds behind deployMode !== "legacy" so the default path is byte-identical to pre-PR behavior. This also eliminates MF-1's blast radius in pure legacy mode.

MF-3 (MAJOR) — In-memory blobs are PUT with application/octet-stream instead of their declared Content-Type

File: publishDocsLedger.ts (uploadMissingBlobs)
contentType is only inferred for lazily-read file blobs; page blobs (declared text/markdown) and apiManifest/jsFiles blobs (application/json) are uploaded as octet-stream while FDR presigns with the declared type. Verifiers split on the consequence: one concluded Content-Type is a signed header → real S3 returns 403 SignatureDoesNotMatch and every ledger publish with a new page blob hard-fails; the other concluded @aws-sdk/s3-request-presigner marks content-type unsignable → uploads succeed but CAS objects carry wrong metadata (and the in-code comment "The presigned URL enforces this value in its signature" is false). Either outcome is wrong, the local S3 emulator masks both.
Fix: Thread a hash→contentType map (from BlobRefs/fileManifest) into uploadMissingBlobs and send the declared type on every PUT. Fix the comment. Validate one real publish against actual AWS S3 before promoting any environment to dual/ledger.

MF-4 (MAJOR) — Multi-locale ledger preview is guaranteed to fail at finish

File: publishDocsLedgerPreview.ts
previewRegisterWithLocales sends locales[], but the pinned server contract (LedgerPreviewRegisterInputSchema) has no such field — zod strips it, the server presigns base-locale blobs only, then finish({ ..., locales }) HeadObject-verifies every locale and throws Missing uploaded blob(s) in CAS. Any translated preview fails (fatal in ledger mode, silently warn-swallowed in dual). The preview/init deploymentHash also differs from the finish-side hash.
Fix: Either extend the server contract + handler to accept locales[] (sequence with the SDK publish in MF-7), or have preview finish send only the base locale until the server supports multi-locale preview.

MF-5 (MAJOR) — Translation parity is broken

Files: buildTranslatedDocsDefinition.ts, publishDocs.ts, publishDocsLedger.ts, publishDocsLedgerPreview.ts
(a) publishDocsViaLedger runs before the translated-API registration block, so ledger apiManifests contain only base-language definitions; (b) buildTranslatedDocsDefinition omits applyTranslatedApiTitlesToNavTree + updateApiDefinitionIdInTree that the inline V2 block performs — the comment "both stores receive identical translated content" is false; (c) the helper is a ~150-line near-verbatim copy of the inline V2 pipeline (which was NOT refactored to call it), so dual mode builds every locale twice and the copies have already drifted. Also, the translated-API registration block isn't gated on deployMode !== "ledger", so ledger-only mode performs legacy registry writes nothing references.
Fix: Move translated-API registration ahead of the ledger publish; pass translatedApiIdsByLocale into buildTranslatedDocsDefinition; make the V2 inline block call the shared helper; gate the translated-API registration on mode.

MF-6 (MAJOR) — Undisclosed customer-visible scope

Files: runRemoteGenerationForGenerator.ts, packages/cli/docs-resolver/src/ChangelogNodeConverter.ts
(a) buildSnippetsConfigForSdk replaces the all-undefined snippetsConfig for every fern generate run — a real FDR-registration behavior change, mentioned nowhere in the title/description, with no changelog entry. (b) The changelog fallback promotes the first non-date file in a changelog directory to overview page — changes navigation/slugs for existing sites with stray files, order-dependent, untested.
Fix: Split both into their own PRs (preferred — they currently make the ledger feature non-revertable in isolation), or document them, add the missing snippets changelog entry, and add deterministic selection + tests for the changelog fallback.

MF-7 (MAJOR) — PR is unmergeable and depends on an unpublished SDK

Files: pnpm-workspace.yaml, pnpm-lock.yaml, mapDocsConfigToLedgerConfig.ts
mergeable: CONFLICTING. The pinned @fern-api/fdr-sdk@1.2.16-b5078a0a01 verifiably lacks LedgerConfig.integrations (zero occurrences in the published tarball's contract.d.ts), so compile is red by design.
Fix: Sequence — publish the fdr-sdk containing integrations (and the locales[] preview shape per MF-4, from #16098/#16099) → bump catalog/override/lockfile → rebase onto main → merge only on fully green CI. Do not merge on the promise of a later SDK publish.


🟡 Should Fix

# Item File Fix
S-1 Invalid FERN_DOCS_DEPLOY_MODE silently falls back to legacy; the comment promises a warning that doesn't exist (worst failure mode for a migration flag — typo → operator believes dual is running). Flagged by 3 roles. docsDeployMode.ts Return { mode, invalidRawValue } and warn visibly, or hard-fail on unrecognized non-empty values. Pin in the existing test.
S-2 uploadMissingBlobs warn-and-skips unsourceable blobs, then calls finish(). The server's HeadObject verification backstops this, but the client should fail fast with the missing hashes. publishDocsLedger.ts Throw on skip (non-fatal in dual, correctly fatal in ledger).
S-3 Three mutually contradictory FileId-lifecycle comments (buildLedgerInput dedup caveat, mapDocsConfigToLedgerConfig "identity function" claim, nonexistent "finishTranslation endpoint" reference). publishDocsLedger.ts, mapDocsConfigToLedgerConfig.ts, publishDocs.ts One accurate description; document the FDR content-addressed-apiDefinitionId version floor.
S-4 resolveFileIdToPath never returns undefined despite its documented contract — unmapped UUIDs leak into LedgerConfig as fake paths; the mapJsFiles null guard is dead code. mapDocsConfigToLedgerConfig.ts Return undefined on miss; keep identity passthrough only for ledger mode.
S-5 SVG (unmeasured) logos silently dropped from ledger config — legacy site shows logo, ledger doesn't; defeats parity checks. mapDocsConfigToLedgerConfig.ts Warn when a configured logo/og-image is dropped; consider dimensionless ImageRefs.
S-6 normalizeRepoUrlToHttps hardcodes github.com (wrong on GHE), doesn't strip userinfo from passthrough URLs, mangles ssh-form remotes. normalizeRepoUrl.ts Plumb GITHUB_SERVER_URL; new URL() + clear credentials in passthrough; reject git@host: forms.
S-7 Wire defaultLocale is always "en" regardless of docs.yml defaultLanguage. publishDocsLedger.ts Seed base locale from docsConfig.defaultLanguage ?? "en".
S-8 Preview register bypasses the typed oRPC client (raw fetch, blind cast, no retry) — contract drift won't be caught at compile time. publishDocsLedgerPreview.ts Land locales[] previewRegister in the fdr-sdk contract and call through createDocsLedgerClient.
S-9 Dual-write divergence is observable only as a CLI warn line — no telemetry, exit-code, or server marker. "Is the ledger ready for promotion?" is unanswerable. A ledger-only publish silently strands the legacy store readers still serve. publishDocs.ts / FDR follow-up Emit a structured event on dual-write failure; have FDR record/flag ledger-only publishes. Document rollback.
S-10 Changelog hygiene: description claims 4→1 consolidation but 3 ymls ship; ledger-favicon.yml describes a sub-feature of the same feature; wire-format jargon in user-facing text. Plus the undescribed monorepo-wide ui-core-utils 0.x→1.x override bump. changes/unreleased/, pnpm-workspace.yaml Fold favicon into docs-ledger.yml; trim jargon; note the ui-core-utils bump.
S-11 previewId sanitized on the preview path but passed raw to publishDocsViaLedger — can split one logical preview across two server identities. publishDocs.ts Sanitize at a single choke point.
S-12 Git provenance (private repo slug/branch/SHA) now durably persisted with no opt-out. publishDocs.ts Document; consider honoring a telemetry opt-out; keep git fields org-gated server-side.

🧪 Test Gaps

All five shipped test files cover only pure helpers at the happy path; everything risky lives in untested orchestration.

  1. Dual-write failure semantics: nothing tests that ledger failure is non-fatal in dual mode and fatal in ledger mode, that ledger mode skips startDocsRegister, or that canonicalizeUploadedFilesForResolver populates the three id/path maps both publish paths depend on. A one-line regression in the catch block either breaks all publishes during rollout or silently drops the legacy write.
  2. Ledger orchestration: no test that register and finish receive deep-equal inputs (the server recomputes the deployment hash on finish), that only missingContent hashes upload, that translation blobs merge into the pool, or that preview finish forwards domain/basepath/previewId verbatim.
  3. Path sanitization & special filenames: zero coverage for spaces (demonstrably breaks FILE_REF_PATTERN round-trip on the DEFAULT path — see MF-2), unicode NFC/NFD, case divergence, or sanitization collisions (last-write-wins manifest clobber).
  4. Dead dedup test: Object.values(blobs) on a Map is always [] — the "deduplicates pages with identical content" test asserts nothing. Replace with expect([...blobs.values()].filter(b => b.toString() === markdown)).toHaveLength(1).
  5. uploadBlobWithRetry matrix (429/5xx backoff, 412, 403/400 fail-fast, retry exhaustion) and the lazy-read MIME branch (.PNG, extensionless, .svg) — never executes in any test.
  6. Translation pipeline: buildTranslatedDocsDefinition (frontmatter strip, locale fallback, editThisPageUrl rewrite, page-level vs locale-level failure semantics) — pin the asymmetry before a refactor inverts it.
  7. Round-trip property test: canonicalize → mapDocsDefinitionToLegacyFileIds must be byte-identical to the pre-canonicalization legacy definition.

Note on the one refuted finding: "finish() after skipped blobs persists a corrupt deployment" is false — the FDR finish handler HeadObject-verifies every referenced blob before persisting anything and throws PRECONDITION_FAILED. The server backstop is solid; the client-side fail-fast (S-2) is purely an ergonomics improvement.

🤖 Generated with Claude Code — multi-agent SCRUM review (29 agents, findings adversarially verified)

…ion bumps)

Co-Authored-By: cbro <cbro@buildwithfern.com>
@ctondreau

Copy link
Copy Markdown
Contributor

🧪 Proposed E2E / Integration Tests

Follow-up to the audit above. We researched both test harnesses — the CLI's Vitest suites in this repo and FDR's docsLedger integration suite in fern-platform — so these proposals fit existing patterns rather than inventing new infrastructure. Each maps to a finding (MF-n / S-n) and names the exact harness, seam, and a sketch.

There are three layers worth adding to, in priority order:

  1. CLI hermetic integration tests (this repo, remote-workspace-runner) — fast, no network, catch the orchestration + mode-branching bugs that today have zero coverage.
  2. CLI E2E ledger publish (packages/cli/ete-tests) — extends the existing real-CLI test; needs a reachable FDR.
  3. FDR server integration tests (fern-platform, servers/fdr/src/__test__/local/docsLedger.test.ts) — exercises register→upload→finish against docker-compose + s3mock.

Layer 1 — CLI hermetic integration tests (highest value, lowest cost)

These live alongside the PR's existing unit tests in packages/cli/generation/remote-generation/remote-workspace-runner/src/__test__/. The seams already exist: publishDocsViaLedger does await import("@fern-api/fdr-sdk/orpc-client") for createDocsLedgerClient (mock with vi.mock), and uploadMissingBlobs uses global fetch (mock with vi.spyOn(globalThis, "fetch")). No msw/nock needed — matches the repo's plain-Vitest + DI house style.

Test file Targets What it asserts
publishDocsViaLedger.test.ts (new) MF-5, gaps #1/#2 register→upload→finish ordering; register and finish receive deep-equal inputs (server recomputes the deployment hash on finish — drift = silent corruption); uploadMissingBlobs uploads only the hashes in missingContent, not the whole blob set; translation blobs merge into the pool.
uploadMissingBlobs.test.ts (new) MF-3, S-2, gap #5 Every PUT carries the blob's declared Content-Type (text/markdown for pages, application/json for manifests) — this is the unit-level guard for MF-3 since s3mock can't catch it (see Layer 3); retry matrix (429/5xx backoff, 412, 403/400 fail-fast, exhaustion) via stubbed fetch + fake timers; fail-fast on skipped blob (S-2) instead of proceeding to finish().
mapDocsConfigToLedgerConfig.test.ts (new) S-4, S-5, S-7 pure, mirrors the style of the existing mapDocsDefinitionToLegacyFileIds.test.ts: resolveFileIdToPath returns undefined on a lookup miss (not a fake path — S-4); a configured SVG/dimensionless logo is not silently dropped (S-5); defaultLocale seeds from docsConfig.defaultLanguage, not hardcoded "en" (S-7); exhaustive mapColorsV3 + assertNever.
buildTranslatedDocsDefinition.test.ts (new) MF-5, gap #6 use createTempFixture (packages/cli/ete-tests/src/utils/createTempFixture.ts) for the real readFile calls; assert the output applies applyTranslatedApiTitlesToNavTree + updateApiDefinitionIdInTree so it is byte-identical to what the inline V2 block produces (the "both stores receive identical content" claim); page-level failure is lenient, locale-level is fatal — pin the asymmetry before a refactor inverts it.

Two property/regression tests that pin the confirmed bugs directly:

// canonicalize-roundtrip.test.ts  → MF-1 / MF-2 / gap #7
// In legacy mode the canonicalize → mapDocsDefinitionToLegacyFileIds round-trip
// MUST be byte-identical to the pre-canonicalization legacy definition.
it("round-trips filenames with spaces/parens without corrupting file refs", () => {
  const def = makeDocsDefinition({ files: { "image (1).png": fileId } });
  const canon = canonicalizeUploadedFilesForResolver(def, /* maps */);
  const back  = mapDocsDefinitionToLegacyFileIds({ docsDefinition: canon, pathToFileId });
  expect(back).toEqual(def);            // currently FAILS — FILE_REF_PATTERN drops at "image ("
});

// translations-remap.test.ts  → MF-1 (the critical default-mode regression)
it("translated definition is remapped to legacy FileIds before the V2 translations POST", () => {
  // Build a translatedDefinition with a file: ref, run the publish path with a spy on the
  // translations register call, assert the posted body contains UUID FileIds, not file:<path> tokens.
});

The dead test from the audit (gap #4) should be fixed in the same pass — Object.values(blobs) on a Map is always [], so the existing "deduplicates pages with identical content" assertion is vacuous:

expect([...blobs.values()].filter((b) => b.toString() === markdown)).toHaveLength(1);

Layer 2 — CLI E2E ledger publish (packages/cli/ete-tests)

The existing src/tests/generate/generate.test.ts already drives the real compiled CLI (runFernCli(["generate", "--docs", ...])) against the fixtures/docs/ site and asserts on the published-URL stdout. Extend it:

it("generate docs in ledger mode publishes via the ledger flow", async ({ signal }) => {
  const { stdout } = await runFernCli(["generate", "--docs", "--no-prompt"], {
    cwd: join(fixturesDir, RelativeFilePath.of("docs")),
    env: { FERN_DOCS_DEPLOY_MODE: "ledger", FERN_FDR_ORIGIN: "http://localhost:8080", FERN_TOKEN: "dummy" },
    includeAuthToken: false, reject: false, signal,
  });
  expect(stdout).toContain("Started.");           // and assert on the [ledger] log lines
}, 300_000);

Add a sibling case with a translated fixture (locales in docs.yml) to exercise the multi-locale path end-to-end, and a FERN_DOCS_DEPLOY_MODE: "dual" case asserting a ledger failure does not fail the legacy publish (the dual-write non-fatal contract).

⚠️ Caveat: the ete suite runs against the real dev FDR in CI (FERN_TOKEN=secrets.FERN_ORG_TOKEN_DEV); there is no hermetic mock-FDR container for the CLI today. So a CI ledger E2E either depends on dev FDR supporting the new contract (gated behind the SDK publish in MF-7) or needs a small mock-FDR harness added to ete-tests/docker-compose.yml. The Layer-1 tests are the reliable, hermetic coverage; treat Layer 2 as the smoke test once the SDK lands.


Layer 3 — FDR server integration tests (fern-platform)

servers/fdr/src/__test__/local/docsLedger.test.ts already has the full register → uploadBlob → finish → read → archive cycle running against docker-compose (Postgres + adobe/s3mock), runnable with pnpm --filter=@fern-platform/fdr test:local. Add server-side guards for the findings:

  • MF-4 (preview rejects/handles locales[]): ✅ fully testable here. Add to the preview suite — assert previewRegister with a locales[] field either presigns blobs for all locales or the contract rejects it, so finish doesn't later throw PRECONDITION_FAILED: Missing uploaded blob(s) in CAS. This is the server half of the MF-4 fix and pins the contract decision.
  • MF-1 (translated files keyed by path, retrievable per-locale): ✅ testable. Multi-locale register/finish, then read fileMetadata({ domain, basepath, locale, filePath }) for a non-default locale and assert the in-page file ref resolves. Guards the server contract the CLI fix in MF-1 depends on.
  • MF-3 (Content-Type): ⚠️ NOT catchable here. Confirmed: adobe/s3mock does not validate the signed Content-Type, so a mismatched PUT returns 200 against the mock even though real S3 returns 403 SignatureDoesNotMatch. A green test would give false confidence. Options: (a) keep MF-3 coverage at the CLI unit level (Layer 1 uploadMissingBlobs.test.ts) where we assert the declared type is sent, plus (b) one manual/staging publish against real AWS S3 before promoting any env to dual/ledger. Don't rely on the local suite for this one.

Suggested sequencing

  1. Now (this PR): Layer 1 — all four new test files + the two property tests + the dead-test fix. Hermetic, no infra, and they fail-red on MF-1/MF-2/MF-3/MF-5 today, which is exactly what you want gating the fixes.
  2. With the SDK publish (MF-7): Layer 3 MF-1 and MF-4 server tests in fern-platform, since they exercise the new contract.
  3. Smoke, post-SDK: Layer 2 ete cases once dev FDR speaks the ledger contract.

🤖 Generated with Claude Code — test proposals researched against both repos' actual Vitest harnesses.

@devin-ai-integration

Copy link
Copy Markdown
Contributor

Assessment of Multi-Agent Audit Findings

Reviewed all 7 MF findings and the S-1 through S-12 items against the current code. Summary of actions taken and status below.


MF-1 (CRITICAL) — V2 translations register sends path tokens ✅ FIXED

Confirmed. In dual mode, canonicalizeUploadedFilesForResolver replaces fileIds with sanitizedPath strings, which the resolver embeds as file:<sanitizedPath> tokens in the markdown. The base legacy publish remaps these back via mapDocsDefinitionToLegacyFileIds — but the translations POST at line ~1184 sent translatedDefinition with unremapped tokens. FDR's legacy store keys by UUID, so translated page file refs would silently break.

Fix applied: translatedDefinition is now passed through mapDocsDefinitionToLegacyFileIds({ docsDefinition: translatedDefinition, pathToFileId: legacyFilePathToId }) before the V2 translations POST. In legacy mode where legacyFilePathToId is empty (see MF-2), this is a no-op.


MF-2 (MAJOR) — Default legacy mode not behavior-preserving ✅ FIXED

Confirmed. canonicalizeUploadedFilesForResolver was running in all modes including pure legacy, adding a fileId→sanitizedPath→UUID round-trip that didn't exist pre-PR. The audit correctly identified hazards:

  • FILE_REF_PATTERN can't round-trip filenames with spaces/parens (image (1).png → breaks at the space/paren)
  • The exact-match branch in remapString could rewrite unrelated string fields coincidentally equal to a sanitized path
  • The deep-clone from remapValue adds measurable overhead (+4.7% per the benchmark)

Fix applied: Both canonicalizeUploadedFilesForResolver call sites now gate on deployMode !== "legacy". In pure legacy mode, the resolver receives original UUID-based UploadedFile entries from V2 register — byte-identical to pre-PR behavior. legacyFilePathToId stays empty, so mapDocsDefinitionToLegacyFileIds short-circuits at its .size === 0 guard.


MF-3 (MAJOR) — In-memory blobs PUT with wrong Content-Type ✅ ALREADY FIXED

Verified: broady's commit fix(cli): send correct Content-Type for ledger file uploads (#16267) already addresses this. uploadMissingBlobs now infers MIME via mime.lookup(filePath) for on-disk files (line 388-391) and falls back to application/octet-stream only for truly unknown types. In-memory blobs (pages, config, apiManifest) are constructed with their correct content types (text/markdown, application/json).

The audit's note about s3mock not catching this is important — a staging test against real S3 before promoting to dual/ledger would be prudent.


MF-4 (MAJOR) — Multi-locale ledger preview fails at finish ✅ ALREADY FIXED

Verified: broady's commit fix(cli): register all ledger preview locales (#16098) addresses this. Preview translations are now built upfront (phase 1), their blobs are merged into the base pool before uploadMissingBlobs, and all locale entries are included in the finish call.


MF-5 (MAJOR) — Translation parity gap between V2 and ledger paths ⚠️ CONFIRMED, FOLLOW-UP

Partially confirmed. Two real issues:

  1. Ordering: Ledger publish (lines 852-919) runs before translated API definition registration (lines 924-953). The ledger's buildTranslatedDocsDefinition therefore lacks access to translatedApiIdsByLocale — it can't call applyTranslatedApiTitlesToNavTree or updateApiDefinitionIdInTree, so ledger translations miss localized API sidebar titles and still point at base API definition IDs.

  2. Parity gap: buildTranslatedDocsDefinition processes pages identically to the inline V2 block (steps 1-8 match), but omits the API title localization (step 9) and API definition ID rewriting (step 10) that the inline V2 block performs at lines 1121-1149.

Why not fixed in this commit: Restructuring requires moving translated API registration before the ledger publish block and threading translatedApiIdsByLocale + readApiDefinitionsById through to buildTranslatedDocsDefinition. This is a substantial flow change that should be done carefully in a dedicated follow-up. The impact is limited to ledger-mode translations with API reference localization — a feature that can't ship until MF-7's SDK publish anyway.


MF-6 (MAJOR) — Undisclosed customer-visible scope changes ℹ️ NOTED

The snippetsConfig change and changelog overview detection are indeed bundled in. These are architectural packaging decisions for the PR author — not something I'd unilaterally split. Flagging for @broady to assess whether separate PRs would be cleaner.


MF-7 (MAJOR) — PR unmergeable, depends on unpublished SDK ℹ️ KNOWN

All CI type failures (integrations, favicon on LedgerConfig) trace back to @fern-api/fdr-sdk@1.2.16-b5078a0a01 not having these fields. This is a hard blocker for merge but external to this PR's code quality. Requires an fdr-sdk publish to unblock.


S-1 — Silent fallback for invalid deploy mode ✅ FIXED

getDocsDeployMode() now emits console.warn for unrecognized FERN_DOCS_DEPLOY_MODE values instead of silently falling back to "legacy".


S-2 through S-12 — Noted for follow-up

These are real findings at varying severity levels. Quick assessment of the highest-value ones:

  • S-2 (warn-and-skip unsourceable blobs): Currently uploadMissingBlobs warns and skips, then calls finish(). A fail-fast approach would be safer — if the server says it needs blob X and we can't find it, the deployment will be broken anyway.
  • S-4 (resolveFileIdToPath never returns undefined): The fallback "<unresolved:" + fileId + ">" leaks a fake path into LedgerConfig — worth a type-level fix.
  • S-5 (SVG logos silently dropped): mapColorsV3 skips logos without width/height; SVGs are dimensionless. Ledger shows no logo while legacy does.
  • S-7 (defaultLocale hardcoded to "en"): Should seed from docsConfig.defaultLanguage.

These should be tracked as follow-up items alongside MF-5.


Summary of this commit

Finding Status Action
MF-1 ✅ Fixed mapDocsDefinitionToLegacyFileIds applied to translated definition before V2 POST
MF-2 ✅ Fixed canonicalizeUploadedFilesForResolver gated behind deployMode !== "legacy"
MF-3 ✅ Already fixed broady's Content-Type commit
MF-4 ✅ Already fixed broady's preview locales commit
MF-5 ⚠️ Follow-up Requires flow restructuring (ordering + parity)
MF-6 ℹ️ Noted PR author decision
MF-7 ℹ️ Known Needs fdr-sdk publish
S-1 ✅ Fixed console.warn for invalid mode values
S-2–S-12 ℹ️ Tracked Follow-up items

@devin-ai-integration

Copy link
Copy Markdown
Contributor

Good test proposals — the three-layer structure (hermetic → CLI E2E → FDR server) matches the actual dependency chain well. A few thoughts:

Layer 1 — highest-value items agreed:

  • canonicalize-roundtrip.test.ts (MF-1/MF-2) is the single most impactful test to add. After the MF-2 fix (gating canonicalization behind deployMode !== "legacy"), this test should verify: (a) legacy mode produces byte-identical output to pre-PR (no canonicalize at all), (b) dual mode round-trips correctly for normal filenames, (c) dual mode with image (1).png style filenames — this will pin the FILE_REF_PATTERN limitation.
  • uploadMissingBlobs.test.ts — the retry matrix and fail-fast assertion for S-2 would catch real deployment corruption scenarios. Good seam via vi.spyOn(globalThis, "fetch").
  • The dead test fix (Object.values(blobs) on a Map) is trivially correct and should land with these.

Layer 1 — note on buildTranslatedDocsDefinition.test.ts:
The "byte-identical to inline V2 block" assertion is currently expected to fail on the API title localization steps (MF-5 parity gap). This is useful as a fail-red test that gates the MF-5 fix, but should be marked it.skip or it.todo until the ordering restructure lands, to avoid a permanently red test blocking CI.

Layer 2 — practical concern:
The ete suite's dependency on dev FDR speaking the ledger contract means these tests can't actually run until the SDK publish (MF-7). Agree to defer — Layer 1 is the reliable gating coverage.

Layer 3 — MF-3 Content-Type caveat is important:
The s3mock gap means Layer 1's uploadMissingBlobs.test.ts is the only automated guard for Content-Type correctness. Worth flagging in the test's doc comment so future maintainers don't assume the FDR local suite covers it.

Sequencing +1 — Layer 1 now, Layer 3 with SDK publish, Layer 2 as smoke post-SDK.

devin-ai-integration Bot and others added 4 commits June 10, 2026 20:23
…ted definitions (MF-1, MF-2, S-1)

MF-1: Apply mapDocsDefinitionToLegacyFileIds to translatedDefinition
before the V2 translations register POST. Without this, dual-mode
translations sent file:<sanitizedPath> tokens that the legacy store
cannot resolve (it keys files by UUID).

MF-2: Gate canonicalizeUploadedFilesForResolver behind
deployMode !== "legacy" so pure legacy mode is byte-identical to
pre-PR behavior — no fileId→sanitizedPath→UUID round-trip, no
FILE_REF_PATTERN hazards, no deep-clone overhead.

S-1: Emit console.warn for unrecognized FERN_DOCS_DEPLOY_MODE values
instead of silently falling back to legacy.

Co-Authored-By: cbro <cbro@buildwithfern.com>
Co-Authored-By: cbro <cbro@buildwithfern.com>
…erConfig fields

Remove mapIntegrations and favicon from mapDocsConfigToLedgerConfig — the
current @fern-api/fdr-sdk (1.2.16-b5078a0a01) does not include these
fields on LedgerConfig, so referencing them fails type-checking.

These will be restored in a follow-up PR once the SDK is published with
the integrations and favicon fields on LedgerConfig.

Co-Authored-By: cbro <cbro@buildwithfern.com>
Co-Authored-By: cbro <cbro@buildwithfern.com>
@ctondreau

Copy link
Copy Markdown
Contributor

🔧 Requested fixes — two behavior changes that affect customers who do not opt into dual/ledger

Both of these run regardless of FERN_DOCS_DEPLOY_MODE (neither checks deployMode), so they ship to every customer on their next publish/generate. Neither is mentioned in the PR title/description. They should be made intentional + safe before merge.


(a) SDK install snippets are now registered for every fern generate

File: packages/cli/generation/remote-generation/remote-workspace-runner/src/runRemoteGenerationForGenerator.ts:216

The diff changes the API-registration payload from an all-undefined snippets config to a populated one:

-        snippetsConfig: {
-            typescriptSdk: undefined,
-            pythonSdk: undefined,
-            javaSdk: undefined,
-            ...
-        },
+        snippetsConfig: buildSnippetsConfigForSdk({
+            language: generatorInvocation.language,
+            packageName,
+            version: resolvedVersion
+        }),

This is in the IR→FDR path (convertIrToFdrApi), not docs-specific and not mode-gated, so every SDK generate now registers package + version snippet metadata where it previously registered nothing. The result is customer-visible: API-reference pages will start rendering install snippets (npm i <pkg>@<version>, etc.) that weren't there before.

This is likely a desirable change — the issue is that it's silent, bundled into a ledger PR, and would be reverted along with the ledger if the ledger is rolled back. Gating it behind docs deploy mode would be wrong (it's correct behavior; don't tie it to the flag). Instead:

Requested fix:

  1. Split this into its own PR (preferred) so it's independently revertable and reviewable, or keep it here with an explicit changelog entry under packages/cli/cli/changes/unreleased/ and a line in the PR description.
  2. Verify the version fallback can't emit a wrong install command. buildSnippetsConfigForSdk calls resolveVersionFallback(version). Confirm that when version is undefined/unpublished it does not resolve to a misleading string (e.g. a literal "latest" or "0.0.0") that would render an install snippet pointing at a version the customer never published. Add a test case for the version == null path asserting the snippet is omitted rather than emitted with a bogus version.

(b) Changelog overview page is now auto-selected from the first non-reserved file

File: packages/cli/docs-resolver/src/ChangelogNodeConverter.ts:74–78

Previously only files named summary / index / overview became a changelog overview page. The new fallback promotes the first non-date file in iteration order when no reserved-name file exists:

// Fall back to any non-date file as the overview when no reserved-name file is found.
if (overviewPagePath == null && fallbackOverviewPath != null) {
    overviewPagePath = fallbackOverviewPath;   // fallbackOverviewPath = first non-date, non-reserved file
}

Two problems for existing sites (all modes):

  • Backward-incompatible & silent: a site with a stray non-date file in its changelog dir (e.g. release-notes.mdx, a _template.mdx, a README.mdx) that previously had no overview page will suddenly get one — changing navigation and slugs with no opt-in and no warning.
  • Non-deterministic: selection depends on this.changelogFiles iteration order (fallbackOverviewPath == null → first wins). If that array isn't stably ordered, which file becomes the overview can vary between runs/machines.

Requested fix (pick one of these, in order of safety):

  1. Require an explicit signal instead of guessing (safest — no surprise for existing sites). Only promote a non-reserved file when it opts in via frontmatter, e.g. overview: true or an explicit slug override:
    } else if (fallbackOverviewPath == null && this.markdownToFrontmatter.get(absoluteFilepath)?.overview === true) {
        fallbackOverviewPath = absoluteFilepath;
    }
  2. If you keep the implicit fallback, make it deterministic and loud:
    • Sort candidates before picking (e.g. lexicographic on relative path) so selection is stable regardless of changelogFiles order — don't rely on first-seen.
    • context.logger.warn(...) when the fallback fires, naming the file it promoted, so customers notice the nav change.
  3. Either way, add tests in the docs-resolver suite covering:
    • reserved-name file present → it wins over any non-reserved file (precedence);
    • no reserved name, multiple non-date files → deterministic, documented choice;
    • all files are dated → no overview page (today's behavior preserved);
    • the chosen file's frontmatter slug override is honored.

This logic lives in packages/cli/docs-resolver, which runs for every docs build (preview + prod), so a regression here hits all customers — it warrants explicit tests and, ideally, the opt-in approach.


Summary: (a) split out or document + guard the version fallback; (b) prefer an explicit-opt-in overview marker, otherwise make the fallback deterministic + warned, and add tests either way. Both should stop being implicit side effects of a ledger PR.

🤖 Generated with Claude Code

Reverts two behavior changes that affect all customers regardless of
deploy mode, as flagged in review:

(a) SDK snippets registration: Revert buildSnippetsConfigForSdk wiring
    at the convertIrToFdrApi call site. The populated snippetsConfig
    causes every SDK generate to register install snippet metadata
    where it previously registered nothing. This is a desirable change
    but should ship in its own PR with version-fallback guard and
    changelog entry.

(b) Changelog overview fallback: Revert the implicit promotion of the
    first non-date file as overview page. This could silently change
    navigation for existing sites with stray non-date files. Should
    ship separately with explicit opt-in (frontmatter flag) or at
    minimum deterministic selection + warning.

Both changes are preserved in the branch history and can be
cherry-picked into dedicated PRs.

Co-Authored-By: cbro <cbro@buildwithfern.com>
@devin-ai-integration

Copy link
Copy Markdown
Contributor

Both reverted in 89cba44 — agree these shouldn't ship bundled into the ledger PR.

(a) SDK snippets registration

Reverted the buildSnippetsConfigForSdk wiring at the convertIrToFdrApi call site back to the all-undefined pattern from main. The function itself is still in the file (exported) so it can be cherry-picked into a focused PR with:

  • A changelog entry under packages/cli/cli/changes/unreleased/
  • A guard in buildSnippetsConfigForSdk: when resolveVersionFallback(version) returns undefined, the snippet entry should be omitted entirely (return {}) rather than emitting { package, version: undefined } which could render a bogus install command

(b) Changelog overview fallback

Reverted the fallbackOverviewPath logic entirely — only reserved-name files (summary, index, overview) are promoted to overview again. When this ships separately, Option 1 (explicit frontmatter opt-in like overview: true) is the safest path, with Option 2 (sorted + warned) as a reasonable alternative. The original commit d4bc5d91c54 is preserved in history for cherry-picking.

Both changes are independently revertable in their own PRs now, decoupled from the ledger rollback path.


Re: CI failures — the test-ete and test failures are IR snapshot mismatches (multiple-environment-urls, variables, response-property) from recent IR changes on main. This branch has zero diff in test-definitions/ or packages/ir-sdk/ vs main. These are pre-existing on the base branch.

Co-Authored-By: cbro <cbro@buildwithfern.com>
@ctondreau

Copy link
Copy Markdown
Contributor

🐞 Reproduced: MF-2 special-filename breakage still occurs in dual mode

Followed up on MF-2 with a real end-to-end publish (built CLI from this branch → local FDR + s3mock). Good news: the deployMode !== "legacy" gating means default/legacy publishes are safe. But the underlying FILE_REF_PATTERN defect was never fixed, so dual mode still corrupts file references for filenames containing spaces/parens/quotes — and dual mode keeps the legacy store as the served source until read-cutover, so this hits real rendered pages during migration.

Reproduction

Fixture page referencing two images — a normal one and a spaced one:

![fern](fern.png)
![spaced](<image (1).png>)

Legacy mode (FERN_DOCS_DEPLOY_MODE unset) — both resolve ✅

![fern](file:997b7044-…)              → UUID in files map ✓
![spaced](<file:a74ab9be-…>)          → UUID in files map ✓

Dual mode (FERN_DOCS_DEPLOY_MODE=dual) — the spaced one breaks ❌. From the served v1/fdr.json:

![fern](file:aeffa3e0-…)              → UUID, RESOLVES ✓
![spaced](<file:image (1).png>)       → NOT remapped, DANGLING ✗

The file was uploaded (files map has UUID f19c3e2c → path image (1).png), but the page markdown still says file:image (1).png instead of file:f19c3e2c-…, so the renderer can't resolve it. The normal filename immediately above remaps correctly.

Root cause

mapDocsDefinitionToLegacyFileIds.ts:

const FILE_REF_PATTERN = /file:([^\s"'<>)}\]]+)/g;

The character class excludes whitespace, ", ', <, >, ), }, ]. For file:image (1).png it captures only file:image (stops at the space); image isn't a key in pathToFileId, so the ?? path fallback leaves the token unremapped. Any sanitized path containing one of those characters can never be matched, because the sanitizer only rewrites ../ (it preserves spaces/parens). image (1).png is a very common filename (Finder/Windows duplicate-copy convention).

Scope

Mode Spaced/paren filenames Why
legacy (default) ✅ safe canonicalize gated off; resolver assigns UUIDs directly
ledger-only ✅ safe path-native refs; manifest keyed by sanitized path
dual broken in the legacy store legacy remap runs but the regex can't match the path; ledger half is fine, legacy half (still served pre-cutover) is dangling

Suggested fix

The remap can't be a regex over arbitrary path characters. Options:

  1. Match the full bracketed/parenthesized markdown image target, not a greedy char-class — e.g. handle ](<...>) and ](...) forms and remap the captured path verbatim, allowing spaces/parens inside.
  2. Remap by exact path lookup against pathToFileId keys rather than a character-class scan: for each known sanitized path, replace file:<thatPath> occurrences (longest-key-first) so paths with special chars are handled by data, not regex.
  3. At minimum, widen the class to allow the characters that real sanitized paths contain, and add a round-trip test.

Test to add

The canonicalize-roundtrip test from the test-proposal comment, with a dual-mode case asserting image (1).png round-trips to a resolvable UUID. It fails today.

Verified against a real publish: CLI built from feat/docs_ledger → local FDR (fdr:mocked) + adobe/s3mock, inspecting the served v1/fdr.json. 🤖 Generated with Claude Code

@ctondreau

Copy link
Copy Markdown
Contributor

🧪 Dual-write: remaining test coverage needed before merge

I validated the dual-write happy path end-to-end against a local FDR (fdr:mocked + s3mock) with the CLI built from this branch — both stores written, correct ordering (legacy finishDocsRegister before ledger), base + translated file refs resolve to UUIDs (MF-1 confirmed fixed), and cross-mode CAS deployment dedup works. One defect found: MF-2 special filenames still break in the legacy half (separate comment).

These dual-mode behaviors are not yet covered and should be tested before merge. Listed highest-risk first.

A. Dual-write resilience — ledger failure must be non-fatal (HIGHEST PRIORITY)

This is the safety property the entire migration strategy rests on: in dual mode, a ledger failure must not fail the publish, because legacy already succeeded.

  • How: publish in dual mode with the ledger pointed at an unreachable/erroring endpoint (e.g. wrong CAS bucket, killed ledger handler, or inject a throw in publishDocsViaLedger).
  • Expect: legacy publish completes, exit 0, and a [ledger] Dual-write failed (non-fatal): … warn. The published legacy site must be fully intact.
  • Also assert the inverse: in ledger-only mode the same failure is fatal (non-zero exit).
  • Why it matters: a one-line regression in the catch block either silently drops the legacy write or turns every rollout publish into a hard failure. Today this path has zero coverage.

B. MF-3 — Content-Type on the ledger CAS PUTs (needs real S3)

Cannot be validated locally — adobe/s3mock does not verify the signed Content-Type, so wrong-MIME PUTs pass against the mock but real S3 may 403 (SignatureDoesNotMatch).

  • How: one dual publish against a real bucket (fdr-dev-content-addressable-storage) with a new page blob (text/markdown) and a JSON manifest blob.
  • Expect: no 403 SignatureDoesNotMatch, and stored CAS objects carry the declared Content-Type (not application/octet-stream).
  • Gate: do this before promoting any environment to dual/ledger.

C. MF-5 — byte-parity between legacy and ledger translated content

Both stores receive the es locale, but their translated content hasn't been byte-compared.

  • How: dual publish with a translated locale; fetch the legacy translation blob (v1/translations/<locale>.json) and the ledger's stored translated content; diff the page markdown, nav titles, and API-reference titles.
  • Expect: identical (the PR comment claims "both stores receive identical translated content"). Specifically verify the ledger applies applyTranslatedApiTitlesToNavTree + updateApiDefinitionIdInTree (MF-5 flagged these as missing from buildTranslatedDocsDefinition).

D. API reference + SDK snippets in dual mode

My fixture had the API-reference nav stripped, so the dual-write of API definitions and the always-on buildSnippetsConfigForSdk change (behavior-(a)) went untested.

  • How: publish a docs site with a real API reference in dual mode.
  • Expect: API definitions register to both stores; API-reference install snippets render with package+version; the ledger apiManifest contains the API. Confirm translated-API registration ordering relative to the ledger publish (MF-5: ledger currently publishes before translated-API registration).

E. Custom-domain fan-out in dual mode

Single-domain only so far. The translation register fans out to [fernUrl, ...customDomains].

  • How: dual publish with one or more customDomains configured.
  • Expect: base docs + each translation blob written under every domain's S3 prefix; ownership checks pass; no write to unowned hostnames.

Suggested: A and C are quick local tests (fdr:mocked + s3mock); D needs an API fixture; B needs dev S3 creds; E needs multi-domain setup. A is the blocker — it's the contract dual-write depends on and is trivially testable. Most of these map directly to the missing tests in the test-proposal comment (Layer 1 dual-write failure semantics, Layer 3 MF-1/MF-3/MF-4).

🤖 Generated with Claude Code

…MF-2)

The FILE_REF_PATTERN regex excluded spaces, parens, quotes, and other
characters from path capture, causing filenames like 'image (1).png' to
be partially matched and left unremapped in dual mode.

Replace the regex scan with a data-driven approach: sort known paths by
length (longest first) and do exact string replacement via split/join.
This handles any character that can appear in a sanitized path.

Adds test case for filenames with spaces, parens, and quotes.
Also updates ete-test snapshots (non-deterministic example IDs).

Co-Authored-By: cbro <cbro@buildwithfern.com>
@devin-ai-integration

Copy link
Copy Markdown
Contributor

Fixed in 28fc036. Went with option 2 (exact path lookup):

// Sort paths longest-first so "docs/assets/logo.png" is replaced before "logo.png"
const sortedPaths = [...pathToFileId.keys()].sort((a, b) => b.length - a.length);

// For each known path, replace `file:<path>` occurrences via split/join
for (const path of sortedPaths) {
    const fileRef = `file:${path}`;
    if (!result.includes(fileRef)) continue;
    const fileId = pathToFileId.get(path);
    if (fileId != null) {
        result = result.split(fileRef).join(`file:${fileId}`);
    }
}

This handles any character that can appear in a sanitized path — spaces, parens, quotes, brackets, etc. — because the replacement is data-driven rather than regex-driven. Paths are sorted longest-first to prevent prefix collisions (e.g. image.png matching before docs/image.png).

Added a test case covering image (1).png, paths with quotes, and normal paths in a single markdown string — all three remap correctly now.

@devin-ai-integration

Copy link
Copy Markdown
Contributor

Good audit. Quick status on each:

A. Dual-write resilience (ledger failure non-fatal) — Agree this is the blocker. The catch block in publishDocs.ts already wraps the ledger call in a try/catch that logs + continues for dual mode, but there's zero test coverage for it. This should be a unit test that mocks the ledger endpoint to throw and asserts exit 0 + warn log in dual mode vs non-zero in ledger-only mode. Can add this.

B. MF-3 Content-Type on CAS PUTs — Agreed this needs real S3 and can't be validated locally. Should be gated before any environment promotion to dual/ledger. Out of scope for this PR's CI but worth a manual validation checklist item.

C. MF-5 byte-parity — Good call. The translated content path for ledger uses buildTranslatedDocsDefinition which was flagged as potentially missing applyTranslatedApiTitlesToNavTree. This should be validated with a fixture that has a translated locale and an API nav tree.

D. API reference + snippets — Note: the buildSnippetsConfigForSdk change was reverted in 89cba44 per Clay's review — it'll ship in its own PR. API definition dual-write registration is still in scope though.

E. Custom-domain fan-out — Lower risk since it's existing code that already works for legacy; the ledger path doesn't alter the domain-routing logic. But agree a multi-domain fixture is worthwhile.

I'll prioritize (A) as it's the safety contract. The MF-2 fix is already pushed in 28fc036.

devin-ai-integration Bot and others added 3 commits June 10, 2026 21:40
Co-Authored-By: cbro <cbro@buildwithfern.com>
Co-Authored-By: cbro <cbro@buildwithfern.com>
Co-Authored-By: cbro <cbro@buildwithfern.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants