Skip to content

Commit c11cc85

Browse files
feat: enhance release notification workflow by adding a required release body input and integrating it into the Discord webhook message. Introduced a new script for generating release notes based on commit history.
1 parent ca4a2ad commit c11cc85

3 files changed

Lines changed: 226 additions & 0 deletions

File tree

.github/workflows/community-release-notifier.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ on:
1212
required: true
1313
description: "release URL"
1414
type: 'string'
15+
body:
16+
required: true
17+
description: "Release Body"
18+
type: 'string'
19+
default: ''
1520
secrets:
1621
DISCORD_WEBHOOK_RELEASE_NOTES:
1722
description: 'Discord Webhook for Notifying Releases to Discord'
@@ -30,6 +35,7 @@ jobs:
3035
stringToTruncate: |
3136
📢 Acode [${{ github.event.release.tag_name || inputs.tag_name }}](<${{ github.event.release.url || inputs.url }}>) was just Released 🎉!
3237
38+
${{ github.event.release.body || inputs.body }}
3339
3440
- name: Discord Webhook Action (Publishing)
3541
uses: tsickert/discord-webhook@c840d45a03a323fbc3f7507ac7769dbd91bfb164 # v5.3.0

.github/workflows/nightly-build.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
outputs:
6666
release_output_url: ${{ steps.release.outputs.url }}
6767
updated_version: ${{ steps.update-version.outputs.UPDATED_VERSION}}
68+
RELEASE_NOTES: ${{ env.RELEASE_NOTES }}
6869
steps:
6970
- name: Fast Fail if secrets are missing
7071
if: ${{ env.KEYSTORE_CONTENT == '' || env.BUILD_JSON_CONTENT == '' }}
@@ -256,5 +257,6 @@
256257
with:
257258
tag_name: ${{ needs.build.outputs.updated_version }}
258259
url: ${{ needs.build.outputs.release_output_url }}
260+
body: ${{ needs.build.outputs.RELEASE_NOTES }}
259261
secrets:
260262
DISCORD_WEBHOOK_RELEASE_NOTES: ${{ secrets.DISCORD_WEBHOOK_RELEASE_NOTES }}
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
#!/usr/bin/env node
2+
/**
3+
* ✨ @ UnschooledGamer (baked With AI, Modified by @ UnschooledGamer) ~ 2025.
4+
*
5+
* GitHub Release Notes Generator
6+
*
7+
* Features:
8+
* - Auto categorizes commits by type
9+
* - Optional compact "plain" output to save space
10+
* - Option to include only important tags (feat, fix, refactor, perf)
11+
* - Option to use only merge commits
12+
*
13+
* Usage:
14+
* GITHUB_TOKEN=<token> node generate-release-notes.js <owner> <repo> <current_tag> [options]
15+
*
16+
* Options:
17+
* --plain Output minimal Markdown (no emojis, compact)
18+
* --important-only Include only features, fixes, refactors, and perf
19+
* --merge-only Include only merge commits
20+
* --help Show usage
21+
* --format [md/json] Output Format
22+
* --fromTag v1.11.0 The From/Previous Tag
23+
* --quiet Suppress output to stdout
24+
*/
25+
26+
const args = process.argv.slice(2);
27+
28+
function getArgValue(flag) {
29+
const idx = args.indexOf(flag);
30+
return idx !== -1 && !args[idx + 1].startsWith("-") ? args[idx + 1] : null;
31+
}
32+
33+
if (args.includes("--help") || args.length < 3) {
34+
console.log(`
35+
Usage: GITHUB_TOKEN=<token> node generate-release-notes.js <owner> <repo> <tag> [options]
36+
✨ @ UnschooledGamer (baked With AI, Modified by @ UnschooledGamer) ~ 2025
37+
38+
Options:
39+
--plain Compact, no emojis (saves space)
40+
--important-only Only include Features, Fixes, Refactors, Perf
41+
--merge-only Include only merge commits
42+
--help Show this help message
43+
--format [md/json] Output Format
44+
--fromTag v1.11.0 The From/Previous Tag
45+
--quiet Suppress output to stdout
46+
`);
47+
process.exit(0);
48+
}
49+
50+
const [owner, repo, currentTag, previousTagArg] = args;
51+
const token = process.env.GITHUB_TOKEN;
52+
if (!token) {
53+
console.error("❌ Missing GITHUB_TOKEN environment variable.");
54+
process.exit(1);
55+
}
56+
57+
const flags = {
58+
plain: args.includes("--plain"),
59+
importantOnly: args.includes("--important-only"),
60+
mergeOnly: args.includes("--merge-only"),
61+
quiet: args.includes("--quiet") || args.includes("--stdout-only"),
62+
format: getArgValue("--format") || "md",
63+
fromTag: getArgValue("--from-tag"),
64+
changelogOnly: args.includes("--changelog-only"),
65+
};
66+
67+
function log(...msg) {
68+
if (!flags.quiet) console.error(...msg);
69+
}
70+
71+
const headers = {
72+
"Authorization": `token ${token}`,
73+
"Accept": "application/vnd.github+json",
74+
"User-Agent": "release-notes-script"
75+
};
76+
77+
async function getPreviousTag() {
78+
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/tags`, { headers });
79+
const tags = await res.json();
80+
if (!Array.isArray(tags) || tags.length < 2) return null;
81+
return tags[1].name;
82+
}
83+
84+
async function getCommits(previousTag, currentTag) {
85+
const url = `https://api.github.com/repos/${owner}/${repo}/compare/${previousTag}...${currentTag}`;
86+
const res = await fetch(url, { headers });
87+
if (!res.ok) throw new Error(`Failed to fetch commits: ${res.status}`);
88+
const data = await res.json();
89+
return data.commits || [];
90+
}
91+
92+
function categorizeCommits(commits, { mergeOnly, importantOnly }) {
93+
const sections = {
94+
feat: [],
95+
fix: [],
96+
perf: [],
97+
refactor: [],
98+
docs: [],
99+
chore: [],
100+
test: [],
101+
add: [],
102+
revert: [],
103+
update: [],
104+
other: [],
105+
};
106+
107+
for (const c of commits) {
108+
const msg = c.commit.message.split("\n")[0];
109+
const isMerge = msg.startsWith("Merge pull request") || msg.startsWith("Merge branch");
110+
111+
if (mergeOnly && !isMerge) continue;
112+
113+
const type = Object.keys(sections).find(k => msg.toLowerCase().startsWith(`${k}:`) || msg.toLowerCase().startsWith(`${k} `)) || "other";
114+
115+
if (importantOnly && !["feat", "fix", "refactor", "perf", "add", "revert", "update"].includes(type)) continue;
116+
117+
const author = c.author?.login
118+
? `[${c.author.login}](https://github.com/${c.author.login})`
119+
: "unknown";
120+
121+
const entry = `- ${msg} (${c.sha.slice(0, 7)}) by ${author}`;
122+
sections[type].push(entry);
123+
}
124+
125+
return sections;
126+
}
127+
128+
const emojis = {
129+
feat: flags.plain ? "" : "✨ ",
130+
fix: flags.plain ? "" : "🐞 ",
131+
perf: flags.plain ? "" : "⚡ ",
132+
refactor: flags.plain ? "" : "🔧 ",
133+
docs: flags.plain ? "" : "📝 ",
134+
chore: flags.plain ? "" : "🧹 ",
135+
test: flags.plain ? "" : "🧪 ",
136+
other: flags.plain ? "" : "📦 ",
137+
revert: flags.plain ? "" : "⏪ ",
138+
add: flags.plain ? "" : "➕ ",
139+
update: flags.plain ? "" : "🔄 ",
140+
};
141+
142+
function formatMarkdown(tag, prevTag, sections, { plain }) {
143+
144+
const lines = [
145+
flags.changelogOnly ? "" : `Changes since [${prevTag}](https://github.com/${owner}/${repo}/releases/tag/${prevTag})`,
146+
"",
147+
];
148+
149+
for (const [type, list] of Object.entries(sections)) {
150+
if (list.length === 0) continue;
151+
const header = plain ? `## ${type}` : `## ${emojis[type]}${type[0].toUpperCase() + type.slice(1)}`;
152+
lines.push(header, "", list.join("\n"), "");
153+
}
154+
155+
// Compact single-line mode for super small output
156+
// if (plain) {
157+
// const compact = Object.entries(sections)
158+
// .filter(([_, list]) => list.length)
159+
// .map(([type, list]) => `${type.toUpperCase()}: ${list.length} commits`)
160+
// .join(" | ");
161+
// lines.push(`\n_Summary: ${compact}_`);
162+
// }
163+
164+
return lines.join("\n");
165+
}
166+
167+
function formatJSON(tag, prevTag, sections, plain = true) {
168+
169+
const lines = [
170+
"",
171+
flags.changelogOnly ? "" : `Changes since [${prevTag}](https://github.com/${owner}/${repo}/releases/tag/${prevTag})`,
172+
"",
173+
];
174+
175+
// todo: split into function
176+
for (const [type, list] of Object.entries(sections)) {
177+
if (list.length === 0) continue;
178+
const header = plain ? `## ${type}` : `## ${emojis[type]}${type[0].toUpperCase() + type.slice(1)}`;
179+
lines.push(header, "", list.join("\n"), "");
180+
}
181+
return JSON.stringify({
182+
release: tag,
183+
previous: prevTag,
184+
sections: Object.fromEntries(
185+
Object.entries(sections).filter(([_, v]) => v.length)
186+
),
187+
notes: lines.join("\n")
188+
}, null, 2);
189+
}
190+
191+
192+
async function main() {
193+
log(`🔍 Generating release notes for ${owner}/${repo} @ ${currentTag}...`);
194+
195+
const prevTag = flags.fromTag || await getPreviousTag();
196+
if (!prevTag) {
197+
console.error("No previous tag found. Use --from-tag to specify one.");
198+
process.exit(1);
199+
}
200+
201+
const commits = await getCommits(prevTag, currentTag);
202+
if(!commits.length) {
203+
console.error("No commits found.");
204+
process.exit(1);
205+
}
206+
const categorized = categorizeCommits(commits, flags);
207+
let output;
208+
209+
if (flags.format === "json") {
210+
output = formatJSON(currentTag, prevTag, categorized);
211+
} else {
212+
output = formatMarkdown(currentTag, prevTag, categorized, flags);
213+
}
214+
215+
process.stdout.write(output + "\n");
216+
}
217+
218+
main().catch(err => console.error(err));

0 commit comments

Comments
 (0)