11 KiB
Build system architecture
How Slopsmith Desktop is built — the flow of scripts, config, and packaging across local dev and CI.
Philosophy
- npm scripts are the API. Every build step is callable via
npm run <name>. CI, local dev, and the DevContainer all use the same entry points. - Shell scripts implement. Complex bundling lives in
scripts/bundle-*.sh; npm just invokes them. - Configuration is centralized. Tool versions + external pins
live in
.build-config.json; per-OS system deps in.packages/. - Reproducibility is pinned, not floating. External dependencies (FluidR3/GeneralUser soundfonts, fluidsynth Windows zip) are tied to specific commits or SHA256s; CI and local see the same bytes.
High-level flow
┌─────────────────────────────────────────────────────────────────────┐
│ Build environments │
│ │
│ GitHub Actions DevContainer (Docker) Developer CLI │
│ │ │ │ │
│ └─────────────────────┼───────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ npm scripts │ │
│ │ (package.json) │ ← single-source entry │
│ └─────────────────────┘ │
│ │ │
│ ┌──────────────────┼──────────────────┐ │
│ ▼ ▼ ▼ │
│ ┌────────────┐ ┌──────────────┐ ┌────────────┐ │
│ │build:native│ │ bundle │ │ build:ts │ │
│ │ │ │ │ │ │ │
│ │• build:audio│ │• bundle: │ │ (tsc) │ │
│ │ │ │ slopsmith │ │ │ │
│ └────────────┘ │• bundle: │ └────────────┘ │
│ │ python │ │
│ │• bundle: │ │
│ │ binaries │ │
│ │• bundle: │ │
│ │ soundfont │ │
│ └──────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ electron-builder │ │
│ │ (dist:linux etc.) │ │
│ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ Configuration files │
│ │
│ .build-config.json .packages/ │
│ ├── versions ├── apt.txt (Ubuntu/Debian) │
│ │ ├── node ├── brew.txt (macOS) │
│ │ ├── python ├── choco.txt (Windows) │
│ │ ├── electron └── README.md │
│ │ ├── cmake │
│ │ └── ubuntu │
│ └── external │
│ ├── fluidsynth_windows (version + URL) │
│ └── soundfont_general_user (URL + SHA256) │
└─────────────────────────────────────────────────────────────────────┘
Detailed flow for npm run dist:linux
npm run dist:linux
└── npm run dist -- --linux
└── npm run bundle && npm run build:ts && electron-builder --linux
└── bundle
├── bundle-slopsmith.sh — server.py + lib + static + plugins
├── bundle-python.sh — portable Python 3.12 runtime + pip + app deps
├── bundle-binaries.sh — ffmpeg + vgmstream-cli + fluidsynth + its .so chain
└── bundle-soundfont.sh — GeneralUser-GS.sf2 (SHA256-verified)
└── build:ts
└── tsc
└── electron-builder
└── AppImage + .deb
For build:native (run separately, not part of dist):
npm run build:native
└── build:audio — cmake-js builds the JUCE C++ native addon
Scripts directory
scripts/
├── bundle.sh — top-level Linux delegator (→ bundle-*.sh)
├── bundle-slopsmith.sh — copy server + plugins
├── bundle-python.sh — portable Python runtime (Linux)
├── bundle-binaries.sh — ffmpeg + vgmstream + fluidsynth + libs (Linux)
├── bundle-soundfont.sh — GeneralUser-GS.sf2 download + verify (cross-platform)
├── build-linux-release.sh — Docker wrapper for reproducible Linux AppImage
├── parse-build-config.py — JSON value extractor
└── setup-dev.sh — local prerequisite checker
Why modular
- Composability: debug or re-run a single step (
npm run bundle:python) - Readability: each script does one thing and has a single reason to change
- CI/local parity: CI calls the same scripts where platform-independent; platform-specific inline bash stays in
build.ymlfor macOS/Windows (dylibbundler, zip downloads) only because those tools don't fit the Linux shell-script model
Platform scope per script
| Script | Linux | macOS | Windows |
|---|---|---|---|
bundle.sh |
yes | n/a | n/a |
bundle-slopsmith.sh |
yes | yes (untested) | yes (untested) |
bundle-python.sh |
yes | no (CI inline) | no (CI inline) |
bundle-binaries.sh |
yes | no (CI uses dylibbundler inline) | no (CI downloads zips) |
bundle-soundfont.sh |
yes | yes | yes |
Configuration
.build-config.json
Tool versions pinned once, read by the CI workflow (via node -p) and
by the DevContainer (via parse-build-config.py). External pins
(soundfont SHA256, fluidsynth Windows zip URL) live under external.*.
Update tool versions in one place; every consumer picks them up.
.packages/
Platform system-dep lists. One package per line; # comments and
blank lines are filtered by consumers. See .packages/README.md for
manual install snippets.
Electron version
One source of truth: devDependencies.electron in package.json.
build:audio derives --runtime-version via
node -p "require('electron/package.json').version" so cmake-js
rebuilds the native addon against the currently-installed Electron.
CI vs local parity
| Aspect | GitHub Actions | DevContainer |
|---|---|---|
| Entry | npm run dist:linux (via checkout + setup-* + install) |
npm run dist:linux (inside container) |
| Node / Python | From .build-config.json |
From .build-config.json |
| System deps | .packages/apt.txt |
.packages/apt.txt |
| Slopsmith source | Cloned fresh to $RUNNER_TEMP |
Bind-mounted from ../slopsmith |
| Python bundle | scripts/bundle-python.sh |
scripts/bundle-python.sh |
| Binary bundle | Inline bash (ffmpeg / vgmstream / fluidsynth lib chain) | scripts/bundle-binaries.sh (invoked by npm run bundle) |
| Soundfont bundle | scripts/bundle-soundfont.sh |
scripts/bundle-soundfont.sh |
| Smoke test | fluidsynth --version + ffmpeg -version + vgmstream-cli --help |
(run manually) |
Adding a new build step
- Create a script in
scripts/(e.g.bundle-foo.sh) - Register an npm script in
package.json(e.g."bundle:foo": "bash scripts/bundle-foo.sh") - Wire into the appropriate chain (
bundle,build:native, etc.) - Test locally:
npm run bundle:foo - Open a PR — CI consumes the same npm scripts
Troubleshooting
Run individual steps
npm run bundle:python # rebuild just the Python runtime
npm run bundle:binaries # rebuild just the binary chain
npm run bundle:soundfont # re-verify the soundfont
Read the current config
python3 scripts/parse-build-config.py .build-config.json
python3 scripts/parse-build-config.py .build-config.json .versions.electron
Local vs CI divergence
If local and CI builds differ:
.build-config.json— pinned versions identical?.packages/apt.txt— are you running in the DevContainer?- The CI workflow inline-bundles some things differently on macOS/Windows (dylibbundler, zip downloads). Local linux dev won't reproduce those.