mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-08-03 03:44:22 -04:00
Add GitHub Actions workflow to announce releases on Mastodon
This commit is contained in:
144
.github/scripts/post-release-to-mastodon.mjs
vendored
Normal file
144
.github/scripts/post-release-to-mastodon.mjs
vendored
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Announces a GitHub release on Mastodon.
|
||||
*
|
||||
* The @compassmeet account lives on someone else's instance (mastodon.social by default), so this is a
|
||||
* plain authenticated REST call to their API — no ActivityPub implementation on our side. Federation to
|
||||
* the rest of the fediverse is the host instance's job.
|
||||
*
|
||||
* Reads the release JSON produced by `gh api` (see cd-mastodon.yml) and posts a condensed version of the
|
||||
* user-facing release notes. Run with DRY_RUN=true to print the post without sending it; a status cannot
|
||||
* be unsent from instances that have already received it, so prefer checking the output first.
|
||||
*/
|
||||
|
||||
import {readFileSync} from 'node:fs'
|
||||
|
||||
// Mastodon's default status limit. Instances may allow more, but assume the stock value.
|
||||
const MAX_CHARS = 500
|
||||
|
||||
// Mastodon counts every URL as this many characters regardless of its real length, so budget accordingly.
|
||||
const URL_WEIGHT = 23
|
||||
|
||||
// Release notes are a user-facing summary followed by a technical section separated by this marker (see
|
||||
// CHANGELOG.md and web/pages/news.tsx). Only the summary is worth announcing.
|
||||
const TECHNICAL_SECTION_MARKER = '<!--tech-->'
|
||||
|
||||
const NEWS_URL = 'https://compassmeet.com/news'
|
||||
const HASHTAGS = '#Compass #OpenSource'
|
||||
|
||||
const {MASTODON_ACCESS_TOKEN, MASTODON_INSTANCE, RELEASE_JSON_PATH, DRY_RUN} = process.env
|
||||
|
||||
// An unset repository variable arrives as an empty string, which a destructuring default would not catch.
|
||||
const instance = MASTODON_INSTANCE || 'https://mastodon.social'
|
||||
const isDryRun = DRY_RUN === 'true'
|
||||
|
||||
/** Length as Mastodon counts it: every URL is a fixed 23 characters. */
|
||||
function weightedLength(text) {
|
||||
return [...text.replace(/https?:\/\/\S+/g, '#'.repeat(URL_WEIGHT))].length
|
||||
}
|
||||
|
||||
/** Markdown -> plain text. Mastodon renders the `status` param as plain text, so markup would show raw. */
|
||||
function stripMarkdown(text) {
|
||||
return text
|
||||
.replace(/!\[[^\]]*\]\([^)]*\)/g, '') // images
|
||||
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') // links -> label
|
||||
.replace(/`([^`]+)`/g, '$1')
|
||||
.replace(/(\*\*|__)(.*?)\1/g, '$2')
|
||||
.replace(/(^|\s)([*_])(?=\S)(.*?)\S\2(?=\s|$)/g, '$1$3')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/** The bullet lines of the user-facing summary, best first (release notes lead with new features). */
|
||||
function extractHighlights(body) {
|
||||
const summary = body.split(TECHNICAL_SECTION_MARKER)[0]
|
||||
|
||||
const highlights = summary
|
||||
.split('\n')
|
||||
.filter((line) => /^[-*]\s+/.test(line)) // top-level bullets only; indented ones are sub-details
|
||||
// A trailing colon means the detail lives in indented sub-bullets we are dropping, so drop it too.
|
||||
.map((line) => stripMarkdown(line.replace(/^[-*]\s+/, '')).replace(/:$/, ''))
|
||||
.filter(Boolean)
|
||||
|
||||
if (highlights.length) return highlights
|
||||
|
||||
// Releases without bullets (rare) fall back to the first prose paragraph.
|
||||
return summary
|
||||
.split('\n\n')
|
||||
.map((paragraph) => stripMarkdown(paragraph))
|
||||
.filter((paragraph) => paragraph && !paragraph.startsWith('#'))
|
||||
.slice(0, 1)
|
||||
}
|
||||
|
||||
function buildStatus(release) {
|
||||
const version = release.name?.trim() || release.tag_name
|
||||
const title = /compass/i.test(version) ? version : `Compass ${version}`
|
||||
const header = `🧭 ${title} is out!`
|
||||
const footer = `\n\nFull release notes: ${NEWS_URL}\n\n${HASHTAGS}`
|
||||
|
||||
const highlights = extractHighlights(release.body || '')
|
||||
|
||||
const render = (lines, hasMore) =>
|
||||
header +
|
||||
(lines.length ? `\n\n${lines.join('\n')}` : '') +
|
||||
(hasMore ? '\n…and more.' : '') +
|
||||
footer
|
||||
|
||||
const lines = []
|
||||
for (const highlight of highlights) {
|
||||
const candidate = [...lines, `• ${highlight}`]
|
||||
// Reserve room for the "…and more." line unless this highlight is the last one anyway.
|
||||
const fitsEverything = candidate.length === highlights.length
|
||||
if (weightedLength(render(candidate, !fitsEverything)) > MAX_CHARS) break
|
||||
lines.push(`• ${highlight}`)
|
||||
}
|
||||
|
||||
return render(lines, lines.length < highlights.length)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!RELEASE_JSON_PATH) throw new Error('RELEASE_JSON_PATH is not set')
|
||||
|
||||
const release = JSON.parse(readFileSync(RELEASE_JSON_PATH, 'utf8'))
|
||||
const status = buildStatus(release)
|
||||
|
||||
console.log(`--- status (${weightedLength(status)}/${MAX_CHARS} chars) ---`)
|
||||
console.log(status)
|
||||
console.log('---')
|
||||
|
||||
if (isDryRun) {
|
||||
console.log('DRY_RUN=true, not posting.')
|
||||
return
|
||||
}
|
||||
|
||||
// Mirrors sendDiscordMessage: a missing credential is a no-op, not a failure, so forks and unconfigured
|
||||
// environments do not fail the workflow.
|
||||
if (!MASTODON_ACCESS_TOKEN) {
|
||||
console.log('MASTODON_ACCESS_TOKEN is not set, skipping.')
|
||||
return
|
||||
}
|
||||
|
||||
const response = await fetch(`${instance}/api/v1/statuses`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${MASTODON_ACCESS_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
// Makes a retried run (or a re-dispatched workflow) reuse the existing status instead of duplicating.
|
||||
'Idempotency-Key': `compass-release-${release.tag_name}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
status,
|
||||
visibility: 'public',
|
||||
language: 'en', // release notes are English-only, same as /news
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Mastodon API ${response.status}: ${await response.text()}`)
|
||||
}
|
||||
|
||||
const {url} = await response.json()
|
||||
console.log(`Posted: ${url}`)
|
||||
}
|
||||
|
||||
await main()
|
||||
58
.github/workflows/cd-mastodon.yml
vendored
Normal file
58
.github/workflows/cd-mastodon.yml
vendored
Normal file
@@ -0,0 +1,58 @@
|
||||
name: Announce Release on Mastodon
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [ published ]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag to announce (defaults to the latest release)'
|
||||
required: false
|
||||
dry_run:
|
||||
description: 'Print the post without sending it'
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
announce:
|
||||
name: Announce
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
# Fetched rather than read from github.event so that workflow_dispatch and the release event take the
|
||||
# same path, and so release bodies (backticks, quotes, newlines) never pass through shell expansion.
|
||||
- name: Resolve release
|
||||
id: release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ github.event.release.tag_name || inputs.tag }}
|
||||
run: |
|
||||
if [ -n "$TAG" ]; then
|
||||
gh api "repos/${{ github.repository }}/releases/tags/$TAG" > "$RUNNER_TEMP/release.json"
|
||||
else
|
||||
gh api "repos/${{ github.repository }}/releases/latest" > "$RUNNER_TEMP/release.json"
|
||||
fi
|
||||
|
||||
# Prereleases and drafts are not user-facing news, so they are not announced.
|
||||
if [ "$(jq -r '.prerelease or .draft' "$RUNNER_TEMP/release.json")" = "true" ]; then
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Skipping: release is a draft or prerelease."
|
||||
else
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Post to Mastodon
|
||||
if: steps.release.outputs.skip != 'true'
|
||||
env:
|
||||
MASTODON_ACCESS_TOKEN: ${{ secrets.MASTODON_ACCESS_TOKEN }}
|
||||
MASTODON_INSTANCE: ${{ vars.MASTODON_INSTANCE }}
|
||||
RELEASE_JSON_PATH: ${{ runner.temp }}/release.json
|
||||
DRY_RUN: ${{ inputs.dry_run }}
|
||||
run: node .github/scripts/post-release-to-mastodon.mjs
|
||||
Reference in New Issue
Block a user