Add lifecycle smoke test ci plugin

This commit adds a plugin that is heavily based on the one used by the
AOT smoke tests. It uses the description of each lifecycle smoke tests
to generate a GitHub Actions workflow with the necessary jobs.

There is also an additional task that generates or update a status
document that list the smoke tests per generation with a link to the
code and workflow.

See gh-61
This commit is contained in:
Stéphane Nicoll
2024-07-16 15:25:19 +02:00
parent 6686a6fe3b
commit 9138f2fc2e
13 changed files with 664 additions and 0 deletions

21
.github/send-notification/action.yml vendored Normal file
View File

@@ -0,0 +1,21 @@
name: Send notification
description: Sends a Google Chat message as a notification of the job's outcome
inputs:
branch:
description: 'The branch on which the failure occurred'
required: true
failure-url:
description: 'The URL to use to link to the failure'
required: true
task:
description: 'The task that has failed'
required: true
webhook-url:
description: 'Google Chat Webhook URL'
required: true
runs:
using: composite
steps:
- shell: bash
run: |
curl -s -o /dev/null -X POST '${{ inputs.webhook-url }}' -H 'Content-Type: application/json' -d '{ text: "<users/all> ${{ inputs.task }} <${{ inputs.failure-url }}|failed> on ${{ inputs.branch }} "}' || true

View File

@@ -0,0 +1,25 @@
plugins {
id "checkstyle"
id "io.spring.javaformat" version "0.0.41"
id "java-gradle-plugin"
}
repositories {
mavenCentral()
}
gradlePlugin {
plugins {
lifecycleSmokeTestCiPlugin {
id = "org.springframework.lifecycle.smoke-test-ci"
implementationClass = "org.springframework.lifecycle.gradle.LifecycleSmokeTestCiPlugin"
}
}
}
dependencies {
checkstyle("io.spring.javaformat:spring-javaformat-checkstyle:0.0.41")
}
sourceCompatibility = "17"
targetCompatibility = "17"

View File

@@ -0,0 +1,7 @@
<?xml version="1.0"?>
<!DOCTYPE module PUBLIC
"-//Checkstyle//DTD Checkstyle Configuration 1.3//EN"
"https://checkstyle.org/dtds/configuration_1_3.dtd">
<module name="com.puppycrawl.tools.checkstyle.Checker">
<module name="io.spring.javaformat.checkstyle.SpringChecks" />
</module>

View File

@@ -0,0 +1 @@
rootProject.name="lifecycle-smoke-test-ci-plugin"

View File

@@ -0,0 +1,143 @@
/*
* Copyright 2022-2024 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 org.springframework.lifecycle.gradle;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.io.PrintWriter;
import org.gradle.api.DefaultTask;
import org.gradle.api.GradleException;
import org.gradle.api.file.DirectoryProperty;
import org.gradle.api.provider.ListProperty;
import org.gradle.api.provider.Property;
import org.gradle.api.tasks.Input;
import org.gradle.api.tasks.OutputDirectory;
import org.gradle.api.tasks.TaskAction;
/**
* Task to generate the GitHub Actions workflows for the smoke tests.
*
* @author Andy Wilkinson
*/
public abstract class GenerateGitHubActionsWorkflows extends DefaultTask {
private static final String GITHUB_REPOSITORY = "spring-projects/spring-lifecycle-smoke-tests";
@OutputDirectory
public abstract DirectoryProperty getOutputDirectory();
@Input
public abstract ListProperty<SmokeTest> getSmokeTests();
@Input
public abstract Property<String> getGitBranch();
@Input
public abstract Property<String> getCronSchedule();
@Input
public abstract Property<String> getSpringBootGeneration();
public GenerateGitHubActionsWorkflows() {
getGitBranch().convention(getSpringBootGeneration());
getOutputDirectory().convention(getSpringBootGeneration()
.flatMap((generation) -> getProject().getLayout().getBuildDirectory().dir("workflows/" + generation)));
}
@TaskAction
void generateWorkflows() {
getProject().delete(getOutputDirectory());
getSmokeTests().get().forEach(this::generateWorkflow);
}
void generateWorkflow(SmokeTest smokeTest) {
File workflowFile = getOutputDirectory()
.file(getSpringBootGeneration().get() + "-" + smokeTest.group() + "-" + smokeTest.name() + ".yml")
.get()
.getAsFile();
workflowFile.getParentFile().mkdirs();
String workflowName = getSpringBootGeneration().get() + " | " + name(smokeTest.group()) + " Smoke Tests | "
+ name(smokeTest.name());
try (PrintWriter writer = new PrintWriter(new FileWriter(workflowFile))) {
writer.println("name: " + workflowName);
writer.println("on:");
writer.println(" schedule:");
writer.println(" - cron : '" + getCronSchedule().get() + "'");
writer.println(" workflow_dispatch:");
writer.println("jobs:");
if (smokeTest.tests()) {
writeJob(writer, smokeTest, "test");
}
if (smokeTest.appTests()) {
writeJob(writer, smokeTest, "appTest");
writeJob(writer, smokeTest, "checkpointRestoreAppTest");
}
}
catch (IOException ex) {
throw new GradleException("Failed to write workflow file '" + workflowFile + "'", ex);
}
}
private void writeJob(PrintWriter writer, SmokeTest smokeTest, String taskName) {
writer.println(" " + jobId(smokeTest.name(), taskName) + ":");
writer.println(" name: " + name(smokeTest.name() + " " + taskName));
writer.println(" uses: ./.github/workflows/smoke-test.yml");
writer.println(" secrets: inherit");
writer.println(" with:");
writer.println(" checkout_repository: " + GITHUB_REPOSITORY);
writer.println(" checkout_ref: " + getGitBranch().get());
writer.println(" project: " + smokeTest.group() + ":" + smokeTest.name());
writer.println(" task: " + taskName);
}
private String jobId(String smokeTestName, String taskName) {
StringBuilder output = new StringBuilder();
output.append(smokeTestName.replace("-", "_"));
output.append("_");
for (char c : taskName.toCharArray()) {
if (Character.isUpperCase(c)) {
output.append("_");
output.append(Character.toLowerCase(c));
}
else {
output.append(c);
}
}
return output.toString();
}
private String name(String input) {
StringBuilder output = new StringBuilder(input.length());
char previous = ' ';
for (char c : input.replace("-", " ").toCharArray()) {
if (previous == ' ') {
output.append(Character.toUpperCase(c));
}
else {
if (Character.isUpperCase(c)) {
output.append(' ');
}
output.append(c);
}
previous = c;
}
return output.toString();
}
}

