Releasing
Releases are tag-triggered. Pushing a vX.Y.Z tag runs .github/workflows/release.yml, which:
- verifies the tag matches
versioninpyproject.toml(fails fast on drift), - re-runs the full gate (
gates.yml— tests across Python 3.10–3.13, lint, types, benchmark), - publishes in parallel:
- PyPI — sdist + wheel, so
uv tool install malware-detector-py/pipx install malware-detector-pyget the new version, - GHCR — the Docker image to
ghcr.io/benzid-wael/malware-detector, tagged with the version andlatest.
- PyPI — sdist + wheel, so
The package version is static in pyproject.toml; the tag must match it. Use scripts/release.sh to keep the two in sync (prepare bumps the version, tag tags and pushes).
One-time setup
Do these once, before the first release. None require secrets in the repo.
1. PyPI Trusted Publishing (OIDC)
Publishing uses PyPI Trusted Publishing — no API token is stored anywhere. Because malware-detector-py is not on PyPI yet, register a pending publisher:
- Sign in to PyPI → https://pypi.org/manage/account/publishing/.
- Add a new pending trusted publisher:
- PyPI project name:
malware-detector-py - Owner:
benzid-wael - Repository name:
malware-detector - Workflow name:
release.yml - Environment name:
pypi
- PyPI project name:
- The first successful publish creates the project and converts it to a normal trusted publisher.
Prefer a token instead? Store it as the
PYPI_API_TOKENrepo secret and swap thepypijob's publish step towith: { password: ${{ secrets.PYPI_API_TOKEN }} }. Trusted Publishing is recommended — there is no long-lived credential to leak or rotate.
2. GitHub pypi environment
The pypi job runs in a pypi environment (matching the trusted-publisher config above):
- Repo Settings → Environments → New environment → name it
pypi. - (Optional, recommended) add a required reviewer so a human approves each PyPI publish, and/or restrict deployments to tag refs.
3. GHCR package visibility
The Docker push authenticates with the built-in GITHUB_TOKEN (packages: write) — no setup needed to publish. After the first release:
- Repo Packages →
malware-detector→ Package settings. - Set visibility to Public (if you want unauthenticated
docker pull) and link the package to this repository.
Cutting a release
A release takes two steps, so the version bump goes through a PR like any other change:
# 1. From main: create release/v0.1.0, bump the version and commit (nothing is pushed)
scripts/release.sh prepare 0.1.0 --dry-run # preview
scripts/release.sh prepare 0.1.0
# → push the branch, open a PR, merge it once CI is green
# 2. On main, after the merge: tag the release commit and push the tag
git checkout main && git pull --ff-only
scripts/release.sh tag 0.1.0 --dry-run # preview
scripts/release.sh tag 0.1.0 # asks for confirmationprepare updates version in pyproject.toml, refreshes uv.lock (it records the project's own version, and CI installs with --locked), and points the pre-commit rev: examples in README.md and the pre-commit guide at the new tag. It then commits release: v0.1.0. Run from main, it first switches to a new release/v0.1.0 branch, so main itself is never committed to; from any other branch it commits there.
tag refuses to run unless you're on main, level with origin/main, with a clean tree and a pyproject.toml version equal to the one you pass. It creates the annotated tag v0.1.0 and pushes only the tag, which triggers release.yml → verify → gate → publish to PyPI + GHCR. If the pypi environment has a required reviewer, approve the run under Actions.
Then confirm the artifacts:
uv tool install --force malware-detector-py==0.1.0 && malware-detector --version
docker pull ghcr.io/benzid-wael/malware-detector:0.1.0Optionally publish release notes: gh release create v0.1.0 --generate-notes.
Note on the version/tag contract: the static version and the tag are two places that must agree.
release.yml'sverifyjob is the safety net — a tag that doesn't matchpyproject.tomlfails before anything is published.
Fixing a bad release
PyPI releases are immutable and cannot be overwritten — yank the bad version (pip/uv will skip yanked versions) and publish a new patch. Delete the bad GHCR tag from the package's versions page if needed.