Writing

Team-Grade Releases for Solo Side Projects

QA, DevOps, security and release management for apps on Google Play, run by one developer. What I kept, what I compromised, and why quality didn't slip.

12 min read

  • Android
  • CI/CD
  • Claude Code
  • DevOps

I’m building two apps for Android and iOS by myself, and every release was a handful of steps I had to remember. I needed a release process I could repeat the same way every time, one that catches my mistakes before users do.

The apps: Driftwell is a sleep app that reads affirmations you choose over generated tones and ambient sound, for hours, with the screen off. It’s live on Google Play under my LLC (get it on Google Play) and halfway through a port to iOS. RuckPace, a pace coach for rucking, is still with field testers.

These are personal side projects, built on my own time under my own LLC. Teams that ship apps usually have QA, release management, DevOps and security covered by different people. On my own, I have a desk and two computers.

I started out the standard way: everything hosted, every check in the cloud. Then I saw the bill after a few pull requests, and I had to compromise. Compromising on where things run doesn’t mean letting quality slip. I still run every check that matters, on every change. That’s what this article is about.

A solo developer's Android and iOS release org: me at the top, AI coding accounts on a dotted line, and QA, DevOps, Security & Compliance, IT and an empty Test Engineering seat below Four departments are filled by something that runs or is written down. Test Engineering is the one gap.

*Not counting the AI. I pay for multiple Claude, OpenAI and Google Antigravity accounts to do the coding.

How a change ships

From idea to Google Play: plan, build, pull request, merge, ship to testers, Fastlane, internal track, phone QA, promote, review Claude writes the plans, the code and the QA pages. CI and a release script do the checking and shipping.

Every change, from a new feature to a one-line fix, takes the same path:

  1. Plan. I describe what I want, and Claude writes a spec and a step-by-step plan as .md files in the repo. I approve the plan before any code exists. Bugs from the field get the same treatment: my notes from a ruck become a triaged list, then a plan.
  2. Build. Claude works through the plan on a branch with sub-agents, task by task, writing tests alongside the code and following each repo’s CLAUDE.md rules. I review what it did.
  3. Pull request. CI runs on two machines. Windows runs the unit tests, a debug build and lint. The Mac tests the shared code on iOS and builds the iPhone app. Red on either stops everything.
  4. Merge. Only when both machines are green.
  5. Ship to testers. I type /ship-testers. Claude shows me the commit and the release notes, then asks before anything uploads.
  6. Test on the phone. The build reaches my phone through Play’s internal track in minutes, and I work through a QA page Claude wrote for that change.
  7. Promote. /ship-production promotes that build to Production without a rebuild.
  8. Review and live. Google reviews it, and it goes out on approval. Whatever users or testers report becomes the next plan.

Where the process lives. None of this is in my head. It’s in .md files the AI reads every session:

The .md files behind the process in both repos: CLAUDE.md rules, the two ship skills, specs, plans, QA cases, the Play runbook, and RuckPace's bug pipeline from field reports to decisions Blue files are Markdown the AI reads. The release lane and CI are the only other moving parts.

The rest of this piece walks through the parts that make that safe.

The release pipeline

Driftwell used to publish from a GitHub Actions workflow. I deleted it when shipping moved to a Fastlane lane on my desktop. A release isn’t a build. It’s a decision, made by whoever just tested the build on their phone.

The lane’s guards, each from a mistake I made or nearly made:

  • A typo never ships. Unknown options abort, so dry-run:true can’t publish while I think I’m rehearsing.
  • One upload, one commit. It refuses uncommitted changes and aborts if the code moves mid-run.
  • Play picks the version number, not a file I have to remember to bump.
  • Once Play accepts it, nothing fails the ship. If tagging fails afterward, it prints the fix and says in capitals not to rerun, because a rerun uploads again.

The skills on top. Nobody types the command. Two Claude Code skills wrap it:

  • /ship-testers shows the commit and the exact release notes testers will see, then asks. A test failure means fixing the code, never skipping tests.
  • /ship-production promotes a build testers already have, without a rebuild, and refuses any build carrying the in-app diagnostics screen I give testers.
  • Rollout: I’ve used staged rollouts. Today I promote at 100%, because 20% of a small user base is a handful of phones, too few to show a trend.

A real dry run of Driftwell's release lane: tests, a signed release build, and Play validating the upload without publishing A real dry run, 3 minutes 27 seconds end to end: 81 seconds of tests, an 83-second signed build, and 37 seconds for Play to validate the upload and throw it away.

