mirror of
https://github.com/SethCohen/github-releases-to-discord
synced 2026-09-21 20:13:56 +00:00
feat: add manual dispatch support for GitHub Actions to test Discord webhook integration with optional release inputs. resolves #52
This commit is contained in:
@@ -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.
|
||||||
@@ -77,20 +121,23 @@ 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`). |
|
||||||
| `footer_title` | ❌ | | Footer title. |
|
| `release_name` | ❌ | | Manual release title for `workflow_dispatch` testing. |
|
||||||
| `footer_icon_url` | ❌ | | Footer icon image URL. |
|
| `release_body` | ❌ | | Manual release body for `workflow_dispatch` testing. |
|
||||||
| `footer_timestamp` | ❌ | false | Show timestamp in footer (`true`/`false`). |
|
| `release_html_url` | ❌ | | Manual release URL for `workflow_dispatch` testing. |
|
||||||
| `max_description` | ❌ | 4096 | Max description length (Discord limit: 4096). |
|
| `footer_title` | ❌ | | Footer title. |
|
||||||
| `remove_github_reference_links`| ❌ | false | Remove PR, commit, and issue links from the description. |
|
| `footer_icon_url` | ❌ | | Footer icon image URL. |
|
||||||
| `reduce_headings` | ❌ | false | Reduce heading sizes for compact display. |
|
| `footer_timestamp` | ❌ | false | Show timestamp in footer (`true`/`false`). |
|
||||||
|
| `max_description` | ❌ | 4096 | Max description length (Discord limit: 4096). |
|
||||||
|
| `remove_github_reference_links`| ❌ | false | Remove PR, commit, and issue links from the description. |
|
||||||
|
| `reduce_headings` | ❌ | false | Reduce heading sizes for compact display. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -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`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -148,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 {
|
||||||
|
body: release.body || '',
|
||||||
|
name: release.name || '',
|
||||||
|
html_url: release.html_url || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
body: release.body,
|
body: manualInputs.body || '',
|
||||||
name: release.name,
|
name: manualInputs.name || '',
|
||||||
html_url: release.html_url
|
html_url: manualInputs.html_url || ''
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -192,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');
|
||||||
}
|
}
|
||||||
@@ -277,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 (!body || !name) {
|
||||||
|
return core.setFailed('No GitHub release payload found. When using workflow_dispatch, pass release_name and release_body inputs 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);
|
||||||
@@ -286,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];
|
||||||
.then(() => core.info('Action completed successfully'))
|
|
||||||
.catch(err => core.setFailed(err.message));
|
if (isMainModule) {
|
||||||
|
run()
|
||||||
|
.then(() => core.info('Action completed successfully'))
|
||||||
|
.catch(err => core.setFailed(err.message));
|
||||||
|
}
|
||||||
|
|
||||||
// Export utility functions for testing
|
// Export utility functions for testing
|
||||||
export {
|
export {
|
||||||
@@ -300,5 +334,8 @@ export {
|
|||||||
reduceHeadings,
|
reduceHeadings,
|
||||||
convertLinksToMarkdown,
|
convertLinksToMarkdown,
|
||||||
limitString,
|
limitString,
|
||||||
formatDescription
|
formatDescription,
|
||||||
|
resolveReleaseContext,
|
||||||
|
getContext,
|
||||||
|
run
|
||||||
};
|
};
|
||||||
|
|||||||
+30
-1
@@ -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';
|
||||||
|
|
||||||
@@ -94,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');
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
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: {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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"}');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user