DEV Community

Cover image for Building a macOS Dev Workstation Toolkit in Bash: 187 Tests, Pinned CI, and Verified Backups
Sunny JayaRaju
Sunny JayaRaju

Posted on

Building a macOS Dev Workstation Toolkit in Bash: 187 Tests, Pinned CI, and Verified Backups

Every workstation setup starts the same way: a setup.sh that works. Then it accretes.

Someone adds a second script. The logging block gets copy-pasted. Config lives in three places and nobody can say which one wins. The README describes a flag that was renamed four months ago. Validation is whatever you remember to run before you break something.

The rot isn't dramatic. It's a repo nobody dares to touch, and no idea whether your last change broke anything.

I wanted to fix that on my own machine, in Bash.

What it is

Developer Workstation Framework

An engineering-first framework for building, managing, validating, and evolving a professional macOS development workstation using modern Bash engineering practices.

Ten utilities — install, update, uninstall, sync, backup, restore, check-project, doctor, repo-clean, shell-quality — built on seven shared Bash libraries. Current release is v2.2.0.

git clone https://github.com/SunnyJayaRaju/workstation-framework.git
cd workstation-framework
./scripts/install.sh
./scripts/doctor.sh
Enter fullscreen mode Exit fullscreen mode

The problem it solves

Workstation scripts rot for the same reason any codebase without tests rots. There's no compiler and no failing build, so nothing tells you that you broke something. You find out when the machine is subtly wrong.

The usual failure pattern:

  • Scripts become duplicated. The same date-formatting logic in five files, one of which is wrong.
  • Configuration is scattered. No answer to "what is this actually set to on this machine?"
  • Documentation drifts. The docs describe the previous release, confidently.
  • Validation is manual. Which means it doesn't happen.
  • Small changes cause regressions. And you find out weeks later.

This project treats workstation automation as a software project: shared libraries instead of copy-paste, one documented config precedence order, behavioural tests, and CI on every change.

Why it's engineered, not just scripted

Shared libraries, not copy-paste

Seven libraries in scripts/lib/ — logging.sh, config.sh, errors.sh, filesystem.sh, checks.sh, backup_paths.sh, secrets.sh. Common behaviour lives in one place, which means it's fixed in one place.

One documented configuration precedence

Environment variables beat config/user.conf, which beats config/default.conf.

Environment Variables
        ↓
config/user.conf
        ↓
config/default.conf
Enter fullscreen mode Exit fullscreen mode

You can override anything for one command without editing a tracked file, which is also what makes CI able to test paths your laptop will never take.

CI on macOS and Ubuntu, with pinned tools

Every push and pull request runs Bash syntax validation, ShellCheck, shfmt verification, and the Bats suite — on both macos-latest and ubuntu-latest.

The pinning is the part worth copying. Ubuntu gets exact apt versions (shellcheck=0.9.0-1, shfmt=3.8.0-1, bats=1.10.0-1). Homebrew has no per-version pin, so macOS can't do that — instead it asserts the versions after install and fails if they differ.

Both approaches exist for the same reason: unpinned installs had drifted, so the Ubuntu runner ended up on shfmt 3.8.0 while macOS and developer machines were on 3.14.1. That mismatch caused two red pushes.

Bats tests, and a single command to run them

v2.2.0 ships 187 passing Bats tests covering backup, restore, install, update, uninstall, doctor, cleanup, config, shell quality, and repo sync.

make all      # full quality gate — run this before every commit
make test     # just the Bats suite
make lint     # ShellCheck
Enter fullscreen mode Exit fullscreen mode

v2.2.0: making backups actually verifiable

The headline release was backup/restore integrity, and the theme of it was checks that could not fail. The repo had an assert helper with zero callers, a verification harness invoking a flag that never existed, and a standards document mandating a versioning scheme no script used. A test suite could be comprehensively green while a data-loss bug sat in restore.sh.

So the release went through making each safeguard actually do something:

  • Backups are recorded and verified. Every backup.sh run writes a manifest_<timestamp>.json with the filename, source path, and a SHA-256 of the content. restore.sh verifies a backup against its manifest before overwriting anything — a mismatch names the file, refuses it, and carries on with the rest. --dry-run reports the same verdict, so you can't use a dry run to discover a corrupt backup would have been installed.
  • A manifest can't redirect a write. It's parsed as text, never evaluated; only the digest is read, and the destination always comes from BACKUP_SOURCES. A tampered manifest can make the tool refuse — it cannot make it write somewhere else.
  • A 0-byte backup is refused outright. Checked before the manifest, deliberately: the SHA-256 of empty content is a fixed constant, so a manifest written for an empty backup matches perfectly and the hash check alone could never catch it.
  • Safety copies stop overwriting each other. The pre-restore safety copy is what makes a restore reversible, but its name had one-second resolution. Measured before the fix: one surviving copy in four runs out of five. After: two in five out of five.
  • Backup names are keyed on path, not basename. ~/.ssh/config and a top-level ~/config no longer collide into a single backup file.
  • uninstall.sh validates its target before deleting. It fails closed on /, //, relative paths, and shallow paths like /usr/local. The destructive-path tests resolve everything from their own temp directory, and a logging rm stub asserts no removal was even attempted.

That last one is the lesson I'd keep: a test that runs against your real ~/.ssh is a test that will eventually delete your real ~/.ssh.

doctor.sh as an honest reporter

doctor.sh reports an absent ShellCheck as an incomplete environment, never as a lint failure — so a missing tool can't be mistaken for a code defect. It also checks that the installed VERSION still matches the source. That check exists because the live machine had silently drifted several releases behind source, which nothing else noticed.

Why this might interest you

If you set up macOS for a living, or for yourself, repeatedly. install.sh writes the utilities to INSTALL_DIR (default ~/.local/bin), update.sh reinstalls in place, doctor.sh tells you what it finds. Your workstation becomes something you can rebuild and audit instead of something you remember.

If you're tired of "just shell scripts." Everything here is Bash, but the discipline isn't language-specific. Tests, a lint gate, pinned CI, documented precedence, and a changelog that reflects reality are all transferable to a shell project you're maintaining right now.

If you enjoy the unglamorous middle. v2.2.0 also removed an unused eval-based assert helper, two existence predicates with no call sites, and an entire lib/colors.sh that duplicated what logging.sh already provided. Deleting dead code that only looked load-bearing is most of the job.

Try it

The roadmap is more automation, broader backup support, cross-platform compatibility, better diagnostics, and more tests. If that sounds like your kind of problem:

  • Try it — clone it and run ./scripts/install.sh
  • Star it if it's useful
  • Open an issue — especially if a utility doesn't fit how you actually work
  • Contribute — CONTRIBUTING.md has the workflow; the short version is make all must pass

Everything ships through the same gate as everything else: tests, ShellCheck, shfmt, and CI on two platforms. If it merges, it was verified.


Cover Image Description

This section is not part of the article — it is the prompt for generating the cover image.

A clean, minimalist flat-vector illustration of a macOS developer workstation, viewed straight on. A single modern desktop monitor displays a dark terminal window with crisp green monospace text and a tidy vertical list of shell commands, accented by a small green checkmark badge suggesting a passing test run. Beside the monitor sit a laptop and a coffee cup on a light desk. Muted palette: charcoal, soft white, and a restrained green accent, with generous negative space. Flat geometric shapes, no gradients, no photorealism, no text other than abstract terminal glyphs.

Top comments (0)