Skip to content

Commit 6db4a44

Browse files
committed
New release workflow
1 parent e07136e commit 6db4a44

3 files changed

Lines changed: 380 additions & 0 deletions

File tree

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import subprocess
2+
import re
3+
import argparse
4+
5+
6+
def get_commits(from_tag=None):
7+
"""Get commits since from_tag, or all commits if from_tag is None."""
8+
if from_tag:
9+
cmd = ["git", "log", f"{from_tag}..HEAD", "--pretty=format:%H|%h|%s"]
10+
else:
11+
cmd = ["git", "log", "--pretty=format:%H|%h|%s"]
12+
13+
try:
14+
output = subprocess.check_output(cmd, text=True).strip()
15+
if not output:
16+
return []
17+
return output.split("\n")
18+
except Exception as e:
19+
print(f"Error getting commits: {e}")
20+
return []
21+
22+
23+
def build_changelog(commits, repo_url):
24+
"""Categorize commits and build a markdown changelog."""
25+
cats = {
26+
"✨ Features": [],
27+
"🐛 Bug Fixes": [],
28+
"📦 Dependencies": [],
29+
"🔧 Maintenance & CI": [],
30+
"📝 Documentation": [],
31+
"🧪 Tests": [],
32+
"🚀 Other": [],
33+
}
34+
35+
for line in commits:
36+
if not line or "|" not in line:
37+
continue
38+
full_hash, short_hash, subject = line.split("|", 2)
39+
subject_lower = subject.lower()
40+
41+
# Skip release and merge commits
42+
if any(
43+
x in subject_lower
44+
for x in [
45+
"chore: release",
46+
"chore: bump",
47+
"merge ",
48+
"[skip ci]",
49+
"chore(release)",
50+
"chore(dev)",
51+
]
52+
):
53+
continue
54+
55+
# Linkify PR numbers
56+
subject = re.sub(r"\(#(\d+)\)", rf"([#\1]({repo_url}/pull/\1))", subject)
57+
entry = f"- {subject} ([{short_hash}]({repo_url}/commit/{full_hash}))"
58+
59+
if re.match(r"^(feat|add|new|✨)", subject_lower):
60+
cats["✨ Features"].append(entry)
61+
elif re.match(r"^(fix|bug|patch|fixed|fixes|🐛)", subject_lower):
62+
cats["🐛 Bug Fixes"].append(entry)
63+
elif re.match(r"^(deps|dep|update|bump|renovate|📦|⬆️)", subject_lower):
64+
cats["📦 Dependencies"].append(entry)
65+
elif re.match(r"^(chore|ci|workflow|config|ruff|🔧)", subject_lower):
66+
cats["🔧 Maintenance & CI"].append(entry)
67+
elif re.match(r"^(docs|documentation|📝)", subject_lower):
68+
cats["📝 Documentation"].append(entry)
69+
elif re.match(r"^(test|pytest|🧪)", subject_lower):
70+
cats["🧪 Tests"].append(entry)
71+
else:
72+
cats["🚀 Other"].append(entry)
73+
74+
changelog = "## Changelog\n\n"
75+
has_content = False
76+
77+
for title in [
78+
"✨ Features",
79+
"🐛 Bug Fixes",
80+
"📦 Dependencies",
81+
"🔧 Maintenance & CI",
82+
"📝 Documentation",
83+
"🧪 Tests",
84+
"🚀 Other",
85+
]:
86+
items = cats[title]
87+
if items:
88+
changelog += f"### {title}\n"
89+
for item in items:
90+
changelog += f"{item}\n"
91+
changelog += "\n"
92+
has_content = True
93+
94+
if not has_content:
95+
changelog += "No significant changes in this release."
96+
97+
return changelog
98+
99+
100+
def main():
101+
parser = argparse.ArgumentParser(description="Build a changelog from git commits.")
102+
parser.add_argument("--from-tag", help="Tag to start from")
103+
parser.add_argument("--repo-url", required=True, help="GitHub repository URL")
104+
parser.add_argument("--output", default="CHANGELOG_BODY.md", help="Output file")
105+
106+
args = parser.parse_args()
107+
108+
commits = get_commits(args.from_tag)
109+
changelog = build_changelog(commits, args.repo_url)
110+
111+
with open(args.output, "w", encoding="utf-8") as f:
112+
f.write(changelog)
113+
print(f"Changelog written to {args.output}")
114+
115+
116+
if __name__ == "__main__":
117+
main()

