Skip to content

Commit 6f5615c

Browse files
authored
Pin testLatestDeps versions for reproducible builds (open-telemetry#16344)
1 parent e8eff35 commit 6f5615c

24 files changed

Lines changed: 1064 additions & 94 deletions

File tree

.github/config/latest-dep-versions.json

Lines changed: 546 additions & 0 deletions
Large diffs are not rendered by default.

.github/workflows/build-pull-request.yml

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -55,29 +55,17 @@ jobs:
5555
uses: ./.github/workflows/reusable-markdown-lint-check.yml
5656

5757
required-status-check:
58-
# test-latest-deps is not included in the required status checks
59-
# because any time a new library version is released to maven central
60-
# it can fail due to test code incompatibility with the new library version,
61-
# or due to slight changes in emitted telemetry
62-
# (muzzle can also fail when a new library version is released to maven central
63-
# but that happens much less often)
64-
#
65-
# only the "common" checks are required for release branch PRs in order to avoid any unnecessary
66-
# release branch maintenance (especially for patches)
6758
needs:
6859
- common
60+
- test-latest-deps
6961
- muzzle
7062
- markdown-lint-check
7163
runs-on: ubuntu-latest
7264
if: always()
7365
steps:
7466
- if: |
7567
needs.common.result != 'success' ||
76-
(
77-
!startsWith(github.base_ref, 'release/') &&
78-
(
79-
needs.muzzle.result != 'success' ||
80-
needs.markdown-lint-check.result != 'success'
81-
)
82-
)
68+
needs.test-latest-deps.result != 'success' ||
69+
needs.muzzle.result != 'success' ||
70+
needs.markdown-lint-check.result != 'success'
8371
run: exit 1 # fail
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
name: Update latest dep versions
2+
3+
on:
4+
schedule:
5+
# daily at 4:12 UTC
6+
- cron: "12 4 * * *"
7+
workflow_dispatch:
8+
9+
permissions:
10+
contents: read
11+
12+
jobs:
13+
update-latest-dep-versions:
14+
permissions:
15+
contents: write # for git push to PR branch
16+
runs-on: ubuntu-latest
17+
steps:
18+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
19+
20+
- name: Free disk space
21+
run: .github/scripts/gha-free-disk-space.sh
22+
23+
- name: Set up JDK for running Gradle
24+
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
25+
with:
26+
distribution: temurin
27+
java-version-file: .java-version
28+
29+
- name: Setup Gradle
30+
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
31+
32+
- name: Resolve latest dep versions
33+
run: >
34+
./gradlew resolveLatestDepVersions
35+
-PtestLatestDeps=true
36+
-PresolveLatestDeps=true
37+
38+
- name: Check for changes
39+
id: check-changes
40+
run: |
41+
if git diff --quiet .github/config/latest-dep-versions.json; then
42+
echo "changed=false" >> $GITHUB_OUTPUT
43+
else
44+
echo "changed=true" >> $GITHUB_OUTPUT
45+
fi
46+
47+
- name: Use CLA approved bot
48+
if: steps.check-changes.outputs.changed == 'true'
49+
run: .github/scripts/use-cla-approved-bot.sh
50+
51+
- uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
52+
if: steps.check-changes.outputs.changed == 'true'
53+
id: otelbot-token
54+
with:
55+
app-id: ${{ vars.OTELBOT_APP_ID }}
56+
private-key: ${{ secrets.OTELBOT_PRIVATE_KEY }}
57+
58+
- name: Create pull request
59+
if: steps.check-changes.outputs.changed == 'true'
60+
env:
61+
# not using secrets.GITHUB_TOKEN since pull requests from that token do not run workflows
62+
GH_TOKEN: ${{ steps.otelbot-token.outputs.token }}
63+
run: |
64+
message="Update pinned latest dep versions"
65+
body="Auto-generated update of pinned latest dependency versions used by \`testLatestDeps\` builds."
66+
branch="otelbot/update-latest-dep-versions"
67+
68+
git checkout -b $branch
69+
git add .github/config/latest-dep-versions.json
70+
git commit -m "$message"
71+
72+
# If the remote branch already exists, only force-push when its tip
73+
# was authored by otelbot. This preserves any manual commits that
74+
# may have pushed on top of an open auto-PR.
75+
if git ls-remote --exit-code --heads origin "$branch" >/dev/null; then
76+
git fetch origin "$branch"
77+
author_email=$(git log -1 --format='%ae' "origin/$branch")
78+
if [ "$author_email" != "197425009+otelbot@users.noreply.github.com" ]; then
79+
echo "Remote tip of $branch was authored by $author_email (not otelbot); skipping push." >&2
80+
exit 0
81+
fi
82+
fi
83+
git push --force-with-lease --set-upstream origin $branch
84+
85+
# only create a new PR if one doesn't already exist
86+
existing_pr=$(gh pr list --head "$branch" --state open --json number --jq '.[0].number')
87+
if [ -z "$existing_pr" ]; then
88+
gh pr create --title "$message" \
89+
--body "$body" \
90+
--base main
91+
fi

build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ plugins {
99

1010
id("io.github.gradle-nexus.publish-plugin")
1111
id("otel.spotless-conventions")
12+
id("otel.resolve-latest-dep-versions")
1213
/* workaround for
1314
What went wrong:
1415
Could not determine the dependencies of task ':smoke-tests-otel-starter:spring-boot-3.2:bootJar'.

conventions/src/main/kotlin/io.opentelemetry.instrumentation.base.gradle.kts

Lines changed: 109 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/** Common setup for manual instrumentation of libraries and javaagent instrumentation. */
22

33
import io.opentelemetry.instrumentation.gradle.OtelPropsExtension
4+
import io.opentelemetry.javaagent.muzzle.AcceptableVersions
45

56
plugins {
67
`java-library`
@@ -26,36 +27,63 @@ val otelProps = the<OtelPropsExtension>()
2627
* described above.
2728
*
2829
* - latestDepTestLibrary: A dependency on a library for testing when testing of latest dependency
29-
* version is enabled. This dependency will be added as-is to testImplementation, but only if
30-
* -PtestLatestDeps=true. The version will not be modified but it will be given highest
31-
* precedence. Use this to restrict the latest version dependency from the default `+`, for
32-
* example to restrict to just a major version by specifying `2.+`.
30+
* version is enabled. This dependency will be added to testImplementation only if
31+
* -PtestLatestDeps=true. Its version overrides the `latest.release` from library/testLibrary
32+
* via resolutionStrategy.eachDependency (which takes highest precedence in Gradle). Use this
33+
* to restrict the latest version to a specific range, e.g. `2.+` to stay on major version 2.
3334
*/
35+
36+
val resolveLatestDeps = gradle.startParameter.projectProperties["resolveLatestDeps"] == "true"
37+
val pinLatestDeps = otelProps.testLatestDeps && !resolveLatestDeps
38+
39+
fun getPinnedVersions(): Map<String, String> {
40+
if (!pinLatestDeps) return emptyMap()
41+
val key = "latestDepPinnedVersions"
42+
if (!rootProject.extra.has(key)) {
43+
val file = rootProject.file(".github/config/latest-dep-versions.json")
44+
if (!file.exists()) {
45+
throw GradleException("Pinned latest-dep versions file is missing: ${file}.")
46+
}
47+
@Suppress("UNCHECKED_CAST")
48+
rootProject.extra[key] = groovy.json.JsonSlurper().parse(file) as Map<String, String>
49+
}
50+
@Suppress("UNCHECKED_CAST")
51+
return rootProject.extra[key] as Map<String, String>
52+
}
53+
54+
fun lookupPinnedVersion(group: String?, name: String, version: String?): String? {
55+
if (!pinLatestDeps || group == null) return null
56+
val pinned = getPinnedVersions()
57+
return if (version == "latest.release") {
58+
pinned["$group:$name#+"]
59+
} else if (version != null && version.contains("+")) {
60+
val rangeKey = "$group:$name#$version"
61+
val rangeVersion = pinned[rangeKey]
62+
if (rangeVersion != null) {
63+
rangeVersion
64+
} else {
65+
// Range-specific key is missing from the pinned versions JSON.
66+
// Do NOT fall back to the base key because it could be a different major version
67+
// (e.g. base key resolves to 4.x but the range "2.+" expects 2.x).
68+
// Run resolveLatestDepVersions to populate the missing key.
69+
throw GradleException(
70+
"Pinned version missing for range key \"$rangeKey\". " +
71+
"Run ./gradlew resolveLatestDepVersions -PtestLatestDeps=true -PresolveLatestDeps=true " +
72+
"to regenerate .github/config/latest-dep-versions.json"
73+
)
74+
}
75+
} else {
76+
null
77+
}
78+
}
79+
3480
@CacheableRule
3581
abstract class TestLatestDepsRule : ComponentMetadataRule {
3682
override fun execute(context: ComponentMetadataContext) {
37-
val version = context.details.id.version
38-
if (version.contains("-alpha", true)
39-
|| version.contains("-beta", true)
40-
|| version.contains("-rc", true)
41-
|| version.contains(".rc", true)
42-
|| version.contains("-m", true) // e.g. spring milestones are published to grails repo
43-
|| version.contains(".m", true) // e.g. lettuce
44-
|| version.contains(".alpha", true) // e.g. netty
45-
|| version.contains(".beta", true) // e.g. hibernate
46-
|| version.contains(".cr", true) // e.g. hibernate
47-
|| version.endsWith("-nf-execution") // graphql
48-
|| GIT_SHA_PATTERN.matches(version) // graphql
49-
|| DATETIME_PATTERN.matches(version) // graphql
50-
) {
83+
if (!AcceptableVersions.isStable(context.details.id.version)) {
5184
context.details.status = "milestone"
5285
}
5386
}
54-
55-
companion object {
56-
private val GIT_SHA_PATTERN = Regex("^.*-[0-9a-f]{7,}$")
57-
private val DATETIME_PATTERN = Regex("^\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}.*$")
58-
}
5987
}
6088

6189
configurations {
@@ -74,14 +102,20 @@ configurations {
74102

75103
val testImplementation by getting
76104

105+
// Collect latestDepTestLibrary overrides so we can apply them via resolutionStrategy.
106+
// This map is populated during configuration and read during resolution.
107+
val latestDepTestLibraryOverrides = mutableMapOf<String, String>()
108+
77109
listOf(library, testLibrary).forEach { configuration ->
78110
// We use whenObjectAdded and copy into the real configurations instead of extension to allow
79111
// mutating the version for latest dep tests.
80112
configuration.dependencies.whenObjectAdded {
81113
val dep = copy()
82114
if (otelProps.testLatestDeps) {
115+
val extDep = this as ExternalDependency
116+
val pinnedVersion = lookupPinnedVersion(extDep.group, extDep.name, "latest.release")
83117
(dep as ExternalDependency).version {
84-
require("latest.release")
118+
require(pinnedVersion ?: "latest.release")
85119
}
86120
}
87121
testImplementation.dependencies.add(dep)
@@ -94,12 +128,31 @@ configurations {
94128
}
95129
}
96130

131+
// latestDepTestLibrary lets modules restrict the latest version to a specific range
132+
// (e.g. "3.+" to stay on major version 3). These constraints must beat the
133+
// require("latest.release") from library/testLibrary above.
134+
//
135+
// We can't use strictly() on the dependency itself because it conflicts with require()
136+
// from library/testLibrary (Gradle rejects conflicting strict/require constraints).
137+
// We can't use prefer() on library/testLibrary either because prefer() is too weak and
138+
// loses against the original library() version from compileOnly.
139+
//
140+
// Instead, we collect latestDepTestLibrary versions here and apply them via
141+
// resolutionStrategy.eachDependency below, which overrides all other constraints.
97142
latestDepTestLibrary.dependencies.whenObjectAdded {
98143
val dep = copy()
99144
val declaredVersion = dep.version
100145
if (declaredVersion != null) {
146+
val extDep = this as ExternalDependency
147+
val pinnedVersion = lookupPinnedVersion(extDep.group, extDep.name, declaredVersion)
148+
val resolvedVersion = pinnedVersion ?: declaredVersion
149+
// Record the override; the actual version forcing happens in eachDependency below.
150+
// We use require() here (not strictly()) because strictly() would conflict with
151+
// the require() from library/testLibrary for the same artifact. The eachDependency
152+
// callback enforces the final version regardless.
153+
latestDepTestLibraryOverrides["${extDep.group}:${extDep.name}"] = resolvedVersion
101154
(dep as ExternalDependency).version {
102-
strictly(declaredVersion)
155+
require(resolvedVersion)
103156
}
104157
}
105158
testImplementation.dependencies.add(dep)
@@ -108,6 +161,37 @@ configurations {
108161
named("compileOnly") {
109162
extendsFrom(library)
110163
}
164+
165+
// Apply version overrides via resolutionStrategy which takes highest precedence.
166+
// This handles two cases:
167+
// 1. latestDepTestLibraryOverrides: modules that restrict latest deps to a version range
168+
// (e.g. spring-boot-starter-test:3.+ while latest is 4.x). These must override
169+
// the require("latest.release") from library/testLibrary.
170+
// 2. pinLatestDeps pinned versions: when pinning is enabled, any dependency still using
171+
// "latest.release" or "+" versions (e.g. transitive deps) gets pinned to a concrete
172+
// version from the JSON file.
173+
if (otelProps.testLatestDeps) {
174+
// Only apply to test-related configurations, not build tool configurations like Zinc
175+
// (the Scala compiler). Overriding scala-library in Zinc's configuration breaks compilation.
176+
configureEach {
177+
if (isCanBeResolved && (name.startsWith("test") || name.startsWith("latestDepTest"))) {
178+
resolutionStrategy.eachDependency {
179+
// latestDepTestLibrary overrides take priority over pinned versions
180+
val override = latestDepTestLibraryOverrides["${requested.group}:${requested.name}"]
181+
if (override != null) {
182+
useVersion(override)
183+
return@eachDependency
184+
}
185+
if (pinLatestDeps) {
186+
val pinnedVersion = lookupPinnedVersion(requested.group, requested.name, requested.version)
187+
if (pinnedVersion != null) {
188+
useVersion(pinnedVersion)
189+
}
190+
}
191+
}
192+
}
193+
}
194+
}
111195
}
112196

113197
if (otelProps.testLatestDeps) {

0 commit comments

Comments
 (0)