- Sun 14 May 2023
- programming
- Gaige B. Paulsen
- #programming, #gitlab, #automation, #poetry
This weekend, I had occasion to build a new python-based utility and leaned in to my existing poetry tooling in order to do so. While starting the new project, I wanted to take advantage of some gitlab automation I'd previously used on other projects, so I figured I'd document it here.
Tooling overview for automation
The purpose of the gitlab automation here is to go from a feature branch to a new release without having to do any of the work myself.
I'm using a bunch of tools to achieve this:
poetry
for dependency management and packagingcommitizen
for enforcing conventional commits and managing release notespytest
for test running and reportingtox
for test automation in multiple language versions (currently 3.10 and 3.11)
And, for good measure, I'll mention GitLab (the Pro version) for source repository and CI/CD, and JetBrains PyCharm, which I use as my IDE most of the time.
Automating the poetry delivery pipeline
Once I've got the project building and tests running, then I want to start
rolling it out in versions. I first established this pipeline for another
command-line tool (certalerter
, my alerting tool for certlogger
), so
adapting for a new project should be straightforward.
I'm going to elide the coding and testing and stick to the automation for this post,
and mostly do it by going through my .gitlab-ci.yml
file a bit at a time.
Overall workflow
I've broken the workflow into 5 stages and limited the running of the workflow to: merge requests, commits to main branch when not in a merge request, adding a tag (mostly to handle releases).
I'm running all of this in docker (possibly k8s, but I haven't specifically enabled that yet). My python is pretty clean and I havne't had any problems with portability.
workflow:
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS'
when: never
- if: '$CI_COMMIT_BRANCH'
- if: $CI_COMMIT_TAG
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
default:
image: python:3.11
There are 5 stages, of which most of them are pretty straightforward
stages:
- build
- test
- bump
- package
- release
Building the project
Building the project is pretty straightforward, load in poetry
to
get our environment and the let it build
. I've chosen to capture
the distribution binaries (whl
and tar.gz
files) in the artifacts
paths so that they don't need to be rebuilt for the testing phase. I'm
not using the PyPi
repository from gitlab yet, because I don't want
every build to be uniquely kept there, but that's addressed in the
package
phase later.
build-job:
stage: build
script:
- pip install poetry
- poetry build
artifacts:
paths:
- dist/ct_nagios_plugins*.whl
- dist/ct_nagios_plugins*.tar.gz
expire_in: 1 week
interruptible: true
Testing and coverage
In order to enable pushing automatically to production, I feel it's necessary to have well maintained test suites. As such, code is tested on every commit (and in all major environments) and coverage is maintained to see when the tests are back-sliding.
Each of the test environments is started in the appropriate python image
and then tox
and the coverage
tools are installed so that we don't
need to fully install python. Since the goal here is to create a stand-alone
package, I want to take care not to introduce any unintended poetry
dependencies.
The funky grep
/sed
/awk
bit is to tease the coverage out of the
coverage file for use by gitlab. The || true
at the end of it ensures
that being unable to get coverage through this method doesn't spoil the
stage.
Finally, the test logs (junit-*.xml
) and coverage reports (coverage-*.xml
)
are stored as artifacts.
test:
needs:
- build-job
parallel:
matrix:
- PYTHON_VERSION: "3.11"
TOXENV: py311
- PYTHON_VERSION: "3.10"
TOXENV: py310
image: python:${PYTHON_VERSION}
stage: test
script:
- pip install tox coverage
- tox --installpkg dist/*.whl
- coverage xml -o coverage-${PYTHON_VERSION}.xml
- >
grep ^\<coverage coverage-${PYTHON_VERSION}.xml
| sed -n -e 's/.*line-rate=\"\([0-9.]*\)\".*/\1/p'
| awk '{print "CodeCoverageOverall =" $1*100}'
|| true
interruptible: true
artifacts:
reports:
junit: junit-*.xml
coverage_report:
coverage_format: cobertura
path: coverage-*.xml
coverage: '/^CodeCoverageOverall =(\d+\.?\d*)$/'
Bumping versions
In addition to the previously-mentioned rules for running, the version bump
is very selective. It will only run on commits to main
where bump
is not
part of the commit message. This (hopefully) prevents it from running twice
without need. It also should stop loops.
Note the use of CI_BUMP_TOKEN
here, which is a Personal Access Token (PAT)
for GitLab that has permissions to read_repository
and write_repository
so that it can be used to write back to the repo. When I tried this originally,
I expected to be able to commit back to my own repo, but ran into trouble, so
using the PAT here makes that straightforward. The CI_BUMP_GITLAB_ID
is
probably not necessary, as __token__
should suffice.
Using poetry
and cz
here guarantees that the steps that are expected all
run, but it also results in the above requirements. If I weren't committing
back, but just setting a tag or release, I could easily do that with the API.
Specifically, the CI_JOB_TOKEN
doesn't have write_repository
permission.
In my case, I use a specific PAT to this repository, so that I can limit
the blast radius. I'd be happier if there were a way to request a read/write
CI_JOB_TOKEN
for certain stages, but even if that were available, it's
not clear how that would be governed effectively without giving all
stages in the pipeline access.
# need to clean in case tagging is screwy, since `git clean` doesn't know to remove tags
bump:
needs:
- test
stage: bump
variables:
GIT_STRATEGY: clone
script:
- pip install poetry
- poetry install
- git config --global user.email "${GITLAB_USER_EMAIL}"
- git config --global user.name "${GITLAB_USER_NAME}"
- exit_code=0
- poetry run cz bump --annotated-tag --changelog || exit_code=$?
- echo "$exit_code is exit code ; $? was result"
- |
if [ $exit_code -eq 0 ]
then
git remote set-url origin ${CI_SERVER_PROTOCOL}://${CI_BUMP_GITLAB_ID}:${CI_BUMP_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}
git push origin --follow-tags HEAD:${CI_COMMIT_BRANCH}
elif [ $exit_code -eq 21 ]
then
echo "Skipping push with no version change"
elif [ $exit_code -eq 3 ]
then
echo "Skipping push with no commits"
else
echo "cz error code $exit_code"
exit $exit_code
fi
rules:
- if: $CI_COMMIT_BRANCH==$CI_DEFAULT_BRANCH && $CI_COMMIT_TITLE =~ /^bump/
when: never
- if: $CI_COMMIT_BRANCH==$CI_DEFAULT_BRANCH
# skip on bump, because you'll never bump after bump
Packaging the job
As with the bump
stage, the package
stage runs only at specific
times. In particular, it will only run directly following a bump
commit on
the main
branch.
Theoretically, I could use poetry
and then make use of the publish
command, but in this case, twine
is fine (and dedicated).
package-job:
stage: package
script:
- pip install twine
- TWINE_PASSWORD=${CI_JOB_TOKEN} TWINE_USERNAME=gitlab-ci-token python -m twine upload --verbose --disable-progress-bar --repository-url ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/pypi dist/*
rules:
- if: $CI_COMMIT_BRANCH==$CI_DEFAULT_BRANCH && $CI_COMMIT_TITLE =~ /^bump/
Finishing off the release
The final phase, which only happens on tagged commits, is to tag the release. GitLab makes this easy by directly supporting the release process in the CI file.
release_job:
stage: release
image: registry.gitlab.com/gitlab-org/release-cli:latest
rules:
- if: $CI_COMMIT_TAG
script:
- echo "Running the release job for $CI_COMMIT_TAG."
- "awk '/^## Unreleased/ { next } ; /^## / { r++ ; if ( r <2) { print ; next } else { exit } }; /^/ { print } ;' < CHANGELOG.md >INCREMENTAL_CHANGELOG.md"
release:
tag_name: $CI_COMMIT_TAG
name: 'v$CI_COMMIT_TAG'
description: INCREMENTAL_CHANGELOG.md