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:

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

2. Upload binary assets (if any)

For each media file in the draft that exceeds 100KB (rough threshold; tunable):

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:

If steps 6-7 fail (CF build or indexer):


Unpublish action

Remove a post from the public repo:

  1. Delete posts/<slug>.md + posts/<slug>.meta.json
  2. Optionally delete any release assets only referenced by this post (we track ref-counts)
  3. Regenerate feed.json + feed.xml
  4. Commit + push
  5. CF Pages rebuilds, post is gone from the site
  6. (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:

  1. Update posts/<slug>.md body
  2. Update posts/<slug>.meta.json (bump updated_at)
  3. Re-upload changed media if any
  4. Regenerate feeds
  5. Commit + push
  6. CF rebuilds
  7. (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:

The two-repo split is the source-of-truth design. Private is yours alone; public is your face to the world.