diff --git a/.github/workflows/fern-docs-ci.yml b/.github/workflows/fern-docs-ci.yml
new file mode 100644
index 000000000..97cbdf3d4
--- /dev/null
+++ b/.github/workflows/fern-docs-ci.yml
@@ -0,0 +1,128 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Validates Fern docs configuration on pull requests that touch docs or fern/.
+
+name: Fern Docs CI
+
+on:
+ pull_request:
+ paths:
+ - 'docs/**'
+ - 'fern/**'
+ - '.github/workflows/fern-docs-ci.yml'
+ workflow_dispatch: {}
+ ## --- OPTION: NVIDIA copy-PR-bot trigger ---
+ ## Replace the pull_request block above with:
+ # push:
+ # branches:
+ # - "pull-request/[0-9]+"
+ # paths:
+ # - 'docs/**'
+ # - 'fern/**'
+ # - '.github/workflows/fern-docs-ci.yml'
+ ## and add the changed-files gating job below (uncomment the full block).
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ ## --- OPTION: copy-PR-bot changed-files gate ---
+ ## Uncomment this job and add "needs: changed-files" +
+ ## "if: needs.changed-files.outputs.docs == 'true'" to fern-check when
+ ## using push-to-pull-request/* triggers without paths: filters.
+ #
+ # changed-files:
+ # runs-on: ubuntu-latest
+ # outputs:
+ # docs: ${{ steps.changes.outputs.docs }}
+ # steps:
+ # - name: Checkout code
+ # uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ # with:
+ # fetch-depth: 0
+ #
+ # - name: Check for docs changes
+ # id: changes
+ # run: |
+ # if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
+ # echo "docs=true" >> $GITHUB_OUTPUT
+ # elif [ "${{ github.event.before }}" = "0000000000000000000000000000000000000000" ]; then
+ # echo "docs=true" >> $GITHUB_OUTPUT
+ # elif git diff --name-only "${{ github.event.before }}" HEAD 2>/dev/null | grep -qE '^(docs/|fern/)'; then
+ # echo "docs=true" >> $GITHUB_OUTPUT
+ # else
+ # echo "docs=false" >> $GITHUB_OUTPUT
+ # fi
+
+ fern-check:
+ name: Fern Check
+ runs-on: ubuntu-latest
+ # runs-on: linux-amd64-cpu8 # NVIDIA self-hosted runner
+ timeout-minutes: 10
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v7
+
+ ## --- OPTION: filename convention check (warning) ---
+ # - name: Warn on non-lowercase or underscore markdown filenames
+ # run: |
+ # find docs/ -name "*.md" | while read f; do
+ # base=$(basename "$f")
+ # if echo "$base" | grep -qE '[A-Z]|_'; then
+ # suggested=$(echo "$base" | tr '[:upper:]' '[:lower:]' | tr '_' '-')
+ # echo "::warning file=$f::Filename '$base' should be lowercase with hyphens (e.g. '$suggested')"
+ # fi
+ # done
+
+ ## --- OPTION: filename convention check (hard fail) ---
+ # - name: Check markdown filename conventions
+ # run: |
+ # BAD=$(find docs/ -name '*.md' | grep -E '[A-Z_]' || true)
+ # if [ -n "$BAD" ]; then
+ # echo "::error::Markdown filenames must be lowercase with hyphens only:"
+ # echo "$BAD"
+ # exit 1
+ # fi
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+
+ - name: Install Fern CLI
+ run: npm install -g fern-api@$(jq -r .version fern/fern.config.json)
+
+ - name: Check MDX safety
+ run: |
+ BAD=$(grep -rn '
]*[^/]>\|
' docs/ fern/ --include="*.md" --include="*.mdx" || true)
+ if [ -n "$BAD" ]; then
+ echo "::error::Non-self-closing
tags found (MDX requires
):"
+ echo "$BAD"
+ exit 1
+ fi
+
+ - name: Fern check
+ run: fern check
+
+ - name: Check MDX validity
+ run: fern docs md check
+
+ - name: Check broken links
+ run: fern docs broken-links
diff --git a/.github/workflows/fern-docs-preview-build.yml b/.github/workflows/fern-docs-preview-build.yml
new file mode 100644
index 000000000..0c8192ef3
--- /dev/null
+++ b/.github/workflows/fern-docs-preview-build.yml
@@ -0,0 +1,144 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Workflow 1 of 2 for Fern doc previews.
+#
+# Collects the fern/ and docs/ sources plus PR metadata from the PR branch and
+# uploads them as an artifact. Both directories are needed because
+# fern/docs.yml references ../docs/index.yml; omitting docs/ causes
+# `fern generate --docs` to fail in the companion workflow. No secrets are
+# used here, so this is safe to run on fork PRs via the regular pull_request
+# trigger.
+#
+# The companion workflow (fern-docs-preview-comment.yml) picks up the artifact,
+# builds the preview with DOCS_FERN_TOKEN, and posts the PR comment.
+
+name: "Preview Fern Docs: Build"
+
+on:
+ pull_request:
+ paths:
+ - 'docs/**'
+ - 'fern/**'
+ - '.github/workflows/fern-docs-preview-build.yml'
+ ## --- OPTION: NVIDIA copy-PR-bot trigger ---
+ ## Replace the pull_request block above with:
+ # push:
+ # branches:
+ # - "pull-request/[0-9]+"
+ # paths:
+ # - 'docs/**'
+ # - 'fern/**'
+ # - '.github/workflows/fern-docs-preview-build.yml'
+ ## and switch the "Save PR metadata" step to the copy-PR-bot variant below.
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ ## --- OPTION: copy-PR-bot changed-files gate ---
+ ## Uncomment this job and add "needs: changed-files" +
+ ## "if: needs.changed-files.outputs.docs == 'true'" to collect when using
+ ## push-to-pull-request/* triggers without paths: filters.
+ #
+ # changed-files:
+ # runs-on: ubuntu-latest
+ # outputs:
+ # docs: ${{ steps.changes.outputs.docs }}
+ # steps:
+ # - name: Checkout code
+ # uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ # with:
+ # fetch-depth: 0
+ #
+ # - name: Check for docs changes
+ # id: changes
+ # run: |
+ # if [ "${{ github.event.before }}" = "0000000000000000000000000000000000000000" ]; then
+ # echo "docs=true" >> $GITHUB_OUTPUT
+ # elif git diff --name-only "${{ github.event.before }}" HEAD 2>/dev/null | grep -qE '^(docs/|fern/)'; then
+ # echo "docs=true" >> $GITHUB_OUTPUT
+ # else
+ # echo "docs=false" >> $GITHUB_OUTPUT
+ # fi
+
+ collect:
+ runs-on: ubuntu-latest
+ # runs-on: linux-amd64-cpu8 # NVIDIA self-hosted runner
+ timeout-minutes: 10
+ steps:
+ - name: Checkout PR
+ uses: actions/checkout@v7
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: Save PR metadata
+ env:
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ HEAD_REF: ${{ github.head_ref }}
+ BASE_REF: ${{ github.base_ref }}
+ run: |
+ mkdir -p preview-metadata
+ echo "$PR_NUMBER" > preview-metadata/pr_number
+ echo "$HEAD_REF" > preview-metadata/head_ref
+ git diff --name-only "origin/${BASE_REF}...HEAD" -- '*.md' '*.mdx' > preview-metadata/changed_md_files 2>/dev/null || true
+ ## --- OPTION: copy-PR-bot metadata extraction ---
+ ## Replace the step above with this when using push-to-pull-request/*:
+ # - name: Save PR metadata
+ # env:
+ # BRANCH_NAME: ${{ github.ref_name }}
+ # run: |
+ # mkdir -p preview-metadata
+ # echo "${BRANCH_NAME#pull-request/}" > preview-metadata/pr_number
+ # echo "$BRANCH_NAME" > preview-metadata/head_ref
+ # git diff --name-only "origin/main...HEAD" -- '*.md' '*.mdx' > preview-metadata/changed_md_files 2>/dev/null || true
+
+ - name: Checkout frozen version content
+ run: |
+ set -eo pipefail
+ for version_file in fern/versions/v*.yml; do
+ [ -f "$version_file" ] || continue
+ version=$(basename "$version_file" .yml)
+ # Registry files are named by the stripped version; the tag they were
+ # cut from still carries the chart/ prefix.
+ tag="chart/${version}"
+ if git show-ref --verify --quiet "refs/tags/${tag}"; then
+ mkdir -p "fern/versions/${version}-content"
+ git archive "refs/tags/${tag}" -- docs/ | tar -x --strip-components=1 -C "fern/versions/${version}-content"
+ find "fern/versions/${version}-content" -name '*.md' -print0 | xargs -0 -r sed -i \
+ -e 's/{/\\{/g' \
+ -e 's/}/\\}/g' \
+ -e 's/\</g'
+ echo "Extracted docs from $version"
+ else
+ echo "::error::Tag $tag not found — cannot load frozen docs content"
+ exit 1
+ fi
+ done
+
+ - name: Upload fern sources and metadata
+ uses: actions/upload-artifact@v4
+ with:
+ name: fern-preview
+ path: |
+ fern/
+ docs/
+ preview-metadata/
+ retention-days: 1
diff --git a/.github/workflows/fern-docs-preview-comment.yml b/.github/workflows/fern-docs-preview-comment.yml
new file mode 100644
index 000000000..0ae47fed0
--- /dev/null
+++ b/.github/workflows/fern-docs-preview-comment.yml
@@ -0,0 +1,152 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Workflow 2 of 2 for Fern doc previews.
+#
+# Triggered by workflow_run after "Preview Fern Docs: Build" completes.
+# Downloads the fern/ artifact, builds a preview with DOCS_FERN_TOKEN, and
+# posts a stable :herb: comment on the PR. This workflow never checks out the
+# PR branch directly, keeping secrets isolated from untrusted code.
+#
+# Required configuration:
+# - Organization secret: DOCS_FERN_TOKEN (from `fern token` for the nvidia Fern org)
+
+name: "Preview Fern Docs: Comment"
+
+on:
+ workflow_run:
+ workflows: ["Preview Fern Docs: Build"]
+ types: [completed]
+
+permissions:
+ pull-requests: write
+ actions: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.workflow_run.head_repository.full_name || github.repository }}-${{ github.event.workflow_run.head_branch || github.event.workflow_run.id }}
+ cancel-in-progress: true
+
+jobs:
+ preview:
+ runs-on: ubuntu-latest
+ # runs-on: linux-amd64-cpu8 # NVIDIA self-hosted runner
+ timeout-minutes: 15
+ if: ${{ github.event.workflow_run.conclusion == 'success' }}
+ steps:
+ - name: Download fern sources and metadata
+ uses: actions/download-artifact@v4
+ with:
+ name: fern-preview
+ run-id: ${{ github.event.workflow_run.id }}
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Read PR metadata
+ id: metadata
+ run: |
+ echo "pr_number=$(cat preview-metadata/pr_number)" >> "$GITHUB_OUTPUT"
+ echo "head_ref=$(cat preview-metadata/head_ref)" >> "$GITHUB_OUTPUT"
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+
+ - name: Install Fern CLI
+ run: |
+ set -euo pipefail
+ VERSION=$(jq -r .version fern/fern.config.json)
+ if ! [[ "$VERSION" =~ ^([0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.]+)?|latest)$ ]]; then
+ echo "::error::fern.config.json .version '$VERSION' is not a valid semver or 'latest'"
+ exit 1
+ fi
+ npm install -g "fern-api@${VERSION}"
+
+ - name: Generate preview URL
+ id: generate-docs
+ env:
+ FERN_TOKEN: ${{ secrets.DOCS_FERN_TOKEN }}
+ HEAD_REF: ${{ steps.metadata.outputs.head_ref }}
+ working-directory: ./fern
+ run: |
+ set -o pipefail
+ fern generate --docs --preview --id "$HEAD_REF" 2>&1 | tee /tmp/fern-output.log
+ URL=$(grep -oP 'Published docs to \K.*(?= \()' /tmp/fern-output.log || true)
+ if [ -z "$URL" ]; then
+ echo "::error::Failed to generate preview URL. See fern output above."
+ exit 1
+ fi
+ echo "preview_url=$URL" >> "$GITHUB_OUTPUT"
+
+ - name: Build page links for changed files
+ id: page-links
+ env:
+ FERN_TOKEN: ${{ secrets.DOCS_FERN_TOKEN }}
+ PREVIEW_URL: ${{ steps.generate-docs.outputs.preview_url }}
+ run: |
+ CHANGED_FILES=""
+ if [ -f preview-metadata/changed_md_files ]; then
+ CHANGED_FILES=$(cat preview-metadata/changed_md_files)
+ fi
+
+ if [ -z "$CHANGED_FILES" ] || [ -z "$PREVIEW_URL" ]; then
+ echo "page_links=" >> "$GITHUB_OUTPUT"; exit 0
+ fi
+
+ BASE_URL="${PREVIEW_URL%/}"
+ FILES_PARAM=$(echo "$CHANGED_FILES" | tr '\n' ',' | sed 's/,$//' \
+ | python3 -c "import sys, urllib.parse; print(urllib.parse.quote(sys.stdin.read().strip(), safe=',/'))")
+ RESPONSE=$(curl -sf -H "FERN_TOKEN: $FERN_TOKEN" "${PREVIEW_URL}/api/fern-docs/get-slug-for-file?files=${FILES_PARAM}" 2>/dev/null) || {
+ echo "page_links=" >> "$GITHUB_OUTPUT"; exit 0
+ }
+
+ PAGE_LINKS=$(echo "$RESPONSE" | jq -r --arg url "$BASE_URL" \
+ '.mappings[] | select(.slug != null) | "- [\(.slug)](\($url)/\(.slug))"')
+
+ if [ -n "$PAGE_LINKS" ]; then
+ { echo "page_links<> "$GITHUB_OUTPUT"
+ else
+ echo "page_links=" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Post or update PR comment
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ PR_NUMBER: ${{ steps.metadata.outputs.pr_number }}
+ PREVIEW_URL: ${{ steps.generate-docs.outputs.preview_url }}
+ PAGE_LINKS: ${{ steps.page-links.outputs.page_links }}
+ run: |
+ BODY=":herb: **Preview your docs:** <${PREVIEW_URL}>"
+ if [ -n "${PAGE_LINKS}" ]; then
+ BODY="${BODY}
+
+ Here are the markdown pages you've updated:
+ ${PAGE_LINKS}"
+ fi
+
+ MARKER=""
+ BODY="${BODY}
+
+ ${MARKER}"
+
+ COMMENT_ID=$(gh api "repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" \
+ --jq ".[] | select(.body | contains(\"${MARKER}\")) | .id" | tr -d '\r' | head -1)
+
+ if [ -n "$COMMENT_ID" ]; then
+ gh api "repos/${{ github.repository }}/issues/comments/${COMMENT_ID}" \
+ -X PATCH -f body="$BODY"
+ else
+ gh api "repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" \
+ -f body="$BODY"
+ fi
diff --git a/.github/workflows/publish-fern-docs.yml b/.github/workflows/publish-fern-docs.yml
new file mode 100644
index 000000000..aa6671b86
--- /dev/null
+++ b/.github/workflows/publish-fern-docs.yml
@@ -0,0 +1,318 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Publishes the Fern documentation site on a chart release or manual dispatch.
+#
+# The docs version axis follows the Helm chart. This repo releases four
+# components independently (operator, agent, chart, cli) and every one of them
+# publishes a GitHub Release, so an unfiltered `release` trigger would fire up
+# to four times per coordinated release and label the docs with whichever
+# component won the race. The chart is what users install and it ships on every
+# rollout, so its version is the one meaningful label for a docs snapshot.
+# Releases of the other three components are skipped, not failed.
+#
+# Tags are `chart/vX.Y.Z`. git is asked for the full tag; the stripped `vX.Y.Z`
+# names version files, slugs and display names. The resolve step emits both as
+# separate outputs — do not use one where the other is meant.
+#
+# All versions serve frozen content extracted from their git tag — there is no
+# "live docs" entry, so a docs change merged to main is not published until the
+# next chart release carries it. The newest version is stamped "Latest · vX.Y.Z"
+# at publish time so readers know which is current; the stamp is transient and
+# not persisted.
+#
+# Manual dispatch accepts an optional `tag` input:
+# - empty: resolves to the newest chart/vX.Y.Z tag in the repository
+# - explicit: `chart/v1.2.0` or the bare `v1.2.0` — both resolve the same
+#
+# On a stable chart/vX.Y.Z release (publish or dispatch), the workflow also:
+# 1. Generates fern/versions/vX.Y.Z.yml from the tag's docs/index.yml
+# 2. Adds a version entry to fern/docs.yml, sorted by semver descending
+# 3. Prunes older versions to keep only the MAX_VERSIONS most recent
+# 4. Opens a PR to persist registry changes back to main (after publish)
+#
+# Pre-releases (chart/vX.Y.Z-rc.N) publish but skip version registration.
+#
+# Required configuration:
+# - Organization secret: DOCS_FERN_TOKEN (from `fern token` for the nvidia Fern org)
+
+name: Publish Fern Docs
+
+on:
+ release:
+ types: [published]
+ workflow_dispatch:
+ inputs:
+ tag:
+ description: "Chart tag to publish (e.g. chart/v1.2.0, or the bare v1.2.0). Leave empty to use the newest chart tag."
+ required: false
+ type: string
+ ## --- OPTION: tag-push trigger ---
+ ## Use this instead of `release` for repos that push tags without creating
+ ## GitHub Releases. The resolve step handles both triggers, but the release
+ ## trigger is preferred because the Release object always exists at workflow
+ ## start — no race between tag push and Release creation.
+ # push:
+ # tags:
+ # - "chart/v[0-9]*.[0-9]*.[0-9]*"
+
+permissions:
+ contents: write
+ pull-requests: write
+
+concurrency:
+ group: fern-publish
+ cancel-in-progress: true
+
+env:
+ YQ_VERSION: v4.53.2
+ MAX_VERSIONS: 3
+
+jobs:
+ publish:
+ # `on: release` has no tag filter, so the component gate lives here. The
+ # operator, agent and cli releases fire this workflow too; they are not
+ # docs-publishing events, so skip them rather than failing the run.
+ if: >-
+ github.event_name != 'release' ||
+ startsWith(github.event.release.tag_name, 'chart/v')
+ runs-on: ubuntu-latest
+ # runs-on: linux-amd64-cpu8 # NVIDIA self-hosted runner
+ timeout-minutes: 20
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v7
+ with:
+ ref: main
+ fetch-tags: true
+ persist-credentials: false
+
+ - name: Install yq
+ run: |
+ if command -v yq &>/dev/null; then
+ echo "yq already installed: $(yq --version)"
+ else
+ OS=$(uname -s | tr '[:upper:]' '[:lower:]')
+ ARCH=$(uname -m)
+ case "$ARCH" in
+ x86_64) ARCH="amd64" ;;
+ aarch64) ARCH="arm64" ;;
+ esac
+ mkdir -p "$HOME/.local/bin"
+ curl -sSfL "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_${OS}_${ARCH}" -o "$HOME/.local/bin/yq"
+ chmod +x "$HOME/.local/bin/yq"
+ echo "$HOME/.local/bin" >> "$GITHUB_PATH"
+ fi
+
+ - name: Resolve target tag
+ id: resolve
+ env:
+ INPUT_TAG: ${{ inputs.tag }}
+ RELEASE_TAG: ${{ github.event.release.tag_name }}
+ RELEASE_PRERELEASE: ${{ github.event.release.prerelease }}
+ run: |
+ set -eo pipefail
+ if [ "${GITHUB_EVENT_NAME}" = "release" ]; then
+ TAG="${RELEASE_TAG}"
+ IS_PRERELEASE="${RELEASE_PRERELEASE}"
+ elif [ "${GITHUB_EVENT_NAME}" = "push" ]; then
+ TAG="${GITHUB_REF#refs/tags/}"
+ elif [ -n "${INPUT_TAG}" ]; then
+ TAG="${INPUT_TAG}"
+ else
+ # Scoped to chart tags on purpose: the newest release in this repo
+ # is often an operator or cli release, which is not publishable here.
+ # Chart tags cut before Fern navigation landed are skipped — see the
+ # docs/index.yml check below for why they cannot be published.
+ for CANDIDATE in $(git tag -l 'chart/v*' --sort=-v:refname \
+ | grep -E '^chart/v[0-9]+\.[0-9]+\.[0-9]+$'); do
+ if git cat-file -e "refs/tags/${CANDIDATE}:docs/index.yml" 2>/dev/null; then
+ TAG="${CANDIDATE}"
+ break
+ fi
+ done
+ if [ -z "${TAG:-}" ]; then
+ echo "::error::No chart tag carries docs/index.yml yet — the first publishable release is the first chart tag cut after this workflow landed"
+ exit 1
+ fi
+ fi
+ # A bare version is accepted on manual dispatch: the docs version axis
+ # follows the chart, so v1.2.0 and chart/v1.2.0 name the same release.
+ # Only a bare version is promoted; anything else keeps the name the
+ # caller gave it so the error below names what they actually typed.
+ case "$TAG" in
+ v*) TAG="chart/${TAG}" ;;
+ esac
+ if ! [[ "$TAG" =~ ^chart/v[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; then
+ echo "::error::Tag '${TAG}' is not a publishable chart tag (expected chart/vMAJOR.MINOR.PATCH[-rc.N])"
+ exit 1
+ fi
+ if ! git show-ref --verify --quiet "refs/tags/${TAG}"; then
+ echo "::error::Tag '${TAG}' does not exist in this repository"
+ exit 1
+ fi
+ # Chart tags predating the Fern scaffold have no docs/index.yml to freeze.
+ # Without this the run dies later inside `git archive` with a bare
+ # "pathspec did not match any files", which reads as a workflow bug.
+ if ! git cat-file -e "refs/tags/${TAG}:docs/index.yml" 2>/dev/null; then
+ echo "::error::Tag '${TAG}' has no docs/index.yml — it predates the Fern docs navigation and cannot be published"
+ exit 1
+ fi
+ # The docs version axis drops the component prefix: chart/v1.2.0 -> v1.2.0.
+ VERSION="${TAG#chart/}"
+ # Pre-release detection: use the Release event flag when available,
+ # fall back to hyphen check for tag-push and dispatch triggers.
+ if [ "${IS_PRERELEASE}" = "true" ] || [[ "$VERSION" == *-* ]]; then
+ IS_RELEASE=false
+ else
+ IS_RELEASE=true
+ fi
+ echo "tag=$TAG" >> "$GITHUB_OUTPUT"
+ echo "version=$VERSION" >> "$GITHUB_OUTPUT"
+ echo "is_release=$IS_RELEASE" >> "$GITHUB_OUTPUT"
+ echo "Resolved target tag: $TAG (version=$VERSION, is_release=$IS_RELEASE)"
+
+ - name: Register new version from tag
+ if: steps.resolve.outputs.is_release == 'true'
+ env:
+ TAG: ${{ steps.resolve.outputs.tag }}
+ TAG_VERSION: ${{ steps.resolve.outputs.version }}
+ run: |
+ set -eo pipefail
+
+ if [ -f "fern/versions/${TAG_VERSION}.yml" ]; then
+ echo "Version ${TAG_VERSION} already registered — skipping"
+ exit 0
+ fi
+
+ mkdir -p fern/versions
+ echo "Generating fern/versions/${TAG_VERSION}.yml from tag $TAG"
+ git archive "refs/tags/${TAG}" -- docs/index.yml | tar -xO docs/index.yml | \
+ sed "s|path: |path: ${TAG_VERSION}-content/|g" > "fern/versions/${TAG_VERSION}.yml"
+
+ export TAG_VERSION
+ EXPR='.products[0].versions += [{"display-name": env(TAG_VERSION),'
+ EXPR+=' "path": "versions/" + env(TAG_VERSION) + ".yml",'
+ EXPR+=' "slug": env(TAG_VERSION), "availability": "stable"}]'
+ yq -i "$EXPR" fern/docs.yml
+
+ # Sort all version entries by semver descending
+ SORTED_SLUGS=$(yq '.products[0].versions[].slug' fern/docs.yml | sort -rV)
+ yq -i '.products[0].versions = []' fern/docs.yml
+ for slug in $SORTED_SLUGS; do
+ export slug
+ yq -i '.products[0].versions += [{"display-name": env(slug), "path": "versions/" + env(slug) + ".yml", "slug": env(slug), "availability": "stable"}]' fern/docs.yml
+ done
+
+ echo "--- docs.yml versions after insert + sort ---"
+ yq '.products[0].versions[].display-name' fern/docs.yml
+
+ - name: Prune old versions
+ if: steps.resolve.outputs.is_release == 'true'
+ run: |
+ set -eo pipefail
+ TOTAL=$(yq '.products[0].versions | length' fern/docs.yml)
+
+ if [ "$TOTAL" -le "$MAX_VERSIONS" ]; then
+ echo "Only $TOTAL versions — nothing to prune (keeping $MAX_VERSIONS)"
+ exit 0
+ fi
+
+ PRUNED=$(yq ".products[0].versions[$MAX_VERSIONS:][].slug" fern/docs.yml)
+ yq -i ".products[0].versions |= .[:$MAX_VERSIONS]" fern/docs.yml
+
+ for slug in $PRUNED; do
+ rm -f "fern/versions/${slug}.yml"
+ echo "Pruned $slug"
+ done
+
+ echo "--- docs.yml versions after prune ---"
+ yq '.products[0].versions[].display-name' fern/docs.yml
+
+ - name: Checkout frozen version content
+ run: |
+ set -eo pipefail
+ for version_file in fern/versions/v*.yml; do
+ [ -f "$version_file" ] || continue
+ version=$(basename "$version_file" .yml)
+ # Registry files are named by the stripped version; the tag they were
+ # cut from still carries the chart/ prefix.
+ tag="chart/${version}"
+ if git show-ref --verify --quiet "refs/tags/${tag}"; then
+ mkdir -p "fern/versions/${version}-content"
+ git archive "refs/tags/${tag}" -- docs/ | tar -x --strip-components=1 -C "fern/versions/${version}-content"
+ find "fern/versions/${version}-content" -name '*.md' -print0 | xargs -0 -r sed -i \
+ -e 's/{/\\{/g' \
+ -e 's/}/\\}/g' \
+ -e 's/\</g'
+ echo "Extracted docs from $version"
+ else
+ echo "::error::Tag $tag not found — cannot pin frozen docs content"
+ exit 1
+ fi
+ done
+
+ - name: Stamp newest version as Latest
+ run: |
+ cp fern/docs.yml /tmp/docs.yml.preStamp
+ NEWEST=$(yq '.products[0].versions[0].display-name' fern/docs.yml)
+ if [ -n "$NEWEST" ] && [ "$NEWEST" != "null" ]; then
+ export NEWEST
+ yq -i '.products[0].versions[0].display-name = "Latest · " + env(NEWEST)' fern/docs.yml
+ fi
+ echo "--- docs.yml versions after stamp ---"
+ yq '.products[0].versions[].display-name' fern/docs.yml
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+
+ - name: Install Fern CLI
+ run: npm install -g fern-api@$(jq -r .version fern/fern.config.json)
+
+ - name: Publish docs
+ env:
+ FERN_TOKEN: ${{ secrets.DOCS_FERN_TOKEN }}
+ working-directory: ./fern
+ run: |
+ set -eo pipefail
+ fern generate --docs 2>&1 | tee /tmp/fern-output.log
+ URL=$(grep -oP 'Published docs to \K.*(?= \()' /tmp/fern-output.log || true)
+ if [ -n "$URL" ]; then
+ echo "### Published: [$URL]($URL)" >> "$GITHUB_STEP_SUMMARY"
+ fi
+
+ - name: Restore unstamped docs.yml for persistence
+ if: steps.resolve.outputs.is_release == 'true'
+ run: cp /tmp/docs.yml.preStamp fern/docs.yml
+
+ - name: Open PR for version registry changes
+ if: steps.resolve.outputs.is_release == 'true'
+ uses: peter-evans/create-pull-request@v7
+ with:
+ base: main
+ branch: chore/fern-register-${{ steps.resolve.outputs.version }}
+ delete-branch: true
+ sign-commits: true
+ commit-message: "chore(fern): register ${{ steps.resolve.outputs.version }}, keep latest ${{ env.MAX_VERSIONS }} versions [automated]"
+ title: "chore(fern): register ${{ steps.resolve.outputs.version }}"
+ body: |
+ Automated registration of `${{ steps.resolve.outputs.tag }}` in the Fern docs versions registry.
+
+ Generated by the `Publish Fern Docs` workflow.
+ add-paths: |
+ fern/versions/v*.yml
+ fern/docs.yml
diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md
index b7a9f0aa1..b42f1f0fc 100644
--- a/docs/getting-started/installation.md
+++ b/docs/getting-started/installation.md
@@ -2,19 +2,72 @@
NodeWright is installed via Helm as an OCI artifact from GitHub Container Registry.
-For prerequisites, install commands, and configuration options, see the [project README](../../README.md#installation-via-helm).
+## Prerequisites
-## Quick install
+- Kubernetes cluster (tested on v1.30+)
+- Helm 3.8+ installed (required for native OCI support)
+- Container registry access credentials (if using private registries)
+
+## Configure Image Pull Secrets (if needed)
+
+If you're using private container registries, create the namespace and secret before installing so the release can reference it:
+
+```bash
+kubectl create namespace nodewright
+
+kubectl create secret generic node-init-secret \
+ --from-file=.dockerconfigjson=${HOME}/.docker/config.json \
+ --type=kubernetes.io/dockerconfigjson \
+ --namespace nodewright
+```
+
+NodeWright currently uses a single shared image pull secret for all packages, and agent/operator containers. If you need access to multiple registries, combine the credentials into one `dockerconfigjson` secret with multiple registry auths.
+
+## Install NodeWright
```bash
+# The chart is distributed as an OCI artifact on GitHub Container Registry.
+# Helm 3.8+ supports OCI natively — no `helm repo add` needed.
helm install nodewright oci://ghcr.io/nvidia/nodewright/charts/nodewright \
--version v0.18.0 \
--namespace nodewright \
- --create-namespace
+ --create-namespace \
+ --set imagePullSecret=node-init-secret
```
+Omit `--set imagePullSecret=node-init-secret` if you're pulling from public registries only.
+
+> **Where things live:** chart at `oci://ghcr.io/nvidia/nodewright/charts/nodewright`, operator image at `ghcr.io/nvidia/nodewright/operator`, agent image at `ghcr.io/nvidia/nodewright/agent`.
+> **Migrating from `helm repo add skyhook https://helm.ngc.nvidia.com/...`?** Run `helm repo remove skyhook` and use the OCI install above.
+
+## Verify Installation
+
+```bash
+# Check that the operator is running
+kubectl get pods -n nodewright
+
+# Wait for the deployment to be available
+kubectl wait --for=condition=Available deployment -l control-plane=controller-manager -n nodewright --timeout=300s
+
+# Wait for the operator pod to be ready
+kubectl wait --for=condition=Ready pod -l control-plane=controller-manager -n nodewright --timeout=300s
+
+# Verify the CRDs are installed
+kubectl get crd | grep nodewright
+```
+
+## Uninstalling
+
+By default, the Helm chart includes a pre-delete hook that automatically cleans up all NodeWright and DeploymentPolicy resources before uninstalling:
+
+```bash
+helm uninstall nodewright --namespace nodewright
+```
+
+For more details on explicit package uninstall, see [Uninstall](../user-guide/uninstall.md).
+
## Related
- [Kubernetes support matrix](../operations/kubernetes-support.md)
- [Versioning](../operations/versioning.md)
-- [Uninstall](../user-guide/uninstall.md)
+- [Migration from Skyhook](migration.md)
diff --git a/docs/getting-started/overview.md b/docs/getting-started/overview.md
index 3be032021..7da83f64a 100644
--- a/docs/getting-started/overview.md
+++ b/docs/getting-started/overview.md
@@ -1,19 +1,52 @@
# NodeWright Overview
-NodeWright is a Kubernetes-aware package manager for cluster administrators to safely modify and maintain underlying hosts declaratively at scale.
+**NodeWright** is a Kubernetes-aware package manager for cluster administrators to safely modify and maintain underlying hosts declaratively at scale.
-For a full introduction — what NodeWright is, why it exists, where it fits, and its key features — see the [project README](../../README.md#what-is-nodewright).
+## Why NodeWright?
-## Components
+Managing and updating Kubernetes clusters is challenging. While Kubernetes advocates treating compute as disposable, certain scenarios make this difficult:
-1. **NodeWright Operator** — manages installing, updating, and removing packages
-2. **NodeWright Custom Resource** — declarative definitions of changes to apply
-3. **Packages** — the actual modifications you want to implement
+- **Updating hosts without re-imaging:** Limited excess hardware/capacity for rolling replacements, or long node replacement times (can be hours in some cloud providers).
+- **OS image management:** Maintain a common base image with workload-specific overlays instead of multiple OS images.
+- **Workload sensitivity:** Some workloads can't be moved, are difficult to move, or take a long time to migrate.
-Pre-built packages are available at [NVIDIA/skyhook-packages](https://github.com/NVIDIA/skyhook-packages).
+## What is NodeWright?
-## Next steps
+NodeWright functions like a package manager but for your entire Kubernetes cluster, with three main components:
-- [Installation](installation.md) — install NodeWright via Helm
-- [Migration from Skyhook](migration.md) — rename transition guide
-- [CLI reference](../user-guide/cli.md) — `kubectl nodewright` commands
+1. **NodeWright Operator** — Manages installing, updating, and removing packages.
+2. **NodeWright Custom Resource** — Declarative definitions of changes to apply.
+3. **Packages** — The actual modifications you want to implement.
+
+NodeWright works in any Kubernetes environment (self-managed, on-prem, cloud) and shines when you need:
+
+- Kubernetes-aware scheduling that protects important workloads
+- Rolling or simultaneous updates across your cluster
+- Declarative configuration management for host-level changes
+
+## Benefits
+
+- **Native Kubernetes integration** — Packages are standard Kubernetes resources compatible with GitOps tools like ArgoCD, Helm, and Flux.
+- **Autoscaling support** — Ensure newly created nodes are properly configured before schedulable.
+- **First-class upgrades** — Deploys changes with minimal disruption, waiting for running workloads to complete when needed.
+
+## Key Features
+
+- **Interruption Budget:** percent of nodes or count
+- **Node Selectors:** selectors for which nodes to apply to (node labels)
+- **Pod Non Interrupt Labels:** labels for pods to **never** interrupt
+- **Package Interrupt:** service (containerd, cron, anything systemd), or reboot
+- **Additional Tolerations:** tolerations added to the packages
+- [**Runtime Required**](../user-guide/runtime-required.md): requires node to come into the cluster with a taint, and will do work prior to removing custom taint
+- [**Resource Management**](../operations/resource-management.md): CPU/memory resources using LimitRange, per-package overrides, and validation rules
+- [**Explicit Uninstall**](../user-guide/uninstall.md): controlled uninstall of packages from nodes with webhook guards, finalizer-driven cleanup, and cancel support
+
+## Pre-built Packages
+
+There are pre-built generalist packages available at [NVIDIA/skyhook-packages](https://github.com/NVIDIA/skyhook-packages).
+
+## Next Steps
+
+- [Installation](installation.md) — Install NodeWright via Helm
+- [Migration from Skyhook](migration.md) — Transition guide from Skyhook to NodeWright
+- [CLI Reference](../user-guide/cli.md) — `kubectl nodewright` commands
diff --git a/docs/index.yml b/docs/index.yml
new file mode 100644
index 000000000..77ec4db54
--- /dev/null
+++ b/docs/index.yml
@@ -0,0 +1,57 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+
+navigation:
+ - section: Getting Started
+ contents:
+ - page: Overview
+ path: getting-started/overview.md
+ - page: Installation
+ path: getting-started/installation.md
+ - page: Migration from Skyhook
+ path: getting-started/migration.md
+
+ - section: Architecture
+ contents:
+ - page: Operator Status Definitions
+ path: architecture/operator-status.md
+ - page: Interrupt Flow
+ path: architecture/interrupt-flow.md
+ - page: Strict Ordering
+ path: architecture/ordering.md
+
+ - section: User Guide
+ contents:
+ - page: CLI Reference
+ path: user-guide/cli.md
+ - page: Deployment Policy
+ path: user-guide/deployment-policy.md
+ - page: Providing Secrets
+ path: user-guide/providing-secrets.md
+ - page: Runtime Required
+ path: user-guide/runtime-required.md
+ - page: Taints
+ path: user-guide/taints.md
+ - page: Uninstall
+ path: user-guide/uninstall.md
+
+ - section: Operations
+ contents:
+ - page: Kubernetes Support
+ path: operations/kubernetes-support.md
+ - page: Resource Management
+ path: operations/resource-management.md
+ - page: Resources at Scale
+ path: operations/resources-at-scale.md
+ - page: Versioning
+ path: operations/versioning.md
+
+ - section: Observability
+ contents:
+ - page: Metrics
+ path: observability/metrics.md
+
+ - section: Security
+ contents:
+ - page: Kyverno Policies
+ path: security/kyverno/README.md
diff --git a/fern/.gitignore b/fern/.gitignore
new file mode 100644
index 000000000..ff9bdf6fd
--- /dev/null
+++ b/fern/.gitignore
@@ -0,0 +1,2 @@
+.fern/
+node_modules/
diff --git a/fern/docs.yml b/fern/docs.yml
new file mode 100644
index 000000000..a3fb86a43
--- /dev/null
+++ b/fern/docs.yml
@@ -0,0 +1,28 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+
+instances:
+ - url: nvidia-nodewright.docs.buildwithfern.com/nodewright
+ custom-domain: docs.nvidia.com/nodewright
+ multi-source: true
+
+redirects:
+ - source: /nodewright/index.html
+ destination: /nodewright
+
+title: NVIDIA NodeWright
+
+global-theme: nvidia
+
+logo:
+ href: /nodewright
+ right-text: NodeWright
+
+navbar-links:
+ - type: github
+ value: https://github.com/NVIDIA/nodewright
+
+products:
+ - display-name: NodeWright
+ path: ../docs/index.yml
+ slug: /
diff --git a/fern/fern.config.json b/fern/fern.config.json
new file mode 100644
index 000000000..2e340abf2
--- /dev/null
+++ b/fern/fern.config.json
@@ -0,0 +1,4 @@
+{
+ "organization": "nvidia",
+ "version": "latest"
+}