diff --git a/.github/workflows/reusable-build-workflow.yml b/.github/workflows/reusable-build-workflow.yml index b38930f6..5f9a4d51 100644 --- a/.github/workflows/reusable-build-workflow.yml +++ b/.github/workflows/reusable-build-workflow.yml @@ -89,6 +89,7 @@ jobs: uses: actions/checkout@v4 with: submodules: recursive + fetch-depth: 0 - name: Install dependencies - ldid & xcbeautify run: | @@ -340,7 +341,49 @@ jobs: git push --verbose popd + - name: Get last successful commit + id: get_last_commit + run: | + # Try to get the last successful workflow run commit + LAST_SUCCESS_SHA=$(gh run list --branch "${{ github.ref_name }}" --status success --json headSha --jq '.[0].headSha') + echo "LAST_SUCCESS_SHA=$LAST_SUCCESS_SHA" >> $GITHUB_OUTPUT + echo "LAST_SUCCESS_SHA=$LAST_SUCCESS_SHA" >> $GITHUB_ENV + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Create release notes + run: | + LAST_SUCCESS_SHA=${{ steps.get_last_commit.outputs.LAST_SUCCESS_SHA}} + echo "Last successful commit SHA: $LAST_SUCCESS_SHA" + + FROM_COMMIT=$LAST_SUCCESS_SHA + # Check if we got a valid SHA + if [ -z "$LAST_SUCCESS_SHA" ] || [ "$LAST_SUCCESS_SHA" = "null" ]; then + echo "No successful run found, using initial commit of branch" + # Get the first commit of the branch (initial commit) + FROM_COMMIT=$(git rev-list --max-parents=0 HEAD) + fi + python3 update_release_notes.py $FROM_COMMIT ${{ inputs.release_tag }} ${{ github.ref_name }} + # cat release-notes.md + + - name: Upload release-notes.md + uses: actions/upload-artifact@v4 + with: + name: release-notes-${{ needs.common.outputs.short-commit }}.md + path: release-notes.md + + - name: Upload update_release_notes.py + uses: actions/upload-artifact@v4 + with: + name: update_release_notes-${{ needs.common.outputs.short-commit }}.py + path: update_release_notes.py + + - name: Upload update_apps.py + uses: actions/upload-artifact@v4 + with: + name: update_apps-${{ needs.common.outputs.short-commit }}.py + path: update_apps.py tests-build: name: Tests-Build SideStore - ${{ inputs.release_tag }} @@ -716,6 +759,29 @@ jobs: with: name: test-results-${{ needs.common.outputs.short-commit }}.zip + - name: Download release-notes.md + uses: actions/download-artifact@v4 + with: + name: release-notes-${{ needs.common.outputs.short-commit }}.md + + - name: Download update_release_notes.py + uses: actions/download-artifact@v4 + with: + name: update_release_notes-${{ needs.common.outputs.short-commit }}.py + + - name: Download update_apps.py + uses: actions/download-artifact@v4 + with: + name: update_apps-${{ needs.common.outputs.short-commit }}.py + + - name: Read release notes + id: release_notes + run: | + CONTENT=$(python3 update_release_notes.py --retrieve ${{ inputs.release_tag }}) + echo "content<> $GITHUB_OUTPUT + echo "$CONTENT" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + - name: List files before upload run: | echo ">>>>>>>>> Workdir <<<<<<<<<<" @@ -752,6 +818,8 @@ jobs: Commit SHA: `${{ github.sha }}` Version: `${{ needs.build.outputs.version }}` + ${{ steps.release_notes.outputs.content }} + - name: Get formatted date run: | FORMATTED_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") @@ -784,6 +852,9 @@ jobs: - version: "${{ needs.build.outputs.version }}" - revision: "${{ needs.common.outputs.short-commit }}" - timestamp: "${{ steps.date.outputs.date }}" + + Release Notes: + ${{ steps.release_notes.outputs.content }} EOF ) diff --git a/update_release_notes.py b/update_release_notes.py new file mode 100644 index 00000000..9c696f97 --- /dev/null +++ b/update_release_notes.py @@ -0,0 +1,328 @@ +#!/usr/bin/env python3 +import subprocess +import sys +import os +import re + +def run_command(cmd): + """Run a shell command and return its trimmed output.""" + return subprocess.check_output(cmd, shell=True, text=True).strip() + +def get_head_commit(): + """Return the HEAD commit SHA.""" + return run_command("git rev-parse HEAD") + +def get_commit_messages(last_successful, current="HEAD"): + """Return a list of commit messages between last_successful and current.""" + cmd = f"git log {last_successful}..{current} --pretty=format:%s" + output = run_command(cmd) + if not output: + return [] + return output.splitlines() + +def get_authors_in_range(commit_range, fmt="%an"): + """Return a set of commit authors in the given commit range using the given format.""" + cmd = f"git log {commit_range} --pretty=format:{fmt}" + output = run_command(cmd) + if not output: + return set() + return set(line.strip() for line in output.splitlines() if line.strip()) + +def get_first_commit_of_repo(): + """Return the first commit in the repository (root commit).""" + cmd = "git rev-list --max-parents=0 HEAD" + output = run_command(cmd) + return output.splitlines()[0] + +def get_branch(): + """ + Attempt to determine the branch base (the commit where the current branch diverged + from the default remote branch). Falls back to the repo's first commit. + """ + try: + default_ref = run_command("git rev-parse --abbrev-ref origin/HEAD") + default_branch = default_ref.split('/')[-1] + base_commit = run_command(f"git merge-base HEAD origin/{default_branch}") + return base_commit + except Exception: + return get_first_commit_of_repo() + +def get_repo_url(): + """Extract and clean the repository URL from the remote 'origin'.""" + url = run_command("git config --get remote.origin.url") + if url.startswith("git@"): + url = url.replace("git@", "https://").replace(":", "/") + if url.endswith(".git"): + url = url[:-4] + return url + +def format_contributor(author): + """ + Convert an author name to a GitHub username or first name. + If the author already starts with '@', return it; + otherwise, take the first token and prepend '@'. + """ + if author.startswith('@'): + return author + return f"@{author.split()[0]}" + +def format_commit_message(msg): + """Format a commit message as a bullet point for the release notes.""" + msg_clean = msg.lstrip() # remove leading spaces + if msg_clean.startswith("-"): + msg_clean = msg_clean[1:].strip() # remove leading '-' and spaces + return f"- {msg_clean}" + +def generate_release_notes(last_successful, tag, branch): + """Generate release notes for the given tag.""" + current_commit = get_head_commit() + messages = get_commit_messages(last_successful, current_commit) + + # Start with the tag header + new_section = f"### {tag}\n" + + # What's Changed section (always present) + new_section += "#### What's Changed\n" + + if not messages or last_successful == current_commit: + new_section += "- Nothing...\n" + else: + for msg in messages: + new_section += f"{format_commit_message(msg)}\n" + + # New Contributors section (only if there are new contributors) + all_previous_authors = get_authors_in_range(f"{branch}") + recent_authors = get_authors_in_range(f"{last_successful}..{current_commit}") + new_contributors = recent_authors - all_previous_authors + + if new_contributors: + new_section += "\n#### New Contributors\n" + for author in sorted(new_contributors): + new_section += f"- {format_contributor(author)} made their first contribution\n" + + # Full Changelog section (only if there are changes) + if messages and last_successful != current_commit: + repo_url = get_repo_url() + changelog_link = f"{repo_url}/compare/{last_successful}...{current_commit}" + new_section += f"\n#### Full Changelog: [{last_successful[:8]}...{current_commit[:8]}]({changelog_link})\n" + + return new_section + +def update_release_md(existing_content, new_section, tag): + """ + Update release.md based on rules: + 1. If tag exists, update it + 2. Special tags (alpha, beta, nightly) stay at the top in that order + 3. Numbered tags follow special tags + 4. Remove duplicate tags + 5. Insert new numbered tags at the top of the numbered section + """ + tag_lower = tag.lower() + is_special_tag = tag_lower in ["alpha", "beta", "nightly"] + + # Parse the existing content into sections + if not existing_content: + return new_section + + # Split the content into sections by headers + pattern = r'(^## .*$)' + sections = re.split(pattern, existing_content, flags=re.MULTILINE) + + # Create a list to store the processed content + processed_sections = [] + + # Track special tag positions and whether tag was found + special_tags_map = {"alpha": False, "beta": False, "nightly": False} + last_special_index = -1 + tag_found = False + numbered_tag_index = -1 + + i = 0 + while i < len(sections): + # Check if this is a header + if i % 2 == 1: # Headers are at odd indices + header = sections[i] + content = sections[i+1] if i+1 < len(sections) else "" + current_tag = header[3:].strip().lower() + + # Check for special tags to track their positions + if current_tag in special_tags_map: + special_tags_map[current_tag] = True + last_special_index = len(processed_sections) + + # Check if this is the first numbered tag + elif re.match(r'^[0-9]+\.[0-9]+(\.[0-9]+)?$', current_tag) and numbered_tag_index == -1: + numbered_tag_index = len(processed_sections) + + # If this is the tag we're updating, mark it but don't add yet + if current_tag == tag_lower: + if not tag_found: # Replace the first occurrence + tag_found = True + i += 2 # Skip the content + continue + else: # Skip duplicate occurrences + i += 2 + continue + + # Add the current section + processed_sections.append(sections[i]) + i += 1 + + # Determine where to insert the new section + if tag_found: + # We need to determine the insertion point + if is_special_tag: + # For special tags, insert after last special tag or at beginning + desired_index = -1 + for pos, t in enumerate(["alpha", "beta", "nightly"]): + if t == tag_lower: + desired_index = pos + + # Find position to insert + insert_pos = 0 + for pos, t in enumerate(["alpha", "beta", "nightly"]): + if t == tag_lower: + break + if special_tags_map[t]: + insert_pos = processed_sections.index(f"### {t}") + insert_pos += 2 # Move past the header and content + + # Insert at the determined position + processed_sections.insert(insert_pos, new_section) + if insert_pos > 0 and not processed_sections[insert_pos-1].endswith('\n\n'): + processed_sections.insert(insert_pos, '\n\n') + else: + # For numbered tags, insert after special tags but before other numbered tags + insert_pos = 0 + + if last_special_index >= 0: + # Insert after the last special tag + insert_pos = last_special_index + 2 # +2 to skip header and content + + processed_sections.insert(insert_pos, new_section) + if insert_pos > 0 and not processed_sections[insert_pos-1].endswith('\n\n'): + processed_sections.insert(insert_pos, '\n\n') + else: + # Tag doesn't exist yet, determine insertion point + if is_special_tag: + # For special tags, maintain alpha, beta, nightly order + special_tags = ["alpha", "beta", "nightly"] + insert_pos = 0 + + for i, t in enumerate(special_tags): + if t == tag_lower: + # Check if preceding special tags exist + for prev_tag in special_tags[:i]: + if special_tags_map[prev_tag]: + # Find the position after this tag + prev_index = processed_sections.index(f"### {prev_tag}") + insert_pos = prev_index + 2 # Skip header and content + + processed_sections.insert(insert_pos, new_section) + if insert_pos > 0 and not processed_sections[insert_pos-1].endswith('\n\n'): + processed_sections.insert(insert_pos, '\n\n') + else: + # For numbered tags, insert after special tags but before other numbered tags + insert_pos = 0 + + if last_special_index >= 0: + # Insert after the last special tag + insert_pos = last_special_index + 2 # +2 to skip header and content + + processed_sections.insert(insert_pos, new_section) + if insert_pos > 0 and not processed_sections[insert_pos-1].endswith('\n\n'): + processed_sections.insert(insert_pos, '\n\n') + + # Combine sections ensuring proper spacing + result = "" + for i, section in enumerate(processed_sections): + if i > 0 and section.startswith("### "): + # Ensure single blank line before headers + if not result.endswith("\n\n"): + result = result.rstrip("\n") + "\n\n" + result += section + + return result.rstrip() + "\n" + + +def retrieve_tag_content(tag, file_path="release.md"): + if not os.path.exists(file_path): + return "" + + with open(file_path, "r") as f: + content = f.read() + + # Create a pattern for the tag header (case-insensitive) + pattern = re.compile(r'^## ' + re.escape(tag) + r'$', re.MULTILINE | re.IGNORECASE) + + # Find the tag header + match = pattern.search(content) + if not match: + return "" + + # Start after the tag line + start_pos = match.end() + + # Skip a newline if present + if start_pos < len(content) and content[start_pos] == "\n": + start_pos += 1 + + # Find the next tag header after the current tag's content + next_tag_match = re.search(r'^## ', content[start_pos:], re.MULTILINE) + + if next_tag_match: + end_pos = start_pos + next_tag_match.start() + return content[start_pos:end_pos].strip() + else: + # Return until the end of the file if this is the last tag + return content[start_pos:].strip() + +def main(): + # Update release.md file + release_file = "release-notes.md" + + # Usage: python release.py [tag] [branch] + # Or: python release.py --retrieve + args = sys.argv[1:] + + if len(args) < 1: + print("Usage: python release.py [tag] [branch]") + print(" or: python release.py --retrieve ") + sys.exit(1) + + # Check if we're retrieving a tag + if args[0] == "--retrieve": + if len(args) < 2: + print("Error: Missing tag name after --retrieve") + sys.exit(1) + + tag_content = retrieve_tag_content(args[1], file_path=release_file) + if tag_content: + print(tag_content) + else: + print(f"Tag '{args[1]}' not found in release.md") + return + + # Original functionality for generating release notes + last_successful = args[0] + tag = args[1] if len(args) > 1 else get_head_commit() + branch = args[2] if len(args) > 2 else (os.environ.get("GITHUB_REF") or get_branch()) + + # Generate release notes + new_section = generate_release_notes(last_successful, tag, branch) + + existing_content = "" + if os.path.exists(release_file): + with open(release_file, "r") as f: + existing_content = f.read() + + updated_content = update_release_md(existing_content, new_section, tag) + + with open(release_file, "w") as f: + f.write(updated_content) + + # Output the new section for display + print(new_section) + +if __name__ == "__main__": + main() \ No newline at end of file