From c074b8a35fe8275428a2c6bb4b8736a048705c4b Mon Sep 17 00:00:00 2001 From: Justin Merrell Date: Sun, 29 Mar 2026 21:17:27 +0000 Subject: [PATCH 1/5] feat: add public cache management API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose cache inspection and management to users via module-level convenience functions and Client methods. Users can now scan cache contents, remove specific bundles, clean expired entries, and clear the entire cache — all without requiring auth credentials for read-only operations. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/musher/__init__.py | 80 ++++++++++ src/musher/_cache.py | 141 ++++++++++++++++- src/musher/_cache_info.py | 43 ++++++ src/musher/_client.py | 37 +++++ tests/test_cache_info.py | 309 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 607 insertions(+), 3 deletions(-) create mode 100644 src/musher/_cache_info.py create mode 100644 tests/test_cache_info.py diff --git a/src/musher/__init__.py b/src/musher/__init__.py index 82f988a..37049dc 100644 --- a/src/musher/__init__.py +++ b/src/musher/__init__.py @@ -1,9 +1,17 @@ """Musher Python SDK — programmatic access to the Musher bundle registry.""" +from __future__ import annotations + from importlib.metadata import PackageNotFoundError, version as _metadata_version +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pathlib import Path from musher._auth import resolve_registry_url from musher._bundle import Asset, Bundle, Manifest, ManifestAsset, ResolveResult +from musher._cache import BundleCache +from musher._cache_info import CachedBundle, CachedBundleVersion, CacheInfo from musher._client import AsyncClient, Client from musher._config import MusherConfig, configure, get_config from musher._errors import ( @@ -54,6 +62,9 @@ "BundleVersionState", "BundleVisibility", "CacheError", + "CacheInfo", + "CachedBundle", + "CachedBundleVersion", "ClaudePluginExport", "Client", "FileHandle", @@ -72,6 +83,11 @@ "ToolsetHandle", "VersionNotFoundError", "__version__", + "cache_clean", + "cache_clear", + "cache_info", + "cache_path", + "cache_remove", "configure", "get_config", "pull", @@ -104,3 +120,67 @@ async def resolve_async(ref: str) -> ResolveResult: """Resolve a bundle reference without pulling (async convenience).""" async with AsyncClient() as client: return await client.resolve(ref) + + +# ── Cache management ────────────────────────────────────────────── + + +def _make_cache( + cache_dir: Path | None = None, + registry_url: str | None = None, +) -> BundleCache: + from musher._paths import cache_dir as _default_cache_dir # noqa: PLC0415 + + return BundleCache( + cache_dir=cache_dir or _default_cache_dir(), + registry_url=registry_url or resolve_registry_url(), + ) + + +def cache_info( + *, + cache_dir: Path | None = None, + registry_url: str | None = None, +) -> CacheInfo: + """Scan the local bundle cache and return statistics.""" + return _make_cache(cache_dir, registry_url).scan() + + +def cache_remove( + ref: str, + *, + cache_dir: Path | None = None, + registry_url: str | None = None, +) -> None: + """Remove a specific bundle from the cache.""" + parsed = BundleRef.parse(ref) + _make_cache(cache_dir, registry_url).purge(parsed.namespace, parsed.slug, parsed.version) + + +def cache_clear(*, cache_dir: Path | None = None) -> None: + """Remove all cached data.""" + from musher._paths import cache_dir as _default_cache_dir # noqa: PLC0415 + + cache = BundleCache(cache_dir=cache_dir or _default_cache_dir()) + cache.clear() + + +def cache_clean( + *, + cache_dir: Path | None = None, + registry_url: str | None = None, +) -> int: + """Remove expired entries and garbage-collect orphaned blobs. + + Returns the number of entries removed. + """ + return _make_cache(cache_dir, registry_url).clean() + + +def cache_path(*, cache_dir: Path | None = None) -> Path: + """Return the cache directory path.""" + if cache_dir: + return cache_dir + from musher._paths import cache_dir as _default_cache_dir # noqa: PLC0415 + + return _default_cache_dir() diff --git a/src/musher/_cache.py b/src/musher/_cache.py index 5c62584..c54d510 100644 --- a/src/musher/_cache.py +++ b/src/musher/_cache.py @@ -11,6 +11,7 @@ from typing import cast from urllib.parse import urlparse +from musher._cache_info import CachedBundle, CachedBundleVersion, CacheInfo from musher._config import get_config _CACHEDIR_TAG_HEADER = ( @@ -178,6 +179,78 @@ def put_ref( } self._atomic_write_json(path, data) + # ── Inspection ───────────────────────────────────────────────── + + def scan(self) -> CacheInfo: + """Walk the cache directory and return structured information.""" + blob_sizes = self._scan_blobs() + bundles_tuple = self._scan_bundles(blob_sizes) + version_count = sum(len(b.versions) for b in bundles_tuple) + total_size = sum(blob_sizes.values()) + self._metadata_size() + + return CacheInfo( + path=self._cache_dir, + total_size_bytes=total_size, + bundle_count=len(bundles_tuple), + version_count=version_count, + blob_count=len(blob_sizes), + bundles=bundles_tuple, + ) + + def _scan_blobs(self) -> dict[str, int]: + """Walk blobs directory and return {digest: size_bytes} mapping.""" + blob_sizes: dict[str, int] = {} + blobs_dir = self._cache_dir / "blobs" / "sha256" + if not blobs_dir.is_dir(): + return blob_sizes + for prefix_dir in blobs_dir.iterdir(): + if not prefix_dir.is_dir(): + continue + for blob_file in prefix_dir.iterdir(): + if blob_file.is_file(): + blob_sizes[blob_file.name] = blob_file.stat().st_size + return blob_sizes + + def _scan_bundles(self, blob_sizes: dict[str, int]) -> tuple[CachedBundle, ...]: + """Walk manifests directory and build CachedBundle entries.""" + bundles: list[CachedBundle] = [] + manifests_root = self._cache_dir / "manifests" + if not manifests_root.is_dir(): + return () + for host_dir in manifests_root.iterdir(): + if not host_dir.is_dir(): + continue + for ns_dir in host_dir.iterdir(): + if not ns_dir.is_dir(): + continue + for slug_dir in ns_dir.iterdir(): + if not slug_dir.is_dir(): + continue + versions = _scan_versions(slug_dir, blob_sizes) + if versions: + total = sum(v.size_bytes for v in versions) + bundles.append( + CachedBundle( + namespace=ns_dir.name, + slug=slug_dir.name, + host=host_dir.name, + versions=tuple(versions), + total_size_bytes=total, + ) + ) + return tuple(bundles) + + def _metadata_size(self) -> int: + """Sum file sizes under manifests/ and refs/ directories.""" + total = 0 + for root_name in ("manifests", "refs"): + root = self._cache_dir / root_name + if root.is_dir(): + for f in root.rglob("*"): + if f.is_file(): + total += f.stat().st_size + return total + # ── Maintenance ──────────────────────────────────────────────── def clean(self) -> int: @@ -253,11 +326,11 @@ def _collect_referenced_blobs(self) -> set[str]: if not manifests_root.is_dir(): return referenced - for manifest_file in manifests_root.rglob("*.json"): - if manifest_file.name.endswith(".meta.json"): + for mf in manifests_root.rglob("*.json"): + if mf.name.endswith(".meta.json"): continue try: - manifest = cast("dict[str, object]", json.loads(manifest_file.read_text())) + manifest = cast("dict[str, object]", json.loads(mf.read_text())) manifest_obj = cast("dict[str, object]", manifest.get("manifest", manifest)) layers = cast("list[dict[str, object]]", manifest_obj.get("layers", [])) for layer in layers: @@ -339,3 +412,65 @@ def _atomic_write_json(path: Path, data: dict[str, object]) -> None: except BaseException: tmp.unlink(missing_ok=True) raise + + +# ── Module-level scan helpers ───────────────────────────────────── + + +def _scan_versions( + slug_dir: Path, + blob_sizes: dict[str, int], +) -> list[CachedBundleVersion]: + """Scan a slug directory for cached versions.""" + versions: list[CachedBundleVersion] = [] + + for manifest_file in slug_dir.iterdir(): + if not manifest_file.is_file() or manifest_file.name.endswith(".meta.json"): + continue + if not manifest_file.name.endswith(".json"): + continue + + version = manifest_file.stem + fetched_at, is_fresh = _read_meta_freshness(manifest_file.with_name(f"{version}.meta.json")) + size_bytes = _compute_version_blob_size(manifest_file, blob_sizes) + + versions.append( + CachedBundleVersion( + version=version, + size_bytes=size_bytes, + fetched_at=fetched_at, + is_fresh=is_fresh, + ) + ) + + return versions + + +def _read_meta_freshness(meta_file: Path) -> tuple[datetime | None, bool]: + """Read a .meta.json sidecar and return (fetched_at, is_fresh).""" + if not meta_file.is_file(): + return None, False + try: + raw = cast("dict[str, object]", json.loads(meta_file.read_text(encoding="utf-8"))) + fetched_at = datetime.fromisoformat(cast("str", raw["fetchedAt"])) + ttl = cast("int", raw.get("ttlSeconds", _DEFAULT_MANIFEST_TTL)) + age = (datetime.now(UTC) - fetched_at).total_seconds() + return fetched_at, age < ttl + except (KeyError, ValueError, json.JSONDecodeError): + return None, False + + +def _compute_version_blob_size(manifest_file: Path, blob_sizes: dict[str, int]) -> int: + """Sum the sizes of blobs referenced by a manifest file.""" + try: + manifest = cast("dict[str, object]", json.loads(manifest_file.read_text(encoding="utf-8"))) + manifest_obj = cast("dict[str, object]", manifest.get("manifest", manifest)) + layers = cast("list[dict[str, object]]", manifest_obj.get("layers", [])) + total = 0 + for layer in layers: + sha = cast("str | None", layer.get("contentSha256")) + if sha and sha in blob_sizes: + total += blob_sizes[sha] + return total + except (json.JSONDecodeError, KeyError, TypeError): + return 0 diff --git a/src/musher/_cache_info.py b/src/musher/_cache_info.py new file mode 100644 index 0000000..08082bc --- /dev/null +++ b/src/musher/_cache_info.py @@ -0,0 +1,43 @@ +"""Data types for cache inspection results.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from datetime import datetime + from pathlib import Path + + +@dataclass(frozen=True) +class CachedBundleVersion: + """Information about a single cached bundle version.""" + + version: str + size_bytes: int + fetched_at: datetime | None + is_fresh: bool + + +@dataclass(frozen=True) +class CachedBundle: + """Information about a cached bundle across all its versions.""" + + namespace: str + slug: str + host: str + versions: tuple[CachedBundleVersion, ...] + total_size_bytes: int + + +@dataclass(frozen=True) +class CacheInfo: + """Summary of the local bundle cache contents.""" + + path: Path + total_size_bytes: int + bundle_count: int + version_count: int + blob_count: int + bundles: tuple[CachedBundle, ...] diff --git a/src/musher/_client.py b/src/musher/_client.py index d560fd1..9d8ef4d 100644 --- a/src/musher/_client.py +++ b/src/musher/_client.py @@ -22,8 +22,11 @@ if TYPE_CHECKING: from collections.abc import Coroutine + from pathlib import Path from types import TracebackType + from musher._cache_info import CacheInfo + class _AssetResponse(_SDKSchema): """API response model for a single asset (camelCase wire format).""" @@ -331,3 +334,37 @@ def fetch_asset( logical_path, namespace=namespace, slug=slug, version=version ) ) + + # ── Cache management ────────────────────────────────────────── + + @property + def _cache(self) -> BundleCache: + return self._async_client._cache # pyright: ignore[reportPrivateUsage] + + def cache_info(self) -> CacheInfo: + """Return information about the local bundle cache.""" + return self._cache.scan() + + def cache_remove(self, ref: str) -> None: + """Remove a specific bundle from the cache. + + Args: + ref: Bundle reference (e.g. ``"myorg/my-bundle:1.0.0"`` or ``"myorg/my-bundle"``). + """ + parsed = BundleRef.parse(ref) + self._cache.purge(parsed.namespace, parsed.slug, parsed.version) + + def cache_clear(self) -> None: + """Remove all cached data.""" + self._cache.clear() + + def cache_clean(self) -> int: + """Remove expired entries and garbage-collect orphaned blobs. + + Returns the number of entries removed. + """ + return self._cache.clean() + + def cache_path(self) -> Path: + """Return the cache directory path.""" + return self._cache.cache_dir diff --git a/tests/test_cache_info.py b/tests/test_cache_info.py new file mode 100644 index 0000000..6862e91 --- /dev/null +++ b/tests/test_cache_info.py @@ -0,0 +1,309 @@ +"""Tests for cache management API — scan, module-level functions, and Client methods.""" + +from pathlib import Path + +import pytest + +import musher +from musher._cache import BundleCache +from musher._cache_info import CachedBundle, CachedBundleVersion, CacheInfo +from musher._client import Client +from musher._config import MusherConfig + +REGISTRY_URL = "https://api.musher.dev" + + +def _populate_cache( + cache: BundleCache, + namespace: str = "myorg", + slug: str = "my-bundle", + version: str = "1.0.0", + blob_data: bytes = b"hello world", + blob_sha: str = "abcdef1234567890" * 4, +) -> None: + """Helper to populate a cache with a manifest referencing a blob.""" + manifest: dict[str, object] = { + "manifest": { + "layers": [ + { + "logicalPath": "prompt.txt", + "assetType": "prompt", + "contentSha256": blob_sha, + "sizeBytes": len(blob_data), + } + ] + } + } + cache.put_manifest(namespace, slug, version, manifest) + cache.put_blob(blob_sha, blob_data) + + +class TestCacheInfoTypes: + def test_cached_bundle_version_frozen(self) -> None: + v = CachedBundleVersion(version="1.0.0", size_bytes=100, fetched_at=None, is_fresh=False) + with pytest.raises(AttributeError): + v.version = "2.0.0" # type: ignore[misc] + + def test_cached_bundle_frozen(self) -> None: + b = CachedBundle( + namespace="ns", + slug="slug", + host="localhost", + versions=(), + total_size_bytes=0, + ) + with pytest.raises(AttributeError): + b.namespace = "other" # type: ignore[misc] + + def test_cache_info_frozen(self) -> None: + info = CacheInfo( + path=Path("/tmp"), + total_size_bytes=0, + bundle_count=0, + version_count=0, + blob_count=0, + bundles=(), + ) + with pytest.raises(AttributeError): + info.bundle_count = 5 # type: ignore[misc] + + +class TestBundleCacheScan: + def test_empty_cache(self, tmp_path: Path) -> None: + cache = BundleCache(cache_dir=tmp_path, registry_url=REGISTRY_URL) + info = cache.scan() + + assert info.path == tmp_path + assert info.total_size_bytes == 0 + assert info.bundle_count == 0 + assert info.version_count == 0 + assert info.blob_count == 0 + assert info.bundles == () + + def test_single_bundle_single_version(self, tmp_path: Path) -> None: + cache = BundleCache(cache_dir=tmp_path, registry_url=REGISTRY_URL) + blob_data = b"hello world" + blob_sha = "abcdef1234567890" * 4 + _populate_cache(cache, blob_data=blob_data, blob_sha=blob_sha) + + info = cache.scan() + + assert info.bundle_count == 1 + assert info.version_count == 1 + assert info.blob_count == 1 + assert info.total_size_bytes > 0 + + bundle = info.bundles[0] + assert bundle.namespace == "myorg" + assert bundle.slug == "my-bundle" + assert bundle.host == "api.musher.dev" + assert len(bundle.versions) == 1 + + ver = bundle.versions[0] + assert ver.version == "1.0.0" + assert ver.size_bytes == len(blob_data) + assert ver.fetched_at is not None + assert ver.is_fresh is True + + def test_single_bundle_multiple_versions(self, tmp_path: Path) -> None: + cache = BundleCache(cache_dir=tmp_path, registry_url=REGISTRY_URL) + sha1 = "aa" * 32 + sha2 = "bb" * 32 + _populate_cache(cache, version="1.0.0", blob_sha=sha1, blob_data=b"v1") + _populate_cache(cache, version="2.0.0", blob_sha=sha2, blob_data=b"v2data") + + info = cache.scan() + + assert info.bundle_count == 1 + assert info.version_count == 2 + assert info.blob_count == 2 + + bundle = info.bundles[0] + versions_by_name = {v.version: v for v in bundle.versions} + assert "1.0.0" in versions_by_name + assert "2.0.0" in versions_by_name + assert versions_by_name["1.0.0"].size_bytes == 2 + assert versions_by_name["2.0.0"].size_bytes == 6 + + def test_multiple_bundles(self, tmp_path: Path) -> None: + cache = BundleCache(cache_dir=tmp_path, registry_url=REGISTRY_URL) + sha1 = "aa" * 32 + sha2 = "bb" * 32 + _populate_cache(cache, namespace="org1", slug="bundle-a", blob_sha=sha1, blob_data=b"a") + _populate_cache(cache, namespace="org2", slug="bundle-b", blob_sha=sha2, blob_data=b"b") + + info = cache.scan() + + assert info.bundle_count == 2 + assert info.version_count == 2 + slugs = {b.slug for b in info.bundles} + assert slugs == {"bundle-a", "bundle-b"} + + def test_multi_host_partitions(self, tmp_path: Path) -> None: + cache1 = BundleCache(cache_dir=tmp_path, registry_url="https://host1.dev") + cache2 = BundleCache(cache_dir=tmp_path, registry_url="https://host2.dev") + sha1 = "aa" * 32 + sha2 = "bb" * 32 + _populate_cache(cache1, slug="a", blob_sha=sha1, blob_data=b"a") + _populate_cache(cache2, slug="b", blob_sha=sha2, blob_data=b"b") + + # scan() discovers all hosts + info = cache1.scan() + + assert info.bundle_count == 2 + hosts = {b.host for b in info.bundles} + assert hosts == {"host1.dev", "host2.dev"} + + def test_missing_meta_sidecar(self, tmp_path: Path) -> None: + cache = BundleCache(cache_dir=tmp_path, registry_url=REGISTRY_URL) + _populate_cache(cache) + + # Remove the meta sidecar + meta_files = list((tmp_path / "manifests").rglob("*.meta.json")) + assert len(meta_files) == 1 + meta_files[0].unlink() + + info = cache.scan() + + assert info.bundle_count == 1 + ver = info.bundles[0].versions[0] + assert ver.fetched_at is None + assert ver.is_fresh is False + + def test_expired_manifest_not_fresh(self, tmp_path: Path) -> None: + cache = BundleCache(cache_dir=tmp_path, registry_url=REGISTRY_URL) + # Put manifest with a very short TTL + manifest: dict[str, object] = {"manifest": {"layers": []}} + cache.put_manifest("ns", "slug", "1.0.0", manifest, ttl=0) + + # Give it a moment to expire + info = cache.scan() + ver = info.bundles[0].versions[0] + assert ver.is_fresh is False + + def test_blob_without_manifest_counted(self, tmp_path: Path) -> None: + cache = BundleCache(cache_dir=tmp_path, registry_url=REGISTRY_URL) + sha = "cc" * 32 + cache.put_blob(sha, b"orphan blob") + + info = cache.scan() + + assert info.blob_count == 1 + assert info.bundle_count == 0 + assert info.total_size_bytes == len(b"orphan blob") + + +class TestModuleLevelCacheFunctions: + def test_cache_info_on_empty(self, tmp_path: Path) -> None: + info = musher.cache_info(cache_dir=tmp_path, registry_url=REGISTRY_URL) + assert info.bundle_count == 0 + assert info.path == tmp_path + + def test_cache_info_with_data(self, tmp_path: Path) -> None: + cache = BundleCache(cache_dir=tmp_path, registry_url=REGISTRY_URL) + _populate_cache(cache) + + info = musher.cache_info(cache_dir=tmp_path, registry_url=REGISTRY_URL) + assert info.bundle_count == 1 + assert info.blob_count == 1 + + def test_cache_path(self, tmp_path: Path) -> None: + assert musher.cache_path(cache_dir=tmp_path) == tmp_path + + def test_cache_remove_by_version(self, tmp_path: Path) -> None: + cache = BundleCache(cache_dir=tmp_path, registry_url=REGISTRY_URL) + sha1 = "aa" * 32 + sha2 = "bb" * 32 + _populate_cache(cache, version="1.0.0", blob_sha=sha1, blob_data=b"v1") + _populate_cache(cache, version="2.0.0", blob_sha=sha2, blob_data=b"v2") + + musher.cache_remove( + "myorg/my-bundle:1.0.0", + cache_dir=tmp_path, + registry_url=REGISTRY_URL, + ) + + info = musher.cache_info(cache_dir=tmp_path, registry_url=REGISTRY_URL) + assert info.version_count == 1 + assert info.bundles[0].versions[0].version == "2.0.0" + + def test_cache_remove_all_versions(self, tmp_path: Path) -> None: + cache = BundleCache(cache_dir=tmp_path, registry_url=REGISTRY_URL) + sha1 = "aa" * 32 + sha2 = "bb" * 32 + _populate_cache(cache, version="1.0.0", blob_sha=sha1, blob_data=b"v1") + _populate_cache(cache, version="2.0.0", blob_sha=sha2, blob_data=b"v2") + + musher.cache_remove( + "myorg/my-bundle", + cache_dir=tmp_path, + registry_url=REGISTRY_URL, + ) + + info = musher.cache_info(cache_dir=tmp_path, registry_url=REGISTRY_URL) + assert info.bundle_count == 0 + + def test_cache_clear(self, tmp_path: Path) -> None: + cache = BundleCache(cache_dir=tmp_path, registry_url=REGISTRY_URL) + _populate_cache(cache) + + musher.cache_clear(cache_dir=tmp_path) + assert not tmp_path.exists() + + def test_cache_clean_removes_expired(self, tmp_path: Path) -> None: + cache = BundleCache(cache_dir=tmp_path, registry_url=REGISTRY_URL) + # Create manifest with TTL=0 so it's immediately expired + manifest: dict[str, object] = {"manifest": {"layers": []}} + cache.put_manifest("ns", "slug", "1.0.0", manifest, ttl=0) + + removed = musher.cache_clean(cache_dir=tmp_path, registry_url=REGISTRY_URL) + assert removed >= 1 + + +class TestClientCacheMethods: + def test_client_cache_info(self, tmp_path: Path) -> None: + config = MusherConfig( + token="test-key", + cache_dir=tmp_path, + registry_url=REGISTRY_URL, + ) + with Client(config=config) as client: + info = client.cache_info() + assert info.path == tmp_path + assert info.bundle_count == 0 + + def test_client_cache_path(self, tmp_path: Path) -> None: + config = MusherConfig( + token="test-key", + cache_dir=tmp_path, + registry_url=REGISTRY_URL, + ) + with Client(config=config) as client: + assert client.cache_path() == tmp_path + + def test_client_cache_clear(self, tmp_path: Path) -> None: + config = MusherConfig( + token="test-key", + cache_dir=tmp_path, + registry_url=REGISTRY_URL, + ) + cache = BundleCache(cache_dir=tmp_path, registry_url=REGISTRY_URL) + _populate_cache(cache) + + with Client(config=config) as client: + client.cache_clear() + assert not tmp_path.exists() + + def test_client_cache_remove(self, tmp_path: Path) -> None: + config = MusherConfig( + token="test-key", + cache_dir=tmp_path, + registry_url=REGISTRY_URL, + ) + cache = BundleCache(cache_dir=tmp_path, registry_url=REGISTRY_URL) + _populate_cache(cache) + + with Client(config=config) as client: + client.cache_remove("myorg/my-bundle:1.0.0") + info = client.cache_info() + assert info.bundle_count == 0 From 1eea59c5b87a3c744950f214126ee2283a1d0ec7 Mon Sep 17 00:00:00 2001 From: Justin Merrell Date: Sun, 29 Mar 2026 21:19:12 +0000 Subject: [PATCH 2/5] ci: bump GitHub Actions to latest major versions Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 6 +++--- .github/workflows/release.yml | 14 +++++++------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c552a1..14ddf12 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,9 +13,9 @@ jobs: check: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - uses: astral-sh/setup-uv@v4 + - uses: astral-sh/setup-uv@v8 with: enable-cache: true @@ -23,7 +23,7 @@ jobs: with: version: 3.x - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: "3.13" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d8f46d3..ecae1e8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,23 +30,23 @@ jobs: id-token: write attestations: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - uses: astral-sh/setup-uv@v4 + - uses: astral-sh/setup-uv@v8 with: enable-cache: true - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: "3.13" - run: uv build - - uses: actions/attest-build-provenance@v2 + - uses: actions/attest-build-provenance@v4 with: subject-path: dist/* - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: dist path: dist/ @@ -58,7 +58,7 @@ jobs: permissions: id-token: write steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 with: name: dist path: dist/ @@ -75,7 +75,7 @@ jobs: permissions: id-token: write steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 with: name: dist path: dist/ From 99bc0c0c0acd548cd58e734fef928d916ee1988b Mon Sep 17 00:00:00 2001 From: Justin Merrell Date: Sun, 29 Mar 2026 21:34:48 +0000 Subject: [PATCH 3/5] fix(ci): use astral-sh/setup-uv@v5 (v8 short tag does not exist yet) Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 14ddf12..50e33fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: astral-sh/setup-uv@v8 + - uses: astral-sh/setup-uv@v5 with: enable-cache: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ecae1e8..9874298 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,7 +32,7 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: astral-sh/setup-uv@v8 + - uses: astral-sh/setup-uv@v5 with: enable-cache: true From f18db785a06a2be08f9573844c32a10706b05196 Mon Sep 17 00:00:00 2001 From: Justin Merrell Date: Sun, 29 Mar 2026 21:46:18 +0000 Subject: [PATCH 4/5] ci: bump astral-sh/setup-uv to v7 (latest available major tag) Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 50e33fa..3be344d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: astral-sh/setup-uv@v5 + - uses: astral-sh/setup-uv@v7 with: enable-cache: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9874298..6532354 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,7 +32,7 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: astral-sh/setup-uv@v5 + - uses: astral-sh/setup-uv@v7 with: enable-cache: true From 235a5590e71608119839f7f63349c80bd59dd72a Mon Sep 17 00:00:00 2001 From: Justin Merrell Date: Sun, 29 Mar 2026 22:02:11 +0000 Subject: [PATCH 5/5] ci: optimize validate workflow with path filtering and cache - Add dorny/paths-filter to skip devcontainer build, shellcheck, and compose validation when their respective files haven't changed - Add GHCR-based cacheFrom for devcontainer builds to reuse layers - Add validate-status rollup job for branch protection compatibility - Bump actions/checkout to v6 and use dorny/paths-filter@v4 Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/validate.yaml | 127 ++++++++++++++++++++++---------- 1 file changed, 88 insertions(+), 39 deletions(-) diff --git a/.github/workflows/validate.yaml b/.github/workflows/validate.yaml index 33f3598..b800979 100644 --- a/.github/workflows/validate.yaml +++ b/.github/workflows/validate.yaml @@ -1,39 +1,88 @@ -name: Validate - -on: - push: - branches: [main] - pull_request: - branches: [main] - -jobs: - shellcheck: - name: ShellCheck - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Run ShellCheck - uses: ludeeus/action-shellcheck@2.0.0 - with: - scandir: .devcontainer/scripts - severity: warning - - compose: - name: Compose Config - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Validate compose config - run: | - cp .devcontainer/.env.example .devcontainer/.env - docker compose -f .devcontainer/compose.yaml config --quiet - - build: - name: Devcontainer Build - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Build devcontainer - uses: devcontainers/ci@v0.3 - with: - runCmd: echo "devcontainer smoke test passed" +name: Validate + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + changes: + name: Detect Changes + runs-on: ubuntu-latest + outputs: + devcontainer: ${{ steps.filter.outputs.devcontainer }} + scripts: ${{ steps.filter.outputs.scripts }} + compose: ${{ steps.filter.outputs.compose }} + steps: + - uses: actions/checkout@v6 + - uses: dorny/paths-filter@v4 + id: filter + with: + filters: | + devcontainer: + - '.devcontainer/devcontainer.json' + - '.devcontainer/compose.yaml' + - '.devcontainer/compose/**' + - '.devcontainer/config/**' + - '.devcontainer/scripts/**' + - '.devcontainer/.env.example' + scripts: + - '.devcontainer/scripts/**' + compose: + - '.devcontainer/compose.yaml' + - '.devcontainer/compose/**' + - '.devcontainer/.env.example' + + shellcheck: + name: ShellCheck + needs: changes + if: needs.changes.outputs.scripts == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Run ShellCheck + uses: ludeeus/action-shellcheck@2.0.0 + with: + scandir: .devcontainer/scripts + severity: warning + + compose: + name: Compose Config + needs: changes + if: needs.changes.outputs.compose == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Validate compose config + run: | + cp .devcontainer/.env.example .devcontainer/.env + docker compose -f .devcontainer/compose.yaml config --quiet + + build: + name: Devcontainer Build + needs: changes + if: needs.changes.outputs.devcontainer == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Build devcontainer + uses: devcontainers/ci@v0.3 + with: + cacheFrom: ghcr.io/${{ github.repository }}-devcontainer + runCmd: echo "devcontainer smoke test passed" + + # Always-pass job for branch protection required checks + validate-status: + name: Validate Status + if: always() + needs: [shellcheck, compose, build] + runs-on: ubuntu-latest + steps: + - name: Check results + run: | + if [[ "${{ contains(needs.*.result, 'failure') }}" == "true" ]]; then + echo "One or more validation jobs failed" + exit 1 + fi + echo "All validation checks passed (or were skipped due to no relevant changes)"