diff --git a/.github/scripts/release/central-bundle.sh b/.github/scripts/release/central-bundle.sh new file mode 100755 index 00000000..55e8f8f2 --- /dev/null +++ b/.github/scripts/release/central-bundle.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# Builds the Maven Central deployment of python-multiplatform: every component, signed, in one zip. +# .github/scripts/release/central-bundle.sh [--throwaway-key] +# Run from the repository root. Reads MAVEN_GPG_PRIVATE_KEY and MAVEN_GPG_PASSPHRASE (Gradle signs +# in memory, gradle/central-publishing.gradle.kts). --throwaway-key makes a key for this run only, +# for a dry run that must prove signing works without the release key. Without either, the bundle is +# unsigned and the script says so; Central would refuse it. +# Writes build/central-staging (a Maven layout) and ; uploads nothing, see central-upload.sh. +set -euo pipefail +out="${1:?usage: central-bundle.sh [--throwaway-key]}" +mode="${2:-}" +root="$(pwd)" +staging="$root/build/central-staging" +version="$(sed -nE 's/^pythonVersion=(.*)$/\1/p' gradle.properties)-$(sed -nE 's/^libraryVersionSuffix=(.*)$/\1/p' gradle.properties)" + +# Not under the repository: gpg-agent's socket path must stay under about 100 characters. +verify_home="$(mktemp -d)" +chmod 700 "$verify_home" +trap 'rm -rf "$verify_home"' EXIT + +if [[ "$mode" == "--throwaway-key" ]]; then + GNUPGHOME="$verify_home" gpg --batch --quiet --passphrase '' --quick-generate-key \ + "python-multiplatform dry run " rsa3072 sign 1d + MAVEN_GPG_PRIVATE_KEY="$(GNUPGHOME="$verify_home" gpg --batch --pinentry-mode loopback --passphrase '' --armor --export-secret-keys)" + export MAVEN_GPG_PRIVATE_KEY MAVEN_GPG_PASSPHRASE="" +elif [[ -n "${MAVEN_GPG_PRIVATE_KEY:-}" ]]; then + GNUPGHOME="$verify_home" gpg --batch --quiet --import <<< "$MAVEN_GPG_PRIVATE_KEY" +fi +signed=false +[[ -n "${MAVEN_GPG_PRIVATE_KEY:-}" ]] && signed=true + +mkdir -p "$root/.cache" +rm -rf "$staging" +# Gradle reads the key from the environment; the plugin build is a separate build but inherits it. +./gradlew --console=plain publishAllToCentralStaging 2>&1 | tee "$root/.cache/central-gradle.log" || { echo "error: gradle failed (pipe status ${PIPESTATUS[0]})" >&2; exit 1; } + +# What Central checks first: every module has pom + jar/klib/zip, sources and javadoc where it is a +# jar publication, and (signed) a good .asc beside every file. +fail=0 +while IFS= read -r pom; do + dir="$(dirname "$pom")"; base="$(basename "$pom" .pom)" + ver="$(basename "$dir")"; art="$(basename "$(dirname "$dir")")" + [[ "$ver" == "$version" ]] || { echo "error: $pom is not at $version" >&2; fail=1; } + grep -q 'https://github.com/thisisthepy/python-multiplatform' "$pom" || { echo "error: $pom has no project url" >&2; fail=1; } + grep -q '' "$pom" && grep -q '' "$pom" && grep -q '' "$pom" || { echo "error: $pom lacks licenses, developers or scm" >&2; fail=1; } + if ! grep -q 'pom' "$pom"; then + [[ -e "$dir/$base-javadoc.jar" ]] || { echo "error: $art has no javadoc jar" >&2; fail=1; } + [[ -e "$dir/$base-sources.jar" ]] || { echo "error: $art has no sources jar" >&2; fail=1; } + fi +done < <(find "$staging" -name '*.pom') +if $signed; then + while IFS= read -r f; do + case "$f" in *.asc|*.md5|*.sha1|*.sha256|*.sha512) continue ;; esac + [[ -e "$f.asc" ]] || { echo "error: $f is not signed" >&2; fail=1; continue; } + GNUPGHOME="$verify_home" gpg --batch --quiet --verify "$f.asc" "$f" 2>/dev/null || { echo "error: bad signature on $f" >&2; fail=1; } + done < <(find "$staging" -type f -not -name 'maven-metadata*') +else + echo "warning: no signing key, the bundle is unsigned and Central will refuse it" >&2 +fi +(( fail == 0 )) || exit 1 + +echo "== left out of the bundle (not built on this machine)" +grep -h "CENTRAL-SKIPPED" "$root/.cache/central-gradle.log" || echo "(none)" +echo "== coordinates in the bundle" +find "$staging" -name '*.pom' | while IFS= read -r pom; do + rel="${pom#"$staging"/}"; ver="$(basename "$(dirname "$rel")")"; art="$(basename "$(dirname "$(dirname "$rel")")")" + grp="$(dirname "$(dirname "$(dirname "$rel")")" | tr / .)" + echo "$grp:$art:$ver" +done | sort + +mkdir -p "$(dirname "$out")" +out="$(cd "$(dirname "$out")" && pwd)/$(basename "$out")" +rm -f "$out" +(cd "$staging" && zip -qr "$out" . -x 'maven-metadata*' '*/maven-metadata*') +echo "== bundle $out ($(find "$staging" -type f | wc -l | tr -d ' ') files)" diff --git a/.github/scripts/release/central-upload.sh b/.github/scripts/release/central-upload.sh new file mode 100755 index 00000000..39c3a85e --- /dev/null +++ b/.github/scripts/release/central-upload.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Uploads a bundle made by central_bundle.py to the Sonatype Central Portal and waits for its +# validation. +# +# Usage: central-upload.sh [USER_MANAGED|AUTOMATIC] +# +# Reads CENTRAL_TOKEN_USERNAME and CENTRAL_TOKEN_PASSWORD, the two halves of a Central Portal +# user token (central.sonatype.com, View Account, Generate User Token). The Portal API takes +# them as a bearer token of base64(":"). +# +# USER_MANAGED (the default) stops after validation: the deployment waits in the Portal under +# Deployments until someone presses Publish or Drop, so a person looks at it before it becomes +# permanent. AUTOMATIC publishes as soon as validation passes. Either way nothing published to +# Central can be changed or removed afterwards. +# +# Exits 0 when the deployment is VALIDATED (USER_MANAGED) or PUBLISHING/PUBLISHED (AUTOMATIC), +# 1 when it FAILED, printing the Portal's errors, and 2 on a usage or network error. +set -euo pipefail + +API="https://central.sonatype.com/api/v1/publisher" + +bundle="${1:?usage: central-upload.sh [USER_MANAGED|AUTOMATIC]}" +name="${2:?usage: central-upload.sh [USER_MANAGED|AUTOMATIC]}" +type="${3:-USER_MANAGED}" +case "$type" in USER_MANAGED|AUTOMATIC) ;; *) echo "error: publishing type '$type'" >&2; exit 2 ;; esac +[[ -s "$bundle" ]] || { echo "error: no bundle at $bundle" >&2; exit 2; } +[[ -n "${CENTRAL_TOKEN_USERNAME:-}" && -n "${CENTRAL_TOKEN_PASSWORD:-}" ]] || { + echo "error: CENTRAL_TOKEN_USERNAME and CENTRAL_TOKEN_PASSWORD must hold a Central Portal user token" >&2 + exit 2 +} + +token="$(printf '%s:%s' "$CENTRAL_TOKEN_USERNAME" "$CENTRAL_TOKEN_PASSWORD" | base64 | tr -d '\n')" +auth="Authorization: Bearer $token" + +encoded_name="$(python3 -I -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1]))' "$name")" +echo "==> uploading $bundle as '$name' ($type)" +id="$(curl --fail-with-body --silent --show-error --retry 3 \ + --header "$auth" \ + --form "bundle=@$bundle" \ + "$API/upload?publishingType=$type&name=$encoded_name")" +echo "deployment $id" + +# Validation of a large bundle takes minutes; give it an hour. +for _ in $(seq 1 120); do + status="$(curl --fail-with-body --silent --show-error --retry 3 --request POST \ + --header "$auth" "$API/status?id=$id")" + state="$(python3 -I -c 'import json, sys; print(json.load(sys.stdin)["deploymentState"])' <<< "$status")" + echo "state $state" + case "$state" in + VALIDATED) + if [[ "$type" == USER_MANAGED ]]; then + echo "validated; publish or drop it at https://central.sonatype.com/publishing/deployments" + exit 0 + fi + ;; + PUBLISHING|PUBLISHED) + echo "publishing; it appears on https://repo1.maven.org/maven2/org/thisisthepy/ within about 30 minutes" + exit 0 + ;; + FAILED) + python3 -I -c 'import json, sys; print(json.dumps(json.load(sys.stdin).get("errors"), indent=2))' <<< "$status" >&2 + exit 1 + ;; + esac + sleep 30 +done +echo "error: deployment $id is still $state after an hour; see https://central.sonatype.com/publishing/deployments" >&2 +exit 2 diff --git a/.github/workflows/maven-central.yml b/.github/workflows/maven-central.yml new file mode 100644 index 00000000..d22eca2e --- /dev/null +++ b/.github/workflows/maven-central.yml @@ -0,0 +1,144 @@ +name: Maven Central + +# Publishes python-multiplatform (library for every target, KSP processor, Gradle plugin and its +# plugin marker) to Maven Central through the Sonatype Central Portal, as ONE deployment. +# +# - A version tag (v-, the tag desktop-release.yml also reads) +# builds, signs with the release key and uploads. +# - A manual run is a dry run unless "dry_run" is turned off: it builds and signs with a key made in +# the job and thrown away, checks the bundle and keeps it as a workflow artifact. Nothing is +# uploaded. With dry_run off, pick the tag to publish in "tag". +# +# macOS is required: the Apple targets (iosArm64, iosSimulatorArm64, iosX64, macosArm64) only build +# there. Secrets read by a real upload, nothing else reads them: +# MAVEN_GPG_PRIVATE_KEY signing key, ASCII armoured (gpg --armor --export-secret-keys) +# MAVEN_GPG_PASSPHRASE its passphrase +# CENTRAL_TOKEN_USERNAME Central Portal user token, username half +# CENTRAL_TOKEN_PASSWORD Central Portal user token, password half +# The upload runs in the environment "maven-central", where required reviewers can be set. + +on: + push: + tags: + - 'v*' + # A pull request that touches the publishing setup runs the dry run, so the bundle is checked + # before the first tag depends on it. + pull_request: + paths: + - '.github/workflows/maven-central.yml' + - '.github/scripts/release/central-*.sh' + - 'gradle/central-publishing.gradle.kts' + workflow_dispatch: + inputs: + dry_run: + description: 'Build and sign with a throwaway key, upload nothing' + type: boolean + default: true + tag: + description: 'Tag to publish when dry_run is off (e.g. v3.15.0-alpha02); ignored for a dry run' + required: false + default: '' + publishing_type: + description: 'USER_MANAGED waits in the Central Portal for someone to press Publish; AUTOMATIC publishes once validated' + type: choice + options: [USER_MANAGED, AUTOMATIC] + default: USER_MANAGED + +permissions: + contents: read + +concurrency: + group: maven-central-${{ github.event.inputs.tag || github.ref }} + cancel-in-progress: false + +jobs: + bundle: + name: Build and sign the bundle (macOS) + runs-on: macos-latest + timeout-minutes: 180 + env: + DRY_RUN: ${{ github.event_name == 'pull_request' || (github.event_name == 'workflow_dispatch' && inputs.dry_run) }} + TAG: ${{ github.event.inputs.tag || github.ref_name }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ (github.event_name == 'push' || inputs.dry_run == false) && (github.event.inputs.tag || github.ref) || github.ref }} + # The tag's own gradle.properties decides the version, not the branch it was cut from. + + - name: Setup Java + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'zulu' + + - name: Setup uv + # The KSP processor's typedpython-gate bundle is built with uv build. + uses: astral-sh/setup-uv@v6 + + - name: The tag names this build's version + if: env.DRY_RUN != 'true' + run: | + set -euo pipefail + version="$(sed -nE 's/^pythonVersion=(.*)$/\1/p' gradle.properties)-$(sed -nE 's/^libraryVersionSuffix=(.*)$/\1/p' gradle.properties)" + test "$TAG" = "v$version" || { echo "error: tag $TAG does not match version $version" >&2; exit 1; } + echo "VERSION=$version" >> "$GITHUB_ENV" + + - name: Build and sign the bundle (dry run, throwaway key) + if: env.DRY_RUN == 'true' + run: .github/scripts/release/central-bundle.sh "$RUNNER_TEMP/central-bundle.zip" --throwaway-key + + - name: Build and sign the bundle (release key) + if: env.DRY_RUN != 'true' + env: + MAVEN_GPG_PRIVATE_KEY: ${{ secrets.MAVEN_GPG_PRIVATE_KEY }} + MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }} + run: | + set -euo pipefail + [[ -n "$MAVEN_GPG_PRIVATE_KEY" ]] || { echo "::error::the MAVEN_GPG_PRIVATE_KEY secret is not set"; exit 1; } + .github/scripts/release/central-bundle.sh "$RUNNER_TEMP/central-bundle.zip" + + - name: Keep the bundle + uses: actions/upload-artifact@v4 + with: + name: central-bundle + path: ${{ runner.temp }}/central-bundle.zip + retention-days: 14 + + + upload: + name: Upload to the Central Portal + needs: bundle + if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.dry_run == false) + runs-on: ubuntu-24.04 + timeout-minutes: 90 + # Required reviewers can be set on this environment, so even a pushed tag waits for a person + # before anything reaches Central. A dry run never gets here. + environment: maven-central + env: + TAG: ${{ github.event.inputs.tag || github.ref_name }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.tag || github.ref }} + sparse-checkout: | + .github/scripts/release + gradle.properties + sparse-checkout-cone-mode: false + + - uses: actions/download-artifact@v4 + with: + name: central-bundle + path: ${{ runner.temp }} + + - name: Upload + env: + CENTRAL_TOKEN_USERNAME: ${{ secrets.CENTRAL_TOKEN_USERNAME }} + CENTRAL_TOKEN_PASSWORD: ${{ secrets.CENTRAL_TOKEN_PASSWORD }} + TYPE: ${{ inputs.publishing_type || 'USER_MANAGED' }} + run: | + set -euo pipefail + version="$(sed -nE 's/^pythonVersion=(.*)$/\1/p' gradle.properties)-$(sed -nE 's/^libraryVersionSuffix=(.*)$/\1/p' gradle.properties)" + .github/scripts/release/central-upload.sh "$RUNNER_TEMP/central-bundle.zip" \ + "python-multiplatform $version" "$TYPE" diff --git a/build.gradle.kts b/build.gradle.kts index 63c01e66..cad457f0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -45,6 +45,19 @@ tasks.register("publishAllToMavenLocal") { dependsOn(gradle.includedBuild("python-multiplatform-gradle-plugin").task(":publishToMavenLocal")) } +// Maven Central (ROADMAP 15.1): one entry point that fills build/central-staging with all three +// components. Signed when MAVEN_GPG_PRIVATE_KEY is set (gradle/central-publishing.gradle.kts). +// It does not clean the directory (the plugin build is another build, so ordering cannot reach +// it): .github/scripts/release/central-bundle.sh empties it first. +tasks.register("publishAllToCentralStaging") { + group = "publishing" + description = "Publishes python-multiplatform, python-multiplatform-ksp and " + + "python-multiplatform-gradle-plugin (with its plugin marker) to build/central-staging." + dependsOn(":python-multiplatform:publishAllToCentralStaging") + dependsOn(":python-multiplatform-ksp:publishAllToCentralStaging") + dependsOn(gradle.includedBuild("python-multiplatform-gradle-plugin").task(":publishAllToCentralStaging")) +} + // The build's single Kotlin/Wasm npm lock (one workspace for every wasm module) lives next to the // version catalog instead of a root `kotlin-js-store/` (issue #110). KGP only allows this on the // root project; the `*Property` forms are the non-deprecated API from Kotlin 2.4 on. Mismatch diff --git a/docs/guide/getting-started.html b/docs/guide/getting-started.html index 61f3d1b0..dd10ff83 100644 --- a/docs/guide/getting-started.html +++ b/docs/guide/getting-started.html @@ -69,7 +69,24 @@

