Merge tools/release-docs-in-step

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
anas 2026-10-08 00:31:02 +02:00
commit 5a338d0e0a
4 changed files with 183 additions and 0 deletions

View File

@ -13,6 +13,15 @@ An Android reader for [Ganjoor](https://ganjoor.net), the open archive of Persia
240 poets and ~135,000 poems, laid out for comfortable long-form reading in Persian, Urdu 240 poets and ~135,000 poems, laid out for comfortable long-form reading in Persian, Urdu
and Arabic script, online or fully offline. and Arabic script, online or fully offline.
### ⬇ [Download ganjoor-0.4.0.apk](releases/ganjoor-0.4.0.apk)
Android 7.0 and up · 31.7 MB · [older releases](releases/) · `sha256 1c64cefa…fb863ab9fc7`
Android will call the developer unknown and offer to install anyway — it says that about every
app installed outside a store. Allow installs from your browser once and it will go through.
Every release is signed with the same key, so a new one upgrades the last without losing your
bookmarks. [Build it yourself](#build) instead if you would rather.
Jetpack Compose, Material 3, `minSdk 24`. No account, no tracking, no server of its own. Jetpack Compose, Material 3, `minSdk 24`. No account, no tracking, no server of its own.
## Reading ## Reading

View File

@ -1,3 +1,4 @@
import org.gradle.api.tasks.PathSensitivity
import java.util.Properties import java.util.Properties
plugins { plugins {
@ -75,6 +76,14 @@ android {
} }
} }
// ReleaseDocsTest reads these, so a change to either has to re-run the tests. Without this
// Gradle sees only Kotlin sources, calls the task up to date, and a README that has fallen
// behind the build sails through green.
tasks.withType<Test>().configureEach {
inputs.file(rootProject.file("README.md")).withPathSensitivity(PathSensitivity.RELATIVE)
inputs.file(rootProject.file("releases/README.md")).withPathSensitivity(PathSensitivity.RELATIVE)
}
dependencies { dependencies {
implementation(platform(libs.androidx.compose.bom)) implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)

View File

@ -0,0 +1,78 @@
package com.ganjoor.android
import java.io.File
import java.security.MessageDigest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The download the README offers must be the release the build actually produces.
*
* A README that advertises last version is worse than one that says nothing: someone follows the
* link, installs an old build, and has no way to know. tools/update_release_docs.py keeps these in
* step; this is what makes forgetting to run it a failed build rather than a quiet wrong answer.
*/
class ReleaseDocsTest {
private val root: File =
generateSequence(File(System.getProperty("user.dir")!!)) { it.parentFile }
.first { File(it, "settings.gradle.kts").exists() }
private fun read(path: String) = File(root, path).readText()
private fun sha256(file: File): String =
MessageDigest.getInstance("SHA-256").digest(file.readBytes())
.joinToString("") { "%02x".format(it) }
private val declaredVersion: String by lazy {
Regex("versionName\\s*=\\s*\"([^\"]+)\"")
.find(read("app/build.gradle.kts"))!!
.groupValues[1]
}
@Test
fun `the README offers the version the build declares`() {
val offered = Regex("### . \\[Download ganjoor-([^\\]]+)\\.apk]")
.find(read("README.md"))
?.groupValues?.get(1)
assertEquals(
"README.md offers a different version than app/build.gradle.kts declares — " +
"run tools/update_release_docs.py",
declaredVersion,
offered,
)
}
@Test
fun `the APK the README links to is archived`() {
val apk = File(root, "releases/ganjoor-" + declaredVersion + ".apk")
assertTrue(apk.name + " is linked from README.md but not in releases/", apk.exists())
}
@Test
fun `the README short checksum matches the archived APK`() {
val short = Regex("`sha256 ([0-9a-f]+)…([0-9a-f]+)`")
.find(read("README.md"))!!
.groupValues
val full = sha256(File(root, "releases/ganjoor-" + declaredVersion + ".apk"))
assertTrue(
"README.md's checksum does not match releases/ganjoor-" + declaredVersion + ".apk",
full.startsWith(short[1]) && full.endsWith(short[2]),
)
}
@Test
fun `every checksum in releases matches the APK beside it`() {
val listed = Regex("^([0-9a-f]{64}) (ganjoor-.+\\.apk)$", RegexOption.MULTILINE)
.findAll(read("releases/README.md"))
.map { it.groupValues[1] to it.groupValues[2] }
.toList()
assertTrue("no checksums found in releases/README.md", listed.isNotEmpty())
listed.forEach { (digest, name) ->
val apk = File(root, "releases/" + name)
assertTrue(name + " is listed in releases/README.md but missing", apk.exists())
assertEquals(name + " does not match its listed checksum", digest, sha256(apk))
}
}
}

87
tools/update_release_docs.py Executable file
View File

@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""
Bring the download line in README.md and the checksum list in releases/README.md
into step with whatever version app/build.gradle.kts declares.
Run it after building and copying the signed APK into releases/:
./gradlew :app:assembleRelease
cp app/build/outputs/apk/release/ganjoor-<v>-release.apk releases/ganjoor-<v>.apk
python3 tools/update_release_docs.py
It reads the version from the build file rather than taking an argument, so the
documentation can only ever describe the release the build actually produces.
ReleaseDocsTest fails if either file falls behind, so forgetting this is caught
rather than shipped.
"""
import hashlib
import pathlib
import re
import sys
ROOT = pathlib.Path(__file__).resolve().parent.parent
def version() -> str:
build = (ROOT / "app/build.gradle.kts").read_text(encoding="utf-8")
m = re.search(r'versionName\s*=\s*"([^"]+)"', build)
if not m:
sys.exit("could not find versionName in app/build.gradle.kts")
return m.group(1)
def main() -> None:
v = version()
apk = ROOT / "releases" / f"ganjoor-{v}.apk"
if not apk.exists():
sys.exit(f"{apk.relative_to(ROOT)} is missing — build and copy it in first")
digest = hashlib.sha256(apk.read_bytes()).hexdigest()
megabytes = apk.stat().st_size / 1_000_000
# README.md — the download heading and the line of facts under it.
readme = ROOT / "README.md"
text = readme.read_text(encoding="utf-8")
text, n = re.subn(
r"### ⬇ \[Download ganjoor-[^\]]+\]\(releases/ganjoor-[^)]+\)",
f"### ⬇ [Download ganjoor-{v}.apk](releases/ganjoor-{v}.apk)",
text,
count=1,
)
if n != 1:
sys.exit("could not find the download heading in README.md")
short = f"{digest[:8]}…{digest[-11:]}"
text, n = re.subn(
r"Android 7\.0 and up · [^·]+· \[older releases\]\(releases/\) · `sha256 [^`]+`",
f"Android 7.0 and up · {megabytes:.1f} MB · [older releases](releases/) · `sha256 {short}`",
text,
count=1,
)
if n != 1:
sys.exit("could not find the download facts line in README.md")
readme.write_text(text, encoding="utf-8")
# releases/README.md — one checksum line per archived APK, in version order.
index = ROOT / "releases" / "README.md"
listing = index.read_text(encoding="utf-8")
line = f"{digest} ganjoor-{v}.apk"
if line not in listing:
# replace an existing line for this version, or append to the block
existing = re.search(rf"^[0-9a-f]{{64}} ganjoor-{re.escape(v)}\.apk$", listing, re.M)
if existing:
listing = listing[: existing.start()] + line + listing[existing.end():]
else:
last = None
for last in re.finditer(r"^[0-9a-f]{64} ganjoor-[^\n]+$", listing, re.M):
pass
if not last:
sys.exit("could not find the checksum block in releases/README.md")
listing = listing[: last.end()] + "\n" + line + listing[last.end():]
index.write_text(listing, encoding="utf-8")
print(f"{v} {megabytes:.1f} MB {digest}")
print("README.md and releases/README.md are in step")
if __name__ == "__main__":
main()