From b19963158f067ff8ae8073c1a1fd87eae112b278 Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Wed, 5 Nov 2025 03:28:37 -0500 Subject: [PATCH 01/41] feat: swap to making a new feature branch in demo rather than working from develop always --- scripts/update-demo.py | 27 ++++++++++++++++++++++++++- scripts/util.py | 32 +++++++++++++++++++++++++++++++- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/scripts/update-demo.py b/scripts/update-demo.py index 4d2ad30..5a88532 100644 --- a/scripts/update-demo.py +++ b/scripts/update-demo.py @@ -7,7 +7,11 @@ import typer from cookiecutter.utils import work_in +from util import is_ancestor +from util import get_current_branch +from util import get_current_commit from util import get_demo_name +from util import get_last_cruft_update_commit from util import git from util import FolderOption from util import REPO_FOLDER @@ -24,13 +28,28 @@ def update_demo( ) -> None: """Runs precommit in a generated project and matches the template to the results.""" try: - develop_branch: str = os.getenv("COOKIECUTTER_ROBUST_PYTHON_DEVELOP_BRANCH", "develop") demo_name: str = get_demo_name(add_rust_extension=add_rust_extension) demo_path: Path = demos_cache_folder / demo_name + develop_branch: str = os.getenv("COOKIECUTTER_ROBUST_PYTHON_DEVELOP_BRANCH", "develop") + + current_branch: str = get_current_branch() + current_commit: str = get_current_commit() + + _validate_is_feature_branch(branch=current_branch) + typer.secho(f"Updating demo project at {demo_path=}.", fg="yellow") with work_in(demo_path): require_clean_and_up_to_date_repo() git("checkout", develop_branch) + + last_update_commit: str = get_last_cruft_update_commit(demo_path=demo_path) + if not is_ancestor(last_update_commit, current_commit): + raise ValueError( + f"The last update commit '{last_update_commit}' is not an ancestor of the current commit " + f"'{current_commit}'." + ) + + git("checkout", "-b", current_branch) cruft.update( project_dir=demo_path, template_path=REPO_FOLDER, @@ -45,5 +64,11 @@ def update_demo( sys.exit(1) +def _validate_is_feature_branch(branch: str) -> None: + """Validates that the cookiecutter has a feature branch checked out.""" + if not branch.startswith("feature/"): + raise ValueError(f"Received branch '{branch}' is not a feature branch.") + + if __name__ == '__main__': cli() diff --git a/scripts/util.py b/scripts/util.py index 5ce7147..d9e6b1a 100644 --- a/scripts/util.py +++ b/scripts/util.py @@ -1,4 +1,5 @@ """Module containing utility functions used throughout cookiecutter_robust_python scripts.""" +import json import os import shutil import stat @@ -17,6 +18,8 @@ import cruft import typer from cookiecutter.utils import work_in +from cruft._commands.utils.cruft import get_cruft_file +from cruft._commands.utils.cruft import json_dumps from dotenv import load_dotenv from typer.models import OptionInfo @@ -106,7 +109,34 @@ def is_branch_synced_with_remote(branch: str) -> bool: def is_ancestor(ancestor: str, descendent: str) -> bool: """Checks if the branch is synced with its remote.""" - return git("merge-base", "--is-ancestor", ancestor, descendent).returncode == 0 + return git("merge-base", "--is-ancestor", ancestor, descendent, ignore_error=True) is not None + + +def get_current_branch() -> str: + """Returns the current branch name.""" + return git("branch", "--show-current").stdout.strip() + + +def get_current_commit() -> str: + """Returns the current commit reference.""" + return git("rev-parse", "HEAD").stdout.strip() + + +def get_last_cruft_update_commit(demo_path: Path) -> str: + """Returns the commit id for the last time cruft update was ran.""" + existing_cruft_config: dict[str, Any] = _read_cruft_file(demo_path) + last_cookiecutter_commit: Optional[str] = existing_cruft_config.get("commit", None) + if last_cookiecutter_commit is None: + raise ValueError("Could not find last commit id used to generate demo.") + return last_cookiecutter_commit + + +def _read_cruft_file(project_path: Path) -> dict[str, Any]: + """Reads the cruft file for the project path provided and returns the results.""" + cruft_path: Path = get_cruft_file(project_dir_path=project_path) + cruft_text: str = cruft_path.read_text() + cruft_config: dict[str, Any] = json.loads(cruft_text) + return cruft_config @contextmanager From 8df514fb91a8f6b6a506f8328eff591bb859ed10 Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Wed, 5 Nov 2025 03:32:02 -0500 Subject: [PATCH 02/41] chore: small niceties for commit message and not trying a redundant branch creation --- scripts/update-demo.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/update-demo.py b/scripts/update-demo.py index 5a88532..4ae547f 100644 --- a/scripts/update-demo.py +++ b/scripts/update-demo.py @@ -49,14 +49,16 @@ def update_demo( f"'{current_commit}'." ) - git("checkout", "-b", current_branch) + if current_branch != develop_branch: + git("checkout", "-b", current_branch) + cruft.update( project_dir=demo_path, template_path=REPO_FOLDER, extra_context={"project_name": demo_name, "add_rust_extension": add_rust_extension}, ) git("add", ".") - git("commit", "-m", "chore: update demo to the latest cookiecutter-robust-python", "--no-verify") + git("commit", "-m", f"chore: {last_update_commit} -> {current_commit}", "--no-verify") git("push") except Exception as error: From 2d1d15b9f580a083d39b547a7360b3b3386488e8 Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Wed, 5 Nov 2025 20:12:11 -0500 Subject: [PATCH 03/41] fix: swap is_ancestor to use its own error handling due to git merge-base --is-ancestor only showing through status --- scripts/util.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/util.py b/scripts/util.py index d9e6b1a..ff07a36 100644 --- a/scripts/util.py +++ b/scripts/util.py @@ -17,6 +17,7 @@ import cruft import typer +from bandit.plugins.injection_shell import subprocess_popen_with_shell_equals_true from cookiecutter.utils import work_in from cruft._commands.utils.cruft import get_cruft_file from cruft._commands.utils.cruft import json_dumps @@ -109,7 +110,11 @@ def is_branch_synced_with_remote(branch: str) -> bool: def is_ancestor(ancestor: str, descendent: str) -> bool: """Checks if the branch is synced with its remote.""" - return git("merge-base", "--is-ancestor", ancestor, descendent, ignore_error=True) is not None + try: + git("merge-base", "--is-ancestor", ancestor, descendent) + return True + except subprocess.CalledProcessError: + return False def get_current_branch() -> str: From f99c0b8b35a36aa8b8a06034f43d72d0db943836 Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Wed, 5 Nov 2025 20:13:13 -0500 Subject: [PATCH 04/41] fix: remove accidentally added import --- scripts/util.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/scripts/util.py b/scripts/util.py index ff07a36..ac30f5f 100644 --- a/scripts/util.py +++ b/scripts/util.py @@ -17,10 +17,9 @@ import cruft import typer -from bandit.plugins.injection_shell import subprocess_popen_with_shell_equals_true + from cookiecutter.utils import work_in from cruft._commands.utils.cruft import get_cruft_file -from cruft._commands.utils.cruft import json_dumps from dotenv import load_dotenv from typer.models import OptionInfo @@ -46,7 +45,6 @@ def _load_env() -> None: # Load environment variables at module import time _load_env() - FolderOption: partial[OptionInfo] = partial( typer.Option, dir_okay=True, file_okay=False, resolve_path=True, path_type=Path ) From 0ff3bcd7d76c63ddaf3087eb1687419fe389a603 Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Wed, 5 Nov 2025 20:26:43 -0500 Subject: [PATCH 05/41] fix: add a few workarounds trying to get POC branching going before refactoring --- scripts/update-demo.py | 35 +++++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/scripts/update-demo.py b/scripts/update-demo.py index 4ae547f..e9c539a 100644 --- a/scripts/update-demo.py +++ b/scripts/update-demo.py @@ -30,26 +30,23 @@ def update_demo( try: demo_name: str = get_demo_name(add_rust_extension=add_rust_extension) demo_path: Path = demos_cache_folder / demo_name - develop_branch: str = os.getenv("COOKIECUTTER_ROBUST_PYTHON_DEVELOP_BRANCH", "develop") current_branch: str = get_current_branch() current_commit: str = get_current_commit() _validate_is_feature_branch(branch=current_branch) - typer.secho(f"Updating demo project at {demo_path=}.", fg="yellow") - with work_in(demo_path): - require_clean_and_up_to_date_repo() - git("checkout", develop_branch) + last_update_commit: str = _get_last_demo_develop_cruft_update(demo_path=demo_path) - last_update_commit: str = get_last_cruft_update_commit(demo_path=demo_path) - if not is_ancestor(last_update_commit, current_commit): - raise ValueError( - f"The last update commit '{last_update_commit}' is not an ancestor of the current commit " - f"'{current_commit}'." - ) + if not is_ancestor(last_update_commit, current_commit): + raise ValueError( + f"The last update commit '{last_update_commit}' is not an ancestor of the current commit " + f"'{current_commit}'." + ) - if current_branch != develop_branch: + typer.secho(f"Updating demo project at {demo_path=}.", fg="yellow") + with work_in(demo_path): + if current_branch != "develop": git("checkout", "-b", current_branch) cruft.update( @@ -66,6 +63,20 @@ def update_demo( sys.exit(1) +def _get_last_demo_develop_cruft_update(demo_path: Path) -> str: + """Gets the last cruft update commit for the demo project's develop branch.""" + _prep_demo_develop(demo_path=demo_path) + last_update_commit: str = get_last_cruft_update_commit(demo_path=demo_path) + return last_update_commit + + +def _prep_demo_develop(demo_path: Path) -> None: + """Checks out the demo development branch and validates it is up to date.""" + with work_in(demo_path): + require_clean_and_up_to_date_repo() + git("checkout", "develop") + + def _validate_is_feature_branch(branch: str) -> None: """Validates that the cookiecutter has a feature branch checked out.""" if not branch.startswith("feature/"): From f5aebbec954eef60243c466695b0be656abcaff2 Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Thu, 6 Nov 2025 04:24:32 -0500 Subject: [PATCH 06/41] fix: ensure that pushing a new branch for the first time works --- scripts/update-demo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/update-demo.py b/scripts/update-demo.py index e9c539a..55386fb 100644 --- a/scripts/update-demo.py +++ b/scripts/update-demo.py @@ -56,7 +56,7 @@ def update_demo( ) git("add", ".") git("commit", "-m", f"chore: {last_update_commit} -> {current_commit}", "--no-verify") - git("push") + git("push", "-u", "origin", current_branch) except Exception as error: typer.secho(f"error: {error}", fg="red") From d746b9a67721b98cfd63c603eda1626ac61dc2b3 Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Wed, 5 Nov 2025 03:28:37 -0500 Subject: [PATCH 07/41] feat: rebase branching pattern on develop --- scripts/update-demo.py | 27 ++++++++++++++++++++++++++- scripts/util.py | 32 +++++++++++++++++++++++++++++++- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/scripts/update-demo.py b/scripts/update-demo.py index 4668a36..47b796c 100644 --- a/scripts/update-demo.py +++ b/scripts/update-demo.py @@ -7,7 +7,11 @@ import typer from cookiecutter.utils import work_in +from util import is_ancestor +from util import get_current_branch +from util import get_current_commit from util import get_demo_name +from util import get_last_cruft_update_commit from util import git from util import FolderOption from util import REPO_FOLDER @@ -27,13 +31,28 @@ def update_demo( ) -> None: """Runs precommit in a generated project and matches the template to the results.""" try: - develop_branch: str = os.getenv("COOKIECUTTER_ROBUST_PYTHON_DEVELOP_BRANCH", "develop") demo_name: str = get_demo_name(add_rust_extension=add_rust_extension) demo_path: Path = demos_cache_folder / demo_name + develop_branch: str = os.getenv("COOKIECUTTER_ROBUST_PYTHON_DEVELOP_BRANCH", "develop") + + current_branch: str = get_current_branch() + current_commit: str = get_current_commit() + + _validate_is_feature_branch(branch=current_branch) + typer.secho(f"Updating demo project at {demo_path=}.", fg="yellow") with work_in(demo_path): require_clean_and_up_to_date_repo() git("checkout", develop_branch) + + last_update_commit: str = get_last_cruft_update_commit(demo_path=demo_path) + if not is_ancestor(last_update_commit, current_commit): + raise ValueError( + f"The last update commit '{last_update_commit}' is not an ancestor of the current commit " + f"'{current_commit}'." + ) + + git("checkout", "-b", current_branch) uv("python", "pin", min_python_version) uv("python", "install", min_python_version) cruft.update( @@ -56,5 +75,11 @@ def update_demo( sys.exit(1) +def _validate_is_feature_branch(branch: str) -> None: + """Validates that the cookiecutter has a feature branch checked out.""" + if not branch.startswith("feature/"): + raise ValueError(f"Received branch '{branch}' is not a feature branch.") + + if __name__ == '__main__': cli() diff --git a/scripts/util.py b/scripts/util.py index 5ce7147..d9e6b1a 100644 --- a/scripts/util.py +++ b/scripts/util.py @@ -1,4 +1,5 @@ """Module containing utility functions used throughout cookiecutter_robust_python scripts.""" +import json import os import shutil import stat @@ -17,6 +18,8 @@ import cruft import typer from cookiecutter.utils import work_in +from cruft._commands.utils.cruft import get_cruft_file +from cruft._commands.utils.cruft import json_dumps from dotenv import load_dotenv from typer.models import OptionInfo @@ -106,7 +109,34 @@ def is_branch_synced_with_remote(branch: str) -> bool: def is_ancestor(ancestor: str, descendent: str) -> bool: """Checks if the branch is synced with its remote.""" - return git("merge-base", "--is-ancestor", ancestor, descendent).returncode == 0 + return git("merge-base", "--is-ancestor", ancestor, descendent, ignore_error=True) is not None + + +def get_current_branch() -> str: + """Returns the current branch name.""" + return git("branch", "--show-current").stdout.strip() + + +def get_current_commit() -> str: + """Returns the current commit reference.""" + return git("rev-parse", "HEAD").stdout.strip() + + +def get_last_cruft_update_commit(demo_path: Path) -> str: + """Returns the commit id for the last time cruft update was ran.""" + existing_cruft_config: dict[str, Any] = _read_cruft_file(demo_path) + last_cookiecutter_commit: Optional[str] = existing_cruft_config.get("commit", None) + if last_cookiecutter_commit is None: + raise ValueError("Could not find last commit id used to generate demo.") + return last_cookiecutter_commit + + +def _read_cruft_file(project_path: Path) -> dict[str, Any]: + """Reads the cruft file for the project path provided and returns the results.""" + cruft_path: Path = get_cruft_file(project_dir_path=project_path) + cruft_text: str = cruft_path.read_text() + cruft_config: dict[str, Any] = json.loads(cruft_text) + return cruft_config @contextmanager From dc323d2497a3795b9cedadd50d8ec16bb77132e2 Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Wed, 5 Nov 2025 03:32:02 -0500 Subject: [PATCH 08/41] chore: small niceties for commit message and not trying a redundant branch creation --- scripts/update-demo.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/update-demo.py b/scripts/update-demo.py index 47b796c..ca81db5 100644 --- a/scripts/update-demo.py +++ b/scripts/update-demo.py @@ -52,7 +52,9 @@ def update_demo( f"'{current_commit}'." ) - git("checkout", "-b", current_branch) + if current_branch != develop_branch: + git("checkout", "-b", current_branch) + uv("python", "pin", min_python_version) uv("python", "install", min_python_version) cruft.update( @@ -67,7 +69,7 @@ def update_demo( ) uv("lock") git("add", ".") - git("commit", "-m", "chore: update demo to the latest cookiecutter-robust-python", "--no-verify") + git("commit", "-m", f"chore: {last_update_commit} -> {current_commit}", "--no-verify") git("push") except Exception as error: From a04fa6cf64569184b8fd1ac0e30f069cb5518b63 Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Wed, 5 Nov 2025 20:12:11 -0500 Subject: [PATCH 09/41] fix: swap is_ancestor to use its own error handling due to git merge-base --is-ancestor only showing through status --- scripts/util.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/util.py b/scripts/util.py index d9e6b1a..ff07a36 100644 --- a/scripts/util.py +++ b/scripts/util.py @@ -17,6 +17,7 @@ import cruft import typer +from bandit.plugins.injection_shell import subprocess_popen_with_shell_equals_true from cookiecutter.utils import work_in from cruft._commands.utils.cruft import get_cruft_file from cruft._commands.utils.cruft import json_dumps @@ -109,7 +110,11 @@ def is_branch_synced_with_remote(branch: str) -> bool: def is_ancestor(ancestor: str, descendent: str) -> bool: """Checks if the branch is synced with its remote.""" - return git("merge-base", "--is-ancestor", ancestor, descendent, ignore_error=True) is not None + try: + git("merge-base", "--is-ancestor", ancestor, descendent) + return True + except subprocess.CalledProcessError: + return False def get_current_branch() -> str: From 85e2d50a47c7669339e7a1927703714682eb8ad7 Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Wed, 5 Nov 2025 20:13:13 -0500 Subject: [PATCH 10/41] fix: remove accidentally added import --- scripts/util.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/scripts/util.py b/scripts/util.py index ff07a36..ac30f5f 100644 --- a/scripts/util.py +++ b/scripts/util.py @@ -17,10 +17,9 @@ import cruft import typer -from bandit.plugins.injection_shell import subprocess_popen_with_shell_equals_true + from cookiecutter.utils import work_in from cruft._commands.utils.cruft import get_cruft_file -from cruft._commands.utils.cruft import json_dumps from dotenv import load_dotenv from typer.models import OptionInfo @@ -46,7 +45,6 @@ def _load_env() -> None: # Load environment variables at module import time _load_env() - FolderOption: partial[OptionInfo] = partial( typer.Option, dir_okay=True, file_okay=False, resolve_path=True, path_type=Path ) From 791cdcfbce28fadf63c138df77908dd9d83f5584 Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Wed, 5 Nov 2025 20:26:43 -0500 Subject: [PATCH 11/41] fix: add a few workarounds trying to get POC branching going before refactoring --- scripts/update-demo.py | 35 +++++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/scripts/update-demo.py b/scripts/update-demo.py index ca81db5..a33694f 100644 --- a/scripts/update-demo.py +++ b/scripts/update-demo.py @@ -33,26 +33,23 @@ def update_demo( try: demo_name: str = get_demo_name(add_rust_extension=add_rust_extension) demo_path: Path = demos_cache_folder / demo_name - develop_branch: str = os.getenv("COOKIECUTTER_ROBUST_PYTHON_DEVELOP_BRANCH", "develop") current_branch: str = get_current_branch() current_commit: str = get_current_commit() _validate_is_feature_branch(branch=current_branch) - typer.secho(f"Updating demo project at {demo_path=}.", fg="yellow") - with work_in(demo_path): - require_clean_and_up_to_date_repo() - git("checkout", develop_branch) + last_update_commit: str = _get_last_demo_develop_cruft_update(demo_path=demo_path) - last_update_commit: str = get_last_cruft_update_commit(demo_path=demo_path) - if not is_ancestor(last_update_commit, current_commit): - raise ValueError( - f"The last update commit '{last_update_commit}' is not an ancestor of the current commit " - f"'{current_commit}'." - ) + if not is_ancestor(last_update_commit, current_commit): + raise ValueError( + f"The last update commit '{last_update_commit}' is not an ancestor of the current commit " + f"'{current_commit}'." + ) - if current_branch != develop_branch: + typer.secho(f"Updating demo project at {demo_path=}.", fg="yellow") + with work_in(demo_path): + if current_branch != "develop": git("checkout", "-b", current_branch) uv("python", "pin", min_python_version) @@ -77,6 +74,20 @@ def update_demo( sys.exit(1) +def _get_last_demo_develop_cruft_update(demo_path: Path) -> str: + """Gets the last cruft update commit for the demo project's develop branch.""" + _prep_demo_develop(demo_path=demo_path) + last_update_commit: str = get_last_cruft_update_commit(demo_path=demo_path) + return last_update_commit + + +def _prep_demo_develop(demo_path: Path) -> None: + """Checks out the demo development branch and validates it is up to date.""" + with work_in(demo_path): + require_clean_and_up_to_date_repo() + git("checkout", "develop") + + def _validate_is_feature_branch(branch: str) -> None: """Validates that the cookiecutter has a feature branch checked out.""" if not branch.startswith("feature/"): From 456aaafac730b8b144299f41d6b68bbe940617c5 Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Thu, 6 Nov 2025 04:24:32 -0500 Subject: [PATCH 12/41] fix: ensure that pushing a new branch for the first time works --- scripts/update-demo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/update-demo.py b/scripts/update-demo.py index a33694f..a7a1a0f 100644 --- a/scripts/update-demo.py +++ b/scripts/update-demo.py @@ -67,7 +67,7 @@ def update_demo( uv("lock") git("add", ".") git("commit", "-m", f"chore: {last_update_commit} -> {current_commit}", "--no-verify") - git("push") + git("push", "-u", "origin", current_branch) except Exception as error: typer.secho(f"error: {error}", fg="red") From 589dfcdb53e1561c0b0f87ee457a8cb472742611 Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Tue, 18 Nov 2025 00:24:21 -0500 Subject: [PATCH 13/41] refactor: make git related commands more accurate --- scripts/update-demo.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/scripts/update-demo.py b/scripts/update-demo.py index a7a1a0f..eb7ee34 100644 --- a/scripts/update-demo.py +++ b/scripts/update-demo.py @@ -1,4 +1,3 @@ -import os import sys from pathlib import Path from typing import Annotated @@ -35,16 +34,16 @@ def update_demo( demo_path: Path = demos_cache_folder / demo_name current_branch: str = get_current_branch() - current_commit: str = get_current_commit() + template_commit: str = get_current_commit() _validate_is_feature_branch(branch=current_branch) last_update_commit: str = _get_last_demo_develop_cruft_update(demo_path=demo_path) - if not is_ancestor(last_update_commit, current_commit): + if not is_ancestor(last_update_commit, template_commit): raise ValueError( f"The last update commit '{last_update_commit}' is not an ancestor of the current commit " - f"'{current_commit}'." + f"'{template_commit}'." ) typer.secho(f"Updating demo project at {demo_path=}.", fg="yellow") @@ -66,7 +65,7 @@ def update_demo( ) uv("lock") git("add", ".") - git("commit", "-m", f"chore: {last_update_commit} -> {current_commit}", "--no-verify") + git("commit", "-m", f"chore: {last_update_commit} -> {template_commit}", "--no-verify") git("push", "-u", "origin", current_branch) except Exception as error: From 317c7a5736740c30b0e42f65bf8d9a897e5c90ae Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Tue, 18 Nov 2025 01:11:25 -0500 Subject: [PATCH 14/41] chore: update logo --- .../cookiecutter-robust-python-banner.png | Bin 11405 -> 11115 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/docs/_static/cookiecutter-robust-python-banner.png b/docs/_static/cookiecutter-robust-python-banner.png index 7a2a9a30470754b722ee8785959c2e5de0b7b157..a016668a7f17f4f3e9b894a6e8193663ea5c0a11 100644 GIT binary patch literal 11115 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 Date: Tue, 18 Nov 2025 03:14:26 -0500 Subject: [PATCH 15/41] feat: add PEP 723 syntax installs --- scripts/generate-demo.py | 10 ++++++++++ scripts/lint-from-demo.py | 11 +++++++++++ scripts/update-demo.py | 27 ++++++++++++++++++--------- scripts/util.py | 10 ++++++++++ 4 files changed, 49 insertions(+), 9 deletions(-) diff --git a/scripts/generate-demo.py b/scripts/generate-demo.py index 5d91dfa..9918e02 100644 --- a/scripts/generate-demo.py +++ b/scripts/generate-demo.py @@ -1,4 +1,14 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "cookiecutter", +# "cruft", +# "python-dotenv", +# "typer", +# ] +# /// """Python script for generating a demo project.""" + import sys from pathlib import Path from typing import Annotated diff --git a/scripts/lint-from-demo.py b/scripts/lint-from-demo.py index 57a7749..2e8bc71 100644 --- a/scripts/lint-from-demo.py +++ b/scripts/lint-from-demo.py @@ -1,3 +1,14 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "cookiecutter", +# "cruft", +# "python-dotenv", +# "retrocookie", +# "typer", +# ] +# /// + import os from pathlib import Path from typing import Annotated diff --git a/scripts/update-demo.py b/scripts/update-demo.py index eb7ee34..3ac296d 100644 --- a/scripts/update-demo.py +++ b/scripts/update-demo.py @@ -1,3 +1,13 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "cookiecutter", +# "cruft", +# "python-dotenv", +# "typer", +# ] +# /// + import sys from pathlib import Path from typing import Annotated @@ -37,7 +47,7 @@ def update_demo( template_commit: str = get_current_commit() _validate_is_feature_branch(branch=current_branch) - + _set_demo_to_clean_develop(demo_path=demo_path) last_update_commit: str = _get_last_demo_develop_cruft_update(demo_path=demo_path) if not is_ancestor(last_update_commit, template_commit): @@ -73,20 +83,19 @@ def update_demo( sys.exit(1) -def _get_last_demo_develop_cruft_update(demo_path: Path) -> str: - """Gets the last cruft update commit for the demo project's develop branch.""" - _prep_demo_develop(demo_path=demo_path) - last_update_commit: str = get_last_cruft_update_commit(demo_path=demo_path) - return last_update_commit - - -def _prep_demo_develop(demo_path: Path) -> None: +def _set_demo_to_clean_develop(demo_path: Path) -> None: """Checks out the demo development branch and validates it is up to date.""" with work_in(demo_path): require_clean_and_up_to_date_repo() git("checkout", "develop") +def _get_last_demo_develop_cruft_update(demo_path: Path) -> str: + """Gets the last cruft update commit for the demo project's develop branch.""" + last_update_commit: str = get_last_cruft_update_commit(demo_path=demo_path) + return last_update_commit + + def _validate_is_feature_branch(branch: str) -> None: """Validates that the cookiecutter has a feature branch checked out.""" if not branch.startswith("feature/"): diff --git a/scripts/util.py b/scripts/util.py index ac30f5f..38a5028 100644 --- a/scripts/util.py +++ b/scripts/util.py @@ -1,4 +1,14 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "cookiecutter", +# "cruft", +# "python-dotenv", +# "typer", +# ] +# /// """Module containing utility functions used throughout cookiecutter_robust_python scripts.""" + import json import os import shutil From 9816734a34cc05a19a3f8b8b79ca25d736e86ea9 Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Tue, 18 Nov 2025 16:38:49 -0500 Subject: [PATCH 16/41] refactor: break apart util function for validating branch history and state --- scripts/util.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/scripts/util.py b/scripts/util.py index 38a5028..a0970c3 100644 --- a/scripts/util.py +++ b/scripts/util.py @@ -103,12 +103,17 @@ def require_clean_and_up_to_date_repo() -> None: git("fetch") git("status", "--porcelain") - if not is_branch_synced_with_remote(develop_branch): - raise ValueError(f"{develop_branch} is not synced with origin/{develop_branch}") - if not is_branch_synced_with_remote(main_branch): - raise ValueError(f"{main_branch} is not synced with origin/{main_branch}") - if not is_ancestor(main_branch, develop_branch): - raise ValueError(f"{main_branch} is not an ancestor of {develop_branch}") + validate_is_synced_ancestor(ancestor=main_branch, descendent=develop_branch) + + +def validate_is_synced_ancestor(ancestor: str, descendent: str) -> None: + """Returns whether the given ancestor is actually an up-to-date ancestor of the given descendent branch.""" + if not is_branch_synced_with_remote(branch=descendent): + raise ValueError(f"{descendent} is not synced with origin/{descendent}") + if not is_branch_synced_with_remote(branch=ancestor): + raise ValueError(f"{ancestor} is not synced with origin/{ancestor}") + if not is_ancestor(ancestor=ancestor, descendent=descendent): + raise ValueError(f"{ancestor} is not an ancestor of {descendent}") def is_branch_synced_with_remote(branch: str) -> bool: From 6b020564b34a8be42fddf24325145d2a2caf8ed0 Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Tue, 18 Nov 2025 16:41:52 -0500 Subject: [PATCH 17/41] refactor: move constants to module level in places --- scripts/release-demo.py | 50 +++++++++++++++++++++++++++++++++++++++++ scripts/util.py | 9 ++++---- 2 files changed, 55 insertions(+), 4 deletions(-) create mode 100644 scripts/release-demo.py diff --git a/scripts/release-demo.py b/scripts/release-demo.py new file mode 100644 index 0000000..c6a92a2 --- /dev/null +++ b/scripts/release-demo.py @@ -0,0 +1,50 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# cookiecutter, +# cruft, +# typer, +# ] +# /// +from pathlib import Path +from typing import Annotated + +import typer +from cookiecutter.utils import work_in + +from util import get_demo_name +from util import git +from util import is_ancestor +from util import uv +from util import validate_is_synced_ancestor +from util import FolderOption + + +cli: typer.Typer = typer.Typer() + + +@cli.callback(invoke_without_command=True) +def release_demo( + demos_cache_folder: Annotated[Path, FolderOption("--demos-cache-folder", "-c")], + min_python_version: Annotated[str, typer.Option("--min-python-version")], + max_python_version: Annotated[str, typer.Option("--max-python-version")], + add_rust_extension: Annotated[bool, typer.Option("--add-rust-extension", "-r")] = False +) -> None: + """Creates a release of the demo's current develop branch if changes exist.""" + demo_name: str = get_demo_name(add_rust_extension=add_rust_extension) + demo_path: Path = demos_cache_folder / demo_name + + + + +def _validate_demo_develop_up_to_date(demo_path: Path) -> None: + """Ensures the demo's develop branch is up to date.""" + with work_in(demo_path): + validate_is_synced_ancestor(ancestor=) + + + + + + + diff --git a/scripts/util.py b/scripts/util.py index a0970c3..a8e92e2 100644 --- a/scripts/util.py +++ b/scripts/util.py @@ -60,6 +60,10 @@ def _load_env() -> None: ) +MAIN_BRANCH: str = os.getenv("COOKIECUTTER_ROBUST_PYTHON_MAIN_BRANCH", "main") +DEVELOP_BRANCH: str = os.getenv("COOKIECUTTER_ROBUST_PYTHON_DEVELOP_BRANCH", "develop") + + def remove_readonly(func: Callable[[str], Any], path: str, _: Any) -> None: """Clears the readonly bit and attempts to call the provided function. @@ -98,12 +102,9 @@ def run_command(command: str, *args: str, ignore_error: bool = False) -> Optiona def require_clean_and_up_to_date_repo() -> None: """Checks if the repo is clean and up to date with any important branches.""" - main_branch: str = os.getenv("COOKIECUTTER_ROBUST_PYTHON_MAIN_BRANCH", "main") - develop_branch: str = os.getenv("COOKIECUTTER_ROBUST_PYTHON_DEVELOP_BRANCH", "develop") - git("fetch") git("status", "--porcelain") - validate_is_synced_ancestor(ancestor=main_branch, descendent=develop_branch) + validate_is_synced_ancestor(ancestor=MAIN_BRANCH, descendent=DEVELOP_BRANCH) def validate_is_synced_ancestor(ancestor: str, descendent: str) -> None: From 35b918b02331afa6ccc79e20053e0eb479a0c66a Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Tue, 18 Nov 2025 18:41:43 -0500 Subject: [PATCH 18/41] feat: add a check to the setup-release script in generated project along with light refactors --- .../scripts/setup-release.py | 2 ++ {{cookiecutter.project_name}}/scripts/util.py | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/{{cookiecutter.project_name}}/scripts/setup-release.py b/{{cookiecutter.project_name}}/scripts/setup-release.py index 980af60..e8b8a03 100644 --- a/{{cookiecutter.project_name}}/scripts/setup-release.py +++ b/{{cookiecutter.project_name}}/scripts/setup-release.py @@ -4,6 +4,7 @@ import subprocess from typing import Optional +from scripts.util import require_clean_and_up_to_date_repo from util import REPO_FOLDER from util import bump_version from util import check_dependencies @@ -42,6 +43,7 @@ def setup_release(increment: Optional[str] = None) -> None: release or push any changes. """ check_dependencies(path=REPO_FOLDER, dependencies=["git"]) + require_clean_and_up_to_date_repo() current_version: str = get_package_version() new_version: str = get_bumped_package_version(increment=increment) diff --git a/{{cookiecutter.project_name}}/scripts/util.py b/{{cookiecutter.project_name}}/scripts/util.py index e09e873..b2b89dc 100644 --- a/{{cookiecutter.project_name}}/scripts/util.py +++ b/{{cookiecutter.project_name}}/scripts/util.py @@ -10,6 +10,8 @@ REPO_FOLDER: Path = Path(__file__).resolve().parent.parent +MAIN_BRANCH: str = "main" +DEVELOP_BRANCH: str = "develop" class MissingDependencyError(Exception): @@ -34,6 +36,22 @@ def check_dependencies(path: Path, dependencies: list[str]) -> None: raise MissingDependencyError(path, dependency) from e +def require_clean_and_up_to_date_repo() -> None: + """Checks if the repo is clean and up to date with any important branches.""" + commands: list[list[str]] = [ + ["git", "fetch"], + ["git", "merge-base", "--is-ancestor", MAIN_BRANCH, f"origin/{MAIN_BRANCH}"], + ["git", "merge-base", "--is-ancestor", f"origin/{MAIN_BRANCH}", MAIN_BRANCH], + ["git", "merge-base", "--is-ancestor", DEVELOP_BRANCH, f"origin/{DEVELOP_BRANCH}"], + ["git", "merge-base", "--is-ancestor", f"origin/{DEVELOP_BRANCH}", DEVELOP_BRANCH], + ["git", "merge-base", "--is-ancestor", MAIN_BRANCH, DEVELOP_BRANCH], + ["git", "status", "--porcelain"], + ] + + for command in commands: + subprocess.run(command, cwd=REPO_FOLDER, check=True) + + def existing_dir(value: str) -> Path: """Responsible for validating argparse inputs and returning them as pathlib Path's if they meet criteria.""" path = Path(value).expanduser().resolve() From e25b228f8b18181f46f6630cc14a244666bdd276 Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Wed, 19 Nov 2025 01:49:42 -0500 Subject: [PATCH 19/41] feat: add logic for rolling back release creation --- .../scripts/setup-release.py | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/{{cookiecutter.project_name}}/scripts/setup-release.py b/{{cookiecutter.project_name}}/scripts/setup-release.py index e8b8a03..26201ae 100644 --- a/{{cookiecutter.project_name}}/scripts/setup-release.py +++ b/{{cookiecutter.project_name}}/scripts/setup-release.py @@ -39,14 +39,26 @@ def get_parser() -> argparse.ArgumentParser: def setup_release(increment: Optional[str] = None) -> None: """Prepares a release of the {{cookiecutter.project_name}} package. - Sets up a release branch from the branch develop, bumps the version, and creates a release commit. Does not tag the - release or push any changes. + Will try to create the release and push, however will return to pre-existing state on error. """ check_dependencies(path=REPO_FOLDER, dependencies=["git"]) require_clean_and_up_to_date_repo() current_version: str = get_package_version() new_version: str = get_bumped_package_version(increment=increment) + try: + _setup_release(increment=increment, current_version=current_version, new_version=new_version) + except Exception as error: + _rollback_release(version=new_version) + raise error + + +def _setup_release(increment: str, current_version: str, new_version: str) -> None: + """Prepares a release of the {{cookiecutter.project_name}} package. + + Sets up a release branch from the branch develop, bumps the version, and creates a release commit. Does not tag the + release or push any changes. + """ create_release_branch(new_version=new_version) bump_version(increment=increment) @@ -60,5 +72,17 @@ def setup_release(increment: Optional[str] = None) -> None: subprocess.run(command, cwd=REPO_FOLDER, capture_output=True, check=True) +def _rollback_release(version: str) -> None: + """Rolls back to the pre-existing state on error.""" + commands: list[list[str]] = [ + ["git", "checkout", "develop"], + ["git", "checkout", "."], + ["git", "branch", "-D", f"release/{version}"] + ] + + for command in commands: + subprocess.run(command, cwd=REPO_FOLDER, check=True) + + if __name__ == "__main__": main() From 18a10d84e1c4f065d74aeed577a733307df8abcd Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Wed, 19 Nov 2025 01:52:37 -0500 Subject: [PATCH 20/41] feat: update logic in release rollback to not get hung up on faulty cleanup --- {{cookiecutter.project_name}}/scripts/setup-release.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/{{cookiecutter.project_name}}/scripts/setup-release.py b/{{cookiecutter.project_name}}/scripts/setup-release.py index 26201ae..7572946 100644 --- a/{{cookiecutter.project_name}}/scripts/setup-release.py +++ b/{{cookiecutter.project_name}}/scripts/setup-release.py @@ -81,7 +81,7 @@ def _rollback_release(version: str) -> None: ] for command in commands: - subprocess.run(command, cwd=REPO_FOLDER, check=True) + subprocess.run(command, cwd=REPO_FOLDER, check=False) if __name__ == "__main__": From 426a90ac82b3a82aae77d88e23a7feb73565a2cc Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Mon, 24 Nov 2025 14:23:44 -0500 Subject: [PATCH 21/41] feat: add a ton of half finished logic for release handling that will be refactored soon --- .env | 22 +++++-- noxfile.py | 21 +++++++ scripts/release-demo.py | 57 +++++++++++++++---- scripts/util.py | 35 ++++++++++-- .../scripts/publish-release.py | 0 .../scripts/setup-release.py | 6 +- 6 files changed, 117 insertions(+), 24 deletions(-) create mode 100644 {{cookiecutter.project_name}}/scripts/publish-release.py diff --git a/.env b/.env index 10e89b9..9084726 100644 --- a/.env +++ b/.env @@ -2,10 +2,22 @@ # Users can override these by creating a .env.local file (not committed to git) # App author name used for cache directory paths -COOKIECUTTER_ROBUST_PYTHON_APP_AUTHOR=robust-python +COOKIECUTTER_ROBUST_PYTHON__DEMOS_CACHE_FOLDER="" -# Main branch name (typically used for stable releases) -COOKIECUTTER_ROBUST_PYTHON_MAIN_BRANCH=main +COOKIECUTTER_ROBUST_PYTHON__APP_NAME="cookiecutter-robust-python" +COOKIECUTTER_ROBUST_PYTHON__APP_AUTHOR="robust-python" +COOKIECUTTER_ROBUST_PYTHON__REMOTE="origin" +COOKIECUTTER_ROBUST_PYTHON__MAIN_BRANCH="main" +COOKIECUTTER_ROBUST_PYTHON__DEVELOP_BRANCH="develop" -# Development branch name (where feature development occurs) -COOKIECUTTER_ROBUST_PYTHON_DEVELOP_BRANCH=develop +ROBUST_PYTHON_DEMO__APP_NAME="robust-python-demo" +ROBUST_PYTHON_DEMO__APP_AUTHOR="robust-python" +ROBUST_PYTHON_DEMO__REMOTE="origin" +ROBUST_PYTHON_DEMO__MAIN_BRANCH="main" +ROBUST_PYTHON_DEMO__DEVELOP_BRANCH="develop" + +ROBUST_MATURIN_DEMO__APP_NAME="robust-maturin-demo" +ROBUST_MATURIN_DEMO__APP_AUTHOR="robust-python" +ROBUST_MATURIN_DEMO__REMOTE="origin" +ROBUST_MATURIN_DEMO__MAIN_BRANCH="main" +ROBUST_MATURIN_DEMO__DEVELOP_BRANCH="develop" diff --git a/noxfile.py b/noxfile.py index 8a2936d..30f6e1d 100644 --- a/noxfile.py +++ b/noxfile.py @@ -6,6 +6,7 @@ import os import shutil +from dataclasses import dataclass from pathlib import Path import nox @@ -66,6 +67,25 @@ ) +@dataclass +class RepoMetadata: + """Metadata for a given repo.""" + app_name: str + app_author: str + remote: str + main_branch: str + develop_branch: str + + +TEMPLATE: RepoMetadata = RepoMetadata( + app_name=os.getenv("COOKIECUTTER_ROBUST_PYTHON__APP_NAME"), + app_author=os.getenv("COOKIECUTTER_ROBUST_PYTHON__APP_AUTHOR"), + remote=os.getenv("COOKIECUTTER_ROBUST_PYTHON__REMOTE"), + main_branch=os.getenv("COOKIECUTTER_ROBUST_PYTHON__MAIN_BRANCH"), + develop_branch=os.getenv("COOKIECUTTER_ROBUST_PYTHON__DEVELOP_BRANCH") +) + + @nox.session(python=DEFAULT_TEMPLATE_PYTHON_VERSION, name="generate-demo") def generate_demo(session: Session) -> None: """Generates a project demo using the cookiecutter-robust-python template.""" @@ -103,6 +123,7 @@ def lint_from_demo(session: Session): session.log("Installing linting dependencies for the generated project...") session.install("-e", ".", "--group", "dev", "--group", "lint") session.run("python", LINT_FROM_DEMO_SCRIPT, *LINT_FROM_DEMO_OPTIONS, *session.posargs) + session.install_and_run_script() @nox.session(python=DEFAULT_TEMPLATE_PYTHON_VERSION) diff --git a/scripts/release-demo.py b/scripts/release-demo.py index c6a92a2..f37ff30 100644 --- a/scripts/release-demo.py +++ b/scripts/release-demo.py @@ -1,23 +1,30 @@ # /// script # requires-python = ">=3.10" # dependencies = [ -# cookiecutter, -# cruft, -# typer, +# "cookiecutter", +# "cruft", +# "python-dotenv", +# "typer", # ] # /// +import itertools +import subprocess from pathlib import Path from typing import Annotated import typer from cookiecutter.utils import work_in +from loguru import logger +from util import get_current_branch from util import get_demo_name +from util import gh from util import git -from util import is_ancestor -from util import uv -from util import validate_is_synced_ancestor +from util import nox +from util import require_clean_and_up_to_date_repo from util import FolderOption +from util import RepoMetadata +from util import DEMO cli: typer.Typer = typer.Typer() @@ -26,25 +33,51 @@ @cli.callback(invoke_without_command=True) def release_demo( demos_cache_folder: Annotated[Path, FolderOption("--demos-cache-folder", "-c")], - min_python_version: Annotated[str, typer.Option("--min-python-version")], - max_python_version: Annotated[str, typer.Option("--max-python-version")], add_rust_extension: Annotated[bool, typer.Option("--add-rust-extension", "-r")] = False ) -> None: """Creates a release of the demo's current develop branch if changes exist.""" demo_name: str = get_demo_name(add_rust_extension=add_rust_extension) demo_path: Path = demos_cache_folder / demo_name + with work_in(demo_path): + require_clean_and_up_to_date_repo() + git("checkout", DEMO.develop_branch) + try: + nox("setup-release", "--", "MINOR") + logger.success(f"Successfully created release {demo_name}") + _ensure_github_repo_set(repo=DEMO) + except subprocess.CalledProcessError as error: + logger.warning(f"Failed to setup release: {error}") + _rollback_failed_release() + raise error + git("push", "-u") + _create_demo_pr(version=) -def _validate_demo_develop_up_to_date(demo_path: Path) -> None: - """Ensures the demo's develop branch is up to date.""" - with work_in(demo_path): - validate_is_synced_ancestor(ancestor=) + +def _ensure_github_repo_set(repo: RepoMetadata) -> None: + """Ensures the repo has a github repo set.""" + gh("repo", "set-default", repo.app_author, repo.app_name) +def _rollback_failed_release() -> None: + """Returns the demo repo back to the state it was prior to the release attempt.""" + starting_demo_branch: str = get_current_branch() + if starting_demo_branch != "develop": + git("checkout", DEMO.develop_branch) + git("checkout", ".") + git("branch", "-D", starting_demo_branch) +def _create_demo_pr(version: str) -> None: + """Creates a pull request to merge the demo's feature branch into .""" + pr_kwargs: dict[str, str] = { + "--title": f"Release/{version}" + } + publish_release_commands: list[list[str]] = [ + ["gh", "pr", "create", *itertools.chain(pr_kwargs.items())], + ] diff --git a/scripts/util.py b/scripts/util.py index a8e92e2..ddd0fd2 100644 --- a/scripts/util.py +++ b/scripts/util.py @@ -16,6 +16,7 @@ import subprocess import sys from contextlib import contextmanager +from dataclasses import dataclass from functools import partial from pathlib import Path from typing import Any @@ -23,6 +24,7 @@ from typing import Generator from typing import Literal from typing import Optional +from typing import TypedDict from typing import overload import cruft @@ -60,8 +62,31 @@ def _load_env() -> None: ) -MAIN_BRANCH: str = os.getenv("COOKIECUTTER_ROBUST_PYTHON_MAIN_BRANCH", "main") -DEVELOP_BRANCH: str = os.getenv("COOKIECUTTER_ROBUST_PYTHON_DEVELOP_BRANCH", "develop") +@dataclass +class RepoMetadata: + """Metadata for a given repo.""" + app_name: str + app_author: str + remote: str + main_branch: str + develop_branch: str + + +TEMPLATE: RepoMetadata = RepoMetadata( + app_name=os.getenv("COOKIECUTTER_ROBUST_PYTHON__APP_NAME"), + app_author=os.getenv("COOKIECUTTER_ROBUST_PYTHON__APP_AUTHOR"), + remote=os.getenv("COOKIECUTTER_ROBUST_PYTHON__REMOTE"), + main_branch=os.getenv("COOKIECUTTER_ROBUST_PYTHON__MAIN_BRANCH"), + develop_branch=os.getenv("COOKIECUTTER_ROBUST_PYTHON__DEVELOP_BRANCH") +) + +DEMO: RepoMetadata = RepoMetadata( + app_name=os.getenv("ROBUST_DEMO__APP_NAME"), + app_author=os.getenv("ROBUST_DEMO__APP_AUTHOR"), + remote=os.getenv("ROBUST_DEMO__REMOTE"), + main_branch=os.getenv("ROBUST_DEMO__MAIN_BRANCH"), + develop_branch=os.getenv("ROBUST_DEMO__DEVELOP_BRANCH") +) def remove_readonly(func: Callable[[str], Any], path: str, _: Any) -> None: @@ -93,18 +118,20 @@ def run_command(command: str, *args: str, ignore_error: bool = False) -> Optiona return None print(error.stdout, end="") print(error.stderr, end="", file=sys.stderr) - raise + raise error git: partial[subprocess.CompletedProcess] = partial(run_command, "git") uv: partial[subprocess.CompletedProcess] = partial(run_command, "uv") +nox: partial[subprocess.CompletedProcess] = partial(run_command, "nox") +gh: partial[subprocess.CompletedProcess] = partial(run_command, "gh") def require_clean_and_up_to_date_repo() -> None: """Checks if the repo is clean and up to date with any important branches.""" git("fetch") git("status", "--porcelain") - validate_is_synced_ancestor(ancestor=MAIN_BRANCH, descendent=DEVELOP_BRANCH) + validate_is_synced_ancestor(ancestor=DEMO.main_branch, descendent=DEMO.develop_branch) def validate_is_synced_ancestor(ancestor: str, descendent: str) -> None: diff --git a/{{cookiecutter.project_name}}/scripts/publish-release.py b/{{cookiecutter.project_name}}/scripts/publish-release.py new file mode 100644 index 0000000..e69de29 diff --git a/{{cookiecutter.project_name}}/scripts/setup-release.py b/{{cookiecutter.project_name}}/scripts/setup-release.py index 7572946..258728b 100644 --- a/{{cookiecutter.project_name}}/scripts/setup-release.py +++ b/{{cookiecutter.project_name}}/scripts/setup-release.py @@ -4,13 +4,13 @@ import subprocess from typing import Optional -from scripts.util import require_clean_and_up_to_date_repo from util import REPO_FOLDER from util import bump_version from util import check_dependencies from util import create_release_branch from util import get_bumped_package_version from util import get_package_version +from util import require_clean_and_up_to_date_repo def main() -> None: @@ -21,9 +21,9 @@ def main() -> None: def get_parser() -> argparse.ArgumentParser: - """Creates the argument parser for prepare-release.""" + """Creates the argument parser for setup-release.""" parser: argparse.ArgumentParser = argparse.ArgumentParser( - prog="prepare-release", usage="python ./scripts/prepare-release.py patch" + prog="setup-release", usage="python ./scripts/setup-release.py patch" ) parser.add_argument( "increment", From b8c4885b95f39af8af5f21a6064d5d970f4ed92d Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Mon, 24 Nov 2025 14:24:10 -0500 Subject: [PATCH 22/41] fix: remove faulty syntax --- scripts/release-demo.py | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/release-demo.py b/scripts/release-demo.py index f37ff30..2d12a03 100644 --- a/scripts/release-demo.py +++ b/scripts/release-demo.py @@ -53,7 +53,6 @@ def release_demo( raise error git("push", "-u") - _create_demo_pr(version=) def _ensure_github_repo_set(repo: RepoMetadata) -> None: From 0aece5977f7471d74be6c5875bb3fc346ac8ad19 Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Mon, 24 Nov 2025 15:06:35 -0500 Subject: [PATCH 23/41] feat: improve logic for creating demo releases in github --- scripts/release-demo.py | 38 +++++++++++++------ .../scripts/publish-release.py | 0 2 files changed, 26 insertions(+), 12 deletions(-) delete mode 100644 {{cookiecutter.project_name}}/scripts/publish-release.py diff --git a/scripts/release-demo.py b/scripts/release-demo.py index 2d12a03..27a4251 100644 --- a/scripts/release-demo.py +++ b/scripts/release-demo.py @@ -9,6 +9,7 @@ # /// import itertools import subprocess +import tempfile from pathlib import Path from typing import Annotated @@ -23,7 +24,6 @@ from util import nox from util import require_clean_and_up_to_date_repo from util import FolderOption -from util import RepoMetadata from util import DEMO @@ -45,7 +45,7 @@ def release_demo( try: nox("setup-release", "--", "MINOR") logger.success(f"Successfully created release {demo_name}") - _ensure_github_repo_set(repo=DEMO) + gh("repo", "set-default", DEMO.app_author, DEMO.app_name) except subprocess.CalledProcessError as error: logger.warning(f"Failed to setup release: {error}") @@ -53,11 +53,7 @@ def release_demo( raise error git("push", "-u") - - -def _ensure_github_repo_set(repo: RepoMetadata) -> None: - """Ensures the repo has a github repo set.""" - gh("repo", "set-default", repo.app_author, repo.app_name) + _create_demo_pr() def _rollback_failed_release() -> None: @@ -71,12 +67,30 @@ def _rollback_failed_release() -> None: git("branch", "-D", starting_demo_branch) -def _create_demo_pr(version: str) -> None: +def _create_demo_pr() -> None: """Creates a pull request to merge the demo's feature branch into .""" + current_branch: str = get_current_branch() + if not current_branch.startswith("release/"): + raise ValueError("Not in a release branch, canceling PR creation.") + title: str = current_branch.capitalize() + release_notes: str = __get_demo_release_notes() + pr_kwargs: dict[str, str] = { - "--title": f"Release/{version}" + "--title": title, + "--body": release_notes, + "--assignee": "@me", + "--base": "main", } - publish_release_commands: list[list[str]] = [ - ["gh", "pr", "create", *itertools.chain(pr_kwargs.items())], - ] + command: list[str] = ["gh", "pr", "create", *itertools.chain(pr_kwargs.items())] + subprocess.run(command, check=True) + + +def __get_demo_release_notes() -> str: + """Returns the release notes for the demo.""" + temp_folder: Path = Path(tempfile.mkdtemp()).resolve() + notes_path: Path = temp_folder / "body.md" + command: list[str] = ["uv", "run", "./scripts/get-release-notes.py", notes_path] + subprocess.run(command, check=True) + notes_contents: str = notes_path.read_text() + return notes_contents diff --git a/{{cookiecutter.project_name}}/scripts/publish-release.py b/{{cookiecutter.project_name}}/scripts/publish-release.py deleted file mode 100644 index e69de29..0000000 From c6d8074dd4156c1f0e12be28de9a7a1db8239dec Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Thu, 27 Nov 2025 22:26:47 -0500 Subject: [PATCH 24/41] feat: update demo handling to create new feature branches when targeting template feature branches --- scripts/lint-from-demo.py | 10 +++---- scripts/release-demo.py | 2 +- scripts/update-demo.py | 63 ++++++++++++++++++++++++++++++--------- scripts/util.py | 10 ++++--- 4 files changed, 61 insertions(+), 24 deletions(-) diff --git a/scripts/lint-from-demo.py b/scripts/lint-from-demo.py index 2e8bc71..b77bc53 100644 --- a/scripts/lint-from-demo.py +++ b/scripts/lint-from-demo.py @@ -17,6 +17,7 @@ import typer from retrocookie.core import retrocookie +from util import DEMO from util import git from util import FolderOption from util import in_new_demo @@ -40,23 +41,22 @@ def lint_from_demo( no_cache: Annotated[bool, typer.Option("--no-cache", "-n")] = False ) -> None: """Runs precommit in a generated project and matches the template to the results.""" - develop_branch: str = os.getenv("COOKIECUTTER_ROBUST_PYTHON_DEVELOP_BRANCH", "develop") with in_new_demo( demos_cache_folder=demos_cache_folder, add_rust_extension=add_rust_extension, no_cache=no_cache ) as demo_path: - require_clean_and_up_to_date_repo() - git("checkout", develop_branch) + require_clean_and_up_to_date_repo(demo_path=demo_path) + git("checkout", DEMO.develop_branch) git("branch", "-D", "temp/lint-from-demo", ignore_error=True) - git("checkout", "-b", "temp/lint-from-demo", develop_branch) + git("checkout", "-b", "temp/lint-from-demo", DEMO.develop_branch) pre_commit.main.main(["run", "--all-files", "--show-diff-on-failure"]) for path in IGNORED_FILES: git("checkout", "HEAD", "--", path) git("add", ".") git("commit", "-m", "meta: lint-from-demo", "--no-verify") - retrocookie(instance_path=demo_path, commits=[f"{develop_branch}..temp/lint-from-demo"]) + retrocookie(instance_path=demo_path, commits=[f"{DEMO.develop_branch}..temp/lint-from-demo"]) if __name__ == '__main__': diff --git a/scripts/release-demo.py b/scripts/release-demo.py index 27a4251..675df4c 100644 --- a/scripts/release-demo.py +++ b/scripts/release-demo.py @@ -40,7 +40,7 @@ def release_demo( demo_path: Path = demos_cache_folder / demo_name with work_in(demo_path): - require_clean_and_up_to_date_repo() + require_clean_and_up_to_date_repo(demo_path) git("checkout", DEMO.develop_branch) try: nox("setup-release", "--", "MINOR") diff --git a/scripts/update-demo.py b/scripts/update-demo.py index 3ac296d..10bd51d 100644 --- a/scripts/update-demo.py +++ b/scripts/update-demo.py @@ -10,12 +10,16 @@ import sys from pathlib import Path +from subprocess import CompletedProcess from typing import Annotated +from typing import Optional import cruft import typer from cookiecutter.utils import work_in +from scripts.util import TEMPLATE +from util import DEMO from util import is_ancestor from util import get_current_branch from util import get_current_commit @@ -46,9 +50,10 @@ def update_demo( current_branch: str = get_current_branch() template_commit: str = get_current_commit() - _validate_is_feature_branch(branch=current_branch) - _set_demo_to_clean_develop(demo_path=demo_path) - last_update_commit: str = _get_last_demo_develop_cruft_update(demo_path=demo_path) + _validate_template_main_not_checked_out(branch=current_branch) + require_clean_and_up_to_date_repo(demo_path=demo_path) + _checkout_demo_develop_or_existing_branch(demo_path=demo_path, branch=current_branch) + last_update_commit: str = get_last_cruft_update_commit(demo_path=demo_path) if not is_ancestor(last_update_commit, template_commit): raise ValueError( @@ -83,23 +88,53 @@ def update_demo( sys.exit(1) -def _set_demo_to_clean_develop(demo_path: Path) -> None: - """Checks out the demo development branch and validates it is up to date.""" +def _checkout_demo_develop_or_existing_branch(demo_path: Path, branch: str) -> None: + """Checkout either develop or an existing demo branch.""" with work_in(demo_path): - require_clean_and_up_to_date_repo() + if __has_existing_local_demo_branch(demo_path=demo_path, branch=branch): + typer.secho(f"Local demo found, updating demo from base {branch}") + git("checkout", branch) + return + + if __has_existing_remote_demo_branch(demo_path=demo_path, branch=branch): + remote_branch: str = f"{DEMO.remote}/{branch}" + typer.secho(f"Remote demo found, updating demo from base {remote_branch}") + git("checkout", "-b", branch, remote_branch) + return + git("checkout", "develop") -def _get_last_demo_develop_cruft_update(demo_path: Path) -> str: - """Gets the last cruft update commit for the demo project's develop branch.""" - last_update_commit: str = get_last_cruft_update_commit(demo_path=demo_path) - return last_update_commit +def __has_existing_local_demo_branch(demo_path: Path, branch: str) -> bool: + """Returns whether a local branch has been made for the given branch.""" + with work_in(demo_path): + local_result: Optional[CompletedProcess] = git("branch", "--list", branch, text=True) + return local_result is not None and branch in local_result.stdout + + +def __has_existing_remote_demo_branch(demo_path: Path, branch: str) -> bool: + """Returns whether a remote branch has been made for the given branch.""" + with work_in(demo_path): + remote_result: Optional[CompletedProcess] = git("ls-remote", DEMO.remote, branch, text=True) + return remote_result is not None and branch in remote_result.stdout + + +def _set_demo_to_clean_branch(demo_path: Path, branch: str) -> None: + """Checks out the demo branch and validates it is up to date.""" + with work_in(demo_path): + git("checkout", "develop") + +def _validate_template_main_not_checked_out(branch: str) -> None: + """Validates that the cookiecutter isn't currently on main. -def _validate_is_feature_branch(branch: str) -> None: - """Validates that the cookiecutter has a feature branch checked out.""" - if not branch.startswith("feature/"): - raise ValueError(f"Received branch '{branch}' is not a feature branch.") + We allow direct develop commits (although avoid it usually), but never direct main. This may change later if the + template moves to a trunk based structure, but for now options are being kept open due to the possibility of a + package release handling demo creation one day. + """ + main_like_names: list[str] = ["main", "master"] + if branch == TEMPLATE.main_branch or branch in main_like_names: + raise ValueError(f"Updating demos directly to main is not allowed currently.") if __name__ == '__main__': diff --git a/scripts/util.py b/scripts/util.py index ddd0fd2..47b5043 100644 --- a/scripts/util.py +++ b/scripts/util.py @@ -127,11 +127,13 @@ def run_command(command: str, *args: str, ignore_error: bool = False) -> Optiona gh: partial[subprocess.CompletedProcess] = partial(run_command, "gh") -def require_clean_and_up_to_date_repo() -> None: +def require_clean_and_up_to_date_repo(demo_path: Path) -> None: """Checks if the repo is clean and up to date with any important branches.""" - git("fetch") - git("status", "--porcelain") - validate_is_synced_ancestor(ancestor=DEMO.main_branch, descendent=DEMO.develop_branch) + with work_in(demo_path): + git("fetch") + git("status", "--porcelain") + validate_is_synced_ancestor(ancestor=DEMO.main_branch, descendent=DEMO.develop_branch) + typer.secho def validate_is_synced_ancestor(ancestor: str, descendent: str) -> None: From a42e68b7be729cd1aec98619f57d340e2c47b2ed Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Thu, 27 Nov 2025 23:40:56 -0500 Subject: [PATCH 25/41] fix: replace broken import in scripts --- scripts/update-demo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/update-demo.py b/scripts/update-demo.py index 10bd51d..96b49ca 100644 --- a/scripts/update-demo.py +++ b/scripts/update-demo.py @@ -18,7 +18,6 @@ import typer from cookiecutter.utils import work_in -from scripts.util import TEMPLATE from util import DEMO from util import is_ancestor from util import get_current_branch @@ -29,6 +28,7 @@ from util import FolderOption from util import REPO_FOLDER from util import require_clean_and_up_to_date_repo +from util import TEMPLATE from util import uv From 28dab179b5508fef55e4c779d029cdf4ae28d09d Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Thu, 27 Nov 2025 23:46:09 -0500 Subject: [PATCH 26/41] feat: add initial attempt at a template level github action to sync demos on PR updates that target develop --- .github/workflows/sync-demos.yml | 49 ++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 .github/workflows/sync-demos.yml diff --git a/.github/workflows/sync-demos.yml b/.github/workflows/sync-demos.yml new file mode 100644 index 0000000..fa8ec47 --- /dev/null +++ b/.github/workflows/sync-demos.yml @@ -0,0 +1,49 @@ +name: sync-demo.yml +on: + pull_request: + branches: + - develop + +env: + COOKIECUTTER_ROBUST_PYTHON__DEMOS_CACHE_FOLDER: ${{ github.workspace }} + COOKIECUTTER_ROBUST_PYTHON__APP_AUTHOR: ${{ github.repository_owner }} + ROBUST_PYTHON_DEMO__APP_AUTHOR: ${{ github.repository_owner }} + ROBUST_MATURIN_DEMO__APP_AUTHOR: ${{ github.repository_owner }} + +jobs: + update-demo: + name: Update Demo + runs-on: ubuntu-latest + + strategy: + matrix: + demo: + - { name: "robust-python-demo", session_modifier: "no-rust" } + - { name: "robust-maturin-demo", session_modifier: "rust" } + steps: + - name: Checkout Template + uses: actions/checkout@v4 + with: + repository: ${{ github.repository }} + path: cookiecutter-robust-python + ref: ${{ github.head_ref }} + + - name: Checkout Demo + uses: actions/checkout@v4 + with: + repository: "${{ github.repository_owner }}/${{ matrix.demo.name }}" + path: ${{ matrix.demo.name }} + ref: develop + + - name: Set up uv + uses: astral-sh/setup-uv@v6 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Update Demo + run: "uvx nox -s 'update-demo(${{ matrix.demo.session_modifier }})'" + + From a424626f5f9cad4d5c7ad8169c6d275baa644a74 Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Thu, 27 Nov 2025 23:55:01 -0500 Subject: [PATCH 27/41] fix: remove unintended broken snippet --- scripts/util.py | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/util.py b/scripts/util.py index 47b5043..1cf82d8 100644 --- a/scripts/util.py +++ b/scripts/util.py @@ -133,7 +133,6 @@ def require_clean_and_up_to_date_repo(demo_path: Path) -> None: git("fetch") git("status", "--porcelain") validate_is_synced_ancestor(ancestor=DEMO.main_branch, descendent=DEMO.develop_branch) - typer.secho def validate_is_synced_ancestor(ancestor: str, descendent: str) -> None: From 0aebe5cda362ea775e6f79e7e6721e28b94755a9 Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Fri, 28 Nov 2025 00:13:53 -0500 Subject: [PATCH 28/41] fix: add interop layer between older noxfile methods and newer env var logic for the time being --- noxfile.py | 34 +++++++++++++++--- scripts/update-demo.py | 81 ++++++++++++++++++++---------------------- scripts/util.py | 12 ++++--- 3 files changed, 76 insertions(+), 51 deletions(-) diff --git a/noxfile.py b/noxfile.py index 30f6e1d..f532bac 100644 --- a/noxfile.py +++ b/noxfile.py @@ -6,8 +6,10 @@ import os import shutil +from dataclasses import asdict from dataclasses import dataclass from pathlib import Path +from typing import Any import nox import platformdirs @@ -85,6 +87,22 @@ class RepoMetadata: develop_branch=os.getenv("COOKIECUTTER_ROBUST_PYTHON__DEVELOP_BRANCH") ) +PYTHON_DEMO: RepoMetadata = RepoMetadata( + app_name=os.getenv("ROBUST_PYTHON_DEMO__APP_NAME"), + app_author=os.getenv("ROBUST_PYTHON_DEMO__APP_AUTHOR"), + remote=os.getenv("ROBUST_PYTHON_DEMO__REMOTE"), + main_branch=os.getenv("ROBUST_PYTHON_DEMO__MAIN_BRANCH"), + develop_branch=os.getenv("ROBUST_PYTHON_DEMO__DEVELOP_BRANCH") +) + +MATURIN_DEMO: RepoMetadata = RepoMetadata( + app_name=os.getenv("ROBUST_MATURIN_DEMO__APP_NAME"), + app_author=os.getenv("ROBUST_MATURIN_DEMO__APP_AUTHOR"), + remote=os.getenv("ROBUST_MATURIN_DEMO__REMOTE"), + main_branch=os.getenv("ROBUST_MATURIN_DEMO__MAIN_BRANCH"), + develop_branch=os.getenv("ROBUST_MATURIN_DEMO__DEVELOP_BRANCH") +) + @nox.session(python=DEFAULT_TEMPLATE_PYTHON_VERSION, name="generate-demo") def generate_demo(session: Session) -> None: @@ -164,17 +182,25 @@ def test(session: Session) -> None: session.run("pytest", "tests") -@nox.parametrize(arg_names="add_rust_extension", arg_values_list=[False, True], ids=["no-rust", "rust"]) +@nox.parametrize( + arg_names="demo", + arg_values_list=[PYTHON_DEMO, MATURIN_DEMO], + ids=["robust-python-demo", "robust-maturin-demo"] +) @nox.session(python=DEFAULT_TEMPLATE_PYTHON_VERSION, name="update-demo") -def update_demo(session: Session, add_rust_extension: bool) -> None: +def update_demo(session: Session, demo: RepoMetadata) -> None: session.log("Installing script dependencies for updating generated project demos...") session.install("cookiecutter", "cruft", "platformdirs", "loguru", "python-dotenv", "typer") session.log("Updating generated project demos...") args: list[str] = [*UPDATE_DEMO_OPTIONS] - if add_rust_extension: + if "maturin" in demo.app_name: args.append("--add-rust-extension") - session.run("python", UPDATE_DEMO_SCRIPT, *args) + + demo_env: dict[str, Any] = { + key.replace(demo.app_name.upper(), "ROBUST_DEMO"): value for key, value in asdict(demo).items() + } + session.run("python", UPDATE_DEMO_SCRIPT, *args, env=demo_env) @nox.session(python=False, name="release-template") diff --git a/scripts/update-demo.py b/scripts/update-demo.py index 96b49ca..c6a358a 100644 --- a/scripts/update-demo.py +++ b/scripts/update-demo.py @@ -43,49 +43,44 @@ def update_demo( max_python_version: Annotated[str, typer.Option("--max-python-version")] = "3.14" ) -> None: """Runs precommit in a generated project and matches the template to the results.""" - try: - demo_name: str = get_demo_name(add_rust_extension=add_rust_extension) - demo_path: Path = demos_cache_folder / demo_name - - current_branch: str = get_current_branch() - template_commit: str = get_current_commit() - - _validate_template_main_not_checked_out(branch=current_branch) - require_clean_and_up_to_date_repo(demo_path=demo_path) - _checkout_demo_develop_or_existing_branch(demo_path=demo_path, branch=current_branch) - last_update_commit: str = get_last_cruft_update_commit(demo_path=demo_path) - - if not is_ancestor(last_update_commit, template_commit): - raise ValueError( - f"The last update commit '{last_update_commit}' is not an ancestor of the current commit " - f"'{template_commit}'." - ) - - typer.secho(f"Updating demo project at {demo_path=}.", fg="yellow") - with work_in(demo_path): - if current_branch != "develop": - git("checkout", "-b", current_branch) - - uv("python", "pin", min_python_version) - uv("python", "install", min_python_version) - cruft.update( - project_dir=demo_path, - template_path=REPO_FOLDER, - extra_context={ - "project_name": demo_name, - "add_rust_extension": add_rust_extension, - "min_python_version": min_python_version, - "max_python_version": max_python_version - }, - ) - uv("lock") - git("add", ".") - git("commit", "-m", f"chore: {last_update_commit} -> {template_commit}", "--no-verify") - git("push", "-u", "origin", current_branch) - - except Exception as error: - typer.secho(f"error: {error}", fg="red") - sys.exit(1) + demo_name: str = get_demo_name(add_rust_extension=add_rust_extension) + demo_path: Path = demos_cache_folder / demo_name + + current_branch: str = get_current_branch() + template_commit: str = get_current_commit() + + _validate_template_main_not_checked_out(branch=current_branch) + require_clean_and_up_to_date_repo(demo_path=demo_path) + _checkout_demo_develop_or_existing_branch(demo_path=demo_path, branch=current_branch) + last_update_commit: str = get_last_cruft_update_commit(demo_path=demo_path) + + if not is_ancestor(last_update_commit, template_commit): + raise ValueError( + f"The last update commit '{last_update_commit}' is not an ancestor of the current commit " + f"'{template_commit}'." + ) + + typer.secho(f"Updating demo project at {demo_path=}.", fg="yellow") + with work_in(demo_path): + if current_branch != "develop": + git("checkout", "-b", current_branch) + + uv("python", "pin", min_python_version) + uv("python", "install", min_python_version) + cruft.update( + project_dir=demo_path, + template_path=REPO_FOLDER, + extra_context={ + "project_name": demo_name, + "add_rust_extension": add_rust_extension, + "min_python_version": min_python_version, + "max_python_version": max_python_version + }, + ) + uv("lock") + git("add", ".") + git("commit", "-m", f"chore: {last_update_commit} -> {template_commit}", "--no-verify") + git("push", "-u", "origin", current_branch) def _checkout_demo_develop_or_existing_branch(demo_path: Path, branch: str) -> None: diff --git a/scripts/util.py b/scripts/util.py index 1cf82d8..1e011fa 100644 --- a/scripts/util.py +++ b/scripts/util.py @@ -129,10 +129,14 @@ def run_command(command: str, *args: str, ignore_error: bool = False) -> Optiona def require_clean_and_up_to_date_repo(demo_path: Path) -> None: """Checks if the repo is clean and up to date with any important branches.""" - with work_in(demo_path): - git("fetch") - git("status", "--porcelain") - validate_is_synced_ancestor(ancestor=DEMO.main_branch, descendent=DEMO.develop_branch) + try: + with work_in(demo_path): + git("fetch") + git("status", "--porcelain") + validate_is_synced_ancestor(ancestor=DEMO.main_branch, descendent=DEMO.develop_branch) + except Exception as e: + typer.secho(f"Failed initial repo state check.") + raise e def validate_is_synced_ancestor(ancestor: str, descendent: str) -> None: From b20c803ff226d80e83df0560bc3ad48ffe836b7a Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Fri, 28 Nov 2025 00:19:02 -0500 Subject: [PATCH 29/41] chore: remove accidentally left unused snippet --- noxfile.py | 1 - scripts/util.py | 1 - 2 files changed, 2 deletions(-) diff --git a/noxfile.py b/noxfile.py index f532bac..8ed38e3 100644 --- a/noxfile.py +++ b/noxfile.py @@ -141,7 +141,6 @@ def lint_from_demo(session: Session): session.log("Installing linting dependencies for the generated project...") session.install("-e", ".", "--group", "dev", "--group", "lint") session.run("python", LINT_FROM_DEMO_SCRIPT, *LINT_FROM_DEMO_OPTIONS, *session.posargs) - session.install_and_run_script() @nox.session(python=DEFAULT_TEMPLATE_PYTHON_VERSION) diff --git a/scripts/util.py b/scripts/util.py index 1e011fa..db365df 100644 --- a/scripts/util.py +++ b/scripts/util.py @@ -24,7 +24,6 @@ from typing import Generator from typing import Literal from typing import Optional -from typing import TypedDict from typing import overload import cruft From dad65b3ba7695e53b688d007ba384bfd4b89b521 Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Fri, 28 Nov 2025 00:26:00 -0500 Subject: [PATCH 30/41] fix: remove invalid pass through attempts of text=true throughout scripts --- noxfile.py | 4 +--- scripts/update-demo.py | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/noxfile.py b/noxfile.py index 8ed38e3..1a7ad17 100644 --- a/noxfile.py +++ b/noxfile.py @@ -196,9 +196,7 @@ def update_demo(session: Session, demo: RepoMetadata) -> None: if "maturin" in demo.app_name: args.append("--add-rust-extension") - demo_env: dict[str, Any] = { - key.replace(demo.app_name.upper(), "ROBUST_DEMO"): value for key, value in asdict(demo).items() - } + demo_env: dict[str, Any] = {f"ROBUST_DEMO__{key.upper()}": value for key, value in asdict(demo).items()} session.run("python", UPDATE_DEMO_SCRIPT, *args, env=demo_env) diff --git a/scripts/update-demo.py b/scripts/update-demo.py index c6a358a..31bfe7b 100644 --- a/scripts/update-demo.py +++ b/scripts/update-demo.py @@ -103,14 +103,14 @@ def _checkout_demo_develop_or_existing_branch(demo_path: Path, branch: str) -> N def __has_existing_local_demo_branch(demo_path: Path, branch: str) -> bool: """Returns whether a local branch has been made for the given branch.""" with work_in(demo_path): - local_result: Optional[CompletedProcess] = git("branch", "--list", branch, text=True) + local_result: Optional[CompletedProcess] = git("branch", "--list", branch) return local_result is not None and branch in local_result.stdout def __has_existing_remote_demo_branch(demo_path: Path, branch: str) -> bool: """Returns whether a remote branch has been made for the given branch.""" with work_in(demo_path): - remote_result: Optional[CompletedProcess] = git("ls-remote", DEMO.remote, branch, text=True) + remote_result: Optional[CompletedProcess] = git("ls-remote", DEMO.remote, branch) return remote_result is not None and branch in remote_result.stdout From 1b56f02aa0d99b7e782e868f275f8380b8b5ca28 Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Fri, 28 Nov 2025 00:27:24 -0500 Subject: [PATCH 31/41] fix: remove old code from update-demo that is no longer compatible --- scripts/update-demo.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/scripts/update-demo.py b/scripts/update-demo.py index 31bfe7b..a8f27a1 100644 --- a/scripts/update-demo.py +++ b/scripts/update-demo.py @@ -62,9 +62,6 @@ def update_demo( typer.secho(f"Updating demo project at {demo_path=}.", fg="yellow") with work_in(demo_path): - if current_branch != "develop": - git("checkout", "-b", current_branch) - uv("python", "pin", min_python_version) uv("python", "install", min_python_version) cruft.update( @@ -114,12 +111,6 @@ def __has_existing_remote_demo_branch(demo_path: Path, branch: str) -> bool: return remote_result is not None and branch in remote_result.stdout -def _set_demo_to_clean_branch(demo_path: Path, branch: str) -> None: - """Checks out the demo branch and validates it is up to date.""" - with work_in(demo_path): - git("checkout", "develop") - - def _validate_template_main_not_checked_out(branch: str) -> None: """Validates that the cookiecutter isn't currently on main. From c3f1a3c83ffee0fcad32aa6a33d90527e96c5750 Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Fri, 28 Nov 2025 01:26:05 -0500 Subject: [PATCH 32/41] refactor: rename script function to reflect usage only occurring in demo --- scripts/lint-from-demo.py | 4 ++-- scripts/release-demo.py | 4 ++-- scripts/update-demo.py | 7 +++---- scripts/util.py | 12 +++++++++++- 4 files changed, 18 insertions(+), 9 deletions(-) diff --git a/scripts/lint-from-demo.py b/scripts/lint-from-demo.py index b77bc53..c9458a9 100644 --- a/scripts/lint-from-demo.py +++ b/scripts/lint-from-demo.py @@ -21,7 +21,7 @@ from util import git from util import FolderOption from util import in_new_demo -from util import require_clean_and_up_to_date_repo +from util import require_clean_and_up_to_date_demo_repo # These still may need linted, but retrocookie shouldn't be used on them @@ -46,7 +46,7 @@ def lint_from_demo( add_rust_extension=add_rust_extension, no_cache=no_cache ) as demo_path: - require_clean_and_up_to_date_repo(demo_path=demo_path) + require_clean_and_up_to_date_demo_repo(demo_path=demo_path) git("checkout", DEMO.develop_branch) git("branch", "-D", "temp/lint-from-demo", ignore_error=True) git("checkout", "-b", "temp/lint-from-demo", DEMO.develop_branch) diff --git a/scripts/release-demo.py b/scripts/release-demo.py index 675df4c..ca07e33 100644 --- a/scripts/release-demo.py +++ b/scripts/release-demo.py @@ -22,7 +22,7 @@ from util import gh from util import git from util import nox -from util import require_clean_and_up_to_date_repo +from util import require_clean_and_up_to_date_demo_repo from util import FolderOption from util import DEMO @@ -40,7 +40,7 @@ def release_demo( demo_path: Path = demos_cache_folder / demo_name with work_in(demo_path): - require_clean_and_up_to_date_repo(demo_path) + require_clean_and_up_to_date_demo_repo(demo_path) git("checkout", DEMO.develop_branch) try: nox("setup-release", "--", "MINOR") diff --git a/scripts/update-demo.py b/scripts/update-demo.py index a8f27a1..02e244c 100644 --- a/scripts/update-demo.py +++ b/scripts/update-demo.py @@ -8,7 +8,6 @@ # ] # /// -import sys from pathlib import Path from subprocess import CompletedProcess from typing import Annotated @@ -20,6 +19,7 @@ from util import DEMO from util import is_ancestor +from util import is_merge_commit from util import get_current_branch from util import get_current_commit from util import get_demo_name @@ -27,7 +27,7 @@ from util import git from util import FolderOption from util import REPO_FOLDER -from util import require_clean_and_up_to_date_repo +from util import require_clean_and_up_to_date_demo_repo from util import TEMPLATE from util import uv @@ -48,9 +48,8 @@ def update_demo( current_branch: str = get_current_branch() template_commit: str = get_current_commit() - _validate_template_main_not_checked_out(branch=current_branch) - require_clean_and_up_to_date_repo(demo_path=demo_path) + require_clean_and_up_to_date_demo_repo(demo_path=demo_path) _checkout_demo_develop_or_existing_branch(demo_path=demo_path, branch=current_branch) last_update_commit: str = get_last_cruft_update_commit(demo_path=demo_path) diff --git a/scripts/util.py b/scripts/util.py index db365df..181a872 100644 --- a/scripts/util.py +++ b/scripts/util.py @@ -126,7 +126,7 @@ def run_command(command: str, *args: str, ignore_error: bool = False) -> Optiona gh: partial[subprocess.CompletedProcess] = partial(run_command, "gh") -def require_clean_and_up_to_date_repo(demo_path: Path) -> None: +def require_clean_and_up_to_date_demo_repo(demo_path: Path) -> None: """Checks if the repo is clean and up to date with any important branches.""" try: with work_in(demo_path): @@ -162,6 +162,16 @@ def is_ancestor(ancestor: str, descendent: str) -> bool: return False +def is_merge_commit() -> bool: + """Returns whether the latest commit was a merge commit.""" + output: str = git("log", "--format=%P", "-n", "1", "HEAD").stdout.strip() + if output == "": + raise ValueError("No existing commit was found.") + + parent_count: int = len(output.split(" ")) + return parent_count > 1 + + def get_current_branch() -> str: """Returns the current branch name.""" return git("branch", "--show-current").stdout.strip() From 964ec8e59d4d2c03034abf5fe250165ece0a06fc Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Fri, 28 Nov 2025 01:30:54 -0500 Subject: [PATCH 33/41] feat: remove unused merge commit check --- scripts/update-demo.py | 1 - scripts/util.py | 10 ---------- 2 files changed, 11 deletions(-) diff --git a/scripts/update-demo.py b/scripts/update-demo.py index 02e244c..c71d976 100644 --- a/scripts/update-demo.py +++ b/scripts/update-demo.py @@ -19,7 +19,6 @@ from util import DEMO from util import is_ancestor -from util import is_merge_commit from util import get_current_branch from util import get_current_commit from util import get_demo_name diff --git a/scripts/util.py b/scripts/util.py index 181a872..2844afb 100644 --- a/scripts/util.py +++ b/scripts/util.py @@ -162,16 +162,6 @@ def is_ancestor(ancestor: str, descendent: str) -> bool: return False -def is_merge_commit() -> bool: - """Returns whether the latest commit was a merge commit.""" - output: str = git("log", "--format=%P", "-n", "1", "HEAD").stdout.strip() - if output == "": - raise ValueError("No existing commit was found.") - - parent_count: int = len(output.split(" ")) - return parent_count > 1 - - def get_current_branch() -> str: """Returns the current branch name.""" return git("branch", "--show-current").stdout.strip() From 0e8f4e7ac9aaf3a6d947b0c0538cd37728841993 Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Fri, 28 Nov 2025 02:00:57 -0500 Subject: [PATCH 34/41] feat: alter sync demo process to use ephemeral github commits for generation so that everything is more accurate --- .github/workflows/sync-demos.yml | 3 +-- noxfile.py | 3 +++ scripts/update-demo.py | 10 ++++++++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/sync-demos.yml b/.github/workflows/sync-demos.yml index fa8ec47..36ce3aa 100644 --- a/.github/workflows/sync-demos.yml +++ b/.github/workflows/sync-demos.yml @@ -26,7 +26,6 @@ jobs: with: repository: ${{ github.repository }} path: cookiecutter-robust-python - ref: ${{ github.head_ref }} - name: Checkout Demo uses: actions/checkout@v4 @@ -44,6 +43,6 @@ jobs: python-version: ${{ matrix.python-version }} - name: Update Demo - run: "uvx nox -s 'update-demo(${{ matrix.demo.session_modifier }})'" + run: "uvx nox -s 'update-demo(${{ matrix.demo.session_modifier }})' -- --branch-override ${{ github.head_ref }}" diff --git a/noxfile.py b/noxfile.py index 1a7ad17..33d7d3d 100644 --- a/noxfile.py +++ b/noxfile.py @@ -196,6 +196,9 @@ def update_demo(session: Session, demo: RepoMetadata) -> None: if "maturin" in demo.app_name: args.append("--add-rust-extension") + if session.posargs: + args.extend(session.posargs) + demo_env: dict[str, Any] = {f"ROBUST_DEMO__{key.upper()}": value for key, value in asdict(demo).items()} session.run("python", UPDATE_DEMO_SCRIPT, *args, env=demo_env) diff --git a/scripts/update-demo.py b/scripts/update-demo.py index c71d976..83eb020 100644 --- a/scripts/update-demo.py +++ b/scripts/update-demo.py @@ -39,14 +39,20 @@ def update_demo( demos_cache_folder: Annotated[Path, FolderOption("--demos-cache-folder", "-c")], add_rust_extension: Annotated[bool, typer.Option("--add-rust-extension", "-r")] = False, min_python_version: Annotated[str, typer.Option("--min-python-version")] = "3.10", - max_python_version: Annotated[str, typer.Option("--max-python-version")] = "3.14" + max_python_version: Annotated[str, typer.Option("--max-python-version")] = "3.14", + branch_override: Annotated[Optional[str], typer.Option("--branch-override")] = None ) -> None: """Runs precommit in a generated project and matches the template to the results.""" demo_name: str = get_demo_name(add_rust_extension=add_rust_extension) demo_path: Path = demos_cache_folder / demo_name - current_branch: str = get_current_branch() + if branch_override is not None: + typer.secho(f"Overriding current branch name for demo reference. Using '{branch_override}' instead.") + current_branch: str = branch_override + else: + current_branch: str = get_current_branch() template_commit: str = get_current_commit() + _validate_template_main_not_checked_out(branch=current_branch) require_clean_and_up_to_date_demo_repo(demo_path=demo_path) _checkout_demo_develop_or_existing_branch(demo_path=demo_path, branch=current_branch) From b2f748bc6e94c7989ab166c3838bcc410a4c7954 Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Fri, 28 Nov 2025 02:49:25 -0500 Subject: [PATCH 35/41] feat: add on automated demo PR creation from feature branches into develop --- .github/workflows/sync-demos.yml | 2 -- scripts/update-demo.py | 45 +++++++++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/.github/workflows/sync-demos.yml b/.github/workflows/sync-demos.yml index 36ce3aa..84b6533 100644 --- a/.github/workflows/sync-demos.yml +++ b/.github/workflows/sync-demos.yml @@ -44,5 +44,3 @@ jobs: - name: Update Demo run: "uvx nox -s 'update-demo(${{ matrix.demo.session_modifier }})' -- --branch-override ${{ github.head_ref }}" - - diff --git a/scripts/update-demo.py b/scripts/update-demo.py index 83eb020..341e6a4 100644 --- a/scripts/update-demo.py +++ b/scripts/update-demo.py @@ -7,22 +7,26 @@ # "typer", # ] # /// - +import itertools +import subprocess from pathlib import Path from subprocess import CompletedProcess from typing import Annotated +from typing import Any from typing import Optional import cruft import typer from cookiecutter.utils import work_in +from util import _read_cruft_file from util import DEMO from util import is_ancestor from util import get_current_branch from util import get_current_commit from util import get_demo_name from util import get_last_cruft_update_commit +from util import gh from util import git from util import FolderOption from util import REPO_FOLDER @@ -82,6 +86,8 @@ def update_demo( git("add", ".") git("commit", "-m", f"chore: {last_update_commit} -> {template_commit}", "--no-verify") git("push", "-u", "origin", current_branch) + if current_branch != "develop": + _create_demo_pr(demo_path=demo_path, branch=current_branch, commit_start=last_update_commit) def _checkout_demo_develop_or_existing_branch(demo_path: Path, branch: str) -> None: @@ -127,5 +133,42 @@ def _validate_template_main_not_checked_out(branch: str) -> None: raise ValueError(f"Updating demos directly to main is not allowed currently.") +def _create_demo_pr(demo_path: Path, branch: str, commit_start: str) -> None: + """Creates a PR to merge the given branch into develop.""" + gh("repo", "set-default", f"{DEMO.app_author}/{DEMO.app_name}") + + body: str = _get_demo_feature_pr_body(demo_path=demo_path, commit_start=commit_start) + + pr_kwargs: dict[str, Any] = { + "--title": branch.capitalize(), + "--body": body, + "--base": DEMO.develop_branch, + "--assignee": "@me", + "--repo": f"{DEMO.app_author}/{DEMO.app_name}", + } + gh("pr", "create", *itertools.chain(pr_kwargs.items())) + + +def _get_demo_feature_pr_body(demo_path: Path, commit_start: str) -> str: + """Creates the body of the demo feature pull request.""" + cruft_config: dict[str, Any] = _read_cruft_file(demo_path) + commit_end: Optional[str] = cruft_config.get("commit_end", None) + if commit_end is None: + raise ValueError(f"Unable to find latest commit in .cruft.json for demo at {demo_path}.") + rev_range: str = f"{commit_start}..{commit_end}" + command: list[str] = [ + "uvx", + "--from", + "commitizen", + "cz", + "changelog", + rev_range, + "--dry-run", + "--unreleased-version" + ] + section_notes: str = subprocess.check_output(command, text=True) + return section_notes.strip() + + if __name__ == '__main__': cli() From d1e939f39cec6f55e65899c55ba2c58078baa7c8 Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Fri, 28 Nov 2025 02:51:11 -0500 Subject: [PATCH 36/41] fix: update key used to find commit info in cruft json --- scripts/update-demo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/update-demo.py b/scripts/update-demo.py index 341e6a4..b2a8549 100644 --- a/scripts/update-demo.py +++ b/scripts/update-demo.py @@ -152,7 +152,7 @@ def _create_demo_pr(demo_path: Path, branch: str, commit_start: str) -> None: def _get_demo_feature_pr_body(demo_path: Path, commit_start: str) -> str: """Creates the body of the demo feature pull request.""" cruft_config: dict[str, Any] = _read_cruft_file(demo_path) - commit_end: Optional[str] = cruft_config.get("commit_end", None) + commit_end: Optional[str] = cruft_config.get("commit", None) if commit_end is None: raise ValueError(f"Unable to find latest commit in .cruft.json for demo at {demo_path}.") rev_range: str = f"{commit_start}..{commit_end}" From 8bca3afcee0e56c01acc32869c2f6a1791ecbe52 Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Fri, 28 Nov 2025 02:53:08 -0500 Subject: [PATCH 37/41] feat: temporarily remove the body generation of the feature to develop PR --- scripts/update-demo.py | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/scripts/update-demo.py b/scripts/update-demo.py index b2a8549..e008dab 100644 --- a/scripts/update-demo.py +++ b/scripts/update-demo.py @@ -8,7 +8,6 @@ # ] # /// import itertools -import subprocess from pathlib import Path from subprocess import CompletedProcess from typing import Annotated @@ -156,18 +155,7 @@ def _get_demo_feature_pr_body(demo_path: Path, commit_start: str) -> str: if commit_end is None: raise ValueError(f"Unable to find latest commit in .cruft.json for demo at {demo_path}.") rev_range: str = f"{commit_start}..{commit_end}" - command: list[str] = [ - "uvx", - "--from", - "commitizen", - "cz", - "changelog", - rev_range, - "--dry-run", - "--unreleased-version" - ] - section_notes: str = subprocess.check_output(command, text=True) - return section_notes.strip() + return rev_range if __name__ == '__main__': From 39b6f07c56cf65bbb90f2b4956e2550f0b56695b Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Fri, 28 Nov 2025 02:56:34 -0500 Subject: [PATCH 38/41] fix: alter kwarg chaining in hopes of fixing error --- scripts/update-demo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/update-demo.py b/scripts/update-demo.py index e008dab..a300991 100644 --- a/scripts/update-demo.py +++ b/scripts/update-demo.py @@ -145,7 +145,7 @@ def _create_demo_pr(demo_path: Path, branch: str, commit_start: str) -> None: "--assignee": "@me", "--repo": f"{DEMO.app_author}/{DEMO.app_name}", } - gh("pr", "create", *itertools.chain(pr_kwargs.items())) + gh("pr", "create", *itertools.chain.from_iterable(pr_kwargs.items())) def _get_demo_feature_pr_body(demo_path: Path, commit_start: str) -> str: From eca180fbb8a5d1723bcb9af5026a7ec380e475ba Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Fri, 28 Nov 2025 03:05:37 -0500 Subject: [PATCH 39/41] fix: add working-directory kwarg to sync-demo so that the nox command is run inside the repo --- .github/workflows/sync-demos.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/sync-demos.yml b/.github/workflows/sync-demos.yml index 84b6533..bc5a2fd 100644 --- a/.github/workflows/sync-demos.yml +++ b/.github/workflows/sync-demos.yml @@ -43,4 +43,5 @@ jobs: python-version: ${{ matrix.python-version }} - name: Update Demo + working-directory: "${{ github.workspace }}/cookiecutter-robust-python" run: "uvx nox -s 'update-demo(${{ matrix.demo.session_modifier }})' -- --branch-override ${{ github.head_ref }}" From c2cb3c567c57478c2dbbdd538488b3ffc99952ea Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Fri, 28 Nov 2025 03:07:23 -0500 Subject: [PATCH 40/41] fix: replace matrix values in sync-demo to account for previous simplification in internal nox session --- .github/workflows/sync-demos.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/sync-demos.yml b/.github/workflows/sync-demos.yml index bc5a2fd..4c89862 100644 --- a/.github/workflows/sync-demos.yml +++ b/.github/workflows/sync-demos.yml @@ -17,9 +17,9 @@ jobs: strategy: matrix: - demo: - - { name: "robust-python-demo", session_modifier: "no-rust" } - - { name: "robust-maturin-demo", session_modifier: "rust" } + demo_name: + - "robust-python-demo" + - "robust-maturin-demo" steps: - name: Checkout Template uses: actions/checkout@v4 @@ -30,8 +30,8 @@ jobs: - name: Checkout Demo uses: actions/checkout@v4 with: - repository: "${{ github.repository_owner }}/${{ matrix.demo.name }}" - path: ${{ matrix.demo.name }} + repository: "${{ github.repository_owner }}/${{ matrix.demo_name }}" + path: ${{ matrix.demo_name }} ref: develop - name: Set up uv @@ -44,4 +44,4 @@ jobs: - name: Update Demo working-directory: "${{ github.workspace }}/cookiecutter-robust-python" - run: "uvx nox -s 'update-demo(${{ matrix.demo.session_modifier }})' -- --branch-override ${{ github.head_ref }}" + run: "uvx nox -s 'update-demo(${{ matrix.demo_name }})' -- --branch-override ${{ github.head_ref }}" From eda488c4de2e404622e41fa9602419a99bacbd7f Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Fri, 28 Nov 2025 03:11:01 -0500 Subject: [PATCH 41/41] fix: add old env var for project cache to sync-demos.yml for the time being --- .github/workflows/sync-demos.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/sync-demos.yml b/.github/workflows/sync-demos.yml index 4c89862..abeab83 100644 --- a/.github/workflows/sync-demos.yml +++ b/.github/workflows/sync-demos.yml @@ -5,6 +5,7 @@ on: - develop env: + COOKIECUTTER_ROBUST_PYTHON_PROJECT_DEMOS_FOLDER: ${{ github.workspace }} COOKIECUTTER_ROBUST_PYTHON__DEMOS_CACHE_FOLDER: ${{ github.workspace }} COOKIECUTTER_ROBUST_PYTHON__APP_AUTHOR: ${{ github.repository_owner }} ROBUST_PYTHON_DEMO__APP_AUTHOR: ${{ github.repository_owner }}