Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
dda15e6
test
Luna712 May 6, 2026
11ab291
Update
Luna712 May 6, 2026
ce79221
Try
Luna712 May 6, 2026
b78cb95
Try
Luna712 May 6, 2026
1c31500
Try
Luna712 May 6, 2026
c942c6c
Update
Luna712 May 6, 2026
40544eb
-
Luna712 May 6, 2026
e486498
Update
Luna712 May 6, 2026
591eaa1
Update
Luna712 May 6, 2026
680341f
chore(test): test
Luna712 May 6, 2026
1c3e8c6
chore(test): test
Luna712 May 6, 2026
35eaf44
chore(test): test
Luna712 May 6, 2026
d86c68b
chore(test): test
Luna712 May 6, 2026
f4b62a1
chore(test): test
Luna712 May 6, 2026
d60b79d
Test
Luna712 May 6, 2026
3408aff
chore(test): test
Luna712 May 6, 2026
1060df5
chore(test): test
Luna712 May 6, 2026
243cb86
Update
Luna712 May 6, 2026
dac1695
feat(ci): python
Luna712 May 6, 2026
385dc7e
fix(ci): delete
Luna712 May 6, 2026
d755f01
fix(ci): delete
Luna712 May 6, 2026
7b63760
fix(ci): delete
Luna712 May 6, 2026
ea3d465
Use classes
Luna712 May 6, 2026
b433e33
Test
Luna712 May 6, 2026
3ac1755
Test
Luna712 May 6, 2026
2a45744
Test tags
Luna712 May 6, 2026
ee8f6d8
Try
Luna712 May 6, 2026
4e11a17
Try
Luna712 May 6, 2026
67f0feb
500
Luna712 May 6, 2026
e4c3c44
argparse
Luna712 May 6, 2026
ee36235
Update
Luna712 May 6, 2026
3b5e3d4
Update
Luna712 May 6, 2026
64deaa3
Try
Luna712 May 6, 2026
ee38c6e
Try fix
Luna712 May 6, 2026
bee82e8
Don't delete tag
Luna712 May 6, 2026
9d1486c
Try
Luna712 May 6, 2026
066db61
Update
Luna712 May 6, 2026
e0af53a
Fix
Luna712 May 6, 2026
32c5c03
Fix
Luna712 May 6, 2026
74b1679
Update
Luna712 May 6, 2026
78f8450
Fix
Luna712 May 7, 2026
0ad1f18
Test
Luna712 May 7, 2026
8e769d7
-
Luna712 May 7, 2026
1ad5559
test fetch
Luna712 May 7, 2026
09e50c1
try fetch again
Luna712 May 7, 2026
5430fcb
Try again
Luna712 May 7, 2026
aa3cc4a
-
Luna712 May 7, 2026
86402cc
Try branch
Luna712 May 7, 2026
424b1bc
-
Luna712 May 7, 2026
7465761
Remove comments
Luna712 May 7, 2026
4585a31
Extend MERGE_RE
Luna712 May 7, 2026
57ed6ac
Test
Luna712 May 7, 2026
eb6e54d
Test
Luna712 May 7, 2026
95c35b7
Fix
Luna712 May 7, 2026
be376ec
1000
Luna712 May 7, 2026
411a847
token
Luna712 May 7, 2026
48b5805
Update
Luna712 May 7, 2026
9ee4362
Permissions fix
Luna712 May 7, 2026
8e38208
-
Luna712 May 7, 2026
2092155
Merge branch 'token-action' into release
Luna712 May 7, 2026
61d3766
Try
Luna712 May 7, 2026
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
257 changes: 257 additions & 0 deletions .github/generate_changelog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
#!/usr/bin/env python3

import argparse
import json
import os
import re
import secrets
import subprocess
import sys
import urllib.error
import urllib.request
from dataclasses import dataclass, field


MERGE_RE = re.compile(r'^Merge pull request #\d+ from |^Merge (remote-tracking )?branch ')
CC_HEADER_RE = re.compile(r'^([a-z]+)(\([^)]*\))?(!)?:(.*)$')
BREAKING_BODY_RE = re.compile(r'^BREAKING\s+CHANGES?:\s+', re.MULTILINE)

CC_TYPES: dict[str, str] = {
'feat': 'Features',
'fix': 'Bug Fixes',
'docs': 'Documentation',
'style': 'Styles',
'refactor': 'Code Refactoring',
'perf': 'Performance Improvements',
'test': 'Tests',
'build': 'Builds',
'ci': 'Continuous Integration',
'chore': 'Chores',
'revert': 'Reverts',
}


