05 — Two-Repo Model
Every creator has two repos. Private = working DB. Public = curated archive. Publish action graduates content from private → public. This doc nails down the data shapes, the API calls, and the binary-asset strategy.
Why two repos
The single-repo alternative — "draft = unpublished file, published = published file" with everything in one repo — was rejected because:
- A single repo conflates working state and presented state. Drafts the creator regrets writing would still be visible in git history forever.
- The public repo needs to stay small + fast (so CF Pages builds quickly). A repo full of half-written drafts gets unwieldy.
- Visitors browsing the creator's archive on github.com would see the working mess, not the curated face.
Two repos cleanly separates "the studio" from "the gallery." Standard pattern in creative work.
Private repo layout
<handle>-bka-private (private, on the creator's GitHub account).
<handle>-bka-private/
├── README.md — what this repo is, kept simple
├── .gitignore — ignores OS cruft, lock files
├── drafts/
│ ├── <draft-id>.md — markdown body of an unfinished post
│ └── <draft-id>.meta.json — title, tags, status, paired published id
├── scratch/ — anything that isn't a draft: ideas, notes, snippets
│ └── *
├── media/ — staging area for media before publish
│ ├── images/
│ ├── audio/
│ └── video/
├── meta/
│ ├── settings.json — creator's BKA preferences (theme, defaults, syndication targets)
│ ├── audience.json — (future) subscriber list snapshots
│ └── history.json — (future) publish history backup
└── .bka/
└── state.json — small machine-state blob (last-publish ts, indexer-reg, etc.)
The BKA app reads/writes this freely. Auto-commits on a debounce (mirroring the Foundry git-canonical pattern, doc 13 in the 4G stack).
Sync cadence: writes are local-first (Lightning-FS in browser), pushed to GitHub on a 30s debounce (matching Foundry's auto-push).
Public repo layout
<handle>-bka-public (public, on the creator's GitHub account).
<handle>-bka-public/
├── README.md — short bio + link to site
├── posts/
│ ├── <slug>.md — published post body, markdown
│ └── <slug>.meta.json — title, tags, publish date, asset references
├── assets/
│ ├── thumbnails/ — small images (under 100KB) committed directly
│ │ └── *.jpg|png|webp
│ └── inline/ — small inline images for posts
│ └── *
├── feed.json — the discovery + RSS-equivalent feed (see 10_FEED_SCHEMA.md)
├── feed.xml — RSS 2.0 mirror of feed.json (for RSS readers)
├── viewer/ — the viewer-template source (or symlink to npm pkg)
│ └── (4G app structure: src/, package.json, vite.config.ts, etc.)
├── theme.json — creator's theme choices
└── .github/
└── workflows/
└── publish.yml — Cloudflare Pages deploy trigger + indexer ping
Binary assets (video, audio, large images >100KB) live in GitHub Releases on this repo, NOT committed to the repo body. Each release holds one asset; the posts/<slug>.meta.json references it by release URL.
CF Pages connects to this repo. Push → Pages build → live.
The publish action, in detail
When the creator clicks Publish on a draft:
1. Validate the draft is publishable
- Has a title
- Has a body
- All referenced media is uploaded
- Slug doesn't collide with an existing published post (unless this is an edit)
2. Upload binary assets (if any)
For each media file in the draft that exceeds 100KB (rough threshold; tunable):
- Create a GitHub Release on the public repo (one release per asset, or grouped by post — open design question, see 13_OPEN_QUESTIONS.md)
- Upload the file as a release asset (
POST /repos/<owner>/<repo>/releases/<id>/assets) - Capture the asset's public URL (
https://github.com/<owner>/<repo>/releases/download/<tag>/<filename>) - Replace the in-draft media reference with the public URL
Small media (thumbnails, inline images <100KB) commit directly to assets/ for simplicity.
3. Compose the post file
Write posts/<slug>.md (the body) + posts/<slug>.meta.json (the metadata):
{
"slug": "my-first-post",
"title": "My First Post",
"tags": ["intro", "meta"],
"published_at": "2026-06-29T20:00:00Z",
"updated_at": "2026-06-29T20:00:00Z",
"summary": "Short summary for feed/discovery (creator-written or AI-summarized)",
"thumbnail": "assets/thumbnails/my-first-post.jpg",
"media": [
{ "kind": "video", "url": "https://github.com/.../releases/download/.../intro.mp4", "duration_sec": 245, "mime": "video/mp4" }
]
}
4. Regenerate feed.json
Read all posts/*.meta.json, build the canonical feed (sorted by published_at desc), write feed.json. Also regenerate feed.xml (RSS mirror).
See 10_FEED_SCHEMA.md for the exact schema.
5. Commit + push
Single commit to the public repo with all the changed files (post body, meta, feed.json, feed.xml, any small assets). One push.
6. Wait for CF Pages build
CF Pages git integration sees the push and starts building. ~30-60 seconds.
BKA shows a "Building…" indicator until the build completes. We poll the CF API for build status, or rely on a webhook back to the indexer (out of scope for v1).
7. (Optional) ping the indexer
If the creator is registered with the indexer, BKA POSTs to /api/community-indexer/refresh with the creator's feed URL + Google JWT, hinting that we just published. The indexer adds this feed to a priority crawl queue.
If skipped or the indexer is down, the indexer's normal cadence picks the update up within the next pull window (default: hourly).
8. Update the private repo's publish history
Mark the draft as "published" in <draft-id>.meta.json with a link to the published slug. The draft stays in private (history) but the BKA UI shows it as published.
Publish failure handling
If anything in steps 2-5 fails:
- The user sees the error (with the selectable-text discipline from the feedback memory)
- No partial state in public repo (we either commit everything or nothing — the commit is atomic per git)
- The draft stays in
private/drafts/as a draft - BKA offers "retry" + "save error log to private repo for debugging"
If steps 6-7 fail (CF build or indexer):
- The commit is on the public repo, so the post will be live as soon as CF rebuilds (next push, retry, or operator intervention)
- The indexer will pick the update up on its next normal pull
- Not blocking — the creator's already published
Unpublish action
Remove a post from the public repo:
- Delete
posts/<slug>.md+posts/<slug>.meta.json - Optionally delete any release assets only referenced by this post (we track ref-counts)
- Regenerate
feed.json+feed.xml - Commit + push
- CF Pages rebuilds, post is gone from the site
- (Optional) ping the indexer to re-pull
The private repo's draft stays — the creator can re-publish later if they want.
Edit-published-post action
Same shape as publish, but updates instead of creates:
- Update
posts/<slug>.mdbody - Update
posts/<slug>.meta.json(bumpupdated_at) - Re-upload changed media if any
- Regenerate feeds
- Commit + push
- CF rebuilds
- (Optional) indexer ping
The binary-asset strategy (GitHub Releases)
Repos hate big files. Releases love big files. So:
Threshold: files >100KB go to Releases. Files ≤100KB commit directly to assets/. This keeps the repo browsable + fast while still allowing inline thumbnails to commit normally.
One release per asset (simple, easy to grep) vs one release per post-bundle (groups related media). v1 ships with one-per-asset for simplicity; one-per-post can be a future migration.
Tag scheme: asset-<sha8> where <sha8> is the first 8 hex of the file's SHA256. Lets us dedupe re-uploads of identical files.
Public URL: https://github.com/<owner>/<repo>/releases/download/<tag>/<filename> — durable, CDN-friendly, no auth needed for public repos.
Range request support: GitHub release assets support Accept-Ranges. <video>/<audio> elements progressive-download / scrub natively. No streaming server needed.
Total quotas: GitHub release assets are 2GB per file, no per-release limit, no per-repo limit (separate from repo size). So even a video-heavy creator stays well under any GitHub limits for a long time.
Future migration path: if a creator outgrows GitHub Releases (rare), the metadata schema's url field is provider-agnostic. They can move assets to R2/B2/S3 and update the metadata; viewer site keeps working.
Sync between private and public
The publish action is the only one-way bridge from private → public. The reverse (pulling published data back to private) is the unpublish action OR a re-import flow:
- Migration: a creator who previously had a site on Substack/Medium can import their content as drafts into private; from there, publish as normal.
- Cross-device sync: a creator with two devices uses the same GitHub account; the BKA app on each device pulls from + pushes to the private repo. Last-write-wins per file; conflicts are rare in single-creator usage.
The two-repo split is the source-of-truth design. Private is yours alone; public is your face to the world.