diff --git a/.github/workflows/community-release-notifier.yml b/.github/workflows/community-release-notifier.yml index 9b418a1b2..cc91e9ae0 100644 --- a/.github/workflows/community-release-notifier.yml +++ b/.github/workflows/community-release-notifier.yml @@ -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' @@ -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 diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml index 74f61c144..2252bfcd8 100644 --- a/.github/workflows/nightly-build.yml +++ b/.github/workflows/nightly-build.yml @@ -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 == '' }} @@ -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 @@ -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<> $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 @@ -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 @@ -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 }} diff --git a/utils/scripts/generate-release-notes.js b/utils/scripts/generate-release-notes.js new file mode 100644 index 000000000..cb0c7a4a8 --- /dev/null +++ b/utils/scripts/generate-release-notes.js @@ -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= node generate-release-notes.js [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= node generate-release-notes.js [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));