Files
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

10 KiB

Release Governance — Branching, Protection, OIDC, and Access Control

Table of Contents

  1. Branch Strategy
  2. Branch Protection Rules
  3. Tag-Based Release Model
  4. Role-Based Access Control
  5. Secure Publishing with OIDC (Trusted Publishing)
  6. Validate Tag Author in CI
  7. Prevent Invalid Release Tags
  8. Full publish.yml 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

# 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

  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

# .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_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.

- 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 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