fix(build): isolate model catalog bundle preparation
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
package org.egothor.radixor
|
||||
|
||||
import org.gradle.api.DefaultTask
|
||||
import org.gradle.api.GradleException
|
||||
import org.gradle.api.file.DirectoryProperty
|
||||
import org.gradle.api.provider.Property
|
||||
import org.gradle.api.tasks.Input
|
||||
import org.gradle.api.tasks.InputDirectory
|
||||
import org.gradle.api.tasks.OutputDirectory
|
||||
import org.gradle.api.tasks.PathSensitive
|
||||
import org.gradle.api.tasks.PathSensitivity
|
||||
import org.gradle.api.tasks.TaskAction
|
||||
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.StandardCopyOption
|
||||
import java.security.MessageDigest
|
||||
import java.util.stream.Stream
|
||||
|
||||
/** Prepares the two POM-only catalog publications for a Maven Central bundle. */
|
||||
abstract class PrepareModelCatalogBundleInputTask extends DefaultTask {
|
||||
@InputDirectory
|
||||
@PathSensitive(PathSensitivity.RELATIVE)
|
||||
abstract DirectoryProperty getRawRepositoryDirectory()
|
||||
|
||||
@OutputDirectory
|
||||
abstract DirectoryProperty getPreparedBundleDirectory()
|
||||
|
||||
@Input
|
||||
abstract Property<String> getCatalogVersion()
|
||||
|
||||
/** Copies permitted publication files and creates Central's required legacy checksums. */
|
||||
@TaskAction
|
||||
void prepare() {
|
||||
prepareBundle(rawRepositoryDirectory.get().asFile.toPath(),
|
||||
preparedBundleDirectory.get().asFile.toPath(), catalogVersion.get())
|
||||
}
|
||||
|
||||
static void prepareBundle(final Path rawRepository, final Path preparedDirectory,
|
||||
final String version) {
|
||||
if (!Files.isDirectory(rawRepository)) {
|
||||
throw new GradleException("The raw model catalog staging repository does not exist: ${rawRepository}.")
|
||||
}
|
||||
deleteTree(preparedDirectory)
|
||||
Files.createDirectories(preparedDirectory)
|
||||
|
||||
final Set<String> expectedPoms = [
|
||||
"org/egothor/radixor-models-standard/${version}/radixor-models-standard-${version}.pom",
|
||||
"org/egothor/radixor-models-bom/${version}/radixor-models-bom-${version}.pom"
|
||||
] as Set<String>
|
||||
final List<Path> copied = []
|
||||
Files.walk(rawRepository).withCloseable { Stream<Path> paths ->
|
||||
paths.filter(Files::isRegularFile).sorted().forEach { Path source ->
|
||||
final String relative = rawRepository.relativize(source).toString().replace(File.separatorChar, '/' as char)
|
||||
if (isExcludedPublicationMetadata(relative)) return
|
||||
if (relative.endsWith('.jar') || relative.endsWith('/stemmer.gz')
|
||||
|| relative.contains('benchmark-pack')) {
|
||||
throw new GradleException("Unsupported model catalog publication file: ${relative}.")
|
||||
}
|
||||
final String pom = expectedPoms.find { String candidate ->
|
||||
relative == candidate || relative.startsWith(candidate + '.')
|
||||
}
|
||||
if (pom == null) {
|
||||
throw new GradleException("Unexpected file in the raw model catalog repository: ${relative}.")
|
||||
}
|
||||
if (relative == pom || relative == pom + '.asc') {
|
||||
final Path target = preparedDirectory.resolve(relative)
|
||||
Files.createDirectories(target.parent)
|
||||
Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING)
|
||||
copied.add(target)
|
||||
} else if (!(relative ==~ /.*\.pom(?:\.asc)?\.(?:md5|sha1|sha256|sha512)/)) {
|
||||
throw new GradleException("Unsupported model catalog publication file: ${relative}.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final List<Path> poms = copied.findAll { Path path -> path.fileName.toString().endsWith('.pom') }
|
||||
if (copied.isEmpty()) {
|
||||
throw new GradleException('No model catalog publication files were copied from the raw staging repository.')
|
||||
}
|
||||
if (poms.size() != 2 || !expectedPoms.every { String expected -> Files.isRegularFile(preparedDirectory.resolve(expected)) }) {
|
||||
throw new GradleException("The prepared model catalog must contain exactly the standard and BOM POMs; found ${poms.size()} POM files.")
|
||||
}
|
||||
copied.each { Path artifact ->
|
||||
writeDigest(artifact, 'MD5', artifact.resolveSibling(artifact.fileName.toString() + '.md5'))
|
||||
writeDigest(artifact, 'SHA-1', artifact.resolveSibling(artifact.fileName.toString() + '.sha1'))
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isExcludedPublicationMetadata(final String relative) {
|
||||
final String name = relative.substring(relative.lastIndexOf('/') + 1)
|
||||
return name ==~ /maven-metadata.*\.xml(?:\..*)?/ || relative ==~ /.*\.module(?:\..*)?/
|
||||
}
|
||||
|
||||
private static void writeDigest(final Path source, final String algorithm, final Path target) {
|
||||
final MessageDigest digest = MessageDigest.getInstance(algorithm)
|
||||
Files.newInputStream(source).withCloseable { InputStream input ->
|
||||
final byte[] buffer = new byte[16 * 1024]
|
||||
int count
|
||||
while ((count = input.read(buffer)) >= 0) {
|
||||
if (count > 0) digest.update(buffer, 0, count)
|
||||
}
|
||||
}
|
||||
Files.writeString(target, digest.digest().encodeHex().toString(), java.nio.charset.StandardCharsets.US_ASCII)
|
||||
}
|
||||
|
||||
private static void deleteTree(final Path directory) {
|
||||
if (!Files.exists(directory)) return
|
||||
Files.walk(directory).withCloseable { Stream<Path> paths ->
|
||||
paths.sorted(Comparator.reverseOrder()).forEach(Files::delete)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,5 +12,13 @@ final class RadixorBuildSupportPlugin implements Plugin<Project> {
|
||||
group = 'verification'
|
||||
description = 'Creates an isolated local Maven repository for model dependency-resolution integration tests.'
|
||||
}
|
||||
project.tasks.register('prepareModelCatalogReleaseCandidate', PrepareModelCatalogBundleInputTask) {
|
||||
group = 'publishing'
|
||||
description = 'Prepares the isolated POM-only model catalog input for Maven Central.'
|
||||
}
|
||||
project.tasks.register('verifyModelCatalogReleaseCandidate', VerifyModelCatalogReleaseCandidateTask) {
|
||||
group = 'verification'
|
||||
description = 'Verifies catalog bundle contents, checksums, coordinates, and dependency semantics.'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package org.egothor.radixor
|
||||
|
||||
import org.gradle.api.DefaultTask
|
||||
import org.gradle.api.GradleException
|
||||
import org.gradle.api.file.RegularFileProperty
|
||||
import org.gradle.api.provider.ListProperty
|
||||
import org.gradle.api.provider.Property
|
||||
import org.gradle.api.tasks.Input
|
||||
import org.gradle.api.tasks.InputFile
|
||||
import org.gradle.api.tasks.OutputFile
|
||||
import org.gradle.api.tasks.PathSensitive
|
||||
import org.gradle.api.tasks.PathSensitivity
|
||||
import org.gradle.api.tasks.TaskAction
|
||||
import org.w3c.dom.Element
|
||||
|
||||
import javax.xml.XMLConstants
|
||||
import javax.xml.parsers.DocumentBuilderFactory
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.nio.file.Files
|
||||
import java.security.MessageDigest
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
/** Verifies the contents and Maven semantics of the model catalog Central bundle. */
|
||||
abstract class VerifyModelCatalogReleaseCandidateTask extends DefaultTask {
|
||||
@InputFile
|
||||
@PathSensitive(PathSensitivity.RELATIVE)
|
||||
abstract RegularFileProperty getBundleFile()
|
||||
|
||||
@OutputFile
|
||||
abstract RegularFileProperty getReportFile()
|
||||
|
||||
@Input abstract Property<String> getCatalogVersion()
|
||||
@Input abstract Property<String> getModelVersion()
|
||||
@Input abstract ListProperty<String> getDefaultModelIds()
|
||||
@Input abstract ListProperty<String> getAllModelIds()
|
||||
|
||||
/** Performs byte-level archive and semantic POM validation. */
|
||||
@TaskAction
|
||||
void verify() {
|
||||
final List<String> entries = verifyBundle(bundleFile.get().asFile, catalogVersion.get(),
|
||||
modelVersion.get(), defaultModelIds.get(), allModelIds.get())
|
||||
final File report = reportFile.get().asFile
|
||||
Files.createDirectories(report.toPath().parent)
|
||||
Files.writeString(report.toPath(), "Bundle: ${bundleFile.get().asFile.name}\nBytes: ${bundleFile.get().asFile.length()}\n"
|
||||
+ entries.join('\n') + '\n', StandardCharsets.UTF_8)
|
||||
}
|
||||
|
||||
static List<String> verifyBundle(final File bundle, final String catalogVersion,
|
||||
final String modelVersion, final List<String> defaultIds, final List<String> allIds) {
|
||||
if (!bundle.isFile() || bundle.length() == 0L) {
|
||||
throw new GradleException("The model catalog Central bundle is missing or empty: ${bundle}.")
|
||||
}
|
||||
final Map<String, byte[]> content = new TreeMap<>()
|
||||
new ZipFile(bundle).withCloseable { ZipFile archive ->
|
||||
archive.entries().each { ZipEntry entry ->
|
||||
if (!entry.directory) {
|
||||
archive.getInputStream(entry).withCloseable { InputStream input ->
|
||||
content.put(entry.name, input.readAllBytes())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
final List<String> entries = content.keySet().toList()
|
||||
final List<String> poms = entries.findAll { String entry -> entry.endsWith('.pom') }
|
||||
final List<String> unsupported = entries.findAll { String entry ->
|
||||
!(entry ==~ 'org/egothor/radixor-models-(?:standard|bom)/[^/]+/'
|
||||
+ 'radixor-models-(?:standard|bom)-[^/]+\\.pom(?:\\.asc)?(?:\\.(?:md5|sha1))?')
|
||||
}
|
||||
if (!unsupported.isEmpty()) {
|
||||
throw new GradleException("The model catalog bundle contains unsupported files: ${unsupported}.")
|
||||
}
|
||||
if (poms.size() != 2) {
|
||||
throw new GradleException("The model catalog bundle must contain exactly two POM files; found ${poms.size()}.")
|
||||
}
|
||||
if (entries.any { String entry -> entry.endsWith('.jar') || entry.endsWith('/stemmer.gz')
|
||||
|| entry.endsWith('.module') || entry.contains('maven-metadata') || entry.contains('benchmark-pack') }) {
|
||||
throw new GradleException('The model catalog bundle contains forbidden publication content.')
|
||||
}
|
||||
poms.each { String pom -> verifyChecksums(content, pom) }
|
||||
entries.findAll { String entry -> entry.endsWith('.pom.asc') }.each { String signature ->
|
||||
verifyChecksums(content, signature)
|
||||
}
|
||||
|
||||
final String standardPath = expectedPomPath('standard', catalogVersion)
|
||||
final String bomPath = expectedPomPath('bom', catalogVersion)
|
||||
if (!content.containsKey(standardPath) || !content.containsKey(bomPath)) {
|
||||
throw new GradleException('The bundle does not contain the expected standard and BOM coordinates.')
|
||||
}
|
||||
final Element standard = parsePom(content.get(standardPath))
|
||||
final Element bom = parsePom(content.get(bomPath))
|
||||
verifyCoordinates(standard, 'radixor-models-standard', catalogVersion)
|
||||
verifyCoordinates(bom, 'radixor-models-bom', catalogVersion)
|
||||
|
||||
final Map<String, String> standardDependencies = dependencies(standard, false)
|
||||
final Map<String, String> bomConstraints = dependencies(bom, true)
|
||||
final Set<String> expectedDefaults = defaultIds.collect { String id -> "org.egothor:radixor-model-${id}" } as Set<String>
|
||||
final Set<String> expectedAll = allIds.collect { String id -> "org.egothor:radixor-model-${id}" } as Set<String>
|
||||
if (standardDependencies.keySet() != expectedDefaults
|
||||
|| standardDependencies.values().any { String version -> version != modelVersion }
|
||||
|| standardDependencies.containsKey('org.egothor:radixor-model-pl-pl-polimorf')
|
||||
|| dependencyScopes(standard).any { String scope -> scope != 'runtime' }) {
|
||||
throw new GradleException('The standard catalog POM must reference exactly the 20 default model artifacts at the model version.')
|
||||
}
|
||||
if (!dependencies(bom, false).isEmpty()) {
|
||||
throw new GradleException('The model BOM must not introduce runtime dependencies.')
|
||||
}
|
||||
if (bomConstraints.keySet() != expectedAll
|
||||
|| bomConstraints.values().any { String version -> version != modelVersion }) {
|
||||
throw new GradleException('The model BOM must manage exactly all 21 model artifacts at the model version.')
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
private static String expectedPomPath(final String kind, final String version) {
|
||||
return "org/egothor/radixor-models-${kind}/${version}/radixor-models-${kind}-${version}.pom"
|
||||
}
|
||||
|
||||
private static void verifyChecksums(final Map<String, byte[]> content, final String artifact) {
|
||||
['MD5': 'md5', 'SHA-1': 'sha1'].each { String algorithm, String extension ->
|
||||
final String checksum = artifact + '.' + extension
|
||||
if (!content.containsKey(checksum)) {
|
||||
throw new GradleException("The catalog artifact is missing its ${algorithm} checksum: ${artifact}.")
|
||||
}
|
||||
final String expected = MessageDigest.getInstance(algorithm).digest(content.get(artifact)).encodeHex().toString()
|
||||
final String actual = new String(content.get(checksum), StandardCharsets.US_ASCII).trim()
|
||||
if (actual != expected) {
|
||||
throw new GradleException("The ${algorithm} checksum does not match ${artifact}.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Element parsePom(final byte[] xml) {
|
||||
final DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance()
|
||||
factory.setNamespaceAware(true)
|
||||
factory.setFeature('http://apache.org/xml/features/disallow-doctype-decl', true)
|
||||
factory.setFeature('http://xml.org/sax/features/external-general-entities', false)
|
||||
factory.setFeature('http://xml.org/sax/features/external-parameter-entities', false)
|
||||
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, '')
|
||||
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, '')
|
||||
return factory.newDocumentBuilder().parse(new ByteArrayInputStream(xml)).documentElement
|
||||
}
|
||||
|
||||
private static void verifyCoordinates(final Element project, final String artifactId, final String version) {
|
||||
if (directText(project, 'groupId') != 'org.egothor'
|
||||
|| directText(project, 'artifactId') != artifactId
|
||||
|| directText(project, 'version') != version) {
|
||||
throw new GradleException("Unexpected Maven coordinates for ${artifactId}.")
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, String> dependencies(final Element project, final boolean managed) {
|
||||
final Map<String, String> result = new TreeMap<>()
|
||||
final Element parent = managed ? directChild(project, 'dependencyManagement') : project
|
||||
final Element container = parent == null ? null : directChild(parent, 'dependencies')
|
||||
if (container == null) return result
|
||||
childElements(container, 'dependency').each { Element dependency ->
|
||||
final String coordinate = directText(dependency, 'groupId') + ':' + directText(dependency, 'artifactId')
|
||||
if (result.put(coordinate, directText(dependency, 'version')) != null) {
|
||||
throw new GradleException("The catalog POM contains duplicate dependency ${coordinate}.")
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private static List<String> dependencyScopes(final Element project) {
|
||||
final Element container = directChild(project, 'dependencies')
|
||||
if (container == null) return []
|
||||
return childElements(container, 'dependency').collect { Element dependency -> directText(dependency, 'scope') }
|
||||
}
|
||||
|
||||
private static String directText(final Element parent, final String name) {
|
||||
final Element child = directChild(parent, name)
|
||||
return child == null ? null : child.textContent.trim()
|
||||
}
|
||||
|
||||
private static Element directChild(final Element parent, final String name) {
|
||||
if (parent == null) return null
|
||||
for (int index = 0; index < parent.childNodes.length; index++) {
|
||||
if (parent.childNodes.item(index) instanceof Element
|
||||
&& parent.childNodes.item(index).localName == name) return (Element) parent.childNodes.item(index)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private static List<Element> childElements(final Element parent, final String name) {
|
||||
final List<Element> result = []
|
||||
for (int index = 0; index < parent.childNodes.length; index++) {
|
||||
if (parent.childNodes.item(index) instanceof Element
|
||||
&& parent.childNodes.item(index).localName == name) result.add((Element) parent.childNodes.item(index))
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
package org.egothor.radixor
|
||||
|
||||
import org.gradle.api.GradleException
|
||||
import org.gradle.testkit.runner.GradleRunner
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.security.MessageDigest
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue
|
||||
|
||||
/** Exercises catalog publication filtering, isolation, checksums, and semantic verification. */
|
||||
final class ModelCatalogBundleTaskTest {
|
||||
private static final String CATALOG_VERSION = '2026.1'
|
||||
private static final String MODEL_VERSION = '1.0.0'
|
||||
private static final List<String> DEFAULTS = ['alpha', 'beta']
|
||||
private static final List<String> ALL = ['alpha', 'beta', 'pl-pl-polimorf']
|
||||
|
||||
@TempDir Path temporaryDirectory
|
||||
|
||||
/** Prepares exactly two unsigned POMs and their checksums without changing raw bytes. */
|
||||
@Test
|
||||
void preparesUnsignedPublicationsWithoutMutatingRawInput() {
|
||||
final Path raw = fixture(false)
|
||||
final byte[] before = Files.readAllBytes(standardPom(raw))
|
||||
final Path prepared = temporaryDirectory.resolve('prepared')
|
||||
PrepareModelCatalogBundleInputTask.prepareBundle(raw, prepared, CATALOG_VERSION)
|
||||
assertArrayEquals(before, Files.readAllBytes(standardPom(raw)))
|
||||
assertEquals(6L, regularFiles(prepared))
|
||||
assertTrue(Files.isRegularFile(prepared.resolve(relativeStandardPom() + '.md5')))
|
||||
assertTrue(Files.isRegularFile(prepared.resolve(relativeBomPom() + '.sha1')))
|
||||
}
|
||||
|
||||
/** Copies test-only signatures and generates checksums for both signatures. */
|
||||
@Test
|
||||
void preparesSignedPublications() {
|
||||
final Path prepared = temporaryDirectory.resolve('prepared')
|
||||
PrepareModelCatalogBundleInputTask.prepareBundle(fixture(true), prepared, CATALOG_VERSION)
|
||||
assertEquals(12L, regularFiles(prepared))
|
||||
assertTrue(Files.isRegularFile(prepared.resolve(relativeStandardPom() + '.asc.md5')))
|
||||
assertTrue(Files.isRegularFile(prepared.resolve(relativeBomPom() + '.asc.sha1')))
|
||||
}
|
||||
|
||||
/** Deletes stale prepared content before copying current publication files. */
|
||||
@Test
|
||||
void removesStalePreparedContent() {
|
||||
final Path prepared = temporaryDirectory.resolve('prepared')
|
||||
Files.createDirectories(prepared)
|
||||
Files.writeString(prepared.resolve('stale.jar'), 'stale')
|
||||
PrepareModelCatalogBundleInputTask.prepareBundle(fixture(false), prepared, CATALOG_VERSION)
|
||||
assertFalse(Files.exists(prepared.resolve('stale.jar')))
|
||||
}
|
||||
|
||||
/** Excludes Gradle module metadata, its sidecars, and Maven metadata. */
|
||||
@Test
|
||||
void excludesModuleAndMavenMetadata() {
|
||||
final Path raw = fixture(false)
|
||||
final Path module = standardPom(raw).resolveSibling("radixor-models-standard-${CATALOG_VERSION}.module")
|
||||
Files.writeString(module, 'module')
|
||||
Files.writeString(module.resolveSibling(module.fileName.toString() + '.asc'), 'signature')
|
||||
Files.writeString(module.resolveSibling(module.fileName.toString() + '.sha1'), 'checksum')
|
||||
Files.writeString(module.parent.resolve('maven-metadata-local.xml'), 'metadata')
|
||||
final Path prepared = temporaryDirectory.resolve('prepared')
|
||||
PrepareModelCatalogBundleInputTask.prepareBundle(raw, prepared, CATALOG_VERSION)
|
||||
assertEquals(6L, regularFiles(prepared))
|
||||
}
|
||||
|
||||
/** Rejects a missing standard publication. */
|
||||
@Test
|
||||
void rejectsMissingStandardPom() {
|
||||
final Path raw = fixture(false)
|
||||
Files.delete(standardPom(raw))
|
||||
assertThrows(GradleException) {
|
||||
PrepareModelCatalogBundleInputTask.prepareBundle(raw, temporaryDirectory.resolve('prepared'), CATALOG_VERSION)
|
||||
}
|
||||
}
|
||||
|
||||
/** Rejects a missing BOM publication. */
|
||||
@Test
|
||||
void rejectsMissingBomPom() {
|
||||
final Path raw = fixture(false)
|
||||
Files.delete(bomPom(raw))
|
||||
assertThrows(GradleException) {
|
||||
PrepareModelCatalogBundleInputTask.prepareBundle(raw, temporaryDirectory.resolve('prepared'), CATALOG_VERSION)
|
||||
}
|
||||
}
|
||||
|
||||
/** Rejects unexpected binary publication content. */
|
||||
@Test
|
||||
void rejectsUnexpectedJar() {
|
||||
final Path raw = fixture(false)
|
||||
Files.writeString(standardPom(raw).resolveSibling('unexpected.jar'), 'binary')
|
||||
assertThrows(GradleException) {
|
||||
PrepareModelCatalogBundleInputTask.prepareBundle(raw, temporaryDirectory.resolve('prepared'), CATALOG_VERSION)
|
||||
}
|
||||
}
|
||||
|
||||
/** Rejects dictionary content in the catalog staging repository. */
|
||||
@Test
|
||||
void rejectsDictionaryContent() {
|
||||
final Path raw = fixture(false)
|
||||
final Path dictionary = raw.resolve('unrelated/stemmer.gz')
|
||||
Files.createDirectories(dictionary.parent)
|
||||
Files.writeString(dictionary, 'dictionary')
|
||||
assertThrows(GradleException) {
|
||||
PrepareModelCatalogBundleInputTask.prepareBundle(raw, temporaryDirectory.resolve('prepared'), CATALOG_VERSION)
|
||||
}
|
||||
}
|
||||
|
||||
/** Produces and semantically verifies a nonempty ZIP from prepared files. */
|
||||
@Test
|
||||
void verifiesRealPreparedArchive() {
|
||||
final Path prepared = temporaryDirectory.resolve('prepared')
|
||||
PrepareModelCatalogBundleInputTask.prepareBundle(fixture(false), prepared, CATALOG_VERSION)
|
||||
final File archive = zip(prepared, temporaryDirectory.resolve('catalog.zip'))
|
||||
final List<String> entries = VerifyModelCatalogReleaseCandidateTask.verifyBundle(
|
||||
archive, CATALOG_VERSION, MODEL_VERSION, DEFAULTS, ALL)
|
||||
assertEquals(6, entries.size())
|
||||
}
|
||||
|
||||
/** Rejects an archived checksum that does not match its POM. */
|
||||
@Test
|
||||
void rejectsIncorrectArchivedChecksum() {
|
||||
final Path prepared = temporaryDirectory.resolve('prepared')
|
||||
PrepareModelCatalogBundleInputTask.prepareBundle(fixture(false), prepared, CATALOG_VERSION)
|
||||
Files.writeString(prepared.resolve(relativeStandardPom() + '.sha1'), 'incorrect')
|
||||
final File archive = zip(prepared, temporaryDirectory.resolve('catalog.zip'))
|
||||
assertThrows(GradleException) {
|
||||
VerifyModelCatalogReleaseCandidateTask.verifyBundle(
|
||||
archive, CATALOG_VERSION, MODEL_VERSION, DEFAULTS, ALL)
|
||||
}
|
||||
}
|
||||
|
||||
/** Repeated preparation replaces restored or stale output deterministically. */
|
||||
@Test
|
||||
void repeatedPreparationRecreatesValidInput() {
|
||||
final Path raw = fixture(false)
|
||||
final Path prepared = temporaryDirectory.resolve('prepared')
|
||||
PrepareModelCatalogBundleInputTask.prepareBundle(raw, prepared, CATALOG_VERSION)
|
||||
final String first = treeDigest(prepared)
|
||||
Files.writeString(prepared.resolve('restored-history-stale.txt'), 'stale')
|
||||
PrepareModelCatalogBundleInputTask.prepareBundle(raw, prepared, CATALOG_VERSION)
|
||||
assertEquals(first, treeDigest(prepared))
|
||||
}
|
||||
|
||||
/** Creates a real Gradle ZIP, rebuilds a missing output, and reuses Configuration Cache. */
|
||||
@Test
|
||||
void gradleZipRebuildsWithConfigurationCacheReuse() {
|
||||
final Path project = temporaryDirectory.resolve('testkit-project')
|
||||
Files.createDirectories(project)
|
||||
Files.writeString(project.resolve('settings.gradle'), "rootProject.name = 'catalog-fixture'\n")
|
||||
Files.writeString(project.resolve('build.gradle'), '''plugins {
|
||||
id 'org.egothor.radixor.build-support'
|
||||
}
|
||||
tasks.named('prepareModelCatalogReleaseCandidate') {
|
||||
rawRepositoryDirectory = layout.projectDirectory.dir('raw')
|
||||
preparedBundleDirectory = layout.buildDirectory.dir('prepared')
|
||||
catalogVersion = '2026.1'
|
||||
}
|
||||
tasks.register('bundle', Zip) {
|
||||
dependsOn(tasks.named('prepareModelCatalogReleaseCandidate'))
|
||||
from(layout.buildDirectory.dir('prepared'))
|
||||
destinationDirectory = layout.buildDirectory.dir('candidate')
|
||||
archiveFileName = 'catalog.zip'
|
||||
}
|
||||
''')
|
||||
final Path raw = project.resolve('raw')
|
||||
write(standardPom(raw), pom('radixor-models-standard', false))
|
||||
write(bomPom(raw), pom('radixor-models-bom', true))
|
||||
|
||||
final List<String> arguments = ['bundle', '--configuration-cache',
|
||||
'--configuration-cache-problems=fail', '--warning-mode=fail']
|
||||
final String first = GradleRunner.create().withProjectDir(project.toFile())
|
||||
.withPluginClasspath().withArguments(arguments).build().output
|
||||
final Path archive = project.resolve('build/candidate/catalog.zip')
|
||||
assertTrue(Files.size(archive) > 0L)
|
||||
Files.delete(archive)
|
||||
final String second = GradleRunner.create().withProjectDir(project.toFile())
|
||||
.withPluginClasspath().withArguments(arguments).build().output
|
||||
assertTrue(Files.size(archive) > 0L)
|
||||
assertTrue(first.contains('Configuration cache entry stored.'))
|
||||
assertTrue(second.contains('Configuration cache entry reused.'))
|
||||
}
|
||||
|
||||
private Path fixture(final boolean signed) {
|
||||
final Path raw = temporaryDirectory.resolve('raw')
|
||||
write(standardPom(raw), pom('radixor-models-standard', false))
|
||||
write(bomPom(raw), pom('radixor-models-bom', true))
|
||||
if (signed) {
|
||||
Files.writeString(standardPom(raw).resolveSibling(standardPom(raw).fileName.toString() + '.asc'), 'test signature')
|
||||
Files.writeString(bomPom(raw).resolveSibling(bomPom(raw).fileName.toString() + '.asc'), 'test signature')
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
private static String pom(final String artifact, final boolean managed) {
|
||||
final List<String> ids = managed ? ALL : DEFAULTS
|
||||
final String dependencies = ids.collect { String id ->
|
||||
"<dependency><groupId>org.egothor</groupId><artifactId>radixor-model-${id}</artifactId>" +
|
||||
"<version>${MODEL_VERSION}</version>${managed ? '' : '<scope>runtime</scope>'}</dependency>"
|
||||
}.join()
|
||||
final String body = managed ? "<dependencyManagement><dependencies>${dependencies}</dependencies></dependencyManagement>"
|
||||
: "<dependencies>${dependencies}</dependencies>"
|
||||
return "<?xml version=\"1.0\"?><project xmlns=\"http://maven.apache.org/POM/4.0.0\">" +
|
||||
"<modelVersion>4.0.0</modelVersion><groupId>org.egothor</groupId>" +
|
||||
"<artifactId>${artifact}</artifactId><version>${CATALOG_VERSION}</version>${body}</project>"
|
||||
}
|
||||
|
||||
private static Path standardPom(final Path raw) { raw.resolve(relativeStandardPom()) }
|
||||
private static Path bomPom(final Path raw) { raw.resolve(relativeBomPom()) }
|
||||
private static String relativeStandardPom() {
|
||||
"org/egothor/radixor-models-standard/${CATALOG_VERSION}/radixor-models-standard-${CATALOG_VERSION}.pom"
|
||||
}
|
||||
private static String relativeBomPom() {
|
||||
"org/egothor/radixor-models-bom/${CATALOG_VERSION}/radixor-models-bom-${CATALOG_VERSION}.pom"
|
||||
}
|
||||
|
||||
private static void write(final Path path, final String value) {
|
||||
Files.createDirectories(path.parent)
|
||||
Files.writeString(path, value, StandardCharsets.UTF_8)
|
||||
}
|
||||
|
||||
private static long regularFiles(final Path root) {
|
||||
Files.walk(root).withCloseable { paths -> paths.filter(Files::isRegularFile).count() }
|
||||
}
|
||||
|
||||
private static File zip(final Path root, final Path target) {
|
||||
new ZipOutputStream(Files.newOutputStream(target)).withCloseable { ZipOutputStream output ->
|
||||
Files.walk(root).withCloseable { paths ->
|
||||
paths.filter(Files::isRegularFile).sorted().forEach { Path file ->
|
||||
output.putNextEntry(new ZipEntry(root.relativize(file).toString().replace(File.separatorChar, '/' as char)))
|
||||
Files.copy(file, output)
|
||||
output.closeEntry()
|
||||
}
|
||||
}
|
||||
}
|
||||
return target.toFile()
|
||||
}
|
||||
|
||||
private static String treeDigest(final Path root) {
|
||||
final MessageDigest digest = MessageDigest.getInstance('SHA-256')
|
||||
Files.walk(root).withCloseable { paths ->
|
||||
paths.filter(Files::isRegularFile).sorted().forEach { Path path ->
|
||||
digest.update(root.relativize(path).toString().getBytes(StandardCharsets.UTF_8))
|
||||
digest.update(Files.readAllBytes(path))
|
||||
}
|
||||
}
|
||||
return digest.digest().encodeHex().toString()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user