chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
#!/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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user