From cf8e555bf0e1fdde955541f271b2bc573724fc29 Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Mon, 2 Nov 2026 13:40:13 -0700 Subject: [PATCH 1/3] ci(release): publish stable Python wheels directly to PyPI Signed-off-by: Piotr Mlocek --- .agents/skills/release-openshell/SKILL.md | 8 ++ .github/workflows/release-dev.yml | 46 +------- .github/workflows/release-prerelease.yml | 1 - .github/workflows/release-publish.yml | 55 +--------- .github/workflows/release.yml | 95 ++++++++++++++++- .github/workflows/wheel-publish.yml | 124 ---------------------- fern/README.md | 2 +- python/release_tooling_test.py | 112 +++++++++++++++++++ tasks/scripts/sync_docs_website_test.py | 21 ++-- 9 files changed, 227 insertions(+), 237 deletions(-) delete mode 100644 .github/workflows/wheel-publish.yml diff --git a/.agents/skills/release-openshell/SKILL.md b/.agents/skills/release-openshell/SKILL.md index 470d91467e..a1acf8bb92 100644 --- a/.agents/skills/release-openshell/SKILL.md +++ b/.agents/skills/release-openshell/SKILL.md @@ -83,3 +83,11 @@ updating a Snap Store support track. Docs retain an immutable version and advance latest only for a newer version. Watch the stable release run and report its result. + +## PyPI publishing + +Stable releases publish the built Python wheel directly to PyPI from the `publish-pypi` job in `release.yml`, after the other release publishers succeed. The job checks the stable tag's source commit and the wheel's release checksum. It uses Trusted Publishing with the `pypi` GitHub environment. Configure the `openshell` PyPI project's GitHub publisher with owner `NVIDIA`, repository `OpenShell`, workflow `release.yml`, and environment `pypi`. No PyPI API token or GitLab trigger credentials are required. + +The environment must allow the branch used to dispatch Release. For a support release, explicitly allow its support branch before dispatching. Required reviewers, if configured, must approve the PyPI job. Stable docs publish after PyPI succeeds. An existing wheel on PyPI is skipped when retrying Release; PyPI does not allow replacing a published wheel. + +Development and prerelease wheels remain GitHub Release assets and do not publish to PyPI. diff --git a/.github/workflows/release-dev.yml b/.github/workflows/release-dev.yml index eae71894e5..29dc853e29 100644 --- a/.github/workflows/release-dev.yml +++ b/.github/workflows/release-dev.yml @@ -292,8 +292,6 @@ jobs: id-token: write attestations: write artifact-metadata: write - outputs: - wheel_filenames: ${{ steps.wheel_filenames.outputs.wheel_filenames }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -397,8 +395,7 @@ jobs: ls -la release/ - - name: Capture wheel filenames - id: wheel_filenames + - name: Verify wheel count run: | set -euo pipefail shopt -s nullglob @@ -407,8 +404,6 @@ jobs: echo "expected exactly one Python wheel, found ${#wheels[@]}" >&2 exit 1 fi - wheel_filename=$(basename "${wheels[0]}") - echo "wheel_filenames=${wheel_filename}" >> "$GITHUB_OUTPUT" - name: Generate checksums run: | @@ -580,7 +575,7 @@ jobs: publish-fern-docs: name: Sync and Publish Fern Docs - needs: [compute-versions, release-dev, release-helm, trigger-wheel-publish] + needs: [compute-versions, release-dev, release-helm] permissions: contents: write uses: ./.github/workflows/sync-docs.yml @@ -593,40 +588,3 @@ jobs: publish: true secrets: FERN_TOKEN: ${{ secrets.FERN_TOKEN }} - - trigger-wheel-publish: - name: Trigger Wheel Publish - needs: [compute-versions, release-dev] - runs-on: [self-hosted, nv] - timeout-minutes: 10 - steps: - - name: Trigger GitLab CI - env: - GITLAB_CI_TRIGGER_TOKEN: ${{ secrets.GITLAB_CI_TRIGGER_TOKEN }} - GITLAB_CI_TRIGGER_URL: ${{ secrets.GITLAB_CI_TRIGGER_URL }} - RELEASE_VERSION: ${{ needs.compute-versions.outputs.python_version }} - WHEEL_FILENAMES: ${{ needs.release-dev.outputs.wheel_filenames }} - run: | - set -euo pipefail - if [ -z "${WHEEL_FILENAMES}" ]; then - echo "No wheel filenames provided by build job" >&2 - exit 1 - fi - - response=$(curl -X POST \ - --fail \ - --silent \ - --show-error \ - -F "token=${GITLAB_CI_TRIGGER_TOKEN}" \ - -F "ref=main" \ - -F "variables[PIPELINE_ACTION]=publish_wheels" \ - -F "variables[GITHUB_REPOSITORY]=${GITHUB_REPOSITORY}" \ - -F "variables[COMMIT_SHA]=${GITHUB_SHA}" \ - -F "variables[RELEASE_TAG]=dev" \ - -F "variables[RELEASE_VERSION]=${RELEASE_VERSION}" \ - -F "variables[RELEASE_KIND]=dev" \ - -F "variables[WHEEL_FILENAMES]=${WHEEL_FILENAMES}" \ - "${GITLAB_CI_TRIGGER_URL}") - pipeline_id=$(printf '%s' "$response" | sed -n 's/.*"id":\([0-9][0-9]*\).*/\1/p') - pipeline_status=$(printf '%s' "$response" | sed -n 's/.*"status":"\([^"]*\)".*/\1/p') - echo "Triggered GitLab pipeline ${pipeline_id:-unknown} with status=${pipeline_status:-unknown}" diff --git a/.github/workflows/release-prerelease.yml b/.github/workflows/release-prerelease.yml index 2d54d02b23..6dc3b51378 100644 --- a/.github/workflows/release-prerelease.yml +++ b/.github/workflows/release-prerelease.yml @@ -124,7 +124,6 @@ jobs: release-tag: ${{ needs.resolve.outputs.tag }} source-sha: ${{ needs.build.outputs.source_sha }} semver: ${{ needs.build.outputs.semver }} - python-version: ${{ needs.build.outputs.python_version }} npm-version: ${{ needs.build.outputs.npm_version }} is-prerelease: true secrets: diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index 24190aab68..ff00d8537c 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -27,10 +27,6 @@ on: description: Release version without the v prefix required: true type: string - python-version: - description: Python package version - required: true - type: string npm-version: description: TypeScript package version required: true @@ -38,12 +34,6 @@ on: secrets: SNAPCRAFT_STORE_CREDENTIALS: required: false - FERN_TOKEN: - required: false - GITLAB_CI_TRIGGER_TOKEN: - required: false - GITLAB_CI_TRIGGER_URL: - required: false permissions: contents: read @@ -66,8 +56,6 @@ jobs: id-token: write attestations: write artifact-metadata: write - outputs: - wheel_filenames: ${{ steps.wheel_filenames.outputs.wheel_filenames }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -149,8 +137,7 @@ jobs: path: release/ merge-multiple: true - - name: Capture wheel filenames - id: wheel_filenames + - name: Verify wheel count run: | set -euo pipefail shopt -s nullglob @@ -159,8 +146,6 @@ jobs: echo "expected exactly one Python wheel, found ${#wheels[@]}" >&2 exit 1 fi - wheel_filename=$(basename "${wheels[0]}") - echo "wheel_filenames=${wheel_filename}" >> "$GITHUB_OUTPUT" - name: Generate checksums run: | @@ -352,8 +337,6 @@ jobs: timeout-minutes: 10 permissions: contents: write - outputs: - wheel_filenames: ${{ needs.assemble.outputs.wheel_filenames }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 if: ${{ !inputs.is-prerelease && inputs.make-latest }} @@ -563,38 +546,6 @@ jobs: version: ${{ inputs.semver }} source-sha: ${{ inputs.source-sha }} - trigger-wheel-publish: - name: Trigger Wheel Publish - needs: release - if: ${{ !inputs.is-prerelease }} - uses: ./.github/workflows/wheel-publish.yml - with: - release-tag: ${{ inputs.release-tag }} - source-sha: ${{ inputs.source-sha }} - python-version: ${{ inputs.python-version }} - wheel-filenames: ${{ needs.release.outputs.wheel_filenames }} - secrets: - GITLAB_CI_TRIGGER_TOKEN: ${{ secrets.GITLAB_CI_TRIGGER_TOKEN }} - GITLAB_CI_TRIGGER_URL: ${{ secrets.GITLAB_CI_TRIGGER_URL }} - - publish-fern-docs: - name: Sync and Publish Fern Docs - needs: [release, publish-sdk-typescript, trigger-wheel-publish] - if: ${{ !inputs.is-prerelease }} - permissions: - contents: write - uses: ./.github/workflows/sync-docs.yml - with: - operation: sync - channel: stable - source_ref: ${{ inputs.source-sha }} - release_version: ${{ inputs.semver }} - version_slug: v${{ inputs.semver }} - display_name: Latest (v${{ inputs.semver }}) - publish: true - secrets: - FERN_TOKEN: ${{ secrets.FERN_TOKEN }} - # GitHub treats skipped jobs as passing, so a publisher that never ran would # leave the run green. Fail when any publisher expected for this release kind # did not succeed, so callers' failure notifications fire. @@ -609,8 +560,6 @@ jobs: - publish-prerelease-snap - publish-sdk-typescript - tag-sdk-go - - trigger-wheel-publish - - publish-fern-docs if: always() runs-on: ubuntu-latest timeout-minutes: 2 @@ -626,7 +575,7 @@ jobs: if [[ "${IS_PRERELEASE}" == "true" ]]; then expected+=(publish-prerelease-snap) else - expected+=(publish-sdk-typescript tag-sdk-go trigger-wheel-publish publish-fern-docs) + expected+=(publish-sdk-typescript tag-sdk-go) if [[ "${MAKE_LATEST}" == "true" ]]; then expected+=(publish-snap) fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 15ab451116..618b21409f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -153,19 +153,106 @@ jobs: release-tag: ${{ needs.promote.outputs.tag }} source-sha: ${{ needs.build.outputs.source_sha }} semver: ${{ needs.build.outputs.semver }} - python-version: ${{ needs.build.outputs.python_version }} npm-version: ${{ needs.build.outputs.npm_version }} is-prerelease: false make-latest: ${{ needs.promote.outputs.make-latest == 'true' }} secrets: SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} + + publish-pypi: + name: Publish Python Wheel to PyPI + needs: [promote, build, publish] + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: + name: pypi + url: https://pypi.org/project/openshell/ + permissions: + contents: read + id-token: write + steps: + - name: Download built wheel + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: python-wheel + path: dist/ + + - name: Verify wheel against the stable release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ needs.promote.outputs.tag }} + SOURCE_SHA: ${{ needs.build.outputs.source_sha }} + PYTHON_VERSION: ${{ needs.build.outputs.python_version }} + run: | + set -euo pipefail + tag_sha=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${RELEASE_TAG}" --jq .sha) + if [[ "${tag_sha}" != "${SOURCE_SHA}" ]]; then + echo "${RELEASE_TAG} points to ${tag_sha}, not ${SOURCE_SHA}" >&2 + exit 1 + fi + shopt -s nullglob + wheels=(dist/*.whl) + if [[ "${#wheels[@]}" -ne 1 ]]; then + echo "Expected exactly one Python wheel, found ${#wheels[@]}" >&2 + exit 1 + fi + wheel=$(basename "${wheels[0]}") + if [[ "${wheel}" != openshell-"${PYTHON_VERSION}"-*.whl ]]; then + echo "${wheel} is not an openshell ${PYTHON_VERSION} wheel" >&2 + exit 1 + fi + gh release download "${RELEASE_TAG}" -R "${GITHUB_REPOSITORY}" \ + --pattern openshell-checksums-sha256.txt --dir checksums + checksum=$(awk -v wheel="${wheel}" '$2 == wheel { print $1 " " $2 }' checksums/openshell-checksums-sha256.txt) + if [[ -z "${checksum}" ]]; then + echo "${wheel} is missing from the release checksums" >&2 + exit 1 + fi + cd dist + sha256sum -c - <<<"${checksum}" + + - name: Publish wheel to PyPI + uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3 + with: + skip-existing: true + + publish-fern-docs: + name: Sync and Publish Fern Docs + needs: [build, publish, publish-pypi] + permissions: + contents: write + uses: ./.github/workflows/sync-docs.yml + with: + operation: sync + channel: stable + source_ref: ${{ needs.build.outputs.source_sha }} + release_version: ${{ needs.build.outputs.semver }} + version_slug: v${{ needs.build.outputs.semver }} + display_name: Latest (v${{ needs.build.outputs.semver }}) + publish: true + secrets: FERN_TOKEN: ${{ secrets.FERN_TOKEN }} - GITLAB_CI_TRIGGER_TOKEN: ${{ secrets.GITLAB_CI_TRIGGER_TOKEN }} - GITLAB_CI_TRIGGER_URL: ${{ secrets.GITLAB_CI_TRIGGER_URL }} + + check-publication: + name: Check Stable Publication + needs: [promote, build, release-notes, publish, publish-pypi, publish-fern-docs] + if: ${{ always() && !inputs.notes-only }} + runs-on: ubuntu-latest + timeout-minutes: 2 + permissions: {} + steps: + - name: Require every stable publisher to succeed + shell: bash + env: + NEEDS: ${{ toJSON(needs) }} + run: | + set -euo pipefail + jq -e 'all(.[]; .result == "success")' <<<"${NEEDS}" notify-release-failure: name: Notify Release Failure - needs: [promote, build, release-notes, publish] + needs: [promote, build, release-notes, publish, publish-pypi, publish-fern-docs, check-publication] if: failure() runs-on: ubuntu-latest timeout-minutes: 2 diff --git a/.github/workflows/wheel-publish.yml b/.github/workflows/wheel-publish.yml deleted file mode 100644 index 1456b70b4b..0000000000 --- a/.github/workflows/wheel-publish.yml +++ /dev/null @@ -1,124 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -name: Publish Wheels - -on: - workflow_call: - inputs: - release-tag: - required: true - type: string - source-sha: - required: true - type: string - python-version: - required: true - type: string - wheel-filenames: - required: true - type: string - secrets: - GITLAB_CI_TRIGGER_TOKEN: - required: false - GITLAB_CI_TRIGGER_URL: - required: false - workflow_dispatch: - inputs: - release-tag: - description: Stable tag whose GitHub Release holds the wheels, e.g. v0.1.6 - required: true - type: string - source-sha: - description: Commit the tag points to - required: true - type: string - python-version: - description: Python package version, e.g. 0.1.6 - required: true - type: string - wheel-filenames: - description: Wheel filenames attached to the GitHub Release - required: true - type: string - -permissions: - contents: read - -jobs: - # Dispatch inputs are free-form, so confirm they describe a published release. - verify: - name: Verify Release Inputs - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: Check tag, commit and wheels - shell: bash - env: - GH_TOKEN: ${{ github.token }} - COMMIT_SHA: ${{ inputs.source-sha }} - RELEASE_VERSION: ${{ inputs.python-version }} - RELEASE_TAG: ${{ inputs.release-tag }} - WHEEL_FILENAMES: ${{ inputs.wheel-filenames }} - run: | - set -euo pipefail - tag_sha=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${RELEASE_TAG}" --jq .sha) - if [[ "${tag_sha}" != "${COMMIT_SHA}" ]]; then - echo "${RELEASE_TAG} points to ${tag_sha}, not ${COMMIT_SHA}" >&2 - exit 1 - fi - assets=$(gh release view "${RELEASE_TAG}" -R "${GITHUB_REPOSITORY}" --json assets --jq '.assets[].name') - for wheel in ${WHEEL_FILENAMES//,/ }; do - if ! grep -qxF "${wheel}" <<<"${assets}"; then - echo "${wheel} is not attached to the ${RELEASE_TAG} release" >&2 - exit 1 - fi - if [[ "${wheel}" != *"-${RELEASE_VERSION}-"* ]]; then - echo "${wheel} is not version ${RELEASE_VERSION}" >&2 - exit 1 - fi - done - - trigger: - name: Trigger Wheel Publish - needs: verify - runs-on: [self-hosted, nv] - timeout-minutes: 10 - steps: - - name: Trigger GitLab CI - shell: bash - env: - GITLAB_CI_TRIGGER_TOKEN: ${{ secrets.GITLAB_CI_TRIGGER_TOKEN }} - GITLAB_CI_TRIGGER_URL: ${{ secrets.GITLAB_CI_TRIGGER_URL }} - COMMIT_SHA: ${{ inputs.source-sha }} - RELEASE_VERSION: ${{ inputs.python-version }} - RELEASE_TAG: ${{ inputs.release-tag }} - WHEEL_FILENAMES: ${{ inputs.wheel-filenames }} - run: | - set -euo pipefail - if [ -z "${WHEEL_FILENAMES}" ]; then - echo "No wheel filenames provided by build job" >&2 - exit 1 - fi - if curl --fail --silent --output /dev/null "https://pypi.org/pypi/openshell/${RELEASE_VERSION}/json"; then - echo "openshell ${RELEASE_VERSION} is already on PyPI." - exit 0 - fi - - response=$(curl -X POST \ - --fail \ - --silent \ - --show-error \ - -F "token=${GITLAB_CI_TRIGGER_TOKEN}" \ - -F "ref=main" \ - -F "variables[PIPELINE_ACTION]=publish_wheels" \ - -F "variables[GITHUB_REPOSITORY]=${GITHUB_REPOSITORY}" \ - -F "variables[COMMIT_SHA]=${COMMIT_SHA}" \ - -F "variables[RELEASE_TAG]=${RELEASE_TAG}" \ - -F "variables[RELEASE_VERSION]=${RELEASE_VERSION}" \ - -F "variables[RELEASE_KIND]=stable" \ - -F "variables[WHEEL_FILENAMES]=${WHEEL_FILENAMES}" \ - "${GITLAB_CI_TRIGGER_URL}") - pipeline_id=$(printf '%s' "$response" | sed -n 's/.*"id":\([0-9][0-9]*\).*/\1/p') - pipeline_status=$(printf '%s' "$response" | sed -n 's/.*"status":"\([^"]*\)".*/\1/p') - echo "Triggered GitLab pipeline ${pipeline_id:-unknown} with status=${pipeline_status:-unknown}" diff --git a/fern/README.md b/fern/README.md index 71c26fa1e2..226c233ebb 100644 --- a/fern/README.md +++ b/fern/README.md @@ -49,7 +49,7 @@ The automated site uses these version types: Release Dev waits for the development artifacts and Helm chart, then calls `.github/workflows/sync-docs.yml` once. The reusable workflow updates `dev`, validates the generated site, commits and pushes the branch when needed, and publishes the production site once. -Release follows the same sequence for a stable tag through `release-publish.yml` after the release artifacts, SDK package, Helm chart, and wheel publication complete. One sync copies the tagged source into both the immutable `vX.Y.Z` snapshot and `latest` when the release is not older than the current version, then publishes the production site once. The version selector pins `latest` and `dev` first, followed by versioned snapshots in descending version order. Other historical entries follow in their existing order. +Release follows the same sequence for a stable tag through `release.yml` after the release artifacts, SDK package, Helm chart, and PyPI wheel publication complete. One sync copies the tagged source into both the immutable `vX.Y.Z` snapshot and `latest` when the release is not older than the current version, then publishes the production site once. The version selector pins `latest` and `dev` first, followed by versioned snapshots in descending version order. Other historical entries follow in their existing order. The sync workflow still accepts an optional Fern availability badge, but the current Release Dev and Release jobs do not request one. The next `dev` sync replaces the existing `dev` Beta badge with an unbadged entry. Other entries retain their badge setting until that entry is synced or removed. diff --git a/python/release_tooling_test.py b/python/release_tooling_test.py index d170839cca..95c1e1db16 100644 --- a/python/release_tooling_test.py +++ b/python/release_tooling_test.py @@ -3,10 +3,16 @@ from __future__ import annotations +import hashlib import importlib.util +import os +import subprocess import sys from pathlib import Path +import pytest +import yaml + def _load_release_module(): path = Path(__file__).resolve().parents[1] / "tasks/scripts/release.py" @@ -92,3 +98,109 @@ def fake_git(args: list[str]) -> str: assert versions.python == "0.0.38.dev108+g152d05940" assert versions.cargo == "0.0.38-dev.108+g152d05940" assert versions.git_tag == "v0.0.37" + + +def _stable_workflow_step(name: str) -> dict: + workflow = Path(__file__).resolve().parents[1] / ".github/workflows/release.yml" + jobs = yaml.load(workflow.read_text(), Loader=yaml.BaseLoader)["jobs"] + return next( + step + for job in jobs.values() + for step in job.get("steps", []) + if step.get("name") == name + ) + + +@pytest.mark.parametrize( + ("scenario", "expected_error"), + [ + ("valid", None), + ("wrong-commit", "points to"), + ("no-wheel", "Expected exactly one Python wheel"), + ("multiple-wheels", "Expected exactly one Python wheel"), + ("wrong-version", "is not an openshell"), + ("wrong-project", "is not an openshell"), + ("missing-checksum", "missing from the release checksums"), + ("tampered-wheel", "FAILED"), + ("release-download-fails", None), + ], +) +def test_pypi_upload_validates_the_release_wheel(tmp_path, scenario, expected_error): + wheel_name = "openshell-1.2.3-py3-none-any.whl" + if scenario == "wrong-version": + wheel_name = "openshell-1.2.4-py3-none-any.whl" + elif scenario == "wrong-project": + wheel_name = "other-1.2.3-py3-none-any.whl" + dist = tmp_path / "dist" + dist.mkdir() + payload = b"release wheel" + if scenario != "no-wheel": + (dist / wheel_name).write_bytes(payload) + if scenario == "multiple-wheels": + (dist / "openshell-1.2.3-py3-none-linux_x86_64.whl").write_bytes(payload) + if scenario == "tampered-wheel": + (dist / wheel_name).write_bytes(b"changed wheel") + digest = hashlib.sha256(payload).hexdigest() + checksum_name = "other.whl" if scenario == "missing-checksum" else wheel_name + (tmp_path / "release-checksums.txt").write_text(f"{digest} {checksum_name}\n") + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + gh = bin_dir / "gh" + gh.write_text( + "#!/bin/bash\n" + 'if [[ "$1" == api ]]; then\n' + ' echo "${TAG_SHA}"\n' + 'elif [[ "$1 $2" == "release download" ]]; then\n' + ' [[ "${DOWNLOAD_FAIL}" == false ]] || exit 1\n' + " mkdir checksums\n" + " cp release-checksums.txt checksums/openshell-checksums-sha256.txt\n" + "else\n" + " exit 1\n" + "fi\n" + ) + gh.chmod(0o755) + result = subprocess.run( + [ + "bash", + "-c", + _stable_workflow_step("Verify wheel against the stable release")["run"], + ], + cwd=tmp_path, + env={ + **os.environ, + "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}", + "GITHUB_REPOSITORY": "NVIDIA/OpenShell", + "RELEASE_TAG": "v1.2.3", + "SOURCE_SHA": "source-commit", + "TAG_SHA": "other-commit" + if scenario == "wrong-commit" + else "source-commit", + "PYTHON_VERSION": "1.2.3", + "DOWNLOAD_FAIL": "true" + if scenario == "release-download-fails" + else "false", + }, + capture_output=True, + text=True, + ) + if scenario == "valid": + assert result.returncode == 0, result.stderr + else: + assert result.returncode != 0 + if expected_error: + assert expected_error in result.stdout + result.stderr + + +@pytest.mark.parametrize("result", ["success", "failure", "skipped", "cancelled"]) +def test_stable_publication_requires_pypi_success(result): + outcome = subprocess.run( + [ + "bash", + "-c", + _stable_workflow_step("Require every stable publisher to succeed")["run"], + ], + env={**os.environ, "NEEDS": '{"publish-pypi":{"result":"' + result + '"}}'}, + capture_output=True, + text=True, + ) + assert (outcome.returncode == 0) == (result == "success") diff --git a/tasks/scripts/sync_docs_website_test.py b/tasks/scripts/sync_docs_website_test.py index 61486702f4..fdc6beb906 100644 --- a/tasks/scripts/sync_docs_website_test.py +++ b/tasks/scripts/sync_docs_website_test.py @@ -30,7 +30,7 @@ def read_workflow(name: str) -> dict: def test_release_workflows_sync_and_publish_docs_once() -> None: dev = read_workflow("release-dev.yml") - tag = read_workflow("release-publish.yml") + tag = read_workflow("release.yml") dev_job = dev["jobs"]["publish-fern-docs"] tag_job = tag["jobs"]["publish-fern-docs"] @@ -38,7 +38,6 @@ def test_release_workflows_sync_and_publish_docs_once() -> None: "compute-versions", "release-dev", "release-helm", - "trigger-wheel-publish", ] assert dev_job["uses"] == "./.github/workflows/sync-docs.yml" assert dev_job["with"]["channel"] == "dev" @@ -51,19 +50,21 @@ def test_release_workflows_sync_and_publish_docs_once() -> None: assert "availability" not in dev_job["with"] assert tag_job["needs"] == [ - "release", - "publish-sdk-typescript", - "trigger-wheel-publish", + "build", + "publish", + "publish-pypi", ] assert tag_job["uses"] == "./.github/workflows/sync-docs.yml" assert tag_job["with"]["channel"] == "stable" - assert tag_job["with"]["release_version"] == "${{ inputs.semver }}" - assert tag_job["with"]["version_slug"] == "v${{ inputs.semver }}" - assert tag_job["with"]["display_name"] == "Latest (v${{ inputs.semver }})" + assert tag_job["with"]["release_version"] == "${{ needs.build.outputs.semver }}" + assert tag_job["with"]["version_slug"] == "v${{ needs.build.outputs.semver }}" + assert ( + tag_job["with"]["display_name"] == "Latest (v${{ needs.build.outputs.semver }})" + ) assert tag_job["with"]["publish"] == "true" - assert tag_job["if"] == "${{ !inputs.is-prerelease }}" + assert "publish-fern-docs" not in read_workflow("release-publish.yml")["jobs"] - for workflow_name in ("release-dev.yml", "release-publish.yml"): + for workflow_name in ("release-dev.yml", "release.yml"): workflow_path = ( Path(__file__).resolve().parents[2] / ".github" From 7096f9421de21ce28effbea8aaa0923044f718a0 Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Mon, 2 Nov 2026 13:47:02 -0700 Subject: [PATCH 2/3] test(release): remove publishing workflow shell tests Signed-off-by: Piotr Mlocek --- python/release_tooling_test.py | 112 --------------------------------- 1 file changed, 112 deletions(-) diff --git a/python/release_tooling_test.py b/python/release_tooling_test.py index 95c1e1db16..d170839cca 100644 --- a/python/release_tooling_test.py +++ b/python/release_tooling_test.py @@ -3,16 +3,10 @@ from __future__ import annotations -import hashlib import importlib.util -import os -import subprocess import sys from pathlib import Path -import pytest -import yaml - def _load_release_module(): path = Path(__file__).resolve().parents[1] / "tasks/scripts/release.py" @@ -98,109 +92,3 @@ def fake_git(args: list[str]) -> str: assert versions.python == "0.0.38.dev108+g152d05940" assert versions.cargo == "0.0.38-dev.108+g152d05940" assert versions.git_tag == "v0.0.37" - - -def _stable_workflow_step(name: str) -> dict: - workflow = Path(__file__).resolve().parents[1] / ".github/workflows/release.yml" - jobs = yaml.load(workflow.read_text(), Loader=yaml.BaseLoader)["jobs"] - return next( - step - for job in jobs.values() - for step in job.get("steps", []) - if step.get("name") == name - ) - - -@pytest.mark.parametrize( - ("scenario", "expected_error"), - [ - ("valid", None), - ("wrong-commit", "points to"), - ("no-wheel", "Expected exactly one Python wheel"), - ("multiple-wheels", "Expected exactly one Python wheel"), - ("wrong-version", "is not an openshell"), - ("wrong-project", "is not an openshell"), - ("missing-checksum", "missing from the release checksums"), - ("tampered-wheel", "FAILED"), - ("release-download-fails", None), - ], -) -def test_pypi_upload_validates_the_release_wheel(tmp_path, scenario, expected_error): - wheel_name = "openshell-1.2.3-py3-none-any.whl" - if scenario == "wrong-version": - wheel_name = "openshell-1.2.4-py3-none-any.whl" - elif scenario == "wrong-project": - wheel_name = "other-1.2.3-py3-none-any.whl" - dist = tmp_path / "dist" - dist.mkdir() - payload = b"release wheel" - if scenario != "no-wheel": - (dist / wheel_name).write_bytes(payload) - if scenario == "multiple-wheels": - (dist / "openshell-1.2.3-py3-none-linux_x86_64.whl").write_bytes(payload) - if scenario == "tampered-wheel": - (dist / wheel_name).write_bytes(b"changed wheel") - digest = hashlib.sha256(payload).hexdigest() - checksum_name = "other.whl" if scenario == "missing-checksum" else wheel_name - (tmp_path / "release-checksums.txt").write_text(f"{digest} {checksum_name}\n") - bin_dir = tmp_path / "bin" - bin_dir.mkdir() - gh = bin_dir / "gh" - gh.write_text( - "#!/bin/bash\n" - 'if [[ "$1" == api ]]; then\n' - ' echo "${TAG_SHA}"\n' - 'elif [[ "$1 $2" == "release download" ]]; then\n' - ' [[ "${DOWNLOAD_FAIL}" == false ]] || exit 1\n' - " mkdir checksums\n" - " cp release-checksums.txt checksums/openshell-checksums-sha256.txt\n" - "else\n" - " exit 1\n" - "fi\n" - ) - gh.chmod(0o755) - result = subprocess.run( - [ - "bash", - "-c", - _stable_workflow_step("Verify wheel against the stable release")["run"], - ], - cwd=tmp_path, - env={ - **os.environ, - "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}", - "GITHUB_REPOSITORY": "NVIDIA/OpenShell", - "RELEASE_TAG": "v1.2.3", - "SOURCE_SHA": "source-commit", - "TAG_SHA": "other-commit" - if scenario == "wrong-commit" - else "source-commit", - "PYTHON_VERSION": "1.2.3", - "DOWNLOAD_FAIL": "true" - if scenario == "release-download-fails" - else "false", - }, - capture_output=True, - text=True, - ) - if scenario == "valid": - assert result.returncode == 0, result.stderr - else: - assert result.returncode != 0 - if expected_error: - assert expected_error in result.stdout + result.stderr - - -@pytest.mark.parametrize("result", ["success", "failure", "skipped", "cancelled"]) -def test_stable_publication_requires_pypi_success(result): - outcome = subprocess.run( - [ - "bash", - "-c", - _stable_workflow_step("Require every stable publisher to succeed")["run"], - ], - env={**os.environ, "NEEDS": '{"publish-pypi":{"result":"' + result + '"}}'}, - capture_output=True, - text=True, - ) - assert (outcome.returncode == 0) == (result == "success") From a1cadbc9028ee0f5e4c737ba9cfdff51dd28b4b9 Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Tue, 3 Nov 2026 08:18:54 -0800 Subject: [PATCH 3/3] ci(release): publish the PyPI wheel from the GitHub Release Download the wheel and checksums from the stable GitHub Release instead of the run's build artifact. A new Release run for the same tag rebuilds a wheel whose bytes differ from the released asset, so publishing the run artifact could not finish a failed PyPI upload after the artifact expired. Signed-off-by: Piotr Mlocek --- .agents/skills/release-openshell/SKILL.md | 4 ++-- .github/workflows/release.yml | 25 ++++++++++------------- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/.agents/skills/release-openshell/SKILL.md b/.agents/skills/release-openshell/SKILL.md index a1acf8bb92..4682c8438f 100644 --- a/.agents/skills/release-openshell/SKILL.md +++ b/.agents/skills/release-openshell/SKILL.md @@ -86,8 +86,8 @@ Watch the stable release run and report its result. ## PyPI publishing -Stable releases publish the built Python wheel directly to PyPI from the `publish-pypi` job in `release.yml`, after the other release publishers succeed. The job checks the stable tag's source commit and the wheel's release checksum. It uses Trusted Publishing with the `pypi` GitHub environment. Configure the `openshell` PyPI project's GitHub publisher with owner `NVIDIA`, repository `OpenShell`, workflow `release.yml`, and environment `pypi`. No PyPI API token or GitLab trigger credentials are required. +Stable releases publish the built Python wheel directly to PyPI from the `publish-pypi` job in `release.yml`, after the other release publishers succeed. The job downloads the wheel from the stable GitHub Release, not the run's build artifact, and checks the stable tag's source commit and the wheel's release checksum. It uses Trusted Publishing with the `pypi` GitHub environment. Configure the `openshell` PyPI project's GitHub publisher with owner `NVIDIA`, repository `OpenShell`, workflow `release.yml`, and environment `pypi`. No PyPI API token or GitLab trigger credentials are required. -The environment must allow the branch used to dispatch Release. For a support release, explicitly allow its support branch before dispatching. Required reviewers, if configured, must approve the PyPI job. Stable docs publish after PyPI succeeds. An existing wheel on PyPI is skipped when retrying Release; PyPI does not allow replacing a published wheel. +The environment must allow the branch used to dispatch Release. For a support release, explicitly allow its support branch before dispatching. Required reviewers, if configured, must approve the PyPI job. Stable docs publish after PyPI succeeds. An existing wheel on PyPI is skipped when retrying Release; PyPI does not allow replacing a published wheel. Because the job publishes the release asset, a new Release run for the same tag can finish a failed PyPI upload. Development and prerelease wheels remain GitHub Release assets and do not publish to PyPI. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 618b21409f..73816917d8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -171,13 +171,9 @@ jobs: contents: read id-token: write steps: - - name: Download built wheel - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: python-wheel - path: dist/ - - - name: Verify wheel against the stable release + # Publish the wheel attached to the GitHub Release rather than this run's + # build artifact, so a later run can finish publishing the same bytes. + - name: Download and verify the released wheel shell: bash env: GH_TOKEN: ${{ github.token }} @@ -191,10 +187,12 @@ jobs: echo "${RELEASE_TAG} points to ${tag_sha}, not ${SOURCE_SHA}" >&2 exit 1 fi + gh release download "${RELEASE_TAG}" -R "${GITHUB_REPOSITORY}" \ + --pattern '*.whl' --pattern openshell-checksums-sha256.txt --dir release shopt -s nullglob - wheels=(dist/*.whl) + wheels=(release/*.whl) if [[ "${#wheels[@]}" -ne 1 ]]; then - echo "Expected exactly one Python wheel, found ${#wheels[@]}" >&2 + echo "Expected exactly one Python wheel on ${RELEASE_TAG}, found ${#wheels[@]}" >&2 exit 1 fi wheel=$(basename "${wheels[0]}") @@ -202,15 +200,14 @@ jobs: echo "${wheel} is not an openshell ${PYTHON_VERSION} wheel" >&2 exit 1 fi - gh release download "${RELEASE_TAG}" -R "${GITHUB_REPOSITORY}" \ - --pattern openshell-checksums-sha256.txt --dir checksums - checksum=$(awk -v wheel="${wheel}" '$2 == wheel { print $1 " " $2 }' checksums/openshell-checksums-sha256.txt) + checksum=$(awk -v wheel="${wheel}" '$2 == wheel { print $1 " " $2 }' release/openshell-checksums-sha256.txt) if [[ -z "${checksum}" ]]; then echo "${wheel} is missing from the release checksums" >&2 exit 1 fi - cd dist - sha256sum -c - <<<"${checksum}" + (cd release && sha256sum -c - <<<"${checksum}") + mkdir dist + mv "release/${wheel}" dist/ - name: Publish wheel to PyPI uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3