Commands
gh release-changes [-R [HOST/]OWNER/REPO] default: the repository of the current directory
[--previous-tag <tag>] default: GitHub's choice
[--since-final] compare with the previous final release
[--link closing] also the issues a pull request closes
[--debug] slog diagnostics on stderr
│
├── items [<tag>] JSON contract
├── sort [<tag>] grouped "What's Changed" Markdown
├── issues [<tag>] plain issue list per group
│ [--scope-issue-repo owner/repo] default: the source repo
│ [--relative-to owner/repo] default: the source repo
│ [--full-refs]
│ [--unlinked] also the pull requests without an issue
│ [--tasks] - [ ] #N instead of * #N
│ [--titles] * #N <title>, titles read from GitHub
├── dump [<tag>] issue details to write release notes from
│ [--scope-issue-repo owner/repo]
├── check-notes [<tag>] --file <path> release notes against the issues of the release
│ [--scope-issue-repo owner/repo] default: the source repo
│ [--relative-to owner/repo] default: the source repo
└── versionGlobal flags
| Flag | Default | Meaning |
|---|---|---|
-R, --repo | the current directory’s remote | The source repository, as OWNER/REPO or HOST/OWNER/REPO |
--previous-tag <tag> | GitHub’s choice | Compare with this tag instead |
--since-final | off | Compare with the newest final release older than the tag |
--link closing | off | Also take the issues a pull request closes on GitHub |
--debug | off | Write slog debug diagnostics to stderr |
A malformed -R fails before anything is fetched, with exit code 1 and an error along the lines of:
error: invalid repository reference: want [HOST/]OWNER/REPO: -R "acme"--previous-tag and --since-final exclude each other; passing both fails with exit code 1.
The tag
Every command that works on a release takes its tag as an argument. Leave it out on a terminal and the tool offers the repository’s tags to pick from:
- newest first by semantic version (
v1.10.0beforev1.9.0,v2.0.0beforev2.0.0-rc.2), release candidates and other pre-releases included; - tags that are not a semantic version after them;
- 10 at a time — type to filter over all of them, arrows to move, Enter to pick, Esc or Ctrl+C to cancel.
The picker is drawn on stderr, so gh release-changes sort > notes.md still captures only the notes. It is only
shown when stdin and stdout are terminals: without one — in a pipeline, a script or an agent’s shell — a missing
tag fails at once with exit code 1 and an error along the lines of:
error: operation requires an interactive terminal: pass the tag, as in `gh release-changes sort <tag>`,
or run it in a terminal to pick oneScripts and agents should always pass the tag.
--previous-tag has no picker: GitHub’s choice of base fits the release flow, and the flag takes a tag when you
want another one.
The range
Any tag works as the target: final, release candidate or patch. The base is GitHub’s choice, which fits a release flow without logic of its own:
| Target | Compared with |
|---|---|
v3.8.0 | v3.7.1 |
v3.8.0-rc.1 | v3.7.1 |
v3.8.0-rc.3 | v3.8.0-rc.2 |
v3.7.1 | v3.7.0 |
A final is compared with the previous final, so it covers the whole release-candidate cycle. --previous-tag
overrides the base. Both tags must exist: generate-notes silently compares the default branch for an unknown
tag, so the tool checks them first and fails with tag_not_found.
The whole release cycle: --since-final
A later release candidate is compared with the one before it, so its list holds only what changed since. A
release ticket covers the whole release, though: it is created at rc.1 and updated as candidates follow, and
should not shrink to the delta. --since-final compares with the newest final release — a semantic version
without a pre-release — older than the tag:
| Target | GitHub’s choice | --since-final |
|---|---|---|
v3.8.0-rc.1 | v3.7.1 | v3.7.1 |
v3.8.0-rc.3 | v3.8.0-rc.2 | v3.7.1 |
v3.8.0 | v3.7.1 | v3.7.1 |
v3.7.1 | v3.7.0 | v3.7.0 |
So every candidate of a release lists the same range its final will. The tags are read from the repository and
ordered by semantic version; tags that are not a semantic version are ignored. When there is no older final
release — the first release, or a tag that is no semantic version — the base is GitHub’s choice after all, and
an info: line on stderr says so. --since-final excludes --previous-tag, and works for every command.
Closing references
The issues of a pull request are the digit scopes of its title: fix(12,13): … references #12 and #13.
A pull request can close more than its title says, though — fix(12): … with Closes #12, closes #13 in its
description — and a docs: or chore: pull request often has no scope at all. --link closing also takes the
issues a pull request closes on GitHub: the ones a closing keyword (Closes #13, Fixes #13) or a link in the
pull request’s Development sidebar connects to it.
gh release-changes --link closing issues v3.8.0- They are added after the title’s issues, so a pull request still sorts by its first digit scope, and they
reach every command:
items,sort(the order),issues,dumpandcheck-notes. - Only issues in the repository the digit scopes point at count:
--scope-issue-repofor the commands that have it, the source repository for the others. A number means nothing in another repository, so a reference there is left out (--debuglogs it). itemssays where each issue came from inissue_sources.- The group never changes: a
docs:pull request that closes an issue lists it under Other, and an unclassified one under Unclassified. A pull request that closes an issue is no longer one without an issue. - It costs one GraphQL request per 100 pull requests. A pull request of the release that cannot be read fails the run.
closingis the only source. Branch names (8285-update-changelog) are not read: on the releases this was measured on they added no issue, and gave false hits.
Without --link every output is exactly as before.
Access
Authentication is gh’s own: gh auth login, GH_TOKEN, and GH_HOST or -R HOST/OWNER/REPO for GitHub
Enterprise. Generating release notes needs Contents: write on the repository, even though nothing is
created; with read access only, a run fails with forbidden. A repository that does not exist, or that the
token cannot see, fails with repo_not_found.
version
Prints gh-release-changes version <version> to stdout.
items
gh release-changes [-R OWNER/REPO] [--previous-tag <tag> | --since-final] items [<tag>]Prints the changes of the release as one JSON object on stdout: the contract skills and automation build on.
The fields, the groups and the order are described in The items JSON.
Pull requests whose title is outside the <type>[(<scopes>)][!]: <subject> format land in the unclassified
group and are named in a warning on stderr. They do not change the exit code.
sort
gh release-changes [-R OWNER/REPO] [--previous-tag <tag> | --since-final] sort [<tag>]Prints the grouped “What’s Changed” list as Markdown on stdout, for developers and ops:
## What's Changed
### Features
* **Breaking:** feat(12)!: drop the v1 API by @alice in https://github.com/owner/repo/pull/31
* feat(14): add an export by @bob in https://github.com/owner/repo/pull/30
### Bug fixes
* fix(13): keep the filter after a reload by @carol in https://github.com/owner/repo/pull/29- One
###heading per group that has items, in the order Features, Bug fixes, Other, Maintenance, Dependencies, Unclassified; empty groups are left out. A release without pull requests printsNo changes. - Lines keep GitHub’s format,
* <title> by @<author> in <pr-url>, so the links still render where the list is pasted. Within a group they are in the items order. - A breaking change (a
!before the colon) is prefixed with**Breaking:**; the title itself is unchanged. - Unclassified items are named in a warning on stderr, as for
items.
The output is stable — the same release always renders the same bytes — so it can be pasted into a release description, or compared by a script, as is.
issues
gh release-changes [-R OWNER/REPO] [--previous-tag <tag> | --since-final] issues [<tag>]
[--scope-issue-repo OWNER/REPO] [--relative-to OWNER/REPO] [--full-refs] [--unlinked]
[--tasks] [--titles]Prints a plain issue list per group as Markdown on stdout, for the release ticket (PO, QA, acceptance):
## Issues
### Features
* #14
### Bug fixes
* #12
* #13The issues are the digit scopes of the titles: fix(12,13): … references #12 and #13; with
--link closing also the issues the pull requests close. Per group they are
listed ascending and deduplicated; groups without issues are left out, and a release without any prints
No issues.
| Flag | Default | Meaning |
|---|---|---|
--scope-issue-repo OWNER/REPO | the source repository | The repository the digit scopes of the titles point at |
--relative-to OWNER/REPO | the source repository | The repository references are written relative to: the one the list is pasted into |
--full-refs | off | Write every reference as OWNER/REPO#N |
--unlinked | off | Also list the pull requests without an issue |
--tasks | off | Write every line as a task: - [ ] #N |
--titles | off | Follow every issue reference with its title |
A reference is short (#N) when the --scope-issue-repo repository equals the --relative-to repository,
compared without regard to case, and full (OWNER/REPO#N) otherwise, so it links to the right issue wherever
the list is pasted. Both flags take OWNER/REPO without a host; anything else fails before anything is fetched,
with exit code 1 and an error along the lines of:
error: invalid repository reference: want [HOST/]OWNER/REPO: --scope-issue-repo "github.com/acme/web"
(an issue reference takes OWNER/REPO, without a host)Renamed flags. --scope-issue-repo and --relative-to were called --issue-repo and --ticket-repo up
to v0.2. The old names are hidden from the help but still work, with a warning on stderr; they will be removed
in a later release (#44). Giving a flag under both
names is an error.
Line formats
By default a line is a bullet with the bare reference, * #12: GitHub expands it with the issue’s title and
state where the list is pasted. Two flags change the line for other places, and combine:
| Flags | Line | For |
|---|---|---|
| — | * #12 | GitHub |
--tasks | - [ ] #12 | A GitHub release ticket QA ticks issues off in |
--titles | * #12 <title> | Places that do not expand references: Zammad, mail |
--tasks --titles | - [ ] #12 <title> | Both |
--tasksneeds no extra request: GitHub renders a task-list reference with the issue’s title and state too. With--unlinkedthe pull request lines become tasks as well.--titlesreads the titles from the--scope-issue-reporepository, one GraphQL request per 25 issues, and only when an issue is referenced. The pull request lines of--unlinkedalready carry their title.- An issue whose title cannot be read keeps its bare reference, is named in a warning on stderr, and the run
exits
4; the list is still printed. - Grouping and ordering do not change.
Pull requests without an issue
--unlinked adds a section after the issues, so nothing in the release stays invisible in the ticket:
### Pull requests without an issue
* #1439 ci: update github actions and enable npm provenance
* #1543 feat: add a project metadata file
* #1565 Release v19.0.0- It lists every pull request without a digit scope, by PR number, with its title as GitHub has it — features and fixes, but also Other, Maintenance and Unclassified changes.
- Dependency updates are left out: Dependabot bumps are noise in a ticket. There is no flag to bring them back;
sortlists them. - A pull request is in the source repository, so its reference is short when the
--relative-torepository is the source repository and full otherwise — also when--scope-issue-repopoints the issues elsewhere.--full-refsmakes it full too. - The section is left out when there is nothing to list, and without
--unlinkedthe output is unchanged. - Back-merges of a release branch (
chore: release/v3.8.0 >> main) are Maintenance, so they are listed. Keep them out of the generated notes altogether with anexcludein the repository’s.github/release.yml, which takes them out of every command’s output, rather than out of this list alone.
notes (hidden)
gh release-changes [-R OWNER/REPO] [--previous-tag <tag> | --since-final] notes [<tag>]A debugging aid, left out of --help: prints the body generate-notes returned for the tag, unparsed, to
stdout. With --debug the previous tag the comparison used is logged on stderr. It is not part of the
contract and may change or go away.
dump
gh release-changes [-R OWNER/REPO] [--previous-tag <tag> | --since-final] dump [<tag>]
[--scope-issue-repo OWNER/REPO]Prints everything needed to write release notes or a changelog for the release as one Markdown document on stdout:
- Every referenced issue — the digit scopes of the titles (and with
--link closingthe issues the pull requests close), read from--scope-issue-repo(default: the source repository): title, state, labels, URL, description, comments, and the pull requests that close it (GitHub’sclosedByPullRequestsReferences, merged or not, each markedIn this release: yesorno), with the release’s own pull requests for it. A release pull request whose title references the issue while GitHub does not link it — “Related to #12” is no closing keyword — is described under Unlinked pull requests in this release, with its description. - Every feature or fix without an issue —
featandfixtitles without a digit scope: title, author, URL, and the pull request’s description. Afix(deps)is a dependency update, not a fix, and is left out. - Breaking changes are marked:
* **Breaking change**on the issue or pull request, and**Breaking:**on the release’s pull request line, as insort.
# Release v2.1.0
* Repository: example-org/app
* Compared with: v2.0.0
* Issues from: example-org/app
## Features
### Issue #12: Exports are too slow for large lists
* State: closed
* Labels: bug, export
* URL: https://github.com/example-org/app/issues/12
* **Breaking change**
* Pull requests in this release:
* **Breaking:** feat(12)!: replace the export API by @alice in https://github.com/example-org/app/pull/40
* fix(12,13): keep the filter after a reload by @carol in https://github.com/example-org/app/pull/42
#### Description
~~~markdown
…
~~~
#### Comments
##### @bob
~~~markdown
…
~~~
#### Linked pull requests
##### #40: feat(12)!: replace the export API
* State: merged
* Author: @alice
* URL: https://github.com/example-org/app/pull/40
* In this release: yes
~~~markdown
…
~~~
#### Unlinked pull requests in this release
##### #42: fix(12,13): keep the filter after a reload
* Author: @carol
* URL: https://github.com/example-org/app/pull/42
~~~markdown
…
~~~
### Pull request #44: feat: add a dark theme
* Author: @carol
* URL: https://github.com/example-org/app/pull/44
#### Description
~~~markdown
…
~~~- One
##heading per group, in the order ofsort. An issue appears once, under the first group that references it; the features and fixes without an issue follow the issues of their group. Maintenance, dependency and unclassified changes without an issue are left out. - Descriptions and comments are GitHub’s Markdown, with line endings normalised, so the same release always
renders the same bytes. Each is quoted in a
~~~markdownfence, so its own headings cannot be read as the dump’s; the fence is longer than any run of~in the text, so nothing inside can close it. Only the first 100 comments of an issue are shown; the dump says how many it left out. - An issue or pull request that cannot be read — it does not exist, the number is a pull request rather than an
issue, the token may not see it — is noted in place with GitHub’s reason (
_Could not be read: …_) and named in a warning on stderr. The rest of the dump is still printed, and the run exits4rather than0. --scope-issue-repotakesOWNER/REPOwithout a host, as forissues, where its old name--issue-repoalso still works; issues from another repository are written asOWNER/REPO#N. A malformed value fails before anything is fetched, with exit code1and the same error as forissues.
check-notes
gh release-changes [-R OWNER/REPO] [--previous-tag <tag> | --since-final] check-notes [<tag>] --file <path>
[--scope-issue-repo OWNER/REPO] [--relative-to OWNER/REPO]Checks release notes — written by hand or with a writing skill — against the release: every issue of the release
is referenced in them, and nothing outside it is. --file - reads the notes from stdin. The report is Markdown
on stdout:
## Notes check
### Issues missing from the notes
* #1514
### References outside the release
* example-org/design-system#88
* #1600Notes that cover the release print The notes reference every issue of the release, and nothing outside it.
and exit 0; a section is left out when it is empty, and any difference exits 2. That is the answer to the
question, not a failure: branch on it with (( code & 2 )).
- The issues of the release are the digit scopes of the titles, as for
issues, in the--scope-issue-reporepository (default: the source repository). With--link closingthe issues the pull requests close are in the release too. - The references in the notes are
#N,OWNER/REPO#N, and issue and pull request URLs (https://HOST/OWNER/REPO/issues/N,…/pull/N). A bare#Nis in the--relative-torepository (default: the source repository): set it to where the notes are published when that is not the source repository. Repositories are compared without regard to case. - A reference to one of the release’s own pull requests is in the release, so it is not reported — notes may name the pull request of a change without an issue. It does not cover the issue the pull request fixes.
- What GitHub would not link is not read as a reference either:
#Nglued to a word or a path (v2#3,docs/setup.md#12), an HTML entity ('), or a number followed by letters (#12abc). References in code blocks are read like any other. - The report writes its references relative to the
--relative-torepository, asissuesdoes. --fileis required; a file that cannot be read fails the run with exit code1before the release is fetched. The repository flags takeOWNER/REPOwithout a host and are checked first, with the same error as forissues; their old names--issue-repoand--ticket-repoalso still work here.