GitHub Milestones as Release Payloads
Issues and pull requests accumulate quickly on any active project. Without structure, a repository becomes a flat list of work with no clear sense of what belongs together, what's blocking a release, or how close you're to shipping. Milestones give that structure: lightweight containers that define the payload of a release, track progress automatically, and surface what remains without requiring a separate project board or external tool.
This post covers how milestones work, how to name and manage them, how to operate them through the UI, the GitHub CLI, and the REST API, and how to automate the tedious parts with GitHub Actions. It's opinionated: these are the practices that work in production open source repositories, not a neutral survey of every option.
A milestone is a named container attached to a repository. It holds issues and pull requests, carries an optional description and due date, and displays a live progress bar computed from the ratio of closed to total items. Nothing more. That simplicity is the point.
Milestones appear on the Issues and Pull requests tabs under the Milestones button, which shows every open milestone and its current progress.
There are some noteable contraints:
-
Milestones are repository-scoped. A milestone named
v1.2.0inexample/apihas no relationship to a milestone with the same name inexample/web. There's no native cross-repository milestone on GitHub. When work spans multiple repositories, reach for a GitHub Project instead. -
Pull requests are issues in the data model. The same milestone applies to both. The same API endpoints update both. This matters when you query milestone contents or write automation: filter on
pulls_urlorpull_requestfield presence if you need to separate them, but for tracking purposes they're interchangeable. -
Milestones on transfer. When you transfer an open issue to another repository, GitHub keeps the milestone only if the destination already has a milestone whose title and due date match the source milestone. Otherwise the issue lands with no milestone. Milestone numbers are always repository-local. Plan matching titles (and due dates, when you use them) across repos if you rely on continuity, or reassign after the move. Pull requests don't follow the same transfer path as issues; treat them separately.
Naming Conventions¶
Milestone names are freeform strings. Consistency is what makes them useful. Three patterns cover the common cases, and one of them is the right default for almost every project.
Semantic Version Tags¶
Use semantic version tags. For open source projects, that's the only naming convention that makes sense. Name milestones after the release they represent, following Semantic Versioning:
When the milestone title matches the git tag, everything becomes obvious: the milestone list is a changelog skeleton, automation that closes the milestone on publish needs no special configuration, and any contributor can look at the milestone for v1.1.0 and see exactly what its release payload contained.
Avoid vague names like "Q2 work", "next release", or "stabilization". They can't be matched to a tag automatically, they tell you nothing about scope, and they're impossible to search consistently across repositories.
A Backlog Milestone¶
Create a milestone named Backlog and keep it open permanently. This milestone isn't a release: it's a triage inbox. Every new issue lands in Backlog the moment it's opened, making its state explicit: received but not yet assessed, prioritized, deferred, or declined. Triage moves issues out of Backlog and into the appropriate release milestone or closes them.
This has two important effects. Release milestones stay clean: every issue in v1.2.0 belongs there because a human made that decision. The Backlog milestone never lies: an issue sitting in Backlog for three months signals a triage problem, not a scope problem.
See Routing New Issues to the Backlog for the GitHub Actions workflow that automates this.
Calendar-Based Sprints¶
For teams that plan in time-boxed iterations rather than versioned releases, date-prefixed sprint names keep the list sortable:
Theme or Goal Labels¶
When work is organized around a goal with no target version number, descriptive names work:
These are most useful as temporary, scoped efforts. Avoid using theme milestones as permanent catch-alls: they share that failure mode with an unsorted backlog.
Creating a Milestone¶
Navigate to the Issues tab, then select Milestones. Choose New milestone. Fill in the title, an optional description, and an optional due date. Only the title is required.
Set a due date on release milestones when the delivery date matters to stakeholders. Leave it empty for the Backlog milestone: it has no target date by design.
The gh CLI has no built-in milestone subcommand. All milestone operations go through gh api, which sends authenticated requests to the REST API on your behalf.
Create a milestone with a title only:
gh api \
--method POST \
--header "Accept: application/vnd.github+json" \
/repos/OWNER/REPO/milestones \
--field title="v1.2.0"
Create a milestone with a description and a due date. GitHub expects the due date in ISO 8601 format with a time component at midnight UTC:
curl \
--request POST \
--header "Authorization: Bearer $GITHUB_TOKEN" \
--header "Accept: application/vnd.github+json" \
--header "X-GitHub-Api-Version: 2022-11-28" \
--data '{"title":"v1.2.0","description":"Stabilization release.","due_on":"2026-05-01T00:00:00Z"}' \
https://api.github.com/repos/OWNER/REPO/milestones
The response body includes a numeric number field. This is the identifier used in all subsequent milestone API calls. It's distinct from the id field: most issue and PR update endpoints expect number, not id.
Assigning Issues and Pull Requests¶
The GitHub API exposes pull requests as a superset of issues. The same PATCH endpoint updates milestone assignment on both. Filtering between them is only necessary when you query milestone contents, not when you write to them.
Open any issue or pull request. In the right sidebar, select the Milestone picker and choose the milestone.
For bulk assignment: select multiple items on the issue list using the checkboxes, then choose Milestone from the Actions dropdown. That's useful when onboarding a repository to milestones for the first time.
Assign a milestone at issue creation time:
gh issue create \
--title "Resolve connection timeout under high load" \
--body "Connections are timing out after 30 seconds under load." \
--milestone "Backlog"
Assign or reassign a milestone on an existing issue:
The --milestone flag accepts either the milestone title or its numeric number.
Clear a milestone assignment:
Pull requests use the same flags: gh pr edit 55 --milestone "v1.2.0".
Assign milestone number 7 to issue 142:
curl \
--request PATCH \
--header "Authorization: Bearer $GITHUB_TOKEN" \
--header "Accept: application/vnd.github+json" \
--header "X-GitHub-Api-Version: 2022-11-28" \
--data '{"milestone":7}' \
https://api.github.com/repos/OWNER/REPO/issues/142
Clear a milestone assignment by setting it to null:
curl \
--request PATCH \
--header "Authorization: Bearer $GITHUB_TOKEN" \
--header "Accept: application/vnd.github+json" \
--header "X-GitHub-Api-Version: 2022-11-28" \
--data '{"milestone":null}' \
https://api.github.com/repos/OWNER/REPO/issues/142
The same endpoint (/repos/{owner}/{repo}/issues/{number}) applies to both issues and pull requests.
Tracking Progress¶
GitHub calculates milestone progress as closed items divided by total items. A milestone with 8 of 10 items closed shows 80% complete. The progress bar updates in real time as issues and pull requests are closed or reopened.
The milestone list shows each open milestone's progress bar and item counts. Clicking into a milestone filters the issue list to that milestone's scope, where you can further filter by assignee, label, or item type.
Listing Milestones¶
List all open milestones with their progress counts:
gh api \
--header "Accept: application/vnd.github+json" \
"/repos/OWNER/REPO/milestones?state=open&sort=due_on&direction=asc" \
--jq '.[] | {number: .number, title: .title, open: .open_issues, closed: .closed_issues, due: .due_on}'
Surface overdue milestones (open milestone, due date in the past):
gh api \
--header "Accept: application/vnd.github+json" \
"/repos/OWNER/REPO/milestones?state=open&sort=due_on&direction=asc" | \
jq --arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'.[] | select(.due_on != null and .due_on < $now) | {number: .number, title: .title, due: .due_on}'
Note
The --jq flag on gh api doesn't accept additional jq arguments such as --arg. Pipe the output to jq directly when you need argument injection, as shown above.
Pagination¶
GET /repos/{owner}/{repo}/milestones and GET /repos/{owner}/{repo}/issues (including ?milestone=) paginate: per_page defaults to 30, caps at 100, and page selects which slice to fetch. Responses include a Link header when another page exists.
For one-off exploration, bump per_page in the query string. For automation over large backlogs, loop on page or use gh api --paginate, which follows Link until the stream ends.
Listing Issues in a Milestone¶
List open issues in a milestone by title:
Query open issues in milestone number 7 and format the output:
Search Qualifiers¶
Searching issues and pull requests on github.com uses the same qualifier vocabulary as the gh issue list --search flag.
Use milestone: with the milestone title. Quote the value when it contains spaces:
no:milestone finds items with no milestone assigned. That pairs well with triage: open issues that still need routing after a transfer, or anything your backlog automation missed.
gh issue list --search 'milestone:"v1.2.0"' --state open
gh issue list --search "no:milestone" --state open
See Searching issues and pull requests for the full qualifier list.
Automating Milestone Management¶
Routing New Issues to the Backlog¶
This workflow assigns every new issue to the Backlog milestone the moment it's opened, enforcing the triage-inbox pattern without any manual step. Contributors don't need to select a milestone; maintainers always have a single place to start triage.
---
name: Assign to Backlog Milestone
on:
issues:
types: [opened]
jobs:
triage:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Assign to Backlog Milestone
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
MILESTONE=$(gh api \
--header "Accept: application/vnd.github+json" \
/repos/${{ github.repository }}/milestones \
--jq 'first(.[] | select(.title == "Backlog") | .number)')
if [ -n "$MILESTONE" ]; then
gh api \
--method PATCH \
--header "Accept: application/vnd.github+json" \
/repos/${{ github.repository }}/issues/${{ github.event.issue.number }} \
--field milestone="$MILESTONE"
fi
The workflow queries the open Backlog milestone by name and assigns the incoming issue to it. If no Backlog milestone exists, the step exits silently without assignment. Create the Backlog milestone before enabling this workflow.
Closing a Milestone Automatically on Release¶
When a release is published, close the milestone whose title matches the release tag. This keeps the open milestone list clean and marks the release payload as complete.
---
name: Close Milestone on Release
on:
release:
types: [published]
jobs:
close:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Close matching milestone
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="${{ github.event.release.tag_name }}"
MATCHING_MILESTONES=$(gh api \
--header "Accept: application/vnd.github+json" \
"/repos/${{ github.repository }}/milestones?state=open" | \
jq --compact-output --arg tag "$TAG" '[.[] | select(.title == $tag) | .number]')
MATCH_COUNT=$(printf '%s' "$MATCHING_MILESTONES" | jq 'length')
if [ "$MATCH_COUNT" -gt 1 ]; then
echo "Expected at most one open milestone titled '$TAG', found $MATCH_COUNT." >&2
exit 1
fi
if [ "$MATCH_COUNT" -eq 1 ]; then
MILESTONE=$(printf '%s' "$MATCHING_MILESTONES" | jq --raw-output '.[0]')
gh api \
--method PATCH \
--header "Accept: application/vnd.github+json" \
/repos/${{ github.repository }}/milestones/"$MILESTONE" \
--field state=closed
fi
The release tag (for example, v1.2.0) is matched against open milestone titles. When found, the milestone is closed. Items still open at release time remain in the milestone's history as open. Decide in advance whether those items roll forward to the next milestone or stay in the closed one as a record of what slipped: this workflow doesn't move them.
Close, Reopen, and Delete Milestones¶
Close a Milestone¶
Close a milestone when its release payload has shipped. Closed milestones disappear from the default milestone list but remain permanently accessible under the Closed filter and via the API with state=closed. They're the project's audit trail.
On the Milestones page, select Close next to the milestone.
Reopen a Milestone¶
Reopen when you closed a milestone by mistake, or when you reopen a release line and want the same title active again. Open issues that were still attached keep their association; nothing moves automatically.
On the Milestones page, open the Closed filter, find the milestone, then select Reopen.
Delete a Milestone¶
Deleting a milestone removes it permanently and can't be undone. Issues and pull requests lose the association but are otherwise unchanged. The milestone and its history are gone.
Recommended Practices¶
-
Name milestones after release versions. For any project with versioned releases, the milestone title should be the semantic release tag:
v1.0.0,v1.1.0,v2.0.0. The milestone list becomes a release history. The automation that closes milestones on publish requires no configuration. Ambiguous names break both. -
Keep a permanent
Backlogmilestone. Every issue that arrives goes toBacklogfirst. Triage moves it to a release milestone or closes it. Nothing enters a release milestone without a deliberate decision. That's the single highest-leverage habit for keeping release payloads honest. -
Keep each release milestone's payload small enough to ship. A milestone with 80 open issues isn't a plan: it's a wish list. If a milestone grows unboundedly, it's become a second backlog. Split it into a smaller release and a follow-on, or move lower-priority items back to
Backlog. -
One active milestone per release line. When you maintain multiple release branches simultaneously, carry one open milestone per branch:
v1.2.0andv2.0.0, not a single milestone spanning both. Work that belongs to both branches should be tracked separately. -
Use due dates as signals, not deadlines. A due date on a milestone tells stakeholders when you intend to ship. It doesn't prevent merging, block issue closure, or trigger anything automatically. Treat it as a communication tool and update it honestly when the plan changes. A past-due milestone that hasn't been updated is a maintenance failure, not a feature.
-
Don't use milestones as labels. Labels describe what an issue is. Milestones describe when it ships. A label named
v1.2.0adds noise without adding information. If you're tempted to create both a label and a milestone with the same name, delete the label and use the milestone. -
Milestones aren't a project board. Milestones answer one question: "What's the release payload for this repository and how far along are we?" They don't rank work, visualize workflow states, or aggregate across repositories. When you need any of those things, use a GitHub Project. Milestones and Projects are complementary: milestones own the payload definition, Projects own the planning view.
-
Don't delete a closed milestone. Closed milestones are permanent record. The question "Which release did this issue land in?" has an answer as long as you close milestones and leave them in place. Delete the milestone and the answer disappears.
Summary¶
| Operation | UI | CLI | API |
|---|---|---|---|
| Create milestone | ✓ | ✓ | ✓ |
| Assign to issue or pull request | ✓ | ✓ | ✓ |
| Track progress | ✓ | ✓ | ✓ |
| List issues in scope | ✓ | ✓ | ✓ |
| Search by milestone | ✓ | ✓ | ✓ 1 |
| Close milestone | ✓ | ✓ | ✓ |
| Reopen milestone | ✓ | ✓ | ✓ |
Route new issues to Backlog | ✓ (Actions) | ||
| Close on release publish | ✓ (Actions) |
A milestone is a simple thing: a named group with a progress bar. The practice you build around milestones is what makes them valuable. Name milestones after release versions, keep a permanent Backlog for triage, define release payloads deliberately, and close milestones when releases ship. Done consistently, the milestone list becomes an accurate, always-live picture of where the project stands.
-
REST search uses
GET /search/issueswith aqstring (for examplerepo:OWNER/REPO milestone:"v1.2.0"), not the milestones CRUD routes. ↩