From 002266da411d021690f4b77e6082fc2052c1068a Mon Sep 17 00:00:00 2001 From: Andrey Markelov Date: Mon, 21 Sep 2026 19:57:21 -0700 Subject: [PATCH 1/8] Add admin-triggered release workflow --- .github/workflows/pypiupload.yml | 201 ++++++++++++++++++++++++------- CONTRIBUTING.md | 12 +- 2 files changed, 171 insertions(+), 42 deletions(-) diff --git a/.github/workflows/pypiupload.yml b/.github/workflows/pypiupload.yml index f3b60351..34c74267 100644 --- a/.github/workflows/pypiupload.yml +++ b/.github/workflows/pypiupload.yml @@ -1,54 +1,173 @@ -# This workflow will upload a Python Package using Twine when a release is created -# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries - -name: Publish to PyPi +name: Release +run-name: Release ${{ inputs.version || github.event.release.tag_name }} on: + workflow_dispatch: + inputs: + version: + description: Version to release (for example, 12.3.0) + required: true + type: string release: types: [created] - workflow_dispatch: # Allow manual trigger for testing -permissions: # least privilege; the deploy job overrides this for OIDC +permissions: contents: read +concurrency: + group: release + cancel-in-progress: false + jobs: - deploy: + prepare: + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + tag: ${{ steps.release.outputs.tag }} + version: ${{ steps.release.outputs.version }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Require repository admin permission + if: github.event_name == 'workflow_dispatch' + env: + GH_TOKEN: ${{ github.token }} + run: | + permission=$(gh api \ + "repos/$GITHUB_REPOSITORY/collaborators/$GITHUB_ACTOR/permission" \ + --jq '.permission') + if [[ "$permission" != "admin" ]]; then + echo "Only repository admins can run a release; $GITHUB_ACTOR has $permission permission." + exit 1 + fi + + - name: Validate release + id: release + env: + MANUAL_VERSION: ${{ inputs.version }} + RELEASE_EVENT_TAG: ${{ github.event.release.tag_name }} + run: | + set -euo pipefail + + if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then + if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then + echo "Manual releases must run from main, not $GITHUB_REF." + exit 1 + fi + tag="v$MANUAL_VERSION" + else + tag="$RELEASE_EVENT_TAG" + fi + + if [[ ! "$tag" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "Invalid release tag: $tag. Expected vX.Y.Z with no leading zeroes." + exit 1 + fi + + if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then + if ! remote_tag=$(git ls-remote --tags origin "refs/tags/$tag"); then + echo "Unable to check whether tag $tag already exists." + exit 1 + fi + if [[ -n "$remote_tag" ]]; then + echo "Tag $tag already exists." + exit 1 + fi + fi + + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "version=${tag#v}" >> "$GITHUB_OUTPUT" + + publish: + needs: prepare runs-on: ubuntu-latest - permissions: # required for OIDC authentication + permissions: id-token: write contents: read + env: + SETUPTOOLS_SCM_PRETEND_VERSION: ${{ needs.prepare.outputs.version }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Setup Python environment + uses: actions/setup-python@v7 + with: + python-version: "3.11" + cache: pip + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install build pytest ruff sphinx twine + python -m pip install -r requirements.txt + python -m pip install -r test/requirements.txt + + - name: Run linter + run: | + ruff check --exclude dropbox/_version.py dropbox example test + ruff format --check --exclude dropbox/_version.py dropbox example test + + - name: Build and validate distributions + run: | + python -m build + twine check dist/* + + - name: Verify built package version + env: + VERSION: ${{ needs.prepare.outputs.version }} + run: | + python -m pip install --force-reinstall dist/*.whl + python -c 'import os, dropbox; assert dropbox.__version__ == os.environ["VERSION"], (dropbox.__version__, os.environ["VERSION"])' + - name: Run unit tests + run: pytest -v test/unit/ + + - name: Build documentation + run: sphinx-build -b html docs build/html + + - name: Configure AWS credentials (OIDC) + uses: aws-actions/configure-aws-credentials@v6 + with: + role-to-assume: arn:aws:iam::082972943155:role/oidc-github-dropbox-dropbox-sdk-python-repo + aws-region: us-west-2 + + - name: Get PyPI token from AWS Secrets Manager + uses: aws-actions/aws-secretsmanager-get-secrets@v3 + with: + # Referenced by friendly name; Secrets Manager appends a random suffix to + # the full ARN, so the name is the stable identifier. + secret-ids: | + PYPI_SECRET,pypi-api-token-dropbox-sdk-python + parse-json-secrets: false + + - name: Publish to PyPI + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ env.PYPI_SECRET }} + run: twine upload dist/* + + create-release: + if: github.event_name == 'workflow_dispatch' + needs: [prepare, publish] + runs-on: ubuntu-latest + permissions: + contents: write steps: - - uses: actions/checkout@v7 - - - name: Configure AWS credentials (OIDC) - uses: aws-actions/configure-aws-credentials@v6 - with: - role-to-assume: arn:aws:iam::082972943155:role/oidc-github-dropbox-dropbox-sdk-python-repo - aws-region: us-west-2 - - name: Get PyPI token from AWS Secrets Manager - id: get-secret - uses: aws-actions/aws-secretsmanager-get-secrets@v3 - with: - # Referenced by friendly name; Secrets Manager appends a random suffix to - # the full ARN, so the name is the stable identifier. - secret-ids: | - PYPI_SECRET,pypi-api-token-dropbox-sdk-python - parse-json-secrets: false - - name: Setup Python environment - uses: actions/setup-python@v7 - with: - python-version: "3.x" - - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install build twine - - name: Build sdist and wheel - run: python -m build - - name: Publish - env: - TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ env.PYPI_SECRET }} - run: | - twine check dist/* - twine upload dist/* + # Events created with GITHUB_TOKEN do not start another workflow. PyPI is + # therefore published above. Documentation is hosted by Read the Docs, not + # by a downstream GitHub Actions workflow. + - name: Create GitHub release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ needs.prepare.outputs.tag }} + run: | + gh release create "$RELEASE_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --target "$GITHUB_SHA" \ + --title "$RELEASE_TAG" \ + --generate-notes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1b224fc1..54372b8b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -68,8 +68,18 @@ $ tox -e docs The documentation will be built into `build/html`. +## Cutting New Versions (for Dropboxers) + +Repository admins can cut a new version from the GitHub Actions page: + +1. Select the **Release** workflow and click **Run workflow**. +2. Select the `main` branch and enter the version as `X.Y.Z` (without the `v` prefix). +3. Run the workflow. It validates the request, tests and publishes version `X.Y.Z` to PyPI, and then creates the GitHub release and `vX.Y.Z` tag with generated release notes. + +Creating a GitHub release manually with a `vX.Y.Z` tag remains supported and automatically publishes version `X.Y.Z` to PyPI. + [issues]: https://github.com/dropbox/dropbox-sdk-python/issues [pr]: https://github.com/dropbox/dropbox-sdk-python/pulls [coc]: https://github.com/dropbox/dropbox-sdk-python/blob/main/CODE_OF_CONDUCT.md [license]: https://github.com/dropbox/dropbox-sdk-python/blob/main/LICENSE -[cla]: https://opensource.dropbox.com/cla/ \ No newline at end of file +[cla]: https://opensource.dropbox.com/cla/ From d78e7065ba727c05a5df17eea709dd1556b40feb Mon Sep 17 00:00:00 2001 From: Andrey Markelov Date: Tue, 22 Sep 2026 12:26:38 -0700 Subject: [PATCH 2/8] Make releases tag-driven --- .github/workflows/pypiupload.yml | 121 +++++++++++++++++++++++++++---- 1 file changed, 108 insertions(+), 13 deletions(-) diff --git a/.github/workflows/pypiupload.yml b/.github/workflows/pypiupload.yml index 34c74267..7d1c2d3e 100644 --- a/.github/workflows/pypiupload.yml +++ b/.github/workflows/pypiupload.yml @@ -24,6 +24,7 @@ jobs: permissions: contents: read outputs: + sha: ${{ steps.release.outputs.sha }} tag: ${{ steps.release.outputs.tag }} version: ${{ steps.release.outputs.version }} steps: @@ -78,20 +79,21 @@ jobs: fi fi - echo "tag=$tag" >> "$GITHUB_OUTPUT" - echo "version=${tag#v}" >> "$GITHUB_OUTPUT" + { + echo "sha=$(git rev-parse HEAD)" + echo "tag=$tag" + echo "version=${tag#v}" + } >> "$GITHUB_OUTPUT" - publish: + checks: needs: prepare runs-on: ubuntu-latest permissions: - id-token: write contents: read - env: - SETUPTOOLS_SCM_PRETEND_VERSION: ${{ needs.prepare.outputs.version }} steps: - uses: actions/checkout@v7 with: + ref: ${{ needs.prepare.outputs.sha }} fetch-depth: 0 - name: Setup Python environment @@ -117,12 +119,8 @@ jobs: python -m build twine check dist/* - - name: Verify built package version - env: - VERSION: ${{ needs.prepare.outputs.version }} - run: | - python -m pip install --force-reinstall dist/*.whl - python -c 'import os, dropbox; assert dropbox.__version__ == os.environ["VERSION"], (dropbox.__version__, os.environ["VERSION"])' + - name: Install checked wheel + run: python -m pip install --force-reinstall dist/*.whl - name: Run unit tests run: pytest -v test/unit/ @@ -130,6 +128,103 @@ jobs: - name: Build documentation run: sphinx-build -b html docs build/html + create-tag: + if: github.event_name == 'workflow_dispatch' + needs: [prepare, checks] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ needs.prepare.outputs.sha }} + fetch-depth: 0 + + - name: Create and push release tag + env: + RELEASE_SHA: ${{ needs.prepare.outputs.sha }} + RELEASE_TAG: ${{ needs.prepare.outputs.tag }} + run: | + set -euo pipefail + if ! remote_tag=$(git ls-remote --tags origin "refs/tags/$RELEASE_TAG"); then + echo "Unable to check whether tag $RELEASE_TAG already exists." + exit 1 + fi + if [[ -n "$remote_tag" ]]; then + echo "Tag $RELEASE_TAG already exists." + exit 1 + fi + git tag -a "$RELEASE_TAG" "$RELEASE_SHA" -m "$RELEASE_TAG" + git push origin "refs/tags/$RELEASE_TAG" + + publish: + if: >- + always() && + needs.prepare.result == 'success' && + needs.checks.result == 'success' && + (github.event_name == 'release' || needs.create-tag.result == 'success') + needs: [prepare, checks, create-tag] + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + steps: + - uses: actions/checkout@v7 + with: + ref: refs/tags/${{ needs.prepare.outputs.tag }} + fetch-depth: 0 + + - name: Verify release tag + env: + RELEASE_SHA: ${{ needs.prepare.outputs.sha }} + RELEASE_TAG: ${{ needs.prepare.outputs.tag }} + run: | + set -euo pipefail + tag_sha=$(git rev-parse "$RELEASE_TAG^{commit}") + if [[ "$tag_sha" != "$RELEASE_SHA" ]]; then + echo "Tag $RELEASE_TAG points to $tag_sha, not validated commit $RELEASE_SHA." + exit 1 + fi + + - name: Setup Python environment + uses: actions/setup-python@v7 + with: + python-version: "3.11" + cache: pip + + - name: Install publishing dependencies + run: | + python -m pip install --upgrade pip + python -m pip install build twine + + - name: Build final distributions from tag + run: | + python -m build + twine check dist/* + + - name: Verify built package version + env: + VERSION: ${{ needs.prepare.outputs.version }} + run: | + python - <<'PY' + import email + import glob + import os + import zipfile + + wheels = glob.glob("dist/*.whl") + assert len(wheels) == 1, wheels + with zipfile.ZipFile(wheels[0]) as wheel: + metadata_file = next( + name for name in wheel.namelist() if name.endswith(".dist-info/METADATA") + ) + metadata = email.message_from_bytes(wheel.read(metadata_file)) + assert metadata["Version"] == os.environ["VERSION"], ( + metadata["Version"], + os.environ["VERSION"], + ) + PY + - name: Configure AWS credentials (OIDC) uses: aws-actions/configure-aws-credentials@v6 with: @@ -168,6 +263,6 @@ jobs: run: | gh release create "$RELEASE_TAG" \ --repo "$GITHUB_REPOSITORY" \ - --target "$GITHUB_SHA" \ --title "$RELEASE_TAG" \ + --verify-tag \ --generate-notes From b9b6ebb3c190425a728bee60e2db9a503ca70d36 Mon Sep 17 00:00:00 2001 From: Andrey Markelov Date: Tue, 22 Sep 2026 12:40:04 -0700 Subject: [PATCH 3/8] Disable dependency caching in release workflow --- .github/workflows/pypiupload.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/pypiupload.yml b/.github/workflows/pypiupload.yml index 7d1c2d3e..67ea5fc4 100644 --- a/.github/workflows/pypiupload.yml +++ b/.github/workflows/pypiupload.yml @@ -100,7 +100,6 @@ jobs: uses: actions/setup-python@v7 with: python-version: "3.11" - cache: pip - name: Install dependencies run: | @@ -190,7 +189,6 @@ jobs: uses: actions/setup-python@v7 with: python-version: "3.11" - cache: pip - name: Install publishing dependencies run: | From 42c74ceeacf4d80e8d3dc0053c74291eb2605b82 Mon Sep 17 00:00:00 2001 From: Andrey Markelov Date: Tue, 22 Sep 2026 13:03:44 -0700 Subject: [PATCH 4/8] Use PyPI trusted publishing --- .github/workflows/pypiupload.yml | 106 +++++++++++++++++++++++-------- CONTRIBUTING.md | 8 ++- 2 files changed, 85 insertions(+), 29 deletions(-) diff --git a/.github/workflows/pypiupload.yml b/.github/workflows/pypiupload.yml index 67ea5fc4..315506ce 100644 --- a/.github/workflows/pypiupload.yml +++ b/.github/workflows/pypiupload.yml @@ -1,11 +1,19 @@ name: Release -run-name: Release ${{ inputs.version || github.event.release.tag_name }} +run-name: ${{ inputs.destination || 'pypi' }} ${{ inputs.version || github.event.release.tag_name }} on: workflow_dispatch: inputs: + destination: + description: Publish destination + required: true + type: choice + default: testpypi + options: + - testpypi + - pypi version: - description: Version to release (for example, 12.3.0) + description: Base version without v (for example, 12.3.0) required: true type: string release: @@ -24,8 +32,10 @@ jobs: permissions: contents: read outputs: + package-version: ${{ steps.release.outputs.package-version }} sha: ${{ steps.release.outputs.sha }} tag: ${{ steps.release.outputs.tag }} + test: ${{ steps.release.outputs.test }} version: ${{ steps.release.outputs.version }} steps: - uses: actions/checkout@v7 @@ -48,6 +58,7 @@ jobs: - name: Validate release id: release env: + MANUAL_DESTINATION: ${{ inputs.destination }} MANUAL_VERSION: ${{ inputs.version }} RELEASE_EVENT_TAG: ${{ github.event.release.tag_name }} run: | @@ -59,8 +70,16 @@ jobs: exit 1 fi tag="v$MANUAL_VERSION" + test=false + package_version="$MANUAL_VERSION" + if [[ "$MANUAL_DESTINATION" == "testpypi" ]]; then + test=true + package_version="${MANUAL_VERSION}.dev${GITHUB_RUN_ID}" + fi else tag="$RELEASE_EVENT_TAG" + test=false + package_version="${tag#v}" fi if [[ ! "$tag" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then @@ -68,7 +87,7 @@ jobs: exit 1 fi - if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then + if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" && "$test" == "false" ]]; then if ! remote_tag=$(git ls-remote --tags origin "refs/tags/$tag"); then echo "Unable to check whether tag $tag already exists." exit 1 @@ -80,8 +99,10 @@ jobs: fi { + echo "package-version=$package_version" echo "sha=$(git rev-parse HEAD)" echo "tag=$tag" + echo "test=$test" echo "version=${tag#v}" } >> "$GITHUB_OUTPUT" @@ -128,7 +149,7 @@ jobs: run: sphinx-build -b html docs build/html create-tag: - if: github.event_name == 'workflow_dispatch' + if: github.event_name == 'workflow_dispatch' && inputs.destination == 'pypi' needs: [prepare, checks] runs-on: ubuntu-latest permissions: @@ -156,24 +177,26 @@ jobs: git tag -a "$RELEASE_TAG" "$RELEASE_SHA" -m "$RELEASE_TAG" git push origin "refs/tags/$RELEASE_TAG" - publish: + build-distributions: if: >- always() && needs.prepare.result == 'success' && needs.checks.result == 'success' && - (github.event_name == 'release' || needs.create-tag.result == 'success') + (needs.prepare.outputs.test == 'true' || + github.event_name == 'release' || + needs.create-tag.result == 'success') needs: [prepare, checks, create-tag] runs-on: ubuntu-latest permissions: - id-token: write contents: read steps: - uses: actions/checkout@v7 with: - ref: refs/tags/${{ needs.prepare.outputs.tag }} + ref: ${{ needs.prepare.outputs.test == 'true' && needs.prepare.outputs.sha || format('refs/tags/{0}', needs.prepare.outputs.tag) }} fetch-depth: 0 - name: Verify release tag + if: needs.prepare.outputs.test != 'true' env: RELEASE_SHA: ${{ needs.prepare.outputs.sha }} RELEASE_TAG: ${{ needs.prepare.outputs.tag }} @@ -190,19 +213,25 @@ jobs: with: python-version: "3.11" - - name: Install publishing dependencies + - name: Install build dependencies run: | python -m pip install --upgrade pip python -m pip install build twine - - name: Build final distributions from tag + - name: Configure unique TestPyPI version + if: needs.prepare.outputs.test == 'true' + env: + PACKAGE_VERSION: ${{ needs.prepare.outputs.package-version }} + run: echo "SETUPTOOLS_SCM_PRETEND_VERSION=$PACKAGE_VERSION" >> "$GITHUB_ENV" + + - name: Build distributions run: | python -m build twine check dist/* - name: Verify built package version env: - VERSION: ${{ needs.prepare.outputs.version }} + VERSION: ${{ needs.prepare.outputs.package-version }} run: | python - <<'PY' import email @@ -223,29 +252,52 @@ jobs: ) PY - - name: Configure AWS credentials (OIDC) - uses: aws-actions/configure-aws-credentials@v6 + - name: Upload distributions + uses: actions/upload-artifact@v7 with: - role-to-assume: arn:aws:iam::082972943155:role/oidc-github-dropbox-dropbox-sdk-python-repo - aws-region: us-west-2 + name: release-distributions + path: dist/* + if-no-files-found: error - - name: Get PyPI token from AWS Secrets Manager - uses: aws-actions/aws-secretsmanager-get-secrets@v3 + publish: + if: needs.prepare.outputs.test != 'true' + needs: [prepare, build-distributions] + runs-on: ubuntu-latest + permissions: + id-token: write + steps: + - name: Download distributions + uses: actions/download-artifact@v8 with: - # Referenced by friendly name; Secrets Manager appends a random suffix to - # the full ARN, so the name is the stable identifier. - secret-ids: | - PYPI_SECRET,pypi-api-token-dropbox-sdk-python - parse-json-secrets: false + name: release-distributions + path: dist - name: Publish to PyPI - env: - TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ env.PYPI_SECRET }} - run: twine upload dist/* + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist/ + + publish-testpypi: + if: needs.prepare.outputs.test == 'true' + needs: [prepare, build-distributions] + runs-on: ubuntu-latest + permissions: + id-token: write + steps: + - name: Download distributions + uses: actions/download-artifact@v8 + with: + name: release-distributions + path: dist + + - name: Publish to TestPyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist/ + repository-url: https://test.pypi.org/legacy/ create-release: - if: github.event_name == 'workflow_dispatch' + if: github.event_name == 'workflow_dispatch' && inputs.destination == 'pypi' needs: [prepare, publish] runs-on: ubuntu-latest permissions: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 54372b8b..58dcdf05 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -73,11 +73,15 @@ The documentation will be built into `build/html`. Repository admins can cut a new version from the GitHub Actions page: 1. Select the **Release** workflow and click **Run workflow**. -2. Select the `main` branch and enter the version as `X.Y.Z` (without the `v` prefix). -3. Run the workflow. It validates the request, tests and publishes version `X.Y.Z` to PyPI, and then creates the GitHub release and `vX.Y.Z` tag with generated release notes. +2. Select the `main` branch, choose the `pypi` destination, and enter the version as `X.Y.Z` (without the `v` prefix). +3. Run the workflow. It validates and tests the exact `main` commit, creates the `vX.Y.Z` tag, builds and verifies the distributions from that tag, publishes them to PyPI with Trusted Publishing, and then creates the GitHub Release with generated release notes. Creating a GitHub release manually with a `vX.Y.Z` tag remains supported and automatically publishes version `X.Y.Z` to PyPI. +To validate Trusted Publishing without making a production release, run the same workflow manually from `main` with the `testpypi` destination. The workflow publishes the checked artifacts only to TestPyPI with a unique `X.Y.Z.dev` version; it does not create a tag or GitHub Release. + +TestPyPI requires its own one-time Trusted Publisher configuration; the production PyPI configuration does not apply there. Configure the `dropbox` project (or a pending publisher if the project does not exist yet) for GitHub owner `dropbox`, repository `dropbox-sdk-python`, workflow `pypiupload.yml`, and no environment restriction. + [issues]: https://github.com/dropbox/dropbox-sdk-python/issues [pr]: https://github.com/dropbox/dropbox-sdk-python/pulls [coc]: https://github.com/dropbox/dropbox-sdk-python/blob/main/CODE_OF_CONDUCT.md From d2e97bd2eb8091f856008a49c024d9da94ed7c3d Mon Sep 17 00:00:00 2001 From: Andrey Markelov Date: Tue, 22 Sep 2026 13:11:22 -0700 Subject: [PATCH 5/8] Allow one-time TestPyPI smoke test --- .github/workflows/pypiupload.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pypiupload.yml b/.github/workflows/pypiupload.yml index 315506ce..2163d317 100644 --- a/.github/workflows/pypiupload.yml +++ b/.github/workflows/pypiupload.yml @@ -65,7 +65,9 @@ jobs: set -euo pipefail if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then - if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then + if [[ "$GITHUB_REF" != "refs/heads/main" && + ! ( "$MANUAL_DESTINATION" == "testpypi" && + "$GITHUB_REF" == "refs/heads/andreymarkelov/admin-release-action" ) ]]; then echo "Manual releases must run from main, not $GITHUB_REF." exit 1 fi From 8d75506de43b6521452fbc7f3bdc688d760a6699 Mon Sep 17 00:00:00 2001 From: Andrey Markelov Date: Tue, 22 Sep 2026 13:15:50 -0700 Subject: [PATCH 6/8] Run publisher after skipped tag job --- .github/workflows/pypiupload.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pypiupload.yml b/.github/workflows/pypiupload.yml index 2163d317..336d8a43 100644 --- a/.github/workflows/pypiupload.yml +++ b/.github/workflows/pypiupload.yml @@ -262,7 +262,10 @@ jobs: if-no-files-found: error publish: - if: needs.prepare.outputs.test != 'true' + if: >- + always() && + needs.build-distributions.result == 'success' && + needs.prepare.outputs.test != 'true' needs: [prepare, build-distributions] runs-on: ubuntu-latest permissions: @@ -280,7 +283,10 @@ jobs: packages-dir: dist/ publish-testpypi: - if: needs.prepare.outputs.test == 'true' + if: >- + always() && + needs.build-distributions.result == 'success' && + needs.prepare.outputs.test == 'true' needs: [prepare, build-distributions] runs-on: ubuntu-latest permissions: From 0a9af0632c4876594ca1f25477320fefa77baa79 Mon Sep 17 00:00:00 2001 From: Andrey Markelov Date: Tue, 22 Sep 2026 13:47:16 -0700 Subject: [PATCH 7/8] Remove TestPyPI smoke-test path --- .github/workflows/pypiupload.yml | 78 +++++--------------------------- CONTRIBUTING.md | 8 +--- 2 files changed, 13 insertions(+), 73 deletions(-) diff --git a/.github/workflows/pypiupload.yml b/.github/workflows/pypiupload.yml index 336d8a43..a93e5772 100644 --- a/.github/workflows/pypiupload.yml +++ b/.github/workflows/pypiupload.yml @@ -1,19 +1,11 @@ name: Release -run-name: ${{ inputs.destination || 'pypi' }} ${{ inputs.version || github.event.release.tag_name }} +run-name: Release ${{ inputs.version || github.event.release.tag_name }} on: workflow_dispatch: inputs: - destination: - description: Publish destination - required: true - type: choice - default: testpypi - options: - - testpypi - - pypi version: - description: Base version without v (for example, 12.3.0) + description: Version without v (for example, 12.3.0) required: true type: string release: @@ -32,10 +24,8 @@ jobs: permissions: contents: read outputs: - package-version: ${{ steps.release.outputs.package-version }} sha: ${{ steps.release.outputs.sha }} tag: ${{ steps.release.outputs.tag }} - test: ${{ steps.release.outputs.test }} version: ${{ steps.release.outputs.version }} steps: - uses: actions/checkout@v7 @@ -58,30 +48,19 @@ jobs: - name: Validate release id: release env: - MANUAL_DESTINATION: ${{ inputs.destination }} MANUAL_VERSION: ${{ inputs.version }} RELEASE_EVENT_TAG: ${{ github.event.release.tag_name }} run: | set -euo pipefail if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then - if [[ "$GITHUB_REF" != "refs/heads/main" && - ! ( "$MANUAL_DESTINATION" == "testpypi" && - "$GITHUB_REF" == "refs/heads/andreymarkelov/admin-release-action" ) ]]; then + if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then echo "Manual releases must run from main, not $GITHUB_REF." exit 1 fi tag="v$MANUAL_VERSION" - test=false - package_version="$MANUAL_VERSION" - if [[ "$MANUAL_DESTINATION" == "testpypi" ]]; then - test=true - package_version="${MANUAL_VERSION}.dev${GITHUB_RUN_ID}" - fi else tag="$RELEASE_EVENT_TAG" - test=false - package_version="${tag#v}" fi if [[ ! "$tag" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then @@ -89,7 +68,7 @@ jobs: exit 1 fi - if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" && "$test" == "false" ]]; then + if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then if ! remote_tag=$(git ls-remote --tags origin "refs/tags/$tag"); then echo "Unable to check whether tag $tag already exists." exit 1 @@ -101,10 +80,8 @@ jobs: fi { - echo "package-version=$package_version" echo "sha=$(git rev-parse HEAD)" echo "tag=$tag" - echo "test=$test" echo "version=${tag#v}" } >> "$GITHUB_OUTPUT" @@ -151,7 +128,7 @@ jobs: run: sphinx-build -b html docs build/html create-tag: - if: github.event_name == 'workflow_dispatch' && inputs.destination == 'pypi' + if: github.event_name == 'workflow_dispatch' needs: [prepare, checks] runs-on: ubuntu-latest permissions: @@ -184,8 +161,7 @@ jobs: always() && needs.prepare.result == 'success' && needs.checks.result == 'success' && - (needs.prepare.outputs.test == 'true' || - github.event_name == 'release' || + (github.event_name == 'release' || needs.create-tag.result == 'success') needs: [prepare, checks, create-tag] runs-on: ubuntu-latest @@ -194,11 +170,10 @@ jobs: steps: - uses: actions/checkout@v7 with: - ref: ${{ needs.prepare.outputs.test == 'true' && needs.prepare.outputs.sha || format('refs/tags/{0}', needs.prepare.outputs.tag) }} + ref: refs/tags/${{ needs.prepare.outputs.tag }} fetch-depth: 0 - name: Verify release tag - if: needs.prepare.outputs.test != 'true' env: RELEASE_SHA: ${{ needs.prepare.outputs.sha }} RELEASE_TAG: ${{ needs.prepare.outputs.tag }} @@ -220,12 +195,6 @@ jobs: python -m pip install --upgrade pip python -m pip install build twine - - name: Configure unique TestPyPI version - if: needs.prepare.outputs.test == 'true' - env: - PACKAGE_VERSION: ${{ needs.prepare.outputs.package-version }} - run: echo "SETUPTOOLS_SCM_PRETEND_VERSION=$PACKAGE_VERSION" >> "$GITHUB_ENV" - - name: Build distributions run: | python -m build @@ -233,7 +202,7 @@ jobs: - name: Verify built package version env: - VERSION: ${{ needs.prepare.outputs.package-version }} + VERSION: ${{ needs.prepare.outputs.version }} run: | python - <<'PY' import email @@ -262,11 +231,8 @@ jobs: if-no-files-found: error publish: - if: >- - always() && - needs.build-distributions.result == 'success' && - needs.prepare.outputs.test != 'true' - needs: [prepare, build-distributions] + if: always() && needs.build-distributions.result == 'success' + needs: build-distributions runs-on: ubuntu-latest permissions: id-token: write @@ -282,30 +248,8 @@ jobs: with: packages-dir: dist/ - publish-testpypi: - if: >- - always() && - needs.build-distributions.result == 'success' && - needs.prepare.outputs.test == 'true' - needs: [prepare, build-distributions] - runs-on: ubuntu-latest - permissions: - id-token: write - steps: - - name: Download distributions - uses: actions/download-artifact@v8 - with: - name: release-distributions - path: dist - - - name: Publish to TestPyPI - uses: pypa/gh-action-pypi-publish@release/v1 - with: - packages-dir: dist/ - repository-url: https://test.pypi.org/legacy/ - create-release: - if: github.event_name == 'workflow_dispatch' && inputs.destination == 'pypi' + if: github.event_name == 'workflow_dispatch' needs: [prepare, publish] runs-on: ubuntu-latest permissions: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 58dcdf05..60f8518f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -73,15 +73,11 @@ The documentation will be built into `build/html`. Repository admins can cut a new version from the GitHub Actions page: 1. Select the **Release** workflow and click **Run workflow**. -2. Select the `main` branch, choose the `pypi` destination, and enter the version as `X.Y.Z` (without the `v` prefix). -3. Run the workflow. It validates and tests the exact `main` commit, creates the `vX.Y.Z` tag, builds and verifies the distributions from that tag, publishes them to PyPI with Trusted Publishing, and then creates the GitHub Release with generated release notes. +2. Select the `main` branch and enter the version as `X.Y.Z` (without the `v` prefix). +3. Run the workflow. It validates and tests the exact `main` commit, creates the immutable `vX.Y.Z` tag, builds and verifies the distributions from that tag, uploads them as a GitHub artifact, publishes those artifacts to PyPI with Trusted Publishing, and then creates the GitHub Release with generated release notes. Creating a GitHub release manually with a `vX.Y.Z` tag remains supported and automatically publishes version `X.Y.Z` to PyPI. -To validate Trusted Publishing without making a production release, run the same workflow manually from `main` with the `testpypi` destination. The workflow publishes the checked artifacts only to TestPyPI with a unique `X.Y.Z.dev` version; it does not create a tag or GitHub Release. - -TestPyPI requires its own one-time Trusted Publisher configuration; the production PyPI configuration does not apply there. Configure the `dropbox` project (or a pending publisher if the project does not exist yet) for GitHub owner `dropbox`, repository `dropbox-sdk-python`, workflow `pypiupload.yml`, and no environment restriction. - [issues]: https://github.com/dropbox/dropbox-sdk-python/issues [pr]: https://github.com/dropbox/dropbox-sdk-python/pulls [coc]: https://github.com/dropbox/dropbox-sdk-python/blob/main/CODE_OF_CONDUCT.md From 72eead23aa9f2c3fcc9d30bf5fd5e466d3b4c550 Mon Sep 17 00:00:00 2001 From: Andrey Markelov Date: Tue, 22 Sep 2026 14:03:07 -0700 Subject: [PATCH 8/8] Use trusted event checkout for release checks --- .github/workflows/pypiupload.yml | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pypiupload.yml b/.github/workflows/pypiupload.yml index a93e5772..1eeca716 100644 --- a/.github/workflows/pypiupload.yml +++ b/.github/workflows/pypiupload.yml @@ -93,9 +93,19 @@ jobs: steps: - uses: actions/checkout@v7 with: - ref: ${{ needs.prepare.outputs.sha }} fetch-depth: 0 + - name: Verify validated commit + env: + RELEASE_SHA: ${{ needs.prepare.outputs.sha }} + run: | + set -euo pipefail + checkout_sha=$(git rev-parse HEAD) + if [[ "$checkout_sha" != "$RELEASE_SHA" ]]; then + echo "Checked out $checkout_sha, not validated commit $RELEASE_SHA." + exit 1 + fi + - name: Setup Python environment uses: actions/setup-python@v7 with: @@ -136,7 +146,6 @@ jobs: steps: - uses: actions/checkout@v7 with: - ref: ${{ needs.prepare.outputs.sha }} fetch-depth: 0 - name: Create and push release tag @@ -145,6 +154,11 @@ jobs: RELEASE_TAG: ${{ needs.prepare.outputs.tag }} run: | set -euo pipefail + checkout_sha=$(git rev-parse HEAD) + if [[ "$checkout_sha" != "$RELEASE_SHA" ]]; then + echo "Checked out $checkout_sha, not validated commit $RELEASE_SHA." + exit 1 + fi if ! remote_tag=$(git ls-remote --tags origin "refs/tags/$RELEASE_TAG"); then echo "Unable to check whether tag $RELEASE_TAG already exists." exit 1