I made dailyprog public. The code is on Codeberg now, under AGPL-3.0.
The license was the easy decision. The problem was everything the code needs to run. A puzzle site’s whole point is the puzzles, and the puzzle files carry the hidden tests, the ones you only get graded against after you submit. Publish the catalogue and you’ve published every answer, plus every puzzle I haven’t shipped yet. So the repo can’t just go public. Most of it can. A slice of it can’t, and that slice is the “added value”.
Why AGPL
dailyprog is a hosted web app. Nobody downloads it. That single fact picks the license.
Plain GPL has a hole for software you run as a service: modify it, host it, never distribute a binary, and you never have to share your changes. AGPL closes that hole. If you run a modified version as a network service, you owe your users the source. For something that only ever exists as a website, AGPL is the version of copyleft that actually bites. MIT would have let anyone lift the whole thing and run a closed clone. I didn’t want that. AGPL it is.
The license text is one file in a 23-file commit. The interesting work is the other 22, and the fixes that kept trailing in for two weeks after.
What can’t be public
I walked the tree and sorted it into two piles.
Public: the Next.js app, the shared UI library, the sandbox runners, the puzzle generator, the CI config, the contributor guide.
Private:
- The puzzle catalogue. Every shipped puzzle and every scheduled one, hidden tests included.
- The example bank that steers the generator. Two dozen hand-written exemplars, half of them accepts and half rejects, and every accept carries its hidden tests and a reference solution. It leaks the answers and the quality bar in one file.
- The whole
docs/andinfra/trees, runbooks and ADRs included. Not secret exactly, but they name servers and describe the production shape, and there’s no reason to hand that out.
The trick is that the app doesn’t boot without the first pile, tests read puzzles as fixtures, and the generator reads the example bank. The private stuff isn’t off to the side. It’s wired through the middle.
The split
The private content moved into a separate repo, ops, pulled in as a
git submodule at ops/.
The public repo holds the code and points at a commit of ops it can’t clone
unless you have access.
Then a script puts everything back where the app expects it.
sync-ops.mjs
runs on predev, prebuild, pretest, pretest:e2e, and prepreview, so it
fires before anything that reads content. It copies the puzzle JSON into
content/puzzles/, copies the example bank if it’s there, and symlinks docs,
infra, and DEPLOY.md out of the submodule. The app, the tests, and the
tooling never learn that ops exists. They see files in the paths they’ve
always used.
The part I like: what happens when you don’t have ops at all.
const puzzleSrc = hasOps
? path.join(opsDir, "puzzles")
: path.join(contentDir, "sample-puzzles");
A contributor clones the public repo, the submodule comes up empty, and instead
of copying the real catalogue the sync falls back to six past puzzles checked in
as sample fixtures. The app boots. The unit tests pass. You can work on the
editor, the sandbox, the layout, any of it, without ever touching a real hidden
test. The full catalogue quality checks notice ops is missing and skip
themselves rather than fail. Deploy runbooks and ADRs just don’t appear, and you
don’t need them to hack on the app.
The E2E specs were where that fallback bit me. The fixtures are dated June, so every spec that needs today’s puzzle skipped itself, and Playwright scores a skip as a pass. The entire solve, verify, streak, share path went unexercised and CI stayed green about it for days. The fix was to run the gates against the real catalogue and stop treating a green board as evidence.
The example bank was the one exception I got wrong first. There’s no committed
sample for it, so early on the sync would copy the destination onto itself when
ops was absent, or log a skip that didn’t mean what it said. The fix was to
only ever write it from ops and otherwise leave whatever’s there alone. Small
bug, but exactly the kind a submodule split breeds: a path that’s real in one
checkout and imaginary in another.
CI has to know about the seam
The nightly generator writes a new puzzle, and
a new puzzle is private content, so it commits to ops, not to the public repo.
That took a fix on its own, because the generator was writing into the
synced-out copy, the derived mirror, instead of the submodule that’s the actual
source. Write to the mirror and the next sync clobbers your puzzle. Write to
ops and it survives.
Deploy pulls the submodule forward with
--remote so production lands on the
newest ops commit. And there’s a guard I added after getting bitten, carried
by both the nightly and CI: check the pushed ops remote before authoring
anything. Without it the job reads an empty slot, doesn’t see the puzzle sitting
unpushed on my laptop, and writes a different one straight over the top. That’s
the failure mode of submodules, the pointer that’s valid on the machine that
made it and invisible everywhere else. Better to fail loud in CI than quietly
replace a puzzle nobody pushed.
Is it worth the tax
The days after the split were submodule housekeeping. Getting the gitignore to
match symlinks instead of only directories. Pointing ops at the right deployed
puzzle after a mismatch. A string of “bump ops submodule” commits that are pure
pointer bookkeeping and carry no code. Every puzzle ship is now two repos in a
specific order. The two-repo dance is real and it never fully goes away.
But the alternative was keeping the whole thing closed, and I wanted the code out. The sandbox runners, the generator, the way the app is put together. Those are the parts worth reading, and none of them are the part I need to keep back. The split lets the interesting 95% be public and fences off the 5% that’s just answers. If you clone it today you get a working puzzle site with a handful of sample puzzles, and the only thing you can’t see is which puzzle runs next Tuesday.
Which is the one thing I’d want kept from me too, if I were still playing.
