Continuous integration and releases

This page describes how changes reach a released version of pysnmp-pyasn1. The same model is used by pysmi and pysnmp, so what follows applies to all three projects except where a difference is called out.

Branches

There are exactly two long-lived branches.

next

Where work integrates. Every pull request targets next. It is normal and expected for next to carry commits that main does not.

main

What has been released. It moves only when next is promoted into it.

Maintenance branches matching N.x or N.M.x are configured but none exist yet.

Do not open a pull request against main. A fix merged there would be released without ever having existed as a release candidate, and next would not contain it.

Neither branch releases on push. Both the release candidate and the GA are cut by a human dispatching the workflow; see Cutting a release.

Commit messages

Releases are cut by semantic-release from the commit history, so the subject line of every commit decides whether a release happens and how the version changes.

The Conventional Commits conventionalcommits preset is in use:

Subject

Version change

Appears in release notes

fix: ...

patch

Bug Fixes

feat: ...

minor

Features

perf: ...

patch

Performance Improvements

feat!: ...

major

Features, and BREAKING CHANGES

BREAKING CHANGE: footer

major

BREAKING CHANGES

docs:, test:, ci:, chore:, refactor:, style:

none

hidden

The ! marker and the BREAKING CHANGE: footer are equivalent. Note that the angular preset, which is semantic-release’s default, does not parse ! at all — a feat!: commit under that preset silently produces a minor release rather than a major one. All three projects set conventionalcommits explicitly for this reason.

