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/merge-demo-feature.yml b/.github/workflows/merge-demo-feature.yml index 6ab3f83..95cb946 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 @@ -24,12 +35,21 @@ jobs: uses: actions/checkout@v4 with: repository: ${{ github.repository }} - path: "${{ github.workspace }}/cookiecutter-robust-python" - - name: Sync Demo - uses: "${{ github.workspace }}/cookiecutter-robust-python/.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.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/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/.github/workflows/sync-demos.yml b/.github/workflows/sync-demos.yml index 239b49c..04fcb30 100644 --- a/.github/workflows/sync-demos.yml +++ b/.github/workflows/sync-demos.yml @@ -7,21 +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 }} - path: "${{ github.workspace }}/cookiecutter-robust-python" - - - name: Update Demo - uses: "${{ github.workspace }}/cookiecutter-robust-python/.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..93a933f 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: @@ -29,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" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5dca0c8..106a087 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 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). - ---- diff --git a/noxfile.py b/noxfile.py index b926add..d1cc803 100644 --- a/noxfile.py +++ b/noxfile.py @@ -14,7 +14,6 @@ import nox import platformdirs from dotenv import load_dotenv -from nox.command import CommandFailed from nox.sessions import Session @@ -26,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" @@ -37,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, @@ -46,12 +44,13 @@ ) ).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() +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, ...] = ( @@ -71,6 +70,11 @@ 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" +SETUP_RELEASE_SCRIPT: Path = SCRIPTS_FOLDER / "setup-release.py" +TAG_VERSION_SCRIPT: Path = SCRIPTS_FOLDER / "tag-version.py" + @dataclass class RepoMetadata: @@ -191,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: @@ -203,7 +204,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( @@ -218,48 +219,91 @@ 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) + 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") +def setup_release(session: Session) -> None: + """Prepare a release by creating a release branch and bumping the version. -@nox.session(python=False, name="release-template") -def release_template(session: Session): - """Run the release process for the TEMPLATE using Commitizen. + Creates a release branch from develop, bumps the version using CalVer, + and creates the initial bump commit. Does not push any changes. - 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 setup-release # Auto-increment micro for current month + nox -s setup-release -- 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.install_and_run_script(SETUP_RELEASE_SCRIPT, *session.posargs) + - cz_bump_args = ["uvx", "cz", "bump", "--changelog"] +@nox.session(python=False, name="bump-version") +def bump_version(session: Session) -> None: + """Bump version using CalVer (YYYY.MM.MICRO). - if increment: - cz_bump_args.append(f"--increment={increment}") + Usage: + nox -s bump-version # Auto-increment micro for current month + nox -s bump-version -- 5 # Force micro version to 5 + """ + session.run("python", BUMP_VERSION_SCRIPT, *session.posargs, external=True) - 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) - 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.") +@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) + + 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) + + +@nox.session(python=False, name="tag-version") +def tag_version(session: Session) -> None: + """Create and push a git tag for the current version. + + Usage: + nox -s tag-version # Create tag locally + nox -s tag-version -- push # Create and push tag + """ + 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") +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") @@ -267,4 +311,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/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..1d17192 --- /dev/null +++ b/scripts/bump-version.py @@ -0,0 +1,66 @@ +# /// 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 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 REPO_FOLDER + + +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: + """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) + + +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/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/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/merge-demo-feature.py b/scripts/merge-demo-feature.py index c48a395..d4f1b42 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,22 +27,27 @@ @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[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.""" 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'" + "pr", "list", "--head", branch, "--base", DEMO.develop_branch, "--json", "number", "--jq", ".[0].number" ) pr_number: str = pr_number_query.stdout.strip() 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__": diff --git a/scripts/setup-release.py b/scripts/setup-release.py new file mode 100644 index 0000000..52ab9f3 --- /dev/null +++ b/scripts/setup-release.py @@ -0,0 +1,127 @@ +# /// 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() 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() diff --git a/scripts/update-demo.py b/scripts/update-demo.py index a300991..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 @@ -51,16 +52,22 @@ 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 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 " @@ -69,6 +76,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 +94,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: @@ -135,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) diff --git a/scripts/util.py b/scripts/util.py index 2844afb..ee8226c 100644 --- a/scripts/util.py +++ b/scripts/util.py @@ -248,3 +248,98 @@ 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 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 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") 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" }, 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