* Add python-pypi-package-builder skill for Python packaging - Created `SKILL.md` defining decision-driven workflow for building, testing, versioning, and publishing Python packages. - Added reference modules covering PyPA packaging, architecture patterns, CI/CD, testing, versioning strategy, and release governance. - Implemented scaffold script to generate complete project structure with pyproject.toml, CI workflows, tests, and configuration. - Included support for multiple build backends (setuptools_scm, hatchling, flit, poetry) with clear decision rules. - Added secure release practices including tag-based versioning, branch protection, and OIDC Trusted Publishing. * fix: correct spelling issues detected by codespell
10 KiB
Release Governance — Branching, Protection, OIDC, and Access Control
Table of Contents
- Branch Strategy
- Branch Protection Rules
- Tag-Based Release Model
- Role-Based Access Control
- Secure Publishing with OIDC (Trusted Publishing)
- Validate Tag Author in CI
- Prevent Invalid Release Tags
- Full
publish.ymlwith Governance Gates
1. Branch Strategy
Use a clear branch hierarchy to separate development work from releasable code.
main ← stable; only receives PRs from develop or hotfix/*
develop ← integration branch; all feature PRs merge here first
feature/* ← new capabilities (e.g., feature/add-redis-backend)
fix/* ← bug fixes (e.g., fix/memory-leak-on-close)
hotfix/* ← urgent production fixes; PR directly to main + cherry-pick to develop
release/* ← (optional) release preparation (e.g., release/v2.0.0)
Rules
| Rule | Why |
|---|---|
No direct push to main |
Prevent accidental breakage of the stable branch |
| All changes via PR | Enforces review + CI before merge |
| At least one approval required | Second pair of eyes on all changes |
| CI must pass | Never merge broken code |
| Only tags trigger releases | No ad-hoc publish from branch pushes |
2. Branch Protection Rules
Configure these in GitHub → Settings → Branches → Add rule for main and develop.
For main
# Equivalent GitHub branch protection config (for documentation)
branch: main
rules:
- require_pull_request_reviews:
required_approving_review_count: 1
dismiss_stale_reviews: true
- require_status_checks_to_pass:
contexts:
- "Lint, Format & Type Check"
- "Test (Python 3.11)" # at minimum; add all matrix versions
strict: true # branch must be up-to-date before merge
- restrict_pushes:
allowed_actors: [] # nobody — only PR merges
- require_linear_history: true # prevents merge commits on main
For develop
branch: develop
rules:
- require_pull_request_reviews:
required_approving_review_count: 1
- require_status_checks_to_pass:
contexts: ["CI"]
strict: false # less strict for the integration branch
Via GitHub CLI
# Protect main (requires gh CLI and admin rights)
gh api repos/{owner}/{repo}/branches/main/protection \
--method PUT \
--input - <<'EOF'
{
"required_status_checks": {
"strict": true,
"contexts": ["Lint, Format & Type Check", "Test (Python 3.11)"]
},
"enforce_admins": false,
"required_pull_request_reviews": {
"required_approving_review_count": 1,
"dismiss_stale_reviews": true
},
"restrictions": null
}
EOF
3. Tag-Based Release Model
Only annotated tags on main trigger a release. Branch pushes and PR merges never publish.
Tag Naming Convention
vMAJOR.MINOR.PATCH # Stable: v1.2.3
vMAJOR.MINOR.PATCHaN # Alpha: v2.0.0a1
vMAJOR.MINOR.PATCHbN # Beta: v2.0.0b1
vMAJOR.MINOR.PATCHrcN # Release Candidate: v2.0.0rc1
Release Workflow
# 1. Merge develop → main via PR (reviewed, CI green)
# 2. Update CHANGELOG.md on main
# Move [Unreleased] entries to [vX.Y.Z] - YYYY-MM-DD
# 3. Commit the changelog
git checkout main
git pull origin main
git add CHANGELOG.md
git commit -m "chore: release v1.2.3"
# 4. Create and push an annotated tag
git tag -a v1.2.3 -m "Release v1.2.3"
git push origin v1.2.3 # ← ONLY the tag; not --tags (avoids pushing all tags)
# 5. Confirm: GitHub Actions publish.yml triggers automatically
# Monitor: Actions tab → publish workflow
# Verify: https://pypi.org/project/your-package/
Why annotated tags?
Annotated tags (git tag -a) carry a tagger identity, date, and message — lightweight tags do
not. setuptools_scm works with both, but annotated tags are safer for release governance because
they record who created the tag.
4. Role-Based Access Control
| Role | What they can do |
|---|---|
| Maintainer | Create release tags, approve PRs, manage branch protection |
| Contributor | Open PRs to develop; cannot push to main or create release tags |
| CI (GitHub Actions) | Publish to PyPI via OIDC; cannot push code or create tags |
Implement via GitHub Teams
# Create a Maintainers team and restrict tag creation to that team
gh api repos/{owner}/{repo}/tags/protection \
--method POST \
--field pattern="v*"
# Then set allowed actors to the Maintainers team only
5. Secure Publishing with OIDC (Trusted Publishing)
Never store a PyPI API token as a GitHub secret. Use Trusted Publishing (OIDC) instead. The PyPI project authorises a specific GitHub repository + workflow + environment — no long-lived secret is exchanged.
One-time PyPI Setup
- Go to https://pypi.org/manage/project/your-package/settings/publishing/
- Click Add a new publisher
- Fill in:
- Owner: your-github-username
- Repository: your-repo-name
- Workflow name:
publish.yml - Environment name:
release(must match theenvironment:key in the workflow)
- Save. No token required.
GitHub Environment Setup
- Go to GitHub → Settings → Environments → New environment → name it
release - Add a protection rule: Required reviewers (optional but recommended for extra safety)
- Add a deployment branch rule: Only tags matching
v*
Minimal publish.yml using OIDC
# .github/workflows/publish.yml
name: Publish to PyPI
on:
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+*" # Matches v1.0.0, v2.0.0a1, v1.2.3rc1
jobs:
publish:
name: Build and publish
runs-on: ubuntu-latest
environment: release # Must match the PyPI Trusted Publisher environment name
permissions:
id-token: write # Required for OIDC — grants a short-lived token to PyPI
contents: read
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # REQUIRED for setuptools_scm
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install build
run: pip install build
- name: Build distributions
run: python -m build
- name: Validate distributions
run: pip install twine ; twine check dist/*
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
# No `password:` or `user:` needed — OIDC handles authentication
6. Validate Tag Author in CI
Restrict who can trigger a release by checking GITHUB_ACTOR against an allowlist.
Add this as the first step in your publish job to fail fast.
- name: Validate tag author
run: |
ALLOWED_USERS=("your-github-username" "co-maintainer-username")
if [[ ! " ${ALLOWED_USERS[*]} " =~ " ${GITHUB_ACTOR} " ]]; then
echo "::error::Release blocked: ${GITHUB_ACTOR} is not an authorised releaser."
exit 1
fi
echo "Release authorised for ${GITHUB_ACTOR}."
Notes
GITHUB_ACTORis the GitHub username of the person who pushed the tag.- Store the allowlist in a separate file (e.g.,
.github/MAINTAINERS) for maintainability. - For teams: replace the username check with a GitHub API call to verify team membership.
7. Prevent Invalid Release Tags
Reject workflow runs triggered by tags that do not follow your versioning convention.
This stops accidental publishes from tags like test, backup-old, or v1.
- name: Validate release tag format
run: |
# Accepts: v1.0.0 v1.0.0a1 v1.0.0b2 v1.0.0rc1 v1.0.0.post1
if [[ ! "${GITHUB_REF}" =~ ^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+(a|b|rc|\.post)[0-9]*$ ]] && \
[[ ! "${GITHUB_REF}" =~ ^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::Tag '${GITHUB_REF}' does not match the required format v<MAJOR>.<MINOR>.<PATCH>[pre]."
exit 1
fi
echo "Tag format valid: ${GITHUB_REF}"
Regex explained
| Pattern | Matches |
|---|---|
v[0-9]+\.[0-9]+\.[0-9]+ |
v1.0.0, v12.3.4 |
(a|b|rc)[0-9]* |
v1.0.0a1, v2.0.0rc2 |
\.post[0-9]* |
v1.0.0.post1 |
8. Full publish.yml with Governance Gates
Complete workflow combining tag validation, author check, TestPyPI gate, and production publish.
# .github/workflows/publish.yml
name: Publish to PyPI
on:
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+*"
jobs:
publish:
name: Build, validate, and publish
runs-on: ubuntu-latest
environment: release
permissions:
id-token: write
contents: read
steps:
- name: Validate release tag format
run: |
if [[ ! "${GITHUB_REF}" =~ ^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+(a[0-9]*|b[0-9]*|rc[0-9]*|\.post[0-9]*)?$ ]]; then
echo "::error::Invalid tag format: ${GITHUB_REF}"
exit 1
fi
- name: Validate tag author
run: |
ALLOWED_USERS=("your-github-username")
if [[ ! " ${ALLOWED_USERS[*]} " =~ " ${GITHUB_ACTOR} " ]]; then
echo "::error::${GITHUB_ACTOR} is not authorised to release."
exit 1
fi
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install build tooling
run: pip install build twine
- name: Build
run: python -m build
- name: Validate distributions
run: twine check dist/*
- name: Publish to TestPyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
repository-url: https://test.pypi.org/legacy/
continue-on-error: true # Non-fatal; remove if you always want this to pass
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
Security checklist
- PyPI Trusted Publishing configured (no API token stored in GitHub)
- GitHub
releaseenvironment has branch protection: tags matchingv*only - Tag format validation step is the first step in the job
- Allowed-users list is maintained and reviewed regularly
- No secrets printed in logs (check all
echoandrunsteps) permissions:is scoped toid-token: writeonly — nowrite-all