@@ -4,6 +4,7 @@ import com.android.build.gradle.api.AndroidBasePlugin
44import com.ncorti.ktfmt.gradle.tasks.KtfmtFormatTask
55import java.io.ByteArrayOutputStream
66import javax.inject.Inject
7+ import org.gradle.api.provider.Property
78import org.gradle.api.provider.ValueSource
89import org.gradle.api.provider.ValueSourceParameters
910import org.gradle.process.ExecOperations
@@ -60,46 +61,142 @@ abstract class GitLatestTagValueSource : ValueSource<String, ValueSourceParamete
6061}
6162
6263/* *
63- * The commit a build actually came from, short, with a marker for uncommitted changes .
64+ * Which build this is, in one string: where it was built and from what commit .
6465 *
6566 * The version code is the commit count on origin/master, so every branch build carries master's
6667 * number: a build flashed from a feature branch and one flashed from master both report "v2.0
6768 * (3052)" and cannot be told apart on the device. That is not hypothetical — it cost a real
6869 * investigation to establish which of the two was installed.
6970 *
70- * The hash goes in module.prop's human-readable version only. BuildConfig.VERSION_NAME, which is
71- * what the manager prints on Home, is deliberately left clean.
71+ * Two shapes, because "where did this binary come from" has two different answers:
72+ *
73+ * *On GitHub Actions*, `JingMatrix-Vector-93d66473` — the repository the code came from, then the
74+ * commit. Forks build the same code from the same commits, so the hash alone identifies a
75+ * *revision* and not a *build*, and a fork's artifact was indistinguishable from ours.
76+ *
77+ * Both halves are the *head* ones, and neither is read from the environment GitHub sets by default,
78+ * because on a pull request both defaults name something other than the code that was built:
79+ *
80+ * `GITHUB_REPOSITORY` is the repository that *ran* the workflow. A pull request from a fork is built
81+ * here, so it would stamp `JingMatrix/Vector` onto a branch that has never been near it — which is
82+ * exactly the artifact the name most needs to distinguish.
83+ *
84+ * `HEAD` is worse. For a pull request the runner checks out an ephemeral merge of the branch into
85+ * the base, so `git rev-parse HEAD` names a commit that exists in no repository, cannot be looked up
86+ * by anyone who reads it off a device, and is gone once the run is. The commit that was pushed is
87+ * the one that identifies the build.
88+ *
89+ * So the workflow passes both in: `VECTOR_BUILD_REPOSITORY` and `VECTOR_BUILD_COMMIT`, each taken
90+ * from `github.event.pull_request.head.*` when there is a pull request and from the ordinary default
91+ * when there is not — outside a pull request the two agree anyway. Absent either, this falls back to
92+ * what the environment says and to `HEAD`, which covers a workflow too old to set them.
93+ *
94+ * *Locally*, the bare `93d66473`, or `93d66473-thinkpad` when the tree has uncommitted changes.
95+ * That build corresponds to no commit at all, so naming the commit alone would name something the
96+ * binary does not match — and whoever is looking at it is far better served by the machine it was
97+ * built on than by the word "dirty", since a device that has a hand-built framework on it usually
98+ * has exactly one candidate author.
99+ *
100+ * The dirty check is deliberately not applied on CI. The workflow's own "Write key" step appends
101+ * the signing credentials to the tracked `gradle.properties` before Gradle starts, so every master
102+ * and tag build reports a modified tree — every published canary has said `-dirty` for that reason
103+ * and no other, which sent at least one user looking for a fault that was not there (#815).
104+ *
105+ * The result goes in module.prop's human-readable version and in BuildConfig.VERSION_HASH, which
106+ * the manager shows on the status page. BuildConfig.VERSION_NAME is deliberately left clean.
72107 */
73- abstract class GitCommitHashValueSource : ValueSource <String , ValueSourceParameters .None > {
108+ abstract class GitCommitHashValueSource : ValueSource <String , GitCommitHashValueSource .Parameters > {
109+ interface Parameters : ValueSourceParameters {
110+ /* *
111+ * `owner/repo` when GitHub Actions is building this, empty otherwise.
112+ *
113+ * Threaded in as parameters rather than read with `System.getenv` inside [obtain], which the
114+ * configuration cache does not see and therefore does not invalidate on.
115+ */
116+ val buildRepository: Property <String >
117+
118+ /* * The full SHA that was pushed, when CI knows one and it is not what is checked out. */
119+ val buildCommit: Property <String >
120+ }
121+
74122 @get:Inject abstract val execOperations: ExecOperations
75123
124+ /* *
125+ * Runs [command] and returns its trimmed output, or null if it failed or said nothing.
126+ *
127+ * `exec` *throws* when the executable cannot be started at all, which `isIgnoreExitValue` does
128+ * not cover — `hostname` is not on every machine — so the whole call is wrapped rather than just
129+ * its exit code inspected.
130+ */
131+ private fun capture (vararg command : String ): String? =
132+ runCatching {
133+ val output = ByteArrayOutputStream ()
134+ val result = execOperations.exec {
135+ commandLine(command.toList())
136+ standardOutput = output
137+ errorOutput = ByteArrayOutputStream ()
138+ isIgnoreExitValue = true
139+ }
140+ if (result.exitValue != 0 ) null else output.toString().trim().ifBlank { null }
141+ }
142+ .getOrNull()
143+
144+ /* *
145+ * The machine's name, reduced to what is safe in a version string.
146+ *
147+ * A host name can carry anything the owner typed into it, and this value is interpolated into
148+ * module.prop and a generated Kotlin string literal. Unknown hosts fall back to "local", which
149+ * still reads correctly: not a CI build, and not a clean tree.
150+ */
151+ private fun hostname (): String {
152+ val raw = capture(" hostname" ) ? : System .getenv(" HOSTNAME" ) ? : return " local"
153+ // The short name only. A fully qualified host name is mostly domain, and the domain is the
154+ // part that is identical across every machine that would ever build this.
155+ val short = raw.substringBefore(' .' )
156+ val safe = short.filter { it.isLetterOrDigit() || it == ' -' || it == ' _' }
157+ return safe.ifBlank { " local" }
158+ }
159+
76160 override fun obtain (): String {
77- val hash = ByteArrayOutputStream ()
78- val hashResult = execOperations.exec {
79- commandLine(" git" , " rev-parse" , " --short" , " HEAD" )
80- standardOutput = hash
81- isIgnoreExitValue = true
82- }
83- if (hashResult.exitValue != 0 || hash.toString().isBlank()) return " unknown"
84-
85- // A dirty tree is the case worth naming: that build corresponds to no commit at all, so
86- // "which commit is this" has no answer and the version should say so rather than name a
87- // commit the binary does not match.
88- val dirty = ByteArrayOutputStream ()
89- val dirtyResult = execOperations.exec {
90- commandLine(" git" , " status" , " --porcelain" , " --untracked-files=no" )
91- standardOutput = dirty
92- isIgnoreExitValue = true
161+ // Abbreviated by git rather than by hand, so a CI build and a local build of the same commit
162+ // read identically however long this repository's abbreviation happens to be.
163+ val head = capture(" git" , " rev-parse" , " --short" , " HEAD" ) ? : return " unknown"
164+
165+ val repository = parameters.buildRepository.getOrElse(" " )
166+ if (repository.isBlank()) {
167+ val dirty = capture(" git" , " status" , " --porcelain" , " --untracked-files=no" ) != null
168+ return if (dirty) " $head -${hostname()} " else head
93169 }
94- val suffix =
95- if (dirtyResult.exitValue == 0 && dirty.toString().isNotBlank()) " -dirty" else " "
96- return hash.toString().trim() + suffix
170+
171+ // The pushed commit is an ancestor of the merge that was checked out, so it is in the
172+ // repository and git will abbreviate it. The truncation is only reached if it somehow is
173+ // not, and a slightly odd length beats reporting the merge commit or nothing at all.
174+ val pushed = parameters.buildCommit.getOrElse(" " ).takeIf { it.isNotBlank() }
175+ val short =
176+ pushed?.let { capture(" git" , " rev-parse" , " --short" , it) ? : it.take(head.length) } ? : head
177+ return repository.replace(' /' , ' -' ) + " -" + short
97178 }
98179}
99180
100181// This defers the execution of the git commands and allows Gradle to cache the results.
101182val versionCodeProvider by extra(providers.of(GitCommitCountValueSource ::class .java) {})
102- val versionHashProvider by extra(providers.of(GitCommitHashValueSource ::class .java) {})
183+ val versionHashProvider by
184+ extra(
185+ providers.of(GitCommitHashValueSource ::class .java) {
186+ // Set on every GitHub Actions runner and on nothing else, so the presence of either is
187+ // the test for "this is a CI build". The workflow's own variables win because they name
188+ // the branch that was pushed; GitHub's defaults name the run that built it.
189+ parameters.buildRepository.set(
190+ providers
191+ .environmentVariable(" VECTOR_BUILD_REPOSITORY" )
192+ .orElse(providers.environmentVariable(" GITHUB_REPOSITORY" ))
193+ .orElse(" " )
194+ )
195+ parameters.buildCommit.set(
196+ providers.environmentVariable(" VECTOR_BUILD_COMMIT" ).orElse(" " )
197+ )
198+ }
199+ )
103200val versionNameProvider by extra(providers.of(GitLatestTagValueSource ::class .java) {})
104201
105202val injectedPackageName by extra(" com.android.shell" )
0 commit comments