CI on my own hardware

The bill that started it. My first setup ran on GitHub-hosted runners with a macOS job for the iOS build, and blew through my entire month of GitHub Actions minutes with a few PRs. About 87% of it was the macOS job, which GitHub bills at ten times the Linux rate.

So CI moved onto hardware I already had:

  • Android job: my Windows desktop, an i5, the same machine I develop on.
  • iOS job: an M1 Pro MacBook. On hosted runners I’d have rationed it to iOS-only changes. On my own Mac it runs on every PR, and it has already caught two cleanups that compiled green on Windows and broke the iPhone build.
  • Faster, too. Both machines build quicker than the hosted runners I was paying for.
  • The cost: a job waits in the queue while its machine is off. For one person, that’s fine.

What runs: unit tests, lint as a hard gate, the shared audio engine tested on both Android and iOS, and a full iPhone build. It has saved me many times. Two examples from RuckPace:

  • Screenshot tests. A layout bug once ran a ruck’s time and elevation together into 1:17:00276 ft, which reads like I rucked somewhere in the Himalayas. Raleigh, NC is not the north face of K2. Screenshot tests now compare every screen on every PR, so that kind of bug never reaches a phone.
  • Updating screenshots is one button. When I change a screen on purpose, a manual CI job records the new reference images. CI only goes red when something actually broke.

Testing on a real phone and a real trail

Some bugs only show up on a device: audio at 2 a.m. with the screen off, GPS under trees. That testing isn’t automated, but it is systematic. (What a month of nightly listening caught on Driftwell is in Building Driftwell: A month of testing.)

QA plans as pages. When a change needs a human on a device, Claude writes the QA plan as a page that saves itself. I run it on my phone.

A QA page for RuckPace's onboarding, part way through a run A real QA page. The marks and notes are illustrative.

  • Pinned to a build. Every run names the Play version, commit and PR, so a failure is a version number, not “the new one.”
  • Grouped by location. “On the couch” and “On a ruck,” so every walking case happens in one trip.
  • Observable expectations. “First alert about 10 s after slowing, voice plus vibration.” Never “alerts work.”

GPS you can’t fake at a desk. On RuckPace, mock locations are too clean to find real bugs, which live in dropouts under trees and jumps when the screen locks.

RuckPace's Debug logging switch and the Debug logs row to share a saved ruck *Debug logging is a switch in settings. Turn it on, walk a ruck, share the log.*
  • Record on the trail. The app logs every raw GPS fix, north of 200 MB for a long ruck.
  • Replay at the desk. A replayer feeds the log back through the same code the app runs. As the plan put it, “What this plan buys is a fixture, not a transcript.”
  • Keep it forever. Five real logs, coordinates shifted away from my house, are now test data that CI replays on every push.

A real RuckPace walk replayed: raw GPS dots against the app's smoothed pace, rejected fixes, and the spoken alerts as they fire A real 2.5-minute walk, replayed from its log. Grey dots are raw GPS. The green line is the pace the app trusted. Three fixes were too noisy to use.

Figuring out Google Play

  • Publish as a company. A personal account created after November 2023 needs 12 testers opted in for 14 straight days before it can reach Production, and the clock resets if it dips below 12. Organization accounts are exempt (Google’s policy). My LLC skipped it, and my home address stays off the listing.
  • Plan for the wait anyway. As a new account, Driftwell sat in review for over two weeks with no policy issue and no rejection. It moved only after I opened a support ticket. My guess is new accounts get held by default. Testers aren’t blocked: internal installs work while the listing is unreviewed.
  • Upload the first build by hand. Automation asks Play for the next version number, and Play has no answer until something exists.
  • Lock down the keys. The upload key and the service account key, scoped to release on one app, live in a password manager and are backed up.

Policy never came up, and that wasn’t luck. Most of the code is written with Claude, so each repo’s CLAUDE.md, which it reads every session, carries rules that keep the apps out of Play’s slow lanes:

  • Wellness, never treatment. No “cures” or clinical claims anywhere in Driftwell, an app with affirmations for anxiety and addiction. How that rule played out is in Building Driftwell: Guardrails.
  • No permission without a feature. When voice cloning was cut, its microphone permission went with it. Google flags permissions with nothing behind them.
  • No background location on RuckPace. Its mere presence in the manifest triggers a separate review, and the app doesn’t need it.
  • No analytics. The data safety form stays one line.

