Support Maven's outputTimestamp when repackaging jars and wars
Closes gh-20176
This commit is contained in:
parent
df8c25e213
commit
ca202ad59f
|
|
@ -22,6 +22,7 @@ import java.io.FileOutputStream;
|
|||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.FileTime;
|
||||
import java.nio.file.attribute.PosixFilePermission;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
|
@ -44,6 +45,8 @@ public class JarWriter extends AbstractJarWriter implements AutoCloseable {
|
|||
|
||||
private final JarArchiveOutputStream jarOutputStream;
|
||||
|
||||
private final FileTime lastModifiedTime;
|
||||
|
||||
/**
|
||||
* Create a new {@link JarWriter} instance.
|
||||
* @param file the file to write
|
||||
|
|
@ -62,6 +65,21 @@ public class JarWriter extends AbstractJarWriter implements AutoCloseable {
|
|||
* @throws FileNotFoundException if the file cannot be found
|
||||
*/
|
||||
public JarWriter(File file, LaunchScript launchScript) throws FileNotFoundException, IOException {
|
||||
this(file, launchScript, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new {@link JarWriter} instance.
|
||||
* @param file the file to write
|
||||
* @param launchScript an optional launch script to prepend to the front of the jar
|
||||
* @param lastModifiedTime an optional last modified time to apply to the written
|
||||
* entries
|
||||
* @throws IOException if the file cannot be opened
|
||||
* @throws FileNotFoundException if the file cannot be found
|
||||
* @since 2.3.0
|
||||
*/
|
||||
public JarWriter(File file, LaunchScript launchScript, FileTime lastModifiedTime)
|
||||
throws FileNotFoundException, IOException {
|
||||
FileOutputStream fileOutputStream = new FileOutputStream(file);
|
||||
if (launchScript != null) {
|
||||
fileOutputStream.write(launchScript.toByteArray());
|
||||
|
|
@ -69,6 +87,7 @@ public class JarWriter extends AbstractJarWriter implements AutoCloseable {
|
|||
}
|
||||
this.jarOutputStream = new JarArchiveOutputStream(fileOutputStream);
|
||||
this.jarOutputStream.setEncoding("UTF-8");
|
||||
this.lastModifiedTime = lastModifiedTime;
|
||||
}
|
||||
|
||||
private void setExecutableFilePermission(File file) {
|
||||
|
|
@ -85,7 +104,11 @@ public class JarWriter extends AbstractJarWriter implements AutoCloseable {
|
|||
|
||||
@Override
|
||||
protected void writeToArchive(ZipEntry entry, EntryWriter entryWriter) throws IOException {
|
||||
this.jarOutputStream.putArchiveEntry(asJarArchiveEntry(entry));
|
||||
JarArchiveEntry jarEntry = asJarArchiveEntry(entry);
|
||||
if (this.lastModifiedTime != null) {
|
||||
jarEntry.setLastModifiedTime(this.lastModifiedTime);
|
||||
}
|
||||
this.jarOutputStream.putArchiveEntry(jarEntry);
|
||||
if (entryWriter != null) {
|
||||
entryWriter.write(this.jarOutputStream);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ package org.springframework.boot.loader.tools;
|
|||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.attribute.FileTime;
|
||||
import java.util.jar.JarFile;
|
||||
|
||||
import org.springframework.util.Assert;
|
||||
|
|
@ -82,6 +83,22 @@ public class Repackager extends Packager {
|
|||
* @since 1.3.0
|
||||
*/
|
||||
public void repackage(File destination, Libraries libraries, LaunchScript launchScript) throws IOException {
|
||||
this.repackage(destination, libraries, launchScript, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Repackage to the given destination so that it can be launched using '
|
||||
* {@literal java -jar}'.
|
||||
* @param destination the destination file (may be the same as the source)
|
||||
* @param libraries the libraries required to run the archive
|
||||
* @param launchScript an optional launch script prepended to the front of the jar
|
||||
* @param lastModifiedTime an optional last modified time to apply to the archive and
|
||||
* its contents
|
||||
* @throws IOException if the file cannot be repackaged
|
||||
* @since 2.3.0
|
||||
*/
|
||||
public void repackage(File destination, Libraries libraries, LaunchScript launchScript, FileTime lastModifiedTime)
|
||||
throws IOException {
|
||||
Assert.isTrue(destination != null && !destination.isDirectory(), "Invalid destination");
|
||||
destination = destination.getAbsoluteFile();
|
||||
File source = getSource();
|
||||
|
|
@ -97,7 +114,7 @@ public class Repackager extends Packager {
|
|||
destination.delete();
|
||||
try {
|
||||
try (JarFile sourceJar = new JarFile(workingSource)) {
|
||||
repackage(sourceJar, destination, libraries, launchScript);
|
||||
repackage(sourceJar, destination, libraries, launchScript, lastModifiedTime);
|
||||
}
|
||||
}
|
||||
finally {
|
||||
|
|
@ -107,11 +124,14 @@ public class Repackager extends Packager {
|
|||
}
|
||||
}
|
||||
|
||||
private void repackage(JarFile sourceJar, File destination, Libraries libraries, LaunchScript launchScript)
|
||||
throws IOException {
|
||||
try (JarWriter writer = new JarWriter(destination, launchScript)) {
|
||||
private void repackage(JarFile sourceJar, File destination, Libraries libraries, LaunchScript launchScript,
|
||||
FileTime lastModifiedTime) throws IOException {
|
||||
try (JarWriter writer = new JarWriter(destination, launchScript, lastModifiedTime)) {
|
||||
write(sourceJar, libraries, writer);
|
||||
}
|
||||
if (lastModifiedTime != null) {
|
||||
destination.setLastModified(lastModifiedTime.toMillis());
|
||||
}
|
||||
}
|
||||
|
||||
private void renameFile(File file, File dest) {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ dependencies {
|
|||
|
||||
intTestImplementation(platform(project(":spring-boot-project:spring-boot-parent")))
|
||||
intTestImplementation(project(":spring-boot-project:spring-boot-tools:spring-boot-buildpack-platform"))
|
||||
intTestImplementation(project(":spring-boot-project:spring-boot-tools:spring-boot-loader-tools"))
|
||||
intTestImplementation(project(":spring-boot-project:spring-boot-tools:spring-boot-test-support"))
|
||||
intTestImplementation("org.apache.maven.shared:maven-invoker")
|
||||
intTestImplementation("org.assertj:assertj-core")
|
||||
|
|
|
|||
|
|
@ -16,10 +16,18 @@
|
|||
package org.springframework.boot.maven;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.jar.JarFile;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.junit.jupiter.api.TestTemplate;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
|
||||
import org.springframework.boot.loader.tools.FileUtils;
|
||||
import org.springframework.util.FileSystemUtils;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
|
|
@ -316,4 +324,35 @@ class JarIntegrationTests extends AbstractArchiveIntegrationTests {
|
|||
});
|
||||
}
|
||||
|
||||
@TestTemplate
|
||||
void whenJarIsRepackagedWithOutputTimestampConfiguredThenJarIsReproducible(MavenBuild mavenBuild)
|
||||
throws InterruptedException {
|
||||
String firstHash = buildJarWithOutputTimestamp(mavenBuild);
|
||||
Thread.sleep(1500);
|
||||
String secondHash = buildJarWithOutputTimestamp(mavenBuild);
|
||||
assertThat(firstHash).isEqualTo(secondHash);
|
||||
}
|
||||
|
||||
private String buildJarWithOutputTimestamp(MavenBuild mavenBuild) {
|
||||
AtomicReference<String> jarHash = new AtomicReference<>();
|
||||
mavenBuild.project("jar-output-timestamp").execute((project) -> {
|
||||
File repackaged = new File(project, "target/jar-output-timestamp-0.0.1.BUILD-SNAPSHOT.jar");
|
||||
assertThat(repackaged).isFile();
|
||||
assertThat(repackaged.lastModified()).isEqualTo(1584352800000L);
|
||||
try (JarFile jar = new JarFile(repackaged)) {
|
||||
List<String> unreproducibleEntries = jar.stream()
|
||||
.filter((entry) -> entry.getLastModifiedTime().toMillis() != 1584352800000L)
|
||||
.map((entry) -> entry.getName() + ": " + entry.getLastModifiedTime())
|
||||
.collect(Collectors.toList());
|
||||
assertThat(unreproducibleEntries).isEmpty();
|
||||
jarHash.set(FileUtils.sha1Hash(repackaged));
|
||||
FileSystemUtils.deleteRecursively(project);
|
||||
}
|
||||
catch (IOException ex) {
|
||||
throw new RuntimeException(ex);
|
||||
}
|
||||
});
|
||||
return jarHash.get();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,10 +17,18 @@
|
|||
package org.springframework.boot.maven;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.jar.JarFile;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.junit.jupiter.api.TestTemplate;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
|
||||
import org.springframework.boot.loader.tools.FileUtils;
|
||||
import org.springframework.util.FileSystemUtils;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
|
|
@ -63,4 +71,35 @@ class WarIntegrationTests extends AbstractArchiveIntegrationTests {
|
|||
.hasEntryWithNameStartingWith("WEB-INF/lib/spring-jcl-"));
|
||||
}
|
||||
|
||||
@TestTemplate
|
||||
void whenWarIsRepackagedWithOutputTimestampConfiguredThenWarIsReproducible(MavenBuild mavenBuild)
|
||||
throws InterruptedException {
|
||||
String firstHash = buildWarWithOutputTimestamp(mavenBuild);
|
||||
Thread.sleep(1500);
|
||||
String secondHash = buildWarWithOutputTimestamp(mavenBuild);
|
||||
assertThat(firstHash).isEqualTo(secondHash);
|
||||
}
|
||||
|
||||
private String buildWarWithOutputTimestamp(MavenBuild mavenBuild) {
|
||||
AtomicReference<String> warHash = new AtomicReference<>();
|
||||
mavenBuild.project("war-output-timestamp").execute((project) -> {
|
||||
File repackaged = new File(project, "target/war-output-timestamp-0.0.1.BUILD-SNAPSHOT.war");
|
||||
assertThat(repackaged).isFile();
|
||||
assertThat(repackaged.lastModified()).isEqualTo(1584352800000L);
|
||||
try (JarFile jar = new JarFile(repackaged)) {
|
||||
List<String> unreproducibleEntries = jar.stream()
|
||||
.filter((entry) -> entry.getLastModifiedTime().toMillis() != 1584352800000L)
|
||||
.map((entry) -> entry.getName() + ": " + entry.getLastModifiedTime())
|
||||
.collect(Collectors.toList());
|
||||
assertThat(unreproducibleEntries).isEmpty();
|
||||
warHash.set(FileUtils.sha1Hash(repackaged));
|
||||
FileSystemUtils.deleteRecursively(project);
|
||||
}
|
||||
catch (IOException ex) {
|
||||
throw new RuntimeException(ex);
|
||||
}
|
||||
});
|
||||
return warHash.get();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>org.springframework.boot.maven.it</groupId>
|
||||
<artifactId>jar-output-timestamp</artifactId>
|
||||
<version>0.0.1.BUILD-SNAPSHOT</version>
|
||||
<properties>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<project.build.outputTimestamp>2020-03-16T02:00:00-08:00</project.build.outputTimestamp>
|
||||
<maven.compiler.source>@java.version@</maven.compiler.source>
|
||||
<maven.compiler.target>@java.version@</maven.compiler.target>
|
||||
</properties>
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>@project.groupId@</groupId>
|
||||
<artifactId>@project.artifactId@</artifactId>
|
||||
<version>@project.version@</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>repackage</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-jar-plugin</artifactId>
|
||||
<version>@maven-jar-plugin.version@</version>
|
||||
<configuration>
|
||||
<archive>
|
||||
<manifest>
|
||||
<mainClass>some.random.Main</mainClass>
|
||||
</manifest>
|
||||
<manifestEntries>
|
||||
<Not-Used>Foo</Not-Used>
|
||||
</manifestEntries>
|
||||
</archive>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-context</artifactId>
|
||||
<version>@spring-framework.version@</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>jakarta.servlet</groupId>
|
||||
<artifactId>jakarta.servlet-api</artifactId>
|
||||
<version>@jakarta-servlet.version@</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* Copyright 2012-2020 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.test;
|
||||
|
||||
public class SampleApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>org.springframework.boot.maven.it</groupId>
|
||||
<artifactId>war-output-timestamp</artifactId>
|
||||
<version>0.0.1.BUILD-SNAPSHOT</version>
|
||||
<packaging>war</packaging>
|
||||
<properties>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<project.build.outputTimestamp>2020-03-16T02:00:00-08:00</project.build.outputTimestamp>
|
||||
<maven.compiler.source>@java.version@</maven.compiler.source>
|
||||
<maven.compiler.target>@java.version@</maven.compiler.target>
|
||||
</properties>
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>@project.groupId@</groupId>
|
||||
<artifactId>@project.artifactId@</artifactId>
|
||||
<version>@project.version@</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>repackage</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-war-plugin</artifactId>
|
||||
<version>@maven-war-plugin.version@</version>
|
||||
<configuration>
|
||||
<archive>
|
||||
<manifestEntries>
|
||||
<Not-Used>Foo</Not-Used>
|
||||
</manifestEntries>
|
||||
</archive>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-context</artifactId>
|
||||
<version>@spring-framework.version@</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>jakarta.servlet</groupId>
|
||||
<artifactId>jakarta.servlet-api</artifactId>
|
||||
<version>@jakarta-servlet.version@</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* Copyright 2012-2020 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.test;
|
||||
|
||||
public class SampleApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
<html></html>
|
||||
|
|
@ -18,8 +18,11 @@ package org.springframework.boot.maven;
|
|||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.attribute.FileTime;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Properties;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.apache.maven.artifact.Artifact;
|
||||
|
|
@ -140,6 +143,15 @@ public class RepackageMojo extends AbstractPackagerMojo {
|
|||
@Parameter
|
||||
private Properties embeddedLaunchScriptProperties;
|
||||
|
||||
/**
|
||||
* Timestamp for reproducible output archive entries, either formatted as ISO 8601
|
||||
* (<code>yyyy-MM-dd'T'HH:mm:ssXXX</code>) or an {@code int} representing seconds
|
||||
* since the epoch.
|
||||
* @since 2.3.0
|
||||
*/
|
||||
@Parameter(defaultValue = "${project.build.outputTimestamp}")
|
||||
private String outputTimestamp;
|
||||
|
||||
@Override
|
||||
public void execute() throws MojoExecutionException, MojoFailureException {
|
||||
if (this.project.getPackaging().equals("pom")) {
|
||||
|
|
@ -160,7 +172,7 @@ public class RepackageMojo extends AbstractPackagerMojo {
|
|||
Libraries libraries = getLibraries(this.requiresUnpack);
|
||||
try {
|
||||
LaunchScript launchScript = getLaunchScript();
|
||||
repackager.repackage(target, libraries, launchScript);
|
||||
repackager.repackage(target, libraries, launchScript, parseOutputTimestamp());
|
||||
}
|
||||
catch (IOException ex) {
|
||||
throw new MojoExecutionException(ex.getMessage(), ex);
|
||||
|
|
@ -168,6 +180,22 @@ public class RepackageMojo extends AbstractPackagerMojo {
|
|||
updateArtifact(source, target, repackager.getBackupFile());
|
||||
}
|
||||
|
||||
private FileTime parseOutputTimestamp() {
|
||||
// Maven ignore a single-character timestamp as it is "useful to override a full
|
||||
// value during pom inheritance"
|
||||
if (this.outputTimestamp == null || this.outputTimestamp.length() < 2) {
|
||||
return null;
|
||||
}
|
||||
long epochSeconds;
|
||||
try {
|
||||
epochSeconds = Long.parseLong(this.outputTimestamp);
|
||||
}
|
||||
catch (NumberFormatException ex) {
|
||||
epochSeconds = OffsetDateTime.parse(this.outputTimestamp).toInstant().getEpochSecond();
|
||||
}
|
||||
return FileTime.from(epochSeconds, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the source {@link Artifact} to repackage. If a classifier is specified and
|
||||
* an artifact with that classifier exists, it is used. Otherwise, the main artifact
|
||||
|
|
|
|||
Loading…
Reference in New Issue