Skip to content

Contributing and Releasing

ptweezy edited this page Jul 3, 2026 · 3 revisions

Contributing and Releasing

This page covers the yacron2 developer workflow (environment, tests, linters, type checks, pre-commit) and the fully automated GitHub Actions release pipeline that builds, publishes, tags, and containerizes each version. Version numbers are derived from git tags via setuptools_scm and are never hand-edited.

Development environment

yacron2 targets Python 3.10+; 3.10, 3.11, 3.12, 3.13 and 3.14 are the tested interpreters (pyproject.toml requires-python = ">=3.10", classifiers for 3.10 through 3.14).

yacron2 runs natively on Windows, Linux, and macOS (WSL is not required). All OS-specific behavior is isolated in yacron2/platform.py (grp/pwd are guarded there, not imported unconditionally at load time on Windows), so the package and its full test suite run natively on every supported OS, and pip install yacron2 works on Windows. See Running on Windows for the platform-specific details.

Linting and type checking do not import the package and run on any platform. mypy is pinned to the linux platform (pyproject.toml [tool.mypy] platform = "linux"), so type-checking is identical on every OS: it type-checks the POSIX API surface, and the Windows branches are runtime-guarded.

Clone and install the editable package with the dev extra:

git clone https://github.com/ptweezy/yacron2
cd yacron2
python -m venv .venv && . .venv/bin/activate   # Windows: .venv\Scripts\activate
pip install -e ".[dev]"                         # or: pip install -r requirements_dev.txt

The editable dev install (pip install -e ".[dev]") and the checks (pytest, ruff, mypy) all run natively on Windows too. Use .venv\Scripts\activate to enter the venv as shown above.

The dev optional-dependency group (pyproject.toml) and the equivalent requirements_dev.txt both pull in: mypy, mypy-extensions, pytest, pytest-asyncio, pytest-cov, ruff, and tox. The console entry point yacron2 = yacron2.__main__:main is installed by the editable install (see Command-Line Reference).

Running the checks

All CI checks are driven by tox (tox.ini). The default envlist is py310, py311, py312, py313, py314, lint, mypy.

tox            # all envs: py310-py314, lint, mypy
tox -e lint    # ruff check + ruff format --check
tox -e mypy    # mypy
tox -e py      # pytest on the current interpreter
Env Installs package What it runs
py313, py314 yes (-rrequirements_dev.txt, PYTHONPATH={toxinidir}) pytest --color=yes -vv
lint no (skip_install = true) ruff check yacron2 then ruff format --check yacron2
mypy no (skip_install = true, basepython=python3) mypy -p yacron2 --ignore-missing-imports

The lint and mypy envs deliberately skip installing the package: ruff and mypy analyze the source tree directly, so they avoid imposing the project's requires-python on the lint/type-check interpreter.

Tool configuration

pyproject.toml configures the tooling:

  • ruff: target-version = "py313", line-length = 79. Lint rule sets selected: B, B9 (bugbear), C (mccabe complexity), E (pycodestyle errors), F (pyflakes), W (pycodestyle warnings), I (import sorting). pyupgrade (UP) is present but commented out. max-complexity = 20.
  • mypy: no_implicit_optional = true, warn_no_return = true, warn_return_any = true, strict_optional = true.
  • pytest: asyncio_mode = "auto", testpaths = ["tests"].
  • bandit: exclude_dirs = ["tests"].

pre-commit

pre-commit runs ruff and bandit on staged changes (.pre-commit-config.yaml):

pip install pre-commit
pre-commit install

Configured hooks:

Repo Rev Hook(s) Args
PyCQA/bandit 1.9.4 bandit -c pyproject.toml --severity-level=medium, with bandit[toml]
astral-sh/ruff-pre-commit v0.15.18 ruff (lint), ruff-format ruff runs with --fix

Note pre-commit's ruff runs with --fix (auto-applies fixes), whereas tox -e lint runs ruff check (no fix) plus ruff format --check (verify only). pre-commit is not pinned in the dev extra; install it separately as shown.

CI for every commit