View File

@@ -0,0 +1,110 @@
/*
* Copyright 2022-2024 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 org.springframework.lifecycle.gradle;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.stream.Stream;
import org.gradle.api.GradleException;
import org.gradle.api.NamedDomainObjectContainer;
import org.gradle.api.Plugin;
import org.gradle.api.Project;
import org.gradle.api.tasks.Exec;
import org.gradle.api.tasks.Sync;
import org.gradle.api.tasks.TaskProvider;
/**
* {@link Plugin} for lifecycle smoke test CI.
*
* @author Andy Wilkinson
*/
public class LifecycleSmokeTestCiPlugin implements Plugin<Project> {
@Override
public void apply(Project project) {
NamedDomainObjectContainer<SmokeTests> smokeTests = project.getObjects()
.domainObjectContainer(SmokeTests.class);
project.getExtensions().add("smokeTests", smokeTests);
TaskProvider<Sync> syncWorkflows = project.getTasks().register("syncGitHubActionsWorkflows", Sync.class);
syncWorkflows.configure((sync) -> {
sync.into(".github/workflows");
syncFromClasspath("smoke-test.yml", sync);
syncFromClasspath("validate-gradle-wrapper.yml", sync);
});
smokeTests.configureEach((tests) -> {
TaskProvider<Exec> describeSmokeTestsForBranch = project.getTasks()
.register("describeSmokeTestsFor" + tests.getName(), Exec.class);
describeSmokeTestsForBranch.configure((task) -> {
task.setWorkingDir(new File(tests.getLocation()));
task.commandLine("./gradlew", "describeSmokeTests", "--no-scan");
});
TaskProvider<GenerateGitHubActionsWorkflows> generateWorkflowsForBranch = project.getTasks()
.register("generateGitHubActionsWorkflowsFor" + tests.getName(), GenerateGitHubActionsWorkflows.class);
generateWorkflowsForBranch.configure((task) -> {
task.dependsOn(describeSmokeTestsForBranch);
task.getSpringBootGeneration().set(tests.getName());
if (tests.getBranch() != null) {
task.getGitBranch().set(tests.getBranch());
}
task.getSmokeTests().set(project.provider(() -> loadSmokeTests(tests.getLocation())));
task.getCronSchedule().set(tests.getCronSchedule());
});
syncWorkflows.configure((sync) -> sync.from(generateWorkflowsForBranch));
});
TaskProvider<UpdateStatusPage> updateStatusPage = project.getTasks()
.register("updateStatusPage", UpdateStatusPage.class);
updateStatusPage.configure((task) -> {
task.dependsOn(syncWorkflows);
Map<String, List<SmokeTest>> allSmokeTests = new LinkedHashMap<>();
smokeTests.forEach((tests) -> {
List<SmokeTest> testsForGeneration = loadSmokeTests(tests.getLocation());
allSmokeTests.put(tests.getName(), testsForGeneration);
});
task.getSmokeTests().set(allSmokeTests);
task.getOutputFile().set(project.getLayout().getProjectDirectory().file("STATUS.adoc"));
});
project.getTasks().register("updateInfrastructure", (task) -> task.dependsOn(syncWorkflows, updateStatusPage));
}
private List<SmokeTest> loadSmokeTests(String location) {
File[] smokeTests = new File(location + "/build/smoke-tests").listFiles();
return Stream.of(smokeTests).map(this::load).map(SmokeTest::new).toList();
}
private Properties load(File file) {
Properties properties = new Properties();
try (FileInputStream input = new FileInputStream(file)) {
properties.load(input);
return properties;
}
catch (IOException ex) {
throw new GradleException("Failed to load smoke test properties from '" + file + "'", ex);
}
}
private void syncFromClasspath(String name, Sync sync) {
sync.from(sync.getProject().getResources().getText().fromUri(getClass().getClassLoader().getResource(name)),
(spec) -> spec.rename((temp) -> name));
}
}

View File

@@ -0,0 +1,40 @@
/*
* Copyright 2022-2024 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 org.springframework.lifecycle.gradle;
import java.io.Serializable;
import java.util.Properties;
/**
* A smoke test.
*
* @author Andy Wilkinson
* @param name name of the smoke test
* @param group group of the smoke test
* @param path path of the smoke test project
* @param tests whether the smoke test contains any unit tests
* @param appTests whether the smoke test contains any app tests
*/
public record SmokeTest(String name, String group, String path, boolean tests,
boolean appTests) implements Serializable {
SmokeTest(Properties properties) {
this(properties.getProperty("name"), properties.getProperty("group"), properties.getProperty("path"),
Boolean.valueOf(properties.getProperty("tests")), Boolean.valueOf(properties.getProperty("appTests")));
}
}

View File

@@ -0,0 +1,66 @@
/*
* Copyright 2022-2024 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 org.springframework.lifecycle.gradle;
/**
* A branch's smoke tests.
*
* @author Andy Wilkinson
*/
public class SmokeTests {
private final String name;
private String branch;
private String location;
private String cronSchedule;
public SmokeTests(String name) {
this.name = name;
}
public String getName() {
return this.name;
}
public String getBranch() {
return this.branch;
}
public void setBranch(String branch) {
this.branch = branch;
}
public String getLocation() {
return this.location;
}
public void setLocation(String location) {
this.location = location;
}
public String getCronSchedule() {
return this.cronSchedule;
}
public void setCronSchedule(String cronSchedule) {
this.cronSchedule = cronSchedule;
}
}

View File

@@ -0,0 +1,154 @@
/*
* Copyright 2022-2024 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 org.springframework.lifecycle.gradle;
import java.io.IOException;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.SortedSet;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.function.Predicate;
import org.gradle.api.DefaultTask;
import org.gradle.api.file.RegularFileProperty;
import org.gradle.api.provider.MapProperty;
import org.gradle.api.tasks.Input;
import org.gradle.api.tasks.OutputFile;
import org.gradle.api.tasks.TaskAction;
/**
* Task to update the {@code STATUS.adoc} with the smoke tests.
*
* @author Stephane Nicoll
*/
public abstract class UpdateStatusPage extends DefaultTask {
@OutputFile
public abstract RegularFileProperty getOutputFile();
@Input
public abstract MapProperty<String, List<SmokeTest>> getSmokeTests();
@TaskAction
void updateStatusPage() throws IOException {
List<String> lines = new ArrayList<>();
lines.add("= Smoke Tests Status");
lines.add(":toc:");
lines.add("");
lines.add("Check each test for potential configuration guidance.");
lines.add("");
getSmokeTests().get().forEach((name, tests) -> handleLocation(lines, name, tests));
Files.write(getOutputFile().get().getAsFile().toPath(), lines);
}
private void handleLocation(List<String> content, String generation, List<SmokeTest> smokeTests) {
content.add(":toc-title: %s Projects".formatted(generation));
content.add("== %s Projects".formatted(generation));
content.add("");
Map<String, SortedSet<SmokeTest>> groupedSmokeTests = new TreeMap<>();
for (SmokeTest smokeTest : smokeTests) {
groupedSmokeTests
.computeIfAbsent(smokeTest.group(), (group) -> new TreeSet<>(Comparator.comparing(SmokeTest::name)))
.add(smokeTest);
}
groupedSmokeTests.forEach((group, tests) -> {
content.add("=== " + capitalize(group));
content.add("");
content.add("[%header,cols=\"2\"]");
content.add("|===");
content.add("h|Smoke Test");
content.add("h|Status");
content.add("");
for (SmokeTest test : tests) {
String name = test.name();
String workflowUrl = workflowUrl(generation, name);
content.add("|" + testUrl(group, name) + "[" + name + "]");
content.add("| image:%s/badge.svg[\"Status\", link=\"%s\"]".formatted(workflowUrl, workflowUrl));
content.add("");
}
content.add("|===");
content.add("");
});
}
private String workflowUrl(String generation, String name) {
return "https://github.com/spring-projects/spring-lifecycle-smoke-tests/actions/workflows/%s-%s.yml"
.formatted(generation, name);
}
private String capitalize(String input) {
StringBuffer buffer = new StringBuffer(input.length());
for (char c : input.toCharArray()) {
buffer.append(buffer.isEmpty() ? Character.toUpperCase(c) : c);
}
return buffer.toString();
}
private String testUrl(String group, String name) {
return "https://github.com/spring-projects/spring-lifecycle-smoke-tests/tree/main/" + group + "/" + name;
}
private enum TestType {
APP_TEST(SmokeTest::appTests, "-app-test", "appTest"),
CR_APP_TEST(SmokeTest::appTests, "-cr-app-test", "checkpointRestoreAppTest"),
TEST(SmokeTest::tests, "-test", "test");
private final Predicate<SmokeTest> predicate;
private final String urlSuffix;
private final String taskName;
TestType(Predicate<SmokeTest> predicate, String suffix, String taskName) {
this.predicate = predicate;
this.urlSuffix = suffix;
this.taskName = taskName;
}
String badge(SmokeTest smokeTest) {
if (!this.predicate.test(smokeTest)) {
return "";
}
return "image:" + badgeUrl(smokeTest.name(), this.urlSuffix) + "[link="
+ jobUrl(smokeTest.name(), this.urlSuffix) + "]";
}
String taskName() {
return this.taskName;
}
private String badgeUrl(String name, String suffix) {
return workflowUrl(name) + "/badge.svg?branch=main";
}
private String jobUrl(String name, String suffix) {
return workflowUrl(name);
}
private String workflowUrl(String name) {
return "https://github.com/spring-projects/spring-lifecycle-smoke-tests/workflows/3.3.x-%s.yml"
.formatted(name);
}
}
}

