|
| 1 | +/** |
| 2 | + * Announces a GitHub release on Mastodon. |
| 3 | + * |
| 4 | + * The @compassmeet account lives on someone else's instance (mastodon.social by default), so this is a |
| 5 | + * plain authenticated REST call to their API — no ActivityPub implementation on our side. Federation to |
| 6 | + * the rest of the fediverse is the host instance's job. |
| 7 | + * |
| 8 | + * Reads the release JSON produced by `gh api` (see cd-mastodon.yml) and posts a condensed version of the |
| 9 | + * user-facing release notes. Run with DRY_RUN=true to print the post without sending it; a status cannot |
| 10 | + * be unsent from instances that have already received it, so prefer checking the output first. |
| 11 | + */ |
| 12 | + |
| 13 | +import {readFileSync} from 'node:fs' |
| 14 | + |
| 15 | +// Mastodon's default status limit. Instances may allow more, but assume the stock value. |
| 16 | +const MAX_CHARS = 500 |
| 17 | + |
| 18 | +// Mastodon counts every URL as this many characters regardless of its real length, so budget accordingly. |
| 19 | +const URL_WEIGHT = 23 |
| 20 | + |
| 21 | +// Release notes are a user-facing summary followed by a technical section separated by this marker (see |
| 22 | +// CHANGELOG.md and web/pages/news.tsx). Only the summary is worth announcing. |
| 23 | +const TECHNICAL_SECTION_MARKER = '<!--tech-->' |
| 24 | + |
| 25 | +const NEWS_URL = 'https://compassmeet.com/news' |
| 26 | +const HASHTAGS = '#Compass #OpenSource' |
| 27 | + |
| 28 | +const {MASTODON_ACCESS_TOKEN, MASTODON_INSTANCE, RELEASE_JSON_PATH, DRY_RUN} = process.env |
| 29 | + |
| 30 | +// An unset repository variable arrives as an empty string, which a destructuring default would not catch. |
| 31 | +const instance = MASTODON_INSTANCE || 'https://mastodon.social' |
| 32 | +const isDryRun = DRY_RUN === 'true' |
| 33 | + |
| 34 | +/** Length as Mastodon counts it: every URL is a fixed 23 characters. */ |
| 35 | +function weightedLength(text) { |
| 36 | + return [...text.replace(/https?:\/\/\S+/g, '#'.repeat(URL_WEIGHT))].length |
| 37 | +} |
| 38 | + |
| 39 | +/** Markdown -> plain text. Mastodon renders the `status` param as plain text, so markup would show raw. */ |
| 40 | +function stripMarkdown(text) { |
| 41 | + return text |
| 42 | + .replace(/!\[[^\]]*\]\([^)]*\)/g, '') // images |
| 43 | + .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') // links -> label |
| 44 | + .replace(/`([^`]+)`/g, '$1') |
| 45 | + .replace(/(\*\*|__)(.*?)\1/g, '$2') |
| 46 | + .replace(/(^|\s)([*_])(?=\S)(.*?)\S\2(?=\s|$)/g, '$1$3') |
| 47 | + .replace(/<[^>]+>/g, '') |
| 48 | + .replace(/\s+/g, ' ') |
| 49 | + .trim() |
| 50 | +} |
| 51 | + |
| 52 | +/** The bullet lines of the user-facing summary, best first (release notes lead with new features). */ |
| 53 | +function extractHighlights(body) { |
| 54 | + const summary = body.split(TECHNICAL_SECTION_MARKER)[0] |
| 55 | + |
| 56 | + const highlights = summary |
| 57 | + .split('\n') |
| 58 | + .filter((line) => /^[-*]\s+/.test(line)) // top-level bullets only; indented ones are sub-details |
| 59 | + // A trailing colon means the detail lives in indented sub-bullets we are dropping, so drop it too. |
| 60 | + .map((line) => stripMarkdown(line.replace(/^[-*]\s+/, '')).replace(/:$/, '')) |
| 61 | + .filter(Boolean) |
| 62 | + |
| 63 | + if (highlights.length) return highlights |
| 64 | + |
| 65 | + // Releases without bullets (rare) fall back to the first prose paragraph. |
| 66 | + return summary |
| 67 | + .split('\n\n') |
| 68 | + .map((paragraph) => stripMarkdown(paragraph)) |
| 69 | + .filter((paragraph) => paragraph && !paragraph.startsWith('#')) |
| 70 | + .slice(0, 1) |
| 71 | +} |
| 72 | + |
| 73 | +function buildStatus(release) { |
| 74 | + const version = release.name?.trim() || release.tag_name |
| 75 | + const title = /compass/i.test(version) ? version : `Compass ${version}` |
| 76 | + const header = `🧭 ${title} is out!` |
| 77 | + const footer = `\n\nFull release notes: ${NEWS_URL}\n\n${HASHTAGS}` |
| 78 | + |
| 79 | + const highlights = extractHighlights(release.body || '') |
| 80 | + |
| 81 | + const render = (lines, hasMore) => |
| 82 | + header + |
| 83 | + (lines.length ? `\n\n${lines.join('\n')}` : '') + |
| 84 | + (hasMore ? '\n…and more.' : '') + |
| 85 | + footer |
| 86 | + |
| 87 | + const lines = [] |
| 88 | + for (const highlight of highlights) { |
| 89 | + const candidate = [...lines, `• ${highlight}`] |
| 90 | + // Reserve room for the "…and more." line unless this highlight is the last one anyway. |
| 91 | + const fitsEverything = candidate.length === highlights.length |
| 92 | + if (weightedLength(render(candidate, !fitsEverything)) > MAX_CHARS) break |
| 93 | + lines.push(`• ${highlight}`) |
| 94 | + } |
| 95 | + |
| 96 | + return render(lines, lines.length < highlights.length) |
| 97 | +} |
| 98 | + |
| 99 | +async function main() { |
| 100 | + if (!RELEASE_JSON_PATH) throw new Error('RELEASE_JSON_PATH is not set') |
| 101 | + |
| 102 | + const release = JSON.parse(readFileSync(RELEASE_JSON_PATH, 'utf8')) |
| 103 | + const status = buildStatus(release) |
| 104 | + |
| 105 | + console.log(`--- status (${weightedLength(status)}/${MAX_CHARS} chars) ---`) |
| 106 | + console.log(status) |
| 107 | + console.log('---') |
| 108 | + |
| 109 | + if (isDryRun) { |
| 110 | + console.log('DRY_RUN=true, not posting.') |
| 111 | + return |
| 112 | + } |
| 113 | + |
| 114 | + // Mirrors sendDiscordMessage: a missing credential is a no-op, not a failure, so forks and unconfigured |
| 115 | + // environments do not fail the workflow. |
| 116 | + if (!MASTODON_ACCESS_TOKEN) { |
| 117 | + console.log('MASTODON_ACCESS_TOKEN is not set, skipping.') |
| 118 | + return |
| 119 | + } |
| 120 | + |
| 121 | + const response = await fetch(`${instance}/api/v1/statuses`, { |
| 122 | + method: 'POST', |
| 123 | + headers: { |
| 124 | + Authorization: `Bearer ${MASTODON_ACCESS_TOKEN}`, |
| 125 | + 'Content-Type': 'application/json', |
| 126 | + // Makes a retried run (or a re-dispatched workflow) reuse the existing status instead of duplicating. |
| 127 | + 'Idempotency-Key': `compass-release-${release.tag_name}`, |
| 128 | + }, |
| 129 | + body: JSON.stringify({ |
| 130 | + status, |
| 131 | + visibility: 'public', |
| 132 | + language: 'en', // release notes are English-only, same as /news |
| 133 | + }), |
| 134 | + }) |
| 135 | + |
| 136 | + if (!response.ok) { |
| 137 | + throw new Error(`Mastodon API ${response.status}: ${await response.text()}`) |
| 138 | + } |
| 139 | + |
| 140 | + const {url} = await response.json() |
| 141 | + console.log(`Posted: ${url}`) |
| 142 | +} |
| 143 | + |
| 144 | +await main() |
0 commit comments