diff --git a/app/src/main/java/com/itsaky/androidide/utils/BuildInfoUtils.kt b/app/src/main/java/com/itsaky/androidide/utils/BuildInfoUtils.kt index 7b17c93371..ff4a70ab71 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/BuildInfoUtils.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/BuildInfoUtils.kt @@ -31,7 +31,10 @@ import com.termux.shared.termux.TermuxUtils * @author Akash Yadav */ object BuildInfoUtils { - const val BASIC_INFO = BasicBuildInfo.BASIC_INFO + // Not a `const val`: the underlying version string changes between builds and must + // not be inlined into consumers. See ADR 0012. + @JvmField + val BASIC_INFO = BasicBuildInfo.BASIC_INFO private val BUILD_INFO_HEADER by lazy { val map = diff --git a/build-info/build.gradle.kts b/build-info/build.gradle.kts index 812fc61ced..79dc003c04 100644 --- a/build-info/build.gradle.kts +++ b/build-info/build.gradle.kts @@ -84,4 +84,14 @@ tasks.create("generateBuildInfo") { } tasks.withType { dependsOn("generateBuildInfo") } -tasks.withType { dependsOn("generateBuildInfo") } +tasks.withType { + dependsOn("generateBuildInfo") + + // Jars embed per-entry timestamps by default, so rebuilding identical sources + // still produces different bytes. kapt tracks this jar through its + // `internalNonAbiClasspath` input -- jar contents, not the ABI -- so a + // non-reproducible jar re-runs annotation processing across every kapt module + // for no reason. See docs/adr/0012-volatile-build-metadata-out-of-abis.md. + isPreserveFileTimestamps = false + isReproducibleFileOrder = true +} diff --git a/build-info/src/main/java/com/itsaky/androidide/buildinfo/BuildInfo.java.in b/build-info/src/main/java/com/itsaky/androidide/buildinfo/BuildInfo.java.in index 75ab99615c..475dcd840d 100644 --- a/build-info/src/main/java/com/itsaky/androidide/buildinfo/BuildInfo.java.in +++ b/build-info/src/main/java/com/itsaky/androidide/buildinfo/BuildInfo.java.in @@ -38,16 +38,20 @@ public class BuildInfo { public static final String MVN_GROUP_ID = "@@MVN_GROUP_ID@@"; public static final String VERSION_NAME = "@@VERSION_NAME@@"; - public static final String VERSION_NAME_SIMPLE = "@@VERSION_NAME_SIMPLE@@"; public static final String RELEASE_VERSION = "@@RELEASE_VERSION@@"; - public static final String VERSION_NAME_PUBLISHING = "@@VERSION_NAME_PUBLISHING@@"; - public static final String VERSION_NAME_DOWNLOAD = "@@VERSION_NAME_DOWNLOAD@@"; + + // The three fields below embed the build time to the minute, so they change + // between any two builds. volatileValue() keeps them out of the ConstantValue + // attribute: see the note on that method. + public static final String VERSION_NAME_SIMPLE = volatileValue("@@VERSION_NAME_SIMPLE@@"); + public static final String VERSION_NAME_PUBLISHING = volatileValue("@@VERSION_NAME_PUBLISHING@@"); + public static final String VERSION_NAME_DOWNLOAD = volatileValue("@@VERSION_NAME_DOWNLOAD@@"); // --------- CI info -------------------- public static final boolean CI_BUILD = @@CI_BUILD@@; - public static final String CI_GIT_BRANCH = "@@CI_GIT_BRANCH@@"; - public static final String CI_GIT_COMMIT_HASH = "@@CI_COMMIT_HASH@@"; + public static final String CI_GIT_BRANCH = volatileValue("@@CI_GIT_BRANCH@@"); + public static final String CI_GIT_COMMIT_HASH = volatileValue("@@CI_COMMIT_HASH@@"); // --------- CI info -------------------- @@ -68,4 +72,23 @@ public class BuildInfo { public static final String PROJECT_SITE = "@@PROJECT_SITE@@"; public static final String SNAPSHOTS_REPOSITORY = "@@SNAPSHOTS_REPOSITORY@@"; public static final String PUBLIC_REPOSITORY = "@@PUBLIC_REPOSITORY@@"; + + /** + * Returns its argument unchanged. + * + *

A {@code static final String} initialised by a constant expression is a + * compile-time constant: javac records it in the ConstantValue attribute and inlines + * it into every consumer, which makes its value part of this module's ABI. + * Because :build-info sits at the root of the dependency graph, a value that changes + * between builds would then force the entire project to recompile every time. + * + *

Routing the value through a method call makes the initialiser non-constant, so + * no ConstantValue is emitted and the value leaves the ABI. Do not "simplify" the + * volatile fields back to plain literals. + * + *

See docs/adr/0012-volatile-build-metadata-out-of-abis.md. + */ + private static String volatileValue(String value) { + return value; + } } \ No newline at end of file diff --git a/common/src/main/java/com/itsaky/androidide/utils/BuildInfoUtils.kt b/common/src/main/java/com/itsaky/androidide/utils/BuildInfoUtils.kt index dcb9065c00..ff5a5ccc2e 100644 --- a/common/src/main/java/com/itsaky/androidide/utils/BuildInfoUtils.kt +++ b/common/src/main/java/com/itsaky/androidide/utils/BuildInfoUtils.kt @@ -11,8 +11,12 @@ object BasicBuildInfo { /** * Basic info, includes internal app name and version name. + * + * Not a `const val`: [BuildInfo.VERSION_NAME_SIMPLE] changes between builds, and a + * `const val` would inline it here and put it back in this module's ABI. See ADR 0012. */ - const val BASIC_INFO = "${BuildInfo.INTERNAL_NAME} (${BuildInfo.VERSION_NAME_SIMPLE})" + @JvmField + val BASIC_INFO = "${BuildInfo.INTERNAL_NAME} (${BuildInfo.VERSION_NAME_SIMPLE})" val hasReleaseVersion: Boolean get() = BuildInfo.RELEASE_VERSION.isNotBlank() diff --git a/composite-builds/build-logic/common/src/main/java/com/itsaky/androidide/build/config/CI.kt b/composite-builds/build-logic/common/src/main/java/com/itsaky/androidide/build/config/CI.kt index db22c94e6b..2e4949cde0 100644 --- a/composite-builds/build-logic/common/src/main/java/com/itsaky/androidide/build/config/CI.kt +++ b/composite-builds/build-logic/common/src/main/java/com/itsaky/androidide/build/config/CI.kt @@ -29,6 +29,7 @@ import kotlin.getOrDefault object CI { private var commitHash: String? = null private var branchName: String? = null + private var commitEpochSeconds: Long? = null fun commitHash(project: Project): String { if (commitHash == null) { @@ -63,6 +64,42 @@ object CI { return branchName ?: "unknown" } + /** + * Committer timestamp of the commit being built, in epoch seconds. + * + * Version strings derive from this rather than from the wall clock, so rebuilding + * a commit yields the same version instead of one that changes every minute. That + * keeps the generated BuildInfo, and therefore build-info.jar, byte-stable between + * rebuilds. See docs/adr/0012-volatile-build-metadata-out-of-abis.md. + * + * Falls back to the current time if git cannot be read; determinism then no longer + * holds, but the build still succeeds. + * + * This is read during configuration, so it goes through [ProviderFactory.exec] + * rather than a raw ProcessBuilder: the configuration cache cannot track an + * external process started directly from a build script, but it can track this. + */ + fun commitEpochSeconds(project: Project): Long { + if (commitEpochSeconds == null) { + val sha = System.getenv("GITHUB_SHA") ?: "HEAD" + commitEpochSeconds = + runCatching { + project.providers + .exec { spec -> + spec.workingDir(project.rootProject.projectDir) + spec.commandLine("git", "show", "-s", "--format=%ct", sha) + spec.isIgnoreExitValue = true + }.standardOutput.asText + .get() + .trim() + }.getOrNull() + ?.toLongOrNull() + ?: (System.currentTimeMillis() / 1000L) + } + + return commitEpochSeconds ?: (System.currentTimeMillis() / 1000L) + } + /** Whether the current build is a CI build. */ val isCiBuild by lazy { "true" == System.getenv("CI") } diff --git a/composite-builds/build-logic/common/src/main/java/com/itsaky/androidide/build/config/ProjectConfig.kt b/composite-builds/build-logic/common/src/main/java/com/itsaky/androidide/build/config/ProjectConfig.kt index 87ee78efa3..4b6736ff58 100644 --- a/composite-builds/build-logic/common/src/main/java/com/itsaky/androidide/build/config/ProjectConfig.kt +++ b/composite-builds/build-logic/common/src/main/java/com/itsaky/androidide/build/config/ProjectConfig.kt @@ -68,7 +68,20 @@ val Project.simpleVersionName: String } val buildTypeShort = if (buildType == "debug") "d" else "r" - val calendar = java.util.Calendar.getInstance() + // Derived from the commit being built, not the wall clock, so rebuilding a + // commit produces the same version string. With the wall clock, any two builds + // a minute apart produced different values, which changed BuildInfo and so + // build-info.jar on every build. See ADR 0012. + // + // Fixed to UTC deliberately: Calendar.getInstance() uses the JVM default zone, + // which would make the version a function of the builder's timezone as well as + // the commit, so the same commit built in two places would not agree. + val calendar = + java.util.Calendar + .getInstance(java.util.TimeZone.getTimeZone("UTC")) + .apply { + timeInMillis = CI.commitEpochSeconds(project) * 1000L + } val month = calendar.get(java.util.Calendar.MONTH) + 1 val day = calendar.get(java.util.Calendar.DAY_OF_MONTH) val hour = calendar.get(java.util.Calendar.HOUR_OF_DAY) @@ -89,7 +102,12 @@ val Project.simpleVersionName: String val Project.releaseVersion: String get() { - val raw = providers.gradleProperty("next_release_version").orNull.orEmpty().trim() + val raw = + providers + .gradleProperty("next_release_version") + .orNull + .orEmpty() + .trim() if (raw.isNotEmpty() && !Regex("""^\d{2}\.\d{2}$""").matches(raw)) { throw GradleException( "Invalid next_release_version '$raw'; expected YY.ww (two digits, dot, two digits), e.g. 25.47", diff --git a/docs/adr/0012-volatile-build-metadata-out-of-abis.md b/docs/adr/0012-volatile-build-metadata-out-of-abis.md new file mode 100644 index 0000000000..8853045cee --- /dev/null +++ b/docs/adr/0012-volatile-build-metadata-out-of-abis.md @@ -0,0 +1,126 @@ +# 0012. Keep volatile build metadata out of module ABIs + +- **Status:** Proposed +- **Date:** 2026-08-13 +- **Deciders:** Code On The Go team + +## Context + +`:build-info` generates `BuildInfo.java` from a template and sits at the root of the +dependency graph. Five of its generated fields change from build to build: + +```java +VERSION_NAME_SIMPLE = "C-d-0810-1555" // wall-clock time, to the minute +VERSION_NAME_PUBLISHING = "C-d-0810-1555-98ea6f6a4-SNAPSHOT" // time + commit hash +VERSION_NAME_DOWNLOAD = "C-d-0810-1555-98ea6f6a4-SNAPSHOT" // time + commit hash +CI_GIT_BRANCH = "ci-bench" +CI_GIT_COMMIT_HASH = "98ea6f6a4" +``` + +All are `public static final String`. Java and Kotlin inline compile-time constants +into every consumer, so a constant's *value* belongs to the declaring module's ABI. +Every build therefore changed `:build-info`'s ABI and forced the whole project to +recompile. + +Three of the five derive from the current time (`simpleVersionName` in +`ProjectConfig.kt` formats `C-{d|r}-MMDD-HHMM`), so this fires on **any two builds a +minute apart, even of an identical commit**. That is strictly worse than the commit +hash, and it is why the problem reproduces off CI. + +Measured locally with a scripted no-change scenario - no source edit whatsoever, only +a different `GITHUB_SHA`: + +``` +30 compileV8DebugKotlin <- every Kotlin module in the project +12 kaptGenerateStubsV8DebugKotlin +11 kaptV8DebugKotlin +``` + +The same signature appears on CI (30 executed `compileV8DebugKotlin`). A build in +which nothing changed recompiles the entire tree. + +The churn also propagates a second time. `common/.../BuildInfoUtils.kt` declares: + +```kotlin +const val BASIC_INFO = "${BuildInfo.INTERNAL_NAME} (${BuildInfo.VERSION_NAME_SIMPLE})" +``` + +A Kotlin `const val` is inlined too, so `:common`'s ABI churns as well and everything +depending on `:common` recompiles from there. + +## Decision + +Generate the volatile fields with **non-constant initialisers**, so `javac` emits no +`ConstantValue` attribute and the values leave the ABI entirely: + +```java +public static final String VERSION_NAME_SIMPLE = volatileValue("@@VERSION_NAME_SIMPLE@@"); +``` + +The rule this encodes: **a value that changes between builds must never be a +compile-time constant.** Where it is declared matters less than whether it is +inlinable. + +`:common`'s `BASIC_INFO` becomes a non-`const` `val`. This is not optional - a Kotlin +`const val` requires a compile-time constant initialiser, so it stops compiling until +corrected. + +Stable fields (package name, repo coordinates, AGP versions, F-Droid flags) keep their +constant form. + +Two related changes follow from the same invariant: + +- `simpleVersionName` derives its timestamp from the commit being built rather than + the wall clock, so the generated source is a function of the commit. Format and + ordering are unchanged, so nothing product-visible moves. The calendar is fixed to + UTC, otherwise the version would be a function of the builder's timezone too. +- `:build-info`'s Jar sets `preserveFileTimestamps = false` and + `reproducibleFileOrder = true`. This is not optional in practice: with the timestamp + fixed, `BuildInfo.java` became byte-identical between rebuilds while the *jar* still + changed, because Gradle embeds per-entry timestamps by default. kapt tracks that jar + through an input property named `internalNonAbiClasspath` - jar bytes rather than the + ABI - so the ABI fix above cannot reach it and only a reproducible jar can. + +## Consequences + +**Positive** +- A commit, or the clock advancing, no longer changes any module's ABI. Recompilation + is confined to modules whose sources actually changed: 1 Kotlin module for a no-op or + a leaf edit, 10 for a three-module edit containing one real ABI change. +- Gradle's build cache and up-to-date checks become effective for the first time. +- The invariant is enforced by the compiler rather than by convention: reintroducing a + `const val` over a volatile value fails the build. + +**Negative / costs** +- `BuildInfo`'s volatile fields can no longer be used where Java or Kotlin requires a + compile-time constant (annotation arguments, `when` branch constants). None of the + current call sites need that. +- The `volatileValue()` indirection is unusual and invites "simplification" back into a + plain constant. The generated file carries a comment saying why. +- Values move from being inlined at each call site to a single static read. The runtime + cost is immaterial; the behaviour is unchanged. +- kapt still re-runs across its 11 modules: it resolves the full compile classpath + rather than the ABI-normalised one. Tracked by ADFA-4598 (kapt to KSP). + +## Alternatives considered + +- **Move the fields into `:app`'s `BuildConfig`.** Considered first and rejected on + evidence: `:common` and `:editor` consume `VERSION_NAME_SIMPLE`, and neither can + depend on `:app`. It would have addressed only the two `CI_GIT_*` fields and left the + dominant, time-based churn untouched. +- **A separate `:build-info-git` leaf module.** Same defect - it isolates the git + fields but not the version fields that library modules genuinely need. +- **Drop the timestamp from `simpleVersionName`.** Attacks the root cause rather than + the propagation, and would help independently. Rejected *for this ADR* because the + version string is product-visible (Firebase release notes, tester-facing builds, + Jira), so it is a product decision rather than a build one. Worth revisiting. +- **Leave it and rely on the remote build cache.** Does not help: the compile tasks + miss the cache precisely because their compile classpath genuinely changed. + +## Related + +- [0005](0005-per-abi-product-flavors.md) - the flavor dimension that multiplies every + build task, and so multiplies the cost of this churn. +- [Build and CI glossary](../process/build-ci-glossary.md) - *ABI change*, *ABI churn*, + *build graph health*. +- ADFA-5126 - the ticket, with the full before/after measurements. diff --git a/docs/adr/README.md b/docs/adr/README.md index 7139d240d5..c682eefaca 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -25,3 +25,4 @@ Format is lightweight **MADR / Nygard**: Context → Decision → Consequences | [0009](0009-jetpack-compose-for-new-ui.md) | Build new UI in Jetpack Compose, not XML Views | Proposed | | [0010](0010-navigation-resolves-via-analysis-api.md) | Kotlin navigation resolves via the Analysis API, not the symbol index | Proposed | | [0011](0011-command-analysis-priority.md) | User-invoked commands get their own analysis priority | Proposed | +| [0012](0012-volatile-build-metadata-out-of-abis.md) | Keep volatile build metadata out of module ABIs | Proposed | diff --git a/docs/process/build-ci-glossary.md b/docs/process/build-ci-glossary.md new file mode 100644 index 0000000000..bc6de22bd7 --- /dev/null +++ b/docs/process/build-ci-glossary.md @@ -0,0 +1,60 @@ +# Build and CI glossary + +Vocabulary for build and CI work on Code On The Go. Terms are defined here so that a +word means one thing across code, tickets, PRs, and conversation. + +This file is a **glossary only**. It holds no implementation detail and no decision +rationale - decisions live in [docs/adr/](../adr/), structure lives in +[ARCHITECTURE.md](../../ARCHITECTURE.md). + +## Terms + +**Critical path** +The longest chain of work that must finish before CI reports a verdict. Work that runs +concurrently on another runner is not on the critical path even though it costs time. +Distinct from *runner occupancy*. + +**Runner occupancy** +Total runner-minutes a single push consumes, summed across every job it starts. A push +can have a short critical path and high occupancy (two runners busy in parallel). +Occupancy is what makes other people's builds queue; critical path is what makes one +developer wait. Reducing one can increase the other. + +**ABI change** (of a module) +A change to a module's public compile-time surface: signatures, public constants, +anything a dependent module compiles against. Dependents must recompile. Contrast +*non-ABI change* (a method body, a comment) where dependents need not recompile. +Java and Kotlin **inline** compile-time constants such as `static final String`, so +changing a constant's *value* is an ABI change even though the declaration is untouched. + +**ABI churn** +An ABI change that carries no semantic meaning for dependents, forcing recompilation +for nothing. Build metadata stamped into a widely-depended-on module is the canonical +source - see [ADR 0012](../adr/0012-volatile-build-metadata-out-of-abis.md). + +**Build graph health** +How closely the set of re-executed tasks matches the set of genuinely affected tasks. +Measured as the ratio of `executed` to `up-to-date`/`from-cache` tasks in Gradle's +summary line. Independent of hardware, and therefore comparable across machines - +unlike wall clock. + +**Baseline** +A recorded measurement of the pipeline before a change, against which later iterations +are compared. A measurement is only a baseline if it was produced under the same +protocol and scenario as the runs compared to it. + +**Scenario** +A deterministic, scripted source change of defined scope, used as a measurement +workload. Scenarios differ in blast radius - no-op, single leaf module, ABI change in +a core module, multi-module - so one pipeline produces a profile rather than a number. + +**Warm workspace** +A checkout whose `build/` outputs and Gradle caches survive from a previous run. The +steady state of a self-hosted runner, and the state any representative measurement must +reproduce. Contrast a *cold* build, which no runner ever performs in practice. + +## Related + +- [ARCHITECTURE.md](../../ARCHITECTURE.md) - module map, layering, tech stack. +- [docs/adr/](../adr/) - the decisions and their rationale. +- [CLAUDE.md](../../CLAUDE.md) - build and test invocations.