Files
Stirling-PDF/build.gradle
T
James BruntonandGitHub a3e45bc182 Add frontend autoformatting and set CI to require formatted code for all languages (#6052)
# Description of Changes
Changes the strategy for autoformatting to reject PRs if they are not
formatted correctly instead of allowing them to merge and then spawning
a new PR to fix the formatting. The old strategy just caused more work
for us because we'd have to manually approve the followup PR and get it
merged, which required 2 reviewers so in practice it rarely got done and
just meant everyone's PRs ended up containing reformatting for unrelated
files, which makes code review unnecessarily difficult. If the PR's code
is not formatted correctly after this PR, a comment will be added
automatically to tell the author how to run the formatter script to fix
their code so it can go in.

This also enables autoformatting for the frontend code, using Prettier.
I've enabled it for pretty much everything in the frontend folder, other
than 3rd party files and files it doesn't make sense for. I also
excluded Markdown because it sounds likely to be more annoying to have
to autoformat the Markdown in the frontend folder but nowhere else. Open
to changing this though if people disagree.

> [!note]
> 
> Advice to reviewers: The first commit contains all of the actual logic
I've introduced (CI changes, Prettier config, etc.)
> The second commit is just the reformatting of the entire frontend
folder.
> The first commit needs proper review, the second one just give it a
spot-check that it's doing what you'd expect.
2026-04-10 17:41:19 +01:00

602 lines
21 KiB
Groovy

plugins {
id "java"
id "jacoco"
id "io.spring.dependency-management" version "1.1.7"
id "org.springframework.boot" version "4.0.5"
id "org.springdoc.openapi-gradle-plugin" version "1.9.0"
id "io.swagger.swaggerhub" version "1.3.2"
id "com.diffplug.spotless" version "8.4.0"
id "com.github.jk1.dependency-license-report" version "3.1.1"
//id "nebula.lint" version "19.0.3"
id "org.sonarqube" version "7.2.3.7755"
}
import com.github.jk1.license.render.*
import groovy.json.JsonOutput
import groovy.json.JsonSlurper
import groovy.xml.XmlSlurper
import org.gradle.api.JavaVersion
import org.gradle.api.tasks.testing.Test
import org.gradle.jvm.toolchain.JavaLanguageVersion
ext {
springBootVersion = "4.0.5"
pdfboxVersion = "3.0.7"
imageioVersion = "3.13.1"
lombokVersion = "1.18.44"
bouncycastleVersion = "1.83"
springSecuritySamlVersion = "7.0.4"
openSamlVersion = "5.2.1"
commonmarkVersion = "0.28.0"
googleJavaFormatVersion = "1.28.0"
logback = "1.5.32"
// junit-platform-launcher version managed by Spring Boot BOM
modernJavaVersion = 21
}
java {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
toolchain {
languageVersion = JavaLanguageVersion.of(project.findProperty('javaVersion')?.toString() ?: '25')
}
}
ext.isSecurityDisabled = { ->
System.getenv('DOCKER_ENABLE_SECURITY') == 'false' ||
System.getenv('DISABLE_ADDITIONAL_FEATURES') == 'true' ||
(project.hasProperty('DISABLE_ADDITIONAL_FEATURES') &&
System.getProperty('DISABLE_ADDITIONAL_FEATURES') == 'true')
}
ext.mavenUrl = System.getenv("MAVEN_PUBLIC_URL") ?: ""
ext.username = System.getenv('MAVEN_USER') ?: ""
ext.password = System.getenv('MAVEN_PASSWORD') ?: ""
if (rootProject.ext.mavenUrl.isEmpty()) {
println "No custom MAVEN_PUBLIC_URL set, defaulting to Maven Central"
} else {
println "MAVEN_PUBLIC_URL set"
}
jar {
enabled = false
manifest {
attributes "Implementation-Title": "Stirling-PDF",
"Implementation-Version": project.version
}
}
bootJar {
enabled = false
}
// Configure main class for the root project
springBoot {
mainClass = 'stirling.software.SPDF.SPDFApplication'
}
allprojects {
group = 'stirling.software'
version = '2.9.2'
configurations.configureEach {
exclude group: "org.springframework.boot", module: "spring-boot-starter-tomcat"
}
}
def appVersionStr = project.version.toString()
def tauriConfigPath = layout.projectDirectory.file('frontend/src-tauri/tauri.conf.json').asFile.path
def sim1Path = layout.projectDirectory.file('frontend/src/core/testing/serverExperienceSimulations.ts').asFile.path
def sim2Path = layout.projectDirectory.file('frontend/src/proprietary/testing/serverExperienceSimulations.ts').asFile.path
tasks.register('syncAppVersion') {
group = 'versioning'
description = 'Synchronizes app version across desktop and simulation configs.'
doLast {
println "Synchronizing application version to ${appVersionStr}"
def tauriConfigFile = new File(tauriConfigPath)
if (tauriConfigFile.exists()) {
def parsed = new groovy.json.JsonSlurper().parse(tauriConfigFile)
parsed.version = appVersionStr
def formatted = groovy.json.JsonOutput.prettyPrint(groovy.json.JsonOutput.toJson(parsed)) + System.lineSeparator()
if (tauriConfigFile.getText('UTF-8') != formatted) {
tauriConfigFile.write(formatted, 'UTF-8')
}
}
[new File(sim1Path), new File(sim2Path)].each { f ->
if (f.exists()) {
def content = f.getText('UTF-8')
def matcher = (content =~ /(appVersion:\s*(['"]))(.*?)(\2)/)
if (!matcher.find()) {
throw new GradleException("Could not locate appVersion in ${f} for synchronization")
}
def updatedContent = matcher.replaceFirst("${matcher.group(1)}${appVersionStr}${matcher.group(4)}")
if (content != updatedContent) {
f.write(updatedContent, 'UTF-8')
}
}
}
}
}
tasks.register('writeVersion', WriteProperties) {
destinationFile = layout.projectDirectory.file('app/common/src/main/resources/version.properties')
println "Writing version.properties to ${destinationFile.get().asFile.path}"
comment = "${new Date()}"
property 'version', project.provider { project.version.toString() }
}
subprojects {
apply plugin: 'java'
apply plugin: 'java-library'
apply plugin: 'com.diffplug.spotless'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'
apply plugin: 'jacoco'
java {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
toolchain {
languageVersion = JavaLanguageVersion.of(25)
}
}
if (project.name != "stirling-pdf") {
bootJar {
enabled = false
}
}
repositories {
if (!rootProject.ext.mavenUrl.isEmpty()) {
maven {
url = rootProject.ext.mavenUrl + '/releases'
credentials(PasswordCredentials) {
username = rootProject.ext.username
password = rootProject.ext.password
}
authentication {
basic(BasicAuthentication)
}
allowInsecureProtocol = true
}
}
maven { url = "https://build.shibboleth.net/maven/releases" }
maven { url = "https://repository.jboss.org/" }
mavenCentral()
}
configurations.configureEach {
exclude group: 'org.springframework.boot', module: 'spring-boot-starter-tomcat'
// Exclude vulnerable BouncyCastle version used in tableau
exclude group: 'org.bouncycastle', module: 'bcpkix-jdk15on'
exclude group: 'org.bouncycastle', module: 'bcutil-jdk15on'
exclude group: 'org.bouncycastle', module: 'bcmail-jdk15on'
// Security CVE fixes - hardcoded resolution strategy to ensure safe versions
// Primary fixes via explicit dependencies in app/core/build.gradle:
// - CVE-2022-25647: gson 2.8.9+ (explicit dependency overrides tabula 2.8.7)
// - CVE-2025-66453: rhino 1.7.15 (explicit dependency overrides verapdf 1.7.13)
// Fallback strategy force declarations for additional safety:
resolutionStrategy.force 'com.google.code.gson:gson:2.13.2'
resolutionStrategy.force 'org.mozilla:rhino:1.9.1'
// CVE-2025-48924: commons-lang3 3.20.0 DoS prevention
resolutionStrategy.force 'org.apache.commons:commons-lang3:3.20.0'
// CVE-2024-47554: commons-io 2.21.0 DoS prevention
resolutionStrategy.force 'commons-io:commons-io:2.21.0'
// Bouncycastle 1.83 (from bouncycastleVersion variable)
resolutionStrategy.force 'org.bouncycastle:bcprov-jdk18on:1.83'
resolutionStrategy.force 'org.bouncycastle:bcpkix-jdk18on:1.83'
}
dependencyManagement {
imports {
mavenBom "org.springframework.boot:spring-boot-dependencies:$springBootVersion"
}
dependencies {
// Override BOM-managed commons-lang3 for CVE-2025-48924 fix
dependency 'org.apache.commons:commons-lang3:3.20.0'
}
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'io.github.pixee:java-security-toolkit:1.2.3'
//tmp for security bumps
implementation "ch.qos.logback:logback-core:$logback"
implementation "ch.qos.logback:logback-classic:$logback"
compileOnly "org.projectlombok:lombok:$lombokVersion"
annotationProcessor "org.projectlombok:lombok:$lombokVersion"
// Jackson 3 (Spring Boot 4 uses tools.jackson)
implementation 'org.springframework.boot:spring-boot-starter-jackson'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
testRuntimeOnly 'org.mockito:mockito-inline:5.2.0'
testRuntimeOnly "org.junit.platform:junit-platform-launcher"
testImplementation platform("com.squareup.okhttp3:okhttp-bom:5.3.2")
testImplementation "com.squareup.okhttp3:mockwebserver"
}
tasks.withType(JavaCompile).configureEach {
options.encoding = "UTF-8"
options.release = rootProject.ext.modernJavaVersion
if (!project.hasProperty("noSpotless")) {
dependsOn "spotlessApply"
}
}
tasks.named("compileJava", JavaCompile).configure {
// options.compilerArgs.add("-Xlint:deprecation")
// options.compilerArgs.add("-Xlint:unchecked")
}
def jacocoReport = tasks.named("jacocoTestReport")
tasks.withType(Test).configureEach {
useJUnitPlatform()
finalizedBy(jacocoReport)
}
jacocoReport.configure {
dependsOn(tasks.named("test"))
reports {
xml.required.set(true)
csv.required.set(false)
html.required.set(true)
}
doLast {
def xmlReport = reports.xml.outputLocation.get().asFile
if (!xmlReport.exists()) {
logger.lifecycle("Jacoco coverage report not found at ${xmlReport}")
return
}
def xmlContent = xmlReport.getText("UTF-8")
xmlContent = xmlContent.replaceFirst('(?s)<!DOCTYPE.*?>', '')
def report = new XmlSlurper(false, false).parseText(xmlContent)
def counters = report.counter.collectEntries { counter ->
def type = counter.@type.text()
def covered = counter.@covered.text() as BigDecimal
def missed = counter.@missed.text() as BigDecimal
[(type): [covered: covered, missed: missed]]
}
def thresholds = [
LINE : 0.13,
INSTRUCTION: 0.14,
BRANCH : 0.09
]
def types = ["LINE", "INSTRUCTION", "BRANCH"]
def headers = ["Metric", "Coverage", "Covered/Total", "Status", "Target"]
def rows = types.collect { String type ->
def data = counters[type]
if (!data) {
return [type, "—", "—", "No data", ""]
}
def total = data.covered + data.missed
if (total == 0) {
return [type, "—", "0/${total.toBigInteger()}", "No executions", ""]
}
def ratio = data.covered / total * 100
def coverageText = String.format(Locale.ROOT, "%.2f%%", ratio)
def coveredText = String.format(Locale.ROOT, "%d/%d",
data.covered.toBigInteger(),
total.toBigInteger())
def threshold = thresholds[type]
def thresholdPercent = threshold != null ? threshold * 100 : null
def targetText = thresholdPercent != null ?
String.format(Locale.ROOT, ">= %.2f%%", thresholdPercent) : ""
def passed = thresholdPercent != null ? ratio >= thresholdPercent : null
def statusText = passed == null ? "" : (passed ? "PASS" : "FAIL")
return [type, coverageText, coveredText, statusText, targetText]
}
def columnIndexes = (0..<headers.size())
def columnWidths = columnIndexes.collect { idx ->
Math.max(headers[idx].length(), rows.collect { row ->
row[idx] != null ? row[idx].toString().length() : 0
}.max() ?: 0)
}
def formatRow = { List<String> values ->
columnIndexes.collect { idx ->
def value = values[idx] ?: ""
value.padRight(columnWidths[idx])
}.join(" | ")
}
def separator = columnIndexes.collect { idx ->
''.padRight(columnWidths[idx], '-')
}.join("-+-")
logger.lifecycle("")
logger.lifecycle("==== JaCoCo Coverage Summary ====")
logger.lifecycle(formatRow(headers))
logger.lifecycle(separator)
rows.each { row ->
logger.lifecycle(formatRow(row))
}
logger.lifecycle(separator)
def htmlReport = reports.html.outputLocation.get().asFile
logger.lifecycle("Detailed HTML report available at: ${htmlReport}")
if (rows.any { it[3] == "FAIL" }) {
logger.lifecycle("Some coverage targets were missed. Please review the detailed report above.")
} else if (rows.any { it[3] == "PASS" }) {
logger.lifecycle("Great job! All tracked coverage metrics meet their targets.")
}
logger.lifecycle("=================================\n")
}
}
tasks.named("build") {
dependsOn jacocoReport
}
jacocoTestCoverageVerification {
dependsOn jacocoReport
violationRules {
rule {
enabled = true
element = 'BUNDLE'
// Bytecode-Anweisungen abgedeckt
limit {
counter = 'INSTRUCTION'
value = 'COVEREDRATIO'
minimum = 0.14
}
// wie viele Quellcode-Zeilen abgedeckt
limit {
counter = 'LINE'
value = 'COVEREDRATIO'
minimum = 0.13
}
// Verzweigungen (if/else, switch) abgedeckt; misst Logik-Abdeckung
limit {
counter = 'BRANCH'
value = 'COVEREDRATIO'
minimum = 0.09
}
}
}
}
tasks.named("processResources") {
dependsOn(rootProject.tasks.writeVersion)
}
if (name == 'stirling-pdf') {
apply plugin: 'org.springdoc.openapi-gradle-plugin'
openApi {
apiDocsUrl = "http://localhost:8080/v1/api-docs"
outputDir = file("$projectDir")
outputFileName = "SwaggerDoc.json"
waitTimeInSeconds = 60 // Increase the wait time to 60 seconds
}
tasks.named("forkedSpringBootRun") {
dependsOn(":common:jar")
dependsOn(":proprietary:jar")
}
tasks.register("copySwaggerDoc", Copy) {
doNotTrackState("Writes SwaggerDoc.json to project root")
from(layout.projectDirectory.file("SwaggerDoc.json"))
into(rootProject.projectDir)
dependsOn("generateOpenApiDocs")
}
tasks.register("cleanSwaggerInBuild", Delete) {
doNotTrackState("Cleans up SwaggerDoc.json in build directory")
delete(layout.projectDirectory.file("SwaggerDoc.json"))
dependsOn("copySwaggerDoc")
}
tasks.named("copySwaggerDoc") {
finalizedBy("cleanSwaggerInBuild")
}
tasks.named("generateOpenApiDocs") {
finalizedBy("copySwaggerDoc")
doNotTrackState("OpenAPI plugin writes outside build directory")
}
tasks.named("bootRun") {
jvmArgs = [
"-XX:+UseG1GC",
"-XX:MaxGCPauseMillis=200",
"-XX:G1HeapRegionSize=4m",
"-XX:+ExplicitGCInvokesConcurrent",
"-XX:+UseStringDeduplication",
"-XX:+UseCompactObjectHeaders"
]
}
}
}
tasks.withType(JavaCompile).configureEach {
options.encoding = "UTF-8"
if (!project.hasProperty("noSpotless")) {
dependsOn "spotlessApply"
}
}
gradle.taskGraph.whenReady { graph ->
if (project.hasProperty("noSpotless")) {
allprojects { scopedProject ->
scopedProject.tasks.matching { it.name.startsWith("spotless") }.configureEach {
enabled = false
}
}
}
}
def allProjects = ((subprojects as Set<Project>) + project) as Set<Project>
licenseReport {
projects = allProjects
renderers = [new JsonReportRenderer()]
allowedLicensesFile = project.layout.projectDirectory.file("app/allowed-licenses.json").asFile
outputDir = project.layout.buildDirectory.dir("reports/dependency-license").get().asFile.path
configurations = [ "productionRuntimeClasspath", "runtimeClasspath" ]
}
// Configure the forked spring boot run task to properly delegate to the stirling-pdf module
tasks.named('forkedSpringBootRun') {
dependsOn ':stirling-pdf:bootRun'
doFirst {
println "Delegating forkedSpringBootRun to :stirling-pdf:bootRun"
}
}
spotless {
yaml {
target '*.yml', '*.yaml'
trimTrailingWhitespace()
leadingTabsToSpaces()
endWithNewline()
}
format 'gradle', {
target 'build.gradle', 'settings.gradle', 'gradle/*.gradle', 'gradle/**/*.gradle'
trimTrailingWhitespace()
leadingTabsToSpaces()
endWithNewline()
}
}
sonar {
properties {
property "sonar.projectKey", "Stirling-Tools_Stirling-PDF"
property "sonar.organization", "stirling-tools"
property "sonar.exclusions", "**/build-wrapper-dump.json, **/src/main/java/org/apache/**, **/src/main/resources/static/pdfjs/**, **/src/main/resources/static/pdfjs-legacy/**, **/src/main/resources/static/js/thirdParty/**"
property "sonar.coverage.exclusions", "**/src/main/java/org/apache/**, **/src/main/resources/static/pdfjs/**, **/src/main/resources/static/pdfjs-legacy/**, **/src/main/resources/static/js/thirdParty/**"
property "sonar.cpd.exclusions", "**/src/main/java/org/apache/**, **/src/main/resources/static/pdfjs/**, **/src/main/resources/static/pdfjs-legacy/**, **/src/main/resources/static/js/thirdParty/**"
}
}
swaggerhubUpload {
// dependsOn = generateOpenApiDocs // Depends on your task generating Swagger docs
api = "Stirling-PDF" // The name of your API on SwaggerHub
owner = "${System.getenv().getOrDefault('SWAGGERHUB_USER', 'Frooodle')}" // Your SwaggerHub username (or organization name)
version = project.version // The version of your API
inputFile = file("SwaggerDoc.json") // The path to your Swagger docs
token = "${System.getenv("SWAGGERHUB_API_KEY")}" // Your SwaggerHub API key, passed as an environment variable
oas = "3.0.0" // The version of the OpenAPI Specification you"re using
}
repositories {
if (!rootProject.ext.mavenUrl.isEmpty()) {
maven {
url = rootProject.ext.mavenUrl + '/releases'
credentials(PasswordCredentials) {
username = rootProject.ext.username
password = rootProject.ext.password
}
authentication {
basic(BasicAuthentication)
}
allowInsecureProtocol = true
}
}
maven { url = "https://build.shibboleth.net/maven/releases" }
mavenCentral()
}
dependencies {
implementation project(':stirling-pdf')
implementation project(':common')
if (rootProject.ext.isSecurityDisabled()) {
implementation project(':proprietary')
}
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly "org.junit.platform:junit-platform-launcher"
testImplementation platform("com.squareup.okhttp3:okhttp-bom:5.3.2")
testImplementation "com.squareup.okhttp3:mockwebserver"
}
tasks.named("test") {
useJUnitPlatform()
}
// Make sure all relevant processes depend on writeVersion
processResources.dependsOn(writeVersion)
tasks.register('printVersion') {
doLast {
println project.version
}
}
tasks.named('bootRun') {
group = 'application'
description = 'Delegates to :stirling-pdf:bootRun'
dependsOn ':stirling-pdf:bootRun'
doFirst {
println "Delegating to :stirling-pdf:bootRun"
}
}
tasks.named('build') {
group = 'build'
description = 'Delegates to :stirling-pdf:bootJar'
dependsOn ':stirling-pdf:bootJar', 'buildRestartHelper', 'syncAppVersion'
doFirst {
println "Delegating to :stirling-pdf:bootJar"
}
}
// Task to compile RestartHelper.java
tasks.register('compileRestartHelper', JavaCompile) {
group = 'build'
description = 'Compiles the RestartHelper utility'
source = fileTree(dir: 'scripts', include: 'RestartHelper.java')
classpath = files()
destinationDirectory = layout.buildDirectory.dir("restart-helper-classes")
def restartMajorVersion = project.ext.modernJavaVersion
def restartCompatibility = JavaVersion.toVersion(restartMajorVersion.toString())
sourceCompatibility = restartCompatibility
targetCompatibility = restartCompatibility
options.release.set(restartMajorVersion)
}
// Task to create restart-helper.jar
tasks.register('buildRestartHelper', Jar) {
group = 'build'
description = 'Builds the restart-helper.jar'
dependsOn 'compileRestartHelper'
from layout.buildDirectory.dir("restart-helper-classes")
archiveFileName = 'restart-helper.jar'
destinationDirectory = layout.buildDirectory.dir("libs")
manifest {
attributes 'Main-Class': 'RestartHelper'
}
doLast {
println "restart-helper.jar created at: ${destinationDirectory.get()}/restart-helper.jar"
}
}