diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..c46b3fe --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,7 @@ + + +@../AGENTS.md diff --git a/.github/workflows/bump-versions.yml b/.github/workflows/bump-versions.yml index f11b438..370fa35 100644 --- a/.github/workflows/bump-versions.yml +++ b/.github/workflows/bump-versions.yml @@ -10,10 +10,18 @@ on: schedule: - cron: 0 6 * * * +permissions: + contents: read + jobs: update: name: Update ${{ matrix.name }} runs-on: ubuntu-latest + permissions: + # `create-pull-request` pushes and opens the pull request with `CI_PAT`. + contents: read + # Open an issue when the update fails. + issues: write strategy: matrix: include: @@ -22,6 +30,8 @@ jobs: steps: - name: Check out branch uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Set up pixi uses: prefix-dev/setup-pixi@a09b6247153796b190642a2b53fac4241043cf6f # v0.10.0 with: @@ -32,31 +42,37 @@ jobs: GH_TOKEN: ${{ github.token }} - uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: - commit-message: Auto-update ${{ matrix.name }} - title: Auto-update ${{ matrix.name }} + commit-message: "build: auto-update ${{ matrix.name }}" + title: "build: auto-update ${{ matrix.name }}" body: | New versions of pinned dependencies in ${{ matrix.name }} were detected. This PR updates them to the latest version. branch: update-${{ matrix.name }} delete-branch: true + # A pull request opened with `GITHUB_TOKEN` starts no workflow, so it + # would get no checks. `CI_PAT` is a fine-grained token limited to + # this repository. + token: ${{ secrets.CI_PAT }} - name: Create issue on failure if: failure() uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + NAME: ${{ matrix.name }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} with: script: | - github.rest.issues.listForRepo({ + const { data: open } = await github.rest.issues.listForRepo({ owner: context.repo.owner, repo: context.repo.repo, state: "open", - labels: "[bot] autoupdate" - }).then((issues) => { - if (issues.data.length === 0) { - github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: "${{ matrix.name }} update failed", - body: "See https://github.com/quantco/copier-template-python-open-source/actions/runs/${{ github.run_id }} for details.", - labels: ["[bot] autoupdate"] - }) - } + labels: "[bot] autoupdate", }); + if (open.length === 0) { + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: `${process.env.NAME} update failed`, + body: `See ${process.env.RUN_URL} for details.`, + labels: ["[bot] autoupdate"], + }); + } diff --git a/.github/workflows/ci-copier.yml b/.github/workflows/ci-copier.yml index 27ce4ff..7decded 100644 --- a/.github/workflows/ci-copier.yml +++ b/.github/workflows/ci-copier.yml @@ -14,6 +14,9 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +permissions: + contents: read + jobs: lint: name: Lint @@ -22,6 +25,8 @@ jobs: steps: - name: Check out branch uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Set up pixi uses: prefix-dev/setup-pixi@a09b6247153796b190642a2b53fac4241043cf6f # v0.10.0 - name: Run linting @@ -35,6 +40,8 @@ jobs: steps: - name: Check out branch uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Set up pixi uses: prefix-dev/setup-pixi@a09b6247153796b190642a2b53fac4241043cf6f # v0.10.0 - name: Test @@ -47,6 +54,11 @@ jobs: name: Test CI of generated package (minimal-python = ${{ matrix.minimal-python-version }}) timeout-minutes: 30 runs-on: ubuntu-latest + permissions: + contents: read + # Read and cancel the generated package's runs. Pushing goes through + # `CI_PAT`, not this token. + actions: write strategy: matrix: minimal-python-version: [py312, py314] @@ -54,8 +66,8 @@ jobs: - name: Check out branch uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ssh-key: ${{ secrets.SSH_PRIVATE_KEY }} fetch-depth: 0 + persist-credentials: false - name: Set up pixi uses: prefix-dev/setup-pixi@a09b6247153796b190642a2b53fac4241043cf6f # v0.10.0 with: @@ -65,42 +77,43 @@ jobs: run: | echo "name=ci/$GITHUB_SHA-${{ matrix.minimal-python-version }}" >> $GITHUB_OUTPUT - name: Test generated package CI + env: + # Reading and cancelling the generated package's runs. + GH_TOKEN: ${{ github.token }} + # A fine-grained token limited to this repository. `GITHUB_TOKEN` + # can neither push files under `.github/workflows/` nor start a + # workflow by pushing; a push made with `CI_PAT` does both. + CI_PAT: ${{ secrets.CI_PAT }} + BRANCH: ${{ steps.branch.outputs.name }} + MINIMAL_PYTHON_VERSION: ${{ matrix.minimal-python-version }} run: | - # Name of the generated package. - # Authentication for pushing to $REPO. - AUTH='authorization: Bearer ${{ secrets.GITHUB_TOKEN }}' - eval $(ssh-agent) - ssh-add - <<< "${{ secrets.SSH_PRIVATE_KEY }}" + : "${CI_PAT:?the CI_PAT secret is not set}" + AUTH="authorization: Bearer $GH_TOKEN" # Set up local Git so that copier can run "git commit". git config --global user.email "landocalrissian@example.com" git config --global user.name "Lando Calrissian" - # Generate package with default settings + Windows CI. copier copy \ --data project_slug="package" \ --data project_short_description="Example Package" \ --data github_user="LandoCalrissian" \ --data author_name="Lando Calrissian" \ --data author_email="lando@calrissian.org" \ - --data minimal_python_version="${{ matrix.minimal-python-version }}" \ + --data minimal_python_version="$MINIMAL_PYTHON_VERSION" \ --defaults \ --trust \ --vcs-ref HEAD \ . out cd out - # Replace actions trigger with on: [push] + # The generated CI and build run on pull requests and on `main`; + # here they have to run on a push to the `ci/*` branch. yq eval '.on = ["push"]' -i .github/workflows/ci.yml yq eval '.on = ["push"]' -i .github/workflows/build.yml - git init - # create pixi.lock - pixi lock --manifest-path pixi.toml --color=always git add . git commit -m "Initial commit" - # Push the generated package's HEAD commit to a `ci/*` branch cid=$(git rev-parse HEAD) - git push -f "${GITHUB_SERVER_URL/https:\/\//git@}:$GITHUB_REPOSITORY" $cid:refs/heads/${STEPS_BRANCH_OUTPUTS_NAME} - # Use the GitHub API to wait for the generated package's CI to complete (success or failure). - # We look for a GitHub Actions run for the HEAD commit ID. - WORKFLOW_URL="$GITHUB_API_URL/repos/${GITHUB_REPOSITORY}/actions/runs?branch=${STEPS_BRANCH_OUTPUTS_NAME}&head_sha=${cid}" + git push -f "https://x-access-token:$CI_PAT@${GITHUB_SERVER_URL#https://}/$GITHUB_REPOSITORY.git" "$cid:refs/heads/$BRANCH" + # Wait for the generated package's CI to complete (success or failure). + WORKFLOW_URL="$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/runs?branch=$BRANCH&head_sha=$cid&event=push" echo "Waiting for inner CI to start" while (( $(curl -Ls --header "$AUTH" "$WORKFLOW_URL" | jq -r ".workflow_runs | length") < 1 )); do sleep 10 @@ -114,23 +127,21 @@ jobs: echo "CI pipeline failed" exit 1 fi - env: - STEPS_BRANCH_OUTPUTS_NAME: ${{ steps.branch.outputs.name }} - name: Clean up CI branch if: always() + env: + GH_TOKEN: ${{ github.token }} + CI_PAT: ${{ secrets.CI_PAT }} + BRANCH: ${{ steps.branch.outputs.name }} run: | - set -x - AUTH='authorization: Bearer ${{ secrets.GITHUB_TOKEN }}' - eval $(ssh-agent) - ssh-add - <<< "${{ secrets.SSH_PRIVATE_KEY }}" - - git push -d "${GITHUB_SERVER_URL/https:\/\//git@}:$GITHUB_REPOSITORY" refs/heads/ci/$GITHUB_SHA-${{ matrix.minimal-python-version }} - - cid=$(git rev-parse HEAD) - for line in $(curl -Ls --header "$AUTH" "$GITHUB_API_URL/repos/${GITHUB_REPOSITORY}/actions/runs?branch=ci/${GITHUB_SHA}-${{ matrix.minimal-python-version }}&head_sha=${cid}" | jq -r ".workflow_runs | .[] | select(.status != \"completed\") | .id") + AUTH="authorization: Bearer $GH_TOKEN" + # Cancel anything still running before the branch disappears. + for run_id in $(curl -Ls --header "$AUTH" "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/runs?branch=$BRANCH" | jq -r ".workflow_runs | .[] | select(.status != \"completed\") | .id") do - curl -Ls --header "$AUTH" --request POST "$GITHUB_API_URL/repos/${GITHUB_REPOSITORY}/actions/runs/$line/cancel" > /dev/null + curl -Ls --header "$AUTH" --request POST "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/runs/$run_id/cancel" > /dev/null done + # Delete the branch. Tolerate it never having been created. + git push -d "https://x-access-token:$CI_PAT@${GITHUB_SERVER_URL#https://}/$GITHUB_REPOSITORY.git" "refs/heads/$BRANCH" || true finalize: name: Test CI of generated package (all) diff --git a/.prettierrc.yaml b/.prettierrc.yaml index 3998a84..c559ea5 100644 --- a/.prettierrc.yaml +++ b/.prettierrc.yaml @@ -9,4 +9,4 @@ bracketSpacing: true printWidth: 200 endOfLine: "auto" tabWidth: 2 -proseWrap: "never" +proseWrap: "preserve" diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d4462cd --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,19 @@ + + +# AGENTS.md + +Contribution rules for this copier template, for humans and AI agents alike. +`template/AGENTS.md.jinja` holds the rules for generated projects. +A rule there that also fits this repository applies here too. + +## Markdown line breaks + +Always write one sentence per line in every Markdown file, including the `*.md.jinja` files under `template/`. +Split a very long sentence at a clause boundary, so that each line holds one clause. +Never wrap lines at a fixed width. +A diff then shows the sentence that changed, not a reflowed paragraph. +Code blocks, tables and headings keep their own form. diff --git a/CHANGELOG.md b/CHANGELOG.md index c9654e3..f09e463 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,8 @@ Release headings should be of the form: All notable changes to this project will be documented in this file. -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Unreleased @@ -33,12 +34,19 @@ In this release, we have aligned to current practice in https://github.com/energ - mkdocs theme palette, language, & icon. - extra socials buttons for GH & pypi. - Mkdocs v2 warning suppressor in pixi.toml. +- Release on merge, ported from https://github.com/energy-models/mathspec: + a PR that puts a version heading on top of `CHANGELOG.md` tags, releases and publishes that version (`release.yml`, `tools/changelog.py`, `RELEASING.md`). +- `Changelog line` and `Conventional commit subject` PR checks. ### Changed - Accelerate REUSE check in pre-commit. -- template tests to include only a conftest.py file. +- template tests to include only a conftest.py file and an import smoke test, so that a new project's test suite passes. - AGENTS.md, to include more verbose instructions. +- **Breaking**: the package version comes from the git tag through hatch-vcs, not from a `sed` over `pyproject.toml`. +- **Breaking**: `CHANGELOG.md` uses one `## Upcoming version` heading above `## X.Y.Z (YYYY-MM-DD)` release headings. + CI refuses any other heading. +- `build.yml` no longer publishes on a tag push; `release.yml` calls it. ## v1.0.0 (2026.08.17) diff --git a/README.md b/README.md index d0e6436..73c76b8 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,9 @@ To update to a newer template version: pixi exec --spec copier -- copier update --defaults --trust . ``` -Note that copier will show `Conflict` for files that have manual changes. This is normal. As long as there are no merge conflict markers in the files all patches applied cleanly. +Note that copier will show `Conflict` for files that have manual changes. +This is normal. +As long as there are no merge conflict markers in the files all patches applied cleanly. If you want to change any answer that you gave before, run: diff --git a/SECURITY.md b/SECURITY.md index 4f885d7..c3e5bdd 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -8,8 +8,11 @@ SPDX-License-Identifier: MIT # Reporting Security Issues -We take security bugs in our projects seriously. We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions. +We take security bugs in our projects seriously. +We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions. To report a security issue, please use the GitHub Security Advisory ["Report a Vulnerability"](https://github.com/Quantco/copier-template-python-open-source/security/advisories/new) tab. -We will send a response indicating the next steps in handling your report. After the initial reply to your report, the security team will keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance. +We will send a response indicating the next steps in handling your report. +After the initial reply to your report, the security team will keep you informed of the progress towards a fix and full announcement, +and may ask for additional information or guidance. diff --git a/copier.yml b/copier.yml index a2107f0..03e84f3 100644 --- a/copier.yml +++ b/copier.yml @@ -16,7 +16,6 @@ project_slug: validator: >- {% if '_' in project_slug or ' ' in project_slug or '.' in project_slug %} The project slug should not contain underscores, spaces or dots. {% endif %} - project_slug_snake_case: type: str default: "{{ project_slug.replace('-', '_') }}" @@ -40,7 +39,6 @@ author_email: validator: >- {% if not (author_email | regex_search('^.*@.*$')) %} Invalid email {% endif %} - repository_owner: type: str help: What is the GitHub organisation / user of this project, under which the repository will be hosted? @@ -48,7 +46,6 @@ repository_owner: validator: >- {% if not (repository_owner | regex_search('^[^\\s]+$')) %} Repository owner cannot contain spaces {% endif %} - github_url: type: str default: https://github.com/{{ repository_owner }}/{{ project_slug }} @@ -91,6 +88,13 @@ add_autobump_workflow: help: Do you want to add a GitHub Workflow to automatically bump `pixi.lock`? default: true +_migrations: + - version: v1.1.0 + when: "{{ _stage == 'before' }}" + command: + - "{{ _copier_python }}" + - "{{ _copier_conf.src_path }}/migrations/v1_1_0.py" + _tasks: - "pixi exec --spec git git init" - "pixi run reuse download --all" diff --git a/migrations/v1_1_0.py b/migrations/v1_1_0.py new file mode 100644 index 0000000..4df7bdd --- /dev/null +++ b/migrations/v1_1_0.py @@ -0,0 +1,75 @@ +# SPDX-FileCopyrightText: 2026 copier-template-python-open-source contributors +# +# SPDX-License-Identifier: MIT + +"""Rewrite ``CHANGELOG.md`` headings to the form ``tools/changelog.py`` reads. + +Runs in the project being updated, before copier applies the template diff. +Standard library only, because copier runs it on its own Python. +""" + +import re +import subprocess +import sys +from pathlib import Path + +PATH = Path("CHANGELOG.md") +UPCOMING = "Upcoming version" +GUIDE = re.compile(r"\n*", re.DOTALL) +UNRELEASED = re.compile(r"^## \[?Unreleased\]?[ \t]*$", re.MULTILINE | re.IGNORECASE) +OLD_RELEASE = re.compile( + r"^## \[?v?(?P\d+\.\d+\.\d+(?:(?:a|b|rc)\d+)?)\]?(?:\(\S+\))?" + r"[ \t]*-?[ \t]*\(?(?P\d{4})[-.](?P\d{2})[-.](?P\d{2})\)?[ \t]*$", + re.MULTILINE, +) +RELEASE = re.compile(r"\d+\.\d+\.\d+(?:(?:a|b|rc)\d+)? \(\d{4}-\d{2}-\d{2}\)") + + +def migrate(text: str, tags: set[str]) -> str: + """The changelog with its headings in the new form. + + Raises + ------ + ValueError + If a ``##`` heading has no new form, or if the first release has no + ``v``-prefixed tag, which would make the next merge to ``main`` release + it again. + """ + text = GUIDE.sub("", text) + text = UNRELEASED.sub(f"## {UPCOMING}", text) + text = OLD_RELEASE.sub(r"## \g (\g-\g-\g)", text) + + headings = re.findall(r"^## (.+)$", text, flags=re.MULTILINE) + unknown = [h for h in headings if h != UPCOMING and not RELEASE.fullmatch(h)] + if unknown: + raise ValueError( + "CHANGELOG.md has headings with no new form. Rewrite each as " + '"## Upcoming version" or "## X.Y.Z (YYYY-MM-DD)", then update again: ' + + ", ".join(f'"## {h}"' for h in unknown) + ) + + releases = [h.split(" ")[0] for h in headings if RELEASE.fullmatch(h)] + if releases and f"v{releases[0]}" not in tags: + raise ValueError( + f"{releases[0]} is the latest release in CHANGELOG.md but has no tag " + f'"v{releases[0]}", so the next merge to main would release it again. ' + f"Tag the commit it was released from, then update again: " + f"git tag v{releases[0]} && git push origin v{releases[0]}" + ) + return text + + +def main() -> None: + if not PATH.exists(): + return + tags = subprocess.run( + ["git", "tag", "--list"], capture_output=True, text=True, check=True + ).stdout.split() + try: + PATH.write_text(migrate(PATH.read_text(), set(tags))) + except ValueError as error: + sys.exit(f"Migration v1.1.0 stopped: {error}") + + +if __name__ == "__main__": + main() diff --git a/pixi.lock b/pixi.lock index 760d332..188d86f 100644 --- a/pixi.lock +++ b/pixi.lock @@ -32,6 +32,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_9.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/c-ares-1.34.6-hb03c661_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/cffi-2.0.0-py314h4a8dc5f_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/charset-normalizer-3.5.2-h1114479_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/cryptography-49.0.0-py314h7fe84b3_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/gh-2.95.0-hfc2019e_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/go-yq-4.53.3-h280c20c_0.conda @@ -53,6 +54,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_19.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/liblapack-3.11.0-8_h47877c9_openblas.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libmagic-5.48-h421ea60_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb03c661_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libnghttp2-1.68.1-h877daf1_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenblas-0.3.33-pthreads_h94d23a6_0.conda @@ -88,8 +90,13 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/annotated-types-0.7.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/attrs-26.1.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/binaryornot-0.6.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/boolean.py-5.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cfgv-3.5.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/chardet-7.6.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.5.0-pyh5ded981_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/copier-9.16.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.6-py314hd8ed1ab_100.conda @@ -108,6 +115,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/invoke-3.0.3-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jinja2-ansible-filters-1.3.2-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/license-expression-30.4.4-pyhe01879c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/mypy_extensions-1.1.0-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.10.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.2-pyhc364b38_0.conda @@ -124,15 +132,19 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pydantic-2.13.4-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.20.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-8.4.2-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-debian-1.1.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-discovery-1.4.2-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.6-h4df99d1_100.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-magic-0.4.27-pyhd7f29a5_7.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pywin32-on-windows-0.1.0-pyh1179c8e_3.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/questionary-2.1.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/reuse-6.2.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ruamel.yaml-0.19.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tomlkit-0.15.2-pyh5ded981_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.15.0-h396c80c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/typing-inspection-0.4.2-pyhcf101f3_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda @@ -143,8 +155,13 @@ environments: osx-64: - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/annotated-types-0.7.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/attrs-26.1.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/binaryornot-0.6.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/boolean.py-5.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cfgv-3.5.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/chardet-7.6.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.5.0-pyh5ded981_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/copier-9.16.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.6-py314hd8ed1ab_100.conda @@ -163,6 +180,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/invoke-3.0.3-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jinja2-ansible-filters-1.3.2-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/license-expression-30.4.4-pyhe01879c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/mypy_extensions-1.1.0-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.10.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.2-pyhc364b38_0.conda @@ -179,15 +197,19 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pydantic-2.13.4-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.20.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-8.4.2-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-debian-1.1.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-discovery-1.4.2-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.6-h4df99d1_100.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-magic-0.4.27-pyhd7f29a5_7.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pywin32-on-windows-0.1.0-pyh1179c8e_3.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/questionary-2.1.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/reuse-6.2.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ruamel.yaml-0.19.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tomlkit-0.15.2-pyh5ded981_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.15.0-h396c80c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/typing-inspection-0.4.2-pyhcf101f3_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda @@ -201,6 +223,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-64/bzip2-1.0.8-h500dc9f_9.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/c-ares-1.34.6-hb5e19a0_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/cffi-2.0.0-py314h8ca4d5a_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/charset-normalizer-3.5.2-hd77389f_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/cryptography-49.0.0-py314hd00f597_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/gh-2.95.0-h5839d16_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/go-yq-4.53.3-ha3d0635_0.conda @@ -221,6 +244,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-64/libgfortran5-15.2.0-hd16e46c_19.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/liblapack-3.11.0-8_h859234e_openblas.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/liblzma-5.8.3-hbb4bfdb_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libmagic-5.48-he930e7c_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/libmpdec-4.0.0-hf3981d6_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/libnghttp2-1.68.1-h70048d4_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/libopenblas-0.3.33-openmp_h9e49c7b_0.conda @@ -256,8 +280,14 @@ environments: osx-arm64: - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/annotated-types-0.7.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/attrs-26.1.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/binaryornot-0.6.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/boolean.py-5.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cfgv-3.5.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/chardet-7.6.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.5.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.5.0-pyh5ded981_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/copier-9.16.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.6-py314hd8ed1ab_100.conda @@ -276,6 +306,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/invoke-3.0.3-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jinja2-ansible-filters-1.3.2-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/license-expression-30.4.4-pyhe01879c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/mypy_extensions-1.1.0-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.10.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.2-pyhc364b38_0.conda @@ -292,15 +323,19 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pydantic-2.13.4-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.20.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-8.4.2-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-debian-1.1.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-discovery-1.4.2-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.6-h4df99d1_100.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-magic-0.4.27-pyhd7f29a5_7.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pywin32-on-windows-0.1.0-pyh1179c8e_3.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/questionary-2.1.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/reuse-6.2.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ruamel.yaml-0.19.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tomlkit-0.15.2-pyh5ded981_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.15.0-h396c80c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/typing-inspection-0.4.2-pyhcf101f3_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda @@ -334,6 +369,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgfortran5-15.2.0-hdae7583_19.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblapack-3.11.0-8_hd9741b5_openblas.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.3-h8088a28_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmagic-5.48-h132b30e_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h84a0fba_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libnghttp2-1.68.1-h8f3e76b_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenblas-0.3.33-openmp_he657e61_0.conda @@ -369,8 +405,13 @@ environments: win-64: - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/annotated-types-0.7.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/attrs-26.1.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/binaryornot-0.6.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/boolean.py-5.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-h4c7d964_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cfgv-3.5.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/chardet-7.6.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.5.0-pyh5ded981_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/copier-9.16.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.6-py314hd8ed1ab_100.conda @@ -389,6 +430,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/invoke-3.0.3-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/jinja2-ansible-filters-1.3.2-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/license-expression-30.4.4-pyhe01879c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/mypy_extensions-1.1.0-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.10.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.2-pyhc364b38_0.conda @@ -405,15 +447,19 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pydantic-2.13.4-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.20.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-8.4.2-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-debian-1.1.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-discovery-1.4.2-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.6-h4df99d1_100.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-magic-0.4.27-pyhd7f29a5_7.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pywin32-on-windows-0.1.0-pyh07e9846_2.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/questionary-2.1.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/reuse-6.2.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ruamel.yaml-0.19.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tomlkit-0.15.2-pyh5ded981_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.15.0-h396c80c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/typing-inspection-0.4.2-pyhcf101f3_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda @@ -425,6 +471,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/bcrypt-5.0.0-py314h9f07db2_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/bzip2-1.0.8-h0ad9c76_9.conda - conda: https://conda.anaconda.org/conda-forge/win-64/cffi-2.0.0-py314h5a2d7ad_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/charset-normalizer-3.5.2-h308a848_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/cryptography-49.0.0-py314he884d78_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/gh-2.95.0-h11686cb_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/go-yq-4.53.3-h6a83c73_0.conda @@ -437,6 +484,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/libiconv-1.18-hc1393d2_2.conda - conda: https://conda.anaconda.org/conda-forge/win-64/liblapack-3.11.0-8_hf9ab0e9_mkl.conda - conda: https://conda.anaconda.org/conda-forge/win-64/liblzma-5.8.3-hfd05255_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libmagic-5.48-h94ac8d1_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libmpdec-4.0.0-hfd05255_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libsodium-1.0.22-h6a83c73_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libsqlite-3.53.3-hf5d6505_0.conda @@ -452,6 +500,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/numpy-2.5.0-py314h02f10f6_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/onemkl-license-2026.0.0-h57928b3_908.conda - conda: https://conda.anaconda.org/conda-forge/win-64/openssl-3.6.3-hf411b9b_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/pcre2-10.47-h8466c1e_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/prettier-3.8.5-hc21fffc_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/psutil-7.2.2-py314hc5dbbe4_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/pydantic-core-2.46.4-py314h9f07db2_0.conda @@ -565,6 +614,21 @@ packages: run_exports: {} size: 300271 timestamp: 1761203085220 +- conda: https://conda.anaconda.org/conda-forge/linux-64/charset-normalizer-3.5.2-h1114479_0.conda + noarch: python + sha256: e4f04b45d68716fcc47c808aeacbf165f6f295fb91bd20b91aa38942b8d2cdc6 + md5: 78d5c6112c9320a1a797935cecfec7c2 + depends: + - python + - libgcc >=15 + - __glibc >=2.17,<3.0.a0 + - _python_abi3_support 1.* + - cpython >=3.9 + license: MIT + license_family: MIT + run_exports: {} + size: 217200 + timestamp: 1790766086110 - conda: https://conda.anaconda.org/conda-forge/linux-64/cryptography-49.0.0-py314h7fe84b3_0.conda sha256: 1c8128f2ba8fccf627729968de7fe7504dbb775c829e533439c9ee52134800fd md5: b1dd101231d277ad724c40c788f6fdd8 @@ -847,6 +911,19 @@ packages: - liblzma >=5.8.3,<6.0a0 size: 113478 timestamp: 1775825492909 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libmagic-5.48-h421ea60_1.conda + sha256: c27c09fe89e88000259ad3a83c18cb52804d06a8e25ee51b9bedbcf3584b64ba + md5: 797a795edaf1256b329beee28db47d5d + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libzlib >=1.3.2,<2.0a0 + license: BSD-2-Clause-Darwin + run_exports: + weak: + - libmagic >=5.48,<6.0a0 + size: 473875 + timestamp: 1781763252336 - conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb03c661_1.conda sha256: fe171ed5cf5959993d43ff72de7596e8ac2853e9021dec0344e583734f1e0843 md5: 2c21e66f50753a083cbe6b80f38268fa @@ -1369,6 +1446,38 @@ packages: run_exports: {} size: 18074 timestamp: 1733247158254 +- conda: https://conda.anaconda.org/conda-forge/noarch/attrs-26.1.0-pyhcf101f3_0.conda + sha256: 1b6124230bb4e571b1b9401537ecff575b7b109cc3a21ee019f65e083b8399ab + md5: c6b0543676ecb1fb2d7643941fe375f2 + depends: + - python >=3.10 + - python + license: MIT + license_family: MIT + run_exports: {} + size: 64927 + timestamp: 1773935801332 +- conda: https://conda.anaconda.org/conda-forge/noarch/binaryornot-0.6.0-pyhcf101f3_0.conda + sha256: 1513cf9ab5285c823f90214eb8e638c0d065d3fa11120bfb7bb3cff1eb7a98d0 + md5: 980ed7a17e9279d5c0063d080cf13d0c + depends: + - python >=3.10 + - python + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 21898 + timestamp: 1773003768810 +- conda: https://conda.anaconda.org/conda-forge/noarch/boolean.py-5.0-pyhd8ed1ab_0.conda + sha256: 6195e09f7d8a3a5e2fc0dddd6d1e87198e9c3d2a1982ff04624957a6c6466e54 + md5: 26c3480f80364e9498a48bb5c3e35f85 + depends: + - python >=3.9 + license: BSD-2-Clause + license_family: BSD + run_exports: {} + size: 29946 + timestamp: 1743687383956 - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-h4c7d964_0.conda sha256: 7f458e4a82514d7bebbfef23d92817794a16aaf1c748a15f04870d4fb49aeab2 md5: b9696b2cf00dfeec138c70cee38ed192 @@ -1397,6 +1506,37 @@ packages: run_exports: {} size: 13589 timestamp: 1763607964133 +- conda: https://conda.anaconda.org/conda-forge/noarch/chardet-7.6.0-pyhcf101f3_0.conda + sha256: 80874e53c80c2c0c96d718b5cd95e9a16c619e9ecf8a796768d4e125a3dcff83 + md5: c63ff7d1ea0ff4efbbdce9e64a4a176a + depends: + - python >=3.10 + - python + license: 0BSD + run_exports: {} + size: 672550 + timestamp: 1786923257918 +- conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.5.1-pyhd8ed1ab_0.conda + sha256: cb60ef3e0631c8bacb4f7057196dee4496091a22baa3bb4b9bccb12c7e1c921b + md5: e0ac3accc64e23e40969d660e5f58ac8 + depends: + - python >=3.10 + license: MIT + license_family: MIT + run_exports: {} + size: 64487 + timestamp: 1786835648298 +- conda: https://conda.anaconda.org/conda-forge/noarch/click-8.5.0-pyh5ded981_0.conda + sha256: 9afb0c2c089330321a219c7ee5a25313bd574d4024be606cbb33e7a5735df662 + md5: dea5b13a211bbf99876deb980b408a66 + depends: + - python >=3.11 + - python + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 112341 + timestamp: 1788802215348 - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda sha256: ab29d57dc70786c1269633ba3dff20288b81664d3ff8d21af995742e2bb03287 md5: 962b9857ee8e7018c22f2776ffa0b2d7 @@ -1611,6 +1751,18 @@ packages: run_exports: {} size: 20315 timestamp: 1734906203051 +- conda: https://conda.anaconda.org/conda-forge/noarch/license-expression-30.4.4-pyhe01879c_0.conda + sha256: c0fc62fa5c7552b5ec42324fdc6c9fef05eaa239a434c721df074b42e7a52611 + md5: c9b00d4ac670f5401b9ed080c96eb9f1 + depends: + - boolean.py >=4.0.0 + - python >=3.9 + - python + license: Apache-2.0 + license_family: APACHE + run_exports: {} + size: 120884 + timestamp: 1753294907822 - conda: https://conda.anaconda.org/conda-forge/noarch/mypy_extensions-1.1.0-pyha770c72_0.conda sha256: 6ed158e4e5dd8f6a10ad9e525631e35cee8557718f83de7a4e3966b1f772c4b1 md5: e9c622e0d00fa24a6292279af3ab6d06 @@ -1809,6 +1961,17 @@ packages: run_exports: {} size: 294852 timestamp: 1762354779909 +- conda: https://conda.anaconda.org/conda-forge/noarch/python-debian-1.1.1-pyhcf101f3_0.conda + sha256: d41dd30e86240157a353b7b12051b4c7fb22de8ef601ea365284ae979c4432f2 + md5: 5dbc9d919a0c6e5e0d31fc4917042e35 + depends: + - python >=3.10 + - python + license: GPL-2.0-or-later + license_family: GPL + run_exports: {} + size: 205255 + timestamp: 1780840107326 - conda: https://conda.anaconda.org/conda-forge/noarch/python-discovery-1.4.2-pyhcf101f3_0.conda sha256: 6914da740f6e3ec44ffb2f687dbc9c33abf084e42f34e3a8bb8235e475850619 md5: 7a9095c9300d1b50b1785ca9bc4cadae @@ -1832,6 +1995,18 @@ packages: run_exports: {} size: 49315 timestamp: 1781254664376 +- conda: https://conda.anaconda.org/conda-forge/noarch/python-magic-0.4.27-pyhd7f29a5_7.conda + sha256: 4750592002f9092712fe2edab7044f62bd300b80f72df921739ae04b21068cec + md5: a23c92c99595cf22b42fa6ee89cbd1d7 + depends: + - libmagic + - python >=3.10 + - python + license: MIT + license_family: MIT + run_exports: {} + size: 19236 + timestamp: 1781864718305 - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda build_number: 8 sha256: ad6d2e9ac39751cc0529dd1566a26751a0bf2542adb0c232533d32e176e21db5 @@ -1876,6 +2051,26 @@ packages: run_exports: {} size: 31257 timestamp: 1757356458097 +- conda: https://conda.anaconda.org/conda-forge/noarch/reuse-6.2.0-pyhd8ed1ab_0.conda + sha256: a38305d0f67ecfe81eda17f8e1c3a60073db7f052552359cd3dd56faf8d6ca19 + md5: 847eafe76be70b9126d0a6281e9e8cee + depends: + - attrs >=23.2 + - binaryornot >=0.4.4 + - boolean.py >=3.8 + - chardet >=3.0.2 + - charset-normalizer >=2.0.5 + - click >=8.1 + - jinja2 >=3.0.0 + - license-expression >=21.6.14 + - python >=3.10 + - python-debian >=0.1.48 + - python-magic >=0.4.12 + - tomlkit >=0.8 + license: GPL-3.0-or-later AND Apache-2.0 AND CC0-1.0 AND CC-BY-SA-4.0 + run_exports: {} + size: 130887 + timestamp: 1761583911756 - conda: https://conda.anaconda.org/conda-forge/noarch/ruamel.yaml-0.19.1-pyhcf101f3_0.conda sha256: b48bebe297a63ae60f52e50be328262e880702db4d9b4e86731473ada459c2a1 md5: 06ad944772941d5dae1e0d09848d8e49 @@ -1920,6 +2115,17 @@ packages: run_exports: {} size: 21561 timestamp: 1774492402955 +- conda: https://conda.anaconda.org/conda-forge/noarch/tomlkit-0.15.2-pyh5ded981_0.conda + sha256: e6aa9e1c4d4e229280ef5446ebbc99e7b65c874deb47f83be6a751ba20332cc8 + md5: eadfe340211fea481536d602af9ace99 + depends: + - python >=3.11 + - python + license: MIT + license_family: MIT + run_exports: {} + size: 51412 + timestamp: 1792143573427 - conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.15.0-h396c80c_0.conda sha256: 7c2df5721c742c2a47b2c8f960e718c930031663ac1174da67c1ed5999f7938c md5: edd329d7d3a4ab45dcf905899a7a6115 @@ -2079,6 +2285,20 @@ packages: run_exports: {} size: 293633 timestamp: 1761203106369 +- conda: https://conda.anaconda.org/conda-forge/osx-64/charset-normalizer-3.5.2-hd77389f_0.conda + noarch: python + sha256: c472de27f59dfc9ad7fcf59b0bb560f284764153defa73869882b10fd78b32b4 + md5: 26eaacf697e8bbc4ebbc94db3bb0ab67 + depends: + - python + - __osx >=11.0 + - _python_abi3_support 1.* + - cpython >=3.9 + license: MIT + license_family: MIT + run_exports: {} + size: 191387 + timestamp: 1790766173038 - conda: https://conda.anaconda.org/conda-forge/osx-64/cryptography-49.0.0-py314hd00f597_0.conda sha256: 2eb1805843e65f6057c312edfbef0dab474ff9a12ff93c57f9f9d080b8343f49 md5: ed0cd98452f63f438194881426a39028 @@ -2342,6 +2562,18 @@ packages: - liblzma >=5.8.3,<6.0a0 size: 105724 timestamp: 1775826029494 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libmagic-5.48-he930e7c_1.conda + sha256: 4ce508c357c0b1dc2ee1e6328c3f2410e1084b99a4ef417de6cca1eb2fa39916 + md5: c9b20b33aeb90a7f40c3354cafe6bb11 + depends: + - __osx >=11.0 + - libzlib >=1.3.2,<2.0a0 + license: BSD-2-Clause-Darwin + run_exports: + weak: + - libmagic >=5.48,<6.0a0 + size: 462148 + timestamp: 1781763422330 - conda: https://conda.anaconda.org/conda-forge/osx-64/libmpdec-4.0.0-hf3981d6_1.conda sha256: 1096c740109386607938ab9f09a7e9bca06d86770a284777586d6c378b8fb3fd md5: ec88ba8a245855935b871a7324373105 @@ -3143,6 +3375,18 @@ packages: - liblzma >=5.8.3,<6.0a0 size: 92472 timestamp: 1775825802659 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmagic-5.48-h132b30e_1.conda + sha256: 2e242813645c2737fe9a7b3c5772f74954a922602c8d1e2dee7da9c0658b12cd + md5: 581313aa8da78189ac7bcdb7e31349c7 + depends: + - __osx >=11.0 + - libzlib >=1.3.2,<2.0a0 + license: BSD-2-Clause-Darwin + run_exports: + weak: + - libmagic >=5.48,<6.0a0 + size: 459752 + timestamp: 1781763249676 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h84a0fba_1.conda sha256: 1089c7f15d5b62c622625ec6700732ece83be8b705da8c6607f4dabb0c4bd6d2 md5: 57c4be259f5e0b99a5983799a228ae55 @@ -3674,6 +3918,22 @@ packages: run_exports: {} size: 294731 timestamp: 1761203441365 +- conda: https://conda.anaconda.org/conda-forge/win-64/charset-normalizer-3.5.2-h308a848_0.conda + noarch: python + sha256: 15ef0ff35f316beeb8d0ab045cb3c29655f0e38ed1ea49c56f5606d2ff2bc3e4 + md5: 9435e7be4c7a58a0baa36d1a96e4eea0 + depends: + - python + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - _python_abi3_support 1.* + - cpython >=3.9 + license: MIT + license_family: MIT + run_exports: {} + size: 174725 + timestamp: 1790766070886 - conda: https://conda.anaconda.org/conda-forge/win-64/cryptography-49.0.0-py314he884d78_0.conda sha256: 7348f37f03538c7755824a39f33f6f30312e3349cd7b0a8b285a0bc377ba159e md5: d8cfad425d09c7838d724f6c9501a1c4 @@ -3843,6 +4103,20 @@ packages: - liblzma >=5.8.3,<6.0a0 size: 106486 timestamp: 1775825663227 +- conda: https://conda.anaconda.org/conda-forge/win-64/libmagic-5.48-h94ac8d1_1.conda + sha256: b4a66cc3aeac63227e4de130e0035c315567507b64212717fc2e2de51d6ded77 + md5: 2508fa3f92321e18b6072f264f3e7270 + depends: + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - pcre2 >=10.47,<10.48.0a0 + license: BSD-2-Clause-Darwin + run_exports: + weak: + - libmagic >=5.48,<6.0a0 + size: 487559 + timestamp: 1781792863553 - conda: https://conda.anaconda.org/conda-forge/win-64/libmpdec-4.0.0-hfd05255_1.conda sha256: 40dcd0b9522a6e0af72a9db0ced619176e7cfdb114855c7a64f278e73f8a7514 md5: e4a9fc2bba3b022dad998c78856afe47 @@ -4070,6 +4344,22 @@ packages: - openssl >=3.6.3,<4.0a0 size: 9414790 timestamp: 1781071745579 +- conda: https://conda.anaconda.org/conda-forge/win-64/pcre2-10.47-h8466c1e_1.conda + sha256: d8e9ea26b52a09d880d1e5371830c8dd5b4c099a6db64dbdf8236440744b7499 + md5: 009af999dbe2d1db36a37187a4ca4eb4 + depends: + - bzip2 >=1.0.8,<2.0a0 + - libzlib >=1.3.2,<2.0a0 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - pcre2 >=10.47,<10.48.0a0 + size: 993241 + timestamp: 1787294653206 - conda: https://conda.anaconda.org/conda-forge/win-64/prettier-3.8.5-hc21fffc_0.conda sha256: c262d7954c5e7326052e84de02a615ecbac99791dab0663373569a37bdbae156 md5: 6e8ef90c6fb14f1145d3509844c28772 diff --git a/pixi.toml b/pixi.toml index 030fbe2..e52c82e 100644 --- a/pixi.toml +++ b/pixi.toml @@ -38,6 +38,7 @@ taplo = "*" pre-commit-hooks = "*" typos = "*" zizmor = "*" +reuse = "*" [feature.lint.tasks] pre-commit-install = "lefthook install" lint = "lefthook run pre-commit --all-files" diff --git a/scripts/update_actions.py b/scripts/update_actions.py index e558d1c..fd24747 100644 --- a/scripts/update_actions.py +++ b/scripts/update_actions.py @@ -17,7 +17,7 @@ def update_workflow_actions(file_path: Path): new_lines: list[str] = [] for line in lines: - if "uses: " not in line: + if "uses: " not in line or "uses: ./" in line: new_lines.append(line) else: action_with_rest = line.split(":")[1].strip() diff --git a/template/.github/workflows/build.yml.jinja b/template/.github/workflows/build.yml.jinja index 3603808..9e76727 100644 --- a/template/.github/workflows/build.yml.jinja +++ b/template/.github/workflows/build.yml.jinja @@ -13,13 +13,14 @@ SPDX-License-Identifier: MIT {% raw -%} name: Build +# Builds and checks the wheel on every pull request and push to main, and for +# release.yml, which calls it after tagging and publishes what it uploads. on: pull_request: push: branches: - main - tags: - - "*" + workflow_call: jobs: build: @@ -29,20 +30,16 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + # hatch-vcs derives the version from `git describe`, so the tags have + # to be here. A shallow clone builds a 0.0.1.dev-something wheel. fetch-depth: 0 + persist-credentials: false - name: Set up pixi uses: prefix-dev/setup-pixi@a09b6247153796b190642a2b53fac4241043cf6f # v0.10.0 - - name: Derive version - id: version - if: startsWith(github.ref, 'refs/tags/') - shell: bash - run: echo "version=$(git describe --tags --abbrev=0 | sed 's/^v//')" >> $GITHUB_OUTPUT - - name: Replace version - if: startsWith(github.ref, 'refs/tags/') - run: | - sed -i -e "s/0.0.0/${STEPS_VERSION_OUTPUTS_VERSION}/g" pyproject.toml - env: - STEPS_VERSION_OUTPUTS_VERSION: ${{ steps.version.outputs.version }} + with: + # Off, deliberately: this job builds the artifact a release ships, and + # a restored cache is writable by any job that can populate it. + cache: false - name: Build project run: pixi run build-wheel - name: Check package @@ -52,20 +49,4 @@ jobs: with: name: artifact path: dist/* - - release: - name: Publish package - if: startsWith(github.ref, 'refs/tags/') - needs: [build] - runs-on: ubuntu-latest - permissions: - id-token: write - environment: pypi - steps: - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: artifact - path: dist - - name: Publish package on PyPi - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 {%- endraw %} diff --git a/template/.github/workflows/changelog.yml.jinja b/template/.github/workflows/changelog.yml.jinja new file mode 100644 index 0000000..04ea69f --- /dev/null +++ b/template/.github/workflows/changelog.yml.jinja @@ -0,0 +1,50 @@ +{#- +SPDX-FileCopyrightText: 2026 copier-template-python-open-source contributors + +SPDX-License-Identifier: MIT +-#} +{#- REUSE-IgnoreStart -#} +# SPDX-FileCopyrightText: {{ project_slug }} contributors +# +# SPDX-License-Identifier: {{ license }} +{#- REUSE-IgnoreEnd #} + +{% raw -%} +name: Changelog + +# A feat, fix, perf, refactor, docs or revert PR adds its line under +# `## Upcoming version` in CHANGELOG.md, so the release notes exist before the +# release PR is written. The label `no changelog` opts a PR out, and a skipped +# job counts as passed. See RELEASING.md. + +on: + pull_request: + # `edited` re-runs it when the title changes the type, and `labeled` and + # `unlabeled` when the opt-out label comes or goes. + types: [opened, edited, reopened, synchronize, labeled, unlabeled] + +permissions: + contents: read + +jobs: + line: + name: Changelog line + if: ${{ !contains(github.event.pull_request.labels.*.name, 'no changelog') }} + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # The base branch's changelog is what a new line is told apart from. + fetch-depth: 0 + persist-credentials: false + + - name: Check the PR adds the line it owes + env: + PR_TITLE: ${{ github.event.pull_request.title }} + BASE_REF: ${{ github.base_ref }} + run: | + set -euo pipefail + git show "origin/${BASE_REF}:CHANGELOG.md" > "${RUNNER_TEMP}/base.md" + python3 -m tools.changelog entry "$PR_TITLE" "${RUNNER_TEMP}/base.md" +{%- endraw %} diff --git a/template/.github/workflows/ci.yml.jinja b/template/.github/workflows/ci.yml.jinja index c82408f..ed93e32 100644 --- a/template/.github/workflows/ci.yml.jinja +++ b/template/.github/workflows/ci.yml.jinja @@ -35,8 +35,17 @@ jobs: steps: - name: Check out branch uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # The changelog check compares the release heading with the tags. + fetch-depth: 0 + persist-credentials: false - name: Set up pixi uses: prefix-dev/setup-pixi@a09b6247153796b190642a2b53fac4241043cf6f # v0.10.0 + # A pull request that puts a version heading on top of CHANGELOG.md + # releases it on merge, so the heading is checked here, before the merge + # can act on it. See RELEASING.md. + - name: Check the changelog headings + run: python3 -m tools.changelog check - name: Run linting run: pixi run lint env: diff --git a/template/.github/workflows/pr-title.yml.jinja b/template/.github/workflows/pr-title.yml.jinja new file mode 100644 index 0000000..dd9e711 --- /dev/null +++ b/template/.github/workflows/pr-title.yml.jinja @@ -0,0 +1,95 @@ +{#- +SPDX-FileCopyrightText: 2026 copier-template-python-open-source contributors + +SPDX-License-Identifier: MIT +-#} +{#- REUSE-IgnoreStart -#} +# SPDX-FileCopyrightText: {{ project_slug }} contributors +# +# SPDX-License-Identifier: {{ license }} +{#- REUSE-IgnoreEnd #} + +{% raw -%} +name: PR title + +# The squash subject becomes a commit on main. The PR adds its title to +# CHANGELOG.md by hand, and its type decides whether it owes that line. Nothing +# writes the changelog on merge. See RELEASING.md. +# +# Both the PR title and, when a PR has exactly one commit, that commit's own +# title are validated: GitHub's default `squash_merge_commit_title` is +# COMMIT_OR_PR_TITLE, so which of the two lands on main depends on the commit +# count. Checking both means whichever GitHub picks has been checked. + +on: + pull_request: + # `synchronize` is here because this check is meant to be required, and a + # required check is evaluated against the head commit. Without it a new + # head has no result, and GitHub waits for one that never arrives. + types: [opened, edited, reopened, synchronize] + +permissions: + contents: read + pull-requests: read + +jobs: + conventional: + name: Conventional commit subject + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Validate the subject that will land on main + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + NUMBER: ${{ github.event.pull_request.number }} + PR_TITLE: ${{ github.event.pull_request.title }} + COMMIT_COUNT: ${{ github.event.pull_request.commits }} + run: | + set -euo pipefail + + # keep in sync with the type table in AGENTS.md and CONTRIBUTING.md + TYPES='feat|fix|perf|refactor|docs|chore|test|ci|build|style|revert' + # type, optional (scope), optional ! for breaking, ": ", then a subject + PATTERN="^(${TYPES})(\([a-z0-9._/-]+\))?!?: .+" + + fail=0 + + check() { + local what="$1" subject="$2" + if [[ ! "$subject" =~ $PATTERN ]]; then + echo "::error::${what} is not a conventional commit subject: ${subject}" + fail=1 + return + fi + if (( ${#subject} > 72 )); then + echo "::warning::${what} is ${#subject} chars; git and the GitHub UI truncate around 72." + fi + echo "ok: ${what} — ${subject}" + } + + titles=("PR title|${PR_TITLE}") + if [[ "$COMMIT_COUNT" == "1" ]]; then + only=$(gh api "repos/${REPO}/pulls/${NUMBER}/commits" --jq '.[0].commit.message') + titles+=("the single commit's title|$(head -1 <<<"$only")") + fi + + for entry in "${titles[@]}"; do + check "${entry%%|*}" "${entry#*|}" + done + + if (( fail )); then + cat <<'MSG' + + Expected: [(scope)][!]: + + types feat, fix, perf, refactor, docs, chore, test, ci, build, style, revert + examples feat: a reader for compressed input files + fix(io): a path with a trailing slash loads + docs: describe the configuration options + + Fix the PR title — no need to rewrite the branch. Edits re-run this check. + MSG + exit 1 + fi +{%- endraw %} diff --git a/template/.github/workflows/release.yml.jinja b/template/.github/workflows/release.yml.jinja new file mode 100644 index 0000000..13faeea --- /dev/null +++ b/template/.github/workflows/release.yml.jinja @@ -0,0 +1,104 @@ +{#- +SPDX-FileCopyrightText: 2026 copier-template-python-open-source contributors + +SPDX-License-Identifier: MIT +-#} +{#- REUSE-IgnoreStart -#} +# SPDX-FileCopyrightText: {{ project_slug }} contributors +# +# SPDX-License-Identifier: {{ license }} +{#- REUSE-IgnoreEnd #} + +{% raw -%} +name: Release + +# A release is a merged pull request that puts a version heading, such as +# `## 0.1.0 (2026-10-01)`, above everything else in CHANGELOG.md. On that +# merge this workflow tags the commit, opens the GitHub release with the +# section as its notes, builds the wheel from the tag and publishes it to PyPI. +# Every other push to main finds no untagged version and does nothing. +# See RELEASING.md. + +on: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: release + cancel-in-progress: false + +jobs: + tag: + name: Tag the version the changelog names + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: write + outputs: + version: ${{ steps.pending.outputs.version }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # The tags are what tells a new version from one already released. + fetch-depth: 0 + persist-credentials: false + + - id: pending + name: Read the version to release + run: echo "version=$(python3 -m tools.changelog pending)" >> "$GITHUB_OUTPUT" + + # `gh release create --target` makes the tag on the server, at the + # commit this run is for. A pre-release version is marked as one, so it + # never shows as the latest release. + - name: Tag and open the GitHub release + if: steps.pending.outputs.version != '' + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + VERSION: ${{ steps.pending.outputs.version }} + run: | + set -euo pipefail + python3 -m tools.changelog notes "$VERSION" > notes.md + flags=() + if [[ "$VERSION" =~ (a|b|rc)[0-9]+$ ]]; then flags+=(--prerelease); fi + gh release create "v$VERSION" --target "$GITHUB_SHA" --title "$VERSION" --notes-file notes.md "${flags[@]}" + + build: + needs: tag + if: needs.tag.outputs.version != '' + uses: ./.github/workflows/build.yml + + publish: + name: Publish to PyPI + needs: [tag, build] + runs-on: ubuntu-24.04 + timeout-minutes: 10 + # The trusted publisher on PyPI names this workflow and this environment. + # A required reviewer on the environment is the last point a release can + # be stopped: PyPI never takes a version twice. + environment: pypi + permissions: + id-token: write + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: artifact + path: dist + + - name: Check the wheel is the tagged version + env: + VERSION: ${{ needs.tag.outputs.version }} + run: | + set -euo pipefail + ls dist +{%- endraw %} + test -f "dist/{{ project_slug_snake_case }}-${VERSION}-py3-none-any.whl" + test -f "dist/{{ project_slug_snake_case }}-${VERSION}.tar.gz" +{%- raw %} + + - name: Publish package on PyPI + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 +{%- endraw %} diff --git a/template/AGENTS.md.jinja b/template/AGENTS.md.jinja index 52cf8a4..aa78a4d 100644 --- a/template/AGENTS.md.jinja +++ b/template/AGENTS.md.jinja @@ -16,37 +16,30 @@ SPDX-License-Identifier: CC-BY-4.0 Contribution rules and conventions for {{ project_slug }}, for humans and AI agents alike. -For the general contribution workflow, project conventions and architecture -overview, see `CONTRIBUTING.md` (inlined at the end for Claude -Code via `@`-import). +For the general contribution workflow, project conventions and architecture overview, see `CONTRIBUTING.md` (inlined at the end for Claude Code via `@`-import). -**Part 1 is philosophy**: a change that breaks one of these is wrong here even -where it would be right elsewhere. **Part 2 is defaults**: depart from one where -the work is better served, and **say so in the PR in a sentence**, so the -exception is a decision rather than a slip. +**Part 1 is philosophy**: a change that breaks one of these is wrong here even where it would be right elsewhere. +**Part 2 is defaults**: depart from one where the work is better served, +and **say so in the PR in a sentence**, so the exception is a decision rather than a slip. # Part 1 — Philosophy ## AI-assisted contributions -You're welcome to help author code, pull requests and issues. But the -conversation around a change — PR and issue descriptions, comments and review -discussions — is how maintainers and contributors understand each other, so it -must stay human and honest. Three rules: +You're welcome to help author code, pull requests and issues. +But the conversation around a change — PR and issue descriptions, comments and review discussions — +is how maintainers and contributors understand each other, so it must stay human and honest. +Three rules: -1. **AI-generated content must be marked**, preferably with a GitHub note alert - at the top of the generated section. Put verbose additional context (logs, - generated analysis, long reproductions) in a collapsed `
` block so - it doesn't drown the discussion. +1. **AI-generated content must be marked**, preferably with a GitHub note alert at the top of the generated section. + Put verbose additional context (logs, generated analysis, long reproductions) in a collapsed `
` block so it doesn't drown the discussion. Don't silently mix hand-written and generated prose. -2. **The human writes their own intent.** If a pull request or issue is more - than a self-documenting bug report, the author must write — or at least - rewrite in their own words — their personal intent and motivation by hand, - with everything else placed below it. Don't submit an AI-drafted description - as your own; the maintainers want the human's voice, concise and to the - point. +2. **The human writes their own intent.** + If a pull request or issue is more than a self-documenting bug report, the author must write — or at least rewrite in their own words — their personal intent and motivation by hand, + with everything else placed below it. + Don't submit an AI-drafted description as your own; the maintainers want the human's voice, concise and to the point. ```markdown That's why this is important for me (handwritten) @@ -64,32 +57,29 @@ must stay human and honest. Three rules:
``` -3. **Conversations are not held through an agent.** Posting bare information via - an agent — e.g. a log, a test result, a reproduction — is fine as long as - it's marked (see rule 1). But discussion itself — replies, answers to - questions, review back-and-forth — must be written by the human. Don't let an - agent argue, agree or decide on your behalf. +3. **Conversations are not held through an agent.** + Posting bare information via an agent — e.g. a log, a test result, a reproduction — is fine as long as it's marked (see rule 1). + But discussion itself — replies, answers to questions, review back-and-forth — must be written by the human. + Don't let an agent argue, agree or decide on your behalf. In the tree nothing is marked: `Co-Authored-By: Claude …` is the record. ## Breaking changes are free -The project is pre-1.0 and holds no compatibility promise. Asked to change -something, change it: rename, move, delete. No alias, no deprecation cycle, no -`legacy_` path — spend the effort on the error that fires where the old name was -used instead. **A test asserting the old behaviour is not a blocker**; say in the -PR what coverage moved where. +The project is pre-1.0 and holds no compatibility promise. +Asked to change something, change it: rename, move, delete. +No alias, no deprecation cycle, no `legacy_` path — spend the effort on the error that fires where the old name was used instead. +**A test asserting the old behaviour is not a blocker**; say in the PR what coverage moved where. -Where the change is to a construct the design pages define, the rename lands in -[`docs/design/`](docs/design/) too — a docstring's `Notes` links to a section by -anchor, so a heading that moves has to be followed there. `pixi run docs-build` fails on a link that stopped resolving. +Where the change is to a construct the design pages define, the rename lands in [`docs/design/`](docs/design/) too — +a docstring's `Notes` links to a section by anchor, so a heading that moves has to be followed there. +`pixi run docs-build` fails on a link that stopped resolving. # Part 2 — Good defaults ## Comments -**No explanatory inline comments** — logic that needs explaining becomes a helper -whose docstring carries the sentence: +**No explanatory inline comments** — logic that needs explaining becomes a helper whose docstring carries the sentence: ```python # no @@ -99,23 +89,18 @@ rel = try_read_parquet(...) # union_by_name because a layer may lack a dim colu rel = try_read_parquet(...) ``` -What stays inline, because it belongs where the eye lands rather than in a -docstring nobody opens: +What stays inline, because it belongs where the eye lands rather than in a docstring nobody opens: -- **A justification of a line that reads as a mistake** — a swallowed exception, - a magic literal, a deliberate no-op, a slower construct chosen over the obvious - fast one. `duck.py`'s note on why `PROVIDER credential_chain` is skipped for - local records, and `record.py`'s on why `__contains__` is overridden, are the - model: they answer "why not the obvious thing", which no reader can recover - from the code. Keep them as short as the reason allows — a couple of lines - where the reason genuinely takes that, not a paragraph. +- **A justification of a line that reads as a mistake** — + a swallowed exception, a magic literal, a deliberate no-op, a slower construct chosen over the obvious fast one. + `duck.py`'s note on why `PROVIDER credential_chain` is skipped for local records, + and `record.py`'s on why `__contains__` is overridden, are the model: they answer "why not the obvious thing", which no reader can recover from the code. + Keep them as short as the reason allows — a couple of lines where the reason genuinely takes that, not a paragraph. - **A case label** where parametrizing the test would distort it. -- **Pragmas with their reason** (`# type: ignore[…]`, `# noqa: …`, `# fmt: skip`), - `#:` attribute docs, and section banners. +- **Pragmas with their reason** (`# type: ignore[…]`, `# noqa: …`, `# fmt: skip`), `#:` attribute docs, and section banners. -**Put the claim in the message that prints** — the assertion message in a test, -the error text in the code. A comment is read by whoever already found the line, -a message by whoever hit it: +**Put the claim in the message that prints** — the assertion message in a test, the error text in the code. +A comment is read by whoever already found the line, a message by whoever hit it: ```python # no @@ -127,40 +112,30 @@ assert names == ["Manchester Wind", "Norway Gas"], "members keep record order" ## Docstrings -**As short as it can be without losing the reader** — and the reader of a public -name is the _caller_, not the implementer. +**As short as it can be without losing the reader** — and the reader of a public name is the _caller_, not the implementer. -- **Carry, on anything a caller touches**: what it does, what it takes and - returns, how it fails, what it guarantees, in the caller's terms. How it works - inside belongs in the code, where anyone who needs it is already looking. +- **Carry, on anything a caller touches**: what it does, what it takes and returns, how it fails, what it guarantees, in the caller's terms. + How it works inside belongs in the code, where anyone who needs it is already looking. - **A private helper is the exception** — there the reader _is_ a maintainer: - write the constraint that made it exist, and the invariant a change could break - unknowingly. `layered/resolve.py`'s `_deleted_relation` and - `_component_deleted_for_connections` are the model. -- **Cut** restatement of the signature, argument for a settled decision, - narration of how the answer was found, and any tour of internals a caller - cannot see. -- **Cite the design pages, don't restate them.** A `Notes` section at the end of - the docstring, listing the [`docs/design/`](docs/design/) pages it depends on - by link; the argument stays there. A docstring that re-argues the design is a - defect. `Notes` rather than `References`, which numpydoc reserves for sources - that augment a docstring rather than ones it needs to be understood. -- **The form is - [numpydoc](https://numpydoc.readthedocs.io/en/latest/format.html)**: a summary - line, a blank line, then the body; `Parameters`, `Returns`, `Yields`, `Raises`, - `Attributes`, `Examples`, in that order, each underlined with `-`. **Omit the - type** where the annotation carries it — `name` alone above the description, - not `name : str`. Spell a type only where it adds what the annotation cannot, - as `ancestry : list of UUID` does. -- **Document the parameters that need it, not all of them.** Unlike Google style - there is no all-or-nothing rule and no linter counting them, so a `Parameters` - block covering the two arguments that carry a constraint and skipping `con` is - correct — `resolve_dims` and `_deleted_relation` both do it. A parameter whose - name and annotation already say everything gets no entry, and a function where - that is true of every parameter gets the one-line docstring and no block. + write the constraint that made it exist, and the invariant a change could break unknowingly. + `layered/resolve.py`'s `_deleted_relation` and `_component_deleted_for_connections` are the model. +- **Cut** restatement of the signature, argument for a settled decision, narration of how the answer was found, and any tour of internals a caller cannot see. +- **Cite the design pages, don't restate them.** + A `Notes` section at the end of the docstring, listing the [`docs/design/`](docs/design/) pages it depends on by link; the argument stays there. + A docstring that re-argues the design is a defect. + `Notes` rather than `References`, which numpydoc reserves for sources that augment a docstring rather than ones it needs to be understood. +- **The form is [numpydoc](https://numpydoc.readthedocs.io/en/latest/format.html)**: a summary line, a blank line, then the body; + `Parameters`, `Returns`, `Yields`, `Raises`, `Attributes`, `Examples`, in that order, each underlined with `-`. + **Omit the type** where the annotation carries it — `name` alone above the description, not `name : str`. + Spell a type only where it adds what the annotation cannot, as `ancestry : list of UUID` does. +- **Document the parameters that need it, not all of them.** + Unlike Google style there is no all-or-nothing rule and no linter counting them, + so a `Parameters` block covering the two arguments that carry a constraint and skipping `con` is correct — + `resolve_dims` and `_deleted_relation` both do it. + A parameter whose name and annotation already say everything gets no entry, + and a function where that is true of every parameter gets the one-line docstring and no block. - **The gate is `{{ project_slug_snake_case }}/`**, where a docstring is a contract with a caller. - Under `tests/` the bullets above are off: there a docstring argues for one - assertion, and a summary line it has to fit in is the worse sentence. + Under `tests/` the bullets above are off: there a docstring argues for one assertion, and a summary line it has to fit in is the worse sentence. ```python def do_something(foo: str, bar: int) -> bool: @@ -181,6 +156,14 @@ def do_something(foo: str, bar: int) -> bool: """ ``` +## Markdown line breaks + +Always write one sentence per line in every Markdown file, including `AGENTS.md`, `CHANGELOG.md` and `docs/`. +Split a very long sentence at a clause boundary, so that each line holds one clause. +Never wrap lines at a fixed width. +A diff then shows the sentence that changed, not a reflowed paragraph. +Code blocks, tables and headings keep their own form. + ## Licensing Always place a REUSE 3.0 header comment in each file, @@ -193,19 +176,45 @@ Call `pixi run lint` to run the pre-commit checks (incl. linting & formatting). Call `pixi run test` to run the test suite and `pixi run test ` to run specific files. Call `pixi run test-coverage` to run the test suite with a line coverage check; this is a slower operation so should be run sparingly. +## Commit messages and PR titles + +The PR title is also the line the PR adds to `CHANGELOG.md` by hand; nothing writes the changelog on merge. +The title names the outcome, as a complete lower-case sentence a changelog reader can follow. +Not an activity, not a mechanism. + +The topmost row the diff touches decides the type: + +| The diff touches | Type | +| ------------------------------------------------------ | ------------------------------ | +| `src/` | `feat` `fix` `perf` `refactor` | +| `docs/`, `mkdocs.yml` | `docs` | +| `tests/` | `test` | +| `.github/`, `pixi.toml`, `pixi.lock`, `pyproject.toml` | `ci` / `build` | +| `AGENTS.md`, `CONTRIBUTING.md`, `tools/` | `chore` | + +A reshuffle in `src/` that no consumer can tell apart is `chore`. +A dependency bump that changes what the package accepts is `feat` or `fix` on that outcome. + +```text +yes fix(io): a path with a trailing slash loads +yes feat(io): a reader for compressed input files +no fix(io): fix path bug (activity) +no fix(io): strip the slash before calling Path.resolve (mechanism) +``` + ## Issues -Label from the existing set — `gh label list` shows them and their descriptions; -pick from those rather than inventing new ones. +Label from the existing set — `gh label list` shows them and their descriptions; pick from those rather than inventing new ones. -Findings, issue bodies and your own earlier analysis are **claims to check** — -re-read the code, fix what is still valid, name what you skipped. A body -invalidated by a rewrite is closed and re-filed, not annotated. +Findings, issue bodies and your own earlier analysis are **claims to check** — re-read the code, fix what is still valid, name what you skipped. +A body invalidated by a rewrite is closed and re-filed, not annotated. ## Working with the user -- **"Discuss", "should we", "is it worth" are questions.** Answer, then stop. -- **Do not widen scope.** Name the adjacent thing in a sentence instead. +- **"Discuss", "should we", "is it worth" are questions.** + Answer, then stop. +- **Do not widen scope.** + Name the adjacent thing in a sentence instead. - **Recommend, do not survey.** @CONTRIBUTING.md diff --git a/template/CHANGELOG.md.jinja b/template/CHANGELOG.md.jinja index 6163d2e..d3acabf 100644 --- a/template/CHANGELOG.md.jinja +++ b/template/CHANGELOG.md.jinja @@ -9,33 +9,11 @@ SPDX-FileCopyrightText: {{ project_slug }} contributors SPDX-License-Identifier: CC-BY-4.0 --> {#- REUSE-IgnoreEnd #} - # Changelog -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## Unreleased - -### Fixed - -### Added - -### Changed +Each pull request adds its line under `## Upcoming version`. +A release pull request renames that heading to the version and the day, such as `## 0.1.0 (2026-10-01)`, and edits the section into the release notes. +Merging it releases that version ([RELEASING.md]({{ github_url }}/blob/main/RELEASING.md)). -### Removed +## Upcoming version diff --git a/template/CONTRIBUTING.md.jinja b/template/CONTRIBUTING.md.jinja index 62b717f..a410f29 100644 --- a/template/CONTRIBUTING.md.jinja +++ b/template/CONTRIBUTING.md.jinja @@ -22,7 +22,9 @@ Some of the resources to look at if you're interested in contributing: ## Licensing Copyright (c) {{ '%Y' | strftime }} {{ project_slug }} contributors. -By contributing to {{ project_slug }}, i.e. through opening a pull request, you represent that your contributions are your own original work and that you have the right to license them, and you agree that your contributions are licensed under the {{ open_source_license }}. +By contributing to {{ project_slug }}, i.e. through opening a pull request, you represent that your contributions are your own original work +and that you have the right to license them, +and you agree that your contributions are licensed under the {{ open_source_license }}. ## Reporting bugs and requesting features @@ -60,7 +62,8 @@ When you contribute for the first time, ensure your reviewer [adds you as a cont Before submitting a pull request, check whether you have: -- Added your changes to `CHANGELOG.md`. +- Written the PR title as a conventional commit subject (see below). +- Added its line under `## Upcoming version` in `CHANGELOG.md` by hand, if the PR is a `feat`, `fix`, `perf`, `refactor`, `docs` or `revert` (see below). - Added or updated documentation for your changes. - Added tests if you implemented new functionality. @@ -68,16 +71,25 @@ When opening a pull request, please provide a clear summary of your changes! ### Commit messages -Please try to write clear commit messages. -One-line messages are fine for small changes, but bigger changes should look like this: +Merges are squashed, so the **PR title** becomes the commit on `main`. +Write it so it also works as the line you add to `CHANGELOG.md` by hand. +It must be a [conventional commit](https://www.conventionalcommits.org) subject: ```text -A brief summary of the commit (max 50 characters) +[(scope)]: -A paragraph or bullet-point list describing what changed and its impact, -covering as many lines as needed. +feat: a reader for compressed input files +fix(io): a path with a trailing slash loads +docs: describe the configuration options ``` +A `feat`, `fix`, `perf`, `refactor`, `docs` or `revert` PR adds its title, with a link to the PR, under `## Upcoming version` in `CHANGELOG.md`. +A `chore`, `test`, `ci`, `build` or `style` PR adds no line. +The `Conventional commit subject` check enforces the format on every pull request, and the `Changelog line` check enforces the line. +The label `no changelog` opts a PR out of the second. + +Beyond the subject line, write whatever body the change deserves: a paragraph or bullet list covering what changed and its impact. + ### Code conventions Start reading our code and you'll get the hang of it. @@ -87,31 +99,23 @@ We mostly follow the official [Style Guide for Python Code (PEP8)](https://www.p We have chosen to use the uncompromising code formatter and linter [`ruff`](https://beta.ruff.rs/docs/). When run from the root directory of this repo, `pyproject.toml` should ensure that formatting and linting fixes are in line with our custom preferences (e.g., maximum line length). To make this a smooth experience, you should run `pixi run pre-commit install` after setting up your development environment. -If you prefer, you can also set up your IDE to run these two tools whenever you save your files, and to have `ruff` highlight erroneous code directly as you type. +If you prefer, you can also set up your IDE to run these two tools whenever you save your files, +and to have `ruff` highlight erroneous code directly as you type. Take a look at their documentation for more information on configuring this. We require all new contributions to have docstrings for all modules, classes and methods. When adding docstrings, we request you use the [Google docstring style](https://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings). -## Release checklist - -### Pre-release - -- Make sure all unit and integration tests pass (This is best done by creating a pre-release pull request). -- Make sure documentation builds without errors (`pixi run docs-build`). -- Make sure the [changelog][changelog] is up-to-date, especially that new features and backward incompatible changes are clearly marked. - -### Create release +## Releases -- Bump the version number in `src/{{ project_slug_snake_case }}/__init__.py` -- Update the [changelog][changelog] with final version number of the form `vX.Y.Z` + release date. -- Commit with message `Release vX.Y.Z`, then add a `vX.Y.Z` tag. -- Create a release pull request to verify that all CI and CD checks pass. -- Once the PR is approved and merged, create a release through the GitHub web interface, using the same tag, titling it `Release vX.Y.Z` and include all the changelog elements that are *not- flagged as **internal**. +A release is a PR that renames `## Upcoming version` in `CHANGELOG.md` to the version and the day, +such as `## 0.1.0 (2026-10-01)`, +and edits the section into the release notes. +Merging it tags the release, opens the GitHub release and publishes the package to PyPI. -### Post-release +The version is never written down in the source tree. +It comes from the git tag at build time, and `{{ project_slug_snake_case }}.__version__` reads it back from the installed package metadata. -- Update the changelog, adding a new `[Unreleased]` heading. -- Update `src/{{ project_slug_snake_case }}/__init__.py` to the next version appended with `.dev0`, in preparation for the next main commit. +See [RELEASING.md]({{ github_url }}/blob/main/RELEASING.md) for the steps, what to do when one fails, and the one-time repository setup. diff --git a/template/RELEASING.md.jinja b/template/RELEASING.md.jinja new file mode 100644 index 0000000..b093412 --- /dev/null +++ b/template/RELEASING.md.jinja @@ -0,0 +1,87 @@ +{#- +SPDX-FileCopyrightText: 2026 copier-template-python-open-source contributors + +SPDX-License-Identifier: MIT +-#} +{#- REUSE-IgnoreStart -#} + +{#- REUSE-IgnoreEnd #} + +# Releasing + +A release is a pull request that edits `CHANGELOG.md`. +Merging it tags the commit, opens the GitHub release and publishes the package to PyPI. + +## Between releases + +Each pull request adds one line under `## Upcoming version` at the top of `CHANGELOG.md`: its title and a link to it. +A `feat`, `fix`, `perf`, `refactor`, `docs` or `revert` pull request adds a line. +A `chore`, `test`, `ci`, `build` or `style` pull request adds none. +The `Changelog line` check fails a pull request that owes a line and adds none. +The label `no changelog` opts it out, for a change no reader of the changelog needs to hear about. + +```markdown +## Upcoming version + +- feat(io): a reader for compressed input files ([#123]({{ github_url }}/pull/123)) +``` + +## Cutting a release + +1. Open a pull request that renames `## Upcoming version` to the version and the day, + and edit the section into the release notes: group the lines, merge related ones, and add a paragraph on top if the release needs one. + + ```markdown + ## 0.1.0 (2026-10-01) + ``` + + A version is `X.Y.Z`, with an optional `aN`, `bN` or `rcN` for a pre-release. + The `Lint` check refuses a heading the release workflow cannot act on: + a version that is not newer than the latest tag, a date that is not a day, or a section with nothing under it. + You can also put a new, empty `## Upcoming version` above the release. + +2. Merge it. + `.github/workflows/release.yml` then: + - tags the merge commit `v0.1.0` and opens the GitHub release, with the section as its notes. + A pre-release version is marked as one. + - builds the wheel and the sdist from the tag, through `build.yml`. + - publishes both to PyPI, after a reviewer approves the `pypi` environment. + +Every other push to `main` finds no untagged version heading and does nothing. + +`python -m tools.changelog check` runs the same check locally, and `python -m tools.changelog notes 0.1.0` prints the notes the release will get. + +## The version + +The version comes from the git tag. +`pyproject.toml` declares `dynamic = ["version"]`, and hatch-vcs reads the tag at build time, so the changelog heading, the tag and the wheel carry one number. +The release workflow checks that the wheel it publishes has that number. + +Do not push a version tag by hand. +The release workflow only acts on a heading with no tag, so a hand-made tag makes its heading history before anything is published. + +## When a step fails + +- **The tag or the GitHub release was not made.** + Fix the cause and re-run the workflow. + It acts on the first version heading that has no tag. +- **The build or the upload failed after the tag was made.** + Re-run the failed jobs of that run from the Actions tab. + PyPI never takes a version twice, so a version that reached PyPI is final. + Fix a wrong release with the next version. + +## One-time setup + +- **PyPI.** + Add a trusted publisher for the project `{{ project_slug }}`: + owner `{{ repository_owner }}`, repository `{{ project_slug }}`, workflow `release.yml`, environment `pypi`. + Before the first upload it is a _pending_ publisher. +- **The `pypi` environment.** + Create it under the repository's Settings → Environments, and add the people who may approve a release as required reviewers. +- **Branch protection on `main`.** + Require the `Lint`, `Conventional commit subject` and `Changelog line` checks. +- **The label `no changelog`.** + Create it under Issues → Labels. diff --git a/template/REUSE.toml.jinja b/template/REUSE.toml.jinja index f4ce897..2645514 100644 --- a/template/REUSE.toml.jinja +++ b/template/REUSE.toml.jinja @@ -12,7 +12,7 @@ SPDX-PackageDownloadLocation = "{{ github_url }}" path = [ ".github/pull_request_template.md", ".all-contributorsrc", - "src/py.typed" + "src/py.typed", ] SPDX-FileCopyrightText = "{{ project_slug }} contributors" SPDX-License-Identifier = "CC0-1.0" diff --git a/template/SECURITY.md.jinja b/template/SECURITY.md.jinja index 4128fb6..6cf844e 100644 --- a/template/SECURITY.md.jinja +++ b/template/SECURITY.md.jinja @@ -14,8 +14,11 @@ SPDX-License-Identifier: CC-BY-4.0 # Reporting Security Issues -We take security bugs in our projects seriously. We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions. +We take security bugs in our projects seriously. +We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions. To report a security issue, please use the GitHub Security Advisory ["Report a Vulnerability"]({{ github_url }}/security/advisories/new) tab. -We will send a response indicating the next steps in handling your report. After the initial reply to your report, the security team will keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance. +We will send a response indicating the next steps in handling your report. +After the initial reply to your report, the security team will keep you informed of the progress towards a fix and full announcement, +and may ask for additional information or guidance. diff --git a/template/docs/contributing.md.jinja b/template/docs/contributing.md.jinja index da6b286..11ab8a7 100644 --- a/template/docs/contributing.md.jinja +++ b/template/docs/contributing.md.jinja @@ -55,14 +55,16 @@ If you plan to make changes to the code then please make regular use of the foll ## Documentation With any contribution, you may need to update / add to the documentation (in the `docs` directory). -We use [MkDocs](https://www.mkdocs.org/) and the [Material](https://squidfunk.github.io/mkdocs-material/) theme to build and render our documentation, meaning you can write your documentation in Markdown files. +We use [MkDocs](https://www.mkdocs.org/) and the [Material](https://squidfunk.github.io/mkdocs-material/) theme to build and render our documentation, +meaning you can write your documentation in Markdown files. Here are some use-cases that you may come across in which you are considering updating the documentation: ??? question "I have updated the README.md" We pipe sections of the README over to the documentation automatically. - If you put your changes between the commented lines `:::md ` and `:::md ` it will find its way to the documentation homepage. + If you put your changes between the commented lines `:::md ` and `:::md ` + it will find its way to the documentation homepage. ??? question annotate "I want to add a new page" @@ -129,7 +131,8 @@ Here are some use-cases that you may come across in which you are considering up You can serve your documentation locally by calling `pixi run docs-serve` from the command line. Once the documentation has been built you will see a link to navigate to in your browser, most likely . - When you make changes to your documentation, `mkdocs` will automatically rebuild everything so that you can check the effects of your changes without needing to rerun manually. + When you make changes to your documentation, `mkdocs` will automatically rebuild everything + so that you can check the effects of your changes without needing to rerun manually. ??? question "I want to do something else" diff --git a/template/mkdocs.yml.jinja b/template/mkdocs.yml.jinja index 3c1edbc..a042951 100644 --- a/template/mkdocs.yml.jinja +++ b/template/mkdocs.yml.jinja @@ -135,5 +135,5 @@ nav: - Contributing: contributing.md - Changelog: CHANGELOG.md - Reference: - - reference/index.md - - AGENTS.md + - reference/index.md + - AGENTS.md diff --git a/template/pixi.toml.jinja b/template/pixi.toml.jinja index 8c51f2e..4cd0b79 100644 --- a/template/pixi.toml.jinja +++ b/template/pixi.toml.jinja @@ -20,12 +20,16 @@ preview = ["pixi-build"] [package] name = "{{ project_slug }}" +# pyproject.toml takes its version from the git tag, but pixi needs a literal +# to name the local development install. Nothing ships from this. +version = "0.0.0" [package.build.backend] name = "pixi-build-python" version = "*" [package.host-dependencies] python = ">={{ minimal_python_version.replace('py3', '3.') }}" hatchling = "*" +hatch-vcs = "*" uv = "*" [dependencies] @@ -42,6 +46,7 @@ test-coverage = "pytest --cov={{ project_slug_snake_case }} --cov-report=xml --c [feature.build.dependencies] python = "*" hatchling = "*" +hatch-vcs = "*" python-build = "*" twine = ">=6" [feature.build.tasks] diff --git a/template/pyproject.toml.jinja b/template/pyproject.toml.jinja index d384c5c..33d1b59 100644 --- a/template/pyproject.toml.jinja +++ b/template/pyproject.toml.jinja @@ -12,9 +12,13 @@ SPDX-License-Identifier: MIT {#- REUSE-IgnoreEnd #} [build-system] -requires = ["hatchling"] +requires = ["hatchling", "hatch-vcs"] build-backend = "hatchling.build" +[tool.hatch.version] +source = "vcs" +fallback-version = "0.0.0" + [tool.hatch.build.targets.sdist] only-include = ["src", "LICENSES", "README.md"] @@ -22,8 +26,8 @@ only-include = ["src", "LICENSES", "README.md"] name = "{{ project_slug }}" description = "{{ project_short_description }}" authors = [{ name = "{{ author_name }}", email = "{{ author_email }}" }] -# replaced in CI -version = "0.0.0" +# The git tag is the version, see RELEASING.md. +dynamic = ["version"] classifiers = [ "Programming Language :: Python :: 3", {%- if minimal_python_version <= "py312" %} diff --git a/template/tests/conftest.py.jinja b/template/tests/conftest.py.jinja index fd3bbb5..aca0c0a 100644 --- a/template/tests/conftest.py.jinja +++ b/template/tests/conftest.py.jinja @@ -9,12 +9,4 @@ SPDX-License-Identifier: MIT # SPDX-License-Identifier: {{ license }} {#- REUSE-IgnoreEnd #} -import pytest - - -# This is a fixture that comes with the template; feel free to delete it. -@pytest.fixture -def base(tmp_path, monkeypatch): - """Point layer_dir at a temporary directory for the whole test.""" - monkeypatch.setattr(duck, "DEFAULT_BASE_URI", str(tmp_path)) - return str(tmp_path) +"""Fixtures shared by the whole test suite.""" diff --git a/template/tests/test_import.py.jinja b/template/tests/test_import.py.jinja new file mode 100644 index 0000000..68a33a1 --- /dev/null +++ b/template/tests/test_import.py.jinja @@ -0,0 +1,19 @@ +{#- +SPDX-FileCopyrightText: 2026 copier-template-python-open-source contributors + +SPDX-License-Identifier: MIT +-#} +{#- REUSE-IgnoreStart -#} +# SPDX-FileCopyrightText: {{ project_slug }} contributors +# +# SPDX-License-Identifier: {{ license }} +{#- REUSE-IgnoreEnd #} + +import {{ project_slug_snake_case }} + + +def test_package_imports_with_a_version(): + """Placeholder test to ensure template CI checks can be conducted thoroughly""" + assert {{ project_slug_snake_case }}.__version__ != "unknown", ( + "the package is installed and reads its version from its metadata" + ) diff --git a/template/tools/__init__.py.jinja b/template/tools/__init__.py.jinja new file mode 100644 index 0000000..15a3b37 --- /dev/null +++ b/template/tools/__init__.py.jinja @@ -0,0 +1,10 @@ +{#- +SPDX-FileCopyrightText: 2026 copier-template-python-open-source contributors + +SPDX-License-Identifier: MIT +-#} +{#- REUSE-IgnoreStart -#} +# SPDX-FileCopyrightText: {{ project_slug }} contributors +# +# SPDX-License-Identifier: {{ license }} +{#- REUSE-IgnoreEnd #} diff --git a/template/tools/changelog.py.jinja b/template/tools/changelog.py.jinja new file mode 100644 index 0000000..019908f --- /dev/null +++ b/template/tools/changelog.py.jinja @@ -0,0 +1,221 @@ +{#- +SPDX-FileCopyrightText: 2026 copier-template-python-open-source contributors + +SPDX-License-Identifier: MIT +-#} +{#- REUSE-IgnoreStart -#} +# SPDX-FileCopyrightText: {{ project_slug }} contributors +# +# SPDX-License-Identifier: {{ license }} +{#- REUSE-IgnoreEnd #} + +"""The release a changelog asks for, read off its headings. + + python -m tools.changelog check # fail if the headings are not a release this repo can cut + python -m tools.changelog pending # print the version to release, or nothing + python -m tools.changelog notes 0.1.0 # print that version's section + python -m tools.changelog entry "" base.md # fail if the PR adds no line it owes + +``CHANGELOG.md`` is edited by hand. Above the first release sits at most one +``## Upcoming version``. A release PR renames it to ``## 0.1.0 (2026-10-01)``, +and the merge to ``main`` releases the first version heading that has no tag. +Only the heading lines are read; the prose under them is the release notes as +written. Standard library only, because the release workflow runs it on the +runner's own Python. +""" + +from __future__ import annotations + +import re +import subprocess +import sys +from dataclasses import dataclass +from datetime import date +from pathlib import Path + +PATH = Path(__file__).resolve().parent.parent / "CHANGELOG.md" +UPCOMING = "Upcoming version" + +#: A release heading as a release PR writes it: a PEP 440 release with an +#: optional pre-release, then the date in parentheses. +RELEASE = re.compile( + r"(?P\d+\.\d+\.\d+(?:(?:a|b|rc)\d+)?) \((?P\d{4}-\d{2}-\d{2})\)" +) +#: A tag this repository has cut. +TAG = re.compile(r"v(?P\d+\.\d+\.\d+)(?:(?P
a|b|rc)(?P\d+))?")
+PRE_RANK = {"a": 0, "b": 1, "rc": 2}
+#: The PR types a changelog reader wants to hear about, each of which adds a line.
+NEEDS_LINE = frozenset({"feat", "fix", "perf", "refactor", "docs", "revert"})
+#: The label that lets a PR of one of those types add no line.
+OPT_OUT = "no changelog"
+
+
+class ChangelogError(ValueError):
+    """The headings do not describe a release this repository can cut."""
+
+
+@dataclass(frozen=True)
+class Section:
+    """One ``##`` section: its heading text and the lines under it."""
+
+    heading: str
+    body: str
+
+
+def sections(text: str) -> list[Section]:
+    """Split the changelog at its ``##`` headings, dropping the preamble."""
+    parts = re.split(r"^## (.+)$", text, flags=re.MULTILINE)
+    return [
+        Section(heading.strip(), body.strip("\n"))
+        for heading, body in zip(parts[1::2], parts[2::2], strict=True)
+    ]
+
+
+def order(tag: str) -> tuple[int, ...] | None:
+    """The sort key of a version tag, or None for a tag that is not a version."""
+    match = TAG.fullmatch(tag)
+    if match is None:
+        return None
+    release = tuple(int(part) for part in match["release"].split("."))
+    if match["pre"] is None:
+        return (*release, len(PRE_RANK), 0)
+    return (*release, PRE_RANK[match["pre"]], int(match["number"]))
+
+
+def pending(text: str, tags: set[str]) -> str | None:
+    """The version the changelog asks to release, or None when it asks for none.
+
+    Raises
+    ------
+    ChangelogError
+        If a heading above the first release is not ``Upcoming version``, if
+        there are two of those, or if the first release heading is new but its
+        version, date or section is not one this repository can release.
+    """
+    upcoming = 0
+    for section in sections(text):
+        if section.heading == UPCOMING:
+            upcoming += 1
+            if upcoming > 1:
+                raise ChangelogError(
+                    f'there are two "## {UPCOMING}" headings. Keep one, above every release.'
+                )
+            continue
+        release = RELEASE.fullmatch(section.heading)
+        if release is None:
+            raise ChangelogError(
+                f'"## {section.heading}" is neither "## {UPCOMING}" nor a release such as '
+                f'"## 0.1.0 (2026-10-01)". A version is X.Y.Z, with an optional a, b or rc '
+                f"number: 0.1.0rc1."
+            )
+        return _new_release(release["version"], release["date"], section, tags)
+    return None
+
+
+def _new_release(version: str, day: str, section: Section, tags: set[str]) -> str | None:
+    """Check the first release heading, and return its version if it has no tag yet.
+
+    A tagged version is history, whatever else is true of it. An untagged one is
+    released on the merge, so everything a release needs is checked here, in the
+    pull request that writes it, rather than by the workflow after the merge.
+    """
+    tag = f"v{version}"
+    if tag in tags:
+        return None
+    try:
+        date.fromisoformat(day)
+    except ValueError:
+        raise ChangelogError(
+            f'"## {section.heading}" has the date {day}, which is not a day. Write YYYY-MM-DD.'
+        ) from None
+    if not section.body.strip():
+        raise ChangelogError(
+            f'"## {section.heading}" has nothing under it. Write the release notes under it.'
+        )
+    newest = max((key for key in map(order, tags) if key is not None), default=None)
+    key = order(tag)
+    if newest is not None and key is not None and key <= newest:
+        latest = max(t for t in tags if order(t) == newest)
+        raise ChangelogError(
+            f"{version} is not newer than the latest tag, {latest}. Pick a higher version."
+        )
+    return version
+
+
+def notes(text: str, version: str) -> str:
+    """The section under the release heading of *version*, as the release notes.
+
+    Raises
+    ------
+    ChangelogError
+        If no release heading names *version*.
+    """
+    for section in sections(text):
+        release = RELEASE.fullmatch(section.heading)
+        if release is not None and release["version"] == version:
+            return section.body.strip() + "\n"
+    raise ChangelogError(f'no "## {version} (date)" heading in the changelog.')
+
+
+def upcoming(text: str) -> list[str]:
+    """The non-empty lines under ``## Upcoming version``, or none when it is absent."""
+    for section in sections(text):
+        if section.heading == UPCOMING:
+            return [line for line in section.body.splitlines() if line.strip()]
+    return []
+
+
+def missing_entry(title: str, text: str, base: str) -> str | None:
+    """Why a pull request titled *title* owes the changelog a line, or None when it does not.
+
+    *text* is the changelog on the pull request and *base* the one on its base
+    branch. A line counts when it is under ``## Upcoming version`` and was not
+    there on the base.
+    """
+    kind = re.match(r"[a-z]+", title)
+    if kind is None or kind[0] not in NEEDS_LINE:
+        return None
+    if set(upcoming(text)) - set(upcoming(base)):
+        return None
+    return (
+        f'a {kind[0]} PR adds its title, with a link to the PR, under "## {UPCOMING}". '
+        f'If readers of the changelog do not need to hear about it, add the label "{OPT_OUT}".'
+    )
+
+
+def _tags() -> set[str]:
+    """The tags of the repository this runs in."""
+    return set(
+        subprocess.run(
+            ["git", "tag", "--list"], capture_output=True, text=True, check=True
+        ).stdout.split()
+    )
+
+
+def main(argv: list[str]) -> int:
+    """Run one verb, printing a refusal as a GitHub annotation."""
+    text = PATH.read_text()
+    try:
+        match argv:
+            case ["check"]:
+                version = pending(text, _tags())
+                print(f"releases {version} on merge" if version else "releases nothing on merge")
+            case ["pending"]:
+                print(pending(text, _tags()) or "")
+            case ["notes", version]:
+                sys.stdout.write(notes(text, version))
+            case ["entry", title, base]:
+                if (reason := missing_entry(title, text, Path(base).read_text())) is not None:
+                    raise ChangelogError(reason)
+                print("the changelog line is there, or this PR owes none")
+            case _:
+                print(__doc__, file=sys.stderr)
+                return 2
+    except ChangelogError as error:
+        print(f"::error file=CHANGELOG.md::{error}")
+        return 1
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main(sys.argv[1:]))
diff --git a/tests/conftest.py b/tests/conftest.py
index fb4a11e..85871bc 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -15,41 +15,44 @@
 from .utils import git_user
 
 
