Add Preview update history

This commit is contained in:
xXJsonDeruloXx committed 2026-10-03 00:06:09 -04:00
1 parent 255c64551e
commit 2d8befe1cb
7 files changed
+363 -9

No files matched your search

@@ -116,12 +116,12 @@ internal fun UpdatesPage(s: FrontEndState, a: FrontEndActions, modifier: Modifie
StatusPanel(s, u, ua, me)
Box(Modifier.height(18.dp))
ChannelLabel()
ChannelPicker(u, ua)
ChannelPicker(u, ua, me)
} else {
// The label sits over both columns, so the status card lines up with the first channel.
ChannelLabel()
Row(horizontalArrangement = Arrangement.spacedBy(18.dp)) {
Box(Modifier.weight(1f)) { ChannelPicker(u, ua) }
Box(Modifier.weight(1f)) { ChannelPicker(u, ua, me) }
Box(Modifier.weight(1.15f)) { StatusPanel(s, u, ua, me) }
}
}
@@ -260,7 +260,7 @@ private fun ChannelLabel() {
}
@Composable
private fun ChannelPicker(u: UpdatesState, ua: UpdatesActions) {
private fun ChannelPicker(u: UpdatesState, ua: UpdatesActions, me: AppUpdates.Installed) {
val colors = MaterialTheme.colorScheme
val catalog = u.catalog
val tests = catalog?.tests.orEmpty()
@@ -273,6 +273,7 @@ private fun ChannelPicker(u: UpdatesState, ua: UpdatesActions) {
Icons.Outlined.Bolt, stringResource(R.string.upd_preview), stringResource(R.string.upd_preview_hint),
catalog?.preview?.let { ago(it.publishedAt) }, u.follow.channel == Channel.NIGHTLY,
) { ua.onFollow(Follow(Channel.NIGHTLY)) }
PreviewHistory(catalog, me)
ChannelCard(
Icons.Outlined.Science, stringResource(R.string.upd_tests), stringResource(R.string.upd_tests_hint),
if (tests.isEmpty()) stringResource(R.string.upd_tests_none) else pluralStringResource(R.plurals.upd_tests_count, tests.size, tests.size),
@@ -286,6 +287,63 @@ private fun ChannelPicker(u: UpdatesState, ua: UpdatesActions) {
}
}
/** Compact, read-only history under Preview; only the latest Preview build remains installable. */
@Composable
private fun PreviewHistory(catalog: AppUpdates.Catalog?, me: AppUpdates.Installed) {
val history = catalog?.let { AppUpdates.previewHistory(it, me) } ?: return
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val key = catalog?.recentPreviews?.firstOrNull()?.commit
var expanded by rememberSaveable(key) { mutableStateOf(false) }
val heading = when (history.kind) {
AppUpdates.PreviewHistoryKind.CURRENT -> stringResource(R.string.upd_preview_current)
AppUpdates.PreviewHistoryKind.BEHIND -> pluralStringResource(
R.plurals.upd_preview_behind, history.buildsBehind, history.buildsBehind,
)
AppUpdates.PreviewHistoryKind.RECENT -> pluralStringResource(
R.plurals.upd_preview_recent, history.changes.size, history.changes.size,
)
}
Column(Modifier.fillMaxWidth().padding(start = 66.dp, end = 14.dp, bottom = 5.dp)) {
Text(heading, fontSize = 11.5.sp, fontWeight = FontWeight.Medium, color = colors.onSurfaceVariant)
if (history.changes.isNotEmpty()) {
val visible = if (expanded) history.changes else history.changes.take(3)
Column(Modifier.padding(top = 3.dp), verticalArrangement = Arrangement.spacedBy(3.dp)) {
visible.forEach { change ->
Column {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
changeTitle(change.title.ifBlank { change.commit.take(7) }),
fontSize = 12.5.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground,
maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f),
)
Text(ago(change.publishedAt), fontSize = 11.sp, color = colors.onSurfaceVariant)
}
if (change.summary.isNotBlank()) Text(
change.summary, fontSize = 11.5.sp, color = colors.onSurfaceVariant,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
}
}
if (history.changes.size > 3 || expanded) {
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val toggle = { expanded = !expanded }
Text(
if (expanded) stringResource(R.string.upd_less) else stringResource(R.string.upd_more),
fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = pal.signal,
modifier = Modifier.padding(top = 1.dp).offset(x = (-6).dp).paneItem("preview:history:more")
.clip(Shape12).glideBorder(hot, Shape12, pal.signal)
.hoverable(src).clickable(interactionSource = src, indication = null, role = Role.Button, onClick = toggle)
.controllerConfirm(onClick = toggle)
.padding(horizontal = 6.dp, vertical = 4.dp),
)
}
}
}
}
@Composable
private fun ChannelCard(
icon: ImageVector, title: String, hint: String, latest: String?, selected: Boolean,
@@ -54,11 +54,19 @@ object AppUpdates {
val apk: Apk?,
)
class PreviewChange(
val commit: String,
val title: String,
val summary: String,
val publishedAt: Long,
)
class Catalog(
val stable: Release?,
val preview: Release?,
val tests: List<Release>,
val checkedAt: Long,
val recentPreviews: List<PreviewChange> = emptyList(),
)
class Installed(
@@ -68,6 +76,15 @@ object AppUpdates {
val versionCode: Int,
val updatable: Boolean,
val committedAt: Long = 0L,
val ciBuild: Boolean = BuildConfig.CI_BUILD,
)
enum class PreviewHistoryKind { CURRENT, BEHIND, RECENT }
class PreviewHistory(
val kind: PreviewHistoryKind,
val buildsBehind: Int,
val changes: List<PreviewChange>,
)
enum class Offer {
@@ -146,6 +163,37 @@ object AppUpdates {
r.commit.startsWith(me.commit, ignoreCase = true)
}
/** Recent Preview changes relative to a CI-installed commit, when its position is known. */
fun previewHistory(catalog: Catalog, me: Installed = installed()): PreviewHistory? {
if (!me.ciBuild || catalog.recentPreviews.isEmpty()) return null
val stable = catalog.stable
if (
me.pr != 0 || stable?.let { isRunning(it, me) } == true ||
stable?.version?.let { compareVersions(me.version, it) <= 0 } == true
) {
return PreviewHistory(PreviewHistoryKind.RECENT, 0, catalog.recentPreviews)
}
val matching = catalog.recentPreviews.indices.filter {
matchesInstalledCommit(catalog.recentPreviews[it].commit, me.commit)
}
if (matching.size != 1) {
return PreviewHistory(PreviewHistoryKind.RECENT, 0, catalog.recentPreviews)
}
val index = matching.single()
return if (index == 0) {
PreviewHistory(PreviewHistoryKind.CURRENT, 0, emptyList())
} else {
PreviewHistory(PreviewHistoryKind.BEHIND, index, catalog.recentPreviews.take(index))
}
}
private fun matchesInstalledCommit(published: String, installed: String): Boolean {
if (!Regex("^[0-9a-fA-F]{7,40}$").matches(installed)) return false
return published.equals(installed, ignoreCase = true) ||
(installed.length < 40 && published.startsWith(installed, ignoreCase = true))
}
/** Why Android cannot install this published build over [me], before downloading anything. */
fun installBlock(r: Release, me: Installed = installed()): String? = when {
r.apk == null -> "This build has no download for this copy of DroidDeck."
@@ -218,6 +266,7 @@ object AppUpdates {
)
}
val tests = root.optJSONArray("tests") ?: JSONArray()
val recentPreviews = root.optJSONArray("recentPreviews") ?: JSONArray()
return Catalog(
root.optJSONObject("stable")?.let { readPublishedRelease(it, packageName) },
root.optJSONObject("preview")?.let { readPublishedRelease(it, packageName) },
@@ -226,9 +275,20 @@ object AppUpdates {
.filter { it.pr > 0 }
.sortedByDescending { it.publishedAt },
checkedAt,
(0 until recentPreviews.length())
.mapNotNull { recentPreviews.optJSONObject(it)?.let(::readPublishedPreviewChange) }
.sortedByDescending { it.publishedAt }
.distinctBy { it.commit.lowercase() }
.take(10),
)
}
private fun readPublishedPreviewChange(o: JSONObject): PreviewChange? {
val commit = o.optString("commit")
if (!Regex("^[0-9a-fA-F]{40}$").matches(commit)) return null
return PreviewChange(commit.lowercase(), o.optString("title"), o.optString("summary"), o.optLong("publishedAt"))
}
private fun readPublishedRelease(o: JSONObject, packageName: String): Release {
val commit = o.getString("commit")
if (!Regex("^[0-9a-fA-F]{7,40}$").matches(commit)) {
@@ -358,23 +418,42 @@ object AppUpdates {
},
)
private fun writeCatalog(c: Catalog): JSONObject = JSONObject()
internal fun writeCatalog(c: Catalog): JSONObject = JSONObject()
.put("stable", c.stable?.let(::writeRelease))
.put("preview", c.preview?.let(::writeRelease))
.put("tests", JSONArray().apply { c.tests.forEach { put(writeRelease(it)) } })
.put("recentPreviews", JSONArray().apply { c.recentPreviews.forEach { put(writePreviewChange(it)) } })
.put("checkedAt", c.checkedAt)
private fun readCachedCatalog(o: JSONObject): Catalog {
private fun writePreviewChange(c: PreviewChange): JSONObject = JSONObject()
.put("commit", c.commit)
.put("title", c.title)
.put("summary", c.summary)
.put("publishedAt", c.publishedAt)
internal fun readCachedCatalog(o: JSONObject): Catalog {
val tests = o.optJSONArray("tests") ?: JSONArray()
val preview = o.optJSONObject("preview") ?: o.optJSONObject("nightly")
val recentPreviews = o.optJSONArray("recentPreviews") ?: JSONArray()
return Catalog(
o.optJSONObject("stable")?.let(::readCachedRelease),
preview?.let(::readCachedRelease),
(0 until tests.length()).map { readCachedRelease(tests.getJSONObject(it)) },
o.optLong("checkedAt"),
(0 until recentPreviews.length())
.mapNotNull { recentPreviews.optJSONObject(it)?.let(::readCachedPreviewChange) }
.sortedByDescending { it.publishedAt }
.distinctBy { it.commit.lowercase() }
.take(10),
)
}
private fun readCachedPreviewChange(o: JSONObject): PreviewChange? {
val commit = o.optString("commit")
if (!Regex("^[0-9a-fA-F]{40}$").matches(commit)) return null
return PreviewChange(commit.lowercase(), o.optString("title"), o.optString("summary"), o.optLong("publishedAt"))
}
private fun prefs(context: Context) =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
}
+9
View File
@@ -598,6 +598,7 @@
<string name="upd_channel">CANAL DE ACTUALIZACIÓN</string>
<string name="upd_stable_hint">Versiones probadas, para la mayoría</string>
<string name="upd_preview_hint">Las correcciones más recientes, antes que Stable</string>
<string name="upd_preview_current">Al día</string>
<string name="upd_tests">Versiones de prueba</string>
<string name="upd_tests_hint">Prueba una corrección antes de su publicación</string>
<string name="upd_tests_none">Ninguna por ahora</string>
@@ -610,6 +611,14 @@
<item quantity="one">%1$d para probar</item>
<item quantity="other">%1$d para probar</item>
</plurals>
<plurals name="upd_preview_behind">
<item quantity="one">Cambios desde tu versión · %1$d versión atrás</item>
<item quantity="other">Cambios desde tu versión · %1$d versiones atrás</item>
</plurals>
<plurals name="upd_preview_recent">
<item quantity="one">Cambios recientes · %1$d versión</item>
<item quantity="other">Cambios recientes · %1$d versiones</item>
</plurals>
<string name="gate_title">Un ajuste de Android por cambiar</string>
<string name="gate_lede">Android detiene las apps que inician muchos procesos en segundo plano, y Steam inicia decenas. Hasta que esto cambie, los juegos se cierran sin mostrar ningún error.</string>
<string name="gate_fix_for_me">Solucionarlo por mí</string>
+9
View File
@@ -647,6 +647,7 @@
<string name="upd_version_ago">%1$s · %2$s</string>
<string name="upd_preview">Preview</string>
<string name="upd_preview_hint">Newest fixes, before Stable</string>
<string name="upd_preview_current">Up to date</string>
<string name="upd_tests">Test builds</string>
<string name="upd_tests_hint">Try a fix before it\'s released</string>
<string name="upd_tests_none">None right now</string>
@@ -660,6 +661,14 @@
<item quantity="one">%1$d to try</item>
<item quantity="other">%1$d to try</item>
</plurals>
<plurals name="upd_preview_behind">
<item quantity="one">Changes since your build · %1$d build behind</item>
<item quantity="other">Changes since your build · %1$d builds behind</item>
</plurals>
<plurals name="upd_preview_recent">
<item quantity="one">Recent changes · %1$d build</item>
<item quantity="other">Recent changes · %1$d builds</item>
</plurals>
<string name="gate_title">One Android setting to change</string>
<string name="gate_eyebrow">Steam</string>
<string name="gate_lede">Android stops apps that start many background processes, and Steam starts dozens. Until this changes, games close without an error.</string>
@@ -5,9 +5,12 @@ import com.droiddeck.launcher.update.AppUpdates.Channel
import com.droiddeck.launcher.update.AppUpdates.Follow
import com.droiddeck.launcher.update.AppUpdates.Installed
import com.droiddeck.launcher.update.AppUpdates.Offer
import com.droiddeck.launcher.update.AppUpdates.PreviewChange
import com.droiddeck.launcher.update.AppUpdates.PreviewHistoryKind
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
@@ -63,7 +66,17 @@ class AppUpdatesTest {
pr: Int = 0,
version: String = "0.2.0",
versionCode: Int = 9,
) = Installed(commit, pr, version, versionCode, updatable = true)
ciBuild: Boolean = true,
) = Installed(commit, pr, version, versionCode, updatable = true, ciBuild = ciBuild)
private fun change(commit: String, title: String, publishedAt: Long) =
PreviewChange(commit, title, "Summary for $title", publishedAt)
private val recentPreview = listOf(
change("1".repeat(40), "Newest", 300L),
change("2".repeat(40), "Middle", 200L),
change("3".repeat(40), "Oldest", 100L),
)
@Test
fun theStableBuildIsCurrentOnStable() {
@@ -122,6 +135,76 @@ class AppUpdatesTest {
assertNotNull(AppUpdates.installBlock(missing, me("eeeeeee")))
}
@Test
fun previewHistoryCountsOnlyAnUnambiguousPublishedPreviewCommit() {
val withHistory = AppUpdates.Catalog(stable, release("1111111"), emptyList(), 0L, recentPreview)
val result = AppUpdates.previewHistory(withHistory, me("2".repeat(40), version = "0.3.0"))!!
assertEquals(PreviewHistoryKind.BEHIND, result.kind)
assertEquals(1, result.buildsBehind)
assertEquals(listOf("Newest"), result.changes.map { it.title })
}
@Test
fun currentPreviewHasNoRedundantChangeRows() {
val withHistory = AppUpdates.Catalog(stable, release("1111111"), emptyList(), 0L, recentPreview)
val result = AppUpdates.previewHistory(withHistory, me("1".repeat(40), version = "0.3.0"))!!
assertEquals(PreviewHistoryKind.CURRENT, result.kind)
assertTrue(result.changes.isEmpty())
}
@Test
fun missingOrAmbiguousPreviewCommitsUseNeutralRecentHistory() {
val withHistory = AppUpdates.Catalog(stable, release("1111111"), emptyList(), 0L, recentPreview)
assertEquals(
PreviewHistoryKind.RECENT,
AppUpdates.previewHistory(withHistory, me("9".repeat(40), version = "0.3.0"))!!.kind,
)
val ambiguous = listOf(
change("abcdef0" + "1".repeat(33), "First", 300L),
change("abcdef0" + "2".repeat(33), "Second", 200L),
)
val ambiguousCatalog = AppUpdates.Catalog(stable, release("abcdef0"), emptyList(), 0L, ambiguous)
val result = AppUpdates.previewHistory(ambiguousCatalog, me("abcdef0", version = "0.3.0"))!!
assertEquals(PreviewHistoryKind.RECENT, result.kind)
assertEquals(2, result.changes.size)
}
@Test
fun prStableAndLocalBuildsDoNotGetPreviewDistance() {
val withHistory = AppUpdates.Catalog(stable, release("1111111"), emptyList(), 0L, recentPreview)
assertEquals(
PreviewHistoryKind.RECENT,
AppUpdates.previewHistory(withHistory, me("2".repeat(40), pr = 93, version = "0.3.0"))!!.kind,
)
assertEquals(
PreviewHistoryKind.RECENT,
AppUpdates.previewHistory(withHistory, me(stable.commit))!!.kind,
)
assertNull(AppUpdates.previewHistory(withHistory, me("2".repeat(40), version = "0.3.0", ciBuild = false)))
assertEquals(
PreviewHistoryKind.RECENT,
AppUpdates.previewHistory(withHistory, me("2".repeat(40), version = "0.1.0"))!!.kind,
)
}
@Test
fun cachedPreviewHistorySurvivesRoundTrip() {
val withHistory = AppUpdates.Catalog(stable, release("1111111"), emptyList(), 123L, recentPreview)
val cached = AppUpdates.readCachedCatalog(AppUpdates.writeCatalog(withHistory))
assertEquals(123L, cached.checkedAt)
assertEquals(3, cached.recentPreviews.size)
assertEquals("Newest", cached.recentPreviews.first().title)
assertEquals(recentPreview.first().commit, cached.recentPreviews.first().commit)
}
@Test
fun oldCachedCatalogWithoutPreviewHistoryRemainsReadable() {
val cached = AppUpdates.readCachedCatalog(JSONObject().put("tests", JSONArray()).put("checkedAt", 123L))
assertEquals(123L, cached.checkedAt)
assertTrue(cached.recentPreviews.isEmpty())
}
private fun publishedCatalog(signer: String = BuildConfig.RELEASE_SIGNER): JSONObject {
val raw = """
{
@@ -169,6 +252,25 @@ class AppUpdatesTest {
assertEquals(9, parsed.preview!!.apk!!.versionCode)
assertNull(parsed.preview!!.version)
assertEquals(123L, parsed.checkedAt)
assertTrue(parsed.recentPreviews.isEmpty())
}
@Test
fun publishedCatalogReadsOptionalPreviewHistoryAndDeduplicates() {
val first = "a".repeat(40)
val newest = "b".repeat(40)
val raw = publishedCatalog().put(
"recentPreviews",
JSONArray()
.put(JSONObject().put("commit", first).put("title", "Older").put("summary", "Old").put("publishedAt", 1L))
.put(JSONObject().put("commit", newest).put("title", "Newest").put("summary", "New").put("publishedAt", 2L))
.put(JSONObject().put("commit", first).put("title", "Duplicate").put("summary", "Again").put("publishedAt", 3L))
.put(JSONObject().put("commit", "shortsha").put("title", "Invalid")),
)
val parsed = AppUpdates.readPublishedCatalog(raw, "com.droiddeck.launcher", 123L)
assertEquals(2, parsed.recentPreviews.size)
assertEquals("Duplicate", parsed.recentPreviews.first().title)
assertEquals(newest, parsed.recentPreviews.last().commit)
}
@Test
+51 -3
View File
@@ -229,6 +229,50 @@ def release_entry(
}
def preview_history_entry(release: dict, preview: dict, variants: list[tuple[str, str]]):
"""Return compact history metadata for one complete, signed main build."""
tag = release.get("tag_name") or ""
tag_match = MAIN_TAG.fullmatch(tag)
commit = (preview.get("commit") or "").lower()
expected_packages = {package for package, _ in variants}
apks = preview.get("apks") or {}
if (
release.get("draft")
or not release.get("prerelease")
or not tag_match
or not re.fullmatch(r"[0-9a-f]{40}", commit)
or not commit.startswith(tag_match.group(1))
or set(apks) != expected_packages
or any(
not SHA256.fullmatch((apk.get("sha256") or "").lower())
or not SHA256.fullmatch((apk.get("signerSha256") or "").lower())
for apk in apks.values()
)
):
return None
return {
"commit": commit,
"title": preview["title"],
"summary": preview["summary"][:400],
"publishedAt": preview["publishedAt"],
}
def recent_previews(entries: list[dict], limit: int = 10) -> list[dict]:
"""Keep the newest distinct Preview commits, newest first."""
result = []
seen = set()
for entry in sorted(entries, key=lambda item: item["publishedAt"], reverse=True):
commit = entry["commit"].lower()
if commit in seen:
continue
seen.add(commit)
result.append({**entry, "commit": commit})
if len(result) >= limit:
break
return result
def release_body_value(body: str, key: str):
match = re.search(rf"(?mi)^\s*{re.escape(key)}:\s*`?([^\s`]+)", body or "")
return match.group(1) if match else None
@@ -276,6 +320,7 @@ def build_catalog(source: GitHub, ci: GitHub, source_repo: str, ci_repo: str) ->
releases = ci.get(f"/repos/{ci_repo}/releases?per_page=100")
previews = []
preview_history = []
tests = []
for release in releases:
if release.get("draft"):
@@ -291,9 +336,11 @@ def build_catalog(source: GitHub, ci: GitHub, source_repo: str, ci_repo: str) ->
except ApiError:
continue
title, summary = ci_description(release)
previews.append(
release_entry(release, commit, 0, None, code, variants, signer, title, summary)
)
preview = release_entry(release, commit, 0, None, code, variants, signer, title, summary)
previews.append(preview)
history_entry = preview_history_entry(release, preview, variants)
if history_entry:
preview_history.append(history_entry)
continue
pr_match = PR_TAG.fullmatch(tag)
@@ -356,6 +403,7 @@ def build_catalog(source: GitHub, ci: GitHub, source_repo: str, ci_repo: str) ->
"generatedAt": int(time.time() * 1000),
"stable": stable,
"preview": previews[0] if previews else None,
"recentPreviews": recent_previews(preview_history),
"tests": tests,
}
+49
View File
@@ -6,6 +6,8 @@ from tools.release.update_catalog import (
parse_gradle,
parse_variants,
published_millis,
preview_history_entry,
recent_previews,
release_body_value,
stable_app_release,
)
@@ -87,6 +89,53 @@ pubg com.tencent.ig -pubg
published_millis(release, prefer_updated=True),
)
def test_preview_history_keeps_only_complete_signed_main_releases(self):
variants = [("com.droiddeck.launcher", ""), ("com.example.variant", "-variant")]
commit = "a" * 40
entry = {
"commit": commit,
"title": "Fix Steam resume",
"summary": "Steam stays open after resuming.",
"publishedAt": 123,
"apks": {
package: {"sha256": "a" * 64, "signerSha256": "b" * 64}
for package, _ in variants
},
}
release = {"tag_name": "main-aaaaaaa", "draft": False, "prerelease": True}
self.assertEqual(
{
"commit": commit,
"title": "Fix Steam resume",
"summary": "Steam stays open after resuming.",
"publishedAt": 123,
},
preview_history_entry(release, entry, variants),
)
for invalid_release in (
{**release, "draft": True},
{**release, "prerelease": False},
{**release, "tag_name": "pr-42"},
):
self.assertIsNone(preview_history_entry(invalid_release, entry, variants))
self.assertIsNone(preview_history_entry(release, {**entry, "commit": "a" * 12}, variants))
self.assertIsNone(preview_history_entry(release, {**entry, "apks": {"com.droiddeck.launcher": {}}}, variants))
unsigned = {**entry, "apks": {package: {"sha256": "a" * 64} for package, _ in variants}}
self.assertIsNone(preview_history_entry(release, unsigned, variants))
def test_preview_history_is_newest_first_deduplicated_and_limited(self):
entries = [
{"commit": f"{i:040x}", "title": str(i), "summary": "", "publishedAt": i}
for i in range(12)
]
entries.append({**entries[-1], "title": "duplicate", "publishedAt": 99})
recent = recent_previews(entries)
self.assertEqual(10, len(recent))
self.assertEqual("duplicate", recent[0]["title"])
self.assertEqual("000000000000000000000000000000000000000a", recent[1]["commit"])
self.assertEqual(1, len([item for item in recent if item["commit"].endswith("000b")]))
def test_stable_is_the_newest_app_release_not_github_latest(self):
def release(tag, at, *assets, **flags):
return {"tag_name": tag, "published_at": at, "assets": [{"name": a} for a in assets], **flags}