Repository: cookiecutter/cookiecutter-django

Issue:
Switch template to calendar versioning
## Description

The template is currently versioned after the version of Django it uses:

https://github.com/pydanny/cookiecutter-django/blob/b48067c03494993933bd2eb0aa2e5afdf367f12f/setup.py#L11-L13

I suggest changing this to calendar versioning instead e.g. 2021.1.24

## Rationale

With Github actions, we've been able to automate a few things around the project management:

1. [Drafting Github releases](https://github.com/pydanny/cookiecutter-django/releases) thanks to [release drafter
](https://github.com/pydanny/cookiecutter-django/blob/master/.github/workflows/draft-release.yml)
2. Generating Changelog [with a custom script](https://github.com/pydanny/cookiecutter-django/blob/master/scripts/update_changelog.py) that we [run every night](https://github.com/pydanny/cookiecutter-django/blob/master/.github/workflows/update-changelog.yml).

However, this approach has a few issues:

- Changelog file and release changes are not consistent, they 2 different views of the same thing.
- The Github release doesn't tell much about when changes were actually usable, one typically uses the latest in `master` when calling the cookiecutter CLI.
- The changelog script might need some maintenance down the road, would be nicer to rely on a community supported Github action.
- Changing the version template is not automated yet, and we often miss the step when the version of Django is updated.

## Solution

Ideally we would use off the shelf action(s) like:

- https://github.com/fregante/daily-version-action
- https://github.com/notlmn/release-with-changelog

These are providing some building pieces. Ideally, we also need one to keep the changelog file up to date.

Would be nice to find an all in one solution, an equivalent of https://github.com/relekang/python-semantic-release, but for calendar base releases.

This process would run like the current update changelog script, every night on schedule.


Discussion:
Hm is it possible to simply parse the requirements file?
> is it possible to simply parse the requirements file?

Not sure I understand the question... What file are you talking about?
So in the actual cookiecutter, find requirements/base.txt, parse it and find the version Django is currently using. This way, setup.py's version for this project itself is automated.

Turns out you can just use setuptools or pkg_resources. This was just a suggestion to automating the current method of versioning.
So we could do this in `setup.py`:

```python
import pathlib
import pkg_resources

try:
    with (pathlib.Path.cwd() / "{{ cookiecutter.project_slug }}" / "requirements" / "base.txt").open() as f:
        pkg = [x for x in pkg_resources.parse_requirements(f) if x.name == "django"][0]
except IndexError:
    quit(1)

VERSION = pkg.specs[0][1]
```
where `pkg.specs` is `[('==', '3.0.12')]`
On the topic itself, I like the calendar versioning. I think a calendar versioning method is fine too considering the changes that go into this package are not actually that much related to the version of Django. For the Django versioning method, it's ok too since it acts like a barrier for people to know "hey we don't support this yet so don't try...".
Ah yes, we could indeed parse requirements if the only point was to remove the version from `setup.py`. But you got my main point, which is that the version of the template being tied to the version of Django isn't very meaningful IMO. It might not be bumped for months while the template could be going through significant changes.
Right. So maybe using `datetime` to get the current UTC date, then release at the end of greenwich time for a day in case a PR was merged? That'd make the most sense to me.
Yes, pretty much. That's been working pretty well for generating our CHANGELOG.md. We do so every day at 2 AM (UTC):

https://github.com/pydanny/cookiecutter-django/blob/34ad90bdd83a9e771c3cca801b00b02ad6b8991d/.github/workflows/update-changelog.yml#L4-L6

[The script](https://github.com/pydanny/cookiecutter-django/blob/master/scripts/update_changelog.py) we run iterate through the pull requests from the day before:

https://github.com/pydanny/cookiecutter-django/blob/34ad90bdd83a9e771c3cca801b00b02ad6b8991d/scripts/update_changelog.py#L11-L12

In case it fails for an external reason and needs to retried (that happened once AFAIR), we can trigger it manually, and we have the whole day to do so:

https://github.com/pydanny/cookiecutter-django/blob/34ad90bdd83a9e771c3cca801b00b02ad6b8991d/.github/workflows/update-changelog.yml#L7-L8

Resolving patch:
diff --git a/scripts/update_changelog.py b/scripts/update_changelog.py
--- a/scripts/update_changelog.py
+++ b/scripts/update_changelog.py
@@ -1,26 +1,31 @@
 import datetime as dt
 import os
+import re
 from pathlib import Path
 from typing import Iterable
 
-from github import Github
+import git
 import github.PullRequest
+import github.Repository
+from github import Github
 from jinja2 import Template
 
 CURRENT_FILE = Path(__file__)
 ROOT = CURRENT_FILE.parents[1]
-GITHUB_TOKEN = os.getenv("GITHUB_TOKEN", None)
-GITHUB_REPO = os.getenv("GITHUB_REPOSITORY", None)
-
-# Generate changelog for PRs merged yesterday
-MERGED_DATE = dt.date.today() - dt.timedelta(days=1)
+GITHUB_TOKEN = os.getenv("GITHUB_TOKEN")
+GITHUB_REPO = os.getenv("GITHUB_REPOSITORY")
+GIT_BRANCH = os.getenv("GITHUB_REF_NAME")
 
 
 def main() -> None:
     """
     Script entry point.
     """
-    merged_pulls = list(iter_pulls())
+    # Generate changelog for PRs merged yesterday
+    merged_date = dt.date.today() - dt.timedelta(days=1)
+    repo = Github(login_or_token=GITHUB_TOKEN).get_repo(GITHUB_REPO)
+    merged_pulls = list(iter_pulls(repo, merged_date))
+    print(f"Merged pull requests: {merged_pulls}")
     if not merged_pulls:
         print("Nothing was merged, existing.")
         return
@@ -29,26 +34,44 @@ def main() -> None:
     grouped_pulls = group_pulls_by_change_type(merged_pulls)
 
     # Generate portion of markdown
-    rendered_content = generate_md(grouped_pulls)
+    release_changes_summary = generate_md(grouped_pulls)
+    print(f"Summary of changes: {release_changes_summary}")
 
     # Update CHANGELOG.md file
-    file_path = ROOT / "CHANGELOG.md"
-    old_content = file_path.read_text()
-    updated_content = old_content.replace(
-        "<!-- GENERATOR_PLACEHOLDER -->",
-        f"<!-- GENERATOR_PLACEHOLDER -->\n\n{rendered_content}",
+    release = f"{merged_date:%Y.%m.%d}"
+    changelog_path = ROOT / "CHANGELOG.md"
+    write_changelog(changelog_path, release, release_changes_summary)
+    print(f"Wrote {changelog_path}")
+
+    # Update version
+    setup_py_path = ROOT / "setup.py"
+    update_version(setup_py_path, release)
+    print(f"Updated version in {setup_py_path}")
+
+    # Commit changes, create tag and push
+    update_git_repo([changelog_path, setup_py_path], release)
+
+    # Create GitHub release
+    github_release = repo.create_git_release(
+        tag=release,
+        name=release,
+        message=release_changes_summary,
     )
-    file_path.write_text(updated_content)
+    print(f"Created release on GitHub {github_release}")
 
 
-def iter_pulls() -> Iterable[github.PullRequest.PullRequest]:
+def iter_pulls(
+    repo: github.Repository.Repository,
+    merged_date: dt.date,
+) -> Iterable[github.PullRequest.PullRequest]:
     """Fetch merged pull requests at the date we're interested in."""
-    repo = Github(login_or_token=GITHUB_TOKEN).get_repo(GITHUB_REPO)
     recent_pulls = repo.get_pulls(
-        state="closed", sort="updated", direction="desc"
+        state="closed",
+        sort="updated",
+        direction="desc",
     ).get_page(0)
     for pull in recent_pulls:
-        if pull.merged and pull.merged_at.date() == MERGED_DATE:
+        if pull.merged and pull.merged_at.date() == merged_date:
             yield pull
 
 
@@ -77,7 +100,50 @@ def generate_md(grouped_pulls: dict[str, list[github.PullRequest.PullRequest]])
     """Generate markdown file from Jinja template."""
     changelog_template = ROOT / ".github" / "changelog-template.md"
     template = Template(changelog_template.read_text(), autoescape=True)
-    return template.render(merge_date=MERGED_DATE, grouped_pulls=grouped_pulls)
+    return template.render(grouped_pulls=grouped_pulls)
+
+
+def write_changelog(file_path: Path, release: str, content: str) -> None:
+    """Write Release details to the changelog file."""
+    content = f"## {release}\n{content}"
+    old_content = file_path.read_text()
+    updated_content = old_content.replace(
+        "<!-- GENERATOR_PLACEHOLDER -->",
+        f"<!-- GENERATOR_PLACEHOLDER -->\n\n{content}",
+    )
+    file_path.write_text(updated_content)
+
+
+def update_version(file_path: Path, release: str) -> None:
+    """Update template version in setup.py."""
+    old_content = file_path.read_text()
+    updated_content = re.sub(
+        r'\nversion = "\d+\.\d+\.\d+"\n',
+        f'\nversion = "{release}"\n',
+        old_content,
+    )
+    file_path.write_text(updated_content)
+
+
+def update_git_repo(paths: list[Path], release: str) -> None:
+    """Commit, tag changes in git repo and push to origin."""
+    repo = git.Repo(ROOT)
+    for path in paths:
+        repo.git.add(path)
+    message = f"Release {release}"
+
+    user = repo.git.config("--get", "user.name")
+    email = repo.git.config("--get", "user.email")
+
+    repo.git.commit(
+        m=message,
+        author=f"{user} <{email}>",
+    )
+    repo.git.tag("-a", release, m=message)
+    server = f"https://{GITHUB_TOKEN}@github.com/{GITHUB_REPO}.git"
+    print(f"Pushing changes to {GIT_BRANCH} branch of {GITHUB_REPO}")
+    repo.git.push(server, GIT_BRANCH)
+    repo.git.push("--tags", server, GIT_BRANCH)
 
 
 if __name__ == "__main__":
@@ -85,4 +151,8 @@ def generate_md(grouped_pulls: dict[str, list[github.PullRequest.PullRequest]])
         raise RuntimeError(
             "No github repo, please set the environment variable GITHUB_REPOSITORY"
         )
+    if GIT_BRANCH is None:
+        raise RuntimeError(
+            "No git branch set, please set the GITHUB_REF_NAME environment variable"
+        )
     main()
diff --git a/setup.py b/setup.py
--- a/setup.py
+++ b/setup.py
@@ -1,21 +1,11 @@
 #!/usr/bin/env python
-
-import os
-import sys
-
 try:
     from setuptools import setup
 except ImportError:
     from distutils.core import setup
 
-# Our version ALWAYS matches the version of Django we support
-# If Django has a new release, we branch, tag, then update this setting after the tag.
-version = "3.1.13-01"
-
-if sys.argv[-1] == "tag":
-    os.system(f'git tag -a {version} -m "version {version}"')
-    os.system("git push --tags")
-    sys.exit()
+# We use calendar versioning
+version = "2021.11.17"
 
 with open("README.rst") as readme_file:
     long_description = readme_file.read()


Test patch: