Skip to content
Open
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
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -80,6 +100,8 @@ on:
push:
branches:
- main
release:
types: [published] # Required for updating PR comment after draft release is published

jobs:
publish:
Expand Down
290 changes: 287 additions & 3 deletions __tests__/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
};
Expand Down Expand Up @@ -55,6 +61,9 @@ const mockOctokit = {
},
users: {
getAuthenticated: jest.fn()
},
pulls: {
list: jest.fn()
}
},
paginate: jest.fn(),
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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)');
});
Comment thread
joshjohanning marked this conversation as resolved.

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 =
'<!-- publish-github-action-draft:v1.2.3 -->\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 =
'<!-- publish-github-action-draft:v1.2.3 -->\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 =
'<!-- publish-github-action-draft:v1.2.3 -->\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 = '<!-- publish-github-action-draft:v1.2.3 -->\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 = '<!-- publish-github-action-draft:v1.2.3 -->\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 = `<!-- publish-github-action-draft:v1.2.3 -->`;
// 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'));
});
});
});
2 changes: 1 addition & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion badges/coverage.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading