diff --git a/.github/scripts/announce-discord.mjs b/.github/scripts/announce-discord.mjs new file mode 100644 index 0000000..3f44968 --- /dev/null +++ b/.github/scripts/announce-discord.mjs @@ -0,0 +1,69 @@ +#!/usr/bin/env node +// Post a release announcement to a Discord webhook. Formats mechanically from +// the GitHub Release: title + lead paragraph (word-boundary capped) + link. +// Transport-agnostic formatting lives here so it can move into a shared action +// verbatim when we extend to nimblebrain / mpak. +// +// Env: +// WEBHOOK_URL Discord webhook. Empty/unset -> print payload + skip (never +// fails a release, so merging before the secret exists is safe). +// REPO owner/name (GITHUB_REPOSITORY). +// RELEASE_JSON path to `gh release view ... --json tagName,name,body,url`. +// DRY_RUN "true" -> print payload, don't POST. +import { readFileSync } from "node:fs"; + +const webhook = (process.env.WEBHOOK_URL || "").trim(); +const repoName = (process.env.REPO || "").split("/").pop() || "release"; +const dryRun = process.env.DRY_RUN === "true"; + +const rel = JSON.parse(readFileSync(process.env.RELEASE_JSON || "release.json", "utf8")); +const tag = rel.tagName || rel.name || ""; +const url = rel.url || ""; + +// Lead paragraph: skip leading blanks; take consecutive non-blank lines until a +// blank line, a heading (#), or a list marker (- / *). +const lines = (rel.body || "").replace(/\r/g, "").split("\n"); +let i = 0; +while (i < lines.length && lines[i].trim() === "") i++; +const lead = []; +for (; i < lines.length; i++) { + const t = lines[i].trim(); + if (t === "" || t.startsWith("#") || t.startsWith("- ") || t.startsWith("* ")) break; + lead.push(t); +} +let desc = lead.join(" ").trim(); + +// Word-boundary cap; always link out to the full notes. +const CAP = 280; +if (desc.length > CAP) { + const cut = desc.lastIndexOf(" ", CAP); + desc = desc.slice(0, cut > 0 ? cut : CAP).trimEnd() + "…"; +} +if (!desc) { + // The parser found no prose lead under the version heading (e.g. a CHANGELOG + // that leads with `### Fixed`, or a manually-cut release using GitHub's + // auto-generated notes). Post a generic line but make the gap visible in the + // run log rather than silently shipping content-free copy. + console.log("::warning::No lead paragraph found in the release body — posting generic copy. Check the CHANGELOG format."); +} +const description = `${desc || "New release."}\n\n[Full release notes →](${url})`; + +const payload = { + embeds: [{ title: `🚀 ${repoName} ${tag}`, url, description, color: 0x0055ff }], +}; + +if (!webhook || dryRun) { + console.log(webhook ? "DRY_RUN — payload:" : "::notice::WEBHOOK_URL unset — skipping post. Payload:"); + console.log(JSON.stringify(payload, null, 2)); + process.exit(0); +} +const res = await fetch(webhook, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), +}); +if (!res.ok) { + console.error(`::error::Discord webhook ${res.status}: ${await res.text().catch(() => "")}`); + process.exit(1); +} +console.log(`Announced ${repoName} ${tag} to Discord (${res.status}).`); diff --git a/.github/workflows/announce.yml b/.github/workflows/announce.yml new file mode 100644 index 0000000..f7218bb --- /dev/null +++ b/.github/workflows/announce.yml @@ -0,0 +1,52 @@ +name: Announce release + +# Posts a Discord announcement when a GitHub Release is published. Decoupled +# from publish.yml on purpose: it fires for any published release (including +# manually-cut ones), and a failure here never affects the publish path. +# +# Setup (one-time): create a webhook on the Discord #announcements channel and +# add its URL as the `DISCORD_ANNOUNCE_WEBHOOK` repo/org secret. Until that +# secret exists the job runs and cleanly skips (no post, green run) — so this +# can merge before the webhook is wired. + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: "Release tag to announce (e.g. v0.10.2)" + required: true + dry_run: + description: "Print the payload without posting" + type: boolean + default: false + +permissions: + contents: read + +jobs: + announce: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Resolve release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ github.event.release.tag_name || github.event.inputs.tag }} + run: | + gh release view "$TAG" --repo "$GITHUB_REPOSITORY" \ + --json tagName,name,body,url > release.json + + - name: Announce to Discord + env: + WEBHOOK_URL: ${{ secrets.DISCORD_ANNOUNCE_WEBHOOK }} + REPO: ${{ github.repository }} + RELEASE_JSON: release.json + DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }} + run: node .github/scripts/announce-discord.mjs