Skip to content

Commit ce8ac44

Browse files
authored
Run CI only for tasks affected by git changes (#7464)
1 parent 99cfdba commit ce8ac44

File tree

5 files changed

+231
-60
lines changed

5 files changed

+231
-60
lines changed

.circleci/collect_results.sh

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,17 @@ shopt -s globstar
1010
TEST_RESULTS_DIR=./results
1111
mkdir -p $TEST_RESULTS_DIR >/dev/null 2>&1
1212

13-
echo "saving test results"
1413
mkdir -p $TEST_RESULTS_DIR
15-
find workspace/**/build/test-results -name \*.xml -exec sh -c '
14+
15+
mkdir -p workspace
16+
mapfile -t test_result_dirs < <(find workspace -name test-results -type d)
17+
18+
if [[ ${#test_result_dirs[@]} -eq 0 ]]; then
19+
echo "No test results found"
20+
exit 0
21+
fi
22+
23+
echo "saving test results"
24+
find "${test_result_dirs[@]}" -name \*.xml -exec sh -c '
1625
file=$(echo "$0" | rev | cut -d "/" -f 1,2,5 | rev | tr "/" "_")
1726
cp "$0" "$1/$file"' {} $TEST_RESULTS_DIR \;

.circleci/config.continue.yml.j2

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ system_test_matrix: &system_test_matrix
3232

3333
agent_integration_tests_modules: &agent_integration_tests_modules "dd-trace-core|communication|internal-api|utils"
3434
core_modules: &core_modules "dd-java-agent|dd-trace-core|communication|internal-api|telemetry|utils|dd-java-agent/agent-bootstrap|dd-java-agent/agent-installer|dd-java-agent/agent-tooling|dd-java-agent/agent-builder|dd-java-agent/appsec|dd-java-agent/agent-crashtracking|dd-trace-api|dd-trace-ot"
35-
instrumentation_modules: &instrumentation_modules "dd-java-agent/instrumentation|dd-java-agent/agent-tooling|dd-java-agent/agent-installer|dd-java-agent/agent-builder|dd-java-agent/agent-bootstrap|dd-java-agent/appsec|dd-java-agent/testing|dd-trace-core|dd-trace-api|internal-api|communication"
35+
instrumentation_modules: &instrumentation_modules "dd-java-agent/instrumentation|dd-java-agent/agent-tooling|dd-java-agent/agent-iast|dd-java-agent/agent-installer|dd-java-agent/agent-builder|dd-java-agent/agent-bootstrap|dd-java-agent/appsec|dd-java-agent/testing|dd-trace-core|dd-trace-api|internal-api|communication"
3636
debugger_modules: &debugger_modules "dd-java-agent/agent-debugger|dd-java-agent/agent-bootstrap|dd-java-agent/agent-builder|internal-api|communication|dd-trace-core"
3737
profiling_modules: &profiling_modules "dd-java-agent/agent-profiling"
3838

@@ -99,6 +99,11 @@ commands:
9999
setup_code:
100100
steps:
101101
- checkout
102+
{% if use_git_changes %}
103+
- run:
104+
name: Fetch base branch
105+
command: git fetch origin {{ pr_base_ref }}
106+
{% endif %}
102107
- run:
103108
name: Checkout merge commit
104109
command: .circleci/checkout_merge_commit.sh
@@ -312,6 +317,9 @@ jobs:
312317
./gradlew clean
313318
<< parameters.gradleTarget >>
314319
-PskipTests
320+
{% if use_git_changes %}
321+
-PgitBaseRef=origin/{{ pr_base_ref }}
322+
{% endif %}
315323
<< pipeline.parameters.gradle_flags >>
316324
--max-workers=8
317325
--rerun-tasks
@@ -411,6 +419,9 @@ jobs:
411419
./gradlew
412420
<< parameters.gradleTarget >>
413421
-PskipTests
422+
{% if use_git_changes %}
423+
-PgitBaseRef=origin/{{ pr_base_ref }}
424+
{% endif %}
414425
-PrunBuildSrcTests
415426
-PtaskPartitionCount=${CIRCLE_NODE_TOTAL} -PtaskPartition=${CIRCLE_NODE_INDEX}
416427
<< pipeline.parameters.gradle_flags >>
@@ -556,6 +567,9 @@ jobs:
556567
./gradlew
557568
<< parameters.gradleTarget >>
558569
<< parameters.gradleParameters >>
570+
{% if use_git_changes %}
571+
-PgitBaseRef=origin/{{ pr_base_ref }}
572+
{% endif %}
559573
-PtaskPartitionCount=${CIRCLE_NODE_TOTAL} -PtaskPartition=${CIRCLE_NODE_INDEX}
560574
<<# parameters.testJvm >>-PtestJvm=<< parameters.testJvm >><</ parameters.testJvm >>
561575
<< pipeline.parameters.gradle_flags >>

.circleci/render_config.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
)
5353
resp.raise_for_status()
5454
except Exception as e:
55-
print(f"Request filed: {e}")
55+
print(f"Request failed: {e}")
5656
time.sleep(1)
5757
continue
5858
data = resp.json()
@@ -63,12 +63,18 @@
6363
labels = {
6464
l.replace("run-tests: ", "") for l in labels if l.startswith("run-tests: ")
6565
}
66+
# get the base reference (e.g. `master`), commit hash is also available at the `sha` field.
67+
pr_base_ref = data.get("base", {}).get("ref")
6668
else:
6769
labels = set()
70+
pr_base_ref = ""
6871

6972

7073
branch = os.environ.get("CIRCLE_BRANCH", "")
71-
if branch == "master" or branch.startswith("release/v") or "all" in labels:
74+
run_all = "all" in labels
75+
is_master_or_release = branch == "master" or branch.startswith("release/v")
76+
77+
if is_master_or_release or run_all:
7278
all_jdks = ALWAYS_ON_JDKS | MASTER_ONLY_JDKS
7379
else:
7480
all_jdks = ALWAYS_ON_JDKS | (MASTER_ONLY_JDKS & labels)
@@ -83,6 +89,9 @@
8389
is_weekly = os.environ.get("CIRCLE_IS_WEEKLY", "false") == "true"
8490
is_regular = not is_nightly and not is_weekly
8591

92+
# Use git changes detection on PRs
93+
use_git_changes = not run_all and not is_master_or_release and is_regular
94+
8695
vars = {
8796
"is_nightly": is_nightly,
8897
"is_weekly": is_weekly,
@@ -92,12 +101,14 @@
92101
"nocov_jdks": nocov_jdks,
93102
"flaky": branch == "master" or "flaky" in labels or "all" in labels,
94103
"docker_image_prefix": "" if is_nightly else f"{DOCKER_IMAGE_VERSION}-",
104+
"use_git_changes": use_git_changes,
105+
"pr_base_ref": pr_base_ref,
95106
}
96107

97108
print(f"Variables for this build: {vars}")
98109

99110
loader = jinja2.FileSystemLoader(searchpath=SCRIPT_DIR)
100-
env = jinja2.Environment(loader=loader)
111+
env = jinja2.Environment(loader=loader, trim_blocks=True)
101112
tpl = env.get_template(TPL_FILENAME)
102113
out = tpl.render(**vars)
103114

build.gradle

Lines changed: 1 addition & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -151,57 +151,4 @@ allprojects {
151151
}
152152
}
153153

154-
allprojects { project ->
155-
project.ext {
156-
activePartition = true
157-
}
158-
final boolean shouldUseTaskPartitions = project.rootProject.hasProperty("taskPartitionCount") && project.rootProject.hasProperty("taskPartition")
159-
if (shouldUseTaskPartitions) {
160-
final int taskPartitionCount = project.rootProject.property("taskPartitionCount") as int
161-
final int taskPartition = project.rootProject.property("taskPartition") as int
162-
final currentTaskPartition = Math.abs(project.path.hashCode() % taskPartitionCount)
163-
project.setProperty("activePartition", currentTaskPartition == taskPartition)
164-
}
165-
}
166-
167-
168-
def testAggregate(String baseTaskName, includePrefixes, excludePrefixes, boolean forceCoverage = false) {
169-
def createRootTask = { rootTaskName, subProjTaskName ->
170-
def coverage = forceCoverage || rootProject.hasProperty("checkCoverage")
171-
tasks.register(rootTaskName) { aggTest ->
172-
subprojects { subproject ->
173-
if (subproject.property("activePartition") && includePrefixes.any { subproject.path.startsWith(it) } && !excludePrefixes.any { subproject.path.startsWith(it) }) {
174-
def testTask = subproject.tasks.findByName(subProjTaskName)
175-
if (testTask != null) {
176-
aggTest.dependsOn(testTask)
177-
}
178-
if (coverage) {
179-
def coverageTask = subproject.tasks.findByName("jacocoTestReport")
180-
if (coverageTask != null) {
181-
aggTest.dependsOn(coverageTask)
182-
}
183-
coverageTask = subproject.tasks.findByName("jacocoTestCoverageVerification")
184-
if (coverageTask != null) {
185-
aggTest.dependsOn(coverageTask)
186-
}
187-
}
188-
}
189-
}
190-
}
191-
}
192-
193-
createRootTask "${baseTaskName}Test", 'allTests'
194-
createRootTask "${baseTaskName}LatestDepTest", 'allLatestDepTests'
195-
createRootTask "${baseTaskName}Check", 'check'
196-
}
197-
198-
testAggregate("smoke", [":dd-smoke-tests"], [])
199-
testAggregate("instrumentation", [":dd-java-agent:instrumentation"], [])
200-
testAggregate("profiling", [":dd-java-agent:agent-profiling"], [])
201-
testAggregate("debugger", [":dd-java-agent:agent-debugger"], [], true)
202-
testAggregate("base", [":"], [
203-
":dd-java-agent:instrumentation",
204-
":dd-smoke-tests",
205-
":dd-java-agent:agent-profiling",
206-
":dd-java-agent:agent-debugger"
207-
])
154+
apply from: "$rootDir/gradle/ci_jobs.gradle"

gradle/ci_jobs.gradle

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
/**
2+
* This script defines a set of tasks to be used in CI. These aggregate tasks support partitioning (to parallelize
3+
* jobs) with -PtaskPartitionCount and -PtaskPartition, and limiting tasks to those affected by git changes
4+
* with -PgitBaseRef.
5+
*/
6+
import java.nio.file.Paths
7+
8+
allprojects { project ->
9+
project.ext {
10+
activePartition = true
11+
}
12+
final boolean shouldUseTaskPartitions = project.rootProject.hasProperty("taskPartitionCount") && project.rootProject.hasProperty("taskPartition")
13+
if (shouldUseTaskPartitions) {
14+
final int taskPartitionCount = project.rootProject.property("taskPartitionCount") as int
15+
final int taskPartition = project.rootProject.property("taskPartition") as int
16+
final currentTaskPartition = Math.abs(project.path.hashCode() % taskPartitionCount)
17+
project.setProperty("activePartition", currentTaskPartition == taskPartition)
18+
}
19+
}
20+
21+
File relativeToGitRoot(File f) {
22+
return rootProject.projectDir.toPath().relativize(f.absoluteFile.toPath()).toFile()
23+
}
24+
25+
String isAffectedBy(Task baseTask, Map<Project, Set<String>> affectedProjects) {
26+
HashSet<Task> visited = []
27+
LinkedList<Task> queue = [baseTask]
28+
while (!queue.isEmpty()) {
29+
Task t = queue.poll()
30+
if (visited.contains(t)) {
31+
continue
32+
}
33+
visited.add(t)
34+
35+
final Set<String> affectedTasks = affectedProjects.get(t.project)
36+
if (affectedTasks != null) {
37+
if (affectedTasks.contains("all")) {
38+
return "${t.project.path}:${t.name}"
39+
}
40+
if (affectedTasks.contains(t.name)) {
41+
return "${t.project.path}:${t.name}"
42+
}
43+
}
44+
45+
t.taskDependencies.each { queue.addAll(it.getDependencies(t)) }
46+
}
47+
return null
48+
}
49+
50+
List<File> getChangedFiles(String baseRef, String newRef) {
51+
final stdout = new StringBuilder()
52+
final stderr = new StringBuilder()
53+
final proc = "git diff --name-only ${baseRef}..${newRef}".execute()
54+
proc.consumeProcessOutput(stdout, stderr)
55+
proc.waitForOrKill(1000)
56+
assert proc.exitValue() == 0, "git diff command failed, stderr: ${stderr}"
57+
def out = stdout.toString().trim()
58+
if (out.isEmpty()) {
59+
return []
60+
}
61+
logger.debug("git diff output: ${out}")
62+
return out.split("\n").collect {
63+
new File(rootProject.projectDir, it.trim())
64+
}
65+
}
66+
67+
rootProject.ext {
68+
useGitChanges = false
69+
}
70+
71+
if (rootProject.hasProperty("gitBaseRef")) {
72+
final String baseRef = rootProject.property("gitBaseRef")
73+
final String newRef = rootProject.hasProperty("gitNewRef") ? rootProject.property("gitNewRef") : "HEAD"
74+
75+
rootProject.ext {
76+
it.changedFiles = getChangedFiles(baseRef, newRef)
77+
useGitChanges = true
78+
}
79+
80+
final ignoredFiles = fileTree(rootProject.projectDir) {
81+
include '.gitignore', '.editorconfig'
82+
include '*.md', '**/*.md'
83+
include 'gradlew', 'gradlew.bat', 'mvnw', 'mvnw.cmd'
84+
include 'NOTICE'
85+
include 'static-analysis.datadog.yml'
86+
}
87+
rootProject.changedFiles.each { File f ->
88+
if (ignoredFiles.contains(f)) {
89+
logger.warn("Ignoring changed file: ${relativeToGitRoot(f)}")
90+
}
91+
}
92+
rootProject.changedFiles = rootProject.changedFiles.findAll { !ignoredFiles.contains(it) }
93+
94+
final globalEffectFiles = fileTree(rootProject.projectDir) {
95+
include '.circleci/**'
96+
include 'build.gradle'
97+
include 'gradle/**'
98+
}
99+
100+
for (File f in rootProject.changedFiles) {
101+
if (globalEffectFiles.contains(f)) {
102+
logger.warn("Global effect change: ${relativeToGitRoot(f)} (no tasks will be skipped)")
103+
rootProject.useGitChanges = false
104+
break
105+
}
106+
}
107+
108+
if (rootProject.useGitChanges) {
109+
logger.warn("Git change tracking is enabled: ${baseRef}..${newRef}")
110+
111+
final projects = subprojects.sort { a, b -> b.projectDir.path.length() <=> a.projectDir.path.length() }
112+
Map<Project, Set<String>> _affectedProjects = [:]
113+
// Path prefixes mapped to affected task names. A file not matching any of these prefixes will affect all tasks in
114+
// the project ("all" can be used a task name to explicitly state the same). Only the first matching prefix is used.
115+
final List<Map<String, String>> matchers = [
116+
[prefix: 'src/testFixtures/', task: 'testFixturesClasses'],
117+
[prefix: 'src/test/', task: 'testClasses'],
118+
[prefix: 'src/jmh/', task: 'jmhCompileGeneratedClasses']
119+
]
120+
for (File f in rootProject.changedFiles) {
121+
Project p = projects.find { f.toString().startsWith(it.projectDir.path + "/") }
122+
if (p == null) {
123+
logger.warn("Changed file: ${relativeToGitRoot(f)} at root project (no task will be skipped)")
124+
rootProject.useGitChanges = false
125+
break
126+
}
127+
// Make sure path separator is /
128+
final relPath = Paths.get(p.projectDir.path).relativize(f.toPath()).collect { it.toString() }.join('/')
129+
final String task = matchers.find { relPath.startsWith(it.prefix) }?.task ?: "all"
130+
logger.warn("Changed file: ${relativeToGitRoot(f)} in project ${p.path} (${task})")
131+
_affectedProjects.computeIfAbsent(p, { new HashSet<String>() }).add(task)
132+
}
133+
rootProject.ext {
134+
it.affectedProjects = _affectedProjects
135+
}
136+
}
137+
}
138+
139+
def testAggregate(String baseTaskName, includePrefixes, excludePrefixes, boolean forceCoverage = false) {
140+
def createRootTask = { String rootTaskName, String subProjTaskName ->
141+
def coverage = forceCoverage || rootProject.hasProperty("checkCoverage")
142+
tasks.register(rootTaskName) { aggTest ->
143+
subprojects { subproject ->
144+
if (subproject.property("activePartition") && includePrefixes.any { subproject.path.startsWith(it) } && !excludePrefixes.any { subproject.path.startsWith(it) }) {
145+
Task testTask = subproject.tasks.findByName(subProjTaskName)
146+
boolean isAffected = true
147+
if (testTask != null) {
148+
if (rootProject.useGitChanges) {
149+
final fileTrigger = isAffectedBy(testTask, rootProject.property("affectedProjects"))
150+
if (fileTrigger != null) {
151+
logger.warn("Selecting ${subproject.path}:${subProjTaskName} (triggered by ${fileTrigger})")
152+
} else {
153+
logger.warn("Skipping ${subproject.path}:${subProjTaskName} (not affected by changed files)")
154+
isAffected = false
155+
}
156+
}
157+
if (isAffected) {
158+
aggTest.dependsOn(testTask)
159+
}
160+
}
161+
if (isAffected && coverage) {
162+
def coverageTask = subproject.tasks.findByName("jacocoTestReport")
163+
if (coverageTask != null) {
164+
aggTest.dependsOn(coverageTask)
165+
}
166+
coverageTask = subproject.tasks.findByName("jacocoTestCoverageVerification")
167+
if (coverageTask != null) {
168+
aggTest.dependsOn(coverageTask)
169+
}
170+
}
171+
}
172+
}
173+
}
174+
}
175+
176+
createRootTask "${baseTaskName}Test", 'allTests'
177+
createRootTask "${baseTaskName}LatestDepTest", 'allLatestDepTests'
178+
createRootTask "${baseTaskName}Check", 'check'
179+
}
180+
181+
testAggregate("smoke", [":dd-smoke-tests"], [])
182+
testAggregate("instrumentation", [":dd-java-agent:instrumentation"], [])
183+
testAggregate("profiling", [":dd-java-agent:agent-profiling"], [])
184+
testAggregate("debugger", [":dd-java-agent:agent-debugger"], [], true)
185+
testAggregate("base", [":"], [
186+
":dd-java-agent:instrumentation",
187+
":dd-smoke-tests",
188+
":dd-java-agent:agent-profiling",
189+
":dd-java-agent:agent-debugger"
190+
])

0 commit comments

Comments
 (0)