.github/scripts/version_manager.py

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import argparse
2+
import datetime
3+
import json
4+
import os
5+
import re
6+
import subprocess
7+
8+
MANIFEST_FILE = "custom_components/foodsharing/manifest.json"
9+
10+
11+
def get_current_version():
12+
"""Get the current version from git tags (preferred) or manifest.json."""
13+
# 1. Try Git Tags (Strict CalVer)
14+
try:
15+
# Get all tags
16+
tags = (
17+
subprocess.check_output(["git", "tag"], stderr=subprocess.DEVNULL)
18+
.decode()
19+
.splitlines()
20+
)
21+
22+
valid_tags = []
23+
for tag in tags:
24+
tag = tag.strip()
25+
match = re.match(r"^(\d+)\.(\d+)\.(\d+)(?:(b)(\d+)|(-dev)(\d+))?$", tag)
26+
if match:
27+
y, m, p, b_p, b_n, d_p, d_n = match.groups()
28+
priority = 0
29+
s_num = 0
30+
if b_p:
31+
priority = 1
32+
s_num = int(b_n)
33+
elif d_p:
34+
priority = 0
35+
s_num = int(d_n)
36+
else:
37+
priority = 2
38+
39+
valid_tags.append(
40+
{"tag": tag, "key": (int(y), int(m), int(p), priority, s_num)}
41+
)
42+
43+
if valid_tags:
44+
# Sort by key descending
45+
valid_tags.sort(key=lambda x: x["key"], reverse=True)
46+
return valid_tags[0]["tag"]
47+
48+
except (subprocess.CalledProcessError, FileNotFoundError):
49+
pass
50+
51+
# 2. Try manifest.json
52+
if os.path.exists(MANIFEST_FILE):
53+
with open(MANIFEST_FILE, "r", encoding="utf-8") as f:
54+
data = json.load(f)
55+
v = data.get("version", "0.0.0")
56+
if v != "0.0.0":
57+
return v
58+
59+
return "2024.1.0" # Safe baseline
60+
61+
62+
def write_version(version):
63+
"""Write version to manifest.json."""
64+
if os.path.exists(MANIFEST_FILE):
65+
with open(MANIFEST_FILE, "r", encoding="utf-8") as f:
66+
data = json.load(f)
67+
data["version"] = version
68+
with open(MANIFEST_FILE, "w", encoding="utf-8") as f:
69+
json.dump(data, f, indent=2)
70+
71+
72+
def calculate_version(release_type, now=None):
73+
current_version = get_current_version()
74+
if now is None:
75+
now = datetime.datetime.now()
76+
year = now.year
77+
month = now.month
78+
79+
# Parse CalVer: YEAR.MONTH.PATCH and suffix
80+
# Supports formats like: 2026.1.1, 2026.1.1b1, 2026.1.1-dev1
81+
match = re.match(r"^(\d+)\.(\d+)\.(\d+)(?:(b)(\d+)|(-dev)(\d+))?$", current_version)
82+
83+
if match:
84+
curr_year, curr_month, curr_patch, b_prefix, b_num, dev_prefix, dev_num = (
85+
match.groups()
86+
)
87+
curr_year, curr_month, curr_patch = (
88+
int(curr_year),
89+
int(curr_month),
90+
int(curr_patch),
91+
)
92+
93+
if b_prefix:
94+
suffix_type = "b"
95+
suffix_num = int(b_num)
96+
elif dev_prefix:
97+
suffix_type = "-dev"
98+
suffix_num = int(dev_num)
99+
else:
100+
suffix_type = None
101+
suffix_num = 0
102+
else:
103+
# Fallback for invalid formats
104+
curr_year, curr_month, curr_patch = 0, 0, 0
105+
suffix_type = None
106+
suffix_num = 0
107+
108+
# Logic: Reset patch if Year or Month changes
109+
is_new_cycle = year != curr_year or month != curr_month
110+
if is_new_cycle:
111+
patch = 0
112+
else:
113+
patch = curr_patch
114+
115+
if release_type == "stable":
116+
# If we have a suffix (beta/dev), stable means "cutting the release" of THIS patch
117+
if suffix_type is not None:
118+
return f"{year}.{month}.{patch}"
119+
120+
# If it's a new cycle, we start at .0
121+
if is_new_cycle:
122+
return f"{year}.{month}.0"
123+
124+
# Increment patch
125+
return f"{year}.{month}.{patch + 1}"
126+
127+
elif release_type == "beta":
128+
# If Year/Month changes, we start at .0b0
129+
if is_new_cycle:
130+
return f"{year}.{month}.0b0"
131+
132+
# Already in beta? Increment beta number
133+
if suffix_type == "b":
134+
return f"{year}.{month}.{patch}b{suffix_num + 1}"
135+
136+
# Coming from stable or dev?
137+
# We want to increment patch if coming from stable of the same cycle
138+
# If it was 2026.2.0 (stable), next beta is 2026.2.1b0
139+
if suffix_type is None:
140+
return f"{year}.{month}.{patch + 1}b0"
141+
142+
# Coming from dev? Use current patch but change suffix
143+
return f"{year}.{month}.{patch}b0"
144+
145+
elif release_type == "dev" or release_type == "nightly":
146+
# If Year/Month changes, we start at .0-dev0
147+
if is_new_cycle:
148+
return f"{year}.{month}.0-dev0"
149+
150+
# Already in dev? Increment dev number
151+
if suffix_type == "-dev":
152+
return f"{year}.{month}.{patch}-dev{suffix_num + 1}"
153+
154+
# New dev? Increment patch if coming from stable
155+
if suffix_type is None:
156+
return f"{year}.{month}.{patch + 1}-dev0"
157+
158+
# Coming from beta? Just change suffix
159+
return f"{year}.{month}.{patch}-dev0"
160+
161+
else:
162+
raise ValueError(f"Unknown release type: {release_type}")
163+
164+
165+
def main():
166+
parser = argparse.ArgumentParser(description="Manage project version.")
167+
parser.add_argument("action", choices=["get", "bump"], help="Action to perform")
168+
parser.add_argument(
169+
"--type",
170+
choices=["stable", "beta", "nightly", "dev"],
171+
help="Release type for bump",
172+
)
173+
174+
args = parser.parse_args()
175+
176+
if args.action == "get":
177+
print(get_current_version())
178+
elif args.action == "bump":
179+
if not args.type:
180+
print("Error: --type is required for bump action")
181+
exit(1)
182+
new_version = calculate_version(args.type)
183+
write_version(new_version)
184+
print(new_version)
185+
186+
187+
if __name__ == "__main__":
188+
main()

