From e0537e34550c774f23fffcb07a68b757852b877e Mon Sep 17 00:00:00 2001 From: anas Date: Wed, 7 Oct 2026 23:32:10 +0200 Subject: [PATCH] Keep the README's download in step with the build, and prove it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 rewrites the download heading, the size and the checksum in README.md and the checksum list in releases/README.md from whatever app/build.gradle.kts declares and whatever is actually archived. It takes no arguments on purpose — the documentation can only describe the release the build produced. ReleaseDocsTest is what makes forgetting to run it a failed build rather than a quiet wrong answer: the offered version must match versionName, the APK must be archived, and every checksum in releases/README.md must match the file beside it. The declared test inputs are not decoration. Without them Gradle sees only Kotlin sources, calls the task up to date and never re-runs it — I checked, and a README rolled back to 0.3.0 passed green. With README.md and releases/README.md declared, the same edit fails on the right assertion and passes again when restored. Co-Authored-By: Claude Opus 5 (1M context) --- app/build.gradle.kts | 9 ++ .../com/ganjoor/android/ReleaseDocsTest.kt | 78 +++++++++++++++++ tools/update_release_docs.py | 87 +++++++++++++++++++ 3 files changed, 174 insertions(+) create mode 100644 app/src/test/java/com/ganjoor/android/ReleaseDocsTest.kt create mode 100755 tools/update_release_docs.py diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0e4dbac..3905ab4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,3 +1,4 @@ +import org.gradle.api.tasks.PathSensitivity import java.util.Properties 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().configureEach { + inputs.file(rootProject.file("README.md")).withPathSensitivity(PathSensitivity.RELATIVE) + inputs.file(rootProject.file("releases/README.md")).withPathSensitivity(PathSensitivity.RELATIVE) +} + dependencies { implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.activity.compose) diff --git a/app/src/test/java/com/ganjoor/android/ReleaseDocsTest.kt b/app/src/test/java/com/ganjoor/android/ReleaseDocsTest.kt new file mode 100644 index 0000000..c580e75 --- /dev/null +++ b/app/src/test/java/com/ganjoor/android/ReleaseDocsTest.kt @@ -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)) + } + } +} diff --git a/tools/update_release_docs.py b/tools/update_release_docs.py new file mode 100755 index 0000000..9290bad --- /dev/null +++ b/tools/update_release_docs.py @@ -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--release.apk releases/ganjoor-.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()