From 1ebe72a4d05f31cd77bab811a7e3283902b9eee6 Mon Sep 17 00:00:00 2001 From: aalsanie Date: Tue, 29 Sep 2026 19:20:34 +0300 Subject: [PATCH 01/22] release: freeze 0.1.0 surface and revision --- CHANGELOG.md | 2 +- build.gradle.kts | 88 +++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12a06b1..1965848 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 0.1.0 ### Policy diff --git a/build.gradle.kts b/build.gradle.kts index 73b35dc..d9fe3c9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -28,7 +28,19 @@ plugins { } group = "io.github.aalsanie" -version = "0.1.0-SNAPSHOT" + +val releaseVersion = "0.1.0" +version = releaseVersion + +val mavenPublicationModules = + setOf( + "bounded-origin-api", + "bounded-origin-core", + "bounded-origin-store-fs", + "bounded-origin-proxy", + ) +val githubReleaseDistributionModules = setOf("bounded-origin-cli") +val internalModules = setOf("bounded-origin-benchmarks", "test-infra") val coverageMinimum = BigDecimal("0.91") val jacocoVersion = libs.versions.jacoco.get() @@ -530,6 +542,78 @@ val verifyNoSecrets = tasks.register("verifyNoSecrets") { } } +val verifyReleasePublicationSurface = tasks.register("verifyReleasePublicationSurface") { + group = "verification" + description = "Verifies that every module has exactly one 0.1.0 release classification." + + doLast { + val classifications = + listOf( + mavenPublicationModules, + githubReleaseDistributionModules, + internalModules, + ) + val duplicateClassifications = + classifications + .flatten() + .groupingBy { it } + .eachCount() + .filterValues { it != 1 } + .keys + if (duplicateClassifications.isNotEmpty()) { + throw GradleException( + "Modules have multiple release classifications: " + + duplicateClassifications.sorted().joinToString() + ) + } + + val actualModules = subprojects.map { it.name }.toSet() + val classifiedModules = classifications.flatten().toSet() + val missing = actualModules - classifiedModules + val unknown = classifiedModules - actualModules + if (missing.isNotEmpty() || unknown.isNotEmpty()) { + throw GradleException( + "Release publication surface does not match repository modules; " + + "unclassified=${missing.sorted()}, unknown=${unknown.sorted()}" + ) + } + } +} + +val verifyReleaseRevision = tasks.register("verifyReleaseRevision") { + group = "verification" + description = "Verifies the source tree is the exact 0.1.0 release revision." + + val changelog = layout.projectDirectory.file("CHANGELOG.md") + inputs.property("releaseVersion", releaseVersion) + inputs.file(changelog) + + doLast { + if (rootProject.version.toString() != releaseVersion || releaseVersion.endsWith("-SNAPSHOT")) { + throw GradleException("Root project must be release version $releaseVersion") + } + + val mismatchedModules = + subprojects + .filter { it.version.toString() != releaseVersion } + .map { "${it.name}=${it.version}" } + if (mismatchedModules.isNotEmpty()) { + throw GradleException( + "All modules must use release version $releaseVersion: " + + mismatchedModules.joinToString() + ) + } + + val headings = changelog.asFile.readLines(Charsets.UTF_8).map(String::trim) + if ("## $releaseVersion" !in headings) { + throw GradleException("CHANGELOG.md must contain a $releaseVersion release heading") + } + if ("## Unreleased" in headings) { + throw GradleException("Release revision must not retain an Unreleased heading") + } + } +} + tasks.named("check") { dependsOn( subprojects.map { it.tasks.named("check") }, @@ -541,6 +625,8 @@ tasks.named("check") { verifyDependencyVerification, verifyNoProductionPlaceholders, verifyNoSecrets, + verifyReleasePublicationSurface, + verifyReleaseRevision, "spotlessCheck", ) } From f75b8582302d9594b728dc433e0b76ead777088e Mon Sep 17 00:00:00 2001 From: aalsanie Date: Tue, 29 Sep 2026 19:20:40 +0300 Subject: [PATCH 02/22] release: configure Maven publications and signing --- build.gradle.kts | 124 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index d9fe3c9..26113e8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -9,7 +9,11 @@ import java.security.MessageDigest import org.gradle.api.GradleException import org.gradle.api.artifacts.dsl.LockMode import org.gradle.api.plugins.JavaPluginExtension +import org.gradle.api.publish.PublishingExtension +import org.gradle.api.publish.maven.MavenPublication +import org.gradle.api.publish.tasks.GenerateModuleMetadata import org.gradle.api.tasks.bundling.AbstractArchiveTask +import org.gradle.api.tasks.bundling.Jar import org.gradle.api.tasks.compile.JavaCompile import org.gradle.api.tasks.testing.Test import org.gradle.api.tasks.PathSensitivity @@ -18,6 +22,7 @@ import org.gradle.jvm.toolchain.JavaLanguageVersion import org.gradle.testing.jacoco.plugins.JacocoPluginExtension import org.gradle.testing.jacoco.tasks.JacocoCoverageVerification import org.gradle.testing.jacoco.tasks.JacocoReport +import org.gradle.plugins.signing.SigningExtension plugins { base @@ -42,6 +47,22 @@ val mavenPublicationModules = val githubReleaseDistributionModules = setOf("bounded-origin-cli") val internalModules = setOf("bounded-origin-benchmarks", "test-infra") +val mavenPublicationNames = + mapOf( + "bounded-origin-api" to "Bounded Origin API", + "bounded-origin-core" to "Bounded Origin Core", + "bounded-origin-store-fs" to "Bounded Origin Filesystem Store", + "bounded-origin-proxy" to "Bounded Origin Proxy", + ) +val mavenPublicationDescriptions = + mapOf( + "bounded-origin-api" to "Framework-independent public contracts for Bounded Origin.", + "bounded-origin-core" to "Core policy and bounded origin-execution engine.", + "bounded-origin-store-fs" to "Filesystem-backed artifact store for Bounded Origin.", + "bounded-origin-proxy" to "Standalone HTTP gateway runtime for Bounded Origin.", + ) +val apacheLicensedPublicationModules = setOf("bounded-origin-api") + val coverageMinimum = BigDecimal("0.91") val jacocoVersion = libs.versions.jacoco.get() val googleJavaFormatVersion = libs.versions.google.java.format.get() @@ -196,6 +217,95 @@ subprojects { withSourcesJar() } + if (name in mavenPublicationModules) { + apply(plugin = "maven-publish") + apply(plugin = "signing") + + javaExtension.withJavadocJar() + + val publication = + extensions + .getByType() + .publications + .create("mavenJava", MavenPublication::class.java) { + from(components.getByName("java")) + artifactId = project.name + + pom { + name.set(mavenPublicationNames.getValue(project.name)) + description.set(mavenPublicationDescriptions.getValue(project.name)) + url.set("https://github.com/aalsanie/bounded-origin") + + licenses { + license { + if (project.name in apacheLicensedPublicationModules) { + name.set("Apache License, Version 2.0") + url.set("https://www.apache.org/licenses/LICENSE-2.0.txt") + } else { + name.set("GNU Affero General Public License v3.0 only") + url.set("https://www.gnu.org/licenses/agpl-3.0.txt") + } + distribution.set("repo") + } + } + + developers { + developer { + id.set("aalsanie") + name.set("Ahmad Al-Sanie") + url.set("https://github.com/aalsanie") + } + } + + scm { + connection.set("scm:git:https://github.com/aalsanie/bounded-origin.git") + developerConnection.set( + "scm:git:ssh://git@github.com/aalsanie/bounded-origin.git" + ) + url.set("https://github.com/aalsanie/bounded-origin") + } + } + } + + tasks.withType().configureEach { + enabled = false + } + + val publicationLicense = + if (name in apacheLicensedPublicationModules) { + layout.projectDirectory.file("LICENSE") + } else { + rootProject.layout.projectDirectory.file("LICENSE") + } + + tasks.withType().configureEach { + from(publicationLicense) { + into("META-INF") + rename { "LICENSE" } + } + from(rootProject.layout.projectDirectory.file("LICENSING.md")) { + into("META-INF") + } + } + + extensions.configure { + val signingKey = providers.gradleProperty("signingKey").orNull + val signingPassword = providers.gradleProperty("signingPassword").orNull + val signingKeyId = providers.gradleProperty("signingKeyId").orNull + + if (!signingKey.isNullOrBlank()) { + if (signingKeyId.isNullOrBlank()) { + useInMemoryPgpKeys(signingKey, signingPassword) + } else { + useInMemoryPgpKeys(signingKeyId, signingKey, signingPassword) + } + } + + setRequired(false) + sign(publication) + } + } + val apiSnapshotLauncher = extensions .getByType() @@ -567,6 +677,20 @@ val verifyReleasePublicationSurface = tasks.register("verifyReleasePublicationSu ) } + val publicationMetadataSets = + listOf( + mavenPublicationNames.keys, + mavenPublicationDescriptions.keys, + ) + if (publicationMetadataSets.any { it != mavenPublicationModules }) { + throw GradleException( + "Maven publication metadata must exactly match the frozen publication surface" + ) + } + if (!apacheLicensedPublicationModules.all { it in mavenPublicationModules }) { + throw GradleException("Publication license classification names an unpublished module") + } + val actualModules = subprojects.map { it.name }.toSet() val classifiedModules = classifications.flatten().toSet() val missing = actualModules - classifiedModules From 8870cb1654b735821494a347a072edfe565b3af0 Mon Sep 17 00:00:00 2001 From: aalsanie Date: Tue, 29 Sep 2026 19:20:46 +0300 Subject: [PATCH 03/22] release: build and verify Central bundle --- build.gradle.kts | 541 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 529 insertions(+), 12 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 26113e8..0e4ab2f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,16 +4,21 @@ import com.github.spotbugs.snom.Effort import com.github.spotbugs.snom.SpotBugsExtension import info.solidsoft.gradle.pitest.PitestPluginExtension import java.io.File +import java.io.InputStream import java.math.BigDecimal import java.security.MessageDigest +import java.util.zip.ZipFile +import javax.xml.parsers.DocumentBuilderFactory import org.gradle.api.GradleException import org.gradle.api.artifacts.dsl.LockMode import org.gradle.api.plugins.JavaPluginExtension import org.gradle.api.publish.PublishingExtension import org.gradle.api.publish.maven.MavenPublication +import org.gradle.api.publish.maven.tasks.GenerateMavenPom import org.gradle.api.publish.tasks.GenerateModuleMetadata import org.gradle.api.tasks.bundling.AbstractArchiveTask import org.gradle.api.tasks.bundling.Jar +import org.gradle.api.tasks.bundling.Zip import org.gradle.api.tasks.compile.JavaCompile import org.gradle.api.tasks.testing.Test import org.gradle.api.tasks.PathSensitivity @@ -22,7 +27,9 @@ import org.gradle.jvm.toolchain.JavaLanguageVersion import org.gradle.testing.jacoco.plugins.JacocoPluginExtension import org.gradle.testing.jacoco.tasks.JacocoCoverageVerification import org.gradle.testing.jacoco.tasks.JacocoReport +import org.gradle.plugins.signing.Sign import org.gradle.plugins.signing.SigningExtension +import org.w3c.dom.Element plugins { base @@ -169,19 +176,202 @@ fun publicApiSnapshot( return header + body.trimEnd() + "\n" } -fun sha256(file: File): String { - val digest = MessageDigest.getInstance("SHA-256") - file.inputStream().use { input -> - val buffer = ByteArray(8192) - while (true) { - val count = input.read(buffer) - if (count < 0) break - digest.update(buffer, 0, count) - } +fun digestHex(input: InputStream, algorithm: String): String { + val digest = MessageDigest.getInstance(algorithm) + val buffer = ByteArray(8192) + while (true) { + val count = input.read(buffer) + if (count < 0) break + digest.update(buffer, 0, count) } return digest.digest().joinToString("") { "%02x".format(it.toInt() and 0xff) } } +fun digestHex(file: File, algorithm: String): String = + file.inputStream().use { digestHex(it, algorithm) } + +fun sha256(file: File): String = digestHex(file, "SHA-256") + +fun parseXml(file: File): Element { + val factory = DocumentBuilderFactory.newInstance() + 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.isExpandEntityReferences = false + factory.isXIncludeAware = false + return factory.newDocumentBuilder().parse(file).documentElement +} + +fun directChildren(parent: Element, name: String): List = + (0 until parent.childNodes.length) + .mapNotNull { parent.childNodes.item(it) as? Element } + .filter { it.tagName == name } + +fun directChild(parent: Element, name: String): Element? = + directChildren(parent, name).singleOrNull() + +fun requiredChildText(parent: Element, name: String, context: String): String = + directChild(parent, name) + ?.textContent + ?.trim() + ?.takeIf(String::isNotEmpty) + ?: throw GradleException("Missing $name in $context") + +fun validateMavenPom( + file: File, + module: String, + version: String, + publicationName: String, + publicationDescription: String, + apacheLicensed: Boolean, + publishedModules: Set, +) { + if (!file.isFile || file.length() == 0L) { + throw GradleException("Missing generated POM for $module: $file") + } + + val root = parseXml(file) + val context = file.path + if (requiredChildText(root, "modelVersion", context) != "4.0.0") { + throw GradleException("Unexpected Maven model version in $context") + } + if (requiredChildText(root, "groupId", context) != "io.github.aalsanie") { + throw GradleException("Unexpected groupId in $context") + } + if (requiredChildText(root, "artifactId", context) != module) { + throw GradleException("Unexpected artifactId in $context") + } + if (requiredChildText(root, "version", context) != version) { + throw GradleException("Unexpected version in $context") + } + if (requiredChildText(root, "name", context) != publicationName) { + throw GradleException("Unexpected project name in $context") + } + if (requiredChildText(root, "description", context) != publicationDescription) { + throw GradleException("Unexpected project description in $context") + } + if (requiredChildText(root, "url", context) != "https://github.com/aalsanie/bounded-origin") { + throw GradleException("Unexpected project URL in $context") + } + + val licenses = directChild(root, "licenses") + ?: throw GradleException("Missing licenses in $context") + val license = directChildren(licenses, "license").singleOrNull() + ?: throw GradleException("Expected exactly one license in $context") + val expectedLicenseName = + if (apacheLicensed) "Apache License, Version 2.0" + else "GNU Affero General Public License v3.0 only" + val expectedLicenseUrl = + if (apacheLicensed) "https://www.apache.org/licenses/LICENSE-2.0.txt" + else "https://www.gnu.org/licenses/agpl-3.0.txt" + if (requiredChildText(license, "name", context) != expectedLicenseName || + requiredChildText(license, "url", context) != expectedLicenseUrl || + requiredChildText(license, "distribution", context) != "repo" + ) { + throw GradleException("Unexpected license metadata in $context") + } + + val developers = directChild(root, "developers") + ?: throw GradleException("Missing developers in $context") + val developer = directChildren(developers, "developer").singleOrNull() + ?: throw GradleException("Expected exactly one developer in $context") + if (requiredChildText(developer, "id", context) != "aalsanie" || + requiredChildText(developer, "name", context) != "Ahmad Al-Sanie" || + requiredChildText(developer, "url", context) != "https://github.com/aalsanie" + ) { + throw GradleException("Unexpected developer metadata in $context") + } + + val scm = directChild(root, "scm") + ?: throw GradleException("Missing SCM metadata in $context") + if (requiredChildText(scm, "connection", context) != + "scm:git:https://github.com/aalsanie/bounded-origin.git" || + requiredChildText(scm, "developerConnection", context) != + "scm:git:ssh://git@github.com/aalsanie/bounded-origin.git" || + requiredChildText(scm, "url", context) != "https://github.com/aalsanie/bounded-origin" + ) { + throw GradleException("Unexpected SCM metadata in $context") + } + + directChild(root, "dependencies") + ?.let { dependencies -> + directChildren(dependencies, "dependency").forEach { dependency -> + val dependencyGroup = requiredChildText(dependency, "groupId", context) + val dependencyArtifact = requiredChildText(dependency, "artifactId", context) + val dependencyVersion = requiredChildText(dependency, "version", context) + val lowerVersion = dependencyVersion.lowercase() + if (dependencyVersion.contains("+") || + dependencyVersion.startsWith("[") || + dependencyVersion.startsWith("(") || + "snapshot" in lowerVersion || + lowerVersion.startsWith("latest.") + ) { + throw GradleException( + "Dynamic or snapshot dependency $dependencyGroup:$dependencyArtifact:$dependencyVersion in $context" + ) + } + if (dependencyGroup == "io.github.aalsanie" && + (dependencyArtifact !in publishedModules || dependencyVersion != version) + ) { + throw GradleException( + "POM exposes unpublished or mismatched project dependency " + + "$dependencyGroup:$dependencyArtifact:$dependencyVersion in $context" + ) + } + } + } + + if ("published-with-gradle-metadata" in file.readText(Charsets.UTF_8)) { + throw GradleException( + "POM advertises Gradle Module Metadata that is not in the Central bundle: $context" + ) + } +} + +fun requireArchiveEntry( + file: File, + description: String, + predicate: (String) -> Boolean, +) { + if (!file.isFile || file.length() == 0L) { + throw GradleException("Missing $description archive: $file") + } + ZipFile(file).use { zip -> + val entries = zip.entries() + while (entries.hasMoreElements()) { + val entry = entries.nextElement() + if (!entry.isDirectory && predicate(entry.name)) { + return + } + } + } + throw GradleException("$description archive has no expected content: $file") +} + +fun validatePublicationArchives( + mainJar: File, + sourcesJar: File, + javadocJar: File, +) { + requireArchiveEntry(mainJar, "main") { it.endsWith(".class") } + requireArchiveEntry(sourcesJar, "sources") { it.endsWith(".java") } + requireArchiveEntry(javadocJar, "Javadoc") { it == "index.html" } + + listOf(mainJar, sourcesJar, javadocJar).forEach { archive -> + requireArchiveEntry(archive, archive.name) { it == "META-INF/LICENSE" } + } +} + +val centralStagingDirectory = layout.buildDirectory.dir("central-staging") + +val cleanCentralStaging = tasks.register("cleanCentralStaging") { + group = "release" + description = "Removes staged Maven Central bundle contents." + doLast { + delete(centralStagingDirectory.get().asFile) + } +} + configure { format("rootMisc") { target( @@ -283,9 +473,6 @@ subprojects { into("META-INF") rename { "LICENSE" } } - from(rootProject.layout.projectDirectory.file("LICENSING.md")) { - into("META-INF") - } } extensions.configure { @@ -304,6 +491,126 @@ subprojects { setRequired(false) sign(publication) } + + val mainJar = tasks.named("jar") + val sourcesJar = tasks.named("sourcesJar") + val javadocJar = tasks.named("javadocJar") + val generatedPom = + tasks.named("generatePomFileForMavenJavaPublication") + val signPublication = tasks.named("signMavenJavaPublication") + + val verifyMavenPublication = tasks.register("verifyMavenPublication") { + group = "verification" + description = "Verifies Maven Central metadata and unsigned publication artifacts." + dependsOn(mainJar, sourcesJar, javadocJar, generatedPom) + + doLast { + validatePublicationArchives( + mainJar.get().archiveFile.get().asFile, + sourcesJar.get().archiveFile.get().asFile, + javadocJar.get().archiveFile.get().asFile, + ) + validateMavenPom( + generatedPom.get().destination, + project.name, + releaseVersion, + mavenPublicationNames.getValue(project.name), + mavenPublicationDescriptions.getValue(project.name), + project.name in apacheLicensedPublicationModules, + mavenPublicationModules, + ) + } + } + + val verifyCentralSigningCredentials = tasks.register("verifyCentralSigningCredentials") { + group = "release" + description = "Requires an ASCII-armored private OpenPGP key for Central publication." + doLast { + val signingKey = providers.gradleProperty("signingKey").orNull + if (signingKey.isNullOrBlank() || + !signingKey.contains("-----BEGIN PGP PRIVATE KEY BLOCK-----") + ) { + throw GradleException( + "Central bundle signing requires ORG_GRADLE_PROJECT_signingKey " + + "with an ASCII-armored OpenPGP private key" + ) + } + } + } + + tasks.register("stageCentralPublication") { + group = "release" + description = "Stages the signed $name publication in Maven Repository Layout." + dependsOn( + cleanCentralStaging, + verifyMavenPublication, + verifyCentralSigningCredentials, + signPublication, + ) + + val modulePath = + rootProject.group.toString().replace('.', '/') + + "/$name/$releaseVersion" + val destinationDirectory = + rootProject.layout.buildDirectory.dir("central-staging/$modulePath") + outputs.dir(destinationDirectory) + + doLast { + val destination = destinationDirectory.get().asFile + delete(destination) + if (!destination.mkdirs() && !destination.isDirectory) { + throw GradleException("Could not create Central staging directory: $destination") + } + + val sourceFiles = + linkedMapOf( + "$name-$releaseVersion.jar" to mainJar.get().archiveFile.get().asFile, + "$name-$releaseVersion-sources.jar" to + sourcesJar.get().archiveFile.get().asFile, + "$name-$releaseVersion-javadoc.jar" to + javadocJar.get().archiveFile.get().asFile, + "$name-$releaseVersion.pom" to generatedPom.get().destination, + ) + + sourceFiles.forEach { (targetName, source) -> + if (!source.isFile || source.length() == 0L) { + throw GradleException("Missing Central publication input: $source") + } + source.copyTo(File(destination, targetName), overwrite = true) + + val signature = File(source.parentFile, source.name + ".asc") + if (!signature.isFile || signature.length() == 0L) { + throw GradleException("Missing OpenPGP signature for $source") + } + signature.copyTo( + File(destination, "$targetName.asc"), + overwrite = true, + ) + } + + val checksums = + linkedMapOf( + "md5" to "MD5", + "sha1" to "SHA-1", + "sha256" to "SHA-256", + "sha512" to "SHA-512", + ) + sourceFiles.keys.forEach { targetName -> + val target = File(destination, targetName) + checksums.forEach { (extension, algorithm) -> + File(destination, "$targetName.$extension") + .writeText( + digestHex(target, algorithm) + "\n", + Charsets.US_ASCII, + ) + } + } + } + } + + tasks.named("check") { + dependsOn(verifyMavenPublication) + } } val apiSnapshotLauncher = @@ -495,6 +802,216 @@ subprojects { } } +val stageCentralPublications = tasks.register("stageCentralPublications") { + group = "release" + description = "Stages every signed 0.1.0 Maven publication for Central." + dependsOn( + mavenPublicationModules + .sorted() + .map { ":$it:stageCentralPublication" } + ) +} + +fun expectedCentralFiles( + modules: Set, + groupId: String, + version: String, +): Set { + val groupPath = groupId.replace('.', '/') + val checksumExtensions = listOf("md5", "sha1", "sha256", "sha512") + return buildSet { + modules.sorted().forEach { module -> + val prefix = "$groupPath/$module/$version/$module-$version" + val primaries = + listOf( + "$prefix.jar", + "$prefix-sources.jar", + "$prefix-javadoc.jar", + "$prefix.pom", + ) + primaries.forEach { primary -> + add(primary) + add("$primary.asc") + checksumExtensions.forEach { extension -> + add("$primary.$extension") + } + } + } + } +} + +val verifyCentralStaging = tasks.register("verifyCentralStaging") { + group = "verification" + description = "Verifies the exact signed Maven Repository Layout accepted by Central." + dependsOn(stageCentralPublications) + inputs.dir(centralStagingDirectory) + + doLast { + val staging = centralStagingDirectory.get().asFile + val expected = + expectedCentralFiles( + mavenPublicationModules, + project.group.toString(), + releaseVersion, + ) + val files = + staging + .walkTopDown() + .filter(File::isFile) + .toList() + + val symbolicLinks = + files.filter { java.nio.file.Files.isSymbolicLink(it.toPath()) } + if (symbolicLinks.isNotEmpty()) { + throw GradleException( + "Central staging contains symbolic links: " + + symbolicLinks.joinToString { it.relativeTo(staging).path } + ) + } + + val actual = + files + .map { it.relativeTo(staging).invariantSeparatorsPath } + .toSet() + if (actual != expected) { + throw GradleException( + "Central staging file set mismatch; " + + "missing=${(expected - actual).sorted()}, " + + "unexpected=${(actual - expected).sorted()}" + ) + } + + val checksumAlgorithms = + linkedMapOf( + "md5" to "MD5", + "sha1" to "SHA-1", + "sha256" to "SHA-256", + "sha512" to "SHA-512", + ) + val groupPath = project.group.toString().replace('.', '/') + + mavenPublicationModules.sorted().forEach { module -> + val directory = File(staging, "$groupPath/$module/$releaseVersion") + val base = "$module-$releaseVersion" + val primaryNames = + listOf( + "$base.jar", + "$base-sources.jar", + "$base-javadoc.jar", + "$base.pom", + ) + + primaryNames.forEach { primaryName -> + val primary = File(directory, primaryName) + if (primary.length() == 0L) { + throw GradleException("Empty Central publication file: $primary") + } + + val signature = File(directory, "$primaryName.asc") + val signatureText = signature.readText(Charsets.US_ASCII) + if (!signatureText.contains("-----BEGIN PGP SIGNATURE-----") || + !signatureText.contains("-----END PGP SIGNATURE-----") + ) { + throw GradleException("Malformed armored OpenPGP signature: $signature") + } + + checksumAlgorithms.forEach { (extension, algorithm) -> + val checksumFile = File(directory, "$primaryName.$extension") + val expectedChecksum = digestHex(primary, algorithm) + val actualChecksum = checksumFile.readText(Charsets.US_ASCII).trim() + if (actualChecksum != expectedChecksum) { + throw GradleException( + "Checksum mismatch for $primaryName.$extension" + ) + } + } + } + + validatePublicationArchives( + File(directory, "$base.jar"), + File(directory, "$base-sources.jar"), + File(directory, "$base-javadoc.jar"), + ) + validateMavenPom( + File(directory, "$base.pom"), + module, + releaseVersion, + mavenPublicationNames.getValue(module), + mavenPublicationDescriptions.getValue(module), + module in apacheLicensedPublicationModules, + mavenPublicationModules, + ) + } + } +} + +val centralBundleZip = tasks.register("centralBundleZip") { + group = "release" + description = "Creates the verified Maven Central upload bundle." + dependsOn(verifyCentralStaging) + archiveFileName.set("bounded-origin-$releaseVersion-central-bundle.zip") + destinationDirectory.set(layout.buildDirectory.dir("distributions")) + from(centralStagingDirectory) + includeEmptyDirs = false + isPreserveFileTimestamps = false + isReproducibleFileOrder = true +} + +val verifyCentralBundle = tasks.register("verifyCentralBundle") { + group = "verification" + description = "Verifies the Central ZIP contains exactly the staged Maven files." + dependsOn(centralBundleZip) + + val archive = centralBundleZip.flatMap { it.archiveFile } + inputs.file(archive) + inputs.dir(centralStagingDirectory) + + doLast { + val staging = centralStagingDirectory.get().asFile + val expected = + expectedCentralFiles( + mavenPublicationModules, + project.group.toString(), + releaseVersion, + ) + + ZipFile(archive.get().asFile).use { zip -> + val entries = zip.entries() + val actual = linkedSetOf() + while (entries.hasMoreElements()) { + val entry = entries.nextElement() + if (!entry.isDirectory) { + actual += entry.name + } + } + if (actual != expected) { + throw GradleException( + "Central bundle ZIP file set mismatch; " + + "missing=${(expected - actual).sorted()}, " + + "unexpected=${(actual - expected).sorted()}" + ) + } + + expected.forEach { path -> + val entry = zip.getEntry(path) + ?: throw GradleException("Missing ZIP entry $path") + val archivedHash = + zip.getInputStream(entry).use { digestHex(it, "SHA-256") } + val stagedHash = sha256(File(staging, path)) + if (archivedHash != stagedHash) { + throw GradleException("Central ZIP content differs from staging for $path") + } + } + } + } +} + +tasks.register("centralBundle") { + group = "release" + description = "Builds and verifies the signed Maven Central 0.1.0 upload bundle." + dependsOn(verifyCentralBundle) +} + val everyCleanTask = allprojects.map { project -> project.tasks.named("clean") From 39c0785f8af512dfbbcc5e2b4fc547e59822fa7b Mon Sep 17 00:00:00 2001 From: aalsanie Date: Tue, 29 Sep 2026 19:20:51 +0300 Subject: [PATCH 04/22] release: verify Central bundle with ephemeral signing --- .github/workflows/release-verification.yml | 66 ++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 .github/workflows/release-verification.yml diff --git a/.github/workflows/release-verification.yml b/.github/workflows/release-verification.yml new file mode 100644 index 0000000..df00ec9 --- /dev/null +++ b/.github/workflows/release-verification.yml @@ -0,0 +1,66 @@ +name: Release Verification + +on: + push: + branches: [release/0.1.0] + pull_request: + branches: [main] + paths: + - build.gradle.kts + - .github/workflows/release-verification.yml + +permissions: + contents: read + +concurrency: + group: release-verification-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + central-bundle: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + + - name: Set up Java + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '21' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + with: + cache-provider: basic + + - name: Build and verify Central bundle with ephemeral signing key + shell: bash + run: | + set -euo pipefail + export GNUPGHOME="$RUNNER_TEMP/bounded-origin-release-gpg" + install -d -m 700 "$GNUPGHOME" + + cat > "$RUNNER_TEMP/bounded-origin-release-key.conf" <<'EOF' + Key-Type: RSA + Key-Length: 2048 + Subkey-Type: RSA + Subkey-Length: 2048 + Name-Real: Bounded Origin CI + Name-Email: ci@bounded-origin.invalid + Expire-Date: 0 + %no-protection + %commit + EOF + + gpg --batch --generate-key "$RUNNER_TEMP/bounded-origin-release-key.conf" + export ORG_GRADLE_PROJECT_signingKey + ORG_GRADLE_PROJECT_signingKey="$( + gpg --batch --armor --export-secret-keys ci@bounded-origin.invalid + )" + + ./gradlew centralBundle --stacktrace + + test -s build/distributions/bounded-origin-0.1.0-central-bundle.zip From a6bec6a0830da3682a4289eeb468a8419b912cb9 Mon Sep 17 00:00:00 2001 From: aalsanie Date: Tue, 29 Sep 2026 19:20:57 +0300 Subject: [PATCH 05/22] release: harden Central staging task configuration --- build.gradle.kts | 38 +++++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 0e4ab2f..e6b35d3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -44,6 +44,8 @@ group = "io.github.aalsanie" val releaseVersion = "0.1.0" version = releaseVersion +val releaseGroupId = group.toString() + val mavenPublicationModules = setOf( "bounded-origin-api", @@ -408,6 +410,8 @@ subprojects { } if (name in mavenPublicationModules) { + val moduleName = name + apply(plugin = "maven-publish") apply(plugin = "signing") @@ -419,16 +423,16 @@ subprojects { .publications .create("mavenJava", MavenPublication::class.java) { from(components.getByName("java")) - artifactId = project.name + artifactId = moduleName pom { - name.set(mavenPublicationNames.getValue(project.name)) - description.set(mavenPublicationDescriptions.getValue(project.name)) + name.set(mavenPublicationNames.getValue(moduleName)) + description.set(mavenPublicationDescriptions.getValue(moduleName)) url.set("https://github.com/aalsanie/bounded-origin") licenses { license { - if (project.name in apacheLicensedPublicationModules) { + if (moduleName in apacheLicensedPublicationModules) { name.set("Apache License, Version 2.0") url.set("https://www.apache.org/licenses/LICENSE-2.0.txt") } else { @@ -514,9 +518,9 @@ subprojects { generatedPom.get().destination, project.name, releaseVersion, - mavenPublicationNames.getValue(project.name), - mavenPublicationDescriptions.getValue(project.name), - project.name in apacheLicensedPublicationModules, + mavenPublicationNames.getValue(moduleName), + mavenPublicationDescriptions.getValue(moduleName), + moduleName in apacheLicensedPublicationModules, mavenPublicationModules, ) } @@ -540,7 +544,7 @@ subprojects { tasks.register("stageCentralPublication") { group = "release" - description = "Stages the signed $name publication in Maven Repository Layout." + description = "Stages the signed $moduleName publication in Maven Repository Layout." dependsOn( cleanCentralStaging, verifyMavenPublication, @@ -549,8 +553,8 @@ subprojects { ) val modulePath = - rootProject.group.toString().replace('.', '/') + - "/$name/$releaseVersion" + releaseGroupId.replace('.', '/') + + "/$moduleName/$releaseVersion" val destinationDirectory = rootProject.layout.buildDirectory.dir("central-staging/$modulePath") outputs.dir(destinationDirectory) @@ -564,12 +568,12 @@ subprojects { val sourceFiles = linkedMapOf( - "$name-$releaseVersion.jar" to mainJar.get().archiveFile.get().asFile, - "$name-$releaseVersion-sources.jar" to + "$moduleName-$releaseVersion.jar" to mainJar.get().archiveFile.get().asFile, + "$moduleName-$releaseVersion-sources.jar" to sourcesJar.get().archiveFile.get().asFile, - "$name-$releaseVersion-javadoc.jar" to + "$moduleName-$releaseVersion-javadoc.jar" to javadocJar.get().archiveFile.get().asFile, - "$name-$releaseVersion.pom" to generatedPom.get().destination, + "$moduleName-$releaseVersion.pom" to generatedPom.get().destination, ) sourceFiles.forEach { (targetName, source) -> @@ -851,7 +855,7 @@ val verifyCentralStaging = tasks.register("verifyCentralStaging") { val expected = expectedCentralFiles( mavenPublicationModules, - project.group.toString(), + releaseGroupId, releaseVersion, ) val files = @@ -888,7 +892,7 @@ val verifyCentralStaging = tasks.register("verifyCentralStaging") { "sha256" to "SHA-256", "sha512" to "SHA-512", ) - val groupPath = project.group.toString().replace('.', '/') + val groupPath = releaseGroupId.replace('.', '/') mavenPublicationModules.sorted().forEach { module -> val directory = File(staging, "$groupPath/$module/$releaseVersion") @@ -971,7 +975,7 @@ val verifyCentralBundle = tasks.register("verifyCentralBundle") { val expected = expectedCentralFiles( mavenPublicationModules, - project.group.toString(), + releaseGroupId, releaseVersion, ) From c5510654c95e48581d78761581cff68dfc191b44 Mon Sep 17 00:00:00 2001 From: aalsanie Date: Tue, 29 Sep 2026 19:21:17 +0300 Subject: [PATCH 06/22] release: exercise signing with protected ephemeral key --- .github/workflows/release-verification.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-verification.yml b/.github/workflows/release-verification.yml index df00ec9..f30d2d5 100644 --- a/.github/workflows/release-verification.yml +++ b/.github/workflows/release-verification.yml @@ -51,15 +51,18 @@ jobs: Name-Real: Bounded Origin CI Name-Email: ci@bounded-origin.invalid Expire-Date: 0 - %no-protection + Passphrase: bounded-origin-ci-only %commit EOF gpg --batch --generate-key "$RUNNER_TEMP/bounded-origin-release-key.conf" export ORG_GRADLE_PROJECT_signingKey ORG_GRADLE_PROJECT_signingKey="$( - gpg --batch --armor --export-secret-keys ci@bounded-origin.invalid + gpg --batch --pinentry-mode loopback \ + --passphrase bounded-origin-ci-only \ + --armor --export-secret-keys ci@bounded-origin.invalid )" + export ORG_GRADLE_PROJECT_signingPassword=bounded-origin-ci-only ./gradlew centralBundle --stacktrace From df8d9278b1013f73640d4b17b08ba3667408cca0 Mon Sep 17 00:00:00 2001 From: aalsanie Date: Tue, 29 Sep 2026 19:21:23 +0300 Subject: [PATCH 07/22] release: require Central publication signatures --- build.gradle.kts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index e6b35d3..b9a436e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -480,9 +480,9 @@ subprojects { } extensions.configure { - val signingKey = providers.gradleProperty("signingKey").orNull - val signingPassword = providers.gradleProperty("signingPassword").orNull - val signingKeyId = providers.gradleProperty("signingKeyId").orNull + val signingKey = project.findProperty("signingKey") as String? + val signingPassword = project.findProperty("signingPassword") as String? + val signingKeyId = project.findProperty("signingKeyId") as String? if (!signingKey.isNullOrBlank()) { if (signingKeyId.isNullOrBlank()) { @@ -492,7 +492,7 @@ subprojects { } } - setRequired(false) + setRequired(true) sign(publication) } From d7b7a7726e4e230ece77e53f16402c89ac46bad5 Mon Sep 17 00:00:00 2001 From: aalsanie Date: Tue, 29 Sep 2026 19:21:29 +0300 Subject: [PATCH 08/22] release: remove execution-time project access --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index b9a436e..512087b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -516,7 +516,7 @@ subprojects { ) validateMavenPom( generatedPom.get().destination, - project.name, + moduleName, releaseVersion, mavenPublicationNames.getValue(moduleName), mavenPublicationDescriptions.getValue(moduleName), From e5bce4c715035d0822a0f5a9d7b66989dbae101d Mon Sep 17 00:00:00 2001 From: aalsanie Date: Tue, 29 Sep 2026 19:21:34 +0300 Subject: [PATCH 09/22] release: verify Central signatures end to end --- .github/workflows/release-verification.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/release-verification.yml b/.github/workflows/release-verification.yml index f30d2d5..fbe35b0 100644 --- a/.github/workflows/release-verification.yml +++ b/.github/workflows/release-verification.yml @@ -43,6 +43,11 @@ jobs: export GNUPGHOME="$RUNNER_TEMP/bounded-origin-release-gpg" install -d -m 700 "$GNUPGHOME" + if ./gradlew :bounded-origin-api:verifyCentralSigningCredentials --stacktrace; then + echo "Central signing credential verification unexpectedly passed without a key" >&2 + exit 1 + fi + cat > "$RUNNER_TEMP/bounded-origin-release-key.conf" <<'EOF' Key-Type: RSA Key-Length: 2048 @@ -67,3 +72,11 @@ jobs: ./gradlew centralBundle --stacktrace test -s build/distributions/bounded-origin-0.1.0-central-bundle.zip + + signature_count=0 + while IFS= read -r -d '' signature; do + gpg --batch --verify "$signature" "${signature%.asc}" + signature_count=$((signature_count + 1)) + done < <(find build/central-staging -type f -name '*.asc' -print0) + + test "$signature_count" -eq 16 From b90d35b24d06c16b794a34f62a72258b4b981360 Mon Sep 17 00:00:00 2001 From: aalsanie Date: Tue, 29 Sep 2026 19:21:39 +0300 Subject: [PATCH 10/22] release: package CLI artifacts and bind release identity --- bounded-origin-cli/build.gradle.kts | 210 +++++++++++++++++++++++++++- build.gradle.kts | 13 ++ 2 files changed, 222 insertions(+), 1 deletion(-) diff --git a/bounded-origin-cli/build.gradle.kts b/bounded-origin-cli/build.gradle.kts index 04126aa..26614fa 100644 --- a/bounded-origin-cli/build.gradle.kts +++ b/bounded-origin-cli/build.gradle.kts @@ -1,4 +1,6 @@ import info.solidsoft.gradle.pitest.PitestPluginExtension +import java.io.File +import java.security.MessageDigest import org.gradle.api.tasks.Sync import org.gradle.api.tasks.bundling.Tar import org.gradle.api.tasks.bundling.Zip @@ -24,6 +26,212 @@ application { applicationName = "bounded-origin" } +val releaseVersion = project.version.toString() +val releaseTag = "v$releaseVersion" +val releaseArchiveBaseName = "bounded-origin-$releaseVersion" +val releaseSourceNotice = + layout.buildDirectory.file("generated/release/SOURCE.txt") + +val generateReleaseSourceNotice = tasks.register("generateReleaseSourceNotice") { + group = "release" + description = "Generates source provenance bundled with the CLI release." + + outputs.file(releaseSourceNotice) + + doLast { + releaseSourceNotice.get().asFile.apply { + parentFile.mkdirs() + writeText( + """ + Bounded Origin $releaseVersion + Source repository: https://github.com/aalsanie/bounded-origin + Release tag: $releaseTag + Corresponding source: https://github.com/aalsanie/bounded-origin/tree/$releaseTag + """.trimIndent() + "\n", + Charsets.UTF_8, + ) + } + } +} + +distributions { + main { + distributionBaseName = "bounded-origin" + contents { + from(rootProject.layout.projectDirectory.file("LICENSE")) + from(rootProject.layout.projectDirectory.file("LICENSING.md")) + from(rootProject.layout.projectDirectory.file("CHANGELOG.md")) + from(rootProject.layout.projectDirectory.file("LICENSES/AGPL-3.0-only.txt")) { + into("LICENSES") + } + from(rootProject.layout.projectDirectory.file("LICENSES/Apache-2.0.txt")) { + into("LICENSES") + } + from(releaseSourceNotice) + } + } +} + +fun cliSha256(file: File): String { + val digest = MessageDigest.getInstance("SHA-256") + file.inputStream().use { input -> + val buffer = ByteArray(8192) + while (true) { + val count = input.read(buffer) + if (count < 0) break + digest.update(buffer, 0, count) + } + } + return digest.digest().joinToString("") { "%02x".format(it.toInt() and 0xff) } +} + +fun archivePaths(tree: FileTree): Set { + val paths = linkedSetOf() + tree.visit { + if (!isDirectory) { + paths += relativePath.pathString + } + } + return paths +} + +val releaseZip = tasks.named("distZip") { + dependsOn(generateReleaseSourceNotice) +} + +val releaseTar = tasks.named("distTar") { + dependsOn(generateReleaseSourceNotice) +} + +val verifyCliReleaseArchives = tasks.register("verifyCliReleaseArchives") { + group = "verification" + description = "Verifies the exact 0.1.0 CLI release archive names and required contents." + dependsOn(releaseZip, releaseTar) + + doLast { + val expectedZip = "$releaseArchiveBaseName.zip" + val expectedTar = "$releaseArchiveBaseName.tar" + if (releaseZip.get().archiveFileName.get() != expectedZip || + releaseTar.get().archiveFileName.get() != expectedTar + ) { + throw GradleException( + "Unexpected CLI archive names: " + + "${releaseZip.get().archiveFileName.get()}, " + + releaseTar.get().archiveFileName.get() + ) + } + + val requiredPaths = + setOf( + "$releaseArchiveBaseName/LICENSE", + "$releaseArchiveBaseName/LICENSING.md", + "$releaseArchiveBaseName/CHANGELOG.md", + "$releaseArchiveBaseName/SOURCE.txt", + "$releaseArchiveBaseName/LICENSES/AGPL-3.0-only.txt", + "$releaseArchiveBaseName/LICENSES/Apache-2.0.txt", + "$releaseArchiveBaseName/bin/bounded-origin", + "$releaseArchiveBaseName/bin/bounded-origin.bat", + ) + + val archives = + listOf( + expectedZip to + archivePaths(zipTree(releaseZip.get().archiveFile.get().asFile)), + expectedTar to + archivePaths(tarTree(releaseTar.get().archiveFile.get().asFile)), + ) + archives.forEach { (name, paths) -> + val missing = requiredPaths - paths + if (missing.isNotEmpty()) { + throw GradleException("$name is missing required files: ${missing.sorted()}") + } + if (paths.none { + it.startsWith("$releaseArchiveBaseName/lib/") && + it.endsWith(".jar") + } + ) { + throw GradleException("$name contains no runtime JARs") + } + if (paths.any { !it.startsWith("$releaseArchiveBaseName/") }) { + throw GradleException("$name contains files outside $releaseArchiveBaseName/") + } + if (paths.any { + ".codex-context" in it || + "/.git/" in it || + "/build/" in it + } + ) { + throw GradleException("$name contains private or build-time files") + } + } + + val sourceNotice = + """ + Bounded Origin $releaseVersion + Source repository: https://github.com/aalsanie/bounded-origin + Release tag: $releaseTag + Corresponding source: https://github.com/aalsanie/bounded-origin/tree/$releaseTag + """.trimIndent() + "\n" + val generatedNotice = releaseSourceNotice.get().asFile.readText(Charsets.UTF_8) + if (generatedNotice != sourceNotice) { + throw GradleException("Generated SOURCE.txt does not match release identity") + } + } +} + +val cliReleaseChecksums = + layout.buildDirectory.file("distributions/SHA256SUMS") + +val generateCliReleaseChecksums = tasks.register("generateCliReleaseChecksums") { + group = "release" + description = "Generates SHA-256 checksums for the CLI release archives." + dependsOn(verifyCliReleaseArchives) + outputs.file(cliReleaseChecksums) + + doLast { + val archives = + listOf( + releaseTar.get().archiveFile.get().asFile, + releaseZip.get().archiveFile.get().asFile, + ).sortedBy(File::getName) + + cliReleaseChecksums.get().asFile.writeText( + archives.joinToString(separator = "\n", postfix = "\n") { archive -> + "${cliSha256(archive)} ${archive.name}" + }, + Charsets.US_ASCII, + ) + } +} + +val verifyCliReleaseChecksums = tasks.register("verifyCliReleaseChecksums") { + group = "verification" + description = "Verifies the CLI release checksum manifest." + dependsOn(generateCliReleaseChecksums) + + doLast { + val archives = + listOf( + releaseTar.get().archiveFile.get().asFile, + releaseZip.get().archiveFile.get().asFile, + ).sortedBy(File::getName) + val expected = + archives.joinToString(separator = "\n", postfix = "\n") { archive -> + "${cliSha256(archive)} ${archive.name}" + } + val actual = cliReleaseChecksums.get().asFile.readText(Charsets.US_ASCII) + if (actual != expected) { + throw GradleException("CLI release SHA256SUMS does not match the archives") + } + } +} + +tasks.register("cliReleaseArtifacts") { + group = "release" + description = "Builds and verifies the CLI 0.1.0 ZIP, TAR and SHA256SUMS." + dependsOn(verifyCliReleaseChecksums) +} + extensions.configure { pitestVersion.set(libs.versions.pitest.get()) junit5PluginVersion.set(libs.versions.pitestJunit5.get()) @@ -79,5 +287,5 @@ tasks.named("pitest") { } tasks.named("check") { - dependsOn("pitest") + dependsOn("pitest", verifyCliReleaseArchives) } diff --git a/build.gradle.kts b/build.gradle.kts index 512087b..26ae4c6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -42,6 +42,8 @@ plugins { group = "io.github.aalsanie" val releaseVersion = "0.1.0" +val releaseTag = "v0.1.0" +val releaseArtifactBaseName = "bounded-origin-$releaseVersion" version = releaseVersion val releaseGroupId = group.toString() @@ -1238,6 +1240,17 @@ val verifyReleaseRevision = tasks.register("verifyReleaseRevision") { throw GradleException("Root project must be release version $releaseVersion") } + if (releaseTag != "v$releaseVersion") { + throw GradleException( + "Release tag $releaseTag does not match release version $releaseVersion" + ) + } + if (releaseArtifactBaseName != "bounded-origin-$releaseVersion") { + throw GradleException( + "Release artifact base name $releaseArtifactBaseName does not match release version $releaseVersion" + ) + } + val mismatchedModules = subprojects .filter { it.version.toString() != releaseVersion } From 268f9bf91d289abb9954bc310784fbe12f2bb8c0 Mon Sep 17 00:00:00 2001 From: aalsanie Date: Tue, 29 Sep 2026 19:21:57 +0300 Subject: [PATCH 11/22] release: verify isolated Maven consumption --- build.gradle.kts | 94 +++++++++++++++++++ release-tests/consumer/build.gradle.kts | 20 ++++ release-tests/consumer/settings.gradle.kts | 25 +++++ .../src/main/java/consumer/ConsumerSmoke.java | 39 ++++++++ 4 files changed, 178 insertions(+) create mode 100644 release-tests/consumer/build.gradle.kts create mode 100644 release-tests/consumer/settings.gradle.kts create mode 100644 release-tests/consumer/src/main/java/consumer/ConsumerSmoke.java diff --git a/build.gradle.kts b/build.gradle.kts index 26ae4c6..08dfc72 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -22,6 +22,7 @@ import org.gradle.api.tasks.bundling.Zip import org.gradle.api.tasks.compile.JavaCompile import org.gradle.api.tasks.testing.Test import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.Exec import org.gradle.jvm.toolchain.JavaToolchainService import org.gradle.jvm.toolchain.JavaLanguageVersion import org.gradle.testing.jacoco.plugins.JacocoPluginExtension @@ -367,6 +368,15 @@ fun validatePublicationArchives( } val centralStagingDirectory = layout.buildDirectory.dir("central-staging") +val consumerStagingDirectory = layout.buildDirectory.dir("consumer-repository") + +val cleanConsumerStaging = tasks.register("cleanConsumerStaging") { + group = "release" + description = "Removes the isolated external-consumer Maven repository." + doLast { + delete(consumerStagingDirectory.get().asFile) + } +} val cleanCentralStaging = tasks.register("cleanCentralStaging") { group = "release" @@ -544,6 +554,47 @@ subprojects { } } + tasks.register("stageConsumerPublication") { + group = "release" + description = "Stages the unsigned $moduleName publication for isolated consumer testing." + dependsOn( + cleanConsumerStaging, + verifyMavenPublication, + ) + + val modulePath = + releaseGroupId.replace('.', '/') + + "/$moduleName/$releaseVersion" + val destinationDirectory = + rootProject.layout.buildDirectory.dir("consumer-repository/$modulePath") + outputs.dir(destinationDirectory) + + doLast { + val destination = destinationDirectory.get().asFile + delete(destination) + if (!destination.mkdirs() && !destination.isDirectory) { + throw GradleException("Could not create consumer staging directory: $destination") + } + + val sourceFiles = + linkedMapOf( + "$moduleName-$releaseVersion.jar" to mainJar.get().archiveFile.get().asFile, + "$moduleName-$releaseVersion-sources.jar" to + sourcesJar.get().archiveFile.get().asFile, + "$moduleName-$releaseVersion-javadoc.jar" to + javadocJar.get().archiveFile.get().asFile, + "$moduleName-$releaseVersion.pom" to generatedPom.get().destination, + ) + + sourceFiles.forEach { (targetName, source) -> + if (!source.isFile || source.length() == 0L) { + throw GradleException("Missing consumer publication input: $source") + } + source.copyTo(File(destination, targetName), overwrite = true) + } + } + } + tasks.register("stageCentralPublication") { group = "release" description = "Stages the signed $moduleName publication in Maven Repository Layout." @@ -808,6 +859,49 @@ subprojects { } } +val stageConsumerPublications = tasks.register("stageConsumerPublications") { + group = "release" + description = "Stages every 0.1.0 Maven publication for isolated consumer testing." + dependsOn( + mavenPublicationModules + .sorted() + .map { ":$it:stageConsumerPublication" } + ) +} + +val verifyExternalMavenConsumer = tasks.register("verifyExternalMavenConsumer") { + group = "verification" + description = "Resolves and runs Bounded Origin from an isolated Maven consumer project." + dependsOn(stageConsumerPublications) + + val consumerProject = layout.projectDirectory.dir("release-tests/consumer") + val repositoryUri = consumerStagingDirectory.map { it.asFile.toURI().toString() } + val wrapper = + layout.projectDirectory.file( + if (System.getProperty("os.name").startsWith("Windows")) { + "gradlew.bat" + } else { + "gradlew" + } + ) + + inputs.dir(consumerProject) + inputs.dir(consumerStagingDirectory) + + workingDir(rootDir) + executable(wrapper.asFile.absolutePath) + args( + "--no-daemon", + "-p", + consumerProject.asFile.absolutePath, + "clean", + "run", + "-PboundedOriginRepository=${repositoryUri.get()}", + "-PboundedOriginVersion=$releaseVersion", + "--stacktrace", + ) +} + val stageCentralPublications = tasks.register("stageCentralPublications") { group = "release" description = "Stages every signed 0.1.0 Maven publication for Central." diff --git a/release-tests/consumer/build.gradle.kts b/release-tests/consumer/build.gradle.kts new file mode 100644 index 0000000..9da10ca --- /dev/null +++ b/release-tests/consumer/build.gradle.kts @@ -0,0 +1,20 @@ +plugins { + application +} + +val boundedOriginVersion = providers.gradleProperty("boundedOriginVersion").get() + +dependencies { + implementation("io.github.aalsanie:bounded-origin-api:$boundedOriginVersion") + implementation("io.github.aalsanie:bounded-origin-core:$boundedOriginVersion") + implementation("io.github.aalsanie:bounded-origin-store-fs:$boundedOriginVersion") + implementation("io.github.aalsanie:bounded-origin-proxy:$boundedOriginVersion") +} + +configurations.configureEach { + resolutionStrategy.failOnVersionConflict() +} + +application { + mainClass.set("consumer.ConsumerSmoke") +} diff --git a/release-tests/consumer/settings.gradle.kts b/release-tests/consumer/settings.gradle.kts new file mode 100644 index 0000000..1af41d5 --- /dev/null +++ b/release-tests/consumer/settings.gradle.kts @@ -0,0 +1,25 @@ +import org.gradle.api.initialization.resolve.RepositoriesMode + +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + maven { + name = "boundedOriginRelease" + url = uri(providers.gradleProperty("boundedOriginRepository").get()) + metadataSources { + mavenPom() + artifact() + } + } + mavenCentral() + } +} + +rootProject.name = "bounded-origin-release-consumer" diff --git a/release-tests/consumer/src/main/java/consumer/ConsumerSmoke.java b/release-tests/consumer/src/main/java/consumer/ConsumerSmoke.java new file mode 100644 index 0000000..bc09490 --- /dev/null +++ b/release-tests/consumer/src/main/java/consumer/ConsumerSmoke.java @@ -0,0 +1,39 @@ +package consumer; + +import io.github.aalsanie.boundedorigin.api.Budget; +import io.github.aalsanie.boundedorigin.api.Operation; +import io.github.aalsanie.boundedorigin.core.BoundedOriginExecutor; +import io.github.aalsanie.boundedorigin.proxy.GatewayConfig; +import io.github.aalsanie.boundedorigin.store.fs.FileSystemArtifactStore; +import java.nio.file.Files; +import java.time.Duration; +import java.util.List; +import java.util.Map; + +public final class ConsumerSmoke { + private ConsumerSmoke() {} + + public static void main(String[] args) throws Exception { + Operation operation = new Operation("consumer-smoke", Map.of("id", List.of("42"))); + Budget budget = new Budget(1, 0, Duration.ofSeconds(1), 1024); + + if (!"consumer-smoke".equals(operation.type()) || budget.maxActive() != 1) { + throw new IllegalStateException("public API values did not round-trip"); + } + + try (BoundedOriginExecutor ignored = + new BoundedOriginExecutor(budget, Duration.ZERO, 16); + FileSystemArtifactStore store = + new FileSystemArtifactStore(Files.createTempDirectory("bounded-origin-consumer"), 4096, 16)) { + if (store.stats().entryCount() != 0) { + throw new IllegalStateException("fresh filesystem store was not empty"); + } + } + + if (GatewayConfig.class.getName().isBlank()) { + throw new IllegalStateException("proxy artifact did not resolve"); + } + + System.out.println("bounded-origin external consumer ok"); + } +} From 708ee675616772b34baa303cf2cf5c20e306bd91 Mon Sep 17 00:00:00 2001 From: aalsanie Date: Tue, 29 Sep 2026 19:22:02 +0300 Subject: [PATCH 12/22] release: harden artifact verification --- .github/workflows/release-verification.yml | 74 +++++++++++++++++ build.gradle.kts | 11 +++ scripts/verify-release-negative.sh | 95 ++++++++++++++++++++++ scripts/verify-release-reproducible.sh | 54 ++++++++++++ 4 files changed, 234 insertions(+) create mode 100755 scripts/verify-release-negative.sh create mode 100755 scripts/verify-release-reproducible.sh diff --git a/.github/workflows/release-verification.yml b/.github/workflows/release-verification.yml index fbe35b0..acf3f22 100644 --- a/.github/workflows/release-verification.yml +++ b/.github/workflows/release-verification.yml @@ -7,6 +7,9 @@ on: branches: [main] paths: - build.gradle.kts + - bounded-origin-cli/build.gradle.kts + - release-tests/** + - scripts/verify-release-*.sh - .github/workflows/release-verification.yml permissions: @@ -80,3 +83,74 @@ jobs: done < <(find build/central-staging -type f -name '*.asc' -print0) test "$signature_count" -eq 16 + + - name: Negative release-contract tests + shell: bash + run: bash scripts/verify-release-negative.sh + + cli-release: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + + - name: Set up Java + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '21' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + with: + cache-provider: basic + + - name: Build and verify CLI release artifacts + run: ./gradlew :bounded-origin-cli:cliReleaseArtifacts --stacktrace + + external-consumer: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + + - name: Set up Java + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '21' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + with: + cache-provider: basic + + - name: Resolve and run isolated Maven consumer + run: ./gradlew verifyExternalMavenConsumer --stacktrace + + release-reproducibility: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + + - name: Set up Java + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '21' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + with: + cache-provider: basic + + - name: Verify release artifact reproducibility + shell: bash + run: bash scripts/verify-release-reproducible.sh diff --git a/build.gradle.kts b/build.gradle.kts index 08dfc72..6898d9d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -21,10 +21,12 @@ import org.gradle.api.tasks.bundling.Jar import org.gradle.api.tasks.bundling.Zip import org.gradle.api.tasks.compile.JavaCompile import org.gradle.api.tasks.testing.Test +import org.gradle.api.tasks.javadoc.Javadoc import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.Exec import org.gradle.jvm.toolchain.JavaToolchainService import org.gradle.jvm.toolchain.JavaLanguageVersion +import org.gradle.external.javadoc.StandardJavadocDocletOptions import org.gradle.testing.jacoco.plugins.JacocoPluginExtension import org.gradle.testing.jacoco.tasks.JacocoCoverageVerification import org.gradle.testing.jacoco.tasks.JacocoReport @@ -429,6 +431,15 @@ subprojects { javaExtension.withJavadocJar() + tasks.named("javadoc") { + (options as StandardJavadocDocletOptions).apply { + addBooleanOption("notimestamp", true) + encoding = "UTF-8" + charSet = "UTF-8" + docEncoding = "UTF-8" + } + } + val publication = extensions .getByType() diff --git a/scripts/verify-release-negative.sh b/scripts/verify-release-negative.sh new file mode 100755 index 0000000..0a54644 --- /dev/null +++ b/scripts/verify-release-negative.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +STAGING="$ROOT/build/central-staging" +BASELINE="$(mktemp -d)" +BUILD_BACKUP="$(mktemp)" +LOG="$(mktemp)" +trap 'cp "$BUILD_BACKUP" "$ROOT/build.gradle.kts"; rm -rf "$BASELINE"; rm -f "$BUILD_BACKUP" "$LOG"' EXIT + +test -d "$STAGING" +cp "$ROOT/build.gradle.kts" "$BUILD_BACKUP" +cp -a "$STAGING/." "$BASELINE/" + +excluded_stage_tasks=( + -x stageCentralPublications + -x cleanCentralStaging + -x :bounded-origin-api:stageCentralPublication + -x :bounded-origin-core:stageCentralPublication + -x :bounded-origin-store-fs:stageCentralPublication + -x :bounded-origin-proxy:stageCentralPublication +) + +restore_staging() { + rm -rf "$STAGING" + mkdir -p "$STAGING" + cp -a "$BASELINE/." "$STAGING/" +} + +expect_failure() { + local label="$1" + shift + if "$@" >"$LOG" 2>&1; then + echo "$label unexpectedly succeeded" >&2 + cat "$LOG" >&2 + exit 1 + fi +} + +verify_mutated_staging_fails() { + local label="$1" + expect_failure "$label" ./gradlew verifyCentralStaging \ + "${excluded_stage_tasks[@]}" --rerun-tasks --no-daemon --stacktrace +} + +expect_failure "missing signing key" env \ + -u ORG_GRADLE_PROJECT_signingKey \ + -u ORG_GRADLE_PROJECT_signingPassword \ + -u ORG_GRADLE_PROJECT_signingKeyId \ + ./gradlew :bounded-origin-api:verifyCentralSigningCredentials --no-daemon --stacktrace + +expect_failure "malformed signing key" env \ + ORG_GRADLE_PROJECT_signingKey=not-a-pgp-key \ + ORG_GRADLE_PROJECT_signingPassword= \ + ./gradlew :bounded-origin-api:signMavenJavaPublication \ + --rerun-tasks --no-daemon --stacktrace + +restore_staging +rm "$STAGING/io/github/aalsanie/bounded-origin-api/0.1.0/bounded-origin-api-0.1.0.jar" +verify_mutated_staging_fails "missing publication artifact" + +restore_staging +printf '%064d\n' 0 > \ + "$STAGING/io/github/aalsanie/bounded-origin-api/0.1.0/bounded-origin-api-0.1.0.jar.sha256" +verify_mutated_staging_fails "corrupt checksum" + +restore_staging +touch "$STAGING/unexpected-release-file" +verify_mutated_staging_fails "unexpected Central file" + +restore_staging +mkdir -p "$STAGING/io/github/aalsanie/test-infra/0.1.0" +touch "$STAGING/io/github/aalsanie/test-infra/0.1.0/test-infra-0.1.0.jar" +verify_mutated_staging_fails "internal module exposure" + +restore_staging +pom="$STAGING/io/github/aalsanie/bounded-origin-api/0.1.0/bounded-origin-api-0.1.0.pom" +sed -i 's#Apache License, Version 2.0#Wrong License#' "$pom" +verify_mutated_staging_fails "wrong POM license" + +restore_staging +jar="$STAGING/io/github/aalsanie/bounded-origin-api/0.1.0/bounded-origin-api-0.1.0.jar" +printf 'tamper' >> "$jar" +expect_failure "tampered signed artifact" gpg --batch --verify "$jar.asc" "$jar" + +cp "$BUILD_BACKUP" "$ROOT/build.gradle.kts" +sed -i 's/val releaseVersion = "0.1.0"/val releaseVersion = "0.1.0-SNAPSHOT"/' "$ROOT/build.gradle.kts" +expect_failure "snapshot release revision" ./gradlew verifyReleaseRevision --no-daemon --stacktrace +cp "$BUILD_BACKUP" "$ROOT/build.gradle.kts" + +restore_staging + +echo "negative release verification passed" diff --git a/scripts/verify-release-reproducible.sh b/scripts/verify-release-reproducible.sh new file mode 100755 index 0000000..6962f0d --- /dev/null +++ b/scripts/verify-release-reproducible.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +snapshot() { + local output="$1" + : > "$output" + + local consumer_count + consumer_count="$(find build/consumer-repository -type f | wc -l | tr -d ' ')" + if [[ "$consumer_count" != "16" ]]; then + echo "expected 16 staged Maven primary files, found $consumer_count" >&2 + exit 1 + fi + + while IFS= read -r -d '' file; do + printf '%s %s\n' "$(sha256sum "$file" | awk '{print $1}')" "$file" >> "$output" + done < <(find build/consumer-repository -type f -print0 | sort -z) + + for file in \ + bounded-origin-cli/build/distributions/bounded-origin-0.1.0.tar \ + bounded-origin-cli/build/distributions/bounded-origin-0.1.0.zip \ + bounded-origin-cli/build/distributions/SHA256SUMS + do + test -s "$file" + printf '%s %s\n' "$(sha256sum "$file" | awk '{print $1}')" "$file" >> "$output" + done +} + +build_release_inputs() { + ./gradlew clean \ + stageConsumerPublications \ + :bounded-origin-cli:cliReleaseArtifacts \ + --no-daemon --stacktrace +} + +build_release_inputs +snapshot "$TMP_DIR/first.sha256" + +build_release_inputs +snapshot "$TMP_DIR/second.sha256" + +if ! cmp -s "$TMP_DIR/first.sha256" "$TMP_DIR/second.sha256"; then + echo "release artifacts are not reproducible" >&2 + diff -u "$TMP_DIR/first.sha256" "$TMP_DIR/second.sha256" || true + exit 1 +fi + +echo "release artifacts are reproducible" From 5cffca82808167e3e95126059c08d38684714a98 Mon Sep 17 00:00:00 2001 From: aalsanie Date: Tue, 29 Sep 2026 19:22:08 +0300 Subject: [PATCH 13/22] release: fix consumer smoke and classify release tests --- REUSE.toml | 1 + .../consumer/src/main/java/consumer/ConsumerSmoke.java | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/REUSE.toml b/REUSE.toml index 52a7bbe..546595b 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -27,6 +27,7 @@ path = [ "gradle/libs.versions.toml", "gradle/verification-metadata.xml", "scripts/**", + "release-tests/**", "examples/**", "test-infra/**", "bounded-origin-benchmarks/**", diff --git a/release-tests/consumer/src/main/java/consumer/ConsumerSmoke.java b/release-tests/consumer/src/main/java/consumer/ConsumerSmoke.java index bc09490..6afd51d 100644 --- a/release-tests/consumer/src/main/java/consumer/ConsumerSmoke.java +++ b/release-tests/consumer/src/main/java/consumer/ConsumerSmoke.java @@ -22,7 +22,7 @@ public static void main(String[] args) throws Exception { } try (BoundedOriginExecutor ignored = - new BoundedOriginExecutor(budget, Duration.ZERO, 16); + new BoundedOriginExecutor(budget, Duration.ofMillis(1), 16); FileSystemArtifactStore store = new FileSystemArtifactStore(Files.createTempDirectory("bounded-origin-consumer"), 4096, 16)) { if (store.stats().entryCount() != 0) { From a2cbaf11877ccf0a091b27535c43abf9213d6943 Mon Sep 17 00:00:00 2001 From: aalsanie Date: Tue, 29 Sep 2026 19:22:14 +0300 Subject: [PATCH 14/22] docs: simplify 0.1.0 changelog --- CHANGELOG.md | 24 +----------------------- 1 file changed, 1 insertion(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1965848..ea80552 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,26 +2,4 @@ ## 0.1.0 -### Policy - -* Added policy evaluation. Requests are reduced to stable operation keys before any expensive origin work is considered. -* Added explicit execution modes for serving existing artifacts, bounded origin work, durable materialization, client-side computation, and denial. - -### Origin execution - -* Added single-flight origin execution. Concurrent requests for the same operation share one origin job instead of multiplying upstream work. -* Added bounded scheduling so origin work cannot grow with request volume. Overload is rejected explicitly rather than pushed into an unbounded executor. -* Added failure cooldowns to stop repeatedly failing operations from turning into retry storms. - -### Artifact store - -* Added a durable SHA-256 content-addressed filesystem store. Artifact bodies are streamed, deduplicated, and published atomically. -* Added startup recovery and corruption handling. Incomplete or invalid artifacts are never served. -* Added bounded storage with deterministic eviction while active readers remain safe. - -### Gateway - -* Added the first Netty 4.2 HTTP/1.1 gateway implementation. -* Request and origin bodies are streamed through bounded spools; untrusted uploads do not consume origin-execution capacity while still arriving. -* Client disconnects do not cancel shared origin work or trigger duplicate materialization. -* Added bounded origin connection pooling, strict HTTP parsing, backpressure, graceful draining, and health/readiness/metrics endpoints. +Initial public release of Bounded Origin, including the Java libraries and standalone HTTP gateway. From 68faa9faa6e64a18fc375d50cc6b69ee15c01249 Mon Sep 17 00:00:00 2001 From: aalsanie Date: Tue, 29 Sep 2026 20:17:58 +0300 Subject: [PATCH 15/22] release: fix CLI distribution task graph --- bounded-origin-cli/build.gradle.kts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bounded-origin-cli/build.gradle.kts b/bounded-origin-cli/build.gradle.kts index 26614fa..7d78a1b 100644 --- a/bounded-origin-cli/build.gradle.kts +++ b/bounded-origin-cli/build.gradle.kts @@ -103,6 +103,10 @@ val releaseTar = tasks.named("distTar") { dependsOn(generateReleaseSourceNotice) } +tasks.named("installDist") { + dependsOn(generateReleaseSourceNotice) +} + val verifyCliReleaseArchives = tasks.register("verifyCliReleaseArchives") { group = "verification" description = "Verifies the exact 0.1.0 CLI release archive names and required contents." From ed2880b955a03a837a0b7b8a89c89df92e27e6a6 Mon Sep 17 00:00:00 2001 From: aalsanie Date: Tue, 29 Sep 2026 20:23:01 +0300 Subject: [PATCH 16/22] release: expose verified release identity --- build.gradle.kts | 23 ++++++++++++++++++++++ release-tests/consumer/settings.gradle.kts | 14 +++++++++++++ 2 files changed, 37 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index 6898d9d..48054a7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1377,6 +1377,29 @@ val verifyReleaseRevision = tasks.register("verifyReleaseRevision") { } } +val releaseIdentityFile = layout.buildDirectory.file("release/identity.env") + +tasks.register("writeReleaseIdentity") { + group = "release" + description = "Writes the verified release identity for CI release workflows." + dependsOn(verifyReleaseRevision) + outputs.file(releaseIdentityFile) + + doLast { + val file = releaseIdentityFile.get().asFile + file.parentFile.mkdirs() + file.writeText( + """ + RELEASE_VERSION=$releaseVersion + RELEASE_TAG=$releaseTag + RELEASE_ARTIFACT_BASE=$releaseArtifactBaseName + RELEASE_GROUP_ID=$releaseGroupId + """.trimIndent() + "\n", + Charsets.UTF_8, + ) + } +} + tasks.named("check") { dependsOn( subprojects.map { it.tasks.named("check") }, diff --git a/release-tests/consumer/settings.gradle.kts b/release-tests/consumer/settings.gradle.kts index 1af41d5..158ffd0 100644 --- a/release-tests/consumer/settings.gradle.kts +++ b/release-tests/consumer/settings.gradle.kts @@ -1,4 +1,6 @@ +import org.gradle.api.credentials.HttpHeaderCredentials import org.gradle.api.initialization.resolve.RepositoriesMode +import org.gradle.authentication.http.HttpHeaderAuthentication pluginManagement { repositories { @@ -17,6 +19,18 @@ dependencyResolutionManagement { mavenPom() artifact() } + + val authorization = + providers.environmentVariable("BOUNDED_ORIGIN_REPOSITORY_AUTHORIZATION").orNull + if (!authorization.isNullOrBlank()) { + credentials(HttpHeaderCredentials::class) { + name = "Authorization" + value = authorization + } + authentication { + create("header") + } + } } mavenCentral() } From 5c89f887f58f8fa7613c47626addd61002c581b6 Mon Sep 17 00:00:00 2001 From: aalsanie Date: Tue, 29 Sep 2026 20:23:51 +0300 Subject: [PATCH 17/22] release: add Central Portal client --- scripts/central-portal.sh | 173 ++++++++++++++++++++++ scripts/verify-release-workflow-policy.sh | 40 +++++ 2 files changed, 213 insertions(+) create mode 100755 scripts/central-portal.sh create mode 100755 scripts/verify-release-workflow-policy.sh diff --git a/scripts/central-portal.sh b/scripts/central-portal.sh new file mode 100755 index 0000000..2e87f3b --- /dev/null +++ b/scripts/central-portal.sh @@ -0,0 +1,173 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE_URL="${CENTRAL_PORTAL_URL:-https://central.sonatype.com/api/v1/publisher}" +PUBLIC_REPOSITORY="${CENTRAL_PUBLIC_REPOSITORY:-https://repo.maven.apache.org/maven2}" +MODULES=( + bounded-origin-api + bounded-origin-core + bounded-origin-store-fs + bounded-origin-proxy +) + +require_authorization() { + if [[ -z "${CENTRAL_AUTHORIZATION:-}" ]]; then + echo "CENTRAL_AUTHORIZATION is required" >&2 + exit 2 + fi +} + +request() { + local method="$1" + local url="$2" + shift 2 + require_authorization + curl --fail-with-body --silent --show-error \ + --retry 3 --retry-all-errors \ + --connect-timeout 15 --max-time 120 \ + --request "$method" \ + --header "Authorization: $CENTRAL_AUTHORIZATION" \ + "$@" "$url" +} + +upload() { + local bundle="$1" + local name="$2" + test -s "$bundle" + if [[ ! "$name" =~ ^[A-Za-z0-9._-]+$ ]]; then + echo "Central deployment name contains unsupported characters: $name" >&2 + exit 64 + fi + + local deployment_id + deployment_id="$( + request POST "$BASE_URL/upload?publishingType=USER_MANAGED&name=$name" \ + --form "bundle=@$bundle;type=application/octet-stream" + )" + deployment_id="${deployment_id//$'\r'/}" + deployment_id="${deployment_id//$'\n'/}" + if [[ ! "$deployment_id" =~ ^[0-9a-fA-F-]{36}$ ]]; then + echo "Central returned an invalid deployment ID: $deployment_id" >&2 + exit 1 + fi + printf '%s\n' "$deployment_id" +} + +status() { + local deployment_id="$1" + request POST "$BASE_URL/status?id=$deployment_id" +} + +wait_state() { + local deployment_id="$1" + local target="$2" + local attempts="${3:-120}" + local interval="${4:-15}" + local state="" + local body="" + + for ((i = 1; i <= attempts; i++)); do + body="$(status "$deployment_id")" + state="$(jq -r '.deploymentState // empty' <<<"$body")" + if [[ "$state" == "$target" ]]; then + printf '%s\n' "$body" + return 0 + fi + if [[ "$state" == "FAILED" ]]; then + echo "Central deployment failed:" >&2 + jq . <<<"$body" >&2 + return 1 + fi + + case "$target:$state" in + VALIDATED:PENDING|VALIDATED:VALIDATING|PUBLISHED:PENDING|PUBLISHED:VALIDATING|PUBLISHED:VALIDATED|PUBLISHED:PUBLISHING) + ;; + *) + echo "Unexpected Central deployment state '$state' while waiting for '$target'" >&2 + jq . <<<"$body" >&2 + return 1 + ;; + esac + sleep "$interval" + done + + echo "Timed out waiting for Central deployment $deployment_id to reach $target" >&2 + return 1 +} + +publish() { + local deployment_id="$1" + request POST "$BASE_URL/deployment/$deployment_id" >/dev/null +} + +drop() { + local deployment_id="$1" + request DELETE "$BASE_URL/deployment/$deployment_id" >/dev/null +} + +public_count() { + local version="$1" + local count=0 + local module + for module in "${MODULES[@]}"; do + local url="$PUBLIC_REPOSITORY/io/github/aalsanie/$module/$version/$module-$version.pom" + if curl --fail --silent --show-error --head \ + --connect-timeout 10 --max-time 30 "$url" >/dev/null 2>&1; then + count=$((count + 1)) + fi + done + printf '%s\n' "$count" +} + +wait_public() { + local version="$1" + local attempts="${2:-120}" + local interval="${3:-15}" + local count + + for ((i = 1; i <= attempts; i++)); do + count="$(public_count "$version")" + if [[ "$count" == "${#MODULES[@]}" ]]; then + return 0 + fi + sleep "$interval" + done + + echo "Timed out waiting for all Maven artifacts to become public" >&2 + return 1 +} + +case "${1:-}" in + upload) + [[ $# == 3 ]] || { echo "usage: $0 upload BUNDLE NAME" >&2; exit 64; } + upload "$2" "$3" + ;; + status) + [[ $# == 2 ]] || { echo "usage: $0 status DEPLOYMENT_ID" >&2; exit 64; } + status "$2" + ;; + wait-state) + [[ $# -ge 3 && $# -le 5 ]] || { echo "usage: $0 wait-state DEPLOYMENT_ID STATE [ATTEMPTS] [INTERVAL]" >&2; exit 64; } + wait_state "$2" "$3" "${4:-120}" "${5:-15}" + ;; + publish) + [[ $# == 2 ]] || { echo "usage: $0 publish DEPLOYMENT_ID" >&2; exit 64; } + publish "$2" + ;; + drop) + [[ $# == 2 ]] || { echo "usage: $0 drop DEPLOYMENT_ID" >&2; exit 64; } + drop "$2" + ;; + public-count) + [[ $# == 2 ]] || { echo "usage: $0 public-count VERSION" >&2; exit 64; } + public_count "$2" + ;; + wait-public) + [[ $# -ge 2 && $# -le 4 ]] || { echo "usage: $0 wait-public VERSION [ATTEMPTS] [INTERVAL]" >&2; exit 64; } + wait_public "$2" "${3:-120}" "${4:-15}" + ;; + *) + echo "usage: $0 {upload|status|wait-state|publish|drop|public-count|wait-public} ..." >&2 + exit 64 + ;; +esac diff --git a/scripts/verify-release-workflow-policy.sh b/scripts/verify-release-workflow-policy.sh new file mode 100755 index 0000000..5b21d20 --- /dev/null +++ b/scripts/verify-release-workflow-policy.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +bash -n scripts/central-portal.sh +bash -n scripts/verify-release-negative.sh +bash -n scripts/verify-release-reproducible.sh + +grep -F "tags: ['v*']" .github/workflows/release.yml >/dev/null +grep -F "if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')" .github/workflows/release.yml >/dev/null +grep -F "environment: release" .github/workflows/release.yml >/dev/null +grep -F "environment: release" .github/workflows/central-preflight.yml >/dev/null + +if grep -F "central-portal.sh publish" .github/workflows/central-preflight.yml >/dev/null; then + echo "Central preflight must never publish a deployment" >&2 + exit 1 +fi + +if grep -R -F "publishingType=AUTOMATIC" .github/workflows scripts/central-portal.sh >/dev/null; then + echo "AUTOMATIC Central publishing is forbidden" >&2 + exit 1 +fi + +if grep -E "CENTRAL_TOKEN_|MAVEN_GPG_PRIVATE_KEY|MAVEN_GPG_PASSPHRASE" .github/workflows/release-verification.yml >/dev/null; then + echo "Release Verification must not consume production release secrets" >&2 + exit 1 +fi + +publish_calls="$( + grep -R -F "central-portal.sh publish" .github/workflows | cut -d: -f1 | sort -u +)" +if [[ "$publish_calls" != ".github/workflows/release.yml" ]]; then + echo "Only the tag-governed release workflow may invoke Central publish" >&2 + printf '%s\n' "$publish_calls" >&2 + exit 1 +fi + +echo "release workflow policy verified" From 23f9799ce1fbf7e5158a92841cf64c7cb45f3457 Mon Sep 17 00:00:00 2001 From: aalsanie Date: Tue, 29 Sep 2026 20:25:02 +0300 Subject: [PATCH 18/22] release: add tag-governed Central publishing --- .github/workflows/central-preflight.yml | 137 +++++++++++ .github/workflows/release.yml | 288 ++++++++++++++++++++++++ 2 files changed, 425 insertions(+) create mode 100644 .github/workflows/central-preflight.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/central-preflight.yml b/.github/workflows/central-preflight.yml new file mode 100644 index 0000000..5f5a495 --- /dev/null +++ b/.github/workflows/central-preflight.yml @@ -0,0 +1,137 @@ +name: Central Release Preflight + +on: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: central-release-preflight + cancel-in-progress: false + +jobs: + preflight: + runs-on: ubuntu-latest + environment: release + steps: + - name: Checkout main + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + fetch-depth: 0 + + - name: Require main + shell: bash + run: | + set -euo pipefail + test "$GITHUB_REF" = "refs/heads/main" + git fetch origin main --no-tags + test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" + + - name: Set up Java + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '21' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + with: + cache-provider: basic + + - name: Load release identity + shell: bash + run: | + set -euo pipefail + ./gradlew writeReleaseIdentity --stacktrace + cat build/release/identity.env >> "$GITHUB_ENV" + + - name: Require release credentials + shell: bash + env: + MAVEN_GPG_PRIVATE_KEY: ${{ secrets.MAVEN_GPG_PRIVATE_KEY }} + MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }} + CENTRAL_TOKEN_USERNAME: ${{ secrets.CENTRAL_TOKEN_USERNAME }} + CENTRAL_TOKEN_PASSWORD: ${{ secrets.CENTRAL_TOKEN_PASSWORD }} + run: | + set -euo pipefail + test -n "$MAVEN_GPG_PRIVATE_KEY" + test -n "$MAVEN_GPG_PASSPHRASE" + test -n "$CENTRAL_TOKEN_USERNAME" + test -n "$CENTRAL_TOKEN_PASSWORD" + + - name: Build signed Central bundle + env: + ORG_GRADLE_PROJECT_signingKey: ${{ secrets.MAVEN_GPG_PRIVATE_KEY }} + ORG_GRADLE_PROJECT_signingPassword: ${{ secrets.MAVEN_GPG_PASSPHRASE }} + run: ./gradlew centralBundle --stacktrace + + - name: Verify signatures + shell: bash + env: + MAVEN_GPG_PRIVATE_KEY: ${{ secrets.MAVEN_GPG_PRIVATE_KEY }} + run: | + set -euo pipefail + export GNUPGHOME="$RUNNER_TEMP/bounded-origin-preflight-gpg" + install -d -m 700 "$GNUPGHOME" + printf '%s\n' "$MAVEN_GPG_PRIVATE_KEY" | gpg --batch --import + + signature_count=0 + while IFS= read -r -d '' signature; do + gpg --batch --verify "$signature" "${signature%.asc}" + signature_count=$((signature_count + 1)) + done < <(find build/central-staging -type f -name '*.asc' -print0) + test "$signature_count" -eq 16 + + - name: Prepare Central authorization + shell: bash + env: + CENTRAL_TOKEN_USERNAME: ${{ secrets.CENTRAL_TOKEN_USERNAME }} + CENTRAL_TOKEN_PASSWORD: ${{ secrets.CENTRAL_TOKEN_PASSWORD }} + run: | + set -euo pipefail + encoded="$(printf '%s:%s' "$CENTRAL_TOKEN_USERNAME" "$CENTRAL_TOKEN_PASSWORD" | base64 | tr -d '\n')" + authorization="Bearer $encoded" + echo "::add-mask::$encoded" + echo "::add-mask::$authorization" + echo "CENTRAL_AUTHORIZATION=$authorization" >> "$GITHUB_ENV" + + - name: Upload, validate, consume and drop + shell: bash + run: | + set -euo pipefail + + public_count="$(scripts/central-portal.sh public-count "$RELEASE_VERSION")" + if [[ "$public_count" != "0" ]]; then + echo "0.1.0 is already visible on Maven Central; preflight must run before publication." >&2 + exit 1 + fi + + deployment_id="$( + scripts/central-portal.sh upload \ + "build/distributions/$RELEASE_ARTIFACT_BASE-central-bundle.zip" \ + "$RELEASE_ARTIFACT_BASE-preflight-$GITHUB_RUN_ID" + )" + + validated=0 + cleanup() { + if [[ "$validated" == "1" ]]; then + scripts/central-portal.sh drop "$deployment_id" || true + fi + } + trap cleanup EXIT + + scripts/central-portal.sh wait-state "$deployment_id" VALIDATED >/dev/null + validated=1 + + export BOUNDED_ORIGIN_REPOSITORY_AUTHORIZATION="$CENTRAL_AUTHORIZATION" + GRADLE_USER_HOME="$RUNNER_TEMP/preflight-consumer-gradle" \ + ./gradlew --no-daemon -p release-tests/consumer clean run \ + -PboundedOriginRepository="https://central.sonatype.com/api/v1/publisher/deployment/$deployment_id/download/" \ + -PboundedOriginVersion="$RELEASE_VERSION" \ + --refresh-dependencies --stacktrace + + scripts/central-portal.sh drop "$deployment_id" + validated=0 + trap - EXIT diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8314002 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,288 @@ +name: Release + +on: + push: + tags: ['v*'] + pull_request: + branches: [main] + paths: + - .github/workflows/release.yml + - .github/workflows/central-preflight.yml + - build.gradle.kts + - bounded-origin-cli/build.gradle.kts + - release-tests/** + - scripts/central-portal.sh + - scripts/verify-release-workflow-policy.sh + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + fetch-depth: 0 + + - name: Set up Java + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '21' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + with: + cache-provider: basic + + - name: Verify release workflow policy + run: bash scripts/verify-release-workflow-policy.sh + + - name: Verify release revision + run: ./gradlew verifyReleaseRevision writeReleaseIdentity --stacktrace + + publish: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') + needs: validate + runs-on: ubuntu-latest + environment: release + permissions: + contents: write + steps: + - name: Checkout release tag + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + fetch-depth: 0 + + - name: Set up Java + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '21' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + with: + cache-provider: basic + + - name: Load and verify release identity + shell: bash + run: | + set -euo pipefail + ./gradlew writeReleaseIdentity --stacktrace + cat build/release/identity.env >> "$GITHUB_ENV" + source build/release/identity.env + + git fetch origin main --no-tags + test "$GITHUB_REF_NAME" = "$RELEASE_TAG" + test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" + + - name: Require release credentials + shell: bash + env: + MAVEN_GPG_PRIVATE_KEY: ${{ secrets.MAVEN_GPG_PRIVATE_KEY }} + MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }} + CENTRAL_TOKEN_USERNAME: ${{ secrets.CENTRAL_TOKEN_USERNAME }} + CENTRAL_TOKEN_PASSWORD: ${{ secrets.CENTRAL_TOKEN_PASSWORD }} + run: | + set -euo pipefail + test -n "$MAVEN_GPG_PRIVATE_KEY" + test -n "$MAVEN_GPG_PASSPHRASE" + test -n "$CENTRAL_TOKEN_USERNAME" + test -n "$CENTRAL_TOKEN_PASSWORD" + + - name: Build signed Central and CLI artifacts + env: + ORG_GRADLE_PROJECT_signingKey: ${{ secrets.MAVEN_GPG_PRIVATE_KEY }} + ORG_GRADLE_PROJECT_signingPassword: ${{ secrets.MAVEN_GPG_PASSPHRASE }} + run: | + ./gradlew centralBundle :bounded-origin-cli:cliReleaseArtifacts --stacktrace + + - name: Verify signatures and sign CLI assets + shell: bash + env: + MAVEN_GPG_PRIVATE_KEY: ${{ secrets.MAVEN_GPG_PRIVATE_KEY }} + MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }} + run: | + set -euo pipefail + export GNUPGHOME="$RUNNER_TEMP/bounded-origin-production-gpg" + install -d -m 700 "$GNUPGHOME" + printf '%s\n' "$MAVEN_GPG_PRIVATE_KEY" | gpg --batch --import + + signature_count=0 + while IFS= read -r -d '' signature; do + gpg --batch --verify "$signature" "${signature%.asc}" + signature_count=$((signature_count + 1)) + done < <(find build/central-staging -type f -name '*.asc' -print0) + test "$signature_count" -eq 16 + + for artifact in \ + "bounded-origin-cli/build/distributions/$RELEASE_ARTIFACT_BASE.zip" \ + "bounded-origin-cli/build/distributions/$RELEASE_ARTIFACT_BASE.tar" \ + "bounded-origin-cli/build/distributions/SHA256SUMS" + do + printf '%s\n' "$MAVEN_GPG_PASSPHRASE" | + gpg --batch --yes --pinentry-mode loopback --passphrase-fd 0 \ + --armor --detach-sign --output "$artifact.asc" "$artifact" + gpg --batch --verify "$artifact.asc" "$artifact" + done + + - name: Prepare Central authorization + shell: bash + env: + CENTRAL_TOKEN_USERNAME: ${{ secrets.CENTRAL_TOKEN_USERNAME }} + CENTRAL_TOKEN_PASSWORD: ${{ secrets.CENTRAL_TOKEN_PASSWORD }} + run: | + set -euo pipefail + encoded="$(printf '%s:%s' "$CENTRAL_TOKEN_USERNAME" "$CENTRAL_TOKEN_PASSWORD" | base64 | tr -d '\n')" + authorization="Bearer $encoded" + echo "::add-mask::$encoded" + echo "::add-mask::$authorization" + echo "CENTRAL_AUTHORIZATION=$authorization" >> "$GITHUB_ENV" + + - name: Ensure durable draft release state + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + notes="$RUNNER_TEMP/release-notes.md" + awk -v heading="## $RELEASE_VERSION" ' + $0 == heading { found=1; next } + found && /^## / { exit } + found { print } + ' CHANGELOG.md | sed '/./,$!d' > "$notes" + test -s "$notes" + + if ! gh release view "$RELEASE_TAG" >/dev/null 2>&1; then + gh release create "$RELEASE_TAG" \ + --draft \ + --verify-tag \ + --title "Bounded Origin $RELEASE_VERSION" \ + --notes-file "$notes" + fi + + - name: Validate and publish Central deployment + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + state_dir="$RUNNER_TEMP/bounded-origin-release-state" + mkdir -p "$state_dir" + state_file="$state_dir/central-deployment-id.txt" + deployment_id="" + + if gh release download "$RELEASE_TAG" \ + --pattern central-deployment-id.txt \ + --dir "$state_dir" >/dev/null 2>&1; then + deployment_id="$(tr -d '\r\n' < "$state_file")" + else + public_count="$(scripts/central-portal.sh public-count "$RELEASE_VERSION")" + case "$public_count" in + 4) + echo "All Maven artifacts are already public; resuming GitHub release finalization." + ;; + 0) + deployment_id="$( + scripts/central-portal.sh upload \ + "build/distributions/$RELEASE_ARTIFACT_BASE-central-bundle.zip" \ + "$RELEASE_ARTIFACT_BASE" + )" + printf '%s\n' "$deployment_id" > "$state_file" + gh release upload "$RELEASE_TAG" "$state_file" --clobber + ;; + *) + echo "Only $public_count of 4 Maven artifacts are public; refusing ambiguous recovery." >&2 + exit 1 + ;; + esac + fi + + if [[ -n "$deployment_id" ]]; then + status_json="$(scripts/central-portal.sh status "$deployment_id")" + state="$(jq -r '.deploymentState // empty' <<<"$status_json")" + + case "$state" in + PENDING|VALIDATING) + scripts/central-portal.sh wait-state "$deployment_id" VALIDATED >/dev/null + state="VALIDATED" + ;; + VALIDATED|PUBLISHING|PUBLISHED) + ;; + FAILED) + jq . <<<"$status_json" >&2 + exit 1 + ;; + *) + echo "Unexpected Central deployment state: $state" >&2 + jq . <<<"$status_json" >&2 + exit 1 + ;; + esac + + if [[ "$state" == "VALIDATED" ]]; then + export BOUNDED_ORIGIN_REPOSITORY_AUTHORIZATION="$CENTRAL_AUTHORIZATION" + GRADLE_USER_HOME="$RUNNER_TEMP/validated-consumer-gradle" \ + ./gradlew --no-daemon -p release-tests/consumer clean run \ + -PboundedOriginRepository="https://central.sonatype.com/api/v1/publisher/deployment/$deployment_id/download/" \ + -PboundedOriginVersion="$RELEASE_VERSION" \ + --refresh-dependencies --stacktrace + unset BOUNDED_ORIGIN_REPOSITORY_AUTHORIZATION + + scripts/central-portal.sh publish "$deployment_id" + state="PUBLISHING" + fi + + if [[ "$state" == "PUBLISHING" ]]; then + scripts/central-portal.sh wait-state "$deployment_id" PUBLISHED >/dev/null + fi + fi + + scripts/central-portal.sh wait-public "$RELEASE_VERSION" + + - name: Verify public Maven consumption + shell: bash + run: | + set -euo pipefail + GRADLE_USER_HOME="$RUNNER_TEMP/public-consumer-gradle" \ + ./gradlew --no-daemon -p release-tests/consumer clean run \ + -PboundedOriginRepository="https://repo.maven.apache.org/maven2" \ + -PboundedOriginVersion="$RELEASE_VERSION" \ + --refresh-dependencies --stacktrace + + - name: Finalize GitHub release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + gh release upload "$RELEASE_TAG" \ + "bounded-origin-cli/build/distributions/$RELEASE_ARTIFACT_BASE.zip" \ + "bounded-origin-cli/build/distributions/$RELEASE_ARTIFACT_BASE.zip.asc" \ + "bounded-origin-cli/build/distributions/$RELEASE_ARTIFACT_BASE.tar" \ + "bounded-origin-cli/build/distributions/$RELEASE_ARTIFACT_BASE.tar.asc" \ + "bounded-origin-cli/build/distributions/SHA256SUMS" \ + "bounded-origin-cli/build/distributions/SHA256SUMS.asc" \ + --clobber + + release_id="$(gh release view "$RELEASE_TAG" --json databaseId --jq '.databaseId')" + state_asset_id="$( + gh api "repos/$GITHUB_REPOSITORY/releases/$release_id/assets" \ + --jq '.[] | select(.name == "central-deployment-id.txt") | .id' | + head -n 1 + )" + if [[ -n "$state_asset_id" ]]; then + gh api --method DELETE "repos/$GITHUB_REPOSITORY/releases/assets/$state_asset_id" + fi + + gh release edit "$RELEASE_TAG" --draft=false + test "$(gh release view "$RELEASE_TAG" --json isDraft --jq '.isDraft')" = "false" From 56e962beec44d0b0d65b0633923687c95fe30b9d Mon Sep 17 00:00:00 2001 From: aalsanie Date: Tue, 29 Sep 2026 20:25:22 +0300 Subject: [PATCH 19/22] release: enforce publication workflow policy --- .github/workflows/release-verification.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/workflows/release-verification.yml b/.github/workflows/release-verification.yml index acf3f22..4e62ac1 100644 --- a/.github/workflows/release-verification.yml +++ b/.github/workflows/release-verification.yml @@ -11,6 +11,10 @@ on: - release-tests/** - scripts/verify-release-*.sh - .github/workflows/release-verification.yml + - .github/workflows/release.yml + - .github/workflows/central-preflight.yml + - scripts/central-portal.sh + - scripts/verify-release-workflow-policy.sh permissions: contents: read @@ -154,3 +158,14 @@ jobs: - name: Verify release artifact reproducibility shell: bash run: bash scripts/verify-release-reproducible.sh + + release-policy: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + + - name: Verify tag-only release policy + run: bash scripts/verify-release-workflow-policy.sh From 273b9042af7f7da3befc7fdd837337dadeec1354 Mon Sep 17 00:00:00 2001 From: aalsanie Date: Tue, 29 Sep 2026 20:26:26 +0300 Subject: [PATCH 20/22] docs: surface benchmark charts in README --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index d760e5c..d6b1a38 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,8 @@ workload, the packaged gateway, and independent origin-side work counts. It ran on Java 21 in a shared WSL2 Linux environment. The direct comparison uses the same origin, inputs and cost without the gateway or artifact reuse. +![Origin executions for equivalent requests as client concurrency increases.](bounded-origin-benchmarks/results/2026-09-28/generated/origin-executions.svg) + For 256 requests naming one operation at concurrency 64, across 10 measured repetitions, both gateway strategies used active capacity 1 and queue capacity 0: @@ -57,6 +59,8 @@ versus **20.4 ms** directly. +![Measured p99 latency for direct, bounded and materialized paths.](bounded-origin-benchmarks/results/2026-09-28/generated/latency.svg) + Warm and restarted materialization required no origin recomputation. Distinct-key pressure stayed within capacity while rejecting excess work. Route matching and semantic-key costs grew with configuration complexity. From 172630e5bc98316c441e76e5a8d0dd0357510538 Mon Sep 17 00:00:00 2001 From: aalsanie Date: Tue, 29 Sep 2026 20:28:13 +0300 Subject: [PATCH 21/22] release: make Central mutations non-retrying --- scripts/central-portal.sh | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/scripts/central-portal.sh b/scripts/central-portal.sh index 2e87f3b..b628201 100755 --- a/scripts/central-portal.sh +++ b/scripts/central-portal.sh @@ -18,6 +18,18 @@ require_authorization() { } request() { + local method="$1" + local url="$2" + shift 2 + require_authorization + curl --fail-with-body --silent --show-error \ + --connect-timeout 15 --max-time 120 \ + --request "$method" \ + --header "Authorization: $CENTRAL_AUTHORIZATION" \ + "$@" "$url" +} + +read_request() { local method="$1" local url="$2" shift 2 @@ -55,7 +67,7 @@ upload() { status() { local deployment_id="$1" - request POST "$BASE_URL/status?id=$deployment_id" + read_request POST "$BASE_URL/status?id=$deployment_id" } wait_state() { From 69ec95ab5ac10ccc77dff8d1b4e1e8aab3d0b8ec Mon Sep 17 00:00:00 2001 From: aalsanie Date: Wed, 30 Sep 2026 11:49:30 +0300 Subject: [PATCH 22/22] Update section header --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d6b1a38..32d8d85 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ semantic-key costs grew with configuration complexity. See [Benchmarks](BENCHMARKS.md) for charts, complete results, limitations and reproduction commands, including overload runs with unsent client drops. -## Try it +## Usage Download the **0.1.0 CLI distribution** from [Releases](https://github.com/aalsanie/bounded-origin/releases):