Jenkins CI/CD (10/11): Release Pipeline — Build Artifacts on Tags
Summary: You add a conditional Build Release stage to the pipeline that only runs when Jenkins builds a Git tag. When triggered by a tag like
v0.1.0, the stage produces a wheel and sdist usingpython -m build. On regular branch builds, the stage is skipped entirely. By the end, your pipeline knows the difference between “verify this code” and “produce a release artifact from this code.”
Example Values Used in This Tutorial
| Key | Value |
|---|---|
| Build tool | python -m build |
| Tag format | v0.1.0 |
| When condition | when { buildingTag() } |
| Wheel output | dist/helloci-0.1.0-py3-none-any.whl |
| Sdist output | dist/helloci-0.1.0.tar.gz |
| Previous parts | Parts 1-9 completed |
0. Prerequisites
- A working Jenkins controller at
http://localhost:8080(Part 1). - The
hellociPython package with unit tests, lint, integration tests, artifact archiving, workspace cleanup, timeouts, retries, and concurrency control in the pipeline (Parts 2-9). - Git installed on both your local machine and the Jenkins agent.
- Familiarity with Git tags (
git tag,git push origin <tag>). - A multibranch pipeline job configured in Jenkins (or a pipeline job pointed at your repository).
Note: This tutorial builds on the hardened Jenkinsfile from Parts 8 and 9. If your pipeline does not yet include
timeout,cleanWs(),retry, anddisableConcurrentBuilds(), go back and add them first.
1. CI vs CD — Two Jobs, One Pipeline
Up to this point, the pipeline has done one thing: verify that the code works. Every push triggers lint, unit tests, and integration tests. If everything passes, you get a green checkmark. If something fails, you get a red X and artifacts to diagnose the problem.
That is continuous integration. It answers the question: is this code correct?
Continuous delivery answers a different question: is this code ready to ship? Shipping means producing artifacts — a wheel file, an sdist tarball, a Docker image, a binary — that someone can install, deploy, or upload to a package registry.
You do not want to produce release artifacts on every push. Most pushes are work in progress. You want to produce artifacts only when you explicitly declare “this commit is a release.” In Git, the mechanism for that declaration is a tag.
Here is the model:
- Branch push — run all CI stages (lint, test, integration). Skip the release stage.
- Tag push — run all CI stages, then build release artifacts. The tag is the gate.
Jenkins provides a built-in condition for this: when { buildingTag() }. A stage guarded by this condition runs only when the current build was triggered by a Git tag. On branch builds, Jenkins skips the stage entirely — it does not even appear as failed, it just shows as skipped.
2. Install the Build Tool
Python’s standard way to produce distributable packages is the build module. It reads your pyproject.toml and produces both a wheel (.whl) and a source distribution (.tar.gz).
You have two options for making build available in the pipeline.
Option A: Install it in the pipeline stage. This is the simplest approach and the one this tutorial uses. The Build Release stage installs build before invoking it:
.venv/bin/pip install build
.venv/bin/python -m buildCode language: Shell Session (shell)
Option B: Add it to your project’s optional dependencies. Open pyproject.toml and add a build extra:
[project.optional-dependencies]
test = ["pytest", "ruff"]
build = ["build"]Code language: Shell Session (shell)
Then install it with pip install -e ".[build]" in the pipeline. This approach keeps the dependency declared in the project metadata, which is cleaner for long-term maintenance.
Either option works. Option A is used below because it requires no changes to pyproject.toml and keeps the build dependency isolated to the release stage.
3. Set the Version in pyproject.toml
The build tool reads the version from your pyproject.toml. If you have not set one yet, add it now.
Open pyproject.toml in your helloci project:
[project]
name = "helloci"
version = "0.1.0"
description = "A minimal Python package for learning CI/CD with Jenkins"
requires-python = ">=3.12"Code language: TOML, also INI (ini)
The version field determines the filenames of the built artifacts. With version = "0.1.0", the build produces:
dist/helloci-0.1.0-py3-none-any.whldist/helloci-0.1.0.tar.gz
Tip: Keep the version in
pyproject.tomlin sync with your Git tags. When you tagv0.1.0, thepyproject.tomlshould sayversion = "0.1.0". Mismatches between the tag and the version field cause confusion when you publish to PyPI later. Tools likesetuptools-scmcan automate this, but manual sync is fine for this series.
4. Add the Build Release Stage
Open the Jenkinsfile in your repository root. Add a new stage called Build Release after the Integration Tests stage, guarded by the when { buildingTag() } condition:
stage('Build Release') {
when {
buildingTag()
}
steps {
sh '.venv/bin/pip install build'
sh '.venv/bin/python -m build'
}
}Code language: Groovy (groovy)
The when block is the key. buildingTag() is a built-in Jenkins condition that evaluates to true only when the current build is triggered by a Git tag — not a branch, not a pull request, only a tag.
When the condition is false, Jenkins skips the entire stage. In the Stage View on the Jenkins dashboard, the Build Release stage shows as a grey box with “skipped” — clearly communicating that the stage existed but was intentionally not executed.
The two sh steps inside are straightforward:
pip install buildinstalls the build tool into the existing virtual environment.python -m buildreadspyproject.toml, builds the wheel and sdist, and writes them to thedist/directory.
5. Archive the Release Artifacts
The post block already archives everything in results/. Add a second archiveArtifacts step to capture the dist/ directory:
archiveArtifacts artifacts: 'dist/**', allowEmptyArchive: trueCode language: Groovy (groovy)
The allowEmptyArchive: true flag is important. On branch builds where the Build Release stage is skipped, the dist/ directory does not exist. Without this flag, the archive step would fail and break the post block for every non-tag build.
With this step in place, tag builds produce downloadable wheel and sdist files directly on the Jenkins build page — right alongside the test results and Docker logs.
6. The Full Updated Jenkinsfile
Here is the complete Jenkinsfile with the Build Release stage added. Replace your existing Jenkinsfile with this version:
pipeline {
agent any
options {
timeout(time: 15, unit: 'MINUTES')
disableConcurrentBuilds()
}
stages {
stage('Setup Python') {
steps {
sh 'python3 -m venv .venv'
sh '.venv/bin/pip install --upgrade pip'
}
}
stage('Install Dependencies') {
steps {
sh '.venv/bin/pip install -e ".[test]"'
}
}
stage('Lint') {
steps {
sh '.venv/bin/ruff check src/ tests/'
}
}
stage('Unit Tests') {
steps {
sh 'mkdir -p results'
sh '.venv/bin/pytest tests/test_greet.py --junitxml=results/junit.xml'
}
}
stage('Integration Tests') {
steps {
retry(2) {
sh 'docker compose up -d'
}
sh 'docker compose exec -T postgres pg_isready -U testuser -d testdb --timeout=30'
sh '.venv/bin/pytest tests/test_integration.py --junitxml=results/junit-integration.xml -v'
}
}
stage('Build Release') {
when {
buildingTag()
}
steps {
sh '.venv/bin/pip install build'
sh '.venv/bin/python -m build'
}
}
}
post {
always {
sh 'docker compose logs > results/docker-logs.txt 2>&1 || true'
sh 'docker compose down -v || true'
junit 'results/*.xml'
archiveArtifacts artifacts: 'results/**', allowEmptyArchive: true
archiveArtifacts artifacts: 'dist/**', allowEmptyArchive: true
cleanWs()
}
}
}Code language: Groovy (groovy)
Compare this to the Jenkinsfile from Part 9. Two things changed:
- The
Build Releasestage was added after Integration Tests, guarded bywhen { buildingTag() }. - A second
archiveArtifactsline in thepostblock capturesdist/**.
Everything else — timeout, retry, cleanup, concurrency control — remains unchanged. The CI stages still run on every build. The release stage runs only on tags.
Commit and push:
cd ~/projects/helloci
git add Jenkinsfile
git commit -m "Add tag-triggered Build Release stage to pipeline"
git push origin mainCode language: Shell Session (shell)
7. Configure Jenkins to Discover Tags
Jenkins needs to know about your tags before it can build them. The configuration depends on your job type.
Multibranch Pipeline (recommended):
- Go to your pipeline job in Jenkins and click Configure.
- Under Branch Sources, find the Git or GitHub source configuration.
- Click Add under Behaviors and select Discover tags.
- Save the configuration.
Jenkins now scans for both branches and tags. When you push a new tag, Jenkins discovers it on the next scan and triggers a build.
Tip: You can reduce the scan interval under Scan Multibranch Pipeline Triggers. Set it to 1 minute for testing, then increase it to 5 or 15 minutes for production use. Alternatively, configure a webhook so Jenkins scans immediately on push.
Regular Pipeline job:
If you are using a plain Pipeline job (not multibranch), configure a webhook or poll SCM trigger that fires on tag pushes. Multibranch pipelines are the easier path — they handle tag discovery automatically.
8. Create a Tag and Push It
With the Jenkinsfile committed and Jenkins configured to discover tags, create your first release tag:
cd ~/projects/helloci
git tag v0.1.0
git push origin v0.1.0Code language: Shell Session (shell)
The first command creates a lightweight tag pointing at the current HEAD commit. The second pushes the tag to the remote repository.
Note: Use annotated tags (
git tag -a v0.1.0 -m "Release 0.1.0") if you want to attach a message to the tag. Both lightweight and annotated tags triggerbuildingTag()in Jenkins. Annotated tags are recommended for real releases because they record who created the tag and when.
After pushing the tag, Jenkins discovers it on the next scan (or immediately if you have a webhook configured) and starts a build.
9. Verify the Tag Build
Go to the Jenkins dashboard and find the build triggered by the v0.1.0 tag. Open the build page.
Stage View: You should see all stages — Setup Python, Install Dependencies, Lint, Unit Tests, Integration Tests, and Build Release. All of them should be green. The Build Release stage is no longer skipped — it ran because this build was triggered by a tag.
Console Output: Search for the build output. You should see:
[Build Release] Running shell script
+ .venv/bin/pip install build
+ .venv/bin/python -m build
* Creating venv isolated environment...
* Installing packages in isolated environment...
* Getting build dependencies for sdist...
* Building sdist...
* Building wheel...
Successfully built helloci-0.1.0.tar.gz and helloci-0.1.0-py3-none-any.whlCode language: Shell Session (shell)
Build Artifacts: Click Build Artifacts on the build page. You should see the dist/ directory alongside the usual results/ directory:
dist/
helloci-0.1.0-py3-none-any.whl
helloci-0.1.0.tar.gz
results/
docker-logs.txt
integration-report.md
junit-integration.xml
junit.xmlCode language: Shell Session (shell)
Download the wheel and sdist. These are the release artifacts — the files you would upload to PyPI or distribute to users.
10. Verify the Skip on Branch Builds
Push a small change to main (not a tag) to trigger a regular branch build:
cd ~/projects/helloci
git commit --allow-empty -m "Trigger branch build to verify tag skip"
git push origin mainCode language: Shell Session (shell)
Go to the Jenkins dashboard and open the build triggered by this push.
Stage View: You should see Setup Python, Install Dependencies, Lint, Unit Tests, and Integration Tests — all green. The Build Release stage should appear as skipped (grey). It was not executed because this build was triggered by a branch push, not a tag.
Build Artifacts: Click Build Artifacts. You should see the results/ directory but no dist/ directory. The allowEmptyArchive: true flag prevented an error — the archive step ran, found no dist/ directory, and silently moved on.
This is exactly the behavior you want. Branch builds verify code quality. Tag builds verify code quality and produce release artifacts. The pipeline handles both cases without any manual intervention.
11. Why Reproducible Builds Matter
A tag is a promise. When you tag v0.1.0, you are saying “this specific commit is version 0.1.0.” Anyone who checks out that tag should get the same code, and building that code should produce the same artifact.
This is reproducible building, and it matters for three reasons:
- Auditability. If a user reports a bug in version 0.1.0, you can check out the
v0.1.0tag, build it, and get the exact same wheel they installed. There is no guessing about which commit produced the artifact. - Trust. If you publish a wheel to PyPI, users trust that the file corresponds to the tagged source code. If your build process is non-deterministic (pulling latest dependencies, injecting timestamps, using random seeds), the artifact does not faithfully represent the tag.
- Rollbacks. If version 0.2.0 introduces a regression, you can rebuild 0.1.0 from its tag and redeploy. This only works if the build is reproducible.
The pipeline you built supports reproducibility because python -m build reads the version from pyproject.toml, which is checked into Git and pinned by the tag. The build does not inject dynamic versions or pull unversioned dependencies.
Warning: If your
pyproject.tomluses unpinned dependencies (e.g.,requests>=2.0instead ofrequests==2.31.0), different builds of the same tag could produce wheels with different dependency metadata. For serious release pipelines, pin your dependencies or use a lock file.
12. What Happens Next
The pipeline now produces artifacts, but they sit on the Jenkins build page. Nobody outside your Jenkins instance can install them.
In Part 11, you close the loop by adding a Publish to PyPI stage that uploads the wheel and sdist to PyPI using twine. That stage will also be tag-gated, running immediately after Build Release. The full flow becomes: tag a version, Jenkins verifies the code, builds the artifacts, and publishes them — all automatically.
Summary
You extended the helloci pipeline from a CI-only pipeline to a CI/CD pipeline by adding a tag-triggered Build Release stage. Here is what you accomplished:
- Established the CI vs CD distinction: branch builds verify code, tag builds produce release artifacts. Tags are the gate between the two.
- Added
python -m buildas the tool for producing wheel and sdist packages frompyproject.toml. - Set the version field in
pyproject.tomlto0.1.0, which determines the output filenames. - Added a
Build Releasestage guarded bywhen { buildingTag() }, so it runs only on tag builds and is skipped on branch builds. - Added
archiveArtifacts artifacts: 'dist/**', allowEmptyArchive: trueto thepostblock so release artifacts are downloadable from the Jenkins build page. - Configured Jenkins to discover tags via the multibranch pipeline Discover Tags behavior.
- Created and pushed the
v0.1.0tag, triggering a full pipeline run that produceddist/helloci-0.1.0-py3-none-any.whlanddist/helloci-0.1.0.tar.gz. - Verified that a regular branch build skips the Build Release stage entirely.
- Discussed why reproducible builds matter: auditability, trust, and rollback safety.
The pipeline now has a complete test-and-build flow. Next up in Part 11: you add a Publish to PyPI stage that uploads the release artifacts automatically, completing the CI/CD pipeline.
Jenkins CI/CD — All Parts
- 1 Jenkins CI/CD (1/11): Prerequisites and Mental Model
- 2 Jenkins CI/CD (2/11): Create the Minimal Repo and Prove Local Tests Run
- 3 Jenkins CI/CD (3/11): First Jenkins Job With a Jenkinsfile
- 4 Jenkins CI/CD (4/11): Test Reporting With JUnit Results in Jenkins
- 5 Jenkins CI/CD (5/11): Fast-Fail Static Checks With a Lint Stage
- 6 Jenkins CI/CD (6/11): Integration Tests With Docker Compose
- 7 Jenkins CI/CD (7/11): Artifacts and Debugging Failed Builds
- 8 Jenkins CI/CD (8/11): Workspace Cleanup, Timeouts, and Retries
- 9 Jenkins CI/CD (9/11): Concurrency and Port Conflicts
- 10 Jenkins CI/CD (10/11): Release Pipeline — Build Artifacts on Tags You are here
- 11 Jenkins CI/CD (11/11): Publish to PyPI Securely
