mirror of
https://github.com/vercel/workflow.git
synced 2026-09-14 19:59:43 +08:00
339 lines
9.9 KiB
JavaScript
339 lines
9.9 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Generate and/or post a Slack message for the latest Workflow release.
|
|
*
|
|
* It reuses the existing release-note generator (`scripts/generate-release-notes.mjs`)
|
|
* and converts its Markdown-ish body into Slack mrkdwn blocks.
|
|
*
|
|
* Usage:
|
|
* # Print the Slack API payload JSON to stdout (no network call)
|
|
* node scripts/generate-release-slack-payload.mjs --print
|
|
*
|
|
* # Post to Slack via Web API (requires env vars below)
|
|
* node scripts/generate-release-slack-payload.mjs --post
|
|
*
|
|
* Environment variables:
|
|
* PUBLISHED_PACKAGES - forwarded to scripts/generate-release-notes.mjs so the
|
|
* payload matches the exact set of packages published.
|
|
*
|
|
* SLACK_BOT_TOKEN - Slack Bot User OAuth Token (starts with "xoxb-..."),
|
|
* used to call `chat.postMessage`
|
|
* SLACK_RELEASE_CHANNEL_ID - Slack Channel ID (e.g. "C0123456789")
|
|
*
|
|
* Example output (when run with `--print`):
|
|
*
|
|
* {
|
|
* "channel": "C0123456789",
|
|
* "text": "New release: workflow@4.0.1-beta.46 (workflow@4.0.1-beta.46)",
|
|
* "blocks": [
|
|
* {
|
|
* "type": "header",
|
|
* "text": { "type": "plain_text", "text": "New release: workflow@4.0.1-beta.46", "emoji": true }
|
|
* },
|
|
* {
|
|
* "type": "section",
|
|
* "text": { "type": "mrkdwn", "text": "*Release:* <https://github.com/vercel/workflow/releases/tag/workflow%404.0.1-beta.46|workflow@4.0.1-beta.46>" }
|
|
* },
|
|
* {
|
|
* "type": "section",
|
|
* "text": { "type": "mrkdwn", "text": "*core@4.0.1-beta.46*\n- [#123](https://github.com/vercel/workflow/pull/123) [`abc1234`](https://github.com/vercel/workflow/commit/abc1234) @someone - Fix thing" }
|
|
* },
|
|
* {
|
|
* "type": "section",
|
|
* "text": { "type": "mrkdwn", "text": "*cli@4.0.1-beta.46*\n- [#124](https://github.com/vercel/workflow/pull/124) [`def5678`](https://github.com/vercel/workflow/commit/def5678) @someoneelse - Improve other thing" }
|
|
* }
|
|
* ]
|
|
* }
|
|
*/
|
|
|
|
import { spawnSync } from 'node:child_process';
|
|
|
|
const GITHUB_REPO = 'vercel/workflow';
|
|
|
|
const SLACK_SECTION_TEXT_LIMIT = 2900; // Slack section text limit is 3000 chars; keep buffer.
|
|
const SLACK_BLOCK_LIMIT = 50;
|
|
const SLACK_API_URL = 'https://slack.com/api/chat.postMessage';
|
|
|
|
function runReleaseNotes() {
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
['scripts/generate-release-notes.mjs'],
|
|
{
|
|
encoding: 'utf-8',
|
|
env: process.env,
|
|
}
|
|
);
|
|
|
|
if (result.error) throw result.error;
|
|
if (result.status !== 0) {
|
|
throw new Error(
|
|
`generate-release-notes.mjs failed (exit ${result.status}):\n${result.stderr || ''}`.trim()
|
|
);
|
|
}
|
|
|
|
const stdout = (result.stdout || '').trim();
|
|
if (!stdout) {
|
|
throw new Error('generate-release-notes.mjs produced no output');
|
|
}
|
|
|
|
return JSON.parse(stdout);
|
|
}
|
|
|
|
function chunkByLines(text, limit) {
|
|
const lines = text.split('\n');
|
|
const chunks = [];
|
|
let current = '';
|
|
|
|
for (const line of lines) {
|
|
const next = current ? `${current}\n${line}` : line;
|
|
|
|
if (next.length <= limit) {
|
|
current = next;
|
|
continue;
|
|
}
|
|
|
|
if (current) chunks.push(current);
|
|
// If a single line is too long, hard-split it.
|
|
if (line.length > limit) {
|
|
for (let i = 0; i < line.length; i += limit) {
|
|
chunks.push(line.slice(i, i + limit));
|
|
}
|
|
current = '';
|
|
continue;
|
|
}
|
|
|
|
current = line;
|
|
}
|
|
|
|
if (current) chunks.push(current);
|
|
return chunks.filter((c) => c.trim().length > 0);
|
|
}
|
|
|
|
function parsePackageHeader(line) {
|
|
const headerMatch = line.match(/^##\s+(.+)$/);
|
|
if (!headerMatch) return null;
|
|
const header = headerMatch[1].trim();
|
|
// Strip version suffix from the header: "@workflow/core@1.2.3" -> "@workflow/core"
|
|
return header.split('@').slice(0, -1).join('@') || header;
|
|
}
|
|
|
|
function parseReleaseItem(item) {
|
|
const prMatch = item.match(/\[#(\d+)\]\([^)]+\)/);
|
|
const prNumber = prMatch?.[1] ? Number(prMatch[1]) : null;
|
|
|
|
const authorMatch = item.match(/@([A-Za-z0-9-]+)/);
|
|
const author = authorMatch?.[1] ? authorMatch[1] : null;
|
|
|
|
// Summary is whatever comes after " - " (release-notes generator ensures this shape)
|
|
const summary = item.includes(' - ')
|
|
? item.split(' - ').slice(1).join(' - ').trim()
|
|
: item;
|
|
|
|
return { prNumber, author, summary };
|
|
}
|
|
|
|
function toPkgPrefix(pkgName) {
|
|
if (!pkgName) return '@workflow';
|
|
return pkgName;
|
|
}
|
|
|
|
function parseReleaseBodyToLines(body) {
|
|
const text = (body || '').trim();
|
|
if (!text) return [];
|
|
|
|
const lines = text.split('\n');
|
|
let currentPkg = null;
|
|
|
|
// Collect all changes keyed by (prNumber || summary) so we can merge duplicates.
|
|
// Map key -> { packages: Set<string>, prNumber, author, summary }
|
|
const changeMap = new Map();
|
|
|
|
for (const raw of lines) {
|
|
const line = raw.trim();
|
|
if (!line) continue;
|
|
|
|
const pkgHeader = parsePackageHeader(line);
|
|
if (pkgHeader) {
|
|
currentPkg = pkgHeader;
|
|
continue;
|
|
}
|
|
|
|
if (!line.startsWith('- ')) continue;
|
|
const item = line.slice(2).trim();
|
|
const { prNumber, author, summary } = parseReleaseItem(item);
|
|
|
|
// Use PR number as the unique key if available, otherwise fall back to summary text.
|
|
const key = prNumber != null ? `pr:${prNumber}` : `summary:${summary}`;
|
|
const pkgName = toPkgPrefix(currentPkg);
|
|
|
|
if (changeMap.has(key)) {
|
|
// Merge: add this package to the existing entry.
|
|
changeMap.get(key).packages.add(pkgName);
|
|
} else {
|
|
changeMap.set(key, {
|
|
packages: new Set([pkgName]),
|
|
prNumber,
|
|
author,
|
|
summary,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Build output lines from the deduplicated map.
|
|
const out = [];
|
|
for (const { packages, prNumber, author, summary } of changeMap.values()) {
|
|
// Sort packages alphabetically for consistent output.
|
|
const pkgPrefix = [...packages].sort().join(', ');
|
|
const prLink =
|
|
prNumber != null
|
|
? `<https://github.com/${GITHUB_REPO}/pull/${prNumber}|#${prNumber}>`
|
|
: null;
|
|
const authorText = author ? `by \`${author}\`` : null;
|
|
|
|
// Example:
|
|
// - @workflow/core, @workflow/cli <...|#754> by `SomeUser` - summary of change
|
|
const parts = [pkgPrefix, prLink, authorText, '-', summary].filter(Boolean);
|
|
out.push(`- ${parts.join(' ')}`.replace(/\s+-\s+/, ' - '));
|
|
}
|
|
|
|
// Fallback: if we couldn't parse anything (e.g. "dependency updates"), just return the raw text.
|
|
if (out.length === 0) {
|
|
return chunkByLines(text, SLACK_SECTION_TEXT_LIMIT).map((c) => `- ${c}`);
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
function buildSlackPayload({ tag, title, body }) {
|
|
const releaseUrl = `https://github.com/${GITHUB_REPO}/releases/tag/${encodeURIComponent(tag)}`;
|
|
const pkgLines = parseReleaseBodyToLines(body);
|
|
|
|
// Slack "header" blocks only support plain_text, so use a section for link formatting.
|
|
const blocks = [
|
|
{
|
|
type: 'section',
|
|
text: {
|
|
type: 'mrkdwn',
|
|
text: `:rocket: New release: <${releaseUrl}|${title}>`,
|
|
},
|
|
},
|
|
{ type: 'divider' },
|
|
];
|
|
|
|
if (pkgLines.length > 0) {
|
|
const chunks = chunkByLines(pkgLines.join('\n'), SLACK_SECTION_TEXT_LIMIT);
|
|
|
|
for (const chunk of chunks) {
|
|
if (blocks.length >= SLACK_BLOCK_LIMIT - 2) break;
|
|
blocks.push({ type: 'section', text: { type: 'mrkdwn', text: chunk } });
|
|
}
|
|
|
|
if (chunks.length > 0 && blocks.length >= SLACK_BLOCK_LIMIT - 2) {
|
|
blocks.push({
|
|
type: 'section',
|
|
text: {
|
|
type: 'mrkdwn',
|
|
text: `_(truncated — see full notes: <${releaseUrl}|${tag}>)_`,
|
|
},
|
|
});
|
|
}
|
|
} else {
|
|
blocks.push({
|
|
type: 'section',
|
|
text: {
|
|
type: 'mrkdwn',
|
|
text: `No release notes were generated. See: <${releaseUrl}|${tag}>`,
|
|
},
|
|
});
|
|
}
|
|
|
|
return { text: `New release: ${title} (${tag})`, blocks };
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const args = new Set(argv.slice(2));
|
|
const wantsPost = args.has('--post');
|
|
const wantsPrint = args.has('--print') || (!wantsPost && !args.has('--help'));
|
|
const wantsHelp = args.has('--help') || args.has('-h');
|
|
|
|
return { wantsPost, wantsPrint, wantsHelp };
|
|
}
|
|
|
|
function requireEnv(name) {
|
|
const value = process.env[name];
|
|
if (!value) throw new Error(`Missing required environment variable: ${name}`);
|
|
return value;
|
|
}
|
|
|
|
async function postToSlack({ token, message }) {
|
|
const res = await fetch(SLACK_API_URL, {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
'Content-Type': 'application/json; charset=utf-8',
|
|
},
|
|
body: JSON.stringify(message),
|
|
});
|
|
|
|
const json = await res.json().catch(() => null);
|
|
|
|
if (!res.ok) {
|
|
throw new Error(`Slack API HTTP error: ${res.status} ${res.statusText}`);
|
|
}
|
|
if (!json || json.ok !== true) {
|
|
const err = json?.error ? `: ${json.error}` : '';
|
|
throw new Error(`Slack API chat.postMessage failed${err}`);
|
|
}
|
|
|
|
return json;
|
|
}
|
|
|
|
async function main() {
|
|
const { wantsHelp, wantsPost, wantsPrint } = parseArgs(process.argv);
|
|
|
|
if (wantsHelp) {
|
|
// Keep stdout help minimal to avoid polluting CI logs.
|
|
console.log(
|
|
[
|
|
'Usage:',
|
|
' node scripts/generate-release-slack-payload.mjs --print',
|
|
' node scripts/generate-release-slack-payload.mjs --post',
|
|
'',
|
|
'Env (for --post): SLACK_BOT_TOKEN, SLACK_RELEASE_CHANNEL_ID',
|
|
].join('\n')
|
|
);
|
|
return;
|
|
}
|
|
|
|
const releaseNotes = runReleaseNotes();
|
|
const content = buildSlackPayload(releaseNotes);
|
|
|
|
const channel = process.env.SLACK_RELEASE_CHANNEL_ID;
|
|
const message = { channel, ...content };
|
|
|
|
if (wantsPrint) {
|
|
console.log(JSON.stringify(message));
|
|
}
|
|
|
|
if (wantsPost) {
|
|
const token = requireEnv('SLACK_BOT_TOKEN');
|
|
const realChannel = requireEnv('SLACK_RELEASE_CHANNEL_ID');
|
|
const result = await postToSlack({
|
|
token,
|
|
message: { ...message, channel: realChannel },
|
|
});
|
|
|
|
// Print a tiny confirmation that won't leak secrets.
|
|
console.log(
|
|
JSON.stringify({
|
|
ok: true,
|
|
channel: result.channel,
|
|
ts: result.ts,
|
|
message: 'posted',
|
|
})
|
|
);
|
|
}
|
|
}
|
|
|
|
await main();
|