Preview Fern Docs: Comment #432
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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 |