diff --git a/.github/workflows/release-please-beta.yml b/.github/workflows/release-please-beta.yml index 701f9e5a..a06f1c93 100644 --- a/.github/workflows/release-please-beta.yml +++ b/.github/workflows/release-please-beta.yml @@ -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 }} + diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index f44b6cf8..60c2dd87 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -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 }} + diff --git a/docs/ci/whatsnew-highlights.yml b/.github/workflows/whatsnew-highlights.yml similarity index 57% rename from docs/ci/whatsnew-highlights.yml rename to .github/workflows/whatsnew-highlights.yml index c08c41c5..de262da3 100644 --- a/docs/ci/whatsnew-highlights.yml +++ b/.github/workflows/whatsnew-highlights.yml @@ -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 `` 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 +# `` 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') diff --git a/backend/__tests__/utils/whatsNew.test.js b/backend/__tests__/utils/whatsNew.test.js index 6e91a61a..d396059d 100644 --- a/backend/__tests__/utils/whatsNew.test.js +++ b/backend/__tests__/utils/whatsNew.test.js @@ -33,6 +33,21 @@ describe('parseWhatsNew', () => { ]); }); + it('decodes HTML entities release-please escapes into changelog text', () => { + const body = '### Features\n* **gallery:** supports A & B <tags> "quoted" ([#1](http://x))'; + expect(parseWhatsNew(body)).toEqual(['supports A & B "quoted"']); + }); + + it('trims a trailing "— implementation detail" clause to the headline', () => { + const body = '### Features\n* **gallery:** branded URL shortener — /s/<slug> 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']); diff --git a/backend/src/utils/whatsNew.js b/backend/src/utils/whatsNew.js index cf16b34e..796ba386 100644 --- a/backend/src/utils/whatsNew.js +++ b/backend/src/utils/whatsNew.js @@ -14,14 +14,35 @@ const MAX_BULLETS = 8; +/** + * Decode the handful of HTML entities release-please escapes into changelog + * text (a raw "/s/" in a commit subject lands as "/s/<slug>"). + * Without this the banner shows the literal entity, since React renders text + * nodes verbatim. `&` is decoded last so "&lt;" stays "<". + */ +function decodeEntities(s) { + return s + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/�*39;|�*27;|'/gi, "'") + .replace(/&/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/ 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(); }