Files
ccs/scripts/send-discord-release.cjs
T
kaitranntt 1129ec6ef5 fix(ci): use commit-based changelog for dev release Discord notifications
- dev-release.sh: save release notes to .dev-release-info.json
- send-discord-release.cjs: read from generated file for dev releases
- add .dev-release-info.json to .gitignore
2025-12-21 01:14:49 -05:00

250 lines
6.4 KiB
JavaScript

/**
* Send Release Notification to Discord using Embeds
*
* Usage:
* node send-discord-release.cjs <type> <webhook-url>
*
* Args:
* type: 'production' or 'dev'
* webhook-url: Discord webhook URL
*/
const fs = require('fs');
const https = require('https');
const { URL } = require('url');
const releaseType = process.argv[2]; // 'production' or 'dev'
const webhookUrl = process.argv[3];
if (!releaseType || !webhookUrl) {
console.error('Usage: node send-discord-release.cjs <type> <webhook-url>');
process.exit(1);
}
// Validate webhook URL is Discord
try {
const parsed = new URL(webhookUrl);
if (!parsed.hostname.endsWith('discord.com') || !parsed.pathname.startsWith('/api/webhooks/')) {
console.error('[X] Invalid Discord webhook URL');
process.exit(1);
}
} catch {
console.error('[X] Invalid URL format');
process.exit(1);
}
/**
* Extract latest release from CHANGELOG.md (for production releases)
*/
function extractLatestRelease() {
const changelogPath = 'CHANGELOG.md';
if (!fs.existsSync(changelogPath)) {
return {
version: 'Unknown',
date: new Date().toISOString().split('T')[0],
sections: {},
};
}
const content = fs.readFileSync(changelogPath, 'utf8');
const lines = content.split('\n');
let version = 'Unknown';
let date = new Date().toISOString().split('T')[0];
let collecting = false;
let currentSection = null;
const sections = {};
for (const line of lines) {
// Match: ## [1.0.0](url) (2025-01-01) or ## 1.0.0 (2025-01-01)
const versionMatch = line.match(/^## \[?(\d+\.\d+\.\d+(?:-dev\.\d+)?)\]?.*?\((\d{4}-\d{2}-\d{2})\)/);
if (versionMatch) {
if (!collecting) {
version = versionMatch[1];
date = versionMatch[2];
collecting = true;
continue;
} else {
break; // Found next version, stop
}
}
if (!collecting) continue;
// Match section headers: ### Features, ### Bug Fixes
const sectionMatch = line.match(/^### (.+)/);
if (sectionMatch) {
currentSection = sectionMatch[1];
sections[currentSection] = [];
continue;
}
// Collect bullet points
if (currentSection && line.trim().startsWith('*')) {
const item = line.trim().substring(1).trim();
if (item) {
sections[currentSection].push(item);
}
}
}
return { version, date, sections };
}
/**
* Extract dev release info from .dev-release-info.json (for dev releases)
* Falls back to CHANGELOG.md if file not found
*/
function extractDevRelease() {
const devInfoPath = '.dev-release-info.json';
if (!fs.existsSync(devInfoPath)) {
console.log('[!] .dev-release-info.json not found, falling back to CHANGELOG.md');
return extractLatestRelease();
}
try {
const content = fs.readFileSync(devInfoPath, 'utf8');
const info = JSON.parse(content);
// Parse commit messages into a simple Changes section
const changes = info.notes
.split('\n')
.filter((line) => line.trim().startsWith('-'))
.map((line) => line.trim().substring(1).trim())
.filter((item) => item);
return {
version: info.version,
date: new Date().toISOString().split('T')[0],
sections: changes.length > 0 ? { Changes: changes } : {},
};
} catch (error) {
console.error('[!] Error reading .dev-release-info.json:', error.message);
return extractLatestRelease();
}
}
/**
* Create Discord embed
*/
function createEmbed(release) {
const isDev = releaseType === 'dev';
const color = isDev ? 0xf59e0b : 0x10b981; // Orange for dev, Green for production
const title = isDev ? `Dev Release ${release.version}` : `Release ${release.version}`;
const url = `https://github.com/kaitranntt/ccs/releases/tag/v${release.version}`;
// Section name to indicator mapping (ASCII only per CLAUDE.md)
const sectionIndicators = {
Features: '[+]',
'Bug Fixes': '[X]',
Documentation: '[i]',
Styles: '[~]',
'Code Refactoring': '[~]',
'Performance Improvements': '[!]',
Tests: '[T]',
'Build System': '[B]',
CI: '[C]',
};
const fields = [];
for (const [sectionName, items] of Object.entries(release.sections)) {
if (items.length === 0) continue;
const indicator = sectionIndicators[sectionName] || '[*]';
let fieldValue = items.map((item) => `• ${item}`).join('\n');
// Discord field value max is 1024 characters
if (fieldValue.length > 1024) {
const truncateAt = fieldValue.lastIndexOf('\n', 1000);
fieldValue = fieldValue.substring(0, truncateAt > 0 ? truncateAt : 1000) + '\n... *(truncated)*';
}
fields.push({
name: `${indicator} ${sectionName}`,
value: fieldValue,
inline: false,
});
}
if (fields.length === 0) {
fields.push({
name: '[i] Release Notes',
value: 'Release completed. See changelog on GitHub.',
inline: false,
});
}
return {
title,
url,
color,
timestamp: new Date().toISOString(),
footer: {
text: isDev ? 'npm i @kaitranntt/ccs@dev' : 'npm i @kaitranntt/ccs@latest',
},
fields,
};
}
/**
* Send to Discord webhook
*/
function sendToDiscord(embed) {
const payload = {
username: releaseType === 'dev' ? 'CCS Dev Release' : 'CCS Release',
embeds: [embed],
};
const url = new URL(webhookUrl);
const options = {
hostname: url.hostname,
path: url.pathname + url.search,
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
};
const req = https.request(options, (res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
console.log('[OK] Discord notification sent');
} else {
console.error(`[X] Discord webhook failed: ${res.statusCode}`);
console.error(data);
process.exit(1);
}
});
});
req.on('error', (error) => {
console.error('[X] Error sending Discord notification:', error);
process.exit(1);
});
req.write(JSON.stringify(payload));
req.end();
}
// Main
try {
const isDev = releaseType === 'dev';
const release = isDev ? extractDevRelease() : extractLatestRelease();
console.log(`[i] Preparing ${releaseType} notification for v${release.version}`);
const embed = createEmbed(release);
sendToDiscord(embed);
} catch (error) {
console.error('[X] Error:', error);
process.exit(1);
}