From ff695410b31c76a0770cbd82d90280d9c7165188 Mon Sep 17 00:00:00 2001 From: Justin Merrell Date: Sun, 29 Mar 2026 21:18:03 +0000 Subject: [PATCH 1/3] feat(cache): add cache management API for inspection, removal, and invalidation Expose list, has, remove, stats, and invalidate methods on client.cache so users can inspect cached bundles, selectively remove entries, check freshness, compute disk usage, and force re-fetches without purging the entire cache. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/musher/src/cache.ts | 309 +++++++++++++++++++++++++++- packages/musher/src/client.ts | 20 +- packages/musher/src/index.ts | 3 + packages/musher/src/types.ts | 41 ++++ packages/musher/tests/cache.test.ts | 217 +++++++++++++++++++ 5 files changed, 581 insertions(+), 9 deletions(-) diff --git a/packages/musher/src/cache.ts b/packages/musher/src/cache.ts index ea9450e..c568cd5 100644 --- a/packages/musher/src/cache.ts +++ b/packages/musher/src/cache.ts @@ -17,7 +17,7 @@ import { mkdir, readFile, readdir, rename, rm, stat, unlink, writeFile } from "n import { dirname, join } from "node:path"; import { Bundle } from "./bundle.js"; import { CacheError, IntegrityError } from "./errors.js"; -import type { BundleResolveOutput, CachedBundle } from "./types.js"; +import type { BundleResolveOutput, CacheEntry, CacheStats, CachedBundle } from "./types.js"; const JSON_EXT_RE = /\.json$/; @@ -119,17 +119,29 @@ export class BundleCache { // -- Freshness ---------------------------------------------------------------- - /** Check if a cached manifest is still fresh. */ - async isFresh(namespace: string, slug: string, version: string): Promise { + /** Read cache metadata for an entry. Returns null if missing or corrupt. */ + private async readMeta( + namespace: string, + slug: string, + version: string, + ): Promise { try { const raw = await readFile(this.metaPath(namespace, slug, version), "utf-8"); - const meta: CacheMeta = JSON.parse(raw); - const fetchedAt = new Date(meta.fetchedAt).getTime(); - const ttl = (meta.ttlSeconds ?? this.manifestTtlSeconds) * 1000; - return Date.now() - fetchedAt < ttl; + return JSON.parse(raw) as CacheMeta; } catch { + return null; + } + } + + /** Check if a cached manifest is still fresh. */ + async isFresh(namespace: string, slug: string, version: string): Promise { + const meta = await this.readMeta(namespace, slug, version); + if (!meta) { return false; } + const fetchedAt = new Date(meta.fetchedAt).getTime(); + const ttl = (meta.ttlSeconds ?? this.manifestTtlSeconds) * 1000; + return Date.now() - fetchedAt < ttl; } /** Load only the manifest JSON (no blob content). Returns null if not cached. */ @@ -250,6 +262,257 @@ export class BundleCache { } } + // -- Inspection & management -------------------------------------------------- + + /** Build a CacheEntry from a manifest file and its metadata. */ + private async buildCacheEntry( + namespace: string, + slug: string, + version: string, + ): Promise { + const meta = await this.readMeta(namespace, slug, version); + if (!meta) { + return null; + } + + const manifest = await this.loadManifest(namespace, slug, version); + const sizeBytes = manifest?.manifest?.layers?.reduce((sum, l) => sum + l.sizeBytes, 0) ?? 0; + + const fetchedAt = new Date(meta.fetchedAt).getTime(); + const ttl = (meta.ttlSeconds ?? this.manifestTtlSeconds) * 1000; + + return { + namespace, + slug, + version, + fetchedAt: meta.fetchedAt, + ttlSeconds: meta.ttlSeconds, + fresh: Date.now() - fetchedAt < ttl, + ociDigest: meta.ociDigest, + sizeBytes, + }; + } + + /** Collect cache entries from a single slug directory. */ + private async collectSlugEntries( + namespace: string, + slug: string, + slugDir: string, + entries: CacheEntry[], + ): Promise { + for (const file of await safeReaddir(slugDir)) { + if (!isManifestFile(file)) { + continue; + } + const version = file.replace(JSON_EXT_RE, ""); + const entry = await this.buildCacheEntry(namespace, slug, version); + if (entry) { + entries.push(entry); + } + } + } + + /** List all cached bundle entries for this registry host. */ + async list(): Promise { + try { + const entries: CacheEntry[] = []; + const hostManifests = join(this.cacheDir, "manifests", this.hostId); + + if (!existsSync(hostManifests)) { + return entries; + } + + for (const ns of await listSubdirs(hostManifests)) { + for (const slug of await listSubdirs(ns.path)) { + await this.collectSlugEntries(ns.name, slug.name, slug.path, entries); + } + } + + return entries; + } catch (error) { + throw new CacheError( + `Failed to list cache: ${error instanceof Error ? error.message : String(error)}`, + { cause: error instanceof Error ? error : undefined }, + ); + } + } + + /** Check if a bundle is cached and whether it is fresh. */ + async has( + namespace: string, + slug: string, + version?: string, + ): Promise<{ cached: boolean; fresh: boolean }> { + try { + if (version) { + const mPath = this.manifestPath(namespace, slug, version); + if (!existsSync(mPath)) { + return { cached: false, fresh: false }; + } + const fresh = await this.isFresh(namespace, slug, version); + return { cached: true, fresh }; + } + + const versions = await this.listVersionFiles(namespace, slug); + + if (versions.length === 0) { + return { cached: false, fresh: false }; + } + + for (const v of versions) { + if (await this.isFresh(namespace, slug, v)) { + return { cached: true, fresh: true }; + } + } + return { cached: true, fresh: false }; + } catch { + return { cached: false, fresh: false }; + } + } + + /** Remove cached data for a specific bundle. Returns count of manifests removed. */ + async remove(namespace: string, slug: string, version?: string): Promise { + try { + if (version) { + return await this.removeVersion(namespace, slug, version); + } + return await this.removeAllVersions(namespace, slug); + } catch (error) { + throw new CacheError( + `Failed to remove cache entry: ${error instanceof Error ? error.message : String(error)}`, + { cause: error instanceof Error ? error : undefined }, + ); + } + } + + private async removeVersion(namespace: string, slug: string, version: string): Promise { + const mPath = this.manifestPath(namespace, slug, version); + if (!existsSync(mPath)) { + return 0; + } + await safeRm(mPath); + await safeRm(this.metaPath(namespace, slug, version)); + return 1; + } + + private async removeAllVersions(namespace: string, slug: string): Promise { + let removed = 0; + const dir = this.manifestDir(namespace, slug); + for (const f of await safeReaddir(dir)) { + if (isManifestFile(f)) { + const v = f.replace(JSON_EXT_RE, ""); + await safeRm(join(dir, f)); + await safeRm(join(dir, `${v}.meta.json`)); + removed++; + } + } + // Also remove corresponding refs + const refDir = join(this.cacheDir, "refs", this.hostId, namespace, slug); + if (existsSync(refDir)) { + await rm(refDir, { recursive: true, force: true }); + } + return removed; + } + + /** Get aggregate cache statistics (across all hosts). */ + async stats(): Promise { + try { + let entryCount = 0; + let freshCount = 0; + let staleCount = 0; + let refCount = 0; + + await walkCacheTree(join(this.cacheDir, "manifests"), async (ns, slug, _slugDir, file) => { + if (!isManifestFile(file)) { + return; + } + entryCount++; + const version = file.replace(JSON_EXT_RE, ""); + if (await this.isFresh(ns, slug, version)) { + freshCount++; + } else { + staleCount++; + } + }); + + const { blobCount, blobSizeBytes } = await computeBlobStats(this.cacheDir); + + await walkCacheTree(join(this.cacheDir, "refs"), async (_ns, _slug, _slugDir, file) => { + if (file.endsWith(".json")) { + refCount++; + } + }); + + return { entryCount, freshCount, staleCount, blobSizeBytes, blobCount, refCount }; + } catch (error) { + throw new CacheError( + `Failed to compute cache stats: ${error instanceof Error ? error.message : String(error)}`, + { cause: error instanceof Error ? error : undefined }, + ); + } + } + + /** Mark entries as stale so the next access re-fetches. Returns count invalidated. */ + async invalidate(namespace: string, slug: string, version?: string): Promise { + try { + const versions = version ? [version] : await this.listVersionFiles(namespace, slug); + + let count = 0; + for (const v of versions) { + if (await this.invalidateVersion(namespace, slug, v)) { + count++; + } + } + + await this.invalidateRefs(namespace, slug); + return count; + } catch (error) { + throw new CacheError( + `Failed to invalidate cache: ${error instanceof Error ? error.message : String(error)}`, + { cause: error instanceof Error ? error : undefined }, + ); + } + } + + private async invalidateVersion( + namespace: string, + slug: string, + version: string, + ): Promise { + const meta = await this.readMeta(namespace, slug, version); + if (!meta) { + return false; + } + const updated: CacheMeta = { ...meta, fetchedAt: "1970-01-01T00:00:00.000Z" }; + const metPath = this.metaPath(namespace, slug, version); + await this.atomicWrite(metPath, Buffer.from(JSON.stringify(updated, null, 2))); + return true; + } + + private async invalidateRefs(namespace: string, slug: string): Promise { + const refDir = join(this.cacheDir, "refs", this.hostId, namespace, slug); + for (const f of await safeReaddir(refDir)) { + if (!f.endsWith(".json")) { + continue; + } + try { + const raw = await readFile(join(refDir, f), "utf-8"); + const entry: RefEntry = JSON.parse(raw); + entry.fetchedAt = "1970-01-01T00:00:00.000Z"; + await this.atomicWrite(join(refDir, f), Buffer.from(JSON.stringify(entry, null, 2))); + } catch { + /* skip corrupt */ + } + } + } + + /** List version strings from manifest files in a slug directory. */ + private async listVersionFiles(namespace: string, slug: string): Promise { + const dir = this.manifestDir(namespace, slug); + const files = await safeReaddir(dir); + return files.filter((f) => isManifestFile(f)).map((f) => f.replace(JSON_EXT_RE, "")); + } + // -- Cleanup ------------------------------------------------------------------ /** Remove expired cache entries and garbage-collect unreferenced blobs. */ @@ -417,6 +680,38 @@ export class BundleCache { // -- Helpers ------------------------------------------------------------------ +/** Check if a filename is a manifest JSON (not a .meta.json). */ +function isManifestFile(file: string): boolean { + return file.endsWith(".json") && !file.endsWith(".meta.json"); +} + +/** Compute total blob count and size on disk. */ +async function computeBlobStats( + cacheDir: string, +): Promise<{ blobCount: number; blobSizeBytes: number }> { + let blobCount = 0; + let blobSizeBytes = 0; + + const blobsRoot = join(cacheDir, "blobs", "sha256"); + if (!existsSync(blobsRoot)) { + return { blobCount, blobSizeBytes }; + } + + for (const prefix of await safeReaddir(blobsRoot)) { + const prefixDir = join(blobsRoot, prefix); + if (!(await isDir(prefixDir))) { + continue; + } + for (const digest of await safeReaddir(prefixDir)) { + blobCount++; + const s = await stat(join(prefixDir, digest)); + blobSizeBytes += s.size; + } + } + + return { blobCount, blobSizeBytes }; +} + function computeHostId(registryUrl: string): string { try { const url = new URL(registryUrl); diff --git a/packages/musher/src/client.ts b/packages/musher/src/client.ts index 193f89b..79d428b 100644 --- a/packages/musher/src/client.ts +++ b/packages/musher/src/client.ts @@ -13,7 +13,7 @@ import { IntegrityError } from "./errors.js"; import { HttpTransport } from "./http.js"; import { BundleRef } from "./ref.js"; import { BundlesResource } from "./resources/bundles.js"; -import type { BundleResolveOutput } from "./types.js"; +import type { BundleResolveOutput, CacheEntry, CacheManager, CacheStats } from "./types.js"; let _loadDeprecationWarned = false; @@ -180,7 +180,23 @@ export class MusherClient { } /** Cache management utilities. */ - readonly cache = { + readonly cache: CacheManager = { + /** List all cached bundle entries for this registry. */ + list: (): Promise => this._cache.list(), + /** Check if a bundle is cached (and fresh). */ + has: ( + namespace: string, + slug: string, + version?: string, + ): Promise<{ cached: boolean; fresh: boolean }> => this._cache.has(namespace, slug, version), + /** Remove a specific bundle from the cache. Returns count of entries removed. */ + remove: (namespace: string, slug: string, version?: string): Promise => + this._cache.remove(namespace, slug, version), + /** Get aggregate cache statistics. */ + stats: (): Promise => this._cache.stats(), + /** Mark entries as stale so the next access re-fetches. Returns count invalidated. */ + invalidate: (namespace: string, slug: string, version?: string): Promise => + this._cache.invalidate(namespace, slug, version), /** Remove expired cache entries and garbage-collect unreferenced blobs. */ clean: (): Promise => this._cache.clean(), /** Remove all cached data. */ diff --git a/packages/musher/src/index.ts b/packages/musher/src/index.ts index 95dd4d1..11e8516 100644 --- a/packages/musher/src/index.ts +++ b/packages/musher/src/index.ts @@ -86,6 +86,9 @@ export type { BundleResolveOutput, BundleVersionDetailOutput, BundleVersionSummaryOutput, + CacheEntry, + CacheManager, + CacheStats, CachedBundle, LoadedAsset, LoadedBundle, diff --git a/packages/musher/src/types.ts b/packages/musher/src/types.ts index 323401a..e3c744c 100644 --- a/packages/musher/src/types.ts +++ b/packages/musher/src/types.ts @@ -103,3 +103,44 @@ export interface SelectionFilter { agentSpecs?: string[]; paths?: string[]; } + +// -- Cache management --------------------------------------------------------- + +/** A single cached bundle entry with metadata. */ +export interface CacheEntry { + namespace: string; + slug: string; + version: string; + fetchedAt: string; + ttlSeconds: number; + fresh: boolean; + ociDigest?: string | undefined; + /** Total size of blobs referenced by this manifest, in bytes. */ + sizeBytes: number; +} + +/** Aggregate cache statistics. */ +export interface CacheStats { + entryCount: number; + freshCount: number; + staleCount: number; + /** Total size of all blob files on disk, in bytes. */ + blobSizeBytes: number; + blobCount: number; + refCount: number; +} + +/** Shape of the client.cache management interface. */ +export interface CacheManager { + list(): Promise; + has( + namespace: string, + slug: string, + version?: string, + ): Promise<{ cached: boolean; fresh: boolean }>; + remove(namespace: string, slug: string, version?: string): Promise; + stats(): Promise; + invalidate(namespace: string, slug: string, version?: string): Promise; + clean(): Promise; + purge(): Promise; +} diff --git a/packages/musher/tests/cache.test.ts b/packages/musher/tests/cache.test.ts index d0d4208..b957116 100644 --- a/packages/musher/tests/cache.test.ts +++ b/packages/musher/tests/cache.test.ts @@ -306,6 +306,223 @@ describe("BundleCache", () => { }); }); + describe("list", () => { + it("returns empty array for empty cache", async () => { + const entries = await cache.list(); + expect(entries).toEqual([]); + }); + + it("lists cached entries with metadata", async () => { + const assets = new Map([["hello.txt", Buffer.from("Hello, World!")]]); + await cache.write(FIXTURE_MANIFEST, assets); + + const entries = await cache.list(); + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + namespace: "acme", + slug: "test-bundle", + version: "1.0.0", + fresh: true, + sizeBytes: 13, + ociDigest: "sha256:abc123", + ttlSeconds: 86400, + }); + expect(entries[0]?.fetchedAt).toBeDefined(); + }); + + it("lists multiple versions", async () => { + const assets = new Map([["hello.txt", Buffer.from("Hello, World!")]]); + await cache.write(FIXTURE_MANIFEST, assets); + + const manifest2: BundleResolveOutput = { + ...FIXTURE_MANIFEST, + version: "2.0.0", + }; + await cache.write(manifest2, assets); + + const entries = await cache.list(); + expect(entries).toHaveLength(2); + const versions = entries.map((e) => e.version).sort(); + expect(versions).toEqual(["1.0.0", "2.0.0"]); + }); + + it("only lists entries for the current host", async () => { + const cache2 = new BundleCache(tempDir, "https://staging.musher.dev", 86400, 300); + const assets = new Map([["hello.txt", Buffer.from("Hello, World!")]]); + await cache.write(FIXTURE_MANIFEST, assets); + await cache2.write(FIXTURE_MANIFEST, assets); + + const prodEntries = await cache.list(); + expect(prodEntries).toHaveLength(1); + + const stagingEntries = await cache2.list(); + expect(stagingEntries).toHaveLength(1); + }); + }); + + describe("has", () => { + it("returns false for missing bundle", async () => { + const result = await cache.has("acme", "nonexistent"); + expect(result).toEqual({ cached: false, fresh: false }); + }); + + it("returns cached + fresh for a fresh entry", async () => { + const assets = new Map([["hello.txt", Buffer.from("Hello, World!")]]); + await cache.write(FIXTURE_MANIFEST, assets); + + const result = await cache.has("acme", "test-bundle", "1.0.0"); + expect(result).toEqual({ cached: true, fresh: true }); + }); + + it("returns cached + stale for an expired entry", async () => { + const expiring = new BundleCache(tempDir, REGISTRY_URL, 0, 0); + const assets = new Map([["hello.txt", Buffer.from("Hello, World!")]]); + await expiring.write(FIXTURE_MANIFEST, assets); + + const result = await expiring.has("acme", "test-bundle", "1.0.0"); + expect(result).toEqual({ cached: true, fresh: false }); + }); + + it("checks any version when version is omitted", async () => { + const assets = new Map([["hello.txt", Buffer.from("Hello, World!")]]); + await cache.write(FIXTURE_MANIFEST, assets); + + const result = await cache.has("acme", "test-bundle"); + expect(result).toEqual({ cached: true, fresh: true }); + }); + }); + + describe("remove", () => { + it("removes a specific version", async () => { + const assets = new Map([["hello.txt", Buffer.from("Hello, World!")]]); + await cache.write(FIXTURE_MANIFEST, assets); + + const removed = await cache.remove("acme", "test-bundle", "1.0.0"); + expect(removed).toBe(1); + + const loaded = await cache.load("acme", "test-bundle", "1.0.0"); + expect(loaded).toBeNull(); + }); + + it("removes all versions when version is omitted", async () => { + const assets = new Map([["hello.txt", Buffer.from("Hello, World!")]]); + await cache.write(FIXTURE_MANIFEST, assets); + await cache.write({ ...FIXTURE_MANIFEST, version: "2.0.0" }, assets); + + const removed = await cache.remove("acme", "test-bundle"); + expect(removed).toBe(2); + + expect(await cache.load("acme", "test-bundle", "1.0.0")).toBeNull(); + expect(await cache.load("acme", "test-bundle", "2.0.0")).toBeNull(); + }); + + it("removes refs when removing all versions", async () => { + const assets = new Map([["hello.txt", Buffer.from("Hello, World!")]]); + await cache.write(FIXTURE_MANIFEST, assets); + await cache.cacheRef("acme", "test-bundle", "latest", "1.0.0"); + + await cache.remove("acme", "test-bundle"); + + const version = await cache.resolveRef("acme", "test-bundle", "latest"); + expect(version).toBeNull(); + }); + + it("returns 0 for nonexistent bundle", async () => { + const removed = await cache.remove("acme", "nonexistent", "1.0.0"); + expect(removed).toBe(0); + }); + }); + + describe("stats", () => { + it("returns zeros for empty cache", async () => { + const s = await cache.stats(); + expect(s.entryCount).toBe(0); + expect(s.freshCount).toBe(0); + expect(s.staleCount).toBe(0); + expect(s.blobCount).toBe(0); + expect(s.blobSizeBytes).toBe(0); + expect(s.refCount).toBe(0); + }); + + it("counts entries, blobs, and refs", async () => { + const assets = new Map([["hello.txt", Buffer.from("Hello, World!")]]); + await cache.write(FIXTURE_MANIFEST, assets); + await cache.cacheRef("acme", "test-bundle", "latest", "1.0.0"); + + const s = await cache.stats(); + expect(s.entryCount).toBe(1); + expect(s.freshCount).toBe(1); + expect(s.staleCount).toBe(0); + expect(s.blobCount).toBe(1); + expect(s.blobSizeBytes).toBe(13); + expect(s.refCount).toBe(1); + }); + + it("counts across all hosts", async () => { + const cache2 = new BundleCache(tempDir, "https://staging.musher.dev", 86400, 300); + const assets = new Map([["hello.txt", Buffer.from("Hello, World!")]]); + await cache.write(FIXTURE_MANIFEST, assets); + await cache2.write(FIXTURE_MANIFEST, assets); + + // Stats from either cache should see both hosts' manifests + const s = await cache.stats(); + expect(s.entryCount).toBe(2); + // Blobs are deduplicated + expect(s.blobCount).toBe(1); + }); + }); + + describe("invalidate", () => { + it("marks a specific version as stale", async () => { + const assets = new Map([["hello.txt", Buffer.from("Hello, World!")]]); + await cache.write(FIXTURE_MANIFEST, assets); + expect(await cache.isFresh("acme", "test-bundle", "1.0.0")).toBe(true); + + const count = await cache.invalidate("acme", "test-bundle", "1.0.0"); + expect(count).toBe(1); + expect(await cache.isFresh("acme", "test-bundle", "1.0.0")).toBe(false); + }); + + it("keeps data on disk after invalidation", async () => { + const assets = new Map([["hello.txt", Buffer.from("Hello, World!")]]); + await cache.write(FIXTURE_MANIFEST, assets); + + await cache.invalidate("acme", "test-bundle", "1.0.0"); + + // Data is still loadable (just stale) + const loaded = await cache.load("acme", "test-bundle", "1.0.0"); + expect(loaded).not.toBeNull(); + expect(loaded?.file("hello.txt")?.text()).toBe("Hello, World!"); + }); + + it("invalidates all versions when version is omitted", async () => { + const assets = new Map([["hello.txt", Buffer.from("Hello, World!")]]); + await cache.write(FIXTURE_MANIFEST, assets); + await cache.write({ ...FIXTURE_MANIFEST, version: "2.0.0" }, assets); + + const count = await cache.invalidate("acme", "test-bundle"); + expect(count).toBe(2); + expect(await cache.isFresh("acme", "test-bundle", "1.0.0")).toBe(false); + expect(await cache.isFresh("acme", "test-bundle", "2.0.0")).toBe(false); + }); + + it("invalidates refs for the bundle", async () => { + const assets = new Map([["hello.txt", Buffer.from("Hello, World!")]]); + await cache.write(FIXTURE_MANIFEST, assets); + await cache.cacheRef("acme", "test-bundle", "latest", "1.0.0"); + + await cache.invalidate("acme", "test-bundle"); + + const version = await cache.resolveRef("acme", "test-bundle", "latest"); + expect(version).toBeNull(); + }); + + it("returns 0 for nonexistent bundle", async () => { + const count = await cache.invalidate("acme", "nonexistent"); + expect(count).toBe(0); + }); + }); + describe("clean with blob GC", () => { it("removes expired manifests and garbage-collects orphaned blobs", async () => { // Create cache with 0-second TTL so entries expire immediately From bba048b4e51f58d1f7cd2ead9c682c74f9153c59 Mon Sep 17 00:00:00 2001 From: Justin Merrell Date: Sun, 29 Mar 2026 21:39:33 +0000 Subject: [PATCH 2/3] style: fix biome formatting in package.json Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/musher/package.json | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/packages/musher/package.json b/packages/musher/package.json index b61eb69..ede43ca 100644 --- a/packages/musher/package.json +++ b/packages/musher/package.json @@ -19,9 +19,7 @@ "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/index.d.ts", - "files": [ - "dist" - ], + "files": ["dist"], "scripts": { "build": "tsup", "check": "pnpm check:format && pnpm check:lint && pnpm check:types && pnpm check:test", @@ -55,11 +53,5 @@ "url": "git+https://github.com/musher-dev/typescript-sdk.git", "directory": "packages/musher" }, - "keywords": [ - "musher", - "bundle", - "sdk", - "ai", - "agent" - ] + "keywords": ["musher", "bundle", "sdk", "ai", "agent"] } From 775043e4242beef3a89bdbf4df6b35a389bcff08 Mon Sep 17 00:00:00 2001 From: Justin Merrell Date: Sun, 29 Mar 2026 22:11:04 +0000 Subject: [PATCH 3/3] ci: skip devcontainer validation when irrelevant files change Use dorny/paths-filter to detect changes in .devcontainer/ and the workflow file itself. ShellCheck, Compose Config, and Devcontainer Build jobs now only run when those paths are modified, saving ~7 minutes on commits that only touch source code. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/validate.yaml | 101 ++++++++++++++++++++------------ 1 file changed, 62 insertions(+), 39 deletions(-) diff --git a/.github/workflows/validate.yaml b/.github/workflows/validate.yaml index 33f3598..0cd2285 100644 --- a/.github/workflows/validate.yaml +++ b/.github/workflows/validate.yaml @@ -1,39 +1,62 @@ -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: + detect-changes: + name: Detect Changes + runs-on: ubuntu-latest + permissions: + pull-requests: read + outputs: + devcontainer: ${{ steps.filter.outputs.devcontainer }} + steps: + - uses: actions/checkout@v4 + - uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + devcontainer: + - '.devcontainer/**' + - '.github/workflows/validate.yaml' + + shellcheck: + name: ShellCheck + needs: detect-changes + if: needs.detect-changes.outputs.devcontainer == 'true' + 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 + needs: detect-changes + if: needs.detect-changes.outputs.devcontainer == 'true' + 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 + needs: detect-changes + if: needs.detect-changes.outputs.devcontainer == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build devcontainer + uses: devcontainers/ci@v0.3 + with: + runCmd: echo "devcontainer smoke test passed"