Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/assets/docs-preview-pipeline.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions .github/assets/docs-preview-pipeline.png.license
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
SPDX-License-Identifier: Apache-2.0
152 changes: 152 additions & 0 deletions .github/scripts/docs_preview.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""Select a docs artifact using GitHub metadata, never artifact-supplied identity."""

import json
import os
import subprocess
from pathlib import Path
from urllib.parse import urlencode

CANONICAL_REPO_ID = 1092046207


def api(endpoint, *, paginate=False):
command = ["gh", "api", endpoint]
if paginate:
command += ["--paginate", "--slurp"]
return json.loads(subprocess.check_output(command, text=True))


def matches(pr, run):
return (
pr["state"] == "open"
and pr["base"]["repo"]["id"] == CANONICAL_REPO_ID
and pr["head"]["sha"] == run["head_sha"]
and pr["head"]["ref"] == run["head_branch"]
and (pr["head"]["repo"] or {}).get("id")
== (run["head_repository"] or {}).get("id")
)


def select_preview(event_name, event, request=api):
repo = event["repository"]
if repo["id"] != CANONICAL_REPO_ID:
return None
prefix = f"repos/{repo['full_name']}"
automatic = event_name == "workflow_run"
if automatic:
run = request(f"{prefix}/actions/runs/{event['workflow_run']['id']}")
elif event_name == "issue_comment":
comment = event["comment"]
if not event["issue"].get("pull_request") or not (
comment["body"] == "/preview-docs"
or comment["body"].startswith("/preview-docs ")
):
return None
permission = request(
f"{prefix}/collaborators/{comment['user']['login']}/permission"
)["permission"]
if permission not in {"admin", "maintain", "write"}:
raise PermissionError("/preview-docs requires write access")
pr = request(f"{prefix}/pulls/{event['issue']['number']}")
if pr["state"] != "open":
return None
runs = request(
f"{prefix}/actions/workflows/docs-build-and-deploy.yaml/runs?event=pull_request"
f"&status=success&head_sha={pr['head']['sha']}&per_page=100"
)["workflow_runs"]
run = next((run for run in runs if matches(pr, run)), None)
if run is None:
raise ValueError(
"No successful docs build for the current PR head; retry after it finishes"
)
else:
return None

if (
run["event"] != "pull_request"
or run["conclusion"] != "success"
or run["status"] != "completed"
or run["repository"]["id"] != CANONICAL_REPO_ID
):
return None
workflow = request(f"{prefix}/actions/workflows/docs-build-and-deploy.yaml")
if run["workflow_id"] != workflow["id"]:
return None

publish_docs = True
if automatic:
# Commit-to-PR lookup misses open forks; query by owner/branch and verify identity.
source = run["head_repository"] or {}
owner = source.get("owner", {}).get("login")
if not owner:
return None
query = urlencode(
{"state": "open", "head": f"{owner}:{run['head_branch']}", "per_page": 100}
)
candidates = [
pr
for page in request(f"{prefix}/pulls?{query}", paginate=True)
for pr in page
if matches(pr, run)
]
if len(candidates) != 1:
return None
pr = request(f"{prefix}/pulls/{candidates[0]['number']}")
if not matches(pr, run):
return None
files = [
file
for page in request(
f"{prefix}/pulls/{pr['number']}/files?per_page=100", paginate=True
)
for file in page
]
# The files API is capped at 3,000 entries; don't infer eligibility from a partial diff.
if len(files) != pr["changed_files"]:
return None
publish_docs = any(
file["filename"].startswith("docs/")
or file.get("previous_filename", "").startswith("docs/")
for file in files
)
if not publish_docs and source["id"] != CANONICAL_REPO_ID:
return None

artifacts = [
artifact
for page in request(
f"{prefix}/actions/runs/{run['id']}/artifacts?per_page=100", paginate=True
)
for artifact in page["artifacts"]
if not artifact["expired"]
]
docs = [artifact for artifact in artifacts if artifact["name"] == "docs-html"]
webapp = [artifact for artifact in artifacts if artifact["name"] == "webapp"]
if len(docs) > 1 or len(webapp) > 1:
return None
if not publish_docs:
docs = []
if not docs and (not webapp or (automatic and publish_docs)):
return None
return {
"number": pr["number"],
"head_sha": run["head_sha"],
"head_repo_id": run["head_repository"]["id"],
"run_id": run["id"],
"docs_id": docs[0]["id"] if docs else "",
"webapp_id": webapp[0]["id"] if webapp else "",
}


if __name__ == "__main__":
event = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text())
selected = select_preview(os.environ["GITHUB_EVENT_NAME"], event)
with open(os.environ["GITHUB_OUTPUT"], "a") as output:
print(f"publish={str(selected is not None).lower()}", file=output)
for key, value in (selected or {}).items():
print(f"{key}={value}", file=output)
if selected is None:
print("No eligible preview for this event.")
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,8 @@
# Build docs and Teleop web app, then deploy to GitHub Pages.
# - Push to main/release/v* tag (canonical repo): deploy docs at site root, web app at /client/<slug>/.
# Tag pushes also promote /client/stable/ to the highest v* tag.
# - Same-repo PR: auto-deploy preview to preview/pr-<N>/.
# - Fork PR: build only; a maintainer can deploy via `/preview-docs` (see
# docs-preview-on-demand.yaml).
# - PRs: upload artifacts; docs-publish-preview.yaml publishes docs-changing PRs.
# Fork builds retain GitHub's contributor-approval requirements.
#
# - PR close: docs-cleanup-preview.yaml removes preview/pr-<N>/.
# - Nightly/manual: docs-preview-sweep.yaml drops previews whose PRs are closed.
Expand All @@ -17,7 +16,7 @@
#
# The web client downloads its CloudXR SDK anonymously from public NGC resources.

name: Build & deploy docs
name: Build docs and deploy production

on:
push:
Expand All @@ -30,11 +29,14 @@ on:
types: [opened, synchronize, reopened]
paths:
- "docs/**"
- ".github/workflows/docs.yaml"
- ".github/workflows/docs-build-and-deploy.yaml"
- ".github/actions/build-cloudxr-web-client/**"
- "deps/cloudxr/webxr_client/**"
- "deps/cloudxr/.env.default"

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
Expand Down Expand Up @@ -113,14 +115,14 @@ jobs:
else
base="" # new branch / force push: treat everything as changed
fi
files="$([ -n "$base" ] && git diff --name-only "$base" HEAD || git ls-tree -r --name-only HEAD)"
files="$([ -n "$base" ] && git diff --no-renames --name-only "$base" HEAD || git ls-tree -r --name-only HEAD)"
docs=false; webapp=false
while IFS= read -r f; do
case "$f" in
docs/*|.github/workflows/docs.yaml) docs=true ;;
docs/*|.github/workflows/docs-build-and-deploy.yaml) docs=true ;;
esac
case "$f" in
deps/cloudxr/webxr_client/*|deps/cloudxr/.env.default|.github/actions/build-cloudxr-web-client/*|.github/workflows/docs.yaml|VERSION)
deps/cloudxr/webxr_client/*|deps/cloudxr/.env.default|.github/actions/build-cloudxr-web-client/*|.github/workflows/docs-build-and-deploy.yaml|VERSION)
webapp=true ;;
esac
done <<< "$files"
Expand Down Expand Up @@ -209,7 +211,8 @@ jobs:
if: >-
!cancelled()
&& needs.check-repo.outputs.is-canonical-repo == 'true'
&& (needs.check-repo.outputs.is-deploy-branch == 'true' || github.event_name == 'pull_request')
&& github.event_name == 'push'
&& needs.check-repo.outputs.is-deploy-branch == 'true'
&& needs.build-docs.result != 'failure'
&& needs.build-app.result != 'failure'
&& (needs.build-docs.result == 'success' || needs.build-app.result == 'success')
Expand Down Expand Up @@ -253,12 +256,6 @@ jobs:
set -euo pipefail
CLIENT_BASE=./docs/build/client

if [ "${{ github.event_name }}" = 'pull_request' ]; then
mkdir -p ./docs/build/current/client
cp -r ./webapp/. ./docs/build/current/client/
exit 0
fi

for target in $CLIENT_TARGETS; do
mkdir -p "$CLIENT_BASE/$target"
cp -r ./webapp/. "$CLIENT_BASE/$target/"
Expand Down Expand Up @@ -321,51 +318,3 @@ jobs:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./docs/build
keep_files: true

# Fork PRs have a read-only GITHUB_TOKEN and can't push to gh-pages, so
# the auto-deploy is gated to same-repo PRs. Fork PR previews are
# handled by docs-preview-on-demand.yaml (slash-command triggered).
- name: Deploy PR preview to gh-pages
if: >-
github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.full_name == github.repository
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./docs/build/current
destination_dir: preview/pr-${{ github.event.pull_request.number }}
keep_files: true

- name: Add preview URL to workflow summary
if: >-
github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.full_name == github.repository
run: |
base="https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/preview/pr-${{ github.event.pull_request.number }}/"
client_url="${base}client/"
{
echo "## Docs preview"
echo
echo "Built docs for this PR are available at:"
echo
echo "**${base}**"
echo
echo "Teleop web app (subpath \`/client/\`): **${client_url}**"
echo
echo "(It may take a minute to appear after the workflow completes.)"
} >> "$GITHUB_STEP_SUMMARY"

- name: Note manual-trigger requirement for fork PRs
if: >-
github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.full_name != github.repository
run: |
{
echo "## Docs preview"
echo
echo "This PR is from a fork, so the preview is **not auto-deployed**."
echo "A maintainer with write access can comment \`/preview-docs\` on"
echo "the PR to deploy a preview to:"
echo
echo "**https://nvidia.github.io/IsaacCapture/preview/pr-${{ github.event.pull_request.number }}/**"
} >> "$GITHUB_STEP_SUMMARY"
Loading
Loading