From e15eb81a91d940818d60143a1f57edb92ae0828c Mon Sep 17 00:00:00 2001 From: SethCohen Date: Fri, 3 Apr 2026 12:42:38 -0400 Subject: [PATCH] feat: add manual dispatch support for GitHub Actions to test Discord webhook integration with optional release inputs. resolves #52 --- README.md | 79 ++++++++++++++++++++++++++++------- action.yml | 9 ++++ index.js | 55 ++++++++++++++++++++---- tests/index.test.js | 31 +++++++++++++- tests/manual-dispatch.test.js | 75 +++++++++++++++++++++++++++++++++ 5 files changed, 224 insertions(+), 25 deletions(-) create mode 100644 tests/manual-dispatch.test.js diff --git a/README.md b/README.md index 18cfff6..4ed6476 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,51 @@ jobs: 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**. - Add a new secret named `WEBHOOK_URL` and paste your Discord webhook URL. @@ -77,20 +121,23 @@ jobs: ## Configuration Options -| Input Name | Required | Default | Description | -|-------------------------------|----------|-------------|--------------------------------------------------------------------| -| `webhook_url` | ✔ | | Discord webhook URL (use a GitHub secret). | -| `color` | ❌ | 2105893 | Embed color (decimal). | -| `username` | ❌ | | Webhook username. | -| `avatar_url` | ❌ | | Webhook avatar image URL. | -| `custom_html_url` | ❌ | | Custom URL for the embed title (overrides GitHub release URL). | -| `content` | ❌ | | Additional message content (e.g., `@everyone`). | -| `footer_title` | ❌ | | Footer title. | -| `footer_icon_url` | ❌ | | Footer icon image URL. | -| `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. | +| Input Name | Required | Default | Description | +|--------------------------------|----------|-------------|--------------------------------------------------------------------| +| `webhook_url` | ✔ | | Discord webhook URL (use a GitHub secret). | +| `color` | ❌ | 2105893 | Embed color (decimal). | +| `username` | ❌ | | Webhook username. | +| `avatar_url` | ❌ | | Webhook avatar image URL. | +| `custom_html_url` | ❌ | | Custom URL for the embed title (overrides GitHub release URL). | +| `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_icon_url` | ❌ | | Footer icon image URL. | +| `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. - **Private Repos:** - 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`. --- diff --git a/action.yml b/action.yml index a7b2cb9..e514614 100644 --- a/action.yml +++ b/action.yml @@ -21,6 +21,15 @@ inputs: content: description: String content for webhook. 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: description: Title for the footer. required: false diff --git a/index.js b/index.js index 4cc8c33..49c5d10 100644 --- a/index.js +++ b/index.js @@ -1,6 +1,7 @@ import core from '@actions/core'; import github from '@actions/github'; import fetch from 'node-fetch'; +import { fileURLToPath } from 'node:url'; /** * Removes carriage return characters. @@ -148,11 +149,32 @@ const getMaxDescription = () => { * @returns {object} The context with release details. */ 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 { - body: release.body, - name: release.name, - html_url: release.html_url + body: manualInputs.body || '', + name: manualInputs.name || '', + html_url: manualInputs.html_url || '' }; }; @@ -192,12 +214,15 @@ const limitString = (str, maxLength, url, clipAtLine = false) => { const buildEmbedMessage = (name, html_url, description) => { const embedMsg = { title: limitString(name, 256), - url: html_url, color: core.getInput('color'), description: limitString(description, Math.min(getMaxDescription(), 6000 - name.length)), footer: {} }; + if (html_url) { + embedMsg.url = html_url; + } + if (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.'); 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 embedMsg = buildEmbedMessage(name, html_url, description); @@ -286,9 +316,13 @@ const run = async () => { await sendWebhook(webhookUrl, requestBody); }; -run() - .then(() => core.info('Action completed successfully')) - .catch(err => core.setFailed(err.message)); +const isMainModule = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; + +if (isMainModule) { + run() + .then(() => core.info('Action completed successfully')) + .catch(err => core.setFailed(err.message)); +} // Export utility functions for testing export { @@ -300,5 +334,8 @@ export { reduceHeadings, convertLinksToMarkdown, limitString, - formatDescription + formatDescription, + resolveReleaseContext, + getContext, + run }; diff --git a/tests/index.test.js b/tests/index.test.js index 36f9129..f6e83f1 100644 --- a/tests/index.test.js +++ b/tests/index.test.js @@ -7,7 +7,8 @@ import { reduceHeadings, convertLinksToMarkdown, limitString, - formatDescription + formatDescription, + resolveReleaseContext } from '../index.js'; 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)'); }); + 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', () => { test('returns string unchanged if under maxLength', () => { expect(limitString('short', 10)).toBe('short'); diff --git a/tests/manual-dispatch.test.js b/tests/manual-dispatch.test.js new file mode 100644 index 0000000..4bff355 --- /dev/null +++ b/tests/manual-dispatch.test.js @@ -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"}'); + }); +});