From e986c4720a1d53d3b2bd75462c4dc31e90c4cc5c Mon Sep 17 00:00:00 2001 From: Geoff Hackett Date: Sun, 19 Jul 2026 16:37:30 -0400 Subject: [PATCH] Publish snapshots via Maven's timestamped unique-snapshot protocol Sonatype central only registers the FIRST plain PUT of a non-unique snapshot filename; the repo's maven-metadata.xml keeps pointing at that build, so republishing a -SNAPSHOT version is accepted but never served. scripts/upload-snapshots.py (ported from tacita) uploads timestamped filenames and re-PUTs each module's maven-metadata.xml with an incremented buildNumber, matching what mvn/gradle do when deploying a snapshot remotely. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Xg5JMK3qu2NPT6qKtDj5H6 --- .github/workflows/publish-artifacts.yml | 19 ++- docs/CHANGELOG.md | 6 + scripts/upload-snapshots.py | 156 ++++++++++++++++++++++++ 3 files changed, 169 insertions(+), 12 deletions(-) create mode 100644 scripts/upload-snapshots.py 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()