@dataclass
class Commit:
sha: str
subject: str
author: str
url: str
body: str
cc_type: str
scope: str
parsed_subject: str
breaking: bool
prs: list[dict] = field(default_factory=list)

@property
def known_type(self) -> str:
return self.cc_type if self.cc_type in CC_TYPES else ''

@property
def short_sha(self) -> str:
return self.sha[:7]

@property
def entry(self) -> str:
pr_string = ''
if self.prs:
parts = [f"[#{pr['number']}]({pr['html_url']})" for pr in self.prs]
pr_string = ' ' + ','.join(parts)

if self.known_type:
scope_str = f'**{self.scope}**: ' if self.scope else ''
return f'- {scope_str}{self.parsed_subject}{pr_string} ([{self.author}]({self.url}))'
else:
return f'- {self.short_sha}: {self.subject} ({self.author}){pr_string}'


class ChangelogGenerator:
def __init__(self, token: str, repository: str, sha: str, ref: str, output_path: str):
self.token = token
self.owner, self.repo = repository.split('/', 1)
self.sha = sha
self.ref = ref
self.output_path = output_path

def log(self, msg: str) -> None:
print(f'[generate_changelog] {msg}', file=sys.stderr)

def git(self, *args: str) -> str:
return subprocess.check_output(['git', *args], text=True).strip()

def gh_api(self, endpoint: str) -> list | dict:
req = urllib.request.Request(
f'https://api.github.com{endpoint}',
headers={
'Authorization': f'token {self.token}',
'Accept': 'application/vnd.github.v3+json',
},
)
try:
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
self.log(f'API request failed for {endpoint}: {e}')
return []

def current_tag(self) -> str:
m = re.match(r'^refs/tags/(.+)$', self.ref)
if m:
tag = m.group(1)
self.log(f'Detected tag from GITHUB_REF: {tag}')
return tag
try:
return self.git('describe', '--exact-match', self.sha)
except subprocess.CalledProcessError:
return ''

def find_previous_tag(self, current_tag: str) -> str:
self.log(f'Searching for previous semver tag before {current_tag}')

def parse_semver(tag: str) -> tuple[int, ...]:
parts = re.split(r'[.\-]', re.sub(r'^v', '', tag))
result = []
for p in parts[:3]:
try:
result.append(int(p))
except ValueError:
result.append(0)
return tuple(result)

semver_re = re.compile(r'^v?\d+\.\d+\.\d+')
tags = [t for t in self.git('tag', '--list').splitlines() if semver_re.match(t)]
if not tags:
self.log('No semver tags found')
return ''

current_ver = parse_semver(current_tag)
candidates = sorted(
[t for t in tags if t != current_tag and parse_semver(t) < current_ver],
key=parse_semver,
reverse=True,
)
return candidates[0] if candidates else ''

def ref_branch(self) -> str:
m = re.match(r'^refs/heads/(.+)$', self.ref)
return m.group(1) if m else ''

def tag_exists(self, tag: str) -> bool:
try:
self.git('fetch', '--depth=1', '--no-tags', 'origin', f'refs/tags/{tag}:refs/tags/{tag}')
self.git('fetch', '--no-tags', f'--shallow-exclude={tag}', 'origin', f'refs/heads/{self.ref_branch()}')
return True
except subprocess.CalledProcessError:
self.log(f'Tag {tag} not found, falling back to full history')
return False

def get_raw_commits(self, base: str) -> list[tuple[str, str]]:
if base:
self.log(f'Getting commits between {base} and {self.sha}')
raw = self.git('log', '--format=%H %s', '--max-count=1000', f'{base}..{self.sha}')
else:
self.log(f'No previous tag - using full history to {self.sha}')
raw = self.git('log', '--format=%H %s', '--max-count=1000', self.sha)
return [(line.split(' ', 1)[0], line.split(' ', 1)[1]) for line in raw.splitlines() if line.strip()]

def parse_commit(self, sha: str, subject: str) -> Commit | None:
if MERGE_RE.match(subject):
self.log(f'Skipping merge commit: {sha}')
return None

self.log(f'Processing commit {sha}: {subject}')

author = self.git('log', '-1', '--format=%an', sha) or 'unknown'
url = f'https://github.com/{self.owner}/{self.repo}/commit/{sha}'
body = self.git('log', '-1', '--format=%b', sha)

cc_type, scope, parsed_subject = '', '', ''
m = CC_HEADER_RE.match(subject)
if m:
cc_type = m.group(1)
scope = m.group(2)[1:-1] if m.group(2) else ''
parsed_subject = m.group(4).strip()