.github/workflows/tox.yml runs on every push and pull_request (read-only permissions). It has three jobs: tox-lint (tox -e lint) and tox-mypy (tox -e mypy) on ubuntu-latest, plus a tox matrix running tox -e py (fail-fast: false). The matrix runs the full test suite on both Linux and Windows: os is [ubuntu-latest, windows-latest] across Python 3.10, 3.11, 3.12, 3.13, and 3.14, with an experimental ubuntu-latest/3.15 row (continue-on-error, never gates) and an extra windows-11-arm row pinned to Python 3.14 to exercise Windows ARM64 (the released .exe targets it too). macOS (macos-latest) is still optionally commented out, since macOS is POSIX like Linux.

A second per-commit gate is .github/workflows/docker.yml, which builds the container image on every commit on every branch (push: branches: ["**"]) as linux/amd64 build-only (no push, tagged ci-build), so a broken Dockerfile fails CI before a release. See Production and Container Deployment.

Releasing

Releases are fully automated by .github/workflows/release.yml. You never edit a version by hand; setuptools_scm derives the version from git tags (version_file = "yacron2/version.py").

Triggering a release

A release runs when either:

  1. A push to main in which any commit introduced by the push has a release marker anywhere in its message, not just the tip commit. The scanned range is BEFORE..AFTER (the commits new in the push); on a brand-new branch where BEFORE is all-zeros (or unresolvable) it falls back to the tip commit only.
  2. A manual workflow_dispatch run, choosing the bump level from a dropdown (minor default, or major / patch).

Valid markers (case-insensitive; the bump level is optional):

Marker Bump 1.0.5 →
[release] minor 1.1.0
[release:major] major 2.0.0
[release:minor] minor 1.1.0
[release:patch] patch 1.0.6

If several markers appear across the pushed commits, the most significant bump wins (major > minor > patch); a bare [release] counts as minor.

The marker match is performed in the decide job with grep -oiE '\[release(:(major|minor|patch))?\]' over the commit message bodies.

Footgun: literal-marker substring match. The match is a plain substring against commit message text (not anchored to its own line, not requiring any surrounding structure). Writing a literal [release:patch] (or [release]) anywhere in a commit message (even in prose describing the release process) will trigger a publish. Do not quote a marker verbatim in a commit message unless you mean it. Only commit messages are scanned; file contents are never scanned (this page can name the markers freely).

What the pipeline does

