Files
gef/scripts/new-release.py
T
crazy hugsy 90cced847b [Maintenance] Remove unmaintained commands, tests, and documentation, and moved to Ruff (#1222)
## Description

This PR  
 - removes unmaintained `shellcode` commands, tests, and documentation
- (finally) switches to Ruff instead of Pylint (still handled by
`pre-commit`)
 - fixes a few ruff/ty-found issues
2026-05-02 12:15:26 -07:00

211 lines
6.2 KiB
Python

#!/usr/bin/env python3
"""
Small script to generate the changelog for a new release. It uses information from
both git and Github to create the changelog in Markdown, which can be simply copy/pasted
to the Github release page.
The script requires a Github token to be set in the environment variable `GITHUB_REPO_TOKEN`.
As the name implies, only scope `repo` is required for the token (which can be generated
from https://github.com/settings/tokens/new).
"""
import argparse
import datetime
import os
import pathlib
import subprocess
import tempfile
import requests
__author__ = "@_hugsy_"
__version__ = 0.1
__licence__ = "MIT"
__file__ = "new-release.py"
__desc__ = "Generate a new release for a Github project."
__usage__ = f"""{__file__} v{__version__}\nby {__author__} under {__licence__}\nsyntax: {__file__} [options] args"""
REPOSITORY = "hugsy/gef"
GITHUB_TOKEN = os.getenv("GITHUB_REPO_TOKEN")
DEBUG = False
OUTPUT_FILE = pathlib.Path(tempfile.gettempdir()) / "CHANGELOG.md"
GITHUB_NEW_TOKEN_URL = "https://github.com/settings/personal-access-tokens/new"
def dbg(x: str):
if DEBUG:
print(x)
return
def shell(x: str) -> str:
dbg(f" executing: {x}")
return subprocess.check_output(x, shell=True).strip().decode("utf8")
def generate_changelog(args: argparse.Namespace) -> bool:
"""Generate the changelog for the new release."""
latest_tag = shell("git describe --tags --abbrev=0")
print(f"Creating changelog for {args.version} in {args.output_file.name}")
content = f"# Changelog: {args.version} - {args.codename}{os.linesep}{os.linesep}"
dbg("Adding commit summary...")
content += f"## Highlights of `{args.codename}`{os.linesep}{os.linesep}"
content += f"{os.linesep}{os.linesep}"
dbg("Adding contributor summary...")
content += f"## Contributors{os.linesep}{os.linesep}"
contributor_names = shell(
f"git log {latest_tag}..HEAD --pretty=format:'%aN' | sort -u"
).splitlines()
commits = {}
for author in contributor_names:
author_commits = shell(
f'git log {latest_tag}..HEAD --pretty=format:"%h" --author="{author}"'
).splitlines()
commits[author] = len(author_commits)
total_commits = sum(commits.values())
content += f"| Author | Number of commits | {os.linesep}"
content += f"|:--|--:| {os.linesep}"
commits_sorted = dict(sorted(commits.items(), key=lambda item: -item[1]))
for author in commits_sorted:
content += f"| {author} | {commits[author]}|{os.linesep}"
content += f"{os.linesep}{os.linesep}"
dbg("Adding Github info...")
url = f"https://api.github.com/repos/{args.repository}/issues?state=closed&milestone.title=Release%3a%20{args.version}"
js = requests.get(url, headers={"Authorization": f"token {args.token}"}).json()
prs = {x["number"]: x["html_url"] for x in js if "pull" in x["html_url"]}
issues = {x["number"]: x["html_url"] for x in js if "issues" in x["html_url"]}
closed_prs_item = " • ".join([f" [{nb}]({url}) " for nb, url in prs.items()])
closed_issues_item = " • ".join(
[f" [{nb}]({url}) " for nb, url in issues.items()]
)
content += f"""
## Closed Issues
* {len(issues)} issues closed ({closed_issues_item})
## Closed Pull Requests
* {len(prs)} PRs closed ({closed_prs_item})
"""
dbg("Adding commit summary...")
log = shell(
f"""git log {latest_tag}..HEAD --pretty=format:' * %cs [%h](https://github.com/{args.repository}/commit/%H) • *%aN* • %s ' --reverse"""
)
diff = shell(f"""git diff --no-color --stat {latest_tag}..HEAD""")
content += f"""
## Commit details
<details>
<summary>
{total_commits} commits since <b>{latest_tag}</b>
</summary>
### Commit log
{log}
### File diff
```diff
{diff}
```
</details>
"""
args.output_file.write_text(content)
print(f"Done, the changelog file was written to `{args.output_file.name}`")
if args.push_release:
shell(
f"""git tag --annotate "{args.version}" --message "Release {args.version} - {args.codename}" --sign"""
)
shell(f"""git push origin "{args.version}" """)
return True
def test_token(args) -> bool:
url = f"https://api.github.com/repos/{args.repository}/issues"
req = requests.get(url, headers={"Authorization": f"token {args.token}"})
if req.status_code != 200:
print(f"test_token(): return HTTP code {req.status_code}")
print(
f"Get a token from {GITHUB_NEW_TOKEN_URL} with 'Read-Only' privilege on Issues for the {args.repository} repository"
)
return req.status_code == 200
if __name__ == "__main__":
parser = argparse.ArgumentParser(
usage=__usage__, description=__desc__, prog=__file__
)
parser.add_argument(
"--debug", action="store_true", default=DEBUG, help="Enable debug output"
)
parser.add_argument(
"-r",
"--repository",
type=str,
default=REPOSITORY,
help="Specify the repository (default: '%(default)s')",
)
parser.add_argument(
"-t",
"--token",
dest="token",
type=str,
metavar="TOKEN",
default=GITHUB_TOKEN,
help="Specify the Github token to use (requires `repo` scope)",
)
parser.add_argument(
"-o",
"--output-file",
type=pathlib.Path,
default=OUTPUT_FILE.absolute(),
metavar="/path/to/output_file.md",
help=f"Specify the output file (default: '{OUTPUT_FILE}')",
)
parser.add_argument(
"--version",
type=str,
default=datetime.date.today().strftime("%Y.%m"),
help="Specify the version number (default: '%(default)s')",
)
parser.add_argument(
"--codename",
type=str,
default="GENERATE_ME",
help="Specify the version codename (default: '%(default)s')",
)
parser.add_argument(
"--push-release",
action="store_true",
default=False,
help="Create the new tag and publish the release on Github",
)
args = parser.parse_args()
DEBUG = args.debug
if args.codename == "GENERATE_ME":
args.codename = shell("random-word").title()
if not test_token(args):
dbg("The GITHUB_TOKEN is not valid")
exit(1)
generate_changelog(args)
exit(0)