For months, the Lumo changelog page showed "No public builds yet." Not because we hadn't shipped anything — we'd merged 15 PRs with real features. The page was empty because our release system required uploading an APK file before a build could be published. And since we were moving distribution to the Google Play Store, nobody was uploading APK files to our website.
The entire release pipeline was designed for a use case we'd already abandoned.
The Root Cause
Two things were broken:
-
createAppBuildrequired a file. The mutation validatedif (!storageId && !externalUrl) throw new Error("needs a file"). You literally could not create a release entry without uploading a binary. Release notes without a file? Impossible. -
Builds defaulted to
isActive: false. The public changelog query filtered onisActive: true. Even if you somehow created a build, it wouldn't show up until an admin manually toggled it.
Combined, this meant: no APK upload → no build entry → nothing on the changelog → "No public builds yet" forever.
The Surgery
Backend: Schema Cleanup
We stripped four fields from the appBuilds table:
appBuilds: defineTable({
platform: v.union(v.literal("android"), v.literal("ios")),
version: v.string(),
buildNumber: v.string(),
releaseNotes: v.optional(v.string()),
sha256: v.optional(v.string()),
isActive: v.boolean(),
isRequired: v.boolean(),
- storageId: v.optional(v.id("_storage")),
- externalUrl: v.optional(v.string()),
- fileName: v.optional(v.string()),
- fileSize: v.optional(v.number()),
createdAt: v.number(),
updatedAt: v.number(),
createdBy: v.string(),
})Then we deleted the mutations that supported file uploads:
generateAppBuildUploadUrl— generated a Convex storage upload URL. Gone.getStorageDownloadUrl— resolved a storage ID to a download link. Gone.
The createAppBuild mutation became embarrassingly simple: validate the inputs, insert a row. No file handling, no storage API, no URL generation.
Public Query: Just Metadata
The listPublic query used to enrich each build with ctx.storage.getUrl(storageId) to generate download links. Now it just returns the metadata:
export const listPublic = query({
handler: async (ctx) => {
const builds = await ctx.db
.query("appBuilds")
.withIndex("by_active_and_created_at")
.order("desc")
.collect();
return builds
.filter(b => b.isActive)
.map(b => ({
_id: b._id,
platform: b.platform,
version: b.version,
buildNumber: b.buildNumber,
releaseNotes: b.releaseNotes,
sha256: b.sha256,
createdAt: b.createdAt,
}));
},
});No download URLs. No file sizes. No file names. Just release notes and version info — which is what a changelog actually is.
Admin Panel: The 3-Tab Rebuild
The old Releases page was a single form with a file upload input. We rebuilt it as a three-tab layout:
Overview Tab
Stats charts and summary cards. How many releases, active vs inactive, platform breakdown. Quick glance at the release history health.
Create Release Tab
A clean form with:
- Version, build number, platform, SHA-256 (optional)
- A markdown editor with Write/Preview toggle — type in markdown on the left, see the rendered output on the right
- Draft persistence via Zustand with
localStorage— close the browser, come back, your half-written release notes are still there
export const useReleasesStore = create<ReleasesUiState>()(
persist(
(set, get) => ({
draft: { ...EMPTY_DRAFT },
editDraft: null,
// ... methods
}),
{
name: "lumo-admin-release-draft",
partialize: (state) => ({
draft: state.draft,
editDraft: state.editDraft,
}),
},
),
);The partialize option ensures we only persist the draft data, not UI state like which tab is active.
Manage Releases Tab
The power tools:
- Multi-select checkboxes with a bulk action bar (Activate All / Deactivate All / Delete Selected)
- Expandable release notes — click to see the full markdown rendered inline
- Fullscreen editor — click Edit and get a fixed overlay (
inset-0 z-50) with the complete editing UI, body scroll lock, Escape to close, and edit state persisted to localStorage
The fullscreen editor was a deliberate UX choice. Release notes are often long — multiple sections, code blocks, bullet points. Editing them in a tiny inline textarea is painful. The fullscreen overlay gives you the full viewport to work with.
Seed Data: Real Release History
Instead of generating fake builds with "Bug fixes and improvements" release notes, we wrote a standalone seed migration (seed_releases.ts) with 12 entries documenting the actual release history from PR #1 through PR #15:
const RELEASE_HISTORY: ReleaseEntry[] = [
{
version: "0.0.1",
buildNumber: "1",
platform: "android",
releaseNotes: `## Initial Release — Monorepo Foundation
- Initialized the Lumo monorepo with **Bun + Turborepo**...`,
createdAt: new Date("2026-02-06T12:00:00Z").getTime(),
},
// ... 11 more entries
];Each entry has rich markdown release notes with headers, bullet points, and bold highlights documenting what actually shipped. The createdAt timestamps match the real PR merge dates.
Two mutations: seedReleaseHistory (safe to re-run, skips existing versions) and resetAndSeedReleaseHistory (wipes and re-inserts). Both require admin authentication.
Frontend: Markdown Changelog
The public changelog page at /changelog now renders release notes with full GitHub-Flavored Markdown support via react-markdown + remark-gfm:
- Platform badges (Android/iOS)
- Build numbers
- Formatted dates
- Full markdown rendering: headers, bold, code blocks, bullet points, tables
No download buttons. No APK links. Just a clean, readable changelog that shows what we shipped and when.
The Lesson
Sometimes the best feature is removing a feature. The APK upload pipeline was 400+ lines of code supporting a workflow we'd already abandoned. Removing it simplified the backend, unblocked the changelog, and let us build the admin UI we actually needed.
The changelog finally shows content. Twelve versions of real release history, documented with the detail they deserve.
Abdul Rafay Founder, Syntax Lab Technology Twelve versions in. Still pre-release. Still shipping.