Merge pull request #703 from Luca-Timo/ci/whatsnew-highlights-workflow

ci(whatsnew): generate release highlights via GitHub Models
This commit is contained in:
Paul Nothaft
2026-06-30 22:33:00 +02:00
committed by GitHub
5 changed files with 96 additions and 25 deletions
+13
View File
@@ -35,3 +35,16 @@ jobs:
echo "" >> $GITHUB_STEP_SUMMARY
echo "Docker images will be built and tagged with this beta version." >> $GITHUB_STEP_SUMMARY
# Best-effort "What's New" highlights on the freshly-created release. Runs in
# this same workflow run (not a `release:` trigger) because release-please
# creates the release with GITHUB_TOKEN, which never starts new workflow runs.
whatsnew:
needs: release-please
if: ${{ needs.release-please.outputs.release_created }}
permissions:
contents: write # edit the release body
models: read # GitHub Models (free tier)
uses: ./.github/workflows/whatsnew-highlights.yml
with:
tag: ${{ needs.release-please.outputs.tag_name }}
+13
View File
@@ -34,3 +34,16 @@ jobs:
echo "" >> $GITHUB_STEP_SUMMARY
echo "Docker images will be built and tagged with this version." >> $GITHUB_STEP_SUMMARY
# Best-effort "What's New" highlights on the freshly-created release. Runs in
# this same workflow run (not a `release:` trigger) because release-please
# creates the release with GITHUB_TOKEN, which never starts new workflow runs.
whatsnew:
needs: release-please
if: ${{ needs.release-please.outputs.release_created }}
permissions:
contents: write # edit the release body
models: read # GitHub Models (free tier)
uses: ./.github/workflows/whatsnew-highlights.yml
with:
tag: ${{ needs.release-please.outputs.tag_name }}
@@ -1,41 +1,49 @@
# What's New highlights — GitHub Models release step
# What's New highlights — GitHub Models release step (reusable)
#
# DRAFT — move this into `.github/workflows/` on the repo that publishes the
# canonical releases the app reads (PicPeak/picpeak). It is kept under docs/
# here so it does NOT auto-run on the fork.
# Called by the release-please workflows AFTER a release is created
# (release-please.yml for `stable`, release-please-beta.yml for `main`). It runs
# as a job in the SAME workflow run rather than on its own `release: published`
# trigger, because release-please creates the release with the default
# GITHUB_TOKEN and GitHub does not start new workflow runs from token-generated
# events — a standalone `release:` workflow would simply never fire.
#
# What it does: when a release is published (release-please), it condenses that
# release's "### Features" into <=8 short bullets via GitHub Models (free tier,
# `models: read`) and injects a `<!-- whatsnew -->` block at the top of the
# release notes. The picpeak app reads that block (utils/whatsNew.parseWhatsNew),
# and falls back to the raw Features list for releases without it — so this is
# purely a quality upgrade, never a hard dependency.
# What it does: condenses the new release's "### Features" into <=8 short
# bullets via GitHub Models (free tier, `models: read`) and injects a
# `<!-- whatsnew -->` block at the top of the release notes. The app reads that
# block (backend utils/whatsNew.parseWhatsNew) and falls back to the raw
# Features list for releases without it — so this is purely a quality upgrade,
# never a hard dependency. Failure is isolated by `continue-on-error` + the
# deterministic fallback below, so it can never break a release.
#
# GitHub Models is OPTIONAL. If it is disabled/unavailable for the org the AI
# step fails soft (continue-on-error) and the deterministic fallback produces
# the bullets instead — the feature works either way, Models just polishes them.
#
# Validated end-to-end on a fork (extract -> openai/gpt-4o-mini -> inject into
# real release notes; app parseWhatsNew() reads the block back). The one thing
# that can't be checked from a fork: GitHub Models must be enabled for the
# PicPeak ORG (owner setting). Failure is isolated anyway by `continue-on-error`
# + the deterministic fallback below, so it can never break a release.
# real release notes; app parseWhatsNew() reads the block back).
name: What's New highlights
on:
release:
types: [published]
permissions:
contents: write # to edit the release body
models: read # GitHub Models (free tier)
workflow_call:
inputs:
tag:
description: Release tag to annotate (e.g. v2.3.0)
required: true
type: string
jobs:
highlights:
runs-on: ubuntu-latest
permissions:
contents: write # to edit the release body
models: read # GitHub Models (free tier)
steps:
- name: Extract Features from the published release
id: feat
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.event.release.tag_name }}
TAG: ${{ inputs.tag }}
run: |
BODY=$(gh release view "$TAG" --json body -q .body)
FEATURES=$(printf '%s\n' "$BODY" | awk '/^#{2,4} +Features/{f=1;next} /^#{1,4} +\S/{f=0} f')
@@ -44,6 +52,7 @@ jobs:
- name: Summarize with GitHub Models
if: ${{ steps.feat.outputs.features != '' }}
id: ai
continue-on-error: true # Models may be disabled/unavailable for the org; fall back deterministically below
uses: actions/ai-inference@v1
with:
model: openai/gpt-4o-mini # catalog id (verified present); openai/gpt-4.1-mini or openai/gpt-5-nano also work
@@ -60,12 +69,12 @@ jobs:
continue-on-error: true # never let highlights break a release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.event.release.tag_name }}
TAG: ${{ inputs.tag }}
AI: ${{ steps.ai.outputs.response }}
FEATURES: ${{ steps.feat.outputs.features }}
run: |
BULLETS="$AI"
# Deterministic fallback if the model returned nothing.
# Deterministic fallback if the model returned nothing (e.g. Models not yet enabled).
if [ -z "$BULLETS" ]; then
BULLETS=$(printf '%s\n' "$FEATURES" | head -8 \
| sed -E 's/^\* \*\*[^:]+:\*\* */- /; s/ \(\[[^]]*\]\([^)]*\)\)//g')
+15
View File
@@ -33,6 +33,21 @@ describe('parseWhatsNew', () => {
]);
});
it('decodes HTML entities release-please escapes into changelog text', () => {
const body = '### Features\n* **gallery:** supports A &amp; B &lt;tags&gt; &quot;quoted&quot; ([#1](http://x))';
expect(parseWhatsNew(body)).toEqual(['supports A & B <tags> "quoted"']);
});
it('trims a trailing "— implementation detail" clause to the headline', () => {
const body = '### Features\n* **gallery:** branded URL shortener — /s/&lt;slug&gt; with OG injection ([#699](http://x))';
expect(parseWhatsNew(body)).toEqual(['branded URL shortener']);
});
it('leaves hyphenated words and dash-free bullets intact', () => {
const body = '### Features\n* **invoices:** mark-paid now supports bank transfer ([#2](http://x))';
expect(parseWhatsNew(body)).toEqual(['mark-paid now supports bank transfer']);
});
it('excludes Bug Fixes from the fallback', () => {
const body = '### Features\n* **a:** feature one\n### Bug Fixes\n* **b:** fix one';
expect(parseWhatsNew(body)).toEqual(['feature one']);
+23 -2
View File
@@ -14,14 +14,35 @@
const MAX_BULLETS = 8;
/**
* Decode the handful of HTML entities release-please escapes into changelog
* text (a raw "/s/<slug>" in a commit subject lands as "/s/&lt;slug&gt;").
* Without this the banner shows the literal entity, since React renders text
* nodes verbatim. `&amp;` is decoded last so "&amp;lt;" stays "&lt;".
*/
function decodeEntities(s) {
return s
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#0*39;|&#x0*27;|&apos;/gi, "'")
.replace(/&amp;/g, '&');
}
/** Strip list markers, conventional-commit scope, and trailing PR/sha links. */
function cleanBullet(line) {
return line
return decodeEntities(line
.replace(/^\s*[-*]\s+/, '') // "- " / "* " marker
.replace(/^\*\*([^:*]+):\*\*\s*/, '') // "**scope:** " prefix
.replace(/\s*\(\[[^\]]*\]\([^)]*\)\)/g, '') // " ([#41](url))" / " ([sha](url))"
.replace(/\s*\(#\d+\)/g, '') // bare " (#41)"
.replace(/`/g, '')
.replace(/`/g, ''))
// Drop a trailing "— implementation detail" clause so a release highlight
// reads as the headline ("branded URL shortener"), not the commit subject
// ("branded URL shortener — /s/<slug> with OG injection"). Em dash only, so
// hyphenated words ("mark-paid") are untouched. Skipped if it would empty
// the bullet (i.e. nothing before the dash).
.replace(/^(.+?\S)\s+—\s+.*$/, '$1')
.replace(/\s+/g, ' ')
.trim();
}