forked from vavallee/bindery
-
Notifications
You must be signed in to change notification settings - Fork 0
522 lines (495 loc) · 21.7 KB
/
Copy pathci.yml
File metadata and controls
522 lines (495 loc) · 21.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
name: CI
on:
push:
branches: [main, development]
tags: ['v*']
pull_request:
permissions:
contents: read
jobs:
# ── Parallel checks — all events ───────────────────────────────────────────
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: "1.25.10"
cache: true
- uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0
with:
version: v2.11.4
args: --timeout=5m
- name: govulncheck
run: |
go install golang.org/x/vuln/cmd/govulncheck@d1f380186385b4f64e00313f31743df8e4b89a77
govulncheck ./...
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22"
cache: npm
cache-dependency-path: web/package-lock.json
- run: npm ci --prefix web
- run: npm run lint --prefix web
- run: npm run typecheck --prefix web
smoke:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: "1.25.10"
cache: true
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22"
cache: npm
cache-dependency-path: web/package-lock.json
- run: make smoke
# PR-only: Go race-detector tests and frontend build run in parallel so
# neither blocks the other.
validate-go:
name: validate (Go)
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: "1.25.10"
cache: true
- run: go test -race -coverprofile=coverage.out -covermode=atomic ./cmd/... ./internal/...
- uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5
with:
files: coverage.out
flags: backend
fail_ci_if_error: false
token: ${{ secrets.CODECOV_TOKEN }}
validate-frontend:
name: validate (frontend)
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22"
cache: npm
cache-dependency-path: web/package-lock.json
- run: npm ci --prefix web
- run: npm run build --prefix web
# ── Gate: fast tests that must pass before image + release ─────────────────
test:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/development' || startsWith(github.ref, 'refs/tags/v')
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: "1.25.10"
cache: true
- run: go test -coverprofile=coverage.out -covermode=atomic ./cmd/... ./internal/...
- uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5
with:
files: coverage.out
flags: backend
fail_ci_if_error: false
token: ${{ secrets.CODECOV_TOKEN }}
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22"
cache: npm
cache-dependency-path: web/package-lock.json
- run: npm ci --prefix web
- run: npm run build --prefix web
# Race-detector run — parallel with image/goreleaser, not a deploy gate.
# A failure shows as a non-blocking check; fix before the next release.
race:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: "1.25.10"
cache: true
- run: go test -race ./cmd/... ./internal/...
# ── Docker image — runs after test passes ──────────────────────────────────
image:
needs: test
runs-on: ubuntu-latest
permissions:
packages: write
id-token: write
attestations: write
env:
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
if: env.DOCKERHUB_TOKEN != ''
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
id: meta
with:
images: |
ghcr.io/${{ github.repository }}
vavallee/bindery
tags: |
type=sha,format=short
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=raw,value=development,enable=${{ github.ref == 'refs/heads/development' }}
type=semver,pattern={{version}}
type=semver,pattern=v{{version}}
- name: Derive version
id: ver
run: |
short="${GITHUB_SHA::7}"
if described=$(git describe --tags --match 'v*' --always 2>/dev/null) && [[ "$described" == v* ]]; then
echo "version=${described}" >> "$GITHUB_OUTPUT"
elif [[ "$GITHUB_REF" == refs/heads/development ]]; then
echo "version=dev-${short}" >> "$GITHUB_OUTPUT"
else
echo "version=sha-${short}" >> "$GITHUB_OUTPUT"
fi
- uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
id: push
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ steps.ver.outputs.version }}
COMMIT=${{ github.sha }}
BUILD_DATE=${{ github.event.head_commit.timestamp }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Attest build provenance
uses: actions/attest-build-provenance@ef244123eb79f2f7a7e75d99086184180e6d0018 # v1
with:
subject-name: ghcr.io/${{ github.repository }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
# ── Deploy to bindery-dev (development branch only) ────────────────────────
# Runs after image build on every push to development. Updates
# values-dev.yaml with the SHA image tag so ArgoCD picks up the new build.
deploy-dev:
name: Deploy to dev
needs: image
if: github.ref == 'refs/heads/development'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Update values-dev.yaml on development branch
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
IMAGE_TAG="sha-${GITHUB_SHA::7}"
FILE_PATH="charts/bindery/values-dev.yaml"
DEPLOY_BRANCH="auto/dev-deploy-${GITHUB_SHA::7}"
DEV_SHA=$(gh api "repos/${GITHUB_REPOSITORY}/git/refs/heads/development" --jq .object.sha)
gh api "repos/${GITHUB_REPOSITORY}/git/refs" \
-f ref="refs/heads/${DEPLOY_BRANCH}" \
-f sha="${DEV_SHA}"
FILE_SHA=$(gh api "repos/${GITHUB_REPOSITORY}/contents/${FILE_PATH}?ref=${DEPLOY_BRANCH}" --jq .sha 2>/dev/null || echo "")
CONTENT=$(printf 'image:\n tag: "%s"\n' "${IMAGE_TAG}" | base64 -w0)
if [ -n "$FILE_SHA" ]; then
gh api "repos/${GITHUB_REPOSITORY}/contents/${FILE_PATH}" \
-X PUT \
-f message="chore(deploy-dev): update to ${IMAGE_TAG} [skip ci]" \
-f content="${CONTENT}" \
-f sha="${FILE_SHA}" \
-f branch="${DEPLOY_BRANCH}"
else
gh api "repos/${GITHUB_REPOSITORY}/contents/${FILE_PATH}" \
-X PUT \
-f message="chore(deploy-dev): create values-dev.yaml for ${IMAGE_TAG} [skip ci]" \
-f content="${CONTENT}" \
-f branch="${DEPLOY_BRANCH}"
fi
# --repo is required because this job does not check out the
# repository, so gh has no local git context to infer the target
# from. Without it, `gh pr create` exits 1 with "not a git
# repository: .git" and the 2>/dev/null mask hides that error;
# the subsequent `gh pr merge` then fails for the same reason
# (and orphans the auto/dev-deploy-<sha> branch with the right
# content but no PR). The deploy-prod job below already has the
# --repo flag; this brings deploy-dev in line.
gh pr create \
--repo "${GITHUB_REPOSITORY}" \
--head "${DEPLOY_BRANCH}" \
--base development \
--title "chore(deploy-dev): update to ${IMAGE_TAG}" \
--body "Automated dev deploy: image tag \`${IMAGE_TAG}\`." 2>/dev/null || true
gh pr merge "${DEPLOY_BRANCH}" --repo "${GITHUB_REPOSITORY}" --squash --admin --delete-branch
echo "bindery-dev image tag updated to ${IMAGE_TAG}"
- name: Trigger ArgoCD dev refresh
env:
ARGOCD_SERVER: ${{ secrets.ARGOCD_SERVER }}
ARGOCD_TOKEN: ${{ secrets.ARGOCD_TOKEN }}
run: |
if [ -z "${ARGOCD_SERVER:-}" ] || [ -z "${ARGOCD_TOKEN:-}" ]; then
echo "::warning::ARGOCD_SERVER or ARGOCD_TOKEN not configured — Argo will pick up changes on next poll"
exit 0
fi
curl -sf -X POST \
-H "Authorization: Bearer ${ARGOCD_TOKEN}" \
"${ARGOCD_SERVER}/api/v1/applications/bindery-dev?refresh=hard" \
&& echo "ArgoCD bindery-dev refresh triggered" \
|| echo "::warning::ArgoCD dev refresh failed — Argo will pick up changes on next poll"
# ── GoReleaser — runs after test passes, tag pushes only ───────────────────
goreleaser:
needs: test
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: "1.25.10"
cache: true
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22"
cache: npm
cache-dependency-path: web/package-lock.json
- run: npm ci --prefix web
- run: npm run build --prefix web
- name: Populate embedded frontend
run: rm -rf internal/webui/dist && cp -r web/dist internal/webui/dist && touch internal/webui/dist/.gitkeep
- name: Create GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION="${GITHUB_REF_NAME}"
NOTES=$(awk -v ver="## [${VERSION}]" '
index($0, ver) == 1 { found=1; next }
found && index($0, "## ") == 1 { exit }
found { print }
' CHANGELOG.md)
if [ -z "$NOTES" ]; then
echo "::error::No CHANGELOG section found for ${VERSION}. Add '## [${VERSION}]' to CHANGELOG.md before tagging."
exit 1
fi
TITLE=$(printf '%s' "$NOTES" | awk 'NF { print; exit }' | sed 's/^### //')
RELEASE_TITLE="${VERSION} — ${TITLE}"
if gh release view "$VERSION" &>/dev/null; then
echo "Release $VERSION already exists — updating notes."
printf '%s' "$NOTES" | gh release edit "$VERSION" --title "$RELEASE_TITLE" --notes-file -
else
printf '%s' "$NOTES" | gh release create "$VERSION" --title "$RELEASE_TITLE" --notes-file -
fi
- name: Install Syft
uses: anchore/sbom-action/download-syft@e22c389904149dbc22b58101806040fa8d37a610 # v0
- name: GoReleaser (release binaries)
uses: goreleaser/goreleaser-action@1a80836c5c9d9e5755a25cb59ec6f45a3b5f41a8 # v7.2.1
with:
version: "~> v2"
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Bump bindery-ping LATEST_VERSION
env:
KUBECONFIG_B64: ${{ secrets.HETZ1_KUBECONFIG }}
VERSION: ${{ github.ref_name }}
run: |
[ -z "$KUBECONFIG_B64" ] && exit 0
echo "$KUBECONFIG_B64" | base64 -d > /tmp/kc
KUBECONFIG=/tmp/kc kubectl -n bindery-ping set env deployment/bindery-ping \
LATEST_VERSION="$VERSION"
rm -f /tmp/kc
# Pinned ABS compatibility suite. Runs self-contained against the
# repository's seeded Phase 6 fixture harness and can optionally target a
# real pinned ABS instance when the env vars below are provided.
abs-contract:
name: ABS contract
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: "1.25.10"
cache: true
- name: Run ABS contract suite
env:
BINDERY_ABS_CONTRACT_BASE_URL: ${{ secrets.BINDERY_ABS_CONTRACT_BASE_URL }}
BINDERY_ABS_CONTRACT_API_KEY: ${{ secrets.BINDERY_ABS_CONTRACT_API_KEY }}
BINDERY_ABS_CONTRACT_LIMITED_API_KEY: ${{ secrets.BINDERY_ABS_CONTRACT_LIMITED_API_KEY }}
BINDERY_ABS_CONTRACT_LIBRARY_ID: ${{ secrets.BINDERY_ABS_CONTRACT_LIBRARY_ID }}
run: |
make abs-contract
# ── Pre-deploy smoke — sanity check dev before promoting to prod ───────────
predeploy-smoke:
name: Pre-deploy smoke
needs: image
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: "1.25.10"
cache: true
- name: Wait for ArgoCD dev to sync
env:
ARGOCD_SERVER: ${{ secrets.ARGOCD_SERVER }}
ARGOCD_TOKEN: ${{ secrets.ARGOCD_TOKEN }}
run: |
if [[ -z "$ARGOCD_SERVER" || -z "$ARGOCD_TOKEN" ]]; then
echo "ArgoCD secrets not set — skipping wait"
exit 0
fi
echo "Waiting for ArgoCD app 'bindery-dev' to be Healthy+Synced..."
for i in $(seq 1 24); do
# jq avoids a curl|python3 pipe flagged by OpenSSF Scorecard downloadThenRun
STATUS=$(curl -sf -H "Authorization: Bearer $ARGOCD_TOKEN" \
"$ARGOCD_SERVER/api/v1/applications/bindery-dev" 2>/dev/null | \
jq -r '"\(.status.health.status) \(.status.sync.status)"' 2>/dev/null || echo "unknown unknown")
echo " attempt $i: $STATUS"
[[ "$STATUS" == "Healthy Synced" ]] && echo "ArgoCD dev sync complete" && exit 0
sleep 10
done
echo "Timed out waiting for ArgoCD dev sync"
exit 1
- name: Run pre-deploy smoke tests
env:
BINDERY_URL: https://bindery.autonomy.ninja
BINDERY_API_KEY: ${{ secrets.BINDERY_SMOKE_API_KEY }}
run: |
if ! curl -sf --max-time 5 "$BINDERY_URL/api/v1/health" > /dev/null 2>&1; then
echo "::warning::Live instance unreachable from runner — skipping pre-deploy smoke"
exit 0
fi
make predeploy-smoke
# ── Deploy to prod (bindery) — tag push, after image + goreleaser + smoke ──
# Updates values.yaml on main; ArgoCD prod app (bindery) picks it up.
deploy-prod:
name: Deploy to prod
needs: [image, goreleaser, predeploy-smoke]
if: startsWith(github.ref, 'refs/tags/v') && needs.goreleaser.result == 'success'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Update values.yaml on main
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
IMAGE_TAG="${GITHUB_REF_NAME#v}"
FILE_PATH="charts/bindery/values.yaml"
DEPLOY_BRANCH="auto/prod-deploy-${IMAGE_TAG}"
MAIN_SHA=$(gh api "repos/${GITHUB_REPOSITORY}/git/refs/heads/main" --jq .object.sha)
gh api "repos/${GITHUB_REPOSITORY}/git/refs" \
-f ref="refs/heads/${DEPLOY_BRANCH}" \
-f sha="${MAIN_SHA}"
FILE_SHA=$(gh api "repos/${GITHUB_REPOSITORY}/contents/${FILE_PATH}?ref=${DEPLOY_BRANCH}" --jq .sha)
CONTENT=$(gh api "repos/${GITHUB_REPOSITORY}/contents/${FILE_PATH}?ref=${DEPLOY_BRANCH}" \
--jq '.content' | base64 -d | sed "s|tag:.*|tag: \"${IMAGE_TAG}\"|" | base64 -w0)
gh api "repos/${GITHUB_REPOSITORY}/contents/${FILE_PATH}" \
-X PUT \
-f message="chore(deploy): promote bindery to ${IMAGE_TAG} [skip ci]" \
-f content="${CONTENT}" \
-f sha="${FILE_SHA}" \
-f branch="${DEPLOY_BRANCH}"
gh pr create \
--repo "${GITHUB_REPOSITORY}" \
--head "${DEPLOY_BRANCH}" \
--base main \
--title "chore(deploy): promote bindery to ${GITHUB_REF_NAME}" \
--body "Automated prod deploy: image tag \`${IMAGE_TAG}\`." 2>/dev/null || true
gh pr merge "${DEPLOY_BRANCH}" --repo "${GITHUB_REPOSITORY}" --squash --admin --delete-branch
echo "bindery (prod) image tag updated to ${IMAGE_TAG}"
- name: Trigger ArgoCD prod refresh
env:
ARGOCD_SERVER: ${{ secrets.ARGOCD_SERVER }}
ARGOCD_TOKEN: ${{ secrets.ARGOCD_TOKEN }}
run: |
if [ -z "${ARGOCD_SERVER:-}" ] || [ -z "${ARGOCD_TOKEN:-}" ]; then
echo "::warning::ARGOCD_SERVER or ARGOCD_TOKEN not configured — Argo will pick up changes on next poll"
exit 0
fi
curl -sf -X POST \
-H "Authorization: Bearer ${ARGOCD_TOKEN}" \
"${ARGOCD_SERVER}/api/v1/applications/bindery?refresh=hard" \
&& echo "ArgoCD bindery (prod) refresh triggered" \
|| echo "::warning::ArgoCD prod refresh failed — Argo will pick up changes on next poll"
# ── Discord release announcement ───────────────────────────────────────────
notify-discord:
name: Notify Discord
needs: [goreleaser, image]
if: startsWith(github.ref, 'refs/tags/v') && needs.goreleaser.result == 'success'
runs-on: ubuntu-latest
# A broken/expired webhook should never fail the release workflow.
continue-on-error: true
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Post release announcement
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
VERSION: ${{ github.ref_name }}
run: |
PAYLOAD=$(python3 -c "
import json, re, os, sys
version = os.environ['VERSION']
with open('CHANGELOG.md') as f:
content = f.read()
pattern = r'## \[' + re.escape(version) + r'\][^\n]*\n(.*?)(?=\n## \[|\Z)'
m = re.search(pattern, content, re.DOTALL)
notes = m.group(1).strip() if m else ''
if len(notes) > 900:
notes = notes[:900].rsplit('\n', 1)[0] + '\n...'
body = '**Bindery ' + version + '** is out!'
if notes:
body += '\n\n' + notes
body += '\n\n<https://github.com/vavallee/bindery/releases/tag/' + version + '>'
print(json.dumps({'content': body}))
")
HTTP=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
-H "Content-Type: application/json" \
--data-binary "$PAYLOAD" \
"$DISCORD_WEBHOOK")
if [ "$HTTP" = "204" ]; then
echo "Discord notified for ${VERSION}"
else
echo "::warning::Discord webhook returned HTTP ${HTTP} — check DISCORD_RELEASE_WEBHOOK secret"
exit 1
fi