Viewing archived v1 · 4 h ago · [back to current] · [history]

Built in private, released in public: self-hosted GitLab to GitHub

The workshop is a self-hosted GitLab I own and run. The shop window is GitHub. Development happens in private with no metered CI bill and full control of the runners; when a release is ready, one GitLab CI job mirrors the source to GitHub and cuts a release with the built binaries. Here's the actual pipeline that does it, gotchas included.

Built in private, released in public: self-hosted GitLab to GitHub

The one-line version: I build in private on a self-hosted GitLab where there are no CI bills and I own every runner, and I release in public on GitHub where the people who'd actually download the thing already are. Two hosts, one direction of travel, and a single CI job that bridges them.

This is the setup I landed on for Thermalith, the project I'm building right now (what it actually does is a post for another day). I think the split is right for any small open-source shop that wants to be public about the releases without being public about every half-finished experiment.

The workshop and the shop window

Development lives on gitlab.evilgeniuslabs.ca, an instance I own and run on my own hardware. It's private, and it's the source of truth. Every branch, every messy work-in-progress commit, every pre-release build that isn't ready for anyone's eyes stays there.

GitHub is the public release host. On a release tag, one GitLab CI job pushes the source and the tag to GitHub one-way, then publishes a GitHub release carrying the built binaries and the manual. GitHub mirrors the source. It is not where the work happens. If GitHub vanished tomorrow I'd lose a download page and an audience, not a line of code or a scrap of history.

Why pay nobody for CI and self-host the builds? Three reasons, in rough order of how much they actually matter to me:

No metered CI. A release build here is Windows, Linux x64 and arm64, two macOS targets, plus a PDF manual rendered through LaTeX. On hosted CI minutes that is a real recurring cost, and the cost scales with how often I cut builds, which is exactly the thing I don't want to be thinking about while I work. On my own runners it's free to run as often as I like. I rebuild on a whim and it costs me electricity.

Full control. I own the instance, the runners, the data, and the pipeline. No quota emails, no third party deciding what my CI is allowed to do or when a job is "too long." When I needed a macOS runner I bought a Mac mini and plugged it in.

Private by default. The day-to-day is messy and I'd rather it stay behind a curtain. Going public is a decision I make, never the resting state of the repo.

GitHub earns its place on the other side of the split for the obvious reason: that's where people look. Releases, tags, and source on GitHub are what a developer expects to find and read before they install something off the internet. Discoverability and a baseline of trust come from being where everyone already is. The code is GPL-3.0 and fully public there. Open source, on my terms: public about the artifacts, private about the workshop, nothing locked into a vendor on either side.

The pipeline, for real

The whole thing is gated on one rule: no tag, no pipeline.

workflow:
  rules:
    - if: '$CI_COMMIT_TAG'

Ordinary branch pushes build nothing. Merge requests build nothing. The web "Run pipeline" button builds nothing. The pipeline only exists in response to git tag v0.2.0 && git push origin v0.2.0. That keeps the runners idle during normal development and means a pipeline run always corresponds to a thing I actually intend to ship.

On a tag, the build stage fans out:

