Skip to content

Commit ef70d79

Browse files
[feature] Added guided release tool #496
Added a tool to assist OpenWISP maintainers in publishing releases by automating manual steps. Closes #496 --------- Co-authored-by: Federico Capoano <[email protected]>
1 parent f95b1ae commit ef70d79

24 files changed

+3321
-1
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ jobs:
7676
run: |
7777
pip install -U pip wheel setuptools
7878
pip install -U -r requirements-test.txt
79-
pip install -e .[qa,rest,selenium]
79+
pip install -e .[qa,rest,selenium,releaser]
8080
pip install ${{ matrix.django-version }}
8181
sudo npm install -g prettier
8282

docs/developer/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ Developer Docs
1717
./test-utilities.rst
1818
./other-utilities.rst
1919
./reusable-github-utils.rst
20+
./releaser-tool.rst
2021

2122
Other useful resources:
2223

docs/developer/releaser-tool.rst

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
The Releaser Tool
2+
=================
3+
4+
.. include:: ../partials/developer-docs.rst
5+
6+
This interactive command-line tool streamlines the entire project release
7+
workflow, from generating a change log to creating a draft release on
8+
GitHub. It is designed to be resilient, allowing you to recover from
9+
common failures like network errors without starting over.
10+
11+
Prerequisites
12+
-------------
13+
14+
**1. Installation**
15+
~~~~~~~~~~~~~~~~~~~
16+
17+
Install the releaser and all its Python dependencies from the root of the
18+
``openwisp-utils`` repository:
19+
20+
.. code-block:: shell
21+
22+
pip install .[releaser]
23+
24+
**2. GitHub Personal Access Token**
25+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
26+
27+
The tool requires a GitHub Fine-grained Personal Access Token to create
28+
pull requests, tags, and releases on your behalf.
29+
30+
1. Navigate to **Settings** > **Developer settings** > **Personal access
31+
tokens** > **Fine-grained tokens**.
32+
2. Click **Generate new token**.
33+
3. Give it a descriptive name (e.g., "OpenWISP Releaser") and set an
34+
expiration date.
35+
4. Under **Repository access**, choose either **All repositories** or
36+
select the specific repositories you want to manage.
37+
5. Under **Permissions**, click on **Add permissions**.
38+
6. Grant the following permissions:
39+
40+
- **Metadata**: Read-only
41+
- **Pull requests**: Read & write
42+
43+
7. Generate the token and export it as an environment variable:
44+
45+
.. code-block:: shell
46+
47+
export OW_GITHUB_TOKEN="github_pat_YourTokenGoesHere"
48+
49+
.. image:: https://raw.githubusercontent.com/openwisp/openwisp-utils/media/docs/releaser/github-access-token.png
50+
:alt: Screenshot showing the required repository permissions for a new fine-grained GitHub Personal Access Token.
51+
52+
**3. OpenAI API Token (Optional)**
53+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
54+
55+
The tool can use GPT-4o to generate a human-readable summary of your
56+
change log. If you wish to use this feature, export your OpenAI API key:
57+
58+
.. code-block:: shell
59+
60+
export OPENAI_CHATGPT_TOKEN="sk-YourOpenAITokenGoesHere"
61+
62+
Usage
63+
-----
64+
65+
Navigate to the root directory of the project you want to release and run
66+
the following command:
67+
68+
.. code-block:: shell
69+
70+
python -m openwisp_utils.releaser
71+
72+
The Interactive Workflow
73+
------------------------
74+
75+
The tool will guide you through each step. Here are the key interactions:
76+
77+
**1. Version Confirmation** The tool will detect the current version and
78+
suggest the next one. You can either accept the suggestion or enter a
79+
different version manually.
80+
81+
.. image:: https://raw.githubusercontent.com/openwisp/openwisp-utils/media/docs/releaser/version-confirmation.png
82+
:alt: Screenshot showing the tool suggesting a new version number and asking for user confirmation.
83+
84+
**2. Change Log Generation & Review** A changelog is generated from your
85+
recent commits. If an OpenAI token is configured, the tool will offer to
86+
generate a more readable summary. You will then be shown the final
87+
changelog block and asked to accept it before the files are modified.
88+
89+
**3. Resilient Error Handling** If any network operation fails (e.g.,
90+
creating a pull request), the tool won't crash. Instead, it will prompt
91+
you to **Retry**, **Skip** the step (with manual instructions), or
92+
**Abort**.
93+
94+
.. image:: https://raw.githubusercontent.com/openwisp/openwisp-utils/media/docs/releaser/error-handling.png
95+
:alt: Screenshot of the interactive prompt after a network error, showing Retry, Skip, and Abort options.
96+
97+
Summary of Automated Steps
98+
--------------------------
99+
100+
Once you confirm the change log, the tool automates the rest of the
101+
process:
102+
103+
1. Updates the version number in your project's ``__init__.py``.
104+
2. Writes the new release notes to your ``CHANGES.rst`` or ``CHANGES.md``
105+
file.
106+
3. Creates a ``release/<version>`` branch and commits the changes.
107+
4. Pushes the new branch to GitHub.
108+
5. Creates a pull request and waits for you to merge it.
109+
6. Once merged, it creates and pushes a signed git tag.
110+
7. Finally, it creates a draft release on GitHub with the changelog notes.
111+
8. If releasing a bugfix, it offers to port the changelog to the ``main``
112+
or ``master`` branch.

