Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions .github/scripts/notify-release-pull-requests.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
const RELEASE_PLEASE_BRANCH_PREFIX = 'release-please--';

export function releaseNoticeMarker(tag) {
return `<!-- gitify-release-notice:${tag} -->`;
}

export function releaseNoticeBody(tag, releaseUrl) {
return [
`🎉 This pull request is included in **[Gitify ${tag}](${releaseUrl})**! 🎉`,
'',
'Downloads for macOS, Windows, and Linux are available on the release page.',
'',
'Thanks for helping make Gitify better!',
'',
releaseNoticeMarker(tag),
].join('\n');
}

export async function findVersionMilestone(github, repository, tag) {
const milestoneTitle = `Release ${tag.replace(/^v/, '')}`;
const milestones = await github.paginate(github.rest.issues.listMilestones, {
...repository,
state: 'all',
per_page: 100,
});
const matches = milestones.filter((milestone) => milestone.title === milestoneTitle);

if (matches.length !== 1) {
throw new Error(
`Expected exactly one milestone titled ${milestoneTitle}; found ${matches.length}`,
);
}

return matches[0];
}

export async function listMilestoneItems(github, repository, milestoneNumber) {
return github.paginate(github.rest.issues.listForRepo, {
...repository,
milestone: String(milestoneNumber),
state: 'all',
per_page: 100,
});
}

export function isEligiblePullRequest(pullRequest) {
return Boolean(
pullRequest.merged_at && !pullRequest.head.ref.startsWith(RELEASE_PLEASE_BRANCH_PREFIX),
);
}

export async function hasReleaseNotice(github, repository, pullRequestNumber, marker) {
const comments = await github.paginate(github.rest.issues.listComments, {
...repository,
issue_number: pullRequestNumber,
per_page: 100,
});
return comments.some((comment) => comment.body?.includes(marker));
}

export async function notifyReleasedPullRequests(github, { owner, repo, tag }) {
const repository = { owner, repo };
const { data: release } = await github.rest.repos.getReleaseByTag({
...repository,
tag,
});
if (release.draft) {
throw new Error(`Release ${tag} is still a draft`);
}

const milestone = await findVersionMilestone(github, repository, tag);
const items = await listMilestoneItems(github, repository, milestone.number);
const marker = releaseNoticeMarker(tag);
const failures = [];
let notified = 0;
let skipped = 0;

for (const item of items) {
if (!item.pull_request) {
skipped += 1;
continue;
}

try {
const { data: pullRequest } = await github.rest.pulls.get({
...repository,
pull_number: item.number,
});
if (!isEligiblePullRequest(pullRequest)) {
skipped += 1;
continue;
}

if (await hasReleaseNotice(github, repository, item.number, marker)) {
skipped += 1;
continue;
}

await github.rest.issues.createComment({
...repository,
issue_number: item.number,
body: releaseNoticeBody(tag, release.html_url),
});
notified += 1;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
failures.push(new Error(`Pull request #${item.number}: ${message}`, { cause: error }));
}
}

if (failures.length > 0) {
throw new AggregateError(
failures,
`Failed to notify ${failures.length} pull request(s); ${notified} succeeded`,
);
}

return { notified, skipped };
}
62 changes: 62 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -163,3 +163,65 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG_NAME: ${{ inputs.tag_name }}
REPO: ${{ github.repository }}

notify-pull-requests:
name: Notify included pull requests
needs: publish-release
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
pull-requests: write
steps:
- name: Close milestone for released version
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
TAG_NAME: ${{ inputs.tag_name }}
run: |
# The release is published, so the tag URL is definitively live. Finalize
# the milestone before notifying: description becomes the release URL, the
# due date becomes today, and the milestone is closed. The notification
# step queries milestones across all states, so a closed milestone still
# finds the pull requests it contains on re-runs.
version="${TAG_NAME#v}"
title="Release ${version}"
url="https://github.com/${REPO}/releases/tag/${TAG_NAME}"

number=$(gh api --method GET "repos/${REPO}/milestones?state=open&per_page=100" \
--jq ".[] | select(.title == \"${title}\") | .number" | head -1)
if [ -z "${number}" ]; then
echo "::warning::No open milestone titled '${title}' found; skipping finalize"
exit 0
fi

now=$(date -u +%Y-%m-%dT%H:%M:%SZ)
gh api --method PATCH "repos/${REPO}/milestones/${number}" \
-f "description=${url}" -f "due_on=${now}" -f state=closed
echo "Finalized milestone '${title}': ${url}"

- name: Checkout trusted release automation
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
ref: ${{ github.sha }}