breaking = bool(
re.match(r'^[a-z]+(\([^)]*\))?!:', subject)
or BREAKING_BODY_RE.search(body or '')
)

self.log(f'Fetching PRs for {sha}')
prs = self.gh_api(f'/repos/{self.owner}/{self.repo}/commits/{sha}/pulls')

return Commit(
sha=sha,
subject=subject,
author=author,
url=url,
body=body,
cc_type=cc_type,
scope=scope,
parsed_subject=parsed_subject,
breaking=breaking,
prs=prs if isinstance(prs, list) else [],
)

def build_changelog(self, commits: list) -> str:
breaking = [c.entry for c in commits if c.breaking]
by_type = {k: [c.entry for c in commits if c.known_type == k] for k in CC_TYPES}
other = [c.entry for c in commits if not c.known_type]

sections: list[str] = []

if breaking:
sections.append('## Breaking Changes\n' + '\n'.join(breaking))

for key, label in CC_TYPES.items():
if by_type[key]:
sections.append(f'## {label}\n' + '\n'.join(by_type[key]))

if other:
sections.append('## Commits\n' + '\n'.join(other))

return '\n\n'.join(sections).strip()

def write_output(self, changelog: str) -> None:
delimiter = secrets.token_hex(16)
with open(self.output_path, 'a') as f:
f.write(f'changelog<<{delimiter}\n{changelog}\n{delimiter}\n')
self.log("Changelog written to GITHUB_OUTPUT as 'changelog'")

def run(self, previous_tag: str = '') -> None:
tag = self.current_tag()
if previous_tag and not self.tag_exists(previous_tag):
previous_tag = ''
if not previous_tag and tag:
previous_tag = self.find_previous_tag(tag)

self.log(f"Previous tag: {previous_tag or '<none>'}")

raw_commits = self.get_raw_commits(previous_tag)
commits = [c for sha, subject in raw_commits if (c := self.parse_commit(sha, subject))]

changelog = self.build_changelog(commits) if commits else ''
self.write_output(changelog)


def require_env(name: str) -> str:
val = os.environ.get(name, '')
if not val:
print(f'[generate_changelog] ERROR: {name} is not set', file=sys.stderr)
sys.exit(1)
return val


if __name__ == '__main__':
parser = argparse.ArgumentParser(description='Generate a changelog and write it to GITHUB_OUTPUT.')
parser.add_argument('--previous-tag', default='', help='Tag to compare from (auto-detected if omitted)')
args = parser.parse_args()

ChangelogGenerator(
token=require_env('GITHUB_TOKEN'),
repository=require_env('GITHUB_REPOSITORY'),
sha=os.environ.get('GITHUB_SHA') or subprocess.check_output(['git', 'rev-parse', 'HEAD'], text=True).strip(),
ref=os.environ.get('GITHUB_REF', ''),
output_path=require_env('GITHUB_OUTPUT'),
).run(previous_tag=args.previous_tag)
24 changes: 18 additions & 6 deletions .github/workflows/prerelease.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: Pre-release

on:
push:
branches: [ master ]
branches: [ master, release ]
paths-ignore:
- '*.md'
- '*.json'
Expand Down Expand Up @@ -55,24 +55,36 @@ jobs:
uses: gradle/actions/setup-gradle@v5
with:
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
cache-read-only: false

- name: Run Gradle
run: ./gradlew assemblePrereleaseRelease androidSourcesJar makeJar
env:
SIGNING_KEY_ALIAS: "key0"
SIGNING_KEY_ALIAS: key0
SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }}
SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }}
MDL_API_KEY: ${{ secrets.MDL_API_KEY }}

- name: Generate release notes
id: notes
run: python .github/generate_changelog.py --previous-tag=pre-release
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}

- name: Delete existing pre-release
run: gh release delete pre-release --yes --cleanup-tag || true
env:
GITHUB_TOKEN: ${{ github.token }}

- name: Create pre-release
uses: marvinpinto/action-automatic-releases@latest
uses: softprops/action-gh-release@v3
with:
repo_token: "${{ secrets.GITHUB_TOKEN }}"
automatic_release_tag: "pre-release"
tag_name: pre-release
name: Pre-release Build
prerelease: true
title: "Pre-release Build"
body: ${{ steps.notes.outputs.changelog }}
files: |
app/build/outputs/apk/prerelease/release/*.apk
app/build/libs/app-sources.jar
Expand Down