Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/community-release-notifier.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ on:
required: true
description: "release URL"
type: 'string'
body:
required: true
description: "Release Body"
type: 'string'
default: ''
secrets:
DISCORD_WEBHOOK_RELEASE_NOTES:
description: 'Discord Webhook for Notifying Releases to Discord'
Expand All @@ -30,6 +35,7 @@ jobs:
stringToTruncate: |
📢 Acode [${{ github.event.release.tag_name || inputs.tag_name }}](<${{ github.event.release.url || inputs.url }}>) was just Released 🎉!

${{ github.event.release.body || inputs.body }}

- name: Discord Webhook Action (Publishing)
uses: tsickert/discord-webhook@c840d45a03a323fbc3f7507ac7769dbd91bfb164 # v5.3.0
Expand Down
19 changes: 18 additions & 1 deletion .github/workflows/nightly-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
outputs:
release_output_url: ${{ steps.release.outputs.url }}
updated_version: ${{ steps.update-version.outputs.UPDATED_VERSION}}
RELEASE_NOTES: ${{ env.RELEASE_NOTES }}
steps:
- name: Fast Fail if secrets are missing
if: ${{ env.KEYSTORE_CONTENT == '' || env.BUILD_JSON_CONTENT == '' }}
Expand Down Expand Up @@ -201,6 +202,7 @@
- name: Check Nightly Tag and Force Update
#if: github.event_name == 'push' && contains(github.event.ref, 'tags/nightly') == false
if: ${{ ! inputs.skip_tagging_and_releases }}
id: check-nightly-tag-force-update
run: |
# Check if the nightly tag exists and get the commit it points to
if git show-ref --quiet refs/tags/nightly; then
Expand All @@ -223,10 +225,22 @@
echo "Nightly tag already points to this commit. Skipping update."
fi


- name: Generate Release Notes (Experimental)
if: ${{ success() && env.releaseRequired == 'true' }}
id: gen-release-notes
continue-on-error: true
run: |
RELEASE_NOTES=$(node utils/scripts/generate-release-notes.js ${{ github.repository_owner }} Acode ${{ github.sha }} --format md --from-tag ${{ env.TAG_COMMIT }} --important-only --quiet --changelog-only)
{
echo "RELEASE_NOTES<<EOF"
echo "$RELEASE_NOTES"
echo "EOF"
} >> $GITHUB_ENV
- name: Release Nightly Version
# Only run this step, if not called from another workflow. And a previous step is successful with releasedRequired=true
id: release
if: ${{ ! inputs.skip_tagging_and_releases && success() && env.releaseRequired == 'true' && !inputs.is_PR }}
if: ${{ ! inputs.skip_tagging_and_releases && steps.check-nightly-tag-force-update.outcome == 'success' && env.releaseRequired == 'true' && !inputs.is_PR }}
uses: softprops/action-gh-release@v2
with:
prerelease: true
Expand All @@ -240,6 +254,8 @@

