From 1f1b03c6a8d36a0918b92ebde698e5379a2a5946 Mon Sep 17 00:00:00 2001 From: Leo Galambos Date: Thu, 23 Jul 2026 02:11:50 +0200 Subject: [PATCH] fix(build): isolate model catalog bundle preparation --- .../PrepareModelCatalogBundleInputTask.groovy | 113 ++++++++ .../radixor/RadixorBuildSupportPlugin.groovy | 8 + ...ifyModelCatalogReleaseCandidateTask.groovy | 194 +++++++++++++ .../radixor/ModelCatalogBundleTaskTest.groovy | 259 ++++++++++++++++++ build.gradle | 96 ++----- models/bom/build.gradle | 11 +- models/standard/build.gradle | 11 +- 7 files changed, 618 insertions(+), 74 deletions(-) create mode 100644 build-logic/src/main/groovy/org/egothor/radixor/PrepareModelCatalogBundleInputTask.groovy create mode 100644 build-logic/src/main/groovy/org/egothor/radixor/VerifyModelCatalogReleaseCandidateTask.groovy create mode 100644 build-logic/src/test/groovy/org/egothor/radixor/ModelCatalogBundleTaskTest.groovy diff --git a/build-logic/src/main/groovy/org/egothor/radixor/PrepareModelCatalogBundleInputTask.groovy b/build-logic/src/main/groovy/org/egothor/radixor/PrepareModelCatalogBundleInputTask.groovy new file mode 100644 index 0000000..1e6b066 --- /dev/null +++ b/build-logic/src/main/groovy/org/egothor/radixor/PrepareModelCatalogBundleInputTask.groovy @@ -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 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 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 + final List copied = [] + Files.walk(rawRepository).withCloseable { Stream 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 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 paths -> + paths.sorted(Comparator.reverseOrder()).forEach(Files::delete) + } + } +} diff --git a/build-logic/src/main/groovy/org/egothor/radixor/RadixorBuildSupportPlugin.groovy b/build-logic/src/main/groovy/org/egothor/radixor/RadixorBuildSupportPlugin.groovy index 78e1d00..16e5e47 100644 --- a/build-logic/src/main/groovy/org/egothor/radixor/RadixorBuildSupportPlugin.groovy +++ b/build-logic/src/main/groovy/org/egothor/radixor/RadixorBuildSupportPlugin.groovy @@ -12,5 +12,13 @@ final class RadixorBuildSupportPlugin implements Plugin { 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.' + } } } diff --git a/build-logic/src/main/groovy/org/egothor/radixor/VerifyModelCatalogReleaseCandidateTask.groovy b/build-logic/src/main/groovy/org/egothor/radixor/VerifyModelCatalogReleaseCandidateTask.groovy new file mode 100644 index 0000000..f892a65 --- /dev/null +++ b/build-logic/src/main/groovy/org/egothor/radixor/VerifyModelCatalogReleaseCandidateTask.groovy @@ -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 getCatalogVersion() + @Input abstract Property getModelVersion() + @Input abstract ListProperty getDefaultModelIds() + @Input abstract ListProperty getAllModelIds() + + /** Performs byte-level archive and semantic POM validation. */ + @TaskAction + void verify() { + final List 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 verifyBundle(final File bundle, final String catalogVersion, + final String modelVersion, final List defaultIds, final List allIds) { + if (!bundle.isFile() || bundle.length() == 0L) { + throw new GradleException("The model catalog Central bundle is missing or empty: ${bundle}.") + } + final Map 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 entries = content.keySet().toList() + final List poms = entries.findAll { String entry -> entry.endsWith('.pom') } + final List 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 standardDependencies = dependencies(standard, false) + final Map bomConstraints = dependencies(bom, true) + final Set expectedDefaults = defaultIds.collect { String id -> "org.egothor:radixor-model-${id}" } as Set + final Set expectedAll = allIds.collect { String id -> "org.egothor:radixor-model-${id}" } as Set + 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 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 dependencies(final Element project, final boolean managed) { + final Map 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 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 childElements(final Element parent, final String name) { + final List 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 + } +} diff --git a/build-logic/src/test/groovy/org/egothor/radixor/ModelCatalogBundleTaskTest.groovy b/build-logic/src/test/groovy/org/egothor/radixor/ModelCatalogBundleTaskTest.groovy new file mode 100644 index 0000000..cdc6d4c --- /dev/null +++ b/build-logic/src/test/groovy/org/egothor/radixor/ModelCatalogBundleTaskTest.groovy @@ -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 DEFAULTS = ['alpha', 'beta'] + private static final List 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 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 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 ids = managed ? ALL : DEFAULTS + final String dependencies = ids.collect { String id -> + "org.egothorradixor-model-${id}" + + "${MODEL_VERSION}${managed ? '' : 'runtime'}" + }.join() + final String body = managed ? "${dependencies}" + : "${dependencies}" + return "" + + "4.0.0org.egothor" + + "${artifact}${CATALOG_VERSION}${body}" + } + + 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() + } +} diff --git a/build.gradle b/build.gradle index 8c5e15b..a68c369 100644 --- a/build.gradle +++ b/build.gradle @@ -603,12 +603,13 @@ tasks.named('prepareModelConsumerTestRepository') { repositoryDirectory = layout.buildDirectory.dir('model-consumer-repository') } -def cleanModelCatalogStaging = tasks.register('cleanModelCatalogStaging') { +def rawModelCatalogRepository = layout.buildDirectory.dir('model-catalog-staging-repository') +def preparedModelCatalogBundleInput = layout.buildDirectory.dir('model-catalog-bundle-input') + +def cleanModelCatalogStaging = tasks.register('cleanModelCatalogStaging', Delete) { group = 'publishing' description = 'Cleans the isolated model catalog Maven staging repository.' - doLast { - project.delete(layout.buildDirectory.dir('model-catalog-staging-repository')) - } + delete(rawModelCatalogRepository) } gradle.projectsEvaluated { @@ -620,40 +621,28 @@ gradle.projectsEvaluated { } } -tasks.register('prepareModelCatalogReleaseCandidate') { - group = 'publishing' - description = 'Stages the POM-only standard aggregate and model BOM with Central checksums.' +def prepareModelCatalogReleaseCandidate = tasks.named('prepareModelCatalogReleaseCandidate') { dependsOn(project(':models:standard').tasks.named('check')) dependsOn(project(':models:bom').tasks.named('check')) dependsOn(':models:standard:publishStandardPublicationToCatalogStagingRepository') dependsOn(':models:bom:publishBomPublicationToCatalogStagingRepository') - outputs.dir(layout.buildDirectory.dir('model-catalog-staging-repository')) - doLast { - File repository = layout.buildDirectory.dir('model-catalog-staging-repository').get().asFile - repository.eachFileRecurse { File artifact -> - if (artifact.isFile() && !['.md5', '.sha1', '.sha256', '.sha512'].any { - String extension -> artifact.name.endsWith(extension) - }) { - ['MD5': 'md5', 'SHA-1': 'sha1'].each { String algorithm, String extension -> - String digest = java.security.MessageDigest.getInstance(algorithm) - .digest(artifact.bytes).encodeHex().toString() - new File(artifact.absolutePath + ".${extension}").setText(digest, 'US-ASCII') - } - } - } - } + rawRepositoryDirectory = rawModelCatalogRepository + preparedBundleDirectory = preparedModelCatalogBundleInput + catalogVersion = project(':models:standard').version.toString() } -tasks.register('modelCatalogCentralBundle', Zip) { +def modelCatalogCentralBundle = tasks.register('modelCatalogCentralBundle', Zip) { group = 'publishing' description = 'Builds the local POM-only model catalog bundle without remote publication.' - dependsOn(tasks.named('prepareModelCatalogReleaseCandidate')) - from(layout.buildDirectory.dir('model-catalog-staging-repository')) { - exclude('**/maven-metadata*.xml*', '**/*.module*') - } + dependsOn(prepareModelCatalogReleaseCandidate) + from(preparedModelCatalogBundleInput) destinationDirectory = layout.buildDirectory.dir('model-catalog-release-candidate') archiveFileName = "radixor-models-catalog-${project(':models:standard').version}-central-bundle.zip" doFirst { + File preparedInput = preparedModelCatalogBundleInput.get().asFile + if (!preparedInput.isDirectory() || preparedInput.listFiles() == null || preparedInput.listFiles().length == 0) { + throw new GradleException("The prepared model catalog bundle input is missing or empty: ${preparedInput}.") + } if (providers.environmentVariable('GITHUB_REF_TYPE').orNull == 'tag' && (providers.environmentVariable('SIGNING_KEY').orNull?.isBlank() != false || providers.environmentVariable('SIGNING_PASSWORD').orNull?.isBlank() != false)) { @@ -662,45 +651,20 @@ tasks.register('modelCatalogCentralBundle', Zip) { } } -tasks.register('verifyModelCatalogReleaseCandidate') { - group = 'verification' - description = 'Verifies that the local catalog bundle contains only two POM publications, signatures when configured, and checksums.' - dependsOn(tasks.named('modelCatalogCentralBundle')) - outputs.file(layout.buildDirectory.file('reports/models/catalog-release-candidate.txt')) - doLast { - File bundle = tasks.named('modelCatalogCentralBundle', Zip).get().archiveFile.get().asFile - List entries = [] - new java.util.zip.ZipFile(bundle).withCloseable { java.util.zip.ZipFile archive -> - archive.entries().each { java.util.zip.ZipEntry entry -> - if (!entry.directory) entries.add(entry.name) - } - } - entries.sort() - List poms = entries.findAll { String entry -> entry.endsWith('.pom') } - if (poms.size() != 2 || entries.any { String entry -> - entry.endsWith('.jar') || entry.endsWith('/stemmer.gz') || entry.contains('benchmark-pack') - }) { - throw new GradleException('The model catalog bundle must contain only the standard and BOM POM publications.') - } - List unsupported = entries.findAll { String entry -> - !(entry.endsWith('.pom') || entry.endsWith('.pom.md5') || entry.endsWith('.pom.sha1') - || entry.endsWith('.pom.sha256') || entry.endsWith('.pom.sha512') - || entry.endsWith('.pom.asc') || entry.endsWith('.pom.asc.md5') - || entry.endsWith('.pom.asc.sha1') || entry.endsWith('.pom.asc.sha256') - || entry.endsWith('.pom.asc.sha512')) - } - if (!unsupported.isEmpty()) { - throw new GradleException("The model catalog bundle contains unsupported files: ${unsupported}.") - } - poms.each { String pom -> - if (!entries.contains(pom + '.md5') || !entries.contains(pom + '.sha1')) { - throw new GradleException("The model catalog POM is missing Central checksums: ${pom}.") - } - } - File report = layout.buildDirectory.file('reports/models/catalog-release-candidate.txt').get().asFile - report.parentFile.mkdirs() - report.setText("Bundle: ${bundle.name}\nBytes: ${bundle.length()}\n" + entries.join('\n') + '\n', 'UTF-8') - } +def publishedModelVersions = modelProjects().collect { Project modelProject -> + modelProject.file('model-version.txt').getText('UTF-8').trim() +}.toSet() +if (publishedModelVersions.size() != 1) { + throw new GradleException("The catalog verifier requires one common model version; found ${publishedModelVersions}.") +} + +tasks.named('verifyModelCatalogReleaseCandidate') { + bundleFile = modelCatalogCentralBundle.flatMap { Zip archive -> archive.archiveFile } + reportFile = layout.buildDirectory.file('reports/models/catalog-release-candidate.txt') + catalogVersion = project(':models:standard').version.toString() + modelVersion = publishedModelVersions.first() + defaultModelIds = defaultModelProjects().collect { Project modelProject -> modelProject.name } + allModelIds = modelProjects().collect { Project modelProject -> modelProject.name } } tasks.register('verifyArtifactSizes') { diff --git a/models/bom/build.gradle b/models/bom/build.gradle index 9a32782..8353e10 100644 --- a/models/bom/build.gradle +++ b/models/bom/build.gradle @@ -74,23 +74,26 @@ signing { } } +def bomPublicationPom = layout.buildDirectory.file('publications/bom/pom-default.xml') +def expectedBomArtifactIds = modelIds.collect { String modelId -> "radixor-model-${modelId}" } +def bomHasJarTasks = !tasks.withType(Jar).isEmpty() tasks.register('verifyPomOnlyPlatform') { group = 'verification' description = 'Verifies the POM-only model dependency-management platform.' dependsOn(tasks.named('generatePomFileForBomPublication')) + inputs.file(bomPublicationPom) doLast { - File pomFile = layout.buildDirectory.file('publications/bom/pom-default.xml').get().asFile + File pomFile = bomPublicationPom.get().asFile Node pom = new XmlParser().parse(pomFile) List constraints = pom.dependencyManagement.dependencies.dependency as List List artifactIds = constraints.collect { Node dependency -> dependency.artifactId.text() } - List expected = modelIds.collect { String modelId -> "radixor-model-${modelId}" } - if (pom.packaging.text() != 'pom' || artifactIds != expected) { + if (pom.packaging.text() != 'pom' || artifactIds != expectedBomArtifactIds) { throw new GradleException('radixor-models-bom must publish exactly the ordered model constraints as Maven packaging pom.') } if (!pom.dependencies.isEmpty()) { throw new GradleException('radixor-models-bom must not introduce runtime model dependencies.') } - if (!tasks.withType(Jar).isEmpty()) { + if (bomHasJarTasks) { throw new GradleException('radixor-models-bom must not create binary, sources, or Javadoc JARs.') } } diff --git a/models/standard/build.gradle b/models/standard/build.gradle index ba69894..fc441a3 100644 --- a/models/standard/build.gradle +++ b/models/standard/build.gradle @@ -85,28 +85,31 @@ signing { } } +def standardPublicationPom = layout.buildDirectory.file('publications/standard/pom-default.xml') +def expectedStandardArtifactIds = defaultModelIds.collect { String modelId -> "radixor-model-${modelId}" } +def standardHasJarTasks = !tasks.withType(Jar).isEmpty() tasks.register('verifyPomOnlyAggregate') { group = 'verification' description = 'Verifies the standard POM-only aggregate and its runtime model dependencies.' dependsOn(tasks.named('generatePomFileForStandardPublication')) + inputs.file(standardPublicationPom) doLast { - File pomFile = layout.buildDirectory.file('publications/standard/pom-default.xml').get().asFile + File pomFile = standardPublicationPom.get().asFile Node pom = new XmlParser().parse(pomFile) List dependencies = pom.dependencies.dependency as List List artifactIds = dependencies.collect { Node dependency -> dependency.artifactId.text() } - List expected = defaultModelIds.collect { String modelId -> "radixor-model-${modelId}" } if (pom.packaging.text() != 'pom') { throw new GradleException('radixor-models-standard must publish Maven packaging pom.') } - if (artifactIds != expected || dependencies.any { Node dependency -> dependency.scope.text() != 'runtime' }) { + if (artifactIds != expectedStandardArtifactIds || dependencies.any { Node dependency -> dependency.scope.text() != 'runtime' }) { throw new GradleException('radixor-models-standard must contain exactly the ordered default model runtime dependencies.') } if (artifactIds.contains('radixor-model-pl-pl-polimorf')) { throw new GradleException('radixor-models-standard must exclude the optional PoliMorf model.') } - if (!tasks.withType(Jar).isEmpty()) { + if (standardHasJarTasks) { throw new GradleException('radixor-models-standard must not create binary, sources, or Javadoc JARs.') } }