Building, testing and publishing python packages using Github Actions

Building, testing and publishing python packages using Github Actions

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-on 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@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: 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 pytest

    - name: Run tests
      shell: bash
      run: summit-tests

The code above does the following:

  • I use needs: build line specifies that this job will only run after the build step is complete.
  • The strategy and matrix keys allows 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 the 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, a 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:
  • image

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