Skip to content

feat(cli): add git-ref-based docs versioning - #17288

Merged
Ryan-Amirthan merged 15 commits into
mainfrom
devin/1785354049-git-ref-docs-versioning
Aug 6, 2026
Merged

feat(cli): add git-ref-based docs versioning#17288
Ryan-Amirthan merged 15 commits into
mainfrom
devin/1785354049-git-ref-docs-versioning

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Description

Adds v1 of git-ref-based docs versioning to the CLI. A version entry in docs.yml can name a single ref: (a git branch, tag, or commit SHA) instead of committing that version's content into the working tree. On publish, the CLI resolves the ref to a commit, materializes the repo at that commit, and composes the historical pages/nav/assets/API/libraries with the current branch's theme, shell, and versions: metadata. path:-only entries are byte-for-byte unchanged.

versions:
  - display-name: Latest
    path: ./versions/latest.yml   # working tree, unchanged behavior
  - display-name: "2.3"
    ref: release/2.3              # patchable (branch)
  - display-name: "2.2"
    ref: v2.2.0                   # frozen (tag)
    availability: deprecated

Changes Made

  • docs.yml VersionConfig gains an optional ref: (schema + generated SDK/jsonschema regenerated). The field shape is isolated in getVersionContentRef so it can change in one spot.
  • A version uses either ref: or path:, never both — declaring both is a config error surfaced by both fern check (the valid-version-ref rule) and the publish/build path with an actionable message.
  • git-versions/: materializeGitRef (resolve <ref>^{commit}, backfill shallow/tagless CI history, git worktree materialize the whole repo, cache by SHA, prune + graceful temp cleanup) and resolveRefContentRoot (content-root precedence: ref versions[0].path → ref top-level navigation:, no recursion).
  • DocsDefinitionResolver threads a VersionContentSource so historical pages/assets/snippets/API+OSS workspaces/libraries: output resolve from the ref.
  • Version building: the publish path builds every version (no flag). fern docs dev previews only the working-tree version (git-ref-backed versions are materialized on publish); fern check never materializes refs.
  • Library sections warn-and-skip when their output is missing (library absent from config, missing _navigation.yml, missing generated MDX) — a historical version can publish without its SDK reference. This matches pre-existing single-version behavior; unlike api: sections it is not a hard failure. (An earlier hard-fail proposal was intentionally not shipped; revisit in v2.)
  • Moved/renamed fern folder at a ref → actionable CliError naming the ref, SHA, and expected path (instead of a raw ENOENT).
  • Ref resolution: also tries origin/<ref> and resolves FETCH_HEAD after a targeted fetch (covers shallow/single-branch CI checkouts); --end-of-options hardens refs beginning with -.
  • CI: added docs-preview-smoke-test/playwright/tsconfig.json so a newer Playwright doesn't walk up to the workspace-root tsconfig.json (unrelated harness fix that was blocking the smoke-test).

Scope deliberately left out (per prompt)

  • No FDR/renderer changes (emits the same versioned bundle).
  • No library-generation changes: no ref: on library inputs, no per-version library overrides, fern generate --docs does not invoke md generation. (v2.)

Testing

  • Unit tests added/updated: getVersionContentRef, resolveRefContentRoot precedence + actionable error, refPathMutualExclusion, valid-version-ref, and a git-integration matrix for materializeGitRef (remote-tracking branch, tag, unresolvable ref, shallow/tagless fetch, leading-dash ref, mutability, same-SHA caching, offline fallback, fern-folder-moved).
  • Manual testing completed: demo on fern-api/ryanstep-config (branch + tag) built with this CLI; publish composes all three versions and serves /welcome, /2-0/welcome, /1-0/welcome.

Link to Devin session: https://app.devin.ai/sessions/185c0e74b50b43f79023c793c814ac44

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

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@nitpickybot nitpickybot 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.

Reviewed the changes — everything looks good. No issues found.

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

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Devin Review found 2 potential issues.

View 3 additional findings in Devin Review.

Open in Devin Review

targetAudiences,
buildTranslatedApiDefinitions = false
buildTranslatedApiDefinitions = false,
buildRefVersions = true,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🔴 Documentation validation unexpectedly rebuilds historical versions and can fail

Historical git-ref-backed versions are built by materializing git checkouts during validation and definition-writing (because ref building is left on by default at packages/cli/docs-resolver/src/DocsDefinitionResolver.ts:221) rather than only at publish, so fern check and related commands perform git operations they were never meant to and can error out.

Impact: A project that declares a tag/branch-based docs version can no longer run fern check (or the write-definition/redirect-check flows) reliably — it triggers slow git worktree checkouts and hard-fails in common CI setups, and even the publish flow's validation step can abort before publishing.