Reference an issue in the body (Closes #123) rather than in the subject, so the generated notes link it.

The format is checked rather than assumed. commitlint reads commitlint.config.mjs at the repository root from two places: the commit-msg hook installed by pre-commit install checks a message as it is written, and the Commit conventions workflow checks every commit in a pull request. Run pre-commit install once per checkout — a checkout made before the hook was added has to run it again, because installing the commit-msg hook type is what makes the check run at all.

Merge commits, fixup! commits and the subjects git writes for a revert are ignored. The pull request title is not checked: pull requests here are merged rather than squashed, so the title never enters the history. What the config changes relative to @commitlint/config-conventional, and how to lint a range by hand, is in .github/semantic-release.md.

What runs on a pull request

The CI workflow (.github/workflows/build-test-release.yml) runs on every push to main and next and on every pull request against them.

pre-commit

The repository’s pre-commit hooks, on every file.

lint

ruff check, ruff format --check and mypy. ty also runs but is advisory: it is pre-1.0 and has no equivalent of disallow_untyped_defs, so mypy remains the blocking gate.

docs

sphinx-build -W --keep-going. Warnings are errors, and --keep-going reports all of them rather than stopping at the first.

build

uv build, uploading the wheel and sdist as an artifact.

matrix

Computes the test matrix. See below.

test-unit

The test suite, once per matrix entry, publishing JUnit results and coverage.

Build Release

Runs semantic-release. On a pull request this job does not run at all; on a push it rehearses the release without cutting one.

Two checks run outside that workflow. Commit conventions (.github/workflows/commit-conventions.yml) lints the commit messages of a pull request and runs on pull requests only, so it is not part of the release path. CodSpeed (.github/workflows/codspeed.yml) measures the benchmarks; see Benchmarks. Like the CI jobs, both gate a merge only where branch protection names them as a required check.

Benchmarks

The benchmarks directory holds a pytest-codspeed suite covering the BER, CER, DER and native codecs and the type machinery underneath them. The CodSpeed workflow runs it on every push to main and next and on every pull request against them, and CodSpeed comments the difference against the base branch.

Measurements are taken under CPU simulation rather than by timing a wall clock, so what is reported is work done — instructions, cache behaviour — and not how loaded the runner happened to be. That is what makes a two percent change on a shared CI machine meaningful.

The suite is deliberately outside tests: tests/conftest.py runs every test it collects with pyasn1 debugging on and every debug record rendered, which is the opposite of what a measurement wants. It is also outside testpaths, so a plain pytest does not collect it. Run it by naming the directory:

uv run pytest benchmarks              # check the benchmarks still work
uv run pytest benchmarks --codspeed   # measure them

Adding a benchmark is adding a test_* function that takes the benchmark fixture and hands it the callable to measure. Build the input outside the measured call, and keep the measured call to one unit of work. Operations too small to measure on their own – a tag set comparison is a few nanoseconds – are looped in benchmarks/test_types.py through its repeat helper, so the loop, and not the harness, is what is timed.

Where the library has been

CodSpeed answers one question: is this pull request slower than the commit it would merge into. That is the right question for a pull request, and it says nothing about the releases behind it. Its history begins with the commit that added these benchmarks, and nothing can extend it backwards, because the benchmarks did not exist at the earlier commits to be run there.

So it was measured once, separately. Every release from 0.4.12 to 2.0.6 was installed from PyPI in turn and driven through the equivalent of four of the benchmarks below, counting instructions retired under callgrind on CPython 3.11 — the same quantity CodSpeed’s simulation mode reports. Each version was checked to encode the shared schemas to byte-identical octets first, so the columns describe the same work rather than different work at different speeds.

Instructions retired per benchmark call, thousands

case

0.4.12

1.3.0

2.0.3

2.0.6

0.4.12 → 2.0.6

decode_message_with_spec

9,912

9,837

9,917

8,633

−12.9%

encode_message

3,198

5,155

5,238

3,913

+22.4%

clone

21,730

21,723

21,717

17,712

−18.5%

tag_set_is_super_tag_set_of

11,011

9,656

9,696

9,714

−11.8%

Two things in that table are worth keeping, because neither was visible before there was anything to measure them with.

encode_message costs 22% more at 2.0.6 than it did on 0.4.12. The step is between 1.0.3 and 1.3.0, where it went from 3.2M instructions to 5.2M and stayed there for six releases; 2.0.4 and 2.0.6 recovered about two thirds of the excess, leaving the rest. Nothing here says the 1.3.0 change was wrong — it may well have bought correctness worth the cost — only that the cost was paid and nobody counted it at the time.

clone drops 18% at 2.0.6, which is the initializer-dict work in that release doing exactly what it was meant to do. That is the same measurement read the other way round: a table like this is how an optimisation gets to show that it worked, rather than only that it was intended.

This was a one-off, and the numbers above are a snapshot rather than something the repository can regenerate. Going forward CodSpeed covers it, which is the point of having it.

The test matrix

Every supported Python runs on Linux on every pull request. macOS and Windows run only on the edge Python versions, and only when the run can justify the cost: a push to main or next, a manual dispatch, or a pull request carrying the ci:full-matrix label.

Trigger

Linux

macOS and Windows

Pull request

3.10 – 3.14

—

Pull request labelled ci:full-matrix

3.10 – 3.14

3.10 and 3.14

Push to main or next

3.10 – 3.14

3.10 and 3.14

That is five jobs on an ordinary pull request and nine on a broad run.

The reasoning is worth stating, because it is the opposite of the usual “test everything everywhere” instinct. These are pure-Python projects, so the interpreter version is where the risk lives and the operating system mostly is not. Of the platforms, Windows is the one that can actually diverge — cp1252 as the default encoding, path handling — and until recently it was not tested at all. macOS differs from Linux in nothing these libraries touch.

There was also a concrete failure mode. Running five macOS jobs per pull request sat exactly on GitHub’s free-tier limit of five concurrent macOS jobs for public repositories, so two overlapping runs queued behind each other until they timed out. Two macOS jobs leave headroom.

Windows is currently continue-on-error: it reports its result without blocking a merge, until it has a track record. Remove that once it has one.

To get the broad matrix on a pull request, add the ci:full-matrix label. The workflow listens for the labeled event, so the run starts when the label is applied — no need to push again.

Cutting a release

Nothing releases on push. Both kinds of release are cut by dispatching the CI workflow from the Actions tab and choosing the branch:

next

Cuts a release candidate, X.Y.Z-rc.N.

main

Cuts the GA release, X.Y.Z.

semantic-release works out the version from the commits, writes it into pyproject.toml, pyasn1/__init__.py and CHANGELOG.md, commits that as chore(release): X.Y.Z, tags it, and publishes a GitHub release. The tag is what reaches PyPI.

To ship a GA: promote next into main with a pull request, wait for it to be green, then dispatch the workflow against main.

Every push — to next as much as to main — is a dry run instead. semantic-release computes the version it would cut and tags nothing. Because its prepare step never runs, nothing is built either, so the workflow builds that version as X.Y.Z.devN and keeps it as an artifact for 14 days. Install that artifact and exercise it before dispatching the real release.

Releasing by hand rather than on every merge is deliberate. When next released automatically, it kept cutting release candidates from whatever base its own history reached, which drifts below main as soon as a GA is cut there — see Keeping next and main in step.

Keeping next and main in step

Promoting next into main and then releasing leaves the chore(release): commit, and the tag on it, on main only. next does not contain them, so as far as semantic-release can see from next, the newest release is still the last one that branch cut.

Left alone this drifts. In pysmi it produced 2.0.0-rc.15 and 2.0.0-rc.16 on PyPI after 2.0.1 had shipped: release candidates numbered below a released version, uploaded after it.

So after cutting a GA on main, merge main back into next. This is the only direction in which main should ever be merged into next, and it carries nothing but the release commit.

Commits on next that are not on main are the normal state of things and need no action: that is unreleased work waiting for the next promotion.

Reaching PyPI

Publishing is a separate workflow (publish-to-pypi.yml) triggered by the tag that semantic-release pushes. Keeping it separate means a failed upload can be re-run without re-running the release, and the tag is the only thing that can cause an upload.

It runs in three stages: verify that the tag matches the version recorded in pyproject.toml, build the distributions, then upload them.

The upload uses PyPI Trusted Publishing. No API token exists anywhere in the repository. PyPI matches four claims from the run’s OIDC token — owner, repository, workflow filename, and environment name — and mints a credential valid for that run alone. Under Trusted Publishing the upload also carries PEP 740 attestations by default.

The trusted publisher is registered on the PyPI project’s Publishing settings page and must name the workflow file exactly:

Claim

Value

Owner

pysnmp

Repository

pyasn1

Workflow name

publish-to-pypi.yml

Environment name

release

Renaming the workflow file or the environment breaks publishing until the publisher is re-registered.

Documentation publishing

Every release tag publishes its own documentation. docs-publish.yml builds the docs for the tag and writes them to a directory named for the version on the gh-pages branch, then rebuilds the aliases from whatever directories are present:

stable

The newest final release.

latest

The newest release of any kind, including release candidates.

Because the aliases, the root redirect and versions.json are rebuilt from the directory listing on every run, re-running the workflow repairs the site rather than adding to a broken one.

Troubleshooting

A run was cancelled and no step failed. Look at whether the jobs were killed part-way through. Runner processes that die mid-suite, with no assertion failure in the log, are usually out of memory rather than cancelled. A run whose overall conclusion is cancelled while individual jobs report failure with exit code 143 was terminated from outside.

A release did not happen after merging to next. Merging does not release; dispatch the workflow. If a dispatch also released nothing, check the commit subjects — only fix, feat, perf and breaking changes produce a release, so a branch of nothing but docs and chore commits correctly releases nothing. The Build Release job log states which commits it analysed and what it concluded.

PyPI rejected the upload with 403. The trusted publisher does not match. Confirm all four fields on the PyPI publishing settings page against the table above; the workflow filename is the one most often wrong.