Skip to content

Commit b4ed448

Browse files
committed
✨ feat: F-Droid publishing readiness (#4)
- Tag-driven version derivation in buildSrc/Version.kt (8 unit tests, TDD) replaces env-var-injected CalVer; F-Droid can now reproduce a build from a tag. - CI workflow drops BUILD_DATE / GITHUB_RUN_NUMBER injection; uses fetch-tags: true on actions/checkout so git describe works. - featureGraphic.png (1024x500) for the F-Droid listing. - Changelog renamed to 26050830.txt to match v2026.05.08.30 versionCode. - CONTRIBUTING.md documents 🔧 chore: and 🚧. - docs/release.md documents the local fdroid build --server repro check. Closes #5 (R8 follow-up tracked separately).
1 parent 9ebbe62 commit b4ed448

13 files changed

Lines changed: 1424 additions & 32 deletions

File tree

.github/workflows/android-ci.yaml

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ jobs:
4141
version: ${{ steps.version.outputs.version }}
4242
steps:
4343
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
44+
with:
45+
fetch-depth: 0
46+
fetch-tags: true
4447
- uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4
4548
with:
4649
distribution: temurin
@@ -52,8 +55,14 @@ jobs:
5255
- id: version
5356
name: Compute version
5457
run: |
55-
BUILD_DATE=$(date -u +%Y-%m-%d)
56-
VERSION="${BUILD_DATE//-/.}.${GITHUB_RUN_NUMBER}"
58+
# Match what app/build.gradle.kts derives from the same checkout.
59+
# Tags are pulled by checkout's fetch-tags: true; verify here.
60+
if ! git describe --tags --match 'v*' --abbrev=0 >/dev/null 2>&1; then
61+
echo "::error::No matching v* tag in history. Build will fail."
62+
git tag -l 'v*' | head
63+
exit 1
64+
fi
65+
VERSION=$(git describe --tags --match 'v*' --always | sed 's/^v//')
5766
echo "VERSION_NAME=$VERSION" >> "$GITHUB_ENV"
5867
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
5968
echo "Building version $VERSION"
@@ -83,6 +92,9 @@ jobs:
8392
contents: write
8493
steps:
8594
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
95+
with:
96+
fetch-depth: 0
97+
fetch-tags: true
8698
- uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4
8799
with:
88100
distribution: temurin

CONTRIBUTING.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,12 +76,14 @@ Gitmoji + conventional commits. Examples:
7676
:memo: docs: clarify identity precedence
7777
:white_check_mark: test: cover the alias-merge transaction
7878
:construction_worker: ci: pin actions to commit SHAs
79+
:wrench: chore: bump renovate config
7980
```
8081

8182
Common gitmoji used here: `:sparkles:` (feat), `:bug:` (fix),
8283
`:memo:` (docs), `:white_check_mark:` (tests), `:construction_worker:`
83-
(CI), `:art:` (refactor/style), `:fire:` (removals),
84-
`:lock:` (security), `:page_facing_up:` (legal/license).
84+
(CI), `:wrench:` (chore), `:construction:` (scaffolding / WIP),
85+
`:art:` (refactor/style), `:fire:` (removals), `:lock:` (security),
86+
`:page_facing_up:` (legal/license).
8587

8688
## Identity rules — Kotlin / Python parity
8789

app/build.gradle.kts

Lines changed: 21 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,29 @@
1-
import java.time.LocalDate
2-
import java.time.format.DateTimeFormatter
3-
41
plugins {
52
alias(libs.plugins.android.application)
63
alias(libs.plugins.kotlin.android)
74
alias(libs.plugins.kotlin.compose)
85
alias(libs.plugins.aboutlibraries)
96
}
107

11-
// CalVer: versionName = YYYY.MM.DD[.<run>], versionCode = YYMMDD*100 + run%100.
12-
// BUILD_DATE (YYYY-MM-DD) and GITHUB_RUN_NUMBER are set by CI; local builds fall
13-
// back to today's date and run 0.
14-
val buildDate: LocalDate =
15-
System.getenv("BUILD_DATE")?.takeIf { it.isNotBlank() }
16-
?.let(LocalDate::parse)
17-
?: LocalDate.now()
18-
val buildRun: Int = System.getenv("GITHUB_RUN_NUMBER")?.toIntOrNull() ?: 0
19-
val calverName: String = buildString {
20-
append(buildDate.format(DateTimeFormatter.ofPattern("yyyy.MM.dd")))
21-
if (buildRun > 0) append(".$buildRun")
22-
}
23-
val calverCode: Int =
24-
buildDate.format(DateTimeFormatter.ofPattern("yyMMdd")).toInt() * 100 +
25-
(buildRun % 100)
8+
// Tag-driven CalVer. The build reads `git describe --tags --match 'v*'`
9+
// and parses it via buildSrc/Version.kt. Set QUIRE_VERSION_FALLBACK
10+
// (e.g. "2026.05.08.29") if building from a shallow clone with no tags.
11+
fun gitDescribe(): String =
12+
try {
13+
val process = ProcessBuilder("git", "describe", "--tags", "--match", "v*", "--always")
14+
.directory(rootDir)
15+
.redirectErrorStream(true)
16+
.start()
17+
val output = process.inputStream.bufferedReader().readText().trim()
18+
if (process.waitFor() == 0) output else ""
19+
} catch (_: Exception) {
20+
""
21+
}
22+
23+
val versionInfo = Version.fromGitDescribe(
24+
output = gitDescribe(),
25+
fallback = System.getenv("QUIRE_VERSION_FALLBACK")
26+
)
2627

2728
android {
2829
namespace = "io.theficos.quire"
@@ -31,8 +32,8 @@ android {
3132
applicationId = "io.theficos.quire"
3233
minSdk = 26
3334
targetSdk = 34
34-
versionCode = calverCode
35-
versionName = calverName
35+
versionCode = versionInfo.code
36+
versionName = versionInfo.name
3637
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
3738
}
3839
compileOptions {

buildSrc/build.gradle.kts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
plugins {
2+
`kotlin-dsl`
3+
}
4+
5+
repositories {
6+
mavenCentral()
7+
google()
8+
}
9+
10+
dependencies {
11+
testImplementation("junit:junit:4.13.2")
12+
testImplementation("com.google.truth:truth:1.4.4")
13+
}
14+
15+
tasks.test {
16+
useJUnit()
17+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
data class VersionInfo(val name: String, val code: Int)
2+
3+
object Version {
4+
5+
private val EXACT = Regex("""^v(\d{4})\.(\d{2})\.(\d{2})\.(\d+)$""")
6+
private val POST_TAG = Regex("""^v(\d{4})\.(\d{2})\.(\d{2})\.(\d+)-(\d+)-(g[0-9a-f]+)$""")
7+
8+
fun fromGitDescribe(output: String, fallback: String? = null): VersionInfo {
9+
val trimmed = output.trim()
10+
11+
EXACT.matchEntire(trimmed)?.let { m ->
12+
val (yyyy, mm, dd, run) = m.destructured
13+
return VersionInfo(
14+
name = "$yyyy.$mm.$dd.$run",
15+
code = computeCode(yyyy, mm, dd, run)
16+
)
17+
}
18+
19+
POST_TAG.matchEntire(trimmed)?.let { m ->
20+
val (yyyy, mm, dd, run, dist, sha) = m.destructured
21+
return VersionInfo(
22+
name = "$yyyy.$mm.$dd.$run.dev$dist+$sha",
23+
code = computeCode(yyyy, mm, dd, run)
24+
)
25+
}
26+
27+
if (fallback != null) {
28+
return fromGitDescribe("v$fallback")
29+
}
30+
31+
error(
32+
"Could not derive version from git describe output: '$trimmed'. " +
33+
"Set QUIRE_VERSION_FALLBACK env var to a tag name like '2026.05.08.29' " +
34+
"(without leading 'v'), or build from a checkout that has at least one " +
35+
"matching tag in history."
36+
)
37+
}
38+
39+
private fun computeCode(yyyy: String, mm: String, dd: String, run: String): Int {
40+
val yyMMdd = (yyyy.takeLast(2) + mm + dd).toInt()
41+
return yyMMdd * 100 + (run.toInt() % 100)
42+
}
43+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import com.google.common.truth.Truth.assertThat
2+
import org.junit.Test
3+
import org.junit.Assert.assertThrows
4+
5+
class VersionTest {
6+
7+
@Test
8+
fun exactTag_parsesNameAndCode() {
9+
val result = Version.fromGitDescribe("v2026.05.08.29")
10+
assertThat(result.name).isEqualTo("2026.05.08.29")
11+
assertThat(result.code).isEqualTo(26050829)
12+
}
13+
14+
@Test
15+
fun exactTag_yearEndRollover() {
16+
val result = Version.fromGitDescribe("v2026.12.31.99")
17+
assertThat(result.name).isEqualTo("2026.12.31.99")
18+
assertThat(result.code).isEqualTo(26123199)
19+
}
20+
21+
@Test
22+
fun exactTag_runOver99_wrapsModulo() {
23+
// Run-number byte is mod-100 to fit in Int safely; documented in spec.
24+
val result = Version.fromGitDescribe("v2026.05.08.103")
25+
assertThat(result.name).isEqualTo("2026.05.08.103")
26+
assertThat(result.code).isEqualTo(26050803)
27+
}
28+
29+
@Test
30+
fun postTag_appendsDevSuffix_keepsBaseVersionCode() {
31+
val result = Version.fromGitDescribe("v2026.05.08.29-3-gabcdef0")
32+
assertThat(result.name).isEqualTo("2026.05.08.29.dev3+gabcdef0")
33+
assertThat(result.code).isEqualTo(26050829)
34+
}
35+
36+
@Test
37+
fun postTag_trimsLeadingTrailingWhitespace() {
38+
val result = Version.fromGitDescribe(" v2026.05.08.29-1-gdeadbee\n")
39+
assertThat(result.name).isEqualTo("2026.05.08.29.dev1+gdeadbee")
40+
}
41+
42+
@Test
43+
fun bareSha_withFallback_treatsFallbackAsExactTag() {
44+
val result = Version.fromGitDescribe("abcdef0", fallback = "2026.05.08.29")
45+
assertThat(result.name).isEqualTo("2026.05.08.29")
46+
assertThat(result.code).isEqualTo(26050829)
47+
}
48+
49+
@Test
50+
fun bareSha_noFallback_throwsWithGuidance() {
51+
val ex = assertThrows(IllegalStateException::class.java) {
52+
Version.fromGitDescribe("abcdef0")
53+
}
54+
assertThat(ex).hasMessageThat().contains("QUIRE_VERSION_FALLBACK")
55+
}
56+
57+
@Test
58+
fun emptyOutput_noFallback_throws() {
59+
assertThrows(IllegalStateException::class.java) {
60+
Version.fromGitDescribe("")
61+
}
62+
}
63+
}

docs/release.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,56 @@ scripts/dgradle :app:assembleRelease
5959

6060
If the env vars are unset, `:app:assembleRelease` falls back to
6161
debug-signed.
62+
63+
## Reproducibility check before submitting to F-Droid
64+
65+
F-Droid's builder rebuilds every release from source and compares the
66+
output to the signed APK in your GitHub Release. If the contents
67+
differ, F-Droid won't publish. Run the same check locally before
68+
submitting the recipe MR.
69+
70+
```sh
71+
# In an fdroiddata clone (https://gitlab.com/fdroid/fdroiddata),
72+
# with fdroidserver installed:
73+
cd ~/src/fdroiddata
74+
fdroid lint io.theficos.quire
75+
fdroid readmeta
76+
fdroid rewritemeta io.theficos.quire
77+
fdroid build --server -v -l io.theficos.quire
78+
```
79+
80+
The `--server` flag spins fdroidserver's reproducible build VM
81+
(headless VirtualBox by default; podman backend also supported). On
82+
success, the unsigned APK lands in
83+
`~/src/fdroiddata/unsigned/io.theficos.quire_<versionCode>.apk`.
84+
85+
Compare it to your signed release APK:
86+
87+
```sh
88+
# Strip signatures from both, then diff the contents.
89+
cd /tmp && mkdir cmp && cd cmp
90+
unzip -q ~/src/fdroiddata/unsigned/io.theficos.quire_*.apk -d a
91+
unzip -q ~/Downloads/app-release.apk -d b
92+
rm -rf a/META-INF b/META-INF # signatures differ by design
93+
diff -r a b && echo "REPRODUCIBLE"
94+
```
95+
96+
If `diff` reports no differences, F-Droid will accept the build. If
97+
it reports differences in `classes*.dex`, the build is non-reproducible
98+
— check JDK version, AGP version, and `gradle.properties` flags in
99+
the fdroidserver VM vs the CI runner.
100+
101+
If `git describe` returns nothing inside the fdroidserver VM (it does
102+
a non-shallow clone, but on rare runs tags might not propagate),
103+
set `QUIRE_VERSION_FALLBACK` in the recipe's `Builds:` block:
104+
105+
```yaml
106+
Builds:
107+
- versionName: 2026.05.08.30
108+
versionCode: 26050830
109+
commit: v2026.05.08.30
110+
subdir: app
111+
gradle: [ yes ]
112+
env:
113+
QUIRE_VERSION_FALLBACK: 2026.05.08.30
114+
```

0 commit comments

Comments
 (0)