Hands-On: Build Artifacts, Semantic Versioning, and Package Management
A one-hour session that turns the Python and Java pipelines from the earlier sessions into a release process. You will version the code, build each artifact, verify it, publish it through GitHub Actions and Jenkins, and consume it the way a downstream team would. Docker is not part of this session.
- Session plan and prerequisites
- Part 1: Concepts (5 min)
- Part 2: Versioning one release (10 min)
- Part 3: Build the artifacts locally (10 min)
- Part 4: Checksums and inspection (6 min)
- Part 5: Release pipelines on GitHub (12 min)
- Part 6: Artifacts in Jenkins (6 min)
- Part 7: Consuming and pinning (8 min)
- Part 8: Recap, quiz, cleanup, troubleshooting (3 min)
Session plan and prerequisites
| Time | Part | What you finish |
|---|---|---|
| 0:00 to 0:05 | 1. Concepts | Artifact types and SemVer vocabulary |
| 0:05 to 0:15 | 2. Versioning | All three projects at 1.1.0, version check script passing and failing as expected, annotated tag |
| 0:15 to 0:25 | 3. Local builds | Python wheel and sdist, npm tarball, Java jar installed to the local Maven repository |
| 0:25 to 0:31 | 4. Verification | Checksums, a tamper test, and package contents checked |
| 0:31 to 0:43 | 5. GitHub release | Release created by the tag, assets downloaded and verified |
| 0:43 to 0:49 | 6. Jenkins | Fingerprinted artifacts on the build page |
| 0:49 to 0:57 | 7. Consuming | A consumer project installing 1.1.0 from each artifact type |
| 0:57 to 1:00 | 8. Recap | Quiz and cleanup |
Prerequisites
Confirm each item before starting. The check command is shown for each.
| Requirement | Check | Needed for |
|---|---|---|
| Python 3.11 or later | python3 --version | Parts 2 to 4, 7 |
| Node.js 20 and npm | node --version and npm --version | Parts 2 to 4, 7 |
| JDK 17 and Maven 3.9 | java -version and mvn -version | Parts 2 to 5, 7 |
| Git with push access | git remote -v | Parts 2, 5, 6 |
python-ci-demo repository | Pipeline is green on main | All parts |
java-ci-demo repository | Pipeline is green on main | All parts |
Jenkins (from the earlier session) running at http://localhost:8080 | Open the URL in a browser | Part 6 |
~/work/python-ci-demo and ~/work/java-ci-demo. Adjust the paths if yours are elsewhere. Every step tells you which folder to be in.Part 1: Concepts 5 min
Step 1.1 Read the artifact types
Goal: know what each artifact is and which tool produces it.
| Artifact | What it is | Example in this session | Produced by |
|---|---|---|---|
| Python wheel | Installable, pre-built Python package | calc_ci_demo-1.1.0-py3-none-any.whl | python -m build |
| Python sdist | Source distribution, rebuildable by others | calc_ci_demo-1.1.0.tar.gz | python -m build |
| npm package | JavaScript library packed as a tarball | calc-ci-demo-js-1.1.0.tgz | npm pack |
| JAR | Compiled Java library or application | calc-ci-demo-1.1.0.jar | mvn package |
| Binary | Compiled executable (Go, Rust, C++) | Not built in this session | go build, cargo build --release |
| Helm chart | Kubernetes deployment package | Not built in this session | helm package |
Step 1.2 Learn the SemVer rules
Goal: choose the correct version number for a change.
- MAJOR (for example
2.4.1to3.0.0): a breaking change. Existing callers must change their code. - MINOR (
2.4.1to2.5.0): a new feature that old callers can ignore. - PATCH (
2.4.1to2.4.2): a bug fix with no interface change. - When MINOR increases, PATCH resets to 0. When MAJOR increases, MINOR and PATCH reset to 0.
- Pre-release:
2.5.0-alpha.1, then2.5.0-rc.1, then2.5.0. The hyphen marks a pre-release, and it sorts before the stable version. - Build metadata:
2.5.0+build.42. It does not change precedence.
Step 1.3 Classify changes
Goal: practise the rules on realistic changes. Write your answer, then check it.
- Current version
2.4.1. A typo in an error message is fixed. - Current version
2.4.1. An optionaltimeoutparameter is added. - Current version
2.4.1. A public function is renamed, and the old name is removed. - Current version
2.5.0-rc.1. A bug is found in the release candidate and fixed.
Answers
2.4.2(PATCH).2.5.0(MINOR), because the parameter is optional and old calls still work.3.0.0(MAJOR), because callers using the old name break.2.5.0-rc.2. A fix to a pre-release gets a new pre-release number. The stable version is still 2.5.0.
Part 2: Versioning one release 10 min
Step 2.1 Add packaging metadata to the Python project
Goal: give the Python project a version and tell the build tool how to package it.
Do
Go to the Python repository:
cd ~/work/python-ci-demo
git checkout main
git pull
Create pyproject.toml in the root of the repository:
[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "calc-ci-demo"
version = "1.1.0"
description = "Calculator demo used for CI and artifact lessons"
requires-python = ">=3.11"
[tool.setuptools.packages.find]
include = ["app*"]
Explanation
[build-system]names the builder.python -m buildreads this section and installssetuptoolsinto a temporary environment.[project] versionis the number that ends up in the wheel file name and in the package metadata. It is the value the version check compares.packages.findincludes only theapppackage. Thetestsfolder stays out of the artifact.
Verify
python3 -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])"
Expected output: 1.1.0.
Step 2.2 Create the npm package
Goal: add a small JavaScript package that becomes the second artifact.
Do
Still in python-ci-demo, create the npm folder and its three files.
mkdir -p npm
{
"name": "calc-ci-demo-js",
"version": "1.1.0",
"description": "Calculator helpers packaged as an npm artifact",
"main": "index.js",
"files": ["index.js", "README.md"],
"license": "MIT",
"private": true
}
function add(a, b) {
return a + b;
}
function divide(a, b) {
if (b === 0) {
throw new Error('Cannot divide by zero');
}
return a / b;
}
module.exports = { add, divide };
# calc-ci-demo-js
Calculator helpers. Exports add and divide.
Explanation
filesis an allow-list. Only the listed files enter the tarball. Without it, anything else in the folder, such as a local.envfile, would be packed too."private": truemakesnpm publishrefuse to run.npm packstill works. This protects you from publishing to the public registry by mistake.
Verify
cd npm
node -p "require('./package.json').version"
cd ..
Expected output: 1.1.0.
Step 2.3 Set the Java version
Goal: change the Maven project version to 1.1.0.
Do
Go to the Java repository and open pom.xml:
cd ~/work/java-ci-demo
git checkout main
git pull
grep -n "calc-ci-demo" -A 1 pom.xml | head -5
Find the <version> line that follows <artifactId>calc-ci-demo</artifactId>. Change its value from 1.0.0 to 1.1.0:
<artifactId>calc-ci-demo</artifactId>
<version>1.1.0</version>
Explanation
- Maven builds the jar name from
artifactIdandversion, so the output becomescalc-ci-demo-1.1.0.jar. - Only the project version changes. Dependency and plugin versions stay the same, so the build behaves as before.
Verify
mvn -B -q help:evaluate -Dexpression=project.version -DforceStdout
Expected output: 1.1.0. The help:evaluate goal reads the version Maven resolves, so this confirms the change took effect.
mvn -B verify
Expected output: BUILD SUCCESS. The tests must still pass after the change.
Step 2.4 Create the version check script
Goal: have the pipeline fail when the tag and the files disagree.
Do
Go back to the Python repository and create the script:
cd ~/work/python-ci-demo
mkdir -p scripts
#!/usr/bin/env bash
set -euo pipefail
TAG="${1:?usage: check_version.sh vX.Y.Z}"
VERSION="${TAG#v}"
PY_VERSION=$(python3 -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])")
NPM_VERSION=$(node -p "require('./npm/package.json').version")
[ "$PY_VERSION" = "$VERSION" ] || { echo "FAIL pyproject.toml is $PY_VERSION, tag is $VERSION"; exit 1; }
[ "$NPM_VERSION" = "$VERSION" ] || { echo "FAIL npm/package.json is $NPM_VERSION, tag is $VERSION"; exit 1; }
echo "OK: all versions match $VERSION"
chmod +x scripts/check_version.sh
Explanation
set -euo pipefailstops the script at the first error. It also treats an unset variable as an error, and it fails a pipeline if any command in it fails.${1:?usage...}prints the usage message and exits if no tag is given.${TAG#v}removes the leadingv, sov1.1.0becomes1.1.0.- Each
[ ... ] || { ...; exit 1; }line exits with status 1 on a mismatch. CI treats that exit status as a failed step.
Step 2.5 Run the script in both directions
Goal: see the script pass for the right tag and fail for a wrong one.
Do
(a) Correct tag:
./scripts/check_version.sh v1.1.0
echo "exit code: $?"
Expected output: OK: all versions match 1.1.0 and exit code: 0.
(b) Wrong tag:
./scripts/check_version.sh v1.1.1
echo "exit code: $?"
Expected output: FAIL pyproject.toml is 1.1.0, tag is 1.1.1 and exit code: 1.
Explanation
- The failing run is the one that protects you. Without the script, a tag of 1.1.1 could publish artifacts that still say 1.1.0.
- The release workflow in Part 5 runs this same script, so the rule is enforced in CI as well as on your machine.
Step 2.6 Commit the changes and tag the release
Goal: commit the versions and create an annotated tag. The tag starts the release workflow in Part 5.
Do
Python repository:
cd ~/work/python-ci-demo
git add pyproject.toml npm scripts
git commit -m "Release prep: 1.1.0 packaging, npm package, version check"
git push origin main
git tag -a v1.1.0 -m "Release 1.1.0"
git push origin v1.1.0
Java repository:
cd ~/work/java-ci-demo
git add pom.xml
git commit -m "Release prep: version 1.1.0"
git push origin main
git tag -a v1.1.0 -m "Release 1.1.0"
git push origin v1.1.0
Explanation
- Annotated tags (
-a) store the tagger, date, and message. Release tooling expects them. Lightweight tags store only a name. - Push the commit before the tag. A tag pointing at a commit that is not on the remote branch is confusing for others.
Verify
git tag --list "v1*"
git show v1.1.0 --stat | head -5
v1.1.0. Pushing the tag will start the release workflows, which you add in Part 5. If Actions shows a run before you have added the workflow files, that is expected. Use the re-tag steps in Step 5.3 to run it again after adding them.Part 3: Build the artifacts locally 10 min
Step 3.1 Build the Python wheel and sdist
Goal: produce the two Python artifacts in dist/.
Do
cd ~/work/python-ci-demo
source .venv/bin/activate # Windows PowerShell: .venv\Scripts\Activate.ps1
pip install --upgrade build
rm -rf dist
python -m build
ls -l dist/
Expected output
The build ends with a line like this:
Successfully built calc_ci_demo-1.1.0.tar.gz and calc_ci_demo-1.1.0-py3-none-any.whl
ls shows both files in dist/.
Explanation
python -m buildcreates an isolated environment, installs the backend named inpyproject.toml, and writes both artifacts.- Wheel (
.whl): a ready-to-install package. sdist (.tar.gz): the source, which can be rebuilt on any machine. py3-none-anymeans pure Python for any Python 3 on any platform. The underscore incalc_ci_demois normal. Packaging converts hyphens in file names.rm -rf distremoves old files, sodist/contains only what this build produced.
Step 3.2 Build the npm tarball
Goal: pack the JavaScript package into dist/.
Do
cd ~/work/python-ci-demo/npm
npm pack --pack-destination ../dist
cd ..
ls -l dist/
Expected output
npm pack prints a list of the files it included, then the tarball name, calc-ci-demo-js-1.1.0.tgz. The file also appears in dist/.
Explanation
npm packapplies thefilesallow-list from Step 2.2 and writes the tarball.--pack-destination ../distplaces the file next to the Python artifacts, so the whole release sits in one folder.
Step 3.3 Build and install the Java jar
Goal: build the jar, then install it into your local Maven repository, where other projects can find it.
Do
cd ~/work/java-ci-demo
mvn -B clean verify
mvn -B -DskipTests package
ls -l target/calc-ci-demo-1.1.0.jar
mvn -B -DskipTests install
ls ~/.m2/repository/com/example/calc-ci-demo/1.1.0/
Expected output
mvn -B clean verifyruns the tests and the JaCoCo gate and ends withBUILD SUCCESS.target/calc-ci-demo-1.1.0.jarexists.- The final
lslistscalc-ci-demo-1.1.0.jar,calc-ci-demo-1.1.0.pom, and a.moduleor checksum file depending on your Maven version.
Explanation
verifybeforepackage. Never publish an artifact that skipped its tests. Thecleangoal removes old build output so the jar matches the current code.installcopies the artifact into~/.m2/repository. The path follows Maven coordinates: groupcom.examplebecomes folderscom/example, then artifactcalc-ci-demo, then version1.1.0.
Step 3.4 Run the artifacts
Goal: confirm each artifact runs before you publish it.
Do
# Java jar
cd ~/work/java-ci-demo
java -jar target/calc-ci-demo-1.1.0.jar
# Python wheel, installed into a throwaway environment
python3 -m venv /tmp/check-wheel
/tmp/check-wheel/bin/pip install ~/work/python-ci-demo/dist/calc_ci_demo-1.1.0-py3-none-any.whl
/tmp/check-wheel/bin/python -c "from app.calculator import add, divide; print(add(2, 3), divide(10, 4))"
# npm tarball, installed into a throwaway folder
mkdir -p /tmp/check-npm && cd /tmp/check-npm
npm init -y >/dev/null
npm install ~/work/python-ci-demo/dist/calc-ci-demo-js-1.1.0.tgz
node -p "require('calc-ci-demo-js').add(2, 3)"
cd ~/work/python-ci-demo
Expected output
- Java prints
2 + 3 = 5and10 / 4 = 2.5. - The Python check prints
5 2.5. - The npm check prints
5.
Explanation
- Installing the artifact into a clean environment tests what a consumer receives, not what your working folder happens to contain. A file can work in the repository and still miss a package in its build configuration.
- The
/tmpfolders are throwaway. Part 8 removes them.
Part 4: Checksums and inspection 6 min
Step 4.1 Create the checksum file
Goal: record a SHA-256 hash for every Python and npm artifact.
Do
cd ~/work/python-ci-demo/dist
sha256sum calc_ci_demo-1.1.0-py3-none-any.whl calc_ci_demo-1.1.0.tar.gz calc-ci-demo-js-1.1.0.tgz > SHA256SUMS.txt
cat SHA256SUMS.txt
sha256sum -c SHA256SUMS.txt
cd ~/work/python-ci-demo
Expected output
The cat shows three lines, each a 64-character hash followed by a file name. The -c check prints OK after each file name.
Explanation
- A SHA-256 hash changes if a single byte changes. Publishing the hash lets anyone confirm that a download matches what the pipeline built.
- The file list is explicit so the checksum file does not hash itself.
- macOS: use
shasum -a 256in place ofsha256sum. Windows PowerShell: useGet-FileHash -Algorithm SHA256 <file>for each file.
Step 4.2 Run a tamper test
Goal: see the checksum catch a changed file.
Do
cp ~/work/python-ci-demo/dist/calc_ci_demo-1.1.0-py3-none-any.whl /tmp/tampered.whl
echo "# changed" >> /tmp/tampered.whl
cd /tmp
sha256sum -c <(grep calc_ci_demo-1.1.0-py3-none-any.whl ~/work/python-ci-demo/dist/SHA256SUMS.txt | sed 's|calc_ci_demo-1.1.0-py3-none-any.whl|/tmp/tampered.whl|')
cd ~/work/python-ci-demo
Expected output
The check prints /tmp/tampered.whl: FAILED and a warning that one computed checksum did not match. The original file still passes the check in Step 4.1.
Explanation
- Appending one line changed the file's hash completely. That is why checksums catch corruption and tampering.
- A checksum only proves the file matches the one that was hashed. Keep the checksum file on the same release page as the artifact, and have consumers check it.
rm -f /tmp/tampered.whl
Step 4.3 Inspect the wheel contents
Goal: confirm the wheel contains the application and nothing else.
Do
unzip -l ~/work/python-ci-demo/dist/calc_ci_demo-1.1.0-py3-none-any.whl
Expected output
- Entries under
app/, includingapp/calculator.py,app/main.py, andapp/converter.py. - A
calc_ci_demo-1.1.0.dist-info/folder withMETADATA,WHEEL, andRECORDfiles. - No entries under
tests/,.venv/,npm/, orscripts/.
Explanation
METADATAholds the name and version that installers read.RECORDlists every file with its hash.- An unexpected entry means the packaging configuration needs a fix. Adjust
[tool.setuptools.packages.find]inpyproject.tomland rebuild.
Step 4.4 Inspect the npm tarball before building it
Do
cd ~/work/python-ci-demo/npm
npm pack --dry-run
cd ~/work/python-ci-demo
Expected output
The list contains package.json, index.js, and README.md. Nothing else appears.
Explanation
--dry-runshows the file list without writing a tarball. Use it when you change thefilesallow-list.
Step 4.5 Inspect the jar
unzip -l ~/work/java-ci-demo/target/calc-ci-demo-1.1.0.jar | head -20
Expected output: com/example/calc/ classes and META-INF/MANIFEST.MF. The manifest should name com.example.calc.App as the main class. You can check it with unzip -p ~/work/java-ci-demo/target/calc-ci-demo-1.1.0.jar META-INF/MANIFEST.MF.
sha256sum -c reports OK for every file, the tamper test fails as expected, and the wheel, tarball, and jar contain only what they should.Part 5: Release pipelines on GitHub 12 min
Step 5.1 Create the Python release workflow
Goal: automate the Python and npm artifacts and attach them to a GitHub Release.
Do
cd ~/work/python-ci-demo
mkdir -p .github/workflows
name: Release
on:
push:
tags: ["v*.*.*"]
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout tagged commit
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt build
- name: Test before publishing
run: pytest
- name: Check versions match tag
run: ./scripts/check_version.sh "$GITHUB_REF_NAME"
- name: Build wheel and sdist
run: python -m build
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Pack npm tarball
run: npm pack --pack-destination ../dist
working-directory: npm
- name: Write checksums
working-directory: dist
run: sha256sum * > SHA256SUMS.txt
- name: Upload distribution
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
release:
needs: build
runs-on: ubuntu-latest
steps:
- name: Download distribution
uses: actions/download-artifact@v4
with:
name: dist
path: dist
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
files: dist/*
generate_release_notes: true
prerelease: ${{ contains(github.ref_name, '-') }}
Explanation: how the workflow is organised
| Block | What it does and why |
|---|---|
on.push.tags: ["v*.*.*"] | Runs only for version tags. Pushing to main never publishes a release. |
permissions: contents: write | Lets the workflow create a release. The token is limited to this workflow. |
| Test before publishing | The tag might point at a commit that never passed CI on main. Re-running the tests ensures the published artifact was verified. |
| Check versions match tag | Runs the script from Part 2. A mismatch stops the workflow before anything is published. |
| Build wheel and sdist | Builds the Python artifacts in a clean machine, from the tagged commit. |
| Pack npm tarball | Runs npm pack in the npm folder and writes the tarball to dist. The working-directory key sets the folder for that step. |
| Write checksums | Creates SHA256SUMS.txt in dist. The file is attached to the release, so downloaders can verify the files. |
upload-artifact and download-artifact | Pass the same files from the build job to the release job. The release contains the exact bytes that were tested. |
softprops/action-gh-release@v2 | Creates the release for the tag and attaches every file in dist. generate_release_notes builds notes from merged pull requests. |
prerelease | Marks a release as pre-release when the tag contains a hyphen, such as v1.2.0-rc.1. This follows the SemVer rule that a hyphen means pre-release. |
Step 5.2 Create the Java release workflow
Goal: build the jar from the tagged commit and attach it to a release in the Java repository.
Do
cd ~/work/java-ci-demo
mkdir -p .github/workflows
name: Java Release
on:
push:
tags: ["v*.*.*"]
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout tagged commit
uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
cache: maven
- name: Check pom.xml version matches tag
run: |
POM_VERSION=$(sed -n 's:.*<version>\(.*\)</version>.*:\1:p' pom.xml | head -1)
[ "$POM_VERSION" = "${GITHUB_REF_NAME#v}" ] || { echo "pom.xml is $POM_VERSION, tag is $GITHUB_REF_NAME"; exit 1; }
- name: Verify
run: mvn -B verify
- name: Package jar
run: mvn -B -DskipTests package
- name: Write checksum
working-directory: target
run: sha256sum calc-ci-demo-*.jar > SHA256SUM-jar.txt
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
files: |
target/calc-ci-demo-*.jar
target/SHA256SUM-jar.txt
generate_release_notes: true
prerelease: ${{ contains(github.ref_name, '-') }}
Explanation
- The
sedcommand pulls the first<version>value frompom.xml. That value is the project version, because it appears before any plugin or dependency version in this file. ${GITHUB_REF_NAME#v}removes the leadingvfrom the tag, just like the Python script.- The Java workflow writes its own checksum file, because it runs in a separate repository and does not share the Python
distfolder.
Step 5.3 Push the tag so both workflows run
Goal: start the release workflows for the tag from Part 2.
Do
The tag from Step 2.6 was pushed before the workflows existed, so it did not start a release. Commit the workflow files first, then move the tag to the new commit.
Python repository:
cd ~/work/python-ci-demo
git add .github/workflows/release.yml
git commit -m "Add release workflow"
git push origin main
git push --delete origin v1.1.0
git tag -d v1.1.0
git tag -a v1.1.0 -m "Release 1.1.0"
git push origin v1.1.0
Java repository:
cd ~/work/java-ci-demo
git add .github/workflows/release.yml pom.xml
git commit -m "Add release workflow"
git push origin main
git push --delete origin v1.1.0
git tag -d v1.1.0
git tag -a v1.1.0 -m "Release 1.1.0"
git push origin v1.1.0
v1.1.0, do not delete or move the tag. Publish 1.1.1 instead, because someone may already depend on the old artifacts.Step 5.4 Watch the runs
Goal: follow each job and read what it did.
Do
- Open the python-ci-demo repository on GitHub and click Actions.
- Click the run named Release for the tag
v1.1.0. - Click the
buildjob. Expand these steps and read the output:- Test before publishing: the pytest summary.
- Check versions match tag:
OK: all versions match 1.1.0. - Build wheel and sdist: the
Successfully builtline.
- Click the
releasejob and confirm it finished green. - Repeat steps 1 to 4 for the java-ci-demo repository and its Java Release run.
Explanation
- The build job and the release job run on separate machines. The files travel between them only as the uploaded artifact, which is why the release contains exactly what was built.
- If the version check fails, the build job stops with
FAILin the log. No release is created. This is the behaviour you want.
Step 5.5 Verify the release page
Goal: confirm the release contains the expected assets.
Do
- Open the python-ci-demo repository and click Releases in the right-hand column.
- Open
v1.1.0. Confirm these assets are listed:calc_ci_demo-1.1.0-py3-none-any.whlcalc_ci_demo-1.1.0.tar.gzcalc-ci-demo-js-1.1.0.tgzSHA256SUMS.txt
- Confirm the release notes list the merged pull requests since the previous tag.
- Open the java-ci-demo release page and confirm
calc-ci-demo-1.1.0.jarandSHA256SUM-jar.txtare listed.
Step 5.6 Download and verify the release
Goal: act as a consumer who downloads the release and checks it.
Do
mkdir -p /tmp/release-check && cd /tmp/release-check
BASE=https://github.com/<your-user>/python-ci-demo/releases/download/v1.1.0
curl -sLO "$BASE/calc_ci_demo-1.1.0-py3-none-any.whl"
curl -sLO "$BASE/calc-ci-demo-js-1.1.0.tgz"
curl -sLO "$BASE/SHA256SUMS.txt"
sha256sum -c SHA256SUMS.txt
cd ~/work/python-ci-demo
Expected output
Each downloaded file prints OK. If any file prints FAILED, the download is corrupt or the release was changed. Do not install it.
Explanation
- The
releases/download/<tag>/<file>URL pattern gives a stable address for each asset of a specific version. - Downloading from the release, not from your working folder, is the same path a team member would take.
Step 5.7 Negative test: a mismatched tag must not publish
Goal: confirm the pipeline refuses a bad release.
Do
Create a deliberate mismatch on a new tag in the Python repository. It will fail, and you will not publish anything you need to keep:
cd ~/work/python-ci-demo
git tag -a v9.9.9 -m "Deliberate mismatch test"
git push origin v9.9.9
Open Actions, open the Release run for v9.9.9, and read the Check versions match tag step.
Expected output
The step fails with FAIL pyproject.toml is 1.1.0, tag is 9.9.9. The release job is skipped, and no release appears for v9.9.9.
Clean up
git push --delete origin v9.9.9
git tag -d v9.9.9
v1.1.0 is a published release with the wheel, tarball, npm package, and checksum file. The mismatched v9.9.9 tag was blocked, and you deleted it.Part 6: Artifacts in Jenkins 6 min
Step 6.1 Install Node.js in the Jenkins container
Goal: give Jenkins the npm command for the npm pack stage.
Do
docker exec -u root jenkins bash -c "apt-get update && apt-get install -y nodejs npm"
docker exec jenkins node --version
docker exec jenkins npm --version
Expected output
Both commands print a version number. Debian's Node may be older than 20, which is fine for npm pack.
Explanation
- Jenkins runs inside its own container, so tools installed on your laptop are not visible to it. Install them in the container.
-u rootruns the install with administrator rights inside the container only.
Step 6.2 Replace the Jenkinsfile
Goal: add a stage that builds, checksums, and archives the artifacts after the tests pass.
Do
cd ~/work/python-ci-demo
git checkout -b ext/jenkins-artifacts
pipeline {
agent any
options {
timestamps()
disableConcurrentBuilds()
buildDiscarder(logRotator(numToKeepStr: '10'))
}
stages {
stage('Checkout') {
steps {
checkout scm
}
}
stage('Lint and Test') {
steps {
sh '''
python3 -m venv .venv
. .venv/bin/activate
pip install -r requirements.txt
flake8 app tests
mkdir -p reports
pytest --junitxml=reports/junit.xml
'''
}
post {
always {
junit testResults: 'reports/junit.xml', allowEmptyResults: true
}
}
}
stage('Build artifacts') {
steps {
sh '''
. .venv/bin/activate
pip install --upgrade build
rm -rf dist
python -m build
cd npm
npm pack --pack-destination ../dist
cd ../dist
sha256sum * > SHA256SUMS.txt
ls -l
'''
}
post {
success {
archiveArtifacts artifacts: 'dist/*', fingerprint: true
}
}
}
}
post {
always {
cleanWs()
}
}
}
Explanation
- Lint and Test runs before the artifact stage, so a failing test never produces a package.
- Build artifacts runs the same commands as Part 3, in the same order, inside the Jenkins workspace.
post { success { ... } }archives files only when the stage succeeded. A failed build leaves nothing behind for others to pick up.fingerprint: truerecords a hash of each file. Jenkins can then show which build produced each artifact, and which later builds used it.cleanWs()clears the workspace after the build. The artifacts are already copied into Jenkins' storage by then, so the cleanup does not remove them.
Step 6.3 Commit and run the build
Do
git add Jenkinsfile
git commit -m "Jenkins: build and archive artifacts"
git push -u origin ext/jenkins-artifacts
Merge the branch into main on GitHub, or change the Jenkins job's branch specifier to */ext/jenkins-artifacts for this run. Then, in Jenkins:
- Open the
python-ci-demojob and click Build Now. - Open the build and read the Build artifacts stage in the console output. The
ls -lat the end shows the wheel, sdist, tarball, and checksum file. - Click Build Artifacts on the build page and confirm the four files are listed.
Step 6.4 Follow the fingerprint
Goal: understand what the fingerprint records.
Do
- On the build page, open the fingerprint link next to
calc_ci_demo-1.1.0-py3-none-any.whl. - Read the hash and the build that produced it.
Explanation
- When another Jenkins job copies this file and uses it, the fingerprint page lists that job as a user. You can then see where any artifact came from and where it went.
Step 6.5 Decide the version for internal builds
Goal: choose a version for builds that are not releases.
Jenkins builds from main do not create tags, so they need their own version. The usual convention is a pre-release with the build number:
| Build type | Example version | Why |
|---|---|---|
| Release | 1.1.0 | Matches the tag. Only the release pipeline produces it. |
Snapshot from main | 1.2.0-dev.42 | Sorts below 1.2.0, so it is never mistaken for a final release. 42 is the build number. |
| Traceability only | 1.1.0+build.42 | Records the build. Precedence ignores it, so do not use it to select dependencies. |
1.1.0 is published, a different build must become 1.1.1.Part 7: Consuming and pinning 8 min
Step 7.1 Read the version range syntax
| Ecosystem | Expression | Accepts | Rejects |
|---|---|---|---|
| npm | ^1.1.0 | 1.1.0 up to 1.x | 2.0.0 |
| npm | ~1.1.0 | 1.1.0 up to 1.1.x | 1.2.0 |
| npm | 1.1.0 | Only 1.1.0 | Everything else |
| pip | calc-ci-demo>=1.1,<2 | 1.1 up to 1.x | 2.0 |
| pip | calc-ci-demo==1.1.0 | Only 1.1.0 | Everything else |
| Maven | [1.1.0,2.0.0) | 1.1.0 up to, not including, 2.0.0 | 2.0.0 and above |
Step 7.2 Install the wheel from the release
Goal: install version 1.1.0 into a new project using the release URL.
Do
python3 -m venv /tmp/consumer-py
/tmp/consumer-py/bin/pip install "https://github.com/<your-user>/python-ci-demo/releases/download/v1.1.0/calc_ci_demo-1.1.0-py3-none-any.whl"
/tmp/consumer-py/bin/python -c "from app.calculator import add; print(add(2, 3))"
Expected output
pip reports that it installed calc-ci-demo-1.1.0, and the Python command prints 5.
Explanation
- A URL that names the exact version is a pin. The consumer gets the same file every time.
- The package imports as
app. Its name is generic, so do not install two packages that both provide anapppackage into the same environment.
Step 7.3 Install the npm tarball from the release
Do
mkdir -p /tmp/consumer-js && cd /tmp/consumer-js
npm init -y >/dev/null
npm install "https://github.com/<your-user>/python-ci-demo/releases/download/v1.1.0/calc-ci-demo-js-1.1.0.tgz"
node -p "require('calc-ci-demo-js').add(2, 3)"
cd ~/work/python-ci-demo
Expected output
The Node command prints 5. The install adds calc-ci-demo-js under dependencies in the consumer's package.json, with the tarball URL as its version.
Explanation
- The package is imported by the name in its
package.json,calc-ci-demo-js, not by the file name. - npm records the URL in the consumer's
package-lock.json, so the pin is committed with the consumer project.
Step 7.4 Install the jar and resolve it with Maven
Goal: make the jar available to Maven and confirm a project can resolve it by coordinates.
Do
cd ~/work/java-ci-demo
mvn -B install:install-file \
-Dfile=target/calc-ci-demo-1.1.0.jar \
-DgroupId=com.example -DartifactId=calc-ci-demo \
-Dversion=1.1.0 -Dpackaging=jar
mvn -B dependency:get -Dartifact=com.example:calc-ci-demo:1.1.0 -Dtransitive=false
Expected output
install:install-fileends withBUILD SUCCESS.dependency:getends withBUILD SUCCESSand reports the artifact as resolved from the local repository.
Explanation
install:install-filecopies a jar into the local Maven repository. Use it when a file did not come from a Maven build, or to test a consumer before you publish.dependency:getchecks that the coordinatescom.example:calc-ci-demo:1.1.0resolve. A consumer'spom.xmlwould reference the same coordinates in a<dependency>block.- For a team, publish to a shared repository with
mvn deployinstead. Services such as GitHub Packages or Azure Artifacts store the artifact under the same coordinates.
Step 7.5 Pin a dependency in a consumer's requirements file
Goal: record the chosen version in the consumer project so every build uses it.
Do
calc-ci-demo @ https://github.com/<your-user>/python-ci-demo/releases/download/v1.1.0/calc_ci_demo-1.1.0-py3-none-any.whl
Explanation
- The
name @ URLform is the direct-reference syntax for pip. The version appears in the URL, so the pin is visible in the file. - Applications pin exactly, as above or with
==. Libraries declare ranges, such as^1.1.0in npm, so they can coexist with other packages. - To move to version 1.2.0 later, change the URL in one line and commit it. The change shows up in review.
Part 8: Recap, quiz, cleanup, and troubleshooting 3 min
8.1 Key takeaways
- Build once, test that build, publish that build. The release job uses the uploaded files, not a second build.
- The version is a promise. MAJOR warns of breaking changes, MINOR adds features, PATCH fixes bugs.
- Versions and tags are immutable. Publish a new version instead of changing a published one.
- Check what you ship. Use checksums, inspect contents, and keep allow-lists such as npm's
filestight. - Applications pin, libraries range.
8.2 Quiz
- A release adds one optional function and fixes a bug. Which number changes?
- Why does the release workflow run the tests again when CI already passed on
main? - What does
sha256sum -cprove to someone who downloads the release? - A library depends on
^2.3.0. Does a consumer receive version 3.0.0? Why or why not? - Why should Jenkins builds from
mainnot use the plain version1.2.0?
Answers
- MINOR. A new backward-compatible feature bumps MINOR, and PATCH resets to 0. The bug fix rides along in the same release.
- The tag might point at a commit that was never tested on
main, so the release tests what it publishes. - That the downloaded file is byte-for-byte the file the pipeline produced. A corrupted or altered file fails the check.
- No.
^2.3.0accepts 2.3.0 up to, but not including, 3.0.0. A major release is a breaking change, so it is excluded. - A snapshot must sort below the release it leads to.
1.2.0-dev.42sorts below1.2.0. The plain version would look like a final release that does not exist yet.
8.3 Cleanup
rm -rf /tmp/consumer-py /tmp/consumer-js /tmp/check-wheel /tmp/check-npm /tmp/release-check
cd ~/work/python-ci-demo && git checkout main
cd ~/work/java-ci-demo && git checkout main
8.4 Troubleshooting
| Symptom | Cause and fix |
|---|---|
| Tag pushed but no release workflow started | The tag must match v*.*.*, such as v1.1.0. A tag named 1.1.0 does not match. Also confirm the workflow file was committed before the tag was pushed; if not, re-tag as in Step 5.3. |
FAIL pyproject.toml is 1.0.0, tag is 1.1.0 | The version was not updated before tagging. Update the file, commit, delete the tag, and tag again. |
FAIL npm/package.json ... | The npm version differs from the tag. Update npm/package.json and commit. |
Permission denied when running the script | Run chmod +x scripts/check_version.sh and commit the change, so the executable bit is stored in Git. |
Wheel file is named calc_ci_demo, not calc-ci-demo | Normal. Packaging converts hyphens in file names. The project name in pyproject.toml is unchanged. |
npm pack includes unexpected files | Check the files list in npm/package.json. Run npm pack --dry-run to see the list before building. |
python -m build cannot download setuptools | The build fetches its backend into an isolated environment, so it needs network access. Check your connection or proxy. |
sha256sum: ... No such file or directory in the checksum step | The file list is wrong, or dist/ is empty. Run ls dist/ and check the name matches the wheel built in Step 3.1. |
Maven install:install-file fails with a missing file | Build the jar first with mvn -B -DskipTests package, and check the path in -Dfile. |
Jenkins: npm: not found | Install Node.js in the container, as in Step 6.1. |
Jenkins: No such DSL method 'archiveArtifacts' | The file has a syntax error above the stage. Check the braces, and that the stage is inside stages { }. |
| Release already exists for the tag | Releases are immutable by convention. Publish a new patch version instead of reusing the tag. |