Why buildRefVersions defaults on for validation call sites

The resolver defaults buildRefVersions = true (packages/cli/docs-resolver/src/DocsDefinitionResolver.ts:221), and it is threaded into parseDocsConfiguration (packages/cli/docs-resolver/src/DocsDefinitionResolver.ts:459) which then materializes each ref version via materializeGitRef (git rev-parse / fetch / git worktree add).

The publish path (packages/cli/generation/remote-generation/remote-workspace-runner/src/publishDocs.ts:544) and preview path (packages/cli/docs-preview/src/previewDocs.ts) were updated to pass cliVersion, but the validation/utility call sites were not and do not opt out of ref building:

  • packages/cli/yaml/docs-validator/src/rules/valid-markdown-link/valid-markdown-link.ts:80
  • packages/cli/yaml/docs-validator/src/rules/missing-redirects/missing-redirects.ts:127
  • packages/cli/cli/src/commands/write-docs-definition/writeDocsDefinitionForProject.ts:35

Both rules are registered in getAllRules.ts, so fern check runs them. Each constructs DocsDefinitionResolver without buildRefVersions, so it defaults to true and materializes every ref version. Two concrete failures follow:

  1. In shallow/tagless CI checkouts resolveRefToSha throws the actionable ConfigError — validation fails even though the PR's stated intent is that ref versions are validated at build time, not during current-tree validation.
  2. If a ref version contains an api: section, resolveApiWorkspaces (packages/cli/docs-resolver/src/DocsDefinitionResolver.ts:1185-1192) throws an InternalError because cliVersion is undefined at these call sites.

The docs-validator AST change only skipped current-tree path validation for ref entries; it did not prevent the resolver-backed rules from fully building them.

Prompt for agents
The DocsDefinitionResolver constructor defaults buildRefVersions to true so the publish path (which passes cliVersion) builds git-ref-backed versions. However three other construction sites inherit this default and neither opt out nor pass cliVersion: the docs-validator rules valid-markdown-link.ts (line ~80) and missing-redirects.ts (line ~127), and writeDocsDefinitionForProject.ts (line ~35). Because these run during `fern check` and pre-publish validation, they now materialize git worktrees for every ref version and hard-fail in shallow/tagless CI, and additionally throw an InternalError from resolveApiWorkspaces when a ref version has an `api:` section (cliVersion is undefined there). The PR's design intent is that ref versions are validated at build/publish time, not during current-tree validation. Fix by making these validation/utility call sites explicitly pass buildRefVersions: false (matching the docs-validator AST change that already skips ref entries), so validation only exercises the working-tree versions. Confirm whether writeDocsDefinitionForProject should build refs (and if so thread cliVersion through it); otherwise set it false as well.
Open in Devin Review

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch, fixed in 559ddf4. The three validation/utility construction sites (valid-markdown-link.ts, missing-redirects.ts, writeDocsDefinitionForProject.ts) now explicitly pass buildRefVersions: false, so fern check and write-definition only exercise working-tree versions and never materialize git worktrees or hit the cliVersion == null path. Ref versions are still built at publish (publishDocs.ts) and preview-with---versions all, matching the design intent that ref versions are validated at build time.

