diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 5975712..335197a 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -83,48 +83,22 @@ jobs: with: fetch-depth: 0 - - name: Auto-generate Changelog - uses: BobAnkh/auto-generate-changelog@v1.3.0 - with: - REPO_NAME: 'JnyJny/python-package-cookiecutter' - ACCESS_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PATH: 'CHANGELOG.md' - COMMIT_MESSAGE: 'docs(CHANGELOG): update release notes' - TYPE: 'feat:Feature,bug:Bug Fixes,fix:Bug Fixes,docs:Documentation,refactor:Refactor,perf:Performance Improvements' + - name: Install git-cliff + run: pip install git-cliff + + - name: Generate release notes for this tag + run: git cliff --latest --strip header > release_notes.md + + - name: Update CHANGELOG.md + run: git cliff --output CHANGELOG.md - - name: Generate release notes - id: release_notes + - name: Commit updated CHANGELOG.md run: | - set -euo pipefail - - # Get the tag name - TAG_NAME=${GITHUB_REF#refs/tags/} - echo "Generating release notes for tag: $TAG_NAME" - - # Get the previous tag - if PREVIOUS_TAG=$(git describe --tags --abbrev=0 ${TAG_NAME}^ 2>/dev/null); then - echo "Previous tag found: $PREVIOUS_TAG" - echo "## Changes since $PREVIOUS_TAG" >> release_notes.md - echo "" >> release_notes.md - git log --pretty=format:"- %s (%h)" ${PREVIOUS_TAG}..${TAG_NAME} >> release_notes.md - else - echo "No previous tag found, generating initial release notes" - echo "## Initial Release" >> release_notes.md - echo "" >> release_notes.md - echo "- Initial release of python-package-cookiecutter template" >> release_notes.md - fi - - # Check if there's a CHANGELOG.md file - if [[ -f "CHANGELOG.md" ]]; then - echo "Adding CHANGELOG.md reference" - echo "" >> release_notes.md - echo "## Full Changelog" >> release_notes.md - echo "" >> release_notes.md - echo "See [CHANGELOG.md](CHANGELOG.md) for complete release notes." >> release_notes.md - fi - - echo "Generated release notes:" - cat release_notes.md + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add CHANGELOG.md + git diff --cached --quiet || git commit -m "docs(CHANGELOG): update release notes" + git push origin HEAD:main - name: Create GitHub Release uses: ncipollo/release-action@v1 @@ -132,5 +106,5 @@ jobs: bodyFile: "release_notes.md" draft: false prerelease: false - generateReleaseNotes: true + generateReleaseNotes: false token: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 0000000..57d0631 --- /dev/null +++ b/cliff.toml @@ -0,0 +1,60 @@ +# git-cliff configuration +# https://git-cliff.org/docs/configuration + +[changelog] +header = """# CHANGELOG\n +All notable changes to this project will be documented in this file.\n +""" +body = """ +{%- macro remote_url() -%} + https://github.com/JnyJny/python-package-cookiecutter +{%- endmacro -%} + +{% if version -%} +## [{{ version | trim_start_matches(pat="v") }}]({{ self::remote_url() }}/releases/tag/{{ version }}) - {{ timestamp | date(format="%Y-%m-%d") }} +{% else -%} +## Unreleased +{% endif -%} + +{% for group, commits in commits | group_by(attribute="group") %} +### {{ group | striptags | trim | upper_first }} +{% for commit in commits %} +- {{ commit.message | split(pat="\n") | first | trim }}\ + {% if commit.remote.pr_number %} \ + ([#{{ commit.remote.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.remote.pr_number }}))\ + {% endif %} \ + ([{{ commit.id | truncate(length=7, end="") }}]({{ self::remote_url() }}/commit/{{ commit.id }}))\ +{%- endfor %} +{% endfor %} +""" +footer = "" +trim = true + +[git] +conventional_commits = true +filter_unconventional = false +split_commits = false +commit_parsers = [ + { message = "^Merge pull request", skip = true }, + { message = "^Merge branch", skip = true }, + { message = "^ci\\(deps\\)", skip = true }, + { message = "^ci:", skip = true }, + { message = "^docs\\(CHANGELOG\\)", skip = true }, + { message = "^v\\d+\\.\\d+", skip = true }, + + { message = "^feat", group = "Features" }, + { message = "^fix", group = "Bug Fixes" }, + { message = "^doc", group = "Documentation" }, + { message = "^refactor", group = "Refactor" }, + { message = "^perf", group = "Performance" }, + { message = "^style", group = "Styling" }, + { message = "^test", group = "Testing" }, + + { message = ".*", group = "Other" }, +] +filter_commits = false +tag_pattern = "v[0-9].*" +skip_tags = "" +ignore_tags = "" +topo_order = false +sort_commits = "newest" diff --git a/pyproject.toml b/pyproject.toml index cdd72a8..781053e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ dependencies = [] [dependency-groups] dev = [ "cookiecutter>=2.6.0", + "git-cliff>=2.0.0", "poethepoet>=0.35.0", "pytest>=8.4.0", "ruff>=0.11.13", @@ -58,19 +59,31 @@ _push_tags = "git push --tags" _update_pyproject = ["_add", "_commit", "_tag", "_push_tags", "_push"] # Release management tasks -changelog.shell = "git log --pretty=format:'- %s (%h)' $(git describe --tags --abbrev=0 2>/dev/null || echo 'HEAD')..HEAD" -changelog.help = "[Release] Generate changelog since last tag." - -release_notes.shell = "echo '## Release Notes' > RELEASE_NOTES.md && echo '' >> RELEASE_NOTES.md && git log --pretty=format:'- %s (%h)' $(git describe --tags --abbrev=0 2>/dev/null || echo 'HEAD')..HEAD >> RELEASE_NOTES.md" -release_notes.help = "[Release] Generate release notes file." - -release_patch.sequence = [ "_patch_bump", "_update_pyproject"] +changelog.cmd = "git cliff" +changelog.help = "[Release] Generate full changelog to stdout." + +release_notes.cmd = "git cliff --latest --strip header" +release_notes.help = "[Release] Generate release notes for the latest tag." + +_preflight.shell = """ +if [ "$(git branch --show-current)" != "main" ]; then + echo "ERROR: release must run from main branch" >&2 + exit 1 +fi +if [ -n "$(git status --porcelain)" ]; then + echo "ERROR: working tree is dirty" >&2 + exit 1 +fi +""" +_preflight.help = "Verify on main branch with clean working tree." + +release_patch.sequence = [ "_preflight", "_patch_bump", "_update_pyproject"] release_patch.help = "Publish patch release." -release_minor.sequence = [ "_minor_bump", "_update_pyproject"] +release_minor.sequence = [ "_preflight", "_minor_bump", "_update_pyproject"] release_minor.help = "Publish minor release." -release_major.sequence = [ "_major_bump", "_update_pyproject"] +release_major.sequence = [ "_preflight", "_major_bump", "_update_pyproject"] release_major.help = "Publish major release." release.ref = "release_patch" diff --git a/uv.lock b/uv.lock index 404ebd5..e3fdf8d 100644 --- a/uv.lock +++ b/uv.lock @@ -160,6 +160,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, ] +[[package]] +name = "git-cliff" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/cf/dff8cd706d2e30e264cb3b9880235607188fb3ad596bfe6282147165bdcd/git_cliff-2.12.0.tar.gz", hash = "sha256:57b96b1f61167f85395353d6f47a89944b4882c03880312d53c09dacecb7ff86", size = 102106, upload-time = "2026-01-20T17:46:12.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/a5/dc5f800f6a6dc175faa0787653119754dbbe81a9db1274e041443690287b/git_cliff-2.12.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e9ee9aa29e9435211712fdab4b5ec9fb432c4bc9d244e39351b2be57aeba7999", size = 6879200, upload-time = "2026-01-20T17:45:55.964Z" }, + { url = "https://files.pythonhosted.org/packages/d7/b6/0e251bd49700e767c47d8d524a690ad713a3aed4318074278438042b8f25/git_cliff-2.12.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e18512138db5ef57302155b1163c0a2cf43c3d79071a5e083883b65bb990218c", size = 6456349, upload-time = "2026-01-20T17:45:58.202Z" }, + { url = "https://files.pythonhosted.org/packages/5e/63/4e8780f60ad28e8c26ae2b2b365daff9ffa84cb441a5d5bf62c42a75e75a/git_cliff-2.12.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d24c3e334fdf309c59802ea1a9cd3828e92c8c7cacdd619bcabdc638e00e2ade", size = 6916209, upload-time = "2026-01-20T17:45:59.931Z" }, + { url = "https://files.pythonhosted.org/packages/71/83/0bfab93065e10bcbe97e6136ccf6c1e8552715ef61c11eb678c397ff5fb0/git_cliff-2.12.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f1aa25b05a0315d0f58fc2ac21503538ca749fc3dd7476ee5d6bdf380d9f26ab", size = 7305605, upload-time = "2026-01-20T17:46:01.991Z" }, + { url = "https://files.pythonhosted.org/packages/30/eb/78f624e387c1d9084ca7bcec3a8f28fda9fbbfbeb18c71465a727ee677b5/git_cliff-2.12.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:91eafd2f3ecf226b9a9c2a6c54d96df6042479927b48a97fcf46b728e8744bf1", size = 6927694, upload-time = "2026-01-20T17:46:03.798Z" }, + { url = "https://files.pythonhosted.org/packages/49/3f/735ddcb426c9f77498a039e9398162345c59f29c7990fbf22a530a15fb97/git_cliff-2.12.0-py3-none-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:26c9771a50a039252c67803f4c7f187f2ce9c5eea336b8cef890e94483af7a9d", size = 7118983, upload-time = "2026-01-20T17:46:05.535Z" }, + { url = "https://files.pythonhosted.org/packages/f0/97/68a5bd8063904fc43df7811e713483ccd831a877751283c6514dfb5b079e/git_cliff-2.12.0-py3-none-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:168f48b82f81ab8e1625d42adb739471623e25bd0a7e25b8c70490bad9e90e2b", size = 7541855, upload-time = "2026-01-20T17:46:07.348Z" }, + { url = "https://files.pythonhosted.org/packages/f7/00/2ed0bf7d71340c20906c1317db50cd6c14bdf0c90fa68a62885c9daf40a9/git_cliff-2.12.0-py3-none-win32.whl", hash = "sha256:4bc609a748c1c3493fe3e00a48305d343255ddff80e564fbf8eb954aac387784", size = 6354818, upload-time = "2026-01-20T17:46:09.117Z" }, + { url = "https://files.pythonhosted.org/packages/c0/fd/679d54e4ed37fdbadb58080219af8f35b5f659dd25e47ab1951b6349d1d0/git_cliff-2.12.0-py3-none-win_amd64.whl", hash = "sha256:c992b5756298251ecdd4db8abe087e90d00327f9eaf0c2470a44dbff64377d07", size = 7303564, upload-time = "2026-01-20T17:46:11.154Z" }, +] + [[package]] name = "idna" version = "3.10" @@ -469,6 +486,7 @@ source = { virtual = "." } [package.dev-dependencies] dev = [ { name = "cookiecutter" }, + { name = "git-cliff" }, { name = "poethepoet" }, { name = "pytest" }, { name = "ruff" }, @@ -486,6 +504,7 @@ docs = [ [package.metadata.requires-dev] dev = [ { name = "cookiecutter", specifier = ">=2.6.0" }, + { name = "git-cliff", specifier = ">=2.0.0" }, { name = "poethepoet", specifier = ">=0.35.0" }, { name = "pytest", specifier = ">=8.4.0" }, { name = "ruff", specifier = ">=0.11.13" }, diff --git a/{{ cookiecutter.package_name }}/.github/workflows/release.yaml b/{{ cookiecutter.package_name }}/.github/workflows/release.yaml index d5b7b30..15ac4df 100644 --- a/{{ cookiecutter.package_name }}/.github/workflows/release.yaml +++ b/{{ cookiecutter.package_name }}/.github/workflows/release.yaml @@ -130,6 +130,7 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 + token: {% raw %}${{ secrets.GITHUB_TOKEN }}{% endraw %} - name: Download build artifacts uses: actions/download-artifact@v4 @@ -137,48 +138,22 @@ jobs: name: dist-files path: dist/ - - name: Auto-generate Changelog - uses: BobAnkh/auto-generate-changelog@v1.2.5 - with: - REPO_NAME: '{{ cookiecutter.github_username }}/{{ cookiecutter.package_name }}' - ACCESS_TOKEN: {% raw %}${{ secrets.GITHUB_TOKEN }}{% endraw %} - PATH: 'CHANGELOG.md' - COMMIT_MESSAGE: 'docs(CHANGELOG): update release notes' - TYPE: 'feat:Feature,bug:Bug Fixes,fix:Bug Fixes,docs:Documentation,refactor:Refactor,perf:Performance Improvements' - - - name: Generate release notes - id: release_notes + - name: Install git-cliff + run: pip install git-cliff + + - name: Generate release notes for this tag + run: git cliff --latest --strip header > release_notes.md + + - name: Update CHANGELOG.md + run: git cliff --output CHANGELOG.md + + - name: Commit updated CHANGELOG.md run: | - set -euo pipefail - - # Get the tag name - TAG_NAME=${GITHUB_REF#refs/tags/} - echo "Generating release notes for tag: $TAG_NAME" - - # Get the previous tag - if PREVIOUS_TAG=$(git describe --tags --abbrev=0 ${TAG_NAME}^ 2>/dev/null); then - echo "Previous tag found: $PREVIOUS_TAG" - echo "## Changes since $PREVIOUS_TAG" >> release_notes.md - echo "" >> release_notes.md - git log --pretty=format:"- %s (%h)" ${PREVIOUS_TAG}..${TAG_NAME} >> release_notes.md - else - echo "No previous tag found, generating initial release notes" - echo "## Initial Release" >> release_notes.md - echo "" >> release_notes.md - echo "- Initial release of {{ cookiecutter.package_name }}" >> release_notes.md - fi - - # Check if there's a CHANGELOG.md file - if [[ -f "CHANGELOG.md" ]]; then - echo "Adding CHANGELOG.md reference" - echo "" >> release_notes.md - echo "## Full Changelog" >> release_notes.md - echo "" >> release_notes.md - echo "See [CHANGELOG.md](CHANGELOG.md) for complete release notes." >> release_notes.md - fi - - echo "Generated release notes:" - cat release_notes.md + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add CHANGELOG.md + git diff --cached --quiet || git commit -m "docs(CHANGELOG): update release notes" + git push origin HEAD:main - name: Create GitHub Release uses: ncipollo/release-action@v1 @@ -187,7 +162,7 @@ jobs: bodyFile: "release_notes.md" draft: false prerelease: false - generateReleaseNotes: true + generateReleaseNotes: false token: {% raw %}${{ secrets.GITHUB_TOKEN }}{% endraw %} deploy-docs: diff --git a/{{ cookiecutter.package_name }}/cliff.toml b/{{ cookiecutter.package_name }}/cliff.toml new file mode 100644 index 0000000..164e067 --- /dev/null +++ b/{{ cookiecutter.package_name }}/cliff.toml @@ -0,0 +1,63 @@ +# git-cliff configuration +# https://git-cliff.org/docs/configuration + +[changelog] +header = """# CHANGELOG\n +All notable changes to this project will be documented in this file.\n +""" +body = """ +{%- macro remote_url() -%} + https://github.com/{{ cookiecutter.github_username }}/{{ cookiecutter.package_name }} +{%- endmacro -%} + +{% if version -%} +## [{{ version | trim_start_matches(pat="v") }}]({{ self::remote_url() }}/releases/tag/{{ version }}) - {{ timestamp | date(format="%Y-%m-%d") }} +{% else -%} +## Unreleased +{% endif -%} + +{% for group, commits in commits | group_by(attribute="group") %} +### {{ group | striptags | trim | upper_first }} +{% for commit in commits %} +- {{ commit.message | split(pat="\\n") | first | trim }}\ + {% if commit.remote.pr_number %} \ + ([#{{ commit.remote.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.remote.pr_number }}))\ + {% endif %} \ + ([{{ commit.id | truncate(length=7, end="") }}]({{ self::remote_url() }}/commit/{{ commit.id }}))\ +{%- endfor %} +{% endfor %} +""" +footer = "" +trim = true + +[git] +conventional_commits = true +filter_unconventional = false +split_commits = false +commit_parsers = [ + # Skip merge commits, dependabot CI bumps, changelog updates, and version bumps + { message = "^Merge pull request", skip = true }, + { message = "^Merge branch", skip = true }, + { message = "^ci\\(deps\\)", skip = true }, + { message = "^ci:", skip = true }, + { message = "^docs\\(CHANGELOG\\)", skip = true }, + { message = "^v\\d+\\.\\d+", skip = true }, + + # Standard conventional commits + { message = "^feat", group = "Features" }, + { message = "^fix", group = "Bug Fixes" }, + { message = "^doc", group = "Documentation" }, + { message = "^refactor", group = "Refactor" }, + { message = "^perf", group = "Performance" }, + { message = "^style", group = "Styling" }, + { message = "^test", group = "Testing" }, + + # Catch-all + { message = ".*", group = "Other" }, +] +filter_commits = false +tag_pattern = "v[0-9].*" +skip_tags = "" +ignore_tags = "" +topo_order = false +sort_commits = "newest" diff --git a/{{ cookiecutter.package_name }}/pyproject.toml b/{{ cookiecutter.package_name }}/pyproject.toml index 26c5080..e19786d 100644 --- a/{{ cookiecutter.package_name }}/pyproject.toml +++ b/{{ cookiecutter.package_name }}/pyproject.toml @@ -53,6 +53,7 @@ build-backend = "uv_build" [dependency-groups] dev = [ + "git-cliff>=2.0.0", "poethepoet", "pytest", "pytest-cov", @@ -110,6 +111,18 @@ qc.help = "[Code Quality] Run all code quality tasks." ## update version in pyproject +_preflight.shell = """ +if [ "$(git branch --show-current)" != "main" ]; then + echo "ERROR: publish must run from main branch" >&2 + exit 1 +fi +if [ -n "$(git status --porcelain)" ]; then + echo "ERROR: working tree is dirty" >&2 + exit 1 +fi +""" +_preflight.help = "Verify on main branch with clean working tree." + _patch_bump = "uv version --bump patch" _minor_bump = "uv version --bump minor" _major_bump = "uv version --bump major" @@ -127,13 +140,13 @@ _update_pyproject = ["_add", "_commit", "_tag", "_push_tags", "_push"] ## Publish patch, minor or major releases ## See .github/workflows/release.yaml -publish_patch.sequence = ["_patch_bump", "_update_pyproject"] +publish_patch.sequence = ["_preflight", "_patch_bump", "_update_pyproject"] publish_patch.help = "[Publish] Patch release." -publish_minor.sequence = ["_minor_bump", "_update_pyproject"] +publish_minor.sequence = ["_preflight", "_minor_bump", "_update_pyproject"] publish_minor.help = "[Publish] Minor release." -publish_major.sequence = ["_major_bump", "_update_pyproject"] +publish_major.sequence = ["_preflight", "_major_bump", "_update_pyproject"] publish_major.help = "[Publish] Major release." publish.ref = "publish_patch" @@ -141,11 +154,11 @@ publish.help = "[Publish] Patch release." # Release tasks -changelog.shell = "git log --pretty=format:'- %s (%h)' $(git describe --tags --abbrev=0 2>/dev/null || echo 'HEAD')..HEAD" -changelog.help = "[Release] Generate changelog since last tag." +changelog.cmd = "git cliff" +changelog.help = "[Release] Generate full changelog to stdout." -release-notes.shell = "echo '## Release Notes' > RELEASE_NOTES.md && echo '' >> RELEASE_NOTES.md && poe changelog >> RELEASE_NOTES.md" -release-notes.help = "[Release] Generate release notes file." +release-notes.cmd = "git cliff --latest --strip header" +release-notes.help = "[Release] Generate release notes for the latest tag." # Clean