161 lines
6.4 KiB
YAML
161 lines
6.4 KiB
YAML
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'<think>.*?</think>', '', 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
|