Skip to content

Commit 19ff0bc

Browse files
Merge pull request #42 from off-grid-ai/feat/slack-release-notes
ci: announce every release in Slack (link + notes)
2 parents 7c9b211 + 1c7706a commit 19ff0bc

2 files changed

Lines changed: 101 additions & 0 deletions

File tree

.github/workflows/release.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,3 +268,18 @@ jobs:
268268
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
269269
VERSION: ${{ needs.version.outputs.version }}
270270
run: gh release edit "v$VERSION" --notes-file release-notes.md
271+
272+
# Announce every published release in Slack: the GitHub release link + the notes
273+
# generated above. Fail-soft (scripts/notify-slack-release.mjs always exits 0), so a
274+
# Slack outage never fails a release. Needs org/repo secret SLACK_WEBHOOK_URL (an
275+
# Incoming Webhook, channel-bound). No secret => the step is a logged no-op.
276+
- name: Announce release in Slack
277+
if: success()
278+
env:
279+
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
280+
PRODUCT: Off Grid AI Desktop
281+
VERSION: ${{ needs.version.outputs.version }}
282+
CHANNEL_LABEL: ${{ needs.version.outputs.channel }}
283+
NOTES_FILE: release-notes.md
284+
RELEASE_URL: https://github.com/off-grid-ai/off-grid-ai-desktop/releases/tag/v${{ needs.version.outputs.version }}
285+
run: node scripts/notify-slack-release.mjs

scripts/notify-slack-release.mjs

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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

Comments
 (0)