Release Management
This guide explains how releases are managed for the FMP Data project, including versioning strategy, automated processes, and manual procedures.
Semantic Versioning
We follow Semantic Versioning (SemVer) with automated version bumping based on PR labels.
Version Format
- MAJOR: Breaking changes that require user action
- MINOR: New features and intentional public API/schema changes that remain source-compatible
- PATCH: Bug fixes and minor improvements
- PRERELEASE: Alpha, beta, or release candidate versions
- BUILD: Build metadata (not used in our releases)
Version Bumping Rules
| Change Type | PR Label | Version Bump | Example |
|---|---|---|---|
| Breaking Changes | release:major |
MAJOR | 1.0.0 → 2.0.0 |
| New Features / Public type changes | release:minor |
MINOR | 1.0.0 → 1.1.0 |
| Bug Fixes | release:patch |
PATCH | 1.0.0 → 1.0.1 |
| Documentation | release:patch |
PATCH | 1.0.0 → 1.0.1 |
| Chores | release:patch |
PATCH | 1.0.0 → 1.0.1 |
Automated Release Process
Happy path (dev → main)
- Land work on
devas usual. - The Release-PR workflow runs on every push to
devand opens (or reuses) a PR with headdevand basemain. It fails loudly ifmainis not an ancestor ofdev— it never reports success for work it did not do (#203). On every push it also re-validates an already-open release PR: ancestry plus RESTmergeable/mergeable_state(retried; fails closed ondirty,mergeablenottrue, or still-unknown) must stay clean (#207, #213). That mergeability check is the shared composite action.github/actions/check-pr-mergeable, also used by Guard-Main-Origin (#210); Test-Matrix runs its mock matrix on every PR (#212). - Automation token. Release-PR and Sync-Main-to-Dev prefer the repo
secret
GH_TOKEN(a fine-scoped PAT) forgh pr create/ automation pushes so thepull_requestopened event re-triggers Test-Matrix and Guard-Main-Origin (#206). IfGH_TOKENis unset they fall back toGITHUB_TOKEN, which can open the PR but will not re-fire those workflows (GitHub’s anti-recursion rule — unrelated to the Actions “read/write” permission toggle). Adding a release label still triggers TestPyPI via thelabeledevent either way. - Add exactly one of
release:major/release:minor/release:patchto that PR. - The Publish-to-TestPyPI workflow builds a unique
X.Y.Z.devNversion (N = run_id * 1000 + run_attempt) for each push, asserts the sdist metadata matches that version, uploads it, and comments the version and the commit SHA on the PR. Install the version in the latest comment — older comments point at stale artifacts (#204). - Merge the release PR into
mainonce CI is green and TestPyPI checks out. - The Release workflow tags, publishes to PyPI, and creates the GitHub release.
- The Sync-Main-to-Dev workflow fires on the push to
main. It checks reachability (git merge-base --is-ancestor origin/main origin/dev), not content equality. After a squash-merge the trees match but the histories have diverged; the workflow opens a PR that records a history-only merge (merge -s ours) so the next release PR stays MERGEABLE and gets full CI (#202). It then enables auto-merge with a merge commit so the PR lands when Test-Matrix is green — no human babysitting on the happy path. Never squash that PR (it would recreate the divergence). Concurrent main pushes do not cancel an in-flight sync; human WIP onsync/main-to-devis not force-pushed away; merge conflicts open a tracking issue (#208).
Why three workflows keep each other honest
| Failure mode | What used to happen | What happens now |
|---|---|---|
Squash-merge dev → main |
Content matched, sync no-op; next release PR opened CONFLICTING with no CI | Sync opens a reachability PR immediately after the release |
| Release-PR automation | create-pull-request with no working-tree changes exited green and created nothing |
gh pr create --base main --head dev, or a red failure if histories diverged |
| TestPyPI re-run on the same PR | Version keyed on PR number + skip-existing: true → first build wins forever |
Unique version per run; skip-existing: false; sdist version asserted; comment includes commit SHA |
Guard-Main-Origin also fails the PR when mergeable_state=dirty, when
mergeable is not true (including empty/JSON-null), or when
mergeability never leaves unknown after retries, so a conflicting,
unproven, or unresolved release PR shows a red X instead of a hole in the
checks list (#207, #213). Both workflows share
.github/actions/check-pr-mergeable for that check (#210); Test-Matrix runs
its mock matrix on every PR (#212). Guard checks out the PR head (so
CONFLICTING PRs still reach the check) and, when present on the PR base,
overlays the composite action from origin/<base> so hotfixes cut from an
older tip cannot omit the contract.
Guard base-pin lag for check-pr-mergeable (#218)
Guard’s overlay is intentional and hotfix-safe (#210): a head branch that
rewrites or weakens check.sh still runs the contract pinned on the PR
base (typically main for release PRs). The tradeoff is lag:
| Workflow | Which action copy runs | When a contract change applies |
|---|---|---|
Guard-Main-Origin (dev/hotfix-* → main) |
origin/<base> when that path exists; else head (bootstrap). Guard only targets main, so base is always main. |
After the change is merged into main |
Release-PR (push to dev) |
Tip of the workflow run (checkout of dev) |
As soon as the change is on dev |
Implementation: .github/workflows/guard-main-origin.yml step “Prefer
mergeability action from PR base” runs
git checkout origin/${BASE_REF} -- .github/actions/check-pr-mergeable
when that path exists on the base.
So tightenings such as “require mergeable=true” (#213) or explicit
tostring extraction (#216) land on Release-PR immediately once they reach
dev, but Guard keeps the previous contract until the same change is on
main (normally via the next release PR). Operators reading a red/green
mismatch between Guard and Release-PR after a contract change on dev only
should check whether main still has the older action.
Pin strategy: do not silently allow head to override base for minor contract updates. Changing that tradeoff (forward-compatible pin, dual-run, etc.) is a separate decision; document and review it rather than flipping the overlay in a hotfix-shaped PR.
Related automation (not the release PR itself)
- Dev Release (
dev-release.yml) publishes a unique TestPyPI build on every push todev(X.Y.Z.devNwithN = run_id * 1000 + run_attempt). Re-runs never silently re-serve a previous wheel. Build and publish are separate jobs; only publish holdsid-token: write, behind thetestpypienvironment.workflow_dispatchis bound torefs/heads/dev. - Release (
release.yml) tags and creates the GitHub Release in acontents: writebuild job, then publishes to PyPI from a second job that only downloads the hashed artifacts and hasid-token: write(environmentpypi). Existing tags / releases / PyPI versions fail the job instead of being skipped. Release notes are the matching## [X.Y.Z]section ofCHANGELOG.md(viascripts/github_release_notes.py), not the squash commit list (#370). A missing or empty section fails the job before the local or remote tag. The job is a no-op without arelease:*label. - External Actions are pinned to full commit SHAs. The PEP 517 frontend
(
build) and backend (hatchling,hatch-vcs) are installed from version floors in.github/requirements-build.txt(not a hashed lock). Publish jobs runpython -m build --no-isolationso isolation cannot pull a different backend than the one just installed.
Secrets used by release automation
| Secret | Purpose |
|---|---|
GH_TOKEN |
Fine-scoped PAT (or App token) for Release-PR / Sync-Main-to-Dev gh pr create, automation branch pushes, and sync-PR auto-merge so pull_request CI runs on open (#206) and the history-sync PR can land without a babysitter. Not the same as the automatic GITHUB_TOKEN. |
GITHUB_TOKEN |
Automatic job token; used as fallback and for jobs that must not re-trigger workflows. |
| OIDC / PyPI trusted publishing | Real and Test PyPI uploads (no long-lived PyPI token required when configured). The GitHub environments must be named pypi and testpypi in the matching PyPI / TestPyPI Trusted Publisher entries, or the OIDC exchange will fail. |
Branch protection notes (sync PR)
- Protect Dev requires the Test-Matrix Python jobs (
tests (3.10)…tests (3.14)), blocks force-pushes and branch deletion, and allows merge/squash/rebase methods. Sync automation always requests a merge commit viagh pr merge --auto --merge. - Signed commits are not required on
dev. Requiring verified signatures forced every automation PR (and most human ones) through admin bypass, including the post-release history-sync PR. Quality gates remain the status checks; re-enabling signatures needs a signing key in CI first.
GitHub Actions Workflow (on merge to main)
The job is a no-op without a release:* label. Unlabeled
dev → main leftover landings do not tag or publish; Sync-Main-to-Dev
still records the squash (#375, #383).
- PR Merge: When a labeled release PR is merged to
main - Label Detection: Action reads PR labels to determine version bump
- Version Calculation: New version from the latest tag + bump type
- Extract notes (fail-closed): matching
## [X.Y.Z]viascripts/github_release_notes.py. Missing or empty section fails the job before any tag - Local annotated tag: created for hatch-vcs (not pushed yet)
- Build: sdist/wheel; sdist
Version:must match the calculated version - API tag push: annotated tag object, then the ref
- GitHub Release:
--notes-file release-notes.md - PyPI Publishing: hashed artifacts from the build job
- History sync: Sync-Main-to-Dev restores
mainas an ancestor ofdev
Required PR Labels
Version Bump Labels (exactly one required):
- release:major: For breaking changes
- release:minor: For new features and intentional public type/schema changes
- release:patch: For bug fixes and minor changes
Additional Labels (optional):
- dependencies: Dependency updates
- documentation: Documentation changes
- enhancement: Improvements to existing features
- bug: Bug fixes
- feature: New features
Example Workflow (illustrative shape only)
The live release path lives in .github/workflows/release.yml. Do not copy
skip-existing: true from older snippets — real releases fail on version
collisions rather than reporting a green no-op.
Manual Release Process
For emergency releases or when automation fails:
Prerequisites
- Permissions: Maintainer access to repository and PyPI
- Environment: Local development environment set up
- Credentials: PyPI token configured
Steps
-
Prepare Release Branch
-
Confirm Release Version (Tag-based)
-
Update Changelog
-
Run Quality Checks
-
Commit Changes
-
Create Release PR
- Create PR from release branch to main
- Add exactly one version label:
release:major,release:minor, orrelease:patch -
Include release notes in description
-
Merge, generate notes, then tag
-
Build and Publish
-
Create GitHub Release
- Happy path:
release.ymlextracts## [X.Y.Z]viascripts/github_release_notes.py(fail-closed, before tagging). - If automation failed, use the file from step 7:
- Fold
## Unreleasedinto## [X.Y.Z] - datebefore adding arelease:*label. A heading that exists only under Unreleased fails the job. Preview with--version X.Y.Z.
Pre-release Process
For alpha, beta, and release candidate versions:
Creating Pre-releases
# Alpha release
git tag v1.0.0a1
git push origin v1.0.0a1
# Beta release
git tag v1.0.0b1
git push origin v1.0.0b1
# Release candidate
git tag v1.0.0rc1
git push origin v1.0.0rc1
Publishing Pre-releases
# Build and publish to PyPI
uv build --wheel --sdist
uv publish --token "$PYPI_TOKEN"
# Install pre-release
pip install --pre fmp-data
Pre-release Labels
alpha: Early development versionbeta: Feature-complete but may have bugsrc: Release candidate, final testing phase
Release Notes
GitHub Release notes are the matching ## [X.Y.Z] section of
CHANGELOG.md, wrapped with install / docs links by
scripts/github_release_notes.py. They are not the squash commit
list, PR titles, or GitHub autogen.
Before adding a release:* label, fold ## Unreleased into
## [X.Y.Z] - YYYY-MM-DD for the version that label will produce.
Preview:
A missing or empty section fails the release job before the tag is created.
Version Strategy
Major Releases (X.0.0)
When to Release: - Breaking API changes - Major architecture changes - Dropping support for Python versions - Significant dependency updates
Planning: - Create milestone for major version - Gather breaking changes over time - Provide migration documentation - Consider deprecation warnings in previous minor versions
Minor Releases (X.Y.0)
When to Release: - New features - New API endpoints - Backward-compatible improvements - New optional dependencies
Frequency: Monthly or when significant features are ready
Patch Releases (X.Y.Z)
When to Release: - Bug fixes - Documentation updates - Security fixes - Performance improvements
Frequency: As needed, typically weekly for active development
Hotfix Process
For critical security or data corruption bugs:
-
Create Hotfix Branch
-
Apply Minimal Fix
- Fix only the critical issue
- Avoid unrelated changes
-
Add regression tests
-
Fast-track Release
- Skip normal review process if needed
- Deploy immediately after testing
-
Notify users through appropriate channels
-
Post-hotfix Actions
- Backport to development branches
- Update documentation
- Conduct post-mortem if needed
Release Checklist
Pre-release
- [ ] All tests passing
- [ ] Documentation updated
- [ ] CHANGELOG.md updated
- [ ] Release tag prepared
- [ ] Migration guide written (for breaking changes)
- [ ] Security review completed (for major releases)
Release
- [ ] Git tag created
- [ ] GitHub release published
- [ ] PyPI package published
- [ ] Documentation deployed
- [ ] Release notes published
Post-release
- [ ] Verify PyPI package installation
- [ ] Test key functionality
- [ ] Monitor for reported issues
- [ ] Update example code if needed
- [ ] Announce release (social media, forums, etc.)
Rollback Procedures
If a release introduces critical issues:
PyPI Package
# Remove problematic version
pip install twine
twine delete --repository pypi fmp-data==1.2.3
# Users should pin to previous version
pip install fmp-data==1.2.2
GitHub Release
- Mark release as pre-release
- Add warning to release notes
- Create patch release with fix
Communication
- Update GitHub issue/discussion
- Post on social media/forums
- Email affected enterprise users
- Update documentation with workarounds
Monitoring and Metrics
Release Health
- PyPI download statistics
- GitHub issue reports
- User feedback and discussions
- Performance monitoring
Success Metrics
- Time to release (PR merge to PyPI)
- Release frequency
- Bug reports per release
- User adoption rate
Tools and Infrastructure
Required Access
- GitHub repository admin
- PyPI package maintainer
- Documentation hosting admin
- CI/CD system access
Tools Used
- uv: Dependency management, builds, and publishing
- hatch-vcs: Tag-based versioning
- GitHub Actions: CI/CD automation
- PyPI: Package distribution
- MkDocs: Documentation generation
- CHANGELOG.md: hand-maintained Keep a Changelog. GitHub Release
notes are the matching
## [X.Y.Z]section, not git-log autogen
Troubleshooting
Common Issues
PyPI Publishing Fails
# Check credentials
uv publish --dry-run --token "$PYPI_TOKEN"
# Verify package
uv build --wheel --sdist
uv run twine check dist/*
Version Conflicts
GitHub Actions Failure - Check action logs - Verify secrets and permissions - Test workflow locally if possible
For additional help, consult the Development Guide or create an issue.