03Your first call

There is no close() in that code, and none is missing: every wrapper releases its Python reference when the Kotlin object is collected.위 코드에는 close() 가 없고, 빠진 것도 아니다. 모든 래퍼는 Kotlin 객체가 수거될 때 Python 참조를 놓는다.

-

04Verify a change변경 검증하기

+

04Use it from Maven CentralMaven Central 에서 쓰기

+
// settings.gradle.kts
+pluginManagement { repositories { mavenCentral(); gradlePluginPortal() } }
+dependencyResolutionManagement { repositories { mavenCentral() } }
+
+// build.gradle.kts of a Kotlin Multiplatform module
+plugins {
+    kotlin("multiplatform")
+    id("org.thisisthepy.python.multiplatform.bindings") version "3.15.0-alpha02"
+}
+kotlin {
+    sourceSets.commonMain.dependencies {
+        implementation("org.thisisthepy:python-multiplatform:3.15.0-alpha02")
+    }
+}
+

The library, the KSP processor and the Gradle plugin share one version, $pythonVersion-$libraryVersionSuffix. A version tag publishes them in one deployment through .github/workflows/maven-central.yml; until a release has been published there, resolve from mavenLocal() after ./gradlew publishAllToMavenLocal.라이브러리, KSP 프로세서, Gradle 플러그인은 $pythonVersion-$libraryVersionSuffix 라는 한 버전을 쓴다. 버전 태그를 달면 .github/workflows/maven-central.yml 이 셋을 한 번의 배포로 올린다. 릴리스가 올라가기 전에는 ./gradlew publishAllToMavenLocal 뒤에 mavenLocal() 에서 가져온다.

