From a7a37768292366a35772ab1aaf8a1454e534c990 Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Sat, 29 Nov 2025 22:22:55 -0500 Subject: [PATCH 01/27] feat: add initial implementation of the calendar version release cicd --- .cz.toml | 4 + .github/workflows/.python-version | 1 + .github/workflows/prepare-release.yml | 52 +++++++++ .github/workflows/release-template.yml | 147 +++++++++++++++++++++++++ noxfile.py | 106 +++++++++++++----- pyproject.toml | 2 +- scripts/bump-version.py | 49 +++++++++ scripts/get-release-notes.py | 48 ++++++++ scripts/util.py | 109 ++++++++++++++++++ 9 files changed, 487 insertions(+), 31 deletions(-) create mode 100644 .cz.toml create mode 100644 .github/workflows/.python-version create mode 100644 .github/workflows/prepare-release.yml create mode 100644 .github/workflows/release-template.yml create mode 100644 scripts/bump-version.py create mode 100644 scripts/get-release-notes.py diff --git a/.cz.toml b/.cz.toml new file mode 100644 index 0000000..864ff52 --- /dev/null +++ b/.cz.toml @@ -0,0 +1,4 @@ +[tool.commitizen] +tag_format = "v$version" +version_provider = "pep621" +update_changelog_on_bump = true diff --git a/.github/workflows/.python-version b/.github/workflows/.python-version new file mode 100644 index 0000000..c8cfe39 --- /dev/null +++ b/.github/workflows/.python-version @@ -0,0 +1 @@ +3.10 diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml new file mode 100644 index 0000000..2217f10 --- /dev/null +++ b/.github/workflows/prepare-release.yml @@ -0,0 +1,52 @@ +name: Prepare Release + +on: + push: + branches: + - "release/*" + +permissions: + contents: write + +jobs: + prepare-release: + name: Prepare Release + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Set up uv + uses: astral-sh/setup-uv@v6 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version-file: .github/workflows/.python-version + + - name: Get Current Version + id: current_version + run: echo "CURRENT_VERSION=$(uvx --from commitizen cz version -p)" >> $GITHUB_OUTPUT + + - name: Get New Release Version + id: new_version + run: echo "NEW_VERSION=${GITHUB_REF_NAME#release/}" >> $GITHUB_OUTPUT + + - name: Bump Version + if: ${{ steps.current_version.outputs.CURRENT_VERSION != steps.new_version.outputs.NEW_VERSION }} + run: uvx nox -s bump-version ${{ steps.new_version.outputs.NEW_VERSION }} + + - name: Get Release Notes + run: uvx nox -s get-release-notes -- ${{ github.workspace }}-CHANGELOG.md + + - name: Create Release Draft + uses: softprops/action-gh-release@v2 + with: + body_path: ${{ github.workspace }}-CHANGELOG.md + draft: true + tag_name: v${{ steps.new_version.outputs.NEW_VERSION }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release-template.yml b/.github/workflows/release-template.yml new file mode 100644 index 0000000..4bbe7d1 --- /dev/null +++ b/.github/workflows/release-template.yml @@ -0,0 +1,147 @@ +# .github/workflows/release-template.yml +# Automated release workflow for the cookiecutter-robust-python template +# Uses Calendar Versioning (CalVer): YYYY.MM.MICRO + +name: Release Template + +on: + push: + branches: + - main + + workflow_dispatch: + inputs: + micro_version: + description: 'Override micro version (leave empty for auto-increment)' + required: false + type: string + +jobs: + bump_and_build: + name: Bump Version & Build + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.VERSION }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Set up uv + uses: astral-sh/setup-uv@v6 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version-file: ".github/workflows/.python-version" + + - name: Configure Git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Bump version and generate changelog + run: | + if [ -n "${{ inputs.micro_version }}" ]; then + uvx nox -s bump-version -- ${{ inputs.micro_version }} + else + uvx nox -s bump-version + fi + + - name: Get version + id: version + run: | + VERSION=$(uvx --from commitizen cz version -p) + echo "VERSION=$VERSION" >> $GITHUB_OUTPUT + + - name: Push version bump commit + run: git push origin HEAD + + - name: Build packages + run: uvx nox -s build-python + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: dist-${{ steps.version.outputs.VERSION }} + path: dist/ + retention-days: 7 + + publish_testpypi: + name: Publish to TestPyPI + runs-on: ubuntu-latest + needs: bump_and_build + permissions: + id-token: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up uv + uses: astral-sh/setup-uv@v6 + + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: dist-${{ needs.bump_and_build.outputs.version }} + path: dist/ + + - name: Publish to TestPyPI + run: uvx nox -s publish-python -- --test-pypi + + publish_pypi: + name: Tag & Publish to PyPI + runs-on: ubuntu-latest + needs: [bump_and_build, publish_testpypi] + permissions: + id-token: write + contents: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: main + + - name: Set up uv + uses: astral-sh/setup-uv@v6 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version-file: ".github/workflows/.python-version" + + - name: Configure Git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Pull latest (includes version bump) + run: git pull origin main + + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: dist-${{ needs.bump_and_build.outputs.version }} + path: dist/ + + - name: Create and push tag + run: uvx nox -s tag-version -- push + + - name: Publish to PyPI + run: uvx nox -s publish-python + + - name: Extract release notes + run: uvx nox -s get-release-notes -- release_notes.md + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ needs.bump_and_build.outputs.version }} + name: v${{ needs.bump_and_build.outputs.version }} + body_path: release_notes.md + files: dist/* + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/noxfile.py b/noxfile.py index b926add..2aa3ced 100644 --- a/noxfile.py +++ b/noxfile.py @@ -1,12 +1,17 @@ """Noxfile for the cookiecutter-robust-python template.""" # /// script -# dependencies = ["nox>=2025.5.1", "platformdirs>=4.3.8", "python-dotenv>=1.0.0"] +# dependencies = ["nox>=2025.5.1", "platformdirs>=4.3.8", "python-dotenv>=1.0.0", "tomli>=2.0.0;python_version<'3.11'"] # /// import os import shutil from dataclasses import asdict + +try: + import tomllib +except ModuleNotFoundError: + import tomli as tomllib from dataclasses import dataclass from pathlib import Path from typing import Any @@ -225,41 +230,82 @@ def merge_demo_feature(session: Session, demo: RepoMetadata) -> None: session.run("uv", "run", MERGE_DEMO_FEATURE_SCRIPT, *args) -@nox.session(python=False, name="release-template") -def release_template(session: Session): - """Run the release process for the TEMPLATE using Commitizen. +BUMP_VERSION_SCRIPT: Path = SCRIPTS_FOLDER / "bump-version.py" + + +@nox.session(python=False, name="bump-version") +def bump_version(session: Session) -> None: + """Bump version using CalVer (YYYY.MM.MICRO). - Requires uvx in PATH (from uv install). Requires Git. - Assumes Conventional Commits practice is followed for TEMPLATE repository. - Optionally accepts increment level (major, minor, patch) after '--'. + Usage: + nox -s bump-version # Auto-increment micro for current month + nox -s bump-version -- 5 # Force micro version to 5 """ - session.log("Running release process for the TEMPLATE using Commitizen...") - try: - session.run("git", "version", success_codes=[0], external=True, silent=True) - except CommandFailed: - session.log("Git command not found. Commitizen requires Git.") - session.skip("Git not available.") - - session.log("Checking Commitizen availability via uvx.") - session.run("cz", "--version", successcodes=[0]) - - increment = session.posargs[0] if session.posargs else None - session.log( - "Bumping template version and tagging release (increment: %s).", - increment if increment else "default", - ) + session.run("python", BUMP_VERSION_SCRIPT, *session.posargs, external=True) + + +@nox.session(python=False, name="build-python") +def build_python(session: Session) -> None: + """Build sdist and wheel packages for the template.""" + session.log("Building sdist and wheel packages...") + dist_dir = REPO_ROOT / "dist" + if dist_dir.exists(): + shutil.rmtree(dist_dir) + session.run("uv", "build", external=True) + session.log(f"Packages built in {dist_dir}") + + +@nox.session(python=False, name="publish-python") +def publish_python(session: Session) -> None: + """Publish packages to PyPI. + + Usage: + nox -s publish-python # Publish to PyPI + nox -s publish-python -- --test-pypi # Publish to TestPyPI + """ + session.log("Checking built packages with Twine.") + session.run("uvx", "twine", "check", "dist/*", external=True) - cz_bump_args = ["uvx", "cz", "bump", "--changelog"] + if "--test-pypi" in session.posargs: + session.log("Publishing packages to TestPyPI.") + session.run("uv", "publish", "--publish-url", "https://test.pypi.org/legacy/", external=True) + else: + session.log("Publishing packages to PyPI.") + session.run("uv", "publish", external=True) - if increment: - cz_bump_args.append(f"--increment={increment}") - session.log("Running cz bump with args: %s", cz_bump_args) - # success_codes=[0, 1] -> Allows code 1 which means 'nothing to bump' if no conventional commits since last release - session.run(*cz_bump_args, success_codes=[0, 1], external=True) +@nox.session(python=False, name="tag-version") +def tag_version(session: Session) -> None: + """Create and push a git tag for the current version. - session.log("Template version bumped and tag created locally via Commitizen/uvx.") - session.log("IMPORTANT: Push commits and tags to remote (`git push --follow-tags`) to trigger CD for the TEMPLATE.") + Usage: + nox -s tag-version # Create tag locally + nox -s tag-version -- push # Create and push tag + """ + with open(REPO_ROOT / "pyproject.toml", "rb") as f: + version = tomllib.load(f)["project"]["version"] + + tag_name = f"v{version}" + session.log(f"Creating tag: {tag_name}") + session.run("git", "tag", "-a", tag_name, "-m", f"Release {version}", external=True) + + if "push" in session.posargs: + session.log(f"Pushing tag {tag_name} to origin...") + session.run("git", "push", "origin", tag_name, external=True) + + +GET_RELEASE_NOTES_SCRIPT: Path = SCRIPTS_FOLDER / "get-release-notes.py" + + +@nox.session(python=False, name="get-release-notes") +def get_release_notes(session: Session) -> None: + """Extract release notes for the current version. + + Usage: + nox -s get-release-notes # Write to release_notes.md + nox -s get-release-notes -- /path/to/file.md # Write to custom path + """ + session.run("python", GET_RELEASE_NOTES_SCRIPT, *session.posargs, external=True) @nox.session(python=False, name="remove-demo-release") diff --git a/pyproject.toml b/pyproject.toml index 1ce0604..5d42991 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "cookiecutter-robust-python" -version = "0.1.0" +version = "2025.11.0" description = "Add your description here" readme = "README.md" requires-python = ">=3.10,<4.0" diff --git a/scripts/bump-version.py b/scripts/bump-version.py new file mode 100644 index 0000000..e17573e --- /dev/null +++ b/scripts/bump-version.py @@ -0,0 +1,49 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "python-dotenv", +# "typer", +# "tomli>=2.0.0;python_version<'3.11'", +# ] +# /// +"""Script responsible for bumping the version of cookiecutter-robust-python using CalVer.""" + +import sys +from typing import Annotated +from typing import Optional + +import typer + +from util import bump_version +from util import calculate_calver +from util import get_current_version + + +cli: typer.Typer = typer.Typer() + + +@cli.callback(invoke_without_command=True) +def main( + micro: Annotated[Optional[int], typer.Argument(help="Override micro version (default: auto-increment)")] = None, +) -> None: + """Bump version using CalVer (YYYY.MM.MICRO). + + CalVer format: + - YYYY: Four-digit year + - MM: Month (1-12, no leading zero) + - MICRO: Incremental patch number, resets to 0 each month + """ + try: + current_version: str = get_current_version() + new_version: str = calculate_calver(current_version, micro) + + typer.secho(f"Bumping version: {current_version} -> {new_version}", fg="blue") + bump_version(new_version) + typer.secho(f"Version bumped to {new_version}", fg="green") + except Exception as error: + typer.secho(f"error: {error}", fg="red") + sys.exit(1) + + +if __name__ == "__main__": + cli() diff --git a/scripts/get-release-notes.py b/scripts/get-release-notes.py new file mode 100644 index 0000000..3d982c2 --- /dev/null +++ b/scripts/get-release-notes.py @@ -0,0 +1,48 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "python-dotenv", +# "typer", +# ] +# /// +"""Script responsible for extracting release notes for the cookiecutter-robust-python template.""" + +import sys +from pathlib import Path +from typing import Annotated +from typing import Optional + +import typer + +from util import get_latest_release_notes + + +cli: typer.Typer = typer.Typer() + +DEFAULT_RELEASE_NOTES_PATH: Path = Path("release_notes.md") + + +@cli.callback(invoke_without_command=True) +def main( + path: Annotated[ + Optional[Path], + typer.Argument(help=f"Path to write release notes (default: {DEFAULT_RELEASE_NOTES_PATH})") + ] = None, +) -> None: + """Extract release notes for the current version. + + Uses commitizen to generate changelog entries for unreleased changes. + Must be run before tagging the release. + """ + try: + output_path: Path = path if path else DEFAULT_RELEASE_NOTES_PATH + release_notes: str = get_latest_release_notes() + output_path.write_text(release_notes) + typer.secho(f"Release notes written to {output_path}", fg="green") + except Exception as error: + typer.secho(f"error: {error}", fg="red") + sys.exit(1) + + +if __name__ == "__main__": + cli() diff --git a/scripts/util.py b/scripts/util.py index 2844afb..696da96 100644 --- a/scripts/util.py +++ b/scripts/util.py @@ -4,6 +4,7 @@ # "cookiecutter", # "cruft", # "python-dotenv", +# "tomli>=2.0.0;python_version<'3.11'", # "typer", # ] # /// @@ -248,3 +249,111 @@ def _remove_existing_demo(demo_path: Path) -> None: def get_demo_name(add_rust_extension: bool) -> str: name_modifier: str = "maturin" if add_rust_extension else "python" return f"robust-{name_modifier}-demo" + + +def get_package_version() -> str: + """Gets the current package version using commitizen.""" + result = run_command("uvx", "--from", "commitizen", "cz", "version", "-p") + return result.stdout.strip() + + +def get_current_version() -> str: + """Read current version from pyproject.toml.""" + try: + import tomllib + except ModuleNotFoundError: + import tomli as tomllib + + pyproject_path: Path = REPO_FOLDER / "pyproject.toml" + with pyproject_path.open("rb") as f: + data: dict[str, Any] = tomllib.load(f) + return data["project"]["version"] + + +def calculate_calver(current_version: str, micro_override: Optional[int] = None) -> str: + """Calculate the next CalVer version. + + CalVer format: YYYY.MM.MICRO + - YYYY: Four-digit year + - MM: Month (1-12, no leading zero) + - MICRO: Incremental patch number, resets to 0 each month + + Args: + current_version: The current version string + micro_override: Optional manual micro version override + + Returns: + The new CalVer version string (YYYY.MM.MICRO) + """ + from datetime import date + + today = date.today() + year, month = today.year, today.month + + if micro_override is not None: + micro = micro_override + else: + # Auto-calculate micro + try: + parts: list[str] = current_version.split(".") + curr_year, curr_month, curr_micro = int(parts[0]), int(parts[1]), int(parts[2]) + if curr_year == year and curr_month == month: + micro = curr_micro + 1 # Same month, increment + else: + micro = 0 # New month, reset + except (ValueError, IndexError): + micro = 0 # Invalid version format, start fresh + + return f"{year}.{month}.{micro}" + + +def bump_version(new_version: str) -> None: + """Bump version using commitizen. + + Args: + new_version: The version to bump to + """ + cmd: list[str] = ["uvx", "--from", "commitizen", "cz", "bump", "--changelog", "--yes", "--no-tag", new_version] + # Exit code 1 means 'nothing to bump' - treat as success + result: subprocess.CompletedProcess = subprocess.run(cmd, cwd=REPO_FOLDER) + if result.returncode not in (0, 1): + raise RuntimeError(f"Version bump failed with exit code {result.returncode}") + + +def get_latest_tag() -> Optional[str]: + """Gets the latest git tag, or None if no tags exist.""" + result = run_command("git", "describe", "--tags", "--abbrev=0", ignore_error=True) + if result is None: + return None + tag = result.stdout.strip() + return tag if tag else None + + +def get_latest_release_notes() -> str: + """Gets the release notes for the current version. + + Assumes the tag hasn't been applied yet. + """ + latest_tag: Optional[str] = get_latest_tag() + latest_version: str = get_package_version() + + # Build the revision range for changelog + if latest_tag is None: + rev_range = "" + else: + # Strip 'v' prefix if present for comparison + tag_version = latest_tag.lstrip("v") + if tag_version == latest_version: + raise ValueError( + "The latest tag and version are the same. " + "Please ensure the release notes are taken before tagging." + ) + rev_range = f"{latest_tag}.." + + result = run_command( + "uvx", "--from", "commitizen", "cz", "changelog", + rev_range, + "--dry-run", + "--unreleased-version", latest_version + ) + return result.stdout From 34fc165e01c7c3daa0f04780f6ad3071545adc9d Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Sat, 29 Nov 2025 22:36:55 -0500 Subject: [PATCH 02/27] docs: update CONTRIBUTING.md info --- CONTRIBUTING.md | 121 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 110 insertions(+), 11 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5dca0c8..6746e4c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,20 +14,121 @@ There are several ways to contribute: ## Setting Up Your Development Environment -Refer to the **[Getting Started: Contributing to the Template](https://robust-python.github.io/cookiecutter-robust-python/getting-started-template-contributing.html)** section in the template documentation for instructions on cloning the repository, installing template development dependencies (using uv), setting up the template's pre-commit hooks, and running template checks/tests. +1. **Clone** the repository: + ```bash + git clone https://github.com/robust-python/cookiecutter-robust-python.git + cd cookiecutter-robust-python + ``` + +2. **Install dependencies** using uv: + ```bash + uv sync --all-groups + ``` + +3. **Install pre-commit hooks**: + ```bash + uvx nox -s pre-commit -- install + ``` + +4. **Generate a demo project** to test changes: + ```bash + nox -s generate-demo + ``` + +Refer to the **[Getting Started: Contributing to the Template](https://robust-python.github.io/cookiecutter-robust-python/getting-started-template-contributing.html)** section in the template documentation for more detailed instructions. + +## Development Commands + +### Code Quality +```bash +# Lint the template source code +nox -s lint + +# Lint from generated demo project +nox -s lint-from-demo + +# Run template tests +nox -s test + +# Build template documentation +nox -s docs +``` + +### Demo Projects +```bash +# Generate a demo project for testing +nox -s generate-demo + +# Generate demo with Rust extension +nox -s generate-demo -- --add-rust-extension + +# Update existing demo projects +nox -s update-demo + +# Clear demo cache +nox -s clear-cache +``` ## Contribution Workflow 1. **Fork** the repository and **clone** your fork. -2. Create a **new branch** for your contribution based on the main branch. Use a descriptive name (e.g., `fix/ci-workflow-on-windows`, `feat/update-uv-version`). -3. Set up your development environment following the [Getting Started](https://robust-python.github.io/cookiecutter-robust-python/getting-started-template-contributing.html) guide (clone, `uv sync`, `uvx nox -s pre-commit -- install`). +2. Create a **new branch** for your contribution based on the `develop` branch. Use a descriptive name (e.g., `fix/ci-workflow-on-windows`, `feat/update-uv-version`). +3. Set up your development environment as described above. 4. Make your **code or documentation changes**. -5. Ensure your changes adhere to the template's **code quality standards** (configured in the template's `.pre-commit-config.yaml`, `.ruff.toml`, etc.). The pre-commit hooks will help with this. Run `uvx nox -s lint`, `uvx nox -s check` in the template repository for more comprehensive checks. -6. Ensure your changes **do not break existing functionality**. Run the template's test suite: `uvx nox -s test`. Ideally, add tests for new functionality or bug fixes. -7. Ensure the **template documentation builds correctly** with your changes: `uvx nox -s docs`. -8. Write clear, concise **commit messages** following the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) specification where possible, especially for significant changes (fixes, features, chore updates, etc.). -9. **Push** your branch to your fork. -10. **Open a Pull Request** from your branch to the main branch of the main template repository. Provide a clear description of your changes. Link to any relevant issues. +5. Ensure your changes adhere to the template's **code quality standards**. Run: + ```bash + nox -s lint + nox -s test + ``` +6. Ensure the **template documentation builds correctly**: + ```bash + nox -s docs + ``` +7. Write clear, concise **commit messages** following the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) specification. This is **required** as we use Commitizen to generate changelogs automatically. +8. **Push** your branch to your fork. +9. **Open a Pull Request** from your branch to the `develop` branch of the main repository. Provide a clear description of your changes. Link to any relevant issues. + +## Commit Message Guidelines + +We use [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) for commit messages. This enables automatic changelog generation via Commitizen. + +### Format +``` +(): + +[optional body] + +[optional footer(s)] +``` + +### Types +- `feat`: A new feature +- `fix`: A bug fix +- `docs`: Documentation only changes +- `style`: Changes that do not affect the meaning of the code +- `refactor`: A code change that neither fixes a bug nor adds a feature +- `perf`: A code change that improves performance +- `test`: Adding missing tests or correcting existing tests +- `build`: Changes that affect the build system or external dependencies +- `ci`: Changes to CI configuration files and scripts +- `chore`: Other changes that don't modify src or test files + +### Examples +``` +feat(template): add support for Python 3.13 +fix(ci): correct workflow trigger for demo sync +docs(readme): update installation instructions +chore(deps): bump ruff to 0.12.0 +``` + +## Versioning + +This template uses **Calendar Versioning (CalVer)** with the format `YYYY.MM.MICRO`: +- `YYYY`: Four-digit year +- `MM`: Month (1-12, no leading zero) +- `MICRO`: Incremental patch number, resets to 0 each new month + +Releases are handled automatically via CI when changes are merged to `main`. Contributors do not need to bump versions manually. ## Updating Tool Evaluations @@ -36,5 +137,3 @@ If your contribution involves updating a major tool version or suggesting a diff ## Communication For questions or discussion about contributions, open an issue or a discussion on the [GitHub repository](https://github.com/robust-python/cookiecutter-robust-python). - ---- From 4345fa5fe055f2048f7d5f6f12a4f986a4ea8c60 Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Sun, 30 Nov 2025 00:53:45 -0500 Subject: [PATCH 03/27] docs: fix pre-commit install method in CONTRIBUTING.md --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6746e4c..106a087 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,7 +27,7 @@ There are several ways to contribute: 3. **Install pre-commit hooks**: ```bash - uvx nox -s pre-commit -- install + uvx pre-commit install ``` 4. **Generate a demo project** to test changes: From 7d4cc40dd7eabd8ceb44436deed964aa1867f41e Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Sun, 30 Nov 2025 01:25:40 -0500 Subject: [PATCH 04/27] refactor: move get_current_version into bump-version script to avoid unneeded tomli install in other scripts --- scripts/bump-version.py | 19 ++++++++++++++++++- scripts/util.py | 14 -------------- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/scripts/bump-version.py b/scripts/bump-version.py index e17573e..1d17192 100644 --- a/scripts/bump-version.py +++ b/scripts/bump-version.py @@ -9,14 +9,22 @@ """Script responsible for bumping the version of cookiecutter-robust-python using CalVer.""" import sys +from pathlib import Path from typing import Annotated +from typing import Any from typing import Optional import typer from util import bump_version from util import calculate_calver -from util import get_current_version +from util import REPO_FOLDER + + +try: + import tomllib +except ModuleNotFoundError: + import tomli as tomllib cli: typer.Typer = typer.Typer() @@ -45,5 +53,14 @@ def main( sys.exit(1) +def get_current_version() -> str: + """Read current version from pyproject.toml.""" + + pyproject_path: Path = REPO_FOLDER / "pyproject.toml" + with pyproject_path.open("rb") as f: + data: dict[str, Any] = tomllib.load(f) + return data["project"]["version"] + + if __name__ == "__main__": cli() diff --git a/scripts/util.py b/scripts/util.py index 696da96..ee8226c 100644 --- a/scripts/util.py +++ b/scripts/util.py @@ -4,7 +4,6 @@ # "cookiecutter", # "cruft", # "python-dotenv", -# "tomli>=2.0.0;python_version<'3.11'", # "typer", # ] # /// @@ -257,19 +256,6 @@ def get_package_version() -> str: return result.stdout.strip() -def get_current_version() -> str: - """Read current version from pyproject.toml.""" - try: - import tomllib - except ModuleNotFoundError: - import tomli as tomllib - - pyproject_path: Path = REPO_FOLDER / "pyproject.toml" - with pyproject_path.open("rb") as f: - data: dict[str, Any] = tomllib.load(f) - return data["project"]["version"] - - def calculate_calver(current_version: str, micro_override: Optional[int] = None) -> str: """Calculate the next CalVer version. From e2940a773ea4f5695581bf97eda9f7dff133fe16 Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Sun, 30 Nov 2025 01:42:11 -0500 Subject: [PATCH 05/27] refactor: move tag-version into its own script for the time being Will most likely be recondensed at a later point, but keeping the whole script pattern consistent for easier refactoring later --- noxfile.py | 40 ++++++++------------------- scripts/tag-version.py | 62 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 28 deletions(-) create mode 100644 scripts/tag-version.py diff --git a/noxfile.py b/noxfile.py index 2aa3ced..a7af8c2 100644 --- a/noxfile.py +++ b/noxfile.py @@ -1,17 +1,12 @@ """Noxfile for the cookiecutter-robust-python template.""" # /// script -# dependencies = ["nox>=2025.5.1", "platformdirs>=4.3.8", "python-dotenv>=1.0.0", "tomli>=2.0.0;python_version<'3.11'"] +# dependencies = ["nox>=2025.5.1", "platformdirs>=4.3.8", "python-dotenv>=1.0.0"] # /// import os import shutil from dataclasses import asdict - -try: - import tomllib -except ModuleNotFoundError: - import tomli as tomllib from dataclasses import dataclass from pathlib import Path from typing import Any @@ -19,7 +14,6 @@ import nox import platformdirs from dotenv import load_dotenv -from nox.command import CommandFailed from nox.sessions import Session @@ -31,7 +25,6 @@ SCRIPTS_FOLDER: Path = REPO_ROOT / "scripts" TEMPLATE_FOLDER: Path = REPO_ROOT / "{{cookiecutter.project_name}}" - # Load environment variables from .env and .env.local (if present) LOCAL_ENV_FILE: Path = REPO_ROOT / ".env.local" DEFAULT_ENV_FILE: Path = REPO_ROOT / ".env" @@ -52,9 +45,11 @@ ).resolve() DEFAULT_DEMOS_CACHE_FOLDER = COOKIECUTTER_ROBUST_PYTHON_CACHE_FOLDER / "project_demos" -DEMOS_CACHE_FOLDER: Path = Path(os.getenv( - "COOKIECUTTER_ROBUST_PYTHON__DEMOS_CACHE_FOLDER", default=DEFAULT_DEMOS_CACHE_FOLDER -)).resolve() +DEMOS_CACHE_FOLDER: Path = Path( + os.getenv( + "COOKIECUTTER_ROBUST_PYTHON__DEMOS_CACHE_FOLDER", default=DEFAULT_DEMOS_CACHE_FOLDER + ) +).resolve() DEFAULT_DEMO_NAME: str = "robust-python-demo" DEMO_ROOT_FOLDER: Path = DEMOS_CACHE_FOLDER / DEFAULT_DEMO_NAME @@ -76,6 +71,10 @@ MERGE_DEMO_FEATURE_SCRIPT: Path = SCRIPTS_FOLDER / "merge-demo-feature.py" MERGE_DEMO_FEATURE_OPTIONS: tuple[str, ...] = GENERATE_DEMO_OPTIONS +BUMP_VERSION_SCRIPT: Path = SCRIPTS_FOLDER / "bump-version.py" +GET_RELEASE_NOTES_SCRIPT: Path = SCRIPTS_FOLDER / "get-release-notes.py" +TAG_VERSION_SCRIPT: Path = SCRIPTS_FOLDER / "tag-version.py" + @dataclass class RepoMetadata: @@ -230,9 +229,6 @@ def merge_demo_feature(session: Session, demo: RepoMetadata) -> None: session.run("uv", "run", MERGE_DEMO_FEATURE_SCRIPT, *args) -BUMP_VERSION_SCRIPT: Path = SCRIPTS_FOLDER / "bump-version.py" - - @nox.session(python=False, name="bump-version") def bump_version(session: Session) -> None: """Bump version using CalVer (YYYY.MM.MICRO). @@ -282,19 +278,8 @@ def tag_version(session: Session) -> None: nox -s tag-version # Create tag locally nox -s tag-version -- push # Create and push tag """ - with open(REPO_ROOT / "pyproject.toml", "rb") as f: - version = tomllib.load(f)["project"]["version"] - - tag_name = f"v{version}" - session.log(f"Creating tag: {tag_name}") - session.run("git", "tag", "-a", tag_name, "-m", f"Release {version}", external=True) - - if "push" in session.posargs: - session.log(f"Pushing tag {tag_name} to origin...") - session.run("git", "push", "origin", tag_name, external=True) - - -GET_RELEASE_NOTES_SCRIPT: Path = SCRIPTS_FOLDER / "get-release-notes.py" + args: list[str] = ["--push"] if "push" in session.posargs else [] + session.run("python", TAG_VERSION_SCRIPT, *args, external=True) @nox.session(python=False, name="get-release-notes") @@ -313,4 +298,3 @@ def remove_demo_release(session: Session) -> None: """Deletes the latest demo release.""" session.run("git", "branch", "-d", f"release/{session.posargs[0]}", external=True) session.run("git", "push", "--progress", "--porcelain", "origin", f"release/{session.posargs[0]}", external=True) - diff --git a/scripts/tag-version.py b/scripts/tag-version.py new file mode 100644 index 0000000..0023291 --- /dev/null +++ b/scripts/tag-version.py @@ -0,0 +1,62 @@ +"""Script responsible for creating and pushing git tags for cookiecutter-robust-python releases.""" +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "python-dotenv", +# "typer", +# "tomli>=2.0.0;python_version<'3.11'", +# ] +# /// + +from pathlib import Path +from typing import Annotated +from typing import Any + +import typer +from cookiecutter.utils import work_in + +from scripts.util import git +from util import REPO_FOLDER + + +try: + import tomllib +except ModuleNotFoundError: + import tomli as tomllib + + +cli: typer.Typer = typer.Typer() + + +@cli.callback(invoke_without_command=True) +def main( + push: Annotated[bool, typer.Option("--push", help="Push the tag to origin after creating it")] = False +) -> None: + """Create a git tag for the current version. + + Creates an annotated tag in the format 'vYYYY.MM.MICRO' based on the + version in pyproject.toml. Optionally pushes the tag to origin. + """ + version: str = get_current_version() + tag_name: str = f"v{version}" + with work_in(REPO_FOLDER): + typer.secho(f"Creating tag: {tag_name}", fg="blue") + git("tag", "-a", tag_name, "-m", f"Release {version}") + typer.secho(f"Tag {tag_name} created successfully", fg="green") + + if push: + typer.secho(f"Pushing tag {tag_name} to origin...", fg="blue") + git("push", "origin", tag_name) + typer.secho(f"Tag {tag_name} pushed to origin", fg="green") + + +def get_current_version() -> str: + """Read current version from pyproject.toml.""" + pyproject_path: Path = REPO_FOLDER / "pyproject.toml" + with pyproject_path.open("rb") as f: + data: dict[str, Any] = tomllib.load(f) + return data["project"]["version"] + + +if __name__ == "__main__": + cli() From 782ea9b2fd0416fc002bda3032bceede20169035 Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Sun, 30 Nov 2025 01:46:36 -0500 Subject: [PATCH 06/27] chore: update uv.lock --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index 7e409a7..32deb56 100644 --- a/uv.lock +++ b/uv.lock @@ -356,7 +356,7 @@ wheels = [ [[package]] name = "cookiecutter-robust-python" -version = "0.1.0" +version = "2025.11.0" source = { virtual = "." } dependencies = [ { name = "cookiecutter" }, From 3483e9e38f95d0437dc803cfd09db2050ab10194 Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Sun, 30 Nov 2025 01:50:28 -0500 Subject: [PATCH 07/27] fix: replace uv run call in update-demo nox session with install_and_run_script --- noxfile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/noxfile.py b/noxfile.py index a7af8c2..989fdbe 100644 --- a/noxfile.py +++ b/noxfile.py @@ -207,7 +207,7 @@ def update_demo(session: Session, demo: RepoMetadata) -> None: args.extend(session.posargs) demo_env: dict[str, Any] = {f"ROBUST_DEMO__{key.upper()}": value for key, value in asdict(demo).items()} - session.run("uv", "run", UPDATE_DEMO_SCRIPT, *args, env=demo_env) + session.install_and_run_script(UPDATE_DEMO_SCRIPT, *args, env=demo_env) @nox.parametrize( From 58f3520c775910f9f107bd03463b10b063400a28 Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Sun, 30 Nov 2025 02:22:43 -0500 Subject: [PATCH 08/27] fix: update names throughout nox session and file along with fix update-demo not creating branches and some env var issues --- noxfile.py | 7 +++---- scripts/update-demo.py | 17 ++++++++++------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/noxfile.py b/noxfile.py index 989fdbe..aa02e8e 100644 --- a/noxfile.py +++ b/noxfile.py @@ -35,8 +35,8 @@ if DEFAULT_ENV_FILE.exists(): load_dotenv(DEFAULT_ENV_FILE) -APP_AUTHOR: str = os.getenv("COOKIECUTTER_ROBUST_PYTHON_APP_AUTHOR", "robust-python") -COOKIECUTTER_ROBUST_PYTHON_CACHE_FOLDER: Path = Path( +APP_AUTHOR: str = os.getenv("COOKIECUTTER_ROBUST_PYTHON__APP_AUTHOR", "robust-python") +COOKIECUTTER_ROBUST_PYTHON__CACHE_FOLDER: Path = Path( platformdirs.user_cache_path( appname="cookiecutter-robust-python", appauthor=APP_AUTHOR, @@ -44,14 +44,13 @@ ) ).resolve() -DEFAULT_DEMOS_CACHE_FOLDER = COOKIECUTTER_ROBUST_PYTHON_CACHE_FOLDER / "project_demos" +DEFAULT_DEMOS_CACHE_FOLDER = COOKIECUTTER_ROBUST_PYTHON__CACHE_FOLDER / "project_demos" DEMOS_CACHE_FOLDER: Path = Path( os.getenv( "COOKIECUTTER_ROBUST_PYTHON__DEMOS_CACHE_FOLDER", default=DEFAULT_DEMOS_CACHE_FOLDER ) ).resolve() DEFAULT_DEMO_NAME: str = "robust-python-demo" -DEMO_ROOT_FOLDER: Path = DEMOS_CACHE_FOLDER / DEFAULT_DEMO_NAME GENERATE_DEMO_SCRIPT: Path = SCRIPTS_FOLDER / "generate-demo.py" GENERATE_DEMO_OPTIONS: tuple[str, ...] = ( diff --git a/scripts/update-demo.py b/scripts/update-demo.py index a300991..e8da7f0 100644 --- a/scripts/update-demo.py +++ b/scripts/update-demo.py @@ -51,14 +51,14 @@ def update_demo( 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 + desired_branch_name: str = branch_override else: - current_branch: str = get_current_branch() + desired_branch_name: str = get_current_branch() template_commit: str = get_current_commit() - _validate_template_main_not_checked_out(branch=current_branch) + _validate_template_main_not_checked_out(branch=desired_branch_name) 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) + _checkout_demo_develop_or_existing_branch(demo_path=demo_path, branch=desired_branch_name) last_update_commit: str = get_last_cruft_update_commit(demo_path=demo_path) if not is_ancestor(last_update_commit, template_commit): @@ -69,6 +69,9 @@ def update_demo( typer.secho(f"Updating demo project at {demo_path=}.", fg="yellow") with work_in(demo_path): + if get_current_branch() != desired_branch_name: + git("checkout", "-b", desired_branch_name, DEMO.develop_branch) + uv("python", "pin", min_python_version) uv("python", "install", min_python_version) cruft.update( @@ -84,9 +87,9 @@ def update_demo( uv("lock") 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) + git("push", "-u", "origin", desired_branch_name) + if desired_branch_name != "develop": + _create_demo_pr(demo_path=demo_path, branch=desired_branch_name, commit_start=last_update_commit) def _checkout_demo_develop_or_existing_branch(demo_path: Path, branch: str) -> None: From 8017439d221e07c623d84e34a454bdb14b32562e Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Sun, 30 Nov 2025 02:41:16 -0500 Subject: [PATCH 09/27] chore: manually set environ variable in pytest demos_folder fixture so that it remains isolated from CI interactions --- tests/conftest.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 66b34da..e9e2fdb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,5 @@ """Fixtures used in all tests for cookiecutter-robust-python.""" - +import os import subprocess from pathlib import Path from typing import Any @@ -21,7 +21,9 @@ @pytest.fixture(scope="session") def demos_folder(tmp_path_factory: TempPathFactory) -> Path: """Temp Folder used for storing demos while testing.""" - return tmp_path_factory.mktemp("demos") + path: Path = tmp_path_factory.mktemp("demos") + os.environ["COOKIECUTTER_ROBUST_PYTHON__DEMOS_CACHE_FOLDER"] = str(path) + return path @pytest.fixture(scope="session") From bf45c15ec8123a0d65575859ceedac3af5f756a6 Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Sun, 30 Nov 2025 04:23:25 -0500 Subject: [PATCH 10/27] feat: add initial implementation of setup-release nox session and corresponding script prior to review --- noxfile.py | 15 +++++ scripts/setup-release.py | 128 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 scripts/setup-release.py diff --git a/noxfile.py b/noxfile.py index aa02e8e..9851cc8 100644 --- a/noxfile.py +++ b/noxfile.py @@ -72,6 +72,7 @@ BUMP_VERSION_SCRIPT: Path = SCRIPTS_FOLDER / "bump-version.py" GET_RELEASE_NOTES_SCRIPT: Path = SCRIPTS_FOLDER / "get-release-notes.py" +SETUP_RELEASE_SCRIPT: Path = SCRIPTS_FOLDER / "setup-release.py" TAG_VERSION_SCRIPT: Path = SCRIPTS_FOLDER / "tag-version.py" @@ -228,6 +229,20 @@ def merge_demo_feature(session: Session, demo: RepoMetadata) -> None: session.run("uv", "run", MERGE_DEMO_FEATURE_SCRIPT, *args) +@nox.session(python=DEFAULT_TEMPLATE_PYTHON_VERSION, name="setup-release") +def setup_release(session: Session) -> None: + """Prepare a release by creating a release branch and bumping the version. + + Creates a release branch from develop, bumps the version using CalVer, + and creates the initial bump commit. Does not push any changes. + + Usage: + nox -s setup-release # Auto-increment micro for current month + nox -s setup-release -- 5 # Force micro version to 5 + """ + session.install_and_run_script(SETUP_RELEASE_SCRIPT, *session.posargs) + + @nox.session(python=False, name="bump-version") def bump_version(session: Session) -> None: """Bump version using CalVer (YYYY.MM.MICRO). diff --git a/scripts/setup-release.py b/scripts/setup-release.py new file mode 100644 index 0000000..016bac0 --- /dev/null +++ b/scripts/setup-release.py @@ -0,0 +1,128 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "cookiecutter", +# "python-dotenv", +# "typer", +# "tomli>=2.0.0;python_version<'3.11'", +# ] +# /// +"""Script responsible for preparing a release of the cookiecutter-robust-python template.""" + +import sys +from pathlib import Path +from typing import Annotated +from typing import Any +from typing import Optional + +import typer +from cookiecutter.utils import work_in + +from util import bump_version +from util import calculate_calver +from util import git +from util import REPO_FOLDER +from util import TEMPLATE + + +try: + import tomllib +except ModuleNotFoundError: + import tomli as tomllib + + +cli: typer.Typer = typer.Typer() + + +@cli.callback(invoke_without_command=True) +def main( + micro: Annotated[ + Optional[int], + typer.Argument(help="Override micro version (default: auto-increment)") + ] = None, +) -> None: + """Prepare a release by creating a release branch and bumping the version. + + Creates a release branch from develop, bumps the version using CalVer, + and creates the initial bump commit. Does not push any changes. + + CalVer format: YYYY.MM.MICRO + """ + try: + current_version: str = get_current_version() + new_version: str = calculate_calver(current_version, micro) + + typer.secho(f"Setting up release: {current_version} -> {new_version}", fg="blue") + + setup_release(current_version=current_version, new_version=new_version, micro=micro) + + typer.secho(f"Release branch created: release/{new_version}", fg="green") + typer.secho("Next steps:", fg="blue") + typer.secho(f" 1. Review changes and push: git push -u origin release/{new_version}", fg="white") + typer.secho(" 2. Create a pull request to main", fg="white") + except Exception as error: + typer.secho(f"error: {error}", fg="red") + sys.exit(1) + + +def get_current_version() -> str: + """Read current version from pyproject.toml.""" + pyproject_path: Path = REPO_FOLDER / "pyproject.toml" + with pyproject_path.open("rb") as f: + data: dict[str, Any] = tomllib.load(f) + return data["project"]["version"] + + +def setup_release(current_version: str, new_version: str, micro: Optional[int] = None) -> None: + """Prepares a release of the cookiecutter-robust-python template. + + Creates a release branch from develop, bumps the version, and creates a release commit. + Rolls back on error. + """ + with work_in(REPO_FOLDER): + try: + _setup_release(current_version=current_version, new_version=new_version, micro=micro) + except Exception as error: + _rollback_release(version=new_version) + raise error + + +def _setup_release(current_version: str, new_version: str, micro: Optional[int] = None) -> None: + """Internal setup release logic.""" + develop_branch: str = TEMPLATE.develop_branch + release_branch: str = f"release/{new_version}" + + # Create release branch from develop + typer.secho(f"Creating branch {release_branch} from {develop_branch}...", fg="blue") + git("checkout", "-b", release_branch, develop_branch) + + # Bump version + typer.secho(f"Bumping version to {new_version}...", fg="blue") + bump_version(new_version) + + # Sync dependencies + typer.secho("Syncing dependencies...", fg="blue") + git("add", ".") + + # Create bump commit + typer.secho("Creating bump commit...", fg="blue") + git("commit", "-m", f"bump: version {current_version} → {new_version}") + + +def _rollback_release(version: str) -> None: + """Rolls back to the pre-existing state on error.""" + develop_branch: str = TEMPLATE.develop_branch + release_branch: str = f"release/{version}" + + typer.secho(f"Rolling back release {version}...", fg="yellow") + + # Checkout develop and discard changes + git("checkout", develop_branch, ignore_error=True) + git("checkout", ".", ignore_error=True) + + # Delete the release branch if it exists + git("branch", "-D", release_branch, ignore_error=True) + + +if __name__ == "__main__": + cli() From 63ebafa5b54a433286394fb432bc55aca0999d23 Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Sun, 30 Nov 2025 04:33:35 -0500 Subject: [PATCH 11/27] feat: add check for when the template is already in sync with the demo --- scripts/update-demo.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/update-demo.py b/scripts/update-demo.py index e8da7f0..b73e7f2 100644 --- a/scripts/update-demo.py +++ b/scripts/update-demo.py @@ -61,6 +61,12 @@ def update_demo( _checkout_demo_develop_or_existing_branch(demo_path=demo_path, branch=desired_branch_name) last_update_commit: str = get_last_cruft_update_commit(demo_path=demo_path) + if template_commit == last_update_commit: + typer.secho( + f"{demo_name} is already up to date with {desired_branch_name} at {last_update_commit}", + fg=typer.colors.YELLOW + ) + 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 " From fd27f005672b7020463a61714c9a873032dcf43a Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Sun, 30 Nov 2025 04:40:27 -0500 Subject: [PATCH 12/27] fix: remove github.workspace from uses path --- .github/workflows/sync-demos.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sync-demos.yml b/.github/workflows/sync-demos.yml index 239b49c..10be892 100644 --- a/.github/workflows/sync-demos.yml +++ b/.github/workflows/sync-demos.yml @@ -22,6 +22,6 @@ jobs: path: "${{ github.workspace }}/cookiecutter-robust-python" - name: Update Demo - uses: "${{ github.workspace }}/cookiecutter-robust-python/.github/workflows/update-demo.yml" + uses: "./cookiecutter-robust-python/.github/workflows/update-demo.yml" with: demo_name: ${{ matrix.demo_name }} From c940656a34baa8cff1112bfb6c075cd05c89e89d Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Sun, 30 Nov 2025 04:42:49 -0500 Subject: [PATCH 13/27] fix: change paths pointing toward update-demo reusable workflow --- .github/workflows/merge-demo-feature.yml | 2 +- .github/workflows/sync-demos.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/merge-demo-feature.yml b/.github/workflows/merge-demo-feature.yml index 6ab3f83..ab32a0c 100644 --- a/.github/workflows/merge-demo-feature.yml +++ b/.github/workflows/merge-demo-feature.yml @@ -27,7 +27,7 @@ jobs: path: "${{ github.workspace }}/cookiecutter-robust-python" - name: Sync Demo - uses: "${{ github.workspace }}/cookiecutter-robust-python/.github/workflows/update-demo.yml" + uses: "./.github/workflows/update-demo.yml" with: demo_name: ${{ matrix.demo_name }} diff --git a/.github/workflows/sync-demos.yml b/.github/workflows/sync-demos.yml index 10be892..54a79e6 100644 --- a/.github/workflows/sync-demos.yml +++ b/.github/workflows/sync-demos.yml @@ -22,6 +22,6 @@ jobs: path: "${{ github.workspace }}/cookiecutter-robust-python" - name: Update Demo - uses: "./cookiecutter-robust-python/.github/workflows/update-demo.yml" + uses: "./.github/workflows/update-demo.yml" with: demo_name: ${{ matrix.demo_name }} From 774807233d2ff97ff41cbba6101fc8c8b0aa053e Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Sun, 30 Nov 2025 04:53:13 -0500 Subject: [PATCH 14/27] chore: lint a few scripts --- scripts/lint-from-demo.py | 2 -- scripts/setup-release.py | 1 - 2 files changed, 3 deletions(-) diff --git a/scripts/lint-from-demo.py b/scripts/lint-from-demo.py index c9458a9..2ae5a3f 100644 --- a/scripts/lint-from-demo.py +++ b/scripts/lint-from-demo.py @@ -9,7 +9,6 @@ # ] # /// -import os from pathlib import Path from typing import Annotated @@ -30,7 +29,6 @@ "uv.lock", ] - cli: typer.Typer = typer.Typer() diff --git a/scripts/setup-release.py b/scripts/setup-release.py index 016bac0..52ab9f3 100644 --- a/scripts/setup-release.py +++ b/scripts/setup-release.py @@ -30,7 +30,6 @@ except ModuleNotFoundError: import tomli as tomllib - cli: typer.Typer = typer.Typer() From e1297995cda14fe71360c5da6ab81fd16322bdb9 Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Sun, 30 Nov 2025 04:59:38 -0500 Subject: [PATCH 15/27] fix: tweak gitlab ci to not error from too many uv cache keyfiles being defined --- {{cookiecutter.project_name}}/.gitlab-ci.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/{{cookiecutter.project_name}}/.gitlab-ci.yml b/{{cookiecutter.project_name}}/.gitlab-ci.yml index 6edd465..e766f84 100644 --- a/{{cookiecutter.project_name}}/.gitlab-ci.yml +++ b/{{cookiecutter.project_name}}/.gitlab-ci.yml @@ -26,8 +26,6 @@ stages: files: - pyproject.toml - uv.lock - - requirements*.txt - - "**/requirements*.txt" paths: - $UV_CACHE_DIR - $PIP_CACHE_DIR From 2f4adf2bdfc051898caeaabe420fad15d1da7ee1 Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Mon, 1 Dec 2025 01:46:56 -0500 Subject: [PATCH 16/27] chore: alter initial checkout location to see if it fixes actions references --- .github/workflows/merge-demo-feature.yml | 2 +- .github/workflows/sync-demos.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/merge-demo-feature.yml b/.github/workflows/merge-demo-feature.yml index ab32a0c..8a8a173 100644 --- a/.github/workflows/merge-demo-feature.yml +++ b/.github/workflows/merge-demo-feature.yml @@ -24,7 +24,7 @@ jobs: uses: actions/checkout@v4 with: repository: ${{ github.repository }} - path: "${{ github.workspace }}/cookiecutter-robust-python" + path: "${{ github.workspace }}" - name: Sync Demo uses: "./.github/workflows/update-demo.yml" diff --git a/.github/workflows/sync-demos.yml b/.github/workflows/sync-demos.yml index 54a79e6..7f93e43 100644 --- a/.github/workflows/sync-demos.yml +++ b/.github/workflows/sync-demos.yml @@ -19,7 +19,7 @@ jobs: uses: actions/checkout@v4 with: repository: ${{ github.repository }} - path: "${{ github.workspace }}/cookiecutter-robust-python" + path: "${{ github.workspace }}" - name: Update Demo uses: "./.github/workflows/update-demo.yml" From e74104f7625712a19643c91259177dcdf1e0bf43 Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Mon, 1 Dec 2025 01:50:46 -0500 Subject: [PATCH 17/27] chore: remove path provided to see if it fixes reference issues --- .github/workflows/merge-demo-feature.yml | 1 - .github/workflows/sync-demos.yml | 1 - 2 files changed, 2 deletions(-) diff --git a/.github/workflows/merge-demo-feature.yml b/.github/workflows/merge-demo-feature.yml index 8a8a173..3595ca2 100644 --- a/.github/workflows/merge-demo-feature.yml +++ b/.github/workflows/merge-demo-feature.yml @@ -24,7 +24,6 @@ jobs: uses: actions/checkout@v4 with: repository: ${{ github.repository }} - path: "${{ github.workspace }}" - name: Sync Demo uses: "./.github/workflows/update-demo.yml" diff --git a/.github/workflows/sync-demos.yml b/.github/workflows/sync-demos.yml index 7f93e43..4477e82 100644 --- a/.github/workflows/sync-demos.yml +++ b/.github/workflows/sync-demos.yml @@ -19,7 +19,6 @@ jobs: uses: actions/checkout@v4 with: repository: ${{ github.repository }} - path: "${{ github.workspace }}" - name: Update Demo uses: "./.github/workflows/update-demo.yml" From 13bf965da8fc4440085d11d795edbd93ae896119 Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Mon, 1 Dec 2025 02:02:05 -0500 Subject: [PATCH 18/27] fix: move reusable workflow usage to the job level and piece together portions to get things moving possibly --- .github/workflows/merge-demo-feature.yml | 27 +++++++++++++++++++++--- .github/workflows/sync-demos.yml | 15 +++---------- .github/workflows/update-demo.yml | 6 ++++++ 3 files changed, 33 insertions(+), 15 deletions(-) diff --git a/.github/workflows/merge-demo-feature.yml b/.github/workflows/merge-demo-feature.yml index 3595ca2..2823edf 100644 --- a/.github/workflows/merge-demo-feature.yml +++ b/.github/workflows/merge-demo-feature.yml @@ -11,6 +11,17 @@ env: ROBUST_MATURIN_DEMO__APP_AUTHOR: ${{ github.repository_owner }} jobs: + update-demo: + name: Update Demo + uses: ./.github/workflows/update-demo.yml + strategy: + matrix: + demo_name: + - "robust-python-demo" + - "robust-maturin-demo" + with: + demo_name: ${{ matrix.demo_name }} + merge-demo-feature: name: Merge Demo Feature runs-on: ubuntu-latest @@ -25,10 +36,20 @@ jobs: with: repository: ${{ github.repository }} - - name: Sync Demo - uses: "./.github/workflows/update-demo.yml" + - name: Checkout Demo + uses: actions/checkout@v4 + with: + repository: "${{ github.repository_owner }}/${{ inputs.demo_name }}" + path: ${{ inputs.demo_name }} + ref: develop + + - name: Set up uv + uses: astral-sh/setup-uv@v6 + + - name: Set up Python + uses: actions/setup-python@v5 with: - demo_name: ${{ matrix.demo_name }} + python-version-file: ".github/workflows/.python-version" - name: Merge Demo Feature PR into Develop working-directory: "${{ github.workspace }}/${{ matrix.demo_name }}" diff --git a/.github/workflows/sync-demos.yml b/.github/workflows/sync-demos.yml index 4477e82..04fcb30 100644 --- a/.github/workflows/sync-demos.yml +++ b/.github/workflows/sync-demos.yml @@ -7,20 +7,11 @@ on: jobs: update-demo: name: Update Demo - runs-on: ubuntu-latest - + uses: ./.github/workflows/update-demo.yml strategy: matrix: demo_name: - "robust-python-demo" - "robust-maturin-demo" - steps: - - name: Checkout Template - uses: actions/checkout@v4 - with: - repository: ${{ github.repository }} - - - name: Update Demo - uses: "./.github/workflows/update-demo.yml" - with: - demo_name: ${{ matrix.demo_name }} + with: + demo_name: ${{ matrix.demo_name }} diff --git a/.github/workflows/update-demo.yml b/.github/workflows/update-demo.yml index 9b971ed..9c6859a 100644 --- a/.github/workflows/update-demo.yml +++ b/.github/workflows/update-demo.yml @@ -16,6 +16,12 @@ jobs: update-demo: runs-on: ubuntu-latest steps: + - name: Checkout Template + uses: actions/checkout@v4 + with: + repository: ${{ github.repository }} + path: "${{ github.workspace }}/cookiecutter-robust-python" + - name: Checkout Demo uses: actions/checkout@v4 with: From 6097e9c848506b67b5f0511718b11f033ab1c978 Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Mon, 1 Dec 2025 02:05:30 -0500 Subject: [PATCH 19/27] fix: set references to python version file as absolute positions due to multiple checkout oddities arising --- .github/workflows/merge-demo-feature.yml | 2 +- .github/workflows/update-demo.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/merge-demo-feature.yml b/.github/workflows/merge-demo-feature.yml index 2823edf..95cb946 100644 --- a/.github/workflows/merge-demo-feature.yml +++ b/.github/workflows/merge-demo-feature.yml @@ -49,7 +49,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version-file: ".github/workflows/.python-version" + python-version-file: "${{ github.workspace }}/cookiecutter-robust-python/.github/workflows/.python-version" - name: Merge Demo Feature PR into Develop working-directory: "${{ github.workspace }}/${{ matrix.demo_name }}" diff --git a/.github/workflows/update-demo.yml b/.github/workflows/update-demo.yml index 9c6859a..93a933f 100644 --- a/.github/workflows/update-demo.yml +++ b/.github/workflows/update-demo.yml @@ -35,7 +35,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version-file: ".github/workflows/.python-version" + python-version-file: "${{ github.workspace }}/cookiecutter-robust-python/.github/workflows/.python-version" - name: Update Demo working-directory: "${{ github.workspace }}/cookiecutter-robust-python" From 0198495adf4403d5e66ac89671f1e43423eff0c1 Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Mon, 1 Dec 2025 02:28:10 -0500 Subject: [PATCH 20/27] feat: add small check to gracefully exit when trying to create an existing PR --- scripts/update-demo.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/update-demo.py b/scripts/update-demo.py index b73e7f2..d9a151f 100644 --- a/scripts/update-demo.py +++ b/scripts/update-demo.py @@ -8,6 +8,7 @@ # ] # /// import itertools +import subprocess from pathlib import Path from subprocess import CompletedProcess from typing import Annotated @@ -144,6 +145,10 @@ def _validate_template_main_not_checked_out(branch: str) -> None: 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}") + search_results: subprocess.CompletedProcess = gh("pr", "list", "--state", "open", "--search", branch) + if "no pull requests match your search" not in search_results.stdout: + typer.secho(f"Skipping PR creation due to existing PR found for branch {branch}") + return body: str = _get_demo_feature_pr_body(demo_path=demo_path, commit_start=commit_start) From bda6c838564b98e54c60e7ef56d3689868974e16 Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Mon, 1 Dec 2025 02:36:05 -0500 Subject: [PATCH 21/27] fix: swap to nox session install and run script for merge-demo-feature session along with fixing arg passthrough --- noxfile.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/noxfile.py b/noxfile.py index 9851cc8..b5c8a3e 100644 --- a/noxfile.py +++ b/noxfile.py @@ -222,11 +222,11 @@ def merge_demo_feature(session: Session, demo: RepoMetadata) -> None: Assumes that all PR's already exist. """ args: list[str] = [*MERGE_DEMO_FEATURE_OPTIONS] + if session.posargs: + args = [*session.posargs, *args] if "maturin" in demo.app_name: args.append("--add-rust-extension") - if session.posargs: - args.extend(session.posargs) - session.run("uv", "run", MERGE_DEMO_FEATURE_SCRIPT, *args) + session.install_and_run_script(MERGE_DEMO_FEATURE_SCRIPT, *args) @nox.session(python=DEFAULT_TEMPLATE_PYTHON_VERSION, name="setup-release") From 7969d2315cb9a3350f2bf76d47a8d7c51738c517 Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Mon, 1 Dec 2025 02:41:04 -0500 Subject: [PATCH 22/27] feat: add default option to use current branch and add placeholder default for cache folder for the time until it gets moved to config passthrough later --- scripts/merge-demo-feature.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/scripts/merge-demo-feature.py b/scripts/merge-demo-feature.py index c48a395..b084099 100644 --- a/scripts/merge-demo-feature.py +++ b/scripts/merge-demo-feature.py @@ -10,12 +10,14 @@ import subprocess from pathlib import Path from typing import Annotated +from typing import Optional import typer from cookiecutter.utils import work_in from util import DEMO from util import FolderOption +from util import get_current_branch from util import get_demo_name from util import gh @@ -25,13 +27,18 @@ @cli.callback(invoke_without_command=True) def merge_demo_feature( - branch: str, - demos_cache_folder: Annotated[Path, FolderOption("--demos-cache-folder", "-c")], + branch: Optional[str] = None, + demos_cache_folder: Annotated[Path, FolderOption("--demos-cache-folder", "-c")] = None, add_rust_extension: Annotated[bool, typer.Option("--add-rust-extension", "-r")] = False ) -> None: """Searches for the given demo feature branch's PR and merges it if ready.""" demo_name: str = get_demo_name(add_rust_extension=add_rust_extension) + if demos_cache_folder is None: + raise ValueError("Failed to provide a demos cache folder.") + demo_path: Path = demos_cache_folder / demo_name + branch: str = branch if branch is not None else get_current_branch() + with work_in(demo_path): pr_number_query: subprocess.CompletedProcess = gh( "pr", "list", "--head", branch, "--base", DEMO.develop_branch, "--json", "number", "--jq", "'.[0].number'" From e4bf34d6bbed50f5ac68c5d5673c4d8990926093 Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Mon, 1 Dec 2025 02:43:07 -0500 Subject: [PATCH 23/27] fix: change type of demos_cache_folder to prevent validation error for case not used commonly --- scripts/merge-demo-feature.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/merge-demo-feature.py b/scripts/merge-demo-feature.py index b084099..6b94496 100644 --- a/scripts/merge-demo-feature.py +++ b/scripts/merge-demo-feature.py @@ -28,7 +28,7 @@ @cli.callback(invoke_without_command=True) def merge_demo_feature( branch: Optional[str] = None, - demos_cache_folder: Annotated[Path, FolderOption("--demos-cache-folder", "-c")] = None, + demos_cache_folder: Annotated[Optional[Path], FolderOption("--demos-cache-folder", "-c")] = None, add_rust_extension: Annotated[bool, typer.Option("--add-rust-extension", "-r")] = False ) -> None: """Searches for the given demo feature branch's PR and merges it if ready.""" From 1df07441cae4caba6236eaefd3ca2194c2417c63 Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Mon, 1 Dec 2025 02:48:08 -0500 Subject: [PATCH 24/27] fix: add missing demo env info pass through into nox session install of script --- noxfile.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/noxfile.py b/noxfile.py index b5c8a3e..9da4dc5 100644 --- a/noxfile.py +++ b/noxfile.py @@ -226,7 +226,9 @@ def merge_demo_feature(session: Session, demo: RepoMetadata) -> None: args = [*session.posargs, *args] if "maturin" in demo.app_name: args.append("--add-rust-extension") - session.install_and_run_script(MERGE_DEMO_FEATURE_SCRIPT, *args) + + demo_env: dict[str, Any] = {f"ROBUST_DEMO__{key.upper()}": value for key, value in asdict(demo).items()} + session.install_and_run_script(MERGE_DEMO_FEATURE_SCRIPT, *args, env=demo_env) @nox.session(python=DEFAULT_TEMPLATE_PYTHON_VERSION, name="setup-release") From c414c81856d7a2036484e8cfba5a9d2974ad72a8 Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Mon, 1 Dec 2025 02:49:23 -0500 Subject: [PATCH 25/27] feat: remove unneeded prior install now that PEP 723 is being used --- noxfile.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/noxfile.py b/noxfile.py index 9da4dc5..d1cc803 100644 --- a/noxfile.py +++ b/noxfile.py @@ -195,9 +195,6 @@ def test(session: Session) -> None: ) @nox.session(python=DEFAULT_TEMPLATE_PYTHON_VERSION, name="update-demo") 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 "maturin" in demo.app_name: From 9197f2067f0c95f9eb64018bc974e2d6054ab85c Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Mon, 1 Dec 2025 02:54:58 -0500 Subject: [PATCH 26/27] fix: remove faulty quotes in jq expression for merge-demo-feature script --- scripts/merge-demo-feature.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/merge-demo-feature.py b/scripts/merge-demo-feature.py index 6b94496..0c02798 100644 --- a/scripts/merge-demo-feature.py +++ b/scripts/merge-demo-feature.py @@ -41,7 +41,7 @@ def merge_demo_feature( with work_in(demo_path): pr_number_query: subprocess.CompletedProcess = gh( - "pr", "list", "--head", branch, "--base", DEMO.develop_branch, "--json", "number", "--jq", "'.[0].number'" + "pr", "list", "--head", branch, "--base", DEMO.develop_branch, "--json", "number", "--jq", ".[0].number" ) pr_number: str = pr_number_query.stdout.strip() if pr_number == "": From d5b8fe5b90a23ab2af729484c9e5a342265d3022 Mon Sep 17 00:00:00 2001 From: Kyle Oliver Date: Mon, 1 Dec 2025 02:55:34 -0500 Subject: [PATCH 27/27] fix: specify --merge in merge-demo-feature script attempt at merging due to requirement when used in automation --- scripts/merge-demo-feature.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/merge-demo-feature.py b/scripts/merge-demo-feature.py index 0c02798..d4f1b42 100644 --- a/scripts/merge-demo-feature.py +++ b/scripts/merge-demo-feature.py @@ -47,7 +47,7 @@ def merge_demo_feature( if pr_number == "": raise ValueError("Failed to find an existing PR from {} to {DEMO.develop_branch}") - gh("pr", "merge", pr_number, "--auto", "--delete-branch") + gh("pr", "merge", pr_number, "--auto", "--delete-branch", "--merge") if __name__ == "__main__":