+def copy_template(
+    target: Path, extra_data: dict | None = None, skip_tasks: bool = True
+) -> Path:
+    data = {
+        "project_slug": "project",
+        "project_short_description": "A test project",
+        "github_user": "LandoCalrissian",
+        "author_name": "Lando Calrissian",
+        "author_email": "lando@calrissian.org",
+    }
+    data.update(extra_data or {})
+
+    directory = Path(__file__).parent
+    with git_user():
+        copier_data = chain.from_iterable(
+            [("--data", f"{key}={value}") for key, value in data.items()]
+        )
+        subprocess.check_call(
+            [
+                "copier",
+                "copy",
+                "--defaults",
+                "--vcs-ref=HEAD",
+                "--trust",
+                "--skip-tasks" if skip_tasks else "",
+                *copier_data,
+                directory.parent,
+                target,
+            ]
+        )
+
+    return target
+
+
 @pytest.fixture
 def generate_project(tmp_path):
     def _generate(extra_data=None, skip_tasks: bool = True):
-        if extra_data is None:
-            extra_data = {}
-
-        data = {
-            "project_slug": "project",
-            "project_short_description": "A test project",
-            "github_user": "LandoCalrissian",
-            "author_name": "Lando Calrissian",
-            "author_email": "lando@calrissian.org",
-        }
-        data.update(extra_data)
-
-        directory = Path(__file__).parent
-        with git_user():
-            copier_data = chain.from_iterable(
-                [("--data", f"{key}={value}") for key, value in data.items()]
-            )
-            subprocess.check_call(
-                [
-                    "copier",
-                    "copy",
-                    "--defaults",
-                    "--vcs-ref=HEAD",
-                    "--trust",
-                    "--skip-tasks" if skip_tasks else "",
-                    *copier_data,
-                    directory.parent,
-                    tmp_path,
-                ]
-            )
-
-        return tmp_path
+        return copy_template(tmp_path, extra_data, skip_tasks)
 
     return _generate
 
