Closing a milestone and publishing release notes
Development work on the Nexgen apps is grouped into milestones in the issue-tracking repository. When the work in a milestone has been deployed to production, the milestone is closed, the open issues move to the next milestone, the release is tagged in the nexgen_v3 repository, and the customer-facing summary is added to the release notes page on nutritionistpro.com. This page is that procedure, in order.
The pieces involved:
- Issues and milestones: the
Axxya-Systems/issue-trackingrepository. Every issue also sits in the Nexgen V3 Issue Project (project number 3 in theAxxya-Systemsorganization), whose Status column is To triage, Backlog, Ready, In progress, In review, or Done. - Release tags: the
Axxya-Systems/nexgen_v3repository. Its.github/release.ymlgroups the generated notes into Features, Bug fixes, and Other, and leaves out pull requests labelednotforrelease. - The public release notes: the Release Notes page in WordPress, a Divi page edited in wp-admin.
All of the commands use the GitHub CLI (gh), signed in to an account that can see the private issue-tracking repository.
gh uses a GITHUB_TOKEN or GH_TOKEN environment variable ahead of its stored login. A terminal that has one set for another purpose (for example a fine-grained token for the cawoodaxxya account scoped to the code repositories) gets Could not resolve to a Repository with the name 'Axxya-Systems/issue-tracking' even though the repository exists. Run gh auth status to see which account is active, and clear the variable for that terminal before continuing:
unset GITHUB_TOKEN GH_TOKEN
The stored stephencawood login is the one that reads and writes issues. unset affects only the current terminal window.
Before you start
List the milestones to get the number, title, and counts of the milestone being closed:
gh api "repos/Axxya-Systems/issue-tracking/milestones?state=all&per_page=100" --jq '.[] | "\(.number)\t\(.state)\topen \(.open_issues) / closed \(.closed_issues)\t\(.title)"'
Milestones are named <date>: <milestone focus>, where the date is the planned end of the milestone in YYYY-MM-DD form, for example 2026-09-17: Improve Diet,Menu,Label. The rest of this page uses two shell variables so the commands can be pasted as they are. Set them to the exact titles, and keep the milestone number from the list above for the close step:
OLD="2026-09-17: Improve Diet,Menu,Label"
NEW="2026-11-15: Migration and metered API access"
1. Clean up finished issues
Some issues are finished but were never closed. List the open issues in the milestone with their project status, with the ones most likely to be done (In progress and In review) sorted together:
gh project item-list 3 --owner Axxya-Systems --limit 1000 --format json --jq ".items[] | select(.content.type==\"Issue\" and .milestone.title==\"$OLD\") | \"\(.status)\t#\(.content.number)\t\(.title)\"" | sort
Go through the In review and In progress entries and close every issue whose work has shipped. Closing an issue moves its project status to Done on its own:
gh issue close 123 --repo Axxya-Systems/issue-tracking --reason completed
Use --reason "not planned" for work that was dropped. Issues closed as not planned are left out of the release notes automatically in step 4.
Check that nothing is closed but still sitting outside the Done column. This prints nothing when the board is clean:
gh issue list --repo Axxya-Systems/issue-tracking --milestone "$OLD" --state closed --limit 300 --json number --jq '[.[].number]' > /tmp/closed.json
gh project item-list 3 --owner Axxya-Systems --limit 1000 --format json | jq -r --slurpfile closed /tmp/closed.json '.items[] | select(.content.type=="Issue" and (.content.number as $n | $closed[0] | index($n)) and .status!="Done") | "#\(.content.number)\t\(.status)\t\(.title)"'
2. Close the milestone and move the open issues
Create the next milestone first, so the open issues have somewhere to go. Follow the naming scheme above:
gh api -X POST repos/Axxya-Systems/issue-tracking/milestones -f title="$NEW" -f description="One line on the focus of the milestone"
Move every open issue from the old milestone to the new one. gh issue edit updates a batch of issues at once:
gh issue list --repo Axxya-Systems/issue-tracking --milestone "$OLD" --state open --limit 300 --json number --jq '.[].number' | xargs -n 20 gh issue edit --repo Axxya-Systems/issue-tracking --milestone "$NEW"
Check that none are left. This should print 0:
gh issue list --repo Axxya-Systems/issue-tracking --milestone "$OLD" --state open --limit 300 --json number --jq 'length'
GitHub sometimes answers a batch update with GraphQL: Something went wrong while executing your query or HTTP 502: 502 Bad Gateway. On 2026-09-17 six of 57 issues failed that way, and on 2026-10-15 a whole batch of 20 was dropped by a 502. The command is safe to repeat, and moving the leftovers one at a time avoids the collision:
for n in $(gh issue list --repo Axxya-Systems/issue-tracking --milestone "$OLD" --state open --limit 300 --json number --jq '.[].number'); do gh issue edit "$n" --repo Axxya-Systems/issue-tracking --milestone "$NEW"; done
When the count is 0, close the old milestone. Replace 17 with its number from the list in Before you start:
gh api -X PATCH repos/Axxya-Systems/issue-tracking/milestones/17 -f state=closed
A closed milestone still works with --milestone "$OLD" in the commands below.
3. Mark the issues that stay out of the release notes
Only changes that customers need to know about go in the release notes, so most issues in a milestone are not published: internal tooling, tests, infrastructure, documentation, website work, marketing, admin features, data repairs, and anything embarrassing or security related. Mark each of those with the notforrelease label. List the closed issues that would be published as things stand, with their labels:
gh issue list --repo Axxya-Systems/issue-tracking --milestone "$OLD" --state closed --limit 300 --json number,title,stateReason,labels,issueType --jq '.[] | select(.stateReason=="COMPLETED") | select([.labels[].name] | index("notforrelease") | not) | "#\(.number)\t\(.issueType.name // "no type")\t\([.labels[].name] | join(","))\t\(.title)"'
Add the label to every issue that should not appear. The command takes several issue numbers at once:
gh issue edit 601 612 633 --repo Axxya-Systems/issue-tracking --add-label notforrelease
While the list is open, check the type column as well. The repository uses GitHub issue types (Feature, Bug, DevOps, Task), and step 4 prints an issue as a feature when its type is Feature or it carries the feature label. An issue with no type and no feature label comes out as a fix, so set the type on the issue page in GitHub (the Type field in the right sidebar) for any feature that is missing it. gh issue edit cannot set the type. Issues typed DevOps or Task are usually notforrelease candidates.
Run the list again until everything left on it belongs in the notes. The notforrelease label is permanent, so an issue marked now is also left out of the generated notes on the GitHub release in step 5.
4. Produce the release-notes list
This prints one line per published issue as Feature: <title> or Fix: <title>, features first. An issue whose type is Feature, or that carries the feature label, is a feature; everything else (Bug, no type, usability, ui, performance, and so on) is a fix. The Feature: and Bug: prefixes that many issue titles carry are stripped:
gh issue list --repo Axxya-Systems/issue-tracking --milestone "$OLD" --state closed --limit 300 --json number,title,stateReason,labels,issueType --jq '[.[] | select(.stateReason=="COMPLETED") | select([.labels[].name] | index("notforrelease") | not) | {type: (if (.issueType.name == "Feature") or ([.labels[].name] | index("feature")) then "Feature" else "Fix" end), title: (.title | sub("^(Feature|Bug|Fix) ?: *"; ""))}] | sort_by(.type == "Fix") | .[] | "\(.type): \(.title)"'
The output is a starting point, not the final text. Issue titles are written for developers, so rewrite each line for a customer before it goes on the website:
- Say what the customer gets, in plain words, for example
Fix: downloaded labels are no longer croppedrather thanFix: label download cropping bug. - Use
Feature (beta):for a module or feature that ships in beta. - Do not mention V2 or V3, container names, HTTP status codes, server errors, SQL, or any internal name a customer would not recognize.
- Merge several issues that fixed the same thing into one line, and drop an issue that fixed a feature introduced in the same release.
- Leave out a line that is already in an earlier release's notes.
Before publishing, read the list once more with a customer's eyes:
- Remove anything that describes a security fix, an access-control problem, a billing or subscription repair, or a failure that would alarm a customer. If the fix matters to customers, describe the outcome (
Fix: expired trial users can now log in and upgrade) rather than the failure. - Keep calculation and rounding fixes, even though they are uncomfortable. Customers who made labels while the error existed have a real reason to know.
- Do not label a terms of service or pricing change as a fix. Write it with no prefix, or as a feature when it adds something.
- Drop lines that are too vague to mean anything (
Fix: search issues).
5. Create the GitHub release
Releases are tagged on main in the nexgen_v3 repository, at the commit that is live in production (see Deployment for the live version strings). Find the previous tag to pick the next version number:
gh release list --repo Axxya-Systems/nexgen_v3 --limit 5
Create the release with a one-line summary and the generated notes. The summary comes first in the release body, and the generated section below it is grouped by .github/release.yml and already leaves out pull requests labeled notforrelease:
gh release create v3.3.0 --repo Axxya-Systems/nexgen_v3 --target main --title "v3.3.0 Migration and metered API access" --notes "Desktop migration manager and metered access for the MCP server and customer API" --generate-notes
Open the release on GitHub and check the generated list. A pull request that should not be public needs the notforrelease label on the pull request itself, since the release notes are built from pull requests, not issues. Add the label and regenerate the notes from the release's edit page if one slipped through.
6. Update the release notes page on nutritionistpro.com
The Release Notes page was built with Divi, so it must be edited in the Divi editor. The default WordPress block editor shows only a Divi placeholder for this page, and editing that placeholder damages the page. See How pages are built.
-
Sign in to wp-admin, open Pages, and edit the Release Notes page.
-
Select Use the Divi Builder on the edit screen, so that the page opens in the back-end Divi editor, and open the single Text module that holds all of the release notes. Do not use Edit with the Divi Builder on the live page, which opens the front-end Visual Builder. See the warning below.
-
Switch the module's content to the Text (HTML) tab and paste the new block above the heading of the previous release. The page uses one
h2per release and one paragraph withbrline breaks, each line starting with an en dash. The heading is<date with slashes>: Release <version>: <focus>:<h2>2026/09/17: Release 3.2.0: Menu and Diet modules public Beta</h2><p>– Feature (beta): Diet Analysis module is now available<br />– Feature: type-ahead filtering on long drop-down lists<br />– Fix: downloaded labels are no longer cropped</p> -
Save the module and select Update on the page.
-
Open the live page in a private browser window and confirm the new heading is there, the header and footer appear once each, and the older entries are unchanged.
-
Follow the rest of Publishing a change: check the sitemap and
llms.txt, and request indexing in Google Search Console.
Editing the page in the front-end Visual Builder shows the site's global header and footer alongside the page. A save, or a paste that caught more than the text module, can copy both into the page content, and the theme then renders the header a second time on top of the copy. This happened on 2026-09-17. To fix it, open the page in the Divi builder, switch to wireframe view, delete the Menu section at the top and the footer section at the bottom so that only the release notes section remains, and update the page. Delete the copies inside the page, not the global header and footer; if the builder asks whether you are editing the global template, back out. The alternative is to restore the previous version from Revisions on the edit screen and paste the text again from the back-end builder.
Verify
- The old milestone is closed and shows 0 open issues, and the new milestone holds the moved issues (rerun the command in Before you start).
- The issues left in the old milestone without
notforreleaseare exactly the ones published. - The release is listed by
gh release list --repo Axxya-Systems/nexgen_v3 --limit 1, and its generated notes contain nothing that should be private. - The live release notes page shows the new heading, with one header and one footer.
Related
- Nexgen V3 local development for setting up and starting the dev environment in the main checkout.
- Running multiple local environments for giving git worktrees their own databases, containers, and ports.