Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
d121948
feat: allow 'nanobind' as python binding library, additionally to 'py…
thomasisensee Mar 16, 2026
5c281c1
chore: bump pre-commit hooks versions
thomasisensee Mar 26, 2026
277e115
chore: bump GitHub actions versions
thomasisensee Mar 26, 2026
204f68b
refactor: initialize git repository directly with main as branch name
thomasisensee Mar 26, 2026
6db7f1f
tests: add nanobind option
thomasisensee Mar 26, 2026
184ca68
ci: run pypi publishing on version tags and allow manual dispatch
thomasisensee Mar 26, 2026
218fb26
fix: use tomlkit to modify only the pyproject version, not the cmake …
thomasisensee Mar 26, 2026
e085ab1
fix: do not switch to pypi_release branch, instead make change on mai…
thomasisensee May 19, 2026
4bc527e
fix: consider also existing tags for max version
thomasisensee May 19, 2026
4e86f15
ci: define pypa/cibuildwheel version in cookiecutter.json
thomasisensee May 19, 2026
79b31f6
fix(ci): use python shell to prevent error on windows runners
thomasisensee May 19, 2026
b929871
fix(ci): uploat-testpypi and upload-pypi need build-sdist
thomasisensee May 19, 2026
5220baf
fix: give gh more time to start workflow + don't assume get_runs()[0]…
thomasisensee May 19, 2026
6fdd2ec
fix(ci): use pypa/gh-action-pypi-publish@release/v1
thomasisensee May 19, 2026
0103627
ci: remove GH actions environment
thomasisensee May 19, 2026
7efab77
test: remove unused import
thomasisensee May 19, 2026
402a520
fix: PyPI incorrect classifier
thomasisensee May 19, 2026
2dcbae2
chore: bump pre-commit hooks versions
thomasisensee Jun 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ jobs:
git config --global user.name "SSC CI Test User"

- name: Set up SSH Agent to deploy to test repositories
uses: webfactory/ssh-agent@v0.9.1
uses: webfactory/ssh-agent@v0.10.0
with:
ssh-private-key: |
${{ secrets.GHA_TEST_PRIVATE_KEY }}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/pypi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ jobs:
git config --global user.name "SSC CI Test User"

- name: Set up SSH Agent to deploy to test repositories
uses: webfactory/ssh-agent@v0.9.1
uses: webfactory/ssh-agent@v0.10.0
with:
ssh-private-key: |
${{ secrets.GHA_TEST_PRIVATE_KEY }}
Expand Down
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,6 @@ repos:

