Compare commits

...
2 Commits
Author SHA1 Message Date
Andrey Antukh 2f3d4a193c ♻️ Use consistent string library and add multi-version test
Address code review findings:

- Use str/split (cuerdas) consistently in extract-rocket-items
  instead of mixing cstr/split (clojure.string)
- Add parse-highlights-extracts-multiple-versions test to verify
  the parser correctly extracts 🚀 items from multiple
  versions in a single CHANGES.md body

AI-assisted-by: qwen3.7-plus
2026-09-07 10:36:03 +00:00
Andrey Antukh 65813d1181 ♻️ Consolidate HIGHLIGHTS.md into CHANGES.md 🚀 section
Eliminate the redundant HIGHLIGHTS.md file and make CHANGES.md
the single source of truth for version highlights.

- Add 🚀 section for 2.15.0 (MCP server integration)
- Add 4 missing highlight entries to 2.17.0 🚀 section
- Rewrite frontend parser to extract from CHANGES.md 🚀
  subsections instead of flat HIGHLIGHTS.md format
- Decouple parse-latest-released-version from highlights
  extraction so it works independently of 🚀 content
- Conditionally render highlights section in modal when non-empty
- Rewrite tests for new parser behavior (11 tests, 21 assertions)
- Delete HIGHLIGHTS.md and remove .gitignore exception
- Add step 8b to update-changelog skill for proactively
  proposing highlights during release workflows
- Add missing-highlights and missing-highlight-reference
  anomaly types to the changelog anomaly report script

Closes #11530

AI-assisted-by: qwen3.7-plus
2026-09-07 10:13:48 +00:00
6 changed files with 253 additions and 102 deletions

No files matched your search