The release.yml jobs run in dependency order. Top-level permissions default to contents: read; only the release job (contents: write + id-token: write) and the docker job (packages: write) opt up to the write scopes they need.

  1. decide: determines release (true/false) and bump. Trigger logic lives in a real shell script rather than a fuzzy contains() expression. All downstream jobs are gated on needs.decide.outputs.release == 'true'.
  2. version: computes the next version once, so every builder and the publish job build at the same number. Finds the latest tag matching ^[0-9]+\.[0-9]+\.[0-9]+$ (via git tag -l | … | sort -V | tail -n1, defaulting to 0.0.0), applies the bump, and refuses with an error if the computed tag already exists (refs/tags/$new).
  3. gate: checks out full history and runs tox across Python 3.10–3.14 (tox with no -e runs py310, py311, py312, py313, py314, lint, mypy). A red build means no release.
  4. Native binary builds (run before publishing, so a broken build fails the run instead of producing a half-finished release). Each job pins pyinstaller==6.21.0, runs pip install . to bake SETUPTOOLS_SCM_PRETEND_VERSION (the computed version) into yacron2/version.py, runs pyinstaller pyinstaller/yacron2.spec, and smoke-tests the bundle with dist/yacron2 --version:
    • binaries: Linux glibc, 64-bit, amd64 on ubuntu-24.04 and arm64 on ubuntu-24.04-arm (native runners, no QEMU). Built on Python 3.14. Artifacts yacron2-linux-amd64, yacron2-linux-arm64.
    • binaries-glibc-extra: Linux glibc for the arches with no native GitHub runner (i686, armv7, ppc64le, s390x, riscv64), built inside a python:3.14-slim (Debian) container via docker run --platform (the native binaries job covers only amd64/arm64 and PyInstaller is not a cross-compiler, so these need a foreign-arch container). i686 (linux/386) runs natively on the ubuntu-24.04 runner; armv7 (linux/arm/v7), ppc64le (linux/ppc64le), s390x (linux/s390x) and riscv64 (linux/riscv64) run under QEMU (docker/setup-qemu-action). Installs build-essential libffi-dev zlib1g-dev (the spec sets strip=True; ppc64le/s390x have full manylinux wheels, while the i686 aiohttp stack, propcache-on-armv7, and multidict/frozenlist/ruamel.yaml.clib on riscv64 compile from sdist). Artifacts yacron2-linux-{i686,armv7,ppc64le,s390x,riscv64} (no -musl suffix; they sit beside the 64-bit glibc binaries).
    • binaries-musl: Linux musl/Alpine, amd64, arm64, i686, armv7, ppc64le, s390x, riscv64 and armv6, built inside a python:3.14-alpine container via docker run (so checkout/upload stay on the glibc host). amd64/arm64 use their native runners; i686 (linux/386) runs natively on the ubuntu-24.04 runner and armv7/ppc64le/s390x/riscv64/armv6 under QEMU. armv6 is musl-only (the Debian/glibc image ships no arm32v6, so it has no binaries-glibc-extra counterpart). Installs build-base libffi-dev zlib-dev (the spec sets strip=True, and headers cover any dep that compiles from sdist, notably the i686 aiohttp stack, which ships no musllinux_i686 wheels, and the whole C-ext stack on armv6). Artifacts yacron2-linux-{amd64,arm64,i686,armv7,ppc64le,s390x,riscv64,armv6}-musl.
    • binaries-macos: macOS, arm64 on macos-15 (Apple Silicon) and amd64 on macos-15-intel. Built on Python 3.14. After the smoke test it asserts the native arch with file (so Rosetta cannot let a mislabelled x86_64 build pass on the arm64 runner). Artifacts yacron2-macos-arm64, yacron2-macos-amd64.
    • binaries-windows: Windows, amd64 on windows-latest and arm64 on the windows-11-arm runner (both native; PyInstaller is not a cross-compiler). Built on Python 3.14 with the same pyinstaller==6.21.0 pin and dist/yacron2.exe --version smoke test as the other jobs; any C-extension dep lacking a win_arm64 wheel compiles from sdist via the runner's Visual Studio ARM64 toolchain. There is no Windows code-signing step (the binaries ship unsigned, like the Linux binaries). Artifacts yacron2-windows-amd64.exe, yacron2-windows-arm64.exe. See Running on Windows.
  5. release: runs only after all builders succeed, with permissions: contents: write and id-token: write. In order:
    • Builds the wheel + sdist with python -m build (at SETUPTOOLS_SCM_PRETEND_VERSION) and validates with twine check.
    • Publishes the wheel + sdist to PyPI via Trusted Publishing / OIDC (pypa/gh-action-pypi-publish@release/v1), no API token.
    • Only after a successful publish: creates an annotated tag X.Y.Z and pushes it; downloads every per-arch binary artifact (pattern yacron2-*, merge-multiple: true); extracts the release notes; and creates the GitHub Release.
  6. docker: after release, calls docker.yml via workflow_call with the new version to build and push the multi-arch image (see below).

Because no file is committed back to the repo, a release never re-triggers the workflow. Because the tag is created after publishing, a failed publish leaves no orphan tag and a re-run cleanly retries the same version.

macOS signing and notarization

The macOS binaries are Developer ID signed (hardened runtime) and notarized when the signing secrets are configured; if absent, the "Sign and notarize" step warns and exits 0, shipping an unsigned binary (a release is never blocked on signing setup). The secrets are MACOS_CERT_P12_BASE64, MACOS_CERT_PASSWORD, MACOS_SIGN_IDENTITY, MACOS_NOTARY_KEY_BASE64, MACOS_NOTARY_KEY_ID, MACOS_NOTARY_ISSUER_ID.

Signing imports the cert into a throwaway randomly-keyed keychain, signs with codesign --options runtime --timestamp --entitlements pyinstaller/entitlements.plist, verifies, then notarizes via xcrun notarytool submit … --wait. Because a one-file binary cannot be stapled, notarization publishes the ticket online and Gatekeeper validates on first run, so end users do not need xattr -d com.apple.quarantine.

