Compare commits

...
4 Commits
9 changed files with 295 additions and 31 deletions
+2
View File
@@ -7,3 +7,5 @@ release.json
repository.json repository.json
user.json user.json
tests/sample-test-release-v2.json tests/sample-test-release-v2.json
tests/sample-test-release-v3.json
tests/sample-test-release-null-body.json
+9
View File
@@ -1,5 +1,14 @@
# Changelog # Changelog
## [1.20.0](https://github.com/SethCohen/github-releases-to-discord/compare/v1.19.0...v1.20.0) (2026-04-03)
### Features
* add manual dispatch support for GitHub Actions to test Discord webhook integration with optional release inputs. resolves [#52](https://github.com/SethCohen/github-releases-to-discord/issues/52) ([e15eb81](https://github.com/SethCohen/github-releases-to-discord/commit/e15eb81a91d940818d60143a1f57edb92ae0828c))
* enhance reduceHeadings function to handle indented and closed markdown headings and add tests for new functionality. resolves [#51](https://github.com/SethCohen/github-releases-to-discord/issues/51) ([80aca15](https://github.com/SethCohen/github-releases-to-discord/commit/80aca15d7235082187d6eee6054a69ceed9c45db))
* **tests:** add test for handling release payload with null body and update formatDescription to handle undefined input. resolves [#53](https://github.com/SethCohen/github-releases-to-discord/issues/53) ([60ef92f](https://github.com/SethCohen/github-releases-to-discord/commit/60ef92f1479987b509687244b04859040cb0f43d))
## [1.19.0](https://github.com/SethCohen/github-releases-to-discord/compare/v1.18.0...v1.19.0) (2025-06-17) ## [1.19.0](https://github.com/SethCohen/github-releases-to-discord/compare/v1.18.0...v1.19.0) (2025-06-17)
+51 -2
View File
@@ -68,7 +68,51 @@ jobs:
reduce_headings: true reduce_headings: true
``` ```
### 3. Add Your Webhook URL as a Secret ### 3. Optional: Test with `workflow_dispatch`
If you want to test the action manually without relying on cloning and using `act`, you can add `workflow_dispatch` and pass release fields into the action:
Example inputs:
```yaml
release_name: v1.2.3
release_body: |
## Changes
- Added manual testing support
- Verified Discord webhook output
release_html_url: https://github.com/owner/repo/releases/tag/v1.2.3
```
```yaml
on:
release:
types: [published]
workflow_dispatch:
inputs:
release_name:
description: Release title to post
required: true
release_body:
description: Release notes body to post
required: true
release_html_url:
description: Release URL to link in Discord
required: false
jobs:
github-releases-to-discord:
runs-on: ubuntu-latest
steps:
- name: GitHub Releases to Discord
uses: SethCohen/github-releases-to-discord@v1
with:
webhook_url: ${{ secrets.WEBHOOK_URL }}
release_name: ${{ inputs.release_name }}
release_body: ${{ inputs.release_body }}
release_html_url: ${{ inputs.release_html_url }}
```
### 4. Add Your Webhook URL as a Secret
- In your GitHub repo, go to **Settings → Secrets and variables → Actions**. - In your GitHub repo, go to **Settings → Secrets and variables → Actions**.
- Add a new secret named `WEBHOOK_URL` and paste your Discord webhook URL. - Add a new secret named `WEBHOOK_URL` and paste your Discord webhook URL.
@@ -78,13 +122,16 @@ jobs:
## Configuration Options ## Configuration Options
| Input Name | Required | Default | Description | | Input Name | Required | Default | Description |
|-------------------------------|----------|-------------|--------------------------------------------------------------------| |--------------------------------|----------|-------------|--------------------------------------------------------------------|
| `webhook_url` | ✔ | | Discord webhook URL (use a GitHub secret). | | `webhook_url` | ✔ | | Discord webhook URL (use a GitHub secret). |
| `color` | ❌ | 2105893 | Embed color (decimal). | | `color` | ❌ | 2105893 | Embed color (decimal). |
| `username` | ❌ | | Webhook username. | | `username` | ❌ | | Webhook username. |
| `avatar_url` | ❌ | | Webhook avatar image URL. | | `avatar_url` | ❌ | | Webhook avatar image URL. |
| `custom_html_url` | ❌ | | Custom URL for the embed title (overrides GitHub release URL). | | `custom_html_url` | ❌ | | Custom URL for the embed title (overrides GitHub release URL). |
| `content` | ❌ | | Additional message content (e.g., `@everyone`). | | `content` | ❌ | | Additional message content (e.g., `@everyone`). |
| `release_name` | ❌ | | Manual release title for `workflow_dispatch` testing. |
| `release_body` | ❌ | | Manual release body for `workflow_dispatch` testing. |
| `release_html_url` | ❌ | | Manual release URL for `workflow_dispatch` testing. |
| `footer_title` | ❌ | | Footer title. | | `footer_title` | ❌ | | Footer title. |
| `footer_icon_url` | ❌ | | Footer icon image URL. | | `footer_icon_url` | ❌ | | Footer icon image URL. |
| `footer_timestamp` | ❌ | false | Show timestamp in footer (`true`/`false`). | | `footer_timestamp` | ❌ | false | Show timestamp in footer (`true`/`false`). |
@@ -111,6 +158,8 @@ jobs:
- Use Markdown in your release notes for best results. - Use Markdown in your release notes for best results.
- **Private Repos:** - **Private Repos:**
- The action works for both public and private repositories. - The action works for both public and private repositories.
- **Manual Testing:**
- Use `release_name`, `release_body`, and optionally `release_html_url` when triggering the workflow with `workflow_dispatch`.
--- ---
+9
View File
@@ -21,6 +21,15 @@ inputs:
content: content:
description: String content for webhook. description: String content for webhook.
required: false required: false
release_name:
description: Optional release title used when manually triggering the workflow with workflow_dispatch.
required: false
release_body:
description: Optional release body used when manually triggering the workflow with workflow_dispatch.
required: false
release_html_url:
description: Optional release URL used when manually triggering the workflow with workflow_dispatch.
required: false
footer_title: footer_title:
description: Title for the footer. description: Title for the footer.
required: false required: false
+60 -10
View File
@@ -1,6 +1,7 @@
import core from '@actions/core'; import core from '@actions/core';
import github from '@actions/github'; import github from '@actions/github';
import fetch from 'node-fetch'; import fetch from 'node-fetch';
import { fileURLToPath } from 'node:url';
/** /**
* Removes carriage return characters. * Removes carriage return characters.
@@ -61,8 +62,21 @@ const removeGithubReferenceLinks = (text) => text
* @returns {string} The text with reduced heading sizes. * @returns {string} The text with reduced heading sizes.
*/ */
const reduceHeadings = (text) => text const reduceHeadings = (text) => text
.replace(/^###\s+(.+)$/gm, '**__$1__**') // Convert H3 to bold + underline .split('\n')
.replace(/^##\s+(.+)$/gm, '**$1**'); // Convert H2 to bold .map((line) => {
const h3 = line.match(/^\s*###\s+(.+?)\s*#*\s*$/);
if (h3) {
return `**__${h3[1].trim()}__**`;
}
const h2 = line.match(/^\s*##\s+(.+?)\s*#*\s*$/);
if (h2) {
return `**${h2[1].trim()}**`;
}
return line;
})
.join('\n');
/** /**
* Converts PR, issue, and changelog links to markdown format, ignoring existing markdown links. * Converts PR, issue, and changelog links to markdown format, ignoring existing markdown links.
@@ -96,7 +110,7 @@ const convertLinksToMarkdown = (text) => {
* @returns {string} The formatted description. * @returns {string} The formatted description.
*/ */
const formatDescription = (description) => { const formatDescription = (description) => {
let edit = removeCarriageReturn(description); let edit = removeCarriageReturn(description ?? '');
edit = removeHTMLComments(edit); edit = removeHTMLComments(edit);
edit = reduceNewlines(edit); edit = reduceNewlines(edit);
@@ -135,11 +149,32 @@ const getMaxDescription = () => {
* @returns {object} The context with release details. * @returns {object} The context with release details.
*/ */
const getContext = () => { const getContext = () => {
const { release } = github.context.payload; return resolveReleaseContext(github.context.payload.release, {
body: core.getInput('release_body'),
name: core.getInput('release_name'),
html_url: core.getInput('release_html_url')
});
};
/**
* Resolves release data from either a GitHub release event or manual inputs.
* @param {object|null|undefined} release The release payload from the event.
* @param {object} manualInputs Manual fallback inputs.
* @returns {object} The resolved release context.
*/
const resolveReleaseContext = (release, manualInputs) => {
if (release) {
return { return {
body: release.body, body: release.body || '',
name: release.name, name: release.name || '',
html_url: release.html_url html_url: release.html_url || ''
};
}
return {
body: manualInputs.body || '',
name: manualInputs.name || '',
html_url: manualInputs.html_url || ''
}; };
}; };
@@ -179,12 +214,15 @@ const limitString = (str, maxLength, url, clipAtLine = false) => {
const buildEmbedMessage = (name, html_url, description) => { const buildEmbedMessage = (name, html_url, description) => {
const embedMsg = { const embedMsg = {
title: limitString(name, 256), title: limitString(name, 256),
url: html_url,
color: core.getInput('color'), color: core.getInput('color'),
description: limitString(description, Math.min(getMaxDescription(), 6000 - name.length)), description: limitString(description, Math.min(getMaxDescription(), 6000 - name.length)),
footer: {} footer: {}
}; };
if (html_url) {
embedMsg.url = html_url;
}
if (core.getInput('custom_html_url')) { if (core.getInput('custom_html_url')) {
embedMsg.url = core.getInput('custom_html_url'); embedMsg.url = core.getInput('custom_html_url');
} }
@@ -264,6 +302,11 @@ const run = async () => {
if (!webhookUrl) return core.setFailed('webhook_url not set.'); if (!webhookUrl) return core.setFailed('webhook_url not set.');
const { body, html_url, name } = getContext(); const { body, html_url, name } = getContext();
if (!name) {
return core.setFailed('No GitHub release payload found. When using workflow_dispatch, pass release_name to the action.');
}
const description = formatDescription(body); const description = formatDescription(body);
const embedMsg = buildEmbedMessage(name, html_url, description); const embedMsg = buildEmbedMessage(name, html_url, description);
@@ -273,9 +316,13 @@ const run = async () => {
await sendWebhook(webhookUrl, requestBody); await sendWebhook(webhookUrl, requestBody);
}; };
run() const isMainModule = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
if (isMainModule) {
run()
.then(() => core.info('Action completed successfully')) .then(() => core.info('Action completed successfully'))
.catch(err => core.setFailed(err.message)); .catch(err => core.setFailed(err.message));
}
// Export utility functions for testing // Export utility functions for testing
export { export {
@@ -287,5 +334,8 @@ export {
reduceHeadings, reduceHeadings,
convertLinksToMarkdown, convertLinksToMarkdown,
limitString, limitString,
formatDescription formatDescription,
resolveReleaseContext,
getContext,
run
}; };
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "github-releases-to-discord", "name": "github-releases-to-discord",
"version": "1.19.0", "version": "1.20.0",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "github-releases-to-discord", "name": "github-releases-to-discord",
"version": "1.19.0", "version": "1.20.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@actions/core": "^1.10.1", "@actions/core": "^1.10.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "github-releases-to-discord", "name": "github-releases-to-discord",
"version": "1.19.0", "version": "1.20.0",
"description": "A GitHub Action that automatically sends a stylized Discord webhook of a GitHub Release description to a specified Discord channel.", "description": "A GitHub Action that automatically sends a stylized Discord webhook of a GitHub Release description to a specified Discord channel.",
"type": "module", "type": "module",
"main": "index.js", "main": "index.js",
+40 -1
View File
@@ -7,7 +7,8 @@ import {
reduceHeadings, reduceHeadings,
convertLinksToMarkdown, convertLinksToMarkdown,
limitString, limitString,
formatDescription formatDescription,
resolveReleaseContext
} from '../index.js'; } from '../index.js';
import { jest } from '@jest/globals'; import { jest } from '@jest/globals';
@@ -70,6 +71,16 @@ describe('index.js utility functions', () => {
expect(reduceHeadings('## Heading2')).toBe('**Heading2**'); expect(reduceHeadings('## Heading2')).toBe('**Heading2**');
}); });
test('reduceHeadings reduces repeated section headings', () => {
const input = '### Bug Fixes\n- fix thing\n\n### Features\n- add thing';
expect(reduceHeadings(input)).toBe('**__Bug Fixes__**\n- fix thing\n\n**__Features__**\n- add thing');
});
test('reduceHeadings handles indented and closed markdown headings', () => {
const input = ' ## Changes ##\n ### Features ###';
expect(reduceHeadings(input)).toBe('**Changes**\n**__Features__**');
});
test('convertLinksToMarkdown converts PR/issue/changelog links', () => { test('convertLinksToMarkdown converts PR/issue/changelog links', () => {
const pr = 'https://github.com/owner/repo/pull/1'; const pr = 'https://github.com/owner/repo/pull/1';
const issue = 'https://github.com/owner/repo/issues/2'; const issue = 'https://github.com/owner/repo/issues/2';
@@ -84,6 +95,34 @@ describe('index.js utility functions', () => {
expect(convertLinksToMarkdown(input)).toBe('[already](https://github.com/owner/repo/pull/1) and [PR #2](https://github.com/owner/repo/pull/2)'); expect(convertLinksToMarkdown(input)).toBe('[already](https://github.com/owner/repo/pull/1) and [PR #2](https://github.com/owner/repo/pull/2)');
}); });
describe('resolveReleaseContext', () => {
test('uses release payload when present', () => {
const release = {
name: 'v1.2.3',
body: 'Release notes',
html_url: 'https://example.com/releases/v1.2.3'
};
expect(resolveReleaseContext(release, {
name: 'ignored',
body: 'ignored',
html_url: 'https://example.com/ignored'
})).toEqual(release);
});
test('falls back to manual inputs when release payload is missing', () => {
expect(resolveReleaseContext(null, {
name: 'manual release',
body: 'manual body',
html_url: 'https://example.com/manual'
})).toEqual({
name: 'manual release',
body: 'manual body',
html_url: 'https://example.com/manual'
});
});
});
describe('limitString', () => { describe('limitString', () => {
test('returns string unchanged if under maxLength', () => { test('returns string unchanged if under maxLength', () => {
expect(limitString('short', 10)).toBe('short'); expect(limitString('short', 10)).toBe('short');
+106
View File
@@ -0,0 +1,106 @@
import { jest } from '@jest/globals';
const inputValues = {
webhook_url: 'https://discord.com/api/webhooks/test/webhook',
color: '2105893',
username: 'Release Changelog',
avatar_url: 'https://example.com/avatar.png',
content: '||@everyone||',
release_name: 'v1.2.3',
release_body: '## Changes\n- Added manual dispatch support\n- Verified webhook payload',
release_html_url: 'https://github.com/owner/repo/releases/tag/v1.2.3'
};
const fetchMock = jest.fn(async () => ({
ok: true,
status: 200,
json: async () => ({ id: 'discord-message-id' }),
headers: {
get: () => null
}
}));
const coreMock = {
getInput: jest.fn((name) => inputValues[name] || ''),
getBooleanInput: jest.fn((name) => false),
setFailed: jest.fn(),
info: jest.fn(),
warning: jest.fn()
};
const githubMock = {
context: {
payload: {}
}
};
beforeEach(() => {
fetchMock.mockClear();
coreMock.setFailed.mockClear();
coreMock.info.mockClear();
coreMock.warning.mockClear();
githubMock.context.payload = {};
});
jest.unstable_mockModule('@actions/core', () => ({
default: coreMock
}));
jest.unstable_mockModule('@actions/github', () => ({
default: githubMock
}));
jest.unstable_mockModule('node-fetch', () => ({
default: fetchMock
}));
describe('manual dispatch integration', () => {
test('run() posts a Discord embed using manual workflow inputs', async () => {
const { run } = await import('../index.js');
await run();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toBe('https://discord.com/api/webhooks/test/webhook?wait=true');
const request = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(request).toEqual({
embeds: [{
title: 'v1.2.3',
url: 'https://github.com/owner/repo/releases/tag/v1.2.3',
color: '2105893',
description: '## Changes\n- Added manual dispatch support\n- Verified webhook payload',
footer: {}
}],
username: 'Release Changelog',
avatar_url: 'https://example.com/avatar.png',
content: '||@everyone||'
});
expect(coreMock.setFailed).not.toHaveBeenCalled();
expect(coreMock.info).toHaveBeenCalledWith('{"id":"discord-message-id"}');
});
test('run() tolerates a release payload with a null body', async () => {
githubMock.context.payload.release = {
name: 'v1.2.4',
body: null,
html_url: 'https://github.com/owner/repo/releases/tag/v1.2.4'
};
const { run } = await import('../index.js');
await run();
expect(fetchMock).toHaveBeenCalledTimes(1);
const request = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(request.embeds[0]).toMatchObject({
title: 'v1.2.4',
url: 'https://github.com/owner/repo/releases/tag/v1.2.4',
description: ''
});
expect(coreMock.setFailed).not.toHaveBeenCalled();
});
});