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..efe236c5a 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,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 @@ -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: 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