There's a particular flavor of guilt that comes from a hobby you stopped doing because it became too much work.

Old School RuneScape is supposed to be a low-stakes game. The whole appeal is that you can log on for ten minutes, chop a tree, fish a few salmon, and log off. Come back tomorrow. Come back next month. Come back next year. The character waits patiently. The trees grow back.

For years, that's exactly what I couldn't do.

The catch is that I don't play the vanilla client. I run a custom build of RuneLite, an open-source third-party client with a wonderful plugin ecosystem and an even more wonderful habit of letting me write my own plugins. Mine are nothing fancy -- a few quality-of-life tweaks, a couple of overlays I like, a notifier or two wired exactly to my tastes. Years of small additions, all stitched into a fork that lives on my machine.

Which is fine. Until Thursday.

The Patch-Day Tax

Jagex, who makes the game, ships an update almost every Thursday. Quests, balance changes, the occasional new boss. Patch days are great. The problem is what the patch day does to my setup.

Every Thursday, RuneLite has to merge upstream changes from the official client. And every Thursday, my fork has to merge from RuneLite. Then I have to:

  • Pull both upstreams.
  • Resolve any merge conflicts in my fork.
  • Boot up the IDE.
  • Run a Gradle build that takes a few minutes.
  • Find the new JAR (whose filename helpfully changes with every release because it's versioned).
  • Update my desktop shortcut to point at the new JAR.
  • Hope nothing is broken.

That's the toll. Twenty minutes on a good day, an hour on a bad one if there were merge conflicts to think through. Per Thursday. For years.

Twenty minutes isn't catastrophic. But OSRS isn't a game you play in long uninterrupted blocks. It's a game you play when the mood strikes, in scraps of time you didn't realize you had. And there is no mood that survives the words "before you can play, please update your shortcut path."

If you'd like to know why I gradually stopped playing a game I love, that's why. The game stayed fun. The cover charge to get to the game grew until it crowded out the casual instinct to log on. I'd think I should hop on for a minute, and then remember the merge that was waiting for me, and decide to read a book instead.

I've wanted to fix this for years. I finally had a free weekend.

What I Was Actually Solving

The first thing I did was almost the wrong thing. I sat down, opened a terminal, and started thinking about how to make the build faster. Fewer Gradle modules. Skip the tests. Skip the linter. Skip the documentation generator. I shaved the build down from nearly four minutes to a hair over two -- a pleasing 38% optimization, and a number I was prepared to be smug about.

It also did not solve a single thing.

The build being slow had never been the problem. The problem was that the build was on my laptop, that the artifact lived on my laptop, that the shortcut had to be updated on my laptop -- all while I was trying to play a video game on my laptop.

I'd been thinking of this as a build problem. It was a distribution problem.

Distribution is its own discipline. It's the thing that turns "I have a working binary" into "the binary shows up on the right machine, with the right name, at the right time, ready to run." For software shipped to other people, distribution is most of the job. For software shipped to yourself, it's invisible -- right up until the moment it isn't.

Once I saw the problem that way, the rest of the design fell out almost on its own.

The Tempting Wrong Answer

My first instinct was to take the build off my laptop and put it on my server, where it could happen quietly in the background while I wasn't paying attention. Good instinct. Then I ruined it by reaching for the most familiar tool I had: a cron job.

Every Thursday at 6 PM, the server would pull RuneLite, merge my fork, run the build, and upload the result. Easy. Right?

Wrong.

A cron job is a calendar. Calendars don't know anything. They don't know whether RuneLite shipped a release this week or skipped one. They don't know whether the upstream tag is stable or in the middle of a rebase. They don't know whether the merge will cleanly succeed or whether something will conflict and produce a JAR that crashes on launch. A Thursday cron will happily build broken software at 6:00:01 PM and upload it over the working copy, and then on Friday morning when I want to play, the game won't start, and I'll have to crawl into the server to figure out which Thursday lied to me.

Time is a terrible trigger for an event that has nothing to do with time.

Listen to the Source, Not the Clock

Here's the thing I should have noticed sooner: RuneLite already publishes a tag every time they cut a release. They've been doing it for years. They are extremely good at it. Each release has a name, a tag, a commit, a changelog -- a small, public, machine-readable announcement that says this is the version we recommend you use right now.

So don't trigger off a calendar. Trigger off the tag.

The build doesn't run "every Thursday." It runs whenever upstream cuts a new tag -- which usually means Thursdays, but sometimes Wednesdays, sometimes twice in a week, sometimes not at all. The schedule isn't mine to invent. It belongs to RuneLite. My job is to listen.

It's a problem of when, not who. And the when was never something I had to decide.

Wiring the Pipeline

GitHub Actions makes this almost embarrassingly easy. I wrote a small workflow that polls upstream RuneLite for new tags, and when it sees one, it:

  1. Checks out my fork.
  2. Merges in the new upstream tag.
  3. Builds the client (the same Gradle command I'd been typing by hand for years, minus the tests, the lint, and the doc generation).
  4. Computes a SHA-256 hash of the resulting JAR.
  5. Uploads both the JAR and a tiny manifest file to my static server.

The manifest is the heart of the system. It's a single file, two fields, no surprises:

{
  "version": "2026-05-25",
  "sha256": "8f43a9b1c2e3d4e5f6..."
}

That's the whole truth of the system, in two lines. The version string is a label for my own benefit. The hash is the thing that actually matters: a fingerprint of the exact bytes of the current JAR. If the JAR changes, the hash changes. If the hash hasn't changed, the JAR hasn't, and there's nothing to do.

It's worth being precise about what that hash does, because "SHA-256" has a way of sounding like more than it is. The manifest and the JAR are served by the same host, out of the same directory, under the same authority. So the hash tells me that the bytes I downloaded are the bytes the pipeline meant to publish. It catches a truncated download, a stale CDN object, a half-finished upload, a JAR that got out of step with the manifest describing it. That is integrity, and integrity is the failure mode I actually have.

It is not authenticity. If somebody owns my static server, they replace the JAR and the manifest in one motion, the hashes agree beautifully, and the doorman waves the new thing straight through without a flicker of doubt. Checking a hash against a manifest from the same source proves the source is internally consistent -- not that the source is me. The stronger property would need the manifest signed by a key the build pipeline can't reach, with the public half pinned inside the launcher. I didn't build that. The trust root here is "I control static.scottliu.com," and I'd rather write that sentence out than let a hash imply I'd done something more.

Now I had a build pipeline that ran on its own, on the server, on the world's schedule. The only thing left was to figure out how my laptop was supposed to find out about the new JAR.

Enter the Doorman

I didn't want my game-launching process to be "open a browser, download a 30MB JAR, drag it into a folder, update a shortcut." That's the manual chore in a different costume. I wanted to double-click an icon and play.

So I wrote a tiny launcher, in Go, that lives on my desktop. It is, on purpose, very stupid. It does exactly one job, and the job is to be a doorman.

When I double-click the icon, the doorman doesn't open the door himself. He walks down to the front desk -- my static server, where the manifest lives -- and reads the guest list. He looks at the SHA on the manifest, then at the JAR sitting in my user directory, and computes its SHA. If they match, he waves me through, hands the key to a Java process, and goes back to his post. If they don't match, he politely says "one moment," fetches the new JAR, verifies its hash against the manifest, swaps it into place, then hands the key to Java and goes back to his post.

He never enters the party himself. He doesn't need to. The Java client is the party; the doorman just makes sure I walk in wearing the right thing.

The whole launcher is a few hundred lines of Go. It uses net/http, crypto/sha256, and os/exec -- nothing exotic. The smallness is the point. A doorman with opinions is a security guard. A doorman with a UI is a hotel concierge. I just wanted somebody at the door, and I wanted him to read fast.

This is also where the original reframe pays off. Because the laptop only ever asks the manifest "what hash should I be running?", I can change anything about the upstream pipeline -- the build steps, the host, the storage layer, the signing scheme -- and the doorman doesn't care. He reads one number and acts on it. Everything behind that number is mine to rearrange.

The Mac Boss Fight

I was feeling pretty good about myself, right up until I tried to package the launcher into a real macOS application.

The first surprise was that a macOS .app is not a file. It's a folder -- a directory ending in .app that the Finder draws as a single icon. Browsers can't really download a folder. Email clients can't really attach one. The fix is to wrap the bundle in a .dmg disk image, which I did, and which I will not pretend was the most interesting part of my afternoon.

The second surprise was the actual boss.

I built the .dmg, mounted it, dragged the app into Applications, double-clicked, and got this:

"Apple could not verify [the app] is free of malware that may harm your Mac or compromise your privacy."

[Move to Trash]     [Done]

That's the entire dialog. Two buttons. Neither of them is "Open."

This is Gatekeeper, the macOS feature that refuses to run software that hasn't been signed by an Apple-issued developer certificate. The certificate costs money and requires an audited developer profile. For a launcher I'm building for myself on a Saturday, that's, to put it gently, not a proportionate response.

The technical fix is one line in a terminal. macOS attaches an extended-attribute flag called com.apple.quarantine to anything downloaded from the internet, and xattr -cr /Applications/<thing>.app strips it. The OS then forgets the file came from outside, and the launcher runs.

I knew that fix immediately. I am a technical user. xattr is in my muscle memory.

And yet.

The Real Distribution Problem

Here's the part that almost nobody who builds their own software for themselves takes seriously, and that I almost didn't either.

This launcher is for me. Maybe one or two friends, somewhere down the line, on a case-by-case basis. Nobody else. The audience is a rounding error. There's no business reason to make the install experience friendly.

But I'd just spent a weekend telling myself the lesson of this project was that building isn't shipping. I'd built a whole pipeline around the idea that the work doesn't end when the binary is on disk. And then, the moment Gatekeeper blocked me, my first instinct was to shrug and say "well, I'll just xattr it, it's fine."

If that was fine, I hadn't actually learned anything.

So I held myself to it. The doorman ships with a styled .dmg, a real icon, a proper bundle layout, and a download page that explains -- politely, prominently, in plain English -- what Gatekeeper is going to do, why, and the single command to run if you see the scary dialog. The system can't be friendly, but the experience can be. The xattr workaround isn't hidden behind a README somewhere. It's right there, in front of the download button, where it belongs.

And I should say plainly what that page is asking for, because a well-designed instruction is still an instruction. "Run this command and the security warning goes away" is precisely the habit an attacker would like people to have, and Gatekeeper isn't being unreasonable -- it genuinely cannot distinguish my launcher from anything else unsigned, which is the entire job. Documenting the workaround clearly is the best move available for a project whose audience is me and maybe two friends. It is not equivalent to signing and notarizing. Anyone who runs it is trusting me personally, and not one thing their operating system is able to verify on their behalf. That's an acceptable trade at this scale. It stops being one the moment the audience is strangers.

It's a small thing. It's also the entire point of the project. The chore I was running away from on Thursdays wasn't the build. It was the small, ungoverned moments where the system handed me a problem and expected me to figure it out myself. I built the launcher to make those moments stop -- not just for the moment when a new RuneLite version drops, but for the moment when I install it on a fresh laptop in two years, or when I send it to a friend who plays on Mac and doesn't want to read a wall of text first.

Takeaways

Three things I'm taking with me.

Building isn't shipping. Compiling the binary is the part you put on your resume. Getting the binary onto the right machine, with the right name, at the right time, with a launch experience a human can survive -- that's a separate discipline, and it's usually most of the work. I'd been treating "I have a working JAR" as the finish line for years. It was barely the halfway mark.

Trigger on truth, not on time. Cron is the wrong primitive for "I want to build whenever there's something new to build." Whatever upstream signal already exists -- a tag, a release, a webhook -- is almost always a better trigger than the wall clock, because it tells you something happened instead of some time passed. My pipeline doesn't run on Thursdays. It runs when RuneLite says it's time.

The chore was the barrier. I thought I'd grown out of OSRS. What I'd actually grown out of was the twenty-minute toll booth in front of the front door. Take the toll booth away and the game came back. There's probably a more general lesson here about hobbies, but I'll leave it at this: if you've stopped doing something you used to love, look at the friction around it before you blame the thing itself.

The next RuneLite release lands sometime this week. I haven't opened my IDE. I haven't pulled a single branch. The doorman will handle it.

Now I just have to remember to actually log on.