diff --git a/.github/workflows/main-pipeline.yaml b/.github/workflows/main-pipeline.yaml index e224ba1f..4a10c81e 100644 --- a/.github/workflows/main-pipeline.yaml +++ b/.github/workflows/main-pipeline.yaml @@ -29,6 +29,7 @@ jobs: e2e_android: ${{ steps.filter.outputs.e2e_android }} e2e_ios: ${{ steps.filter.outputs.e2e_ios }} swift_package: ${{ steps.filter.outputs.swift_package }} + android_library: ${{ steps.filter.outputs.android_library }} steps: - uses: namespacelabs/nscloud-checkout-action@938f5d2d403d6224d9a0c0dc559b1dae09c2ede4 # v8.1.1 @@ -153,6 +154,13 @@ jobs: - 'package.json' - 'pnpm-lock.yaml' - '.github/workflows/main-pipeline.yaml' + # Android library scope (the AAR published to Maven Central). + android_library: + - 'packages/android/ContentfulOptimization/**' + - 'packages/universal/optimization-js-bridge/**' + - 'package.json' + - 'pnpm-lock.yaml' + - '.github/workflows/main-pipeline.yaml' setup: name: 🛠️ pnpm install @@ -1071,6 +1079,61 @@ jobs: - name: Swift test run: swift test --package-path packages/ios/ContentfulOptimization + android-library-build: + name: 🤖 Android Library Build & Publish-Local Smoke + runs-on: namespace-profile-linux-8-vcpu-16-gb-ram-optimal + timeout-minutes: 30 + needs: [setup, changes] + if: needs.changes.outputs.android_library == 'true' + steps: + - uses: namespacelabs/nscloud-checkout-action@938f5d2d403d6224d9a0c0dc559b1dae09c2ede4 # v8.1.1 + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version-file: '.nvmrc' + package-manager-cache: false + + - uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e # v6.0.3 + + - name: Set Android SDK environment variables + run: | + echo "ANDROID_SDK_ROOT=$HOME/.android/sdk" >> "$GITHUB_ENV" + echo "ANDROID_HOME=$HOME/.android/sdk" >> "$GITHUB_ENV" + + - name: Prepare cache directories + run: mkdir -p "$HOME/.android/sdk" + + - name: Set up caches (Namespace) + uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + with: + cache: pnpm + path: | + ~/.android/sdk + ~/.gradle/caches + ~/.gradle/wrapper + + - run: pnpm install --prefer-offline --frozen-lockfile + + - name: Build the JS bridge + run: pnpm --filter @contentful/optimization-js-bridge build + + - name: Set up JDK 17 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + distribution: temurin + java-version: '17' + + - name: Set up Android SDK + uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1 + + # Smoke-test packaging: assemble the AAR + sources/javadoc/POM the release would publish + # (no Central access, no signing) so packaging breaks are caught on PRs. + - name: Verify Maven publishing assembles + working-directory: packages/android/ContentfulOptimization + run: | + ./gradlew publishToMavenLocal -Pcontentful.optimization.version=0.0.0-ci \ + --no-configuration-cache --no-daemon --console=plain + e2e-react-native-android: name: 📱 E2E React Native Android (shard ${{ matrix.shard }}/2) runs-on: namespace-profile-linux-16-vcpu-32-gb-ram-optimal diff --git a/.github/workflows/publish-android.yaml b/.github/workflows/publish-android.yaml new file mode 100644 index 00000000..7a283517 --- /dev/null +++ b/.github/workflows/publish-android.yaml @@ -0,0 +1,70 @@ +name: Publish Android Library + +permissions: + contents: read + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: 'Existing release tag (e.g. v1.2.3)' + required: true + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Derive release version + run: | + if [ "${{ github.event_name }}" = "release" ]; then + TAG='${{ github.event.release.tag_name }}' + else + TAG='${{ inputs.tag }}' + fi + echo "RELEASE_TAG=$TAG" >> "$GITHUB_ENV" + echo "RELEASE_VERSION=${TAG#v}" >> "$GITHUB_ENV" + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.tag }} + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version-file: '.nvmrc' + package-manager-cache: false + + - uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e # v6.0.3 + + - run: pnpm install --prefer-offline --frozen-lockfile + + - name: Build the JS bridge (stamps the version into the UMD) + run: pnpm --filter @contentful/optimization-js-bridge build + env: + RELEASE_VERSION: ${{ env.RELEASE_VERSION }} + + - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + distribution: 'temurin' + java-version: '17' + + - uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1 + + # Build the signed AAR (+ sources/javadoc/POM) and publish+release it to Maven Central via the + # Sonatype Central Portal. vanniktech reads credentials and the in-memory GPG key from the + # ORG_GRADLE_PROJECT_* env vars below, populated from the Actions secrets that + # scripts/setup-maven-central-credential.sh provisions. + - name: Publish to Maven Central + working-directory: packages/android/ContentfulOptimization + env: + ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.MAVEN_SIGNING_KEY }} + ORG_GRADLE_PROJECT_signingInMemoryKeyId: ${{ secrets.MAVEN_SIGNING_KEY_ID }} + ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.MAVEN_SIGNING_PASSWORD }} + RELEASE_VERSION: ${{ env.RELEASE_VERSION }} + run: | + ./gradlew publishAndReleaseToMavenCentral \ + -Pcontentful.optimization.version="$RELEASE_VERSION" \ + --no-configuration-cache --no-daemon --stacktrace diff --git a/packages/android/ContentfulOptimization/AGENTS.md b/packages/android/ContentfulOptimization/AGENTS.md index 53f46efa..03509ffe 100644 --- a/packages/android/ContentfulOptimization/AGENTS.md +++ b/packages/android/ContentfulOptimization/AGENTS.md @@ -46,6 +46,21 @@ polyfill bindings (URLSession/OkHttp/timers/UUID), and public API surface. ## Commands -- Gradle build commands require Android SDK. Use `./gradlew build` from this directory. +- Gradle build commands require Android SDK. Use `./gradlew build` from this directory (the module + ships its own pinned wrapper, 8.10.2, and pins its plugin versions in `settings.gradle.kts`, so it + builds standalone — not only inside the demo's composite build). - Run `pnpm --filter @contentful/optimization-js-bridge build` to rebuild the JS bridge bundle - before Gradle build. + before Gradle build. `buildJsBridge` (wired into `preBuild`) also does this automatically. + +## Releasing + +- Published to Maven Central (Sonatype Central Portal) as `com.contentful.java:optimization-android` + by `.github/workflows/publish-android.yaml` on each `v*` release, in parallel with the Swift + package. Version comes from the tag (`-Pcontentful.optimization.version` / `RELEASE_VERSION`); the + group reuses Contentful's existing verified namespace `com.contentful.java`. +- Credentials are GitHub Actions secrets on `contentful/optimization`, provisioned and self-verified + by `scripts/setup-maven-central-credential.sh` (Central Portal token + GPG signing key). The + published artifacts are generated; nobody edits them by hand. +- Smoke-test packaging locally with + `./gradlew publishToMavenLocal -Pcontentful.optimization.version=0.0.0-local` and consume it from + a real app via `mavenLocal()` — this is how the Android demo is verified before a real release. diff --git a/packages/android/ContentfulOptimization/build.gradle.kts b/packages/android/ContentfulOptimization/build.gradle.kts index 0840aa88..baa43047 100644 --- a/packages/android/ContentfulOptimization/build.gradle.kts +++ b/packages/android/ContentfulOptimization/build.gradle.kts @@ -1,9 +1,24 @@ +import com.vanniktech.maven.publish.AndroidSingleVariantLibrary +import com.vanniktech.maven.publish.SonatypeHost + plugins { id("com.android.library") id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.plugin.compose") + // Version inline so the module builds when included as a subproject (parent builds don't pin it). + id("com.vanniktech.maven.publish") version "0.30.0" } +// Published coordinate: com.contentful.java:optimization-android. We reuse Contentful's existing, +// already-verified Maven Central namespace (com.contentful.java) rather than registering a new one. +// The Android package namespace stays com.contentful.optimization (group != package, no conflict). +// Version flows from the release tag in CI (-Pcontentful.optimization.version / RELEASE_VERSION), +// matching the npm and SPM release versions cut from the same tag. +group = "com.contentful.java" +version = (project.findProperty("contentful.optimization.version") as String?) + ?: System.getenv("RELEASE_VERSION") + ?: "0.0.0-SNAPSHOT" + android { namespace = "com.contentful.optimization" compileSdk = 36 @@ -70,6 +85,61 @@ val buildJsBridge = tasks.register("buildJsBridge") { } tasks.named("preBuild").configure { dependsOn(buildJsBridge) } +// Maven Central publishing via the Sonatype Central Portal. The vanniktech plugin configures the +// AGP single-variant ("release") publication, including sources and javadoc jars, so we do NOT add +// an android { publishing { singleVariant(...) } } block ourselves (that would double-configure it). +mavenPublishing { + configure( + AndroidSingleVariantLibrary( + variant = "release", + sourcesJar = true, + publishJavadocJar = true, + ) + ) + + publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL, automaticRelease = true) + + // Sign with the in-memory GPG key supplied by CI (ORG_GRADLE_PROJECT_signingInMemoryKey*). + // Skipped automatically when no key is configured (e.g. local publishToMavenLocal smoke tests), + // so the artifact can be assembled and consumed locally without GPG. + if (project.findProperty("signingInMemoryKey") != null) { + signAllPublications() + } + + coordinates("com.contentful.java", "optimization-android", version.toString()) + + pom { + name.set("Contentful Optimization Android SDK") + description.set( + "Native Android (Kotlin) SDK for the Contentful Optimization product: " + + "personalization, audience qualification, view/click tracking, and preview overrides." + ) + inceptionYear.set("2026") + url.set("https://github.com/contentful/optimization") + licenses { + license { + name.set("MIT License") + url.set("https://opensource.org/licenses/MIT") + distribution.set("repo") + } + } + developers { + developer { + id.set("contentful") + name.set("Contentful") + url.set("https://github.com/contentful") + organization.set("Contentful") + organizationUrl.set("https://www.contentful.com/") + } + } + scm { + url.set("https://github.com/contentful/optimization") + connection.set("scm:git:git://github.com/contentful/optimization.git") + developerConnection.set("scm:git:ssh://git@github.com/contentful/optimization.git") + } + } +} + dependencies { implementation("io.github.dokar3:quickjs-kt:1.0.5") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") diff --git a/packages/android/ContentfulOptimization/gradle/wrapper/gradle-wrapper.jar b/packages/android/ContentfulOptimization/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..a4b76b95 Binary files /dev/null and b/packages/android/ContentfulOptimization/gradle/wrapper/gradle-wrapper.jar differ diff --git a/packages/android/ContentfulOptimization/gradle/wrapper/gradle-wrapper.properties b/packages/android/ContentfulOptimization/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..df97d72b --- /dev/null +++ b/packages/android/ContentfulOptimization/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/packages/android/ContentfulOptimization/gradlew b/packages/android/ContentfulOptimization/gradlew new file mode 100755 index 00000000..f5feea6d --- /dev/null +++ b/packages/android/ContentfulOptimization/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/packages/android/ContentfulOptimization/gradlew.bat b/packages/android/ContentfulOptimization/gradlew.bat new file mode 100644 index 00000000..9b42019c --- /dev/null +++ b/packages/android/ContentfulOptimization/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/packages/android/ContentfulOptimization/settings.gradle.kts b/packages/android/ContentfulOptimization/settings.gradle.kts index 249fcdff..7266ff12 100644 --- a/packages/android/ContentfulOptimization/settings.gradle.kts +++ b/packages/android/ContentfulOptimization/settings.gradle.kts @@ -4,9 +4,17 @@ pluginManagement { mavenCentral() gradlePluginPortal() } + // Plugin versions so the module builds standalone. vanniktech's version is inline in + // build.gradle.kts instead (subproject builds bypass this file). + plugins { + id("com.android.library") version "8.7.3" + id("org.jetbrains.kotlin.android") version "2.3.20" + id("org.jetbrains.kotlin.plugin.compose") version "2.3.20" + } } -dependencyResolution { +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS) repositories { google() mavenCentral() diff --git a/scripts/setup-maven-central-credential.sh b/scripts/setup-maven-central-credential.sh new file mode 100755 index 00000000..49c8d859 --- /dev/null +++ b/scripts/setup-maven-central-credential.sh @@ -0,0 +1,265 @@ +#!/usr/bin/env bash +# +# setup-maven-central-credential.sh +# +# One-time setup for the Android Maven Central publishing workflow. +# +# The release workflow `.github/workflows/publish-android.yaml` builds the Android +# library (`com.contentful.java:optimization-android`) and publishes it to Maven +# Central through the Sonatype Central Portal. That needs two kinds of credential, +# both stored as GitHub Actions secrets on `contentful/optimization`: +# +# 1. A Central Portal USER TOKEN (username + password) that authorizes uploads +# to the `com.contentful.java` namespace. +# 2. A GPG signing key (Maven Central requires every artifact to be signed), with +# its PUBLIC half published to the keyservers Sonatype validates against. +# +# This script provisions all of that and then VERIFIES it, so the person who runs +# it can walk away confident CI will publish on the next release. +# +# WHO RUNS THIS: someone who (a) has been granted access to the Contentful Central +# Portal account that owns the `com.contentful.java` namespace (pending IT), and +# (b) can set GitHub Actions secrets on `contentful/optimization`. +# +# WHAT IT DOES: +# 1. Checks prerequisites (gh authed + can set Actions secrets; gpg; curl; base64). +# 2. Generates (or reuses) an rsa4096 GPG signing key and publishes the public +# key to keyserver.ubuntu.com and keys.openpgp.org. +# 3. Prompts for the Central Portal user token (it cannot be minted via API). +# 4. Stores five GitHub Actions secrets on contentful/optimization. +# 5. Verifies: every secret exists, the token actually authenticates against the +# Central Portal API, and the public key is retrievable from a keyserver. +# +# It is safe to re-run: `gh secret set` overwrites, an existing signing key is +# reused, and the keyserver upload is idempotent. +# +# Usage: +# ./setup-maven-central-credential.sh # interactive +# ./setup-maven-central-credential.sh --yes # skip confirmation prompts +# +set -euo pipefail + +# ---------------------------------------------------------------------------- +# Configuration — change only if the repo, coordinate, or namespace changes. +# ---------------------------------------------------------------------------- +SOURCE_REPO="contentful/optimization" +NAMESPACE="com.contentful.java" +ARTIFACT="optimization-android" + +# GPG identity used for the release signing key (reused across re-runs). +GPG_NAME="Contentful Optimization" +GPG_EMAIL="mobile@contentful.com" +GPG_UID="$GPG_NAME <$GPG_EMAIL>" + +# Keyservers Maven Central fetches public keys from to validate .asc signatures. +KEYSERVERS=("keyserver.ubuntu.com" "keys.openpgp.org") + +# The release workflow reads these five secrets. The names are also the suffixes +# of the ORG_GRADLE_PROJECT_* env vars the vanniktech plugin expects (see the +# workflow): mavenCentralUsername/Password, signingInMemoryKey/KeyId/KeyPassword. +SECRET_USERNAME="MAVEN_CENTRAL_USERNAME" +SECRET_PASSWORD="MAVEN_CENTRAL_PASSWORD" +SECRET_KEY="MAVEN_SIGNING_KEY" +SECRET_KEY_ID="MAVEN_SIGNING_KEY_ID" +SECRET_KEY_PASSWORD="MAVEN_SIGNING_PASSWORD" + +ASSUME_YES=false +[[ "${1:-}" == "--yes" || "${1:-}" == "-y" ]] && ASSUME_YES=true + +# ---------------------------------------------------------------------------- +# Helpers +# ---------------------------------------------------------------------------- +step() { printf '\n\033[1;34m==> %s\033[0m\n' "$*"; } +ok() { printf '\033[1;32m ✓ %s\033[0m\n' "$*"; } +warn() { printf '\033[1;33m ! %s\033[0m\n' "$*"; } +die() { printf '\033[1;31m ✗ %s\033[0m\n' "$*" >&2; exit 1; } + +confirm() { + $ASSUME_YES && return 0 + local reply + read -r -p " $1 [y/N] " reply + [[ "$reply" =~ ^[Yy]$ ]] +} + +# ---------------------------------------------------------------------------- +# 1. Prerequisites +# ---------------------------------------------------------------------------- +step "Checking prerequisites" + +command -v gh >/dev/null || die "GitHub CLI ('gh') is not installed. See https://cli.github.com/" +command -v gpg >/dev/null || die "gpg is not installed (brew install gnupg)." +command -v curl >/dev/null || die "curl is not available." +command -v base64 >/dev/null || die "base64 is not available." + +gh auth status >/dev/null 2>&1 || die "Not logged in to gh. Run: gh auth login" +ok "gh is authenticated as: $(gh api user --jq .login)" + +# Listing Actions secrets needs the same permission as setting them, so this is a +# non-destructive probe: if you can list them, you can set them. +if gh api "repos/$SOURCE_REPO/actions/secrets" >/dev/null 2>&1; then + ok "You can manage Actions secrets on $SOURCE_REPO" +else + die "You can't manage Actions secrets on $SOURCE_REPO. + You need admin (or a role/grant that includes 'secrets') on that repo. + Ask an org admin to run this script, or to grant you that access." +fi + +# ---------------------------------------------------------------------------- +# 2. Summary + confirm +# ---------------------------------------------------------------------------- +step "This will:" +cat </dev/null \ + | awk -F: '/^fpr:/ { print $10; exit }' || true)" + +GPG_PASSPHRASE="" +if [[ -n "$KEY_FPR" ]]; then + ok "Reusing existing signing key: $KEY_FPR" + warn "Re-enter its passphrase so it can be stored for CI (input hidden)." + read -r -s -p " GPG passphrase: " GPG_PASSPHRASE; echo +else + warn "No signing key for $GPG_UID found; generating a fresh rsa4096 key." + read -r -s -p " Choose a passphrase for the new key: " GPG_PASSPHRASE; echo + read -r -s -p " Confirm passphrase: " GPG_PASSPHRASE2; echo + [[ "$GPG_PASSPHRASE" == "$GPG_PASSPHRASE2" ]] || die "Passphrases did not match." + # Non-interactive generation. 2y expiry — document a rotation runbook before it lapses. + gpg --batch --pinentry-mode loopback --passphrase "$GPG_PASSPHRASE" \ + --quick-generate-key "$GPG_UID" rsa4096 sign 2y >/dev/null 2>&1 + KEY_FPR="$(gpg --list-secret-keys --with-colons "$GPG_UID" \ + | awk -F: '/^fpr:/ { print $10; exit }')" + ok "Generated signing key: $KEY_FPR" +fi + +# vanniktech's signingInMemoryKeyId wants the short (last 8 hex) key id. +KEY_ID_SHORT="${KEY_FPR: -8}" +ok "Short key id: $KEY_ID_SHORT" + +# ASCII-armored private key for the in-memory signer. +ARMORED_KEY="$(gpg --batch --pinentry-mode loopback --passphrase "$GPG_PASSPHRASE" \ + --armor --export-secret-keys "$KEY_FPR")" +[[ -n "$ARMORED_KEY" ]] || die "Failed to export the armored secret key." + +step "Publishing the PUBLIC key to keyservers" +# Maven Central's validator downloads the public key by fingerprint to verify the +# .asc signatures on every artifact. Without this, releases fail validation. +for ks in "${KEYSERVERS[@]}"; do + if gpg --keyserver "$ks" --send-keys "$KEY_FPR" >/dev/null 2>&1; then + ok "Sent to $ks" + else + warn "Could not send to $ks (will re-check during verification)." + fi +done + +# ---------------------------------------------------------------------------- +# 4. Central Portal user token +# ---------------------------------------------------------------------------- +step "Central Portal user token" +cat </cmdline). Each call is its own statement so `set -e` +# aborts on a failed upload instead of being swallowed by a `&&` short-circuit. +set_secret() { + local name="$1" value="$2" + printf '%s' "$value" | gh secret set "$name" --repo "$SOURCE_REPO" + ok "$name" +} +set_secret "$SECRET_USERNAME" "$CP_USER" +set_secret "$SECRET_PASSWORD" "$CP_PASS" +set_secret "$SECRET_KEY" "$ARMORED_KEY" +set_secret "$SECRET_KEY_ID" "$KEY_ID_SHORT" +set_secret "$SECRET_KEY_PASSWORD" "$GPG_PASSPHRASE" + +# ---------------------------------------------------------------------------- +# 6. Verify the end state +# ---------------------------------------------------------------------------- +step "Verifying the configuration is complete" + +FAILED=0 + +# 6a. Every secret exists on the repo. +for s in "$SECRET_USERNAME" "$SECRET_PASSWORD" "$SECRET_KEY" "$SECRET_KEY_ID" "$SECRET_KEY_PASSWORD"; do + if gh api "repos/$SOURCE_REPO/actions/secrets/$s" >/dev/null 2>&1; then + ok "secret present: $s" + else + warn "secret MISSING: $s"; FAILED=1 + fi +done + +# 6b. The token actually authenticates against the Central Portal API. +# There is no pure "ping" endpoint, so we hit a read-only endpoint that never creates a +# deployment. 401/403 means the credentials were rejected; 2xx is a positive confirmation. +# Other codes (e.g. 404 for the probe version, 5xx) mean the token was not rejected but we +# can't positively confirm it, so we warn rather than claim success. +B64="$(printf '%s:%s' "$CP_USER" "$CP_PASS" | base64 | tr -d '\n')" +CODE="$(curl -s -o /dev/null -w '%{http_code}' \ + -H "Authorization: Bearer $B64" \ + "https://central.sonatype.com/api/v1/publisher/published?namespace=$NAMESPACE&name=$ARTIFACT&version=0.0.0-probe" || echo 000)" +case "$CODE" in + 2*) ok "Central Portal token authenticated (HTTP $CODE)" ;; + 401|403) warn "Central Portal rejected the token (HTTP $CODE) — wrong token or no access to '$NAMESPACE'."; FAILED=1 ;; + 000) warn "Could not reach the Central Portal API (network?)."; FAILED=1 ;; + *) warn "Central Portal returned HTTP $CODE — credentials were not rejected, but the token could not be positively confirmed. Verify the first real publish." ;; +esac + +# 6c. The public key is retrievable by fingerprint from at least one of the keyservers we +# published to (in a throwaway keyring). It only needs to be servable by one for Maven Central +# to validate signatures; keys.openpgp.org in particular won't serve until the UID email is +# confirmed, so requiring all of them would give a false failure. Propagation lags, so retry. +TMP_GNUPG="$(mktemp -d)" +KEY_FOUND=0 +for attempt in 1 2 3 4 5; do + for ks in "${KEYSERVERS[@]}"; do + if GNUPGHOME="$TMP_GNUPG" gpg --batch --keyserver "$ks" \ + --recv-keys "$KEY_FPR" >/dev/null 2>&1; then + KEY_FOUND=1; FOUND_KS="$ks"; break 2 + fi + done + sleep 10 +done +if [[ "$KEY_FOUND" == 1 ]]; then + ok "Public key retrievable from $FOUND_KS" +else + warn "Public key not yet retrievable from ${KEYSERVERS[*]} — propagation can take minutes; re-check later with: + gpg --keyserver keyserver.ubuntu.com --recv-keys $KEY_FPR" + FAILED=1 +fi +rm -rf "$TMP_GNUPG" + +# ---------------------------------------------------------------------------- +# 7. Result +# ---------------------------------------------------------------------------- +if [[ "$FAILED" == 0 ]]; then + printf '\n\033[1;32mAll set — CI can publish %s:%s to Maven Central.\033[0m\n' "$NAMESPACE" "$ARTIFACT" +else + die "One or more checks failed (see above). Fix them and re-run; this script is idempotent." +fi