8000 Release tooling after 0.2.0: bin/release.sh, verify scripts that check Central, templates on 0.2.0 by rleibman · Pull Request #162 · rleibman/thicket · GitHub
[go: up one dir, main page]

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
21 changes: 13 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
**Native thick-client UIs in Scala 3.** One `Element` tree, real platform widgets —
`android.view.*` on Android, UIKit/AppKit on iOS and macOS, GTK4 on Linux.

Scala 3.9 · sbt 2.0.9 · Scala Native 0.5.12 · Apache-2.0 · **pre-release, not on Maven Central yet**
Scala 3.9 · sbt 2.0.9 · Scala Native 0.5.12 · Apache-2.0 · **pre-release, 0.2.0 on Maven Central**

<p align="center">
<img src="docs/screenshots/android-todo.png" width="260" alt="The demo app on Android">
Expand Down Expand Up @@ -35,7 +35,7 @@ accessibility comes with the control rather than being retrofitted onto a canvas
| Widgets | **26 of 32** as widgets, all 26 measured on every renderer (`TabView` reached AppKit and UIKit in [#69](https://github.com/rleibman/thicket/issues/69); `RadioGroup` and `Stepper`, built as §12.2a exceptions, in [#150](https://github.com/rleibman/thicket/issues/150): radio `NSButton`s and an iOS checkmark list, `NSStepper` and `UIStepper`). **32 of 32** covered, counting the six the framework provides another way (SafeArea and Link as props, List as `LazyColumn`, NavigationStack as `Nav`, Menu as a context-menu prop, Toolbar as screen actions) → [docs/12](docs/12-component-status.md) |
| Navigation | Native container on all four: `AdwNavigationView`, a `FrameLayout` + `Slide`, `UINavigationController`, a macOS sidebar. Back gesture and title bar are the platform's own |
| Theming | By role, scoped to a subtree with `Provide` |
| Published artefacts | `net.leibman`, tagged **v0.1.0** — but not on Maven Central yet, so `publishLocal` is the only route. [docs/14](docs/14-releasing.md) |
| Published artefacts | **0.2.0 on Maven Central** (2026-11-09), group `net.leibman`, 69 modules: the framework, every renderer and device service, and the `sbt-thicket` plugin. Two known Apple faults in it, fixed on `main` by #159. [docs/14](docs/14-releasing.md) |
| API stability | **None.** Expect breaking changes without notice |
| Windows | Not started, and out of the 0.1 scope |

Expand All @@ -44,10 +44,13 @@ platforms rather than sketched on one. Twenty-six widgets on every renderer, wit
toolkit. Where a prop is handled but not yet honoured on a platform, docs/12 §12.4 says which.

What is genuinely missing: nothing from the v1 catalogue — its last two entries, Radio and
Stepper, are built as §12.2a exceptions (#150) — and Maven Central publishing. An app can be built from
outside this repo on GTK, macOS and the iOS simulator — `bin/verify-getting-started.sh` and
`bin/verify-getting-started-apple.sh` check exactly that, by building a template outside the
repo against published artefacts and running it.
Stepper, are built as §12.2a exceptions (#150). An app can be built from outside this repo,
from Maven Central alone, on GTK, macOS and the iOS simulator — `bin/verify-getting-started.sh`
and `bin/verify-getting-started-apple.sh` check exactly that, by building a template outside
the repo against the released artefacts and running it. Two gaps stand between that and
*shipping* an app: there is no Android template or plugin yet, so an Android app outside this
repo has to copy `examples/todo-android`'s build; and the Apple jars carry simulator-only Swift
libraries, so nothing runs on a real iPhone yet (#161).

## Getting started

Expand Down Expand Up @@ -166,11 +169,13 @@ cp -r templates/hello-thicket ~/my-app && cd ~/my-app
sbt nativeLink && ./target/out/native0.5/scala-3.9.0/hello-thicket/hello-thicket
```

It depends on published artefacts rather than on this repo. Until a release is on Maven
Central you need a local publish first:
It depends on published artefacts rather than on this repo: by default the latest release on
Maven Central, `0.2.0`. To build it against this checkout instead, publish locally first and
name that version:

```bash
cd /path/to/thicket && sbt 'signalsNative/publishLocal; rendererApiNative/publishLocal; coreNative/publishLocal; rendererGtk/publishLocal; sbtThicket/publishLocal'
cd ~/my-app && THICKET_VERSION=<the version sbt printed> sbt nativeLink
```

The whole of the build configuration is one plugin:
Expand Down
154 changes: 154 additions & 0 deletions bin/release.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
#!/usr/bin/env bash
# Stage a Thicket release on Maven Central: the Mac builds, this machine signs and uploads.
#
# THICKET_MAC_HOST=<mac> THICKET_GPG_KEY=<fingerprint> bin/release.sh 0.3.0
#
# The route the first release took (0.2.0, 2026-11-09; docs/14 §14.5):
#
# 1. The Mac clones the tag `v<version>` and runs `publish; platforms/publish` unsigned into
# `target/sona-staging`. Only a Mac can build every artefact: seven Apple modules carry
# Swift libraries that need Xcode, and off a Mac their `publish` is skipped. The Mac needs
# no gpg and no Central token.
# 2. The staging directory comes back here, without macOS's AppleDouble `._*` files.
# 3. Every artefact is checked (version, the four files per module, checksums, the POM
# fields Central requires, no SNAPSHOT dependency), then signed with your key here, and
# every signature verified.
# 4. The bundle is uploaded to the Central Portal as USER_MANAGED and the script waits for
# its validation. **It does not publish.** Pressing Publish in the Portal is yours, and
# it is the only permanent step: a Central release can never be withdrawn or replaced.
#
# Needs, here: ssh to the Mac without a password, gpg with the signing key, the Android SDK's
# android.jar (the Mac has no Android SDK; it is copied over), and SONATYPE_USERNAME /
# SONATYPE_PASSWORD (a Central Portal user token) in the environment.
# Needs, on the Mac: git, sbt, Xcode.
#
# RELEASE_UPLOAD=0 stops after signing, leaving the bundle in the work directory: a rehearsal
# of everything but the upload.
set -euo pipefail

ver="${1:?usage: bin/release.sh <version>, e.g. 0.3.0 — the tag v<version> must be on origin}"
mac="${THICKET_MAC_HOST:?set THICKET_MAC_HOST to the Mac that builds the jars (ssh host)}"
key="${THICKET_GPG_KEY:?set THICKET_GPG_KEY to the fingerprint of the signing key}"
upload="${RELEASE_UPLOAD:-1}"
: "${ANDROID_HOME:=$HOME/Android/Sdk}"
: "${ANDROID_PLATFORM:=android-36}"
android_jar="$ANDROID_HOME/platforms/$ANDROID_PLATFORM/android.jar"
repo_url="https://github.com/rleibman/thicket.git"
portal="https://central.sonatype.com/api/v1/publisher"

cd "$(dirname "$0")/.."

fail() { echo "FAIL: $*" >&2; exit 1; }
ssh_mac() { ssh -o BatchMode=yes "$mac" "$@"; }

# --- preconditions ---------------------------------------------------------------------------
# A release version only: anything else would be refused by build.sbt's publishTo anyway, but
# failing here says why before the Mac spends a build on it.
[[ "$ver" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-(M|RC)[0-9]+)?$ ]] || fail "$ver is not a release version (X.Y.Z, -M<n>, -RC<n>)"
git fetch -q origin main "refs/tags/v$ver:refs/tags/v$ver" 2>/dev/null || fail "no tag v$ver on origin"
commit=$(git rev-parse "v$ver^{commit}")
git merge-base --is-ancestor "$commit" origin/main || fail "v$ver ($commit) is not on origin/main"
gpg --list-secret-keys "$key" >/dev/null 2>&1 || fail "no secret key $key in gpg"
[ -f "$android_jar" ] || fail "no $android_jar — install it with: sdkmanager \"platforms;$ANDROID_PLATFORM\""
if [ "$upload" = 1 ]; then
[ -n "${SONATYPE_USERNAME:-}" ] && [ -n "${SONATYPE_PASSWORD:-}" ] ||
fail "SONATYPE_USERNAME / SONATYPE_PASSWORD are not set (a Central Portal user token)"
fi
ssh_mac true || fail "cannot ssh to $mac without a password"
echo "== v$ver is $commit, on origin/main =="
# Kept afterwards on purpose: it holds the Mac's build log and the signed bundle.
work=$(mktemp -d)
echo "== work directory: $work =="

# --- 1. the Mac builds -----------------------------------------------------------------------
remote=$(ssh_mac 'mktemp -d /tmp/thicket-release.XXXXXX')
cleanup() { ssh_mac "rm -rf '$remote'" 2>/dev/null || true; }
trap cleanup EXIT
echo "== the Mac ($mac) builds v$ver in $remote =="
ssh_mac "git clone -q -c advice.detachedHead=false --branch v$ver $repo_url '$remote/thicket' && mkdir -p '$remote/thicket/modules/renderer-android/lib'"
scp -q -o BatchMode=yes "$android_jar" "$mac:$remote/thicket/modules/renderer-android/lib/android.jar"
# A login shell, for the PATH Homebrew's sbt is on.
ssh_mac "zsh -lc 'cd $remote/thicket && sbt --error -J-Xmx8G \"publish; platforms/publish\"'" >"$work/mac-build.log" 2>&1 ||
{ tail -30 "$work/mac-build.log" >&2; fail "the Mac's build failed; the log is $work/mac-build.log"; }
grep -E '^built ' "$work/mac-build.log" | sed 's/^/ /' || true

# --- 2. the staging comes back ---------------------------------------------------------------
# COPYFILE_DISABLE and --no-xattrs: otherwise macOS's tar adds a `._name` file beside every
# file, and they would go into the bundle (measured on the 0.2.0 rehearsal).
staging="$work/staging"
mkdir -p "$staging"
ssh_mac "cd '$remote/thicket/target/sona-staging' && COPYFILE_DISABLE=1 tar --no-xattrs -cf - ." | tar xf - -C "$staging" 2>/dev/null
cd "$staging"

# --- 3. check, sign, verify ------------------------------------------------------------------
modules=$(find . -name '*.pom' | wc -l | tr -d ' ')
[ "$modules" -gt 0 ] || fail "the Mac staged nothing"
[ -z "$(find . -name '._*' -o -name '.DS_Store')" ] || fail "macOS metadata files in the staging"
others=$(find net -mindepth 3 -maxdepth 3 -type d ! -name "$ver")
[ -z "$others" ] || fail "versions other than $ver staged: $others"
for pom in $(find . -name '*.pom'); do
d=$(dirname "$pom"); base="${pom%.pom}"
for f in "$base.jar" "$base-sources.jar" "$base-javadoc.jar"; do
[ -f "$f" ] || fail "missing $(basename "$f") in $d"
done
for t in name description url licenses developers scm; do
grep -q "<$t>" "$pom" || fail "$(basename "$pom") has no <$t>, which Central requires"
done
! grep -q SNAPSHOT "$pom" || fail "$(basename "$pom") depends on a SNAPSHOT"
done
for f in $(find . -type f ! -name '*.md5' ! -name '*.sha1'); do
[ "$(md5sum <"$f" | cut -c1-32)" = "$(cut -c1-32 "$f.md5")" ] &&
[ "$(sha1sum <"$f" | cut -c1-40)" = "$(cut -c1-40 "$f.sha1")" ] || fail "checksum mismatch: $f"
done
echo "== $modules modules, every one with jar, sources, javadoc and a complete POM; checksums match =="

echo "== signing with $key (gpg may ask for the passphrase once) =="
signed=0
for f in $(find . -type f ! -name '*.md5' ! -name '*.sha1' ! -name '*.asc'); do
gpg --batch --yes --armor --detach-sign -u "$key" "$f"
signed=$((signed + 1))
done
for a in $(find . -name '*.asc'); do
gpg --verify "$a" "${a%.asc}" 2>/dev/null || fail "signature does not verify: $a"
done
echo "== $signed files signed, every signature verified =="

bundle="$work/thicket-$ver-bundle.zip"
zip -q -r -X "$bundle" net
echo "== bundle: $bundle ($(du -h "$bundle" | cut -f1)) =="

if [ "$upload" != 1 ]; then
echo "RELEASE_UPLOAD=0: stopping before the upload. Nothing was sent to Central."
exit 0
fi

# --- 4. upload, validate, and stop -----------------------------------------------------------
auth="Authorization: Bearer $(printf '%s:%s' "$SONATYPE_USERNAME" "$SONATYPE_PASSWORD" | base64 | tr -d '\n')"
id=$(curl -sf -m 900 -X POST -H "$auth" -F "bundle=@$bundle" \
"$portal/upload?name=net.leibman:thicket:$ver&publishingType=USER_MANAGED") || fail "the upload was refused"
echo "== uploaded: deployment $id; waiting for Central's validation =="
for _ in $(seq 1 80); do
status=$(curl -sf -m 30 -X POST -H "$auth" "$portal/status?id=$id")
state=$(printf '%s' "$status" | python3 -I -c 'import sys, json; print(json.load(sys.stdin)["deploymentState"])')
case "$state" in VALIDATED | FAILED) break ;; esac
sleep 15
done
printf '%s' "$status" | python3 -I -c '
import sys, json
d = json.load(sys.stdin)
print("== state:", d["deploymentState"], "| components:", len(d.get("purls") or []))
for k, v in (d.get("errors") or {}).items():
print(" ", k, v)'
[ "$state" = VALIDATED ] || fail "Central did not validate deployment $id (state $state); drop it in the Portal"

cat <<EOF
PASS: v$ver is staged on Central as deployment $id, validated, NOT published.

Next, by hand:
1. central.sonatype.com -> View Deployments -> $id: check the component list, then Publish
(permanent), or Drop.
2. Once repo1.maven.org has it (10-30 minutes):
THICKET_VERSION=$ver bin/verify-getting-started.sh
THICKET_VERSION=$ver bin/verify-getting-started-apple.sh # on the Mac
3. A PR moving each templates/*/project/plugins.sbt default to $ver (docs/14 §14.5 step 6).
EOF
80 changes: 59 additions & 21 deletions bin/verify-getting-started-apple.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@
# "It came up" is measured, not assumed:
# macOS the app is still running after 10s, it owns a window, and no crash report appeared;
# iOS the app is still running in the simulator after 10s (ios-app/build-app.sh checks).
#
# Two modes, as in bin/verify-getting-started.sh:
# bin/verify-getting-started-apple.sh ["iPhone 17"] publishes this checkout
# THICKET_VERSION=0.2.0 bin/verify-getting-started-apple.sh [...] a release, from Maven
# Central alone
# In the second nothing is published, sbt gets an empty coursier cache, Ivy home and global
# base, and the script fails unless every thicket jar the builds used came from repo1.maven.org.
set -euo pipefail

cd "$(dirname "$0")/.."
Expand All @@ -19,27 +26,56 @@ work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
device="${1:-iPhone 17}"

echo "== publishing the framework locally (needs Xcode: the renderer's jar carries the Swift shim) =="
for t in signalsNative rendererApiNative coreNative rendererApple sbtThicket; do
sbt --error "$t/publishLocal"
done
if [ -n "${THICKET_VERSION:-}" ]; then
ver="$THICKET_VERSION"
echo "== a released version, $ver: resolving from Maven Central >
export COURSIER_CACHE="$work/coursier"
export SBT_OPTS="${SBT_OPTS:-} -Dsbt.ivy.home=$work/ivy -Dsbt.global.base=$work/sbt-global -Dsbt.boot.directory=$HOME/.sbt/boot"
# The server's socket lives under the global base, and macOS's $TMPDIR is long enough that
# the socket path passes the 104-byte limit ("socket file absolute path too long" —
# measured). sbt's own suggested fix: a short server directory.
server_dir=$(mktemp -d /tmp/thk.XXXXXX)
trap 'rm -rf "$work" "$server_dir"' EXIT
export SBT_GLOBAL_SERVER_DIR="$server_dir"
else
echo "== publishing the framework locally (needs Xcode: the renderer's jar carries the Swift shim) =="
for t in signalsNative rendererApiNative coreNative rendererApple sbtThicket; do
sbt --error "$t/publishLocal"
done

# The version, wherever it is on the line. sbt's thin client surrounds it with "[success]"
# lines and terminal escape sequences, and under the CI runner an escape *precedes* the
# version on its own line — so escapes are stripped and the version matched anywhere, not
# at the start. `|| true`: under `pipefail` a grep that finds nothing would otherwise end the
# script silently, before the message below could say why.
version_of() {
sbt --error "print $1/version" | tr -d '\r' | sed $'s/\x1b\\[[0-9;]*[A-Za-z]//g' |
grep -oE '[0-9]+\.[0-9]+\.[0-9]+[^[:space:]]*' | tail -1 || true
# The version, wherever it is on the line. sbt's thin client surrounds it with "[success]"
# lines and terminal escape sequences, and under the CI runner an escape *precedes* the
# version on its own line — so escapes are stripped and the version matched anywhere, not
# at the start. `|| true`: under `pipefail` a grep that finds nothing would otherwise end the
# script silently, before the message below could say why.
version_of() {
sbt --error "print $1/version" | tr -d '\r' | sed $'s/\x1b\\[[0-9;]*[A-Za-z]//g' |
grep -oE '[0-9]+\.[0-9]+\.[0-9]+[^[:space:]]*' | tail -1 || true
}
ver=$(version_of coreNative)
pluginver=$(version_of sbtThicket)
[ -n "$ver" ] || { echo "FAIL: could not read the version from sbt" >&2; exit 1; }
# The plugin puts its own version on the dependencies it adds; one build makes both, so they
# agree — checked, because the failure would be a resolution error in someone else's project.
[ "$ver" = "$pluginver" ] || { echo "FAIL: framework $ver but sbt-thicket $pluginver" >&2; exit 1; }
echo "== version $ver =="
fi

# In released mode: every thicket jar the builds used must have come from Maven Central, and
# the ones named must be among them.
from_central() {
[ -n "${THICKET_VERSION:-}" ] || return 0
local jars m
jars=$(find "$COURSIER_CACHE" -path '*/net/leibman/*' -name "*-$ver.jar" ! -name '*-sources.jar' ! -name '*-javadoc.jar')
echo "$jars" | sed -E 's|.*/https/([^/]+)/.*/([^/]+)$|== resolved \2 from \1|'
for m in "$@"; do
echo "$jars" | grep -q "/https/repo1.maven.org/maven2/net/leibman/$m/$ver/" ||
{ echo "FAIL: $m $ver was not downloaded from Maven Central" >&2; exit 1; }
done
if echo "$jars" | grep -v '/https/repo1.maven.org/' | grep -q .; then
echo "FAIL: a thicket jar came from somewhere other than Maven Central" >&2; exit 1
fi
}
ver=$(version_of coreNative)
pluginver=$(version_of sbtThicket)
[ -n "$ver" ] || { echo "FAIL: could not read the version from sbt" >&2; exit 1; }
# The plugin puts its own version on the dependencies it adds; one build makes both, so they
# agree — checked, because the failure would be a resolution error in someone else's project.
[ "$ver" = "$pluginver" ] || { echo "FAIL: framework $ver but sbt-thicket $pluginver" >&2; exit 1; }
echo "== version $ver =="

# --- macOS -------------------------------------------------------------------------------
cp -r templates/hello-thicket-macos "$work/mac"
Expand Down Expand Up @@ -99,11 +135,13 @@ crashes_after=$(ls ~/Library/Logs/DiagnosticReports 2>/dev/null | grep -c '^hell
if grep -qiE "Unhandled signal|uncaught|Fatal error" "$work/mac-run.log"; then
echo "FAIL: the macOS app reported a problem:" >&2; cat "$work/mac-run.log" >&2; exit 1
fi
echo "PASS: macOS — outside the repo, it resolves, links and runs against $ver"
from_central sbt-thicket_sbt2_3 thicket-core_native0.5_3 thicket-renderer-apple_native0.5_3 thicket-signals_native0.5_3
echo "PASS: macOS — outside the repo, it resolves, links and runs against $ver${THICKET_VERSION:+ from Maven Central}"

# --- iOS simulator -----------------------------------------------------------------------
cd "$root"
cp -r templates/hello-thicket-ios "$work/ios"
echo "== iOS: building the template outside the repo, and running it on '$device' =="
THICKET_VERSION="$ver" "$work/ios/ios-app/build-app.sh" "$device"
echo "PASS: iOS simulator — outside the repo, it resolves, links and runs against $ver"
from_central sbt-thicket_sbt2_3 thicket-core_native0.5_3 thicket-renderer-apple_native0.5_3
echo "PASS: iOS simulator — outside the repo, it resolves, links and runs against $ver${THICKET_VERSION:+ from Maven Central}"
Loading
Loading
0