Skip to content

Commit f16ea07

Browse files
fredbiclaude
andauthored
feat: add webhook-announcements shared workflow (#219)
Add a reusable workflow that watches a markdown file's "## Announcements" section on the default branch and posts each newly added announcement to a webhook (Discord by default), one message per announcement. How it works: - Diffs the full pushed range (github.event.before..github.sha), isolates the "## Announcements" section in both blobs, and treats a block as new when its normalized first-line key is present in the after blob but not the before one. README edits outside the section, or re-runs over an overlapping range, detect zero new blocks and post nothing (idempotent, no reposts). - Each announcement's first bullet line becomes the embed title ("DATE: summary", stripped of markdown); its sub-bullets become the embed description. Embeds link to the README #announcements anchor. - Default secret is DISCORD_ANNOUNCEMENTS_WEBHOOK_URL, overridable via the explicit webhook-url secret (no secrets[inputs.x] keyed access). A compare-base input overrides the diff base; passing the git empty-tree SHA treats every current announcement as new, which enables manual testing and backfilling. Also add local-webhook-announcements.yml plus a committed fixture: pushes that touch the fixture run in forced dry-run (payloads printed, never posted) to exercise detection safely, while workflow_dispatch runs a manual live test against an arbitrary webhook URL. Signed-off-by: Frederic BIDON <fredbi@yahoo.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2e57e83 commit f16ea07

3 files changed

Lines changed: 396 additions & 0 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Announcements test fixture
2+
3+
This file exists solely to exercise `webhook-announcements.yml` via
4+
`local-webhook-announcements.yml`. Editing the `## Announcements` section below
5+
and pushing to `master` triggers the local test, which dry-run-prints the
6+
payloads it would post (no webhook secret required).
7+
8+
The format mirrors how go-openapi repos write announcements in their real
9+
`README.md`: newest first, one top-level bullet per announcement
10+
(`* **DATE** : summary`), with indented sub-bullets for detail.
11+
12+
## Announcements
13+
14+
* **2026-04-15** : added support for trailing "-" for arrays (v0.23.0)
15+
* this brings full support of [RFC6901][RFC6901]
16+
* API semantics remain essentially unaltered, with one documented exception around
17+
in-place mutation of arrays via a trailing "-"
18+
* types that implement the `JSONSetable` interface keep their behavior
19+
20+
* **2026-04-15** : added support for optional alternate JSON name providers
21+
* the default name provider is not fully aligned with the Go JSON stdlib
22+
* a new alternate provider (imported from `go-openapi/swag/jsonname`) is available
23+
24+
## Status
25+
26+
(end of fixture)
27+
28+
[RFC6901]: https://www.rfc-editor.org/rfc/rfc6901
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
name: Webhook Announcements [Test only]
2+
3+
# description: |
4+
# This workflow mimics how a go-openapi repo would invoke the common
5+
# webhook-announcements workflow, scanning a committed fixture instead of a real
6+
# README.
7+
#
8+
# Two modes:
9+
#
10+
# * push (fixture changed): runs in dry-run mode (payloads printed, never
11+
# posted) so editing the fixture's "## Announcements" section exercises the
12+
# real before..after detection without spamming any channel.
13+
#
14+
# * workflow_dispatch: a manual live test. Provide an arbitrary webhook URL and
15+
# it POSTs for real. By default it diffs against the git empty tree, so every
16+
# announcement currently in the fixture is posted — no need to craft a diff.
17+
#
18+
# NOTE: the webhook URL you type is a workflow_dispatch input and is therefore
19+
# visible in the run's UI/logs. Use a throwaway test webhook (and/or rotate it
20+
# afterwards), not the production go-openapi webhook.
21+
22+
permissions:
23+
contents: read
24+
25+
on:
26+
push:
27+
branches:
28+
- master
29+
paths:
30+
- '.github/test-fixtures/announcements-sample.md'
31+
32+
workflow_dispatch:
33+
inputs:
34+
webhook-url:
35+
description: |
36+
Webhook URL to POST to (e.g. a test Discord channel webhook).
37+
Visible in run logs — use a throwaway webhook.
38+
type: string
39+
required: true
40+
compare-base:
41+
description: |
42+
Git ref to diff the fixture against. The default empty-tree SHA posts
43+
every announcement currently in the fixture.
44+
type: string
45+
default: 4b825dc642cb6eb9a060e54bf8d69288fbee4904
46+
dry-run:
47+
description: |
48+
Print payloads instead of posting.
49+
type: choice
50+
options:
51+
- 'false'
52+
- 'true'
53+
default: 'false'
54+
55+
jobs:
56+
announce:
57+
uses: ./.github/workflows/webhook-announcements.yml
58+
with:
59+
scanned-markdown: .github/test-fixtures/announcements-sample.md
60+
# On push: force dry-run and the normal before..after diff (empty
61+
# compare-base). On dispatch: honor the provided inputs.
62+
dry-run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry-run || 'true' }}
63+
compare-base: ${{ github.event_name == 'workflow_dispatch' && inputs.compare-base || '' }}
64+
secrets:
65+
webhook-url: ${{ inputs.webhook-url }}
Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
name: Webhook Announcements
2+
3+
# description: |
4+
# Reusable workflow to be called on pushes to the default branch, with a
5+
# `paths:` filter on a scanned markdown file (default: README.md).
6+
#
7+
# It detects announcements newly added to the "## Announcements" section of
8+
# that file and routes each one to a webhook (Discord by default).
9+
#
10+
# Each announcement is a top-level bullet of the form:
11+
#
12+
# * **2026-04-15** : added support for trailing "-" for arrays (v0.23.0)
13+
# * detail line
14+
# * detail line
15+
#
16+
# The first line becomes the message title; the indented sub-bullets become
17+
# the message body. If several announcements land in a single push (across one
18+
# or more commits), each one is posted as a separate message.
19+
#
20+
# Caller example:
21+
#
22+
# on:
23+
# push:
24+
# branches: [master]
25+
# paths: ['README.md']
26+
# jobs:
27+
# announce:
28+
# uses: go-openapi/ci-workflows/.github/workflows/webhook-announcements.yml@master
29+
# secrets: inherit
30+
31+
permissions:
32+
contents: read
33+
34+
defaults:
35+
run:
36+
shell: bash
37+
38+
on:
39+
workflow_call:
40+
inputs:
41+
scanned-markdown:
42+
description: |
43+
Markdown file to scan for new announcements.
44+
type: string
45+
default: README.md
46+
section:
47+
description: |
48+
Heading text whose section is scanned (the "## <section>" block).
49+
type: string
50+
default: Announcements
51+
username:
52+
description: |
53+
Username displayed by the webhook for the posted message.
54+
type: string
55+
default: go-openapi news
56+
dry-run:
57+
description: |
58+
When 'true', build and print payloads but do not POST them.
59+
type: string
60+
default: 'false'
61+
compare-base:
62+
description: |
63+
Optional git ref to diff the scanned file against, instead of the
64+
pushed range. Passing the empty-tree SHA
65+
(4b825dc642cb6eb9a060e54bf8d69288fbee4904) treats every current
66+
announcement as new and posts them all (useful for manual testing or
67+
backfilling). Leave empty for normal push-triggered operation.
68+
type: string
69+
default: ''
70+
secrets:
71+
webhook-url:
72+
description: |
73+
Webhook URL to post announcements to.
74+
Default for go-openapi: secrets.DISCORD_ANNOUNCEMENTS_WEBHOOK_URL
75+
required: false
76+
77+
jobs:
78+
notify-announcements:
79+
name: Notify new announcements
80+
runs-on: ubuntu-latest
81+
permissions:
82+
contents: read
83+
steps:
84+
-
85+
name: Checkout code
86+
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
87+
with:
88+
fetch-depth: 0
89+
-
90+
name: Extract new announcements
91+
id: extract
92+
env:
93+
SCANNED: ${{ inputs.scanned-markdown }}
94+
SECTION: ${{ inputs.section }}
95+
BEFORE: ${{ github.event.before }}
96+
AFTER: ${{ github.sha }}
97+
COMPARE_BASE: ${{ inputs.compare-base }}
98+
run: |
99+
set -euo pipefail
100+
101+
: "${SCANNED:=README.md}"
102+
: "${SECTION:=Announcements}"
103+
104+
# Resolve the diff range. An explicit compare-base wins (manual /
105+
# backfill). Otherwise use the full pushed range when available, else
106+
# the last commit only. `before` is all-zeros on a branch's first push
107+
# and may be unreachable on force pushes.
108+
after="${AFTER}"
109+
before="${BEFORE}"
110+
zero="0000000000000000000000000000000000000000"
111+
if [ -n "${COMPARE_BASE}" ]; then
112+
before="${COMPARE_BASE}"
113+
elif [ -z "${before}" ] || [ "${before}" = "${zero}" ] \
114+
|| ! git rev-parse -q --verify "${before}^{commit}" >/dev/null 2>&1; then
115+
before="${after}^"
116+
fi
117+
echo "Scanning '${SCANNED}' for new '${SECTION}' between ${before} and ${after}"
118+
119+
# Extract the "## <section>" block from a markdown stream on stdin:
120+
# everything after the section heading, up to the next heading.
121+
extract_section() {
122+
awk -v sec="${SECTION}" '
123+
BEGIN { inSec = 0 }
124+
!inSec && $0 ~ "^#+[[:space:]]+" sec "[[:space:]]*$" { inSec = 1; next }
125+
inSec && /^#+[[:space:]]/ { inSec = 0 }
126+
inSec { print }
127+
'
128+
}
129+
130+
# Split a section into per-announcement block files in a directory.
131+
# A block starts at a top-level list item and absorbs the indented /
132+
# blank lines that follow, until the next top-level item.
133+
split_blocks() {
134+
local dir="$1"
135+
mkdir -p "${dir}"
136+
awk -v dir="${dir}" '
137+
function flush() {
138+
if (n > 0) {
139+
f = sprintf("%s/block-%04d.txt", dir, n)
140+
printf "%s", buf > f
141+
close(f)
142+
}
143+
}
144+
BEGIN { n = 0; buf = "" }
145+
/^[*-][[:space:]]/ { flush(); n++; buf = $0 "\n"; next }
146+
{ if (n > 0) buf = buf $0 "\n" }
147+
END { flush() }
148+
'
149+
}
150+
151+
# Normalized identity of a block: its first line, lowercased, stripped
152+
# of the bullet marker, bold markers and backticks, spaces collapsed.
153+
block_key() {
154+
head -n 1 "$1" \
155+
| sed -E 's/^[[:space:]]*[*-][[:space:]]+//; s/\*\*//g; s/`//g' \
156+
| tr '[:upper:]' '[:lower:]' \
157+
| tr -s '[:space:]' ' ' \
158+
| sed -E 's/^ +//; s/ +$//'
159+
}
160+
161+
old_dir="$(mktemp -d)"
162+
new_dir="$(mktemp -d)"
163+
git show "${before}:${SCANNED}" 2>/dev/null | extract_section | split_blocks "${old_dir}" || true
164+
git show "${after}:${SCANNED}" 2>/dev/null | extract_section | split_blocks "${new_dir}" || true
165+
166+
# Build the set of keys that already existed before the push.
167+
# block_key strips its trailing newline, so write each key on its own
168+
# line — otherwise the keys concatenate and the grep -x below never
169+
# matches, causing every announcement to be re-posted on every run.
170+
old_keys="$(mktemp)"
171+
for f in "${old_dir}"/block-*.txt; do
172+
[ -e "${f}" ] || continue
173+
printf '%s\n' "$(block_key "${f}")" >> "${old_keys}"
174+
done
175+
176+
# Emit each genuinely new block as a NUL-delimited record:
177+
# <title>\x1f<description>\x00
178+
out="${GITHUB_WORKSPACE}/announcements.records"
179+
: > "${out}"
180+
count=0
181+
for f in "${new_dir}"/block-*.txt; do
182+
[ -e "${f}" ] || continue
183+
key="$(block_key "${f}")"
184+
if grep -qxF "${key}" "${old_keys}"; then
185+
continue
186+
fi
187+
188+
# Title: first line, stripped of bullet / bold / backticks, with the
189+
# "DATE : summary" separator normalized to "DATE: summary".
190+
title="$(head -n 1 "${f}" \
191+
| sed -E 's/^[[:space:]]*[*-][[:space:]]+//; s/\*\*//g; s/`//g; s/[[:space:]]+:[[:space:]]+/: /; s/[[:space:]]+$//')"
192+
193+
# Description: the remaining lines. Strip the 2-space sub-bullet
194+
# indent (so Discord renders a clean top-level list), turn
195+
# reference-style links [text][ref] into plain text, strip backticks,
196+
# and drop leading blank lines.
197+
desc="$(tail -n +2 "${f}" \
198+
| sed -E 's/^ //; s/`//g; s/\[([^]]+)\]\[[^]]*\]/\1/g' \
199+
| sed -E '/./,$!d')"
200+
201+
printf '%s\x1f%s\x00' "${title}" "${desc}" >> "${out}"
202+
count=$((count + 1))
203+
echo "::notice title=announcements::New announcement: ${title}"
204+
done
205+
206+
echo "count=${count}" >> "$GITHUB_OUTPUT"
207+
if [ "${count}" -eq 0 ]; then
208+
echo "No new announcements detected."
209+
else
210+
echo "Detected ${count} new announcement(s)."
211+
fi
212+
-
213+
name: Post announcements
214+
if: ${{ steps.extract.outputs.count != '0' }}
215+
env:
216+
WEBHOOK_URL: ${{ secrets.webhook-url || secrets.DISCORD_ANNOUNCEMENTS_WEBHOOK_URL }}
217+
USERNAME: ${{ inputs.username }}
218+
DRY_RUN: ${{ inputs.dry-run }}
219+
REPO: ${{ github.repository }}
220+
REPO_NAME: ${{ github.event.repository.name }}
221+
run: |
222+
set -euo pipefail
223+
224+
if [ "${DRY_RUN}" != 'true' ] && [ -z "${WEBHOOK_URL}" ]; then
225+
echo "::error title=webhook::No webhook URL set (pass secrets.webhook-url or set DISCORD_ANNOUNCEMENTS_WEBHOOK_URL)"
226+
exit 1
227+
fi
228+
229+
url="https://github.com/${REPO}#announcements"
230+
records="${GITHUB_WORKSPACE}/announcements.records"
231+
232+
post_one() {
233+
local title="$1" desc="$2"
234+
# Discord limits: title 256, description 4096. Stay safely under.
235+
title="${title:0:256}"
236+
desc="${desc:0:4000}"
237+
238+
local args
239+
args=(-n
240+
--arg username "${USERNAME}"
241+
--arg content "ℹ️ **${REPO_NAME} — Heads-up**"
242+
--arg title "${title}"
243+
--arg url "${url}")
244+
local filter
245+
if [ -n "${desc}" ]; then
246+
args+=(--arg desc "${desc}")
247+
filter='{ username: $username, content: $content, allowed_mentions: { parse: [] }, embeds: [ { title: $title, url: $url, color: 5763719, description: $desc } ] }'
248+
else
249+
filter='{ username: $username, content: $content, allowed_mentions: { parse: [] }, embeds: [ { title: $title, url: $url, color: 5763719 } ] }'
250+
fi
251+
jq "${args[@]}" "${filter}" > payload.json
252+
253+
if [ "${DRY_RUN}" = 'true' ]; then
254+
echo "--- dry-run payload ---"
255+
cat payload.json
256+
echo
257+
return 0
258+
fi
259+
260+
local code
261+
code=$(curl -sS -o resp.txt -w '%{http_code}' \
262+
-H "Content-Type: application/json" \
263+
-X POST --data @payload.json \
264+
"${WEBHOOK_URL}?wait=true")
265+
echo "Webhook HTTP ${code}"
266+
case "${code}" in
267+
2*)
268+
echo "::notice title=webhook::Posted announcement (HTTP ${code})"
269+
;;
270+
429)
271+
local retry
272+
retry=$(jq -r '.retry_after // 2' resp.txt 2>/dev/null || echo 2)
273+
echo "::warning title=webhook::Rate limited, retrying after ${retry}s"
274+
sleep "${retry}"
275+
code=$(curl -sS -o resp.txt -w '%{http_code}' \
276+
-H "Content-Type: application/json" \
277+
-X POST --data @payload.json \
278+
"${WEBHOOK_URL}?wait=true")
279+
case "${code}" in
280+
2*) echo "::notice title=webhook::Posted announcement on retry (HTTP ${code})" ;;
281+
*) echo "::error title=webhook::Webhook returned HTTP ${code} on retry"; cat resp.txt || true; exit 1 ;;
282+
esac
283+
;;
284+
*)
285+
echo "::error title=webhook::Webhook returned HTTP ${code}"
286+
cat resp.txt || true
287+
exit 1
288+
;;
289+
esac
290+
}
291+
292+
# Records are NUL-delimited; fields within a record split on \x1f.
293+
posted=0
294+
while IFS= read -r -d '' record; do
295+
title="${record%%$'\x1f'*}"
296+
desc="${record#*$'\x1f'}"
297+
post_one "${title}" "${desc}"
298+
posted=$((posted + 1))
299+
# Gentle spacing to stay clear of webhook rate limits.
300+
sleep 1
301+
done < "${records}"
302+
303+
echo "::notice title=webhook::Processed ${posted} announcement(s)"

0 commit comments

Comments
 (0)