+ +

05Verify a change변경 검증하기

./gradlew :python-multiplatform:compileKotlinAndroidNativeArm64 --console=plain > .tmp/build.log 2>&1; echo "EXIT=$?"
 ./gradlew :python-multiplatform:desktopTest --rerun --console=plain > .tmp/test.log 2>&1; echo "EXIT=$?"
 ./gradlew :python-multiplatform-ksp:fixtures:app:desktopTest --rerun
diff --git a/docs/roadmap/ROADMAP.md b/docs/roadmap/ROADMAP.md
index bd328f95..029df0d5 100644
--- a/docs/roadmap/ROADMAP.md
+++ b/docs/roadmap/ROADMAP.md
@@ -300,10 +300,17 @@ stripped), the root metadata jar is 382 KB, `PythonBootstrap` stages the Android
 desktop stdlib (`GP/test/kotlin/python/multiplatform/gradle/PythonHomeStagingTest.kt`, SPEC L-4).
 Old §15a, §15b, §15d, §15f, §15g, §15h are closed; §15e item 4 is closed by §15h.
 
-- **15.1 Nothing is published to a remote repository, open.** No publishing repository or signing is
-  configured (`signing { }` is commented out in `python-multiplatform/build.gradle.kts`), and
-  `repo1.maven.org/maven2/io/github/thisisthepy/python-multiplatform/` returns 404 (2026-10-03).
-  Every consumer verification so far used `mavenLocal()`. 10.1 depends on this or on a release asset.
+- **15.1 Maven Central publishing, implemented, first upload pending (#375).** All three components
+  (the library for every target, the KSP processor, the Gradle plugin with its plugin marker) carry
+  Central POM metadata, sources and javadoc jars and in-memory GPG signing
+  (`gradle/central-publishing.gradle.kts`, signing only when `MAVEN_GPG_PRIVATE_KEY` is set, so
+  `publishToMavenLocal` still works without keys). `./gradlew publishAllToCentralStaging` fills
+  `build/central-staging`; `.github/scripts/release/central-bundle.sh` signs, checks and zips it, and
+  `.github/workflows/maven-central.yml` (version tag, or manual with a `dry_run` input) builds on macOS and
+  uploads one deployment to the Central Portal (`central-upload.sh`, environment `maven-central`). The
+  dry run signs with a throwaway key and uploads nothing. Open: the first real upload (needs the
+  secrets, only CI can show Central accepting the deployment), and `repo1.maven.org/maven2/org/thisisthepy/`
+  does not list the artifacts until then. 10.1 depends on this or on a release asset.
 - **15.2 The library, KSP processor and plugin publish under one version, done (#70).** Each build
   derives it from `gradle.properties` (`$pythonVersion-$libraryVersionSuffix`, a `-P` override included);
   the root build fails when a project's version differs, and `PublishedVersionTest` pins the processor
diff --git a/gradle/central-publishing.gradle.kts b/gradle/central-publishing.gradle.kts
new file mode 100644
index 00000000..59b6db55
--- /dev/null
+++ b/gradle/central-publishing.gradle.kts
@@ -0,0 +1,148 @@
+// Maven Central publishing, applied by the three published components (the library, the KSP
+// processor and the Gradle plugin, which is an included build):
+//
+//     apply(from = rootProject.file("gradle/central-publishing.gradle.kts"))   // root build
+//     apply(from = rootDir.resolve("../gradle/central-publishing.gradle.kts")) // plugin build
+//
+// What it adds to every Maven publication of the project (ROADMAP 15.1):
+//   * the POM fields Central requires: name, description, url, license, developers, scm;
+//   * an empty javadoc jar and, where the publication has none, an empty sources jar (Central
+//     refuses a jar publication without both; pom-only plugin markers need neither);
+//   * a local `centralStaging` repository, a plain Maven layout under /build/central-staging,
+//     which `.github/scripts/release/central-bundle.sh` zips for the Central Portal upload API.
+//     Every component writes the same directory, so one deployment carries all of them;
+//   * in-memory PGP signing of every publication, only when a key is given
+//     (MAVEN_GPG_PRIVATE_KEY / MAVEN_GPG_PASSPHRASE in the environment, or the Gradle properties
+//     signingInMemoryKey / signingInMemoryKeyPassword). Without a key nothing is signed, so
+//     `publishToMavenLocal` keeps working on a machine that has none.
+// Nothing here uploads anything; the upload is .github/scripts/release/central-upload.sh.
+
+import org.gradle.api.publish.maven.MavenPublication
+import org.gradle.api.publish.maven.tasks.AbstractPublishToMaven
+import org.gradle.api.publish.maven.tasks.PublishToMavenLocal
+import org.gradle.api.publish.maven.tasks.PublishToMavenRepository
+import org.gradle.plugins.signing.Sign
+import org.gradle.plugins.signing.SigningExtension
+
+val repoRoot: File = if (rootDir.resolve("gradle.properties").isFile && rootDir.resolve("gradle").isDirectory) {
+    rootDir
+} else {
+    rootDir.parentFile
+}
+val stagingDir: File = (findProperty("centralStagingDir") as String?)?.let(::File)
+    ?: repoRoot.resolve("build/central-staging")
+
+val signingKey: String? = (findProperty("signingInMemoryKey") as String?)
+    ?: System.getenv("MAVEN_GPG_PRIVATE_KEY")?.takeIf { it.isNotBlank() }
+val signingPassword: String = (findProperty("signingInMemoryKeyPassword") as String?)
+    ?: System.getenv("MAVEN_GPG_PASSPHRASE")
+    ?: ""
+
+val projectUrl = "https://github.com/thisisthepy/python-multiplatform"
+
+val emptyJar = { classifier: String ->
+    tasks.register("central${classifier.replaceFirstChar { it.uppercase() }}Jar") {
+        archiveClassifier.set(classifier)
+        destinationDirectory.set(layout.buildDirectory.dir("central-empty-jars"))
+        // Central only checks that the jar exists; the KDoc lives in the sources jar.
+        from(rootProject.file("LICENSE")) { into("META-INF") }
+    }
+}
+
+fun publicationIsBuilt(publication: org.gradle.api.publish.Publication, logger: org.gradle.api.logging.Logger): Boolean {
+    publication as MavenPublication
+    val missing = publication.artifacts.filter { !it.file.exists() }
+    if (missing.isNotEmpty()) {
+        logger.warn("CENTRAL-SKIPPED ${publication.groupId}:${publication.artifactId}: not built here: " +
+            missing.joinToString { it.file.name })
+    }
+    return missing.isEmpty()
+}
+
+pluginManager.withPlugin("maven-publish") {
+    apply(plugin = "signing")
+
+    val publishing = extensions.getByType()
+    publishing.repositories.maven {
+        name = "centralStaging"
+        url = stagingDir.toURI()
+    }
+
+    val javadocJar = emptyJar("javadoc")
+    val sourcesJar = emptyJar("sources")
+
+    publishing.publications.withType().configureEach {
+        val publication = this
+        // A plugin marker is a pom-only publication: nothing to attach.
+        val markerOnly = publication.name.endsWith("PluginMarkerMaven")
+        pom {
+            name.set(provider { publication.artifactId })
+            description.set(
+                "Embeds CPython in Kotlin Multiplatform and provides Kotlin <-> Python interoperability " +
+                    "(${publication.artifactId})."
+            )
+            url.set(projectUrl)
+            licenses {
+                license {
+                    name.set("The Apache License, Version 2.0")
+                    url.set("https://www.apache.org/licenses/LICENSE-2.0.txt")
+                }
+            }
+            developers {
+                developer {
+                    id.set("thisisthepy")
+                    name.set("thisisthepy")
+                    email.set("thisisthepy@gmail.com")
+                }
+            }
+            scm {
+                connection.set("scm:git:https://github.com/thisisthepy/python-multiplatform.git")
+                developerConnection.set("scm:git:ssh://git@github.com/thisisthepy/python-multiplatform.git")
+                url.set(projectUrl)
+            }
+        }
+        if (!markerOnly) {
+            // Attached once every plugin has added its own artifacts (KGP adds the sources jars of
+            // its targets late); a classifier already present is left alone.
+            gradle.projectsEvaluated {
+                val classifiers = publication.artifacts.mapNotNull { it.classifier }.toSet()
+                if ("javadoc" !in classifiers) publication.artifact(javadocJar)
+                if ("sources" !in classifiers) publication.artifact(sourcesJar)
+            }
+        }
+    }
+
+    if (signingKey != null) {
+        extensions.configure {
+            useInMemoryPgpKeys(signingKey, signingPassword)
+            sign(publishing.publications)
+        }
+        // KMP publications are created late and read the .asc files without declaring the tasks
+        // that write them; make every publish task wait for every signing task.
+        tasks.withType().configureEach {
+            dependsOn(tasks.withType())
+        }
+    }
+
+    // A publication whose file is not built on this machine is left out of the staging, loudly,
+    // rather than failing the whole deployment. Today that is only `wasmRuntime`: its zip needs a
+    // local Emscripten CPython build (-PwasmPythonDir) that CI does not make (ROADMAP 10.x), so
+    // `central-bundle.sh` lists what was left out and a release owner decides whether that is fine.
+    tasks.withType().matching { it.name.endsWith("ToCentralStagingRepository") }
+        .configureEach {
+            val task = this
+            onlyIf { publicationIsBuilt(task.publication, task.logger) }
+        }
+    // The same for mavenLocal, so `publishAllToMavenLocal` works in a checkout without the wasm build.
+    tasks.withType().configureEach {
+        val task = this
+        onlyIf { publicationIsBuilt(task.publication, task.logger) }
+    }
+
+    // One task per component that fills the staging directory with every publication.
+    tasks.register("publishAllToCentralStaging") {
+        group = "publishing"
+        description = "Publishes every publication of this project to $stagingDir (signed when a key is given)."
+        dependsOn(tasks.withType().matching { it.name.endsWith("ToCentralStagingRepository") })
+    }
+}
diff --git a/python-multiplatform-gradle-plugin/build.gradle.kts b/python-multiplatform-gradle-plugin/build.gradle.kts
index 384ce7ce..89fe990e 100644
--- a/python-multiplatform-gradle-plugin/build.gradle.kts
+++ b/python-multiplatform-gradle-plugin/build.gradle.kts
@@ -418,3 +418,5 @@ tasks.test {
     inputs.files(walkerFixtureKlib).withPropertyName("walkerFixtureKlib").withPathSensitivity(PathSensitivity.NAME_ONLY)
     systemProperty("python.multiplatform.walkerFixtureKlib", walkerFixtureKlib.singleFile.absolutePath)
 }
+
+apply(from = rootDir.resolve("../gradle/central-publishing.gradle.kts"))
diff --git a/python-multiplatform-ksp/build.gradle.kts b/python-multiplatform-ksp/build.gradle.kts
index 67b3f22a..c50576ae 100644
--- a/python-multiplatform-ksp/build.gradle.kts
+++ b/python-multiplatform-ksp/build.gradle.kts
@@ -48,3 +48,5 @@ publishing {
         }
     }
 }
