velxio/.github/workflows/discord-release-notify.yml

344 lines
16 KiB
YAML

name: Discord — Release Merge Notification
on:
pull_request:
types:
- closed
branches:
- release
# Manual re-fire (e.g. to retry a failed announce without a new merge).
# Uses THIS file from the dispatched ref; checks out `release` and announces
# whatever version release's frontend/package.json holds.
workflow_dispatch:
inputs:
base:
description: 'Base git ref/SHA to diff from (commits base..HEAD are summarised). Defaults to HEAD~50.'
required: false
default: ''
jobs:
notify:
# Run on a merged PR into release, or on a manual dispatch.
if: github.event_name == 'workflow_dispatch' || github.event.pull_request.merged == true
runs-on: ubuntu-latest
permissions:
contents: write # necesario para pushear CHANGELOG.md + version bump
steps:
- name: Checkout full history
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
ref: release
- name: Configure git identity
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Announce on Discord, then commit CHANGELOG + version bump on success
uses: actions/github-script@v7
env:
DAVEAGENT_API_KEY: ${{ secrets.DAVEAGENT_API_KEY }}
DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }}
DISCORD_CHANNEL_ID: ${{ secrets.DISCORD_CHANNEL_ID }}
with:
script: |
const { execSync } = require('child_process');
const fs = require('fs');
const today = new Date().toISOString().slice(0, 10);
// ── Helpers ──────────────────────────────────────────────────────
function git(cmd) {
try {
return execSync(`git ${cmd}`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
} catch { return ''; }
}
// deepseek-v4-flash is a REASONING model: max_tokens caps the
// reasoning trace + the visible answer TOGETHER, so a stingy budget
// makes `content` come back empty (which once made us POST an empty
// Discord message). Give it generous budgets.
async function callDeepSeek(prompt, maxTokens, temperature = 0.3) {
const apiKey = process.env.DAVEAGENT_API_KEY;
if (!apiKey) throw new Error('DAVEAGENT_API_KEY not set');
const res = await fetch('https://api.deepseek.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: 'deepseek-v4-flash',
max_tokens: maxTokens,
temperature,
messages: [{ role: 'user', content: prompt }],
}),
});
if (!res.ok) {
const err = await res.text();
throw new Error(`DeepSeek ${res.status}: ${err}`);
}
const data = await res.json();
return (data.choices?.[0]?.message?.content ?? '').trim();
}
// ── Version (read from release's package.json) ────────────────────
let version = '3.0.0';
try {
const pkg = JSON.parse(fs.readFileSync('frontend/package.json', 'utf8'));
version = pkg.version ?? '3.0.0';
} catch {}
function bumpPatch(v) {
const m = String(v).match(/^(\d+)\.(\d+)\.(\d+)/);
if (!m) return v;
return `${m[1]}.${m[2]}.${Number(m[3]) + 1}`;
}
const nextVersion = bumpPatch(version);
console.log(`Version: v${version} -> next: v${nextVersion}`);
// ── Commit range + metadata (pull_request OR manual dispatch) ─────
let baseSha, headSha, sourceBranch, prTitle, prBody, prNumber;
if (context.eventName === 'pull_request') {
const pr = context.payload.pull_request;
baseSha = pr.base.sha; // release tip BEFORE the merge
headSha = pr.head.sha; // source branch tip (master)
sourceBranch = pr.head.ref;
prTitle = pr.title ?? '';
prBody = pr.body ?? '';
prNumber = pr.number;
} else {
headSha = git('rev-parse HEAD');
const inputBase = (context.payload.inputs && context.payload.inputs.base || '').trim();
baseSha = inputBase || git('rev-parse HEAD~50') || headSha;
sourceBranch = 'release';
prTitle = `Release v${version}`;
prBody = '';
prNumber = 'manual';
}
console.log(`Event: ${context.eventName} PR: #${prNumber} "${prTitle}"`);
console.log(`Range: ${baseSha.slice(0,7)}..${headSha.slice(0,7)} (${sourceBranch} → release)`);
const commits = git(
`log ${baseSha}..${headSha} --pretty=format:"%h||%s||%b||%an|||" --no-merges`
) || git(`log -50 --pretty=format:"%h||%s||%b||%an|||" --no-merges`);
const commitLines = git(
`log ${baseSha}..${headSha} --pretty=format:"%h %s" --no-merges`
) || git(`log -50 --pretty=format:"%h %s" --no-merges`);
const changedFiles = git(`diff --name-only ${baseSha}..${headSha}`)
.split('\n').filter(Boolean);
console.log(`Commits found: ${commitLines.split('\n').filter(Boolean).length}`);
console.log(`Files changed: ${changedFiles.length}`);
// ──────────────────────────────────────────────────────────────────
// PHASE 1 — Generate the CHANGELOG entry (in memory only).
// ──────────────────────────────────────────────────────────────────
const changelogPrompt = `You are generating a CHANGELOG entry for version ${version} of Velxio.
Velxio is a fully local, open-source Arduino/RP2040 emulator and circuit simulator.
PR TITLE: ${prTitle}
PR DESCRIPTION: ${prBody}
SOURCE BRANCH: ${sourceBranch}
GIT COMMITS (hash||subject||body||author):
${commits}
Generate a changelog entry using EXACTLY this format:
## [${version}] - ${today}
### Added
- New user-facing features
### Changed
- Changes in existing functionality
### Fixed
- Bug fixes
RULES:
1. Group commits by category (Added, Changed, Fixed, Performance, Removed, Security)
2. Write in past tense, user-facing language
3. Skip trivial commits (formatting, typos, version bumps)
4. Combine related commits into single entries
5. Remove commit hashes and author names
6. Only include sections that have real content
7. Output ONLY the changelog entry block, nothing else`;
console.log('Generating CHANGELOG entry...');
const changelogEntry = await callDeepSeek(changelogPrompt, 6000, 0.3);
if (!changelogEntry) {
core.setFailed('CHANGELOG generation returned empty content — aborting before any side effects');
return;
}
// ──────────────────────────────────────────────────────────────────
// PHASE 2 — Generate the Discord announcement (in memory only).
// ──────────────────────────────────────────────────────────────────
const announcementPrompt = `You are writing a Discord announcement for Velxio v${version}.
Velxio is a fully local, open-source Arduino/RP2040 emulator and circuit simulator that runs in the browser.
Website: https://velxio.dev/
GitHub: https://github.com/davidmonterocrespo24/velxio
CRITICAL: The announcement MUST be 1900 characters or less.
CHANGELOG for this version (use this as the source of truth):
${changelogEntry}
RECENT COMMITS (for additional context):
${commitLines}
STYLE EXAMPLES:
EXAMPLE 1:
v2.0.0 @everyone
Velxio just got a major upgrade. Here is what is new.
GROUND CHECK FOR LEDS
LEDs now require a proper cathode connection to GND. No more phantom lights without a complete circuit.
GENERIC OUTPUT COMPONENT PROTECTION
Any output component connected without a ground wire stays off. The simulator now enforces real circuit behavior for all components.
SSD1306 SPI MODE
The SSD1306 OLED display now supports both I2C and SPI. Switch protocols from the component property dialog.
Try it now: https://velxio.dev/
Full release details: https://github.com/davidmonterocrespo24/velxio/releases/tag/v2.0.0
EXAMPLE 2:
v2.0.1 @everyone
Quick fixes in this update:
Fixed LED staying on after simulation stops
Fixed wire color not persisting on reload
Fixed serial monitor scroll position resetting mid-output
Update your Docker image or open https://velxio.dev/
STYLE RULES:
- Start with version number and @everyone on the first line
- No emojis
- No markdown (no **, no ###)
- Use ALL CAPS for section headers when grouping multiple features
- Focus on USER BENEFITS, not implementation details
- Under 1900 characters
- End with link to GitHub release
- Write in English`;
console.log('Generating Discord announcement...');
let announcement = await callDeepSeek(announcementPrompt, 4000, 0.7);
// Limpiar markdown residual
announcement = announcement
.replace(/###/g, '')
.replace(/\*\*/g, '')
.replace(/__/g, '')
.trim();
// Truncar si excede el limite de Discord
if (announcement.length > 1900) {
const cut = announcement.lastIndexOf('\n', 1900);
announcement = announcement.slice(0, cut > 1200 ? cut : 1900).trim();
}
console.log('='.repeat(70));
console.log('DISCORD ANNOUNCEMENT:');
console.log('='.repeat(70));
console.log(announcement);
console.log(`\nCharacters: ${announcement.length}/1900`);
// Guard: never POST an empty message (Discord 400) and never commit
// a version bump for an announcement that did not go out.
if (!announcement) {
core.setFailed('Announcement generation returned empty content — NOT posting, NOT committing');
return;
}
// ──────────────────────────────────────────────────────────────────
// PHASE 3 — Send to Discord FIRST. No side effects before this.
// ──────────────────────────────────────────────────────────────────
const botToken = process.env.DISCORD_BOT_TOKEN;
const channelId = process.env.DISCORD_CHANNEL_ID;
if (!botToken || !channelId) {
core.setFailed('DISCORD_BOT_TOKEN or DISCORD_CHANNEL_ID secret not configured');
return;
}
const discordRes = await fetch(
`https://discord.com/api/v10/channels/${channelId}/messages`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bot ${botToken}`,
},
body: JSON.stringify({ content: announcement }),
}
);
if (!discordRes.ok) {
const err = await discordRes.text();
core.setFailed(`Discord ${discordRes.status}: ${err}`);
return;
}
console.log('='.repeat(70));
console.log(`Release v${version} announced on Discord`);
// ──────────────────────────────────────────────────────────────────
// PHASE 4 — Only NOW persist: write CHANGELOG + bump the version.
// A failed announce above returns early, so it never advances the
// version counter or writes a half-baked CHANGELOG.
// ──────────────────────────────────────────────────────────────────
const changelogPath = 'CHANGELOG.md';
let changelogContent;
if (!fs.existsSync(changelogPath)) {
changelogContent = '# Changelog\n\n'
+ 'All notable changes to Velxio will be documented in this file.\n'
+ 'The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).\n\n'
+ changelogEntry + '\n\n'
+ `[${version}]: https://github.com/davidmonterocrespo24/velxio/releases/tag/v${version}\n`;
} else {
const existing = fs.readFileSync(changelogPath, 'utf8');
const lines = existing.split('\n');
let insertIdx = lines.findIndex(l => l.startsWith('## ['));
if (insertIdx === -1) {
insertIdx = lines.findIndex((l, i) => i > 3 && l.trim() === '') + 1;
}
lines.splice(insertIdx, 0, changelogEntry, '');
const linkLine = `[${version}]: https://github.com/davidmonterocrespo24/velxio/releases/tag/v${version}`;
if (!existing.includes(linkLine)) lines.push(linkLine);
changelogContent = lines.join('\n');
}
fs.writeFileSync(changelogPath, changelogContent, 'utf8');
// Bump the PATCH so the NEXT release announces a fresh version.
try {
const pkgPath = 'frontend/package.json';
const pkgRaw = fs.readFileSync(pkgPath, 'utf8');
const bumped = pkgRaw.replace(/("version"\s*:\s*")[^"]+(")/, `$1${nextVersion}$2`);
fs.writeFileSync(pkgPath, bumped, 'utf8');
console.log(`Bumped frontend/package.json: ${version} -> ${nextVersion}`);
} catch (e) {
console.log('package.json bump failed:', e.message);
}
try {
execSync('git add CHANGELOG.md frontend/package.json', { stdio: 'inherit' });
execSync(
`git commit -m "chore: release v${version}, bump to v${nextVersion} [skip ci]"`,
{ stdio: 'inherit' }
);
execSync('git push origin release', { stdio: 'inherit' });
console.log(`CHANGELOG + version bump committed and pushed (next: v${nextVersion})`);
} catch (e) {
console.log('Nothing to commit or push failed:', e.message);
}
console.log(`PR #${prNumber} | Commits: ${commitLines.split('\n').filter(Boolean).length} | Files: ${changedFiles.length}`);