From 6bb6e9290f727f1af5ab0c1e97b0b638a7ffa537 Mon Sep 17 00:00:00 2001 From: Jason Pascucci Date: Fri, 18 Sep 2026 07:58:47 -0700 Subject: [PATCH 1/5] Closes #2197 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `gh-pages` branch has accumulated thousands of generated-site commits, substantially increasing the size of full repository clones. This PR keeps `gh-pages` at a single commit while preserving the existing documentation, PR previews, and coverage pages. It: - Enables `single-commit` publishing for documentation, PR previews, preview removal, and coverage. - Serializes every automated `gh-pages` writer using the shared `cuda-python-gh-pages-publish` concurrency group. - Separates expensive docs and coverage generation from the serialized deployment jobs, so builds can still run in parallel. - Passes generated output to the deployment jobs through workflow artifacts. - Updates stale-preview cleanup to: - Work from a detached snapshot without modifying a local `gh-pages` branch. - Create a parentless commit containing the updated complete tree. - Push using an explicit `--force-with-lease`. - Permit remote pushes only from the serialized cleanup workflow. - Adds the required targeted `actionlint` suppressions for GitHub's `concurrency.queue` extension. Preview and coverage pages remain intact because every deployment begins with the existing `gh-pages` tree and updates only its assigned target directory. This change intentionally performs a non-fast-forward update on every deployment. The active `Protect gh-pages` ruleset currently blocks these updates. Before exercising this workflow—including PR preview deployment—the authenticated publishing actor must have an `always` bypass for the ruleset, or the force-push restriction must be changed. `git-config-name: cuda-python-bot` is only commit metadata; as written, the authenticated actor is the GitHub Actions integration. The `Restrict deletions` rule is not otherwise a problem: the workflows delete files within commits, not the `gh-pages` branch ref. Granting the GitHub Actions integration a bypass is relatively broad. If that is undesirable, deployment should instead use a dedicated GitHub App with narrowly scoped repository permissions. - Drain any docs, coverage, and cleanup jobs started with the previous workflow before the first compacting deployment. Concurrency groups do not apply retroactively. - After the first deployment, verify that: - `gh-pages` contains one commit. - Latest and versioned documentation still exist. - Existing PR previews still exist. - Coverage pages still exist. - If a deployment-only job fails, use **Re-run all jobs** so its prerequisite job recreates the deployment artifact. - [x] New or existing tests cover these changes. - [x] The documentation is up to date with these changes. --- .github/actionlint.yaml | 6 +++ .github/actions/doc_preview/action.yml | 4 +- .github/workflows/build-docs.yml | 60 +++++++++++++++++++++-- .github/workflows/cleanup-pr-previews.yml | 14 ++++-- .github/workflows/coverage.yml | 26 +++++++++- ci/cleanup-pr-previews | 52 ++++++++++++++------ 6 files changed, 138 insertions(+), 24 deletions(-) diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml index bad051f4642..a76439cbc3d 100644 --- a/.github/actionlint.yaml +++ b/.github/actionlint.yaml @@ -12,6 +12,12 @@ self-hosted-runner: # GitHub supports queued concurrency runs, but the latest actionlint release # does not yet recognize the concurrency.queue key. paths: + ".github/workflows/build-docs.yml": + ignore: + - 'unexpected key "queue" for "concurrency" section' + ".github/workflows/cleanup-pr-previews.yml": + ignore: + - 'unexpected key "queue" for "concurrency" section' ".github/workflows/ci-workflow-health.yml": ignore: - 'unexpected key "queue" for "concurrency" section' diff --git a/.github/actions/doc_preview/action.yml b/.github/actions/doc_preview/action.yml index 0c60b899fbf..b56d37f8f9a 100644 --- a/.github/actions/doc_preview/action.yml +++ b/.github/actions/doc_preview/action.yml @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2024-2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # # SPDX-License-Identifier: Apache-2.0 @@ -29,6 +29,7 @@ runs: folder: ${{ inputs.source-folder }} target-folder: docs/pr-preview/pr-${{ inputs.pr-number }}/ commit-message: "Deploy doc preview for PR ${{ inputs.pr-number }} (${{ github.sha }})" + single-commit: true - name: Leave a comment after deployment if: ${{ github.ref_name != 'main' && !startsWith(github.ref_name, 'release/') }} @@ -56,6 +57,7 @@ runs: folder: ${{ inputs.source-folder }} target-folder: docs/pr-preview/pr-${{ inputs.pr-number }}/ commit-message: "Clean up doc preview for PR ${{ inputs.pr-number }} (${{ github.sha }})" + single-commit: true - name: Leave a comment after removal if: ${{ github.ref_name == 'main' || startsWith(github.ref_name, 'release/') }} diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml index bef2a7e84c0..ffc7d3733d2 100644 --- a/.github/workflows/build-docs.yml +++ b/.github/workflows/build-docs.yml @@ -50,6 +50,10 @@ jobs: # The build stage could fail but we want the CI to keep moving. if: ${{ github.repository_owner == 'nvidia' && !cancelled() }} runs-on: ubuntu-latest + outputs: + deployment-artifact-name: ${{ steps.deployment-metadata.outputs.deployment-artifact-name }} + docs-github-ref: ${{ steps.deployment-metadata.outputs.docs-github-ref }} + pr-number: ${{ steps.deployment-metadata.outputs.pr-number }} defaults: run: shell: bash -el {0} @@ -221,6 +225,13 @@ jobs: if: ${{ !inputs.is-release }} uses: ./.github/actions/get_pr_number + - name: Record deployment metadata + id: deployment-metadata + run: | + echo "deployment-artifact-name=docs-deployment-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT" + echo "docs-github-ref=${CUDA_PYTHON_DOCS_GITHUB_REF}" >> "$GITHUB_OUTPUT" + echo "pr-number=${PR_NUMBER:-}" >> "$GITHUB_OUTPUT" + - name: Set up artifact directories run: | mkdir -p artifacts/docs @@ -330,6 +341,16 @@ jobs: path: artifacts/ retention-days: 3 + - name: Upload docs deployment payload + if: ${{ inputs.deploy-docs }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ steps.deployment-metadata.outputs.deployment-artifact-name }} + path: artifacts/docs/ + retention-days: 3 + include-hidden-files: true + if-no-files-found: error + - name: Upload dry-run docs artifact if: ${{ !inputs.deploy-docs || (inputs.is-release && inputs.docs-branch != 'gh-pages') }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -338,16 +359,46 @@ jobs: path: artifacts/docs/ retention-days: 3 + deploy: + name: Deploy docs + # GitHub discards prerequisite artifacts on a new run attempt. If only this + # job fails, use "Re-run all jobs" so the build recreates its payload. + needs: build + if: ${{ inputs.deploy-docs && needs.build.result == 'success' }} + runs-on: ubuntu-latest + # Must match every job that writes to gh-pages. + concurrency: + group: cuda-python-gh-pages-publish + queue: max + defaults: + run: + shell: bash -el {0} + steps: + - name: Checkout ${{ github.event.repository.name }} + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 1 + ref: ${{ inputs.git-tag }} + + - name: Download docs deployment payload + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ needs.build.outputs.deployment-artifact-name }} + path: artifacts/docs/ + + - name: Set up deployment directories + run: mkdir -p artifacts/empty_docs + - name: Deploy or clean up doc preview - if: ${{ inputs.deploy-docs && !inputs.is-release }} + if: ${{ !inputs.is-release }} uses: ./.github/actions/doc_preview with: source-folder: ${{ (github.ref_name != 'main' && 'artifacts/docs') || 'artifacts/empty_docs' }} - pr-number: ${{ env.PR_NUMBER }} + pr-number: ${{ needs.build.outputs.pr-number }} - name: Deploy doc update - if: ${{ inputs.deploy-docs && (github.ref_name == 'main' || inputs.is-release) }} + if: ${{ github.ref_name == 'main' || inputs.is-release }} uses: JamesIves/github-pages-deploy-action@fa24774553152dd7873cd16ebd8d959b010c5445 # v4.9.0 with: git-config-name: cuda-python-bot @@ -355,5 +406,6 @@ jobs: branch: ${{ inputs.docs-branch }} folder: artifacts/docs/ target-folder: docs/ - commit-message: "Deploy ${{ (inputs.is-release && 'release') || 'latest' }} docs: ${{ env.CUDA_PYTHON_DOCS_GITHUB_REF }}" + commit-message: "Deploy ${{ (inputs.is-release && 'release') || 'latest' }} docs: ${{ needs.build.outputs.docs-github-ref }}" clean: false + single-commit: true diff --git a/.github/workflows/cleanup-pr-previews.yml b/.github/workflows/cleanup-pr-previews.yml index 4c367f415c3..4889a77794f 100644 --- a/.github/workflows/cleanup-pr-previews.yml +++ b/.github/workflows/cleanup-pr-previews.yml @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # # SPDX-License-Identifier: Apache-2.0 @@ -24,6 +24,10 @@ jobs: cleanup: name: Clean up stale PR preview folders runs-on: ubuntu-latest + # Must match every job that writes to gh-pages. + concurrency: + group: cuda-python-gh-pages-publish + queue: max # Only run for NVIDIA org to prevent forks from running this if: github.repository_owner == 'NVIDIA' steps: @@ -41,8 +45,10 @@ jobs: git config --global user.email "cuda-python-bot@users.noreply.github.com" - name: Run PR preview cleanup script + id: cleanup env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_PAGES_PUBLISH_LOCK: cuda-python-gh-pages-publish run: | # Determine if we should run in dry-run mode if [[ "${{ inputs.dry-run }}" == "true" ]]; then @@ -58,11 +64,13 @@ jobs: run: | echo "### PR Preview Cleanup Summary" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - if [[ "${{ inputs.dry-run }}" == "true" ]]; then + if [[ "${{ steps.cleanup.outcome }}" != "success" ]]; then + echo "❌ Cleanup failed; no successful update was confirmed" >> $GITHUB_STEP_SUMMARY + elif [[ "${{ inputs.dry-run }}" == "true" ]]; then echo "✅ Dry-run completed successfully" >> $GITHUB_STEP_SUMMARY echo "No changes were made to the gh-pages branch" >> $GITHUB_STEP_SUMMARY else - echo "✅ Cleanup completed and changes pushed to gh-pages" >> $GITHUB_STEP_SUMMARY + echo "✅ Cleanup completed; gh-pages is up to date" >> $GITHUB_STEP_SUMMARY fi echo "" >> $GITHUB_STEP_SUMMARY echo "Workflow run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 6015db55c9d..87935562e34 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -465,7 +465,7 @@ jobs: if-no-files-found: error combine-and-deploy: - name: Combine Coverage and Deploy + name: Combine Coverage needs: [coverage-linux, coverage-windows] runs-on: ubuntu-latest if: ${{ always() && github.repository_owner == 'nvidia' }} @@ -566,6 +566,29 @@ jobs: retention-days: 7 include-hidden-files: true + deploy: + name: Deploy Coverage + # GitHub discards prerequisite artifacts on a new run attempt. If only this + # job fails, use "Re-run all jobs" so coverage recreates its payload. + needs: combine-and-deploy + if: ${{ needs.combine-and-deploy.result == 'success' && github.repository_owner == 'nvidia' }} + runs-on: ubuntu-latest + permissions: + contents: write + # Must match every job that writes to gh-pages. + concurrency: + group: cuda-python-gh-pages-publish + queue: max + steps: + - name: Checkout ${{ github.event.repository.name }} + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Download combined coverage results + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: coverage-combined + path: docs/coverage/ + - name: Deploy to gh-pages uses: JamesIves/github-pages-deploy-action@fa24774553152dd7873cd16ebd8d959b010c5445 # v4.9.0 with: @@ -575,3 +598,4 @@ jobs: target-folder: docs/ commit-message: "Deploy combined coverage (Linux + Windows): ${{ github.sha }}" clean: false + single-commit: true diff --git a/ci/cleanup-pr-previews b/ci/cleanup-pr-previews index 17fd89ab6c0..f0c5da3eddf 100755 --- a/ci/cleanup-pr-previews +++ b/ci/cleanup-pr-previews @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # # SPDX-License-Identifier: Apache-2.0 @@ -29,14 +29,13 @@ USAGE: $0 [OPTIONS] OPTIONS: -n, --dry-run Preview what would be deleted without actually deleting - --push Commit and push changes to gh-pages (default: false, requires manual push) + --push Commit and push from the serialized GitHub Actions workflow -h, --help Show this help message EXAMPLES: $0 -n # Preview what would be cleaned up (RECOMMENDED FIRST) - $0 # Clean up folders locally (no push) - $0 --push # Clean up folders and push to gh-pages branch - $0 --dry-run --push # Invalid combination (dry-run takes precedence) + $0 # Clean up folders locally for inspection (no push) + $0 --dry-run --push # Dry run; --push is ignored REQUIREMENTS: - GH_TOKEN environment variable must be set with appropriate permissions @@ -45,8 +44,10 @@ REQUIREMENTS: SAFETY: Always run with --dry-run first to verify expected behavior before actual cleanup. -The script will show a summary of what would be removed. Use --push to automatically -commit and push changes, otherwise manual git operations are required. +Without --push, changes are committed locally for inspection only. + +Remote updates must run through the "Cleanup: PR Preview Documentation" workflow +so they share its deployment lock. Direct manual pushes are rejected. This script is specifically designed for the NVIDIA/cuda-python repository structure. EOF @@ -80,6 +81,17 @@ while [[ $# -gt 0 ]]; do esac done +# github-pages-deploy-action force-pushes its result. A direct manual push +# cannot participate in the Actions concurrency queue and could be overwritten +# by a deployment that started from an older branch tip. +if [[ "$PUSH_CHANGES" == "true" && "$DRY_RUN" != "true" ]] && + [[ "${GITHUB_ACTIONS:-}" != "true" || + "${GH_PAGES_PUBLISH_LOCK:-}" != "cuda-python-gh-pages-publish" ]]; then + echo -e "${RED}[ERROR]${NC} --push must run from the serialized PR preview cleanup workflow" >&2 + echo "Use workflow_dispatch for .github/workflows/cleanup-pr-previews.yml" >&2 + exit 1 +fi + # Validate required tools and environment echo -e "${YELLOW}[INFO]${NC} Checking prerequisites..." @@ -212,11 +224,14 @@ else } trap cleanup_worktree EXIT - # Ensure the local gh-pages branch is up-to-date - git fetch origin gh-pages:gh-pages + # Every publisher keeps gh-pages as a single root commit, so its + # remote-tracking ref requires non-fast-forward updates. Do not overwrite a + # user's local gh-pages branch. + git fetch origin +refs/heads/gh-pages:refs/remotes/origin/gh-pages + EXPECTED_GH_PAGES_SHA=$(git rev-parse refs/remotes/origin/gh-pages) echo -e "${YELLOW}[INFO]${NC} Creating git worktree for gh-pages branch..." - if ! git worktree add "$TEMP_DIR" gh-pages >/dev/null 2>&1; then + if ! git worktree add --detach "$TEMP_DIR" "$EXPECTED_GH_PAGES_SHA" >/dev/null 2>&1; then echo -e "${RED}[ERROR]${NC} Failed to create git worktree for gh-pages branch" >&2 # Check if the issue might be a leftover worktree registration @@ -226,7 +241,7 @@ else rm -rf "$TEMP_DIR" >/dev/null 2>&1 || true # Try again - if ! git worktree add "$TEMP_DIR" gh-pages >/dev/null 2>&1; then + if ! git worktree add --detach "$TEMP_DIR" "$EXPECTED_GH_PAGES_SHA" >/dev/null 2>&1; then echo -e "${RED}[ERROR]${NC} Still unable to create worktree after cleanup" >&2 exit 1 fi @@ -262,18 +277,25 @@ Removed preview folders for the following PRs: $(printf '%s\n' "${FOLDERS_TO_REMOVE[@]}" | sed 's/^pr-/- PR #/' | head -20) $(if [[ ${#FOLDERS_TO_REMOVE[@]} -gt 20 ]]; then echo "... and $((${#FOLDERS_TO_REMOVE[@]} - 20)) more"; fi)" - echo -e "${YELLOW}[INFO]${NC} Committing changes..." - git commit -m "$commit_message" + echo -e "${YELLOW}[INFO]${NC} Creating single-commit gh-pages history..." + # Match github-pages-deploy-action's single-commit mode so cleanup does + # not grow gh-pages history again. + GH_PAGES_TREE=$(git write-tree) + GH_PAGES_ROOT=$(printf '%s\n' "$commit_message" | git commit-tree "$GH_PAGES_TREE") + git reset --hard "$GH_PAGES_ROOT" if [[ "$PUSH_CHANGES" == "true" ]]; then echo -e "${YELLOW}[INFO]${NC} Pushing to gh-pages branch..." - git push origin gh-pages + git push \ + --force-with-lease="refs/heads/gh-pages:${EXPECTED_GH_PAGES_SHA}" \ + origin "${GH_PAGES_ROOT}:refs/heads/gh-pages" CHANGES_PUSHED="true" echo -e "${GREEN}[SUCCESS]${NC} Cleanup completed! Removed ${REMOVED_COUNT} PR preview folders and pushed changes" else CHANGES_PUSHED="false" echo -e "${GREEN}[SUCCESS]${NC} Cleanup completed! Removed ${REMOVED_COUNT} PR preview folders" - echo -e "${YELLOW}[INFO]${NC} Changes have been committed locally but not pushed. Use 'git push origin gh-pages' to push manually." + echo -e "${YELLOW}[INFO]${NC} Changes have been committed locally for inspection but not pushed." + echo -e "${YELLOW}[INFO]${NC} Run the PR preview cleanup workflow to update the remote branch safely." echo -e "${YELLOW}[WARNING]${NC} Worktree will be preserved for manual verification." fi else From 6e8bb18779a96d5228edcb307b97e68fd6e8527d Mon Sep 17 00:00:00 2001 From: Jason Pascucci Date: Fri, 18 Sep 2026 10:03:29 -0700 Subject: [PATCH 2/5] Make cancel-in-progress false explicitly. --- .github/workflows/build-docs.yml | 1 + .github/workflows/cleanup-pr-previews.yml | 1 + .github/workflows/coverage.yml | 1 + 3 files changed, 3 insertions(+) diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml index ffc7d3733d2..0297a1f0bd1 100644 --- a/.github/workflows/build-docs.yml +++ b/.github/workflows/build-docs.yml @@ -370,6 +370,7 @@ jobs: concurrency: group: cuda-python-gh-pages-publish queue: max + cancel-in-progress: false defaults: run: shell: bash -el {0} diff --git a/.github/workflows/cleanup-pr-previews.yml b/.github/workflows/cleanup-pr-previews.yml index 4889a77794f..4e37777f7dd 100644 --- a/.github/workflows/cleanup-pr-previews.yml +++ b/.github/workflows/cleanup-pr-previews.yml @@ -28,6 +28,7 @@ jobs: concurrency: group: cuda-python-gh-pages-publish queue: max + cancel-in-progress: false # Only run for NVIDIA org to prevent forks from running this if: github.repository_owner == 'NVIDIA' steps: diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 87935562e34..238d023d360 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -579,6 +579,7 @@ jobs: concurrency: group: cuda-python-gh-pages-publish queue: max + cancel-in-progress: false steps: - name: Checkout ${{ github.event.repository.name }} uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 From 60adbd69086fa5309a374421a86bdd7f9e598672 Mon Sep 17 00:00:00 2001 From: Jason Pascucci Date: Mon, 21 Sep 2026 08:42:20 -0700 Subject: [PATCH 3/5] add no force-push. Let nightly job be sole owner of doc cleanup. remove single-commit. --- .github/actions/doc_preview/action.yml | 35 +++------------ .github/workflows/build-docs.yml | 19 +++----- .github/workflows/cleanup-pr-previews.yml | 20 +-------- .github/workflows/coverage.yml | 4 +- ci/cleanup-pr-previews | 53 ++++++++++++----------- 5 files changed, 45 insertions(+), 86 deletions(-) diff --git a/.github/actions/doc_preview/action.yml b/.github/actions/doc_preview/action.yml index b56d37f8f9a..c6d049fc792 100644 --- a/.github/actions/doc_preview/action.yml +++ b/.github/actions/doc_preview/action.yml @@ -3,7 +3,7 @@ # SPDX-License-Identifier: Apache-2.0 name: Docs in PRs -description: Preview or clean up docs built from PRs +description: Preview docs built from PRs # A re-implementation based on the logic of https://github.com/rossjrw/pr-preview-action/blob/41a957c44a456a34718e9bcf825363194db5e6d5/README.md, due to limitations illustrated in NVIDIA/cuda-python#380. @@ -18,8 +18,6 @@ inputs: runs: using: composite steps: - # The steps below are executed only when testing in a PR. - # Note: the PR previews will be removed once merged to main or release/* (see below) - name: Deploy doc preview if: ${{ github.ref_name != 'main' && !startsWith(github.ref_name, 'release/') }} uses: JamesIves/github-pages-deploy-action@d92aa235d04922e8f08b40ce78cc5442fcfbfa2f # v4.8.0 @@ -29,7 +27,11 @@ runs: folder: ${{ inputs.source-folder }} target-folder: docs/pr-preview/pr-${{ inputs.pr-number }}/ commit-message: "Deploy doc preview for PR ${{ inputs.pr-number }} (${{ github.sha }})" - single-commit: true + # Every PR preview, the main docs deploy, and the coverage deploy commit to + # the shared gh-pages branch. Rebase onto a deployment that landed in the + # meantime instead of force-pushing over it, which the branch ruleset + # rejects (and which used to discard the other deployment). + force: false - name: Leave a comment after deployment if: ${{ github.ref_name != 'main' && !startsWith(github.ref_name, 'release/') }} @@ -46,28 +48,3 @@ runs: |
https://nvidia.github.io/cuda-python/pr-preview/pr-${{ inputs.pr-number }}/cuda-bindings/
|
https://nvidia.github.io/cuda-python/pr-preview/pr-${{ inputs.pr-number }}/cuda-pathfinder/

