Skip to content
Merged
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
fd4bf4c
[feature] Generate CHANGES.rst automatically #496
Dhanus3133 Jul 28, 2025
001fef8
[feat] Added git-cliff config
Dhanus3133 Aug 5, 2025
1ecdcec
[chores] Improve `commit_parsers` regex
Dhanus3133 Aug 5, 2025
5e56828
[chore] Add python script to handle dependencies section
Dhanus3133 Aug 6, 2025
6090373
[tests] Add basic changelog tests
Dhanus3133 Aug 7, 2025
ec3e8a5
[fix] QA
Dhanus3133 Aug 7, 2025
3085f14
[chore] Review changes
Dhanus3133 Aug 8, 2025
c6c9469
[change] Add git-cliff package
Dhanus3133 Aug 8, 2025
a45bf94
[fix] Tests
Dhanus3133 Aug 8, 2025
8e5ca3c
[tests] Refactor tests
Dhanus3133 Aug 8, 2025
fbf436a
[tests] Update test_generate_changelog
Dhanus3133 Aug 10, 2025
84ce3a7
[feature] Releaser script
Dhanus3133 Aug 11, 2025
4537c42
[tests] Add dummy token
Dhanus3133 Aug 11, 2025
092eb2e
[chore] All review changes
Dhanus3133 Aug 12, 2025
d07ae02
[fix] Lower case request type for retryable_request
Dhanus3133 Aug 13, 2025
6beab1e
[fix] Re-read changelog from file to use user's edits for release and…
Dhanus3133 Aug 13, 2025
cac8943
[fix] QA
Dhanus3133 Aug 13, 2025
33bb14b
[chore] Revert back the request method
Dhanus3133 Aug 13, 2025
9a6c45c
[chore] Discussed changes
Dhanus3133 Aug 14, 2025
823590d
[chores] Fixed minor f var string interpolation bug
nemesifier Aug 18, 2025
a590e94
[chore] Improved test coverage and review changes
Dhanus3133 Aug 19, 2025
ef5ec84
[feature] Handle request errors
Dhanus3133 Aug 19, 2025
e09c2ef
[tests] Support multi-line commit messages in changelog tests
Dhanus3133 Aug 20, 2025
f318a65
[docs] Update docs
Dhanus3133 Aug 20, 2025
85ad6ac
[chore] Improvements
Dhanus3133 Aug 20, 2025
c89eabf
[fix] QA checks
Dhanus3133 Aug 20, 2025
5d233d4
[docs] Update terms
Dhanus3133 Aug 21, 2025
00a9816
[feature] Add interactive recovery for docstrfmt errors
Dhanus3133 Aug 30, 2025
ab9ba29
[fix] Handle Version header missing on some modules
Dhanus3133 Aug 30, 2025
94fccdb
Merge branch 'master' into feat/496-changes-gen
Dhanus3133 Sep 12, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ jobs:
run: |
pip install -U pip wheel setuptools
pip install -U -r requirements-test.txt
pip install -e .[qa,rest,selenium]
pip install -e .[qa,rest,selenium,releaser]
pip install ${{ matrix.django-version }}
sudo npm install -g prettier

Expand Down
1 change: 1 addition & 0 deletions docs/developer/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Developer Docs
./test-utilities.rst
./other-utilities.rst
./reusable-github-utils.rst
./releaser-tool.rst

Other useful resources:

Expand Down
112 changes: 112 additions & 0 deletions docs/developer/releaser-tool.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
The Releaser Tool
=================

.. include:: ../partials/developer-docs.rst

This interactive command-line tool streamlines the entire project release
workflow, from generating a change log to creating a draft release on
GitHub. It is designed to be resilient, allowing you to recover from
common failures like network errors without starting over.

Prerequisites
-------------

**1. Installation**
~~~~~~~~~~~~~~~~~~~

Install the releaser and all its Python dependencies from the root of the
``openwisp-utils`` repository:

.. code-block:: shell

pip install .[releaser]

**2. GitHub Personal Access Token**
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

The tool requires a GitHub Fine-grained Personal Access Token to create
pull requests, tags, and releases on your behalf.

