Skip to content

Commit d137064

Browse files
DonalMepascalchevrel
authored andcommitted
Send a slack message to notify of the scheduled ios release
1 parent c93888f commit d137064

2 files changed

Lines changed: 270 additions & 0 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
name: Firefox iOS release Slack announcement
2+
3+
on:
4+
schedule:
5+
# Mondays at 04:00 UTC — two hours after the 02:00 UTC rollout slot,
6+
# which gives the App Store time to flip to the new version.
7+
- cron: '0 4 * * 1'
8+
workflow_dispatch:
9+
inputs:
10+
dry_run:
11+
description: 'Print the message instead of posting to Slack'
12+
required: false
13+
type: boolean
14+
default: true
15+
test_date:
16+
description: 'Pretend today is this date (YYYY-MM-DD), e.g. 2026-07-27'
17+
required: false
18+
type: string
19+
20+
jobs:
21+
announce:
22+
runs-on: ubuntu-latest
23+
timeout-minutes: 30
24+
permissions:
25+
contents: read
26+
steps:
27+
- uses: actions/checkout@v5
28+
with:
29+
persist-credentials: false
30+
31+
- uses: actions/setup-python@v5
32+
with:
33+
python-version: '3.12'
34+
35+
- name: Announce the iOS release in Slack if the App Store confirms it is live
36+
env:
37+
SLACK_WEBHOOK_URL: ${{ secrets.IOS_RELEASE_SLACK_WEBHOOK }}
38+
RELMAN_WEBHOOK_URL: ${{ secrets.RELMAN_SLACK_WEBHOOK }}
39+
DRY_RUN: ${{ inputs.dry_run }}
40+
TEST_DATE: ${{ inputs.test_date }}
41+
run: python3 app/scripts/ios_release_slack_notifier.py
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Announces a Firefox iOS release in Slack once the App Store confirms it is live.
4+
Firefox iOS scheduled releases are always scheduled to rollout at the same time at
5+
02:00 UTC on Mondays.
6+
7+
Run weekly by .github/workflows/ios-release-slack.yml, a couple of hours after the
8+
02:00 UTC Monday scheduled rollout.
9+
10+
Two independent sources have to agree before anything is posted:
11+
1. The iOS schedule says a release was due today (so we know which version).
12+
2. The App Store says that exact version is the one now live.
13+
14+
That means a delayed or pulled rollout is never announced as live. When a release
15+
was due but has not shipped, release management get a heads-up instead.
16+
17+
Two webhooks, set from repo secrets in the workflow:
18+
SLACK_WEBHOOK_URL announcement channel (IOS_RELEASE_SLACK_WEBHOOK)
19+
RELMAN_WEBHOOK_URL release management channel (RELMAN_SLACK_WEBHOOK), used
20+
only when a scheduled release has not actually shipped
21+
22+
Env vars for testing:
23+
DRY_RUN=1 print the message instead of posting it. Accepts
24+
1/true/yes/on to enable and 0/false/no/off or empty to
25+
disable; anything else is an error rather than a guess.
26+
TEST_DATE=Y-m-d pretend today is this date (e.g. 2026-07-27)
27+
"""
28+
import json
29+
import os
30+
import sys
31+
import time
32+
import urllib.error
33+
import urllib.request
34+
from datetime import date, datetime, timezone
35+
36+
SCHEDULE_URL = "https://whattrainisitnow.com/api/release/schedule/?version=nightly"
37+
IOS_SCHEDULE_URL = "https://whattrainisitnow.com/api/release/schedule/ios/?version={}"
38+
# Firefox iOS App Store listing
39+
ITUNES_URL = "https://itunes.apple.com/lookup?id=989804926&country=us"
40+
41+
MESSAGE = "Firefox iOS v{version} is live with a phased rollout as of {rolled_out}"
42+
43+
MISMATCH_MESSAGE = (
44+
"Firefox iOS v{expected} was scheduled to roll out today, but the App Store is "
45+
"showing v{live} (live since {rolled_out}). Investigate why v{expected} is not live."
46+
)
47+
48+
# The shipping iOS major is behind the current Nightly by two versions. Desktop merge day is a
49+
# Thursday, so by the Monday rollout Nightly has always moved on.
50+
NIGHTLY_OFFSET = 2
51+
52+
# The job runs once a week, so a few seconds of waiting is cheap next to missing
53+
# an announcement because whattrainisitnow or the iTunes API blipped.
54+
TIMEOUT_SECONDS = 15
55+
RETRIES = 3
56+
BACKOFF_SECONDS = 5
57+
58+
# HTTPError subclasses URLError, so this covers HTTP errors, connection failures
59+
# and timeouts. JSONDecodeError catches an error page served in place of JSON.
60+
RETRYABLE = (urllib.error.URLError, TimeoutError, json.JSONDecodeError)
61+
62+
# Env vars are strings, and every non-empty string is truthy, so bool("0") is True.
63+
# Spell the accepted values out rather than let DRY_RUN=false enable a dry run.
64+
TRUTHY = frozenset({"1", "true", "yes", "on"})
65+
FALSEY = frozenset({"", "0", "false", "no", "off"})
66+
67+
68+
def env_flag(name: str) -> bool:
69+
"""Parse a boolean env var. An unrecognised value is an error, not a guess."""
70+
value = os.environ.get(name, "").strip().lower()
71+
if value in TRUTHY:
72+
return True
73+
if value in FALSEY:
74+
return False
75+
raise ValueError(f"{name} must be a boolean-ish value, got {value!r}")
76+
77+
78+
def fetch_json(url: str) -> dict:
79+
"""GET JSON, retrying transient failures. A 4xx other than 429 fails at once."""
80+
for attempt in range(1, RETRIES + 1):
81+
try:
82+
with urllib.request.urlopen(url, timeout=TIMEOUT_SECONDS) as resp:
83+
return json.load(resp)
84+
except RETRYABLE as error:
85+
# A bad URL or a rejected request will not fix itself on a retry.
86+
permanent = (
87+
isinstance(error, urllib.error.HTTPError)
88+
and error.code != 429
89+
and error.code < 500
90+
)
91+
if permanent or attempt == RETRIES:
92+
raise
93+
wait = BACKOFF_SECONDS * attempt
94+
print(
95+
f"{url} failed ({error}); retry {attempt}/{RETRIES - 1} in {wait}s",
96+
file=sys.stderr,
97+
)
98+
time.sleep(wait)
99+
100+
raise AssertionError("unreachable: the loop either returns or raises")
101+
102+
103+
def parse_date(value: str) -> date:
104+
"""Parse API date string (e.g. '2026-07-27 02:00:00+00:00') to date object."""
105+
return datetime.strptime(value.split(" ")[0], "%Y-%m-%d").date()
106+
107+
108+
def get_ios_major() -> int:
109+
"""The iOS major currently shipping, derived from the Nightly version."""
110+
nightly = fetch_json(SCHEDULE_URL)
111+
return int(nightly["version"].split(".")[0]) - NIGHTLY_OFFSET
112+
113+
114+
def expected_version(major: int, today: date) -> str | None:
115+
"""
116+
The version due to ship today, or None if no release is scheduled.
117+
118+
release_N maps to {major}.N — release_0 for major 153 is 153.0, release_1 for major 153 is 153.1
119+
120+
Dot releases shipped inbetween release_0 and release_1 use the pattern {major}.N.X, for example 153.0.1
121+
These versions are not covered by the slack notification automation since they are ad-hoc
122+
"""
123+
schedule = fetch_json(IOS_SCHEDULE_URL.format(major))
124+
125+
# Sorted numerically once, so the lookup and the log line cannot disagree:
126+
# a plain sort would order release_10 before release_2.
127+
releases = sorted(
128+
((k, v) for k, v in schedule.items() if k.startswith("release_")),
129+
key=lambda kv: int(kv[0].split("_")[1]),
130+
)
131+
if not releases:
132+
print(f"No release_* milestones for iOS {major} — nothing scheduled.")
133+
return None
134+
135+
for key, value in releases:
136+
if parse_date(value) == today:
137+
dot = key.split("_")[1]
138+
return f"{major}.{dot}"
139+
140+
dates = ", ".join(f"{k}={v[:10]}" for k, v in releases)
141+
print(f"{today}: no iOS {major} release due today ({dates}).")
142+
return None
143+
144+
145+
def live_release() -> tuple[str, datetime]:
146+
"""The version currently live on the App Store, and when its rollout began."""
147+
data = fetch_json(ITUNES_URL)
148+
if not data.get("results"):
149+
raise RuntimeError("iTunes lookup returned no results for Firefox iOS.")
150+
app = data["results"][0]
151+
rolled_out = datetime.strptime(
152+
app["currentVersionReleaseDate"], "%Y-%m-%dT%H:%M:%SZ"
153+
).replace(tzinfo=timezone.utc)
154+
return app["version"], rolled_out
155+
156+
157+
def format_rollout(when: datetime) -> str:
158+
"""Render the rollout time for Slack, e.g. '02:11 UTC on 2026-07-27'."""
159+
return f"{when:%H:%M} UTC on {when:%Y-%m-%d}"
160+
161+
162+
def post_to_slack(webhook_url: str, text: str) -> None:
163+
"""Post a message to Slack via incoming webhook."""
164+
body = json.dumps({"text": text}).encode("utf-8")
165+
req = urllib.request.Request(
166+
webhook_url,
167+
data=body,
168+
headers={"Content-Type": "application/json"},
169+
method="POST",
170+
)
171+
with urllib.request.urlopen(req, timeout=15) as resp:
172+
if resp.status >= 300:
173+
raise RuntimeError(f"Slack webhook returned HTTP {resp.status}")
174+
175+
176+
def main() -> int:
177+
dry_run = env_flag("DRY_RUN")
178+
webhook_url = os.environ.get("SLACK_WEBHOOK_URL")
179+
relman_webhook_url = os.environ.get("RELMAN_WEBHOOK_URL")
180+
if not webhook_url and not dry_run:
181+
print("SLACK_WEBHOOK_URL is not set — nothing to do.", file=sys.stderr)
182+
return 1
183+
184+
test_date = os.environ.get("TEST_DATE")
185+
if test_date:
186+
today = datetime.strptime(test_date, "%Y-%m-%d").date()
187+
print(f"TEST MODE: pretending today is {today}")
188+
else:
189+
# Explicitly UTC: release_* milestones are UTC and runners may not be.
190+
today = datetime.now(timezone.utc).date()
191+
192+
major = get_ios_major()
193+
expected = expected_version(major, today)
194+
if expected is None:
195+
return 0
196+
197+
live, rolled_out = live_release()
198+
if live != expected:
199+
# Scheduled but not live: rollout delayed, pulled, or the version we
200+
# derived is wrong. Release management want to know either way.
201+
print(f"{today}: iOS {expected} was due today but the App Store shows {live}.")
202+
alert = MISMATCH_MESSAGE.format(
203+
expected=expected, live=live, rolled_out=format_rollout(rolled_out)
204+
)
205+
if dry_run:
206+
print(f"DRY RUN — would alert release management:\n{alert}")
207+
return 0
208+
if not relman_webhook_url:
209+
print(
210+
"RELMAN_WEBHOOK_URL is not set — cannot alert release management.",
211+
file=sys.stderr,
212+
)
213+
return 1
214+
post_to_slack(relman_webhook_url, alert)
215+
print(f"Alerted release management that iOS {expected} has not shipped.")
216+
return 0
217+
218+
text = MESSAGE.format(version=live, rolled_out=format_rollout(rolled_out))
219+
if dry_run:
220+
print(f"DRY RUN — would post:\n{text}")
221+
return 0
222+
223+
post_to_slack(webhook_url, text)
224+
print(f"{today}: posted release announcement for Firefox iOS {live}.")
225+
return 0
226+
227+
228+
if __name__ == "__main__":
229+
raise SystemExit(main())

0 commit comments

Comments
 (0)