Files
wehub-resource-sync 26382a7ac6
CI / Clippy (push) Failing after 15m13s
CI / Test (ubuntu-latest) (push) Failing after 16m1s
CI / Test (macos-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Build (no embeddings / no ORT) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Cookbook (Node) (push) Has been cancelled
CI / Pi Extension (Node) (push) Has been cancelled
CI / Rust SDK (lean-ctx-client) (push) Has been cancelled
CI / Embed SDK (lean-ctx-sdk) (push) Has been cancelled
CI / Python SDK (leanctx) (push) Has been cancelled
CI / Hermes Plugin (Python) (push) Has been cancelled
CI / SDK Conformance Matrix (push) Has been cancelled
CI / Coverage (push) Has been cancelled
CI / cargo-deny (push) Has been cancelled
CI / Adversarial Safety (push) Has been cancelled
CI / Benchmarks (push) Has been cancelled
CI / Output-Quality Gate (eval A/B) (push) Has been cancelled
CI / Documentation (push) Has been cancelled
CI / CI Green (push) Has been cancelled
JetBrains Plugin / Actionlint (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (rust) (push) Has been cancelled
JetBrains Plugin / Validation (push) Has been cancelled
JetBrains Plugin / Build (push) Has been cancelled
JetBrains Plugin / Test (push) Has been cancelled
Security Check / Security Scan (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:30 +08:00

212 lines
6.7 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 `## [<version>]` 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);
});