Release checklist¶
Maintainer-only. This is the step-by-step for cutting a new release of compose-lint. Contributors don't need to read this; see CONTRIBUTING.md instead.
The release pipeline is tag-triggered: pushing an annotated, signed
vX.Y.Z tag to main kicks off .github/workflows/publish.yml, which
builds, publishes to TestPyPI and runs smoke tests for all channels, then
waits for a single manual approval on the release environment before
publishing to all production channels in parallel. Sigstore build
attestations are generated automatically.
What's automated vs. manual¶
Most of this checklist is now wired into CI. At a glance:
| Step | Where it runs |
|---|---|
| Pre-release checks (ruff/mypy/pytest) | ci.yml on every PR |
| Version strings in sync | ci.yml → version-consistency job |
| CHANGELOG section exists for bump | ci.yml → changelog-gate job |
| Open the "Prepare X.Y.Z release" PR | release-prep.yml (workflow_dispatch) |
| Create signed tag and push | Manual (your workstation) |
| Build, sign, TestPyPI, smoke tests | publish.yml |
| Release-gate approval | Manual (GitHub Environment release) |
| PyPI + Docker Hub publish | publish.yml |
| GitHub Release created from CHANGELOG | publish.yml → create-release job |
| Marketplace-smoke pin bump PR | publish.yml → bump-marketplace-smoke-pin job |
| Merge pin bump PR, re-run smoke | Manual |
Tag creation stays manual on purpose. Tags created by GITHUB_TOKEN
don't trigger downstream workflows (see "If something goes wrong"), and
the SSH-signed tag is the root of the Sigstore provenance chain for the
built artifact. Release-gate approval stays manual because it's the
human-in-the-loop safety between TestPyPI smoke passing and real PyPI /
Docker Hub publishing.
Everything below is the manual checklist for the steps that are not
automated. If you invoke Release prep from the Actions tab, it does
the "Bump the version", "Update the changelog", and "Commit the bump"
sections for you — your job is to review the resulting PR.
Choosing the version number¶
compose-lint follows Semantic Versioning, with one
project-specific rule about new rules (see below). Pick the bump before
you touch pyproject.toml; "what kind of release is this" is the first
question to answer.
The user-facing version of these guarantees — the stability promise and the deprecation lifecycle — lives in compatibility.md.
Pre-1.0 (current)¶
While the major version is 0, the guarantees are weaker and the MINOR
slot does the work that MAJOR does post-1.0.
- PATCH (
0.2.0 → 0.2.1) — safe changes only. Bug fixes, false-positive fixes, parser fixes, docs, internal refactors, dependency digest bumps. A Compose file that passed on0.2.0must still pass on0.2.1, and existing findings must not change their rule ID, severity, or message shape. - MINOR (
0.2.0 → 0.3.0) — everything else. New rules, new CLI flags, new formatters, new config keys, severity upgrades, severity downgrades, tightening an existing rule's logic, restructuring JSON/SARIF output. If a user's CI could newly fail or newly pass because of this release, it's a MINOR. Call the behavior change out inCHANGELOG.mdunderChangedso pinned users know what to expect on upgrade. - MAJOR (
0.x → 1.0.0) — reserved. Cutting1.0.0is the stabilization commitment: from that point on, the CLI, exit-code contract, config schema, and JSON/SARIF output shape are stable under the post-1.0 rules below. Don't bump to1.0.0casually — do it when you're ready to stand behind those guarantees.
Post-1.0 (future)¶
Once 1.0.0 ships, the contract tightens:
- PATCH (
1.2.3 → 1.2.4) — bug fixes that don't change which findings are emitted for a given input. If the set of findings a user sees on an unchanged Compose file could change, it's not a patch. - MINOR (
1.2.3 → 1.3.0) — additive or backward-compatible changes. New rules, new CLI flags, new config keys, severity downgrades, new formatters, additive fields in JSON/SARIF output. New rules are intentionally MINOR, not MAJOR, following the Hadolint / ShellCheck / ruff convention. Users who need deterministic results across upgrades should pin the version; the--fail-onflag is the documented escape hatch for tolerating new findings without failing CI. - MAJOR (
1.2.3 → 2.0.0) — anything that breaks a pinned, working setup: - Removing or renaming a CLI flag, subcommand, or config key.
- Removing or retiring a rule ID (note: rule IDs are never reused;
see
AGENTS.md). - Changing the exit-code contract (e.g., adding a new non-zero
exit code, changing the default
--fail-onthreshold). - Severity upgrades on existing rules (
LOW → HIGHcan newly fail CI for pinned users). - Restructuring JSON/SARIF output in a way that removes or renames existing fields.
- Dropping support for a Python version listed in
pyproject.toml.
Judgment-call cheat sheet¶
| Change | Pre-1.0 | Post-1.0 |
|---|---|---|
| Fix false positive in an existing rule | PATCH | PATCH |
| Fix a parser crash | PATCH | PATCH |
| Docs-only change | PATCH | PATCH |
| Add a new rule | MINOR | MINOR |
| Add a new CLI flag | MINOR | MINOR |
| Downgrade a rule's severity (HIGH → MEDIUM) | MINOR | MINOR |
| Upgrade a rule's severity (LOW → HIGH) | MINOR | MAJOR |
| Tighten an existing rule (new true positive) | MINOR | MINOR |
| Remove or rename a CLI flag | MINOR | MAJOR |
| Retire a rule ID | MINOR | MAJOR |
Change the default --fail-on threshold |
MINOR | MAJOR |
| Drop a Python version | MINOR | MAJOR |
| Add a field to JSON/SARIF output | MINOR | MINOR |
| Remove or rename a JSON/SARIF field | MINOR | MAJOR |
| Remove or rename a config key | MINOR | MAJOR |
| Deprecate a flag/key (keep it working) | PATCH | MINOR |
When in doubt pre-1.0, pick MINOR. When in doubt post-1.0, pick the higher bump — MAJOR costs the maintainer some release ceremony, but a too-low bump breaks users who trusted the version contract.
Deprecations¶
Removing anything stable follows the deprecation lifecycle in
compatibility.md: announce it under
Deprecated in CHANGELOG.md, emit a stderr warning: for user-invoked
surfaces, keep it working for at least one MINOR, and remove it only in a MAJOR
(listed under Removed).
Pre-release checks¶
All of these run on main, on a clean working tree, before you touch the
version number.
- [ ]
git statusis clean and you're onmain(or the release branch that will merge tomain). - [ ]
git pull --ff-only— up to date with origin. - [ ]
ruff check src/ tests/ - [ ]
ruff format --check src/ tests/ - [ ]
mypy src/ - [ ]
pytest - [ ] CI on
mainis green for the commit you're about to release. - [ ] No open Renovate PRs you meant to merge first.
- [ ]
[Unreleased]inCHANGELOG.mdcovers every user-facing PR merged since the last release tag.release-prep.ymlonly renames[Unreleased]→[X.Y.Z]; it does not author entries, so anything missing here ships with no changelog. Cross-checkgh pr list --state merged --search "merged:>$(git log -1 --format=%cI v$(grep -E '^version' pyproject.toml | head -1 | cut -d'"' -f2))"against the bullets in[Unreleased]and backfill the gaps in a separate chore PR before dispatchingrelease-prep.yml. (0.5.2 tripped on this — four merged PRs had no changelog entries.) - [ ]
.vex/compose-lint.openvex.jsonis current: any new pip (or other stripped-component) CVE that a scanner now reports against the image is either covered by an existingnot_affectedstatement withvulnerable_code_not_present, or added in a fresh statement after you've manually verified the vulnerable code path is absent from the runtime image. If the CVE is reachable, do not VEX it — fix it. Bumpversionandtimestampin the VEX doc when statements change. - [ ] Product identifiers in the VEX doc keep using
repository_url=index.docker.io/composelint/compose-lint(notdocker.io/...). Thedocker.ioalias is silently ignored by Scout, Trivy, and Grype for VEX matching. See ADR-012.
Bump the version¶
compose-lint declares the version in four places that must stay in sync. Missing any one of them is a release-blocker — check all four before opening the bump PR.
- [ ]
pyproject.toml—version = "X.Y.Z"under[project] - [ ]
src/compose_lint/__init__.py—__version__ = "X.Y.Z" -
[ ]
README.md+docs/hardening.md— version references in copy-paste integration snippets. All need bumping each release; otherwise users land on a stale version (v0.14.0 shipped with all of them stale). Four forms exist:tmatens/compose-lint@<sha> # v0.X.Y(README — GitHub Action snippet)rev: v0.X.Y(README — pre-commit snippet)compose-lint==0.X.Y(README — Forgejo Actions snippet, pip pin):0.X.Yimage tags (docs/hardening.md — hardeneddocker runsnippet, plus the digest-lookup prose below it; NOT in README)
All four are automated — nothing to do by hand here. The first three are rewritten by
release-prep.ymlin the bump commit itself, and CI enforces them (version-consistencyjob, "self-referencing version pins" step): anycompose-lint==X.Y.Z,composelint/compose-lint:X.Y.Z, orrev: vX.Y.Zanywhere inREADME.mdordocs/(historical files excluded) must equalpyproject.toml's version, so the prep PR fails CI if the rewrite ever misses one — including pins in docs this list doesn't know about yet. The action-SHA form cannot be checked pre-tag (the new tag's SHA exists only post-release), sopublish.yml'sbump-marketplace-smoke-pinjob rewrites it in bothmarketplace-smoke.ymlandREADME.mdin the post-release follow-up PR.This used to be manual, and drifted: the prep PR failed its own required check on every release after #443 added the gate (0.14.1 needed a hand-pushed pin-bump commit), and the README action pin stayed a release behind because only
marketplace-smoke.ymlwas rewritten. Verify rather than re-do — if the prep PR is green and the follow-up PR touchesREADME.md, this item is satisfied. - [ ].github/workflows/marketplace-smoke.yml— twouses: tmatens/compose-lint@<sha> # vX.Y.Zlines. Automated by the samebump-marketplace-smoke-pinjob as the README pin above: it resolvesgit rev-parse vX.Y.Z^{commit}after the signed tag is pushed and opens the follow-up PR for you. Review and merge that PR; only bump by hand if the job failed.
Verify the first two match:
grep -E '^version' pyproject.toml
grep __version__ src/compose_lint/__init__.py
The marketplace-smoke.yml bump has to land after the release
tag exists, because the commit SHA only exists once the tag is
pushed. Treat it as a post-release step, not part of the bump PR —
see "Post-release" below.
Update the changelog¶
- [ ]
CHANGELOG.md— move items under[Unreleased]to a new[X.Y.Z] - YYYY-MM-DDsection. Follow Keep a Changelog structure (Added,Changed,Deprecated,Removed,Fixed,Security). - [ ] Update the comparison link at the bottom of
CHANGELOG.mdif the file uses one.
Commit the bump¶
Open a PR — even for the release bump. No direct pushes to main.
git checkout -b release/X.Y.Z
git add pyproject.toml src/compose_lint/__init__.py action.yml CHANGELOG.md
git commit -m "Prepare X.Y.Z release"
git push -u origin release/X.Y.Z
gh pr create --fill
- [ ] CI green on the PR.
- [ ] Squash-merge to
main. - [ ]
git checkout main && git pull --ff-only.
Tag and release¶
git pull --ff-only
git tag -s vX.Y.Z -m "compose-lint X.Y.Z"
git push origin vX.Y.Z
- [ ] The tag exists and triggered
Publishin Actions.
Watch smoke tests¶
After the tag push, publish.yml runs automatically:
- Builds the wheel and publishes to TestPyPI.
- Runs PyPI smoke tests (version check, clean/insecure fixtures) against the TestPyPI artifact.
- Runs Docker smoke tests (version check, clean/insecure fixtures, SARIF) against a local image build.
No manual action needed here. Wait for all smoke jobs to go green.
- [ ]
testpypi-smokegreen — TestPyPI artifact is correct. - [ ]
docker-smokegreen — Docker image builds and behaves correctly.
Approve the release gate¶
Once all smoke tests pass, the release-gate job waits for approval.
One approval publishes all channels.
- [ ] Open the running workflow. The
release-gatejob will be pending approval from thereleaseenvironment. - [ ] Review the smoke test results, then approve.
After approval, publish and docker-publish run in parallel.
- [ ] https://pypi.org/project/compose-lint/ shows the new version.
- [ ] The "Build provenance" section on the PyPI page shows the Sigstore
attestation linked to this repo and the
publish.ymlworkflow. - [ ] Docker publish completes green (post-push cosign verify and version check run automatically).
Post-release¶
- [ ] GitHub Release — created automatically by
publish.yml'screate-releasejob (runs after bothpublishanddocker-publishsucceed). Notes come from the matching## [X.Y.Z]section inCHANGELOG.md. Wheels, sdist, and Sigstore bundles are attached as release assets. - [ ] Marketplace smoke test pin bump —
publish.yml'sbump-marketplace-smoke-pinjob (runs aftercreate-release) opens a follow-up PR with the new SHA in bothuses: tmatens/compose-lint@<sha> # vX.Y.Zlines. Review and squash-merge, then trigger Actions → Marketplace smoke test → Run workflow to verify the published Action end-to-end. - [ ] Docker Hub overview sync — runs automatically in
publish.yml'sdockerhub-descriptionjob afterdocker-publish, via the first-party composite action at.github/actions/update-dockerhub-description(which just forwards toscripts/update-dockerhub-description.sh). Syncsdocs/dockerhub-overview.md(NOTREADME.md— the Hub overview is a separate, trimmed, version-free file, so it needs no per-release bump). RequiresDOCKERHUB_TOKENto have Read, Write, Delete scope — Read & Write is not enough for the description PATCH endpoint. Verifyhttps://hub.docker.com/r/composelint/compose-lintreflects the current overview file. - [ ] README demo GIFs — only if this release changed the text-output
appearance (finding layout, verdict line, colors), changed what
fixemits, or you want the banner to show the new version. The demo toolchain installs compose-lint from PyPI, so this is post-publish: bump thecompose-lint==pin inscripts/demo/requirements.in, recompilescripts/demo/requirements.lock(exactuv pip compilecommand in the lock's header), runscripts/demo/render.sh(both casts), and open a follow-up PR with the updateddocs/assets/demo.gif,docs/assets/demo-fix.gif, and their README alt text. Output-only or docs-only releases can skip this. Each cast is sized to its terminal in the tape — if a release adds a line tocheckorfixoutput, check the render didn't scroll before committing it. - [ ] Fresh
[Unreleased]section — already inserted byrelease-prep.ymlas part of the release bump PR. No follow-up PR needed. - [ ] Announce in Discussions if the release has user-visible changes.
If something goes wrong¶
- One channel's smoke is broken but the other must ship: use the
manual escape hatch at Actions → Publish channel (manual) → Run
workflow. Enter the tag and select the channel. That workflow bypasses
the shared gate but still requires the per-channel environment approval
(
pypiordockerhub). Document why you used it in the GitHub Release notes. - TestPyPI publish fails: fix forward. Delete the tag locally and on
origin (
git tag -d vX.Y.Z && git push origin :refs/tags/vX.Y.Z), land the fix via PR, re-tag with the same version number, and push again. TestPyPI allows overwriting a yanked version on retry; real PyPI does not, so always retry on TestPyPI first. - Real PyPI publish fails after TestPyPI succeeded: do not reuse
the version number. Bump to
X.Y.Z+1(usually a patch), land the fix, and cut a new release. PyPI treats deleted versions as permanently burned. - Wrong files in the wheel: the
Verify dist contentsstep inpublish.ymlfails the build if the wheel containsAGENTS.md,CLAUDE.md,.env,tests/, or.git/. If this trips, check[tool.hatch.build.targets.wheel]exclude patterns inpyproject.toml. - Docker push fails on rolling tag (
0.3,latest): Docker Hub repo setting "Immutable tags" must be off. Rolling tags are overwritten on every release by design; immutability blocks that. The versioned tag (0.3.3) is still effectively immutable because the version string itself is never reused. - Docker publish succeeded but left an orphan tag on Docker Hub: happens when a release is retried after a burned PyPI version. Delete the orphan tag from Docker Hub — an image with no matching PyPI release and no GitHub Release is untraceable back to source.
- Release workflow ran but nothing published: tags created via the
GitHub API with
GITHUB_TOKENdon't trigger downstream workflows. Delete the tag and re-push it as a signed tag from your workstation (see "Tag and release" above).
Credential scoping (open items)¶
Two hardening steps live in GitHub and Docker Hub settings, not in this repository, so they cannot be landed by a PR. Both are recorded here rather than left implicit — the workflow side of each is already in place.
A dockerhub-description environment¶
dockerhub-description.yml is dispatched manually and reads the Docker Hub
credential. Its checkout is pinned to the default branch, so a dispatcher
cannot choose the code that runs — but the secrets are still repo-level,
which means nothing scopes them to a ref.
To close the rest:
- Create an environment named
dockerhub-description. - Set its deployment-branch policy to the default branch only. (The existing
dockerhubenvironment is tags-only, which is why this job could not simply reuse it.) - Move
DOCKERHUB_USERNAME/DOCKERHUB_TOKENinto it, removing them from repo scope. - Add
environment: dockerhub-descriptionto the job, and required reviewers if you want a human gate.
Until step 3, a repo-level secret is readable by any workflow in the repository, so steps 1–2 alone change nothing.
Split the Docker Hub PAT by capability¶
One Read, Write, Delete PAT is referenced by every Docker Hub job, including
read-only ones (docker-smoke, scout, report). A leak from any of them
carries delete capability for the whole namespace — the scope of the credential
is set by the single most privileged consumer.
Mint three tokens and route them by need:
| Token | Scope | Used by |
|---|---|---|
DOCKERHUB_TOKEN_READ |
Read | docker-smoke, scout, report |
DOCKERHUB_TOKEN_WRITE |
Read, Write | the four build/publish jobs |
DOCKERHUB_TOKEN_ADMIN |
Read, Write, Delete | the two dockerhub-description jobs only |
The ADMIN token should live in the dockerhub-description environment above,
so the other jobs cannot reference it even by name. Renaming the secrets is a
breaking change to the release pipeline, so do it in one pass: add the new
secrets first, land the workflow change, then revoke the old PAT.
Why this checklist exists¶
- Three version strings (
pyproject.toml,__init__.py, andaction.yml'sDEFAULT_VERSION) drift if you only bump some. We almost shipped 0.2.0 with a mismatch between the first two.DEFAULT_VERSIONis the package the Action installs when a consumer does not passversion:, so a stale one means a SHA-pinneduses:silently gets a different linter than the action it pinned;scripts/bump-version.shrewrites all three andtests/test_action_contract.pyfails if they disagree. - PyPI version numbers are permanent; a rushed release with a broken wheel burns the number forever.
- Signed, annotated tags are the root of the provenance chain that Sigstore attestations extend to the built artifact. An unsigned or lightweight tag breaks that chain even though the publish workflow will still run.