pyinstaller/entitlements.plist enables the three hardened-runtime entitlements a PyInstaller one-file binary needs (com.apple.security.cs.allow-unsigned-executable-memory, …allow-jit, …disable-library-validation) so the unpacked CPython runtime can load and execute its embedded .so/.dylib files.

Release notes

The "Build release notes from HISTORY.md" step extracts this version's section from HISTORY.md (everything between its ## X.Y.Z (…) header and the next ## header, with leading blank lines stripped) into release-notes.md. If there is no matching section it warns and the body is auto-generated only. The Release uses that section as body_path with generate_release_notes: true (the curated notes are prepended above GitHub's auto-generated "What's Changed" / compare link). Keep HISTORY.md entries headed exactly ## X.Y.Z (date) so the matcher (index($0, "## " ver " ") == 1) finds them.

Release assets

The GitHub Release (softprops/action-gh-release@v3) attaches:

  • dist/*.whl, dist/*.tar.gz
  • yacron2-linux-{amd64,arm64,i686,armv7,ppc64le,s390x,riscv64} (glibc)
  • the same seven arches with a -musl suffix, e.g. yacron2-linux-amd64-muslyacron2-linux-riscv64-musl, plus yacron2-linux-armv6-musl (armv6 is musl-only)
  • yacron2-macos-amd64, yacron2-macos-arm64
  • yacron2-windows-amd64.exe, yacron2-windows-arm64.exe

The download-artifact pattern yacron2-* must stay broad enough to match all of them: a too-narrow pattern silently drops artifacts it misses rather than erroring.

Container image release

The official image is built and pushed by .github/workflows/docker.yml, built from the top-level Dockerfile. It runs in three modes:

  • Per-commit gate (push to any branch): builds all six release arches (linux/amd64,linux/arm64,linux/386,linux/arm/v7,linux/ppc64le,linux/s390x; everything but amd64 via QEMU emulation) and does not push (tagged ci-build). Catches arch-specific Dockerfile or dependency breakage before a release.
  • On release (invoked by release.yml via workflow_call with the version): builds multi-arch linux/amd64,linux/arm64,linux/386,linux/arm/v7,linux/ppc64le,linux/s390x and pushes to GHCR as ghcr.io/ptweezy/yacron2:<version> and :latest (and Docker Hub if DOCKERHUB_USERNAME/DOCKERHUB_TOKEN secrets are set). workflow_call is used rather than on: release because a Release created by the default GITHUB_TOKEN does not emit a triggering release: published event.
  • Manual (workflow_dispatch): (re)builds and pushes any existing release tag (defaults to the latest release), e.g. to backfill an image or retry a failed push.

The image build passes the computed version with --build-arg VERSION=X.Y.Z; a plain local docker build . leaves it empty and setuptools_scm reads the version from .git. See Production and Container Deployment.

The PyInstaller build

The self-contained binaries are produced from pyinstaller/yacron2.spec. The spec analyzes the entry script pyinstaller/yacron2 (which simply calls yacron2.__main__:main) and emits a single-file console executable named yacron2 with strip=True, upx=False, debug=False, console=True. PyInstaller is pinned to 6.21.0 consistently across the release jobs and the local Dockerfile.

The version is baked in by installing the package under SETUPTOOLS_SCM_PRETEND_VERSION before running PyInstaller, so the bundled yacron2/version.py carries the release version (verified by the --version smoke test). PyInstaller is not a cross-compiler, so each architecture/libc is built on a matching native runner or container.

Building a binary locally

pyinstaller/Dockerfile builds a glibc binary reproducibly on ubuntu:24.04: it installs build deps and upx-ucl, uses pyenv to install CPython 3.13.14 with --enable-shared, creates a venv, pip install pyinstaller==6.21.0, installs the package, runs the entry script (python pyinstaller/yacron2 --version), runs pyinstaller pyinstaller/yacron2.spec, and smoke-tests dist/yacron2 --version.

pyinstaller/Makefile wraps that: make (target all) builds the image, copies dist/yacron2 out of the container, and runs dist/yacron2 --version.

The standalone binaries unpack their embedded runtime to a temp directory at startup; the temp directory must be writable and executable. See Installation and Troubleshooting and FAQ.

Related pages

Clone this wiki locally