[Compare Changes](https://github.com/${{ github.repository }}/compare/${{ env.TAG_COMMIT }}...${{ github.sha }})

${{ env.RELEASE_NOTES }}

- name: Update Last Comment by bot (If ran in PR)
if: inputs.is_PR
uses: marocchino/sticky-pull-request-comment@v2
Expand All @@ -261,5 +277,6 @@
with:
tag_name: ${{ needs.build.outputs.updated_version }}
url: ${{ needs.build.outputs.release_output_url }}
body: ${{ needs.build.outputs.RELEASE_NOTES }}
secrets:
DISCORD_WEBHOOK_RELEASE_NOTES: ${{ secrets.DISCORD_WEBHOOK_RELEASE_NOTES }}
245 changes: 245 additions & 0 deletions utils/scripts/generate-release-notes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
#!/usr/bin/env node
/**
* ✨ @ UnschooledGamer (baked With AI, Modified by @ UnschooledGamer) ~ 2025.
*
* GitHub Release Notes Generator
*
* Features:
* - Auto categorizes commits by type
* - Optional compact "plain" output to save space
* - Option to include only important tags (feat, fix, refactor, perf)
* - Option to use only merge commits
*
* Usage:
* GITHUB_TOKEN=<token> node generate-release-notes.js <owner> <repo> <current_tag> [options]
*
* Options:
* --plain Output minimal Markdown (no emojis, compact)
* --important-only Include only features, fixes, refactors, and perf
* --merge-only Include only merge commits
* --help Show usage
* --format [md/json] Output Format
* --fromTag v1.11.0 The From/Previous Tag
* --quiet Suppress output to stdout
*/

const args = process.argv.slice(2);

function getArgValue(flag) {
const idx = args.indexOf(flag);
return idx !== -1 && args[idx + 1] && !args[idx + 1].startsWith("-")
? args[idx + 1]
: null;
}
if (args.includes("--help") || args.length < 3) {
console.log(`
Usage: GITHUB_TOKEN=<token> node generate-release-notes.js <owner> <repo> <tag> [options]
✨ @ UnschooledGamer (baked With AI, Modified by @ UnschooledGamer) ~ 2025

Options:
--plain Compact, no emojis (saves space)
--important-only Only include Features, Fixes, Refactors, Perf
--merge-only Include only merge commits
--help Show this help message
--format [md/json] Output Format
--from-tag v1.11.0 The From/Previous Tag
--quiet Suppress output to stdout
--stdout-only Output to stdout only
--changelog-only Output changelog only
`);
process.exit(0);
}

const [owner, repo, currentTag, previousTagArg] = args;
const token = process.env.GITHUB_TOKEN;
if (!token) {
console.error("❌ Missing GITHUB_TOKEN environment variable.");
process.exit(1);
}

const flags = {
plain: args.includes("--plain"),
importantOnly: args.includes("--important-only"),
mergeOnly: args.includes("--merge-only"),
quiet: args.includes("--quiet") || args.includes("--stdout-only"),
format: getArgValue("--format") || "md",
fromTag: getArgValue("--from-tag"),
changelogOnly: args.includes("--changelog-only"),
};

function log(...msg) {
if (!flags.quiet) console.error(...msg);
}

const headers = {
Authorization: `token ${token}`,
Accept: "application/vnd.github+json",
"User-Agent": "release-notes-script",
};

async function getPreviousTag() {
const res = await fetch(
`https://api.github.com/repos/${owner}/${repo}/tags`,
{ headers },
);
const tags = await res.json();
if (!Array.isArray(tags) || tags.length < 2) return null;
return tags[1].name;
}

async function getCommits(previousTag, currentTag) {
const url = `https://api.github.com/repos/${owner}/${repo}/compare/${previousTag}...${currentTag}`;
const res = await fetch(url, { headers });
if (!res.ok) throw new Error(`Failed to fetch commits: ${res.status}`);
const data = await res.json();
return data.commits || [];
}

function categorizeCommits(commits, { mergeOnly, importantOnly }) {
const sections = {
feat: [],
fix: [],
perf: [],
refactor: [],
docs: [],
chore: [],
test: [],
add: [],
revert: [],
update: [],
other: [],
};

for (const c of commits) {
const msg = c.commit.message.split("\n")[0];
const isMerge =
msg.startsWith("Merge pull request") || msg.startsWith("Merge branch");

if (mergeOnly && !isMerge) continue;

const type =
Object.keys(sections).find(
(k) =>
msg.toLowerCase().startsWith(`${k}:`) ||
msg.toLowerCase().startsWith(`${k} `),
) || "other";

if (
importantOnly &&
!["feat", "fix", "refactor", "perf", "add", "revert", "update"].includes(
type,
)
)
continue;

const author = c.author?.login
? `[${c.author.login}](https://github.com/${c.author.login})`
: "unknown";

const entry = `- ${msg} (${c.sha.slice(0, 7)}) by ${author}`;
sections[type].push(entry);
}

return sections;
}

const emojis = {
feat: flags.plain ? "" : "✨ ",
fix: flags.plain ? "" : "🐞 ",
perf: flags.plain ? "" : "⚡ ",
refactor: flags.plain ? "" : "🔧 ",
docs: flags.plain ? "" : "📝 ",
chore: flags.plain ? "" : "🧹 ",
test: flags.plain ? "" : "🧪 ",
other: flags.plain ? "" : "📦 ",
revert: flags.plain ? "" : "⏪ ",
add: flags.plain ? "" : "➕ ",
update: flags.plain ? "" : "🔄 ",
};

function formatMarkdown(tag, prevTag, sections, { plain }) {
const lines = [
flags.changelogOnly
? ""
: `Changes since [${prevTag}](https://github.com/${owner}/${repo}/releases/tag/${prevTag})`,
"",
];

for (const [type, list] of Object.entries(sections)) {
if (list.length === 0) continue;
const header = plain
? `## ${type}`
: `## ${emojis[type]}${type[0].toUpperCase() + type.slice(1)}`;
lines.push(header, "", list.join("\n"), "");
}

// Compact single-line mode for super small output
// if (plain) {
// const compact = Object.entries(sections)
// .filter(([_, list]) => list.length)
// .map(([type, list]) => `${type.toUpperCase()}: ${list.length} commits`)
// .join(" | ");
// lines.push(`\n_Summary: ${compact}_`);
// }

return lines.join("\n");
}

function formatJSON(tag, prevTag, sections, plain = true) {
const lines = [
"",
flags.changelogOnly
? ""
: `Changes since [${prevTag}](https://github.com/${owner}/${repo}/releases/tag/${prevTag})`,
"",
];

// todo: split into function
for (const [type, list] of Object.entries(sections)) {
if (list.length === 0) continue;
const header = plain
? `## ${type}`
: `## ${emojis[type]}${type[0].toUpperCase() + type.slice(1)}`;
lines.push(header, "", list.join("\n"), "");
}
return JSON.stringify(
{
release: tag,
previous: prevTag,
sections: Object.fromEntries(
Object.entries(sections).filter(([_, v]) => v.length),
),
notes: lines.join("\n"),
},
null,
2,
);
}

async function main() {
log(`🔍 Generating release notes for ${owner}/${repo} @ ${currentTag}...`);

const prevTag = flags.fromTag || (await getPreviousTag());
if (!prevTag) {
console.error("No previous tag found. Use --from-tag to specify one.");
process.exit(1);
}

const commits = await getCommits(prevTag, currentTag);
if (!commits.length) {
console.error("No commits found.");
process.exit(1);
}
const categorized = categorizeCommits(commits, flags);
let output;

if (flags.format === "json") {
output = formatJSON(currentTag, prevTag, categorized);
} else {
output = formatMarkdown(currentTag, prevTag, categorized, flags);
}

process.stdout.write(output + "\n");
}

main().catch((err) => console.error(err));