Github Actions is a great product for continuous integration and delivery built directly into Github. I use it to run unit tests and publish python packages automatically in my open source projects (e.g., Summit).
However, getting actions setup properly has been a challenge. In this guide, I’ll show how to use Github Actions to run tests on PRs and publish a python package to pypi.
Choosing When To Run an Action
The first part of an action specifies what events cause the action to run. This is usually through the adverb on
. Note that Github actions are specified in a YAML file in any repository under the path .github/workflows
.
The following code is used to do a common pattern that is surprisingly difficult: Run the action on push or pull request but not both.
on:
push:
pull_request:
branches:
# Branches from forks have the form 'user:branch-name' so we only run
# this job on pull_request events for branches that look like fork
# branches. Without this we would end up running this job twice for non
# forked PRs, once for the push and then once for opening the PR.
- '**:**'
Specifying Jobs
Jobs are the meat of Github actions. Each job consists of keys such as runs-on
(specifies the operating system) and steps
(specifies the list of commands to run). A full list of keys can be found here. I like to break my actions into three jobs: build, test, and publish.
Build
The build job creates a dist
for the package from the source code. I tend to use poetry as an all-in-one dependency and package manager, but you could also do this using build.
The code below checks out the repository, installs python and poetry, and builds the package. It also saves the built package as an artifact that can be used in subsequent steps. I set the retention time for the artifact to one day, which prevents a large number of unused artifacts building up.
jobs:
# Build the package
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install python
uses: actions/setup-python@v4
with:
python-version: '3.9'
- name: Install poetry
uses: Gr1N/setup-poetry@v8
- name: Build package
run: poetry build
- name: Save built package
uses: actions/upload-artifact@v3
with:
name: dist
path: dist/
retention-days: 1
Test
I tend to use pytest to run unit and integration tests. The code below runs unit tests on several python versions. Take a look and I’ll describe each part afterwards.
jobs:
...
# Run pytest using built package
test:
needs: build
runs-on: ubuntu-latest
strategy:
matrix:
python: ["3.8", "3.9", "3.10"]
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Install python
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python }}
cache: 'pip'
cache-dependency-path: "poetry.lock"
- name: Download built package
uses: actions/download-artifact@v3
with:
name: dist
- name: Install package and pytest
shell: bash
run: |
WHL_NAME=$(ls summit-*.whl)
pip install ${WHL_NAME} pytest
- name: Run tests
shell: bash
run: summit-tests
The code above does the following:
- I use the
needs: build
line to specify that this job will only run after the build step is complete. - The
strategy
andmatrix
keys allow me to specify a list of python environments to run in parallel. - I install the correct version of python in each environment using curly brackets syntax for variables. Also, note that I use pip to install the package since this is what most people will use, and I want to test pip works.
- Github actions allows me to cache dependencies to speed things up. I use the standard pip cache, but I specify the cache key as the lock file from poetry.
- I download the artifact I created in the build step and install my package. Replace
pura
with the name of your package. - I run the tests; this code is specific to my use case, but adapt to use whatever command you use to run tests.
Publish
I want Github actions to publish to pypi whenever I bump the package versions in pyproject.toml
. My strategy is to compare the version of the built package to the published version on pypi and push a release if the source code is ahead. Full description follows.
publish:
needs: test
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Install python
uses: actions/setup-python@v2
with:
python-version: '3.9'
- name: Download built package
uses: actions/download-artifact@v3
with:
name: dist
path: dist/
- name: Install poetry
uses: Gr1N/setup-poetry@v7
- name: Determine the version for this release from the build
id: current
run: |
BUILD_VER="$(ls dist/pura-*.tar.gz)"
echo "Path: $BUILD_VER"
if [[ $BUILD_VER =~ (pura-)([^,][0-9.]{4}) ]]; then
echo "::set-output name=version::${BASH_REMATCH[2]}"
echo "Version of build: ${BASH_REMATCH[2]}"
else
echo "No version found found"
fi
- name: Install coveo-pypi-cli
run: pip install coveo-pypi-cli
- name: Get latest published version
id: published
run: |
PUB_VER="$(pypi current-version pura)"
echo "::set-output name=version::$PUB_VER"
echo "Latest published version: $PUB_VER"
- name: Publish to pypi if new version
if: (steps.current.outputs.version != steps.published.outputs.version)
shell: bash
run: |
poetry config pypi-token.pypi ${{ secrets.PYPI_TOKEN }}
if [[ '${{ github.ref_name }}' == 'main' ]]; then
poetry publish
else
echo "Dry run of publishing the package"
poetry publish --dry-run
fi
- name: Tag repository
shell: bash
id: get-next-tag
if: (steps.current.outputs.version != steps.published.outputs.version)
run: |
TAG_NAME=${{ steps.current.outputs.version }}
echo "::set-output name=tag-name::$TAG_NAME"
echo "This release will be tagged as $TAG_NAME"
git config user.name "github-actions"
git config user.email "actions@users.noreply.github.com"
git tag --annotate --message="Automated tagging system" $TAG_NAME ${{ github.sha }}
- name: Push the tag
if: (steps.current.outputs.version != steps.published.outputs.version)
env:
TAG_NAME: ${{ steps.current.outputs.version }}
run: |
if [[ ${{ github.ref_name }} == 'main' ]]; then
git push origin $TAG_NAME
else
echo "If this was the main branch, I would push a new tag named $TAG_NAME"
fi
The code above does the following:
- I first check out the repository, installing python and poetry, and downloading the built artifact. This is the same as in the test job.
- I get the version of the package from the build using regex. Replace
pura
with the name of your package. Note the use of::set-output
to set a variable value to be used later - I use coveo pypi cli to check the current version of the published package.
- I compare the version of the built package to the published version number, and publish the package if the built version is ahead of the published one. Some extra notes on this:
- You will need to create an API token for your pypi account scoped at least to the package you want to publish and add the token as a Github action secret under
PYPI_TOKEN
. - The code only publishes the package on commits to the main branch and does a dry run for all other branches (e.g., on pull requests).I
- I create and push a tag with the new version number. Again I only run this on the main branch and print out text otherwise. The text can be seen in the Github Actions UI:
Wrapping up
I hope this is helpful for your next python project! I’ve combined the full code below, but for an example in context, see Summit.
name: Test and Publish
on:
push:
pull_request:
branches:
# Branches from forks have the form 'user:branch-name' so we only run
# this job on pull_request events for branches that look like fork
# branches. Without this we would end up running this job twice for non
# forked PRs, once for the push and then once for opening the PR.
- '**:**'
jobs:
# Build the package
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Install python
uses: actions/setup-python@v2
with:
python-version: '3.9'
- name: Install poetry
uses: Gr1N/setup-poetry@v7
- name: Build package
run: poetry build
- name: Upload built package
uses: actions/upload-artifact@v3
with:
name: dist
path: dist/
retention-days: 1
# Run pytest using built package
test:
needs: build
runs-on: ubuntu-latest
strategy:
matrix:
python: ["3.8", "3.9", "3.10"]
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Install python
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python }}
cache: 'pip'
cache-dependency-path: "poetry.lock"
- name: Download built package
uses: actions/download-artifact@v3
with:
name: dist
- name: Install pura and pytest
shell: bash
run: |
WHL_NAME=$(ls pura-*.whl)
pip install ${WHL_NAME}[experiments,entmoot] pytest
- name: Run tests
shell: bash
run: pura-tests
# Publish to pypi on version change
# This is based on https://github.com/coveooss/pypi-publish-with-poetry
publish:
needs: build
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Install python
uses: actions/setup-python@v2
with:
python-version: '3.9'
- name: Download built package
uses: actions/download-artifact@v3
with:
name: dist
path: dist/
- name: Install poetry
uses: Gr1N/setup-poetry@v7
- name: Install coveo-pypi-cli
run: pip install coveo-pypi-cli
- name: Determine the version for this release from the build
id: current
run: |
BUILD_VER="$(ls dist/pura-*.tar.gz)"
echo "Path: $BUILD_VER"
if [[ $BUILD_VER =~ (pura-)([^,][0-9.]{4}) ]]; then
echo "::set-output name=version::${BASH_REMATCH[2]}"
echo "Version of build: ${BASH_REMATCH[2]}"
else
echo "No version found found"
fi
- name: Get latest published version
id: published
run: |
PUB_VER="$(pypi current-version pura)"
echo "::set-output name=version::$PUB_VER"
echo "Latest published version: $PUB_VER"
- name: Publish to pypi if new version
if: (steps.current.outputs.version != steps.published.outputs.version)
shell: bash
run: |
poetry config pypi-token.pypi ${{ secrets.PYPI_TOKEN }}
if [[ '${{ github.ref_name }}' == 'main' ]]; then
poetry publish
else
echo "Dry run of publishing the package"
poetry publish --dry-run
fi
- name: Tag repository
shell: bash
id: get-next-tag
if: (steps.current.outputs.version != steps.published.outputs.version)
run: |
TAG_NAME=${{ steps.current.outputs.version }}
echo "::set-output name=tag-name::$TAG_NAME"
echo "This release will be tagged as $TAG_NAME"
git config user.name "github-actions"
git config user.email "actions@users.noreply.github.com"
git tag --annotate --message="Automated tagging system" $TAG_NAME ${{ github.sha }}
- name: Push the tag
if: (steps.current.outputs.version != steps.published.outputs.version)
env:
TAG_NAME: ${{ steps.current.outputs.version }}
run: |
if [[ ${{ github.ref_name }} == 'main' ]]; then
git push origin $TAG_NAME
else
echo "If this was the main branch, I would push a new tag named $TAG_NAME"
fi