Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions scripts/update_android_downloads.py
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,11 @@ def parse_releases(source: str, kind: str, accept_all_devsite_expandables: bool
if previous.fingerprint() != release.fingerprint():
raise UpdateError(f"{kind} 出现相同版本但下载元数据不同: {release.name} / {release.date_iso}")
duplicate_count += 1
if "Android Emulator" in kind:
# Archive insertion dates are not monotonic with Emulator revisions
# (37.3.1 was listed after 37.3.3). Keep every validated entry, but
# use numeric revision for pages, summaries and channel selection.
unique.sort(key=_emulator_order_key, reverse=True)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid exceeding the workflow's diff-size guard

On the first run against the existing source/date-ordered gh-pages archive, sorting the entire Emulator history rewrites most historical blocks rather than only correcting the latest entries. Using tests/fixtures/emulator-frame.html to compare the prior rendered order with this order produces 1,023 additions plus 1,023 deletions for android-emulator.html alone, so the subsequent Fail if the update escaped its allowlist step in .github/workflows/update-android-downloads.yml rejects the change because it caps the combined diff at 2,000 lines; the workflow therefore never reaches PR creation. Limit numeric ordering to latest-version selection or coordinate this migration with the workflow guard.

Useful? React with 👍 / 👎.

return unique, duplicate_count


Expand All @@ -367,6 +372,11 @@ def _release_order_key(release: Release) -> tuple[str, tuple[int, ...], str]:
return (release.date_iso, _release_version(release), release.name)


def _emulator_order_key(release: Release) -> tuple[tuple[int, ...], str, str]:
version = _version_from_emulator_name(release.name)
return (_version_key(version), release.date_iso, release.name)


def _release_id(product: str, release: Release) -> str:
version = VERSION_RE.search(release.name)
version_slug = re.sub(r"[^a-z0-9]+", "-", version.group(0).lower()) if version else "unknown"
Expand Down Expand Up @@ -520,6 +530,15 @@ def _validate_transition(old: Sequence[Release], new: Sequence[Release], kind: s
minimum = max(1, int(len(old) * MIN_COUNT_RATIO))
if len(new) < minimum:
raise UpdateError(f"{kind} 条目数异常下降: 旧 {len(old)},新 {len(new)},最低允许 {minimum}")
if kind == "Android Emulator":
# Compare maxima within each channel, including unsorted legacy pages.
# A newer stable/canary must not hide a regression or missing channel.
for channel in {release.channel for release in old}:
previous = max((r for r in old if r.channel == channel), key=_emulator_order_key)
candidates = [r for r in new if r.channel == channel]
if not candidates or max(_emulator_order_key(r)[0] for r in candidates) < _emulator_order_key(previous)[0]:
raise UpdateError(f"{kind} {channel} 最新版本倒退或缺失: 旧 {previous.name}")
return
if _release_order_key(new[0]) < _release_order_key(old[0]):
raise UpdateError(f"{kind} 最新版本倒退: 旧 {old[0].name},新 {new[0].name}")

Expand Down
40 changes: 40 additions & 0 deletions tests/fixtures/emulator-nonmonotonic-frame.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<!-- Official archive metadata captured 2026-10-08; source order preserved. -->
<devsite-expandable class="expandable" is-upgraded="" id="emulator-37-3-1-canary">
<p class="expand-control" aria-controls="emulator-37-3-1-canary" aria-expanded="false" tabindex="0" role="button">
Android Emulator (37.3.1) Canary
<span>Oct 07, 2026</span>
</p>
<div class="downloads">
Zip files
Linux: <a href="https://redirector.gvt1.com/edgedl/android/repository/emulator-linux_x64-16373887.zip">emulator-linux_x64-16373887.zip</a> (353795619 bytes)
Mac (Intel): <a href="https://redirector.gvt1.com/edgedl/android/repository/emulator-darwin_x64-16373887.zip">emulator-darwin_x64-16373887.zip</a> (492796833 bytes)
Mac (Apple Silicon): <a href="https://redirector.gvt1.com/edgedl/android/repository/emulator-darwin_aarch64-16373887.zip">emulator-darwin_aarch64-16373887.zip</a> (419849447 bytes)
Windows (64-bit): <a href="https://redirector.gvt1.com/edgedl/android/repository/emulator-windows_x64-16373887.zip">emulator-windows_x64-16373887.zip</a> (459169650 bytes)

SHA-256 checksums
e8f46e19b8dedad03f2387caa2676a6d9b0ddbb3db850202167346da75098260 emulator-linux_x64-16373887.zip
903400cabc3d684c8a7d463524990e508412de849a78a00772c22e865d0241e6 emulator-darwin_x64-16373887.zip
9d0f6ae4f27a886d947098bb6f5f3148a3f2217ce4a58cb81dbec0222da68d21 emulator-darwin_aarch64-16373887.zip
b294385071f0dad738308b91c60f605c4a817c617352136a0b5625e1f800e359 emulator-windows_x64-16373887.zip
</div>
</devsite-expandable>

<devsite-expandable class="expandable" is-upgraded="" id="emulator-37-3-3-canary">
<p class="expand-control" aria-controls="emulator-37-3-3-canary" aria-expanded="false" tabindex="0" role="button">
Android Emulator (37.3.3) Canary
<span>Oct 06, 2026</span>
</p>
<div class="downloads">
Zip files
Linux: <a href="https://redirector.gvt1.com/edgedl/android/repository/emulator-linux_x64-16489710.zip">emulator-linux_x64-16489710.zip</a> (353959232 bytes)
Mac (Intel): <a href="https://redirector.gvt1.com/edgedl/android/repository/emulator-darwin_x64-16489710.zip">emulator-darwin_x64-16489710.zip</a> (493027673 bytes)
Mac (Apple Silicon): <a href="https://redirector.gvt1.com/edgedl/android/repository/emulator-darwin_aarch64-16489710.zip">emulator-darwin_aarch64-16489710.zip</a> (420070680 bytes)
Windows (64-bit): <a href="https://redirector.gvt1.com/edgedl/android/repository/emulator-windows_x64-16489710.zip">emulator-windows_x64-16489710.zip</a> (459469167 bytes)

SHA-256 checksums
a0da0fa20903a69ae52ab501f30fc5c768dcce31434cd2f55d52addc1a2c0495 emulator-linux_x64-16489710.zip
1829806279af5365812cc2d22c9d292a535b2157c99575378d2e8e6ede2704b5 emulator-darwin_x64-16489710.zip
c2efd240c708f7f900a100d398faa606cc96ff1b1932805df78b8c4cc272fdd4 emulator-darwin_aarch64-16489710.zip
b9c90996c4ebca457e3e5dfd61ed2c74227a6f7ffc3a382db5ced8d37da15cbe emulator-windows_x64-16489710.zip
</div>
</devsite-expandable>
63 changes: 63 additions & 0 deletions tests/test_update_android_downloads.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,69 @@ def test_repository_xml_cross_validation_matches_stable_and_beta(self) -> None:
checks,
)

def test_emulator_numeric_order_preserves_official_dates_and_downloads(self) -> None:
frame = (FIXTURES / "emulator-nonmonotonic-frame.html").read_text(encoding="utf-8")
releases, _ = updater.parse_releases(frame, "Android Emulator")
self.assertEqual(["37.3.3", "37.3.1"], [updater._version_from_emulator_name(r.name) for r in releases])
self.assertEqual(["2026-10-06", "2026-10-07"], [r.date_iso for r in releases])
self.assertEqual("emulator-linux_x64-16489710.zip", releases[0].downloads[0].filename)
self.assertEqual("a0da0fa20903a69ae52ab501f30fc5c768dcce31434cd2f55d52addc1a2c0495", releases[0].downloads[0].checksum)
self.assertEqual(4, len(releases[1].downloads))

def test_emulator_transition_checks_numeric_maximum_per_channel(self) -> None:
frame = (FIXTURES / "emulator-nonmonotonic-frame.html").read_text(encoding="utf-8")
high, low = updater.parse_releases(frame, "Android Emulator")[0]
stable = replace(high, name="Android Emulator (37.2.12) Stable", channel="stable")
# Unsorted old pages and a newer publication date cannot mask rollback.
old = [low, high, stable]
updater._validate_transition(old, [high, low, stable], "Android Emulator")
advanced = replace(stable, name="Android Emulator (38.0.0) Stable")
for new in ([low, advanced], [advanced], [high, low]):
with self.subTest(new=new), self.assertRaises(updater.UpdateError):
updater._validate_transition(old, new, "Android Emulator")
# A date correction on the same revision is not a binary downgrade.
corrected = replace(high, date_iso="2026-10-05")
updater._validate_transition(old, [corrected, low, stable], "Android Emulator")
advanced_canary = replace(high, name="Android Emulator (37.3.10) Canary", date_iso="2026-10-05", date_display="Oct 05, 2026")
updater._validate_transition(old, [advanced_canary, low, stable], "Android Emulator")
nine = replace(high, name="Android Emulator (37.3.9) Canary")
numeric_frame = updater.render_releases("emulator", [nine, advanced_canary])
ordered, _ = updater.parse_releases(numeric_frame, "Android Emulator", True)
self.assertEqual(advanced_canary, ordered[0])

def test_nonmonotonic_emulator_update_is_idempotent_and_rollback_writes_nothing(self) -> None:
sources = updater.load_sources(True, ROOT / "scripts" / "update_android_downloads.py")
frame = (FIXTURES / "emulator-nonmonotonic-frame.html").read_text(encoding="utf-8")
with tempfile.TemporaryDirectory() as temporary_dir:
site = Path(temporary_dir)
for filename, marker_names in (
("android-studio.html", ["studio"]),
("android-emulator.html", ["emulator"]),
("index.html", ["studio-summary", "emulator-summary"]),
):
parts = []
for name in marker_names:
product = "studio" if name.startswith("studio") else "emulator"
releases, _ = updater.parse_releases(sources[0 if product == "studio" else 1], "Android " + ("Studio" if product == "studio" else "Emulator"))
start, end = updater.MARKERS[name]
parts.append(start + "\n" + updater.render_releases(product, releases) + "\n" + end)
(site / filename).write_text("\n".join(parts), encoding="utf-8")
with patch.object(updater, "load_sources", return_value=(sources[0], frame + sources[1], sources[2], sources[3])):
report = updater.run_update(site, "write", True, ROOT / "scripts" / "update_android_downloads.py")
snapshot = {name: (site / name).read_bytes() for name in updater.TARGET_FILES}
again = updater.run_update(site, "write", True, ROOT / "scripts" / "update_android_downloads.py")
self.assertEqual("Android Emulator (37.3.3) Canary", report["emulator"]["latest"])
self.assertFalse(again["changed"])
for name in ("index.html", "android-emulator.html"):
page = snapshot[name].decode()
self.assertLess(page.index("37.3.3"), page.index("37.3.1"))
low = updater.parse_releases(frame, "Android Emulator")[0][1]
rollback_frame = updater.render_releases("emulator", [low]).replace('<devsite-expandable ', '<devsite-expandable class="expandable" ')
with patch.object(updater, "load_sources", return_value=(sources[0], rollback_frame + sources[1], sources[2], sources[3])):
with self.assertRaises(updater.UpdateError):
updater.run_update(site, "write", True, ROOT / "scripts" / "update_android_downloads.py")
self.assertEqual(snapshot, {name: (site / name).read_bytes() for name in updater.TARGET_FILES})

def test_repository_newer_beta_rollout_is_reported_without_failing(self) -> None:
releases, _ = updater.parse_releases(self.emulator_frame, "Android Emulator")
newer_repository_xml = self.repository_xml.replace(
Expand Down