Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions .github/scripts/release/central-bundle.sh
Original file line number Diff line number Diff line change
@@ -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 <out.zip> [--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 <out.zip>; uploads nothing, see central-upload.sh.
set -euo pipefail
out="${1:?usage: central-bundle.sh <out.zip> [--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 <dry-run@invalid>" 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 '<url>https://github.com/thisisthepy/python-multiplatform</url>' "$pom" || { echo "error: $pom has no project url" >&2; fail=1; }
grep -q '<licenses>' "$pom" && grep -q '<developers>' "$pom" && grep -q '<scm>' "$pom" || { echo "error: $pom lacks licenses, developers or scm" >&2; fail=1; }
if ! grep -q '<packaging>pom</packaging>' "$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)"
68 changes: 68 additions & 0 deletions .github/scripts/release/central-upload.sh
Original file line number Diff line number Diff line change
@@ -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 <bundle.zip> <deployment name> [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("<username>:<password>").
#
# 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 <bundle.zip> <deployment name> [USER_MANAGED|AUTOMATIC]}"
name="${2:?usage: central-upload.sh <bundle.zip> <deployment name> [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
144 changes: 144 additions & 0 deletions .github/workflows/maven-central.yml
Original file line number Diff line number Diff line change
@@ -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<pythonVersion>-<libraryVersionSuffix>, 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"
13 changes: 13 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 18 additions & 1 deletion docs/guide/getting-started.html
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,24 @@ <h2><span class="num">03</span><span data-lang="en">Your first call</span><span
}</code></pre>
<div class="note"><p><span data-lang="en">There is no <code>close()</code> in that code, and none is missing: every wrapper releases its Python reference when the Kotlin object is collected.</span><span data-lang="ko">위 코드에는 <code>close()</code> 가 없고, 빠진 것도 아니다. 모든 래퍼는 Kotlin 객체가 수거될 때 Python 참조를 놓는다.</span></p></div>

<h2><span class="num">04</span><span data-lang="en">Verify a change</span><span data-lang="ko">변경 검증하기</span></h2>
<h2><span class="num">04</span><span data-lang="en">Use it from Maven Central</span><span data-lang="ko">Maven Central 에서 쓰기</span></h2>
<pre translate="no"><code class="language-kotlin">// settings.gradle.kts
pluginManagement { repositories { mavenCentral(); gradlePluginPortal() } }
dependencyResolutionManagement { repositories { mavenCentral() } }

// build.gradle.kts of a Kotlin Multiplatform module
plugins {
kotlin(&quot;multiplatform&quot;)
id(&quot;org.thisisthepy.python.multiplatform.bindings&quot;) version &quot;3.15.0-alpha02&quot;
}
kotlin {
sourceSets.commonMain.dependencies {
implementation(&quot;org.thisisthepy:python-multiplatform:3.15.0-alpha02&quot;)
}
}</code></pre>
<p><span data-lang="en">The library, the KSP processor and the Gradle plugin share one version, <code>$pythonVersion-$libraryVersionSuffix</code>. A version tag publishes them in one deployment through <code>.github/workflows/maven-central.yml</code>; until a release has been published there, resolve from <code>mavenLocal()</code> after <code>./gradlew publishAllToMavenLocal</code>.</span><span data-lang="ko">라이브러리, KSP 프로세서, Gradle 플러그인은 <code>$pythonVersion-$libraryVersionSuffix</code> 라는 한 버전을 쓴다. 버전 태그를 달면 <code>.github/workflows/maven-central.yml</code> 이 셋을 한 번의 배포로 올린다. 릴리스가 올라가기 전에는 <code>./gradlew publishAllToMavenLocal</code> 뒤에 <code>mavenLocal()</code> 에서 가져온다.</span></p>

<h2><span class="num">05</span><span data-lang="en">Verify a change</span><span data-lang="ko">변경 검증하기</span></h2>
<pre translate="no"><code class="language-bash">./gradlew :python-multiplatform:compileKotlinAndroidNativeArm64 --console=plain &gt; .tmp/build.log 2&gt;&amp;1; echo &quot;EXIT=$?&quot;
./gradlew :python-multiplatform:desktopTest --rerun --console=plain &gt; .tmp/test.log 2&gt;&amp;1; echo &quot;EXIT=$?&quot;
./gradlew :python-multiplatform-ksp:fixtures:app:desktopTest --rerun
Expand Down
15 changes: 11 additions & 4 deletions docs/roadmap/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading