diff --git a/README.md b/README.md index 8851691..4ab5ab1 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Please refer to the [release page](https://github.com/joshjohanning/publish-gith | `publish_minor_version` | Whether to publish minor version tag (e.g., `v1.2`) | No | `false` | | `publish_release_branch` | Whether to publish release branch (e.g., `releases/v1.2.3`) | No | `false` | | `create_release_as_draft` | Whether to create release as draft to allow review of the release before publishing; useful with [immutable releases](https://docs.github.com/en/actions/how-tos/create-and-publish-actions/using-immutable-releases-and-tags-to-manage-your-actions-releases) where changes cannot be made after publishing | No | `false` | -| `draft_release_pr_reminder` | Post a reminder comment on the merged PR when creating a draft release | No | `false` | +| `draft_release_pr_reminder` | Post a reminder comment on the merged PR when creating a draft release. When the release is published (requires `on: release: types: [published]` trigger), the comment is updated to show the published state with checked-off steps. | No | `false` | | `comment_on_linked_issues` | Comment on closed issues linked to PRs in the release notes to notify followers of the release (uses GraphQL `closingIssuesReferences`; idempotent — updates existing comment on rerun). Comments are posted for both published and draft releases; the comment is updated if the version changes on a subsequent run. | No | `false` | ### Commit Signing Behavior @@ -50,6 +50,26 @@ The action automatically handles clean builds and file management: - **Dist folder cleaning**: When `commit_dist_folder: true` and `npm_package_command` is specified, the `dist/` folder is cleaned before building to ensure no stale files persist - **Automatic file deletion**: The action removes `.github/` files from release commits and properly handles renamed/deleted files in the `dist/` folder +### Draft Release PR Reminder + +When `draft_release_pr_reminder: true` is enabled, the action: + +1. **On PR merge** — Posts a reminder comment on the merged PR with a link to the draft release and a next-steps checklist +2. **On release publish** — Automatically updates the comment to show "✅ Release Published" with checked-off steps and a working link + +To enable comment updates when a draft release is published, add the `on: release: types: [published]` trigger to your workflow: + +```yml +on: + push: + branches: + - main + release: + types: [published] +``` + +> **Note:** Legacy draft comments (created before this update feature was added) are also detected and updated via a fallback matcher. + ## Permissions The action requires specific permissions depending on features used: @@ -80,6 +100,8 @@ on: push: branches: - main + release: + types: [published] # Required for updating PR comment after draft release is published jobs: publish: diff --git a/__tests__/index.test.js b/__tests__/index.test.js index 4e1bce4..15259a5 100644 --- a/__tests__/index.test.js +++ b/__tests__/index.test.js @@ -20,10 +20,16 @@ const mockExec = { }; // Mock the @actions/github module +const mockGithubContext = { + eventName: 'push', + repo: { owner: 'test-owner', repo: 'test-repo' }, + sha: 'abc123def456', + payload: {} +}; + const mockGithub = { - context: { - repo: { owner: 'test-owner', repo: 'test-repo' }, - sha: 'abc123def456' + get context() { + return mockGithubContext; }, getOctokit: jest.fn() }; @@ -55,6 +61,9 @@ const mockOctokit = { }, users: { getAuthenticated: jest.fn() + }, + pulls: { + list: jest.fn() } }, paginate: jest.fn(), @@ -117,6 +126,12 @@ describe('Publish GitHub Action', () => { beforeEach(() => { jest.clearAllMocks(); + // Reset context to default push event + mockGithubContext.eventName = 'push'; + mockGithubContext.repo = { owner: 'test-owner', repo: 'test-repo' }; + mockGithubContext.sha = 'abc123def456'; + mockGithubContext.payload = {}; + // Set up default mocks mockGithub.getOctokit.mockReturnValue(mockOctokit); @@ -166,6 +181,7 @@ describe('Publish GitHub Action', () => { mockOctokit.rest.issues.createComment.mockResolvedValue({ data: { id: 456 } }); mockOctokit.rest.issues.updateComment.mockResolvedValue({ data: { id: 456 } }); mockOctokit.rest.users.getAuthenticated.mockResolvedValue({ data: { login: 'test-bot' } }); + mockOctokit.rest.pulls.list.mockResolvedValue({ data: [] }); mockOctokit.graphql.mockResolvedValue({ repository: { pullRequest: { @@ -2066,4 +2082,272 @@ describe('Publish GitHub Action', () => { expect(mockCore.setFailed).toHaveBeenCalledWith('Object does not exist'); }); }); + + describe('release.published event (update draft PR comment)', () => { + const releasePayload = { + action: 'published', + release: { + tag_name: 'v1.2.3', + html_url: 'https://github.com/test-owner/test-repo/releases/tag/untagged-abc123def456' + } + }; + + beforeEach(() => { + mockGithubContext.eventName = 'release'; + mockGithubContext.payload = releasePayload; + + // Default: tag resolves to a commit, commit is associated with a merged PR + mockOctokit.rest.git.getRef.mockResolvedValue({ data: { object: { sha: 'tag-commit-sha' } } }); + mockOctokit.rest.repos.listPullRequestsAssociatedWithCommit.mockResolvedValue({ data: [] }); + }); + + it('should route release.published event to handleReleasePublished', async () => { + await run(); + + expect(mockOctokit.rest.git.getRef).toHaveBeenCalledWith(expect.objectContaining({ ref: 'tags/v1.2.3' })); + expect(mockOctokit.rest.repos.listPullRequestsAssociatedWithCommit).toHaveBeenCalledWith( + expect.objectContaining({ commit_sha: 'tag-commit-sha' }) + ); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining('Release published: v1.2.3')); + expect(mockCore.info).toHaveBeenCalledWith('✅ Release published event handled!'); + }); + + it('should not read package.json or create releases on release event', async () => { + await run(); + + // These are part of the normal publish flow — should NOT be called + expect(mockOctokit.rest.repos.listTags).not.toHaveBeenCalled(); + expect(mockOctokit.rest.repos.createRelease).not.toHaveBeenCalled(); + expect(mockExec.exec).not.toHaveBeenCalled(); + }); + + it('should skip when draft_release_pr_reminder is false', async () => { + mockCore.getInput.mockImplementation(name => { + if (name === 'draft_release_pr_reminder') return 'false'; + if (name === 'github_token') return 'test-token'; + if (name === 'github_api_url') return 'https://api.github.com'; + return ''; + }); + + await run(); + + expect(mockOctokit.rest.git.getRef).not.toHaveBeenCalled(); + expect(mockCore.info).toHaveBeenCalledWith('Skipping PR comment update (draft_release_pr_reminder is disabled)'); + }); + + it('should warn and return on missing release payload', async () => { + mockGithubContext.payload = { action: 'published', release: {} }; + + await run(); + + expect(mockCore.warning).toHaveBeenCalledWith( + 'Release event missing expected payload data; cannot update PR comments.' + ); + expect(mockOctokit.rest.git.getRef).not.toHaveBeenCalled(); + }); + + it('should find and update a draft comment by version-specific marker', async () => { + const draftBody = + '\n## 📦 Draft Release Created\n\nA draft release **v1.2.3** has been created.'; + + mockOctokit.rest.repos.listPullRequestsAssociatedWithCommit.mockResolvedValue({ + data: [{ number: 42, merged_at: '2024-01-01T00:00:00Z' }] + }); + mockOctokit.rest.issues.listComments.mockResolvedValue({ + data: [{ id: 100, body: draftBody, user: { login: 'test-bot' } }] + }); + + await run(); + + expect(mockOctokit.rest.issues.updateComment).toHaveBeenCalledWith( + expect.objectContaining({ + comment_id: 100, + body: expect.stringContaining('## ✅ Release Published') + }) + ); + expect(mockOctokit.rest.issues.updateComment).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.stringContaining('https://github.com/test-owner/test-repo/releases/tag/v1.2.3') + }) + ); + expect(mockCore.info).toHaveBeenCalledWith('✅ Updated PR comment on PR #42'); + }); + + it('should find and update a legacy comment without marker', async () => { + // Legacy comments don't have the HTML marker + const legacyBody = '## 📦 Draft Release Created\n\nA draft release **v1.2.3** has been created.'; + + mockOctokit.rest.repos.listPullRequestsAssociatedWithCommit.mockResolvedValue({ + data: [{ number: 55, merged_at: '2024-01-01T00:00:00Z' }] + }); + mockOctokit.rest.issues.listComments.mockResolvedValue({ + data: [{ id: 200, body: legacyBody, user: { login: 'test-bot' } }] + }); + + await run(); + + expect(mockOctokit.rest.issues.updateComment).toHaveBeenCalledWith( + expect.objectContaining({ + comment_id: 200, + body: expect.stringContaining('## ✅ Release Published') + }) + ); + }); + + it('should skip unmerged PRs', async () => { + mockOctokit.rest.repos.listPullRequestsAssociatedWithCommit.mockResolvedValue({ + data: [{ number: 10, merged_at: null }] + }); + + await run(); + + expect(mockOctokit.rest.issues.listComments).not.toHaveBeenCalled(); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining('No PR comment found')); + }); + + it('should skip comments from other users', async () => { + const draftBody = + '\n## 📦 Draft Release Created\n\nA draft release **v1.2.3** has been created.'; + + mockOctokit.rest.repos.listPullRequestsAssociatedWithCommit.mockResolvedValue({ + data: [{ number: 42, merged_at: '2024-01-01T00:00:00Z' }] + }); + // Comment is from a different user + mockOctokit.rest.issues.listComments.mockResolvedValue({ + data: [{ id: 100, body: draftBody, user: { login: 'other-user' } }] + }); + + await run(); + + expect(mockOctokit.rest.issues.updateComment).not.toHaveBeenCalled(); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining('No PR comment found')); + }); + + it('should still match comments when auth fails (no author filter)', async () => { + mockOctokit.rest.users.getAuthenticated.mockRejectedValue(new Error('Auth failed')); + + const draftBody = + '\n## 📦 Draft Release Created\n\nA draft release **v1.2.3** has been created.'; + + mockOctokit.rest.repos.listPullRequestsAssociatedWithCommit.mockResolvedValue({ + data: [{ number: 42, merged_at: '2024-01-01T00:00:00Z' }] + }); + mockOctokit.rest.issues.listComments.mockResolvedValue({ + data: [{ id: 100, body: draftBody, user: { login: 'unknown-bot' } }] + }); + + await run(); + + // Should still update since marker match doesn't require author + expect(mockOctokit.rest.issues.updateComment).toHaveBeenCalledWith(expect.objectContaining({ comment_id: 100 })); + }); + + it('should skip legacy fallback when auth fails (no author confirmation)', async () => { + mockOctokit.rest.users.getAuthenticated.mockRejectedValue(new Error('Auth failed')); + + // Legacy comment has no marker — only heading + version + const legacyBody = '## 📦 Draft Release Created\n\nA draft release **v1.2.3** has been created.'; + + mockOctokit.rest.repos.listPullRequestsAssociatedWithCommit.mockResolvedValue({ + data: [{ number: 42, merged_at: '2024-01-01T00:00:00Z' }] + }); + mockOctokit.rest.issues.listComments.mockResolvedValue({ + data: [{ id: 100, body: legacyBody, user: { login: 'unknown-bot' } }] + }); + + await run(); + + // Should NOT update — legacy fallback requires confirmed author + expect(mockOctokit.rest.issues.updateComment).not.toHaveBeenCalled(); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining('No PR comment found')); + }); + + it('should skip if comment already shows published state (idempotency)', async () => { + const publishedBody = '\n## ✅ Release Published\n\nAlready done.'; + + mockOctokit.rest.repos.listPullRequestsAssociatedWithCommit.mockResolvedValue({ + data: [{ number: 42, merged_at: '2024-01-01T00:00:00Z' }] + }); + mockOctokit.rest.issues.listComments.mockResolvedValue({ + data: [{ id: 100, body: publishedBody, user: { login: 'test-bot' } }] + }); + + await run(); + + expect(mockOctokit.rest.issues.updateComment).not.toHaveBeenCalled(); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining('already shows published state')); + }); + + it('should log info when no matching comment is found', async () => { + mockOctokit.rest.repos.listPullRequestsAssociatedWithCommit.mockResolvedValue({ + data: [{ number: 42, merged_at: '2024-01-01T00:00:00Z' }] + }); + mockOctokit.rest.issues.listComments.mockResolvedValue({ + data: [{ id: 100, body: 'Unrelated comment', user: { login: 'test-bot' } }] + }); + + await run(); + + expect(mockOctokit.rest.issues.updateComment).not.toHaveBeenCalled(); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining('No PR comment found')); + }); + + it('should use predictable tag URL instead of draft URL', async () => { + const draftBody = '\n## 📦 Draft Release Created'; + + mockOctokit.rest.repos.listPullRequestsAssociatedWithCommit.mockResolvedValue({ + data: [{ number: 42, merged_at: '2024-01-01T00:00:00Z' }] + }); + mockOctokit.rest.issues.listComments.mockResolvedValue({ + data: [{ id: 100, body: draftBody, user: { login: 'test-bot' } }] + }); + + await run(); + + // The URL in the updated comment should be the predictable tag URL, not the untagged draft URL + const updateCall = mockOctokit.rest.issues.updateComment.mock.calls[0][0]; + expect(updateCall.body).toContain('https://github.com/test-owner/test-repo/releases/tag/v1.2.3'); + expect(updateCall.body).not.toContain('untagged-'); + }); + + it('should warn on API error when resolving tag', async () => { + mockOctokit.rest.git.getRef.mockRejectedValue(new Error('API rate limit exceeded')); + + await run(); + + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining('Could not update PR comment')); + }); + + it('should continue to next PR when updateComment fails on one', async () => { + // Two merged PRs associated with the tag commit + mockOctokit.rest.repos.listPullRequestsAssociatedWithCommit.mockResolvedValue({ + data: [ + { number: 50, merged_at: '2024-01-01T00:00:00Z' }, + { number: 51, merged_at: '2024-01-01T00:00:00Z' } + ] + }); + + const marker = ``; + // PR #50 has the marker but updateComment will fail; PR #51 also has the marker + mockOctokit.rest.issues.listComments + .mockResolvedValueOnce({ + data: [{ id: 600, body: `${marker}\n## 📦 Draft Release Created`, user: { login: 'test-bot' } }] + }) + .mockResolvedValueOnce({ + data: [{ id: 601, body: `${marker}\n## 📦 Draft Release Created`, user: { login: 'test-bot' } }] + }); + + // First PR fails immediately (non-retryable, no retry attempts), second PR succeeds + mockOctokit.rest.issues.updateComment + .mockRejectedValueOnce(new Error('Resource not accessible by integration')) + .mockResolvedValueOnce({}); + + await run(); + + // Should warn about PR #50 but still update PR #51 + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining('Could not update comment on PR #50')); + expect(mockOctokit.rest.issues.updateComment).toHaveBeenCalledTimes(2); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining('Updated PR comment on PR #51')); + }); + }); }); diff --git a/action.yml b/action.yml index d233306..0d0333d 100644 --- a/action.yml +++ b/action.yml @@ -37,7 +37,7 @@ inputs: default: 'false' required: false draft_release_pr_reminder: - description: 'Post a reminder comment on the merged PR when creating a draft release' + description: 'Post a reminder comment on the merged PR when creating a draft release. When the release is later published (requires `on: release: types: [published]` trigger), the comment is updated to show the published state with checked-off steps.' default: 'false' required: false comment_on_linked_issues: diff --git a/badges/coverage.svg b/badges/coverage.svg index 63077f6..625ab0e 100644 --- a/badges/coverage.svg +++ b/badges/coverage.svg @@ -1 +1 @@ -Coverage: 97.42%Coverage97.42% \ No newline at end of file +Coverage: 97.61%Coverage97.61% \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index e84c927..28ce943 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "publish-github-action", - "version": "3.1.2", + "version": "3.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "publish-github-action", - "version": "3.1.2", + "version": "3.2.0", "license": "MIT", "dependencies": { "@actions/core": "^3.0.0", diff --git a/package.json b/package.json index 3444c1c..104d31a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "publish-github-action", - "version": "3.1.2", + "version": "3.2.0", "type": "module", "private": true, "description": "Publish your GitHub Action", diff --git a/src/index.js b/src/index.js index db83399..e3b88f4 100644 --- a/src/index.js +++ b/src/index.js @@ -291,6 +291,9 @@ export function parsePullRequestNumbers(text) { const RELEASE_COMMENT_MARKER = ''; +/** Version-specific marker prefix for draft release PR reminder comments */ +const DRAFT_COMMENT_MARKER_PREFIX = '\n` + + `## 📦 Draft Release Created\n\n` + + `A draft release **${version}** has been created for this PR.\n\n` + + `🔗 **[View Draft Release](${releaseUrl})**\n\n` + + `### Next Steps\n` + + `- [ ] Review the release notes\n` + + `- [ ] Publish the release to make it permanent\n\n` + + `> _This is an automated reminder from the publish-github-action workflow._` + ); +} + +/** + * Build the published release PR comment body (replaces draft reminder). + * @param {string} version - Version tag (e.g. v1.2.3) + * @param {string} releaseUrl - URL to the published release + * @returns {string} Comment body + */ +function buildPublishedReminderBody(version, releaseUrl) { + return ( + `${DRAFT_COMMENT_MARKER_PREFIX}${version} -->\n` + + `## ✅ Release Published\n\n` + + `Release **${version}** has been published!\n\n` + + `🔗 **[View Published Release](${releaseUrl})**\n\n` + + `### Next Steps\n` + + `- [x] Review the release notes\n` + + `- [x] Publish the release to make it permanent\n\n` + + `> _This comment was updated by the publish-github-action workflow._` + ); +} + +/** + * Handle release.published event by updating the draft PR reminder comment. + * @param {object} octokit - GitHub API client + * @param {object} context - GitHub Actions context + */ +async function handleReleasePublished(octokit, context) { + const release = context.payload?.release; + + if (!release || !release.tag_name || !release.html_url) { + core.warning('Release event missing expected payload data; cannot update PR comments.'); + return; + } + + const version = release.tag_name; + // Construct predictable tag-based URL (draft html_url contains untagged-... that 404s) + const repoUrl = release.html_url.replace(/\/releases\/tag\/.*$/, ''); + const releaseUrl = `${repoUrl}/releases/tag/${version}`; + const marker = `${DRAFT_COMMENT_MARKER_PREFIX}${version} -->`; + + core.info(`Release published: ${version}`); + core.info(`Searching for PR comment with marker: ${marker}`); + + // Get authenticated user for author filtering + let authenticatedLogin = null; + try { + const { data: authUser } = await retryWithBackoff(() => octokit.rest.users.getAuthenticated(), { + retries: 2, + baseDelay: 1000, + description: 'Get authenticated user' + }); + authenticatedLogin = authUser.login; + core.debug(`Authenticated as: ${authenticatedLogin}`); + } catch (error) { + core.debug(`Could not determine authenticated user: ${error.message}`); + } + + try { + // Resolve the tag to its commit SHA, then find associated PRs deterministically + const { data: tagRef } = await retryWithBackoff( + () => + octokit.rest.git.getRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `tags/${version}` + }), + { retries: 2, baseDelay: 1000, description: `Get tag ref for ${version}` } + ); + + const commitSha = tagRef.object.sha; + core.debug(`Tag ${version} points to commit ${commitSha}`); + + const { data: associatedPrs } = await retryWithBackoff( + () => + octokit.rest.repos.listPullRequestsAssociatedWithCommit({ + owner: context.repo.owner, + repo: context.repo.repo, + commit_sha: commitSha, + per_page: 30 + }), + { retries: 2, baseDelay: 1000, description: 'List PRs associated with tag commit' } + ); + + // Filter to merged PRs only + const mergedPrs = associatedPrs.filter(pr => pr.merged_at); + core.debug(`Found ${mergedPrs.length} merged PR(s) associated with tag commit`); + + let updatedCount = 0; + for (const pr of mergedPrs) { + try { + const { data: comments } = await retryWithBackoff( + () => + octokit.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + per_page: 100, + direction: 'desc' + }), + { retries: 2, baseDelay: 1000, description: `List comments on PR #${pr.number}` } + ); + + // Match by version-specific marker, or fallback to legacy comment shape + const markerComment = comments.find(c => { + if (!c.body) return false; + // Skip if we know the author and it's not us + if (authenticatedLogin && c.user?.login !== authenticatedLogin) return false; + // Primary: version-specific marker + if (c.body.includes(marker)) return true; + // Fallback: legacy comments without marker (created before this feature) + // Only use fallback when author is confirmed to avoid updating someone else's comment + if (authenticatedLogin && c.body.includes('## 📦 Draft Release Created') && c.body.includes(`**${version}**`)) + return true; + return false; + }); + + if (markerComment) { + // Check if already updated + if (markerComment.body.includes('## ✅ Release Published')) { + core.info(`PR #${pr.number} comment already shows published state, skipping`); + continue; + } + + const updatedBody = buildPublishedReminderBody(version, releaseUrl); + + await retryWithBackoff( + () => + octokit.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: markerComment.id, + body: updatedBody + }), + { retries: 2, baseDelay: 1000, description: `Update comment on PR #${pr.number}` } + ); + + core.info(`✅ Updated PR comment on PR #${pr.number}`); + updatedCount++; + } + } catch (error) { + core.warning(`Could not update comment on PR #${pr.number}: ${error.message}`); + } + } + + if (updatedCount === 0) { + core.info(`No PR comment found with marker for ${version}`); + } + } catch (error) { + core.warning(`Could not update PR comment: ${error.message}`); + } +} + /** * Main action logic */ @@ -321,6 +493,17 @@ export async function run() { const opts = githubApiUrl ? { baseUrl: githubApiUrl } : {}; const octokit = github.getOctokit(githubToken, opts); + // Handle release.published event — update the draft PR reminder comment + if (context.eventName === 'release' && context.payload?.action === 'published') { + if (draftReleasePrReminder === 'true') { + await handleReleasePublished(octokit, context); + } else { + core.info('Skipping PR comment update (draft_release_pr_reminder is disabled)'); + } + core.info('✅ Release published event handled!'); + return; + } + const json = JSON.parse(readFileSync('package.json', 'utf8')); const version = `v${json.version}`; const minorVersion = `v${semver.major(json.version)}.${semver.minor(json.version)}`; @@ -514,7 +697,7 @@ export async function run() { }); // Post reminder comment on merged PR if draft release was created - if (createReleaseAsDraft === 'true' && draftReleasePrReminder !== 'false') { + if (createReleaseAsDraft === 'true' && draftReleasePrReminder === 'true') { try { // Find the PR associated with the current commit (the merge commit) const commitShaForPr = context.sha; @@ -536,14 +719,7 @@ export async function run() { const mergedPr = mergedPrs[0]; const releaseUrl = release.data.html_url; - const commentBody = - `## 📦 Draft Release Created\n\n` + - `A draft release **${version}** has been created for this PR.\n\n` + - `🔗 **[View Draft Release](${releaseUrl})**\n\n` + - `### Next Steps\n` + - `- [ ] Review the release notes\n` + - `- [ ] Publish the release to make it permanent\n\n` + - `> _This is an automated reminder from the publish-github-action workflow._`; + const commentBody = buildDraftReminderBody(version, releaseUrl); await octokit.rest.issues.createComment({ owner: context.repo.owner,