From ce3c15a31295fb1308b860dcbe390c913ca9a7a4 Mon Sep 17 00:00:00 2001 From: Ryan VanderMeulen Date: Tue, 28 Jul 2026 17:21:20 -0400 Subject: [PATCH 1/7] Make the android-gradle-dependencies toolchain task not execute Gradle tasks Ported from bug 1977709 in mozilla-central (D257608). Rather than building everything in order to pull dependencies into the local Nexus, ask Gradle to enumerate and download the dependencies of every resolvable configuration, and use --dry-run to stop the requested tasks from actually running. Unlike mozilla-central, one real task execution is still needed. AGP resolves the aapt2 binary through a detached configuration while its tasks are running, so the enumeration never sees it, and downstream tasks are given the packaged repository as their only google() repo, so a missing aapt2 is a hard resolution failure for them. :app:processDebugResources is the cheapest task that forces the download. mozilla-central gets away without this because its builds resolve from GRADLE_MAVEN_REPOSITORIES, which normally points at the live remote repos. --- .../toolchain/android-gradle-dependencies.sh | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/taskcluster/scripts/toolchain/android-gradle-dependencies.sh b/taskcluster/scripts/toolchain/android-gradle-dependencies.sh index d5f7c26a4b..e73ad24ee9 100755 --- a/taskcluster/scripts/toolchain/android-gradle-dependencies.sh +++ b/taskcluster/scripts/toolchain/android-gradle-dependencies.sh @@ -18,8 +18,20 @@ pushd $PROJECT_DIR . taskcluster/scripts/toolchain/android-gradle-dependencies/before.sh -# We build everything to be sure to fetch all dependencies -./gradlew --no-daemon -Pcoverage -PgoogleRepo='http://localhost:8081/nexus/content/repositories/google/' -PcentralRepo='http://localhost:8081/nexus/content/repositories/central/' assemble assembleAndroidTest bundle test lint ktlint detekt +GRADLE_FLAGS=( + --no-daemon + --no-configuration-cache + -Pcoverage + -PgoogleRepo='http://localhost:8081/nexus/content/repositories/google/' + -PcentralRepo='http://localhost:8081/nexus/content/repositories/central/' +) + +# Enumerates and downloads the dependencies of every resolvable configuration +# without running the requested tasks. +./gradlew "${GRADLE_FLAGS[@]}" --write-verification-metadata sha256 --dry-run assemble assembleAndroidTest bundle test lint ktlint detekt + +# AGP resolves the aapt2 binary while its tasks run, so the pass above misses it. +./gradlew "${GRADLE_FLAGS[@]}" :app:processDebugResources . taskcluster/scripts/toolchain/android-gradle-dependencies/after.sh From de9403b196e507843158127895382e5636e3c0a2 Mon Sep 17 00:00:00 2001 From: Ryan VanderMeulen Date: Tue, 28 Jul 2026 17:22:02 -0400 Subject: [PATCH 2/7] Ask the toolchain task for the Gradle tasks CI actually runs Replace `assemble assembleAndroidTest bundle test lint ktlint detekt` with `detekt ktlint lint buildHealth build`, which is what the build, test and lint kinds run. buildHealth was missing from the old list entirely. This does not change what gets downloaded. With --write-verification-metadata, Gradle resolves every resolvable configuration in every project regardless of which tasks were requested, so the task list no longer drives coverage. Both lists were checked to enumerate an identical set of 915 components. --- taskcluster/scripts/toolchain/android-gradle-dependencies.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/taskcluster/scripts/toolchain/android-gradle-dependencies.sh b/taskcluster/scripts/toolchain/android-gradle-dependencies.sh index e73ad24ee9..98895158dc 100755 --- a/taskcluster/scripts/toolchain/android-gradle-dependencies.sh +++ b/taskcluster/scripts/toolchain/android-gradle-dependencies.sh @@ -28,7 +28,7 @@ GRADLE_FLAGS=( # Enumerates and downloads the dependencies of every resolvable configuration # without running the requested tasks. -./gradlew "${GRADLE_FLAGS[@]}" --write-verification-metadata sha256 --dry-run assemble assembleAndroidTest bundle test lint ktlint detekt +./gradlew "${GRADLE_FLAGS[@]}" --write-verification-metadata sha256 --dry-run detekt ktlint lint buildHealth build # AGP resolves the aapt2 binary while its tasks run, so the pass above misses it. ./gradlew "${GRADLE_FLAGS[@]}" :app:processDebugResources From 26d6d865d6164aff165fe160515a0080cb65528d Mon Sep 17 00:00:00 2001 From: Ryan VanderMeulen Date: Tue, 28 Jul 2026 17:23:28 -0400 Subject: [PATCH 3/7] Package the Gradle dependency cache with zstd instead of xz Matches what mozilla-central does now. xz is single threaded on both ends and this artifact is unpacked by the build, build-bundle, test and lint kinds. Two additions to the base image are needed for this. `tar -a` shells out to the zstd binary, which is priority optional on Ubuntu and so is not installed (libzstd1 is present, but that is only the shared library). And run-task's fetch-content decompresses .tar.zst with the zstandard Python package, failing with "zstandard Python package not available" without it; the taskcluster package we already install does not pull it in. The base image covers both sides, since android-build and ui-tests derive from it and every kind that fetches this artifact uses it. No taskgraph change is needed: fetch-content already treats any *.tar.* as a tar and sniffs the zstd magic to pick a decompressor. Verified by extracting a `tar cavf` archive with the pinned taskgraph's fetch-content, which fails without the zstandard package and succeeds with it. --- taskcluster/docker/base/Dockerfile | 3 ++- taskcluster/kinds/toolchain/android.yml | 2 +- .../scripts/toolchain/android-gradle-dependencies/after.sh | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/taskcluster/docker/base/Dockerfile b/taskcluster/docker/base/Dockerfile index 802cb7e83e..3c83458e8c 100644 --- a/taskcluster/docker/base/Dockerfile +++ b/taskcluster/docker/base/Dockerfile @@ -42,10 +42,11 @@ RUN apt-get update -qq \ locales \ unzip \ mercurial \ + zstd \ && apt-get clean RUN pip3 install --upgrade pip -RUN pip3 install taskcluster +RUN pip3 install taskcluster zstandard RUN locale-gen "$LANG" diff --git a/taskcluster/kinds/toolchain/android.yml b/taskcluster/kinds/toolchain/android.yml index e406a832d4..24c07d4858 100644 --- a/taskcluster/kinds/toolchain/android.yml +++ b/taskcluster/kinds/toolchain/android.yml @@ -41,7 +41,7 @@ linux64-android-gradle-dependencies: - 'gradle.properties' - 'gradle/**' - taskcluster/scripts/toolchain/android-gradle-dependencies/** - toolchain-artifact: public/build/android-gradle-dependencies.tar.xz + toolchain-artifact: public/build/android-gradle-dependencies.tar.zst toolchain-alias: android-gradle-dependencies treeherder: symbol: TL(gradle-dependencies) diff --git a/taskcluster/scripts/toolchain/android-gradle-dependencies/after.sh b/taskcluster/scripts/toolchain/android-gradle-dependencies/after.sh index e2e52b51c9..f7c849aca3 100644 --- a/taskcluster/scripts/toolchain/android-gradle-dependencies/after.sh +++ b/taskcluster/scripts/toolchain/android-gradle-dependencies/after.sh @@ -23,6 +23,6 @@ mkdir -p android-gradle-dependencies /builds/worker/artifacts cp -R ${NEXUS_WORK}/storage/central android-gradle-dependencies cp -R ${NEXUS_WORK}/storage/google android-gradle-dependencies -tar cf - android-gradle-dependencies | xz > /builds/worker/artifacts/android-gradle-dependencies.tar.xz +tar cavf /builds/worker/artifacts/android-gradle-dependencies.tar.zst android-gradle-dependencies popd From d21af2555da73bd18fc60ec81ea2dbf782093d37 Mon Sep 17 00:00:00 2001 From: Ryan VanderMeulen Date: Tue, 28 Jul 2026 17:23:48 -0400 Subject: [PATCH 4/7] Let buildSrc resolve through the local Nexus in CI buildSrc declares a bare mavenCentral(), so unlike the root project it ignores the centralRepo property that CI points at the local Nexus. Its classpath is therefore fetched straight from Maven Central and never lands in the android-gradle-dependencies artifact, even though the artifact is otherwise meant to be the only Maven Central the downstream tasks see. Mirror what the root project does, and add buildSrc to the toolchain task's resources. buildSrc/build.gradle.kts didn't match any of them, `*.gradle` not covering `.kts`, so with buildSrc's classpath now part of the artifact a change to it would otherwise silently reuse a stale one. --- buildSrc/build.gradle.kts | 12 +++++++++++- taskcluster/kinds/toolchain/android.yml | 1 + 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index c520450455..64dea564db 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -7,5 +7,15 @@ plugins { } repositories { - mavenCentral() + // Mirrors the root project so that CI resolves through the local Nexus. + val centralRepo = providers.gradleProperty("centralRepo") + if (centralRepo.isPresent) { + maven { + name = "MavenCentral" + url = uri(centralRepo.get()) + isAllowInsecureProtocol = true // Local Nexus in CI uses HTTP + } + } else { + mavenCentral() + } } diff --git a/taskcluster/kinds/toolchain/android.yml b/taskcluster/kinds/toolchain/android.yml index 24c07d4858..82eadf945a 100644 --- a/taskcluster/kinds/toolchain/android.yml +++ b/taskcluster/kinds/toolchain/android.yml @@ -38,6 +38,7 @@ linux64-android-gradle-dependencies: resources: - '*.gradle' - 'app/*.gradle' + - 'buildSrc/**' - 'gradle.properties' - 'gradle/**' - taskcluster/scripts/toolchain/android-gradle-dependencies/** From 5506c8670b53400e5aeeb7505e5b1481f35db91f Mon Sep 17 00:00:00 2001 From: Ryan VanderMeulen Date: Tue, 28 Jul 2026 17:47:47 -0400 Subject: [PATCH 5/7] Let the toolchain task's two Gradle invocations share a daemon Now that this task runs Gradle twice in a row, running each one without a daemon makes the second pay for a cold JVM again. Measured locally, the second pass drops from 37s to 13s when it can reuse a warm one. Dropping --no-daemon is not enough on its own: the base image puts -Dorg.gradle.daemon=false in GRADLE_OPTS, which a first attempt at this showed still forks a single-use daemon per invocation in CI. Pass --daemon explicitly to override it, and stop the daemon once both passes are done rather than leaving it holding its heap while after.sh copies, hashes and compresses the artifact. --- .../scripts/toolchain/android-gradle-dependencies.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/taskcluster/scripts/toolchain/android-gradle-dependencies.sh b/taskcluster/scripts/toolchain/android-gradle-dependencies.sh index 98895158dc..d391fd925a 100755 --- a/taskcluster/scripts/toolchain/android-gradle-dependencies.sh +++ b/taskcluster/scripts/toolchain/android-gradle-dependencies.sh @@ -19,7 +19,8 @@ pushd $PROJECT_DIR . taskcluster/scripts/toolchain/android-gradle-dependencies/before.sh GRADLE_FLAGS=( - --no-daemon + # Overrides the daemon=false the base image puts in GRADLE_OPTS. + --daemon --no-configuration-cache -Pcoverage -PgoogleRepo='http://localhost:8081/nexus/content/repositories/google/' @@ -33,6 +34,9 @@ GRADLE_FLAGS=( # AGP resolves the aapt2 binary while its tasks run, so the pass above misses it. ./gradlew "${GRADLE_FLAGS[@]}" :app:processDebugResources +# Don't leave a 4GB heap sitting there while `after.sh` packages everything up. +./gradlew --stop + . taskcluster/scripts/toolchain/android-gradle-dependencies/after.sh popd From f65cb2f4055b876f6e335a4a0791140f3fa4775e Mon Sep 17 00:00:00 2001 From: Ryan VanderMeulen Date: Tue, 28 Jul 2026 20:35:26 -0400 Subject: [PATCH 6/7] Resolve plugins through the local Nexus in CI too Project dependencies already resolve through the local Nexus, but plugins did not, and that left the packaged artifact incomplete in a way that is impossible to distinguish from the packaging having dropped something. Two separate holes. pluginManagement in settings.gradle declared google() and mavenCentral() outright, and Gradle contributes those to the buildscript classpath alongside the proxies, so resolution could take a component's pom through Nexus and its jar from the unproxied twin. buildSrc resolves its own plugins separately again, from the plugin portal, which nothing pointed at Nexus either. A CI run packaged com.google.errorprone's annotations as a pom with no jar, which either hole would explain. nexus.xml already had a gradle-plugins proxy for the portal, unused until now, so this only has to point at it and package its storage. buildSrc needs its own settings.gradle.kts because the root one doesn't govern it, and the Kotlin DSL plugin it applies is published to the portal and not to Maven Central. The two Mozilla repositories are still not proxied, but they serve nothing outside org.mozilla, so they can only ever account for a component being absent in full, never for a component that is packaged with a file missing. --- build.gradle | 3 ++ buildSrc/settings.gradle.kts | 31 +++++++++++++++++++ settings.gradle | 26 ++++++++++++++-- taskcluster/rb_taskgraph/job.py | 10 ++++-- .../toolchain/android-gradle-dependencies.sh | 3 +- .../android-gradle-dependencies/after.sh | 6 +++- 6 files changed, 72 insertions(+), 7 deletions(-) create mode 100644 buildSrc/settings.gradle.kts diff --git a/build.gradle b/build.gradle index 2c32a11c90..4c4d53ab42 100644 --- a/build.gradle +++ b/build.gradle @@ -221,5 +221,8 @@ tasks.register('listRepositories') { doLast { println "Repositories:" project.repositories.each { println "Name: " + it.name + "; url: " + it.url } + // Plugin repositories have no url to report, being wrappers. + println "Buildscript repositories:" + buildscript.repositories.each { println "Name: " + it.name } } } diff --git a/buildSrc/settings.gradle.kts b/buildSrc/settings.gradle.kts new file mode 100644 index 0000000000..b656c43e6b --- /dev/null +++ b/buildSrc/settings.gradle.kts @@ -0,0 +1,31 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +pluginManagement { + repositories { + // buildSrc resolves its plugins separately from the root project, and + // the Kotlin DSL plugin is only published to the plugin portal. + val pluginRepo = providers.gradleProperty("pluginRepo") + if (pluginRepo.isPresent) { + maven { + name = "GradlePlugins" + url = uri(pluginRepo.get()) + isAllowInsecureProtocol = true // Local Nexus in CI uses HTTP + } + } else { + gradlePluginPortal() + } + + val centralRepo = providers.gradleProperty("centralRepo") + if (centralRepo.isPresent) { + maven { + name = "MavenCentral" + url = uri(centralRepo.get()) + isAllowInsecureProtocol = true // Local Nexus in CI uses HTTP + } + } else { + mavenCentral() + } + } +} diff --git a/settings.gradle b/settings.gradle index 253b7b6dab..9cb3821643 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,7 +1,29 @@ pluginManagement { repositories { - google() - mavenCentral() + // Without these, plugin resolution reaches past the local Nexus in CI to + // the real repositories, and whatever it fetches is missing from the + // android-gradle-dependencies artifact. + def googleRepo = providers.gradleProperty("googleRepo") + if (googleRepo.present) { + maven { + name "Google" + url googleRepo.get() + allowInsecureProtocol true // Local Nexus in CI uses HTTP + } + } else { + google() + } + + def centralRepo = providers.gradleProperty("centralRepo") + if (centralRepo.present) { + maven { + name "MavenCentral" + url centralRepo.get() + allowInsecureProtocol true // Local Nexus in CI uses HTTP + } + } else { + mavenCentral() + } } } diff --git a/taskcluster/rb_taskgraph/job.py b/taskcluster/rb_taskgraph/job.py index 1b29421dc4..a8256dacfe 100644 --- a/taskcluster/rb_taskgraph/job.py +++ b/taskcluster/rb_taskgraph/job.py @@ -95,10 +95,14 @@ def _extract_gradlew_command(run, fetches_dir): maven_dependencies_dir = path.join(fetches_dir, "android-gradle-dependencies") gradle_repos_args = [ - "-P{repo_name}Repo=file://{dir}/{repo_name}".format( - dir=maven_dependencies_dir, repo_name=repo_name + "-P{property_name}=file://{dir}/{repo_name}".format( + dir=maven_dependencies_dir, property_name=property_name, repo_name=repo_name + ) + for property_name, repo_name in ( + ("centralRepo", "central"), + ("googleRepo", "google"), + ("pluginRepo", "gradle-plugins"), ) - for repo_name in ("google", "central") ] gradle_command = ["./gradlew"] + gradle_repos_args + ["listRepositories"] + run.pop("gradlew") post_gradle_commands = run.pop("post-gradlew", []) diff --git a/taskcluster/scripts/toolchain/android-gradle-dependencies.sh b/taskcluster/scripts/toolchain/android-gradle-dependencies.sh index d391fd925a..ef8efcdec4 100755 --- a/taskcluster/scripts/toolchain/android-gradle-dependencies.sh +++ b/taskcluster/scripts/toolchain/android-gradle-dependencies.sh @@ -23,8 +23,9 @@ GRADLE_FLAGS=( --daemon --no-configuration-cache -Pcoverage - -PgoogleRepo='http://localhost:8081/nexus/content/repositories/google/' -PcentralRepo='http://localhost:8081/nexus/content/repositories/central/' + -PgoogleRepo='http://localhost:8081/nexus/content/repositories/google/' + -PpluginRepo='http://localhost:8081/nexus/content/repositories/gradle-plugins/' ) # Enumerates and downloads the dependencies of every resolvable configuration diff --git a/taskcluster/scripts/toolchain/android-gradle-dependencies/after.sh b/taskcluster/scripts/toolchain/android-gradle-dependencies/after.sh index f7c849aca3..00bf4fa293 100644 --- a/taskcluster/scripts/toolchain/android-gradle-dependencies/after.sh +++ b/taskcluster/scripts/toolchain/android-gradle-dependencies/after.sh @@ -6,7 +6,7 @@ # This is copy of # https://searchfox.org/mozilla-central/rev/2cd2d511c0d94a34fb7fa3b746f54170ee759e35/taskcluster/scripts/misc/android-gradle-dependencies/after.sh. -# gradle-plugins was removed because it's not used in this project. +# Later changes to that file have been picked up piecemeal. set -x -e @@ -22,6 +22,10 @@ mkdir -p android-gradle-dependencies /builds/worker/artifacts cp -R ${NEXUS_WORK}/storage/central android-gradle-dependencies cp -R ${NEXUS_WORK}/storage/google android-gradle-dependencies +cp -R ${NEXUS_WORK}/storage/gradle-plugins android-gradle-dependencies || { + echo "FATAL ERROR: no gradle-plugins storage. Did plugin resolution reach the proxy?" + exit 1 +} tar cavf /builds/worker/artifacts/android-gradle-dependencies.tar.zst android-gradle-dependencies From 9b7a63cd00c35dc40a4d161cbdc5b2defea25fb5 Mon Sep 17 00:00:00 2001 From: Ryan VanderMeulen Date: Tue, 28 Jul 2026 17:22:57 -0400 Subject: [PATCH 7/7] Check that the packaged Gradle dependency cache is complete Picked up from bug 1953671 in mozilla-central. This task has intermittently produced artifacts with files missing, which only surfaces much later as a confusing resolution failure in a downstream task. mozilla-central checks a single hardcoded artifact as a canary. We can do better: the enumeration pass leaves behind an inventory of every artifact Gradle downloaded, with a sha256 for each, so check all three packaged repositories against it. Two wrinkles. Downstream tasks are handed all of them, so an artifact only has to appear in one. And the inventory records the file name from the module metadata, which for Kotlin Multiplatform is not the name the repository publishes the file under, so where the recorded name isn't on disk we look for its checksum among the files that are. Whatever the caller names in --unproxied is expected to be absent, which for us is what the unproxied Mozilla repositories serve. Anything else absent is a fault, as is any packaged component with a file missing, which is the shape bug 1953671 describes. Failing on those is the point of this. Verified against fixtures for a complete tree, a Kotlin Multiplatform component laid out the way a repository lays it out, a component split across two repositories, and a plugin marker packaged under gradle-plugins; and that it fails on a missing file, a corrupt file, a component absent from outside the unproxied prefixes, and a missing or empty inventory. --- .../android-gradle-dependencies/after.sh | 13 ++ .../verify_dependencies.py | 170 ++++++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 taskcluster/scripts/toolchain/android-gradle-dependencies/verify_dependencies.py diff --git a/taskcluster/scripts/toolchain/android-gradle-dependencies/after.sh b/taskcluster/scripts/toolchain/android-gradle-dependencies/after.sh index 00bf4fa293..477df7bc0e 100644 --- a/taskcluster/scripts/toolchain/android-gradle-dependencies/after.sh +++ b/taskcluster/scripts/toolchain/android-gradle-dependencies/after.sh @@ -16,6 +16,10 @@ echo "running as" $(id) set -v +# Resolved before the `pushd` below leaves the project directory. +DEPENDENCY_INVENTORY="$PWD/gradle/verification-metadata.dryrun.xml" +VERIFY_DEPENDENCIES="$PWD/taskcluster/scripts/toolchain/android-gradle-dependencies/verify_dependencies.py" + # Package everything up. pushd $WORKSPACE mkdir -p android-gradle-dependencies /builds/worker/artifacts @@ -27,6 +31,15 @@ cp -R ${NEXUS_WORK}/storage/gradle-plugins android-gradle-dependencies || { exit 1 } +# Bug 1953671: catch intermittently incomplete artifacts here rather than +# downstream. +# The Mozilla repositories in build.gradle are not proxied, so what they serve +# is legitimately absent from the packaged tree. +python3 "$VERIFY_DEPENDENCIES" --inventory "$DEPENDENCY_INVENTORY" \ + --unproxied org/mozilla \ + android-gradle-dependencies/central android-gradle-dependencies/google \ + android-gradle-dependencies/gradle-plugins + tar cavf /builds/worker/artifacts/android-gradle-dependencies.tar.zst android-gradle-dependencies popd diff --git a/taskcluster/scripts/toolchain/android-gradle-dependencies/verify_dependencies.py b/taskcluster/scripts/toolchain/android-gradle-dependencies/verify_dependencies.py new file mode 100644 index 0000000000..9a70f6de70 --- /dev/null +++ b/taskcluster/scripts/toolchain/android-gradle-dependencies/verify_dependencies.py @@ -0,0 +1,170 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +"""Check the packaged Gradle dependency cache against the inventory that +`--write-verification-metadata` leaves behind, so that an incomplete artifact +fails here rather than in a downstream task (bug 1953671). + +Some of what the inventory lists comes from repositories that aren't proxied +through Nexus and so is expected to be absent from the packaged tree; --unproxied +says which paths those are. Anything else absent, or any component packaged with +one of its files missing, is a real fault. +""" + +import argparse +import hashlib +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + + +def parse_inventory(path): + """Yield (relative_directory, {filename: sha256}) for each component.""" + root = ET.parse(path).getroot() + for component in root.iter(): + if not component.tag.endswith("component"): + continue + group = component.get("group") + name = component.get("name") + version = component.get("version") + if not (group and name and version): + continue + + artifacts = {} + for artifact in component: + if not artifact.tag.endswith("artifact"): + continue + checksum = next( + ( + child.get("value") + for child in artifact + if child.tag.endswith("sha256") + ), + None, + ) + artifacts[artifact.get("name")] = checksum + + if artifacts: + yield Path(*group.split("."), name, version), artifacts + + +def sha256(path): + digest = hashlib.sha256() + with path.open("rb") as fh: + for block in iter(lambda: fh.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def verify(inventory, repositories, unproxied): + problems = [] + unpackaged = [] + components = checked = artifacts = 0 + + for relative_dir, expected in inventory: + components += 1 + + # Downstream tasks are given every one of these as a repository, so an + # artifact only has to be in one of them. + directories = [ + repository / relative_dir + for repository in repositories + if (repository / relative_dir).is_dir() + ] + if not directories: + if str(relative_dir).startswith(unproxied): + unpackaged.append(str(relative_dir)) + else: + problems.append(f"absent: {relative_dir}") + continue + + checked += 1 + checksums = None + for filename, checksum in sorted(expected.items()): + found = next( + (d / filename for d in directories if (d / filename).is_file()), None + ) + if found: + if checksum and sha256(found) != checksum: + problems.append(f"corrupt: {found}") + else: + artifacts += 1 + continue + + # The inventory records the file name from the module metadata, + # which for Kotlin Multiplatform is not the name the repository + # publishes it under. Fall back to matching the contents. + if checksums is None: + checksums = { + sha256(path) + for directory in directories + for path in directory.iterdir() + if path.is_file() + } + if checksum and checksum in checksums: + artifacts += 1 + else: + problems.append(f"missing: {relative_dir}/{filename}") + + return components, checked, artifacts, unpackaged, problems + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--inventory", + required=True, + type=Path, + help="verification-metadata.dryrun.xml written by the enumeration pass", + ) + parser.add_argument( + "--unproxied", + action="append", + default=[], + metavar="PREFIX", + help="path prefix served by a repository that isn't proxied through " + "Nexus, so is expected to be absent from the packaged tree; repeatable", + ) + parser.add_argument( + "repository", + nargs="+", + type=Path, + help="packaged repository directories to check, e.g. central google", + ) + args = parser.parse_args() + + if not args.inventory.is_file(): + print( + f"FATAL ERROR: no inventory at {args.inventory}. Did the " + "enumeration pass run?" + ) + return 1 + + components, checked, artifacts, unpackaged, problems = verify( + parse_inventory(args.inventory), args.repository, tuple(args.unproxied) + ) + print( + f"verified {artifacts} artifacts across {checked} of {components} " + f"components; {len(unpackaged)} are not packaged, coming from a " + "repository we don't proxy" + ) + for path in unpackaged: + print(f" not packaged: {path}") + + if not components: + print(f"FATAL ERROR: {args.inventory} lists no components at all.") + return 1 + + if problems: + print(f"FATAL ERROR: {len(problems)} absent, missing or corrupt:") + for problem in problems: + print(f" {problem}") + print("The dependency cache is incomplete. Try re-running this task.") + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main())