├── action.yml ├── validate_version.py ├── Dockerfile ├── validate └── action.yml ├── LICENSE ├── publish └── action.yml ├── .gitignore ├── entrypoint.sh ├── .github └── workflows │ └── workflow.yml └── README.md /action.yml: -------------------------------------------------------------------------------- 1 | name: 'Publish to PyPI' 2 | description: 'Publish a package to PyPI after performing version checks.' 3 | runs: 4 | using: 'docker' 5 | image: 'Dockerfile' 6 | args: 7 | - 'none' 8 | -------------------------------------------------------------------------------- /validate_version.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # Perform a simple check on the tag portion of the git reference 3 | # passed in as the first argument. 4 | 5 | import sys 6 | import semver 7 | 8 | gitref = sys.argv[1] 9 | print(f'\nVersion validation argument: {gitref}') 10 | tag = gitref.split('/')[-1] 11 | print(f'Tag: {tag}') 12 | version = semver.VersionInfo.parse(tag) 13 | print('Tag is a valid semver value.') 14 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Container image that runs your code 2 | FROM ubuntu:18.04 3 | 4 | RUN apt-get update \ 5 | && apt-get install -y \ 6 | build-essential \ 7 | python3-pip \ 8 | python3.6 \ 9 | git \ 10 | && python3 -m pip install --upgrade pip \ 11 | && python3 -m pip install --upgrade setuptools 12 | 13 | # Copies code file action repository to the filesystem path `/` of the container 14 | COPY entrypoint.sh /entrypoint.sh 15 | 16 | COPY validate_version.py /validate_version.py 17 | 18 | # Code file to execute when the docker container starts up (`entrypoint.sh`) 19 | ENTRYPOINT ["/entrypoint.sh"] 20 | -------------------------------------------------------------------------------- /validate/action.yml: -------------------------------------------------------------------------------- 1 | name: 'Validate metadata' 2 | description: 'Validates tagged package version is a valid semver value' 3 | runs: 4 | using: 'composite' 5 | steps: 6 | - uses: actions/setup-python@v2 7 | name: Install Python 8 | with: 9 | python-version: '3.8' 10 | 11 | - name: Validate tag 12 | shell: 'bash' 13 | run: | 14 | python -m pip install semver 15 | echo " 16 | import os 17 | import semver 18 | 19 | gitref = os.environ['GITHUB_REF'] 20 | print(f'\nVersion validation argument: {gitref}') 21 | tag = gitref.split('/')[-1] 22 | print(f'Tag: {tag}') 23 | version = semver.VersionInfo.parse(tag.lstrip('v')) 24 | print('Tag is a valid semver value.') 25 | " | python 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2020, Space Telescope Science Institute 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /publish/action.yml: -------------------------------------------------------------------------------- 1 | name: 'Publish to PyPI' 2 | description: 'Publishes an artifact to PyPI' 3 | inputs: 4 | user: 5 | description: 'The username to use to publish to PyPI. Defaults to `__token__`.' 6 | required: false 7 | default: '__token__' 8 | password: 9 | description: 'The password to use to publish to PyPI' 10 | required: true 11 | repository_url: 12 | description: 'The PyPI repository URL to publish to' 13 | required: false 14 | test_user: 15 | description: 'The username to use when test is set to true. Uses the value of `user` if not provided.' 16 | required: false 17 | test_password: 18 | description: 'The password to use when test is set to true. Uses the value of `password` if not provided.' 19 | required: false 20 | test_repository_url: 21 | description: 'The PyPI repository URL to publish to when test is set to true. Defaults to `https://test.pypi.org/legacy/`' 22 | required: false 23 | default: 'https://test.pypi.org/legacy/' 24 | test: 25 | description: 'Whether to publish to the test repository or the production repository' 26 | required: false 27 | default: false 28 | verify_metadata: 29 | description: 'Whether to check package metadata before uploading' 30 | required: false 31 | default: true 32 | skip_existing: 33 | description: 'Do not fail if a Python package distribution exists in the target package index' 34 | required: false 35 | default: false 36 | verbose: 37 | description: 'Show verbose output.' 38 | required: false 39 | default: false 40 | runs: 41 | using: 'composite' 42 | steps: 43 | - uses: actions/download-artifact@v2 44 | with: 45 | name: artifact 46 | path: dist 47 | 48 | - uses: pypa/gh-action-pypi-publish@v1.4.2 49 | with: 50 | user: ${{ (inputs.test == 'true' && inputs.test_user) || inputs.user }} 51 | password: ${{ (inputs.test == 'true' && inputs.test_password) || inputs.password }} 52 | repository_url: ${{ (inputs.test == 'true' && inputs.test_repository_url) || inputs.repository_url }} 53 | verify_metadata: ${{ inputs.verify_metadata }} 54 | skip_existing: ${{ inputs.skip_existing }} 55 | verbose: ${{ inputs.verbose }} 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -l 2 | 3 | # Publish to the PyPI testing instance URL if the env var 4 | # PYPI_TEST is set to a non-empty value, otherwise publish 5 | # to the main index. 6 | URL_ARG="" 7 | if [[ "${PYPI_TEST}" != "" ]]; then 8 | echo -e "\n----------------------------------------------------------------" 9 | echo "PYPI_TEST var set. Will attempt to publish to the PyPI" 10 | echo "testing instance." 11 | echo -e "----------------------------------------------------------------" 12 | URL_ARG="--repository-url https://test.pypi.org/legacy/" 13 | fi 14 | 15 | if [[ "${PYPI_USERNAME_OVERRIDE}" != "" ]]; then 16 | echo -e "\n----------------------------------------------------------------" 17 | echo "PYPI_USERNAME_OVERRIDE var set for this repository." 18 | echo -e "----------------------------------------------------------------" 19 | TWINE_USERNAME=$PYPI_USERNAME_OVERRIDE 20 | else 21 | TWINE_USERNAME=$PYPI_USERNAME_STSCI_MAINTAINER 22 | fi 23 | export TWINE_USERNAME 24 | 25 | if [[ "${PYPI_PASSWORD_OVERRIDE}" != "" ]]; then 26 | echo -e "\n----------------------------------------------------------------" 27 | echo "PYPI_PASSWORD_OVERRIDE var set for this repository." 28 | echo -e "----------------------------------------------------------------" 29 | TWINE_PASSWORD=$PYPI_PASSWORD_OVERRIDE 30 | else 31 | TWINE_PASSWORD=$PYPI_PASSWORD_STSCI_MAINTAINER 32 | fi 33 | export TWINE_PASSWORD 34 | 35 | REF=$GITHUB_REF 36 | echo "GITHUB_REF=${REF}" 37 | 38 | PYTHON=$(which python3.6) 39 | echo "PYTHON=${PYTHON}" 40 | PIP="${PYTHON} -m pip" 41 | echo "PIP=${PIP}" 42 | GCC=$(which gcc) 43 | echo "GCC=${GCC}" 44 | GIT=$(which git) 45 | echo "GIT=${GIT}" 46 | 47 | /usr/bin/python3 --version 48 | /usr/bin/gcc --version 49 | 50 | $PYTHON --version 51 | 52 | $GIT fetch -t 53 | $GIT tag 54 | 55 | printf "Install package\n\n" 56 | $PIP install . 57 | 58 | printf "Install publication deps\n\n" 59 | $PIP install twine semver pep517 60 | 61 | # Validate the version 62 | $PYTHON /validate_version.py $REF 63 | 64 | printf "Prepare for publication...\n\n" 65 | $GIT clean -fxd 66 | retval=1 67 | if [[ -e pyproject.toml ]]; then 68 | echo "Found a pyproject.toml file" 69 | grep "build-backend" pyproject.toml 70 | retval=$? 71 | fi 72 | if [[ $retval -eq 0 ]]; then 73 | echo -e "\n\nDetected a PEP517-compatible project..." 74 | $PYTHON -m pep517.build --source . 75 | else 76 | echo -e "\n\nBuilding sdist via setup.py..." 77 | $PYTHON setup.py build sdist --format=gztar 78 | fi 79 | TWINE=$(which twine) 80 | 81 | printf "Publish...\n\n" 82 | $TWINE upload $URL_ARG dist/* 83 | -------------------------------------------------------------------------------- /.github/workflows/workflow.yml: -------------------------------------------------------------------------------- 1 | name: Publish to PyPI 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | test: 7 | required: false 8 | default: false 9 | type: boolean 10 | build_platform_wheels: 11 | required: false 12 | default: false 13 | type: boolean 14 | secrets: 15 | user: 16 | required: true 17 | password: 18 | required: true 19 | test_password: 20 | required: false 21 | 22 | jobs: 23 | validate: 24 | name: Validate metadata 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: spacetelescope/action-publish_to_pypi/validate@master 28 | 29 | build_wheels: 30 | name: Build wheels on ${{ matrix.os }} 31 | if: ${{ inputs.build_platform_wheels }} 32 | needs: [validate] 33 | runs-on: ${{ matrix.os }} 34 | strategy: 35 | matrix: 36 | os: [ubuntu-20.04, macos-12] 37 | 38 | steps: 39 | - uses: spacetelescope/action-publish_to_pypi/build-wheel@master 40 | 41 | build_wheel: 42 | name: Build wheel 43 | if: ${{ !inputs.build_platform_wheels }} 44 | needs: [validate] 45 | runs-on: ubuntu-20.04 46 | steps: 47 | - uses: actions/checkout@v2 48 | with: 49 | fetch-depth: 0 50 | 51 | - uses: actions/setup-python@v2 52 | name: Install Python 53 | with: 54 | python-version: '3.9' 55 | 56 | - name: Install build tools 57 | run: python -m pip install build 58 | 59 | - name: Build wheel 60 | run: python -m build --wheel . 61 | 62 | - uses: actions/upload-artifact@v2 63 | with: 64 | path: dist/*.whl 65 | 66 | build_sdist: 67 | name: Build source distribution 68 | needs: [validate] 69 | runs-on: ubuntu-latest 70 | steps: 71 | - uses: spacetelescope/action-publish_to_pypi/build-sdist@master 72 | 73 | upload_pypi_platform_wheels: 74 | if: ${{ inputs.build_platform_wheels }} 75 | needs: [build_wheels, build_sdist] 76 | runs-on: ubuntu-latest 77 | steps: 78 | - uses: spacetelescope/action-publish_to_pypi/publish@master 79 | with: 80 | test: ${{ inputs.test }} 81 | user: ${{ secrets.user }} 82 | password: ${{ secrets.password }} 83 | test_password: ${{ secrets.test_password }} 84 | 85 | upload_pypi: 86 | if: ${{ !inputs.build_platform_wheels }} 87 | needs: [build_wheel, build_sdist] 88 | runs-on: ubuntu-latest 89 | steps: 90 | - uses: spacetelescope/action-publish_to_pypi/publish@master 91 | with: 92 | test: ${{ inputs.test }} 93 | user: ${{ secrets.user }} 94 | password: ${{ secrets.password }} 95 | test_password: ${{ secrets.test_password }} 96 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Automatic publication of Python packages to PyPI 2 | 3 | This repository defines a Github action to provide opt-in automatic publication of Python packages to PyPI upon qualifying Github release events. It also defines separate custom Github action steps for building Python wheels and source distributions, validating repository metadata, and publishing to PyPI. 4 | This repository does not need to be cloned or interacted with in order to take advantage of the service. 5 | See below for documentation. 6 | 7 | Default criteria for publishing a release: 8 | * Tag must conform to the semantic versioning specification (https://semver.org/). i.e. 9 | * 0.0.8 10 | * 1.0.0 11 | * 1.0.0-alpha 12 | * 1.0.0-alpha.1 13 | 14 | ## How to use 15 | 16 | The publication mechanism is provided on an opt-in basis to repositories within the following Github organizations: 17 | * spacetelescope 18 | 19 | Stipulations: 20 | * If the project being published already exists on PyPI, the PyPI account named `stsci_maintainer` must be defined as a 'maintainer'. 21 | * If you wish to specify the set of credentials used to publish the package, please see [Using other credentials](#othercreds) below. 22 | * If the project does not yet exist, it will be created on PyPI by the account `stsci_maintainer`. 23 | 24 | ### To add publication capability to a repository 25 | 26 | 1) On the main repository page, click **Actions** at the top, just below the name of the repository. 27 | 2) In the **Workflows made for your Python repository**, locate the "*Publish to PyPI*" workflow made by "Space Telescope Science Institute". 28 | 3) Click the **Set up this workflow** button. This will open a commit page with the workflow code that will be added to the repository. 29 | 4) Click the **Start Commit** button in the upper right hand corner. 30 | 5) Add a descriptive commit message if you like. "Activating automatic PyPI publication" or similar. 31 | 6) Click **Commit new file**. 32 | 33 | Whenever a new release with a semver-compliant tag is made on Github from the repository, the package will be created and published to PyPI using the `stsci_maintainer` account. 34 | 35 | 36 | 37 | ### Using other credentials 38 | 39 | The default set of credentials used to publish packages to PyPI are those of the PyPI account `stsci_maintainer`. If you want or need to use other credentials, they may be specified as secrets in the settings of the Github repository. 40 | 41 | 1) Open the **Settings** page of the repository (top portion of the page, on the line under the repository name). 42 | 2) Select **Secrets** from the list on the left hand side. 43 | 3) Define a secret with the name `PYPI_USERNAME_STSCI_MAINTAINER` the value of which is the PyPI username to be used. 44 | 4) Define another secret with the name `PYPI_PASSWORD_STSCI_MAINTAINER` the value of which is the PyPI password to be used. 45 | 46 | Whenever a new release is made, the package will be created and published to PyPI using the supplied credentials. 47 | 48 | ### Testing the workflow 49 | 50 | By default, the workflow will publish to the production PyPI repository. For testing purposes, it can be configured to publish to the test PyPI repository at https://test.pypi.org by creating a secret called `PYPI_TEST`: 51 | 52 | 1) Open the **Settings** page of the repository (top portion of the page, on the line under the repository name). 53 | 2) Select **Secrets** from the list on the left hand side. 54 | 3) Define a secret with the name `PYPI_TEST` the value of which is 'true'. 55 | 56 | ## Problems 57 | 58 | If a problem occurs with the publication, an e-mail message with "Run failed: Publish to PyPI" in the subject will be sent to all users subscribed to notifications from the repository in question. The message will contain a link to the logs from the publication attempt. 59 | --------------------------------------------------------------------------------