From 5c414f046cff129c0b259cd05d1c78ddeb95e690 Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Sat, 3 Oct 2026 10:34:05 -0400 Subject: [PATCH 1/2] ci: automate release milestones and notify included pull requests Port the Atlassify release automation to Gitify: - Derive the open `Release X.Y.Z` milestone from the release-please release-PR title and keep it in step as the calculated version changes. - Finalize the milestone (release URL, due date, closed) once the release is published, on both automated and manual publish paths. - Comment once per included pull request with a link to the published release, using a hidden per-tag marker for idempotent re-runs. See openspec/changes/release-milestone-automation. --- .../scripts/notify-release-pull-requests.mjs | 119 ++++++++++++++++++ .github/workflows/publish.yml | 62 +++++++++ .github/workflows/release.yml | 51 ++++++++ CONTRIBUTING.md | 17 ++- 4 files changed, 245 insertions(+), 4 deletions(-) create mode 100644 .github/scripts/notify-release-pull-requests.mjs diff --git a/.github/scripts/notify-release-pull-requests.mjs b/.github/scripts/notify-release-pull-requests.mjs new file mode 100644 index 000000000..6f1ca1678 --- /dev/null +++ b/.github/scripts/notify-release-pull-requests.mjs @@ -0,0 +1,119 @@ +const RELEASE_PLEASE_BRANCH_PREFIX = 'release-please--'; + +export function releaseNoticeMarker(tag) { + return ``; +} + +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 }; +} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b21e836dc..0695e5d30 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -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 }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5bac7b569..cb5857dee 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -38,11 +39,59 @@ 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 + steps: + - name: Ensure open milestone for upcoming release + 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" + fi + lint: name: Lint App uses: ./.github/workflows/lint.yml @@ -66,6 +115,8 @@ jobs: needs: [release-please, tests] permissions: contents: write + issues: write + pull-requests: write with: tag_name: ${{ needs.release-please.outputs.tag_name }} secrets: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2dae854fa..d04bac4bb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 @@ -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 From f0801c0bae72fb3dc834681d03d4f205e4c488bb Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Sat, 3 Oct 2026 10:58:42 -0400 Subject: [PATCH 2/2] ci: attach the triggering pull request to the release milestone `milestone.yml` runs on `pull_request: closed` and can reach its attach step before release-please has opened the next release PR and the `milestones` job has created the new milestone. When it wins that race it finds no open milestone and skips, so the first pull request of the cycle is never attached and never notified. In the `milestones` job, after the milestone is ensured, resolve the merged pull request(s) for the push tip and attach any that do not already carry it. Idempotent (skips matching milestones), merged-only, and env-indirected. See openspec/changes/harden-release-milestone-attachment. --- .github/workflows/release.yml | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cb5857dee..efe236c5a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -52,8 +52,10 @@ jobs: 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 }} @@ -90,8 +92,41 @@ jobs: 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