The Linux job cross-builds Windows and both Linux architectures on a single Linux runner. Because dotnet publish is RID-targeted, one Debian-based .NET SDK container publishes win-x64, linux-x64, and linux-arm64 self-contained single-file binaries without needing a Windows host or an ARM host anywhere. (The one thing the SDK image doesn't ship is zip, which the Windows packaging step needs, so the job apt-installs it first. Small papercut, fixed in one line.)

The macOS job runs on an always-on M4 Mac mini registered as a self-hosted runner. This one can't be faked on Linux: codesign and hdiutil are macOS-only, so the .app bundle and .dmg can only be produced on a real Mac. The mini sits on a shelf as an appliance and picks up the job automatically on a tag, same as the Linux runner.

Honesty note on signing, both platforms: nothing is code-signed yet. The macOS builds are ad-hoc signed only, not notarized, and the Windows binaries aren't signed at all. So Gatekeeper will grumble on a Mac and SmartScreen will throw its warning on Windows, and on both you'll have to click through to run the first download. I'm still getting the signing certificates sorted out under the new evilgeniuslabs.ca identity. I made the pipeline build and ship end-to-end first, on the reasoning that an unsigned binary that exists beats a signed one that's still theoretical. Developer ID notarization and an Authenticode cert are both on the list, just not in the pipeline yet.

A third job builds the user manual as a PDF in a completely separate pandoc/latex container, rendering Markdown through XeLaTeX with repo-bundled fonts. It has nothing to do with the .NET build and shares nothing with it except the tag that triggered them both.

So one tag produces five platform binary packs, a NuGet package, and a typeset PDF manual, across three different container environments, with zero of it touching a paid CI minute.

The one job that reaches across to GitHub

Everything above runs automatically. The actual release does not. The job that publishes to GitHub is when: manual, which in GitLab means it sits there with a play button until I press it. Every tag builds; only the ones I bless become public releases. That's my beta-vs-final gate.

When I do press play, the job does two things. First, a one-way source mirror: it force-pushes the tagged commit and the tag to GitHub, so the public repo carries the real source with real history (a full clone, not a shallow tip) and GitHub can generate its "Source code" archives. GitLab is the source of truth, so the force-push is intentional rather than a smell.

git push --force "$GH_PUSH_URL" "HEAD:refs/heads/main"
git push --force "$GH_PUSH_URL" "refs/tags/${CI_COMMIT_TAG}"

Then it cuts the release with the GitHub CLI, attaching the binaries, the NuGet package, the printer profiles, and the manual PDF. The release title and the prerelease flag come from a per-tag notes file in the repo, Documentation/releases/<tag>.md, with a tiny frontmatter block:

---
title: Thermalith 1.2.0
prerelease: false
---

Set prerelease: true and the same machinery publishes it as a GitHub pre-release instead. The release body is just the markdown below the frontmatter. Writing release notes becomes "edit a file in the repo," which is where I want that work to live anyway.

Authentication is a GitHub fine-grained personal access token with exactly one permission, Contents: write on the one repo, stored as a masked CI variable. Nothing token-bearing is ever echoed into the job log.

Who gets the nightlies

The public, stable releases go to GitHub. The bleeding-edge builds do not. Every tagged pipeline leaves its binaries on GitLab as those 90-day CI artifacts, and that's the nightly and beta channel. There's no separate GitLab release page, just the artifacts hanging off each pipeline, which is all that audience needs.

Access to them is by request for now. If you want in on the early builds, email evilgenius@evilgeniuslabs.ca and I'll grant you an account on the GitLab instance; once you're signed in the artifacts are right there on each tagged run. It's the same split as everything else here. The public sees finished, release-noted (and eventually signed) builds on GitHub, and the people who actively want to help shake out a beta come into the workshop through a door I control, with an account I handed out, not because a download link got loose.

The gotcha that cost me an afternoon

If you set that token as a "Protected" CI variable, GitLab only injects it into pipelines running on protected refs. Release tags aren't protected by default. So the variable is set, looks correct, and is simply absent from the job, which fails with an empty token and a confusing error. The fix is either to protect your release tags (a v* rule under Protected tags) or to untick Protected on the variable. The pipeline now fails fast with a message that says exactly this, because the version of me that hit it would have appreciated the version of me that wrote it down.

One more design choice I'm quietly happy about: there is no hardcoded GitLab hostname anywhere in the pipeline. When I renamed the instance from gitlab.glyphdeck.org to gitlab.evilgeniuslabs.ca a few days ago, the entire build-and-release pipeline needed zero edits. The only thing that changed was a git remote set-url. Infrastructure that doesn't know its own address is infrastructure you can move.

The part I haven't figured out: pull requests

The one-way mirror has an obvious hole, and I'll be straight that I haven't closed it: contributions. Because GitLab force-pushes onto GitHub, GitHub is downstream and effectively read-only. If someone forks the public repo and opens a pull request there, that change has no clean path back to the GitLab side where the real work happens, and the next release force-push would cheerfully stomp anything sitting on GitHub's main.

A few options exist, none obviously right. I could pull the PR branch down by hand and replay it onto GitLab, treating a GitHub PR as a patch submission rather than a merge. A bot that syncs PRs back the other way is possible, but it's one more moving part to babysit and it muddies which side is the source of truth. Or I point contributors at the GitLab instance like the beta folks, which is simplest but raises the bar for a drive-by fix.

I haven't picked one, mostly because nobody has opened a PR yet, so it's a problem I get to solve lazily. If you've run a public-mirror-of-a-private-repo setup and handled inbound PRs cleanly, I'd genuinely like to hear how.

Is this overkill for a one-person project?

Probably, by some measure. I could push to GitHub and let GitHub Actions build it and never think about a runner again. But then I'd be metering my own curiosity, building only when the bill justified it, and handing the keys to my release process to a platform that changes its terms on its own schedule. This way the only thing I'm spending is power and a Mac mini that was a one-time cost, and the whole system does exactly what I told it to, in private, until the moment I decide to make something public.

That decision being a single deliberate button press, and not the default state of my repo, is the part I'd keep no matter how small the project got.