Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 =
Expand Down
12 changes: 11 additions & 1 deletion build-info/build.gradle.kts
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,4 +84,14 @@ tasks.create("generateBuildInfo") {
}

tasks.withType<JavaCompile> { dependsOn("generateBuildInfo") }
tasks.withType<Jar> { dependsOn("generateBuildInfo") }
tasks.withType<Jar> {
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
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -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@@");
Comment thread
itsaky-adfa marked this conversation as resolved.

// --------- 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 --------------------

Expand All@@ -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.
*
* <p>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 <em>value</em> 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.
*
* <p>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.
*
* <p>See docs/adr/0012-volatile-build-metadata-out-of-abis.md.
*/
private static String volatileValue(String value) {
return value;
}
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -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()
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) {
Expand DownExpand Up@@ -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)
Comment thread
itsaky-adfa marked this conversation as resolved.
}

return commitEpochSeconds ?: (System.currentTimeMillis() / 1000L)
}

/** Whether the current build is a CI build. */
val isCiBuild by lazy { "true" == System.getenv("CI") }

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Comment thread
itsaky-adfa marked this conversation as resolved.
.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)
Expand All@@ -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",
Expand Down
126 changes: 126 additions & 0 deletions docs/adr/0012-volatile-build-metadata-out-of-abis.md
Original file line numberDiff line numberDiff line change
@@ -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
```
Comment thread
itsaky-adfa marked this conversation as resolved.

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.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 |
60 changes: 60 additions & 0 deletions docs/process/build-ci-glossary.md
Original file line numberDiff line numberDiff line change
@@ -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.
Comment thread
itsaky-adfa marked this conversation as resolved.

**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.
Loading