# GitHub Actions Workflow linter
- repo: https://github.com/rhysd/actionlint
rev: v1.7.11
rev: v1.7.12
hooks:
- id: actionlint
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ The core features of our C++ Cookiecutter in a nutshell:
* Ready-to-use integration with the Github Actions and Gitlab CI
* Generation and deployment of Sphinx-based documentation for [Read the Docs](https://readthedocs.org)
* Building a Doxygen documentation
* Setup of Python bindings with Pybind11
* Setup of Python bindings with [pybind11](https://github.com/pybind/pybind11) or [nanobind](https://github.com/wjakob/nanobind)
* CI-based deployment of Python wheels to PyPI
* Generated [pre-commit](https://pre-commit.com/) configuration is added if you have `pre-commit` installed
* Integration with coverage testing from [codecov.io](https://codecov.io)
Expand Down Expand Up @@ -76,7 +76,7 @@ This cookiecutter accepts the following configuration options:
* `cxx_minimum_standard`: The minimum C++ standard required for this project. It can be chosen from `14` (default), `17`, `20` and `23`.
`C++11` and earlier are not supported, because the generated project will depend on libraries that require `C++14` ([Catch2](https://github.com/catchorg/Catch2)
for testing and [pybind11](https://github.com/pybind/pybind11) for potential Python bindings).
* `python_bindings`: Whether to automatically add a PyBind11-based Python binding package.
* `python_bindings`: Whether to automatically add a PyBind11-based Python binding package. It can be chosen from [pybind11](https://github.com/pybind/pybind11) and [nanobind](https://github.com/wjakob/nanobind) or it can be omitted ([None]).
* `pypi_release`: Whether to add an automatic PyPI deploy workflow to the CI system.
This is currently limited to Github Actions CI as it provides cloud-based runners for all relevant
platforms (Linux, MacOS, Windows). A PyPI release is automatically triggered when a release is
Expand Down
8 changes: 4 additions & 4 deletions cookiecutter.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,15 @@
"readthedocs": ["Yes", "No"],
"doxygen": ["Yes", "No"],
"cxx_minimum_standard": ["14", "17", "20", "23"],
"python_bindings": ["No", "Yes"],
"pypi_release": "{{ cookiecutter.python_bindings }}",
"python_bindings": ["None", "pybind11", "nanobind"],
"pypi_release": "{%- if cookiecutter.python_bindings == 'None' -%}No{%- else -%}Yes{%- endif -%}",
"codecovio": "{{ cookiecutter.github_actions_ci }}",
"_extensions": [
"local_extensions.PrecommitExtension",
"local_extensions.ShortcutExtension",
"local_extensions.CurrentDateExtension"
],
"__python_module": "{{ cookiecutter|modname }}",
"_catch_version": "3.12.0",
"_cibuildwheel_version": "3.3.1"
"_catch_version": "3.13.0",
"_cibuildwheel_version": "3.4.1"
}
11 changes: 5 additions & 6 deletions hooks/post_gen_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,7 @@ def __init__(self):

def __enter__(self):
# Initialize the git repository
subprocess.check_call("git init".split())
subprocess.check_call("git checkout -b main".split())
subprocess.check_call("git init -b main".split())
return self

def __exit__(self, exc_type, exc_val, exc_tb):
Expand Down Expand Up @@ -72,10 +71,10 @@ def conditional_remove(condition, path):
conditional_remove("{{ cookiecutter.readthedocs }}" == "No", "doc/index.rst")
conditional_remove("{{ cookiecutter.readthedocs }}" == "No", "doc/requirements-rtd.txt")
conditional_remove("{{ cookiecutter.doxygen }}" == "No" and "{{ cookiecutter.readthedocs }}" == "No", "doc")
conditional_remove("{{ cookiecutter.python_bindings }}" == "No", "pyproject.toml")
conditional_remove("{{ cookiecutter.python_bindings }}" == "No", "requirements-dev.txt")
conditional_remove("{{ cookiecutter.python_bindings }}" == "No", "python")
conditional_remove("{{ cookiecutter.python_bindings }}" == "No", "tests/python")
conditional_remove("{{ cookiecutter.python_bindings }}" == "None", "pyproject.toml")
conditional_remove("{{ cookiecutter.python_bindings }}" == "None", "requirements-dev.txt")
conditional_remove("{{ cookiecutter.python_bindings }}" == "None", "python")
conditional_remove("{{ cookiecutter.python_bindings }}" == "None", "tests/python")
conditional_remove("{{ cookiecutter.pypi_release }}" != "Yes", ".github/workflows/pypi.yml")
conditional_remove("{{ cookiecutter.codecovio }}" == "No", "codecov.yml")
conditional_remove("{{ cookiecutter.github_actions_ci }}" == "No", ".github")
Expand Down
2 changes: 1 addition & 1 deletion hooks/pre_gen_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ def fail_if(condition, message):


fail_if(
"{{ cookiecutter.pypi_release }}" != "No" and "{{ cookiecutter.python_bindings }}" == "No",
"{{ cookiecutter.pypi_release }}" != "No" and "{{ cookiecutter.python_bindings }}" == "None",
"Can't do PyPI release without building Python bindings"
)

Expand Down
1 change: 1 addition & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ requests
ruamel.yaml
sphinx
sphinx_rtd_theme
tomlkit
11 changes: 6 additions & 5 deletions tests/test_bake_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ def test_github_actions_ci(cookies):
extra_context={
'remote_url': 'git@github.com:dokempf/test-gha-cookiecutter.git',
'github_actions_ci': 'Yes',
'python_bindings': 'Yes',
'python_bindings': 'pybind11',
'pypi_release': 'Yes',
}
)
Expand All @@ -171,12 +171,13 @@ def test_gitlabci(cookies):


@pytest.mark.local
@pytest.mark.parametrize("python_bindings", ("pybind11", "nanobind"))
@pytest.mark.parametrize("submodules", ("Yes", "No"))
def test_python(cookies, virtualenv, submodules):
def test_python(cookies, virtualenv, python_bindings, submodules):
bake = cookies.bake(
extra_context={
'project_slug': 'my-project',
'python_bindings': 'Yes',
'python_bindings': python_bindings,
'submodules': submodules,
}
)
Expand All @@ -191,13 +192,13 @@ def test_python(cookies, virtualenv, submodules):

@pytest.mark.local
def test_pypi_without_python(cookies):
bake = cookies.bake(extra_context={'python_bindings': 'No', 'pypi_release': 'Yes'})
bake = cookies.bake(extra_context={'python_bindings': 'None', 'pypi_release': 'Yes'})
assert bake.exit_code != 0


@pytest.mark.local
def test_pypi_without_github(cookies):
bake = cookies.bake(extra_context={'github_actions_ci': 'No', 'pypi_release': 'Yes', 'python_bindings': 'Yes'})
bake = cookies.bake(extra_context={'github_actions_ci': 'No', 'pypi_release': 'Yes', 'python_bindings': 'pybind11'})
assert bake.exit_code != 0


Expand Down
34 changes: 19 additions & 15 deletions tests/test_deploy_bake.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ def test_push_remote(cookies):
'github_actions_ci': 'Yes',
'gitlab_ci': 'Yes',
'readthedocs': 'Yes',
'python_bindings': 'Yes',
'python_bindings': 'pybind11',
'pypi_release': 'Yes',
'use_submodules': 'No',
'codecovio': 'Yes',
Expand All @@ -33,31 +33,35 @@ def test_push_remote(cookies):
subprocess.check_call("git push -f gitlab main".split())


def find_github_workflow_run(repo, workflow_name, sha, branch="main", event="push"):
workflow = repo.get_workflow(workflow_name)

for _ in range(30):
for run in workflow.get_runs(branch=branch):
if run.head_sha == sha and run.event == event:
return run

time.sleep(10)

pytest.fail(f"No {workflow_name} run found for {branch} at {sha}")

@pytest.mark.integrations
@pytest.mark.flaky(max_runs=3, min_passes=1, rerun_filter=wait_five_seconds)
@pytest.mark.timeout(300)
@pytest.mark.timeout(600)
def test_github_actions_ci_on_deployed_bake():
# Authenticate with the Github API
gh = github.Github(os.getenv("GH_API_ACCESS_TOKEN"))

# Identify the correct workflow
repo = gh.get_repo('dokempf/test-gha-cookiecutter')
branch = repo.get_branch('main')
workflow = repo.get_workflow("ci.yml").get_runs()[0]
workflow = find_github_workflow_run(repo, "ci.yml", branch.commit.sha)
assert workflow.head_sha == branch.commit.sha

def check_workflow(name):
# Poll the workflow status
workflow = repo.get_workflow(name).get_runs()[0]
while workflow.status != 'completed':
# We poll at a relatively large interval to avoid running against the Github API
# limitations in times of heavy development activities on the cookiecutter.
time.sleep(30)
workflow = repo.get_workflow(name).get_runs()[0]

assert workflow.conclusion == 'success'
while workflow.status != "completed":
time.sleep(30)
workflow = repo.get_workflow_run(workflow.id)

check_workflow("ci.yml")
assert workflow.conclusion == "success"


@pytest.mark.integrations
Expand Down
97 changes: 70 additions & 27 deletions tests/test_pypi_release.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,28 @@
import github
import os
import re
import pytest
import requests
import subprocess
import time
import tomlkit

from packaging import version


def github_tag_versions(repo):
versions = []

for tag in repo.get_tags():
if not tag.name.startswith("v"):
continue

try:
versions.append(version.parse(tag.name[1:]))
except version.InvalidVersion:
pass

return versions

@pytest.mark.pypi
@pytest.mark.timeout(1800)
def test_pypi_deploy():
Expand All @@ -22,45 +37,73 @@ def upstream_version(url):
# Construct a version, by finding the maximum version across Github, PyPI and TestPyPI and increasing that
gh = github.Github(os.getenv("GH_API_ACCESS_TOKEN"))
repo = gh.get_repo('dokempf/test-gha-cookiecutter')

# Modify the version in pyproject.toml and commit the change
subprocess.check_call("git clone git@github.com:dokempf/test-gha-cookiecutter.git".split())
os.chdir("test-gha-cookiecutter")

# Parse the pyproject.toml file to get the current version on the branch
with open("pyproject.toml", "r") as f:
data = tomlkit.parse(f.read())
branch_version = version.parse(data["project"]["version"])

# Identify the maximum version across PyPI, TestPyPI, Github and the branch and increase that by one patch version
current_version = max([
upstream_version('https://pypi.org/pypi/testghacookiecutter/json'),
upstream_version('https://test.pypi.org/pypi/testghacookiecutter/json'),
version.parse(repo.get_latest_release().title[1:])
version.parse(repo.get_latest_release().title[1:]),
branch_version,
*github_tag_versions(repo)
])

# Increase the version by one
next_version = version.Version('{}.{}.{}'.format(current_version.major, current_version.minor, current_version.micro + 1))

# Modify the version in pyproject.toml and commit the change
subprocess.check_call("git clone git@github.com:dokempf/test-gha-cookiecutter.git".split())
os.chdir("test-gha-cookiecutter")
with open("pyproject.toml", "r") as source:
lines = source.readlines()
with open("pyproject.toml", "w") as source:
for line in lines:
source.write(re.sub(r'version = .*$', 'version = "{}"'.format(str(next_version)), line))
# Update the version in pyproject.toml
data["project"]["version"] = str(next_version)
with open("pyproject.toml", "w") as f:
f.write(tomlkit.dumps(data))

# Commit the change and push it to the remote branch
subprocess.check_call("git add pyproject.toml".split())
subprocess.check_call(["git", "commit", "-m", "Bump version in pyproject.toml"])
subprocess.check_call("git push -f origin main:pypi_release".split())

commit_sha = subprocess.check_output(["git", "rev-parse", "HEAD"], text=True,).strip()

subprocess.check_call("git push -f origin HEAD:pypi_release".split())
time.sleep(2)

# Create the release - this will trigger the PyPI release workflow
repo.create_git_release(
'v{}'.format(str(next_version)),
'v{}'.format(str(next_version)),
"Test Release",
target_commitish='pypi_release'
# Create the release tag - this will trigger the PyPI release workflow
branch = repo.get_branch("pypi_release")

repo.create_git_ref(
ref=f"refs/tags/v{next_version}",
sha=commit_sha,
)
time.sleep(2)

# Identify the PyPI release workflow
branch = repo.get_branch('pypi_release')
workflow = repo.get_workflow("pypi.yml").get_runs()[0]
assert workflow.head_sha == branch.commit.sha

# Wait until the PyPI release workflow triggered by this tag appears
pypi_workflow = repo.get_workflow("pypi.yml")

workflow = None
for _ in range(30):
runs = pypi_workflow.get_runs()

for run in runs:
if run.head_sha == commit_sha:
workflow = run
break

if workflow is not None:
break

time.sleep(10)

assert workflow is not None, f"No pypi.yml workflow run found for commit {commit_sha}"

# Poll the workflow status
while workflow.status != 'completed':
# We poll at a relatively large interval to avoid running against the Github API
# limitations in times of heavy development activities on the cookiecutter.
while workflow.status != "completed":
time.sleep(30)
workflow = repo.get_workflow("pypi.yml").get_runs()[0]
workflow = repo.get_workflow_run(workflow.id)

assert workflow.conclusion == 'success'
assert workflow.conclusion == "success"
10 changes: 5 additions & 5 deletions {{cookiecutter.project_slug}}/.github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ jobs:
shell: bash
working-directory: ${{ "{{ github.workspace }}" }}/build
run: ctest
{%- if cookiecutter.python_bindings == "Yes" %}
{%- if cookiecutter.python_bindings != "None" %}
python-testing:
name: Python package testing on ${{ "{{matrix.os}}" }}
runs-on: ${{ "{{matrix.os}}" }}
Expand Down Expand Up @@ -97,10 +97,10 @@ jobs:
- name: Install Doxygen
uses: ssciwr/doxygen-install@v2
{% endif %}
{% if cookiecutter.python_bindings == "Yes" %}
{% if cookiecutter.python_bindings != "None" %}
- name: Install Python package editable
run: |
python -m pip install scikit_build_core pybind11[global]
python -m pip install scikit_build_core {% if cookiecutter.python_bindings == "pybind11" -%}pybind11[global]{%- elif cookiecutter.python_bindings == "nanobind" %}nanobind[global]{% endif %}
python -m pip install --no-build-isolation --config-settings=build-dir="${{ "{{ github.workspace }}" }}/build" --config-settings=cmake.define.CMAKE_CXX_FLAGS="--coverage" --config-settings=cmake.define.BUILD_TESTING="ON" -v -e .
python -m pip install -r requirements-dev.txt

Expand Down Expand Up @@ -138,5 +138,5 @@ jobs:
uses: codecov/codecov-action@v5
with:
fail_ci_if_error: true
files: ${{ "{{github.workspace}}" }}/coverage.info{% if cookiecutter.python_bindings == "Yes" %},coverage.xml{% endif %}
{% endif %}
files: ${{ "{{github.workspace}}" }}/coverage.info{% if cookiecutter.python_bindings != "None" %},coverage.xml{% endif %}
{% endif %}
Loading
Loading