diff --git a/apps/desktop/scripts/ci/plan-r2-release-asset-prune.mjs b/apps/desktop/scripts/ci/plan-r2-release-asset-prune.mjs index 74f7653f..d91bc2e6 100644 --- a/apps/desktop/scripts/ci/plan-r2-release-asset-prune.mjs +++ b/apps/desktop/scripts/ci/plan-r2-release-asset-prune.mjs @@ -10,6 +10,7 @@ import { githubRepositoryFromProduct, isDownloadAssetName, relativeUpdatePath, + versionFromAssetName, } from '../update-release-assets.mjs'; const GITHUB_API_BASE_URL = 'https://api.github.com'; @@ -55,6 +56,11 @@ function githubHeaders(token) { return headers; } +function releaseVersionFromTag(tagName) { + const value = String(tagName ?? ''); + return /^v\d/u.test(value) ? value.slice(1) : null; +} + function cloudflareHeaders(token) { return new Headers({ accept: 'application/json', @@ -222,9 +228,14 @@ function staleReleaseAssetKeys(releases, keepVersions, updatePath, channel) { const keys = []; for (const release of channelReleases.slice(keepVersions)) { + const releaseVersion = releaseVersionFromTag(release?.tag_name); for (const asset of release.assets ?? []) { const fileName = asset?.name; - if (!isDownloadAssetName(fileName) || channelFromAssetName(fileName) !== channel) { + if ( + !isDownloadAssetName(fileName) || + channelFromAssetName(fileName) !== channel || + versionFromAssetName(fileName) !== releaseVersion + ) { continue; } keys.push(`${updatePath}/${fileName}`); diff --git a/apps/desktop/tests/ci/plan-r2-release-asset-prune.test.ts b/apps/desktop/tests/ci/plan-r2-release-asset-prune.test.ts index 2073cf87..fa819d71 100644 --- a/apps/desktop/tests/ci/plan-r2-release-asset-prune.test.ts +++ b/apps/desktop/tests/ci/plan-r2-release-asset-prune.test.ts @@ -70,6 +70,20 @@ function notFoundResponse() { return new Response(null, { status: 404 }); } +const tagAssetBoundaries = [ + ['v1.2.3', 'TouchAI-1.2.3-windows-full.nupkg', true], + ['v1.2.3', 'TouchAI-beta-1.2.3-beta.1-windows-full.nupkg', false], + ['v1.2.3-beta.1', 'TouchAI-beta-1.2.3-beta.1-windows-full.nupkg', true], + ['v1.2.3-beta.1', 'TouchAI-1.2.3-windows-full.nupkg', false], + [ + 'v1.2.3-nightly.20260728.1', + 'TouchAI-nightly-1.2.3-nightly.20260728.1-windows-full.nupkg', + true, + ], + ['release-1.2.3', 'TouchAI-1.2.3-windows-full.nupkg', false], + ['1.2.3', 'TouchAI-1.2.3-windows-full.nupkg', false], +] as const; + describe('planR2ReleaseAssetPrune', () => { it('plans deletion for old channel assets that already exist on GitHub Releases', async () => { const planner = await loadPlanner(); @@ -88,6 +102,7 @@ describe('planR2ReleaseAssetPrune', () => { release('v0.2.0-beta.2', '2026-05-22T00:00:00Z', [ 'TouchAI-beta-0.2.0-beta.2-windows-full.nupkg', 'TouchAI-beta-0.2.0-beta.2-windows-delta.nupkg', + 'TouchAI-beta-0.2.0-beta.20-windows-full.nupkg', 'release-notes.md', ]), release('v0.2.0', '2026-05-21T00:00:00Z', ['TouchAI-0.2.0-windows-full.nupkg']), @@ -289,6 +304,49 @@ describe('planR2ReleaseAssetPrune', () => { } }); + it.each(tagAssetBoundaries)( + 'matches release tag %s to asset %s: %s', + async (tagName, assetName, shouldDelete) => { + const planner = await loadPlanner(); + const product = productWithRetention(); + const channel = tagName.includes('-nightly.') + ? 'nightly' + : tagName.includes('-beta.') + ? 'beta' + : 'stable'; + product.services.updates.deployment.r2HotAssetVersions[channel] = 1; + const root = await createFixture(product); + const retainedTag = + channel === 'nightly' + ? 'v9.9.9-nightly.20990101.1' + : channel === 'beta' + ? 'v9.9.9-beta.1' + : 'v9.9.9'; + const fetchMock = vi.fn(async (input) => { + if (new URL(input.toString()).hostname === 'api.github.com') { + return githubReleasesResponse([ + release(retainedTag, '2026-07-29T00:00:00Z', []), + release(tagName, '2026-07-28T00:00:00Z', [assetName]), + ]); + } + + return notFoundResponse(); + }); + + try { + expect(planner?.planR2ReleaseAssetPrune).toBeTypeOf('function'); + await expect( + planner!.planR2ReleaseAssetPrune(root, channel, { + fetch: fetchMock as unknown as typeof fetch, + token: 'token', + }) + ).resolves.toEqual(shouldDelete ? [`touchai-app/v1/${assetName}`] : []); + } finally { + await rm(root, { recursive: true, force: true }); + } + } + ); + it('rejects unsupported channels before querying GitHub releases', async () => { const planner = await loadPlanner(); const product = productWithRetention();