Skip to content

Commit

Permalink
Add binary compatibility checks (#282)
Browse files Browse the repository at this point in the history
* Initial support for binary compatibility checks

This commit introduces a plugin to automate binary compatibility checks.
It is based on the japicmp Gradle plugin and currently relies on the
default rules, except for one which turns errors into warnings when
violations are found on internal types.

* Add ability to accept regressions

* Add extension to configure binary compatibility checks

This extension lets the user declare where the acceptance JSON file
lives (defaults to the root project's dir), if binary compatibility
checks are enabled at all, and finally can force to a particular
baseline version.

* Make all Micronaut modules use binary compatibility checks

* Automatically accept changes on `@Internal` types

* Bump version
  • Loading branch information
melix authored Mar 8, 2022
1 parent 913fce0 commit 83e2c62
Show file tree
Hide file tree
Showing 15 changed files with 681 additions and 2 deletions.
6 changes: 5 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ dependencies {
implementation 'org.nosphere.gradle.github:gradle-github-actions-plugin:1.3.2'
implementation 'com.gradle:common-custom-user-data-gradle-plugin:1.6.3'
implementation 'org.gradle:test-retry-gradle-plugin:1.3.1'
implementation 'me.champeau.gradle:japicmp-gradle-plugin:0.4.0'

implementation 'org.tomlj:tomlj:1.0.0'

Expand Down Expand Up @@ -177,7 +178,10 @@ gradlePlugin {
id = 'io.micronaut.build.internal.quality-checks'
implementationClass = 'io.micronaut.build.MicronautQualityChecksParticipantPlugin'
}

binaryCompatibilityCheck {
id = 'io.micronaut.build.internal.binary-compatibility-check'
implementationClass = 'io.micronaut.build.compat.MicronautBinaryCompatibilityPlugin'
}
}
}

Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
projectVersion=5.2.3-SNAPSHOT
projectVersion=5.3.0-SNAPSHOT
title=Micronaut Build Plugins
projectDesc=Micronaut internal Gradle plugins. Not intended to be used in user's projects
projectUrl=https://micronaut.io
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package io.micronaut.build

import groovy.transform.CompileStatic
import io.micronaut.build.compat.MicronautBinaryCompatibilityPlugin
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.internal.GradleInternal
Expand All @@ -23,6 +24,7 @@ class MicronautBaseModulePlugin implements Plugin<Project> {
project.pluginManager.apply(MicronautBuildCommonPlugin)
project.pluginManager.apply(MicronautDependencyUpdatesPlugin)
project.pluginManager.apply(MicronautPublishingPlugin)
project.pluginManager.apply(MicronautBinaryCompatibilityPlugin)
configureJUnit(project)
assertSettingsPluginApplied(project)
}
Expand Down
24 changes: 24 additions & 0 deletions src/main/groovy/io/micronaut/build/compat/AcceptanceHelper.groovy
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package io.micronaut.build.compat

class AcceptanceHelper {
static String formatAcceptance(String type, String member ) {
String json = """{
"type": "$type",
"member": "$member",
"reason": "Provide a human readable reason for the change"
}"""
def changeId = (type + member).replaceAll('[^a-zA-Z0-9]', '_')
""".
<br>
<p>
If you did this intentionally, please accept the change and provide an explanation:
<a class="btn btn-info" role="button" data-toggle="collapse" href="#accept-${changeId}" aria-expanded="false" aria-controls="collapseExample">Accept this change</a>
<div class="collapse" id="accept-${changeId}">
<div class="well">
In order to accept this change add the following to <code>accepted-api-changes.json</code>:
<pre>$json</pre>
</div>
</div>
</p>""".stripIndent()
}
}
46 changes: 46 additions & 0 deletions src/main/groovy/io/micronaut/build/compat/AcceptedApiChange.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/*
* Copyright 2003-2021 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.micronaut.build.compat;

import java.io.Serializable;

public class AcceptedApiChange implements Serializable {
private final String type;
private final String member;
private final String reason;

public AcceptedApiChange(String type, String member, String reason) {
this.type = type;
this.member = member;
this.reason = reason;
}

public boolean matches(String type, String member) {
return this.type.equals(type) && this.member.equals(member);
}

public String getType() {
return type;
}

public String getMember() {
return member;
}

public String getReason() {
return reason;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package io.micronaut.build.compat

import groovy.json.JsonSlurper
import groovy.transform.CompileStatic

@CompileStatic
class AcceptedApiChangesParser {
static List<AcceptedApiChange> parse(InputStream jsonStream) {
def parser = new JsonSlurper()
List<Map<String, String>> json = parser.parse(jsonStream) as List<Map<String, String>>
return json.collect {map ->
new AcceptedApiChange(
map["type"],
map["member"],
map["reason"]
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/*
* Copyright 2003-2021 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.micronaut.build.compat;

import me.champeau.gradle.japicmp.report.Violation;
import me.champeau.gradle.japicmp.report.ViolationTransformer;
import org.gradle.api.GradleException;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;

import static io.micronaut.build.compat.AcceptanceHelper.formatAcceptance;

public class AcceptedApiChangesRule implements ViolationTransformer {
public static final String CHANGES_FILE = "changesFile";

private final Map<String, List<AcceptedApiChange>> changes;

public AcceptedApiChangesRule(Map<String, String> params) {
String filePath = params.get(CHANGES_FILE);
File changesFile = new File(filePath);
if (changesFile.exists()) {
try (FileInputStream fis = new FileInputStream(filePath)) {
this.changes = AcceptedApiChangesParser.parse(fis)
.stream()
.collect(Collectors.groupingBy(AcceptedApiChange::getType));
} catch (IOException e) {
throw new GradleException("Unable to parse accepted regressions file", e);
}
} else {
this.changes = Collections.emptyMap();
}
}

@Override
public Optional<Violation> transform(String type, Violation violation) {
List<AcceptedApiChange> apiChanges = changes.get(type);
String violationDescription = Violation.describe(violation.getMember());
if (apiChanges != null) {
Optional<AcceptedApiChange> any = apiChanges.stream()
.filter(c -> c.matches(type, violationDescription))
.findAny();
if (any.isPresent()) {
return Optional.of(violation.acceptWithDescription(any.get().getReason()));
}
}
switch (violation.getSeverity()) {
case info:
case accepted:
case warning:
return Optional.of(violation);
default:
return Optional.of(violation.withDescription(
violation.getHumanExplanation() + formatAcceptance(type, violationDescription))
);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*
* Copyright 2003-2021 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.micronaut.build.compat;

import org.gradle.api.file.RegularFileProperty;
import org.gradle.api.provider.Property;

public interface BinaryCompatibibilityExtension {
RegularFileProperty getAcceptedRegressionsFile();
Property<Boolean> getEnabled();
Property<String> getBaselineVersion();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/*
* Copyright 2003-2021 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.micronaut.build.compat;

import japicmp.model.JApiClass;
import japicmp.model.JApiCompatibility;
import japicmp.model.JApiHasAnnotations;
import me.champeau.gradle.japicmp.report.AbstractContextAwareViolationRule;
import me.champeau.gradle.japicmp.report.Violation;

import java.util.HashSet;

public class InternalAnnotationCollectorRule extends AbstractContextAwareViolationRule {
public static final String INTERNAL_TYPES = "micronaut.internal.types";

@Override
public Violation maybeViolation(JApiCompatibility member) {
if (member instanceof JApiClass) {
JApiClass jApiClass = (JApiClass) member;
maybeRecord((JApiHasAnnotations) member, jApiClass.getFullyQualifiedName());
}
return null;
}

private void maybeRecord(JApiHasAnnotations member, String name) {
if (isAnnotatedWithInternal(member)) {
HashSet<String> types = getContext().getUserData(INTERNAL_TYPES);
if (types == null) {
types = new HashSet<>();
getContext().putUserData(INTERNAL_TYPES, types);
}
types.add(name);
}
}

static boolean isAnnotatedWithInternal(JApiHasAnnotations member) {
return member.getAnnotations()
.stream()
.anyMatch(ann -> ann.getFullyQualifiedName().equals("io.micronaut.core.annotation.Internal"));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/*
* Copyright 2003-2021 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.micronaut.build.compat;

import me.champeau.gradle.japicmp.report.PostProcessViolationsRule;
import me.champeau.gradle.japicmp.report.Severity;
import me.champeau.gradle.japicmp.report.Violation;
import me.champeau.gradle.japicmp.report.ViolationCheckContextWithViolations;

import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;

public class InternalAnnotationPostProcessRule implements PostProcessViolationsRule {
@Override
public void execute(ViolationCheckContextWithViolations context) {
Set<String> internalTypes = context.getUserData(InternalAnnotationCollectorRule.INTERNAL_TYPES);
if (internalTypes != null) {
Set<String> toBeSuppressed = context.getViolations().keySet().stream().filter(internalTypes::contains).collect(Collectors.toSet());
Set<Map.Entry<String, List<Violation>>> entries = context.getViolations().entrySet();
for (Map.Entry<String, List<Violation>> entry : entries) {
if (toBeSuppressed.contains(entry.getKey())) {
List<Violation> replacement = entry.getValue().stream().map(v -> {
if (v.getSeverity() == Severity.error) {
return v.withSeverity(Severity.warning);
}
return v;
}).collect(Collectors.toList());
entry.getValue().clear();
entry.getValue().addAll(replacement);
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/*
* Copyright 2003-2021 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.micronaut.build.compat;

import japicmp.model.JApiHasAnnotations;
import me.champeau.gradle.japicmp.report.Severity;
import me.champeau.gradle.japicmp.report.Violation;
import me.champeau.gradle.japicmp.report.ViolationTransformer;

import java.util.Optional;

import static io.micronaut.build.compat.InternalAnnotationCollectorRule.isAnnotatedWithInternal;

/**
* This rule turns errors on internal types into warnings.
*/
public class InternalMicronautTypeRule implements ViolationTransformer {
private static boolean isInternalType(String className) {
return className.startsWith("io.micronaut") && className.contains(".internal.");
}

/**
* Transforms the current violation.
*
* @param type the type on which the violation was found
* @param violation the violation
* @return a transformed violation. If the violation should be suppressed, return Optional.empty()
*/
@Override
public Optional<Violation> transform(String type, Violation violation) {
if (isInternalType(type) && violation.getSeverity() == Severity.error) {
return Optional.of(violation.withSeverity(Severity.warning));
}
if (violation.getMember() instanceof JApiHasAnnotations) {
JApiHasAnnotations member = (JApiHasAnnotations) violation.getMember();
if (isAnnotatedWithInternal(member)) {
return Optional.of(violation.withSeverity(Severity.warning));
}
}
return Optional.of(violation);
}

}
Loading

0 comments on commit 83e2c62

Please sign in to comment.