1. Navigate to **Settings** > **Developer settings** > **Personal access
tokens** > **Fine-grained tokens**.
2. Click **Generate new token**.
3. Give it a descriptive name (e.g., "OpenWISP Releaser") and set an
expiration date.
4. Under **Repository access**, choose either **All repositories** or
select the specific repositories you want to manage.
5. Under **Permissions**, click on **Add permissions**.
6. Grant the following permissions:

- **Metadata**: Read-only
- **Pull requests**: Read & write

7. Generate the token and export it as an environment variable:

.. code-block:: shell

export OW_GITHUB_TOKEN="github_pat_YourTokenGoesHere"

.. image:: https://raw.githubusercontent.com/openwisp/openwisp-utils/media/docs/releaser/github-access-token.png
:alt: Screenshot showing the required repository permissions for a new fine-grained GitHub Personal Access Token.

**3. OpenAI API Token (Optional)**
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

The tool can use GPT-4o to generate a human-readable summary of your
change log. If you wish to use this feature, export your OpenAI API key:

.. code-block:: shell

export OPENAI_CHATGPT_TOKEN="sk-YourOpenAITokenGoesHere"

Usage
-----

Navigate to the root directory of the project you want to release and run
the following command:

.. code-block:: shell

python -m openwisp_utils.releaser

The Interactive Workflow
------------------------

The tool will guide you through each step. Here are the key interactions:

**1. Version Confirmation** The tool will detect the current version and
suggest the next one. You can either accept the suggestion or enter a
different version manually.

.. image:: https://raw.githubusercontent.com/openwisp/openwisp-utils/media/docs/releaser/version-confirmation.png
:alt: Screenshot showing the tool suggesting a new version number and asking for user confirmation.

**2. Change Log Generation & Review** A changelog is generated from your
recent commits. If an OpenAI token is configured, the tool will offer to
generate a more readable summary. You will then be shown the final
changelog block and asked to accept it before the files are modified.

**3. Resilient Error Handling** If any network operation fails (e.g.,
creating a pull request), the tool won't crash. Instead, it will prompt
you to **Retry**, **Skip** the step (with manual instructions), or
**Abort**.

.. image:: https://raw.githubusercontent.com/openwisp/openwisp-utils/media/docs/releaser/error-handling.png
:alt: Screenshot of the interactive prompt after a network error, showing Retry, Skip, and Abort options.

Summary of Automated Steps
--------------------------

Once you confirm the change log, the tool automates the rest of the
process:

1. Updates the version number in your project's ``__init__.py``.
2. Writes the new release notes to your ``CHANGES.rst`` or ``CHANGES.md``
file.
3. Creates a ``release/<version>`` branch and commits the changes.
4. Pushes the new branch to GitHub.
5. Creates a pull request and waits for you to merge it.
6. Once merged, it creates and pushes a signed git tag.
7. Finally, it creates a draft release on GitHub with the changelog notes.
8. If releasing a bugfix, it offers to port the changelog to the ``main``
or ``master`` branch.
113 changes: 113 additions & 0 deletions openwisp_utils/cliff.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
[changelog]
# template for the changelog body
# https://keats.github.io/tera/docs/#introduction
body = """
{% if version %}\
Version {{ version | trim_start_matches(pat="v") }} [{{ timestamp | date(format="%Y-%m-%d") }}]
-------------------------------------
{% else %}\
[unreleased]
------------
{% endif %}\
{#- Features block #}
{% for group, commits in commits | group_by(attribute="group") %}
{% set clean_group = group | striptags | trim %}
{%- if clean_group == "Features" -%}
{{ clean_group | upper_first }}
~~~~~~~~
{% for commit in commits %}
- {{ commit.message | split(pat="\n") | first | split(pat="]") | nth(n=1) | trim | upper_first }}
{%- endfor -%}
{%- endif -%}
{%- endfor -%}
{#- Changes block -#}
{% for group, commits in commits | group_by(attribute="group") %}
{% set clean_group = group | striptags | trim -%}
{% if clean_group == "Backward-incompatible changes" or clean_group == "Other changes" or clean_group == "Dependencies" -%}
Changes
~~~~~~~
{% break %}
{% endif -%}
{% endfor %}
{%- for group, commits in commits | group_by(attribute="group") %}
{% set clean_group = group | striptags | trim -%}
{%- if clean_group == "Backward-incompatible changes" -%}
{{ clean_group | upper_first }}
+++++++++++++++++++++++++++++++++
{% for commit in commits %}
- {{ commit.message | split(pat="\n") | first | split(pat="]") | nth(n=1) | trim | upper_first }}
{%- endfor -%}
{%- endif -%}
{%- endfor -%}
{% for group, commits in commits | group_by(attribute="group") %}
{% set clean_group = group | striptags | trim %}
{%- if clean_group == "Other changes" -%}
{{ clean_group | upper_first }}
+++++++++++++
{% for commit in commits %}
- {{ commit.message | split(pat="\n") | first | split(pat="]") | nth(n=1) | trim | upper_first }}
{%- endfor -%}
{% endif %}
{%- endfor -%}
{% for group, commits in commits | group_by(attribute="group") %}
{%- set clean_group = group | striptags | trim -%}
{%- if clean_group == "Dependencies" -%}
{{ clean_group | upper_first }}
++++++++++++
{% for commit in commits %}
- {{ commit.message | split(pat="\n") | first | split(pat="]") | nth(n=1) | trim | upper_first }}
{%- endfor -%}
{%- endif -%}
{%- endfor -%}
{#- Bugfixes block #}
{%- for group, commits in commits | group_by(attribute="group") -%}
{% set clean_group = group | striptags | trim %}
{% if clean_group == "Bugfixes" %}
{{ clean_group | upper_first }}
~~~~~~~~
{% for commit in commits %}
- {{ commit.message | split(pat="\n") | first | split(pat="]") | nth(n=1) | trim | upper_first }}
{%- endfor -%}
{% endif %}
{% endfor %}
"""

