Script: Build and Deploy
What’s a deploy script?
Section titled “What’s a deploy script?”A deploy script is a recipe you can re-run end-to-end: build the artifact, run the test suite, push the result to a server. The whole point is repeatability — the steps in your head become the steps in a file, so the next person (including yourself in three months) doesn’t have to remember them.
The shape we’ll use is the same one most teams settle on:
- Verify prerequisites — bail out early if
makeorrsyncisn’t on the path. - Build — turn source code into something deployable.
- Test — run the suite. Refuse to deploy if anything fails.
- Deploy — push the artifact to the right server.
Each step that fails should explain why it failed and stop the script. Deployment scripts that silently continue past a failure are how you end up shipping broken code to production.
Build deploy.lash. Given a target (staging or production), build
the project, run tests, and deploy.
lash --test deploy.lash # the unittests onlylash deploy.lash --help # auto-generated helpThe --test flag runs only the unittest { } blocks; the fn main
body is skipped, so the tests don’t need to actually build or deploy
anything.
Step 1: Decide where each target deploys to
Section titled “Step 1: Decide where each target deploys to”The target → server mapping is a pure function. Test it before
writing it:
/// server_for routes a target name to its deployment SSH endpointunittest { server_for("staging").must.equal("deploy@staging.example.com:/opt/app") server_for("production").must.equal("deploy@prod.example.com:/opt/app") server_for("unknown").must.equal("")}The third row is the safety net: an unrecognised target must return a
sentinel (empty string), not crash, so main can detect it and fail
loudly. Run lash --test deploy.lash; it fails (no server_for yet).
Now write it:
fn server_for(target) { return match target { "staging" => "deploy@staging.example.com:/opt/app" "production" => "deploy@prod.example.com:/opt/app" _ => "" }}match is an expression — every arm produces a value, including the
wildcard _ arm.
Step 2: Decide whether a target is production
Section titled “Step 2: Decide whether a target is production”Production deploys deserve an extra guardrail (the 5-second pause before pushing). That’s another small decision worth pinning down:
/// is_production indicates whether the target requires a confirmation pauseunittest { is_production("production").must.equal(true) is_production("staging").must.equal(false) is_production("dev").must.equal(false)}fn is_production(target) { return target == "production"}A one-line function with three test rows feels like overkill — until
someone proposes prod as an alias and the test forces a deliberate
decision rather than a silent change.
Step 3: A reusable prerequisite check
Section titled “Step 3: A reusable prerequisite check”The “is this binary on my path” pattern shows up several times in deploy scripts. Pull it out into a helper:
fn require(cmd) { let check = `which $cmd`.capture if check.isFailure { exit "'$cmd' is required but not installed" }}We don’t unittest require — it shells out to which and calls
exit. The behaviour is “obviously correct” enough that a test would
just be re-implementing it. Test the decisions, not the I/O glue.
Step 4: Wire it into a main
Section titled “Step 4: Wire it into a main”fn main(target: string, skip_tests: bool = false, jobs: int = 4) { let server = server_for(target) if server == "" { exit "unknown target: $target (use 'staging' or 'production')" }
require("make") require("rsync")
echo "Building with $jobs parallel jobs..." let build = `make -j$jobs`.capture if build.isFailure { echo "Build failed:" echo build["stderr"] exit 1 } echo "Build succeeded."
if skip_tests { echo "Skipping tests (skip_tests=true)" } else { echo "Running tests..." let result = `make test`.capture if result.isFailure { echo "Tests failed:" echo result["stderr"] exit "fix failing tests before deploying" } echo "Tests passed." }
if is_production(target) { echo "" echo "WARNING: deploying to PRODUCTION" echo "Press Ctrl+C to abort, or wait 5 seconds..." sleep 5 }
echo "Deploying to $target..." rsync -az --delete ./build/ $server echo "Deployed to $target."}Notice the validation order: target check first (so a typo’d target
fails before we waste time building), prerequisites next (so a missing
binary fails before tests run), then build → test → deploy. Each step
either succeeds or exits with a useful message.
Complete script
Section titled “Complete script”#!/usr/bin/env lash
/// Build, test, and deploy a project.fn main(target: string, skip_tests: bool = false, jobs: int = 4) { let server = server_for(target) if server == "" { exit "unknown target: $target (use 'staging' or 'production')" }
require("make") require("rsync")
echo "Building with $jobs parallel jobs..." let build = `make -j$jobs`.capture if build.isFailure { echo "Build failed:" echo build["stderr"] exit 1 } echo "Build succeeded."
if skip_tests { echo "Skipping tests (skip_tests=true)" } else { echo "Running tests..." let result = `make test`.capture if result.isFailure { echo "Tests failed:" echo result["stderr"] exit "fix failing tests before deploying" } echo "Tests passed." }
if is_production(target) { echo "" echo "WARNING: deploying to PRODUCTION" echo "Press Ctrl+C to abort, or wait 5 seconds..." sleep 5 }
echo "Deploying to $target..." rsync -az --delete ./build/ $server echo "Deployed to $target."}
fn server_for(target) { return match target { "staging" => "deploy@staging.example.com:/opt/app" "production" => "deploy@prod.example.com:/opt/app" _ => "" }}
fn is_production(target) { return target == "production"}
fn require(cmd) { let check = `which $cmd`.capture if check.isFailure { exit "'$cmd' is required but not installed" }}
/// server_for routes a target name to its deployment SSH endpointunittest { server_for("staging").must.equal("deploy@staging.example.com:/opt/app") server_for("production").must.equal("deploy@prod.example.com:/opt/app") server_for("unknown").must.equal("")}
/// is_production indicates whether the target requires a confirmation pauseunittest { is_production("production").must.equal(true) is_production("staging").must.equal(false) is_production("dev").must.equal(false)}Run it:
lash --test deploy.lash # the unittestslash deploy.lash --help # helplash deploy.lash staging # build, test, deploy to staginglash deploy.lash production true # production, skipping testslash deploy.lash staging false 8 # 8 parallel build jobsThe doc-comment plus typed parameters become the --help:
lash deploy.lash --helpdeploy.lash — Build, test, and deploy a project.
Arguments: target (string) skip_tests (bool) default: false jobs (int) default: 4Further reading
Section titled “Further reading”Constraining the target argument: for a fixed list of valid targets with tab-completion support, declare a user-defined type:
type DeployTarget { values(query) { return ["staging", "production"] }}
fn main(target: DeployTarget, skip_tests: bool = false, jobs: int = 4) { ... }Now lash deploy.lash test-server is rejected with 'test-server' is not a valid DeployTarget before the script runs, and the user-defined
type drives completion in the interactive shell.