Adds a changelog workflow (#3819)
* Adds the changelog generation workflow * Allow changelog output directory in secrets * Missing S3_BUILD_DIRECTORY var definition * Improve scrpt and fix automatic version backtrack issues * Improvements in the changelog script * Set changelog.py to use git merge-base --octopus * Update workflow and remove uneeded scripts
This commit is contained in:
parent
ec5a6fcc42
commit
73ab4abb9d
2 changed files with 249 additions and 43 deletions
65
.github/workflows/changelog.yml
vendored
Normal file
65
.github/workflows/changelog.yml
vendored
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
name: Changelog
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
repo:
|
||||||
|
description: "Repository"
|
||||||
|
default: "nanocurrency/nano-node"
|
||||||
|
required: true
|
||||||
|
ref:
|
||||||
|
description: "Version (VX.Y tag)"
|
||||||
|
default: "VX.Y"
|
||||||
|
required: true
|
||||||
|
ref_start:
|
||||||
|
description: "Start (VX.Y tag)"
|
||||||
|
default: ""
|
||||||
|
required: false
|
||||||
|
mode:
|
||||||
|
description: "Find tag as start"
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- final
|
||||||
|
- beta
|
||||||
|
default: final
|
||||||
|
required: true
|
||||||
|
env:
|
||||||
|
artifact: 1
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
changelog_job:
|
||||||
|
runs-on: ubuntu-20.04
|
||||||
|
timeout-minutes: 90
|
||||||
|
steps:
|
||||||
|
- name: Sets the tag and repo variables (to build)
|
||||||
|
run: |
|
||||||
|
echo "TAG=${{ github.event.inputs.ref }}" >> $GITHUB_ENV
|
||||||
|
echo "REF_START=${{ github.event.inputs.ref_start }}" >> $GITHUB_ENV
|
||||||
|
echo "REPO_TO_RUN=${{ github.event.inputs.repo }}" >> $GITHUB_ENV
|
||||||
|
echo "MODE=${{ github.event.inputs.mode }}" >> $GITHUB_ENV
|
||||||
|
- name: Checks out the required workflow files (workflow repo)
|
||||||
|
uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 #v3.1.0
|
||||||
|
with:
|
||||||
|
ref: ${{ github.ref }}
|
||||||
|
repository: ${{ github.repository }}
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: sudo apt-get install -yqq uuid
|
||||||
|
- name: Setup Python 3.x
|
||||||
|
uses: actions/setup-python@13ae5bb136fac2878aff31522b9efb785519f984 #v4.3.0
|
||||||
|
with:
|
||||||
|
python-version: '3.x'
|
||||||
|
- name: Installs PIP Packages
|
||||||
|
run: |
|
||||||
|
pip install PyGithub
|
||||||
|
pip install mdutils
|
||||||
|
- name: Generating the CHANGELOG file (automatic interval)
|
||||||
|
if: ${{ github.event.inputs.ref_start == '' }}
|
||||||
|
run: python util/changelog.py -v --repo $REPO_TO_RUN -m $MODE -t $TAG -p "${{ secrets.GITHUB_TOKEN }}"
|
||||||
|
- name: Generating the CHANGELOG file (selected interval)
|
||||||
|
if: ${{ github.event.inputs.ref_start != '' }}
|
||||||
|
run: python util/changelog.py -v --repo $REPO_TO_RUN --start-tag $REF_START -t $TAG -p "${{ secrets.GITHUB_TOKEN }}"
|
||||||
|
- name: Upload the Changelog Artifact
|
||||||
|
uses: actions/upload-artifact@83fd05a356d7e2593de66fc9913b3002723633cb #v3.1.1
|
||||||
|
with:
|
||||||
|
name: CHANGELOG_${{ env.TAG }}.md
|
||||||
|
path: |
|
||||||
|
${{ github.workspace }}/CHANGELOG.md
|
||||||
|
|
@ -3,33 +3,39 @@ import copy
|
||||||
import sys
|
import sys
|
||||||
import re
|
import re
|
||||||
from typing import Tuple
|
from typing import Tuple
|
||||||
|
import subprocess
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Changelog generation script, requires PAT with public_repo access,
|
Changelog generation script, requires PAT with public_repo access,
|
||||||
see https://github.com/settings/tokens
|
see https://github.com/settings/tokens
|
||||||
|
|
||||||
usage: changelog [-h] [-e END] [-m {full,incremental}] -p PAT [-r REPO] [-s START] [-t TAG]
|
usage: changelog [-h] [-e END] [-m {final,beta}] -p PAT [-r REPO] [-s START] [-t TAG]
|
||||||
|
|
||||||
Generate Changelogs between tags or commits
|
Generate Changelogs between tags or commits
|
||||||
|
|
||||||
optional arguments:
|
optional arguments:
|
||||||
-h, --help show this help message and exit
|
-h, --help Show this help message and exit
|
||||||
-e END, --end END Ending reference for Changelog(newest)
|
-m {final,beta}, --mode {final,beta}
|
||||||
-m {full,incremental}, --mode {full,incremental}
|
Mode to run changelog for [final, beta]
|
||||||
Mode to run changelog for [full, incremental]
|
|
||||||
-p PAT, --pat PAT Personal Access Token
|
-p PAT, --pat PAT Personal Access Token
|
||||||
-r REPO, --repo REPO <org/repo> to generate logs for
|
-r REPO, --repo REPO <org/repo> to generate logs for
|
||||||
|
-e END, --end END Ending reference for Changelog(newest)
|
||||||
-s START, --start START
|
-s START, --start START
|
||||||
Starting reference for Changelog(oldest)
|
Starting reference for Changelog(oldest)
|
||||||
-t TAG, --tag TAG Tag to use for changelog generation
|
-t TAG, --tag TAG
|
||||||
|
Tag to use for changelog generation
|
||||||
|
--start-tag START_TAG
|
||||||
|
Tag to use as start reference (instead of -s)
|
||||||
|
-v, --verbose Verbose mode
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
full = re.compile(r"^(V(\d)+.(\d)+.?(\d)?)$")
|
final = re.compile(r"^(V(\d)+.(\d)+)$")
|
||||||
incremental = re.compile(r"^(V(\d)+.(\d)+.?(\d)?(RC(\d)+)?(DB(\d)+)?)$")
|
beta = re.compile(r"^(V(\d)+.(\d)+((RC(\d)+)|(DB(\d)+))?)$")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from github import Github, UnknownObjectException
|
from github import Github, UnknownObjectException
|
||||||
|
from github.Label import Label
|
||||||
from mdutils import MdUtils
|
from mdutils import MdUtils
|
||||||
except BaseException:
|
except BaseException:
|
||||||
sys.exit("Error: run 'pip install PyGithub mdutils'")
|
sys.exit("Error: run 'pip install PyGithub mdutils'")
|
||||||
|
|
@ -96,16 +102,15 @@ SECTIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class cliArgs():
|
class CliArgs:
|
||||||
def __init__(self) -> dict:
|
def __init__(self) -> dict:
|
||||||
|
|
||||||
changelog_choices = ["full", "incremental"]
|
changelog_choices = ["final", "beta"]
|
||||||
|
|
||||||
parse = argparse.ArgumentParser(
|
parse = argparse.ArgumentParser(
|
||||||
prog="changelog",
|
prog="changelog",
|
||||||
description="Generate Changelogs between tags or commits"
|
description="Generate Changelogs between tags or commits"
|
||||||
)
|
)
|
||||||
|
|
||||||
parse.add_argument(
|
parse.add_argument(
|
||||||
'-e', '--end',
|
'-e', '--end',
|
||||||
help="Ending reference for Changelog(newest)",
|
help="Ending reference for Changelog(newest)",
|
||||||
|
|
@ -113,9 +118,9 @@ class cliArgs():
|
||||||
)
|
)
|
||||||
parse.add_argument(
|
parse.add_argument(
|
||||||
"-m", "--mode",
|
"-m", "--mode",
|
||||||
help="Mode to run changelog for [full, incremental]",
|
help="Mode to run changelog for [final, beta]",
|
||||||
type=str, action="store",
|
type=str, action="store",
|
||||||
default="incremental",
|
default="beta",
|
||||||
choices=changelog_choices
|
choices=changelog_choices
|
||||||
)
|
)
|
||||||
parse.add_argument(
|
parse.add_argument(
|
||||||
|
|
@ -140,6 +145,17 @@ class cliArgs():
|
||||||
help="Tag to use for changelog generation",
|
help="Tag to use for changelog generation",
|
||||||
type=str, action="store"
|
type=str, action="store"
|
||||||
)
|
)
|
||||||
|
parse.add_argument(
|
||||||
|
'--start-tag',
|
||||||
|
dest='start_tag',
|
||||||
|
help="Tag for start reference",
|
||||||
|
type=str, action="store"
|
||||||
|
)
|
||||||
|
parse.add_argument(
|
||||||
|
'-v', '--verbose',
|
||||||
|
help="Verbose mode",
|
||||||
|
action="store_true"
|
||||||
|
)
|
||||||
options = parse.parse_args()
|
options = parse.parse_args()
|
||||||
self.end = options.end
|
self.end = options.end
|
||||||
self.mode = options.mode
|
self.mode = options.mode
|
||||||
|
|
@ -147,28 +163,73 @@ class cliArgs():
|
||||||
self.repo = options.repo.rstrip("/")
|
self.repo = options.repo.rstrip("/")
|
||||||
self.start = options.start
|
self.start = options.start
|
||||||
self.tag = options.tag
|
self.tag = options.tag
|
||||||
|
self.verbose = options.verbose
|
||||||
|
self.start_tag = options.start_tag
|
||||||
|
|
||||||
|
|
||||||
class generateTree:
|
def validate_sha(hash_value: str) -> bool:
|
||||||
|
if len(hash_value) != 40:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
sha_int = int(hash_value, 16)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class GenerateTree:
|
||||||
def __init__(self, args):
|
def __init__(self, args):
|
||||||
github = Github(args.pat)
|
github = Github(args.pat)
|
||||||
self.name = args.repo
|
self.name = args.repo
|
||||||
self.repo = github.get_repo(self.name)
|
self.repo = github.get_repo(self.name)
|
||||||
|
self.args = args
|
||||||
if args.tag:
|
if args.tag:
|
||||||
self.tag = args.tag
|
self.tag = args.tag
|
||||||
self.end = self.repo.get_commit(args.tag).sha
|
self.end = self.repo.get_commit(args.tag).sha
|
||||||
elif args.end:
|
if args.end:
|
||||||
|
print("error: set either --end or --tag")
|
||||||
|
exit(1)
|
||||||
|
if args.end:
|
||||||
|
if not validate_sha(args.end):
|
||||||
|
print("error: --end argument is not a valid hash")
|
||||||
|
exit(1)
|
||||||
self.end = self.repo.get_commit(args.end).sha
|
self.end = self.repo.get_commit(args.end).sha
|
||||||
else:
|
if not args.start:
|
||||||
print("need end or tag")
|
print("error: --end argument requires --start")
|
||||||
|
exit(1)
|
||||||
|
if not args.end and not args.tag:
|
||||||
|
print("error: need either --end or --tag")
|
||||||
|
exit(1)
|
||||||
|
if args.start and args.start_tag:
|
||||||
|
print("error: set either --start or --start-tag")
|
||||||
exit(1)
|
exit(1)
|
||||||
if args.start:
|
if args.start:
|
||||||
|
if not validate_sha(args.start):
|
||||||
|
print("error: --start argument is not a valid hash")
|
||||||
|
exit(1)
|
||||||
self.start = self.repo.get_commit(args.start).sha
|
self.start = self.repo.get_commit(args.start).sha
|
||||||
|
elif args.start_tag:
|
||||||
|
self.start = self.select_start_ref(args.start_tag)
|
||||||
else:
|
else:
|
||||||
self.start = self.get_common(args.mode)
|
assert args.tag
|
||||||
|
self.start = self.get_common_by_tag(args.mode)
|
||||||
|
|
||||||
self.commits = {}
|
self.commits = {}
|
||||||
self.other_commits = []
|
self.other_commits = []
|
||||||
|
self.excluded = []
|
||||||
commits = self.repo.get_commits(sha=self.end)
|
commits = self.repo.get_commits(sha=self.end)
|
||||||
|
|
||||||
|
# Check if the common ancestor exists in the commit list.
|
||||||
|
found_common_ancestor = False
|
||||||
|
for commit in commits:
|
||||||
|
if commit.sha == self.start:
|
||||||
|
found_common_ancestor = True
|
||||||
|
break
|
||||||
|
if not found_common_ancestor:
|
||||||
|
print("error: the common ancestor was not found")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
# Retrieve the complementary information for each commit.
|
||||||
for commit in commits:
|
for commit in commits:
|
||||||
if commit.sha == self.start:
|
if commit.sha == self.start:
|
||||||
break
|
break
|
||||||
|
|
@ -182,35 +243,114 @@ class generateTree:
|
||||||
pr_number = p[0].number
|
pr_number = p[0].number
|
||||||
pull = self.repo.get_pull(pr_number)
|
pull = self.repo.get_pull(pr_number)
|
||||||
else:
|
else:
|
||||||
print(
|
if args.verbose:
|
||||||
f"Commit has no associated PR {commit.sha}: \"{m}\"")
|
print(f"info: commit has no associated PR {commit.sha}: \"{m}\"")
|
||||||
self.other_commits.append((commit.sha, m))
|
self.other_commits.append((commit.sha, m))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if pull.state == 'open':
|
||||||
|
if args.verbose:
|
||||||
|
print(f"info: commit is in tree but only associated with open PR {pr_number}: \"{pull.title}\"")
|
||||||
|
self.other_commits.append((commit.sha, m))
|
||||||
|
continue
|
||||||
|
|
||||||
|
if self.excluded_from_changelog(pull.labels):
|
||||||
|
if args.verbose:
|
||||||
|
print(f"info: the PR {pr_number}: \"{pull.title}\" was excluded from the changelog")
|
||||||
|
self.excluded.append((commit.sha, m))
|
||||||
|
continue
|
||||||
|
|
||||||
labels = []
|
labels = []
|
||||||
for label in pull.labels:
|
for label in pull.labels:
|
||||||
labels.append(label.name)
|
labels.append(label.name)
|
||||||
|
|
||||||
self.commits[pull.number] = {
|
self.commits[pull.number] = {
|
||||||
"Title": pull.title,
|
"Title": pull.title,
|
||||||
"Url": pull.html_url,
|
"Url": pull.html_url,
|
||||||
"labels": labels
|
"labels": labels
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_common(self, mode) -> str:
|
@staticmethod
|
||||||
|
def excluded_from_changelog(labels: list[Label]) -> bool:
|
||||||
|
for label in labels:
|
||||||
|
if label.name == 'exclude from changelog':
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_common_ancestor(self, start_tag: str) -> str:
|
||||||
|
print("info: will look for the common ancestor by local git repo")
|
||||||
|
cmd = f'''
|
||||||
|
repo_path=/tmp/$(uuid)
|
||||||
|
(
|
||||||
|
mkdir -p "$repo_path"
|
||||||
|
if [[ ! -d $repo_path || ! -z "$(ls -A $repo_path)" ]]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
pushd "$repo_path"
|
||||||
|
git clone https://github.com/{self.name} .
|
||||||
|
develop_head=$(git show-ref -s origin/develop)
|
||||||
|
common_ancestor=$(git merge-base --octopus "$develop_head" "{start_tag}")
|
||||||
|
echo "$common_ancestor" > "$repo_path/output_file"
|
||||||
|
popd
|
||||||
|
) > /dev/null 2>&1
|
||||||
|
cat "$repo_path/output_file"
|
||||||
|
rm -rf "$repo_path"
|
||||||
|
'''
|
||||||
|
common_ancestor = subprocess.check_output(f"echo '{cmd}' | /bin/bash", shell=True, text=True).rstrip()
|
||||||
|
if self.args.verbose:
|
||||||
|
print("info: found common ancestor: " + common_ancestor)
|
||||||
|
return common_ancestor
|
||||||
|
|
||||||
|
def get_common_by_tag(self, mode) -> str:
|
||||||
tags = []
|
tags = []
|
||||||
|
found_end_tag = False
|
||||||
for tag in self.repo.get_tags():
|
for tag in self.repo.get_tags():
|
||||||
if mode == "full":
|
if not found_end_tag and tag.name == self.tag:
|
||||||
found = full.match(tag.name)
|
found_end_tag = True
|
||||||
|
if found_end_tag:
|
||||||
|
if mode == "final":
|
||||||
|
matched_tag = final.match(tag.name)
|
||||||
else:
|
else:
|
||||||
found = incremental.match(tag.name)
|
matched_tag = beta.match(tag.name)
|
||||||
if found:
|
if matched_tag:
|
||||||
tags.append(tag)
|
tags.append(tag)
|
||||||
tree = self.repo.compare(self.end, tags[1].commit.sha)
|
|
||||||
return tree.merge_base_commit.sha
|
if len(tags) < 2:
|
||||||
|
return None
|
||||||
|
|
||||||
|
selected_tag = None
|
||||||
|
if self.major_version_match(tags[0].name, self.tag):
|
||||||
|
selected_tag = tags[1]
|
||||||
|
else:
|
||||||
|
selected_tag = tags[0]
|
||||||
|
|
||||||
|
if self.args.verbose:
|
||||||
|
print(f"info: selected start tag {selected_tag.name}: {selected_tag.commit.sha}")
|
||||||
|
|
||||||
|
return self.select_start_ref(selected_tag.name)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def major_version_match(first_tag: str, second_tag: str) -> bool:
|
||||||
|
major_version_tag_pattern = r"(\d)+."
|
||||||
|
first_tag_major = re.search(major_version_tag_pattern, first_tag)
|
||||||
|
second_tag_major = re.search(major_version_tag_pattern, second_tag)
|
||||||
|
if first_tag_major and second_tag_major and first_tag_major.group(0) == second_tag_major.group(0):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def select_start_ref(self, start_tag: str) -> str:
|
||||||
|
if self.major_version_match(start_tag, self.tag):
|
||||||
|
start_commit = self.repo.get_commit(start_tag).sha
|
||||||
|
if self.args.verbose:
|
||||||
|
print(f"info: selected start tag {start_tag} (commit: {start_commit}) "
|
||||||
|
f"has the same major version of the end tag ({self.tag})")
|
||||||
|
return start_commit
|
||||||
|
|
||||||
|
return self.get_common_ancestor(start_tag)
|
||||||
|
|
||||||
|
|
||||||
class generateMarkdown():
|
class GenerateMarkdown:
|
||||||
def __init__(self, repo: generateTree):
|
def __init__(self, repo: GenerateTree):
|
||||||
self.mdFile = MdUtils(
|
self.mdFile = MdUtils(
|
||||||
file_name='CHANGELOG', title='CHANGELOG'
|
file_name='CHANGELOG', title='CHANGELOG'
|
||||||
)
|
)
|
||||||
|
|
@ -225,16 +365,16 @@ class generateMarkdown():
|
||||||
f"/compare/{repo.start}...{repo.end})", wrap_width=0)
|
f"/compare/{repo.start}...{repo.end})", wrap_width=0)
|
||||||
sort = self.pull_to_section(repo.commits)
|
sort = self.pull_to_section(repo.commits)
|
||||||
for section, prs in sort.items():
|
for section, prs in sort.items():
|
||||||
self.write_header_PR(section)
|
self.write_header_pr(section)
|
||||||
for pr in prs:
|
for pr in prs:
|
||||||
self.write_PR(pr, repo.commits[pr[0]])
|
self.write_pr(pr, repo.commits[pr[0]])
|
||||||
if repo.other_commits:
|
if repo.other_commits:
|
||||||
self.write_header_no_PR()
|
self.write_header_no_pr()
|
||||||
for sha, message in repo.other_commits:
|
for sha, message in repo.other_commits:
|
||||||
self.write_no_PR(repo, sha, message)
|
self.write_no_pr(repo, sha, message)
|
||||||
self.mdFile.create_md_file()
|
self.mdFile.create_md_file()
|
||||||
|
|
||||||
def write_header_PR(self, section):
|
def write_header_pr(self, section):
|
||||||
self.mdFile.new_line("---")
|
self.mdFile.new_line("---")
|
||||||
self.mdFile.new_header(level=3, title=section,
|
self.mdFile.new_header(level=3, title=section,
|
||||||
add_table_of_contents='n')
|
add_table_of_contents='n')
|
||||||
|
|
@ -242,25 +382,26 @@ class generateMarkdown():
|
||||||
"|Pull Request|Title")
|
"|Pull Request|Title")
|
||||||
self.mdFile.new_line("|:-:|:--")
|
self.mdFile.new_line("|:-:|:--")
|
||||||
|
|
||||||
def write_header_no_PR(self):
|
def write_header_no_pr(self):
|
||||||
self.mdFile.new_line()
|
self.mdFile.new_line()
|
||||||
self.mdFile.new_line(
|
self.mdFile.new_line(
|
||||||
"|Commit|Title")
|
"|Commit|Title")
|
||||||
self.mdFile.new_line("|:-:|:--")
|
self.mdFile.new_line("|:-:|:--")
|
||||||
|
|
||||||
def write_PR(self, pr, info):
|
def write_pr(self, pr, info):
|
||||||
imp = ""
|
imp = ""
|
||||||
if pr[1]:
|
if pr[1]:
|
||||||
imp = "**BREAKING** "
|
imp = "**BREAKING** "
|
||||||
self.mdFile.new_line(
|
self.mdFile.new_line(
|
||||||
f"|[#{pr[0]}]({info['Url']})|{imp}{info['Title']}", wrap_width=0)
|
f"|[#{pr[0]}]({info['Url']})|{imp}{info['Title']}", wrap_width=0)
|
||||||
|
|
||||||
def write_no_PR(self, repo, sha, message):
|
def write_no_pr(self, repo, sha, message):
|
||||||
url = f"https://github.com/{repo.name}/commit/{sha}"
|
url = f"https://github.com/{repo.name}/commit/{sha}"
|
||||||
self.mdFile.new_line(
|
self.mdFile.new_line(
|
||||||
f"|[{sha[:8]}]({url})|{message}", wrap_width=0)
|
f"|[{sha[:8]}]({url})|{message}", wrap_width=0)
|
||||||
|
|
||||||
def handle_labels(self, labels) -> Tuple[str, bool]:
|
@staticmethod
|
||||||
|
def handle_labels(labels) -> Tuple[str, bool]:
|
||||||
for section, values in SECTIONS.items():
|
for section, values in SECTIONS.items():
|
||||||
for label in labels:
|
for label in labels:
|
||||||
if label in values:
|
if label in values:
|
||||||
|
|
@ -290,6 +431,6 @@ class generateMarkdown():
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
arg = cliArgs()
|
arg = CliArgs()
|
||||||
trees = generateTree(arg)
|
trees = GenerateTree(arg)
|
||||||
generateMarkdown(trees)
|
GenerateMarkdown(trees)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue