Skip to content

Commit d3dde75

Browse files
authored
ci: automate release milestones and notify included pull requests (#3357)
1 parent 3c46eff commit d3dde75

4 files changed

Lines changed: 280 additions & 4 deletions

File tree

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
const RELEASE_PLEASE_BRANCH_PREFIX = 'release-please--';
2+
3+
export function releaseNoticeMarker(tag) {
4+
return `<!-- gitify-release-notice:${tag} -->`;
5+
}
6+
7+
export function releaseNoticeBody(tag, releaseUrl) {
8+
return [
9+
`🎉 This pull request is included in **[Gitify ${tag}](${releaseUrl})**! 🎉`,
10+
'',
11+
'Downloads for macOS, Windows, and Linux are available on the release page.',
12+
'',
13+
'Thanks for helping make Gitify better!',
14+
'',
15+
releaseNoticeMarker(tag),
16+
].join('\n');
17+
}
18+
19+
export async function findVersionMilestone(github, repository, tag) {
20+
const milestoneTitle = `Release ${tag.replace(/^v/, '')}`;
21+
const milestones = await github.paginate(github.rest.issues.listMilestones, {
22+
...repository,
23+
state: 'all',
24+
per_page: 100,
25+
});
26+
const matches = milestones.filter((milestone) => milestone.title === milestoneTitle);
27+
28+
if (matches.length !== 1) {
29+
throw new Error(
30+
`Expected exactly one milestone titled ${milestoneTitle}; found ${matches.length}`,
31+
);
32+
}
33+
34+
return matches[0];
35+
}
36+
37+
export async function listMilestoneItems(github, repository, milestoneNumber) {
38+
return github.paginate(github.rest.issues.listForRepo, {
39+
...repository,
40+
milestone: String(milestoneNumber),
41+
state: 'all',
42+
per_page: 100,
43+
});
44+
}
45+
46+
export function isEligiblePullRequest(pullRequest) {
47+
return Boolean(
48+
pullRequest.merged_at && !pullRequest.head.ref.startsWith(RELEASE_PLEASE_BRANCH_PREFIX),
49+
);
50+
}
51+
52+
export async function hasReleaseNotice(github, repository, pullRequestNumber, marker) {
53+
const comments = await github.paginate(github.rest.issues.listComments, {
54+
...repository,
55+
issue_number: pullRequestNumber,
56+
per_page: 100,
57+
});
58+
return comments.some((comment) => comment.body?.includes(marker));
59+
}
60+
61+
export async function notifyReleasedPullRequests(github, { owner, repo, tag }) {
62+
const repository = { owner, repo };
63+
const { data: release } = await github.rest.repos.getReleaseByTag({
64+
...repository,
65+
tag,
66+
});
67+
if (release.draft) {
68+
throw new Error(`Release ${tag} is still a draft`);
69+
}
70+
71+
const milestone = await findVersionMilestone(github, repository, tag);
72+
const items = await listMilestoneItems(github, repository, milestone.number);
73+
const marker = releaseNoticeMarker(tag);
74+
const failures = [];
75+
let notified = 0;
76+
let skipped = 0;
77+
78+
for (const item of items) {
79+
if (!item.pull_request) {
80+
skipped += 1;
81+
continue;
82+
}
83+
84+
try {
85+
const { data: pullRequest } = await github.rest.pulls.get({
86+
...repository,
87+
pull_number: item.number,
88+
});
89+
if (!isEligiblePullRequest(pullRequest)) {
90+
skipped += 1;
91+
continue;
92+
}
93+
94+
if (await hasReleaseNotice(github, repository, item.number, marker)) {
95+
skipped += 1;
96+
continue;
97+
}
98+
99+
await github.rest.issues.createComment({
100+
...repository,
101+
issue_number: item.number,
102+
body: releaseNoticeBody(tag, release.html_url),
103+
});
104+
notified += 1;
105+
} catch (error) {
106+
const message = error instanceof Error ? error.message : String(error);
107+
failures.push(new Error(`Pull request #${item.number}: ${message}`, { cause: error }));
108+
}
109+
}
110+
111+
if (failures.length > 0) {
112+
throw new AggregateError(
113+
failures,
114+
`Failed to notify ${failures.length} pull request(s); ${notified} succeeded`,
115+
);
116+
}
117+
118+
return { notified, skipped };
119+
}

‎.github/workflows/publish.yml‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,3 +163,65 @@ jobs:
163163
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
164164
TAG_NAME: ${{ inputs.tag_name }}
165165
REPO: ${{ github.repository }}
166+
167+
notify-pull-requests:
168+
name: Notify included pull requests
169+
needs: publish-release
170+
runs-on: ubuntu-latest
171+
permissions:
172+
contents: read
173+
issues: write
174+
pull-requests: write
175+
steps:
176+
- name: Close milestone for released version
177+
env:
178+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
179+
REPO: ${{ github.repository }}
180+
TAG_NAME: ${{ inputs.tag_name }}
181+
run: |
182+
# The release is published, so the tag URL is definitively live. Finalize
183+
# the milestone before notifying: description becomes the release URL, the
184+
# due date becomes today, and the milestone is closed. The notification
185+
# step queries milestones across all states, so a closed milestone still
186+
# finds the pull requests it contains on re-runs.
187+
version="${TAG_NAME#v}"
188+
title="Release ${version}"
189+
url="https://github.com/${REPO}/releases/tag/${TAG_NAME}"
190+
191+
number=$(gh api --method GET "repos/${REPO}/milestones?state=open&per_page=100" \
192+
--jq ".[] | select(.title == \"${title}\") | .number" | head -1)
193+
if [ -z "${number}" ]; then
194+
echo "::warning::No open milestone titled '${title}' found; skipping finalize"
195+
exit 0
196+
fi
197+
198+
now=$(date -u +%Y-%m-%dT%H:%M:%SZ)
199+
gh api --method PATCH "repos/${REPO}/milestones/${number}" \
200+
-f "description=${url}" -f "due_on=${now}" -f state=closed
201+
echo "Finalized milestone '${title}': ${url}"
202+
203+
- name: Checkout trusted release automation
204+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
205+
with:
206+
persist-credentials: false
207+
ref: ${{ github.sha }}
208+
209+
- name: Notify pull requests included in the release
210+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
211+
with:
212+
github-token: ${{ secrets.GITHUB_TOKEN }}
213+
script: |
214+
const { pathToFileURL } = await import('node:url');
215+
const scriptUrl = pathToFileURL(
216+
`${process.env.GITHUB_WORKSPACE}/.github/scripts/notify-release-pull-requests.mjs`,
217+
);
218+
const { notifyReleasedPullRequests } = await import(scriptUrl);
219+
const result = await notifyReleasedPullRequests(github, {
220+
...context.repo,
221+
tag: process.env.TAG_NAME,
222+
});
223+
core.info(
224+
`Release ${process.env.TAG_NAME}: notified ${result.notified} pull request(s), skipped ${result.skipped}`,
225+
);
226+
env:
227+
TAG_NAME: ${{ inputs.tag_name }}

‎.github/workflows/release.yml‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ jobs:
2121
outputs:
2222
release_created: ${{ steps.release.outputs.release_created }}
2323
tag_name: ${{ steps.release.outputs.tag_name }}
24+
release_pr_title: ${{ steps.pr.outputs.prs_created == 'true' && fromJSON(steps.pr.outputs.pr).title || '' }}
2425
steps:
2526
- name: Run release-please (releases)
2627
id: release
@@ -38,11 +39,94 @@ jobs:
3839
echo "count=${count}" >> "$GITHUB_OUTPUT"
3940
4041
- name: Run release-please (release PR)
42+
id: pr
4143
if: ${{ steps.release.outputs.release_created != 'true' && steps.drafts.outputs.count == '0' }}
4244
uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0
4345
with:
4446
skip-github-release: true
4547

48+
milestones:
49+
name: Milestones
50+
needs: release-please
51+
if: ${{ needs.release-please.outputs.release_pr_title != '' }}
52+
runs-on: ubuntu-latest
53+
permissions:
54+
issues: write
55+
pull-requests: write
56+
steps:
57+
- name: Ensure open milestone for upcoming release
58+
id: ensure
59+
env:
60+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
61+
REPO: ${{ github.repository }}
62+
TITLE: ${{ needs.release-please.outputs.release_pr_title }}
63+
run: |
64+
# The release-please release PR title is `chore(main): release X.Y.Z`.
65+
# Parse the version so the open milestone always tracks the next release.
66+
version=$(printf '%s' "${TITLE}" | sed -n -E 's/.*release[[:space:]]+([0-9]+\.[0-9]+\.[0-9]+).*/\1/p')
67+
if [ -z "${version}" ]; then
68+
echo "::warning::Could not parse a version from the release-please PR title '${TITLE}'"
69+
exit 0
70+
fi
71+
72+
target="Release ${version}"
73+
open=$(gh api --method GET "repos/${REPO}/milestones?state=open&per_page=100" \
74+
--jq '[.[] | {number, title}]')
75+
count=$(printf '%s' "${open}" | jq 'length')
76+
77+
if [ "${count}" -eq 0 ]; then
78+
gh api --method POST "repos/${REPO}/milestones" \
79+
-f "title=${target}" \
80+
-f "description=Upcoming release; merged pull requests ship in v${version}."
81+
echo "Created open milestone '${target}'"
82+
elif [ "${count}" -eq 1 ]; then
83+
current=$(printf '%s' "${open}" | jq -r '.[0].title')
84+
if [ "${current}" != "${target}" ]; then
85+
number=$(printf '%s' "${open}" | jq -r '.[0].number')
86+
# Rename (not recreate): pull requests already attached stay attached
87+
# and still ship in the bumped version.
88+
gh api --method PATCH "repos/${REPO}/milestones/${number}" -f "title=${target}"
89+
echo "Renamed open milestone '${current}' to '${target}'"
90+
else
91+
echo "Open milestone '${target}' already exists"
92+
fi
93+
else
94+
echo "::warning::Expected at most one open milestone, found ${count}; leaving them untouched"
95+
exit 0
96+
fi
97+
98+
echo "target=${target}" >> "$GITHUB_OUTPUT"
99+
100+
- name: Attach triggering pull request(s)
101+
if: steps.ensure.outputs.target != ''
102+
env:
103+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
104+
REPO: ${{ github.repository }}
105+
SHA: ${{ github.sha }}
106+
TARGET: ${{ steps.ensure.outputs.target }}
107+
run: |
108+
# milestone.yml can lose a race with this job: it evaluates the open
109+
# milestone on `pull_request: closed`, before release-please has opened
110+
# the next release PR and this job has created the milestone. Resolve the
111+
# pull request(s) for the push tip and attach any that shipped without one,
112+
# so they are neither missing from the cycle's milestone nor the notify job.
113+
prs=$(gh api "repos/${REPO}/commits/${SHA}/pulls" \
114+
--jq '.[] | select(.merged_at != null) | [.number, (.milestone.title // "")] | @tsv')
115+
if [ -z "${prs}" ]; then
116+
echo "No merged pull request associated with ${SHA}; nothing to attach"
117+
exit 0
118+
fi
119+
120+
while IFS=$'\t' read -r number current; do
121+
if [ -z "${number}" ]; then continue; fi
122+
if [ "${current}" = "${TARGET}" ]; then
123+
echo "Pull request #${number} already on '${TARGET}'; skipping"
124+
continue
125+
fi
126+
gh pr edit "${number}" --repo "${REPO}" --milestone "${TARGET}"
127+
echo "Attached pull request #${number} to '${TARGET}'"
128+
done <<< "${prs}"
129+
46130
lint:
47131
name: Lint App
48132
uses: ./.github/workflows/lint.yml
@@ -66,6 +150,8 @@ jobs:
66150
needs: [release-please, tests]
67151
permissions:
68152
contents: write
153+
issues: write
154+
pull-requests: write
69155
with:
70156
tag_name: ${{ needs.release-please.outputs.tag_name }}
71157
secrets:

‎CONTRIBUTING.md‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -107,10 +107,20 @@ Releases are automated with [release-please][release-please]. There is no releas
107107
- attaches the assets to the release that release-please drafted, and
108108
- publishes the release (creating the `vX.Y.Z` tag), which redeploys the website and triggers the automatic [Homebrew cask bump][homebrew-cask-autobump-workflow] (workflow runs ~3 hours).
109109

110-
4. **(Optional) Update milestones:**
110+
4. **Milestones and notifications are automated.**
111111

112-
- Edit the current [Milestone][github-milestones]: add a link to the release notes, set the due date to the release date, and close it.
113-
- Create a [New Milestone][github-new-milestone] for the next release cycle.
112+
- An open [Milestone][github-milestones] titled `Release X.Y.Z` is kept in step with the release-please version, and merged pull requests are attached to it as they land.
113+
- When the release is published, the milestone is finalized: its description becomes the release URL, its due date is set to the release date, and it is closed.
114+
- A separate job comments once on every pull request included in the release, linking contributors to the release page. Re-runs are idempotent and never post duplicate comments.
115+
116+
No manual milestone creation, renaming, or closing is required.
117+
118+
#### Release automation permissions
119+
120+
- Packaging and publication use `contents: write`.
121+
- The milestone automation uses `issues: write`.
122+
- Pull request notification uses `issues: write`, `pull-requests: write`, and `contents: read`.
123+
- The validation jobs (`lint`, `tests`) remain read-only.
114124

115125
### Design Guidelines
116126

@@ -150,7 +160,6 @@ Currently supported forges: **GitHub** (Cloud, Enterprise Server, Enterprise Clo
150160
[github-dependency-dashboard]: https://github.com/gitify-app/gitify/issues/576
151161
[github-issues]: https://github.com/setchy/gitify/issues
152162
[github-milestones]: https://github.com/gitify-app/gitify/milestones
153-
[github-new-milestone]: https://github.com/gitify-app/gitify/milestones/new
154163
[github-new-release]: https://github.com/gitify-app/gitify/releases/new
155164
[github-octicons]: https://primer.style/foundations/icons
156165
[homebrew-cask-autobump-workflow]: https://github.com/Homebrew/homebrew-cask/actions/workflows/autobump.yml

0 commit comments

Comments
 (0)