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: if version: target_tag = version if version.startswith("v") else f"v{version}" res = subprocess.run(["git", "describe", "--tags", f"--match={tag_pattern}", "--abbrev=0", f"{target_tag}^"], capture_output=True, text=True) if res.returncode == 0 and res.stdout.strip(): from_ref = res.stdout.strip() 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