diff --git a/.github/workflows/publish-artifacts.yml b/.github/workflows/publish-artifacts.yml
index 38feb15..306805b 100644
--- a/.github/workflows/publish-artifacts.yml
+++ b/.github/workflows/publish-artifacts.yml
@@ -81,19 +81,14 @@ jobs:
echo "Refusing to publish a non-snapshot version from a pull request"
exit 1
fi
+ # If any file path contains -SNAPSHOT treat as a snapshot and upload via the
+ # timestamped unique-snapshot protocol. Plain PUTs of non-unique snapshot
+ # filenames only register on a version's first publish — central keeps serving
+ # the first build forever after that (see scripts/upload-snapshots.py).
if find bundle -type f | grep -q -- '-SNAPSHOT'; then
- echo "Snapshot artifacts detected — uploading files individually"
- cd bundle
- AUTH_BASIC=$(printf "%s:%s" "$NEXUS_USERNAME" "$NEXUS_PASSWORD" | base64 | tr -d '\n')
- find . -type f \( ! -name "*.asc" -a ! -name "*.md5" -a ! -name "*.sha1" -a ! -name "*.sha256" -a ! -name "*.sha512" \) -print | while read -r file; do
- rel="${file#./}"
- url="https://central.sonatype.com/repository/maven-snapshots/${rel}"
- echo "Uploading $rel"
- curl --silent --show-error --fail -X PUT \
- -H "Authorization: Basic ${AUTH_BASIC}" \
- -H "Content-Type: application/octet-stream" \
- --data-binary @"$file" "$url" || { echo "Failed uploading $rel"; exit 1; }
- done
+ echo "Snapshot artifacts detected — uploading timestamped snapshot builds to maven-snapshots"
+ python3 scripts/upload-snapshots.py --bundle bundle \
+ --repo-url https://central.sonatype.com/repository/maven-snapshots
else
mv sonatype-bundle.zip ${{ github.event.repository.name }}.zip
SONATYPE_TOKEN=$(printf "%s:%s" "$NEXUS_USERNAME" "$NEXUS_PASSWORD" | base64 | tr -d '\n')
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index b3b42fa..ff6fe2a 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -2,6 +2,12 @@
### v2.0.0-alpha04 - Unreleased
+- CI: snapshot publishes now use Maven's timestamped unique-snapshot protocol (new
+ `scripts/upload-snapshots.py`, ported from tacita — uploads timestamped filenames and
+ re-PUTs each module's `maven-metadata.xml` with an incremented buildNumber). The
+ previous plain PUTs of non-unique snapshot filenames only registered on a version's
+ first publish; sonatype central accepted but never served later republishes, so a
+ republished `-SNAPSHOT` version kept serving its first build's bytes
- Add new `datastore-preferences` module: type-safe keys for Jetpack DataStore (Preferences) via `DataStoreKeyNamespace`/`DataStoreKey` (all keys are async; `get`/`set` are suspend functions; mutableStateFlow emissions are wrapped in `DataStoreValue` to distinguish the uninitialized state from an absent key)
- Raise minSdk from 21 to 23 (Android 6.0) across all modules (required by androidx.datastore 1.2)
- Add a DataStore screen + instrumented tests to the sample app, and document `flow()` observation support in the usage docs
diff --git a/scripts/upload-snapshots.py b/scripts/upload-snapshots.py
new file mode 100644
index 0000000..f3165fd
--- /dev/null
+++ b/scripts/upload-snapshots.py
@@ -0,0 +1,156 @@
+#!/usr/bin/env python3
+"""Uploads a merged mavenLocal-layout bundle of -SNAPSHOT artifacts to a Maven
+snapshot repository using the timestamped unique-snapshot protocol.
+
+Plain PUTs of non-unique snapshot filenames (foo-1.0-SNAPSHOT.jar) only register on
+the FIRST publish of a version: the repo's maven-metadata.xml keeps pointing at that
+first build, so later re-publishes are accepted but never served (observed on
+sonatype central's maven-snapshots repo, July 2026). Resolvers follow the metadata,
+so each publish must upload NEW timestamped filenames (foo-1.0-20260719.123456-2.jar)
+and re-PUT a maven-metadata.xml with an incremented buildNumber — which is exactly
+what mvn/gradle do when deploying a snapshot to a remote repo, and what this script
+recreates for a bundle assembled from per-OS mavenLocal shards.
+
+Auth comes from the NEXUS_USERNAME / NEXUS_PASSWORD env vars. Signature and checksum
+files are not uploaded (matching the repo's snapshot behavior); mavenLocal's
+maven-metadata-local.xml files are skipped in favor of the generated metadata.
+"""
+import argparse
+import base64
+import os
+import re
+import sys
+import urllib.error
+import urllib.request
+from datetime import datetime, timezone
+
+SKIP_SUFFIXES = (".asc", ".md5", ".sha1", ".sha256", ".sha512")
+
+
+def auth_header():
+ username = os.environ.get("NEXUS_USERNAME")
+ password = os.environ.get("NEXUS_PASSWORD")
+ if not username or not password:
+ sys.exit("NEXUS_USERNAME and NEXUS_PASSWORD must be set")
+ token = base64.b64encode(f"{username}:{password}".encode()).decode()
+ return f"Basic {token}"
+
+
+def http(method, url, auth, data=None):
+ request = urllib.request.Request(url, data=data, method=method)
+ request.add_header("Authorization", auth)
+ request.add_header("User-Agent", "tacita-snapshot-upload")
+ if data is not None:
+ request.add_header("Content-Type", "application/octet-stream")
+ return urllib.request.urlopen(request)
+
+
+def next_build_number(repo_url, module_rel, auth):
+ """Reads the module's existing remote metadata so the new build supersedes it."""
+ try:
+ with http("GET", f"{repo_url}/{module_rel}/maven-metadata.xml", auth) as response:
+ existing = response.read().decode()
+ except urllib.error.HTTPError as e:
+ if e.code == 404:
+ return 1
+ raise
+ builds = [int(m) for m in re.findall(r"(\d+)", existing)]
+ return max(builds, default=0) + 1
+
+
+def classifier_and_extension(filename, artifact_id, version):
+ """Splits foo-1.0-SNAPSHOT[-classifier].ext into (classifier, ext)."""
+ prefix = f"{artifact_id}-{version}"
+ if not filename.startswith(prefix):
+ return None
+ rest = filename[len(prefix):]
+ left, dot, ext = rest.partition(".")
+ if not dot or (left and not left.startswith("-")):
+ return None
+ return (left[1:] if left else None), ext
+
+
+def metadata_xml(group_id, artifact_id, version, timestamp, build, entries, updated):
+ lines = [
+ '',
+ '',
+ f" {group_id}",
+ f" {artifact_id}",
+ f" {version}",
+ " ",
+ " ",
+ f" {timestamp}",
+ f" {build}",
+ " ",
+ f" {updated}",
+ " ",
+ ]
+ for classifier, ext, value in entries:
+ lines.append(" ")
+ if classifier:
+ lines.append(f" {classifier}")
+ lines.extend([
+ f" {ext}",
+ f" {value}",
+ f" {updated}",
+ " ",
+ ])
+ lines.extend([" ", " ", "", ""])
+ return "\n".join(lines)
+
+
+def upload_module(bundle, module_rel, repo_url, auth, now):
+ artifact_id = os.path.basename(os.path.dirname(module_rel))
+ version = os.path.basename(module_rel)
+ group_id = os.path.dirname(os.path.dirname(module_rel)).replace("/", ".")
+ base_version = version[: -len("-SNAPSHOT")]
+ timestamp = now.strftime("%Y%m%d.%H%M%S")
+ updated = now.strftime("%Y%m%d%H%M%S")
+ build = next_build_number(repo_url, module_rel, auth)
+ value = f"{base_version}-{timestamp}-{build}"
+
+ entries = []
+ for filename in sorted(os.listdir(os.path.join(bundle, module_rel))):
+ if filename.endswith(SKIP_SUFFIXES) or filename.startswith("maven-metadata"):
+ continue
+ parsed = classifier_and_extension(filename, artifact_id, version)
+ if parsed is None:
+ sys.exit(f"Unexpected file in {module_rel}: {filename}")
+ classifier, ext = parsed
+ remote_name = f"{artifact_id}-{value}" + (f"-{classifier}" if classifier else "") + f".{ext}"
+ print(f"Uploading {module_rel}/{remote_name}")
+ with open(os.path.join(bundle, module_rel, filename), "rb") as f:
+ http("PUT", f"{repo_url}/{module_rel}/{remote_name}", auth, data=f.read())
+ entries.append((classifier, ext, value))
+
+ if not entries:
+ return
+ print(f"Uploading {module_rel}/maven-metadata.xml (build {build})")
+ xml = metadata_xml(group_id, artifact_id, version, timestamp, build, entries, updated)
+ http("PUT", f"{repo_url}/{module_rel}/maven-metadata.xml", auth, data=xml.encode())
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--bundle", required=True, help="root of the mavenLocal-layout bundle")
+ parser.add_argument("--repo-url", required=True, help="snapshot repository base url")
+ args = parser.parse_args()
+
+ repo_url = args.repo_url.rstrip("/")
+ auth = auth_header()
+ now = datetime.now(timezone.utc)
+
+ modules = sorted(
+ os.path.relpath(dirpath, args.bundle).replace(os.sep, "/")
+ for dirpath, _, filenames in os.walk(args.bundle)
+ if os.path.basename(dirpath).endswith("-SNAPSHOT") and filenames
+ )
+ if not modules:
+ sys.exit(f"No -SNAPSHOT module directories found under {args.bundle}")
+ for module_rel in modules:
+ upload_module(args.bundle, module_rel, repo_url, auth, now)
+ print(f"Uploaded {len(modules)} snapshot modules")
+
+
+if __name__ == "__main__":
+ main()