|
| 1 | +#!/usr/bin/env node |
| 2 | +// Announce a published release to Slack. Called as the LAST step of each release workflow |
| 3 | +// (desktop + mobile android/ios) so a message lands in the release channel on every release. |
| 4 | +// |
| 5 | +// Single responsibility: read the already-generated release notes + a few env facts and post |
| 6 | +// one chat.postMessage. It NEVER fails the release — a missing token, an unreachable Slack, or a |
| 7 | +// non-ok response logs a warning and exits 0. The posting logic lives here once; each workflow |
| 8 | +// just sets env and runs it. |
| 9 | +// |
| 10 | +// Delivery (either works; webhook wins if both set): |
| 11 | +// SLACK_WEBHOOK_URL an Incoming Webhook (channel-bound — no token/scope/channel needed) |
| 12 | +// SLACK_BOT_TOKEN a Bot User OAuth token (xoxb-…, chat:write) + SLACK_CHANNEL |
| 13 | +// With neither set => no-op, exit 0. |
| 14 | +// Content env: |
| 15 | +// PRODUCT e.g. "Off Grid AI Desktop" |
| 16 | +// VERSION e.g. "0.0.39-beta.63" |
| 17 | +// CHANNEL_LABEL "beta" | "stable" (optional, shown as a tag) |
| 18 | +// NOTES_FILE (default release-notes.md) |
| 19 | +// RELEASE_URL (optional; default derived from GITHUB_SERVER_URL/GITHUB_REPOSITORY + tag) |
| 20 | +import { readFileSync } from 'node:fs'; |
| 21 | + |
| 22 | +const warn = (m) => console.warn(`[slack-release] ${m}`); |
| 23 | + |
| 24 | +const webhook = process.env.SLACK_WEBHOOK_URL; |
| 25 | +const token = process.env.SLACK_BOT_TOKEN; |
| 26 | +if (!webhook && !token) { warn('no SLACK_WEBHOOK_URL or SLACK_BOT_TOKEN set — skipping announcement (no-op).'); process.exit(0); } |
| 27 | + |
| 28 | +const channel = process.env.SLACK_CHANNEL || 'C0AFARY80HJ'; |
| 29 | +const product = process.env.PRODUCT || 'Off Grid AI'; |
| 30 | +const version = process.env.VERSION || ''; |
| 31 | +const label = (process.env.CHANNEL_LABEL || '').trim(); |
| 32 | +const notesFile = process.env.NOTES_FILE || 'release-notes.md'; |
| 33 | + |
| 34 | +const server = process.env.GITHUB_SERVER_URL || 'https://github.com'; |
| 35 | +const repo = process.env.GITHUB_REPOSITORY || ''; |
| 36 | +const releaseUrl = process.env.RELEASE_URL || (repo && version ? `${server}/${repo}/releases/tag/v${version}` : ''); |
| 37 | + |
| 38 | +// Slack mrkdwn reserves & < > — a raw commit subject containing them (release notes are raw |
| 39 | +// commit subjects) would misrender or be read as a <link|mention>. Escape the note body only; |
| 40 | +// NOT the intentional <url|label> link line below. |
| 41 | +const esc = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); |
| 42 | + |
| 43 | +let notes = ''; |
| 44 | +try { notes = readFileSync(notesFile, 'utf8').trim(); } catch { /* notes optional */ } |
| 45 | +// Slack section text caps at 3000 chars; keep well under and never dump a wall. |
| 46 | +if (notes.length > 2600) { notes = `${notes.slice(0, 2600)}\n…`; } |
| 47 | + |
| 48 | +const tag = label ? ` \`${label}\`` : ''; |
| 49 | +const header = `:package: *${esc(product)}* \`${version || 'release'}\`${tag}`; |
| 50 | +const linkLine = releaseUrl ? `<${releaseUrl}|Download / release page>` : ''; |
| 51 | +const body = notes ? esc(notes) : '_No release notes generated for this build._'; |
| 52 | + |
| 53 | +const blocks = [ |
| 54 | + { type: 'section', text: { type: 'mrkdwn', text: header } }, |
| 55 | + { type: 'section', text: { type: 'mrkdwn', text: body } }, |
| 56 | +]; |
| 57 | +if (linkLine) { blocks.push({ type: 'context', elements: [{ type: 'mrkdwn', text: linkLine }] }); } |
| 58 | + |
| 59 | +const fallbackText = `${product} ${version} released`; |
| 60 | +try { |
| 61 | + if (webhook) { |
| 62 | + // Incoming webhook: channel is fixed by the hook; POST the blocks, expect body "ok". |
| 63 | + const res = await fetch(webhook, { |
| 64 | + method: 'POST', |
| 65 | + headers: { 'Content-Type': 'application/json; charset=utf-8' }, |
| 66 | + body: JSON.stringify({ text: fallbackText, blocks, unfurl_links: false }), |
| 67 | + signal: AbortSignal.timeout(10000), |
| 68 | + }); |
| 69 | + const t = await res.text().catch(() => ''); |
| 70 | + if (!res.ok) { warn(`webhook not ok: HTTP ${res.status} ${t}`); process.exit(0); } |
| 71 | + console.log(`[slack-release] announced ${product} ${version} via webhook`); |
| 72 | + } else { |
| 73 | + const res = await fetch('https://slack.com/api/chat.postMessage', { |
| 74 | + method: 'POST', |
| 75 | + headers: { 'Content-Type': 'application/json; charset=utf-8', Authorization: `Bearer ${token}` }, |
| 76 | + body: JSON.stringify({ channel, text: fallbackText, blocks, unfurl_links: false }), |
| 77 | + signal: AbortSignal.timeout(10000), |
| 78 | + }); |
| 79 | + const j = await res.json().catch(() => ({})); |
| 80 | + if (!res.ok || !j.ok) { warn(`chat.postMessage not ok: HTTP ${res.status} ${j.error || ''}`); process.exit(0); } |
| 81 | + console.log(`[slack-release] announced ${product} ${version} to ${channel} (ts=${j.ts})`); |
| 82 | + } |
| 83 | +} catch (e) { |
| 84 | + warn(`post failed: ${e?.message || e}`); |
| 85 | +} |
| 86 | +process.exit(0); |
0 commit comments