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. My changes live in a fork that I maintain myself.

Which is fine. Until patch day.

The Patch-Day Tax

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

RuneLite has to adapt to changes in the game, and my fork needs RuneLite's updates. That leaves me with a small ritual:

  • Pull the upstream changes.
  • 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 update. 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 tried was making the build faster. Trim the distribution build, skip style checks and documentation, get to the JAR sooner. My recorded times went from 3 minutes 43 seconds to 2 minutes 19 - about 38% less build time, and a number I was prepared to be smug about.

It made the wait shorter. It didn't make the ritual go away.

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 turns "I have a working binary" into "the binary shows up on the right machine, ready to run." I'd been doing that part by hand, which rather undermined the ambition to log on for ten minutes.

The Calendar Can Stay

My first thought was a cron job: every Thursday evening, pull upstream, merge, build, upload. A regular appointment with the compiler.

There is nothing wrong with cron. A timer can run a perfectly sensible release check. The mistake would be rebuilding and publishing merely because the timer fired, without checking what changed or whether the build succeeded.

RuneLite already provides the useful signal: release tags identifying particular versions. The automation needs to ask whether there is a release my fork hasn't incorporated, rather than assume that Thursday means new software.

I put the build in GitHub Actions. The workflow accepts a trigger from a person or another service; it does not itself contain a schedule. Whenever it runs, it fetches upstream's release tags and checks whether the selected tag is already in the fork's history. Normally it skips the build if it is, with a force option for rebuilding deliberately.

The timer and the release check aren't competing ideas. One decides when to look; the other decides whether there is work to do.

Wiring the Pipeline

When there is a new release to incorporate, the workflow:

  1. Merges the selected upstream tag into my fork.
  2. Assembles the client JAR, the Java application packaged into one file.
  3. Computes its SHA-256 hash.
  4. Publishes the JAR and a small manifest describing it.

A failed merge or build stops the job before publication. It doesn't replace the published client with whatever happened to be lying in a build directory. Merge conflicts still need me; successful compilation also doesn't establish that every plugin will work when the game starts.

One detail matters for recovery: the merge is pushed before compilation. If compilation fails, the next ordinary run can see the tag as already merged and skip building. After fixing the failure, I need the force-build option. This is automation of the routine path, not an unattended repair service.

The original manifest had two fields: the release version and the JAR's hash. The hash is a fingerprint of the bytes, not a judgment about how good they are. Compare a downloaded file with that fingerprint and I can detect an incomplete transfer or a mismatch between the manifest and the file being served.

It is not an independent proof of authorship. Someone able to replace both the JAR and its manifest can make their hashes agree. A signed manifest verified against a separately trusted public key could protect against a compromised file host, though not a compromised signing pipeline. I haven't built that; the launcher trusts the publisher it contacts over HTTPS.

Enter the Doorman

I didn't want the replacement chore to be "open a browser, download a JAR, move it into place, update the shortcut." I wanted to double-click an icon and play.

So I wrote a small launcher in Go. It reads the manifest, hashes the installed JAR, and downloads a replacement when the hashes differ. Then it starts Java. The desktop icon stays pointed at the launcher; the changing client lives behind it.

He never enters the party himself. The Java client is the party. The doorman just checks the guest list and lets me get on with it.

That gives me freedom to change the build process without changing the desktop launcher, provided the delivery contract stays compatible. The manifest format, download URLs, and verification rules still matter. Moving storage behind the same URLs can be invisible to the launcher; changing those URLs or requiring signature verification cannot simply be wished away.

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, checking downloaded software before allowing it to run. My launcher wasn't signed and notarized for distribution, so macOS had no such approval to rely on. For a tool intended for me and perhaps a couple of friends, I chose a manual trust step rather than that distribution process.

The relevant file attribute is com.apple.quarantine. For a copy I have chosen to trust, the narrowly targeted terminal command is:

xattr -dr com.apple.quarantine "/Applications/ScottLite.app"

That removes this attribute recursively from the app bundle. Unlike xattr -cr, it does not clear every extended attribute. It also does not inspect the app, sign it, or establish that it is safe. It removes the quarantine marker; the trust decision is mine. Anyone else following that instruction is trusting me personally too. I wouldn't offer that as the normal install path for strangers.

Knowing the workaround wasn't the same as finishing the install experience.

The Real Distribution Problem

The audience is me and perhaps one or two friends. That is hardly a business case for a polished installer. But I'd just spent a weekend deciding that the work didn't end when the binary appeared on disk. Stopping at "well, I'll just fix the warning in Terminal" would have recreated the chore in a new place.

So the launcher got a proper app bundle, an icon, a disk image, and a download page explaining the installation warning rather than leaving it as a surprise. That explanation matters even if the only reader is me, installing on a fresh laptop two years from now.

The chore I was running away from wasn't just compilation. It was all the little moments where my software handed me a problem and expected me to sort it out before I could do the thing I'd opened it for.

Getting Back to the Game

The useful distinction is between automating the build and automating the arrival of a usable client. A release-aware workflow avoids needless builds; failure checks stop it publishing a failed build; the launcher handles the routine update without making me edit a shortcut. None of those removes the need to resolve a real merge conflict or diagnose a bad release.

What they remove is the tax on every ordinary update.

I thought I'd grown out of OSRS. What I'd actually grown out of was the twenty-minute toll booth in front of it. Take the toll booth away and the game came back. If you've stopped doing something you used to love, look at the friction around it before you blame the thing itself.

Now I just have to remember to actually log on.


Addendum: a few improvements I made much later on

I came back to this in July 2026 to make updates less fragile. The launcher now downloads to a temporary file and checks its hash before replacing the installed client, keeping the previous copy for manual recovery. The pipeline also publishes each new, hash-named client before announcing it in the manifest.

The desktop shortcut hasn't changed, which is rather the point.