forked from CopilotKit/CopilotKit
-
Notifications
You must be signed in to change notification settings - Fork 0
470 lines (428 loc) · 22.7 KB
/
Copy pathshowcase_docs-sync.yml
File metadata and controls
470 lines (428 loc) · 22.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
name: "Showcase: Docs Sync"
on:
# Auto-trigger disabled ahead of the shell-docs cutover (2026-05-19).
# Shell-docs is becoming the canonical authoring source; an upstream→shell
# sync would clobber edits made directly in showcase/shell-docs/. Re-enable
# only if the cutover is reverted and docs/content/docs/ becomes upstream
# again. Manual runs remain available via workflow_dispatch.
workflow_dispatch:
permissions:
contents: read
concurrency:
group: showcase-docs-sync
cancel-in-progress: false
jobs:
sync-docs:
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write
pull-requests: write
env:
SHOWCASE_BRANCH: main
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: ${{ env.SHOWCASE_BRANCH }}
fetch-depth: 0
persist-credentials: false
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 20
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Fetch main branch
run: git fetch origin main
- name: Run docs sync
id: sync
run: |
set +e
npx tsx showcase/scripts/sync-docs-from-main.ts > sync-output.txt 2>&1
EXIT_CODE=$?
set -e
cat sync-output.txt
if [ $EXIT_CODE -eq 2 ]; then
echo "No changes to sync"
echo "action=none" >> "$GITHUB_OUTPUT"
elif [ $EXIT_CODE -eq 0 ]; then
echo "Clean transforms only"
echo "action=auto_push" >> "$GITHUB_OUTPUT"
elif [ $EXIT_CODE -eq 3 ]; then
echo "Has review items + clean transforms"
echo "action=push_and_pr" >> "$GITHUB_OUTPUT"
# The sync script writes review-items.txt and emits
# review_items_file=<abs-path> directly to GITHUB_OUTPUT.
# Verify the file actually exists so downstream steps don't
# silently read an empty path.
if [ ! -f review-items.txt ]; then
echo "::warning::review-items.txt not found despite exit code 3 — sync script may have a bug"
fi
else
echo "Sync failed with exit code $EXIT_CODE"
exit 1
fi
# Generate a token from copilotkit-devops-bot (bypass actor on branch protection)
- name: Generate bot token
id: bot-token
if: steps.sync.outputs.action == 'auto_push' || steps.sync.outputs.action == 'push_and_pr'
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: 1108748
private-key: ${{ secrets.DEVOPS_BOT_PRIVATE_KEY }}
permission-contents: write
permission-pull-requests: write
# Create PR. For action=auto_push (no review items) the PR is
# auto-merged via the devops bot (bypasses branch protection). For
# action=push_and_pr the sync script has already written best-effort
# 3-way merged content to disk — this step commits it and opens a PR
# tagged [NEEDS REVIEW]. Auto-merge is DISABLED for needs-review PRs
# so a human reconciles any upstream-wins overrides.
- name: Create PR for docs sync
id: push
if: steps.sync.outputs.action == 'auto_push' || steps.sync.outputs.action == 'push_and_pr'
env:
GH_TOKEN: ${{ steps.bot-token.outputs.token }}
ACTION: ${{ steps.sync.outputs.action }}
REVIEW_ITEMS_FILE: ${{ steps.sync.outputs.review_items_file }}
run: |
# Trap-based cleanup so temp files are removed even on signal kill.
CLEANUP_FILES=()
cleanup() {
for f in "${CLEANUP_FILES[@]}"; do
[ -n "$f" ] && [ -e "$f" ] && rm -f "$f" || true
done
}
trap cleanup EXIT INT TERM
git config user.name "copilotkit-devops-bot[bot]"
git config user.email "copilotkit-devops-bot[bot]@users.noreply.github.com"
SHORT_SHA=$(git rev-parse --short origin/main)
if [ "$ACTION" = "push_and_pr" ]; then
# Dedupe against open needs-review PRs. If one already exists,
# skip PR creation and let the Slack alert point at the existing
# one (simpler than re-pushing to its branch).
#
# NOTE: `gh pr list --search "head:..."` is NOT supported —
# GitHub's PR search syntax has no `head:` qualifier, so that
# query always returns empty. Use jq on the full list instead
# to filter by headRefName prefix client-side.
# Fail the step on gh API failure rather than swallowing it —
# `|| echo ""` would silently treat a transient 5xx as "no
# existing PR" and open a duplicate. Retry once with backoff
# to absorb blips; if both attempts fail, exit non-zero.
gh_pr_list_existing() {
gh pr list \
--state open \
--base "$SHOWCASE_BRANCH" \
--json number,url,headRefName \
--jq '[.[] | select(.headRefName | startswith("docs-sync/needs-review/"))] | .[0].url'
}
if ! EXISTING_PR=$(gh_pr_list_existing 2>gh-err.txt); then
echo "::warning::gh pr list failed on first attempt — retrying after 5s"
cat gh-err.txt || true
sleep 5
if ! EXISTING_PR=$(gh_pr_list_existing 2>gh-err.txt); then
echo "::error::gh pr list failed twice — aborting to avoid duplicate PR creation"
cat gh-err.txt || true
rm -f gh-err.txt
exit 1
fi
fi
rm -f gh-err.txt
if [ -n "$EXISTING_PR" ]; then
echo "Open needs-review PR already exists: $EXISTING_PR"
echo "Skipping new PR — Slack alert will point at the existing one."
echo "files_changed=0" >> "$GITHUB_OUTPUT"
echo "pr_url=${EXISTING_PR}" >> "$GITHUB_OUTPUT"
echo "pr_opened=false" >> "$GITHUB_OUTPUT"
echo "needs_review=true" >> "$GITHUB_OUTPUT"
echo "existing_pr=true" >> "$GITHUB_OUTPUT"
exit 0
fi
BRANCH="docs-sync/needs-review/${SHORT_SHA}-$(date +%s)"
COMMIT_SUBJECT="chore: docs sync from main — needs review ($(date +%Y-%m-%d))"
else
BRANCH="docs-sync/auto/${SHORT_SHA}-$(date +%s)"
COMMIT_SUBJECT="chore: auto-sync docs from main ($(date +%Y-%m-%d))"
fi
git checkout -b "$BRANCH"
# For push_and_pr: apply the conflict manifest FIRST (upstream-wins
# content for conflicted files, written ONLY to the PR branch so
# the main branch worktree stays as-is and future sync runs still
# flag those files for review until this PR is merged).
#
# CRITICAL: This MUST run BEFORE `git add` below. `git add` stages
# the current working tree, so any manifest files written after
# that point would be left un-staged and never make it into the
# commit.
if [ "$ACTION" = "push_and_pr" ] && [ -f conflict-manifest.json ]; then
CLEANUP_FILES+=("conflict-manifest.json")
node -e '
const fs = require("fs");
const path = require("path");
const manifest = JSON.parse(fs.readFileSync("conflict-manifest.json", "utf-8"));
for (const entry of manifest) {
const target = path.resolve(entry.showcasePath);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, entry.content);
console.log("Applied upstream-wins to PR branch: " + entry.showcasePath);
}
'
fi
# Stage AFTER manifest has been applied so conflicted files land
# in the commit.
git add showcase/shell-docs/src/content/ showcase/shell-docs/.docs-sync-sha
CHANGED=$(git diff --cached --name-only | wc -l | tr -d ' ')
if [ "$CHANGED" = "0" ]; then
echo "Nothing to commit — no PR will be opened (deliberate)"
echo "files_changed=0" >> "$GITHUB_OUTPUT"
# Explicit signal: no PR opened, and this is the intended outcome
# (not an error path). Downstream alerts key on this.
echo "pr_opened=false" >> "$GITHUB_OUTPUT"
# Set needs_review explicitly so downstream `needs_review != 'true'`
# gates (notify-auto-sync) don't misfire on this deliberate
# no-op. Value depends on why we got here: push_and_pr path =
# review still pending; auto_push path = no review needed.
if [ "$ACTION" = "push_and_pr" ]; then
echo "needs_review=true" >> "$GITHUB_OUTPUT"
else
echo "needs_review=false" >> "$GITHUB_OUTPUT"
fi
exit 0
fi
git commit --no-verify -m "$COMMIT_SUBJECT"
# Override git credential to use bot token (checkout configured GITHUB_TOKEN)
git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git"
git push origin "$BRANCH"
if [ "$ACTION" = "push_and_pr" ]; then
# Build the PR body from review-items.txt plus a clear callout.
REVIEW_ITEMS_CONTENT="(no review-items file produced)"
if [ -n "${REVIEW_ITEMS_FILE:-}" ] && [ -f "$REVIEW_ITEMS_FILE" ]; then
REVIEW_ITEMS_CONTENT=$(cat "$REVIEW_ITEMS_FILE")
fi
PR_BODY_FILE=$(mktemp)
CLEANUP_FILES+=("$PR_BODY_FILE")
{
printf '%s\n' ':warning: **Docs sync — MANUAL REVIEW REQUIRED**'
printf '\n'
printf '%s\n' 'This PR was auto-opened because the docs-sync script detected'
printf '%s\n' 'showcase-local modifications overlapping with upstream changes.'
printf '\n'
printf '%s\n' 'The script attempted a best-effort 3-way merge:'
printf '\n'
printf '%s\n' '- Where `git merge-file` produced a clean merge, the merged content was written.'
printf '%s\n' '- Where `git merge-file` produced conflict markers, **upstream content was written as-is** and showcase-local modifications were overridden. **Manual review required.**'
printf '\n'
printf '%s\n' '### Review items'
printf '\n'
printf '%s\n' '```'
# printf '%s\n' avoids running command substitution / backticks
# embedded in review-items content (cat "$REVIEW_ITEMS_FILE"
# would also work; printf is equivalent here since we already
# captured the content).
printf '%s\n' "$REVIEW_ITEMS_CONTENT"
printf '%s\n' '```'
printf '\n'
printf '%s\n' '### Source'
printf '\n'
printf '%s\n' "- Upstream ref: [\`${SHORT_SHA}\`](https://github.com/${{ github.repository }}/commit/${SHORT_SHA})"
printf '%s\n' "- Workflow run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
printf '\n'
printf '%s\n' '**Review before merging.** Auto-merge is intentionally disabled'
printf '%s\n' 'for `needs-review` PRs — confirm the upstream-wins sections'
printf '%s\n' 'preserve any intentional showcase-local divergence you want to'
printf '%s\n' 'keep, then merge manually.'
} > "$PR_BODY_FILE"
PR_URL=$(gh pr create \
--title "docs-sync(needs-review): sync from main (${SHORT_SHA}) [NEEDS REVIEW]" \
--body-file "$PR_BODY_FILE" \
--base "$SHOWCASE_BRANCH" \
--head "$BRANCH")
echo "Created NEEDS-REVIEW PR: $PR_URL"
echo "files_changed=${CHANGED}" >> "$GITHUB_OUTPUT"
echo "pr_url=${PR_URL}" >> "$GITHUB_OUTPUT"
# Only set after URL captured — error paths leave this unset so
# downstream alerts fall through to failure().
echo "pr_opened=true" >> "$GITHUB_OUTPUT"
echo "needs_review=true" >> "$GITHUB_OUTPUT"
# Intentionally no `gh pr merge` — human must review & merge.
else
PR_URL=$(gh pr create \
--title "chore: auto-sync docs from main (${SHORT_SHA})" \
--body "Automated docs sync. Clean transforms / clean 3-way merges only." \
--base "$SHOWCASE_BRANCH" \
--head "$BRANCH")
echo "Created PR: $PR_URL"
echo "files_changed=${CHANGED}" >> "$GITHUB_OUTPUT"
echo "pr_url=${PR_URL}" >> "$GITHUB_OUTPUT"
echo "pr_opened=true" >> "$GITHUB_OUTPUT"
echo "needs_review=false" >> "$GITHUB_OUTPUT"
gh pr merge "$PR_URL" --merge
fi
# Build all Slack payloads via jq into tmpfiles. This guarantees any
# review-item filename containing ", \, or control chars is safely
# JSON-escaped (never string-interpolated into a JSON literal).
- name: Build Slack payloads
id: payloads
if: always()
env:
RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
PR_URL: ${{ steps.push.outputs.pr_url }}
FILES_CHANGED: ${{ steps.push.outputs.files_changed }}
REVIEW_ITEMS_FILE: ${{ steps.sync.outputs.review_items_file }}
SYNC_OUTCOME: ${{ steps.sync.outcome }}
BOT_TOKEN_OUTCOME: ${{ steps.bot-token.outcome }}
PUSH_OUTCOME: ${{ steps.push.outcome }}
run: |
set -euo pipefail
mkdir -p slack-payloads
# auto-sync success (PR merged)
jq -n \
--arg pr_url "${PR_URL:-}" \
--arg files "${FILES_CHANGED:-?}" \
--arg run_url "$RUN_URL" \
'{text: (":arrows_counterclockwise: *Docs sync*: auto-merged " + $files + " file(s)\n" + $pr_url + "\n<" + $run_url + "|View run>")}' \
> slack-payloads/auto-sync.json
# merge failed (PR exists but gh pr merge failed)
jq -n \
--arg pr_url "${PR_URL:-}" \
--arg run_url "$RUN_URL" \
'{text: (":warning: *Docs sync*: PR created but auto-merge FAILED — needs manual merge\n" + $pr_url + "\n<" + $run_url + "|View run>")}' \
> slack-payloads/merge-failed.json
# review-needed payloads: read items from file, let jq handle escaping
REVIEW_ITEMS=""
if [ -n "${REVIEW_ITEMS_FILE:-}" ] && [ -f "$REVIEW_ITEMS_FILE" ]; then
REVIEW_ITEMS=$(cat "$REVIEW_ITEMS_FILE")
fi
jq -n \
--arg items "$REVIEW_ITEMS" \
--arg pr_url "${PR_URL:-}" \
--arg run_url "$RUN_URL" \
'{text: (":warning: *Docs sync*: auto-opened *NEEDS REVIEW* PR (best-effort 3-way merge; upstream-wins where conflicts) — human must review + merge\n```" + $items + "```\nReview: " + $pr_url + "\n<" + $run_url + "|View run>")}' \
> slack-payloads/review-with-pr.json
# Collision path: new review items flagged but an existing open
# needs-review PR already covers the territory; we did NOT open a
# new PR. Point reviewers at the existing one.
jq -n \
--arg items "$REVIEW_ITEMS" \
--arg pr_url "${PR_URL:-}" \
--arg run_url "$RUN_URL" \
'{text: (":warning: *Docs sync*: new review items detected, but an open *NEEDS REVIEW* PR already exists — skipped new PR creation to avoid collision. Please resolve the existing PR.\n```" + $items + "```\nExisting PR: " + $pr_url + "\n<" + $run_url + "|View run>")}' \
> slack-payloads/review-existing-pr.json
# Fallback path: review items flagged but no PR opened (e.g. 3-way
# merge produced bit-for-bit identical content to what's already on
# disk so nothing to commit). Keep an alert so it's visible.
jq -n \
--arg items "$REVIEW_ITEMS" \
--arg run_url "$RUN_URL" \
'{text: (":warning: *Docs sync*: review items flagged but produced no diff (no PR opened)\n```" + $items + "```\n<" + $run_url + "|View run>")}' \
> slack-payloads/review-no-pr.json
# failure alert
FAILED_STEP="unknown"
if [ "${SYNC_OUTCOME:-}" = "failure" ]; then
FAILED_STEP="sync-docs script"
elif [ "${BOT_TOKEN_OUTCOME:-}" = "failure" ]; then
FAILED_STEP="bot token generation (check DEVOPS_BOT_PRIVATE_KEY secret)"
elif [ "${PUSH_OUTCOME:-}" = "failure" ]; then
FAILED_STEP="push/PR creation"
fi
jq -n \
--arg failed_step "$FAILED_STEP" \
--arg run_url "$RUN_URL" \
'{text: (":x: *Docs sync*: workflow failed\n*Failed step:* " + $failed_step + " | <" + $run_url + "|View run>")}' \
> slack-payloads/failure.json
- name: Notify Slack (auto-sync)
id: notify-auto-sync
if: always() && env.SLACK_WEBHOOK != '' && steps.push.outcome == 'success' && steps.push.outputs.files_changed != '0' && steps.push.outputs.needs_review != 'true'
uses: slackapi/slack-github-action@b0fa283ad8fea605de13dc3f449259339835fc52 # v2.1.0
with:
webhook: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
webhook-type: incoming-webhook
payload-file-path: slack-payloads/auto-sync.json
- name: Notify Slack (merge failed)
id: notify-merge-failed
if: failure() && env.SLACK_WEBHOOK != '' && steps.push.outputs.pr_opened == 'true' && steps.push.outputs.needs_review != 'true'
uses: slackapi/slack-github-action@b0fa283ad8fea605de13dc3f449259339835fc52 # v2.1.0
with:
webhook: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
webhook-type: incoming-webhook
payload-file-path: slack-payloads/merge-failed.json
# Review items = files the sync script flagged but did NOT write to disk
# (local modifications, files deleted on main). A PR was opened for the
# clean-transform portion — link it so reviewers can click through.
# Gated on pr_opened == 'true' so it only fires when we actually have a
# PR URL (not on any error path that leaves pr_url empty).
- name: Notify Slack (review needed, with PR)
id: notify-review-with-pr
if: always() && env.SLACK_WEBHOOK != '' && steps.push.outputs.needs_review == 'true' && steps.push.outputs.pr_opened == 'true'
uses: slackapi/slack-github-action@b0fa283ad8fea605de13dc3f449259339835fc52 # v2.1.0
with:
webhook: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
webhook-type: incoming-webhook
payload-file-path: slack-payloads/review-with-pr.json
# Review items but the clean-transform portion was empty so no PR was
# opened. Gated on pr_opened == 'false' (deliberate no-PR path) — not
# empty pr_url, which would also match error paths. Explicitly excludes
# the existing-PR collision path, which gets its own step below.
- name: Notify Slack (review needed, no PR)
id: notify-review-no-pr
if: always() && env.SLACK_WEBHOOK != '' && steps.sync.outputs.action == 'push_and_pr' && steps.push.outputs.pr_opened == 'false' && steps.push.outputs.existing_pr != 'true'
uses: slackapi/slack-github-action@b0fa283ad8fea605de13dc3f449259339835fc52 # v2.1.0
with:
webhook: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
webhook-type: incoming-webhook
payload-file-path: slack-payloads/review-no-pr.json
# Collision path: new review items flagged but an existing open
# needs-review PR already exists — point reviewers at it.
- name: Notify Slack (review needed, existing PR)
id: notify-review-existing-pr
if: always() && env.SLACK_WEBHOOK != '' && steps.push.outputs.existing_pr == 'true'
uses: slackapi/slack-github-action@b0fa283ad8fea605de13dc3f449259339835fc52 # v2.1.0
with:
webhook: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
webhook-type: incoming-webhook
payload-file-path: slack-payloads/review-existing-pr.json
- name: Notify Slack (failure)
id: notify-failure
if: failure() && env.SLACK_WEBHOOK != '' && steps.push.outputs.pr_opened != 'true'
uses: slackapi/slack-github-action@b0fa283ad8fea605de13dc3f449259339835fc52 # v2.1.0
with:
webhook: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
webhook-type: incoming-webhook
payload-file-path: slack-payloads/failure.json
# Unconditional fallback: if any notify-* step above failed (webhook 5xx,
# rate limit, malformed payload), fire a plain-text alert so we never
# silently lose a review-needed or failure notification. Uses curl
# directly so it doesn't share failure modes with the slackapi action.
# Payload is inlined (not read from slack-payloads/) so this fallback
# has no dependency on the Build Slack payloads step succeeding — if
# that step broke (jq missing, mkdir failed, etc), every notify-* step
# would fail AND the fallback could not read its file. RUN_URL is
# constructed from GitHub-controlled env vars only (no user input), so
# direct string interpolation into the JSON literal is safe — the
# values cannot contain " or \.
- name: Notify Slack (alert machinery failed)
if: >-
always() && env.SLACK_WEBHOOK != '' && (
steps.notify-auto-sync.outcome == 'failure' ||
steps.notify-merge-failed.outcome == 'failure' ||
steps.notify-review-with-pr.outcome == 'failure' ||
steps.notify-review-no-pr.outcome == 'failure' ||
steps.notify-review-existing-pr.outcome == 'failure' ||
steps.notify-failure.outcome == 'failure'
)
env:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
run: |
set -eu
if [ -z "${SLACK_WEBHOOK:-}" ]; then
echo "::warning::SLACK_WEBHOOK_OSS_ALERTS not set — cannot post fallback alert"
exit 0
fi
RUN_URL="https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
curl -sS -X POST \
-H "Content-Type: application/json" \
--data "{\"text\": \":rotating_light: *Docs sync*: review/alert machinery failed — check Actions UI ${RUN_URL}\"}" \
"$SLACK_WEBHOOK" || echo "::warning::fallback Slack post also failed"