.github/workflows/release.yml

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
name: Release
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
release_type:
7+
description: 'Release Type'
8+
required: true
9+
type: choice
10+
options:
11+
- stable
12+
- beta
13+
- nightly
14+
15+
jobs:
16+
release:
17+
name: Create Release
18+
runs-on: ubuntu-latest
19+
permissions:
20+
contents: write
21+
packages: write
22+
steps:
23+
- uses: actions/checkout@v6
24+
with:
25+
fetch-depth: 0
26+
27+
- name: Set up Python
28+
uses: actions/setup-python@v6
29+
with:
30+
python-version: '3.14'
31+
32+
- name: Calculate Version
33+
id: version
34+
run: |
35+
NEW_VERSION=$(python .github/scripts/version_manager.py bump --type ${{ inputs.release_type }})
36+
echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT
37+
38+
- name: Create Release Tag
39+
if: inputs.release_type != 'nightly'
40+
run: |
41+
git config user.name "GitHub Actions"
42+
git config user.email "actions@github.com"
43+
git commit -am "chore(release): bump version to ${{ steps.version.outputs.version }}"
44+
git tag ${{ steps.version.outputs.version }}
45+
git push origin ${{ steps.version.outputs.version }}
46+
git push origin main
47+
48+
- name: 📝 Find Previous Tag
49+
id: pre_tag
50+
run: |
51+
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
52+
echo "tag=$PREV_TAG" >> $GITHUB_OUTPUT
53+
54+
- name: 📝 Generate Changelog
55+
if: inputs.release_type != 'nightly'
56+
run: |
57+
python .github/scripts/changelog_builder.py \
58+
--from-tag "${{ steps.pre_tag.outputs.tag }}" \
59+
--repo-url "https://github.com/${{ github.repository }}" \
60+
--output "CHANGELOG_BODY.md"
61+
62+
- name: Create Release Notes
63+
uses: softprops/action-gh-release@v3
64+
if: inputs.release_type != 'nightly'
65+
with:
66+
tag_name: ${{ steps.version.outputs.version }}
67+
body_path: CHANGELOG_BODY.md
68+
prerelease: ${{ inputs.release_type == 'beta' }}
69+
70+
- name: Post-Release Dev Bump
71+
if: inputs.release_type != 'nightly'
72+
run: |
73+
NEW_DEV=$(python .github/scripts/version_manager.py bump --type dev)
74+
git commit -am "chore(dev): bump to $NEW_DEV"
75+
git push origin main

0 commit comments

Comments
 (0)