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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
314 changes: 272 additions & 42 deletions .github/workflows/pypiupload.yml
Original file line number Diff line number Diff line change
@@ -1,54 +1,284 @@
# 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 without v (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: # required for OIDC authentication
id-token: write
permissions:
contents: read
outputs:
sha: ${{ steps.release.outputs.sha }}
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 "sha=$(git rev-parse HEAD)"
echo "tag=$tag"
echo "version=${tag#v}"
} >> "$GITHUB_OUTPUT"

checks:
needs: prepare
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7
with:
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:
python-version: "3.11"

- 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
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
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: Install checked wheel
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
run: python -m pip install --force-reinstall dist/*.whl

- name: Run unit tests
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
run: pytest -v test/unit/

- name: Build documentation
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
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:
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
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
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"

build-distributions:
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:
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"

- name: Install build dependencies
run: |
python -m pip install --upgrade pip
python -m pip install build twine

- name: Build distributions
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: Upload distributions
uses: actions/upload-artifact@v7
with:
name: release-distributions
path: dist/*
if-no-files-found: error

publish:
if: always() && needs.build-distributions.result == 'success'
needs: 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 PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: 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" \
--title "$RELEASE_TAG" \
--verify-tag \
--generate-notes
12 changes: 11 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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.

[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/
[cla]: https://opensource.dropbox.com/cla/
Loading