docs(releases): narrative, prose-driven website release posts (#2764)
* docs(releases): reformat website release posts in MLflow narrative style The website /releases/<version> post was a verbatim mechanical mirror of the GitHub Release body (emoji bullets). Reformat it into the narrative, prose-driven style of mlflow.org/releases, while leaving the GitHub Release notes untouched. - New release-post-formatter agent rewrites the curated release body into an intro summary + numbered prose feature sections (no emoji), preserving every PR ref and inventing nothing. Same tools-less security posture as release-notes-drafter. - publish-changelog.yml gains the LLM machinery to run it, degrading to the raw release body on any failure, plus a workflow_dispatch dry_run mode that renders and prints the page (log + job summary) without minting a token or opening a PR. - release_to_mdx.py adds MLflow-style site chrome the release body can't carry: a byline (date + read time + author) and a "What's Next" footer. Keeps the exact _Released <date>_ token the site index reads. Co-authored-by: Isaac Signed-off-by: Serena Ruan <serena.rxy@gmail.com> * refactor(ci): extract shared LLM-runner into a composite action The publish-changelog release-post formatter reused ~150 lines of the draft-release-notes LLM machinery (uv, venv cache, Claude CLI, provider config, agent run, output secret-scan) verbatim. Extract it into a .github/actions/run-omnigent-agent composite action and call it from both workflows, so the runner scaffold lives in one place. - The action takes a workdir input so it works whether the repo is checked out at the workspace root (draft-release-notes) or in an omnigent/ subdir (publish-changelog), driving the venv path, cache key, and uv --project/agent paths off it. - The action now always secret-scans the agent output when it runs (gated by the caller's creds check), instead of the old outcome=='success' gate that also skipped the scan when the step was skipped. - Callers keep their own prompt-build, output-extract/fallback, and artifact redaction; only the shared scaffold moved. Co-authored-by: Isaac Signed-off-by: Serena Ruan <serena.rxy@gmail.com> --------- Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
name: Run Omnigent agent
|
||||
description: >-
|
||||
Set up uv + the Claude Code CLI + an Omnigent gateway provider, run a tools-less
|
||||
Omnigent agent headlessly on a prompt file, and secret-scan its output. Shared by
|
||||
the release-cut (draft-release-notes) and publish (publish-changelog) workflows so
|
||||
the LLM-runner scaffold lives in one place. The caller mints no write-token until
|
||||
after this action returns — the only secret here is the model key.
|
||||
|
||||
inputs:
|
||||
workdir:
|
||||
description: >-
|
||||
Repo checkout dir relative to the workspace (`.` when checked out at the
|
||||
root, `omnigent` when checked out into a subdir). Drives the venv path, the
|
||||
cache key, and the uv --project / agent paths.
|
||||
required: false
|
||||
default: "."
|
||||
agent:
|
||||
description: Agent directory name under <workdir>/.github/agents/.
|
||||
required: true
|
||||
prompt-file:
|
||||
description: Absolute path to the file holding the agent prompt.
|
||||
required: true
|
||||
output-file:
|
||||
description: Absolute path to write the agent's stdout to.
|
||||
required: true
|
||||
stderr-file:
|
||||
description: Absolute path to write the agent's stderr to.
|
||||
required: false
|
||||
default: /tmp/omnigent-agent-stderr.log
|
||||
gateway-base-url:
|
||||
description: Base URL of the Anthropic-compatible gateway.
|
||||
required: true
|
||||
llm-api-key:
|
||||
description: Model API key (referenced by the provider config, used to scan output).
|
||||
required: true
|
||||
claude-code-version:
|
||||
description: "@anthropic-ai/claude-code npm version to install."
|
||||
required: false
|
||||
default: 2.1.170
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Cache virtualenv
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ${{ inputs.workdir }}/.venv
|
||||
key: venv-${{ runner.os }}-${{ hashFiles(format('{0}/.python-version', inputs.workdir)) }}-${{ hashFiles(format('{0}/uv.lock', inputs.workdir)) }}
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
working-directory: ${{ inputs.workdir }}
|
||||
run: uv sync --extra all --extra dev
|
||||
|
||||
- name: Install Claude Code CLI
|
||||
shell: bash
|
||||
env:
|
||||
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
|
||||
CLAUDE_CODE_VERSION: ${{ inputs.claude-code-version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p "${GITHUB_WORKSPACE}/.cc-cli"
|
||||
cd "${GITHUB_WORKSPACE}/.cc-cli"
|
||||
npm install --ignore-scripts --no-audit --no-fund "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}"
|
||||
node node_modules/@anthropic-ai/claude-code/install.cjs
|
||||
echo "${GITHUB_WORKSPACE}/.cc-cli/node_modules/.bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Write Omnigent provider config
|
||||
shell: bash
|
||||
env:
|
||||
GATEWAY_BASE_URL: ${{ inputs.gateway-base-url }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p "$HOME/.omnigent"
|
||||
python3 -c "
|
||||
import pathlib, os, json
|
||||
gw = os.environ['GATEWAY_BASE_URL']
|
||||
cfg = {'providers': {'databricks-gateway': {
|
||||
'kind': 'gateway', 'default': ['anthropic'],
|
||||
'anthropic': {
|
||||
'base_url': gw + '/anthropic',
|
||||
'api_key_ref': 'env:LLM_API_KEY',
|
||||
'models': {'default': 'databricks-claude-opus-4-8'},
|
||||
}}}}
|
||||
pathlib.Path.home().joinpath('.omnigent', 'config.yaml').write_text(json.dumps(cfg, indent=2))
|
||||
"
|
||||
|
||||
- name: Run the agent
|
||||
shell: bash
|
||||
env:
|
||||
LLM_API_KEY: ${{ inputs.llm-api-key }}
|
||||
WORKDIR: ${{ inputs.workdir }}
|
||||
AGENT: ${{ inputs.agent }}
|
||||
PROMPT_FILE: ${{ inputs.prompt-file }}
|
||||
OUTPUT_FILE: ${{ inputs.output-file }}
|
||||
STDERR_FILE: ${{ inputs.stderr-file }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
project="${GITHUB_WORKSPACE}/${WORKDIR}"
|
||||
prompt="$(cat "$PROMPT_FILE")"
|
||||
uv run --project "$project" omnigent run \
|
||||
"${project}/.github/agents/${AGENT}" \
|
||||
-p "$prompt" --no-session \
|
||||
2>"$STDERR_FILE" | tee "$OUTPUT_FILE" \
|
||||
|| { echo "::warning::agent exited non-zero — caller keeps its fallback"; cat "$STDERR_FILE"; }
|
||||
|
||||
# ::add-mask:: only redacts rendered logs; the caller still redacts artifact
|
||||
# files before upload. This aborts the run outright if the key leaked to stdout.
|
||||
- name: Scan agent output for secrets
|
||||
shell: bash
|
||||
env:
|
||||
LLM_API_KEY: ${{ inputs.llm-api-key }}
|
||||
OUTPUT_FILE: ${{ inputs.output-file }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -n "${LLM_API_KEY:-}" ] && grep -qF "$LLM_API_KEY" "$OUTPUT_FILE" 2>/dev/null; then
|
||||
echo "::error::Agent output contains LLM_API_KEY — aborting."
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,125 @@
|
||||
# release-post-formatter — a tiny, single-purpose agent used by the
|
||||
# publish-changelog.yml workflow at release-PUBLISH time.
|
||||
#
|
||||
# The GitHub Release notes stay as they are (crisp emoji bullets under
|
||||
# "Major new features" / "Bug fixes"). This agent rewrites that already-curated,
|
||||
# already-published body into the narrative, prose-driven post the WEBSITE wants
|
||||
# (mlflow.org/releases/<v>-style): a short intro summary paragraph followed by a
|
||||
# handful of numbered feature sections written as prose, no emoji. It changes
|
||||
# only presentation for the site — it must invent no new facts, PRs, or versions.
|
||||
# It has NO tools and NO sub-agents: it reflows the text it is handed, so a run is
|
||||
# fast, cheap, and can't hang. The workflow drops its output into the site page;
|
||||
# on any failure the publish step falls back to the raw release body.
|
||||
#
|
||||
# Run headlessly: omnigent run .github/agents/release-post-formatter -p "<release body>" --no-session
|
||||
#
|
||||
# Security posture (mirrors release-notes-drafter / doc-drafter):
|
||||
# - Runs only on an ALREADY-PUBLISHED, maintainer-curated release body, at
|
||||
# publish time on the trusted default branch.
|
||||
# - The only secret in this process's env is LLM_API_KEY. The omnigent-site
|
||||
# write-token that opens the release-post PR is minted by the workflow AFTER
|
||||
# this agent finishes, so it never coexists with model input.
|
||||
# - Its input is maintainer-written release text — a prose prompt-injection
|
||||
# surface. The workflow secret-scans this agent's stdout for LLM_API_KEY
|
||||
# (abort on hit) and redacts artifacts, and a human reviews the site PR before
|
||||
# merge. Honest residual risk: with network allowed and LLM_API_KEY in env, an
|
||||
# injection could drive an outbound request that exfiltrates the key; a
|
||||
# network-denying sandbox is the real mitigation but is not used here for the
|
||||
# same CI-fragility reason documented in release-notes-drafter/config.yaml.
|
||||
# We accept the same residual risk already accepted for release-notes-drafter.
|
||||
|
||||
spec_version: 1
|
||||
name: release-post-formatter
|
||||
description: >-
|
||||
Rewrites an already-curated GitHub Release body into the narrative, prose-driven
|
||||
post the website uses: a short intro summary paragraph followed by numbered
|
||||
feature sections written as prose (no emoji). Presentation only — invents no new
|
||||
facts. Emits the post between RELEASE_POST markers. No tools, no sub-agents.
|
||||
|
||||
executor:
|
||||
type: omnigent
|
||||
config:
|
||||
harness: claude-sdk
|
||||
|
||||
prompt: |
|
||||
You are the Omnigent release-POST formatter. A version has just been published.
|
||||
You are given the curated GitHub Release body — crisp, emoji-prefixed bullets
|
||||
under headings like "Major new features" and "Bug fixes", each bullet ending
|
||||
with the contributing PR references, e.g. `(#123, #456)`.
|
||||
|
||||
Your job: rewrite that SAME content into the narrative, prose-driven post the
|
||||
website uses, matching the style of the MLflow 3.14.0 release post
|
||||
(https://mlflow.org/releases/3.14.0/) — see "The MLflow 3.14.0 style" below for
|
||||
exactly what that means. This is a PRESENTATION change only. You must NOT invent
|
||||
features, versions, flag names, or PR numbers, and you must NOT drop a highlight
|
||||
— every bullet in the input maps into the post.
|
||||
|
||||
## Output shape (STRICT)
|
||||
Emit ONLY the following, between the markers, and nothing else — no preamble, no
|
||||
top-level `# vX.Y.Z` heading (the site adds the title, date, and byline):
|
||||
|
||||
<!-- RELEASE_POST -->
|
||||
<one-paragraph intro summary — see the intro pattern below; flowing prose, no
|
||||
bullet list>
|
||||
|
||||
## 1. <Feature title — a noun phrase naming the feature/command>
|
||||
|
||||
<2-3 short paragraphs of prose, present tense, addressing the reader as "you".
|
||||
Open with the friction/capability, then the solution. Optionally start a
|
||||
paragraph with a **bold lead-in**. Keep the contributing PR refs from the source,
|
||||
e.g. (#123, #456), at the end of the relevant sentence or paragraph.>
|
||||
|
||||
## 2. <Next feature title>
|
||||
|
||||
<...>
|
||||
|
||||
## Fixes & improvements
|
||||
|
||||
<collapse the "Bug fixes" bullets into one short prose paragraph (or a few), each
|
||||
keeping its PR refs — do NOT number these; only the marquee features are numbered>
|
||||
|
||||
Full Changelog: <copy the exact `Full Changelog:` line from the input, verbatim>
|
||||
<!-- /RELEASE_POST -->
|
||||
|
||||
## The MLflow 3.14.0 style (match this)
|
||||
- Intro: ONE flowing paragraph. First sentence follows the shape
|
||||
"Omnigent <version> is a major release focused on <core theme>, from <X> to
|
||||
<Y>." — where the theme and X→Y span come from the actual highlights. Then a
|
||||
sentence or two weaving in the handful of biggest features as prose. No bullets.
|
||||
(MLflow's reads: "MLflow 3.14.0 is a major release focused on closing the GenAI
|
||||
development loop, from getting an app instrumented in the first place to
|
||||
reviewing, testing, and iterating on it.")
|
||||
- Headings: `## N. <Title>` — a NOUN PHRASE that names the feature, and includes
|
||||
the concrete command/flag/UI name when the input gives one (e.g.
|
||||
"## 1. Omnigent for iOS", "## 2. Generic ACP harness"). Never a verb phrase.
|
||||
- Sections: 2-3 short paragraphs. Present tense. Address the reader as "you" /
|
||||
"your". Lead with the problem or the capability, THEN the mechanics — e.g.
|
||||
MLflow opens a section with "Getting an app onto MLflow observability should
|
||||
not mean reading setup guides". A **bold lead-in** at a paragraph start is
|
||||
welcome but optional.
|
||||
- Tone: hybrid marketing-technical — name the developer friction and the
|
||||
practical workflow, in approachable language. Conversational, not academic and
|
||||
not cutesy.
|
||||
- Do NOT fabricate MLflow-style extras the input can't support: no invented code
|
||||
blocks, no `` screenshot placeholders, no "Learn more in <link>" callouts
|
||||
unless a real link is present in the input. Prose only.
|
||||
|
||||
## Fidelity rules
|
||||
- Number ONLY the marquee "Major new features" (and any "Breaking changes",
|
||||
which keep their own numbered section titled `## N. Breaking change: <what>`).
|
||||
Bug fixes collapse into a single un-numbered "Fixes & improvements" section.
|
||||
- Preserve EVERY PR reference exactly as given — `(#123)` stays `(#123)`. Cite no
|
||||
PR that is not in the input.
|
||||
- Prose, not bullets: turn "- 📱 X — Y (#1)" into a sentence. Drop ALL emoji.
|
||||
- Keep Omnigent's voice; never invent facts, versions, or flag names.
|
||||
- If the input has no "Full Changelog:" line, omit that line.
|
||||
- Do NOT reproduce the "Thanks to our community" note — the site page does not
|
||||
carry it (the GitHub Release keeps it).
|
||||
|
||||
## Security
|
||||
You are running in CI with access to secrets. Never echo secrets, tokens, or
|
||||
credentials, and never make outbound network calls.
|
||||
|
||||
## Act in the same turn you announce
|
||||
Never end a turn after only saying what you will do — produce the RELEASE_POST
|
||||
block in the same turn.
|
||||
@@ -3,14 +3,18 @@
|
||||
|
||||
The website's `/releases/<version>` post is the *concise, curated highlights* —
|
||||
it mirrors the GitHub Release notes a maintainer already hand-edits in the
|
||||
draft→edit→publish flow. This module does a small mechanical transform so that
|
||||
GitHub-flavoured Markdown renders cleanly through the site's MDX pipeline
|
||||
(`@next/mdx`):
|
||||
draft→edit→publish flow. The narrative body (intro summary + numbered feature
|
||||
sections) is written by the release-notes-drafter agent; this module does a small
|
||||
mechanical transform so that GitHub-flavoured Markdown renders cleanly through the
|
||||
site's MDX pipeline (`@next/mdx`), and wraps it in the site-only chrome the
|
||||
release body can't carry (a byline and a "What's Next" footer):
|
||||
|
||||
* unwrap `<https://…>` autolinks (angle brackets are JSX in MDX),
|
||||
* escape `{`, `}`, and any remaining `<` so MDX never tries to evaluate them,
|
||||
* linkify bare `#1234` references to the PR,
|
||||
* prepend a `# vX.Y.Z` heading + a `_Released <date>_` line the index reads.
|
||||
* prepend a `# vX.Y.Z` heading + a byline (`_Released <date>_` — the exact token
|
||||
the site index reads — plus estimated read time and author),
|
||||
* append a static "What's Next" footer (install command + community links).
|
||||
|
||||
No LLM, no reflow — the curation is the human's; we only make it MDX-safe.
|
||||
"""
|
||||
@@ -28,6 +32,22 @@ _AUTOLINK_RE = re.compile(r"<((?:https?://)[^>\s]+)>")
|
||||
# "# Title" (space after #), so they never match.
|
||||
_PR_REF_RE = re.compile(r"(?<![\w/#])#(\d+)\b")
|
||||
|
||||
AUTHOR = "Omnigent maintainers"
|
||||
# Average adult reading speed; used only for the "N min read" byline estimate.
|
||||
_WORDS_PER_MINUTE = 200
|
||||
|
||||
WHATS_NEXT = """## What's Next
|
||||
|
||||
Install or upgrade Omnigent:
|
||||
|
||||
```bash
|
||||
uv tool install --python 3.12 omnigent # or: pip install "omnigent"
|
||||
```
|
||||
|
||||
- Star the project and file issues on [GitHub](https://github.com/omnigent-ai/omnigent).
|
||||
- Join the conversation on our [Discord](https://discord.gg/omnigent).
|
||||
- Browse the [docs](https://omnigent.ai/docs) to go deeper."""
|
||||
|
||||
|
||||
def mdx_escape(text: str) -> str:
|
||||
"""Make GitHub-flavoured Markdown safe to parse as MDX."""
|
||||
@@ -44,6 +64,12 @@ def linkify_pr_refs(text: str, repo: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _read_time_minutes(text: str) -> int:
|
||||
"""Estimate reading time in whole minutes (>=1) from a word count."""
|
||||
words = len(text.split())
|
||||
return max(1, round(words / _WORDS_PER_MINUTE))
|
||||
|
||||
|
||||
def release_body_to_mdx(tag: str, date: str, body: str, repo: str) -> str:
|
||||
"""Render the MDX page for one release."""
|
||||
transformed = linkify_pr_refs(mdx_escape(body or ""), repo)
|
||||
@@ -52,8 +78,13 @@ def release_body_to_mdx(tag: str, date: str, body: str, repo: str) -> str:
|
||||
+ tag
|
||||
+ ". Edit the GitHub Release, not this file. */}"
|
||||
)
|
||||
header = f"{comment}\n\n# {tag}\n\n_Released {date}_\n\n"
|
||||
return header + transformed.strip() + "\n"
|
||||
# Byline mirrors the MLflow release-post layout: keep the exact
|
||||
# `_Released <date>_` token the site index regex reads, then append the
|
||||
# read-time estimate and author on the same line.
|
||||
minutes = _read_time_minutes(transformed)
|
||||
byline = f"_Released {date}_ · {minutes} min read · {AUTHOR}"
|
||||
header = f"{comment}\n\n# {tag}\n\n{byline}\n\n"
|
||||
return header + transformed.strip() + "\n\n" + WHATS_NEXT + "\n"
|
||||
|
||||
|
||||
def _tag_date(tag: str) -> str:
|
||||
|
||||
@@ -199,52 +199,6 @@ jobs:
|
||||
echo "available=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Set up uv
|
||||
if: steps.guard.outputs.proceed == 'true' && steps.creds.outputs.available == 'true'
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Cache virtualenv
|
||||
if: steps.guard.outputs.proceed == 'true' && steps.creds.outputs.available == 'true'
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: .venv
|
||||
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('uv.lock') }}
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.guard.outputs.proceed == 'true' && steps.creds.outputs.available == 'true'
|
||||
run: uv sync --extra all --extra dev
|
||||
|
||||
- name: Install Claude Code CLI
|
||||
if: steps.guard.outputs.proceed == 'true' && steps.creds.outputs.available == 'true'
|
||||
env:
|
||||
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
|
||||
run: |
|
||||
mkdir -p "${GITHUB_WORKSPACE}/.cc-cli" && cd "${GITHUB_WORKSPACE}/.cc-cli"
|
||||
npm install --ignore-scripts --no-audit --no-fund @anthropic-ai/claude-code@2.1.170
|
||||
node node_modules/@anthropic-ai/claude-code/install.cjs
|
||||
echo "${GITHUB_WORKSPACE}/.cc-cli/node_modules/.bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Write Omnigent provider config
|
||||
if: steps.guard.outputs.proceed == 'true' && steps.creds.outputs.available == 'true'
|
||||
env:
|
||||
GATEWAY_BASE_URL: ${{ secrets.GATEWAY_BASE_URL }}
|
||||
run: |
|
||||
mkdir -p "$HOME/.omnigent"
|
||||
python3 -c "
|
||||
import pathlib, os, json
|
||||
gw = os.environ['GATEWAY_BASE_URL']
|
||||
cfg = {'providers': {'databricks-gateway': {
|
||||
'kind': 'gateway', 'default': ['anthropic'],
|
||||
'anthropic': {
|
||||
'base_url': gw + '/anthropic',
|
||||
'api_key_ref': 'env:LLM_API_KEY',
|
||||
'models': {'default': 'databricks-claude-opus-4-8'},
|
||||
}}}}
|
||||
pathlib.Path.home().joinpath('.omnigent', 'config.yaml').write_text(json.dumps(cfg, indent=2))
|
||||
"
|
||||
|
||||
- name: Build drafter prompt
|
||||
if: steps.guard.outputs.proceed == 'true' && steps.creds.outputs.available == 'true'
|
||||
env:
|
||||
@@ -277,30 +231,20 @@ jobs:
|
||||
pathlib.Path("/tmp/draft_prompt.txt").write_text(prompt)
|
||||
PYEOF
|
||||
|
||||
# Runs the tools-less drafter and secret-scans its output; the mechanical
|
||||
# scaffold (already in /tmp/release_notes.md) is the fallback if it can't run.
|
||||
# Checked out at the workspace root, so the action's workdir is the default.
|
||||
- name: Run release-notes drafter
|
||||
id: draft
|
||||
if: steps.guard.outputs.proceed == 'true' && steps.creds.outputs.available == 'true'
|
||||
env:
|
||||
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
prompt="$(cat /tmp/draft_prompt.txt)"
|
||||
uv run --project "${GITHUB_WORKSPACE}" omnigent run \
|
||||
"${GITHUB_WORKSPACE}/.github/agents/release-notes-drafter" \
|
||||
-p "$prompt" --no-session \
|
||||
2>draft-stderr.log | tee /tmp/draft_out.txt \
|
||||
|| { echo "::warning::drafter exited non-zero — keeping mechanical draft"; cat draft-stderr.log; }
|
||||
|
||||
- name: Scan drafter output for secrets
|
||||
if: steps.draft.outcome == 'success'
|
||||
env:
|
||||
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -n "${LLM_API_KEY:-}" ] && grep -qF "$LLM_API_KEY" /tmp/draft_out.txt 2>/dev/null; then
|
||||
echo "::error::Drafter output contains LLM_API_KEY — aborting."
|
||||
exit 1
|
||||
fi
|
||||
uses: ./.github/actions/run-omnigent-agent
|
||||
with:
|
||||
agent: release-notes-drafter
|
||||
prompt-file: /tmp/draft_prompt.txt
|
||||
output-file: /tmp/draft_out.txt
|
||||
stderr-file: /tmp/draft-stderr.log
|
||||
gateway-base-url: ${{ secrets.GATEWAY_BASE_URL }}
|
||||
llm-api-key: ${{ secrets.LLM_API_KEY }}
|
||||
|
||||
- name: Extract synthesized notes (fall back to mechanical)
|
||||
if: steps.guard.outputs.proceed == 'true' && steps.creds.outputs.available == 'true'
|
||||
@@ -455,7 +399,7 @@ jobs:
|
||||
python3 - <<'PYEOF'
|
||||
import os, pathlib
|
||||
key = os.environ.get("LLM_API_KEY", "")
|
||||
for f in ["draft-stderr.log", "/tmp/draft_out.txt", "/tmp/draft_prompt.txt",
|
||||
for f in ["/tmp/draft-stderr.log", "/tmp/draft_out.txt", "/tmp/draft_prompt.txt",
|
||||
"/tmp/release_notes.md"]:
|
||||
p = pathlib.Path(f)
|
||||
if not p.is_file() or not key:
|
||||
@@ -472,7 +416,7 @@ jobs:
|
||||
with:
|
||||
name: draft-release-notes-${{ steps.guard.outputs.tag }}-${{ github.run_id }}
|
||||
path: |
|
||||
draft-stderr.log
|
||||
/tmp/draft-stderr.log
|
||||
/tmp/draft_out.txt
|
||||
/tmp/release_notes.md
|
||||
/tmp/mechanical_notes.md
|
||||
|
||||
@@ -17,6 +17,11 @@ name: Publish Changelog
|
||||
# Cross-repo writes can't use the workflow's own GITHUB_TOKEN (scoped to this
|
||||
# repo), so we mint a short-lived token from the omnigent-ci GitHub App scoped to
|
||||
# omnigent-site — the same App used by sync-openapi-to-site.yml.
|
||||
#
|
||||
# Manual dispatch with `dry_run: true` previews only — it renders the site page
|
||||
# and prints it to the run log + job summary, mints no token, and opens no PR (so
|
||||
# it runs from a fork too). Use it to eyeball the narrative reflow before a real
|
||||
# publish.
|
||||
|
||||
on:
|
||||
release:
|
||||
@@ -27,6 +32,13 @@ on:
|
||||
description: Final release tag to (re)publish, e.g. v0.3.0
|
||||
required: true
|
||||
type: string
|
||||
dry_run:
|
||||
description: >-
|
||||
Preview only: render the site page and print it to the run log +
|
||||
job summary, but do NOT mint a token or open any PR.
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -42,6 +54,7 @@ jobs:
|
||||
outputs:
|
||||
tag: ${{ steps.r.outputs.tag }}
|
||||
is_final: ${{ steps.r.outputs.is_final }}
|
||||
dry_run: ${{ steps.r.outputs.dry_run }}
|
||||
steps:
|
||||
- name: Resolve tag and finality
|
||||
id: r
|
||||
@@ -49,11 +62,18 @@ jobs:
|
||||
EVENT_TAG: ${{ github.event.release.tag_name }}
|
||||
PRERELEASE: ${{ github.event.release.prerelease }}
|
||||
INPUT_TAG: ${{ inputs.tag }}
|
||||
# Only a manual dispatch can request dry-run; a real published release
|
||||
# is never a preview.
|
||||
INPUT_DRY_RUN: ${{ inputs.dry_run }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag="${INPUT_TAG:-$EVENT_TAG}"
|
||||
echo "tag=${tag}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
dry_run=false
|
||||
[ "${INPUT_DRY_RUN}" = "true" ] && dry_run=true
|
||||
echo "dry_run=${dry_run}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
is_final=true
|
||||
# Only final vX.Y.Z tags; exclude rc/dev/alpha/beta and the
|
||||
# event's prerelease flag.
|
||||
@@ -68,22 +88,29 @@ jobs:
|
||||
is_final=false
|
||||
fi
|
||||
echo "is_final=${is_final}" >> "$GITHUB_OUTPUT"
|
||||
echo "Resolved tag=${tag} is_final=${is_final}" | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
echo "Resolved tag=${tag} is_final=${is_final} dry_run=${dry_run}" | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
publish:
|
||||
name: Open release-post PR (omnigent-site)
|
||||
needs: resolve
|
||||
runs-on: ubuntu-latest
|
||||
# Canonical repo only; skip cleanly where the App isn't configured.
|
||||
# Canonical repo only, and only where the App is configured — EXCEPT a
|
||||
# dry-run, which just renders + prints (no cross-repo write), so it runs
|
||||
# anywhere (e.g. a fork) to preview the page.
|
||||
if: >-
|
||||
needs.resolve.outputs.is_final == 'true' &&
|
||||
github.repository == 'omnigent-ai/omnigent' &&
|
||||
vars.OMNIGENT_BOT_APP_ID != ''
|
||||
(needs.resolve.outputs.dry_run == 'true' ||
|
||||
(github.repository == 'omnigent-ai/omnigent' &&
|
||||
vars.OMNIGENT_BOT_APP_ID != ''))
|
||||
env:
|
||||
TAG: ${{ needs.resolve.outputs.tag }}
|
||||
DRY_RUN: ${{ needs.resolve.outputs.dry_run }}
|
||||
SOURCE_REPO: ${{ github.repository }}
|
||||
SITE_REPO: ${{ github.repository_owner }}/omnigent-site
|
||||
RELEASES_BRANCH: auto/releases/${{ needs.resolve.outputs.tag }}
|
||||
OMNIGENT_SKIP_WEB_UI: "true"
|
||||
UV_INDEX_URL: https://pypi.org/simple
|
||||
PIP_INDEX_URL: https://pypi.org/simple
|
||||
steps:
|
||||
- name: Checkout omnigent (for the render script)
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
@@ -95,7 +122,7 @@ jobs:
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Render the curated release body to MDX
|
||||
- name: Read the curated release body
|
||||
working-directory: omnigent
|
||||
# The release read uses the workflow's own token (scoped to this repo);
|
||||
# only the cross-repo site write needs the App token, minted below.
|
||||
@@ -110,14 +137,112 @@ jobs:
|
||||
--json body,publishedAt > /tmp/release.json
|
||||
jq -r '.body' /tmp/release.json > /tmp/release_body.md
|
||||
date="$(jq -r '.publishedAt' /tmp/release.json | cut -c1-10)"
|
||||
echo "RELEASE_DATE=${date}" >> "$GITHUB_ENV"
|
||||
# The site post is the narrative reflow; the raw release body is the
|
||||
# fallback if the formatter agent is unavailable or fails.
|
||||
cp /tmp/release_body.md /tmp/site_body.md
|
||||
|
||||
# --- AI reflow (primary; degrades to the raw release body) ---
|
||||
# The GitHub Release itself is untouched — this rewrites its body into the
|
||||
# website's narrative, prose-driven post for the site page ONLY.
|
||||
- name: Check LLM credentials
|
||||
id: creds
|
||||
env:
|
||||
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
|
||||
run: |
|
||||
if [ -z "${LLM_API_KEY:-}" ]; then
|
||||
echo "::warning::No LLM credentials — publishing the raw release body."
|
||||
echo "available=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "::add-mask::${LLM_API_KEY}"
|
||||
echo "available=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Build formatter prompt
|
||||
if: steps.creds.outputs.available == 'true'
|
||||
env:
|
||||
TAG: ${{ env.TAG }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 -u <<'PYEOF'
|
||||
import os, pathlib
|
||||
tag = os.environ["TAG"]
|
||||
# `omnigent run -p` passes the whole prompt as one argv string, capped at
|
||||
# ~128 KiB on Linux (MAX_ARG_STRLEN). A release body is far smaller, but
|
||||
# cap defensively; the raw body is the fallback if the agent can't run.
|
||||
MAX = 100_000
|
||||
body = pathlib.Path("/tmp/release_body.md").read_text(encoding="utf-8", errors="replace")[:MAX]
|
||||
prompt = f"""Reformat the {tag} release notes into the website post.
|
||||
|
||||
## Curated GitHub Release body (rewrite this — do not add or drop facts)
|
||||
{body}
|
||||
|
||||
Produce the RELEASE_POST block per your instructions."""
|
||||
pathlib.Path("/tmp/format_prompt.txt").write_text(prompt)
|
||||
PYEOF
|
||||
|
||||
# Runs the tools-less formatter and secret-scans its output; degrades to the
|
||||
# raw release body (below) if the agent can't run. Omnigent is checked out
|
||||
# into omnigent/, so the action's workdir is that subdir.
|
||||
- name: Run release-post formatter
|
||||
id: format
|
||||
if: steps.creds.outputs.available == 'true'
|
||||
uses: ./omnigent/.github/actions/run-omnigent-agent
|
||||
with:
|
||||
workdir: omnigent
|
||||
agent: release-post-formatter
|
||||
prompt-file: /tmp/format_prompt.txt
|
||||
output-file: /tmp/format_out.txt
|
||||
stderr-file: /tmp/format-stderr.log
|
||||
gateway-base-url: ${{ secrets.GATEWAY_BASE_URL }}
|
||||
llm-api-key: ${{ secrets.LLM_API_KEY }}
|
||||
|
||||
- name: Extract narrative post (fall back to raw body)
|
||||
if: steps.creds.outputs.available == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 -u <<'PYEOF'
|
||||
import pathlib, re
|
||||
raw = pathlib.Path("/tmp/format_out.txt").read_text(encoding="utf-8", errors="replace") \
|
||||
if pathlib.Path("/tmp/format_out.txt").is_file() else ""
|
||||
m = re.search(r"<!--\s*RELEASE_POST\s*-->(.*?)<!--\s*/RELEASE_POST\s*-->", raw, re.DOTALL)
|
||||
post = (m.group(1).strip() if m else "")
|
||||
if post:
|
||||
pathlib.Path("/tmp/site_body.md").write_text(post + "\n")
|
||||
print("Using AI-formatted narrative release post.")
|
||||
else:
|
||||
print("::warning::No RELEASE_POST block parsed — publishing the raw release body.")
|
||||
PYEOF
|
||||
|
||||
- name: Render the release post to MDX
|
||||
working-directory: omnigent
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p /tmp/site_page
|
||||
python3 .github/scripts/changelog/release_to_mdx.py \
|
||||
--tag "$TAG" --repo "$SOURCE_REPO" --date "$date" \
|
||||
--body-file /tmp/release_body.md \
|
||||
--tag "$TAG" --repo "$SOURCE_REPO" --date "$RELEASE_DATE" \
|
||||
--body-file /tmp/site_body.md \
|
||||
--out "/tmp/site_page/page.mdx"
|
||||
|
||||
# Dry-run stops here: print the generated page (and the intermediate
|
||||
# narrative body) to the log and the job summary. No token is minted and no
|
||||
# PR is opened — every step below is gated on DRY_RUN != 'true'.
|
||||
- name: Preview rendered page (dry-run)
|
||||
if: env.DRY_RUN == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
{
|
||||
echo "## Dry-run — release post for \`${TAG}\` at \`/releases/${VERSION}\`"
|
||||
echo "### Narrative body (pre-MDX)"
|
||||
echo '```markdown'; cat /tmp/site_body.md; echo '```'
|
||||
echo "### Rendered \`app/releases/${VERSION}/page.mdx\`"
|
||||
echo '```mdx'; cat /tmp/site_page/page.mdx; echo '```'
|
||||
} | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
echo "Dry-run: no token minted, no PR opened."
|
||||
|
||||
- name: Mint App token (omnigent-site)
|
||||
id: app-token
|
||||
if: env.DRY_RUN != 'true'
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
app-id: ${{ vars.OMNIGENT_BOT_APP_ID }}
|
||||
@@ -126,6 +251,7 @@ jobs:
|
||||
repositories: omnigent-site
|
||||
|
||||
- name: Checkout omnigent-site (sync target)
|
||||
if: env.DRY_RUN != 'true'
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
repository: ${{ env.SITE_REPO }}
|
||||
@@ -133,6 +259,7 @@ jobs:
|
||||
path: site
|
||||
|
||||
- name: Open or update the release-post PR (omnigent-site)
|
||||
if: env.DRY_RUN != 'true'
|
||||
working-directory: site
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
@@ -158,7 +285,7 @@ jobs:
|
||||
echo "Release-post PR already open for ${RELEASES_BRANCH} — force-push updated it."
|
||||
exit 0
|
||||
fi
|
||||
body="$(printf 'Publishes the **%s** release post at `/releases/%s`, mirroring the curated GitHub Release notes.\n\nGenerated by omnigent `.github/workflows/publish-changelog.yml`. Edit the GitHub Release, not this file.' "$TAG" "$VERSION")"
|
||||
body="$(printf 'Publishes the **%s** release post at `/releases/%s` — the curated GitHub Release notes reformatted into the site'"'"'s narrative, prose-driven style.\n\nGenerated by omnigent `.github/workflows/publish-changelog.yml`. Edit the GitHub Release, not this file.' "$TAG" "$VERSION")"
|
||||
gh pr create \
|
||||
--repo "$SITE_REPO" \
|
||||
--base main \
|
||||
@@ -173,6 +300,7 @@ jobs:
|
||||
# when the branch doesn't exist or carries nothing beyond main (e.g. a patch
|
||||
# release with no staged docs).
|
||||
- name: Open docs-branch → main PR (omnigent-site)
|
||||
if: env.DRY_RUN != 'true'
|
||||
working-directory: site
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
@@ -206,3 +334,38 @@ jobs:
|
||||
--head "$DOCS_BRANCH" \
|
||||
--title "docs: publish ${VERSION%.*} docs to the live site" \
|
||||
--body "$body"
|
||||
|
||||
# ::add-mask:: redacts rendered logs, not artifact files — scrub the key
|
||||
# from artifacts (incl. the unscanned stderr) before upload.
|
||||
- name: Redact secrets from artifacts
|
||||
if: always() && steps.creds.outputs.available == 'true'
|
||||
env:
|
||||
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[ -n "${LLM_API_KEY:-}" ] || exit 0
|
||||
python3 - <<'PYEOF'
|
||||
import os, pathlib
|
||||
key = os.environ.get("LLM_API_KEY", "")
|
||||
for f in ["/tmp/format-stderr.log", "/tmp/format_out.txt",
|
||||
"/tmp/format_prompt.txt", "/tmp/site_body.md"]:
|
||||
p = pathlib.Path(f)
|
||||
if not p.is_file() or not key:
|
||||
continue
|
||||
t = p.read_text(encoding="utf-8", errors="replace")
|
||||
if key in t:
|
||||
p.write_text(t.replace(key, "***REDACTED***"), encoding="utf-8")
|
||||
print(f"redacted key from {f}")
|
||||
PYEOF
|
||||
|
||||
- name: Upload logs on failure
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: publish-changelog-${{ env.TAG }}-${{ github.run_id }}
|
||||
path: |
|
||||
/tmp/format-stderr.log
|
||||
/tmp/format_out.txt
|
||||
/tmp/site_body.md
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -48,6 +49,25 @@ def test_release_body_to_mdx_structure() -> None:
|
||||
page = mod.release_body_to_mdx("v0.3.0", "2026-06-27", body, "omnigent-ai/omnigent")
|
||||
assert page.startswith("{/* Auto-generated")
|
||||
assert "# v0.3.0" in page
|
||||
# The site index regex reads the exact `_Released <date>_` token; the byline
|
||||
# appends read time + author on the same line.
|
||||
assert "_Released 2026-06-27_" in page
|
||||
assert "min read" in page
|
||||
assert mod.AUTHOR in page
|
||||
assert "### Major new features" in page
|
||||
assert "/pull/1132" in page # PR refs linkified
|
||||
# The MLflow-style "What's Next" footer is appended after the body.
|
||||
assert "## What's Next" in page
|
||||
assert "uv tool install" in page
|
||||
|
||||
|
||||
def test_release_date_token_matches_site_index_regex() -> None:
|
||||
# lib/releases.js extracts the date with /_Released\s+(\d{4}-\d{2}-\d{2})_/;
|
||||
# the byline must keep that token intact so the index/sidebar still find it.
|
||||
page = mod.release_body_to_mdx("v0.3.0", "2026-06-27", "hi", "o/o")
|
||||
assert re.search(r"_Released\s+(\d{4}-\d{2}-\d{2})_", page).group(1) == "2026-06-27"
|
||||
|
||||
|
||||
def test_read_time_is_at_least_one_minute() -> None:
|
||||
assert mod._read_time_minutes("") == 1
|
||||
assert mod._read_time_minutes("word " * 600) == 3
|
||||
|
||||
Reference in New Issue
Block a user