# remove the leading and trailing space
trim = true

[git]
# parse the commits based on https://www.conventionalcommits.org
conventional_commits = false
# filter out the commits that are not conventional
filter_unconventional = false
# process each line of a commit as an individual commit
split_commits = false
# an array of regex based parsers to modify commit messages prior to further processing.
commit_preprocessors = [
{ pattern = '#([0-9]+)', replace = '`#$1 <https://github.com/#REPO#/issues/$1>`_' },
]

# regex for parsing and grouping commits
commit_parsers = [
# Any commits with [skip changelog] in their message will be ignored.
{ message = "\\[skip changelog\\]", skip = true },
# Regex for features, e.g., '[feature]' or '[feature:api]'
{ message = "^(\\[feature(:[a-zA-Z0-9_-]+)?\\])", group = "<!-- 0 -->Features", strip = "message" },
# Regex for backward-incompatible changes, e.g., '[change!]' or '[change:db!]'
{ message = "^(\\[change(:[a-zA-Z0-9_-]+)?!\\])", group = "<!-- 1 -->Backward-incompatible changes", strip = "message" },
# Regex for regular changes, e.g., '[change]' or '[change:ui]'
{ message = "^(\\[change(:[a-zA-Z0-9_-]+)?\\])", group = "<!-- 2 -->Other changes", strip = "message" },
# Regex for dependency updates, e.g., '[deps]' or '[deps:npm]'
{ message = "^(\\[deps(:[a-zA-Z0-9_-]+)?\\])", group = "<!-- 3 -->Dependencies", strip = "message" },
# Regex for bugfixes, e.g., '[fix]' or '[fix:tests]'
{ message = "^(\\[fix(:[a-zA-Z0-9_-]+)?\\])", group = "<!-- 4 -->Bugfixes", strip = "message" },
]

# protect breaking changes from being skipped due to matching a skipping commit_parser
protect_breaking_commits = false
# filter out the commits that are not matched by commit parsers
filter_commits = false
# sort the tags topologically
topo_order = false
# sort the commits inside sections by oldest/newest order
sort_commits = "newest"
22 changes: 22 additions & 0 deletions openwisp_utils/releaser/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import subprocess
import sys

import requests
from openwisp_utils.releaser.release import main

if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\n\n❌ Release process terminated by user.")
sys.exit(1)
except (
subprocess.CalledProcessError,
requests.RequestException,
RuntimeError,
FileNotFoundError,
) as e:
print(f"\n❌ An error occurred: {e}", file=sys.stderr)
if isinstance(e, subprocess.CalledProcessError):
print(f"Error Details: {e.stderr}", file=sys.stderr)
sys.exit(1)
Loading
Loading