Skip to content

Commit efda041

Browse files
committed
Add GitHub Actions workflow to announce releases on Mastodon
1 parent 3f2cfeb commit efda041

2 files changed

Lines changed: 202 additions & 0 deletions

File tree

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
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()

‎.github/workflows/cd-mastodon.yml‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
name: Announce Release on Mastodon
2+
3+
on:
4+
release:
5+
types: [ published ]
6+
workflow_dispatch:
7+
inputs:
8+
tag:
9+
description: 'Release tag to announce (defaults to the latest release)'
10+
required: false
11+
dry_run:
12+
description: 'Print the post without sending it'
13+
type: boolean
14+
default: false
15+
16+
jobs:
17+
announce:
18+
name: Announce
19+
runs-on: ubuntu-latest
20+
timeout-minutes: 5
21+
22+
steps:
23+
- uses: actions/checkout@v4
24+
25+
- uses: actions/setup-node@v4
26+
with:
27+
node-version: '22'
28+
29+
# Fetched rather than read from github.event so that workflow_dispatch and the release event take the
30+
# same path, and so release bodies (backticks, quotes, newlines) never pass through shell expansion.
31+
- name: Resolve release
32+
id: release
33+
env:
34+
GH_TOKEN: ${{ github.token }}
35+
TAG: ${{ github.event.release.tag_name || inputs.tag }}
36+
run: |
37+
if [ -n "$TAG" ]; then
38+
gh api "repos/${{ github.repository }}/releases/tags/$TAG" > "$RUNNER_TEMP/release.json"
39+
else
40+
gh api "repos/${{ github.repository }}/releases/latest" > "$RUNNER_TEMP/release.json"
41+
fi
42+
43+
# Prereleases and drafts are not user-facing news, so they are not announced.
44+
if [ "$(jq -r '.prerelease or .draft' "$RUNNER_TEMP/release.json")" = "true" ]; then
45+
echo "skip=true" >> "$GITHUB_OUTPUT"
46+
echo "Skipping: release is a draft or prerelease."
47+
else
48+
echo "skip=false" >> "$GITHUB_OUTPUT"
49+
fi
50+
51+
- name: Post to Mastodon
52+
if: steps.release.outputs.skip != 'true'
53+
env:
54+
MASTODON_ACCESS_TOKEN: ${{ secrets.MASTODON_ACCESS_TOKEN }}
55+
MASTODON_INSTANCE: ${{ vars.MASTODON_INSTANCE }}
56+
RELEASE_JSON_PATH: ${{ runner.temp }}/release.json
57+
DRY_RUN: ${{ inputs.dry_run }}
58+
run: node .github/scripts/post-release-to-mastodon.mjs

0 commit comments

Comments
 (0)