feat: initial implementation of reusable LLM release notes action
This commit is contained in:
1 parent
c0cc91c359
commit
e188bd69ba
3 files changed
+487
-1
No files matched your search
@@ -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 `<think>...</think>` 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"
|
||||
```
|
||||
+160
@@ -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'<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
|
||||
Executable
+219
@@ -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'<think>.*?</think>', '', 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()
|
||||
Reference in new issue
Block a user