diff --git a/tests/test_changelog.py b/tests/test_changelog.py
new file mode 100644
index 0000000..7c23df7
--- /dev/null
+++ b/tests/test_changelog.py
@@ -0,0 +1,189 @@
+# SPDX-FileCopyrightText: 2026 copier-template-python-open-source contributors
+#
+# SPDX-License-Identifier: MIT
+
+import importlib.util
+import re
+import subprocess
+import sys
+from pathlib import Path
+from types import ModuleType
+
+import pytest
+
+from .conftest import copy_template
+
+PREAMBLE = "# Changelog\n\nProse the tool never reads.\n\n"
+HISTORY = "## 0.0.9 (2026-09-25)\n\n- an old line\n"
+TAGS = {"v0.0.8", "v0.0.9"}
+
+
+def changelog(*blocks: str) -> str:
+    return PREAMBLE + "".join(blocks) + HISTORY
+
+
+@pytest.fixture(scope="module")
+def project(tmp_path_factory: pytest.TempPathFactory) -> Path:
+    return copy_template(
+        tmp_path_factory.mktemp("project"), {"project_slug": "my-project"}
+    )
+
+
+@pytest.fixture(scope="module")
+def tool(project: Path) -> ModuleType:
+    spec = importlib.util.spec_from_file_location(
+        "changelog", project / "tools" / "changelog.py"
+    )
+    assert spec is not None and spec.loader is not None
+    module = importlib.util.module_from_spec(spec)
+    sys.modules[spec.name] = module
+    spec.loader.exec_module(module)
+    return module
+
+
+@pytest.mark.parametrize(
+    ("text", "tags", "expected"),
+    [
+        pytest.param(changelog("## Upcoming version\n\n- a line\n\n"), TAGS, None, id="upcoming-only"),
+        pytest.param(changelog(), TAGS, None, id="nothing-above-the-history"),
+        pytest.param(PREAMBLE + "## Upcoming version\n", set(), None, id="no-release-yet"),
+        pytest.param(changelog("## 0.1.0 (2026-10-01)\n\n- a line\n\n"), TAGS, "0.1.0", id="a-new-release"),
+        pytest.param(
+            changelog("## Upcoming version\n\n## 0.1.0 (2026-10-01)\n\n- a line\n\n"),
+            TAGS,
+            "0.1.0",
+            id="a-new-release-under-an-empty-upcoming",
+        ),
+        pytest.param(changelog("## 0.1.0rc1 (2026-10-01)\n\n- a line\n\n"), TAGS, "0.1.0rc1", id="a-release-candidate"),
+        pytest.param(PREAMBLE + "## 0.1.0 (2026-10-01)\n\n- a line\n", set(), "0.1.0", id="the-first-release"),
+        pytest.param(
+            changelog("## 0.1.0 (2026-10-01)\n\n- a line\n\n"), TAGS | {"v0.1.0"}, None, id="a-release-already-tagged"
+        ),
+        pytest.param(
+            changelog("## 0.1.1 (2026-10-02)\n\n- b\n\n## 0.1.0 (2026-10-01)\n\n- a\n\n"),
+            TAGS | {"v0.1.0"},
+            "0.1.1",
+            id="the-next-release-above-a-tagged-one",
+        ),
+    ],
+)  # fmt: skip
+def test_the_first_untagged_release_heading_is_the_release(tool, text, tags, expected):
+    assert tool.pending(text, tags) == expected
+
+
+@pytest.mark.parametrize(
+    ("text", "message"),
+    [
+        pytest.param(changelog("## Unreleased\n\n- a line\n\n"), 'neither "## Upcoming version"', id="another-name"),
+        pytest.param(changelog("## v0.1.0 (2026-10-01)\n\n- a\n\n"), "neither", id="a-v-before-the-version"),
+        pytest.param(changelog("## 0.1 (2026-10-01)\n\n- a\n\n"), "neither", id="two-parts"),
+        pytest.param(changelog("## 0.1.0-alpha.1 (2026-10-01)\n\n- a\n\n"), "neither", id="a-semver-pre"),
+        pytest.param(changelog("## 0.1.0\n\n- a\n\n"), "neither", id="no-date"),
+        pytest.param(changelog("## 0.1.0 (2026-13-01)\n\n- a\n\n"), "not a day", id="a-date-that-is-not-a-day"),
+        pytest.param(changelog("## 0.1.0 (2026-10-01)\n\n"), "nothing under it", id="no-notes"),
+        pytest.param(
+            changelog("## 0.0.7 (2026-10-01)\n\n- a\n\n"),
+            "not newer than the latest tag, v0.0.9",
+            id="older-than-the-latest-tag",
+        ),
+        pytest.param(changelog("## Upcoming version\n\n## Upcoming version\n\n"), 'two "## Upcoming version"', id="two"),
+    ],
+)  # fmt: skip
+def test_a_heading_the_workflow_cannot_release_is_refused(tool, text, message):
+    with pytest.raises(tool.ChangelogError, match=re.escape(message)):
+        tool.pending(text, TAGS)
+
+
+@pytest.mark.parametrize(
+    ("smaller", "larger"),
+    [
+        pytest.param("v0.1.0a1", "v0.1.0b1", id="alpha-before-beta"),
+        pytest.param("v0.1.0a9", "v0.1.0a10", id="a-pre-release-counter-as-a-number"),
+        pytest.param("v0.1.0rc2", "v0.1.0", id="a-candidate-before-the-release"),
+        pytest.param("v0.9.0", "v0.10.0", id="a-part-as-a-number"),
+    ],
+)
+def test_tags_order_as_versions(tool, smaller, larger):
+    assert tool.order(smaller) < tool.order(larger)
+
+
+def test_a_tag_that_is_not_a_version_is_ignored(tool):
+    assert tool.order("docs-snapshot") is None, (
+        "a stray tag neither blocks nor pins a release"
+    )
+
+
+def test_the_notes_are_the_section_as_written(tool):
+    text = changelog("## 0.1.0 (2026-10-01)\n\n### Features\n\n- a line\n\n")
+    assert tool.notes(text, "0.1.0") == "### Features\n\n- a line\n", (
+        "the release notes are the text under the heading"
+    )
+
+
+def test_notes_for_a_version_with_no_heading_are_refused(tool):
+    with pytest.raises(
+        tool.ChangelogError, match=re.escape('no "## 0.2.0 (date)" heading')
+    ):
+        tool.notes(changelog(), "0.2.0")
+
+
+BASE = changelog("## Upcoming version\n\n- an earlier line\n\n")
+ADDED = changelog("## Upcoming version\n\n- an earlier line\n- this PR\n\n")
+
+
+@pytest.mark.parametrize(
+    ("title", "text", "owes"),
+    [
+        pytest.param("feat(io): a new thing", ADDED, False, id="a-feat-that-adds-its-line"),
+        pytest.param("feat(io): a new thing", BASE, True, id="a-feat-that-adds-none"),
+        pytest.param("fix: a bug", BASE, True, id="a-fix-that-adds-none"),
+        pytest.param("docs: a page", BASE, True, id="a-docs-pr-that-adds-none"),
+        pytest.param("revert: a change", BASE, True, id="a-revert-that-adds-none"),
+        pytest.param("chore: a tidy", BASE, False, id="a-chore-owes-none"),
+        pytest.param("ci: a workflow", BASE, False, id="a-ci-pr-owes-none"),
+        pytest.param("test: a case", BASE, False, id="a-test-pr-owes-none"),
+        pytest.param(
+            "feat: a new thing",
+            changelog("## Upcoming version\n\n- an earlier line, reworded\n\n"),
+            False,
+            id="a-reworded-line-counts",
+        ),
+        pytest.param(
+            "feat: a new thing",
+            changelog("## 0.1.0 (2026-10-01)\n\n- an earlier line\n- this PR\n\n"),
+            True,
+            id="a-line-outside-upcoming-does-not-count",
+        ),
+    ],
+)  # fmt: skip
+def test_a_pr_a_changelog_reader_wants_to_hear_about_adds_a_line(
+    tool, title, text, owes
+):
+    assert (tool.missing_entry(title, text, BASE) is not None) == owes
+
+
+def test_a_pr_that_owes_a_line_is_told_the_way_out(tool):
+    reason = tool.missing_entry("feat: a new thing", BASE, BASE)
+    assert reason is not None
+    assert 'under "## Upcoming version"' in reason, (
+        "the message says where the line goes"
+    )
+    assert 'the label "no changelog"' in reason, "and how a PR that owes none says so"
+
+
+def test_the_generated_changelog_releases_nothing(project):
+    subprocess.run(["git", "init", "-q"], cwd=project, check=True)
+    result = subprocess.run(
+        [sys.executable, "-m", "tools.changelog", "check"],
+        cwd=project,
+        capture_output=True,
+        text=True,
+        check=True,
+    )
+    assert result.stdout.strip() == "releases nothing on merge"
+
+
+def test_the_release_checks_the_normalised_wheel_name(project):
+    release = (project / ".github" / "workflows" / "release.yml").read_text()
+    assert 'test -f "dist/my_project-${VERSION}-py3-none-any.whl"' in release
+    assert 'test -f "dist/my_project-${VERSION}.tar.gz"' in release
diff --git a/tests/test_migrations.py b/tests/test_migrations.py
new file mode 100644
index 0000000..1871a3b
--- /dev/null
+++ b/tests/test_migrations.py
@@ -0,0 +1,97 @@
+# SPDX-FileCopyrightText: 2026 copier-template-python-open-source contributors
+#
+# SPDX-License-Identifier: MIT
+
+import re
+
+import pytest
+
+from migrations.v1_1_0 import migrate
+
+GUIDE = """
+
+"""
+OLD = """# Changelog
+
+## Unreleased
+
+### Added
+
+- a line
+
+## [0.2.0] - 2026-09-01
+
+- b
+
+## v0.1.0 (2026.08.17)
+
+- a
+"""
+NEW = """# Changelog
+
+## Upcoming version
+
+### Added
+
+- a line
+
+## 0.2.0 (2026-09-01)
+
+- b
+
+## 0.1.0 (2026-08-17)
+
+- a
+"""
+
+
+def test_the_old_template_changelog_reads_as_the_new_one():
+    assert migrate(GUIDE + OLD, {"v0.1.0", "v0.2.0"}) == NEW
+
+
+@pytest.mark.parametrize(
+    ("heading", "expected"),
+    [
+        ("## [Unreleased]", "## Upcoming version"),
+        ("## [0.1.0rc1] - 2026-10-01", "## 0.1.0rc1 (2026-10-01)"),
+        (
+            "## [v0.1.0](https://example.org/compare) - 2026-10-01",
+            "## 0.1.0 (2026-10-01)",
+        ),
+        ("## 0.1.0 (2026-10-01)", "## 0.1.0 (2026-10-01)"),
+    ],
+)
+def test_a_heading_takes_the_new_form(heading, expected):
+    assert migrate(f"# Changelog\n\n{heading}\n\n- a\n", {"v0.1.0", "v0.1.0rc1"}) == (
+        f"# Changelog\n\n{expected}\n\n- a\n"
+    )
+
+
+@pytest.mark.parametrize(
+    ("text", "tags", "message"),
+    [
+        pytest.param(
+            "## Next release\n", set(), '"## Next release"', id="unknown-heading"
+        ),
+        pytest.param(
+            "## [0.1] - 2026-10-01\n\n- a\n",
+            set(),
+            "no new form",
+            id="two-part-version",
+        ),
+        pytest.param(OLD, {"v0.1.0"}, 'no tag "v0.2.0"', id="latest-release-untagged"),
+        pytest.param(OLD, {"0.2.0", "0.1.0"}, "git tag v0.2.0", id="tags-without-v"),
+    ],
+)
+def test_a_changelog_the_release_tool_would_refuse_stops_the_update(
+    text, tags, message
+):
+    with pytest.raises(ValueError, match=re.escape(message)):
+        migrate(text, tags)
diff --git a/tests/test_template.py b/tests/test_template.py
index 9de0156..79aef51 100644
--- a/tests/test_template.py
+++ b/tests/test_template.py
@@ -13,6 +13,8 @@ def test_generation(generated_project, project_slug):
     assert (
         generated_project / "src" / project_slug.replace("-", "_") / "__init__.py"
     ).exists()
+    smoke_test = (generated_project / "tests" / "test_import.py").read_text()
+    assert f"import {project_slug.replace('-', '_')}\n" in smoke_test
     readme = (generated_project / "README.md").read_text()
     assert (
         f"https://img.shields.io/github/actions/workflow/status/energy-models/{project_slug}/ci.yml"