mirror of
https://github.com/SethCohen/github-releases-to-discord
synced 2026-09-14 20:13:23 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60a5c60e64 | ||
|
|
925765f099 | ||
|
|
37afa88c8c | ||
|
|
735cca9de3 | ||
|
|
5ca1453a69 | ||
|
|
305fe9299d | ||
|
|
613ba269d7 | ||
|
|
940a1bd2d2 |
@@ -11,4 +11,14 @@ jobs:
|
||||
- name: Github Releases To Discord
|
||||
uses: ./ # Uses an action in the root directory
|
||||
with:
|
||||
webhook_url: ${{ secrets.WEBHOOK_URL }}
|
||||
webhook_url: ${{ inputs.WEBHOOK_URL }}
|
||||
color: ${{ inputs.COLOR }}
|
||||
username: ${{ inputs.USERNAME }}
|
||||
avatar_url: ${{ inputs.AVATAR_URL }}
|
||||
content: ${{ inputs.CONTENT }}
|
||||
footer_title: ${{ inputs.FOOTER_TITLE }}
|
||||
footer_icon_url: ${{ inputs.FOOTER_ICON_URL }}
|
||||
footer_timestamp: ${{ inputs.FOOTER_TIMESTAMP }}
|
||||
max_description: ${{ inputs.MAX_DESCRIPTION }}
|
||||
reduce_headings: ${{ inputs.REDUCE_HEADINGS }}
|
||||
|
||||
@@ -2,3 +2,5 @@
|
||||
*.iml
|
||||
out
|
||||
gen
|
||||
.env
|
||||
tests/sample-test-release.json
|
||||
|
||||
@@ -1,5 +1,30 @@
|
||||
# Changelog
|
||||
|
||||
## [1.15.2](https://github.com/SethCohen/github-releases-to-discord/compare/v1.15.1...v1.15.2) (2024-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* improve [@mention](https://github.com/mention) parsing for GitHub usernames ([#33](https://github.com/SethCohen/github-releases-to-discord/issues/33)) ([925765f](https://github.com/SethCohen/github-releases-to-discord/commit/925765f099dcdc3b12316eaa6dc3c17506734b51))
|
||||
|
||||
## [1.15.1](https://github.com/SethCohen/github-releases-to-discord/compare/v1.15.0...v1.15.1) (2024-10-04)
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* updated README.md ([613ba26](https://github.com/SethCohen/github-releases-to-discord/commit/613ba269d7fe40e97040da19de58b0ae00b47aaf))
|
||||
|
||||
|
||||
### Miscellaneous
|
||||
|
||||
* added test envs to .gitignore ([735cca9](https://github.com/SethCohen/github-releases-to-discord/commit/735cca9de37b345e69b0c74ff761610eab6f1fd1))
|
||||
* updated test action ([305fe92](https://github.com/SethCohen/github-releases-to-discord/commit/305fe9299dddb3a514f2b1692773570862c34d46))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* code cleanup ([5ca1453](https://github.com/SethCohen/github-releases-to-discord/commit/5ca1453a69f962930450a9f77d4e20cc37e4110f))
|
||||
|
||||
## [1.15.0](https://github.com/SethCohen/github-releases-to-discord/compare/v1.14.0...v1.15.0) (2023-12-14)
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# GitHub Releases To Discord Action
|
||||
A GitHub action that parses a GitHub release and posts it to a Discord channel as a stylized Discord webhook.
|
||||
|
||||
A GitHub Action that sends a stylized Discord webhook containing the description of a GitHub Release to a specified Discord channel. It formats the description to improve readability and includes various configuration options to customize the message.
|
||||
|
||||
## Features
|
||||
|
||||
- Formats release descriptions by:
|
||||
- Removing carriage returns and HTML comments.
|
||||
- Styling headers for better readability in Discord (optional).
|
||||
- Cleaning up redundant whitespace and newlines.
|
||||
- Converting common GitHub links (e.g., PRs, Issues) into clickable URLs.
|
||||
- Limits description length to avoid exceeding Discord's character limits.
|
||||
- Provides customization for the Discord message, including:
|
||||
- Webhook URL, message color, footer text/icon, and timestamp.
|
||||
- Username and avatar for the webhook.
|
||||
- Optional message content outside the embed.
|
||||
|
||||
---
|
||||
## Output
|
||||
@@ -35,7 +49,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: Github Releases To Discord
|
||||
uses: SethCohen/github-releases-to-discord@v1.13.1
|
||||
uses: SethCohen/github-releases-to-discord@v1.15.1
|
||||
with:
|
||||
webhook_url: ${{ secrets.WEBHOOK_URL }}
|
||||
color: "2105893"
|
||||
@@ -45,6 +59,8 @@ jobs:
|
||||
footer_title: "Changelog"
|
||||
footer_icon_url: "https://cdn.discordapp.com/avatars/487431320314576937/bd64361e4ba6313d561d54e78c9e7171.png"
|
||||
footer_timestamp: true
|
||||
max_description: '4096'
|
||||
reduce_headings: true
|
||||
```
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
@@ -2,176 +2,205 @@ import core from '@actions/core';
|
||||
import github from '@actions/github';
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
/**
|
||||
* Removes carriage return characters.
|
||||
* @param {string} text The input text.
|
||||
* @returns {string} The text without carriage return characters.
|
||||
*/
|
||||
const removeCarriageReturn = (text) => text.replace(/\r/g, '');
|
||||
|
||||
/**
|
||||
* Removes HTML comments.
|
||||
* @param {string} text The input text.
|
||||
* @returns {string} The text without HTML comments.
|
||||
*/
|
||||
const removeHTMLComments = (text) => text.replace(/<!--.*?-->/gs, '');
|
||||
|
||||
/**
|
||||
* Reduces redundant newlines and spaces.
|
||||
* Keeps a maximum of 2 newlines to provide spacing between paragraphs.
|
||||
* @param {string} text The input text.
|
||||
* @returns {string} The text with reduced newlines.
|
||||
*/
|
||||
const reduceNewlines = (text) => text.replace(/\n\s*\n/g, (ws) => {
|
||||
const nlCount = (ws.match(/\n/g) || []).length;
|
||||
return nlCount >= 2 ? '\n\n' : '\n';
|
||||
});
|
||||
|
||||
/**
|
||||
* Converts @mentions to GitHub profile links for valid GitHub usernames.
|
||||
* @param {string} text The input text.
|
||||
* @returns {string} The text with valid @mentions converted to links.
|
||||
*/
|
||||
const convertMentionsToLinks = (text) => text.replace(
|
||||
/(?<![/@\w])@((?!-)(?!.*?--)[a-zA-Z0-9](?:-?[a-zA-Z0-9]){0,37})(?![.\w/-])(?!.*\])/g,
|
||||
(match, name) => `[@${name}](https://github.com/${name})`
|
||||
);
|
||||
|
||||
/**
|
||||
* Reduces headings to a smaller format if 'reduce_headings' is enabled.
|
||||
* Converts H3 to bold+underline, H2 to bold.
|
||||
* @param {string} text The input text.
|
||||
* @returns {string} The text with reduced heading sizes.
|
||||
*/
|
||||
const reduceHeadings = (text) => text
|
||||
.replace(/^###\s+(.+)$/gm, '**__$1__**') // Convert H3 to bold + underline
|
||||
.replace(/^##\s+(.+)$/gm, '**$1**'); // Convert H2 to bold
|
||||
|
||||
/**
|
||||
* Stylizes a markdown body into an appropriate embed message style.
|
||||
* Remove Carriage Return character to reduce size
|
||||
* Remove HTML comments (commonly added by 'Generate release notes' button)
|
||||
* Better URL linking for common Github links: PRs, Issues, Compare
|
||||
* Redundant whitespace and newlines removed, keeping at max 2 to provide space between paragraphs
|
||||
* Trim leading/trailing whitespace
|
||||
* If reduce_headings:
|
||||
* H3s converted to bold and underlined
|
||||
* H2s converted to bold
|
||||
* @param {string} description
|
||||
* @param {string} description The description to format.
|
||||
* @returns {string} The formatted description.
|
||||
*/
|
||||
const formatDescription = (description) => {
|
||||
let edit = description
|
||||
.replace(/\r/g, '')
|
||||
.replace(/<!--.*?-->/gs, '')
|
||||
.replace(
|
||||
new RegExp(
|
||||
"https://github.com/(.+)/(.+)/(issues|pull|commit|compare)/(\\S+)",
|
||||
"g"
|
||||
),
|
||||
(match, user, repo, type, id) => {
|
||||
return `[${getTypePrefix(type) + id}](${match})`
|
||||
}
|
||||
)
|
||||
.replace(/\n\s*\n/g, (ws) => {
|
||||
const nlCount = (ws.match(/\n/g) || []).length
|
||||
return nlCount >= 2 ? '\n\n' : '\n'
|
||||
})
|
||||
.trim()
|
||||
let edit = removeCarriageReturn(description);
|
||||
edit = removeHTMLComments(edit);
|
||||
edit = reduceNewlines(edit);
|
||||
edit = convertMentionsToLinks(edit);
|
||||
edit = edit.trim();
|
||||
|
||||
if (core.getBooleanInput('reduce_headings')) {
|
||||
edit = edit
|
||||
.replace(/^###\s+(.+)$/gm, '**__$1__**')
|
||||
.replace(/^##\s+(.+)$/gm, '**$1**')
|
||||
edit = reduceHeadings(edit);
|
||||
}
|
||||
|
||||
return edit
|
||||
}
|
||||
return edit;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a prefix to use for Github link display
|
||||
* @param {'issues' | 'pull' | 'commit' | 'compare'} type
|
||||
* Gets the max description length, defaulting to 4096 if not set or invalid.
|
||||
* @returns {number} The max description length.
|
||||
*/
|
||||
function getTypePrefix (type) {
|
||||
switch (type) {
|
||||
case 'issues':
|
||||
return 'Issue #'
|
||||
case 'pull':
|
||||
return 'PR #'
|
||||
case 'commit':
|
||||
return 'Commit #'
|
||||
case 'compare':
|
||||
return ''
|
||||
default:
|
||||
return '#'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the max description length if set to a valid number,
|
||||
* otherwise the default of 4096
|
||||
*/
|
||||
function getMaxDescription () {
|
||||
const getMaxDescription = () => {
|
||||
try {
|
||||
const max = core.getInput('max_description')
|
||||
if (typeof max === 'string' && max.length) {
|
||||
// 4096 is max for Embed Description
|
||||
// https://discord.com/developers/docs/resources/channel#embed-object-embed-limits
|
||||
return Math.min(parseInt(max, 10), 4096)
|
||||
const max = core.getInput('max_description');
|
||||
if (max && !isNaN(max)) {
|
||||
return Math.min(parseInt(max, 10), 4096);
|
||||
}
|
||||
} catch (err) {
|
||||
core.warning(`max_description not a valid number: ${err}`)
|
||||
core.warning(`Invalid max_description: ${err}`);
|
||||
}
|
||||
return 4096
|
||||
}
|
||||
return 4096;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the context of the action, returns a GitHub Release payload.
|
||||
* Get the context of the action, returning a GitHub Release payload.
|
||||
* @returns {object} The context with release details.
|
||||
*/
|
||||
function getContext () {
|
||||
const payload = github.context.payload;
|
||||
|
||||
const getContext = () => {
|
||||
const { release } = github.context.payload;
|
||||
return {
|
||||
body: payload.release.body,
|
||||
name: payload.release.name,
|
||||
html_url: payload.release.html_url
|
||||
}
|
||||
}
|
||||
body: release.body,
|
||||
name: release.name,
|
||||
html_url: release.html_url
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} str
|
||||
* @param {number} maxLength
|
||||
* @param {string} [url]
|
||||
* @param {boolean} [clipAtLine=false]
|
||||
* Limits the string to a maximum length, optionally adding a URL or clipping at a newline.
|
||||
* @param {string} str The string to limit.
|
||||
* @param {number} maxLength The maximum allowed length.
|
||||
* @param {string} [url] Optional URL for linking the truncated text.
|
||||
* @param {boolean} [clipAtLine=false] Whether to clip at the nearest newline.
|
||||
* @returns {string} The limited string.
|
||||
*/
|
||||
function limit(str, maxLength, url, clipAtLine) {
|
||||
clipAtLine ??= false
|
||||
if (str.length <= maxLength)
|
||||
return str
|
||||
let replacement = clipAtLine ? '\n…' : '…'
|
||||
if (url) {
|
||||
replacement = `${clipAtLine ? '\n' : ''}([…](${url}))`
|
||||
}
|
||||
maxLength = maxLength - replacement.length
|
||||
str = str.substring(0, maxLength)
|
||||
const limitString = (str, maxLength, url, clipAtLine = false) => {
|
||||
if (str.length <= maxLength) return str;
|
||||
|
||||
const lastNewline = str.search(new RegExp(`[^${clipAtLine ? '\n' : '\s'}]*$`))
|
||||
const replacement = url
|
||||
? `${clipAtLine ? '\n' : ''}([…](${url}))`
|
||||
: (clipAtLine ? '\n…' : '…');
|
||||
|
||||
maxLength -= replacement.length;
|
||||
str = str.substring(0, maxLength);
|
||||
|
||||
const lastNewline = str.search(new RegExp(`[^${clipAtLine ? '\n' : '\s'}]*$`));
|
||||
if (lastNewline > -1) {
|
||||
str = str.substring(0, lastNewline)
|
||||
str = str.substring(0, lastNewline);
|
||||
}
|
||||
|
||||
return str + replacement
|
||||
}
|
||||
return str + replacement;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the action.
|
||||
* Get inputs, creates a stylized response webhook, and sends it to the channel.
|
||||
* Builds the embed message for the Discord webhook.
|
||||
* @param {string} name The title or name of the release.
|
||||
* @param {string} html_url The URL of the release.
|
||||
* @param {string} description The formatted description of the release.
|
||||
* @returns {object} The embed message to send in the webhook.
|
||||
*/
|
||||
async function run () {
|
||||
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 (core.getInput('footer_title')) {
|
||||
embedMsg.footer.text = limitString(core.getInput('footer_title'), 2048);
|
||||
}
|
||||
if (core.getInput('footer_icon_url')) {
|
||||
embedMsg.footer.icon_url = core.getInput('footer_icon_url');
|
||||
}
|
||||
if (core.getInput('footer_timestamp') === 'true') {
|
||||
embedMsg.timestamp = new Date().toISOString();
|
||||
}
|
||||
|
||||
return embedMsg;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sends the webhook request to Discord.
|
||||
* @param {string} webhookUrl The URL of the Discord webhook.
|
||||
* @param {object} requestBody The payload to send in the webhook.
|
||||
*/
|
||||
const sendWebhook = async (webhookUrl, requestBody) => {
|
||||
try {
|
||||
const response = await fetch(`${webhookUrl}?wait=true`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
const data = await response.json();
|
||||
core.info(JSON.stringify(data));
|
||||
} catch (err) {
|
||||
core.setFailed(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the request body for the Discord webhook.
|
||||
* @param {object} embedMsg The embed message to include in the request body.
|
||||
* @returns {object} The request body for the webhook.
|
||||
*/
|
||||
const buildRequestBody = (embedMsg) => {
|
||||
return {
|
||||
embeds: [embedMsg],
|
||||
...(core.getInput('username') && { username: core.getInput('username') }),
|
||||
...(core.getInput('avatar_url') && { avatar_url: core.getInput('avatar_url') }),
|
||||
...(core.getInput('content') && { content: core.getInput('content') })
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Main function to handle the action: get inputs, format the message, and send the webhook.
|
||||
*/
|
||||
const run = async () => {
|
||||
const webhookUrl = core.getInput('webhook_url');
|
||||
const color = core.getInput('color');
|
||||
const username = core.getInput('username');
|
||||
const avatarUrl = core.getInput('avatar_url');
|
||||
const content = core.getInput('content');
|
||||
const footerTitle = core.getInput('footer_title');
|
||||
const footerIconUrl = core.getInput('footer_icon_url');
|
||||
const footerTimestamp = core.getInput('footer_timestamp');
|
||||
|
||||
if (!webhookUrl) return core.setFailed('webhook_url not set. Please set it.');
|
||||
|
||||
const {body, html_url, name} = getContext();
|
||||
if (!webhookUrl) return core.setFailed('webhook_url not set.');
|
||||
|
||||
const { body, html_url, name } = getContext();
|
||||
const description = formatDescription(body);
|
||||
|
||||
let embedMsg = {
|
||||
title: limit(name, 256),
|
||||
url: html_url,
|
||||
color: color,
|
||||
description: description,
|
||||
footer: {}
|
||||
}
|
||||
const embedMsg = buildEmbedMessage(name, html_url, description);
|
||||
|
||||
if (footerTitle != '') embedMsg.footer.text = limit(footerTitle, 2048);
|
||||
if (footerIconUrl != '') embedMsg.footer.icon_url = footerIconUrl;
|
||||
if (footerTimestamp == 'true') embedMsg.timestamp = new Date().toISOString();
|
||||
const requestBody = buildRequestBody(embedMsg);
|
||||
|
||||
let embedSize = embedMsg.title.length + (embedMsg.footer?.text?.length ?? 0)
|
||||
embedMsg.description = limit(embedMsg.description, Math.min(getMaxDescription(), 6000 - embedSize), embedMsg.url, true)
|
||||
|
||||
let requestBody = {
|
||||
embeds: [embedMsg]
|
||||
}
|
||||
|
||||
if (username != '') requestBody.username = username;
|
||||
if (avatarUrl != '') requestBody.avatar_url = avatarUrl;
|
||||
if (content != '') requestBody.content = content;
|
||||
|
||||
const url = `${webhookUrl}?wait=true`;
|
||||
fetch(url, {
|
||||
method: 'post',
|
||||
body: JSON.stringify(requestBody),
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => core.info(JSON.stringify(data)))
|
||||
.catch(err => core.info(err))
|
||||
}
|
||||
await sendWebhook(webhookUrl, requestBody);
|
||||
};
|
||||
|
||||
run()
|
||||
.then(() => {core.info('Action completed successfully')})
|
||||
.catch(err => {core.setFailed(err.message)})
|
||||
.then(() => core.info('Action completed successfully'))
|
||||
.catch(err => core.setFailed(err.message));
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "github-releases-to-discord",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.2",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "github-releases-to-discord",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.10.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "github-releases-to-discord",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.2",
|
||||
"description": "A GitHub Action that automatically sends a stylized Discord webhook of a GitHub Release description to a specified Discord channel.",
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
|
||||
Reference in New Issue
Block a user