What It Took to Take a Private Repo Public — Part 1 of 3
OPEN-SOURCE · SECURITY

The Boring 90%

I have collaborated on other people’s open source projects for years, but I had never put one of my own out. Looking at my own code, I mostly see the corners I cut, not the parts worth showing anyone — which is a bad way to judge whether something’s actually ready. I went through the roughly hundred private repos I run for myself and the three dozen I actively maintain, and picked the one I thought could survive contact with a stranger: media-api, a media catalog and job-orchestration service that had been happily running my home media pipeline for a couple of years.

Deciding to do it was the easy part. Then I had to work out how. Flipping a repo from private to public is the last five seconds of a much longer process. I sketched my own sequence of what I expected would be needed, then went back and forth on it with Claude and Copilot. I really underestimated how many separate tasks and gates were actually hiding within “just make it public.”


This article covers the part that looked, going in, like a checklist: rotate what’s leaked, scrub what’s committed, license it properly, review the design with a stranger’s eyes. Mostly it was exactly that… boring. Working through this, I turned up a few things I had not factored into my plans.

Rotate first, always

The first pass got the leaks out of HEAD: a live Postgres password and an internal SSH host, both committed inside a .idea/ directory that had been gitignored only after it was already tracked; a hardcoded internal frontend origin baked into the CORS config; an internal Gitea URL wired into the Logfire integration. All of it became either an environment variable or a generic default.

None of that makes the leak go away, though: it just stops HEAD from repeating it. The Postgres password had already been sitting in cloned history for however long the repo had existed, and no amount of git surgery changes that. Rotating it on the server was separate work, done first, independent of anything about git. That ordering is worth pulling on: fix what a stranger can already use before you worry about what a stranger can still find. The second half of this — what a stranger can still find in history — turned out to be its own much longer story, which is Part 2.

The migration that had never actually run

A smaller, unrelated bug turned up in the same pass. alembic upgrade head against an empty database failed immediately — a mid-chain migration tried to alter a table called rename_requests that, by that point in the chain, didn’t exist under that name anymore; it had already become media_transform_requests everywhere else in the code. It had never been caught, because it had never been run: production’s actual schema came from Base.metadata.create_all() firing at startup. The migrations existed, faithfully written for every schema change, but they were a parallel, untested track that nothing had ever exercised end to end.

Fixing it was mechanical once it was visible: one clean baseline, migrations promoted to the only schema authority (create_all removed from the app’s startup entirely), and a CI check that builds a database from nothing and confirms it matches the models exactly. It’s the kind of bug a public repo would have found for you, on someone else’s laptop, within a week. Better to find it first, on your own database, in your own time.

The same week, the actual leaked content still lived in git history in the form of 36 tags, roughly 1,165 commits deep, too pervasive for anything scalpel-shaped. That got squashed to a single orphan root and re-tagged as v0.6.0. What that surgery actually involved, and the much longer tail of consequences it kicked off with GitHub itself, is all covered in Part 2. Now, back to the checklist…

The compose file that wasn’t a template

The plan for docker-compose.yml and its deploy workflow was to genericize them — replace the real values with placeholders, ship them as documented examples. Then it became clear that docker-compose.yml was the actual production manifest, and deploy.yml copied it onto the host on every release. Genericizing it in place wouldn’t have illustrated anything; but it would have quietly turned off production auto-deploy.

The fix was to stop treating this as one repo’s problem. A new private repo, media-api-deploy, took over the real compose file and the actual deploy logic; this repo kept only the parts that make sense in public — building and publishing an image, firing a dispatch event when a new version exists. Two config defaults that no deploy pipeline had ever explicitly written (a Prefect job-routing map, and an internal repo URL feeding Logfire’s code-source link) turned out to have been silently load-bearing in production the whole time. Zeroing them safely meant first finding somewhere else for the real values to live, which the new repo took on.

Verifying this against real production, more than once, caught one more thing along the way: app/routers/logs.py imported httpx (a dev-only dependency at that time) at module level, which meant the production image couldn’t boot at all until it was fixed. A checklist item about scrubbing a compose file turned into an actual outage catch, which is a better trade than the task looked like it would be going in.

What it actually took to build a private deploy pipeline for a public repo — the staging gate, the shared storage mount, the migration checks — is covered in Part 3.

De-branding without a redesign

app/auth/jwt.py was already a generic OIDC bearer-token validator; nothing about the actual logic assumed Keycloak specifically. What leaked Keycloak into the codebase was naming — keycloak_issuer, keycloak_audience, four config fields and their env vars, none of it structural. Renaming them to oidc_* was close to a pure find-and-replace, done alongside adding an auth-disabled dev mode so a contributor could hit protected routes without standing up an identity provider locally. This is refused at settings-load time if APP_ENV=production, so it can’t ship enabled by accident.

