Capstone: build the release bot
Turn merged pull requests into a version number, release notes, closed issues and contributor thanks - the bot Dungeon Dash runs on every release.
- Classify merged pull requests from their conventional-commit titles
- Generate grouped Markdown release notes with links and authors
- Combine versioning, notes, closed issues and contributor thanks into one release
You’ve come a long way: from your first pull request to reviewing others’, wiring up CI, and hardening the project’s workflows. The other maintainers have one last request: automate releases. Writing release notes by hand takes an afternoon, and somebody always forgets to thank a contributor.
The bot reads the last release tag and the pull requests merged since (as the API returns them - squash-merged, so each title is a conventional commit), and produces:
1v1.5.0
2
3## v1.5.0
4
5### Features
6- boss battles (#128) @mira
7- **audio:** victory music (#134) @ana
8
9### Fixes
10- **physics:** stop wall clipping after a dash (#131) @kai
11- faster dungeon generation (#137) @mira
12
13### Other changes
14- explain the controls (#133) @sam-sprites
15- bump pillow to 10.3.0 (#136) @dependabot[bot]
16
17Closes: #42, #43, #30, #57, #61
18New contributors: @ana, @kaiThe plan
Everything here is a piece you’ve already built:
- Classify each PR title (conventional commits lesson): type, optional scope,
!or aBREAKING CHANGE:note in the body. - Version from the most significant change (semver lesson).
- Notes: group entries under headings, with the PR number and author (Markdown lesson).
- Closed issues from closing keywords in the PR bodies (issues lesson).
- Contributors: thank everyone, and welcome first-timers - people not in the list of earlier contributors.
Build it in that order in the three exercises: classify and version, then notes, then the whole release.
1import re
2
3TITLE = re.compile(r"(?P<type>\w+)(?:\((?P<scope>[\w-]+)\))?(?P<bang>!)?: (?P<subject>.+)")
4for title in ["feat(audio): victory music", "fix!: drop the old save format", "Update README"]:
5 match = TITLE.match(title)
6 print(match.groupdict() if match else f"not conventional: {title}"){'type': 'feat', 'scope': 'audio', 'bang': None, 'subject': 'victory music'}
{'type': 'fix', 'scope': None, 'bang': '!', 'subject': 'drop the old save format'}
not conventional: Update READMENamed groups ((?P<type>...)) make the parsing readable, and titles that don’t follow the convention fall into “Other changes” rather than crashing the bot - real repositories always have a few.
Running it on GitHub
Once the script works, a workflow runs it at release time: a maintainer clicks Run workflow, the bot computes the version and notes, and gh release create publishes them. Notice the permissions and pinned versions from the security lesson:
1name: Release
2on:
3 workflow_dispatch:
4
5permissions:
6 contents: write # to create the tag and release
7 pull-requests: read
8
9jobs:
10 release:
11 runs-on: ubuntu-latest
12 environment: release # requires a maintainer's approval
13 steps:
14 - uses: actions/checkout@v4
15 with:
16 fetch-depth: 0 # full history, so the last tag is available
17 - name: Collect merged pull requests
18 env:
19 GH_TOKEN: ${{ github.token }}
20 run: |
21 last_tag=$(git describe --tags --abbrev=0)
22 echo "$last_tag" > input.txt
23 gh pr list --state merged --search "merged:>$(git log -1 --format=%cI "$last_tag")" \
24 --json number,title,author,body --jq '.[] | {number, title, author: .author.login, body}' -c >> input.txt
25 - name: Build the release
26 run: python scripts/release_bot.py < input.txt > release.md
27 - name: Publish
28 env:
29 GH_TOKEN: ${{ github.token }}
30 run: gh release create "$(head -n 1 release.md)" --notes-file <(tail -n +3 release.md)Where to go next
- Contribute for real: find a project you use, read its CONTRIBUTING.md, and pick a good first issue.
- Automate your own repos: add CI, Dependabot and a release workflow to a project of yours.
- Go deeper: reusable workflows and composite actions, GitHub Apps, Codespaces for cloud dev environments, and GitHub Copilot.
- Release tooling: release-please and semantic-release do what your bot does, for many ecosystems.
Key takeaways
Conventional PR titles make versions and release notes computable.
Group notes by kind, link PRs and credit authors - and handle titles that don’t follow the rules.
Closing keywords and contributor lists come straight from the merged PRs.
Run release automation in a workflow with least-privilege permissions and an approval gate.
Lesson quiz
6 questions · pass with 5 correct · up to 50 XP
Passing this quiz completes the lesson and keeps your streak going. Questions you miss come back in review sessions later.
Practice: automate GitHub chores with Python
Real GitHub work involves lots of small automation: matching CODEOWNERS, expanding build matrices, bumping versions, reading the API. Write those helpers in Python and run them against sample inputs - locally in your browser, with no GitHub account needed.
Classify the merged PRs
The first input line is the last release tag; each following line is a merged PR as JSON with number, title, author and body. For each PR print #128 feature: boss battles (kinds: breaking for a ! or a BREAKING CHANGE: in the body, feature for feat, fix for fix and perf, otherwise other; non-conventional titles are other with the whole title). Then print next: v1.5.0, or next: none when nothing is releasable.
- Spring release
- Big release
- Nothing to release
Python runs in a sandboxed browser worker with a 60 second time limit. Its runtime loads from the Pyodide CDN; your code stays in this browser.
Write the release notes
Using the same input, print Markdown release notes: ## v1.5.0, then - each preceded by a blank line - sections ### ⚠️ Breaking changes, ### Features, ### Fixes and ### Other changes in that order, skipping empty ones. Each entry is - subject (#N) @author, with **scope:** before the subject when there is a scope. If nothing is releasable, print No release needed. instead.
- Spring release
- Big release
- Nothing to release
Python runs in a sandboxed browser worker with a 60 second time limit. Its runtime loads from the Pyodide CDN; your code stays in this browser.
Ship the whole release
Put it all together. The input is the same, plus a final line known: mira sam-sprites ... listing everyone who contributed before. Print:
- the version on its own line, then a blank line, then the release notes (as in the last exercise);
- a blank line, then
Closes: #42, #43- every issue closed by a closing keyword in a PR body (keyword before each reference), in order, without duplicates - orCloses: none; New contributors: @ana, @kai- authors not in the known list, sorted, ignoring bots (names ending in[bot]) - orNew contributors: none.
If nothing is releasable, print only No release needed.
- Spring release
- Big release
- Nothing to release
Python runs in a sandboxed browser worker with a 60 second time limit. Its runtime loads from the Pyodide CDN; your code stays in this browser.
Questions about this lesson
Stuck? Ask. Figured something out? Share it. Explaining is one of the best ways to learn.
Loading posts…