diff --git a/.github/workflows/publish-fern-docs.yaml b/.github/workflows/publish-fern-docs.yaml index 66cfec036a..82e49a724d 100644 --- a/.github/workflows/publish-fern-docs.yaml +++ b/.github/workflows/publish-fern-docs.yaml @@ -1,9 +1,14 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -# Publishes the Fern documentation site when a docs tag is pushed or manually triggered. +# Publishes the Fern documentation site from the default branch when docs +# change, a release tag is pushed, a docs republish tag is pushed, or the +# workflow is manually triggered. # -# To publish: git tag docs/v1.2.0 && git push origin docs/v1.2.0 +# To publish a release version: push a stable SemVer release tag, for example +# `git tag 1.2.0 && git push origin 1.2.0`. +# To republish docs without creating a release version: push a docs tag, for +# example `git tag docs/v1.2.0 && git push origin docs/v1.2.0`. # Or use the "Run workflow" button in the Actions tab. # # Required configuration: @@ -18,6 +23,7 @@ on: paths: - 'docs/**' tags: + - '*.*.*' - 'docs/v*' workflow_dispatch: {} @@ -35,6 +41,8 @@ jobs: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: + ref: ${{ github.event.repository.default_branch }} + fetch-depth: 0 persist-credentials: false - name: Setup Node.js @@ -42,7 +50,28 @@ jobs: with: node-version: '20' + - name: Install Fern dependencies + working-directory: ./docs/fern + run: npm ci + + - name: Check release tag eligibility + id: release-tag + if: ${{ github.ref_type == 'tag' && !startsWith(github.ref_name, 'docs/') }} + working-directory: ./docs/fern + run: | + node scripts/materialize-release-versions.mjs --check-tag "$GITHUB_REF_NAME" >> "$GITHUB_OUTPUT" + + - name: Materialize release versions + if: ${{ steps.release-tag.outputs.eligible != 'false' }} + working-directory: ./docs/fern + run: npm run materialize:versions + + - name: Check Fern docs + if: ${{ steps.release-tag.outputs.eligible != 'false' }} + run: make docs-check + - name: Publish Docs + if: ${{ steps.release-tag.outputs.eligible != 'false' }} env: FERN_TOKEN: ${{ secrets.DOCS_FERN_TOKEN }} working-directory: ./docs/fern diff --git a/.gitignore b/.gitignore index aad7467c65..96eac96c6e 100644 --- a/.gitignore +++ b/.gitignore @@ -146,6 +146,9 @@ site/ # Generated Fern-only public API reference spec docs/fern/openapi/openapi.public.yaml +docs/fern/generated/ +docs/fern/versions/[0-9]*.yml +docs/fern/versions/v[0-9]*.yml # Generated from k8s/helm/values.yaml + README.md at docs build time (docs/fern/scripts/sync-helm-docs.mjs) docs/helm/index.mdx @@ -153,4 +156,3 @@ docs/helm/index.mdx # nektos/act files commonly used .act-variables .act-secrets - diff --git a/docs/AGENTS.md b/docs/AGENTS.md index a5a83d7239..f598ce7ad6 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -17,14 +17,14 @@ Run these from the repo root (they wrap `cd docs/fern && npm run …`): | `make docs-broken-links` | Report broken links | | `make docs-fix-links` | Auto-delink references into gated pages | -Local preview and the published site read the **same** `docs/fern/versions/latest.yml`, so what you see locally is what ships. +Normal authoring and local preview read `docs/fern/versions/latest.yml`. During production publish, `.github/workflows/publish-fern-docs.yaml` also runs `npm run materialize:versions` to generate frozen release-version entries from stable SemVer tags that already contain Fern config. Tags from before the Fern migration, such as early `0.1.x` tags, cannot become Fern versions unless those docs are backported or migrated. Use `make docs` when you are only editing `docs/fern/` config. Use `make docs-watch` when you are editing page content elsewhere under `docs/`, since it restarts the Fern dev server when repo-level docs files change outside `docs/fern/`. ## Rules that bite if you miss them -- **Navigation is the build.** Fern only builds pages listed in `docs/fern/versions/latest.yml`. A `.mdx` not in the nav is **not built** (404, not indexed) — that is how unready features are gated. Do **not** use `hidden: true` for gating (it still builds/serves the page). -- **Publication state is nav-derived.** Do not maintain or rely on a hard-coded list of gated directories. Check `docs/fern/versions/latest.yml`: listed pages are published, and omitted pages are gated. `docs/fern/gated-nav.yml` contains reference blocks for some gated features. To publish one: move its block into `latest.yml`, re-add inbound links, run `make docs-check && make docs-broken-links`. +- **Navigation is the build.** Fern only builds pages listed in a version nav file. For ordinary docs work, that is `docs/fern/versions/latest.yml`. A `.mdx` not in the nav is **not built** (404, not indexed) — that is how unready features are gated. Do **not** use `hidden: true` for gating (it still builds/serves the page). +- **Publication state is nav-derived.** Do not maintain or rely on a hard-coded list of gated directories. Check `docs/fern/versions/latest.yml`: listed pages are published in Latest, and omitted pages are gated. `docs/fern/gated-nav.yml` contains reference blocks for some gated features. To publish one: move its block into `latest.yml`, re-add inbound links, run `make docs-check && make docs-broken-links`. - **Don't link into gated pages.** A link from a published page into a gated page is a dead link. `make docs-check` fails on it; `make docs-fix-links` delinks it to plain text. (Replaces the old MkDocs `hide_unready_docs` auto-delinking.) - **Internal links** use canonical nav URLs like `/documentation/get-started/core-concepts/workspaces`, not relative `.md`/source paths. `make docs-broken-links` is the check. - **No `{{variable}}` substitutions.** Fern has no substitution step; product names are inlined as literal text. (Prompt-template tokens like `` `{{input}}` `` inside backticks are real content — leave them.) diff --git a/docs/fern/README.md b/docs/fern/README.md index 115c1b2e3e..4f6ea2cbb8 100644 --- a/docs/fern/README.md +++ b/docs/fern/README.md @@ -46,10 +46,11 @@ docs/ # page content (.mdx), one tree per product area ├── snippets/ # reusable fragments ├── scripts/ # validate-mdx.mjs, delink-gated.mjs, ipynb-to-fern-json.py ├── gated-nav.yml # reference nav blocks for gated (unready) features - └── versions/latest.yml # navigation tree (defines what gets built) + ├── generated/ # ignored release snapshots created during publish + └── versions/latest.yml # Latest navigation tree (ordinary docs authoring) ``` -The site uses a single `Latest` version. `versions/latest.yml` defines the sidebar and maps each page file to its canonical route — and, because Fern only builds pages listed there, it is also what gates unready content (see below). +Normal docs authoring targets `Latest`. `versions/latest.yml` defines the sidebar and maps each page file to its canonical route — and, because Fern only builds pages listed there, it is also what gates unready content (see below). Production publish additionally materializes frozen release versions from stable SemVer tags that already contain Fern config. ## Authoring @@ -89,6 +90,22 @@ The REST API reference is generated natively by Fern from the OpenAPI spec — n Fern groups endpoints by their OpenAPI tag in the sidebar (Customizer, Evaluator, Guardrails, …), which replaces the old per-service filter chips. Link to it from other pages with the nav URL `/documentation/reference/api-reference`. +## Release versioning + +Fern does not infer docs versions from git tags. The version selector is built from `docs.yml` `versions:` entries, and each entry points at a version-specific nav file. + +For public publishing, `.github/workflows/publish-fern-docs.yaml` runs: + +```bash +npm run materialize:versions +``` + +That script discovers stable SemVer tags in the full checkout, skips tags that predate `docs/fern/versions/latest.yml`, exports each tag's `docs/` tree into ignored `docs/fern/generated/release-versions//` snapshots, rewrites the tag's `versions/latest.yml` to point at the snapshot, and injects generated entries into `docs.yml` before `npm run generate` publishes. Tag pushes are publish signals; the workflow checks out the default branch so the current Fern publishing code and `Latest` docs remain the source for the live site. + +This means early `0.1.x` tags that were cut before the Fern migration cannot appear as true Fern versions unless someone deliberately backports/migrates those docs into Fern. A release tag such as `0.2.0`, which already contains `docs/fern`, can be materialized. + +To preview the release selector locally, run `npm run materialize:versions` from `docs/fern/`, then `npm run dev`. The generated snapshots and generated version YAMLs are gitignored; restore `docs/fern/docs.yml` afterward if you only needed a local preview. + ## Gated (unready) features Some features are not shipped yet and must be **fully excluded from the build** — not just hidden from the sidebar. Fern's `hidden: true` still builds and serves the page (reachable by direct URL and indexable), so it is **not** used for this. Instead, the gated pages are simply **left out of `versions/latest.yml`**: Fern only builds pages referenced in the navigation, so an omitted page is never built (it 404s and is not indexed). This matches the old MkDocs `hide_unready_docs` hook, which dropped the same files from the build. @@ -117,7 +134,7 @@ One difference from the old MkDocs hook: that hook ran at build time and kept th | `fern-docs-ci.yaml` | `pull_request` touching `docs/**` | `npm run check` (fern check + MDX + NotebookViewer artifacts + gated links) and `npm run broken-links` | | `fern-docs-preview-build.yaml` | `pull_request` touching `docs/**` | Upload PR `docs/` sources as an artifact (no secrets — fork-safe) | | `fern-docs-preview-comment.yaml` | successful preview build (`workflow_run`) | Build a Fern preview with `DOCS_FERN_TOKEN` and post/update the PR comment | -| `publish-fern-docs.yaml` | push to `main` touching `docs/**`, `docs/v*` tag, or manual dispatch | Publish the Fern docs site | +| `publish-fern-docs.yaml` | push to `main` touching `docs/**`, stable SemVer tag, `docs/v*` republish tag, or manual dispatch | Materialize release versions and publish the Fern docs site | Required secret: `DOCS_FERN_TOKEN` (org-level), from `fern token` for an account that can publish to the NVIDIA Fern organization. @@ -132,3 +149,4 @@ PRs that touch `docs/**` get a shared preview URL posted as a comment after the | Page 404 in preview | Check that `versions/latest.yml` lists the page (gated pages are intentionally omitted and *will* 404) | | Broken internal link | Rewrite to the nav URL `/documentation/...`; if it targets a gated page, run `make docs-fix-links` to delink it. `make docs-broken-links` reports them all | | JSX or MDX parse error | Escape raw `{}`, `<`, or `>` in prose, and use Fern components instead of raw MkDocs syntax | +| Release tag missing from version selector | Confirm the tag is stable SemVer and contains `docs/fern/versions/latest.yml`; pre-Fern tags are skipped by `npm run materialize:versions` | diff --git a/docs/fern/package-lock.json b/docs/fern/package-lock.json index 9edfe6515d..ecdf961ef9 100644 --- a/docs/fern/package-lock.json +++ b/docs/fern/package-lock.json @@ -5,7 +5,8 @@ "packages": { "": { "devDependencies": { - "@mdx-js/mdx": "^3.1.0" + "@mdx-js/mdx": "^3.1.0", + "yaml": "^2.9.0" } }, "node_modules/@mdx-js/mdx": { @@ -1800,6 +1801,22 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/docs/fern/package.json b/docs/fern/package.json index c42ea3a2a6..034dcd0641 100644 --- a/docs/fern/package.json +++ b/docs/fern/package.json @@ -8,6 +8,7 @@ "check:fern": "npm run prepare && npx -y fern-api@latest check", "validate-mdx": "node scripts/validate-mdx.mjs", "validate-notebook-viewer": "node scripts/validate-notebook-viewer.mjs", + "materialize:versions": "node scripts/materialize-release-versions.mjs", "check:gated-links": "node scripts/delink-gated.mjs", "fix:gated-links": "node scripts/delink-gated.mjs --fix", "broken-links": "npm run prepare && npx -y fern-api@latest docs broken-links", @@ -17,6 +18,7 @@ "preview": "npm run prepare && npx -y fern-api@latest generate --docs --preview" }, "devDependencies": { - "@mdx-js/mdx": "^3.1.0" + "@mdx-js/mdx": "^3.1.0", + "yaml": "^2.9.0" } } diff --git a/docs/fern/scripts/materialize-release-versions.mjs b/docs/fern/scripts/materialize-release-versions.mjs new file mode 100644 index 0000000000..d0aef804de --- /dev/null +++ b/docs/fern/scripts/materialize-release-versions.mjs @@ -0,0 +1,423 @@ +#!/usr/bin/env node +/** + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Materialize frozen Fern docs versions from release tags before publishing. + * + * Fern does not infer docs versions from git tags. The version selector is + * driven by docs.yml, and each entry needs a version yml that points at pages + * available in the current Fern project. This script exports the docs/ tree + * from stable SemVer tags that already contain Fern config, rewrites that tag's + * versions/latest.yml to point at the exported snapshot, and injects matching + * release entries into docs.yml. + */ + +import { execFileSync } from "node:child_process"; +import { + cpSync, + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, join, posix as pathPosix, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { isMap, isScalar, isSeq, parseDocument } from "yaml"; + +import { buildHelmReferenceMdx } from "./sync-helm-docs.mjs"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const fernDir = resolve(scriptDir, ".."); +const repoRoot = resolve(fernDir, "..", ".."); +const docsYmlPath = resolve(fernDir, "docs.yml"); +const versionsDir = resolve(fernDir, "versions"); +const generatedRoot = resolve(fernDir, "generated", "release-versions"); + +const generatedFileHeader = + "# Generated by scripts/materialize-release-versions.mjs; do not commit.\n"; +const taggedVersionDir = "docs/fern/versions"; +const releaseTagPattern = + /^v?(?(?0|[1-9]\d*)\.(?0|[1-9]\d*)\.(?0|[1-9]\d*))(?:-(?[0-9A-Za-z.-]+))?(?:\+(?[0-9A-Za-z.-]+))?$/; + +const argv = process.argv.slice(2); +const includePrerelease = argv.includes("--include-prerelease"); +const tagArgs = valuesForFlag("--tag"); +const checkTagArgs = valuesForFlag("--check-tag"); +if (checkTagArgs.length > 1) { + throw new Error("--check-tag accepts at most one value"); +} +const checkTag = checkTagArgs[0]; + +function valuesForFlag(flag) { + const values = []; + for (let i = 0; i < argv.length; i += 1) { + if (argv[i] === flag) { + const value = argv[i + 1]; + if (!value || value.startsWith("--")) { + throw new Error(`${flag} requires a value`); + } + values.push(value); + i += 1; + } + } + return values; +} + +// Shelling out keeps the dependency surface small and gives us native git object +// and archive semantics. The publish workflow and local dev already require git. +function git(args, options = {}) { + return execFileSync("git", args, { + cwd: repoRoot, + encoding: "utf8", + maxBuffer: 512 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + ...options, + }).trimEnd(); +} + +function gitOk(args) { + try { + execFileSync("git", args, { + cwd: repoRoot, + encoding: "utf8", + stdio: ["ignore", "ignore", "ignore"], + }); + return true; + } catch { + return false; + } +} + +function parseReleaseTag(tag) { + const match = releaseTagPattern.exec(tag); + if (!match?.groups) { + return undefined; + } + + const { core, major, minor, patch, prerelease, build } = match.groups; + const isPrerelease = prerelease !== undefined; + if (isPrerelease && !includePrerelease) { + return undefined; + } + + const normalizedVersion = [ + core, + prerelease ? `-${prerelease}` : "", + build ? `+${build}` : "", + ].join(""); + const displayName = tag.startsWith("v") + ? `v${normalizedVersion}` + : normalizedVersion; + const slug = safePathName(`v${normalizedVersion}`); + return { + tag, + displayName, + slug, + dirName: slug, + semver: [Number(major), Number(minor), Number(patch)], + prerelease, + normalizedVersion, + isPrerelease, + }; +} + +function safePathName(value) { + return value.replace(/[^A-Za-z0-9._-]/g, "-"); +} + +function compareReleaseTags(a, b) { + for (let index = 0; index < 3; index += 1) { + const diff = b.semver[index] - a.semver[index]; + if (diff !== 0) { + return diff; + } + } + + if (a.isPrerelease !== b.isPrerelease) { + return a.isPrerelease ? 1 : -1; + } + + return a.tag.localeCompare(b.tag); +} + +function discoverReleaseTags() { + const tagList = tagArgs.length > 0 ? tagArgs : git(["tag", "--list"]).split("\n"); + + const releases = []; + for (const tag of tagList.filter(Boolean)) { + const release = parseReleaseTag(tag); + if (!release) { + continue; + } + + if (!gitOk(["cat-file", "-e", `${tag}:docs/fern/versions/latest.yml`])) { + console.log(`Skipping ${tag}: tag does not contain docs/fern/versions/latest.yml`); + continue; + } + + releases.push(release); + } + + releases.sort(compareReleaseTags); + return releases; +} + +function cleanGeneratedFiles() { + rmSync(generatedRoot, { recursive: true, force: true }); + mkdirSync(generatedRoot, { recursive: true }); + + for (const entry of readdirSync(versionsDir, { withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith(".yml") || entry.name === "latest.yml") { + continue; + } + + const path = join(versionsDir, entry.name); + if (readFileSync(path, "utf8").startsWith(generatedFileHeader)) { + rmSync(path, { force: true }); + } + } +} + +function materializeRelease(release) { + const tempRoot = mkdtempSync(join(tmpdir(), `nemo-platform-docs-${release.dirName}-`)); + const outputRoot = join(generatedRoot, release.dirName); + + try { + const archive = execFileSync("git", ["archive", "--format=tar", release.tag, "docs"], { + cwd: repoRoot, + maxBuffer: 512 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }); + execFileSync("tar", ["-xf", "-", "-C", tempRoot], { + input: archive, + maxBuffer: 512 * 1024 * 1024, + stdio: ["pipe", "pipe", "pipe"], + }); + + const exportedDocs = join(tempRoot, "docs"); + rmSync(join(exportedDocs, "fern"), { recursive: true, force: true }); + rmSync(outputRoot, { recursive: true, force: true }); + cpSync(exportedDocs, outputRoot, { recursive: true }); + materializeGeneratedDocs(release, outputRoot); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + + const taggedNav = git(["show", `${release.tag}:docs/fern/versions/latest.yml`]); + const snapshotPrefix = `../generated/release-versions/${release.dirName}/`; + const rewrittenNav = rewriteNavPaths(taggedNav, snapshotPrefix); + const versionYmlPath = join(versionsDir, `${release.dirName}.yml`); + assertCanWriteGeneratedVersionNav(versionYmlPath); + writeFileSync( + versionYmlPath, + `${generatedFileHeader}${rewrittenNav}${rewrittenNav.endsWith("\n") ? "" : "\n"}`, + ); + + console.log( + `Materialized ${release.tag}: ${basename(versionYmlPath)} -> ${snapshotPrefix}`, + ); +} + +function updateDocsYml(releases, previousGeneratedVersionPaths) { + const doc = parseDocument(readFileSync(docsYmlPath, "utf8")); + assertValidYaml(doc, docsYmlPath); + + if (!isMap(doc.contents)) { + throw new Error(`${docsYmlPath} must contain a top-level YAML mapping`); + } + + const versionsNode = doc.contents.get("versions", true); + if (!isSeq(versionsNode)) { + throw new Error(`${docsYmlPath} must contain a top-level versions sequence`); + } + + const generatedPaths = new Set(releases.map((release) => versionPath(release))); + for (const path of previousGeneratedVersionPaths) { + generatedPaths.add(path); + } + + versionsNode.items = versionsNode.items.filter( + (item) => !generatedPaths.has(readVersionEntryPath(item)), + ); + + const latestEntry = versionsNode.items.find( + (item) => readVersionEntryPath(item) === "versions/latest.yml", + ); + if (!latestEntry) { + throw new Error(`${docsYmlPath} must contain a versions/latest.yml entry`); + } + + if (releases.length > 0) { + const generatedVersions = releases.map((release) => ({ + "display-name": release.displayName, + path: versionPath(release), + slug: release.slug, + availability: "stable", + })); + const generatedNode = doc.createNode(generatedVersions); + if (!isSeq(generatedNode)) { + throw new Error("Failed to build generated release versions sequence"); + } + versionsNode.items.push(...generatedNode.items); + } + + writeFileSync(docsYmlPath, String(doc)); +} + +function materializeGeneratedDocs(release, outputRoot) { + const helmReadmeRef = `${release.tag}:k8s/helm/README.md`; + const helmValuesRef = `${release.tag}:k8s/helm/values.yaml`; + if (!gitOk(["cat-file", "-e", helmReadmeRef]) || !gitOk(["cat-file", "-e", helmValuesRef])) { + return; + } + + const outputPath = join(outputRoot, "helm", "index.mdx"); + mkdirSync(dirname(outputPath), { recursive: true }); + writeFileSync( + outputPath, + buildHelmReferenceMdx(git(["show", helmReadmeRef]), git(["show", helmValuesRef])), + ); +} + +function rewriteNavPaths(navText, snapshotPrefix) { + const doc = parseDocument(navText); + assertValidYaml(doc, "tagged docs/fern/versions/latest.yml"); + rewritePathNodes(doc.contents, snapshotPrefix); + return String(doc); +} + +function rewritePathNodes(node, snapshotPrefix) { + if (isMap(node)) { + for (const pair of node.items) { + if ( + isScalar(pair.key) && + pair.key.value === "path" && + isScalar(pair.value) && + typeof pair.value.value === "string" + ) { + pair.value.value = rewriteNavPathValue(pair.value.value, snapshotPrefix); + } else { + rewritePathNodes(pair.value, snapshotPrefix); + } + } + return; + } + + if (isSeq(node)) { + for (const item of node.items) { + rewritePathNodes(item, snapshotPrefix); + } + } +} + +function rewriteNavPathValue(pathValue, snapshotPrefix) { + if (pathValue.startsWith("/") || /^[A-Za-z][A-Za-z0-9+.-]*:/.test(pathValue)) { + return pathValue; + } + + const docsPath = pathPosix.normalize(pathPosix.join(taggedVersionDir, pathValue)); + if (!docsPath.startsWith("docs/") || docsPath.startsWith("docs/fern/")) { + return pathValue; + } + + return `${snapshotPrefix}${docsPath.slice("docs/".length)}`; +} + +function existingGeneratedVersionPaths() { + const paths = []; + for (const entry of readdirSync(versionsDir, { withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith(".yml") || entry.name === "latest.yml") { + continue; + } + + const path = join(versionsDir, entry.name); + if (readFileSync(path, "utf8").startsWith(generatedFileHeader)) { + paths.push(`versions/${entry.name}`); + } + } + return paths; +} + +function readVersionEntryPath(node) { + if (!isMap(node)) { + return undefined; + } + + const pathNode = node.get("path", true); + if (!isScalar(pathNode) || typeof pathNode.value !== "string") { + return undefined; + } + + return pathNode.value; +} + +function versionPath(release) { + return `versions/${release.dirName}.yml`; +} + +function assertGeneratedVersionPathsAreUnique(releases) { + const seenByPath = new Map(); + for (const release of releases) { + const path = versionPath(release); + const existingTag = seenByPath.get(path); + if (existingTag) { + throw new Error( + `Release tags ${existingTag} and ${release.tag} both materialize ${path}`, + ); + } + seenByPath.set(path, release.tag); + } +} + +function assertGeneratedVersionNavsAreWritable(releases) { + for (const release of releases) { + assertCanWriteGeneratedVersionNav(join(versionsDir, `${release.dirName}.yml`)); + } +} + +function assertCanWriteGeneratedVersionNav(path) { + if (!existsSync(path)) { + return; + } + + if (readFileSync(path, "utf8").startsWith(generatedFileHeader)) { + return; + } + + throw new Error(`${path} already exists and is not generated; refusing to overwrite`); +} + +function assertValidYaml(doc, source) { + if (doc.errors.length === 0) { + return; + } + + const messages = doc.errors.map((error) => error.message).join("; "); + throw new Error(`Invalid YAML in ${source}: ${messages}`); +} + +if (!existsSync(docsYmlPath)) { + throw new Error(`Missing ${docsYmlPath}`); +} + +if (checkTag !== undefined) { + console.log(`eligible=${parseReleaseTag(checkTag) ? "true" : "false"}`); + process.exit(0); +} + +const releases = discoverReleaseTags(); +assertGeneratedVersionPathsAreUnique(releases); +assertGeneratedVersionNavsAreWritable(releases); +const previousGeneratedVersionPaths = existingGeneratedVersionPaths(); +cleanGeneratedFiles(); +for (const release of releases) { + materializeRelease(release); +} +updateDocsYml(releases, previousGeneratedVersionPaths); + +console.log(`Updated docs.yml with ${releases.length} generated release version(s).`); diff --git a/docs/fern/scripts/sync-helm-docs.mjs b/docs/fern/scripts/sync-helm-docs.mjs index 66b7d9276e..fb484e254e 100644 --- a/docs/fern/scripts/sync-helm-docs.mjs +++ b/docs/fern/scripts/sync-helm-docs.mjs @@ -62,11 +62,11 @@ function extractIntro(readme) { return out.join("\n"); } -function buildMdx(intro, valuesYaml) { +export function buildHelmReferenceMdx(readme, valuesYaml) { return ( FRONTMATTER + "\n" + - intro + + extractIntro(readme) + "\n\n" + "## Values\n\n" + "The following is the complete `values.yaml` for the NeMo Platform Helm Chart.\n" + @@ -78,8 +78,8 @@ function buildMdx(intro, valuesYaml) { } export function syncHelmDocs() { - const mdxText = buildMdx( - extractIntro(readFileSync(README, "utf8")), + const mdxText = buildHelmReferenceMdx( + readFileSync(README, "utf8"), readFileSync(VALUES, "utf8"), );