The version of this that would have been a redesign (a full pluggable-authenticator interface, so any OIDC provider could be swapped in cleanly) got looked at and explicitly turned down. Standard OIDC didn’t need an abstraction layer built for flexibility nobody was going to use. Not every impulse toward “more generic” is worth following through on.

Gates that deliver value

Adding a secret-scanning CI check turned up a platform detail I hadn’t accounted for: the org’s branch-protection ruleset requires a status check literally named test before it’ll allow a merge. A separate secret-scan job would run, report its result, and be entirely irrelevant to whether the merge was allowed - i.e. lip-service of a gate, not a true gate. Folding gitleaks into the existing test job instead of giving it its own name is what actually makes it load-bearing:

.github/workflows/ci.yml
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- run: uv run pytest
- name: Scan for secrets
run: ./gitleaks git --log-opts="--all"

A separate review of the CI/CD setup for public-runner exposure came back clean. The self-hosted runner that actually touches production infrastructure lives entirely in media-api-deploy, staying private by design, so this repo’s workflows never see it.

Enabling Dependabot surfaced seven alerts; six had clean patched versions available, and the seventh — a timing-attack advisory in a dependency this app only ever uses to verify tokens, never sign them — got dismissed with the reasoning written down rather than silently ignored or blindly patched around. (Though I did use this to exercise and test the Dependabot flows!)

The paperwork

Legal and licensing turned out to be the least eventful part of any of this. The full commit history was sole-authored, no vendored code, no third-party assets. Of 175 locked dependencies, every one was permissively licensed except psycopg (LGPL-3.0, used unmodified — fine under MIT, but worth a NOTICE file rather than staying implicit).

The rename I didn’t make

Somewhere in the middle of this, renaming the repo itself to something less generic was seriously on the table. It got punted, on purpose: the in-code naming debt around what the API actually calls things needed settling first. Settling that turned into its own piece of work.

A cost I’d already priced

The in-code naming debt was real, and I already knew about it: a database table called videos, referred to as “assets” almost everywhere else in the code — including in some of the public API’s own response fields, where GET /api/assets/{id}/accessories handed back a video_id.

I hadn’t missed this. I’d looked at it before and made a call: the system worked, the actual cost was just remembering my own naming rule every time I touched that part of the code, and a rename across the database, the models, the repositories, the services, and every public schema was a lot of tedious, error-prone work for a problem I could just keep working around.

What changed wasn’t that a design review had turned up something new. It’s that the rename stopped being expensive. An AI-assisted sweep could actually find every place the old name was used — across five different layers of the codebase — and make the change correctly in one pass, including a real Alembic migration verified against a live Postgres instance before it shipped. A trade-off I’d deliberately been carrying, because fixing it properly wasn’t worth my own time, turned into a same-day fix once the cost of finding every call site dropped to nearly zero.

Not everything the review turned up was about naming. One real bug: an endpoint declared async def whose entire body was blocking, synchronous file-system calls (no await anywhere in sight) which meant it blocked FastAPI’s event loop for every concurrent request while it scanned a directory. One line fixed it:

app/routers/assets/files.py
async def list_accessories(...):
def list_accessories(...):

Small, but the kind of thing that’s genuinely hard to catch by reading — it looks identical either way until you know to ask which side of the async boundary it ought to sit on.

What’s still missing

Not everything closed cleanly. Adding a SECURITY.md with a disclosure process meant confronting that GitHub’s private vulnerability reporting isn’t available on private repos — so the intake channel it names is written down but not actually usable until the repo goes public. No personal email as a fallback, either; that’s a deliberate choice, not an oversight. (…and writing this article is a great reminder that I still did not patch it!)

The docs describing the internal architecture review that guided a lot of this work had to move out of the repo entirely, since they quoted real hostnames and internal repo names as investigation detail. Deleting them from HEAD, it turned out, wasn’t the same as deleting them. That gap is where Part 2 picks up.

Write it once, at the end

The README got rewritten last, on purpose — after the rename decision, after the design review, against whatever the repo actually looked like once everything above had landed. Writing it earlier would have meant writing it twice.


None of this was hard. Every item on this list was something I could have done myself, on my own schedule, without any help. That’s what made it feel like the boring part going in.

What AI actually added wasn’t the ability to do any of these tasks alone; it was what I’ve come to think of as a hive-mind of experience distilled into its training — knowing what needed to change before I’d have thought to go looking for it, catching two bugs neither the checklist nor a read-through was watching for, and turning a trade-off I’d already priced and permanently deferred into a same-day fix. And, not least, the speed.


Part 2 turned out to be the piece with the most meat — wrong assumptions, a false alarm, a corrected plan, and an ending that took nine separate “solved it, actually didn’t” beats to reach. A comedy of errors that would have taken weeks to grind through without AI.

The repo: github.com/Glitchedpixel-io/media-api