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:
Serena Ruan
2026-07-17 11:29:06 +08:00
committed by GitHub
parent e177a15ebe
commit 3cd67bc0ca
6 changed files with 490 additions and 83 deletions
@@ -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.
+37 -6
View File
@@ -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:
+13 -69
View File
@@ -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
+171 -8
View File
@@ -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
+20
View File
@@ -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