- name: Notify pull requests included in the release
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const { pathToFileURL } = await import('node:url');
const scriptUrl = pathToFileURL(
`${process.env.GITHUB_WORKSPACE}/.github/scripts/notify-release-pull-requests.mjs`,
);
const { notifyReleasedPullRequests } = await import(scriptUrl);
const result = await notifyReleasedPullRequests(github, {
...context.repo,
tag: process.env.TAG_NAME,
});
core.info(
`Release ${process.env.TAG_NAME}: notified ${result.notified} pull request(s), skipped ${result.skipped}`,
);
env:
TAG_NAME: ${{ inputs.tag_name }}
86 changes: 86 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ jobs:
outputs:
release_created: ${{ steps.release.outputs.release_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
release_pr_title: ${{ steps.pr.outputs.prs_created == 'true' && fromJSON(steps.pr.outputs.pr).title || '' }}
steps:
- name: Run release-please (releases)
id: release
Expand All @@ -38,11 +39,94 @@ jobs:
echo "count=${count}" >> "$GITHUB_OUTPUT"

- name: Run release-please (release PR)
id: pr
if: ${{ steps.release.outputs.release_created != 'true' && steps.drafts.outputs.count == '0' }}
uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0
with:
skip-github-release: true

milestones:
name: Milestones
needs: release-please
if: ${{ needs.release-please.outputs.release_pr_title != '' }}
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- name: Ensure open milestone for upcoming release
id: ensure
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
TITLE: ${{ needs.release-please.outputs.release_pr_title }}
run: |
# The release-please release PR title is `chore(main): release X.Y.Z`.
# Parse the version so the open milestone always tracks the next release.
version=$(printf '%s' "${TITLE}" | sed -n -E 's/.*release[[:space:]]+([0-9]+\.[0-9]+\.[0-9]+).*/\1/p')
if [ -z "${version}" ]; then
echo "::warning::Could not parse a version from the release-please PR title '${TITLE}'"
exit 0
fi

target="Release ${version}"
open=$(gh api --method GET "repos/${REPO}/milestones?state=open&per_page=100" \
--jq '[.[] | {number, title}]')
count=$(printf '%s' "${open}" | jq 'length')

if [ "${count}" -eq 0 ]; then
gh api --method POST "repos/${REPO}/milestones" \
-f "title=${target}" \
-f "description=Upcoming release; merged pull requests ship in v${version}."
echo "Created open milestone '${target}'"
elif [ "${count}" -eq 1 ]; then
current=$(printf '%s' "${open}" | jq -r '.[0].title')
if [ "${current}" != "${target}" ]; then
number=$(printf '%s' "${open}" | jq -r '.[0].number')
# Rename (not recreate): pull requests already attached stay attached
# and still ship in the bumped version.
gh api --method PATCH "repos/${REPO}/milestones/${number}" -f "title=${target}"
echo "Renamed open milestone '${current}' to '${target}'"
else
echo "Open milestone '${target}' already exists"
fi
else
echo "::warning::Expected at most one open milestone, found ${count}; leaving them untouched"
exit 0
fi

echo "target=${target}" >> "$GITHUB_OUTPUT"

- name: Attach triggering pull request(s)
if: steps.ensure.outputs.target != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
SHA: ${{ github.sha }}
TARGET: ${{ steps.ensure.outputs.target }}
run: |
# milestone.yml can lose a race with this job: it evaluates the open
# milestone on `pull_request: closed`, before release-please has opened
# the next release PR and this job has created the milestone. Resolve the
# pull request(s) for the push tip and attach any that shipped without one,
# so they are neither missing from the cycle's milestone nor the notify job.
prs=$(gh api "repos/${REPO}/commits/${SHA}/pulls" \
--jq '.[] | select(.merged_at != null) | [.number, (.milestone.title // "")] | @tsv')
if [ -z "${prs}" ]; then
echo "No merged pull request associated with ${SHA}; nothing to attach"
exit 0
fi

while IFS=$'\t' read -r number current; do
if [ -z "${number}" ]; then continue; fi
if [ "${current}" = "${TARGET}" ]; then
echo "Pull request #${number} already on '${TARGET}'; skipping"
continue
fi
gh pr edit "${number}" --repo "${REPO}" --milestone "${TARGET}"
echo "Attached pull request #${number} to '${TARGET}'"
done <<< "${prs}"

lint:
name: Lint App
uses: ./.github/workflows/lint.yml
Expand All @@ -66,6 +150,8 @@ jobs:
needs: [release-please, tests]
permissions:
contents: write
issues: write
pull-requests: write
with:
tag_name: ${{ needs.release-please.outputs.tag_name }}
secrets:
Expand Down
17 changes: 13 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,20 @@ Releases are automated with [release-please][release-please]. There is no releas
- attaches the assets to the release that release-please drafted, and
- 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).

4. **(Optional) Update milestones:**
4. **Milestones and notifications are automated.**

- Edit the current [Milestone][github-milestones]: add a link to the release notes, set the due date to the release date, and close it.
- Create a [New Milestone][github-new-milestone] for the next release cycle.
- 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.
- 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.
- 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.

No manual milestone creation, renaming, or closing is required.

#### Release automation permissions

- Packaging and publication use `contents: write`.
- The milestone automation uses `issues: write`.
- Pull request notification uses `issues: write`, `pull-requests: write`, and `contents: read`.
- The validation jobs (`lint`, `tests`) remain read-only.

### Design Guidelines

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