#!/usr/bin/env node // Posts a single release-announcement tweet for @leanctx. // // Triggered from .github/workflows/release.yml after a stable release is // created. Dependency-free (Node built-ins only) so the workflow needs no // `npm install`. Auth is Twitter/X API v2 + OAuth 1.0a user context. // // Required env: // TWITTER_CONSUMER_KEY, TWITTER_CONSUMER_SECRET, // TWITTER_ACCESS_TOKEN, TWITTER_ACCESS_SECRET // RELEASE_TAG e.g. "v3.6.26" // REPO e.g. "yvgude/lean-ctx" // Optional env: // DRY_RUN=1 compose + print the tweet, do not post // CHANGELOG path to changelog (default: CHANGELOG.md) import crypto from "node:crypto"; import https from "node:https"; import { readFileSync } from "node:fs"; const MAX_TWEET = 280; function requireEnv(name) { const v = process.env[name]; if (!v) { console.error(`Missing required env: ${name}`); process.exit(1); } return v; } /** * Pull a concise, human highlight for `version` from the changelog. * Prefers the section's blockquote summary (the "EPIC โ€ฆ" one-liner); falls * back to a factual count of Added/Fixed/Changed/Security entries. * Returns "" when nothing meaningful is available (caller posts version+link). */ function extractHighlight(changelog, version) { const lines = changelog.split(/\r?\n/); // Match `## []` literally โ€” `version` is data, never compiled into a // RegExp, so there is no escaping to get wrong and no regex-injection surface. const needle = `[${version}]`; const start = lines.findIndex( (l) => l.startsWith("##") && l.slice(2).trimStart().startsWith(needle), ); if (start === -1) return ""; const section = []; for (let i = start + 1; i < lines.length; i++) { if (/^##\s*\[/.test(lines[i])) break; section.push(lines[i]); } // Join the section's leading blockquote (the "EPIC โ€ฆ" summary) into one line. const quoteLines = []; for (const l of section) { if (/^\s*>/.test(l)) { quoteLines.push(l.replace(/^\s*>\s?/, "")); } else if (quoteLines.length) { break; // end of the contiguous blockquote block } else if (l.trim() === "") { continue; // skip blank lines before the quote } else { break; // section starts with real content -> no summary quote } } if (quoteLines.length) { const clean = quoteLines.join(" ").replace(/\*\*/g, "").replace(/\s+/g, " ").trim(); if (clean) return clean; } const counts = {}; let cat = null; for (const l of section) { const h = l.match(/^###\s+(\w+)/); if (h) { cat = h[1]; continue; } if (cat && /^\s*-\s+/.test(l)) counts[cat] = (counts[cat] || 0) + 1; } const order = ["Added", "Fixed", "Changed", "Security"]; const labels = { Added: ["new feature", "new features"], Fixed: ["fix", "fixes"], Changed: ["change", "changes"], Security: ["security fix", "security fixes"], }; const parts = order .filter((c) => counts[c]) .map((c) => `${counts[c]} ${labels[c][counts[c] === 1 ? 0 : 1]}`); return parts.length ? parts.join(" ยท ") : ""; } function composeTweet(tag, repo, highlight) { const url = `https://github.com/${repo}/releases/tag/${tag}`; const header = `๐Ÿš€ lean-ctx ${tag} is out`; // Twitter counts every URL as 23 chars (t.co), so reserve that, not url.length. const urlCost = 23; let body = header; if (highlight) { const budget = MAX_TWEET - header.length - urlCost - 4; // 2ร—"\n\n" let h = highlight; if (h.length > budget) h = h.slice(0, Math.max(0, budget - 1)).trimEnd() + "โ€ฆ"; if (h) body += `\n\n${h}`; } return `${body}\n\n${url}`; } function postTweet(text) { const CK = requireEnv("TWITTER_CONSUMER_KEY"); const CS = requireEnv("TWITTER_CONSUMER_SECRET"); const AT = requireEnv("TWITTER_ACCESS_TOKEN"); const AS = requireEnv("TWITTER_ACCESS_SECRET"); const endpoint = "https://api.twitter.com/2/tweets"; const pct = (s) => encodeURIComponent(s).replace(/[!'()*]/g, (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase()); const oauth = { oauth_consumer_key: CK, oauth_nonce: crypto.randomBytes(16).toString("hex"), oauth_signature_method: "HMAC-SHA1", oauth_timestamp: Math.floor(Date.now() / 1000).toString(), oauth_token: AT, oauth_version: "1.0", }; const paramStr = Object.keys(oauth) .sort() .map((k) => `${pct(k)}=${pct(oauth[k])}`) .join("&"); const baseStr = `POST&${pct(endpoint)}&${pct(paramStr)}`; const signingKey = `${pct(CS)}&${pct(AS)}`; const signature = crypto.createHmac("sha1", signingKey).update(baseStr).digest("base64"); const authHeader = "OAuth " + Object.entries({ ...oauth, oauth_signature: signature }) .map(([k, v]) => `${k}="${pct(v)}"`) .join(", "); const payload = JSON.stringify({ text }); return new Promise((resolve, reject) => { const req = https.request( endpoint, { method: "POST", headers: { Authorization: authHeader, "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload), }, }, (res) => { let data = ""; res.on("data", (c) => (data += c)); res.on("end", () => resolve({ status: res.statusCode, body: data })); }, ); req.on("error", reject); req.write(payload); req.end(); }); } async function main() { const tag = requireEnv("RELEASE_TAG"); const repo = requireEnv("REPO"); const version = tag.replace(/^v/, ""); const changelogPath = process.env.CHANGELOG || "CHANGELOG.md"; let highlight = ""; try { highlight = extractHighlight(readFileSync(changelogPath, "utf8"), version); } catch (e) { console.warn(`Could not read ${changelogPath}: ${e.message} โ€” posting version + link only`); } const tweet = composeTweet(tag, repo, highlight); // Twitter weights every URL as 23 chars (t.co), regardless of real length. const weighted = tweet.replace(/https?:\/\/\S+/g, "x".repeat(23)).length; console.log(`Tweet (${weighted}/${MAX_TWEET} weighted chars):\n---\n${tweet}\n---`); if (process.env.DRY_RUN === "1" || process.argv.includes("--dry-run")) { console.log("DRY_RUN โ€” not posting."); return; } const res = await postTweet(tweet); if (res.status === 403 && res.body.includes("duplicate content")) { console.log("Tweet already posted (duplicate) โ€” treating as success."); return; } if (res.status !== 201) { console.error(`Twitter API error ${res.status}: ${res.body}`); process.exit(1); } let id = ""; try { id = JSON.parse(res.body).data.id; } catch { /* keep id empty */ } console.log(`Posted: https://x.com/leanctx/status/${id}`); } main().catch((e) => { console.error("Fatal:", e); process.exit(1); });