Comment on lines +99 to +108
const result = await runGit({
args: ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`],
cwd: repoRoot,
context
});
if (result.exitCode !== 0 || result.stdout.length === 0) {
return undefined;
}
return result.stdout;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🟨 Git ref from docs.yml passed to git without option terminator

The tag/branch value from docs.yml is passed directly as a positional argument to git subcommands (git rev-parse --verify --quiet ${ref}^{commit} at packages/cli/configuration-loader/src/docs-yml/git-versions/materializeGitRef.ts:100, and git fetch ... ${remote} ${ref} at packages/cli/configuration-loader/src/docs-yml/git-versions/materializeGitRef.ts:134) without a -- end-of-options separator. A ref value beginning with - (e.g. --upload-pack=...) could be interpreted by git as an option rather than a ref, enabling git option-injection. Commands are invoked via execa with an argv array (no shell), so classic shell command injection does not apply.

Open in Devin Review

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Hardened in 559ddf4 — added a -- end-of-options terminator before the ref in both git rev-parse --verify --quiet -- <ref>^{commit} and git fetch ... <remote> -- <ref>, so a ref value beginning with - can no longer be interpreted as a git flag. (As you note, execa uses an argv array with no shell, so there was never a shell-injection vector — this closes the git option-injection one.)

Ryan-Amirthan and others added 4 commits July 29, 2026 19:55
…ld in validation call sites; harden git ref args

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…treat ref as pathspec

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…right doesn't resolve root tsconfig extends

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

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Docs Generation Benchmark Results

Comparing PR branch against median of 5 nightly run(s) on main (latest: 2026-08-05T05:01:21Z).

Fixture main PR Delta
docs 252.7s (n=5) 250.4s (35 versions) -2.3s (-0.9%)

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-08-05T05:01:21Z). Trigger benchmark-baseline to refresh.
Last updated: 2026-08-05 17:49 UTC

@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

SDK Generation Benchmark Results

Comparing PR branch against median of 5 nightly run(s) on main (latest: 2026-08-05T05:01:21Z).

Full benchmark table (click to expand)
Generator Spec main (generator) main (E2E) PR (generator) Delta
csharp-sdk square 98s (n=5) N/A 90s -8s (-8.2%)
go-sdk square 151s (n=5) 298s (n=5) 141s -10s (-6.6%)
java-sdk square 208s (n=5) 272s (n=5) 168s -40s (-19.2%)
php-sdk square 64s (n=5) N/A 55s -9s (-14.1%)
python-sdk square 150s (n=5) 250s (n=5) 144s -6s (-4.0%)
ruby-sdk-v2 square 110s (n=5) 146s (n=5) 106s -4s (-3.6%)
rust-sdk square 221s (n=5) 225s (n=5) 172s -49s (-22.2%)
swift-sdk square 80s (n=5) 448s (n=5) 56s -24s (-30.0%)
ts-sdk square 133s (n=5) 134s (n=5) 172s +39s (+29.3%)

main (generator): generator-only time via --skip-scripts (includes Docker image build, container startup, IR parsing, and code generation — this is the same Docker-based flow customers use via fern generate). main (E2E): full customer-observable time including build/test scripts (nightly baseline, informational). Delta is computed against generator-only baseline.
⚠️ = generation exited with a non-zero exit code (timing may not reflect a successful run).
Baseline from nightly runs on main (latest: 2026-08-05T05:01:21Z). Trigger benchmark-baseline to refresh.
Last updated: 2026-08-05 17:49 UTC

Ryan-Amirthan and others added 8 commits July 30, 2026 13:26
A declared ref (e.g. release/2.3) present only as origin/<ref> after a
normal clone failed to resolve because git does not fall through from a
bare name to refs/remotes/<remote>/<ref>. Also resolve FETCH_HEAD after a
targeted fetch so shallow/single-branch CI checkouts can backfill the ref.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…issing library, mutable/immutable resolution)

- Replace version tag:/branch: with a single ref: field (branch, tag, or SHA),
  matching the generators.yml git-source convention; regenerate schemas.
- Drop explicit path: on ref-backed versions; content root resolves from the
  ref's versions[0].path then top-level navigation.
- Revert library sections to warn-and-skip on missing config/output (keeps the
  behavior consistent with working-tree docs; no breaking change bundled in).
- Resolve mutable branch refs from the freshest remote commit while keeping
  tags/SHAs local-first; share isCommitSha/isGitAvailable via core-utils.

Co-Authored-By: Claude <noreply@anthropic.com>
…rn docs dev --versions

Remove the `fern docs dev --versions current|all` flag and its dev/preview-path
plumbing (buildRefVersions/cliVersion/cliName through devDocsWorkspace, previewDocs,
runPreviewServer, runAppPreviewServer). `fern docs dev` now always previews the
working-tree version only; git-ref-backed versions are materialized on the publish
path. Local preview of ref-backed versions can be re-added as a follow-up.

Co-Authored-By: Claude <noreply@anthropic.com>
…n up worktree temp dirs

- materializeGitRef now validates the fern folder exists in the materialized
  worktree and throws a CliError naming the ref, sha, and expected path instead
  of leaking a raw ENOENT on docs.yml with a temp path.
- Register tmp graceful cleanup so materialized worktree temp dirs are removed on
  process exit; the start-of-run 'git worktree prune' then reaps the stale
  registrations. Verified steady-state (no unbounded /tmp or .git/worktrees growth).
- Add a real-git end-to-end matrix test covering ref resolution, mutability,
  caching, environment, content-root selection, and the fern-folder-moved case.
A git-ref-backed version derives its content root from the ref itself, so a
current-branch 'path:' has no effect. Fail with an actionable config error
instead of silently ignoring 'path:'. The check runs regardless of
buildRefVersions so 'fern check' surfaces it too.
Adds a valid-version-ref docs-validator rule so fern check surfaces the
ref+path config error (the parser-level guard only fired at build time).
Trims redundant git-ref tests (materializeGitRef/getVersionContentRef are
covered by the matrix).

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

Copy link
Copy Markdown
Contributor

LGTM, but a small nit on the interface. It seems this is the current use case:
fern generate --docs --version all
and the docs dev version doesn't work yet (can be a follow up PR)

fern docs dev --version all
(flag is just not available yet)

However, @Ryan-Amirthan why have a version flag at all? If multiple versions are provided, isn't the default to assume we'd want all - then the flag can be used to narrow down specific versions for some deploys?

@Ryan-Amirthan

Copy link
Copy Markdown
Contributor

LGTM, but a small nit on the interface. It seems this is the current use case: fern generate --docs --version all and the docs dev version doesn't work yet (can be a follow up PR)

fern docs dev --version all (flag is just not available yet)

However, @Ryan-Amirthan why have a version flag at all? If multiple versions are provided, isn't the default to assume we'd want all - then the flag can be used to narrow down specific versions for some deploys?

agreed @aditya-arolkar-swe , stale code that should have been removed when we changed the scope to remove version specific publishing.

…ing docstrings

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

devin-ai-integration Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Re: @aditya-arolkar-swe's interface nit + @Ryan-Amirthan's reply — agreed, no version flag.

Correcting my earlier wording here: fern docs dev --versions current|all did exist (added in the initial feat commit 729b01a1c76). It was intentionally removed in c58c289acb3"scope git-ref docs versioning to publish only; drop fern docs dev --versions" — which also dropped the dev/preview plumbing. My later commit 4fabaddabe3 only cleaned up leftover docstrings that still referenced the flag. So it's not "never existed" — it was deliberately scoped out.

Current behavior:

  • Publish (fern generate --docs) builds every version by default; no narrowing flag (--version is the SDK-package version flag, ignored for docs).
  • fern docs dev previews the working-tree version only.

@aditya-arolkar-swe

aditya-arolkar-swe commented Aug 5, 2026

Copy link
Copy Markdown
Contributor
  • Publish (fern generate --docs) already builds every version by default; there's no narrowing flag, and --version is ignored for docs (it's the SDK-package version flag).

Thanks - the usual fern generate --docs command now works for seeing all versions

  • fern docs dev previews the working-tree version only (git-ref-backed versions are materialized on publish). Per Ryan's call this stays the fixed behavior rather than gaining a --versions flag.

@Ryan-Amirthan why would we not want them previewing this with fern docs dev? I was able to do this before the latest commit with fern docs dev --versions all and the recent commit removed that. Was there a use case they needed?

Regardless, looks good now. Can merge and address above in follow up PR.

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

@aditya-arolkar-swe on the "why can't fern docs dev preview them" — that's @Ryan-Amirthan's scoping call (c58c289acb3), so I'll let him speak to the rationale. What I can add on the mechanics: dev preview of ref-backed versions is fully feasible — it worked in 729b01a1c76. Re-adding it means restoring the buildRefVersions/cliVersion plumbing through devDocsWorkspacepreviewDocsrunAppPreviewServer and reintroducing the flag (or just always building all in dev). The one tradeoff is dev-loop speed: each reload would materialize a git worktree per ref, which is why it was scoped to publish for v1.

Agreed this is a clean follow-up. If Ryan wants it, I'm happy to open that PR (restore dev preview, default to all versions, no flag — matching the "default = all" idea from your earlier comment).

@Ryan-Amirthan

Copy link
Copy Markdown
Contributor
  • Publish (fern generate --docs) already builds every version by default; there's no narrowing flag, and --version is ignored for docs (it's the SDK-package version flag).

Thanks - the usual fern generate --docs command now works for seeing all versions

  • fern docs dev previews the working-tree version only (git-ref-backed versions are materialized on publish). Per Ryan's call this stays the fixed behavior rather than gaining a --versions flag.

@Ryan-Amirthan why would we not want them previewing this with fern docs dev? I was able to do this before the latest commit with fern docs dev --versions all and the recent commit removed that. Was there a use case they needed?

Regardless, looks good now. Can merge and address above in follow up PR.

@aditya-arolkar-swe removed previewing from fern docs dev because I did not want fern docs dev to run slower due to it needing to create the git worktrees for each version and then render them. Since git ref based versioning is more of a snapshot in time I believe it will not be as necessary for fern docs dev. Checking out into the respective commit SHA would still allow someone to preview the version with fern docs dev.

@Ryan-Amirthan
Ryan-Amirthan merged commit 4804e1e into main Aug 6, 2026
233 checks passed
@Ryan-Amirthan
Ryan-Amirthan deleted the devin/1785354049-git-ref-docs-versioning branch August 6, 2026 18:03
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