Skip to content

Preview Fern Docs: Comment #432

Preview Fern Docs: Comment

Preview Fern Docs: Comment #432

# SPDX-FileCopyrightText: Copyright (c) 2025-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.
#
# Not checking out the PR branch is not by itself sufficient. The artifact IS
# the PR branch content, and `fern generate` processes it with DOCS_FERN_TOKEN
# in the environment, so a fork could have its docs.yml and API specs handled
# by a privileged process holding the token. The job is therefore restricted to
# pull requests from this repository; a fork gets no preview rather than a
# preview built with a secret.
#
# 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
timeout-minutes: 15
# A fork must never reach DOCS_FERN_TOKEN. workflow_run runs privileged with
# repository secrets, and the artifact it consumes is built from the PR
# branch, so this is the boundary that keeps untrusted content away from the
# token. head_repository is the repository the PR branch lives in.
if: >-
${{ github.event.workflow_run.conclusion == 'success'
&& github.event.workflow_run.event == 'pull_request'
&& github.event.workflow_run.head_repository.full_name == github.repository }}
steps:
- name: Download fern sources and metadata
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: fern-preview
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Read PR metadata
id: metadata
run: |
set -euo pipefail
# These files come from the artifact, which is PR branch content. Written
# to $GITHUB_OUTPUT unchecked, a value containing a newline injects
# further outputs: a crafted head_ref could set preview_url, and
# pr_number is interpolated into a gh api path, so it could redirect the
# comment elsewhere.
#
# Reject, do not normalise. Stripping carriage returns turns
# "123\r456" into "123456", which is a different pull request that
# still passes a digits check, so the sanitiser would launder the input
# rather than refuse it. Anything but a single clean line is an error.
read_one_line() {
path="$1"
if [ ! -f "$path" ]; then
echo "::error::missing $path in the artifact" >&2
return 1
fi
# Read a bounded amount: the artifact is untrusted, so a huge file
# should not be pulled into memory.
value=$(head -c 512 "$path")
value=${value%$'\n'}
# Match with bash, not grep. grep splits its input on LF, so a
# bracket expression can never match one, and an anchored pattern
# matches if any single line matches. LF is precisely the
# $GITHUB_OUTPUT injection character, so a grep-based guard passes
# the one input it most needs to reject. Bash patterns have no
# concept of a line and test the whole string.
case $value in
*[[:cntrl:]]*)
echo "::error::$path contains control characters" >&2
return 1
;;
esac
printf '%s' "$value"
}
pr_number=$(read_one_line preview-metadata/pr_number)
head_ref=$(read_one_line preview-metadata/head_ref)
# Same reason as above: bash =~ anchors against the whole string,
# where grep -E '^...$' would anchor per line.
if ! [[ $pr_number =~ ^[0-9]{1,10}$ ]]; then
echo "::error::pr_number from the artifact is not a plain number"
exit 1
fi
# git decides what a valid ref is. A character-class pattern accepted
# ../evil, foo//bar, /foo, foo/ and -x, all of which git rejects.
# Reject a leading dash before git sees it. check-ref-format has no
# -- separator, so it would parse the value as an option and exit on
# a usage error: the right verdict reached by accident, and one that
# would silently change if git ever grew a matching option.
case $head_ref in
-*)
echo "::error::head_ref from the artifact starts with a dash"
exit 1
;;
esac
if ! git check-ref-format --allow-onelevel "$head_ref"; then
echo "::error::head_ref from the artifact is not a valid git ref"
exit 1
fi
echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT"
echo "head_ref=$head_ref" >> "$GITHUB_OUTPUT"
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
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.]+)?$ ]]; then
echo "::error::fern.config.json .version '$VERSION' is not a valid semver"
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
if ! [[ "$PREVIEW_URL" =~ ^https://[^/]+\.(fern\.(app|dev)|buildwithfern\.com)(/|$) ]]; then
echo "::warning::Unexpected preview URL domain; skipping page-link fetch"
echo "page_links=" >> "$GITHUB_OUTPUT"; exit 0
fi
BASE_URL=$(echo "$PREVIEW_URL" | grep -oP 'https?://[^/]+')
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<<EOF"; echo "$PAGE_LINKS"; echo "EOF"; } >> "$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="<!-- preview-docs -->"
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