|
| 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