From 93aee56b837b9563c4afae0b22f896cb2218aec3 Mon Sep 17 00:00:00 2001 From: "Ralf W. Grosse-Kunstleve" Date: Mon, 1 Jun 2026 21:23:10 -0700 Subject: [PATCH 1/5] Check release notes for planned backports Require mainline cuda-bindings and cuda-python releases to explicitly declare a planned backport tag or mark it not planned. Keep actual backport releases unblocked while surfacing missing notes as warnings, and preserve docs builds for older tags that still use ci/versions.json. --- .github/workflows/build-docs.yml | 11 +- .github/workflows/release.yml | 23 ++- ci/tools/check_release_notes.py | 137 ++++++++++++++++- ci/tools/tests/test_check_release_notes.py | 169 +++++++++++++++++++++ 4 files changed, 330 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml index 1bd0c0194c1..95fe20b9a28 100644 --- a/.github/workflows/build-docs.yml +++ b/.github/workflows/build-docs.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 @@ -52,7 +52,14 @@ jobs: - name: Read build CTK version run: | - BUILD_CTK_VER=$(yq '.cuda.build.version' ci/versions.yml) + if [[ -f ci/versions.yml ]]; then + BUILD_CTK_VER=$(yq '.cuda.build.version' ci/versions.yml) + elif [[ -f ci/versions.json ]]; then + BUILD_CTK_VER=$(jq -r '.cuda.build.version' ci/versions.json) + else + echo "error: cannot find ci/versions.yml or ci/versions.json" >&2 + exit 1 + fi if [[ ! "${BUILD_CTK_VER}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "error: derived CTK build version ${BUILD_CTK_VER} does not match MAJOR.MINOR.MICRO" >&2 exit 1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 871e38ad4ec..7ce21ea9b01 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.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 @@ -24,6 +24,11 @@ on: description: "The release git tag" required: true type: string + backport-git-tag: + description: "Mainline cuda-bindings/cuda-python only: planned backport tag, or 'not planned'. Leave blank for backport releases." + required: false + type: string + default: "" run-id: description: "The GHA run ID that generated validated artifacts (optional - auto-detects successful tag-triggered CI run for git-tag)" required: false @@ -51,9 +56,14 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - echo "Auto-detecting successful tag-triggered run ID for tag: ${{ inputs.git-tag }}" - RUN_ID=$(./ci/tools/lookup-run-id "${{ inputs.git-tag }}" "${{ github.repository }}") - echo "Auto-detected run ID: $RUN_ID" + if [[ -n "${{ inputs.run-id }}" ]]; then + echo "Using provided run ID: ${{ inputs.run-id }}" + RUN_ID="${{ inputs.run-id }}" + else + echo "Auto-detecting successful tag-triggered run ID for tag: ${{ inputs.git-tag }}" + RUN_ID=$(./ci/tools/lookup-run-id "${{ inputs.git-tag }}" "${{ github.repository }}") + echo "Auto-detected run ID: $RUN_ID" + fi echo "run-id=$RUN_ID" >> "$GITHUB_OUTPUT" check-tag: @@ -93,8 +103,6 @@ jobs: steps: - name: Checkout Source uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: ${{ inputs.git-tag }} - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 @@ -110,7 +118,8 @@ jobs: run: | python ci/tools/check_release_notes.py \ --git-tag "${{ inputs.git-tag }}" \ - --component "${{ inputs.component }}" + --component "${{ inputs.component }}" \ + --backport-git-tag "${{ inputs.backport-git-tag }}" doc: name: Build release docs diff --git a/ci/tools/check_release_notes.py b/ci/tools/check_release_notes.py index ad2a640a865..75d2c9871f0 100644 --- a/ci/tools/check_release_notes.py +++ b/ci/tools/check_release_notes.py @@ -39,6 +39,11 @@ "cuda-pathfinder": re.compile(rf"^cuda-pathfinder-v(?P{_VERSION_PATTERN})$"), } +BACKPORT_PLANNING_COMPONENTS = frozenset({"cuda-bindings", "cuda-python"}) +BACKPORT_NOT_PLANNED = "not planned" +BACKPORT_BRANCH_RE = re.compile(r"""^backport_branch:\s*["']?(?P[^"'\s#]+)""") +BACKPORT_BRANCH_NAME_RE = re.compile(r"^\d+\.\d+\.x$") + def parse_version_from_tag(git_tag: str, component: str) -> str | None: """Extract the version string from a tag, given the target component. @@ -57,6 +62,28 @@ def is_post_release(version: str) -> bool: return ".post" in version +def load_backport_branch(repo_root: str = ".") -> str | None: + path = os.path.join(repo_root, "ci", "versions.yml") + try: + with open(path, encoding="utf-8") as f: + for line in f: + m = BACKPORT_BRANCH_RE.match(line.strip()) + if m: + return m.group("branch") + except FileNotFoundError: + pass + github_ref_name = os.environ.get("GITHUB_REF_NAME", "") + if BACKPORT_BRANCH_NAME_RE.match(github_ref_name): + return github_ref_name + return None + + +def is_backport_version(version: str, backport_branch: str) -> bool: + if backport_branch.endswith(".x"): + return version.startswith(backport_branch[:-1]) + return version == backport_branch + + def notes_path(package: str, version: str) -> str: return os.path.join(package, "docs", "source", "release", f"{version}-notes.rst") @@ -86,11 +113,101 @@ def check_release_notes(git_tag: str, component: str, repo_root: str = ".") -> l return [] +def write_step_summary(message: str) -> None: + summary_path = os.environ.get("GITHUB_STEP_SUMMARY") + if not summary_path: + return + with open(summary_path, "a", encoding="utf-8") as f: + f.write(message) + if not message.endswith("\n"): + f.write("\n") + + +def warn_missing_backport_notes(git_tag: str, component: str, problems: list[tuple[str, str]]) -> None: + print(f"WARNING: missing or empty release notes for backport tag {git_tag}:") + summary_lines = [ + "## Release Notes Reminder", + "", + f"Backport release `{git_tag}` for `{component}` is allowed to continue,", + "but the following release-note files are missing or empty in the workflow source:", + "", + ] + for path, reason in problems: + print(f"::warning file={path}::Release notes for backport tag {git_tag} are {reason}.") + print(f" - {path} ({reason})") + summary_lines.append(f"- `{path}` ({reason})") + summary_lines.extend(["", "Please add the backport release notes on `main` if they are not already present."]) + write_step_summary("\n".join(summary_lines)) + + +def validate_backport_decision( + *, + git_tag: str, + component: str, + version: str, + backport_git_tag: str, + backport_branch: str | None, + repo_root: str, +) -> tuple[int | None, list[tuple[str, str]]]: + if component not in BACKPORT_PLANNING_COMPONENTS or is_post_release(version): + return None, [] + + if backport_branch is None: + print("ERROR: cannot determine backport branch from ci/versions.yml or GITHUB_REF_NAME.", file=sys.stderr) + return 2, [] + + if is_backport_version(version, backport_branch): + problems = check_release_notes(git_tag, component, repo_root) + if problems: + warn_missing_backport_notes(git_tag, component, problems) + else: + print(f"Release notes present for backport tag {git_tag}, component {component}.") + return 0, [] + + decision = backport_git_tag.strip() + if not decision: + return ( + 1, + [ + ( + "", + f"required for {component} mainline releases; use a backport tag or '{BACKPORT_NOT_PLANNED}'", + ) + ], + ) + + if decision == BACKPORT_NOT_PLANNED: + print(f"Backport release not planned for {git_tag}, skipping backport release-notes check.") + return None, [] + + backport_version = parse_version_from_tag(decision, component) + if backport_version is None: + print( + f"ERROR: backport tag {decision!r} does not match the expected format for component {component!r}.", + file=sys.stderr, + ) + return 2, [] + + if not is_backport_version(backport_version, backport_branch): + print( + f"ERROR: backport tag {decision!r} does not match configured backport branch {backport_branch!r}.", + file=sys.stderr, + ) + return 2, [] + + problems = check_release_notes(decision, component, repo_root) + if problems: + return 1, problems + return None, [] + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--git-tag", required=True) parser.add_argument("--component", required=True, choices=list(COMPONENT_TO_PACKAGE)) parser.add_argument("--repo-root", default=".") + parser.add_argument("--backport-git-tag", default="") + parser.add_argument("--backport-branch", default="") args = parser.parse_args(argv) version = parse_version_from_tag(args.git_tag, args.component) @@ -105,7 +222,25 @@ def main(argv: list[str] | None = None) -> int: print(f"Post-release tag ({args.git_tag}), skipping release-notes check.") return 0 - problems = check_release_notes(args.git_tag, args.component, args.repo_root) + backport_branch = args.backport_branch or load_backport_branch(args.repo_root) + rc, problems = validate_backport_decision( + git_tag=args.git_tag, + component=args.component, + version=version, + backport_git_tag=args.backport_git_tag, + backport_branch=backport_branch, + repo_root=args.repo_root, + ) + if rc is not None: + if problems: + print(f"ERROR: release notes policy failed for tag {args.git_tag}:", file=sys.stderr) + for path, reason in problems: + print(f" - {path} ({reason})", file=sys.stderr) + return rc + + if not problems: + problems = check_release_notes(args.git_tag, args.component, args.repo_root) + if not problems: print(f"Release notes present for tag {args.git_tag}, component {args.component}.") return 0 diff --git a/ci/tools/tests/test_check_release_notes.py b/ci/tools/tests/test_check_release_notes.py index 8033eca620c..4f65404eed5 100644 --- a/ci/tools/tests/test_check_release_notes.py +++ b/ci/tools/tests/test_check_release_notes.py @@ -10,6 +10,7 @@ from check_release_notes import ( check_release_notes, is_post_release, + load_backport_branch, main, parse_version_from_tag, ) @@ -126,7 +127,31 @@ def test_plain_v_tag(self, tmp_path): assert problems == [] +class TestLoadBackportBranch: + def test_from_versions_yml(self, tmp_path): + d = tmp_path / "ci" + d.mkdir(parents=True) + (d / "versions.yml").write_text('backport_branch: "12.9.x"\n') + + assert load_backport_branch(str(tmp_path)) == "12.9.x" + + def test_from_github_ref_name_for_legacy_backport_branch(self, tmp_path, monkeypatch): + monkeypatch.setenv("GITHUB_REF_NAME", "12.9.x") + + assert load_backport_branch(str(tmp_path)) == "12.9.x" + + def test_ignores_non_backport_github_ref_name(self, tmp_path, monkeypatch): + monkeypatch.setenv("GITHUB_REF_NAME", "main") + + assert load_backport_branch(str(tmp_path)) is None + + class TestMain: + def _make_notes(self, tmp_path, pkg, version, content="Release notes."): + d = tmp_path / pkg / "docs" / "source" / "release" + d.mkdir(parents=True, exist_ok=True) + (d / f"{version}-notes.rst").write_text(content) + def test_success(self, tmp_path): d = tmp_path / "cuda_core" / "docs" / "source" / "release" d.mkdir(parents=True) @@ -162,3 +187,147 @@ def test_component_prefix_mismatch_returns_2(self, tmp_path): ] ) assert rc == 2 + + def test_mainline_bindings_requires_backport_decision(self, tmp_path, capsys): + rc = main( + [ + "--git-tag", + "v13.3.0", + "--component", + "cuda-bindings", + "--repo-root", + str(tmp_path), + "--backport-branch", + "12.9.x", + ] + ) + + captured = capsys.readouterr() + assert rc == 1 + assert "" in captured.err + + def test_mainline_bindings_accepts_not_planned(self, tmp_path): + self._make_notes(tmp_path, "cuda_bindings", "13.3.0") + + rc = main( + [ + "--git-tag", + "v13.3.0", + "--component", + "cuda-bindings", + "--repo-root", + str(tmp_path), + "--backport-branch", + "12.9.x", + "--backport-git-tag", + "not planned", + ] + ) + + assert rc == 0 + + def test_mainline_bindings_checks_planned_backport_notes(self, tmp_path, capsys): + self._make_notes(tmp_path, "cuda_bindings", "13.3.0") + + rc = main( + [ + "--git-tag", + "v13.3.0", + "--component", + "cuda-bindings", + "--repo-root", + str(tmp_path), + "--backport-branch", + "12.9.x", + "--backport-git-tag", + "v12.9.7", + ] + ) + + captured = capsys.readouterr() + assert rc == 1 + assert "12.9.7-notes.rst" in captured.err + + def test_mainline_bindings_accepts_planned_backport_notes(self, tmp_path): + self._make_notes(tmp_path, "cuda_bindings", "13.3.0") + self._make_notes(tmp_path, "cuda_bindings", "12.9.7") + + rc = main( + [ + "--git-tag", + "v13.3.0", + "--component", + "cuda-bindings", + "--repo-root", + str(tmp_path), + "--backport-branch", + "12.9.x", + "--backport-git-tag", + "v12.9.7", + ] + ) + + assert rc == 0 + + def test_mainline_cuda_python_accepts_planned_backport_notes(self, tmp_path): + self._make_notes(tmp_path, "cuda_python", "13.3.0") + self._make_notes(tmp_path, "cuda_python", "12.9.7") + + rc = main( + [ + "--git-tag", + "v13.3.0", + "--component", + "cuda-python", + "--repo-root", + str(tmp_path), + "--backport-branch", + "12.9.x", + "--backport-git-tag", + "v12.9.7", + ] + ) + + assert rc == 0 + + def test_backport_bindings_missing_notes_warns_without_failing(self, tmp_path, monkeypatch, capsys): + summary_path = tmp_path / "summary.md" + monkeypatch.setenv("GITHUB_STEP_SUMMARY", str(summary_path)) + + rc = main( + [ + "--git-tag", + "v12.9.7", + "--component", + "cuda-bindings", + "--repo-root", + str(tmp_path), + "--backport-branch", + "12.9.x", + ] + ) + + captured = capsys.readouterr() + assert rc == 0 + assert "::warning file=cuda_bindings/docs/source/release/12.9.7-notes.rst::" in captured.out + assert "12.9.7-notes.rst" in summary_path.read_text() + + def test_mainline_bindings_rejects_non_backport_tag(self, tmp_path): + self._make_notes(tmp_path, "cuda_bindings", "13.3.0") + + rc = main( + [ + "--git-tag", + "v13.3.0", + "--component", + "cuda-bindings", + "--repo-root", + str(tmp_path), + "--backport-branch", + "12.9.x", + "--backport-git-tag", + "v13.2.0", + ] + ) + + assert rc == 2 From 43b043f63e46dc78244be6ced4395b5851428ba8 Mon Sep 17 00:00:00 2001 From: "Ralf W. Grosse-Kunstleve" Date: Mon, 1 Jun 2026 22:02:49 -0700 Subject: [PATCH 2/5] Add dry-run release workflow mode Validate release docs, archives, and wheels without publishing to GitHub Releases, GitHub Pages, TestPyPI, or PyPI. --- .github/workflows/build-docs.yml | 21 +++++++++++++++++---- .github/workflows/release-upload.yml | 24 +++++++++++++++++++++--- .github/workflows/release.yml | 25 ++++++++++++++++++++++--- 3 files changed, 60 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml index 95fe20b9a28..e8157d7b368 100644 --- a/.github/workflows/build-docs.yml +++ b/.github/workflows/build-docs.yml @@ -33,6 +33,11 @@ on: required: false default: false type: boolean + deploy-docs: + description: "Deploy generated docs to GitHub Pages or preview branches" + required: false + default: true + type: boolean jobs: build: @@ -249,15 +254,23 @@ jobs: fi mv ${COMPONENT}/docs/build/html/* artifacts/docs/${TARGET} - # TODO: Consider removing this step? - - name: Upload doc artifacts + - name: Upload pages doc artifacts + if: ${{ inputs.deploy-docs }} uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 with: path: artifacts/ retention-days: 3 + - name: Upload dry-run docs artifact + if: ${{ !inputs.deploy-docs }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-docs-dry-run-${{ inputs.component }}-${{ inputs.git-tag }} + path: artifacts/docs/ + retention-days: 3 + - name: Deploy or clean up doc preview - if: ${{ !inputs.is-release }} + if: ${{ inputs.deploy-docs && !inputs.is-release }} uses: ./.github/actions/doc_preview with: source-folder: ${{ (github.ref_name != 'main' && 'artifacts/docs') || @@ -265,7 +278,7 @@ jobs: pr-number: ${{ env.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@d92aa235d04922e8f08b40ce78cc5442fcfbfa2f # v4.8.0 with: git-config-name: cuda-python-bot diff --git a/.github/workflows/release-upload.yml b/.github/workflows/release-upload.yml index 52a34e6c77f..415cb6c7103 100644 --- a/.github/workflows/release-upload.yml +++ b/.github/workflows/release-upload.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 @@ -18,6 +18,11 @@ on: description: "Component to download wheels for" type: string required: true + dry-run: + description: "Validate release artifacts without uploading them to the GitHub release" + type: boolean + required: false + default: false concurrency: # Concurrency group that uses the workflow name and PR number if available @@ -64,6 +69,7 @@ jobs: > "release/${{ env.ARCHIVE_NAME }}.tar.gz.sha256sum" - name: Upload Archive + if: ${{ !inputs.dry-run }} env: GH_TOKEN: ${{ github.token }} run: > @@ -72,7 +78,7 @@ jobs: --repo "${{ github.repository }}" release/* - - name: Download and Upload Wheels + - name: Download and Validate Wheels env: GH_TOKEN: ${{ github.token }} run: | @@ -82,8 +88,20 @@ jobs: # Validate that release wheels match the expected version from tag. ./ci/tools/validate-release-wheels "${{ inputs.git-tag }}" "${{ inputs.component }}" "release/wheels" - # Upload wheels to the release + - name: Upload Wheels + if: ${{ !inputs.dry-run }} + env: + GH_TOKEN: ${{ github.token }} + run: | if [[ -d "release/wheels" && $(ls -A release/wheels 2>/dev/null | wc -l) -gt 0 ]]; then echo "Uploading wheels to release ${{ inputs.git-tag }}" gh release upload --clobber "${{ inputs.git-tag }}" --repo "${{ github.repository }}" release/wheels/* fi + + - name: Upload dry-run release artifacts + if: ${{ inputs.dry-run }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-artifacts-dry-run-${{ inputs.component }}-${{ inputs.git-tag }} + path: release/ + retention-days: 3 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7ce21ea9b01..4994bc6a6f2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,7 +6,8 @@ name: "CI: Release" # Manually-triggered release workflow. Creates a release draft if one doesn't exist # for the given tag, or uses an existing draft, then publishes the selected wheels -# to TestPyPI followed by PyPI. +# to TestPyPI followed by PyPI. The dry-run mode validates the release path +# without publishing to external release surfaces. on: workflow_dispatch: @@ -20,6 +21,14 @@ on: - cuda-bindings - cuda-pathfinder - cuda-python + release-action: + description: "What to run" + required: true + type: choice + options: + - full-release + - dry-run + default: full-release git-tag: description: "The release git tag" required: true @@ -74,10 +83,16 @@ jobs: with: fetch-depth: 0 - - name: Check or create draft release for the tag + - name: Check release tag and draft state env: GH_TOKEN: ${{ github.token }} run: | + if [[ "${{ inputs.release-action }}" == "dry-run" ]]; then + git rev-parse --verify "${{ inputs.git-tag }}^{commit}" + echo "Dry-run selected; not checking or creating a GitHub release draft." + exit 0 + fi + mapfile -t tags < <(gh release list -R "${{ github.repository }}" --json tagName --jq '.[] | .tagName') mapfile -t is_draft < <(gh release list -R "${{ github.repository }}" --json isDraft --jq '.[] | .isDraft') @@ -140,9 +155,10 @@ jobs: git-tag: ${{ inputs.git-tag }} run-id: ${{ needs.determine-run-id.outputs.run-id }} is-release: true + deploy-docs: ${{ inputs.release-action == 'full-release' }} upload-archive: - name: Upload source archive + name: Validate release artifacts permissions: contents: write needs: @@ -156,9 +172,11 @@ jobs: git-tag: ${{ inputs.git-tag }} run-id: ${{ needs.determine-run-id.outputs.run-id }} component: ${{ inputs.component }} + dry-run: ${{ inputs.release-action == 'dry-run' }} publish-testpypi: name: Publish wheels to TestPyPI + if: ${{ inputs.release-action == 'full-release' }} runs-on: ubuntu-latest needs: - check-tag @@ -191,6 +209,7 @@ jobs: publish-pypi: name: Publish wheels to PyPI + if: ${{ inputs.release-action == 'full-release' }} runs-on: ubuntu-latest needs: - determine-run-id From 9219b22c336d02924daa2e4ade8ae489df4675a9 Mon Sep 17 00:00:00 2001 From: "Ralf W. Grosse-Kunstleve" Date: Tue, 2 Jun 2026 15:18:13 -0700 Subject: [PATCH 3/5] Allow dry-run docs branch deployment Add an explicit dry-run docs branch input so release dry-runs can optionally write generated docs to a seeded non-production branch while keeping artifact-only dry-runs as the default. --- .github/workflows/build-docs.yml | 8 +++++++- .github/workflows/release.yml | 16 +++++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml index e8157d7b368..853df6ab935 100644 --- a/.github/workflows/build-docs.yml +++ b/.github/workflows/build-docs.yml @@ -38,6 +38,11 @@ on: required: false default: true type: boolean + docs-branch: + description: "Branch that receives deployed docs" + required: false + default: "gh-pages" + type: string jobs: build: @@ -262,7 +267,7 @@ jobs: retention-days: 3 - name: Upload dry-run docs artifact - if: ${{ !inputs.deploy-docs }} + if: ${{ !inputs.deploy-docs || (inputs.is-release && inputs.docs-branch != 'gh-pages') }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: release-docs-dry-run-${{ inputs.component }}-${{ inputs.git-tag }} @@ -283,6 +288,7 @@ jobs: with: git-config-name: cuda-python-bot git-config-email: cuda-python-bot@users.noreply.github.com + branch: ${{ inputs.docs-branch }} folder: artifacts/docs/ target-folder: docs/ commit-message: "Deploy ${{ (inputs.is-release && 'release') || 'latest' }} docs: ${{ env.COMMIT_HASH }}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4994bc6a6f2..ed25a164a5a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -43,6 +43,11 @@ on: required: false type: string default: "" + dry-run-docs-branch: + description: "Dry-run only: optional branch to receive generated docs, for example gh-pages-dry-run" + required: false + type: string + default: "" defaults: run: @@ -87,6 +92,14 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | + if [[ "${{ inputs.release-action }}" == "full-release" && -n "${{ inputs.dry-run-docs-branch }}" ]]; then + echo "error: dry-run-docs-branch is only valid with release-action=dry-run" >&2 + exit 1 + fi + if [[ "${{ inputs.release-action }}" == "dry-run" && "${{ inputs.dry-run-docs-branch }}" == "gh-pages" ]]; then + echo "error: dry-run-docs-branch must not be gh-pages" >&2 + exit 1 + fi if [[ "${{ inputs.release-action }}" == "dry-run" ]]; then git rev-parse --verify "${{ inputs.git-tag }}^{commit}" echo "Dry-run selected; not checking or creating a GitHub release draft." @@ -155,7 +168,8 @@ jobs: git-tag: ${{ inputs.git-tag }} run-id: ${{ needs.determine-run-id.outputs.run-id }} is-release: true - deploy-docs: ${{ inputs.release-action == 'full-release' }} + deploy-docs: ${{ inputs.release-action == 'full-release' || inputs.dry-run-docs-branch != '' }} + docs-branch: ${{ (inputs.release-action == 'dry-run' && inputs.dry-run-docs-branch) || 'gh-pages' }} upload-archive: name: Validate release artifacts From 9f1a110b885072f683203bcac309d6fa70f02ce7 Mon Sep 17 00:00:00 2001 From: "Ralf W. Grosse-Kunstleve" Date: Tue, 2 Jun 2026 16:58:13 -0700 Subject: [PATCH 4/5] Default release workflow dispatches to dry-run Make dry-run the first and default release action so production publishing must be deliberately selected for manual release workflow runs. --- .github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ed25a164a5a..a424e8cb8b9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,9 +26,9 @@ on: required: true type: choice options: - - full-release - dry-run - default: full-release + - full-release + default: dry-run git-tag: description: "The release git tag" required: true From 0b9a4085e0ab651c3f54caac5c2d464c45b7690c Mon Sep 17 00:00:00 2001 From: "Ralf W. Grosse-Kunstleve" Date: Tue, 2 Jun 2026 17:06:03 -0700 Subject: [PATCH 5/5] Constrain dry-run docs deploy branches Require optional dry-run docs deployments to target a non-production gh-pages-* branch so manual release dry-runs cannot accidentally publish docs to production or source branches. --- .github/workflows/release.yml | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a424e8cb8b9..72d11c017b8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,7 +44,7 @@ on: type: string default: "" dry-run-docs-branch: - description: "Dry-run only: optional branch to receive generated docs, for example gh-pages-dry-run" + description: "Dry-run only: optional gh-pages-* branch to receive generated docs, for example gh-pages-dry-run" required: false type: string default: "" @@ -91,17 +91,20 @@ jobs: - name: Check release tag and draft state env: GH_TOKEN: ${{ github.token }} + RELEASE_ACTION: ${{ inputs.release-action }} + RELEASE_GIT_TAG: ${{ inputs.git-tag }} + DRY_RUN_DOCS_BRANCH: ${{ inputs.dry-run-docs-branch }} run: | - if [[ "${{ inputs.release-action }}" == "full-release" && -n "${{ inputs.dry-run-docs-branch }}" ]]; then + if [[ "$RELEASE_ACTION" == "full-release" && -n "$DRY_RUN_DOCS_BRANCH" ]]; then echo "error: dry-run-docs-branch is only valid with release-action=dry-run" >&2 exit 1 fi - if [[ "${{ inputs.release-action }}" == "dry-run" && "${{ inputs.dry-run-docs-branch }}" == "gh-pages" ]]; then - echo "error: dry-run-docs-branch must not be gh-pages" >&2 + if [[ "$RELEASE_ACTION" == "dry-run" && -n "$DRY_RUN_DOCS_BRANCH" && ! "$DRY_RUN_DOCS_BRANCH" =~ ^gh-pages-[[:alnum:]._/-]+$ ]]; then + echo "error: dry-run-docs-branch must be a non-production gh-pages-* branch" >&2 exit 1 fi - if [[ "${{ inputs.release-action }}" == "dry-run" ]]; then - git rev-parse --verify "${{ inputs.git-tag }}^{commit}" + if [[ "$RELEASE_ACTION" == "dry-run" ]]; then + git rev-parse --verify "${RELEASE_GIT_TAG}^{commit}" echo "Dry-run selected; not checking or creating a GitHub release draft." exit 0 fi