diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 645969b..b555528 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -8,6 +8,24 @@ on: branches: - main workflow_dispatch: + inputs: + retry_failed_deployment_id: + description: Exact FAILED Central deployment UUID authorizing a corrected same-version upload + required: false + type: string + resume_deployment_id: + description: Existing non-failed Central deployment UUID to resume polling without another upload + required: false + type: string + resume_commit_sha: + description: Exact 40-character commit SHA encoded in the resumed deployment name + required: false + type: string + repair_release_tag: + description: Move an existing unpublished release tag to the exact corrected publication commit + required: false + default: false + type: boolean permissions: contents: read @@ -37,6 +55,7 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 + ref: ${{ inputs.resume_commit_sha || github.sha }} - name: Grant execute permissions run: | @@ -109,7 +128,10 @@ jobs: RETRY_DELAY=30 for i in $(seq 1 $MAX_RETRIES); do echo "Attempt $i of $MAX_RETRIES" - if ./gradlew verifyExpectedSourceTasks check -x wasmJsBrowserTest --dependency-verification=strict --no-daemon --stacktrace; then + MAVEN_REPO="$RUNNER_TEMP/aether-verify-m2-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${i}" + rm -rf "$MAVEN_REPO" + mkdir -p "$MAVEN_REPO" + if ./gradlew -Dmaven.repo.local="$MAVEN_REPO" verifyExpectedSourceTasks verifyCentralPublicationArtifacts check -x wasmJsBrowserTest --dependency-verification=strict --no-daemon --stacktrace; then exit 0 fi if [ $i -lt $MAX_RETRIES ]; then @@ -194,6 +216,7 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 + ref: ${{ inputs.resume_commit_sha || github.sha }} - name: Grant execute permissions run: | @@ -211,6 +234,11 @@ jobs: - name: Read release version and publication state id: version shell: bash + env: + RETRY_FAILED_DEPLOYMENT_ID: ${{ inputs.retry_failed_deployment_id || '' }} + RESUME_DEPLOYMENT_ID: ${{ inputs.resume_deployment_id || '' }} + RESUME_COMMIT_SHA: ${{ inputs.resume_commit_sha || '' }} + REPAIR_RELEASE_TAG: ${{ inputs.repair_release_tag || false }} run: | VERSION=$(grep "^VERSION=" version.properties | cut -d'=' -f2) [[ -n "$VERSION" ]] || { echo "::error::version.properties has no VERSION"; exit 1; } @@ -219,43 +247,163 @@ jobs: echo "::error::The first CHANGELOG.md release ($CHANGELOG_VERSION) does not match version.properties ($VERSION)." exit 1 } + RELEASE_COMMIT=$(git rev-parse HEAD) echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "release_commit=$RELEASE_COMMIT" >> "$GITHUB_OUTPUT" + echo "deployment_name=aether-${VERSION}-${RELEASE_COMMIT}" >> "$GITHUB_OUTPUT" + + if [[ -n "$RETRY_FAILED_DEPLOYMENT_ID" && -n "$RESUME_DEPLOYMENT_ID" ]]; then + echo "::error::retry_failed_deployment_id and resume_deployment_id are mutually exclusive" + exit 1 + fi + if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" && -z "$RETRY_FAILED_DEPLOYMENT_ID" && -z "$RESUME_DEPLOYMENT_ID" ]]; then + echo "::error::Manual publication is recovery-only; provide a failed retry ID or an exact resume ID." + exit 1 + fi + for DEPLOYMENT_ID in "$RETRY_FAILED_DEPLOYMENT_ID" "$RESUME_DEPLOYMENT_ID"; do + if [[ -n "$DEPLOYMENT_ID" && ! "$DEPLOYMENT_ID" =~ ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$ ]]; then + echo "::error::Central deployment IDs must be UUIDs" + exit 1 + fi + done + if [[ -n "$RESUME_DEPLOYMENT_ID" ]]; then + [[ "$RESUME_COMMIT_SHA" =~ ^[0-9a-fA-F]{40}$ ]] || { + echo "::error::resume_commit_sha must be the exact 40-character upload commit" + exit 1 + } + [[ "${RESUME_COMMIT_SHA,,}" == "${RELEASE_COMMIT,,}" ]] || { + echo "::error::Checkout commit does not match resume_commit_sha" + exit 1 + } + elif [[ -n "$RESUME_COMMIT_SHA" ]]; then + echo "::error::resume_commit_sha is valid only with resume_deployment_id" + exit 1 + fi + if git rev-parse --verify --quiet "refs/tags/v${VERSION}" >/dev/null; then echo "tag_exists=true" >> "$GITHUB_OUTPUT" - echo "Release tag v${VERSION} already exists; publication is already complete." + TAG_EXISTS=true + TAG_COMMIT=$(git rev-list -n 1 "v${VERSION}") else echo "tag_exists=false" >> "$GITHUB_OUTPUT" + TAG_EXISTS=false + TAG_COMMIT="" + fi + TAG_NEEDS_REPAIR=false + UPLOAD_REQUIRED=false + + if [[ -n "$RETRY_FAILED_DEPLOYMENT_ID" ]]; then + [[ "$TAG_EXISTS" == "true" ]] || { + echo "::error::A failed-deployment retry is only allowed for an existing release tag." + exit 1 + } + git merge-base --is-ancestor "v${VERSION}" HEAD || { + echo "::error::Existing tag v${VERSION} is not an ancestor of the repair commit." + exit 1 + } + if [[ "$TAG_COMMIT" != "$RELEASE_COMMIT" ]]; then + [[ "$REPAIR_RELEASE_TAG" == "true" ]] || { + echo "::error::The corrected bundle differs from v${VERSION}; explicitly enable repair_release_tag." + exit 1 + } + TAG_NEEDS_REPAIR=true + fi + UPLOAD_REQUIRED=true + echo "publish_required=true" >> "$GITHUB_OUTPUT" + echo "upload_required=true" >> "$GITHUB_OUTPUT" + echo "release_mode=retry_failed" >> "$GITHUB_OUTPUT" + elif [[ -n "$RESUME_DEPLOYMENT_ID" ]]; then + git fetch --no-tags origin main + git merge-base --is-ancestor "$RELEASE_COMMIT" origin/main || { + echo "::error::The resumed release commit is not contained in origin/main." + exit 1 + } + if [[ "$TAG_EXISTS" == "true" ]]; then + git merge-base --is-ancestor "v${VERSION}" HEAD || { + echo "::error::Existing tag v${VERSION} is not an ancestor of the repair commit." + exit 1 + } + if [[ "$TAG_COMMIT" != "$RELEASE_COMMIT" ]]; then + [[ "$REPAIR_RELEASE_TAG" == "true" ]] || { + echo "::error::Resume would change v${VERSION}; explicitly enable repair_release_tag." + exit 1 + } + TAG_NEEDS_REPAIR=true + fi + fi + echo "publish_required=true" >> "$GITHUB_OUTPUT" + echo "upload_required=false" >> "$GITHUB_OUTPUT" + echo "release_mode=resume" >> "$GITHUB_OUTPUT" + elif [[ "$TAG_EXISTS" == "true" ]]; then + git merge-base --is-ancestor "v${VERSION}" HEAD || { + echo "::error::Existing tag v${VERSION} is not an ancestor of the current main commit." + exit 1 + } + [[ "$REPAIR_RELEASE_TAG" == "false" ]] || { + echo "::error::repair_release_tag is valid only for retry or resume recovery." + exit 1 + } + echo "publish_required=false" >> "$GITHUB_OUTPUT" + echo "upload_required=false" >> "$GITHUB_OUTPUT" + echo "release_mode=existing" >> "$GITHUB_OUTPUT" + echo "Release tag v${VERSION} already exists; Central upload is skipped." + else + [[ "$REPAIR_RELEASE_TAG" == "false" ]] || { + echo "::error::repair_release_tag requires an existing release tag." + exit 1 + } + UPLOAD_REQUIRED=true + echo "publish_required=true" >> "$GITHUB_OUTPUT" + echo "upload_required=true" >> "$GITHUB_OUTPUT" + echo "release_mode=new" >> "$GITHUB_OUTPUT" fi - - name: Ensure publishing secrets are present - if: steps.version.outputs.tag_exists != 'true' + if [[ "$TAG_NEEDS_REPAIR" == "true" ]]; then + UNEXPECTED_REPAIR_FILES=() + while IFS= read -r FILE; do + case "$FILE" in + .github/workflows/publish.yml|CHANGELOG.md|aether-plugin/build.gradle.kts|build.gradle.kts|docs/identity/deployment.md|e2e-tests/tests/config/scaffold.test.mjs|sign-artifact.sh) + ;; + *) + UNEXPECTED_REPAIR_FILES+=("$FILE") + ;; + esac + done < <(git diff --name-only "v${VERSION}"...HEAD) + if (( ${#UNEXPECTED_REPAIR_FILES[@]} > 0 )); then + echo "::error::Same-version recovery is restricted to reviewed publication metadata; unexpected files: ${UNEXPECTED_REPAIR_FILES[*]}" + exit 1 + fi + fi + echo "tag_needs_repair=$TAG_NEEDS_REPAIR" >> "$GITHUB_OUTPUT" + + if [[ "$GITHUB_RUN_ATTEMPT" != "1" && "$UPLOAD_REQUIRED" == "true" ]]; then + echo "::error::A rerun may duplicate an accepted Central upload. Start a fresh dispatch or resume an exact deployment ID." + exit 1 + fi + + - name: Ensure Central credentials are present + if: steps.version.outputs.publish_required == 'true' shell: bash + env: + CENTRAL_USERNAME: ${{ secrets.CENTRAL_USERNAME }} + CENTRAL_PASSWORD: ${{ secrets.CENTRAL_PASSWORD }} run: | - [[ -n "${{ secrets.CENTRAL_USERNAME }}" ]] || { echo "::error::Missing required secret CENTRAL_USERNAME"; exit 1; } - [[ -n "${{ secrets.CENTRAL_PASSWORD }}" ]] || { echo "::error::Missing required secret CENTRAL_PASSWORD"; exit 1; } - [[ -n "${{ secrets.CENTRAL_SIGNING_PASSWORD }}" ]] || { echo "::error::Missing required secret CENTRAL_SIGNING_PASSWORD"; exit 1; } - [[ -n "${{ secrets.CENTRAL_SIGNING_KEY_ASC }}" ]] || { echo "::error::Missing required secret CENTRAL_SIGNING_KEY_ASC"; exit 1; } + [[ -n "$CENTRAL_USERNAME" ]] || { echo "::error::Missing required secret CENTRAL_USERNAME"; exit 1; } + [[ -n "$CENTRAL_PASSWORD" ]] || { echo "::error::Missing required secret CENTRAL_PASSWORD"; exit 1; } - - name: Configure publishing credentials - if: steps.version.outputs.tag_exists != 'true' + - name: Configure signing key + if: steps.version.outputs.upload_required == 'true' shell: bash + env: + CENTRAL_SIGNING_PASSWORD: ${{ secrets.CENTRAL_SIGNING_PASSWORD }} + CENTRAL_SIGNING_KEY_ASC: ${{ secrets.CENTRAL_SIGNING_KEY_ASC }} run: | - cat <<'EOF' > local.properties - mavenCentralUsername=${{ secrets.CENTRAL_USERNAME }} - mavenCentralPassword=${{ secrets.CENTRAL_PASSWORD }} - signingPassword=${{ secrets.CENTRAL_SIGNING_PASSWORD }} - EOF - - SIGNING_SECRET="${{ secrets.CENTRAL_SIGNING_KEY_ASC }}" - if [[ -z "$SIGNING_SECRET" ]]; then - echo "::error::CENTRAL_SIGNING_KEY_ASC secret is not set" - exit 1 - fi - - if echo "$SIGNING_SECRET" | base64 -d > private-key.asc 2>/tmp/base64-decode.log; then + [[ -n "$CENTRAL_SIGNING_PASSWORD" ]] || { echo "::error::Missing required secret CENTRAL_SIGNING_PASSWORD"; exit 1; } + [[ -n "$CENTRAL_SIGNING_KEY_ASC" ]] || { echo "::error::Missing required secret CENTRAL_SIGNING_KEY_ASC"; exit 1; } + if printf '%s' "$CENTRAL_SIGNING_KEY_ASC" | base64 --decode > private-key.asc 2>/tmp/base64-decode.log; then echo "Imported signing key from base64-encoded secret" else - echo "$SIGNING_SECRET" > private-key.asc + printf '%s\n' "$CENTRAL_SIGNING_KEY_ASC" > private-key.asc echo "Stored signing key as literal ASCII-armored block" fi @@ -271,27 +419,110 @@ jobs: fi rm -rf "$TMP_GNUPGHOME" - - name: Publish Aether to Maven Central - if: steps.version.outputs.tag_exists != 'true' + - name: Validate Central recovery request + if: >- + steps.version.outputs.publish_required == 'true' && + (inputs.retry_failed_deployment_id != '' || inputs.resume_deployment_id != '') shell: bash + env: + CENTRAL_USERNAME: ${{ secrets.CENTRAL_USERNAME }} + CENTRAL_PASSWORD: ${{ secrets.CENTRAL_PASSWORD }} + RELEASE_VERSION: ${{ steps.version.outputs.version }} + EXPECTED_DEPLOYMENT_NAME: ${{ steps.version.outputs.deployment_name }} + RETRY_FAILED_DEPLOYMENT_ID: ${{ inputs.retry_failed_deployment_id || '' }} + RESUME_DEPLOYMENT_ID: ${{ inputs.resume_deployment_id || '' }} + run: | + ./gradlew writeExpectedCentralPurls --no-daemon --quiet + DEPLOYMENT_ID="${RETRY_FAILED_DEPLOYMENT_ID:-$RESUME_DEPLOYMENT_ID}" + export DEPLOYMENT_ID + AUTHORIZATION="Bearer $(printf '%s' "$CENTRAL_USERNAME:$CENTRAL_PASSWORD" | base64 | tr -d '\n\r')" + STATUS_JSON=$(curl --request POST \ + --url "https://central.sonatype.com/api/v1/publisher/status?id=${DEPLOYMENT_ID}" \ + --header "Authorization: ${AUTHORIZATION}" \ + --connect-timeout 10 --max-time 30 \ + --fail-with-body --silent --show-error) + export STATUS_JSON + + python3 <<'PY' + import json + import os + import sys + + data = json.loads(os.environ["STATUS_JSON"]) + version = os.environ["RELEASE_VERSION"] + deployment_id = os.environ["DEPLOYMENT_ID"] + retry_id = os.environ.get("RETRY_FAILED_DEPLOYMENT_ID", "") + expected_name = os.environ["EXPECTED_DEPLOYMENT_NAME"] + state = data.get("deploymentState") + purls = data.get("purls") or [] + with open("build/central-expected-purls.txt", encoding="utf-8") as source: + expected_purls = {line.strip() for line in source if line.strip()} + + if data.get("deploymentId") != deployment_id: + sys.exit("Central status response did not match the requested deployment") + if not retry_id and data.get("deploymentName") != expected_name: + sys.exit("resumed deployment name is not bound to the requested release commit") + + if retry_id: + if state != "FAILED": + sys.exit(f"retry deployment must be FAILED, received {state!r}") + elif state not in {"PENDING", "VALIDATING", "VALIDATED", "PUBLISHING", "PUBLISHED"}: + sys.exit(f"resume deployment is not resumable, received {state!r}") + + if len(purls) != len(set(purls)): + sys.exit("deployment contains duplicate component PURLs") + actual_purls = set(purls) + require_complete_manifest = bool(retry_id) or state == "PUBLISHED" or bool(actual_purls) + if require_complete_manifest and actual_purls != expected_purls: + missing = sorted(expected_purls - actual_purls) + unexpected = sorted(actual_purls - expected_purls) + sys.exit(f"deployment component mismatch; missing={missing}, unexpected={unexpected}") + print(f"Central recovery request verified: {data.get('deploymentId')} is {state}") + PY + + if [[ -n "$RETRY_FAILED_DEPLOYMENT_ID" ]]; then + HTTP_CODE=$(curl --head --silent --output /dev/null --write-out '%{http_code}' \ + --connect-timeout 10 --max-time 30 \ + "https://repo1.maven.org/maven2/codes/yousef/aether/aether-core/${RELEASE_VERSION}/aether-core-${RELEASE_VERSION}.pom") + case "$HTTP_CODE" in + 404) ;; + 200) echo "::error::Version ${RELEASE_VERSION} is already public and cannot be replaced"; exit 1 ;; + *) echo "::error::Could not prove version ${RELEASE_VERSION} is unpublished (HTTP ${HTTP_CODE})"; exit 1 ;; + esac + fi + + - name: Upload one Aether bundle to Maven Central + id: central_upload + if: steps.version.outputs.upload_required == 'true' + shell: bash + env: + mavenCentralUsername: ${{ secrets.CENTRAL_USERNAME }} + mavenCentralPassword: ${{ secrets.CENTRAL_PASSWORD }} + signingPassword: ${{ secrets.CENTRAL_SIGNING_PASSWORD }} + AETHER_CENTRAL_DEPLOYMENT_NAME: ${{ steps.version.outputs.deployment_name }} run: | MAVEN_REPO="$RUNNER_TEMP/aether-release-m2-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" mkdir -p "$MAVEN_REPO" - MAX_RETRIES=3 - RETRY_DELAY=30 - for i in $(seq 1 $MAX_RETRIES); do - echo "Attempt $i of $MAX_RETRIES" - if ./gradlew -Dmaven.repo.local="$MAVEN_REPO" publishToCentralPortalManually --no-daemon --stacktrace --info; then - exit 0 - fi - if [ $i -lt $MAX_RETRIES ]; then - echo "Publish failed, retrying in ${RETRY_DELAY}s..." - sleep $RETRY_DELAY - RETRY_DELAY=$((RETRY_DELAY * 2)) - fi - done - echo "Publish failed after $MAX_RETRIES attempts" - exit 1 + ./gradlew -Dmaven.repo.local="$MAVEN_REPO" uploadCentralPortalBundle --no-daemon --stacktrace + DEPLOYMENT_ID=$(tr -d '\n\r' < build/central-portal-deployment-id.txt) + [[ "$DEPLOYMENT_ID" =~ ^[0-9a-fA-F-]{36}$ ]] || { echo "::error::Central deployment ID was not recorded"; exit 1; } + echo "deployment_id=$DEPLOYMENT_ID" >> "$GITHUB_OUTPUT" + + - name: Wait for Maven Central publication + if: steps.version.outputs.publish_required == 'true' + shell: bash + env: + mavenCentralUsername: ${{ secrets.CENTRAL_USERNAME }} + mavenCentralPassword: ${{ secrets.CENTRAL_PASSWORD }} + AETHER_CENTRAL_DEPLOYMENT_ID: ${{ inputs.resume_deployment_id || steps.central_upload.outputs.deployment_id }} + AETHER_CENTRAL_DEPLOYMENT_NAME: ${{ steps.version.outputs.deployment_name }} + run: | + ./gradlew waitForCentralPortalPublication --no-daemon --stacktrace + + - name: Delete publishing credentials + if: always() + shell: bash + run: rm -f private-key.asc local.properties - name: Extract latest changelog entry id: changelog @@ -331,23 +562,31 @@ jobs: echo "notes=" >> "$GITHUB_OUTPUT" fi - - name: Create and push release tag - if: steps.version.outputs.tag_exists != 'true' + - name: Create or repair the release tag + if: steps.version.outputs.publish_required == 'true' shell: bash run: | VERSION="${{ steps.version.outputs.version }}" + RELEASE_COMMIT="${{ steps.version.outputs.release_commit }}" git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - if git rev-parse "v${VERSION}" >/dev/null 2>&1; then - echo "Tag v${VERSION} already exists, skipping." + if [[ "${{ steps.version.outputs.tag_needs_repair }}" == "true" ]]; then + OLD_TAG_OBJECT=$(git rev-parse "refs/tags/v${VERSION}") + git tag --force --annotate "v${VERSION}" "$RELEASE_COMMIT" --message "Aether ${VERSION}" + git push --force-with-lease="refs/tags/v${VERSION}:${OLD_TAG_OBJECT}" origin "refs/tags/v${VERSION}" + elif git rev-parse "v${VERSION}" >/dev/null 2>&1; then + [[ "$(git rev-list -n 1 "v${VERSION}")" == "$RELEASE_COMMIT" ]] || { + echo "::error::Existing release tag does not match the published commit" + exit 1 + } else - git tag -a "v${VERSION}" -m "Aether ${VERSION}" + git tag --annotate "v${VERSION}" "$RELEASE_COMMIT" --message "Aether ${VERSION}" git push origin "v${VERSION}" fi - name: Create GitHub release uses: softprops/action-gh-release@v2 - if: steps.changelog.outputs.notes != '' + if: steps.version.outputs.publish_required == 'true' && steps.changelog.outputs.notes != '' with: tag_name: v${{ steps.version.outputs.version }} name: Aether ${{ steps.version.outputs.version }} diff --git a/CHANGELOG.md b/CHANGELOG.md index af42f26..0bce2d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,13 @@ - Kotlin and the dependency stack are upgraded for Summon `0.7.0.2`; the CLI and example are now compiled from real Kotlin source sets and CI rejects expected tasks that report `NO-SOURCE`. +### Fixed + +- Maven Central publication now includes the Gradle plugin's real Kotlin sources, validates every + binary component's sources and Javadoc companions before upload, and waits for the Central + deployment to reach `PUBLISHED` before completing the release. Upload IDs are recorded before + polling so interrupted releases resume the exact commit without submitting a duplicate bundle. + ### Known limitations - The Kotlin `2.3.x` wasmWasi artifact is still a Preview1 core module. The WIT contract, guest diff --git a/aether-plugin/build.gradle.kts b/aether-plugin/build.gradle.kts index bd08789..0f6b84b 100644 --- a/aether-plugin/build.gradle.kts +++ b/aether-plugin/build.gradle.kts @@ -8,6 +8,7 @@ plugins { java { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 + withSourcesJar() } tasks.withType().configureEach { diff --git a/build.gradle.kts b/build.gradle.kts index 99180bc..dc9ab8a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,9 @@ +import groovy.json.JsonSlurper import java.security.MessageDigest import java.util.* +import java.util.zip.ZipEntry +import java.util.zip.ZipFile +import java.util.zip.ZipOutputStream plugins { alias(libs.plugins.kotlin.multiplatform) apply false @@ -193,289 +197,453 @@ tasks.register("verifyExpectedSourceTasks") { } } -// Custom task to bundle and publish artifacts to Maven Central Portal -tasks.register("publishToCentralPortalManually") { - group = "publishing" - description = "Publish to Maven Central using Central Portal API" - - dependsOn(subprojects.map { ":${it.name}:publishToMavenLocal" }) - - doLast { - val username = localProperties.getProperty("mavenCentralUsername") - ?: System.getenv("mavenCentralUsername") - ?: throw GradleException("mavenCentralUsername not found") - - val password = localProperties.getProperty("mavenCentralPassword") - ?: System.getenv("mavenCentralPassword") - ?: throw GradleException("mavenCentralPassword not found") +val centralPortalModules = listOf( + "aether-core", + "aether-signals", + "aether-tasks", + "aether-channels", + "aether-db", + "aether-web", + "aether-ui", + "aether-net", + "aether-ksp", + "aether-plugin", + "aether-auth", + "aether-auth-postgresql", + "aether-auth-firestore", + "aether-auth-summon", + "aether-auth-oidc", + "aether-auth-saml", + "aether-auth-scim", + "aether-forms", + "aether-admin", + "aether-grpc" +) +val centralPortalVariants = listOf("", "-jvm", "-wasm-js", "-wasm-wasi") +val centralPluginMarkerArtifactId = "codes.yousef.aether.plugin.gradle.plugin" +val centralPluginMarkerGroupPath = "codes/yousef/aether/plugin" +val centralPortalArtifactIds = centralPortalModules.flatMap { module -> + when (module) { + "aether-plugin" -> listOf(module) + "aether-ksp" -> listOf(module, "$module-jvm") + "aether-auth-summon" -> listOf(module, "$module-jvm", "$module-wasm-js") + else -> centralPortalVariants.map { variant -> "$module$variant" } + } +} +val centralDeploymentIdPattern = Regex( + "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" +) - println("🚀 Publishing to Maven Central via Central Portal API...") - println("📦 Username: $username") +fun centralExpectedPurls(version: String): Set = buildSet { + centralPortalArtifactIds.forEach { artifactId -> + add("pkg:maven/codes.yousef.aether/$artifactId@$version") + } + add("pkg:maven/codes.yousef.aether.plugin/$centralPluginMarkerArtifactId@$version") +} - // Create bundle directory with proper Maven structure - val bundleDir = file("${layout.buildDirectory.get()}/central-portal-bundle") - bundleDir.deleteRecursively() - bundleDir.mkdirs() +val writeExpectedCentralPurls by tasks.registering { + group = "publishing" + description = "Writes the exact component set expected in a Central Portal deployment." + val outputFile = layout.buildDirectory.file("central-expected-purls.txt") + outputs.file(outputFile) - // List of modules to publish - val modules = listOf( - "aether-core", - "aether-signals", - "aether-tasks", - "aether-channels", - "aether-db", - "aether-web", - "aether-ui", - "aether-net", - "aether-ksp", - "aether-plugin", - "aether-auth", - "aether-auth-postgresql", - "aether-auth-firestore", - "aether-auth-summon", - "aether-auth-oidc", - "aether-auth-saml", - "aether-auth-scim", - "aether-forms", - "aether-admin", - "aether-grpc" - ) - // List of variants for each module - val variants = listOf("", "-jvm", "-wasm-js", "-wasm-wasi") + doLast { + outputFile.get().asFile.apply { + parentFile.mkdirs() + writeText(centralExpectedPurls(project.version.toString()).sorted().joinToString("\n", postfix = "\n")) + } + } +} - val allFilesToProcess = mutableListOf() - var foundModuleCount = 0 - var missingModuleCount = 0 - val missingRequiredCoordinates = mutableListOf() +val verifyCentralPublicationArtifacts by tasks.registering { + group = "verification" + description = "Verifies every Maven Central component has its required primary, sources, and Javadoc artifacts." + dependsOn(centralPortalModules.map { ":$it:publishToMavenLocal" }) + doLast { + val releaseVersion = project.version.toString() val mavenLocalRepository = System.getProperty("maven.repo.local") ?.takeIf { it.isNotBlank() } - ?.let { file(it) } + ?.let(::file) ?: file("${System.getProperty("user.home")}/.m2/repository") - val mavenLocalRoot = file("$mavenLocalRepository/codes/yousef/aether") - println("📂 Looking for artifacts in: $mavenLocalRoot") + val failures = mutableListOf() + var validatedCoordinates = 0 + + fun verifyCoordinate(groupPath: String, artifactId: String, required: Boolean) { + val coordinate = "${groupPath.replace('/', '.')}:$artifactId:$releaseVersion" + val publicationDir = file("$mavenLocalRepository/$groupPath/$artifactId/$releaseVersion") + if (!publicationDir.isDirectory) { + if (required) failures += "$coordinate: publication directory is missing" + return + } - if (!mavenLocalRoot.exists()) { - throw GradleException("Maven local repository path does not exist: $mavenLocalRoot. Did publishToMavenLocal run?") - } + val prefix = "$artifactId-$releaseVersion" + val pom = File(publicationDir, "$prefix.pom") + if (!pom.isFile || pom.length() == 0L) { + failures += "$coordinate: POM is missing or empty" + return + } - modules.forEach { module -> - variants.forEach { variant -> - val artifactId = "$module$variant" - val version = project.version.toString() - - // Construct path in local repo - val localMavenDir = file("${mavenLocalRoot.absolutePath}/$artifactId/$version") - - if (localMavenDir.exists()) { - foundModuleCount++ - val mavenPath = "codes/yousef/aether/$artifactId/$version" - val targetDir = file("$bundleDir/$mavenPath") - targetDir.mkdirs() - - val files = localMavenDir.listFiles() ?: emptyArray() - val artifactFiles = files.filter { file -> - (file.name.endsWith(".jar") || file.name.endsWith(".pom") || file.name.endsWith(".klib") || file.name.endsWith( - ".module" - )) && - !file.name.endsWith(".md5") && !file.name.endsWith(".sha1") && !file.name.endsWith(".asc") - } + val pomOnly = Regex("\\s*pom\\s*", RegexOption.IGNORE_CASE) + .containsMatchIn(pom.readText()) + if (pomOnly) { + validatedCoordinates++ + return + } - if (artifactFiles.isNotEmpty()) { - println("📦 Processing $artifactId (${artifactFiles.size} files)...") - artifactFiles.forEach { file -> - file.copyTo(File(targetDir, file.name), overwrite = true) - allFilesToProcess.add(File(targetDir, file.name)) - } - } else { - println("⚠️ Directory exists but no artifact files found for $artifactId") - if (variant.isEmpty()) { - missingRequiredCoordinates.add("codes.yousef.aether:$artifactId:${project.version}") + val primaryArtifacts = listOf( + File(publicationDir, "$prefix.jar"), + File(publicationDir, "$prefix.klib") + ).filter { it.isFile && it.length() > 0L } + if (primaryArtifacts.isEmpty()) { + failures += "$coordinate: primary JAR or KLIB is missing or empty" + } + + val sourcesJar = File(publicationDir, "$prefix-sources.jar") + if (!sourcesJar.isFile || sourcesJar.length() == 0L) { + failures += "$coordinate: sources JAR is missing or empty" + } else if (artifactId == "aether-plugin") { + val hasSources = runCatching { + ZipFile(sourcesJar).use { archive -> + archive.entries().asSequence().any { entry -> + !entry.isDirectory && + (entry.name.endsWith(".kt") || entry.name.endsWith(".java")) } } - } else { - missingModuleCount++ - // Only warn for base modules without variant suffix, as variants may not exist for all modules - if (variant.isEmpty()) { - println("⚠️ No artifacts found for $artifactId at $localMavenDir") - missingRequiredCoordinates.add("codes.yousef.aether:$artifactId:${project.version}") - } - } + }.getOrDefault(false) + if (!hasSources) failures += "$coordinate: sources JAR contains no Kotlin or Java source" } + + val javadocJar = File(publicationDir, "$prefix-javadoc.jar") + if (!javadocJar.isFile || javadocJar.length() == 0L) { + failures += "$coordinate: Javadoc JAR is missing or empty" + } + validatedCoordinates++ } - // java-gradle-plugin publishes a marker coordinate outside the project group. Include it - // alongside the plugin implementation so the plugins DSL can resolve the released ID. - val pluginMarkerArtifactId = "codes.yousef.aether.plugin.gradle.plugin" - val pluginMarkerGroupPath = "codes/yousef/aether/plugin" - val pluginMarkerCoordinate = "codes.yousef.aether.plugin:$pluginMarkerArtifactId:${project.version}" - val pluginMarkerSource = file( - "$mavenLocalRepository/$pluginMarkerGroupPath/$pluginMarkerArtifactId/${project.version}" + centralPortalArtifactIds.forEach { artifactId -> + verifyCoordinate( + groupPath = "codes/yousef/aether", + artifactId = artifactId, + required = true + ) + } + verifyCoordinate( + groupPath = centralPluginMarkerGroupPath, + artifactId = centralPluginMarkerArtifactId, + required = true ) - val pluginMarkerFiles = pluginMarkerSource.listFiles() - ?.filter { file -> - (file.name.endsWith(".jar") || file.name.endsWith(".pom") || - file.name.endsWith(".klib") || file.name.endsWith(".module")) && - !file.name.endsWith(".md5") && !file.name.endsWith(".sha1") && - !file.name.endsWith(".asc") - } - .orEmpty() - if (pluginMarkerFiles.isEmpty()) { - missingRequiredCoordinates.add(pluginMarkerCoordinate) - } else { - foundModuleCount++ - val targetDir = file( - "$bundleDir/$pluginMarkerGroupPath/$pluginMarkerArtifactId/${project.version}" + + if (failures.isNotEmpty()) { + throw GradleException( + "Maven Central publication preflight failed:\n" + + failures.distinct().sorted().joinToString("\n") { " - $it" } ) - targetDir.mkdirs() - println("📦 Processing Gradle plugin marker (${pluginMarkerFiles.size} files)...") - pluginMarkerFiles.forEach { source -> + } + println("Verified $validatedCoordinates Maven Central publication coordinates in $mavenLocalRepository") + } +} + +val prepareCentralPortalBundle by tasks.registering { + group = "publishing" + description = "Builds, validates, signs, and archives the exact Central Portal publication set." + dependsOn(verifyCentralPublicationArtifacts, writeExpectedCentralPurls) + + doLast { + val bundleDir = layout.buildDirectory.dir("central-portal-bundle").get().asFile + bundleDir.deleteRecursively() + bundleDir.mkdirs() + + val mavenLocalRepository = System.getProperty("maven.repo.local") + ?.takeIf(String::isNotBlank) + ?.let(::file) + ?: file("${System.getProperty("user.home")}/.m2/repository") + val releaseVersion = project.version.toString() + val allFilesToProcess = mutableListOf() + val missingCoordinates = mutableListOf() + + fun collectCoordinate(groupPath: String, artifactId: String) { + val sourceDir = file("$mavenLocalRepository/$groupPath/$artifactId/$releaseVersion") + val artifactFiles = sourceDir.listFiles().orEmpty().filter { source -> + source.extension in setOf("jar", "pom", "klib", "module") + } + if (artifactFiles.isEmpty()) { + missingCoordinates += "${groupPath.replace('/', '.')}:$artifactId:$releaseVersion" + return + } + val targetDir = file("$bundleDir/$groupPath/$artifactId/$releaseVersion").apply(File::mkdirs) + artifactFiles.forEach { source -> val target = File(targetDir, source.name) source.copyTo(target, overwrite = true) - allFilesToProcess.add(target) + allFilesToProcess += target } } - println("📊 Found $foundModuleCount module variants, $missingModuleCount not found") + centralPortalArtifactIds.forEach { artifactId -> + collectCoordinate("codes/yousef/aether", artifactId) + } + collectCoordinate(centralPluginMarkerGroupPath, centralPluginMarkerArtifactId) - if (missingRequiredCoordinates.isNotEmpty()) { + if (missingCoordinates.isNotEmpty()) { throw GradleException( - "Required Maven publications are missing or empty: " + - missingRequiredCoordinates.distinct().sorted().joinToString() + "Required Maven publications are missing: " + missingCoordinates.sorted().joinToString() ) } - if (allFilesToProcess.isEmpty()) { - throw GradleException("No Maven artifacts found. Make sure publishToMavenLocal ran successfully.") - } - - println("📝 Generating checksums and signatures...") - - allFilesToProcess.forEach { file -> - // Generate MD5 checksum - val md5Hash = MessageDigest.getInstance("MD5") - .digest(file.readBytes()) + val privateKeyFile = rootProject.file("private-key.asc") + if (!privateKeyFile.isFile) throw GradleException("private-key.asc not found; cannot sign artifacts") + val signScript = rootProject.file("sign-artifact.sh") + if (!signScript.isFile) throw GradleException("sign-artifact.sh not found; cannot sign artifacts") + val signingPassword = localProperties.getProperty("signingPassword") + ?: System.getenv("signingPassword") + ?: throw GradleException("signingPassword not found") + + println("Generating checksums and signatures for ${allFilesToProcess.size} artifacts...") + allFilesToProcess.forEach { source -> + val md5 = MessageDigest.getInstance("MD5").digest(source.readBytes()) .joinToString("") { byte -> "%02x".format(byte) } - File(file.parent, "${file.name}.md5").writeText(md5Hash) - - // Generate SHA1 checksum - val sha1Hash = MessageDigest.getInstance("SHA-1") - .digest(file.readBytes()) + File(source.parent, "${source.name}.md5").writeText(md5) + val sha1 = MessageDigest.getInstance("SHA-1").digest(source.readBytes()) .joinToString("") { byte -> "%02x".format(byte) } - File(file.parent, "${file.name}.sha1").writeText(sha1Hash) - - // Generate GPG signature - val sigFile = File(file.parent, "${file.name}.asc") - println(" Creating GPG signature for ${file.name}...") - - val privateKeyFile = rootProject.file("private-key.asc") - if (!privateKeyFile.exists()) { - throw GradleException("private-key.asc not found. Cannot sign artifacts.") - } - - val signScript = rootProject.file("sign-artifact.sh") - if (!signScript.exists()) { - throw GradleException("sign-artifact.sh not found. Cannot sign artifacts.") - } - - val signingPassword = localProperties.getProperty("signingPassword") - ?: System.getenv("signingPassword") - ?: throw GradleException("signingPassword not found") + File(source.parent, "${source.name}.sha1").writeText(sha1) exec { + environment("AETHER_SIGNING_PASSPHRASE", signingPassword) commandLine( "bash", signScript.absolutePath, - signingPassword, privateKeyFile.absolutePath, - sigFile.absolutePath, - file.absolutePath + File(source.parent, "${source.name}.asc").absolutePath, + source.absolutePath ) } } - // Verify we have files to publish - val filesInBundle = bundleDir.walkTopDown().filter { it.isFile }.toList() - println("📦 Total files in bundle: ${filesInBundle.size}") - if (filesInBundle.isEmpty()) { - throw GradleException("Bundle directory is empty. No artifacts to publish.") - } - - // Show bundle structure for debugging - println("📋 Bundle contents:") - filesInBundle.take(20).forEach { println(" - ${it.relativeTo(bundleDir)}") } - if (filesInBundle.size > 20) { - println(" ... and ${filesInBundle.size - 20} more files") - } - - println("📦 Zipping bundle...") - val zipFile = file("${layout.buildDirectory.get()}/central-portal-bundle.zip") - if (zipFile.exists()) zipFile.delete() - - exec { - workingDir = bundleDir - commandLine("zip", "-r", zipFile.absolutePath, ".") + val filesInBundle = bundleDir.walkTopDown().filter(File::isFile) + .sortedBy { it.relativeTo(bundleDir).invariantSeparatorsPath } + .toList() + if (filesInBundle.isEmpty()) throw GradleException("Central Portal bundle is empty") + + val zipFile = layout.buildDirectory.file("central-portal-bundle.zip").get().asFile + zipFile.parentFile.mkdirs() + ZipOutputStream(zipFile.outputStream().buffered()).use { zip -> + filesInBundle.forEach { source -> + val entry = ZipEntry(source.relativeTo(bundleDir).invariantSeparatorsPath).apply { time = 0L } + zip.putNextEntry(entry) + source.inputStream().buffered().use { input -> input.copyTo(zip) } + zip.closeEntry() + } } + if (zipFile.length() == 0L) throw GradleException("Central Portal bundle ZIP is empty") + println("Prepared ${filesInBundle.size} bundle entries (${zipFile.length() / 1024} KiB)") + } +} - val bundleSizeKB = zipFile.length() / 1024 - println("📋 Bundle size: $bundleSizeKB KB") +val uploadCentralPortalBundle by tasks.registering { + group = "publishing" + description = "Uploads exactly one prepared bundle and records the Central deployment ID." + dependsOn(prepareCentralPortalBundle) - if (bundleSizeKB == 0L) { - throw GradleException("Bundle zip file is empty!") + doFirst { + if (!System.getenv("AETHER_CENTRAL_DEPLOYMENT_ID").isNullOrBlank()) { + throw GradleException( + "uploadCentralPortalBundle cannot resume an existing deployment; " + + "run waitForCentralPortalPublication with that deployment ID" + ) } + } - println("🚀 Uploading to Central Portal...") - - // Base64 encode credentials for UserToken auth - val userPass = "$username:$password" - val userPassBase64 = java.util.Base64.getEncoder().encodeToString(userPass.toByteArray()) - - // Temp files for curl output - val responseFile = file("${layout.buildDirectory.get()}/central-portal-response.txt") - val httpCodeFile = file("${layout.buildDirectory.get()}/central-portal-httpcode.txt") - val curlStderrFile = file("${layout.buildDirectory.get()}/central-portal-curl-stderr.txt") - - // Upload using curl with proper error handling - exec { + doLast { + val username = localProperties.getProperty("mavenCentralUsername") + ?: System.getenv("mavenCentralUsername") + ?: throw GradleException("mavenCentralUsername not found") + val password = localProperties.getProperty("mavenCentralPassword") + ?: System.getenv("mavenCentralPassword") + ?: throw GradleException("mavenCentralPassword not found") + val deploymentName = System.getenv("AETHER_CENTRAL_DEPLOYMENT_NAME") + ?.trim()?.takeIf { it.matches(Regex("^[A-Za-z0-9._-]{1,128}$")) } + ?: throw GradleException("AETHER_CENTRAL_DEPLOYMENT_NAME must contain only letters, digits, dot, underscore, or hyphen") + val authorization = "Bearer " + Base64.getEncoder() + .encodeToString("$username:$password".toByteArray()) + val zipFile = layout.buildDirectory.file("central-portal-bundle.zip").get().asFile + if (!zipFile.isFile || zipFile.length() == 0L) throw GradleException("Prepared bundle ZIP is missing") + + val responseFile = layout.buildDirectory.file("central-portal-response.txt").get().asFile + val httpCodeFile = layout.buildDirectory.file("central-portal-httpcode.txt").get().asFile + val stderrFile = layout.buildDirectory.file("central-portal-curl-stderr.txt").get().asFile + val uploadResult = exec { + isIgnoreExitValue = true + environment("AETHER_CENTRAL_AUTHORIZATION", authorization) commandLine( "bash", "-c", """ + CURL_EXIT=0 HTTP_CODE=${'$'}(curl --request POST \ - --url "https://central.sonatype.com/api/v1/publisher/upload?publishingType=AUTOMATIC" \ - --header "Authorization: UserToken $userPassBase64" \ + --url "https://central.sonatype.com/api/v1/publisher/upload?name=$deploymentName&publishingType=AUTOMATIC" \ + --header "Authorization: ${'$'}AETHER_CENTRAL_AUTHORIZATION" \ --form "bundle=@${zipFile.absolutePath}" \ + --connect-timeout 15 \ + --max-time 600 \ --write-out "%{http_code}" \ --output "${responseFile.absolutePath}" \ - --fail-with-body \ - --silent \ - 2>"${curlStderrFile.absolutePath}") + --silent --show-error \ + 2>"${stderrFile.absolutePath}") || CURL_EXIT=${'$'}? echo "${'$'}HTTP_CODE" > "${httpCodeFile.absolutePath}" - echo "HTTP Code: ${'$'}HTTP_CODE" - echo "Response:" - cat "${responseFile.absolutePath}" || echo "(empty)" - echo "" - if [ "${'$'}HTTP_CODE" -ge 200 ] && [ "${'$'}HTTP_CODE" -lt 300 ]; then - exit 0 - else - echo "Curl stderr:" - cat "${curlStderrFile.absolutePath}" || echo "(empty)" - exit 1 - fi + exit "${'$'}CURL_EXIT" """.trimIndent() ) } - // Read and display results - val httpCode = if (httpCodeFile.exists()) httpCodeFile.readText().trim() else "unknown" - val response = if (responseFile.exists()) responseFile.readText().trim() else "" + val httpCode = httpCodeFile.takeIf(File::isFile)?.readText()?.trim() ?: "unknown" + val response = responseFile.takeIf(File::isFile)?.readText()?.trim().orEmpty() + val numericHttpCode = httpCode.toIntOrNull() + val definitelyRejected = uploadResult.exitValue == 0 && + numericHttpCode in 400..499 && numericHttpCode !in setOf(408, 409, 425, 429) + if (uploadResult.exitValue != 0 || httpCode != "201" || !centralDeploymentIdPattern.matches(response)) { + if (definitelyRejected) { + val stderr = stderrFile.takeIf(File::isFile)?.readText()?.trim().orEmpty() + throw GradleException( + "Central definitively rejected the upload (HTTP $httpCode): " + + "${response.take(1000)} ${stderr.take(1000)}" + ) + } + throw GradleException( + "Central upload outcome is indeterminate (curl ${uploadResult.exitValue}, HTTP $httpCode). " + + "Do not upload again. Find deployment '$deploymentName' in Central Portal and resume by ID." + ) + } + + val deploymentIdFile = layout.buildDirectory.file("central-portal-deployment-id.txt").get().asFile + deploymentIdFile.writeText("$response\n") + layout.buildDirectory.file("central-portal-deployment-name.txt").get().asFile + .writeText("$deploymentName\n") + System.getenv("GITHUB_STEP_SUMMARY")?.takeIf(String::isNotBlank)?.let { summaryPath -> + File(summaryPath).appendText("Central deployment: `$response` (`$deploymentName`)\n") + } + println("CENTRAL_DEPLOYMENT_ID=$response") + println("Central accepted deployment $response as $deploymentName") + } +} - println("📋 HTTP Status Code: $httpCode") - println("📋 Response Body: $response") +val waitForCentralPortalPublication by tasks.registering { + group = "publishing" + description = "Polls one known Central deployment until it reaches PUBLISHED or FAILED." + dependsOn(writeExpectedCentralPurls) - if (httpCode.toIntOrNull()?.let { it < 200 || it >= 300 } != false) { - val stderr = if (curlStderrFile.exists()) curlStderrFile.readText().trim() else "" - throw GradleException("Failed to upload to Maven Central. HTTP Code: $httpCode, Response: $response, Stderr: $stderr") + doLast { + val username = localProperties.getProperty("mavenCentralUsername") + ?: System.getenv("mavenCentralUsername") + ?: throw GradleException("mavenCentralUsername not found") + val password = localProperties.getProperty("mavenCentralPassword") + ?: System.getenv("mavenCentralPassword") + ?: throw GradleException("mavenCentralPassword not found") + val authorization = "Bearer " + Base64.getEncoder() + .encodeToString("$username:$password".toByteArray()) + val deploymentIdFile = layout.buildDirectory.file("central-portal-deployment-id.txt").get().asFile + val deploymentId = System.getenv("AETHER_CENTRAL_DEPLOYMENT_ID") + ?.trim()?.takeIf(String::isNotEmpty) + ?: deploymentIdFile.takeIf(File::isFile)?.readText()?.trim() + ?: throw GradleException("AETHER_CENTRAL_DEPLOYMENT_ID or a recorded deployment ID is required") + if (!centralDeploymentIdPattern.matches(deploymentId)) { + throw GradleException("Central deployment ID is not a UUID") } + val deploymentNameFile = layout.buildDirectory.file("central-portal-deployment-name.txt").get().asFile + val expectedDeploymentName = System.getenv("AETHER_CENTRAL_DEPLOYMENT_NAME") + ?.trim()?.takeIf(String::isNotEmpty) + ?: deploymentNameFile.takeIf(File::isFile)?.readText()?.trim() + ?: throw GradleException("AETHER_CENTRAL_DEPLOYMENT_NAME or a recorded deployment name is required") + val expectedPurls = centralExpectedPurls(project.version.toString()) + val timeoutSeconds = System.getenv("AETHER_CENTRAL_STATUS_TIMEOUT_SECONDS") + ?.toLongOrNull()?.coerceIn(60L, 7200L) ?: 1800L + val pollSeconds = System.getenv("AETHER_CENTRAL_STATUS_POLL_SECONDS") + ?.toLongOrNull()?.coerceIn(5L, 60L) ?: 10L + val deadline = System.nanoTime() + timeoutSeconds * 1_000_000_000L + val statusFile = layout.buildDirectory.file("central-portal-status.json").get().asFile + val httpCodeFile = layout.buildDirectory.file("central-portal-status-httpcode.txt").get().asFile + val stderrFile = layout.buildDirectory.file("central-portal-status-stderr.txt").get().asFile + + while (true) { + if (System.nanoTime() >= deadline) { + throw GradleException( + "Timed out waiting for Central deployment $deploymentId; resume this exact deployment ID" + ) + } + val result = exec { + isIgnoreExitValue = true + environment("AETHER_CENTRAL_AUTHORIZATION", authorization) + commandLine( + "bash", "-c", """ + CURL_EXIT=0 + HTTP_CODE=${'$'}(curl --request POST \ + --url "https://central.sonatype.com/api/v1/publisher/status?id=$deploymentId" \ + --header "Authorization: ${'$'}AETHER_CENTRAL_AUTHORIZATION" \ + --connect-timeout 10 \ + --max-time 30 \ + --write-out "%{http_code}" \ + --output "${statusFile.absolutePath}" \ + --silent --show-error \ + 2>"${stderrFile.absolutePath}") || CURL_EXIT=${'$'}? + echo "${'$'}HTTP_CODE" > "${httpCodeFile.absolutePath}" + exit "${'$'}CURL_EXIT" + """.trimIndent() + ) + } + val httpCode = httpCodeFile.takeIf(File::isFile)?.readText()?.trim() ?: "unknown" + if (result.exitValue != 0 || httpCode.toIntOrNull() !in 200..299) { + val retryable = result.exitValue != 0 || httpCode == "429" || httpCode.toIntOrNull() in 500..599 + if (!retryable) { + val stderr = stderrFile.takeIf(File::isFile)?.readText()?.trim().orEmpty() + throw GradleException( + "Central status request failed for $deploymentId (curl ${result.exitValue}, " + + "HTTP $httpCode): ${stderr.take(1000)}" + ) + } + println("Central status is transiently unavailable; retrying in $pollSeconds seconds") + } else { + val body = statusFile.readText() + val status = runCatching { JsonSlurper().parseText(body) as? Map<*, *> }.getOrNull() + ?: throw GradleException("Central returned malformed status JSON for $deploymentId") + val returnedId = status["deploymentId"] as? String + val returnedName = status["deploymentName"] as? String + val state = status["deploymentState"] as? String + ?: throw GradleException("Central status has no deploymentState for $deploymentId") + val purlList = (status["purls"] as? List<*>)?.filterIsInstance().orEmpty() + val purls = purlList.toSet() + if (returnedId != deploymentId) throw GradleException("Central status returned a different deployment ID") + if (returnedName != expectedDeploymentName) { + throw GradleException("Central status returned unexpected deployment name '$returnedName'") + } + if (purlList.size != purls.size) throw GradleException("Central status contains duplicate PURLs") + if (purls.isNotEmpty() && purls != expectedPurls) { + throw GradleException( + "Central deployment component mismatch; missing=${(expectedPurls - purls).sorted()}, " + + "unexpected=${(purls - expectedPurls).sorted()}" + ) + } - println("✅ Upload complete!") + println("Central deployment $deploymentId is $state") + when (state) { + "PUBLISHED" -> { + if (purls != expectedPurls) { + throw GradleException("Published deployment did not expose the exact expected component set") + } + println("Maven Central deployment $deploymentId is PUBLISHED") + break + } + "FAILED" -> throw GradleException( + "Central deployment $deploymentId failed: ${status["errors"].toString().take(4000)}" + ) + "PENDING", "VALIDATING", "VALIDATED", "PUBLISHING" -> Unit + else -> throw GradleException("Central deployment $deploymentId returned unknown state '$state'") + } + } + Thread.sleep(pollSeconds * 1000L) + } } } + +tasks.register("publishToCentralPortalManually") { + group = "publishing" + description = "Prepares, uploads once, and waits for one Central Portal deployment." + dependsOn(uploadCentralPortalBundle, waitForCentralPortalPublication) + waitForCentralPortalPublication.configure { mustRunAfter(uploadCentralPortalBundle) } +} diff --git a/docs/identity/deployment.md b/docs/identity/deployment.md index 4930981..c13b21c 100644 --- a/docs/identity/deployment.md +++ b/docs/identity/deployment.md @@ -340,7 +340,18 @@ intentional property. ## Release verification Pushes to `main` publish after the automated workflow verification succeeds. `workflow_dispatch` -remains a fallback for rerunning a release from `main`; it is not required for normal publication. +is reserved for a guarded failed-deployment retry or exact-ID resume; it is not required for normal +publication and must not be used to repeat an upload with an unknown outcome. +Before any upload, the workflow publishes into an isolated local Maven repository and verifies that +every non-POM component has its exact primary artifact, sources JAR, and Javadoc JAR. A Central +upload is not considered complete at HTTP acceptance: the workflow polls the deployment until it +reaches `PUBLISHED`, and a validation failure prevents release completion. Exceptional recovery of +an already-tagged but unpublished release requires the exact authenticated `FAILED` deployment ID +and explicit authorization to move the GitHub tag to the corrected commit. Upload and polling are +separate steps: the accepted deployment UUID is recorded immediately and an interrupted deployment +is resumed by that UUID plus the exact commit encoded in its deterministic deployment name. Never +rerun an ambiguous upload. Recovery compares Central's complete component set with the 75-coordinate +release manifest before it can repair a tag or update the GitHub release. Automated verification includes JVM, wasmJs and wasmWasi guest protocol/crypto tests, the native OpenSSL host-library tests, PostgreSQL 16 and Firestore conformance/race suites, Summon browser tests, federation adversarial suites, and the complete example build. Production wasmWasi diff --git a/e2e-tests/tests/config/scaffold.test.mjs b/e2e-tests/tests/config/scaffold.test.mjs index ea2a45d..ecd7f53 100644 --- a/e2e-tests/tests/config/scaffold.test.mjs +++ b/e2e-tests/tests/config/scaffold.test.mjs @@ -76,7 +76,11 @@ test('successful main verification publishes automatically and only once per ver assert.match(workflow, /pull_request:\n\s+branches:\n\s+- main/); assert.match(workflow, /push:\n\s+branches:\n\s+- main/); - assert.match(workflow, /workflow_dispatch:\n\npermissions:/); + assert.match(workflow, /workflow_dispatch:\n\s+inputs:/); + assert.match(workflow, /retry_failed_deployment_id:/); + assert.match(workflow, /resume_deployment_id:/); + assert.match(workflow, /resume_commit_sha:/); + assert.match(workflow, /repair_release_tag:/); assert.doesNotMatch(workflow, /hardware_passkey_smoke_|adversarial_review_/); assert.match( workflow, @@ -89,22 +93,90 @@ test('successful main verification publishes automatically and only once per ver ); assert.match( workflow, - /Read release version and publication state[\s\S]*?refs\/tags\/v\$\{VERSION\}[\s\S]*?tag_exists=true[\s\S]*?tag_exists=false/ + /Read release version and publication state[\s\S]*?refs\/tags\/v\$\{VERSION\}[\s\S]*?tag_exists=true[\s\S]*?tag_exists=false[\s\S]*?publish_required=true[\s\S]*?publish_required=false/ ); assert.match(workflow, /CHANGELOG_VERSION[\s\S]*?does not match version\.properties/); + assert.match(workflow, /retry_failed_deployment_id and resume_deployment_id are mutually exclusive/); + assert.match(workflow, /Manual publication is recovery-only; provide a failed retry ID or an exact resume ID/); + assert.match(workflow, /resume_commit_sha must be the exact 40-character upload commit/); + assert.match(workflow, /A rerun may duplicate an accepted Central upload/); + assert.match(workflow, /Same-version recovery is restricted to reviewed publication metadata/); + assert.match(workflow, /git diff --name-only "v\$\{VERSION\}"\.\.\.HEAD/); + assert.match(workflow, /git merge-base --is-ancestor "v\$\{VERSION\}" HEAD/); assert.match( workflow, - /- name: Publish Aether to Maven Central\n\s+if: steps\.version\.outputs\.tag_exists != 'true'/ + /- name: Upload one Aether bundle to Maven Central[\s\S]*?if: steps\.version\.outputs\.upload_required == 'true'/ ); assert.match( workflow, - /- name: Create and push release tag\n\s+if: steps\.version\.outputs\.tag_exists != 'true'/ + /- name: Wait for Maven Central publication[\s\S]*?if: steps\.version\.outputs\.publish_required == 'true'/ ); + assert.match( + workflow, + /- name: Create or repair the release tag\n\s+if: steps\.version\.outputs\.publish_required == 'true'/ + ); + assert.match(workflow, /--force-with-lease="refs\/tags\/v\$\{VERSION\}:\$\{OLD_TAG_OBJECT\}"/); assert.match(workflow, /- name: Extract latest changelog entry\n\s+id: changelog/); assert.match( workflow, - /- name: Create GitHub release\n\s+uses: softprops\/action-gh-release@v2\n\s+if: steps\.changelog\.outputs\.notes != ''/ + /- name: Create GitHub release\n\s+uses: softprops\/action-gh-release@v2\n\s+if: steps\.version\.outputs\.publish_required == 'true' && steps\.changelog\.outputs\.notes != ''/ ); + + const validateIndex = workflow.indexOf('- name: Validate Central recovery request'); + const uploadIndex = workflow.indexOf('- name: Upload one Aether bundle to Maven Central'); + const waitIndex = workflow.indexOf('- name: Wait for Maven Central publication'); + const tagIndex = workflow.indexOf('- name: Create or repair the release tag'); + assert.ok(validateIndex < uploadIndex && uploadIndex < waitIndex && waitIndex < tagIndex); +}); + +test('Maven Central publication requires real sources and waits for PUBLISHED', async () => { + const pluginBuild = await text('../aether-plugin/build.gradle.kts'); + const rootBuild = await text('../build.gradle.kts'); + const workflow = await text('../.github/workflows/publish.yml'); + const signScript = await text('../sign-artifact.sh'); + + assert.match(pluginBuild, /java\s*\{[\s\S]*?withSourcesJar\(\)/); + assert.match(rootBuild, /verifyCentralPublicationArtifacts by tasks\.registering/); + assert.match(rootBuild, /sources JAR is missing or empty/); + assert.match(rootBuild, /sources JAR contains no Kotlin or Java source/); + assert.match(rootBuild, /val pomOnly = Regex/); + assert.match(rootBuild, /if \(pomOnly\)/); + assert.match(rootBuild, /prepareCentralPortalBundle by tasks\.registering/); + assert.match(rootBuild, /uploadCentralPortalBundle by tasks\.registering/); + assert.match(rootBuild, /waitForCentralPortalPublication by tasks\.registering/); + assert.match(rootBuild, /dependsOn\(verifyCentralPublicationArtifacts, writeExpectedCentralPurls\)/); + assert.match(rootBuild, /publishingType=AUTOMATIC/); + assert.match(rootBuild, /Authorization: \$\{'\$'\}AETHER_CENTRAL_AUTHORIZATION/); + assert.match(rootBuild, /"PUBLISHED" -> \{/); + assert.match(rootBuild, /"FAILED" -> throw GradleException/); + assert.match(rootBuild, /JsonSlurper\(\)\.parseText/); + assert.match(rootBuild, /--connect-timeout 15/); + assert.match(rootBuild, /Central upload outcome is indeterminate/); + assert.match(rootBuild, /numericHttpCode !in setOf\(408, 409, 425, 429\)/); + assert.match(rootBuild, /uploadCentralPortalBundle cannot resume an existing deployment/); + assert.match(rootBuild, /centralExpectedPurls/); + assert.match(rootBuild, /purlList\.size != purls\.size/); + assert.match(rootBuild, /purls != expectedPurls/); + assert.match(signScript, /AETHER_SIGNING_PASSPHRASE/); + assert.match(signScript, /--passphrase-fd 0/); + assert.doesNotMatch(signScript, /PASSPHRASE="\$1"|--passphrase "\$PASSPHRASE"/); + assert.match(workflow, /verifyExpectedSourceTasks verifyCentralPublicationArtifacts check/); + assert.match(workflow, /retry deployment must be FAILED/); + assert.match(workflow, /data\.get\("deploymentName"\) != expected_name/); + assert.match(workflow, /actual_purls != expected_purls/); + assert.match(workflow, /state == "PUBLISHED" or bool\(actual_purls\)/); + assert.match(workflow, /Version \$\{RELEASE_VERSION\} is already public and cannot be replaced/); + + const uploadStep = workflow.match( + /- name: Upload one Aether bundle to Maven Central[\s\S]*?(?=\n\s+- name: Wait for Maven Central publication)/ + )?.[0] ?? ''; + const waitStep = workflow.match( + /- name: Wait for Maven Central publication[\s\S]*?(?=\n\s+- name: Delete publishing credentials)/ + )?.[0] ?? ''; + assert.doesNotMatch(uploadStep, /MAX_RETRIES|for i in|waitForCentralPortalPublication/); + assert.match(uploadStep, /uploadCentralPortalBundle/); + assert.doesNotMatch(waitStep, /uploadCentralPortalBundle|signingPassword|private-key\.asc/); + assert.match(waitStep, /waitForCentralPortalPublication/); }); test('the virtual authenticator models a discoverable user-verified CTAP2 passkey', async () => { diff --git a/sign-artifact.sh b/sign-artifact.sh index b4d7e2e..7b2737a 100644 --- a/sign-artifact.sh +++ b/sign-artifact.sh @@ -1,10 +1,10 @@ #!/bin/bash set -euo pipefail -PASSPHRASE="$1" -KEY_FILE="$2" -OUTPUT_FILE="$3" -INPUT_FILE="$4" +PASSPHRASE="${AETHER_SIGNING_PASSPHRASE:?AETHER_SIGNING_PASSPHRASE is required}" +KEY_FILE="$1" +OUTPUT_FILE="$2" +INPUT_FILE="$3" GPG_BIN="${GPG_BIN:-$(command -v gpg)}" @@ -37,8 +37,8 @@ if [[ -z "$KEY_ID" ]]; then fi # Sign the artifact -"$GPG_BIN" --batch --yes --pinentry-mode loopback \ - --passphrase "$PASSPHRASE" \ +printf '%s\n' "$PASSPHRASE" | "$GPG_BIN" --batch --yes --pinentry-mode loopback \ + --passphrase-fd 0 \ --default-key "$KEY_ID" \ --local-user "$KEY_ID" \ --armor --detach-sign \