-1
View File
@@ -24,7 +24,6 @@ opencode.json
!AGENTS.md
!CODE_OF_CONDUCT.md
!SECURITY.md
!HIGHLIGHTS.md
/*.png
/*.svg
/*.sql
+105 -14
View File
@@ -357,6 +357,39 @@ Insert the new version section right after the `# CHANGELOG` header (before
the previous version entry). Use the `edit` tool with enough context to make
a unique match.
### 8b. Propose and populate the `:rocket: Epics and highlights` subsection
After inserting the version section, proactively create or populate the
`### :rocket: Epics and highlights` subsection. This section surfaces the
most impactful changes for self-hosted users checking for updates.
**When to create:** If the version section does not already have a
`### :rocket: Epics and highlights` subsection, create one. Place it before
`### :sparkles:` (matching existing order in CHANGES.md).
**How to identify highlights:** Review the `:sparkles:` entries for the
version and select 25 of the most impactful/user-visible ones. Criteria:
- New user-visible features (not internal refactors)
- Significant capability additions
- Items that create "FOMO" for self-hosted users on older versions
**Use release notes as hints:** Check
`frontend/src/app/main/ui/releases/v2_<MINOR>.cljs` for the corresponding
version. The slide titles and feature descriptions there are curated
marketing content indicating what the team considers highlight-worthy. Match
those themes to changelog entries. Treat these files as optional hints — they
may not exist for every version.
**Format requirement:** Every `:rocket:` entry MUST follow the standard
changelog format with issue/PR references:
```
- <description> [#<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
```
An entry without references is an anomaly.
**Preserve existing entries:** If the `:rocket:` section already exists from
a prior run, preserve its entries. Do not remove or rewrite them.
### 9. Verify
Read the top of `CHANGES.md` and confirm:
@@ -468,9 +501,7 @@ Markdown viewer.
## What is an anomaly
**An anomaly is a milestone-mismatch between an issue and its referenced
PR.** It indicates that the changelog claim "this issue is fixed by this PR,
all in milestone M" is inconsistent with the actual milestone assignments.
There are exactly two types:
PR, or a missing/defective `:rocket:` entry.** There are four types:
1. **Issue is in the milestone, but its referenced PR is in a different
milestone (or has no milestone).** The changelog claims a fix in this
@@ -486,6 +517,12 @@ There are exactly two types:
PR that closes an issue with no milestone references an issue from
another (probably private) project; that is expected and the issue is
not part of this changelog. Do not report it.
3. **missing-highlights:** A released version section has no
`### :rocket: Epics and highlights` subsection. Every released version
should have at least one highlight entry.
4. **missing-highlight-reference:** A `:rocket:` entry lacks issue/PR
references. Every highlight entry must follow the standard changelog
format with `[#ISSUE]` and `(PR: [#PR])` links.
**Anything else is not an anomaly.** Other discrepancies (exclusion
labels on in-changelog issues, missing valid issues, unmerged PR
@@ -653,6 +690,35 @@ for pr_num in sorted(changelog_prs):
'issue_milestone': issue_ms, # may be None
})
# --- Type C: released version sections without :rocket: subsection ---
anomalies_c = [] # list of version strings
rocket_heading_re = re.compile(r'^### :rocket:', re.MULTILINE)
version_sections = re.split(r'(?=^## \d+\.\d+\.\d+)', content, flags=re.MULTILINE)
for vs in version_sections:
m = re.match(r'^## (\d+\.\d+\.\d+)(.*)', vs)
if not m: continue
ver, suffix = m.group(1), m.group(2)
if 'unreleased' in suffix.lower(): continue
if not rocket_heading_re.search(vs):
anomalies_c.append(ver)
# --- Type D: :rocket: entries without issue/PR references ---
anomalies_d = [] # list of dicts: {version, line}
issue_ref_re = re.compile(r'\[#\d+\]\(https://github\.com/penpot/penpot/issues/\d+\)')
for vs in version_sections:
m = re.match(r'^## (\d+\.\d+\.\d+)(.*)', vs)
if not m: continue
ver = m.group(1)
rocket_match = rocket_heading_re.search(vs)
if not rocket_match: continue
# Extract the :rocket: subsection body (up to next ### or ##)
rocket_body = vs[rocket_match.end():]
rocket_body = re.split(r'(?m)^#{2,3}\s', rocket_body)[0]
for line in rocket_body.splitlines():
line = line.strip()
if line.startswith('- ') and not issue_ref_re.search(line):
anomalies_d.append({'version': ver, 'line': line[:100]})
# --- Write report ---
def fmt_ms(ms):
return ms if ms else "_none_"
@@ -664,21 +730,27 @@ with open(OUTPUT, 'w') as f:
n_a = len(anomalies_a)
n_b = len(anomalies_b)
n_c = len(anomalies_c)
n_d = len(anomalies_d)
f.write('## Summary\n\n')
f.write(f'- **Issue in {MILESTONE}, referenced PR in different milestone or no milestone:** {n_a}\n')
f.write(f'- **PR in {MILESTONE}, closing issue in a different milestone:** {n_b}\n')
f.write(f'- **Total anomalies:** {n_a + n_b}\n\n')
f.write(f'- **Released version missing :rocket: section:** {n_c}\n')
f.write(f'- **:rocket: entry without issue/PR references:** {n_d}\n')
f.write(f'- **Total anomalies:** {n_a + n_b + n_c + n_d}\n\n')
# --- Anomalies section ---
if n_a or n_b:
if n_a or n_b or n_c or n_d:
f.write('## Anomalies\n\n')
f.write('These are milestone mismatches between an issue in the changelog '
'and its referenced PR (or vice-versa). The changelog claim '
'"this issue is fixed by this PR, all in this milestone" is '
'inconsistent with the actual milestone assignments. '
'Resolve by either updating the milestone on the issue/PR or '
'removing the misleading entry from the changelog.\n\n')
if n_a or n_b:
f.write('These are milestone mismatches between an issue in the changelog '
'and its referenced PR (or vice-versa). The changelog claim '
'"this issue is fixed by this PR, all in this milestone" is '
'inconsistent with the actual milestone assignments. '
'Resolve by either updating the milestone on the issue/PR or '
'removing the misleading entry from the changelog.\n\n')
if n_a:
f.write(f'### Issue in {MILESTONE}, PR in different milestone or no milestone\n\n')
@@ -709,8 +781,24 @@ with open(OUTPUT, 'w') as f:
badge = '🔴' if e['issue_milestone'] is None else '⚠️'
f.write(f' - {badge} Closing {issue_link(e["issue"])} is in milestone **{ms_label}** (expected: {MILESTONE})\n')
f.write('\n')
if n_c:
f.write(f'\n### Released version missing :rocket: section\n\n')
f.write('These released versions have no `### :rocket: Epics and highlights` subsection. '
'Add highlights to help self-hosted users understand what they are missing.\n\n')
for ver in anomalies_c:
f.write(f'- Version **{ver}**\n')
f.write('\n')
if n_d:
f.write(f'\n### :rocket: entry without issue/PR references\n\n')
f.write('These highlight entries lack the required issue/PR references. '
'Add `[#ISSUE](...)` and `(PR: [#PR](...))` links.\n\n')
for d in anomalies_d:
f.write(f'- **{d["version"]}**: `{d["line"]}`\n')
f.write('\n')
else:
f.write('✅ No anomalies found. All (issue, PR) pairs in the changelog have aligned milestone assignments.\n\n')
f.write('✅ No anomalies found. All (issue, PR) pairs in the changelog have aligned milestone assignments, and all released versions have properly referenced :rocket: entries.\n\n')
# --- Context ---
f.write('---\n\n')
@@ -726,8 +814,7 @@ print(f"Anomaly report written to {OUTPUT}")
PYEOF
```
This generates `CHANGES-ISSUES.md` containing **only the anomalies**
milestone mismatches between issues and their referenced PRs:
This generates `CHANGES-ISSUES.md` containing **only the anomalies**:
1. **Issue in milestone, referenced PR in different milestone or no milestone**
the changelog claims a fix here, but the PR is released elsewhere.
@@ -736,6 +823,10 @@ milestone mismatches between issues and their referenced PRs:
(An issue with *no* milestone belongs to another, probably private,
project — milestones are only required on the "Main" project — so it is
neither an anomaly nor a changelog candidate.)
3. **missing-highlights** — a released version section has no
`### :rocket: Epics and highlights` subsection.
4. **missing-highlight-reference** — a `:rocket:` entry lacks issue/PR
references.
**Rule violations are not in the report** — they are workflow errors the
LLM must fix directly in `CHANGES.md` during step 6a (pre-flight checks).
+8
View File
@@ -185,6 +185,10 @@
### :rocket: Epics and highlights
- Render prototype viewer with WASM (Skia) engine instead of SVG [#10037](https://github.com/penpot/penpot/issues/10037) (PR: [#10038](https://github.com/penpot/penpot/pull/10038))
- Add layer blur effect for visual depth and styling [#9844](https://github.com/penpot/penpot/issues/9844) (PR: [#10034](https://github.com/penpot/penpot/pull/10034))
- Render guides in WebGL for consistent viewer performance [#10068](https://github.com/penpot/penpot/issues/10068) (PR: [#10014](https://github.com/penpot/penpot/pull/10014))
- Add concurrency limiter and status indicators for MCP server communications [#9493](https://github.com/penpot/penpot/issues/9493) (PR: [#9748](https://github.com/penpot/penpot/pull/9748))
- Add typography token row to multiselected texts for better token visibility [#9336](https://github.com/penpot/penpot/issues/9336) (PR: [#9128](https://github.com/penpot/penpot/pull/9128))
### :sparkles: New features & Enhancements
@@ -539,6 +543,10 @@
## 2.15.0
### :rocket: Epics and highlights
- Add MCP server integration for AI-assisted design workflows [#9174](https://github.com/penpot/penpot/issues/9174) (PR: [#9032](https://github.com/penpot/penpot/pull/9032), [#9321](https://github.com/penpot/penpot/pull/9321))
### :sparkles: New features & Enhancements
- Add MCP server integration [GH #9174](https://github.com/penpot/penpot/issues/9174)
-26
View File
@@ -1,26 +0,0 @@
# HIGHLIGHTS
## 2.17.0
- Background blur is here
- WebGL rendering gets stronger
- MCP connection status and more
- Design tokens: more visible, more user-friendly
## 2.16.0
- Design tokens in the design panel
- Major community contributions
- WebGL rendering (beta)
## 2.15.0
- AI connected to real design context
- Multi-directional workflow
- Your stack, your model, your decision
@@ -29,8 +29,8 @@
(def ^:private telemetry-origin
"check-updates-modal")
(def ^:private highlights-md-url
"https://raw.githubusercontent.com/penpot/penpot/refs/heads/staging/HIGHLIGHTS.md")
(def ^:private changelog-md-url
"https://raw.githubusercontent.com/penpot/penpot/refs/heads/staging/CHANGES.md")
(def ^:private changelog-url
"https://github.com/penpot/penpot/blob/staging/CHANGES.md")
@@ -44,6 +44,9 @@
(def ^:private bullet-re
#"^- (.+)$")
(def ^:private rocket-heading-re
#"(?m)^### :rocket: Epics and highlights\s*$")
(defn- unreleased-suffix?
[suffix]
(str/includes? (str/lower (or suffix "")) "unreleased"))
@@ -56,9 +59,21 @@
item)))
vec))
(defn- extract-rocket-items
"Given a version section body, find the :rocket: subsection and
extract its bullet items. Returns nil if no :rocket: or empty."
[version-body]
(when-let [[_ rocket-body] (str/split version-body rocket-heading-re 2)]
(let [subsection (-> (str/split rocket-body #"(?m)(?=^#{2,3}\s)") first)]
(when subsection
(let [items (parse-section-items subsection)]
(when (seq items) items))))))
(defn parse-highlights
"Parse HIGHLIGHTS.md into released version sections with bullet items.
Skips Unreleased headings. Preserves file order (newest first)."
"Parse CHANGES.md into released version sections with bullet items from
the :rocket: Epics and highlights subsection. Skips Unreleased headings,
versions without a :rocket: section, and versions with an empty one.
Preserves file order (newest first)."
[markdown]
(if-not (string? markdown)
[]
@@ -66,14 +81,19 @@
(keep (fn [part]
(when-let [[_ version suffix] (re-find version-heading-re part)]
(when-not (unreleased-suffix? suffix)
{:version version
:items (parse-section-items part)}))))
(when-let [items (extract-rocket-items part)]
{:version version
:items items})))))
vec)))
(defn parse-latest-released-version
"Return the first non-unreleased `## X.Y.Z` heading from a highlights body."
"Return the first non-unreleased `## X.Y.Z` heading from a CHANGES.md body."
[markdown]
(some-> (parse-highlights markdown) first :version))
(when (string? markdown)
(some->> (re-seq version-heading-re markdown)
(keep (fn [[_ version suffix]]
(when-not (unreleased-suffix? suffix) version)))
first)))
(defn highlights-until-installed
"Keep released sections newer than the installed version (major, minor,
@@ -101,8 +121,8 @@
(defn- handle-highlights
[installed body]
(let [sections (parse-highlights body)
latest (some-> sections first :version)]
(let [latest (parse-latest-released-version body)
sections (parse-highlights body)]
(cond
(nil? latest)
(show-unable-dialog)
@@ -124,7 +144,7 @@
(->> (http/send! {:method :get
:mode :cors
:omit-default-headers true
:uri highlights-md-url
:uri changelog-md-url
:response-type :text})
(rx/subs!
(fn [response]
@@ -280,23 +300,25 @@
:class (stl/css :modal-msg)}
(tr "dashboard.check-updates.available-message")]
[:> text* {:as "h3"
:typography t/headline-small
:class (stl/css :highlights-title)}
(tr "dashboard.check-updates.highlights-title")]
(when (seq highlights)
[:*
[:> text* {:as "h3"
:typography t/headline-small
:class (stl/css :highlights-title)}
(tr "dashboard.check-updates.highlights-title")]
[:div {:class (stl/css :highlights-scroll)}
(for [section highlights]
(let [version (:version section)
items (:items section)]
[:div {:key version
:class (stl/css :highlights-section)}
[:div {:class (stl/css :highlights-version)} version]
[:ul {:class (stl/css :highlights-list)}
(for [item items]
[:li {:key item
:class (stl/css :highlights-item)}
item])]]))]]
[:div {:class (stl/css :highlights-scroll)}
(for [section highlights]
(let [version (:version section)
items (:items section)]
[:div {:key version
:class (stl/css :highlights-section)}
[:div {:class (stl/css :highlights-version)} version]
[:ul {:class (stl/css :highlights-list)}
(for [item items]
[:li {:key item
:class (stl/css :highlights-item)}
item])]]))]])]
[:div {:class (stl/css :modal-footer :modal-footer-available)}
[:> button* {:variant "secondary"
@@ -10,30 +10,49 @@
[app.main.ui.dashboard.check-updates :as dcu]
[cljs.test :as t :include-macros true]))
(def ^:private sample-highlights
(str "# HIGHLIGHTS\n"
(def ^:private sample-changes
(str "# CHANGELOG\n"
"\n"
"## 2.18.0 (Unreleased)\n"
"\n"
"- To do\n"
"### :sparkles: New features & Enhancements\n"
"\n"
"## 2.17.2\n"
"- Something WIP\n"
"\n"
"- Background blur is here\n"
"- WebGL rendering gets stronger\n"
"## 2.17.0\n"
"\n"
"## 2.17.1\n"
"### :rocket: Epics and highlights\n"
"\n"
"- MCP connection status and more\n"
"- Design tokens: more visible, more user-friendly\n"))
"- Background blur [#9844](https://github.com/penpot/penpot/issues/9844) (PR: [#10034](https://github.com/penpot/penpot/pull/10034))\n"
"- WebGL rendering [#10068](https://github.com/penpot/penpot/issues/10068) (PR: [#10014](https://github.com/penpot/penpot/pull/10014))\n"
"\n"
"### :sparkles: New features & Enhancements\n"
"\n"
"- Other stuff\n"
"\n"
"## 2.16.0\n"
"\n"
"### :rocket: Epics and highlights\n"
"\n"
"### :sparkles: New features & Enhancements\n"
"\n"
"- Tokens stuff\n"
"\n"
"## 2.15.0\n"
"\n"
"### :sparkles: New features & Enhancements\n"
"\n"
"- MCP server\n"))
(t/deftest parse-latest-released-version-skips-unreleased
(t/is (= "2.17.2" (dcu/parse-latest-released-version sample-highlights))))
;; --- parse-latest-released-version ---
(t/deftest parse-latest-released-version-from-changes
(t/is (= "2.17.0" (dcu/parse-latest-released-version sample-changes))))
(t/deftest parse-latest-released-version-first-released
(t/is (= "2.17.2"
(t/is (= "2.17.0"
(dcu/parse-latest-released-version
"## 2.17.2\n\n- Fix\n\n## 2.17.1\n\n- Fix\n"))))
"## 2.17.0\n\n### :sparkles:\n\n- Fix\n\n## 2.16.0\n\n### :sparkles:\n\n- Fix\n"))))
(t/deftest parse-latest-released-version-only-unreleased
(t/is (nil? (dcu/parse-latest-released-version
@@ -41,30 +60,68 @@
(t/deftest parse-latest-released-version-empty
(t/is (nil? (dcu/parse-latest-released-version "")))
(t/is (nil? (dcu/parse-latest-released-version "# HIGHLIGHTS\n"))))
(t/is (nil? (dcu/parse-latest-released-version "# CHANGELOG\n"))))
(t/deftest parse-highlights-skips-unreleased-and-collects-bullets
(t/is (= [{:version "2.17.2"
:items ["Background blur is here"
"WebGL rendering gets stronger"]}
{:version "2.17.1"
:items ["MCP connection status and more"
"Design tokens: more visible, more user-friendly"]}]
(dcu/parse-highlights sample-highlights))))
;; --- parse-highlights ---
(t/deftest parse-highlights-extracts-rocket-items
(let [result (dcu/parse-highlights sample-changes)]
(t/is (= [{:version "2.17.0"
:items ["Background blur [#9844](https://github.com/penpot/penpot/issues/9844) (PR: [#10034](https://github.com/penpot/penpot/pull/10034))"
"WebGL rendering [#10068](https://github.com/penpot/penpot/issues/10068) (PR: [#10014](https://github.com/penpot/penpot/pull/10014))"]}]
result))))
(t/deftest parse-highlights-skips-empty-rocket
;; 2.16.0 has an empty :rocket: section — should be skipped
(let [result (dcu/parse-highlights sample-changes)]
(t/is (not (some #(= "2.16.0" (:version %)) result)))))
(t/deftest parse-highlights-skips-missing-rocket
;; 2.15.0 has no :rocket: section — should be skipped
(let [result (dcu/parse-highlights sample-changes)]
(t/is (not (some #(= "2.15.0" (:version %)) result)))))
(t/deftest parse-highlights-skips-unreleased
;; 2.18.0 (Unreleased) should be skipped
(let [result (dcu/parse-highlights sample-changes)]
(t/is (not (some #(= "2.18.0" (:version %)) result)))))
(t/deftest parse-highlights-empty-input
(t/is (= [] (dcu/parse-highlights "")))
(t/is (= [] (dcu/parse-highlights nil)))
(t/is (= [] (dcu/parse-highlights 42))))
(t/deftest parse-highlights-extracts-multiple-versions
(let [input (str "## 2.17.0\n\n### :rocket: Epics and highlights\n\n"
"- Feature A [#1](https://github.com/penpot/penpot/issues/1)\n\n"
"## 2.16.0\n\n### :rocket: Epics and highlights\n\n"
"- Feature B [#2](https://github.com/penpot/penpot/issues/2)\n")
result (dcu/parse-highlights input)]
(t/is (= 2 (count result)))
(t/is (= "2.17.0" (:version (first result))))
(t/is (= "2.16.0" (:version (second result))))))
;; --- version-compare ---
(t/deftest version-compare
(t/is (zero? (v/compare-versions "2.17.1" "2.17.1")))
(t/is (pos? (v/compare-versions "2.17.2" "2.17.1")))
(t/is (neg? (v/compare-versions "2.17.1" "2.17.2")))
(t/is (zero? (v/compare-versions "2.17.0" "2.17.0")))
(t/is (pos? (v/compare-versions "2.17.0" "2.16.0")))
(t/is (neg? (v/compare-versions "2.16.0" "2.17.0")))
(t/is (pos? (v/compare-versions "3.0.0" "2.99.99")))
(t/is (neg? (v/compare-versions "2.17.2" "2.17.10"))))
(t/is (neg? (v/compare-versions "2.17.0" "2.17.10"))))
;; --- highlights-until-installed ---
(t/deftest highlights-until-installed
(let [sections (dcu/parse-highlights sample-highlights)]
(t/is (= [{:version "2.17.2"
:items ["Background blur is here"
"WebGL rendering gets stronger"]}]
(dcu/highlights-until-installed sections "2.17.1")))
(t/is (= [] (dcu/highlights-until-installed sections "2.17.2")))
(t/is (= sections (dcu/highlights-until-installed sections "2.16.0")))
(t/is (= [] (dcu/highlights-until-installed sections "2.17.10")))))
(let [sections (dcu/parse-highlights sample-changes)]
;; installed 2.16.0 → shows 2.17.0 highlights
(t/is (= [{:version "2.17.0"
:items ["Background blur [#9844](https://github.com/penpot/penpot/issues/9844) (PR: [#10034](https://github.com/penpot/penpot/pull/10034))"
"WebGL rendering [#10068](https://github.com/penpot/penpot/issues/10068) (PR: [#10014](https://github.com/penpot/penpot/pull/10014))"]}]
(dcu/highlights-until-installed sections "2.16.0")))
;; installed 2.17.0 → no newer highlights
(t/is (= [] (dcu/highlights-until-installed sections "2.17.0")))
;; installed older version → shows all available highlights
(t/is (= sections (dcu/highlights-until-installed sections "2.14.0")))
;; installed newer than any highlight → empty
(t/is (= [] (dcu/highlights-until-installed sections "2.99.0")))))