Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Update the project CHANGES.md with issues from a given GitHub milestone, with correct categorization and references.
.claude/skills/penpot-update-changelog/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-04 | ✗→✓ | ▲ Improved | 972% | 0% |
| case-05 | ✗→✓ | ▲ Improved | 806% | 0% |
| case-07 | ✗→✓ | ▲ Improved | 462% | 0% |
| case-09 | ✗→✓ | ▲ Improved | 679% | 0% |
| case-10 | ✗→✓ | ▲ Improved | 474% | 0% |
Update CHANGES.md with entries for all issues and PRs in a given GitHub milestone. Each entry references the user-facing issue (not the PR) as the primary link, with the fix PR inline on the same line.
to be refreshed
gh CLI authenticated (gh auth status)scripts/gh.py helper script availableThe version is typically a semver string like 2.15.3. Confirm with the user if not specified.
Use the helper script. It uses GraphQL for efficient single-pass fetching (closing PRs are included in the same query — no N+1):
bash# All closed issues (default) python3 scripts/gh.py issues "2.16.0" # Include open issues too python3 scripts/gh.py issues "2.16.0" --state all # Exclude entries that should not go in the changelog python3 scripts/gh.py issues "2.16.0" --exclude "release blocker,no changelog"
Exclusion rules (issue-level):
no changelog label — Chore/refactor work that doesn't need a changelog entryrelease blocker label — Blocked issues not yet ready for changelogTask issue type — Internal chores are not user-facing; automatically excluded by gh.py. Use --include-tasks to override.gh.py. This project-level status (independent of the GitHub issue state) indicates the issue was rejected from the release. Use --include-rejected to override.Exclusion rules (PR-level): In addition to issue-level exclusions, PRs with these labels should be excluded regardless of their linked issue's labels:
release blocker — PR is part of a pending release blocker batchno issue required — Trivial fix not tracked as an issueThe script outputs JSON with each entry containing number, title, state, issue_type, labels, closing_prs (the PRs that fix each issue), and project_status (the "Main" project board status, e.g. "Done", "Rejected", or null if not tracked in a project).
If updating from an existing CHANGES.md, find issues in the milestone that are NOT yet referenced in the changelog:
bashpython3 scripts/gh.py issues "2.16.0" --exclude "release blocker,no changelog" --compare CHANGES.md
This returns a filtered JSON array with only the missing issues.
> Note: The --compare flag checks issues only (via issue number > references in the changelog). To find merged PRs not yet referenced, > use the milestone PR cross-reference described in step 10 below.
When you need more context for specific PRs (e.g. to find the PR author for community contribution attribution, or to read the PR body for "Fixes/Closes #NNN" patterns):
bash# One or more PR numbers python3 scripts/gh.py prs 9179 9204 9311 # From a file python3 scripts/gh.py prs --file prs.txt # From stdin cat prs.txt | python3 scripts/gh.py prs --stdin
The prs command also supports listing all PRs in a milestone in one call:
bash# All merged PRs in a milestone (default) python3 scripts/gh.py prs --milestone "2.16.0" # All states (merged, open, closed) python3 scripts/gh.py prs --milestone "2.16.0" --state all
The prs command returns JSON with number, title, body, state, merged_at, author, labels, and closing_issues. PRs are fetched in batches of 50 via GraphQL to stay within API limits (milestone mode uses paginated GraphQL on the milestone's pullRequests connection).
You can also list all PRs in a milestone in a single call:
bash# All merged PRs in a milestone (default) python3 scripts/gh.py prs --milestone "2.16.0" # All states (merged, open, closed) python3 scripts/gh.py prs --milestone "2.16.0" --state all # Open PRs only python3 scripts/gh.py prs --milestone "2.16.0" --state open
The milestone path uses paginated GraphQL on the milestone's pullRequests connection (100 per page), avoiding one-by-one fetches.
Use the Issue Type field (GitHub's native issue type, exposed as issue_type in the gh.py JSON output) to determine which section an entry belongs to.
> ⚠️ CRITICAL: Never use labels or title emoji prefixes for categorization. > Labels like bug and enhancement, as well as title prefixes like :bug: > and :sparkles:, are frequently inaccurate, missing, or contradictory to the > actual issue type. The issue_type field from gh.py is the single source > of truth.
| issue_type value | Changelog section | |--------------------|-------------------| | Bug | ### :bug: Bugs fixed | | Feature or Enhancement | ### :sparkles: New features & Enhancements | | Task | Exclude — internal chores are not user-facing | | null (not set) | Check labels as a fallback: bug label → bugs, otherwise enhancements |
The gh.py issues command already includes issue_type in every entry's output. No separate GraphQL query is needed.
Preserve highlighted entries: If an entry is already featured in ### :rocket: Epics and highlights, keep it in that section when refreshing a changelog version. Do not remove a highlighted entry just because issue type categorization would otherwise place it under ### :sparkles: New features & Enhancements.
Community contribution attribution: If the issue or its fix PR has the community contribution label, add an attribution (by @<github_username>) on the changelog entry line, before the GitHub issue/PR references.
The attribution should reference the PR author, not the issue author. The prs subcommand includes the author field — use that:
bashpython3 scripts/gh.py prs <PR_NUMBER> | python3 -c "import sys,json; print(json.load(sys.stdin)[0]['author'])"
Placement in the entry line:
markdown- Fix description of the bug (by @username) [#<ISSUE>](...) (PR: [#<PR>](...))
Only closed issues are included. An issue must have state: "closed" to appear in the changelog. Open/unresolved issues are omitted, even if they are tracked in the milestone.
Pairing rules:
| Pattern | Changelog format | |---------|-----------------| | Closed issue + one or more PRs fix it | Primary link = issue, PR inline comma-separated | | PR exists with no linked issue | If a corresponding closed issue exists in the same milestone, link the issue. Otherwise, skip the entry (the issue must be the changelog unit). | | Closed issue with no fix PR in milestone | Link the issue directly, without a PR reference. |
> False-positive associations: A PR may incorrectly claim to close an issue > from a different context (e.g., a very old PR referencing a modern issue, or a > cross-project reference). If the PR title and issue title are clearly unrelated, > or the PR was created years before the issue, treat it as a data glitch and > skip it. PR #3 (ancient License PR > claiming to close a plugin API issue) is a known example.
A closed issue may list closing PRs that were closed without merging (e.g., a community PR that was superseded by another). The changelog must only reference merged PRs. Verify before writing:
bash# Collect all PR numbers from the candidate entries and check them python3 scripts/gh.py prs <ALL_PR_NUMBERS> | python3 -c " import json, sys for pr in json.load(sys.stdin): if pr['state'] != 'MERGED': print(f'WARNING: #{pr[\"number\"]} is {pr[\"state\"]} (not merged)') "
If a closing PR is closed-unmerged, find the actual merged PR that superseded it:
Replace the reference in the changelog entry with the correct merged PR number.
Security advisories fixed in a release are documented in the changelog even though they are neither milestone issues nor PRs. The GHSA ID and its description are supplied by the user or the release notes — they never come from the milestone fetch in step 2.
Format (matches the existing precedent in CHANGES.md, e.g. the create-font-variant arbitrary file read advisory):
markdown- Fix <user-facing description> (https://github.com/penpot/penpot/security/advisories/GHSA-XXXX-XXXX-XXXX)
Rules:
### :bug: Bugs fixed, with no issue or PR link —only the advisory URL.
publicly). Do not web-fetch or verify the URL, and do not drop the entry because of that. Rely on the GHSA ID provided by the user.
user-facing (e.g. Fix command injection in SVG exporter via legacy fill-color).
gh.py issues, not matched by --compare (step 3), not part of the PR cross-reference (step 10), and not scanned by the anomaly-report regexes (step 11, which only match issues/ and pull/ links). Add them manually.
check: if the same GHSA already appears in an earlier version section, remove it from the current section. Their absence from milestone cross-references is expected, not an anomaly.
Read the top of CHANGES.md to understand the existing format and find the insertion point (newest version goes at the top, after the # CHANGELOG header).
Key format rules from the existing file:
markdown## <VERSION> ### :bug: Bugs fixed - Fix description of the bug [#<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>)) - Fix another bug (by @contributor) [#<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>)) ### :sparkles: New features & Enhancements - Add new feature description [#<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
Format details:
- followed by a short description in imperative mood(PR: [#<N>](<url>))If an issue has multiple fix PRs, they are comma-separated: (PR: [#<N>](<url>), [#<M>](<url>))
(by @<username>) before the issue linksection title
from the current version to avoid duplicates
The LLM must apply these checks during the workflow and fix any violations directly in CHANGES.md. They are not anomalies — they are process errors that should be corrected before writing the new section.
The changelog is a snapshot of the milestone at a point in time, but milestones and changelog entries can drift. The LLM must reconcile the existing changelog against the current state of the milestone and the existing changelog entries.
For each entry that already exists in CHANGES.md (in any version section) or in the candidate set for the current milestone, check:
in another (older) version section? If yes, this is a backport:
from the current section. The earlier version is the canonical reference.
current milestone since the changelog was last updated (e.g., a fix arrived late and the issue was reassigned to a future milestone)?
python3 scripts/gh.py issues <MILESTONE> --state all. If it's no longer there, remove the entry from the current section. (If the target section doesn't exist yet, the entry is simply dropped.)
no changelog or release blocker label since the changelog was last updated? If yes, remove the entry from the current section.
reopened, deleted, or moved to a Rejected project status? If yes, remove the entry.
the entry, is the PR still merged? Was the PR closed without merging (superseded)? Was the PR moved to a different milestone? If the only referenced PR is no longer merged, fix the reference (find the actual merged fix PR) or remove the entry. A PR that is merged in a different milestone is reported as an anomaly in step 11 — do not silently remove it.
Task)? If the new type is Task, the issue is internal and should be removed.
milestone issue that is not referenced in any version section of the changelog, add it to the current section (per the categorization rules in step 5).
After these checks, the changelog should be internally consistent with the milestone. Do not defer these fixes to step 11 — they are workflow errors, not anomalies. Step 11 only reports milestone mismatches that require human judgment about the team's release intent.
Derive the description from the issue title, not the PR title. Strip leading emoji prefixes (:bug:, :sparkles:, :tada:) and focus on the user-facing behavior.
Examples:
| Issue title | Changelog description | |-------------|----------------------| | Plugin API token methods fail with schema validation error on PRO | Fix Plugin API token methods failing with schema validation error on PRO | | Comment content is not sanitized before rendering, enabling stored XSS | Sanitize comment content on rendering | | Custom uploaded font family names are not sanitized | Sanitize font family names on custom uploaded fonts |
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.
:rocket: Epics and highlights subsectionAfter 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 2–5 of the most impactful/user-visible ones. Criteria:
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 issue AND PR references is a highlight gap (warning, not an anomaly).
Preserve existing entries: If the :rocket: section already exists from a prior run, preserve its entries. Do not remove or rewrite them.
Read the top of CHANGES.md and confirm:
Issues can be fixed by PRs that aren't in the milestone, and merged PRs in the milestone may not close any tracked issue. After writing, run a full cross-reference to catch gaps:
bash# List all merged PRs in the milestone python3 scripts/gh.py prs --milestone "<MILESTONE>" --state merged > /tmp/milestone-prs.json # Extract PR numbers from the changelog section python3 -c " import json, re with open('CHANGES.md') as f: content = f.read() # Extract the version section (adjust regex to match the actual version) match = re.search(r'## <MILESTONE> \(Unreleased\)\n(.*?)(?:\n## |\Z)', content, re.DOTALL) section = match.group(1) # Collect all PR numbers referenced changelog_prs = set() for m in re.findall(r'\[#(\d+)\]\(https://github\.com/penpot/penpot/pull/\d+\)', section): changelog_prs.add(int(m)) # Collect all milestone PRs (filtered) with open('/tmp/milestone-prs.json') as f: milestone_prs = json.load(f) milestone_merged = {pr['number'] for pr in milestone_prs} # PRs in milestone but not in changelog missing = sorted(milestone_merged - changelog_prs) print(f'Milestone merged PRs: {len(milestone_merged)}') print(f'Changelog referenced PRs: {len(changelog_prs)}') print(f'PRs in milestone but NOT in changelog: {len(missing)}') for num in missing: pr = next(p for p in milestone_prs if p['number'] == num) print(f' #{num} {pr[\"title\"][:80]}') "
For each missing PR found, decide whether it should be added to the changelog or is legitimately excluded (check its labels).
Also verify that no closed-unmerged PRs remain in the changelog:
bashpython3 scripts/gh.py prs --milestone "<MILESTONE>" --state all | python3 -c " import json, sys data = json.load(sys.stdin) closed = [p for p in data if p['state'] == 'CLOSED'] if closed: print('WARNING: CLOSED (unmerged) PRs in milestone:') for p in closed: print(f' #{p[\"number\"]} {p[\"title\"][:80]}') "
Post-edit audit checklist:
cross-reference is intentional (see step 5b)
markdown## <VERSION> ### :bug: Bugs fixed - <fix description> [#<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>)) - <fix description> (by @contributor) [#<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>)) - <fix description> (https://github.com/penpot/penpot/security/advisories/GHSA-XXXX-XXXX-XXXX)
Advisory (GHSA) entries have no issue or PR link — just the advisory URL. See step 5b.
After all edits and cross-referencing are complete, generate a structured report and save it to CHANGES-ISSUES.md (overwriting if exists). This provides a persistent record of any discrepancies between the milestone and the changelog.
Every issue and PR number in the report must be rendered as a full GitHub Markdown link using the same URL format as CHANGES.md:
[#N](https://github.com/penpot/penpot/issues/N)[#N](https://github.com/penpot/penpot/pull/N)The titles and notes should also link to the corresponding issue/PR page where applicable, so the report is self-contained and clickable from any Markdown viewer.
An anomaly is a milestone-mismatch between an issue and its referenced PR. There are two anomaly types, plus two highlight gaps (warnings that do not count toward the anomaly total):
milestone (or has no milestone). The changelog claims a fix in this release, but the PR is being released elsewhere — the fix may not actually ship here.
milestone. The PR is being released here, but the issue it fixes is being released in a different version — the changelog pairing is misleading.
Exception — issue with no milestone is NOT an anomaly. Milestones are only required for issues tracked in the "Main" project. A milestone 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.
### :rocket: Epics and highlights subsection. Patches (X.Y.Z) never carry highlights, so only minors/majors are checked.
:rocket: entry lacks therequired issue AND PR references. Every highlight entry must follow the standard changelog format with [#ISSUE] and (PR: [#PR]) links (multi-PR (PR: [#A](...), [#B](...)) accepted).
Anything else is not an anomaly. Other discrepancies (exclusion labels on in-changelog issues, missing valid issues, unmerged PR references, duplicates across versions, stale milestone assignments) are rule violations that the LLM must fix directly in CHANGES.md during step 6a (pre-flight checks). They should not appear in this report — if they do, the LLM has skipped the pre-flight step and needs to re-run the workflow.
The changelog's primary unit is the issue, not the PR, so a missing or mismatched PR only matters when its issue is part of this milestone.
Run this self-contained script:
bashpython3 << 'PYEOF' import json, re, subprocess, sys from datetime import datetime, timezone MILESTONE = "<MILESTONE>" CHANGES_MD = "CHANGES.md" OUTPUT = "CHANGES-ISSUES.md" REPO = "penpot/penpot" # --- URL helpers (match CHANGES.md format exactly) --- def issue_url(n): return f"https://github.com/{REPO}/issues/{n}" def pr_url(n): return f"https://github.com/{REPO}/pull/{n}" def issue_link(n): return f"[#{n}]({issue_url(n)})" def pr_link(n): return f"[#{n}]({pr_url(n)})" def issue_link_title(n, title): url = issue_url(n) if title: return f"[#{n}]({url}) — [{title}]({url})" return f"[#{n}]({url})" def pr_link_title(n, title): url = pr_url(n) if title: return f"[#{n}]({url}) — [{title}]({url})" return f"[#{n}]({url})" def fmt_pr_list(nums): return ", ".join(pr_link(n) for n in nums) def fmt_issue_list(nums): return ", ".join(issue_link(n) for n in nums) # --- Fetch milestone data --- result = subprocess.run( ["python3", "scripts/gh.py", "issues", MILESTONE, "--state", "all"], capture_output=True, text=True) all_issues = json.loads(result.stdout) issue_by_num = {i['number']: i for i in all_issues} result = subprocess.run( ["python3", "scripts/gh.py", "prs", "--milestone", MILESTONE, "--state", "all"], capture_output=True, text=True) all_prs = json.loads(result.stdout) pr_by_num = {p['number']: p for p in all_prs} # --- Read changelog section --- with open(CHANGES_MD) as f: content = f.read() m = re.search(rf'## {re.escape(MILESTONE)}(?:\s*\([^)]*\))?\n(.*?)(?:\n## |\Z)', content, re.DOTALL) section = m.group(1) if m else "" changelog_issues = set() for num in re.findall(r'\[#(\d+)\]\(https://github\.com/penpot/penpot/issues/\d+\)', section): changelog_issues.add(int(num)) for num in re.findall(r'\[Github #(\d+)\]', section): changelog_issues.add(int(num)) changelog_prs = set() for num in re.findall(r'\[#(\d+)\]\(https://github\.com/penpot/penpot/pull/\d+\)', section): changelog_prs.add(int(num)) for num in re.findall(r'PR:\[(\d+)\]', section): changelog_prs.add(int(num)) # --- Milestone lookup caches --- # PRs and issues returned by milestone queries are KNOWN to be in MILESTONE. # For everything else, fall back to `gh` per-item lookups. pr_milestone_cache = {p['number']: MILESTONE for p in all_prs} issue_milestone_cache = {i['number']: MILESTONE for i in all_issues} def get_pr_milestone(pr_num): """Return the milestone title for a PR, or None if unassigned / unknown.""" if pr_num in pr_milestone_cache: return pr_milestone_cache[pr_num] try: r = subprocess.run( ["gh", "pr", "view", str(pr_num), "--json", "milestone"], capture_output=True, text=True, check=True) data = json.loads(r.stdout) ms = data.get('milestone') pr_milestone_cache[pr_num] = (ms or {}).get('title') except (subprocess.CalledProcessError, json.JSONDecodeError): pr_milestone_cache[pr_num] = None return pr_milestone_cache[pr_num] def get_issue_milestone(issue_num): """Return the milestone title for an issue, or None if unassigned / unknown.""" if issue_num in issue_milestone_cache: return issue_milestone_cache[issue_num] try: r = subprocess.run( ["gh", "issue", "view", str(issue_num), "--json", "milestone"], capture_output=True, text=True, check=True) data = json.loads(r.stdout) ms = data.get('milestone') issue_milestone_cache[issue_num] = (ms or {}).get('title') except (subprocess.CalledProcessError, json.JSONDecodeError): issue_milestone_cache[issue_num] = None return issue_milestone_cache[issue_num] # --- Exclusion rules (shared) --- EXCLUDED_LABELS = {'release blocker', 'no changelog'} EXCLUDED_ISSUE_TYPES = {'Task'} EXCLUDED_PROJECT_STATUS = {'Rejected'} def issue_excluded(issue): if not issue: return True if issue.get('state') != 'CLOSED': return True if issue.get('issue_type') in EXCLUDED_ISSUE_TYPES: return True if issue.get('project_status') in EXCLUDED_PROJECT_STATUS: return True if EXCLUDED_LABELS & set(issue.get('labels', [])): return True return False # --- ANOMALIES: milestone mismatches between issues and their referenced PRs --- # These are the ONLY items that should appear in the report. All other # discrepancies (exclusion labels, missing valid issues, unmerged PRs, # duplicates, stale milestone assignments) are workflow errors that the # LLM must fix in step 6a (pre-flight checks) — they are not anomalies. # Type A: issue in MILESTONE, referenced PR in different milestone or no milestone anomalies_a = [] # list of dicts: {issue, issue_title, pr, pr_milestone} for issue_num in sorted(changelog_issues): issue = issue_by_num.get(issue_num) if not issue: continue if get_issue_milestone(issue_num) != MILESTONE: continue for pr_num in issue.get('closing_prs', []): pr_ms = get_pr_milestone(pr_num) if pr_ms != MILESTONE: anomalies_a.append({ 'issue': issue_num, 'issue_title': issue.get('title', ''), 'pr': pr_num, 'pr_milestone': pr_ms, # may be None }) # Type B: PR in MILESTONE, the issue it closes is in different milestone or no milestone anomalies_b = [] # list of dicts: {pr, pr_title, issue, issue_milestone} for pr_num in sorted(changelog_prs): pr = pr_by_num.get(pr_num) if not pr: continue if get_pr_milestone(pr_num) != MILESTONE: continue for issue_num in pr.get('closing_issues', []): issue_ms = get_issue_milestone(issue_num) # No milestone = issue from another (probably private) project — # milestones are only required for the "Main" project. Not an # anomaly, and the issue never belongs in this changelog. if issue_ms is None: continue if issue_ms != MILESTONE: anomalies_b.append({ 'pr': pr_num, 'pr_title': pr.get('title', ''), 'issue': issue_num, 'issue_milestone': issue_ms, # may be None }) # --- Type C: released X.Y.0 version sections without :rocket: subsection --- # Patches (X.Y.Z with Z != 0) never carry :rocket: by design — only minors/majors (X.Y.0). 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 ver.split('.')[2] != '0': continue if not rocket_heading_re.search(vs): anomalies_c.append(ver) # --- Type D: :rocket: entries without issue AND PR references --- # Both are required: `[#ISSUE](.../issues/N)` and `(PR: [#PR](.../pull/M))`. # Multi-PR entries `(PR: [#A](...), [#B](...))` are accepted. anomalies_d = [] # list of dicts: {version, line} issue_ref_re = re.compile(r'\[#\d+\]\(https://github\.com/penpot/penpot/issues/\d+\)') pr_ref_re = re.compile(r'\(PR:\s*\[#\d+\]\(https://github\.com/penpot/penpot/pull/\d+\)(\s*,\s*\[#\d+\]\(https://github\.com/penpot/penpot/pull/\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) and pr_ref_re.search(line)): anomalies_d.append({'version': ver, 'line': line[:100]}) # --- Write report --- def fmt_ms(ms): return ms if ms else "_none_" with open(OUTPUT, 'w') as f: f.write(f'# Changelog Anomaly Report — {MILESTONE}\n\n') f.write(f'Generated: {datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")}\n\n') f.write('---\n\n') 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') f.write(f'- **Released X.Y.0 version missing :rocket: section (gap):** {n_c}\n') f.write(f'- **:rocket: entry without issue AND PR references (gap):** {n_d}\n\n') # --- Anomalies section (milestone mismatches only) --- if n_a or n_b: 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: f.write(f'### Issue in {MILESTONE}, PR in different milestone or no milestone\n\n') by_issue = {} for a in anomalies_a: by_issue.setdefault(a['issue'], []).append(a) for issue_num in sorted(by_issue): entries = by_issue[issue_num] title = entries[0]['issue_title'] f.write(f'- {issue_link_title(issue_num, title[:80])}\n') for e in entries: ms_label = fmt_ms(e['pr_milestone']) badge = '🔴' if e['pr_milestone'] is None else '⚠️' f.write(f' - {badge} Referenced {pr_link(e["pr"])} is in milestone **{ms_label}** (expected: {MILESTONE})\n') f.write('\n') if n_b: f.write(f'\n### PR in {MILESTONE}, closing issue in a different milestone\n\n') by_pr = {} for b in anomalies_b: by_pr.setdefault(b['pr'], []).append(b) for pr_num in sorted(by_pr): entries = by_pr[pr_num] title = entries[0]['pr_title'] f.write(f'- {pr_link_title(pr_num, title[:80])}\n') for e in entries: ms_label = fmt_ms(e['issue_milestone']) 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') else: f.write('✅ No anomalies found. All (issue, PR) pairs in the changelog have aligned milestone assignments.\n\n') # --- Highlight gaps (warnings, not anomalies) --- if n_c or n_d: f.write('## Highlight gaps\n\n') f.write('These are warnings, not anomalies: they do not affect the ' 'milestone-mismatch total above. They track `:rocket:` coverage ' 'across all released X.Y.0 versions. Historical entries (e.g. ' 'Taiga links) predate the current reference convention and are ' 'expected to appear here.\n\n') if n_c: f.write(f'### Released X.Y.0 version missing :rocket: section\n\n') f.write('These released minors/majors 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'### :rocket: entry without issue AND PR references\n\n') f.write('These highlight entries lack the required issue AND 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') elif not (n_a or n_b): f.write('✅ No highlight gaps found. All released X.Y.0 versions have properly referenced :rocket: entries.\n\n') # --- Context --- f.write('---\n\n') f.write('## Context\n\n') f.write(f'- Milestone: **{MILESTONE}**\n') f.write(f'- Milestone total issues (all states): {len(all_issues)}\n') f.write(f'- Closed issues in milestone: {sum(1 for i in all_issues if i.get("state") == "CLOSED")}\n') f.write(f'- Valid issues after exclusions (after step 5/6a): {len([i for i in all_issues if not issue_excluded(i)])}\n') f.write(f'- Issues referenced in changelog: {len(changelog_issues)}\n') f.write(f'- PRs referenced in changelog: {len(changelog_prs)}\n') print(f"Anomaly report written to {OUTPUT}") PYEOF
This generates CHANGES-ISSUES.md containing anomalies and highlight gaps:
the changelog claims a fix here, but the PR is released elsewhere.
the PR is released here, but the issue it fixes belongs to another version. (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.)
has no ### :rocket: Epics and highlights subsection. Patches (X.Y.Z) never carry highlights.
:rocket: entry lacksthe required issue AND PR references.
Gaps do not count toward the anomaly total.
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). If the report contains a rule violation, the LLM has skipped the pre-flight step and needs to re-run the workflow before re-generating the report.
The report is overwritten each time it's generated, reflecting the current state of the milestone and changelog. Every number is rendered as a full [#N](https://github.com/penpot/penpot/issues/N) or [#N](https://github.com/penpot/penpot/pull/N) link so the report is self-contained and clickable in any Markdown viewer.
user-facing issue, not the implementation PR.
can find the code changes.
changelog, below the # CHANGELOG header.
issue_type field from gh.py output (Bug → :bug:, Feature/Enhancement → :sparkles:). Do not use labels (bug, enhancement) or title emoji prefixes (:bug:, :sparkles:) — they are frequently wrong or contradictory. The issue_type is the single source of truth.what broke and what was fixed, not internal implementation details.
community contribution label, add (by @<username>) on the entry line between the description and the issue link. Use the PR author (not the issue author) for the attribution.
state: "closed" to appear inthe changelog. Open/unresolved issues are omitted.
project board are automatically excluded by gh.py, even if they are closed. The project status is distinct from the GitHub issue state. Use --include-rejected to override this behavior.
no changelog label must be excluded.Issues with issue_type: "Task" must also be excluded — they are internal chores, not user-facing changes.
comma-separated inline: (PR: [#A](url), [#B](url)).
remove it from the current version. Check for text-level duplicates (after stripping links and attributions) across version sections.
(tree.taiga.io), attempt to find a corresponding GitHub issue via the Taiga description text or by searching GitHub PRs that reference the Taiga URL. Replace the Taiga reference with the GitHub issue link and add the PR reference if applicable.
listed under ### :bug: Bugs fixed with the advisory URL and no issue or PR link, even though they are not in the milestone. The GHSA ID and description come from the user — do not fetch or verify the URL, and do not drop a draft (unpublished) advisory. Precedent: - Fix arbitrary file read security issue on create-font-variant rpc method (https://github.com/penpot/penpot/security/advisories/GHSA-xp3f-g8rq-9px2). See step 5b.
before making edits, don't rely on cached data.
scripts/gh.py. Prefer the helper script over raw gh api calls formilestone issue listing and PR detail fetching. It handles GraphQL pagination, batching, and label filtering automatically.
can be superseded and closed without merging. Always check that every PR referenced in the changelog has state: MERGED.
(release blocker, no issue required) independent of its linked issue's labels. Check both.
--compare flag onthe issues command only compares issue numbers. Merged PRs not linked to any milestone issue can be missed. Use python3 scripts/gh.py prs --milestone for a full PR cross-reference.
issue from a different project or context. If the PR title and issue title are clearly unrelated, or the PR predates the issue by years, treat it as a data glitch and skip it.
anomaly total counts only milestone mismatches: (1) the issue is in this milestone but the referenced PR is in a different milestone (or unassigned), and (2) the PR is in this milestone but the issue it closes is in a different milestone. :rocket: highlight gaps (missing section on a released X.Y.0, entry without issue AND PR references) are reported in a separate Highlight gaps section and never count toward the anomaly total. An unassigned (milestone-less) issue closed by a milestone PR is not an anomaly: milestones are required only for the "Main" project, so such issues come from another (probably private) project and are not changelog candidates. These anomalies are reported because the changelog pairing is misleading — the human needs to decide whether the milestone or the changelog is wrong. All other discrepancies (exclusion labels, missing valid issues, unmerged PR references, duplicates, stale milestone assignments) are rule violations that the LLM must fix directly in CHANGES.md during step 6a (pre-flight checks). They never appear in the report — if they do, the pre-flight step was skipped.
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→fail | 7,290 | 27,266 | +274% | 1 | 1 | 0% | 306 | 12,444 | +3967% | 0 | 0 | — |
case-02 | fail→fail | 7,607 | 11,352 | +49% | 1 | 1 | 0% | 323 | 12,835 | +3874% | 0 | 0 | — |
case-03 | fail→fail | 11,962 | 9,885 | -17% | 1 | 1 | 0% | 280 | 12,690 | +4432% | 0 | 0 | — |
case-04 | fail→pass | 8,560 | 4,436 | -48% | 1 | 1 | 0% | 1,190 | 12,757 | +972% | 0 | 0 | — |
case-05 | fail→pass | 8,942 | 3,670 | -59% | 1 | 1 | 0% | 1,384 | 12,535 | +806% | 0 | 0 | — |
case-06 | pass→pass | 9,075 | 4,469 | -51% | 1 | 1 | 0% | 1,431 | 12,779 | +793% | 0 | 0 | — |
case-07 | fail→pass | 15,112 | 11,633 | -23% | 1 | 1 | 0% | 2,254 | 12,673 | +462% | 0 | 0 | — |
case-08 | pass→pass | 9,162 | 4,718 | -49% | 1 | 1 | 0% | 1,509 | 12,832 | +750% | 0 | 0 | — |
case-09 | fail→pass | 12,424 | 129,213 | +940% | 1 | 1 | 0% | 1,729 | 13,467 | +679% | 0 | 0 | — |
case-10 | fail→pass | 15,105 | 8,050 | -47% | 1 | 1 | 0% | 2,308 | 13,247 | +474% | 0 | 0 | — |
case-11 | fail→pass | 7,402 | 8,621 | +16% | 1 | 1 | 0% | 1,319 | 13,442 | +919% | 0 | 0 | — |
case-12 | pass→pass | 17,366 | 4,536 | -74% | 1 | 1 | 0% | 1,214 | 12,611 | +939% | 0 | 0 | — |
case-13 | fail→pass | 9,680 | 5,068 | -48% | 1 | 1 | 0% | 1,337 | 12,923 | +867% | 0 | 0 | — |
case-14 | fail→pass | 12,300 | 5,727 | -53% | 1 | 1 | 0% | 1,784 | 13,109 | +635% | 0 | 0 | — |
case-15 | pass→pass | 12,140 | 23,098 | +90% | 1 | 1 | 0% | 1,738 | 13,181 | +658% | 0 | 0 | — |
case-16 | fail→pass | 18,725 | 4,814 | -74% | 1 | 1 | 0% | 3,540 | 12,731 | +260% | 0 | 0 | — |
case-17 | fail→pass | 25,526 | 7,479 | -71% | 1 | 1 | 0% | 4,214 | 12,642 | +200% | 0 | 0 | — |
case-18 | pass→pass | 10,872 | 6,874 | -37% | 1 | 1 | 0% | 1,796 | 13,008 | +624% | 0 | 0 | — |
case-19 | pass→pass | 16,859 | 8,031 | -52% | 1 | 1 | 0% | 1,837 | 13,328 | +626% | 0 | 0 | — |
case-20 | pass→fail | 12,084 | 13,611 | +13% | 1 | 1 | 0% | 2,046 | 12,480 | +510% | 0 | 0 | — |
case-21 | pass→pass | 12,600 | 26,988 | +114% | 1 | 1 | 0% | 1,959 | 13,445 | +586% | 0 | 0 | — |
case-22 | pass→fail | 11,876 | 12,956 | +9% | 1 | 1 | 0% | 1,926 | 12,430 | +545% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted, and 17 counted toward the lift figure. The other 5 produced results that are not comparable between the two arms, so they are excluded from the headline rather than averaged into it. The headline lift of +36 percentage points is the difference between those two pass rates over the 17 comparable cases. 2 cases got worse with the skill loaded, and they are included in that figure.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.