Skip to content

Commit 0f4e12e

Browse files
authored
fix: retry the alert on API errors (#22)
`alert.yml` gave up on the first GitHub API error, and in `tibia-sh/mcp.tibia.sh` two alerts were lost that way on 2026-10-30, in runs [`41071296550`](https://github.com/tibia-sh/mcp.tibia.sh/actions/runs/41071296550) and [`41071513666`](https://github.com/tibia-sh/mcp.tibia.sh/actions/runs/41071513666). It now tries 5 times, 30, 60, 120 and 240 seconds apart, with the job bounded at 15 minutes. Every try lists the open issues again, and a failed list writes nothing, so a retry never opens a second issue. Same change as tibia-sh/tibiawiki-mcp#96.
2 parents d992334 + 1056813 commit 0f4e12e

2 files changed

Lines changed: 192 additions & 16 deletions

File tree

‎.github/workflows/alert.yml‎

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ jobs:
2424
github.event.workflow_run.conclusion != 'neutral' &&
2525
github.event.workflow_run.event != 'pull_request'
2626
runs-on: ubuntu-latest
27-
timeout-minutes: 5
27+
timeout-minutes: 15
2828
permissions:
2929
issues: write
3030
concurrency:
@@ -35,17 +35,30 @@ jobs:
3535
# No checkout: gh alone, on this repository, with GITHUB_TOKEN. The event's fields reach the shell through env.
3636
# The issue is found by its exact title in the open issues, not by a search, whose index can lag behind an issue
3737
# opened moments ago.
38+
# One GitHub API error would drop the alert, so write_alert is tried 5 times, 30, 60, 120 and 240 seconds apart,
39+
# which the job's 15 minutes leave room for, with their API calls. A failed list ends the try, so it never
40+
# comments or opens on a list it did not read. bash ignores set -e in a function called before &&, so each command
41+
# that can fail returns by itself. Each try lists again, so an issue opened before its error is found and
42+
# commented on: never a second issue, at worst a repeated comment, when one went through before its error.
3843
- name: Comment on the issue, or open it
3944
run: |
4045
title='Automation needs a look'
4146
body="$WORKFLOW $CONCLUSION: $RUN_URL"
42-
number=$(TITLE=$title gh issue list --state open --limit 1000 --json number,title \
43-
--jq 'map(select(.title == env.TITLE) | .number) | min // empty')
44-
if [[ -n $number ]]; then
45-
gh issue comment "$number" --body "$body"
46-
else
47-
gh issue create --title "$title" --assignee drptbl --body "$body"
48-
fi
47+
write_alert() {
48+
number=$(TITLE=$title gh issue list --state open --limit 1000 --json number,title \
49+
--jq 'map(select(.title == env.TITLE) | .number) | min // empty') || return 1
50+
if [[ -n $number ]]; then
51+
gh issue comment "$number" --body "$body" || return 1
52+
else
53+
gh issue create --title "$title" --assignee drptbl --body "$body" || return 1
54+
fi
55+
}
56+
for delay in 30 60 120 240; do
57+
write_alert && exit 0
58+
echo "::warning::Writing the alert issue failed. Trying again in $delay seconds."
59+
sleep "$delay"
60+
done
61+
write_alert
4962
env:
5063
GH_TOKEN: ${{ github.token }}
5164
GH_REPO: ${{ github.repository }}

‎tests/test_release_pr.py‎

Lines changed: 171 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
import json
33
import os
44
import re
5+
import subprocess
56
import tempfile
7+
import textwrap
68
import unittest
79
from contextlib import redirect_stderr, redirect_stdout
810
from pathlib import Path
@@ -75,6 +77,16 @@ def job(workflow: str, name: str) -> str:
7577
return "\n".join(lines[start:end]) + "\n"
7678

7779

80+
def step_script(workflow: str, name: str) -> str:
81+
"""The script of a job's one ``run: |`` step, dedented as the runner hands it to the shell."""
82+
lines = job(workflow, name).splitlines()
83+
start = next(i for i, line in enumerate(lines) if line.strip() == "run: |")
84+
indent = len(lines[start]) - len(lines[start].lstrip())
85+
end = next((i for i in range(start + 1, len(lines))
86+
if lines[i].strip() and len(lines[i]) - len(lines[i].lstrip()) <= indent), len(lines))
87+
return textwrap.dedent("\n".join(lines[start + 1:end])).strip("\n") + "\n"
88+
89+
7890
class TestNextVersion(unittest.TestCase):
7991
def test_counts_up_from_the_tagged_version(self):
8092
tags = ["v9.0.0+tibiash.1", "v9.0.0+tibiash.2"]
@@ -897,6 +909,75 @@ def test_downstream_dispatches_with_a_contents_token(self):
897909

898910
class TestAlertWorkflow(WorkflowTestCase):
899911
WORKFLOW = "alert.yml"
912+
# What the step gets through env: the watched run's fields, the repository and github.token.
913+
ENV: ClassVar[dict[str, str]] = {
914+
"GH_TOKEN": "ghs_alert",
915+
"GH_REPO": "tibia-sh/tibiawiki-sql",
916+
"WORKFLOW": "Release",
917+
"CONCLUSION": "failure",
918+
"RUN_URL": "https://github.com/tibia-sh/tibiawiki-sql/actions/runs/99",
919+
}
920+
# The open issue the alert comments on, and two it passes over: a later one with its title, and another title.
921+
ISSUES: ClassVar[list[dict]] = [
922+
{"number": 31, "title": "Automation needs a look"},
923+
{"number": 30, "title": "Automation needs a look"},
924+
{"number": 12, "title": "Automation needs a look again"},
925+
]
926+
OPEN = ("gh issue create --title Automation needs a look --assignee drptbl"
927+
" --body Release failure: https://github.com/tibia-sh/tibiawiki-sql/actions/runs/99")
928+
# A stand-in gh that logs each call to $FAKE/events. `issue list ... --jq FILTER` runs the real jq with FILTER over
929+
# $FAKE/issues, as gh does over the fields it fetched. `issue comment N` and `issue create` print the URL gh prints,
930+
# and create opens issue 40. Call N of a kind exits with line N of $FAKE/<kind>-exits, its last line once they run
931+
# out, or 0 without the file. A failed call prints a GraphQL error. A failed list still prints what it found, so a
932+
# step that went on would act on it. A create lands even when it fails, as one whose answer GitHub lost does, so
933+
# every list after it finds issue 40.
934+
FAKE_GH = r"""#!/usr/bin/env bash
935+
here="$FAKE"
936+
echo "gh $*" >> "$here/events"
937+
if [ -z "$GH_REPO" ] || [ -z "$GH_TOKEN" ]; then
938+
echo "fake gh: no GH_REPO or GH_TOKEN" >&2
939+
exit 2
940+
fi
941+
kind="$2"
942+
if [ "$#" -eq 10 ] && [ "$1 $2 $3 $4 $5" = "issue list --state open --limit" ] \
943+
&& [ "$6 $7 $8 $9" = "1000 --json number,title --jq" ]; then
944+
:
945+
elif [ "$#" -eq 5 ] && [ "$1 $2" = "issue comment" ] && [ "$4" = --body ]; then
946+
case "$3" in
947+
'' | *[!0-9]*) echo "fake gh: unsupported issue $3" >&2; exit 2 ;;
948+
esac
949+
elif [ "$#" -eq 8 ] && [ "$1 $2 $3" = "issue create --title" ] && [ "$5 $6 $7" = "--assignee drptbl --body" ]; then
950+
jq -n --arg title "$4" '{number: 40, title: $title}' > "$here/opened"
951+
else
952+
echo "fake gh: unsupported command: $*" >&2
953+
exit 2
954+
fi
955+
count=$(grep -c "^gh issue $kind " "$here/events")
956+
code=0
957+
if [ -e "$here/$kind-exits" ]; then
958+
code=$(sed -n "${count}p" "$here/$kind-exits")
959+
[ -n "$code" ] || code=$(tail -n 1 "$here/$kind-exits")
960+
fi
961+
case "$kind" in
962+
list)
963+
if [ -e "$here/opened" ]; then
964+
jq -c --slurpfile opened "$here/opened" '. + $opened' "$here/issues"
965+
else
966+
cat "$here/issues"
967+
fi | jq -r "${10}"
968+
;;
969+
comment) [ "$code" -ne 0 ] || echo "https://github.com/$GH_REPO/issues/$3#issuecomment-1" ;;
970+
create) [ "$code" -ne 0 ] || echo "https://github.com/$GH_REPO/issues/40" ;;
971+
esac
972+
if [ "$code" -ne 0 ]; then
973+
echo 'GraphQL: Something went wrong while executing your query' >&2
974+
fi
975+
exit "$code"
976+
"""
977+
# A stand-in sleep that logs its seconds to $FAKE/sleeps and returns at once.
978+
FAKE_SLEEP = """#!/usr/bin/env bash
979+
echo "$*" >> "$FAKE/sleeps"
980+
"""
900981

901982
def test_watches_the_automation_workflows_by_their_exact_names(self):
902983
self.assertIn("""\
@@ -921,7 +1002,7 @@ def test_comments_on_one_issue_with_github_token(self):
9211002
github.event.workflow_run.conclusion != 'neutral' &&
9221003
github.event.workflow_run.event != 'pull_request'
9231004
runs-on: ubuntu-latest
924-
timeout-minutes: 5
1005+
timeout-minutes: 15
9251006
permissions:
9261007
issues: write
9271008
concurrency:
@@ -933,13 +1014,21 @@ def test_comments_on_one_issue_with_github_token(self):
9331014
run: |
9341015
title='Automation needs a look'
9351016
body="$WORKFLOW $CONCLUSION: $RUN_URL"
936-
number=$(TITLE=$title gh issue list --state open --limit 1000 --json number,title \\
937-
--jq 'map(select(.title == env.TITLE) | .number) | min // empty')
938-
if [[ -n $number ]]; then
939-
gh issue comment "$number" --body "$body"
940-
else
941-
gh issue create --title "$title" --assignee drptbl --body "$body"
942-
fi
1017+
write_alert() {
1018+
number=$(TITLE=$title gh issue list --state open --limit 1000 --json number,title \\
1019+
--jq 'map(select(.title == env.TITLE) | .number) | min // empty') || return 1
1020+
if [[ -n $number ]]; then
1021+
gh issue comment "$number" --body "$body" || return 1
1022+
else
1023+
gh issue create --title "$title" --assignee drptbl --body "$body" || return 1
1024+
fi
1025+
}
1026+
for delay in 30 60 120 240; do
1027+
write_alert && exit 0
1028+
echo "::warning::Writing the alert issue failed. Trying again in $delay seconds."
1029+
sleep "$delay"
1030+
done
1031+
write_alert
9431032
env:
9441033
GH_TOKEN: ${{ github.token }}
9451034
GH_REPO: ${{ github.repository }}
@@ -948,3 +1037,77 @@ def test_comments_on_one_issue_with_github_token(self):
9481037
RUN_URL: ${{ github.event.workflow_run.html_url }}
9491038
""")
9501039
self.assertNotIn("uses:", self.text())
1040+
1041+
def run_alert(self, issues: list[dict], **exits: list[int]) -> dict:
1042+
"""Run the step's script as the job's bash does, with the stand-in gh and sleep first on PATH.
1043+
1044+
``issues`` are the open issues a list finds, and ``exits`` how each call of a kind (list, comment or create)
1045+
exits in turn. jq and everything else is real.
1046+
"""
1047+
with tempfile.TemporaryDirectory() as tmp:
1048+
fake = Path(tmp)
1049+
for name, script in [("gh", self.FAKE_GH), ("sleep", self.FAKE_SLEEP)]:
1050+
(fake / name).write_text(script, encoding="utf-8")
1051+
(fake / name).chmod(0o755)
1052+
(fake / "issues").write_text(json.dumps(issues), encoding="utf-8")
1053+
for kind, codes in exits.items():
1054+
(fake / f"{kind}-exits").write_text("".join(f"{code}\n" for code in codes), encoding="utf-8")
1055+
env = {**self.ENV, "PATH": f"{fake}{os.pathsep}{os.environ['PATH']}", "FAKE": tmp}
1056+
script = step_script(self.WORKFLOW, "alert")
1057+
# bash from PATH runs the workflow's own script, as the runner does.
1058+
run = subprocess.run(["bash", "-e", "-c", script], env=env, cwd=tmp, # noqa: S603, S607
1059+
capture_output=True, text=True, timeout=60, check=False)
1060+
events = (fake / "events").read_text(encoding="utf-8").splitlines()
1061+
sleeps = (fake / "sleeps").read_text(encoding="utf-8").splitlines() if (fake / "sleeps").exists() else []
1062+
return {
1063+
"status": run.returncode,
1064+
"output": run.stdout + run.stderr,
1065+
"warnings": [line for line in run.stdout.splitlines() if line.startswith("::warning::")],
1066+
"lists": [event for event in events if event.startswith("gh issue list ")],
1067+
"comments": [event for event in events if event.startswith("gh issue comment ")],
1068+
"opens": [event for event in events if event.startswith("gh issue create ")],
1069+
"sleeps": sleeps,
1070+
}
1071+
1072+
def comment(self, number: int) -> str:
1073+
return (f"gh issue comment {number}"
1074+
" --body Release failure: https://github.com/tibia-sh/tibiawiki-sql/actions/runs/99")
1075+
1076+
def test_lists_5_times_and_never_writes_when_it_cannot_list_the_open_issues(self):
1077+
# Writing after a failed list would act on a list it could not trust, and could open a second issue beside
1078+
# the first. The stand-in's failed list still prints the issue it found, so a step that went on would comment.
1079+
for issues in [[], self.ISSUES]:
1080+
with self.subTest(issues=issues):
1081+
run = self.run_alert(issues, list=[1])
1082+
self.assertEqual(1, run["status"], run["output"])
1083+
self.assertEqual(5, len(run["lists"]), run["output"])
1084+
self.assertEqual([], run["comments"])
1085+
self.assertEqual([], run["opens"])
1086+
self.assertEqual(["30", "60", "120", "240"], run["sleeps"])
1087+
self.assertEqual([f"::warning::Writing the alert issue failed. Trying again in {delay} seconds."
1088+
for delay in [30, 60, 120, 240]], run["warnings"])
1089+
1090+
def test_comments_once_a_failed_list_finds_the_issue_on_the_next_try(self):
1091+
run = self.run_alert(self.ISSUES, list=[1, 0])
1092+
self.assertEqual(0, run["status"], run["output"])
1093+
self.assertEqual(2, len(run["lists"]))
1094+
self.assertEqual([self.comment(30)], run["comments"])
1095+
self.assertEqual([], run["opens"])
1096+
self.assertEqual(["30"], run["sleeps"])
1097+
1098+
def test_lists_and_comments_again_after_a_comment_fails(self):
1099+
run = self.run_alert(self.ISSUES, comment=[1, 0])
1100+
self.assertEqual(0, run["status"], run["output"])
1101+
self.assertEqual(2, len(run["lists"]))
1102+
self.assertEqual([self.comment(30), self.comment(30)], run["comments"])
1103+
self.assertEqual([], run["opens"])
1104+
self.assertEqual(["30"], run["sleeps"])
1105+
1106+
def test_comments_on_the_issue_a_failed_open_opened_and_never_opens_a_second(self):
1107+
# GitHub can open the issue and still answer with an error. The next try lists again and finds it.
1108+
run = self.run_alert([{"number": 12, "title": "Automation needs a look again"}], create=[1])
1109+
self.assertEqual(0, run["status"], run["output"])
1110+
self.assertEqual(2, len(run["lists"]))
1111+
self.assertEqual([self.OPEN], run["opens"])
1112+
self.assertEqual([self.comment(40)], run["comments"])
1113+
self.assertEqual(["30"], run["sleeps"])

0 commit comments

Comments
 (0)