Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion apps/api/src/lib/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,12 @@ export async function resolveManifestFromReleaseAssets(
if (assets.length === 0) return null
const byName = new Map(assets.map((a) => [a.name, a]))
for (const candidate of manifestCandidates()) {
const asset = byName.get(candidate.path)
// GitHub rejects release asset names with a leading dot and silently
// renames them on upload (".tabularium" → "default.tabularium"), so a
// dotfile candidate must also match its renamed form.
const asset =
byName.get(candidate.path) ??
(candidate.path.startsWith('.') ? byName.get(`default${candidate.path}`) : undefined)
if (!asset) continue
try {
const got = await fetchAssetContent(asset.url, accessToken)
Expand Down
19 changes: 15 additions & 4 deletions apps/api/src/lib/refresh-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,17 +39,28 @@ export async function refreshManifestForPlugin(
if (e instanceof OAuthExpiredError) return { status: 401, body: reauthErrorBody(e) }
throw e
}
const branch = options.branch ?? plugin.latestVersion ?? 'HEAD'
let manifest
// latestVersion stores the bare semver ("0.1.1") while release tags are
// commonly v-prefixed ("v0.1.1") — try both when we derived the ref ourselves.
const gitRefs = options.branch
? [options.branch]
: plugin.latestVersion
? [plugin.latestVersion, `v${plugin.latestVersion}`]
: ['HEAD']
let manifest: Awaited<ReturnType<typeof resolveManifest>> = null
let branch = gitRefs[0]
try {
manifest = await resolveManifest(token, ref, { ref: branch })
for (const gitRef of gitRefs) {
branch = gitRef
manifest = await resolveManifest(token, ref, { ref: gitRef })
if (manifest) break
}
} catch (e) {
if (e instanceof UpstreamUnauthorizedError) return { status: 401, body: reauthErrorBody(e) }
if (e instanceof ManifestValidationError) return { status: 422, body: { error: e.message } }
throw e
}
if (!manifest) {
return { status: 404, body: { error: `No .tabularium file found in ${plugin.repoUrl} @ ${branch}` } }
return { status: 404, body: { error: `No .tabularium file found in ${plugin.repoUrl} @ ${gitRefs.join(' or ')}` } }
}
const patch = manifestPatch(manifest, { repoBase: rawContentBase(ref, branch), version: branch })
await applyManifestToPlugin(plugin.id, patch)
Expand Down
11 changes: 11 additions & 0 deletions apps/api/tests/lib/asset-manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,17 @@ describe('resolveManifestFromReleaseAssets', () => {
spy.mockRestore()
})

it('matches the GitHub-renamed form of a dotfile candidate (default.tabularium)', async () => {
const spy = mockAssetFetch({
'https://example.com/default.tabularium': { body: SAMPLE_JSON },
})
const manifest = await resolveManifestFromReleaseAssets('test-token', [
{ name: 'default.tabularium', url: 'https://example.com/default.tabularium' },
])
expect(manifest?.parsed.name).toBe('alpha')
spy.mockRestore()
})

it('returns null when no asset name matches the candidate list', async () => {
const spy = mockAssetFetch({})
const manifest = await resolveManifestFromReleaseAssets('test-token', [
Expand Down
48 changes: 48 additions & 0 deletions apps/api/tests/lib/refresh-manifest-tag.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, it, expect, beforeEach, spyOn } from 'bun:test'
import { clearDb, makeUser, makePlugin } from '../helpers'
import { db } from '../../src/db'
import { refreshManifestForPlugin } from '../../src/lib/refresh-manifest'

const SAMPLE_JSON = JSON.stringify({
name: 'alpha',
version: '1.0.0',
description: 'A test plugin.',
category: 'misc',
icon: 'https://example.com/icon.svg',
})

// latestVersion is the bare semver ("1.0.0") but the git tag is v-prefixed
// ("v1.0.0") — the refresh must fall back to the v-prefixed ref.
describe('refreshManifestForPlugin tag fallback', () => {
beforeEach(clearDb)

it('falls back to v-prefixed tag when the bare version ref 404s', async () => {
const u = await makeUser()
const plugin = await makePlugin(u.id, { id: 'alpha', latestVersion: '1.0.0' })
const spy = spyOn(global, 'fetch').mockImplementation((async (url: string | URL | Request) => {
const key = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url
if (key.includes('contents/.tabularium') && key.includes('ref=v1.0.0')) {
return new Response(SAMPLE_JSON, { status: 200 })
}
return new Response('not found', { status: 404 })
}) as unknown as typeof fetch)

const result = await refreshManifestForPlugin(plugin, {})
expect(result).toEqual({ ok: true, slug: 'alpha', ref: 'v1.0.0' })
const row = await db.query.plugins.findFirst({ where: { id: 'alpha' } })
expect(row?.iconUrl).toBe('https://example.com/icon.svg')
spy.mockRestore()
})

it('still 404s with both tried refs in the message when neither exists', async () => {
const u = await makeUser()
const plugin = await makePlugin(u.id, { id: 'alpha', latestVersion: '1.0.0' })
const spy = spyOn(global, 'fetch').mockImplementation((async () =>
new Response('not found', { status: 404 })) as unknown as typeof fetch)

const result = await refreshManifestForPlugin(plugin, {})
expect(result).toMatchObject({ status: 404 })
expect((result as { body: { error: string } }).body.error).toContain('1.0.0 or v1.0.0')
spy.mockRestore()
})
})
Loading