From b461a523787da4ff9db332ceda762511747adc18 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Tue, 21 Jul 2026 08:08:22 +0200 Subject: [PATCH] feat(workflows): add python-app CI + python-release gold-standard reusables Signed-off-by: Sebastian Mendel --- .github/workflows/python-app-ci.yml | 278 ++++++++++++++++++++++++++ .github/workflows/python-release.yml | 231 +++++++++++++++++++++ docs/reusable-workflow-permissions.md | 4 +- 3 files changed, 512 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/python-app-ci.yml create mode 100644 .github/workflows/python-release.yml diff --git a/.github/workflows/python-app-ci.yml b/.github/workflows/python-app-ci.yml new file mode 100644 index 0000000..ee5b705 --- /dev/null +++ b/.github/workflows/python-app-ci.yml @@ -0,0 +1,278 @@ +# Reusable "Python app CI" gold-standard workflow (META). +# +# Composes the granular Python reusables into one CI entry point for a +# Python *application* (CLI tool, service): lint / type-check / tests +# (+ optional Codecov upload), an optional distribution build, the +# security audit (pip-audit + bandit + CycloneDX SBOM), plus opt-in +# CodeQL, secret scanning, dependency review, zizmor and Scorecard. Each +# job calls a single-purpose reusable so consumers get one call site. +# +# This is the Python counterpart to node-ci.yml. It defaults to a `uv` +# project on Python 3.14; every command is a pass-through input, so +# pip/poetry projects override install-cmd / lint-cmd / test-cmd etc. +# +# Caller pattern (uv project, defaults): +# +# name: CI +# on: +# push: +# branches: [main] +# pull_request: +# permissions: {} +# jobs: +# python-app-ci: +# uses: netresearch/.github/.github/workflows/python-app-ci.yml@main +# permissions: +# actions: read +# contents: read +# security-events: write +# pull-requests: write +# id-token: write +# with: +# coverage-upload: true +# secrets: +# CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} +# +# SECURITY: secrets are forwarded explicitly (CODECOV_TOKEN, +# GITLEAKS_LICENSE), never via `secrets: inherit`. Composed first-party +# reusables track `@main` by policy (see .github/zizmor.yml); third-party +# actions inside those leaves are SHA-pinned. + +name: Python app CI (reusable) + +on: + workflow_call: + inputs: + package-manager: + description: "Python package manager: 'uv', 'pip', or 'poetry'." + type: string + default: "uv" + python-version: + description: "Python version for the build and audit jobs." + type: string + default: "3.14" + python-versions: + description: >- + JSON array string of Python versions the ci job matrices over + (e.g. '["3.13","3.14"]'). + type: string + default: '["3.14"]' + os-versions: + description: >- + JSON array string of runner labels the ci job matrices over + (e.g. '["ubuntu-latest","windows-latest"]'). + type: string + default: '["ubuntu-latest"]' + + install-cmd: + description: "Command that installs the project and its dev/test dependencies." + type: string + default: "uv sync --frozen --extra dev" + + # Lint + run-lint: + description: "Run the lint step in the ci job." + type: boolean + default: true + lint-cmd: + description: "Lint command." + type: string + default: "uv run ruff check ." + + # Type check + run-type-check: + description: "Run the type-check step in the ci job." + type: boolean + default: true + type-check-cmd: + description: "Type-check command." + type: string + default: "uv run mypy" + + # Tests + run-tests: + description: "Run the test step in the ci job." + type: boolean + default: true + test-cmd: + description: "Test command. Write a coverage XML to enable coverage-upload." + type: string + default: "uv run pytest --cov --cov-report=xml" + coverage-upload: + description: "Upload coverage to Codecov (in the ci job)." + type: boolean + default: false + + # Build + enable-build: + description: "Run the distribution build job." + type: boolean + default: true + build-cmd: + description: "Command that builds the distribution into `dist/`." + type: string + default: "uv build" + check-cmd: + description: "Command that validates the built distribution." + type: string + default: "uvx twine check dist/*" + + # Audit + python-version-file: + description: "Python version file for the audit job (.python-version, pyproject.toml)." + type: string + default: "pyproject.toml" + run-bandit: + description: "Run the bandit static-analysis security scan in the audit job." + type: boolean + default: true + + # Opt-in security scanners + enable-codeql: + description: "Run the CodeQL analysis job." + type: boolean + default: false + codeql-languages: + description: "CodeQL language(s) to analyze." + type: string + default: "python" + enable-gitleaks: + description: "Run the gitleaks secret-scanning job." + type: boolean + default: true + enable-scorecard: + description: "Run the OpenSSF Scorecard job." + type: boolean + default: false + enable-zizmor: + description: "Run the zizmor GitHub Actions static-analysis job." + type: boolean + default: false + secrets: + CODECOV_TOKEN: + description: "Codecov upload token. Required for private repositories when `coverage-upload` is true." + required: false + GITLEAKS_LICENSE: + description: "Deprecated / accepted for backwards compatibility; forwarded to the gitleaks job." + required: false + +# CALLER REQUIREMENTS +# =================== +# This meta-reusable composes leaves that need SARIF-upload, PR-write and +# OIDC scopes, so its caller must grant the UNION of every composed job's +# requirements. When calling this reusable workflow, the caller's job-level +# `permissions:` block MUST grant at least this full set, otherwise the run +# fails with `startup_failure` before any job executes (GitHub rejects the +# workflow at startup when a nested reusable declares a permission the +# caller did not grant). NOTE: setting ANY scope in a `permissions:` block +# forces every UNLISTED scope to `none`, and a job that omits `permissions:` +# inherits the repository default (default_workflow_permissions) — so a repo +# hardened to `read` will startup-fail here unless these scopes are granted +# explicitly. Secrets are forwarded explicitly (CODECOV_TOKEN, +# GITLEAKS_LICENSE); never use `secrets: inherit`. +# +# id-token: write is only consumed when enable-scorecard is true; the other +# scopes cover codeql/gitleaks/zizmor (security-events), dependency-review +# (pull-requests) and scorecard/codeql (actions). Grant the full union so +# toggling a scanner on never re-introduces a latent startup-failure. +# +# jobs: +# python-app-ci: +# uses: netresearch/.github/.github/workflows/python-app-ci.yml@main +# permissions: +# actions: read +# contents: read +# security-events: write +# pull-requests: write +# id-token: write +permissions: {} + +jobs: + ci: + name: CI + uses: netresearch/.github/.github/workflows/python-ci.yml@main + permissions: + contents: read + with: + package-manager: ${{ inputs.package-manager }} + os-versions: ${{ inputs.os-versions }} + python-versions: ${{ inputs.python-versions }} + install-cmd: ${{ inputs.install-cmd }} + run-lint: ${{ inputs.run-lint }} + lint-cmd: ${{ inputs.lint-cmd }} + run-type-check: ${{ inputs.run-type-check }} + type-check-cmd: ${{ inputs.type-check-cmd }} + run-tests: ${{ inputs.run-tests }} + test-cmd: ${{ inputs.test-cmd }} + upload-coverage-codecov: ${{ inputs.coverage-upload }} + secrets: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + build: + name: Build + if: ${{ inputs.enable-build }} + uses: netresearch/.github/.github/workflows/python-build.yml@main + permissions: + contents: read + with: + package-manager: ${{ inputs.package-manager }} + python-version: ${{ inputs.python-version }} + build-cmd: ${{ inputs.build-cmd }} + check-cmd: ${{ inputs.check-cmd }} + + audit: + name: Audit + uses: netresearch/.github/.github/workflows/python-audit.yml@main + permissions: + contents: read + with: + python-version-file: ${{ inputs.python-version-file }} + package-manager: ${{ inputs.package-manager }} + run-bandit: ${{ inputs.run-bandit }} + + codeql: + name: CodeQL + if: ${{ inputs.enable-codeql }} + uses: netresearch/.github/.github/workflows/codeql.yml@main + permissions: + actions: read + contents: read + security-events: write + with: + languages: ${{ inputs.codeql-languages }} + + gitleaks: + name: Secret Scanning + if: ${{ inputs.enable-gitleaks }} + uses: netresearch/.github/.github/workflows/gitleaks.yml@main + permissions: + contents: read + security-events: write + secrets: + GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }} + + dependency-review: + name: Dependency Review + if: ${{ github.event_name == 'pull_request' }} + uses: netresearch/.github/.github/workflows/dependency-review.yml@main + permissions: + contents: read + pull-requests: write + + zizmor: + name: zizmor + if: ${{ inputs.enable-zizmor }} + uses: netresearch/.github/.github/workflows/zizmor.yml@main + permissions: + contents: read + security-events: write + + scorecard: + name: Scorecard + if: ${{ inputs.enable-scorecard }} + uses: netresearch/.github/.github/workflows/scorecard.yml@main + permissions: + contents: read + security-events: write + id-token: write + actions: read diff --git a/.github/workflows/python-release.yml b/.github/workflows/python-release.yml new file mode 100644 index 0000000..304385b --- /dev/null +++ b/.github/workflows/python-release.yml @@ -0,0 +1,231 @@ +# Reusable "Python release" workflow — publishes a Python package on a +# version tag: build → optional TestPyPI → PyPI via OIDC Trusted +# Publishing → optional GitHub Release. +# +# PyPI upload uses pypa/gh-action-pypi-publish with OpenID Connect +# (Trusted Publishing) — NO API token is passed. The publish job runs in +# a GitHub Environment (`pypi-environment`) so PyPI can bind the OIDC +# identity to a specific repo + workflow + environment, and so the +# environment's deployment protection rules gate the privileged upload. +# +# ┌─────────────────────────────────────────────────────────────────────┐ +# │ PREREQUISITE — Trusted Publishing MUST be configured first. │ +# │ Before this workflow can go green, a maintainer configures the │ +# │ publisher on PyPI (project → Publishing → Add a GitHub publisher): │ +# │ owner/repo, workflow filename (the *caller's* release.yml), and │ +# │ the environment name passed as `pypi-environment` (default │ +# │ "pypi"). TestPyPI is configured separately on test.pypi.org. │ +# │ Internal apps that never publish to PyPI set `publish-pypi: false` │ +# │ (and `publish-testpypi: false`) for a GitHub-Release-only run. │ +# └─────────────────────────────────────────────────────────────────────┘ +# +# Caller pattern (PyPI + GitHub Release via OIDC): +# +# name: Release +# on: +# push: +# tags: ['v*.*.*'] +# permissions: {} +# jobs: +# release: +# uses: netresearch/.github/.github/workflows/python-release.yml@main +# permissions: +# contents: write +# id-token: write +# +# Caller pattern (internal app — GitHub Release only, no PyPI): +# +# jobs: +# release: +# uses: netresearch/.github/.github/workflows/python-release.yml@main +# permissions: +# contents: write +# id-token: write +# with: +# publish-pypi: false +# +# SECURITY: pinned action SHAs, harden-runner, least-privilege per-job +# permissions, `persist-credentials: false` on checkout. Caller-supplied +# commands are routed through `env:` and executed with `bash -c "$VAR"`; +# the release tag is read from `github.ref_name` via `env:` (never +# interpolated into a run block). No `github.event.*` data reaches a run +# block. Inputs arrive via `workflow_call` from trusted callers. + +name: Python Release (reusable) + +on: + workflow_call: + inputs: + package-manager: + description: "Python package manager used for the build: 'uv', 'pip', or 'poetry'." + type: string + default: "uv" + python-version: + description: "Python version for actions/setup-python." + type: string + default: "3.14" + install-cmd: + description: "Optional command that installs build tooling before the build. Empty to skip (uv build is self-contained)." + type: string + default: "" + build-cmd: + description: "Command that builds the distribution into `dist/`. Executed via bash -c." + type: string + default: "uv build" + publish-pypi: + description: "Publish the built distribution to PyPI via OIDC Trusted Publishing." + type: boolean + default: true + publish-testpypi: + description: "Publish the built distribution to TestPyPI (test.pypi.org) via OIDC Trusted Publishing." + type: boolean + default: false + create-github-release: + description: "Create a GitHub Release for the tag with auto-generated notes." + type: boolean + default: true + pypi-environment: + description: >- + Name of the GitHub Environment the publish job runs in. Must match + the environment configured on PyPI's Trusted Publisher for this + repo. Its deployment protection rules gate the PyPI upload. + type: string + default: "pypi" + +# CALLER REQUIREMENTS +# =================== +# When calling this reusable workflow, the caller's job-level +# `permissions:` block MUST grant at least this full set, otherwise +# the run fails with `startup_failure` before any job executes +# (GitHub rejects the workflow at startup when the reusable declares +# a permission the caller did not grant). NOTE: setting ANY scope in a +# `permissions:` block forces every UNLISTED scope to `none`, and a job +# that omits `permissions:` inherits the repository default +# (default_workflow_permissions) — so a repo hardened to `read` will +# startup-fail here unless these scopes are granted explicitly. +# +# - contents: write — create the GitHub Release +# - id-token: write — OIDC identity for PyPI Trusted Publishing +# +# jobs: +# release: +# uses: netresearch/.github/.github/workflows/python-release.yml@main +# permissions: +# contents: write +# id-token: write +permissions: {} + +jobs: + build: + name: Build distribution + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - name: Harden Runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Install uv + if: ${{ inputs.package-manager == 'uv' }} + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + with: + enable-cache: true + + - name: Setup Python ${{ inputs.python-version }} (uv) + if: ${{ inputs.package-manager == 'uv' }} + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: ${{ inputs.python-version }} + + - name: Setup Python ${{ inputs.python-version }} + if: ${{ inputs.package-manager != 'uv' }} + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: ${{ inputs.python-version }} + + - name: Install build tooling + if: ${{ inputs.install-cmd != '' }} + env: + INSTALL_CMD: ${{ inputs.install-cmd }} + run: bash -c "$INSTALL_CMD" + + - name: Build distribution + env: + BUILD_CMD: ${{ inputs.build-cmd }} + run: bash -c "$BUILD_CMD" + + - name: Upload distribution artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: dist + path: dist/ + if-no-files-found: error + retention-days: 7 + + publish: + name: Publish to PyPI + needs: build + if: ${{ inputs.publish-pypi || inputs.publish-testpypi }} + runs-on: ubuntu-latest + timeout-minutes: 15 + # Environment gate is on the publish job ONLY — its deployment + # protection rules guard the privileged OIDC upload, not the build or + # the GitHub Release. + environment: ${{ inputs.pypi-environment }} + permissions: + id-token: write + steps: + - name: Harden Runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Download distribution artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: dist + path: dist/ + + - name: Publish to TestPyPI + if: ${{ inputs.publish-testpypi }} + uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1 + with: + repository-url: https://test.pypi.org/legacy/ + skip-existing: true + + - name: Publish to PyPI + if: ${{ inputs.publish-pypi }} + uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1 + + github-release: + name: Create GitHub Release + needs: build + if: ${{ inputs.create-github-release }} + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + steps: + - name: Harden Runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + run: gh release create "$TAG" --generate-notes diff --git a/docs/reusable-workflow-permissions.md b/docs/reusable-workflow-permissions.md index fb6250b..5485b42 100644 --- a/docs/reusable-workflow-permissions.md +++ b/docs/reusable-workflow-permissions.md @@ -67,7 +67,9 @@ truth; this table is the index.) | `ghcr-retention.yml` | `packages: write` | | `security-container.yml` | `contents: read`, `security-events: write`, `packages: read` | | `node-ci.yml` | `actions: read`, `contents: read`, `security-events: write`, `pull-requests: write` | -| `lint-*.yml` / `php-ci.yml` / `python-*.yml` / `ansible-lint.yml` / `ansible-molecule.yml` / `ts-check.yml` / `node-audit.yml` / `node-test.yml` / `node-build.yml` / `sonarqube.yml` / `smoke-test-container.yml` / `lint-container.yml` / `check-template-drift.yml` | `contents: read` | +| `python-app-ci.yml` | `actions: read`, `contents: read`, `security-events: write`, `pull-requests: write`, `id-token: write` | +| `python-release.yml` | `contents: write`, `id-token: write` | +| `lint-*.yml` / `php-ci.yml` / `python-ci.yml` / `python-build.yml` / `python-audit.yml` / `ansible-lint.yml` / `ansible-molecule.yml` / `ts-check.yml` / `node-audit.yml` / `node-test.yml` / `node-build.yml` / `sonarqube.yml` / `smoke-test-container.yml` / `lint-container.yml` / `check-template-drift.yml` | `contents: read` | ### `netresearch/typo3-ci-workflows`