From e188bd69baf991acaa1b32e077300412c04e258a Mon Sep 17 00:00:00 2001 From: Ricardo Mancinas Date: Mon, 28 Sep 2026 14:37:59 -0700 Subject: [PATCH] feat: initial implementation of reusable LLM release notes action --- README.md | 109 ++++++++++++++++++- action.yml | 160 ++++++++++++++++++++++++++++ scripts/generate_notes.py | 219 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 487 insertions(+), 1 deletion(-) create mode 100644 action.yml create mode 100755 scripts/generate_notes.py diff --git a/README.md b/README.md index d2c5383..db2c3b8 100644 --- a/README.md +++ b/README.md @@ -1 +1,108 @@ -# Action Release Notes +# Gitea Action: AI Release Notes Generator + +Reusable Gitea composite action that inspects git history between releases and calls an LLM provider (default: self-hosted **OmniRoute** or any OpenAI-compatible endpoint) to produce structured Markdown release notes. + +## Features + +- **Automated Commit Discovery**: Automatically finds previous release tags (e.g. `v1.7.0` to `v1.8.0`) and extracts all commit messages in that range. +- **Smart Noise Filtering**: Automatically filters out version bumps (`chore: bump version`, `chore: release`) and merge commit noise. +- **OmniRoute & OpenAI Compatible**: Works out-of-the-box with OmniRoute (`https://omni.freakma.com/v1`, model `auto/fast`), local llama.cpp, or OpenAI/OpenRouter. +- **Thinking Tag Stripping**: Automatically cleans `...` tags produced by reasoning models (Qwen, DeepSeek). +- **Graceful Fallback**: Bounded timeouts with fallback text so CI release cuts never fail or hang if the LLM provider is temporarily unreachable. +- **Multi-Platform Support**: Pure Python standard library implementation (`urllib`, `json`, `subprocess`). Zero external package dependencies; runs seamlessly on macOS (`runs-on: macos`) and Linux/Docker runners (`runs-on: docker`). + +--- + +## Quick Start + +In your Gitea workflow (e.g. `.gitea/workflows/release-cut.yml`): + +```yaml +jobs: + cut-release: + runs-on: macos + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + token: ${{ secrets.TOKEN }} + fetch-depth: 0 + + - name: Bump & Tag + run: | + # Your version bump and tag commands... + git tag "v1.8.0" + + - name: Generate Release Notes + id: release_notes + uses: https://git.mancinas.io/rmancinas/action-release-notes@main + with: + api_key: ${{ secrets.OMNI_API_KEY }} + version: '1.8.0' + + - name: Create Gitea Release + env: + GITEA_TOKEN: ${{ secrets.TOKEN }} + NOTES: ${{ steps.release_notes.outputs.notes }} + run: | + curl -sS -X POST "${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases" \ + -H "Authorization: token $GITEA_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"tag_name\":\"v1.8.0\",\"name\":\"v1.8.0\",\"body\":$(echo "$NOTES" | jq -Rs .)}" +``` + +--- + +## Inputs + +| Input | Description | Required | Default | +| :--- | :--- | :--- | :--- | +| `api_key` | API key for OmniRoute / LLM provider. If omitted, uses fallback notes. | No | `""` | +| `base_url` | OpenAI-compatible API base URL. | No | `https://omni.freakma.com/v1` | +| `model` | Model name / ID to query. | No | `auto/fast` | +| `version` | Target version string (e.g. `1.8.0`) to include in prompt. | No | `""` | +| `from_ref` | Starting git ref or tag. Auto-discovered if omitted. | No | Auto-detected | +| `to_ref` | Ending git ref. | No | `HEAD` | +| `tag_pattern` | Pattern to match prior release tags. | No | `v*` | +| `max_commits` | Max commits to scan if no prior tag found. | No | `50` | +| `fallback_notes` | Fallback text if LLM call fails or times out. | No | `Maintenance updates and performance improvements.` | +| `timeout` | LLM HTTP request timeout in seconds. | No | `25` | +| `temperature` | Sampling temperature for the model. | No | `0.2` | +| `system_prompt` | Custom system prompt override. | No | Standard release notes prompt | +| `save_to_file` | File path to write the release notes to. | No | `""` | + +## Outputs + +| Output | Description | +| :--- | :--- | +| `notes` | The generated Markdown release notes string. | +| `notes_file` | Absolute path to the saved notes file (if `save_to_file` was used). | + +--- + +## Using with Local llama.cpp + +To route to a local `llama.cpp` instance (e.g. on your Mac mini runner or LAN): + +```yaml +- name: Generate Release Notes via llama.cpp + uses: https://git.mancinas.io/rmancinas/action-release-notes@main + with: + api_key: ${{ secrets.LLAMA_API_KEY }} + base_url: 'http://192.168.4.213:8080/v1' + model: 'qwen3.6-35b-a3b' + version: '1.8.0' +``` + +--- + +## Standalone / CLI Usage + +You can run the generator locally in any git repository without Actions: + +```bash +python3 scripts/generate_notes.py \ + --api-key "$OMNI_API_KEY" \ + --version "1.8.0" \ + --save-to-file "release_notes.md" +``` diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..e7ee52e --- /dev/null +++ b/action.yml @@ -0,0 +1,160 @@ +name: 'LLM Release Notes Generator' +description: 'Generate concise Markdown release notes from git commits using OmniRoute or OpenAI-compatible endpoints' +author: 'Ricardo Mancinas' + +inputs: + api_key: + description: 'API key for OmniRoute or OpenAI-compatible endpoint' + required: false + default: '' + base_url: + description: 'API Base URL' + required: false + default: 'https://omni.freakma.com/v1' + model: + description: 'Model ID to query' + required: false + default: 'auto/fast' + version: + description: 'Target release version (e.g. 1.8.0)' + required: false + default: '' + from_ref: + description: 'Starting ref/tag for commit range (defaults to auto-detected previous tag)' + required: false + default: '' + to_ref: + description: 'Ending ref for commit range' + required: false + default: 'HEAD' + tag_pattern: + description: 'Tag pattern to match for previous release (e.g. v*)' + required: false + default: 'v*' + max_commits: + description: 'Maximum commits to inspect if no tag range found' + required: false + default: '50' + fallback_notes: + description: 'Fallback release notes if LLM call fails or API key is absent' + required: false + default: 'Maintenance updates and performance improvements.' + timeout: + description: 'API request timeout in seconds' + required: false + default: '25' + temperature: + description: 'Sampling temperature for generation' + required: false + default: '0.2' + system_prompt: + description: 'Custom system prompt override' + required: false + default: '' + save_to_file: + description: 'Optional file path to write release notes to' + required: false + default: '' + +outputs: + notes: + description: 'Generated markdown release notes' + value: ${{ steps.generate.outputs.notes }} + notes_file: + description: 'Path to saved release notes file' + value: ${{ steps.generate.outputs.notes_file }} + +runs: + using: 'composite' + steps: + - name: Generate Release Notes + id: generate + shell: bash + env: + INPUT_API_KEY: ${{ inputs.api_key }} + INPUT_BASE_URL: ${{ inputs.base_url }} + INPUT_MODEL: ${{ inputs.model }} + INPUT_VERSION: ${{ inputs.version }} + INPUT_FROM_REF: ${{ inputs.from_ref }} + INPUT_TO_REF: ${{ inputs.to_ref }} + INPUT_TAG_PATTERN: ${{ inputs.tag_pattern }} + INPUT_MAX_COMMITS: ${{ inputs.max_commits }} + INPUT_FALLBACK_NOTES: ${{ inputs.fallback_notes }} + INPUT_TIMEOUT: ${{ inputs.timeout }} + INPUT_TEMPERATURE: ${{ inputs.temperature }} + INPUT_SYSTEM_PROMPT: ${{ inputs.system_prompt }} + INPUT_SAVE_TO_FILE: ${{ inputs.save_to_file }} + run: | + SCRIPT_PATH="${{ github.action_path }}/scripts/generate_notes.py" + if [[ -f "$SCRIPT_PATH" ]]; then + python3 "$SCRIPT_PATH" + else + python3 - << 'PYEOF' +import os, sys, subprocess, json, urllib.request, re, uuid + +api_key = os.environ.get("INPUT_API_KEY", "") +base_url = os.environ.get("INPUT_BASE_URL", "https://omni.freakma.com/v1").rstrip("/") +model = os.environ.get("INPUT_MODEL", "auto/fast") +version = os.environ.get("INPUT_VERSION", "") +from_ref = os.environ.get("INPUT_FROM_REF", "") +to_ref = os.environ.get("INPUT_TO_REF", "HEAD") +tag_pattern = os.environ.get("INPUT_TAG_PATTERN", "v*") +max_commits = int(os.environ.get("INPUT_MAX_COMMITS", "50")) +fallback = os.environ.get("INPUT_FALLBACK_NOTES", "Maintenance updates and performance improvements.") +timeout = int(os.environ.get("INPUT_TIMEOUT", "25")) +temp = float(os.environ.get("INPUT_TEMPERATURE", "0.2")) +sys_prompt = os.environ.get("INPUT_SYSTEM_PROMPT", "") +save_file = os.environ.get("INPUT_SAVE_TO_FILE", "") + +subprocess.run(["git", "fetch", "--tags", "--prune"], capture_output=True) + +if not from_ref: + code, out = subprocess.run(["git", "describe", "--tags", f"--match={tag_pattern}", "--exact-match", to_ref], capture_output=True, text=True).returncode, "" + if code == 0: + res = subprocess.run(["git", "describe", "--tags", f"--match={tag_pattern}", "--abbrev=0", f"{to_ref}^"], capture_output=True, text=True) + if res.returncode == 0 and res.stdout.strip(): + from_ref = res.stdout.strip() + if not from_ref: + res = subprocess.run(["git", "describe", "--tags", f"--match={tag_pattern}", "--abbrev=0", to_ref], capture_output=True, text=True) + if res.returncode == 0 and res.stdout.strip(): + from_ref = res.stdout.strip() + +commits = "" +if from_ref: + res = subprocess.run(["git", "log", f"{from_ref}..{to_ref}", "--pretty=format:- %s (%h)"], capture_output=True, text=True) + if res.returncode == 0: + commits = res.stdout.strip() +if not commits: + res = subprocess.run(["git", "log", f"-n{max_commits}", "--pretty=format:- %s (%h)", to_ref], capture_output=True, text=True) + if res.returncode == 0: + commits = res.stdout.strip() + +filtered = [l.strip() for l in commits.splitlines() if l.strip() and not re.search(r'chore(\(ci\))?:\s*(bump version|release)', l, re.I)] +commits_clean = "\n".join(filtered) if filtered else commits + +notes = fallback +if commits_clean and api_key: + default_sys = "You are an automated release notes generator. Group changes under '### Features', '### Bug Fixes', and '### Improvements'. Filter chores. Output ONLY valid Markdown release notes." + p = f"Generate concise release notes{' for v' + version if version else ''} based on commits:\n\n{commits_clean}" + payload = {"model": model, "messages": [{"role": "system", "content": sys_prompt or default_sys}, {"role": "user", "content": p}], "temperature": temp} + try: + req = urllib.request.Request(f"{base_url}/chat/completions", data=json.dumps(payload).encode("utf-8"), headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}) + with urllib.request.urlopen(req, timeout=timeout) as r: + c = json.loads(r.read().decode("utf-8"))["choices"][0]["message"]["content"] + c = re.sub(r'.*?', '', c, flags=re.DOTALL).strip() + notes = c if c else fallback + except Exception as e: + sys.stderr.write(f"LLM call failed: {e}\n") + +if save_file: + with open(save_file, "w", encoding="utf-8") as f: + f.write(notes + "\n") + +gh = os.environ.get("GITHUB_OUTPUT") +if gh and os.path.exists(os.path.dirname(gh) or "."): + d = f"EOF_{uuid.uuid4().hex}" + with open(gh, "a", encoding="utf-8") as f: + f.write(f"notes<<{d}\n{notes}\n{d}\nnotes_file={save_file}\n") +print(notes) +PYEOF + fi diff --git a/scripts/generate_notes.py b/scripts/generate_notes.py new file mode 100755 index 0000000..679074e --- /dev/null +++ b/scripts/generate_notes.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +""" +generate_notes.py - Generate Markdown release notes from git commits using OmniRoute or OpenAI-compatible endpoints. +Works standalone or as part of a Gitea / GitHub composite action. +""" + +import argparse +import json +import os +import re +import subprocess +import sys +import urllib.error +import urllib.request +import uuid + + +def parse_args(): + parser = argparse.ArgumentParser(description="Generate AI release notes from git commits.") + parser.add_argument("--api-key", default=os.environ.get("INPUT_API_KEY", os.environ.get("OMNI_API_KEY", ""))) + parser.add_argument("--base-url", default=os.environ.get("INPUT_BASE_URL", "https://omni.freakma.com/v1")) + parser.add_argument("--model", default=os.environ.get("INPUT_MODEL", "auto/fast")) + parser.add_argument("--version", default=os.environ.get("INPUT_VERSION", "")) + parser.add_argument("--from-ref", default=os.environ.get("INPUT_FROM_REF", "")) + parser.add_argument("--to-ref", default=os.environ.get("INPUT_TO_REF", "HEAD")) + parser.add_argument("--tag-pattern", default=os.environ.get("INPUT_TAG_PATTERN", "v*")) + parser.add_argument("--max-commits", type=int, default=int(os.environ.get("INPUT_MAX_COMMITS", "50"))) + parser.add_argument("--fallback-notes", default=os.environ.get("INPUT_FALLBACK_NOTES", "Maintenance updates and performance improvements.")) + parser.add_argument("--timeout", type=int, default=int(os.environ.get("INPUT_TIMEOUT", "25"))) + parser.add_argument("--temperature", type=float, default=float(os.environ.get("INPUT_TEMPERATURE", "0.2"))) + parser.add_argument("--system-prompt", default=os.environ.get("INPUT_SYSTEM_PROMPT", "")) + parser.add_argument("--save-to-file", default=os.environ.get("INPUT_SAVE_TO_FILE", "")) + return parser.parse_args() + + +def run_git(args): + try: + res = subprocess.run(["git"] + args, capture_output=True, text=True, check=False) + return res.returncode, res.stdout.strip(), res.stderr.strip() + except Exception as e: + return 1, "", str(e) + + +def find_previous_tag(to_ref, tag_pattern): + # 1. Check if to_ref itself is tagged with an exact tag matching tag_pattern: + if tag_pattern: + code, stdout, _ = run_git(["describe", "--tags", f"--match={tag_pattern}", "--exact-match", to_ref]) + if code == 0 and stdout: + # to_ref is an existing release tag (e.g. tag was just created on HEAD). + # The prior tag must come from to_ref's parent commit. + code_prev, prev_tag, _ = run_git(["describe", "--tags", f"--match={tag_pattern}", "--abbrev=0", f"{to_ref}^"]) + if code_prev == 0 and prev_tag: + return prev_tag + + # 2. to_ref is not an exact tag; find the most recent tag matching pattern reaching to_ref: + if tag_pattern: + code, stdout, _ = run_git(["describe", "--tags", f"--match={tag_pattern}", "--abbrev=0", to_ref]) + if code == 0 and stdout: + return stdout + + # 3. Fallback: try describe without tag pattern: + code, stdout, _ = run_git(["describe", "--tags", "--exact-match", to_ref]) + if code == 0 and stdout: + code_prev, prev_tag, _ = run_git(["describe", "--tags", "--abbrev=0", f"{to_ref}^"]) + if code_prev == 0 and prev_tag: + return prev_tag + + code, stdout, _ = run_git(["describe", "--tags", "--abbrev=0", to_ref]) + if code == 0 and stdout: + return stdout + + return None + + +def collect_commits(from_ref, to_ref, tag_pattern, max_commits): + # Ensure tags are up-to-date + run_git(["fetch", "--tags", "--prune"]) + + if not from_ref: + from_ref = find_previous_tag(to_ref, tag_pattern) + + commits = "" + if from_ref: + sys.stderr.write(f"[action-release-notes] Comparing commits from {from_ref} to {to_ref}...\n") + code, stdout, _ = run_git(["log", f"{from_ref}..{to_ref}", "--pretty=format:- %s (%h)"]) + if code == 0 and stdout: + commits = stdout + + if not commits: + sys.stderr.write(f"[action-release-notes] No range commits found or no prior tag; falling back to last {max_commits} commits on {to_ref}...\n") + code, stdout, _ = run_git(["log", f"-n{max_commits}", "--pretty=format:- %s (%h)", to_ref]) + if code == 0 and stdout: + commits = stdout + + if not commits: + return "" + + # Filter out noisy commits (version bump, chores, merge pull requests) + filtered = [] + for line in commits.splitlines(): + line_clean = line.strip() + if not line_clean: + continue + # Drop version bumps + if re.search(r'chore(\(ci\))?:\s*(bump version|release)', line_clean, re.I): + continue + filtered.append(line_clean) + + return "\n".join(filtered) if filtered else commits + + +def clean_llm_markdown(content): + # Remove thinking tags from reasoning models (e.g. Qwen, DeepSeek) + content = re.sub(r'.*?', '', content, flags=re.DOTALL).strip() + + # Strip markdown code fences if model wrapped entire answer + lines = content.splitlines() + if len(lines) >= 2: + if lines[0].strip().startswith("```"): + lines = lines[1:] + if lines and lines[-1].strip().startswith("```"): + lines = lines[:-1] + + return "\n".join(lines).strip() + + +def query_llm(args, commits_text): + if not args.api_key: + sys.stderr.write("[action-release-notes] Warning: No API key provided; using fallback release notes.\n") + return args.fallback_notes + + default_system = ( + "You are an automated release notes generator. Given git commit messages, " + "produce concise, structured Markdown release notes grouped into categories " + "like '### Features', '### Bug Fixes', and '### Improvements'. " + "Filter out chores and noise. Output ONLY valid Markdown release notes without " + "conversational preamble, thinking blocks, or code fences." + ) + system_prompt = args.system_prompt if args.system_prompt else default_system + + version_str = f" v{args.version}" if args.version else "" + user_prompt = f"Generate concise release notes for{version_str} based on the following git commits:\n\n{commits_text}" + + payload = { + "model": args.model, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt} + ], + "temperature": args.temperature + } + + url = f"{args.base_url.rstrip('/')}/chat/completions" + req = urllib.request.Request( + url, + data=json.dumps(payload).encode("utf-8"), + headers={ + "Authorization": f"Bearer {args.api_key}", + "Content-Type": "application/json", + "User-Agent": "gitea-action-release-notes" + } + ) + + try: + sys.stderr.write(f"[action-release-notes] Requesting release notes from {url} using model {args.model}...\n") + with urllib.request.urlopen(req, timeout=args.timeout) as response: + resp_bytes = response.read() + data = json.loads(resp_bytes.decode("utf-8")) + content = data["choices"][0]["message"]["content"] + cleaned = clean_llm_markdown(content) + if cleaned: + return cleaned + sys.stderr.write("[action-release-notes] Model returned empty content; using fallback.\n") + return args.fallback_notes + except Exception as e: + sys.stderr.write(f"[action-release-notes] LLM query failed ({e}); using fallback release notes.\n") + return args.fallback_notes + + +def main(): + args = parse_args() + commits = collect_commits(args.from_ref, args.to_ref, args.tag_pattern, args.max_commits) + + if not commits: + sys.stderr.write("[action-release-notes] No commits discovered; using fallback release notes.\n") + notes = args.fallback_notes + else: + notes = query_llm(args, commits) + + # Save to file if requested + notes_file_path = "" + if args.save_to_file: + try: + with open(args.save_to_file, "w", encoding="utf-8") as f: + f.write(notes + "\n") + notes_file_path = os.path.abspath(args.save_to_file) + sys.stderr.write(f"[action-release-notes] Notes saved to {notes_file_path}\n") + except Exception as e: + sys.stderr.write(f"[action-release-notes] Failed to save notes to file: {e}\n") + + # Write to GITHUB_OUTPUT if available + gh_output = os.environ.get("GITHUB_OUTPUT") + if gh_output and os.path.exists(os.path.dirname(gh_output) or "."): + delim = f"EOF_{uuid.uuid4().hex}" + try: + with open(gh_output, "a", encoding="utf-8") as f: + f.write(f"notes<<{delim}\n") + f.write(f"{notes}\n") + f.write(f"{delim}\n") + f.write(f"notes_file={notes_file_path}\n") + except Exception as e: + sys.stderr.write(f"[action-release-notes] Failed to write to GITHUB_OUTPUT: {e}\n") + + # Output notes to stdout for build log visibility + print(notes) + + +if __name__ == "__main__": + main()