Files
awesome-copilot/skills/python-pypi-package-builder/references/release-governance.md
Patel Dhruv 49fd3f3faf Add new skill: Python PyPI Package Builder (#1302)
* 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
2026-04-09 10:36:17 +10:00

355 lines
10 KiB
Markdown

# Release Governance — Branching, Protection, OIDC, and Access Control
## Table of Contents
1. [Branch Strategy](#1-branch-strategy)
2. [Branch Protection Rules](#2-branch-protection-rules)
3. [Tag-Based Release Model](#3-tag-based-release-model)
4. [Role-Based Access Control](#4-role-based-access-control)
5. [Secure Publishing with OIDC (Trusted Publishing)](#5-secure-publishing-with-oidc-trusted-publishing)
6. [Validate Tag Author in CI](#6-validate-tag-author-in-ci)
7. [Prevent Invalid Release Tags](#7-prevent-invalid-release-tags)
8. [Full `publish.yml` with Governance Gates](#8-full-publishyml-with-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`
```yaml
# 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`
```yaml
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
```bash
# 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
```bash
# 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
```bash
# 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
1. Go to https://pypi.org/manage/project/your-package/settings/publishing/
2. Click **Add a new publisher**
3. Fill in:
- **Owner:** your-github-username
- **Repository:** your-repo-name
- **Workflow name:** `publish.yml`
- **Environment name:** `release` (must match the `environment:` key in the workflow)
4. Save. No token required.
### GitHub Environment Setup
1. Go to **GitHub → Settings → Environments → New environment** → name it `release`
2. Add a protection rule: **Required reviewers** (optional but recommended for extra safety)
3. Add a deployment branch rule: **Only tags matching `v*`**
### Minimal `publish.yml` using OIDC
```yaml
# .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.
```yaml
- 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_ACTOR` is 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`.
```yaml
- 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.
```yaml
# .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 `release` environment has branch protection: tags matching `v*` 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 `echo` and `run` steps)
- [ ] `permissions:` is scoped to `id-token: write` only — no `write-all`