Skip to content

Script: Build and Deploy

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:

  1. Verify prerequisites — bail out early if make or rsync isn’t on the path.
  2. Build — turn source code into something deployable.
  3. Test — run the suite. Refuse to deploy if anything fails.
  4. 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.

Terminal window
lash --test deploy.lash # the unittests only
lash deploy.lash --help # auto-generated help

The --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 endpoint
unittest {
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 pause
unittest {
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.


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.


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.


#!/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 endpoint
unittest {
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 pause
unittest {
is_production("production").must.equal(true)
is_production("staging").must.equal(false)
is_production("dev").must.equal(false)
}

Run it:

Terminal window
lash --test deploy.lash # the unittests
lash deploy.lash --help # help
lash deploy.lash staging # build, test, deploy to staging
lash deploy.lash production true # production, skipping tests
lash deploy.lash staging false 8 # 8 parallel build jobs

The doc-comment plus typed parameters become the --help:

Terminal window
lash deploy.lash --help
deploy.lash — Build, test, and deploy a project.
Arguments:
target (string)
skip_tests (bool) default: false
jobs (int) default: 4

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.