openwisp_utils/cliff.toml

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
[changelog]
2+
# template for the changelog body
3+
# https://keats.github.io/tera/docs/#introduction
4+
body = """
5+
{% if version %}\
6+
Version {{ version | trim_start_matches(pat="v") }} [{{ timestamp | date(format="%Y-%m-%d") }}]
7+
-------------------------------------
8+
{% else %}\
9+
[unreleased]
10+
------------
11+
{% endif %}\
12+
{#- Features block #}
13+
{% for group, commits in commits | group_by(attribute="group") %}
14+
{% set clean_group = group | striptags | trim %}
15+
{%- if clean_group == "Features" -%}
16+
{{ clean_group | upper_first }}
17+
~~~~~~~~
18+
{% for commit in commits %}
19+
- {{ commit.message | split(pat="\n") | first | split(pat="]") | nth(n=1) | trim | upper_first }}
20+
{%- endfor -%}
21+
{%- endif -%}
22+
{%- endfor -%}
23+
{#- Changes block -#}
24+
{% for group, commits in commits | group_by(attribute="group") %}
25+
{% set clean_group = group | striptags | trim -%}
26+
{% if clean_group == "Backward-incompatible changes" or clean_group == "Other changes" or clean_group == "Dependencies" -%}
27+
Changes
28+
~~~~~~~
29+
{% break %}
30+
{% endif -%}
31+
{% endfor %}
32+
{%- for group, commits in commits | group_by(attribute="group") %}
33+
{% set clean_group = group | striptags | trim -%}
34+
{%- if clean_group == "Backward-incompatible changes" -%}
35+
{{ clean_group | upper_first }}
36+
+++++++++++++++++++++++++++++++++
37+
{% for commit in commits %}
38+
- {{ commit.message | split(pat="\n") | first | split(pat="]") | nth(n=1) | trim | upper_first }}
39+
{%- endfor -%}
40+
{%- endif -%}
41+
{%- endfor -%}
42+
{% for group, commits in commits | group_by(attribute="group") %}
43+
{% set clean_group = group | striptags | trim %}
44+
{%- if clean_group == "Other changes" -%}
45+
{{ clean_group | upper_first }}
46+
+++++++++++++
47+
{% for commit in commits %}
48+
- {{ commit.message | split(pat="\n") | first | split(pat="]") | nth(n=1) | trim | upper_first }}
49+
{%- endfor -%}
50+
{% endif %}
51+
{%- endfor -%}
52+
{% for group, commits in commits | group_by(attribute="group") %}
53+
{%- set clean_group = group | striptags | trim -%}
54+
{%- if clean_group == "Dependencies" -%}
55+
{{ clean_group | upper_first }}
56+
++++++++++++
57+
{% for commit in commits %}
58+
- {{ commit.message | split(pat="\n") | first | split(pat="]") | nth(n=1) | trim | upper_first }}
59+
{%- endfor -%}
60+
{%- endif -%}
61+
{%- endfor -%}
62+
{#- Bugfixes block #}
63+
{%- for group, commits in commits | group_by(attribute="group") -%}
64+
{% set clean_group = group | striptags | trim %}
65+
{% if clean_group == "Bugfixes" %}
66+
{{ clean_group | upper_first }}
67+
~~~~~~~~
68+
{% for commit in commits %}
69+
- {{ commit.message | split(pat="\n") | first | split(pat="]") | nth(n=1) | trim | upper_first }}
70+
{%- endfor -%}
71+
{% endif %}
72+
{% endfor %}
73+
"""
74+
75+
# remove the leading and trailing space
76+
trim = true
77+
78+
[git]
79+
# parse the commits based on https://www.conventionalcommits.org
80+
conventional_commits = false
81+
# filter out the commits that are not conventional
82+
filter_unconventional = false
83+
# process each line of a commit as an individual commit
84+
split_commits = false
85+
# an array of regex based parsers to modify commit messages prior to further processing.
86+
commit_preprocessors = [
87+
{ pattern = '#([0-9]+)', replace = '`#$1 <https://github.com/#REPO#/issues/$1>`_' },
88+
]
89+
90+
# regex for parsing and grouping commits
91+
commit_parsers = [
92+
# Any commits with [skip changelog] in their message will be ignored.
93+
{ message = "\\[skip changelog\\]", skip = true },
94+
# Regex for features, e.g., '[feature]' or '[feature:api]'
95+
{ message = "^(\\[feature(:[a-zA-Z0-9_-]+)?\\])", group = "<!-- 0 -->Features", strip = "message" },
96+
# Regex for backward-incompatible changes, e.g., '[change!]' or '[change:db!]'
97+
{ message = "^(\\[change(:[a-zA-Z0-9_-]+)?!\\])", group = "<!-- 1 -->Backward-incompatible changes", strip = "message" },
98+
# Regex for regular changes, e.g., '[change]' or '[change:ui]'
99+
{ message = "^(\\[change(:[a-zA-Z0-9_-]+)?\\])", group = "<!-- 2 -->Other changes", strip = "message" },
100+
# Regex for dependency updates, e.g., '[deps]' or '[deps:npm]'
101+
{ message = "^(\\[deps(:[a-zA-Z0-9_-]+)?\\])", group = "<!-- 3 -->Dependencies", strip = "message" },
102+
# Regex for bugfixes, e.g., '[fix]' or '[fix:tests]'
103+
{ message = "^(\\[fix(:[a-zA-Z0-9_-]+)?\\])", group = "<!-- 4 -->Bugfixes", strip = "message" },
104+
]
105+
106+
# protect breaking changes from being skipped due to matching a skipping commit_parser
107+
protect_breaking_commits = false
108+
# filter out the commits that are not matched by commit parsers
109+
filter_commits = false
110+
# sort the tags topologically
111+
topo_order = false
112+
# sort the commits inside sections by oldest/newest order
113+
sort_commits = "newest"
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import subprocess
2+
import sys
3+
4+
import requests
5+
from openwisp_utils.releaser.release import main
6+
7+
if __name__ == "__main__":
8+
try:
9+
main()
10+
except KeyboardInterrupt:
11+
print("\n\n❌ Release process terminated by user.")
12+
sys.exit(1)
13+
except (
14+
subprocess.CalledProcessError,
15+
requests.RequestException,
16+
RuntimeError,
17+
FileNotFoundError,
18+
) as e:
19+
print(f"\n❌ An error occurred: {e}", file=sys.stderr)
20+
if isinstance(e, subprocess.CalledProcessError):
21+
print(f"Error Details: {e.stderr}", file=sys.stderr)
22+
sys.exit(1)

0 commit comments

Comments
 (0)