diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index 071e295..0227e17 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -5,13 +5,25 @@ on: tags: - 'v*.*.*' +# Least-privilege default: read-only token; the changelog job widens as needed. +permissions: + contents: read + jobs: changelog: runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write steps: + # Mint a short-lived, scoped token from the fleet automation GitHub App. A PR + # opened with the default GITHUB_TOKEN does not trigger CI or the label-gated + # publish, so the automation needs a distinct identity; the App token gives it + # one without a long-lived personal access token. + - name: Generate a token from the fleet automation GitHub App + id: app-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + - name: Checkout repository uses: actions/checkout@v7 with: @@ -63,7 +75,7 @@ jobs: - name: Create Pull Request for CHANGELOG uses: peter-evans/create-pull-request@v8 with: - token: ${{ secrets.CHANGELOG_AUTOMATION_TOKEN }} + token: ${{ steps.app-token.outputs.token }} add-paths: | CHANGELOG.md commit-message: "chore(release): update CHANGELOG.md for ${{ steps.changelog.outputs.version }}" diff --git a/.github/workflows/commit-message.yml b/.github/workflows/commit-message.yml index 96101ad..d0f4cf8 100644 --- a/.github/workflows/commit-message.yml +++ b/.github/workflows/commit-message.yml @@ -5,6 +5,10 @@ on: types: [opened, edited, synchronize, reopened] branches: [main] +# Least-privilege default: read-only token. +permissions: + contents: read + jobs: validate-commit-message: name: Validate Commit Message diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml index e236694..9520fe2 100644 --- a/.github/workflows/pr-title.yml +++ b/.github/workflows/pr-title.yml @@ -5,6 +5,10 @@ on: types: [opened, edited, synchronize, reopened] branches: [main] +# Least-privilege default: read-only token; the job below widens to pull-requests:read. +permissions: + contents: read + jobs: validate-pr-title: name: Validate PR Title diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 959adfa..f74aee6 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -6,6 +6,10 @@ on: branches: - main +# Least-privilege default: read-only token; the create-release job widens to contents:write. +permissions: + contents: read + jobs: create-release: # Only run if PR was merged and has the changelog label diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 597607e..7413966 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,6 +12,10 @@ concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true +# Least-privilege default: read-only token. No job here writes. +permissions: + contents: read + jobs: # Fast tests run on every push and PR across all OS and Python versions test-fast: @@ -92,6 +96,26 @@ jobs: # renovate: datasource=pypi depName=nox uvx nox@2026.7.11 -s fix + # Secret scanning. Runs the gitleaks binary directly (not gitleaks-action, which + # requires a paid license for private org repos). It is a dependency of the ci-passed + # roll-up below, so a detected secret fails the single required merge check. + secret-scan: + name: Secret scanning (gitleaks) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Install gitleaks + run: | + # renovate: datasource=github-releases depName=gitleaks/gitleaks + GITLEAKS_VERSION=8.24.3 + curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" | tar -xz -C /usr/local/bin gitleaks + + - name: Scan for secrets + run: gitleaks detect --source . --redact --verbose + # Single required status check. The branch ruleset requires this one job, not the # individual jobs above: test-fast is a matrix (its check-run names vary by OS/Python and # differ between draft and ready PRs) and test-full is skipped on draft PRs, so neither @@ -106,6 +130,7 @@ jobs: - test-fast - test-full - lint + - secret-scan runs-on: ubuntu-latest steps: - name: Fail if any required job did not pass diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5e6b75f..07d79b1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -30,6 +30,15 @@ repos: - id: commitizen stages: [commit-msg] + # Secret scanning: block secrets before they enter history. prek runs the official + # gitleaks hook (its golang language is supported). The gitleaks CI job is the + # un-bypassable backstop; this hook is the earliest catch, so it stays at pre-commit + # even though the refusing whole-project hooks (interrogate) run at pre-push. + - repo: https://github.com/gitleaks/gitleaks + rev: v8.24.3 + hooks: + - id: gitleaks + # Version-sensitive linters run from the project environment so their single # source of truth is uv.lock. --locked makes a stale uv.lock fail loudly # instead of silently diverging from CI. (Dependabot's "uv" ecosystem then diff --git a/copier.yml b/copier.yml index 731bcd4..7937873 100644 --- a/copier.yml +++ b/copier.yml @@ -113,6 +113,20 @@ license: - "GPL-3.0" - "Proprietary" +repo_visibility: + type: str + # Drives which paid, visibility-sensitive services are provisioned. Almost every + # "paid" security service (CodeQL, Codecov, GitHub-native secret scanning) is paid + # only because the repository is private -- each is free on public repos. Capturing + # visibility once lets the template default those services on for public repos and + # substitute the free OSS equivalents (ruff S, gitleaks) on private ones, instead of + # exposing one flag per service. + help: "Repository visibility (public repos get free paid-tier services; private repos get OSS equivalents)" + default: "public" + choices: + - "public" + - "private" + min_python_version: type: str help: "Minimum Python version" @@ -139,6 +153,15 @@ include_actions: help: "Include GitHub Actions CI/CD workflows?" default: true +include_codecov: + type: bool + # Codecov coverage upload. Free on public repos, a paid org plan on private ones, so + # the default follows repo_visibility. Overridable: a private project willing to pay + # can set this true. Gated on include_actions because the upload runs in CI. + help: "Upload coverage to Codecov? (free on public repos, paid on private)" + default: "{{ repo_visibility == 'public' }}" + when: "{{ include_actions }}" + uv_version: type: str # Pinned exact uv version for the CI setup-uv steps. Pinning an exact X.Y.Z diff --git a/pyproject.toml b/pyproject.toml index 550ef94..cb496c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -123,6 +123,7 @@ select = [ "C4", # flake8-comprehensions "UP", # pyupgrade "PL", # Pylint + "S", # flake8-bandit (security) "T10", # flake8-debugger "T201", # Print statement ] @@ -137,7 +138,7 @@ ignore = [ ] [tool.ruff.lint.per-file-ignores] -"tests/**/*" = ["T201", "PLR2004", "PLC0415"] +"tests/**/*" = ["T201", "PLR2004", "PLC0415", "S101", "S603", "S607", "S506"] # asserts, subprocess to git/uv/copier, and yaml.load of generated files are expected in the template test harness [tool.rumdl] flavor = "mkdocs" diff --git a/template/.claude/skills/update-from-template/references/file-classification.md b/template/.claude/skills/update-from-template/references/file-classification.md index ce49148..374cf8d 100644 --- a/template/.claude/skills/update-from-template/references/file-classification.md +++ b/template/.claude/skills/update-from-template/references/file-classification.md @@ -67,6 +67,7 @@ renovate.json .github/ISSUE_TEMPLATE/config.yml .github/ISSUE_TEMPLATE/feature_request.yml .github/PULL_REQUEST_TEMPLATE.md +SECURITY.md .claude/skills/** # Skill files managed by template (the tracked copy) .github/skills/** # Byte-identical Copilot mirror; gitignored, so an # update never delivers it -- copier works through @@ -95,6 +96,9 @@ docs/pages/how-to/contribute.md .github/workflows/nightly.yml # conditional: include_actions .github/workflows/changelog.yml # conditional: include_actions .github/workflows/commit-message.yml # conditional: include_actions +.github/workflows/codeql.yml # conditional: include_actions + public repo_visibility +.github/workflows/scorecard.yml # conditional: include_actions + public repo_visibility +CODEOWNERS ``` --- diff --git a/template/.copier-answers.yml.jinja b/template/.copier-answers.yml.jinja index c908dbf..274999d 100644 --- a/template/.copier-answers.yml.jinja +++ b/template/.copier-answers.yml.jinja @@ -8,6 +8,7 @@ author_name: {{ author_name }} description: {{ description }} github_username: {{ github_username }} include_actions: {{ include_actions }} +include_codecov: {{ include_codecov }} include_examples: {{ include_examples }} license: {{ license }} max_python_version: '{{ max_python_version }}' @@ -16,4 +17,5 @@ package_name: {{ package_name }} project_name: {{ project_name }} project_slug: {{ project_slug }} renovate_preset: '{{ renovate_preset }}' +repo_visibility: {{ repo_visibility }} uv_version: '{{ uv_version }}' diff --git a/template/.github/skills/update-from-template/references/file-classification.md b/template/.github/skills/update-from-template/references/file-classification.md index ce49148..374cf8d 100644 --- a/template/.github/skills/update-from-template/references/file-classification.md +++ b/template/.github/skills/update-from-template/references/file-classification.md @@ -67,6 +67,7 @@ renovate.json .github/ISSUE_TEMPLATE/config.yml .github/ISSUE_TEMPLATE/feature_request.yml .github/PULL_REQUEST_TEMPLATE.md +SECURITY.md .claude/skills/** # Skill files managed by template (the tracked copy) .github/skills/** # Byte-identical Copilot mirror; gitignored, so an # update never delivers it -- copier works through @@ -95,6 +96,9 @@ docs/pages/how-to/contribute.md .github/workflows/nightly.yml # conditional: include_actions .github/workflows/changelog.yml # conditional: include_actions .github/workflows/commit-message.yml # conditional: include_actions +.github/workflows/codeql.yml # conditional: include_actions + public repo_visibility +.github/workflows/scorecard.yml # conditional: include_actions + public repo_visibility +CODEOWNERS ``` --- diff --git a/template/.github/{% if include_actions %}workflows{% endif %}/changelog.yml.jinja b/template/.github/{% if include_actions %}workflows{% endif %}/changelog.yml.jinja index 170f044..1dfdaf5 100644 --- a/template/.github/{% if include_actions %}workflows{% endif %}/changelog.yml.jinja +++ b/template/.github/{% if include_actions %}workflows{% endif %}/changelog.yml.jinja @@ -1,20 +1,30 @@ -name: Build and create changelog PR +name: Create changelog PR on: push: tags: - 'v*.*.*' -env: - dists-artifact-name: python-package-distributions +# Least-privilege default: read-only token unless a job widens it explicitly. The PR +# is opened with a GitHub App token (below), not GITHUB_TOKEN, so no job needs write. +permissions: + contents: read jobs: changelog: runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write steps: + # Mint a short-lived, scoped token from the fleet automation GitHub App. A PR + # opened with the default GITHUB_TOKEN does not trigger CI or the label-gated + # publish, so the automation needs a distinct identity; the App token gives it + # one without a long-lived personal access token. + - name: Generate a token from the fleet automation GitHub App + id: app-token + uses: actions/create-github-app-token@v2 + with: + app-id: {% raw %}${{ secrets.APP_ID }}{% endraw %} + private-key: {% raw %}${{ secrets.APP_PRIVATE_KEY }}{% endraw %} + - name: Checkout repository uses: actions/checkout@v7 with: @@ -69,7 +79,7 @@ jobs: - name: Create Pull Request for CHANGELOG uses: peter-evans/create-pull-request@v8 with: - token: {% raw %}${{ secrets.CHANGELOG_AUTOMATION_TOKEN }}{% endraw %} + token: {% raw %}${{ steps.app-token.outputs.token }}{% endraw %} add-paths: | CHANGELOG.md commit-message: "chore(release): update CHANGELOG.md for {% raw %}${{ steps.changelog.outputs.version }}{% endraw %}" @@ -89,35 +99,3 @@ jobs: labels: | changelog automated - - build: - runs-on: ubuntu-latest - needs: - - changelog - steps: - - name: Checkout repository - uses: actions/checkout@v7 - with: - fetch-depth: 0 - ref: main - - - name: Install the latest version of uv - uses: astral-sh/setup-uv@v7 - with: - # renovate: datasource=github-releases depName=astral-sh/uv - version: "{{ uv_version }}" - enable-cache: true - cache-dependency-glob: "pyproject.toml" - github-token: {% raw %}${{ secrets.GITHUB_TOKEN }}{% endraw %} - - - name: Build package - run: uv build --python {{ min_python_version }} --python-preference only-managed --sdist --wheel . --out-dir dist - - - name: Check distributions - run: uvx twine check dist/* - - - name: Store the distribution packages - uses: actions/upload-artifact@v7 - with: - name: {% raw %}${{ env.dists-artifact-name }}{% endraw %} - path: dist/* diff --git a/template/.github/{% if include_actions %}workflows{% endif %}/commit-message.yml.jinja b/template/.github/{% if include_actions %}workflows{% endif %}/commit-message.yml.jinja index b6e9725..2a30c50 100644 --- a/template/.github/{% if include_actions %}workflows{% endif %}/commit-message.yml.jinja +++ b/template/.github/{% if include_actions %}workflows{% endif %}/commit-message.yml.jinja @@ -5,6 +5,10 @@ on: types: [opened, edited, synchronize, reopened] branches: [main] +# Least-privilege default: read-only token. +permissions: + contents: read + jobs: validate-commit-message: name: Validate Commit Message diff --git a/template/.github/{% if include_actions %}workflows{% endif %}/nightly.yml.jinja b/template/.github/{% if include_actions %}workflows{% endif %}/nightly.yml.jinja index e4445a1..a8ed950 100644 --- a/template/.github/{% if include_actions %}workflows{% endif %}/nightly.yml.jinja +++ b/template/.github/{% if include_actions %}workflows{% endif %}/nightly.yml.jinja @@ -6,6 +6,10 @@ on: - cron: "30 2 * * *" workflow_dispatch: # Allow manual triggering +# Least-privilege default: read-only token; create-issue-on-failure widens to issues:write. +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest @@ -15,9 +19,9 @@ jobs: {%- for v in versions if v >= min_python_version and v <= max_python_version -%} "{{ v }}"{{ ", " if not loop.last else "" }} {%- endfor -%}] - env: +{% if include_codecov %} env: CODECOV_TOKEN: {% raw %}${{ secrets.CODECOV_TOKEN }}{% endraw %} - steps: +{% endif %} steps: - uses: actions/checkout@v7 - name: Install uv @@ -41,12 +45,12 @@ jobs: - name: Run docstring tests run: nox -s test_docstrings - - name: Upload coverage reports +{% if include_codecov %} - name: Upload coverage reports uses: codecov/codecov-action@v7 if: {% raw %}${{ matrix.python-version == '3.12' && env.CODECOV_TOKEN != '' }}{% endraw %} with: token: {% raw %}${{ env.CODECOV_TOKEN }}{% endraw %} - +{% endif %} create-issue-on-failure: needs: test if: {% raw %}${{ failure() }}{% endraw %} diff --git a/template/.github/{% if include_actions %}workflows{% endif %}/pr-title.yml.jinja b/template/.github/{% if include_actions %}workflows{% endif %}/pr-title.yml.jinja index 578e779..2d22078 100644 --- a/template/.github/{% if include_actions %}workflows{% endif %}/pr-title.yml.jinja +++ b/template/.github/{% if include_actions %}workflows{% endif %}/pr-title.yml.jinja @@ -5,6 +5,10 @@ on: types: [opened, edited, synchronize, reopened] branches: [main] +# Least-privilege default: read-only token; the job below widens to pull-requests:read. +permissions: + contents: read + jobs: validate-pr-title: name: Validate PR Title diff --git a/template/.github/{% if include_actions %}workflows{% endif %}/publish-release.yml.jinja b/template/.github/{% if include_actions %}workflows{% endif %}/publish-release.yml.jinja index 895cba9..540841a 100644 --- a/template/.github/{% if include_actions %}workflows{% endif %}/publish-release.yml.jinja +++ b/template/.github/{% if include_actions %}workflows{% endif %}/publish-release.yml.jinja @@ -6,23 +6,26 @@ on: branches: - main +# Least-privilege default: read-only token unless a job widens it explicitly. +permissions: + contents: read + env: dists-artifact-name: python-package-distributions + sbom-artifact-name: sbom jobs: - create-release: + # Build at the release tag, so hatch-vcs resolves the exact version, and hand the + # artifacts to the release/publish jobs natively within this one workflow run. The + # build previously lived in changelog.yml and was fetched across workflows with a + # third-party action; keeping it here removes that third party from the release path. + build: # Only run if PR was merged and has the changelog label if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'changelog') runs-on: ubuntu-latest - permissions: - contents: write + outputs: + version: {% raw %}${{ steps.find_tag.outputs.version }}{% endraw %} steps: - - name: Checkout repository - uses: actions/checkout@v7 - with: - fetch-depth: 0 - ref: main - - name: Find release tag id: find_tag run: | @@ -37,20 +40,70 @@ jobs: echo "version=$VERSION" >> $GITHUB_OUTPUT + - name: Checkout the release tag + uses: actions/checkout@v7 + with: + fetch-depth: 0 + ref: {% raw %}${{ steps.find_tag.outputs.version }}{% endraw %} + lfs: true + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + # renovate: datasource=github-releases depName=astral-sh/uv + version: "{{ uv_version }}" + enable-cache: true + cache-dependency-glob: "pyproject.toml" + + - name: Build package + run: uv build --python {{ min_python_version }} --python-preference only-managed --sdist --wheel . --out-dir dist + + - name: Check distributions + run: uvx twine check dist/* + + # Software bill of materials (CycloneDX) for the release, generated from the + # locked dependency set so it enumerates exactly what this release resolves. + - name: Generate SBOM (CycloneDX) + run: | + uv export --frozen --no-emit-project --format requirements-txt -o sbom-requirements.txt + uvx --from cyclonedx-bom cyclonedx-py requirements sbom-requirements.txt \ + --output-format JSON --outfile sbom.cdx.json + + - name: Store the distribution packages + uses: actions/upload-artifact@v7 + with: + name: {% raw %}${{ env.dists-artifact-name }}{% endraw %} + path: dist/* + + - name: Store the SBOM + uses: actions/upload-artifact@v7 + with: + name: {% raw %}${{ env.sbom-artifact-name }}{% endraw %} + path: sbom.cdx.json + + create-release: + needs: + - build + runs-on: ubuntu-latest + permissions: + contents: write + steps: + # Check out main (not the tag): main carries the CHANGELOG entry the merged + # changelog PR just added, which the tag does not yet have. + - name: Checkout repository + uses: actions/checkout@v7 + with: + fetch-depth: 0 + ref: main + - name: Extract release notes from CHANGELOG id: release_notes run: | - VERSION="{% raw %}${{ steps.find_tag.outputs.version }}{% endraw %}" + VERSION="{% raw %}${{ needs.build.outputs.version }}{% endraw %}" # Strip the 'v' prefix for CHANGELOG lookup (e.g., v0.4.0 -> 0.4.0) VERSION_NO_V="${VERSION#v}" - # Debug: Show what we're working with - echo "Looking for version: $VERSION_NO_V (from tag: $VERSION)" - echo "First 100 lines of CHANGELOG.md:" - head -n 100 CHANGELOG.md - # Extract the section for this version from CHANGELOG.md - # This uses awk to get content between the version header and the next version header RELEASE_NOTES=$(awk -v ver="$VERSION_NO_V" ' /^## \[.*\]/ { if (found) exit; @@ -73,27 +126,32 @@ jobs: echo "$RELEASE_NOTES" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT - - name: Download release artifacts - uses: dawidd6/action-download-artifact@v21 + - name: Download the distribution packages + uses: actions/download-artifact@v7 with: - workflow: changelog.yml name: {% raw %}${{ env.dists-artifact-name }}{% endraw %} path: dist/ - search_artifacts: true + + - name: Download the SBOM + uses: actions/download-artifact@v7 + with: + name: {% raw %}${{ env.sbom-artifact-name }}{% endraw %} + path: sbom/ - name: Create GitHub Release env: GH_TOKEN: {% raw %}${{ secrets.GITHUB_TOKEN }}{% endraw %} run: | - VERSION="{% raw %}${{ steps.find_tag.outputs.version }}{% endraw %}" + VERSION="{% raw %}${{ needs.build.outputs.version }}{% endraw %}" gh release create "$VERSION" \ --title "$VERSION" \ --notes "{% raw %}${{ steps.release_notes.outputs.notes }}{% endraw %}" \ - dist/* + dist/* sbom/sbom.cdx.json pypi-publish: name: Publish to PyPI needs: + - build - create-release runs-on: ubuntu-latest environment: @@ -102,37 +160,15 @@ jobs: permissions: id-token: write steps: - - name: Checkout repository - uses: actions/checkout@v7 - with: - fetch-depth: 0 - ref: main - - - name: Find release tag - id: find_tag - run: | - # Extract version from PR title (e.g., "chore(release): update CHANGELOG.md for v0.2.0" or "v0.1.0-alpha.1") - PR_TITLE="{% raw %}${{ github.event.pull_request.title }}{% endraw %}" - VERSION=$(echo "$PR_TITLE" | grep -oP 'v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9]+(\.[0-9]+)?)?' || echo "") - - if [ -z "$VERSION" ]; then - echo "No version found in PR title" - exit 1 - fi - - echo "version=$VERSION" >> $GITHUB_OUTPUT - - - name: Download release artifacts - uses: dawidd6/action-download-artifact@v21 + - name: Download the distribution packages + uses: actions/download-artifact@v7 with: - workflow: changelog.yml name: {% raw %}${{ env.dists-artifact-name }}{% endraw %} path: dist/ - search_artifacts: true - workflow_conclusion: success - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + # renovate: datasource=github-releases depName=pypa/gh-action-pypi-publish + uses: pypa/gh-action-pypi-publish@v1.13.0 with: attestations: true verify-metadata: false diff --git a/template/.github/{% if include_actions %}workflows{% endif %}/tests.yml.jinja b/template/.github/{% if include_actions %}workflows{% endif %}/tests.yml.jinja index 156a0d3..d1b053a 100644 --- a/template/.github/{% if include_actions %}workflows{% endif %}/tests.yml.jinja +++ b/template/.github/{% if include_actions %}workflows{% endif %}/tests.yml.jinja @@ -8,6 +8,10 @@ on: types: [opened, synchronize, reopened, ready_for_review] workflow_dispatch: +# Least-privilege default: read-only token. No job here writes. +permissions: + contents: read + concurrency: group: {% raw %}${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}{% endraw %} cancel-in-progress: true @@ -103,9 +107,9 @@ jobs: {%- for v in versions if v >= min_python_version and v <= max_python_version -%} "{{ v }}"{{ ", " if not loop.last else "" }} {%- endfor -%}] - env: +{% if include_codecov %} env: CODECOV_TOKEN: {% raw %}${{ secrets.CODECOV_TOKEN }}{% endraw %} - +{% endif %} steps: - name: Checkout code uses: actions/checkout@v7 @@ -135,7 +139,7 @@ jobs: if: {% raw %}matrix.python-version != '{% endraw %}{{ min_python_version }}{% raw %}'{% endraw %} run: nox -s test --python {% raw %}${{ matrix.python-version }}{% endraw %} - - name: Upload test results to Codecov +{% if include_codecov %} - name: Upload test results to Codecov if: {% raw %}${{ !cancelled() && matrix.python-version == '{% endraw %}{{ min_python_version }}{% raw %}' && env.CODECOV_TOKEN != '' }}{% endraw %} uses: codecov/test-results-action@v1 with: @@ -150,7 +154,7 @@ jobs: files: {% raw %}'${{ github.workspace }}/coverage.${{ matrix.python-version }}.xml'{% endraw %} env_vars: OS,PYTHON fail_ci_if_error: true - +{% endif %} lint: name: Lint and format runs-on: ubuntu-latest @@ -338,6 +342,29 @@ jobs: run: nox -s test_examples {% endif %} + # Secret scanning. Runs the gitleaks binary directly (not gitleaks-action, which + # requires a paid license for private org repos) so it works identically on public + # and private repos. This is the CI backstop to the pre-commit gitleaks hook: it + # cannot be bypassed with --no-verify, and it is a dependency of the ci-passed + # roll-up below, so a detected secret fails the single required merge check. + secret-scan: + name: Secret scanning (gitleaks) + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Install gitleaks + run: | + # renovate: datasource=github-releases depName=gitleaks/gitleaks + GITLEAKS_VERSION=8.24.3 + curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" | tar -xz -C /usr/local/bin gitleaks + + - name: Scan for secrets + run: gitleaks detect --source . --redact --verbose + # Single required status check. The branch ruleset requires this one job, not the # individual jobs above: test-fast is a matrix (its check-run names vary by OS/Python and # differ between draft and ready PRs) and test-full is skipped on draft PRs, so neither @@ -359,6 +386,7 @@ jobs: - lint - test_docstrings - check_docs + - secret-scan {% if include_examples %} - examples {% endif %} runs-on: ubuntu-latest steps: diff --git a/template/.github/{% if include_actions %}workflows{% endif %}/{% if repo_visibility == 'public' %}codeql.yml{% endif %}.jinja b/template/.github/{% if include_actions %}workflows{% endif %}/{% if repo_visibility == 'public' %}codeql.yml{% endif %}.jinja new file mode 100644 index 0000000..efcc96b --- /dev/null +++ b/template/.github/{% if include_actions %}workflows{% endif %}/{% if repo_visibility == 'public' %}codeql.yml{% endif %}.jinja @@ -0,0 +1,33 @@ +name: CodeQL + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + # Weekly, so a newly-disclosed query pattern still runs against unchanged code. + - cron: "27 3 * * 1" + +# Least-privilege default: read-only token; the analyze job widens as needed. +permissions: + contents: read + +jobs: + analyze: + name: Analyze (Python) + runs-on: ubuntu-latest + permissions: + # Required to upload CodeQL results to the Security tab. + security-events: write + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: python + + - name: Perform CodeQL analysis + uses: github/codeql-action/analyze@v3 diff --git a/template/.github/{% if include_actions %}workflows{% endif %}/{% if repo_visibility == 'public' %}scorecard.yml{% endif %}.jinja b/template/.github/{% if include_actions %}workflows{% endif %}/{% if repo_visibility == 'public' %}scorecard.yml{% endif %}.jinja new file mode 100644 index 0000000..d7801b9 --- /dev/null +++ b/template/.github/{% if include_actions %}workflows{% endif %}/{% if repo_visibility == 'public' %}scorecard.yml{% endif %}.jinja @@ -0,0 +1,41 @@ +name: OpenSSF Scorecard + +on: + branch_protection_rule: + schedule: + # Weekly independent grade of the repo's security posture. + - cron: "21 4 * * 2" + push: + branches: [main] + +# Least-privilege default: read-only token; the analysis job widens as needed. +permissions: + contents: read + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-latest + permissions: + # Upload the results to the Security tab and publish to the OpenSSF API. + security-events: write + id-token: write + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Run analysis + uses: ossf/scorecard-action@v2 + with: + results_file: results.sarif + results_format: sarif + repo_token: {% raw %}${{ github.token }}{% endraw %} + # Publish results to the OpenSSF Scorecard API (public repos only). + publish_results: true + + - name: Upload SARIF to code scanning + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: results.sarif diff --git a/template/.pre-commit-config.yaml.jinja b/template/.pre-commit-config.yaml.jinja index d840f15..8415a9b 100644 --- a/template/.pre-commit-config.yaml.jinja +++ b/template/.pre-commit-config.yaml.jinja @@ -32,6 +32,15 @@ default_install_hook_types: [pre-commit, commit-msg, pre-push] stages: [commit-msg] {% endif %} + # Secret scanning: block secrets before they enter history. prek runs the official + # gitleaks hook (its golang language is supported). The gitleaks CI job is the + # un-bypassable backstop; this hook is the earliest catch, so it stays at pre-commit + # even though the refusing whole-project hooks (ty) run at pre-push. + - repo: https://github.com/gitleaks/gitleaks + rev: v8.24.3 + hooks: + - id: gitleaks + # Version-sensitive linters run from the project environment so their single # source of truth is uv.lock. --locked makes a stale uv.lock fail loudly # instead of silently diverging from CI. (Dependabot's "uv" ecosystem then diff --git a/template/CODEOWNERS.jinja b/template/CODEOWNERS.jinja new file mode 100644 index 0000000..1aa9224 --- /dev/null +++ b/template/CODEOWNERS.jinja @@ -0,0 +1,6 @@ +# Code owners for {{ project_name }}. +# +# Each line maps a path pattern to the owner(s) requested for review on changes. +# For an organization repo, replace the owner below with a team (e.g. @{{ github_username }}/maintainers), +# since an org name alone is not a valid code owner. +* @{{ github_username }} diff --git a/template/README.md.jinja b/template/README.md.jinja index 72ae1a3..fd2db62 100644 --- a/template/README.md.jinja +++ b/template/README.md.jinja @@ -11,8 +11,8 @@ [![License](https://img.shields.io/github/license/{{ github_username }}/{{ project_slug }})](https://github.com/{{ github_username }}/{{ project_slug }}/blob/main/LICENSE) [![PyPI Version](https://img.shields.io/pypi/v/{{ package_name }})](https://pypi.org/project/{{ package_name }}/) [![Conda Version](https://img.shields.io/conda/vn/conda-forge/{{ package_name }})](https://anaconda.org/conda-forge/{{ package_name }}) -[![codecov](https://codecov.io/gh/{{ github_username }}/{{ project_slug }}/branch/main/graph/badge.svg)](https://codecov.io/gh/{{ github_username }}/{{ project_slug }}) - +{% if include_codecov %}[![codecov](https://codecov.io/gh/{{ github_username }}/{{ project_slug }}/branch/main/graph/badge.svg)](https://codecov.io/gh/{{ github_username }}/{{ project_slug }}) +{% endif %} ## What is {{ project_name }}? {{ description }} diff --git a/template/SECURITY.md.jinja b/template/SECURITY.md.jinja new file mode 100644 index 0000000..7e9f25e --- /dev/null +++ b/template/SECURITY.md.jinja @@ -0,0 +1,15 @@ +# Security Policy + +## Supported versions + +Security fixes are released for the latest published version of {{ project_name }}. + +## Reporting a vulnerability + +Please report security vulnerabilities privately through GitHub's +[private vulnerability reporting](https://github.com/{{ github_username }}/{{ project_slug }}/security/advisories/new) +rather than opening a public issue. + +We aim to acknowledge a report within a few working days and will keep you updated as +we investigate and prepare a fix. Please give us a reasonable window to release a patch +before any public disclosure. diff --git a/template/pyproject.toml.jinja b/template/pyproject.toml.jinja index e894089..fea3431 100644 --- a/template/pyproject.toml.jinja +++ b/template/pyproject.toml.jinja @@ -172,6 +172,7 @@ select = [ "ARG", # flake8-unused-arguments "SIM", # flake8-simplify "PL", # Pylint + "S", # flake8-bandit (security) "T10", # flake8-debugger "T201", # Print statement ] @@ -187,18 +188,18 @@ ignore = [ ] [tool.ruff.lint.per-file-ignores] -"tests/**/*" = ["ARG", "S101", "T201", "PLR2004"] # Allow assert statements, prints, and magic values in tests +"tests/**/*" = ["ARG", "S101", "S603", "S607", "T201", "PLR2004"] # Allow assert statements, subprocess calls, prints, and magic values in tests # The template's documentation build tooling lives in docs_build/. Every script # there prints build progress and may hold per-build caches. # # Deliberately `docs_build/*.py`, NOT `docs_build/_*.py`: the narrower glob would # silently drop coverage for any un-underscored script, which is the failure # shape this fleet specialises in. The pattern matches what the directory is for. -"docs_build/*.py" = ["T201", "PLW0603"] # Allow prints and per-build cache globals in every build script +"docs_build/*.py" = ["T201", "PLW0603", "S506", "S603", "S112"] # Build tooling: prints, per-build cache globals, mkdocs.yml full-loader, subprocess to run notebooks, and skip-on-error loops over controlled input # `docs/*.py` is kept too, for a project's OWN scripts under docs/ (yohou has # docs/precache_datasets.py) -- the template ships none there any more, but a # project script prints progress the same way and should not lose its exemption. -"docs/*.py" = ["T201", "PLW0603"] +"docs/*.py" = ["T201", "PLW0603", "S506", "S603", "S112"] [tool.ruff.lint.isort] known-first-party = ["{{ package_name }}"] diff --git a/tests/conftest.py b/tests/conftest.py index 3ab7c7c..b2824d0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -30,8 +30,10 @@ def copy(self, extra_answers: dict | None = None): "min_python_version": "3.11", "max_python_version": "3.14", "license": "MIT", + "repo_visibility": "public", "include_actions": True, "include_examples": True, + "include_codecov": True, "uv_version": "0.10.0", "renovate_preset": "", } @@ -118,8 +120,10 @@ def copie_session_default(session_projects_dir): "min_python_version": "3.11", "max_python_version": "3.14", "license": "MIT", + "repo_visibility": "public", "include_actions": True, "include_examples": True, + "include_codecov": True, "uv_version": "0.10.0", "renovate_preset": "", } @@ -165,6 +169,7 @@ def copie_session_minimal(session_projects_dir): "min_python_version": "3.11", "max_python_version": "3.14", "license": "MIT", + "repo_visibility": "public", "include_actions": False, "include_examples": False, "uv_version": "0.10.0", @@ -213,8 +218,10 @@ def copie_session_custom(session_projects_dir): "min_python_version": "3.12", "max_python_version": "3.14", "license": "Apache-2.0", + "repo_visibility": "public", "include_actions": True, "include_examples": True, + "include_codecov": True, "uv_version": "0.10.0", "renovate_preset": "", } diff --git a/tests/test_github_workflows.py b/tests/test_github_workflows.py index 8b1c2e8..8f2a877 100644 --- a/tests/test_github_workflows.py +++ b/tests/test_github_workflows.py @@ -238,22 +238,27 @@ def test_publish_workflow_pypi_job_dependencies(self, copie): assert "name: pypi" in workflow_content def test_changelog_workflow_no_pypi_job(self, copie): - """Test that changelog workflow does not publish to PyPI.""" + """Changelog workflow only opens the changelog PR: no build, no publish. + + The build moved into publish-release.yml so the built artifacts pass natively + within one workflow run, removing the third-party cross-workflow download action. + """ result = copie.copy(extra_answers={"include_actions": True}) assert result.exit_code == 0 workflow_path = result.project_dir / ".github" / "workflows" / "changelog.yml" workflow_content = workflow_path.read_text(encoding="utf-8") - # Should NOT have pypi-publish job (moved to publish-release.yml) + # Should NOT have pypi-publish job (lives in publish-release.yml) assert "pypi-publish:" not in workflow_content assert "pypi_publish:" not in workflow_content - # But should still build packages - assert "uv build" in workflow_content or "build" in workflow_content.lower() + # Build + artifact upload moved to publish-release.yml + assert "uv build" not in workflow_content + assert "upload-artifact" not in workflow_content - # Should store artifacts - assert "upload-artifact" in workflow_content + # It opens the changelog PR + assert "create-pull-request" in workflow_content class TestChangelogWorkflow: @@ -278,8 +283,9 @@ def test_changelog_workflow_uses_git_cliff(self, copie): # Should use git-cliff action assert "git-cliff" in workflow_content - # Should use CHANGELOG_AUTOMATION_TOKEN - assert "CHANGELOG_AUTOMATION_TOKEN" in workflow_content + # Opens the PR with a short-lived GitHub App token, not a long-lived PAT + assert "CHANGELOG_AUTOMATION_TOKEN" not in workflow_content + assert "create-github-app-token" in workflow_content def test_changelog_workflow_triggered_on_tags(self, copie): """Test that changelog workflow triggers on version tags.""" diff --git a/tests/test_security_hardening.py b/tests/test_security_hardening.py new file mode 100644 index 0000000..f3cce40 --- /dev/null +++ b/tests/test_security_hardening.py @@ -0,0 +1,141 @@ +"""Fail-loud checks for the security controls the template ships. + +Each control here is one a green CI run could otherwise hide: a workflow that quietly +regained a broad default token, a paid scanner that silently shipped to a private repo, +a secret-scan job that runs but never gates the merge. These assert the control is +observably present (or absent) in the generated project, so a regression fails a test +rather than passing unnoticed. +""" + +WORKFLOW_NAMES = [ + "tests.yml", + "changelog.yml", + "publish-release.yml", + "pr-title.yml", + "commit-message.yml", + "nightly.yml", +] + + +def _read(project_dir, relpath): + return (project_dir / relpath).read_text(encoding="utf-8") + + +def test_workflows_default_to_read_only_token(copie): + """Every generated workflow declares a top-level read-only permissions default. + + A workflow with no top-level ``permissions`` inherits the repository default token + scope, which can be broad. The read-only default plus per-job opt-in is the + least-privilege posture. + """ + result = copie.copy(extra_answers={"include_actions": True}) + workflows = result.project_dir / ".github" / "workflows" + for name in WORKFLOW_NAMES: + content = _read(result.project_dir, f".github/workflows/{name}") + # A top-level (column-0) permissions block granting no more than contents: read. + assert "\npermissions:\n contents: read\n" in content, f"{name} has no top-level read-only permissions default" + assert workflows.exists() + + +def test_codeql_and_scorecard_ship_for_public(copie): + """CodeQL and Scorecard ship for public repos (free there).""" + public = copie.copy(extra_answers={"repo_visibility": "public", "include_actions": True}) + assert (public.project_dir / ".github/workflows/codeql.yml").exists() + assert (public.project_dir / ".github/workflows/scorecard.yml").exists() + + +def test_codeql_and_scorecard_absent_for_private(copie): + """CodeQL and Scorecard are absent for private repos (paid there); ruff S covers SAST. + + A separate test (fresh directory) rather than a second copy into the public project's + directory: copier's overwrite does not delete files that a new answer set no longer + generates, so a stale codeql.yml would linger and mask this gate. + """ + private = copie.copy( + extra_answers={"repo_visibility": "private", "include_actions": True, "include_codecov": False} + ) + assert not (private.project_dir / ".github/workflows/codeql.yml").exists() + assert not (private.project_dir / ".github/workflows/scorecard.yml").exists() + + +def test_codecov_is_gated_on_include_codecov(copie): + """Codecov wiring ships only when include_codecov is set. + + Private repos default it off (paid on private) and must not ship a half-wired + codecov action or a dead coverage badge. + """ + with_cov = copie.copy(extra_answers={"include_actions": True, "include_codecov": True}) + assert "codecov" in _read(with_cov.project_dir, ".github/workflows/tests.yml") + assert "codecov.io" in _read(with_cov.project_dir, "README.md") + + without_cov = copie.copy( + extra_answers={"repo_visibility": "private", "include_actions": True, "include_codecov": False} + ) + assert "codecov" not in _read(without_cov.project_dir, ".github/workflows/tests.yml") + assert "codecov.io" not in _read(without_cov.project_dir, "README.md") + + +def test_bandit_ruleset_is_selected(copie): + """The generated ruff config selects the S (flake8-bandit) security ruleset.""" + result = copie.copy() + pyproject = _read(result.project_dir, "pyproject.toml") + # The select list carries the S ruleset (the always-on SAST floor). + select = pyproject.split("select = [", 1)[1].split("]", 1)[0] + assert '"S"' in select, "ruff S (flake8-bandit) is not selected in the generated pyproject" + + +def test_gitleaks_hook_and_gating_ci_job(copie): + """gitleaks runs as a pre-commit hook and as a merge-gating CI job. + + The hook prevents secrets locally; the CI job is the un-bypassable backstop, and it + must be a dependency of the ci-passed roll-up or it runs without blocking merges. + """ + result = copie.copy(extra_answers={"include_actions": True}) + precommit = _read(result.project_dir, ".pre-commit-config.yaml") + assert "gitleaks/gitleaks" in precommit, "no gitleaks pre-commit hook" + + tests_yml = _read(result.project_dir, ".github/workflows/tests.yml") + assert "secret-scan:" in tests_yml, "no gitleaks CI job" + assert "gitleaks detect" in tests_yml + + # The job must be a dependency of the single required roll-up, or it never gates. + needs_block = tests_yml.split("ci-passed:", 1)[1].split("runs-on:", 1)[0] + assert "- secret-scan" in needs_block, "secret-scan is not in the ci-passed roll-up needs list" + + +def test_governance_files_ship_for_every_repo(copie): + """SECURITY.md and CODEOWNERS ship regardless of visibility.""" + for answers in ({}, {"repo_visibility": "private", "include_codecov": False}): + result = copie.copy(extra_answers=answers) + assert (result.project_dir / "SECURITY.md").exists() + assert (result.project_dir / "CODEOWNERS").exists() + + +def test_publish_flow_is_self_contained_with_sbom(copie): + """The publish path builds and publishes in one run, with no third-party broker, plus an SBOM.""" + result = copie.copy(extra_answers={"include_actions": True}) + publish = _read(result.project_dir, ".github/workflows/publish-release.yml") + # No third-party cross-workflow artifact download in the release path. + assert "dawidd6" not in publish + # Native same-run artifact passing. + assert "actions/upload-artifact" in publish + assert "actions/download-artifact" in publish + # SBOM produced and attached. + assert "cyclonedx" in publish.lower() + assert "sbom" in publish.lower() + + +def test_changelog_uses_app_token_not_pat(copie): + """The changelog PR is opened with a short-lived GitHub App token, not a long-lived PAT.""" + result = copie.copy(extra_answers={"include_actions": True}) + changelog = _read(result.project_dir, ".github/workflows/changelog.yml") + assert "create-github-app-token" in changelog + assert "CHANGELOG_AUTOMATION_TOKEN" not in changelog + + +def test_publish_action_is_not_a_branch_ref(copie): + """The PyPI publish action is pinned to a tag, not the mutable @release/v1 branch.""" + result = copie.copy(extra_answers={"include_actions": True}) + publish = _read(result.project_dir, ".github/workflows/publish-release.yml") + assert "gh-action-pypi-publish@release/v1" not in publish + assert "gh-action-pypi-publish@v" in publish diff --git a/tests/test_template.py b/tests/test_template.py index 4e7374b..6f2cf2c 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -4173,15 +4173,20 @@ def test_docs_use_no_em_or_en_dashes(copie_session_default): # every repo that copier must replay on every release -- see the test below. EXPECTED_ACTION_PINS = { "actions/checkout": "v7", + "actions/create-github-app-token": "v2", + "actions/download-artifact": "v7", "actions/github-script": "v9", "actions/upload-artifact": "v7", "amannn/action-semantic-pull-request": "v6", "astral-sh/setup-uv": "v7", "codecov/codecov-action": "v7", "codecov/test-results-action": "v1", - "dawidd6/action-download-artifact": "v21", + "github/codeql-action/analyze": "v3", + "github/codeql-action/init": "v3", + "github/codeql-action/upload-sarif": "v3", + "ossf/scorecard-action": "v2", "peter-evans/create-pull-request": "v8", - "pypa/gh-action-pypi-publish": "release/v1", + "pypa/gh-action-pypi-publish": "v1.13.0", "taiki-e/install-action": "v2", }