From ef407e38b4b37590a23c2066f2ffa4e1e5902d1e Mon Sep 17 00:00:00 2001 From: Zach Shallbetter Date: Tue, 21 Jul 2026 19:58:50 -0700 Subject: [PATCH 1/3] build(android): publish core + compose to GitHub Packages (com.fundamental:*:0.9.5) The Kotlin port had no Maven publishing, so downstream apps could not consume it without vendoring the source (which forks the engine). Adds maven-publish to the two modules a Compose consumer needs: com.fundamental:fundamental-core:0.9.5 (pure kotlin/jvm) com.fundamental:fundamental-compose:0.9.5 (Android AAR; FieldView + Modifier.fieldBody) compose's POM carries the transitive core dependency, so a consumer depends only on fundamental-compose. Target is GitHub Packages via a shared gradle/github-packages.gradle.kts; credentials come from GITHUB_ACTOR/GITHUB_TOKEN env, nothing secret committed. local.properties (SDK path) stays gitignored. Verified: both POMs resolve 200 from maven.pkg.github.com. Co-Authored-By: Claude Opus 4.8 --- android/build.gradle.kts | 8 ++++++++ android/fundamental-compose/build.gradle.kts | 15 +++++++++++++++ android/fundamental-core/build.gradle.kts | 12 ++++++++++++ android/gradle/github-packages.gradle.kts | 17 +++++++++++++++++ 4 files changed, 52 insertions(+) create mode 100644 android/gradle/github-packages.gradle.kts diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 06ac980d..a5db7122 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -9,3 +9,11 @@ plugins { id("org.jetbrains.kotlin.android") version "2.1.0" apply false id("org.jetbrains.kotlin.plugin.compose") version "2.1.0" apply false } + +// Coordinates for the published Android/JVM artifacts (GitHub Packages). Mirrors the +// npm scope @fundamental-engine and the Swift package products. Version tracks the +// repo's release tags. +subprojects { + group = "com.fundamental" + version = "0.9.5" +} diff --git a/android/fundamental-compose/build.gradle.kts b/android/fundamental-compose/build.gradle.kts index 34d51f07..b8534d93 100644 --- a/android/fundamental-compose/build.gradle.kts +++ b/android/fundamental-compose/build.gradle.kts @@ -6,9 +6,11 @@ plugins { id("com.android.library") id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.plugin.compose") + `maven-publish` } android { + publishing { singleVariant("release") } namespace = "com.fundamental.compose" compileSdk = 34 buildToolsVersion = "34.0.0" @@ -42,3 +44,16 @@ dependencies { implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") implementation("androidx.core:core-ktx:1.13.1") } + +// ── Publishing ────────────────────────────────────────────────────────────── +apply(from = rootProject.file("gradle/github-packages.gradle.kts")) +afterEvaluate { + publishing { + publications { + create("release") { + from(components["release"]) // AGP's release AAR + POM (transitive core dep included) + artifactId = "fundamental-compose" + } + } + } +} diff --git a/android/fundamental-core/build.gradle.kts b/android/fundamental-core/build.gradle.kts index c28ea4b2..9eb934e5 100644 --- a/android/fundamental-core/build.gradle.kts +++ b/android/fundamental-core/build.gradle.kts @@ -5,6 +5,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { kotlin("jvm") kotlin("plugin.serialization") + `maven-publish` } dependencies { @@ -86,3 +87,14 @@ sourceSets.main { tasks.named("processResources") { dependsOn(syncRecipes) } + +// ── Publishing ────────────────────────────────────────────────────────────── +apply(from = rootProject.file("gradle/github-packages.gradle.kts")) +publishing { + publications { + create("maven") { + from(components["java"]) + artifactId = "fundamental-core" + } + } +} diff --git a/android/gradle/github-packages.gradle.kts b/android/gradle/github-packages.gradle.kts new file mode 100644 index 00000000..72ddc8c4 --- /dev/null +++ b/android/gradle/github-packages.gradle.kts @@ -0,0 +1,17 @@ +// Shared GitHub Packages target. Applied by the modules we publish (core, compose). +// Credentials come from the environment so nothing secret is committed: +// GITHUB_ACTOR (defaults to the repo owner) + GITHUB_TOKEN (needs write:packages). +// Uses configure rather than the `publishing { }` accessor, +// which is not generated for scripts applied via apply(from = …). +configure { + repositories { + maven { + name = "GitHubPackages" + url = uri("https://maven.pkg.github.com/zachshallbetter/fundamental-engine") + credentials { + username = System.getenv("GITHUB_ACTOR") ?: "zachshallbetter" + password = System.getenv("GITHUB_TOKEN") + } + } + } +} From 1dcb84ca828241343684f1aa870b3649ec0b0002 Mon Sep 17 00:00:00 2001 From: Zach Shallbetter Date: Tue, 21 Jul 2026 20:35:44 -0700 Subject: [PATCH 2/3] build(android): tag-derived version + CI publish on release tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the Android publishing setup so a single release tag ships every platform in lockstep. Two flaws in the initial publishing config are fixed here before it merges. Version was hardcoded 'version = "0.9.5"'. The next publisher who forgot to bump it would overwrite 0.9.5 with different code. It is now derived — CI passes -PreleaseVersion=, and a local build with no property is a 0.0.0-SNAPSHOT that GitHub Packages will not mistake for a release. Verified with publishToMavenLocal: the artifact lands at the derived version, not 0.9.5. Publishing was laptop-only with a personal token. android.yml now has a tag-triggered publish job with packages: write and the Actions GITHUB_TOKEN, so releases come from CI with an audit trail. On 'git tag vX.Y.Z': release.yml publishes npm, SPM resolves the Swift tag, and this publishes com.fundamental:{fundamental-core,fundamental-compose}:X.Y.Z — one version across three planes. Maven Central migration (to drop the read-token requirement GitHub Packages imposes on consumers) is deliberately left as a separate, credential-gated task for before any external adopter. --- .github/workflows/android.yml | 33 +++++++++++++++++++++++ android/.gitignore | 1 + android/build.gradle.kts | 10 ++++--- android/gradle/github-packages.gradle.kts | 14 +++++++--- 4 files changed, 51 insertions(+), 7 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 5a72105c..15a9632c 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -13,6 +13,8 @@ name: android on: push: branches: [main] + # a release tag publishes the Maven artifacts (see the `publish` job) + tags: ['v*'] paths: - 'android/**' - '.github/workflows/android.yml' @@ -82,3 +84,34 @@ jobs: sleep 8 adb logcat -d AndroidRuntime:E '*:S' | tee /tmp/crash.log test ! -s /tmp/crash.log || { echo "the sample crashed on the emulator"; exit 1; } + + # Publish the Kotlin artifacts to GitHub Packages on a release tag, so a single `git tag vX.Y.Z` + # releases every platform at one version: release.yml publishes npm, SPM resolves the Swift tag, + # and this publishes com.fundamental:{fundamental-core,fundamental-compose}:X.Y.Z. The version is + # derived from the tag (-PreleaseVersion), never hardcoded, and auth uses the Actions GITHUB_TOKEN + # — no personal token, no laptop publish. + publish: + name: publish maven (release tag) + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + - name: set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + - name: set up Android SDK + uses: android-actions/setup-android@v3 + - name: publish core + compose to GitHub Packages + working-directory: android + env: + GITHUB_ACTOR: ${{ github.actor }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + VERSION="${GITHUB_REF_NAME#v}" + echo "publishing com.fundamental:*:$VERSION from tag $GITHUB_REF_NAME" + ./gradlew :fundamental-core:publish :fundamental-compose:publish -PreleaseVersion="$VERSION" diff --git a/android/.gitignore b/android/.gitignore index f0041560..2f403a5e 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -7,3 +7,4 @@ build/ .idea/ *.iml local.properties +gpr.key diff --git a/android/build.gradle.kts b/android/build.gradle.kts index a5db7122..13d11d15 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -11,9 +11,13 @@ plugins { } // Coordinates for the published Android/JVM artifacts (GitHub Packages). Mirrors the -// npm scope @fundamental-engine and the Swift package products. Version tracks the -// repo's release tags. +// npm scope @fundamental-engine and the Swift package products. +// +// The version is DERIVED, never typed: CI passes -PreleaseVersion= on a +// release tag, so the published version always equals the git tag and can never silently +// overwrite a prior release with different code. A local build with no property is a SNAPSHOT, +// which GitHub Packages will not mistake for a release. subprojects { group = "com.fundamental" - version = "0.9.5" + version = (findProperty("releaseVersion") as String?) ?: "0.0.0-SNAPSHOT" } diff --git a/android/gradle/github-packages.gradle.kts b/android/gradle/github-packages.gradle.kts index 72ddc8c4..67ab3031 100644 --- a/android/gradle/github-packages.gradle.kts +++ b/android/gradle/github-packages.gradle.kts @@ -1,16 +1,22 @@ // Shared GitHub Packages target. Applied by the modules we publish (core, compose). -// Credentials come from the environment so nothing secret is committed: -// GITHUB_ACTOR (defaults to the repo owner) + GITHUB_TOKEN (needs write:packages). +// Credentials come from Gradle properties, android/gpr.key, or the environment so +// nothing secret is committed: +// gpr.user/gpr.key, GITHUB_ACTOR (defaults to repo owner), GITHUB_TOKEN. // Uses configure rather than the `publishing { }` accessor, // which is not generated for scripts applied via apply(from = …). +val gprKeyFromFile = rootProject.rootDir.resolve("gpr.key") + .takeIf { it.exists() } + ?.readText() + ?.trim() + ?.takeIf { it.isNotEmpty() } configure { repositories { maven { name = "GitHubPackages" url = uri("https://maven.pkg.github.com/zachshallbetter/fundamental-engine") credentials { - username = System.getenv("GITHUB_ACTOR") ?: "zachshallbetter" - password = System.getenv("GITHUB_TOKEN") + username = (findProperty("gpr.user") as String?) ?: System.getenv("GITHUB_ACTOR") ?: "zachshallbetter" + password = (findProperty("gpr.key") as String?) ?: gprKeyFromFile ?: System.getenv("GITHUB_TOKEN") } } } From 51b8395be4a67c0ecf547958fd8cb27c71fec956 Mon Sep 17 00:00:00 2001 From: Zach Shallbetter Date: Tue, 21 Jul 2026 20:36:04 -0700 Subject: [PATCH 3/3] docs(changelog): record the Android CI publishing setup --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6685198..4e156d9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ a git tag (see [RELEASING.md](RELEASING.md)). ## [Unreleased] +- **Android artifacts publish from CI on a release tag, at a tag-derived version.** `android.yml` gains a `publish` job (`if: refs/tags/v*`, `packages: write`, Actions `GITHUB_TOKEN` — no personal token) that runs `:fundamental-core:publish :fundamental-compose:publish`. The version is derived from the tag via `-PreleaseVersion` and defaults to a `SNAPSHOT` locally — the hardcoded `version = "0.9.5"` is gone, so a publish can never silently overwrite a release with different code. One `git tag vX.Y.Z` now releases every plane at one version: npm via `release.yml`, Swift via the SPM tag, Kotlin via this. Publication config verified against the local Maven repo before merge. - **Positioning made consistent site-wide.** The homepage rewrite changed the hero, title and description, but the footer — which renders on *every* page — still read "a readable behavior layer for host objects", so the homepage contradicted itself on the same screen. The `Base.astro` default title carried it too. Both now say "a physics engine for interfaces". The only surviving instance is the changelog entry above, which quotes the old phrase to explain why it was retired; historical records that cite a former term are left intact. - **Finished retiring the freeze vocabulary across `docs/canonical/`.** The earlier change corrected the authoritative sources but left the wording in nine other canonical documents, where "the freeze contract" and "the frozen surface" still described a promise the project had stopped making. All now say *protected* / *removal-protection*. Four uses of the word are deliberately untouched because they mean something else entirely — `Object.freeze`'d policy clones, "freeze the sim" for reduced motion, "frozen particles" in the poster render mode, and "frozen design history" for the planning archive. A pattern-matched sweep would have broken all four.