+
+apply(from = rootProject.file("gradle/central-publishing.gradle.kts"))
diff --git a/python-multiplatform/build.gradle.kts b/python-multiplatform/build.gradle.kts
index bb3ac482..30a722fe 100644
--- a/python-multiplatform/build.gradle.kts
+++ b/python-multiplatform/build.gradle.kts
@@ -2789,47 +2789,11 @@ publishing {
             artifact(wasmBrowserRuntimeZip)
         }
     }
-    publications.withType().configureEach {
-        //artifact(javadocJar)
-        groupId = groupId
-        artifactId = artifactId
-        version = version
-
-        pom {
-            name.set("python-multiplatform")
-            description.set("A multiplatform solution to use Python with Kotlin interoperably.")
-            url.set("https://github.com/thisisthepy/python-multiplatform-mobile")
-            licenses {
-                license {
-                    name.set("The Apache License, Version 2.0")
-                    url.set("https://www.apache.org/licenses/LICENSE-2.0.txt")
-                }
-            }
-            developers {
-                developer {
-                    id.set("thisisthepy")
-                    name.set("thisisthepy")
-                    email.set("thisisthepy@gmail.com")
-                }
-            }
-            scm {
-                connection.set("scm:git:github.com/thisisthepy/python-multiplatform-mobile.git")
-                developerConnection.set("scm:git:ssh://github.com/thisisthepy/python-multiplatform-mobile.git")
-                url.set("https://github.com/thisisthepy/python-multiplatform-mobile")
-            }
-        }
-    }
 }
 
-//signing {
-//    useInMemoryPgpKeys(
-//        rootProject.extra["signing_key_id"] as String,
-//        rootProject.extra["signing_secret_key"] as String,
-//        rootProject.extra["signing_password"] as String
-//    )
-//    sign(publishing.publications)
-//}
-
+// POM metadata, the sources and javadoc jars, the centralStaging repository and the in-memory GPG
+// signing (only when MAVEN_GPG_PRIVATE_KEY is set) for Maven Central: ROADMAP 15.1.
+apply(from = rootProject.file("gradle/central-publishing.gradle.kts"))
 
 fun downloadPythonBuilds() {
     // Moved to task `downloadAllPythonBuilds`
diff --git a/python-multiplatform/src/desktopNativeMain/kotlin/python/multiplatform/env/PackagedPythonHome.desktopNative.kt b/python-multiplatform/src/desktopNativeMain/kotlin/python/multiplatform/env/PackagedPythonHome.desktopNative.kt
index a338dc59..ec3d202f 100644
--- a/python-multiplatform/src/desktopNativeMain/kotlin/python/multiplatform/env/PackagedPythonHome.desktopNative.kt
+++ b/python-multiplatform/src/desktopNativeMain/kotlin/python/multiplatform/env/PackagedPythonHome.desktopNative.kt
@@ -1,12 +1,8 @@
 package python.multiplatform.env
 
 import kotlinx.cinterop.ExperimentalForeignApi
-import kotlinx.cinterop.alloc
-import kotlinx.cinterop.memScoped
-import kotlinx.cinterop.ptr
-import platform.posix.S_IFDIR
-import platform.posix.S_IFMT
-import platform.posix.stat
+import platform.posix.closedir
+import platform.posix.opendir
 
 /**
  * The directory containing the running executable, or null when the OS cannot report it.
@@ -67,12 +63,16 @@ internal object DesktopNativePythonHome {
         return null
     }
 
-    /** `stat(2)`'s answer: a directory exists at [path] (a regular file there is not one). */
+    /**
+     * A directory exists at [path] (a regular file there is not one): `opendir(3)` succeeds only on
+     * a directory. Not `stat(2)`: `st_mode` is 16 bits on macOS and 32 on Linux, and the metadata
+     * compilation of this shared source set, which publishing runs, rejects that mismatch.
+     */
     @OptIn(ExperimentalForeignApi::class)
-    internal fun isDirectory(path: String): Boolean = memScoped {
-        val info = alloc()
-        if (stat(path, info.ptr) != 0) return false
-        (info.st_mode.toInt() and S_IFMT) == S_IFDIR
+    internal fun isDirectory(path: String): Boolean {
+        val dir = opendir(path) ?: return false
+        closedir(dir)
+        return true
     }
 }