|

Preview will be ready when the GitHub Pages deployment is complete.

- - # The steps below are executed only when building on main or release/*. - - name: Remove doc preview - if: ${{ github.ref_name == 'main' || startsWith(github.ref_name, 'release/') }} - uses: JamesIves/github-pages-deploy-action@d92aa235d04922e8f08b40ce78cc5442fcfbfa2f # v4.8.0 - with: - git-config-name: cuda-python-bot - git-config-email: cuda-python-bot@users.noreply.github.com - folder: ${{ inputs.source-folder }} - target-folder: docs/pr-preview/pr-${{ inputs.pr-number }}/ - commit-message: "Clean up doc preview for PR ${{ inputs.pr-number }} (${{ github.sha }})" - single-commit: true - - - name: Leave a comment after removal - if: ${{ github.ref_name == 'main' || startsWith(github.ref_name, 'release/') }} - uses: marocchino/sticky-pull-request-comment@d4d6b0936434b21bc8345ad45a440c5f7d2c40ff # v3.0.3 - with: - header: pr-preview - number: ${{ inputs.pr-number }} - hide_and_recreate: true - hide_classify: "OUTDATED" - message: | - Doc Preview CI - :---: - Preview removed because the pull request was closed or merged. diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml index 0297a1f0bd1..e3c4409e4ca 100644 --- a/.github/workflows/build-docs.yml +++ b/.github/workflows/build-docs.yml @@ -233,10 +233,7 @@ jobs: echo "pr-number=${PR_NUMBER:-}" >> "$GITHUB_OUTPUT" - name: Set up artifact directories - run: | - mkdir -p artifacts/docs - # create an empty folder for removal use - mkdir -p artifacts/empty_docs + run: mkdir -p artifacts/docs - name: Build all docs if: ${{ inputs.component == 'all' }} @@ -387,15 +384,11 @@ jobs: name: ${{ needs.build.outputs.deployment-artifact-name }} path: artifacts/docs/ - - name: Set up deployment directories - run: mkdir -p artifacts/empty_docs - - - name: Deploy or clean up doc preview - if: ${{ !inputs.is-release }} + - name: Deploy doc preview + if: ${{ !inputs.is-release && github.ref_name != 'main' && !startsWith(github.ref_name, 'release/') }} uses: ./.github/actions/doc_preview with: - source-folder: ${{ (github.ref_name != 'main' && 'artifacts/docs') || - 'artifacts/empty_docs' }} + source-folder: artifacts/docs pr-number: ${{ needs.build.outputs.pr-number }} - name: Deploy doc update @@ -409,4 +402,6 @@ jobs: target-folder: docs/ commit-message: "Deploy ${{ (inputs.is-release && 'release') || 'latest' }} docs: ${{ needs.build.outputs.docs-github-ref }}" clean: false - single-commit: true + # Rebase onto concurrent PR-preview or coverage deployments instead of + # force-pushing, which the gh-pages ruleset rejects. + force: false diff --git a/.github/workflows/cleanup-pr-previews.yml b/.github/workflows/cleanup-pr-previews.yml index 4e37777f7dd..d411507e004 100644 --- a/.github/workflows/cleanup-pr-previews.yml +++ b/.github/workflows/cleanup-pr-previews.yml @@ -9,13 +9,6 @@ on: # Run every night at 11pm EST (4am UTC during EST, 3am UTC during EDT) # Using 4am UTC to be safe during EST (Nov-Mar) - cron: '0 4 * * *' - workflow_dispatch: - inputs: - dry-run: - description: 'Run in dry-run mode (preview only, no changes)' - required: false - default: false - type: boolean permissions: contents: write # Required to push changes to gh-pages branch @@ -50,15 +43,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_PAGES_PUBLISH_LOCK: cuda-python-gh-pages-publish - run: | - # Determine if we should run in dry-run mode - if [[ "${{ inputs.dry-run }}" == "true" ]]; then - echo "Running in dry-run mode (preview only)" - ./ci/cleanup-pr-previews --dry-run - else - echo "Running cleanup with push to gh-pages" - ./ci/cleanup-pr-previews --push - fi + run: ./ci/cleanup-pr-previews --push - name: Summary if: always() @@ -67,9 +52,6 @@ jobs: echo "" >> $GITHUB_STEP_SUMMARY if [[ "${{ steps.cleanup.outcome }}" != "success" ]]; then echo "❌ Cleanup failed; no successful update was confirmed" >> $GITHUB_STEP_SUMMARY - elif [[ "${{ inputs.dry-run }}" == "true" ]]; then - echo "✅ Dry-run completed successfully" >> $GITHUB_STEP_SUMMARY - echo "No changes were made to the gh-pages branch" >> $GITHUB_STEP_SUMMARY else echo "✅ Cleanup completed; gh-pages is up to date" >> $GITHUB_STEP_SUMMARY fi diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 238d023d360..2f66aa4e156 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -599,4 +599,6 @@ jobs: target-folder: docs/ commit-message: "Deploy combined coverage (Linux + Windows): ${{ github.sha }}" clean: false - single-commit: true + # Rebase onto concurrent docs deployments instead of force-pushing, + # which the gh-pages ruleset rejects. + force: false diff --git a/ci/cleanup-pr-previews b/ci/cleanup-pr-previews index f0c5da3eddf..431289efc6d 100755 --- a/ci/cleanup-pr-previews +++ b/ci/cleanup-pr-previews @@ -29,7 +29,7 @@ USAGE: $0 [OPTIONS] OPTIONS: -n, --dry-run Preview what would be deleted without actually deleting - --push Commit and push from the serialized GitHub Actions workflow + --push Commit and push from the scheduled GitHub Actions workflow -h, --help Show this help message EXAMPLES: @@ -46,8 +46,8 @@ SAFETY: Always run with --dry-run first to verify expected behavior before actual cleanup. Without --push, changes are committed locally for inspection only. -Remote updates must run through the "Cleanup: PR Preview Documentation" workflow -so they share its deployment lock. Direct manual pushes are rejected. +Remote updates are owned by the nightly "Cleanup: PR Preview Documentation" +workflow so they share its deployment lock. Other push contexts are rejected. This script is specifically designed for the NVIDIA/cuda-python repository structure. EOF @@ -81,14 +81,13 @@ while [[ $# -gt 0 ]]; do esac done -# github-pages-deploy-action force-pushes its result. A direct manual push -# cannot participate in the Actions concurrency queue and could be overwritten -# by a deployment that started from an older branch tip. +# Remote cleanup is restricted to the nightly job so all preview deletions have +# one owner and participate in the shared gh-pages concurrency queue. if [[ "$PUSH_CHANGES" == "true" && "$DRY_RUN" != "true" ]] && [[ "${GITHUB_ACTIONS:-}" != "true" || + "${GITHUB_EVENT_NAME:-}" != "schedule" || "${GH_PAGES_PUBLISH_LOCK:-}" != "cuda-python-gh-pages-publish" ]]; then - echo -e "${RED}[ERROR]${NC} --push must run from the serialized PR preview cleanup workflow" >&2 - echo "Use workflow_dispatch for .github/workflows/cleanup-pr-previews.yml" >&2 + echo -e "${RED}[ERROR]${NC} --push is reserved for the scheduled PR preview cleanup workflow" >&2 exit 1 fi @@ -224,14 +223,12 @@ else } trap cleanup_worktree EXIT - # Every publisher keeps gh-pages as a single root commit, so its - # remote-tracking ref requires non-fast-forward updates. Do not overwrite a - # user's local gh-pages branch. + # Track the remote branch without overwriting a user's local gh-pages branch. git fetch origin +refs/heads/gh-pages:refs/remotes/origin/gh-pages - EXPECTED_GH_PAGES_SHA=$(git rev-parse refs/remotes/origin/gh-pages) + GH_PAGES_SHA=$(git rev-parse refs/remotes/origin/gh-pages) echo -e "${YELLOW}[INFO]${NC} Creating git worktree for gh-pages branch..." - if ! git worktree add --detach "$TEMP_DIR" "$EXPECTED_GH_PAGES_SHA" >/dev/null 2>&1; then + if ! git worktree add --detach "$TEMP_DIR" "$GH_PAGES_SHA" >/dev/null 2>&1; then echo -e "${RED}[ERROR]${NC} Failed to create git worktree for gh-pages branch" >&2 # Check if the issue might be a leftover worktree registration @@ -241,7 +238,7 @@ else rm -rf "$TEMP_DIR" >/dev/null 2>&1 || true # Try again - if ! git worktree add --detach "$TEMP_DIR" "$EXPECTED_GH_PAGES_SHA" >/dev/null 2>&1; then + if ! git worktree add --detach "$TEMP_DIR" "$GH_PAGES_SHA" >/dev/null 2>&1; then echo -e "${RED}[ERROR]${NC} Still unable to create worktree after cleanup" >&2 exit 1 fi @@ -277,25 +274,31 @@ Removed preview folders for the following PRs: $(printf '%s\n' "${FOLDERS_TO_REMOVE[@]}" | sed 's/^pr-/- PR #/' | head -20) $(if [[ ${#FOLDERS_TO_REMOVE[@]} -gt 20 ]]; then echo "... and $((${#FOLDERS_TO_REMOVE[@]} - 20)) more"; fi)" - echo -e "${YELLOW}[INFO]${NC} Creating single-commit gh-pages history..." - # Match github-pages-deploy-action's single-commit mode so cleanup does - # not grow gh-pages history again. - GH_PAGES_TREE=$(git write-tree) - GH_PAGES_ROOT=$(printf '%s\n' "$commit_message" | git commit-tree "$GH_PAGES_TREE") - git reset --hard "$GH_PAGES_ROOT" + echo -e "${YELLOW}[INFO]${NC} Committing changes..." + git commit -m "$commit_message" if [[ "$PUSH_CHANGES" == "true" ]]; then echo -e "${YELLOW}[INFO]${NC} Pushing to gh-pages branch..." - git push \ - --force-with-lease="refs/heads/gh-pages:${EXPECTED_GH_PAGES_SHA}" \ - origin "${GH_PAGES_ROOT}:refs/heads/gh-pages" - CHANGES_PUSHED="true" + CHANGES_PUSHED="false" + for attempt in 1 2 3; do + if git push origin HEAD:refs/heads/gh-pages; then + CHANGES_PUSHED="true" + break + fi + if [[ "$attempt" -eq 3 ]]; then + echo -e "${RED}[ERROR]${NC} Push to gh-pages rejected after ${attempt} attempts" >&2 + exit 1 + fi + echo -e "${YELLOW}[INFO]${NC} gh-pages advanced; rebasing before retry $((attempt + 1))/3..." + git fetch origin +refs/heads/gh-pages:refs/remotes/origin/gh-pages + git rebase refs/remotes/origin/gh-pages + done echo -e "${GREEN}[SUCCESS]${NC} Cleanup completed! Removed ${REMOVED_COUNT} PR preview folders and pushed changes" else CHANGES_PUSHED="false" echo -e "${GREEN}[SUCCESS]${NC} Cleanup completed! Removed ${REMOVED_COUNT} PR preview folders" echo -e "${YELLOW}[INFO]${NC} Changes have been committed locally for inspection but not pushed." - echo -e "${YELLOW}[INFO]${NC} Run the PR preview cleanup workflow to update the remote branch safely." + echo -e "${YELLOW}[INFO]${NC} The nightly PR preview cleanup workflow owns remote deletions." echo -e "${YELLOW}[WARNING]${NC} Worktree will be preserved for manual verification." fi else From aff503ec2b4fc870be2028087e310df0df830306 Mon Sep 17 00:00:00 2001 From: Jason Pascucci Date: Mon, 28 Sep 2026 08:53:01 -0700 Subject: [PATCH 4/5] ci: harden PR preview deployment and cleanup Responding to Leo's comments. - detect preview builds using pull-request refs - honor deploy-docs for all documentation deployments - keep nightly cleanup as the sole gh-pages cleanup writer - add read-only manual preview inspection - retire sticky comments for closed PR previews --- .github/actions/doc_preview/action.yml | 4 +- .github/actions/get_pr_number/action.yml | 38 ++---------------- .github/workflows/build-docs.yml | 20 ++++++---- .github/workflows/cleanup-pr-previews.yml | 2 + .github/workflows/inspect-pr-previews.yml | 47 +++++++++++++++++++++++ ci/cleanup-pr-previews | 35 ++++++++++++++++- 6 files changed, 100 insertions(+), 46 deletions(-) create mode 100644 .github/workflows/inspect-pr-previews.yml diff --git a/.github/actions/doc_preview/action.yml b/.github/actions/doc_preview/action.yml index c6d049fc792..a0c7f212297 100644 --- a/.github/actions/doc_preview/action.yml +++ b/.github/actions/doc_preview/action.yml @@ -19,7 +19,7 @@ runs: using: composite steps: - name: Deploy doc preview - if: ${{ github.ref_name != 'main' && !startsWith(github.ref_name, 'release/') }} + if: ${{ startsWith(github.ref_name, 'pull-request/') }} uses: JamesIves/github-pages-deploy-action@d92aa235d04922e8f08b40ce78cc5442fcfbfa2f # v4.8.0 with: git-config-name: cuda-python-bot @@ -34,7 +34,7 @@ runs: force: false - name: Leave a comment after deployment - if: ${{ github.ref_name != 'main' && !startsWith(github.ref_name, 'release/') }} + if: ${{ startsWith(github.ref_name, 'pull-request/') }} uses: marocchino/sticky-pull-request-comment@d4d6b0936434b21bc8345ad45a440c5f7d2c40ff # v3.0.3 with: header: pr-preview diff --git a/.github/actions/get_pr_number/action.yml b/.github/actions/get_pr_number/action.yml index 1641f806849..4c4288204e0 100644 --- a/.github/actions/get_pr_number/action.yml +++ b/.github/actions/get_pr_number/action.yml @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2024-2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # # SPDX-License-Identifier: Apache-2.0 @@ -9,13 +9,11 @@ description: Get the PR number without relying on the pull_request* event trigge runs: using: composite steps: - - name: Get PR info (non-main, non-release branch) - if: ${{ github.ref_name != 'main' && !startsWith(github.ref_name, 'release/') }} + - name: Get PR info uses: nv-gha-runners/get-pr-info@main id: get-pr-info - - name: Extract PR number (non-main, non-release branch) - if: ${{ github.ref_name != 'main' && !startsWith(github.ref_name, 'release/') }} + - name: Extract PR number shell: bash --noprofile --norc -xeuo pipefail {0} run: | trap 'echo "Error at line $LINENO"; exit 1' ERR @@ -26,33 +24,3 @@ runs: fi echo "PR_NUMBER=$PR_NUMBER" >> $GITHUB_ENV echo "BUILD_PREVIEW=1" >> $GITHUB_ENV - - - name: Get PR data (main or release/* branch) - if: ${{ github.ref_name == 'main' || startsWith(github.ref_name, 'release/') }} - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 - id: get-pr-data - with: - script: | - const prs = await github.rest.repos.listPullRequestsAssociatedWithCommit({ - commit_sha: context.sha, - owner: context.repo.owner, - repo: context.repo.repo, - }); - if (!prs.data.length) { - core.setFailed("No PR associated with this commit on 'main' or 'release/*'."); - } else { - return prs.data[0]; - } - - - name: Extract PR number (main or release/* branch) - if: ${{ github.ref_name == 'main' || startsWith(github.ref_name, 'release/') }} - shell: bash --noprofile --norc -xeuo pipefail {0} - run: | - trap 'echo "Error at line $LINENO"; exit 1' ERR - PR_NUMBER="${{ fromJSON(steps.get-pr-data.outputs.result).number }}" - if [[ -z "$PR_NUMBER" ]]; then - echo "No associated PR found for the commit in 'main' or 'release/*'." - exit 1 - fi - echo "PR_NUMBER=$PR_NUMBER" >> $GITHUB_ENV - echo "BUILD_LATEST=1" >> $GITHUB_ENV diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml index e3c4409e4ca..6e770e0a033 100644 --- a/.github/workflows/build-docs.yml +++ b/.github/workflows/build-docs.yml @@ -220,11 +220,15 @@ jobs: # --no-deps avoids re-resolving cuda-core from PyPI during tag releases. pip install --no-deps cuda_python*.whl - # This step sets the PR_NUMBER/BUILD_LATEST/BUILD_PREVIEW env vars. + # PR CI uses pull-request/ refs regardless of the target branch. - name: Get PR number - if: ${{ !inputs.is-release }} + if: ${{ !inputs.is-release && startsWith(github.ref_name, 'pull-request/') }} uses: ./.github/actions/get_pr_number + - name: Mark latest docs build + if: ${{ !inputs.is-release && !startsWith(github.ref_name, 'pull-request/') }} + run: echo "BUILD_LATEST=1" >> "$GITHUB_ENV" + - name: Record deployment metadata id: deployment-metadata run: | @@ -273,7 +277,7 @@ jobs: mv ${COMPONENT}/docs/build/html/* artifacts/docs/${TARGET} - name: Write rendered docs file list - if: ${{ !inputs.is-release && github.ref_name != 'main' && !startsWith(github.ref_name, 'release/') }} + if: ${{ !inputs.is-release && startsWith(github.ref_name, 'pull-request/') }} run: | find "${GITHUB_WORKSPACE}/artifacts/docs" -type f -name '*.html' ! -path '*/_static/*' \ | LC_ALL=C sort > lychee-rendered-html-files.txt @@ -284,7 +288,7 @@ jobs: wc -l lychee-rendered-html-files.txt - name: Restore lychee cache - if: ${{ !inputs.is-release && github.ref_name != 'main' && !startsWith(github.ref_name, 'release/') }} + if: ${{ !inputs.is-release && startsWith(github.ref_name, 'pull-request/') }} id: restore-lychee-cache uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: @@ -294,7 +298,7 @@ jobs: docs-rendered-lychee-${{ env.PR_NUMBER }}- - name: Check rendered docs links - if: ${{ !inputs.is-release && github.ref_name != 'main' && !startsWith(github.ref_name, 'release/') }} + if: ${{ !inputs.is-release && startsWith(github.ref_name, 'pull-request/') }} uses: lycheeverse/lychee-action@6da1d14f3a43098a294b7696d93d938aa8d20fc0 # unreleased: supports v0.24.x archive layout with: # PR-preview canonical URLs are checked by the preview deployment workflow. @@ -325,7 +329,7 @@ jobs: token: ${{ github.token }} - name: Save lychee cache - if: ${{ always() && !inputs.is-release && github.ref_name != 'main' && !startsWith(github.ref_name, 'release/') && steps.restore-lychee-cache.outputs.cache-hit != 'true' && steps.restore-lychee-cache.outputs.cache-primary-key != '' }} + if: ${{ always() && !inputs.is-release && startsWith(github.ref_name, 'pull-request/') && steps.restore-lychee-cache.outputs.cache-hit != 'true' && steps.restore-lychee-cache.outputs.cache-primary-key != '' }} uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: .lycheecache @@ -385,14 +389,14 @@ jobs: path: artifacts/docs/ - name: Deploy doc preview - if: ${{ !inputs.is-release && github.ref_name != 'main' && !startsWith(github.ref_name, 'release/') }} + if: ${{ inputs.deploy-docs && !inputs.is-release && startsWith(github.ref_name, 'pull-request/') }} uses: ./.github/actions/doc_preview with: source-folder: artifacts/docs pr-number: ${{ needs.build.outputs.pr-number }} - name: Deploy doc update - if: ${{ github.ref_name == 'main' || inputs.is-release }} + if: ${{ inputs.deploy-docs && (github.ref_name == 'main' || inputs.is-release) }} uses: JamesIves/github-pages-deploy-action@fa24774553152dd7873cd16ebd8d959b010c5445 # v4.9.0 with: git-config-name: cuda-python-bot diff --git a/.github/workflows/cleanup-pr-previews.yml b/.github/workflows/cleanup-pr-previews.yml index d411507e004..c558e4141fb 100644 --- a/.github/workflows/cleanup-pr-previews.yml +++ b/.github/workflows/cleanup-pr-previews.yml @@ -9,9 +9,11 @@ on: # Run every night at 11pm EST (4am UTC during EST, 3am UTC during EDT) # Using 4am UTC to be safe during EST (Nov-Mar) - cron: '0 4 * * *' + # Manual verification lives in inspect-pr-previews.yml, which is read-only. permissions: contents: write # Required to push changes to gh-pages branch + pull-requests: write # Required to retire sticky preview comments jobs: cleanup: diff --git a/.github/workflows/inspect-pr-previews.yml b/.github/workflows/inspect-pr-previews.yml new file mode 100644 index 00000000000..63fdd657420 --- /dev/null +++ b/.github/workflows/inspect-pr-previews.yml @@ -0,0 +1,47 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +name: "Inspect: PR Preview Documentation Cleanup" + +on: + workflow_dispatch: {} + +# This workflow intentionally cannot modify gh-pages or PR comments. Keeping +# manual inspection separate prevents a selected stale ref from inheriting the +# scheduled cleanup workflow's write permissions. +permissions: + contents: read + pull-requests: read + +jobs: + inspect: + name: Find stale PR preview folders + runs-on: ubuntu-latest + if: ${{ github.repository_owner == 'NVIDIA' && github.ref_name == github.event.repository.default_branch }} + steps: + - name: Checkout trusted cleanup script + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + + - name: Inspect PR previews + id: inspect + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: ./ci/cleanup-pr-previews --dry-run + + - name: Summary + if: always() + run: | + echo "### PR Preview Cleanup Inspection" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + if [[ "${{ steps.inspect.outcome }}" != "success" ]]; then + echo "❌ Inspection failed" >> "$GITHUB_STEP_SUMMARY" + else + echo "✅ Dry-run completed successfully" >> "$GITHUB_STEP_SUMMARY" + echo "No changes were made to the gh-pages branch or PR comments" >> "$GITHUB_STEP_SUMMARY" + fi + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "Workflow run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" >> "$GITHUB_STEP_SUMMARY" diff --git a/ci/cleanup-pr-previews b/ci/cleanup-pr-previews index 431289efc6d..038e433a792 100755 --- a/ci/cleanup-pr-previews +++ b/ci/cleanup-pr-previews @@ -47,7 +47,8 @@ Always run with --dry-run first to verify expected behavior before actual cleanu Without --push, changes are committed locally for inspection only. Remote updates are owned by the nightly "Cleanup: PR Preview Documentation" -workflow so they share its deployment lock. Other push contexts are rejected. +workflow so they share its deployment lock. The manual inspection workflow is +read-only, and other push contexts are rejected. This script is specifically designed for the NVIDIA/cuda-python repository structure. EOF @@ -111,6 +112,29 @@ fi echo -e "${GREEN}[INFO]${NC} All prerequisites satisfied" +update_preview_comment() { + local pr_number="$1" + local status_message="$2" + local comment_body + local comment_id + + printf -v comment_body 'Doc Preview CI\n:---:\n%s\n' "$status_message" + comment_id=$(gh api --paginate repos/"${REPOSITORY}"/issues/"${pr_number}"/comments \ + --header "Accept: application/vnd.github+json" \ + --jq '.[] | select(.body | contains("")) | .id' \ + | tail -n 1) + + if [[ -n "$comment_id" ]]; then + gh api --method PATCH repos/"${REPOSITORY}"/issues/comments/"${comment_id}" \ + --header "Accept: application/vnd.github+json" \ + -f body="$comment_body" >/dev/null + else + gh api --method POST repos/"${REPOSITORY}"/issues/"${pr_number}"/comments \ + --header "Accept: application/vnd.github+json" \ + -f body="$comment_body" >/dev/null + fi +} + # Fetch PR preview folders from gh-pages branch echo -e "${YELLOW}[INFO]${NC} Fetching PR preview folders from gh-pages branch..." @@ -129,6 +153,7 @@ echo -e "${GREEN}[INFO]${NC} Found $(echo "$PR_FOLDERS" | wc -l) PR preview fold # Check each PR folder FOLDERS_TO_REMOVE=() +CLOSED_PRS=() TOTAL_FOLDERS=0 OPEN_PRS=0 @@ -157,6 +182,7 @@ while IFS= read -r folder; do "closed") echo -e "${RED}[REMOVE]${NC} PR #${PR_NUMBER} is closed" FOLDERS_TO_REMOVE+=("$folder") + CLOSED_PRS+=("$PR_NUMBER") ;; "not_found") echo -e "${RED}[REMOVE]${NC} PR #${PR_NUMBER} not found (may have been deleted)" @@ -195,6 +221,13 @@ if [[ "$DRY_RUN" == "true" ]]; then else echo -e "${RED}[CLEANUP]${NC} Proceeding to remove ${#FOLDERS_TO_REMOVE[@]} folders..." + if [[ "$PUSH_CHANGES" == "true" ]]; then + for pr_number in "${CLOSED_PRS[@]}"; do + update_preview_comment "$pr_number" \ + "Preview retired because the pull request was closed or merged. The nightly cleanup removes its published files." + done + fi + # Create a git worktree for gh-pages branch TEMP_DIR="./gh-pages-cleanup" From bb03ab89db1d85ff1d3731b8dc09105c125da809 Mon Sep 17 00:00:00 2001 From: Jason Pascucci Date: Tue, 29 Sep 2026 13:19:02 -0700 Subject: [PATCH 5/5] Address Andy's comments. --- .github/workflows/cleanup-pr-previews.yml | 20 +++++++++- .github/workflows/inspect-pr-previews.yml | 47 ----------------------- ci/cleanup-pr-previews | 38 +++++++++--------- 3 files changed, 38 insertions(+), 67 deletions(-) delete mode 100644 .github/workflows/inspect-pr-previews.yml diff --git a/.github/workflows/cleanup-pr-previews.yml b/.github/workflows/cleanup-pr-previews.yml index c558e4141fb..11cd458dbf9 100644 --- a/.github/workflows/cleanup-pr-previews.yml +++ b/.github/workflows/cleanup-pr-previews.yml @@ -9,7 +9,13 @@ on: # Run every night at 11pm EST (4am UTC during EST, 3am UTC during EDT) # Using 4am UTC to be safe during EST (Nov-Mar) - cron: '0 4 * * *' - # Manual verification lives in inspect-pr-previews.yml, which is read-only. + workflow_dispatch: + inputs: + dry-run: + description: 'Run in dry-run mode (preview only, no changes)' + required: false + default: false + type: boolean permissions: contents: write # Required to push changes to gh-pages branch @@ -45,7 +51,14 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_PAGES_PUBLISH_LOCK: cuda-python-gh-pages-publish - run: ./ci/cleanup-pr-previews --push + run: | + if [[ "${{ inputs.dry-run }}" == "true" ]]; then + echo "Running in dry-run mode (preview only)" + ./ci/cleanup-pr-previews --dry-run + else + echo "Running cleanup with push to gh-pages" + ./ci/cleanup-pr-previews --push + fi - name: Summary if: always() @@ -54,6 +67,9 @@ jobs: echo "" >> $GITHUB_STEP_SUMMARY if [[ "${{ steps.cleanup.outcome }}" != "success" ]]; then echo "❌ Cleanup failed; no successful update was confirmed" >> $GITHUB_STEP_SUMMARY + elif [[ "${{ inputs.dry-run }}" == "true" ]]; then + echo "✅ Dry-run completed successfully" >> $GITHUB_STEP_SUMMARY + echo "No changes were made to the gh-pages branch or PR comments" >> $GITHUB_STEP_SUMMARY else echo "✅ Cleanup completed; gh-pages is up to date" >> $GITHUB_STEP_SUMMARY fi diff --git a/.github/workflows/inspect-pr-previews.yml b/.github/workflows/inspect-pr-previews.yml deleted file mode 100644 index 63fdd657420..00000000000 --- a/.github/workflows/inspect-pr-previews.yml +++ /dev/null @@ -1,47 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# -# SPDX-License-Identifier: Apache-2.0 - -name: "Inspect: PR Preview Documentation Cleanup" - -on: - workflow_dispatch: {} - -# This workflow intentionally cannot modify gh-pages or PR comments. Keeping -# manual inspection separate prevents a selected stale ref from inheriting the -# scheduled cleanup workflow's write permissions. -permissions: - contents: read - pull-requests: read - -jobs: - inspect: - name: Find stale PR preview folders - runs-on: ubuntu-latest - if: ${{ github.repository_owner == 'NVIDIA' && github.ref_name == github.event.repository.default_branch }} - steps: - - name: Checkout trusted cleanup script - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.event.repository.default_branch }} - persist-credentials: false - - - name: Inspect PR previews - id: inspect - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: ./ci/cleanup-pr-previews --dry-run - - - name: Summary - if: always() - run: | - echo "### PR Preview Cleanup Inspection" >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" - if [[ "${{ steps.inspect.outcome }}" != "success" ]]; then - echo "❌ Inspection failed" >> "$GITHUB_STEP_SUMMARY" - else - echo "✅ Dry-run completed successfully" >> "$GITHUB_STEP_SUMMARY" - echo "No changes were made to the gh-pages branch or PR comments" >> "$GITHUB_STEP_SUMMARY" - fi - echo "" >> "$GITHUB_STEP_SUMMARY" - echo "Workflow run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" >> "$GITHUB_STEP_SUMMARY" diff --git a/ci/cleanup-pr-previews b/ci/cleanup-pr-previews index 038e433a792..414e8507ad3 100755 --- a/ci/cleanup-pr-previews +++ b/ci/cleanup-pr-previews @@ -29,7 +29,7 @@ USAGE: $0 [OPTIONS] OPTIONS: -n, --dry-run Preview what would be deleted without actually deleting - --push Commit and push from the scheduled GitHub Actions workflow + --push Commit and push from the cleanup GitHub Actions workflow -h, --help Show this help message EXAMPLES: @@ -46,9 +46,9 @@ SAFETY: Always run with --dry-run first to verify expected behavior before actual cleanup. Without --push, changes are committed locally for inspection only. -Remote updates are owned by the nightly "Cleanup: PR Preview Documentation" -workflow so they share its deployment lock. The manual inspection workflow is -read-only, and other push contexts are rejected. +Remote updates are owned by the scheduled or manually dispatched "Cleanup: PR +Preview Documentation" workflow so they share its deployment lock. Other push +contexts are rejected. This script is specifically designed for the NVIDIA/cuda-python repository structure. EOF @@ -82,13 +82,14 @@ while [[ $# -gt 0 ]]; do esac done -# Remote cleanup is restricted to the nightly job so all preview deletions have -# one owner and participate in the shared gh-pages concurrency queue. +# Remote cleanup is restricted to the cleanup workflow so all preview deletions +# participate in the shared gh-pages concurrency queue. if [[ "$PUSH_CHANGES" == "true" && "$DRY_RUN" != "true" ]] && [[ "${GITHUB_ACTIONS:-}" != "true" || - "${GITHUB_EVENT_NAME:-}" != "schedule" || + ("${GITHUB_EVENT_NAME:-}" != "schedule" && + "${GITHUB_EVENT_NAME:-}" != "workflow_dispatch") || "${GH_PAGES_PUBLISH_LOCK:-}" != "cuda-python-gh-pages-publish" ]]; then - echo -e "${RED}[ERROR]${NC} --push is reserved for the scheduled PR preview cleanup workflow" >&2 + echo -e "${RED}[ERROR]${NC} --push is reserved for the scheduled or manually dispatched PR preview cleanup workflow" >&2 exit 1 fi @@ -119,10 +120,12 @@ update_preview_comment() { local comment_id printf -v comment_body 'Doc Preview CI\n:---:\n%s\n' "$status_message" - comment_id=$(gh api --paginate repos/"${REPOSITORY}"/issues/"${pr_number}"/comments \ + if ! comment_id=$(gh api --paginate repos/"${REPOSITORY}"/issues/"${pr_number}"/comments \ --header "Accept: application/vnd.github+json" \ --jq '.[] | select(.body | contains("")) | .id' \ - | tail -n 1) + | tail -n 1); then + return 1 + fi if [[ -n "$comment_id" ]]; then gh api --method PATCH repos/"${REPOSITORY}"/issues/comments/"${comment_id}" \ @@ -221,13 +224,6 @@ if [[ "$DRY_RUN" == "true" ]]; then else echo -e "${RED}[CLEANUP]${NC} Proceeding to remove ${#FOLDERS_TO_REMOVE[@]} folders..." - if [[ "$PUSH_CHANGES" == "true" ]]; then - for pr_number in "${CLOSED_PRS[@]}"; do - update_preview_comment "$pr_number" \ - "Preview retired because the pull request was closed or merged. The nightly cleanup removes its published files." - done - fi - # Create a git worktree for gh-pages branch TEMP_DIR="./gh-pages-cleanup" @@ -327,11 +323,17 @@ $(if [[ ${#FOLDERS_TO_REMOVE[@]} -gt 20 ]]; then echo "... and $((${#FOLDERS_TO_ git rebase refs/remotes/origin/gh-pages done echo -e "${GREEN}[SUCCESS]${NC} Cleanup completed! Removed ${REMOVED_COUNT} PR preview folders and pushed changes" + for pr_number in "${CLOSED_PRS[@]}"; do + if ! update_preview_comment "$pr_number" \ + "Preview removed because the pull request was closed or merged."; then + echo -e "${YELLOW}[WARNING]${NC} Could not update the preview comment for PR #${pr_number}; continuing" >&2 + fi + done else CHANGES_PUSHED="false" echo -e "${GREEN}[SUCCESS]${NC} Cleanup completed! Removed ${REMOVED_COUNT} PR preview folders" echo -e "${YELLOW}[INFO]${NC} Changes have been committed locally for inspection but not pushed." - echo -e "${YELLOW}[INFO]${NC} The nightly PR preview cleanup workflow owns remote deletions." + echo -e "${YELLOW}[INFO]${NC} The PR preview cleanup workflow owns remote deletions." echo -e "${YELLOW}[WARNING]${NC} Worktree will be preserved for manual verification." fi else