What I don’t run, on purpose

No Firebase, no Crashlytics, no analytics, no telemetry. That’s a choice, not something I haven’t got to yet.

  • I build a wellness app, so I don’t track users. Driftwell is a sleep app with affirmations for anxiety, grief and breaking habits. Which of those someone plays at 2 a.m. isn’t data I want to hold. RuckPace records where people walk. Both apps keep that on the phone, with no accounts and no server of mine.
  • The privacy answer stays short. No analytics, crash-reporting or advertising SDK means the data safety form and the privacy policy match the code without footnotes. Every SDK I add is a new declaration to keep true.
  • No Firebase for distribution either. Testers get builds through Play’s internal track, because that’s where the app ships anyway.

What I use instead:

  • Android vitals. Play Console reports crashes and ANRs from Play itself, with no SDK in the app.
  • A diagnostics screen in tester builds. Driftwell’s tester builds can copy a plain-text report of recent voice-render timings. It holds a character count, never the text of an affirmation, and /ship-production refuses any build that carries it.
  • Logs the user chooses to share. RuckPace’s debug logging is a switch in settings. Nothing leaves the phone until someone shares a ruck.

The cost is real: I find out about a problem when Play’s vitals show it or someone tells me. There’s no alert at 3 a.m. With a small user base, that’s a trade I make on purpose. At scale, it isn’t.

How teams usually do it, and what I kept

If you’ve shipped mobile apps on a team, you know the usual version of this. Here’s where I compromised to get the same quality alone:

StepHere, soloTypical team setup
Release buildsBuilt on my desktopBuilt by CI from the merged commit, in a clean environment, on every merge or tag
Code reviewCI must pass, and I review Claude’s workDefined approval rules: required reviewers, code owners per area, and AI review agents commenting on every PR
Signing keysPassword manager, used from my machineA CI secret store or signing service. No developer ever holds the keys
RunnersTwo machines I own, jobs queueHosted or auto-scaling runners that start clean for every job
Device testingQA pages on my phoneA device farm running automated tests on real phones, plus a QA team
Rollout100%, because the user base is smallStaged rollout that halts automatically if crash rates rise
Release approvalMeA release owner or a required approval step in the pipeline
Release scriptA copy in each appOne shared release lane every app uses
Crash reportingPlay Console’s Android vitals, no SDKA crash-reporting SDK that alerts on new crashes and routes them to whoever is on call
Analytics and telemetryNone, by choiceProduct analytics, centralized logging, dashboards and alerting, with a privacy review deciding what gets collected

The biggest compromise: release builds come from my desktop, not from a CI/CD pipeline. CI checks every pull request, but the build testers and users get is compiled on my machine. The lane refuses uncommitted changes and re-runs the tests, but my toolchain could drift, and only my own discipline stops a bad build from reaching testers. At a company, that’s an unacceptable risk. For one person who tests every build on their own phone first, it’s a trade I make on purpose.

What it costs is budget, and I don’t have one. Hosted macOS runners, a signing service, a device farm and a second engineer are all monthly bills. That’s the whole reason this setup exists.

Tom Cruise as Les Grossman in Tropic Thunder, dangling a gold chain This guy doesn’t care about GitHub bills.

What I get anyway is the same result:

  • Nothing merges red. CI gates every PR, on my hardware instead of rented hardware.
  • Users get the build I tested. Production is a promotion of the build on my phone, without a rebuild.
  • Every release is one commit. Tagged, with release notes, traceable to a line of code.
  • The rules are written down. Policy, release steps and QA plans live in .md files the AI reads every session, the same way a team’s runbooks do.

If Driftwell ever has a team, the table on the right is the to-do list. The process doesn’t change. Only who runs each step does.

Where it’s still rough

  1. No device tests. Nothing automated runs on a real phone. QA pages and replayed logs stand in.
  2. Builds queue up. With only two local runners, CI jobs wait their turn. That would be a real problem for a team. For a team of one, it isn’t.
  3. Each app has its own copy of the release script. When I improve one, I have to remember to copy the change into the other.

Next: iOS

The lanes were written so everything but the build and upload carries over to iOS, and the Mac already builds Driftwell’s iPhone app on every PR. What’s left is the part every iOS developer warned me about: certificates, TestFlight, and App Store review. That’s part 2.

I didn’t need a release team. I needed every mistake I’d made once to become a line that runs before I can make it again.