View File

@@ -0,0 +1,20 @@
/*
* Copyright 2022-2024 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.
*/
/**
* Gradle plugin for CI infrastructure of lifecycle smoke tests.
*/
package org.springframework.lifecycle.gradle;

View File

@@ -0,0 +1,63 @@
name: Smoke Test
on:
workflow_call:
inputs:
project:
required: true
type: string
task:
required: true
type: string
checkout_repository:
required: true
type: string
checkout_ref:
required: true
type: string
expected_to_fail:
required: false
type: boolean
default: false
jobs:
smoke_test:
name: ${{ inputs.task }}
runs-on: ubuntu-latest
steps:
- name: Check out
uses: actions/checkout@v4
with:
repository: ${{ inputs.checkout_repository }}
ref: ${{ inputs.checkout_ref }}
- name: Set up Java
uses: actions/setup-java@v3
with:
java-version: '17'
java-package: 'jdk+crac'
distribution: 'zulu'
- name: Set up Gradle
uses: gradle/gradle-build-action@982da8e78c05368c70dac0351bb82647a9e9a5d2
- name: Configure Gradle user.name
run: |
mkdir -p ~/.gradle
echo 'systemProp.user.name=spring-builds+github' >> ~/.gradle/gradle.properties
- name: Build
id: build
env:
GRADLE_ENTERPRISE_ACCESS_KEY: ${{ secrets.GRADLE_ENTERPRISE_SECRET_ACCESS_KEY }}
run: ./gradlew ${{ inputs.project }}:${{ inputs.task }}
continue-on-error: ${{ inputs.expected_to_fail }}
- name: Check out send notification action
uses: actions/checkout@v4
if: ${{ failure() }}
with:
path: ci
ref: ci
sparse-checkout: .github/actions/send-notification
- name: Send notification
uses: ./ci/.github/actions/send-notification
if: ${{ failure() }}
with:
webhook-url: ${{ secrets.GOOGLE_CHAT_WEBHOOK_URL }}
task: ${{ inputs.project }}:${{ inputs.task }}
branch: ${{ inputs.checkout_ref }}
failure-url: ${{ steps.build.outputs.build-scan-url || format('{0}/{1}/actions/runs/{2}', github.server_url, github.repository, github.run_id) }}

View File

@@ -0,0 +1,11 @@
name: "Validate Gradle Wrapper"
on: [push, pull_request]
permissions:
contents: read
jobs:
validation:
name: "Validate Gradle Wrapper"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: gradle/actions/wrapper-validation@6cec5d49d4d6d4bb982fbed7047db31ea6d38f11 #v3.3.0

View File

@@ -0,0 +1,3 @@
pluginManagement {
includeBuild "gradle/plugins/lifecycle-smoke-test-ci-plugin"
}