Repository: zopefoundation/zope.interface (language: python)

Issue:
Move Mac and Linux builds to GitHub actions
Since Travis CI no longer works. This is probably a blocker for any other PRs.

Because of the C extensions this doesn't quite fit our standard model. 

I have some github actions YAML from other repos that deal with C extensions and building/uploading Mac/Linux[32-bit, 64-bit, aarch64] wheels that I can bring over as a starting point.

Someone with access to the `zope.wheelbuilder` account would need to add a repository secret for `TWINE_PASSWORD` (a token with scoped upload rights to `zope.interface`). (I could create a PyPI token from my personal account, but since we have a dedicated account I'd prefer not to.)

- [x] Environment that builds docs
- [x] 32-bit linux build/upload
- [x] 64-bit linux build/upload
- [x] aarch64 linux build/upload
- [x] coveralls
- [x] mac builds/upload
- [x] Pure-python tests
- [x] Compare other settings with existing "pure-python" configurations
- [x] Update badges
- [x] Update any docs references



Discussion / hints:

Fix patch:
diff --git a/.coveragerc b/.coveragerc
index e8d7d252..f0980240 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -1,6 +1,8 @@
 [run]
 branch = True
 source = zope.interface
+# New in 5.0; required for the GHA coveralls submission.
+relative_files = True
 
 [report]
 show_missing = true
diff --git a/.manylinux-install.sh b/.manylinux-install.sh
index ea41c49b..92873063 100755
--- a/.manylinux-install.sh
+++ b/.manylinux-install.sh
@@ -2,6 +2,25 @@
 
 set -e -x
 
+# Running inside docker
+# Set a cache directory for pip. This was
+# mounted to be the same as it is outside docker so it
+# can be persisted.
+export XDG_CACHE_HOME="/cache"
+# XXX: This works for macOS, where everything bind-mounted
+# is seen as owned by root in the container. But when the host is Linux
+# the actual UIDs come through to the container, triggering
+# pip to disable the cache when it detects that the owner doesn't match.
+# The below is an attempt to fix that, taken from bcrypt. It seems to work on
+# Github Actions.
+if [ -n "$GITHUB_ACTIONS" ]; then
+    echo Adjusting pip cache permissions
+    mkdir -p $XDG_CACHE_HOME/pip
+    chown -R $(whoami) $XDG_CACHE_HOME
+fi
+ls -ld /cache
+ls -ld /cache/pip
+
 # Compile wheels
 for PYBIN in /opt/python/*/bin; do
     if [[ "${PYBIN}" == *"cp27"* ]] || \
diff --git a/.manylinux.sh b/.manylinux.sh
index 2fed7780..520d68e6 100755
--- a/.manylinux.sh
+++ b/.manylinux.sh
@@ -2,4 +2,13 @@
 
 set -e -x
 
-docker run --rm -v "$(pwd)":/io $DOCKER_IMAGE $PRE_CMD /io/.manylinux-install.sh
+# Mount the current directory as /io
+# Mount the pip cache directory as /cache
+# `pip cache` requires pip 20.1
+echo Setting up caching
+python --version
+python -mpip --version
+LCACHE="$(dirname `python -mpip cache dir`)"
+echo Sharing pip cache at $LCACHE $(ls -ld $LCACHE)
+
+docker run --rm -e GITHUB_ACTIONS -v "$(pwd)":/io -v "$LCACHE:/cache" $DOCKER_IMAGE $PRE_CMD /io/.manylinux-install.sh
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 6d4701b8..00000000
--- a/.travis.yml
+++ /dev/null
@@ -1,145 +0,0 @@
-language: python
-dist: focal
-env:
-  global:
-    ZOPE_INTERFACE_STRICT_IRO: 1
-    TWINE_USERNAME: zope.wheelbuilder
-    TWINE_PASSWORD:
-      secure: "AyR5QxUuZKdmywiepdBG0r8hgFQfaEf2hTxOg/HiXisVNcs1sUPjephBP4MqQBbf1/qxLM95F6Mw3sKneO3gDDQSGCsmvY1MWlDc+6R5TgqVBuOoONji5zGQH7v9RR8IPOyple8BNlFePl1hQ8r0dOT1U6rDVh5FkNJgHYE9OJ4="
-
-python:
-  - 2.7
-  - 3.5
-  - 3.6
-  - 3.7
-  - 3.8
-  - 3.9
-  - pypy2
-  - pypy3
-
-jobs:
-  include:
-
-    - name: Documentation
-      python: 3.6
-      install:
-        - pip install -U -e .[docs]
-      script:
-        - sphinx-build -b html -d docs/_build/doctrees docs docs/_build/html
-        - sphinx-build -b doctest -d docs/_build/doctrees docs docs/_build/doctest
-      after_success:
-      env: ZOPE_INTERFACE_STRICT_IRO=0
-
-    - name: CPython No C Extension
-      env: PURE_PYTHON=1
-      python: 3.8
-
-    # manylinux wheel builds
-    - name: 64-bit manylinux wheels (all Pythons)
-      services: docker
-      env: DOCKER_IMAGE=quay.io/pypa/manylinux2010_x86_64
-      install: docker pull $DOCKER_IMAGE
-      script: bash .manylinux.sh
-
-    - name: 32-bit manylinux wheels (all Pythons)
-      services: docker
-      env: DOCKER_IMAGE=quay.io/pypa/manylinux2010_i686 PRE_CMD=linux32
-      install: docker pull $DOCKER_IMAGE
-      script: bash .manylinux.sh
-
-    - name: aarch64 wheels
-      dist: bionic
-      group: edge
-      arch: arm64
-      virt: lxd
-      env: DOCKER_IMAGE=quay.io/pypa/manylinux2014_aarch64
-      install: docker pull $DOCKER_IMAGE
-      script: bash .manylinux.sh
-
-    # It's important to use 'macpython' builds to get the least
-    # restrictive wheel tag. It's also important to avoid
-    # 'homebrew 3' because it floats instead of being a specific version.
-    - name: Python 2.7 wheels for MacOS
-      os: osx
-      language: generic
-      # We require at least 2.7.15 to upload wheels.
-      # See https://github.com/zopefoundation/BTrees/issues/113
-      env: TERRYFY_PYTHON='macpython 2.7.17'
-    - name: Python 3.5 wheels for MacOS
-      os: osx
-      language: generic
-      env: TERRYFY_PYTHON='macpython 3.5'
-    - name: Python 3.6 wheels for MacOS
-      os: osx
-      language: generic
-      # NB: 3.6.0 causes https://github.com/nedbat/coveragepy/issues/703
-      # NB: 3.6.1 had that ABI regression (fixed in 3.6.2) and would be a bad
-      # version to use
-      env: TERRYFY_PYTHON='macpython 3.6.2'
-    - name: Python 3.7 wheels for MacOS
-      os: osx
-      language: generic
-      env: TERRYFY_PYTHON='macpython 3.7.0'
-    - name: Python 3.8 wheels for MacOS
-      os: osx
-      language: generic
-      env: TERRYFY_PYTHON='macpython 3.8.0'
-    - name: Python 3.9 wheels for MacOS
-      os: osx
-      language: generic
-      env: TERRYFY_PYTHON='macpython 3.9.0'
-
-before_install:
-  - |
-    if [[ "$TRAVIS_OS_NAME" == "osx" ]]; then
-      git clone https://github.com/MacPython/terryfy
-      source terryfy/travis_tools.sh
-      get_python_environment $TERRYFY_PYTHON venv
-    fi
-
-install:
-  # virtualenv 20.0 together with terryfy macOS is broken, at least
-  # with CPython 2.7.17 (https://github.com/pypa/virtualenv/issues/1555):
-  # it doesn't install or activate the virtualenv correctly and PATH
-  # isn't setup right. So try to use `python -m` to workaround that.
-  - python -m pip install -U pip setuptools
-  - python -m pip install -U coveralls coverage
-  - python -m pip install -U -e ".[test]"
-
-script:
-  - which python
-  - python --version
-  - python -m coverage run -m unittest discover -s src
-  - python setup.py -q bdist_wheel
-
-after_success:
-  - python -m coveralls
-  - |
-    if [[ "$TRAVIS_OS_NAME" == "osx" ]]; then
-      # macpython 3.5 doesn't support recent TLS protocols which causes twine
-      # upload to fail, so we use the system Python to run twine
-      /usr/bin/python -m ensurepip --user
-      /usr/bin/python -m pip install --user -U pip
-      /usr/bin/python -m pip install --user -U -I twine
-      /usr/bin/python -m twine check dist/*
-      if [[ $TRAVIS_TAG ]]; then
-        printf "Uploading tagged release %s\n" "$TRAVIS_TAG"
-        /usr/bin/python -m twine upload --skip-existing dist/*
-      fi
-    fi
-  - |
-    if [[ -n "$DOCKER_IMAGE" ]]; then
-      pip install twine
-      twine check wheelhouse/*
-      if [[ $TRAVIS_TAG ]]; then
-        printf "Uploading tagged release %s\n" "$TRAVIS_TAG"
-        twine upload --skip-existing wheelhouse/*
-      fi
-    fi
-
-notifications:
-  email: false
-
-cache: pip
-before_cache:
-  - rm -f $HOME/.cache/pip/log/debug.log
diff --git a/README.rst b/README.rst
index 3238c15a..e8a18e90 100644
--- a/README.rst
+++ b/README.rst
@@ -10,8 +10,8 @@
         :target: https://pypi.org/project/zope.interface/
         :alt: Supported Python versions
 
-.. image:: https://travis-ci.com/zopefoundation/zope.interface.svg?branch=master
-        :target: https://travis-ci.com/zopefoundation/zope.interface
+.. image:: https://github.com/zopefoundation/zope.interface/actions/workflows/tests.yml/badge.svg
+        :target: https://github.com/zopefoundation/zope.interface/actions/workflows/tests.yml
 
 .. image:: https://readthedocs.org/projects/zopeinterface/badge/?version=latest
         :target: https://zopeinterface.readthedocs.io/en/latest/


Test patch:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 00000000..f11865b7
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,423 @@
+###
+# Initially copied from
+# https://github.com/actions/starter-workflows/blob/main/ci/python-package.yml
+# And later based on the version I (jamadden) updated at
+# gevent/gevent, and then at zodb/relstorage and zodb/perfmetrics
+#
+# Original comment follows.
+###
+###
+# This workflow will install Python dependencies, run tests and lint with a variety of Python versions
+# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
+###
+
+###
+# Important notes on GitHub actions:
+#
+# - We only get 2,000 free minutes a month (private repos)
+# - We only get 500MB of artifact storage
+# - Cache storage is limited to 7 days and 5GB.
+# - macOS minutes are 10x as expensive as Linux minutes
+# - windows minutes are twice as expensive.
+#
+# So keep those workflows light. Note: Currently, they seem to be free
+# and unlimited for open source projects. But for how long...
+#
+# In December 2020, github only supports x86/64. If we wanted to test
+# on other architectures, we can use docker emulation, but there's no
+# native support. It works, but is slow.
+#
+# Another major downside: You can't just re-run the job for one part
+# of the matrix. So if there's a transient test failure that hit, say, 3.8,
+# to get a clean run every version of Python runs again. That's bad.
+# https://github.community/t/ability-to-rerun-just-a-single-job-in-a-workflow/17234/65
+
+name: tests
+
+
+# Triggers the workflow on push or pull request events
+on: [push, pull_request]
+# Limiting to particular branches might be helpful to conserve minutes.
+#on:
+  # push:
+  #   branches: [ $default-branch ]
+  # pull_request:
+  #   branches: [ $default-branch ]
+
+env:
+  # Weirdly, this has to be a top-level key, not ``defaults.env``
+  PYTHONHASHSEED: 8675309
+  PYTHONUNBUFFERED: 1
+  PYTHONDONTWRITEBYTECODE: 1
+  PYTHONDEVMODE: 1
+  PYTHONFAULTHANDLER: 1
+  ZOPE_INTERFACE_STRICT_IRO: 1
+
+  PIP_UPGRADE_STRATEGY: eager
+  # Don't get warnings about Python 2 support being deprecated. We
+  # know. The env var works for pip 20.
+  PIP_NO_PYTHON_VERSION_WARNING: 1
+  PIP_NO_WARN_SCRIPT_LOCATION: 1
+
+  CFLAGS: -Ofast -pipe
+  CXXFLAGS: -Ofast -pipe
+  # Uploading built wheels for releases.
+  # TWINE_PASSWORD is encrypted and stored directly in the
+  # github repo settings.
+  TWINE_USERNAME: __token__
+
+  ###
+  # caching
+  # This is where we'd set up ccache, but this compiles so fast its not worth it.
+  ###
+
+
+
+jobs:
+  # Because sharing code/steps is so hard, and because it can be
+  # extremely valuable to be able to get binary wheels without
+  # uploading to PyPI and even if there is some failure, (e.g., for
+  # other people to test/debug), the strategy is to divide the process
+  # into several different jobs. The first builds and saves the binary
+  # wheels. It has dependent jobs that download and install the wheel
+  # to run tests, build docs, and perform linting. Building the
+  # manylinux wheels is an independent set of jobs.
+  #
+  # This division is time-saving for projects that take awhile to
+  # build, but somewhat less of a clear-cut win given how quick this
+  # is to compile (at least at this writing).
+  build-zope_interface:
+    # Sigh. Note that the matrix must be kept in sync
+    # with `test`, and `docs` must use a subset.
+    runs-on: ${{ matrix.os }}
+    strategy:
+      matrix:
+        python-version: [2.7, pypy-2.7, pypy-3.6, 3.5, 3.6, 3.7, 3.8, 3.9]
+        os: [ubuntu-20.04, macos-latest]
+        exclude:
+          - os: macos-latest
+            python-version: pypy-2.7
+          - os: macos-latest
+            python-version: pypy-3.6
+          - os: macos-latest
+            python-version: 3.5
+
+    steps:
+      - name: checkout
+        uses: actions/checkout@v2
+      - name: Set up Python ${{ matrix.python-version }}
+        uses: actions/setup-python@v2
+        with:
+          python-version: ${{ matrix.python-version }}
+      ###
+      # Caching.
+      # This actually *restores* a cache and schedules a cleanup action
+      # to save the cache. So it must come before the thing we want to use
+      # the cache.
+      ###
+      - name: Get pip cache dir
+        id: pip-cache
+        run: |
+          echo "::set-output name=dir::$(pip cache dir)"
+
+      - name: pip cache
+        uses: actions/cache@v2
+        with:
+          path: ${{ steps.pip-cache.outputs.dir }}
+          key: ${{ runner.os }}-pip-${{ matrix.python-version }}
+          restore-keys: |
+            ${{ runner.os }}-pip-
+
+      - name: Install Build Dependencies
+        run: |
+          pip install -U pip
+          pip install -U setuptools wheel twine
+          pip install -U coveralls coverage
+
+      - name: Build zope.interface
+        run: |
+          # Next, build the wheel *in place*. This helps ccache, and also lets us cache the configure
+          # output (pip install uses a random temporary directory, making this difficult).
+          python setup.py build_ext -i
+          python setup.py bdist_wheel
+          # Also install it, so that we get dependencies in the (pip) cache.
+          pip install -U coverage
+          pip install -U 'faulthandler; python_version == "2.7" and platform_python_implementation == "CPython"'
+          pip install .[test]
+
+      - name: Check zope.interface build
+        run: |
+          ls -l dist
+          twine check dist/*
+      - name: Upload zope.interface wheel
+        uses: actions/upload-artifact@v2
+        with:
+          name: zope.interface-${{ runner.os }}-${{ matrix.python-version }}.whl
+          path: dist/*whl
+      - name: Publish package to PyPI (mac)
+        # We cannot 'uses: pypa/gh-action-pypi-publish@v1.4.1' because
+        # that's apparently a container action, and those don't run on
+        # the Mac.
+        if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') && startsWith(runner.os, 'Mac')
+        env:
+          TWINE_PASSWORD: ${{ secrets.TWINE_PASSWORD }}
+        run: |
+          twine upload --skip-existing dist/*
+
+  test:
+    needs: build-zope_interface
+    runs-on: ${{ matrix.os }}
+    strategy:
+      matrix:
+        python-version: [2.7, pypy-2.7, pypy-3.6, 3.5, 3.6, 3.7, 3.8, 3.9]
+        os: [ubuntu-20.04, macos-latest]
+        exclude:
+          - os: macos-latest
+            python-version: pypy-2.7
+          - os: macos-latest
+            python-version: pypy-3.6
+          - os: macos-latest
+            python-version: 3.5
+
+    steps:
+      - name: checkout
+        uses: actions/checkout@v2
+      - name: Set up Python ${{ matrix.python-version }}
+        uses: actions/setup-python@v2
+        with:
+          python-version: ${{ matrix.python-version }}
+      - name: Get pip cache dir
+        id: pip-cache
+        run: |
+          echo "::set-output name=dir::$(pip cache dir)"
+
+      - name: pip cache
+        uses: actions/cache@v2
+        with:
+          path: ${{ steps.pip-cache.outputs.dir }}
+          key: ${{ runner.os }}-pip-${{ matrix.python-version }}
+          restore-keys: |
+            ${{ runner.os }}-pip-
+
+      - name: Download zope.interface wheel
+        uses: actions/download-artifact@v2
+        with:
+          name: zope.interface-${{ runner.os }}-${{ matrix.python-version }}.whl
+          path: dist/
+      - name: Install zope.interface
+        # ``python -m unittest discover`` only works with editable
+        # installs, so we have to duplicate some work and can't
+        # install the built wheel. (zope.testrunner
+        # works fine with non-editable installs.)
+        run: |
+          pip install -U wheel
+          pip install -U coverage
+          pip install -U 'faulthandler; python_version == "2.7" and platform_python_implementation == "CPython"'
+          # Unzip into src/ so that testrunner can find the .so files
+          # when we ask it to load tests from that directory. This
+          # might also save some build time?
+          unzip -n dist/zope.interface-*whl -d src
+          pip install -U -e .[test]
+      - name: Run tests and report coverage
+        # Once with C extensions, once without. Yes, this runs them
+        # twice on PyPy.
+        run: |
+          coverage run -p -m unittest discover -s src
+          PURE_PYTHON=1 coverage run -p -m unittest discover -s src
+          coverage combine
+          coverage report -i
+      - name: Submit to Coveralls
+        # This is a container action, which only runs on Linux.
+        if: ${{ startsWith(runner.os, 'Linux') }}
+        uses: AndreMiras/coveralls-python-action@develop
+        with:
+          parallel: true
+
+  coveralls_finish:
+    needs: test
+    runs-on: ubuntu-20.04
+    steps:
+    - name: Coveralls Finished
+      uses: AndreMiras/coveralls-python-action@develop
+      with:
+        parallel-finished: true
+
+  docs:
+    needs: build-zope_interface
+    runs-on: ${{ matrix.os }}
+    strategy:
+      matrix:
+        python-version: [3.9]
+        os: [ubuntu-20.04]
+
+    steps:
+      - name: checkout
+        uses: actions/checkout@v2
+      - name: Set up Python ${{ matrix.python-version }}
+        uses: actions/setup-python@v2
+        with:
+          python-version: ${{ matrix.python-version }}
+      - name: Get pip cache dir
+        id: pip-cache
+        run: |
+          echo "::set-output name=dir::$(pip cache dir)"
+
+      - name: pip cache
+        uses: actions/cache@v2
+        with:
+          path: ${{ steps.pip-cache.outputs.dir }}
+          key: ${{ runner.os }}-pip-${{ matrix.python-version }}
+          restore-keys: |
+            ${{ runner.os }}-pip-
+
+      - name: Download zope.interface wheel
+        uses: actions/download-artifact@v2
+        with:
+          name: zope.interface-${{ runner.os }}-${{ matrix.python-version }}.whl
+          path: dist/
+      - name: Install zope.interface
+        run: |
+          pip install -U wheel
+          pip install -U coverage
+          pip install -U 'faulthandler; python_version == "2.7" and platform_python_implementation == "CPython"'
+          pip install -U  "`ls dist/zope.interface-*.whl`[docs]"
+      - name: Build docs
+        env:
+          ZOPE_INTERFACE_STRICT_IRO: 0
+        run: |
+          sphinx-build -b html -d docs/_build/doctrees docs docs/_build/html
+          sphinx-build -b doctest -d docs/_build/doctrees docs docs/_build/doctest
+
+
+  lint:
+    needs: build-zope_interface
+    runs-on: ${{ matrix.os }}
+    strategy:
+      matrix:
+        python-version: [3.9]
+        os: [ubuntu-20.04]
+
+    steps:
+      - name: checkout
+        uses: actions/checkout@v2
+      - name: Set up Python ${{ matrix.python-version }}
+        uses: actions/setup-python@v2
+        with:
+          python-version: ${{ matrix.python-version }}
+
+      - name: Get pip cache dir
+        id: pip-cache
+        run: |
+          echo "::set-output name=dir::$(pip cache dir)"
+
+      - name: pip cache
+        uses: actions/cache@v2
+        with:
+          path: ${{ steps.pip-cache.outputs.dir }}
+          key: ${{ runner.os }}-pip-${{ matrix.python-version }}
+          restore-keys: |
+            ${{ runner.os }}-pip-
+
+      - name: Download zope.interface wheel
+        uses: actions/download-artifact@v2
+        with:
+          name: zope.interface-${{ runner.os }}-${{ matrix.python-version }}.whl
+          path: dist/
+      - name: Install zope.interface
+        run: |
+          pip install -U pip
+          pip install -U wheel
+          pip install -U `ls dist/zope.interface-*`[test]
+      - name: Lint
+        # We only need to do this on one version, and it should be Python 3, because
+        # pylint has stopped updating for Python 2.
+        # TODO: Pick a linter and configuration and make this step right.
+        run: |
+          pip install -U pylint
+          # python -m pylint --limit-inference-results=1 --rcfile=.pylintrc zope.interface -f parseable -r n
+
+  manylinux:
+    runs-on: ubuntu-20.04
+    # We use a regular Python matrix entry to share as much code as possible.
+    strategy:
+      matrix:
+        python-version: [3.9]
+        image: [manylinux2010_x86_64, manylinux2010_i686, manylinux2014_aarch64]
+
+    steps:
+      - name: checkout
+        uses: actions/checkout@v2
+      - name: Set up Python ${{ matrix.python-version }}
+        uses: actions/setup-python@v2
+        with:
+          python-version: ${{ matrix.python-version }}
+      - name: Get pip cache dir
+        id: pip-cache
+        run: |
+          echo "::set-output name=dir::$(pip cache dir)"
+
+      - name: pip cache
+        uses: actions/cache@v2
+        with:
+          path: ${{ steps.pip-cache.outputs.dir }}
+          key: ${{ runner.os }}-pip_manylinux-${{ matrix.image }}-${{ matrix.python-version }}
+          restore-keys: |
+            ${{ runner.os }}-pip-
+
+      - name: Update pip
+        run: pip install -U pip
+      - name: Build zope.interface (x86_64)
+        if: matrix.image == 'manylinux2010_x86_64'
+        # An alternate way to do this is to run the container directly with a uses:
+        # and then the script runs inside it. That may work better with caching.
+        # See https://github.com/pyca/bcrypt/blob/f6b5ee2eda76d077c531362ac65e16f045cf1f29/.github/workflows/wheel-builder.yml
+        # The 2010 image is the most recent spec that comes with Python 2.7,
+        # and only up through the tag 2021-02-06-3d322a5
+        env:
+          DOCKER_IMAGE: quay.io/pypa/${{ matrix.image }}:2021-02-06-3d322a5
+        run: |
+          bash .manylinux.sh
+      - name: Build zope.interface (i686)
+        if: matrix.image == 'manylinux2010_i686'
+        env:
+          DOCKER_IMAGE: quay.io/pypa/${{ matrix.image }}:2021-02-06-3d322a5
+          PRE_CMD: linux32
+        run: |
+          bash .manylinux.sh
+      - name: Build zope.interface (aarch64)
+        if: matrix.image == 'manylinux2014_aarch64'
+        env:
+          DOCKER_IMAGE: quay.io/pypa/${{ matrix.image }}
+        run: |
+          # First we must enable emulation
+          docker run --rm --privileged hypriot/qemu-register
+          bash .manylinux.sh
+
+      - name: Upload zope.interface wheels
+        uses: actions/upload-artifact@v2
+        with:
+          path: wheelhouse/*whl
+          name: manylinux_${{ matrix.image }}_wheels.zip
+      - name: Restore pip cache permissions
+        run: sudo chown -R $(whoami) ${{ steps.pip-cache.outputs.dir }}
+      - name: Publish package to PyPI
+        uses: pypa/gh-action-pypi-publish@v1.4.1
+        if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
+        with:
+          user: __token__
+          password: ${{ secrets.TWINE_PASSWORD }}
+          skip_existing: true
+          packages_dir: wheelhouse/
+
+# TODO:
+# * Use YAML syntax to share snippets, like the old .travis.yml did
+# Sadly, as of 2021-02-01, Github Actions does not support anchors at
+# all. Just having an anchor results in an error:
+#
+#   The workflow is not valid. .github/workflows/tests.yml: Anchors
+#   are not currently supported. Remove the anchor 'an-strategy'
+#
+# The alternative of using composite actions doesn't work either,
+# because composite actions are limited to running shell scripts.
+# Steps in them can't call other actions with `uses:`, and nor can
+# they be conditional with `if:`.