Shell Lesson 22 of 42

Packaging Shell Scripts: Shebangs, PATH Discipline, Portability, `make install`, deb/rpm & Homebrew — Ship Scripts Like Real Software

A shell script that lives in ~/bin on one developer’s laptop is not a tool. It’s a script. The transition from “script that works on my machine” to “tool we can deploy” requires:

This lesson is the operator’s manual for that transition. By the end, you’ll be able to release a shell tool that installs cleanly on Debian, Red Hat, macOS, and alpine, with a single command.


1. The shebang — your first portability decision

The first line of every executable script is the shebang (#!). It tells the kernel which interpreter to invoke. The choice has real consequences.

1.1 The four candidates

#!/bin/sh                  # POSIX shell. Often dash on Debian, ash on alpine, zsh on macOS sonoma+.
#!/bin/bash                # bash, fixed path. Reliable on Linux. Doesn't exist on some BSDs.
#!/usr/bin/env bash        # bash, found via PATH. Reliable on macOS (where /bin/bash is 3.2 ancient).
#!/usr/bin/env -S bash -e  # As above, with extra flags (Linux-only env -S).

1.2 The recommended default: #!/usr/bin/env bash

Use this everywhere unless you have a specific reason not to. Why:

The trade-off: env adds a tiny startup cost (one extra exec). For batch scripts this is invisible.

1.3 When #!/bin/sh is correct

If your script is genuinely POSIX-compliant — no arrays, no [[ ]], no $(()) shortcuts beyond POSIX, no local, no <() process substitution — then #!/bin/sh is the most portable choice and runs on the smallest interpreters (dash, ash, busybox sh).

This is rare. Most “shell scripts” use bash-isms within five lines. If you’re unsure: shellcheck -s sh yourscript checks against POSIX. If it complains about anything, you’re using bash, not sh.

1.4 Shebang flags — and why env -S matters

You might want strict mode in the shebang:

#!/bin/bash -Eeuo pipefail              # Linux only — kernel passes the whole thing as one arg.

This does not work with env:

#!/usr/bin/env bash -Eeuo pipefail      # Broken — env interprets "bash -Eeuo pipefail" as one arg

GNU env has a workaround flag, -S, which splits the argument:

#!/usr/bin/env -S bash -Eeuo pipefail   # GNU coreutils 8.30+, Linux only.

But env -S doesn’t exist on macOS or BSD. The cross-platform-safe pattern is to use the simple shebang and put strict mode in the body:

#!/usr/bin/env bash
set -Eeuo pipefail
IFS=$'\n\t'

Always do it this way. It works everywhere and the strict-mode line is more visible to readers anyway.

1.5 Which bash version?

If you require bash 4+ features (associative arrays, mapfile, ${var,,} lowercasing, coproc), you have a problem on macOS where /bin/bash is 3.2.

The fix is one of:

For internal tools at most companies, “users have brew bash” is a fine assumption. For widely-distributed tools, bash 3.2 compat is worth keeping.


2. PATH discipline — the executable’s view of the world

Once your script is installed somewhere, it has to find its dependencies (other scripts, library files, external binaries). This is where most “works locally, breaks in production” bugs come from.

2.1 Setting PATH explicitly

A script should never inherit PATH from the user’s environment for security-critical operations. Set it explicitly at the top:

#!/usr/bin/env bash
set -Eeuo pipefail
export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

This:

For scripts that need user-installed tools (kubectl, aws, terraform), include /usr/local/bin and trust the system installer to put them there. Don’t randomly inherit the user’s PATH.

2.2 Finding the script’s own directory

Many scripts need to source library files relative to their own location. The canonical way:

#!/usr/bin/env bash
set -Eeuo pipefail

SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
LIB_DIR="$SCRIPT_DIR/../lib"
source "$LIB_DIR/log.sh"

Why this dance:

This works for both ./bin/myscript and /usr/local/bin/myscriptSCRIPT_DIR is always the absolute directory of the actual script file.

2.3 The “is the script symlinked?” wrinkle

If your script is symlinked from /usr/local/bin/myscript/opt/myapp/bin/myscript, ${BASH_SOURCE[0]} is /usr/local/bin/myscript (the symlink), not the target. If your lib/ is at /opt/myapp/lib, the relative path is wrong.

The fix uses readlink to resolve symlinks:

SCRIPT_PATH=$(readlink -f "${BASH_SOURCE[0]}" 2>/dev/null || python3 -c "import os; print(os.path.realpath('${BASH_SOURCE[0]}'))")
SCRIPT_DIR=$(dirname "$SCRIPT_PATH")

readlink -f is GNU. BSD’s readlink doesn’t have -f. The fallback to python3 -c "..." is a reliable cross-platform escape hatch — but it adds a Python dependency.

Pragmatic alternative: install your script and its lib in the same prefix and walk relatively from BASH_SOURCE[0]:

/opt/myapp/
├── bin/myscript      # real file, not a symlink
└── lib/log.sh

/usr/local/bin/myscript -> /opt/myapp/bin/myscript     # symlink

If you ${BASH_SOURCE[0]} from the real file, it’s already at /opt/myapp/bin/myscript and ../lib resolves correctly. The fix is to ensure the script discovers itself via its real location, which is exactly what readlink -f does.

For most internal tools, just install non-symlinked. Symlinks are a packaging convenience that introduces this edge case.

2.4 The Filesystem Hierarchy Standard (FHS) layout

When packaging for Linux, follow the Filesystem Hierarchy Standard:

/usr/local/                            # Manually-installed software (the default --prefix)
├── bin/myapp                          # Executables (in PATH by default)
├── sbin/myapp-admin                   # Admin executables (in PATH for root)
├── lib/myapp/                         # Per-app libraries (NOT in PATH)
│   ├── log.sh
│   └── time.sh
├── share/myapp/                       # Static data (templates, fixtures)
│   └── default.conf
├── share/man/man1/myapp.1             # Man pages
└── etc/myapp/                         # Config (overrideable by /etc/myapp/)

/etc/myapp/myapp.conf                  # System-wide config

For per-user installs, mirror this under $HOME/.local/:

$HOME/.local/
├── bin/
├── lib/myapp/
└── share/myapp/

Following FHS makes your tool feel native, plays well with packaging tools, and lets sysadmins find what they expect where they expect it.


3. Portability across GNU vs BSD vs busybox

You’ve seen the GNU vs BSD date distinction in L19. The same gulf exists for almost every common utility. A short list of commands where flag behaviour differs:

Command GNU has BSD lacks (or differs) Workaround
sed -i sed -i 's/a/b/' file sed -i '' 's/a/b/' file perl -i -pe 's/a/b/' is portable
readlink readlink -f No -f (use realpath or python3) See §2.3
stat stat -c %Y file stat -f %m file Wrap
date date -d 'yesterday' date -v -1d Wrap (L19)
ls --color yes yes (different default) ls -G on BSD
tar -xzf yes yes Same
grep -P (PCRE) yes not on macOS Use ERE or install GNU grep
getopt (long opts) yes very old, broken Use getopts (POSIX), see L14
sort -V (version sort) yes yes (recent macOS) Avoid on old BSD
head -c N (bytes) yes yes Same
cp --reflink (copy-on-write) yes (Linux btrfs/xfs/apfs) macOS cp -c Use direct call
mktemp -d yes yes Same — but mktemp -d -t foo.XXXX syntax differs

The two strategies:

  1. Detect and branch: write thin wrappers that pick GNU or BSD flags based on detection (the L19 pattern).

  2. Require GNU: document that the tool requires GNU coreutils. On macOS, this means brew install coreutils and using gsed, gdate, etc.

For tools targeting both platforms, strategy 1 is friendlier. For server-only tools, strategy 2 is fine.

3.1 The “BSD compatibility shim”

If your tool needs to work cross-platform but you don’t want detection clutter throughout the code, write a shim library:

# lib/bsd-compat.sh — set up GNU-like aliases on BSD systems.
# Source this near the top of every script. Falls back to GNU on Linux.

if date --version >/dev/null 2>&1; then
  # GNU; nothing to do.
  :
else
  # BSD-style; install wrappers as functions.

  date_iso_n_days_ago() {
    date -u -v "-${1}d" '+%Y-%m-%d'
  }
  sed_inplace() { sed -i '' "$@"; }
  stat_mtime() { stat -f %m "$1"; }
  readlink_f() {
    perl -MCwd -le 'print Cwd::abs_path(shift)' "$1"
  }
fi

Then use the function names everywhere in your tool. Cross-platform without the if/else everywhere.


4. Version embedding

Every release of a shell tool should know its own version. Three approaches:

4.1 Hardcoded constant

#!/usr/bin/env bash
readonly VERSION="1.4.2"

print_version() {
  printf '%s version %s\n' "$(basename "$0")" "$VERSION"
}

Fine for slow-moving tools. The downside: you have to remember to bump it before releasing.

4.2 Replaced at build time

Maintain a template with a placeholder and have your build replace it:

# bin/myapp.in
#!/usr/bin/env bash
readonly VERSION="@VERSION@"
# Makefile
VERSION := $(shell git describe --tags --dirty --always)

bin/myapp: bin/myapp.in
	sed 's/@VERSION@/$(VERSION)/' < $< > $@
	chmod +x $@

Now make produces bin/myapp with the real version stamped in. The user never edits bin/myapp directly — only bin/myapp.in.

4.3 Read git from runtime

detect_version() {
  if command -v git >/dev/null && [[ -d "$SCRIPT_DIR/../.git" ]]; then
    git -C "$SCRIPT_DIR/.." describe --tags --dirty --always
  else
    echo "${VERSION:-unknown}"
  fi
}

Useful in development; not useful once installed (no .git in /usr/local/bin/). Combine with build-time replacement: dev mode reads git, installed mode reads the baked-in constant.

4.4 The --version flag — non-negotiable

Every CLI tool should support --version:

case ${1:-} in
  -V|--version)
    print_version
    exit 0
    ;;
esac

It’s the first thing users type when trying to debug (“what version do you have?”). Without it, you can’t even diagnose remotely.


5. The Makefile pattern — install, uninstall, test, clean

A Makefile is the lowest-friction way to install/uninstall a shell tool. Every Unix has make; no extra tooling required.

5.1 Minimal install Makefile

PREFIX ?= /usr/local
BINDIR  = $(PREFIX)/bin
LIBDIR  = $(PREFIX)/lib/myapp
SHAREDIR = $(PREFIX)/share/myapp
MANDIR  = $(PREFIX)/share/man/man1

VERSION := $(shell git describe --tags --dirty --always 2>/dev/null || echo dev)

.PHONY: all install uninstall test clean

all: bin/myapp

bin/myapp: bin/myapp.in
	@mkdir -p bin
	sed 's/@VERSION@/$(VERSION)/g' < $< > $@
	chmod +x $@

install: all
	install -d $(DESTDIR)$(BINDIR)
	install -d $(DESTDIR)$(LIBDIR)
	install -d $(DESTDIR)$(SHAREDIR)
	install -d $(DESTDIR)$(MANDIR)
	install -m 0755 bin/myapp     $(DESTDIR)$(BINDIR)/
	install -m 0644 lib/*.sh      $(DESTDIR)$(LIBDIR)/
	install -m 0644 share/*.conf  $(DESTDIR)$(SHAREDIR)/
	install -m 0644 man/myapp.1   $(DESTDIR)$(MANDIR)/

uninstall:
	rm -f  $(DESTDIR)$(BINDIR)/myapp
	rm -rf $(DESTDIR)$(LIBDIR)
	rm -rf $(DESTDIR)$(SHAREDIR)
	rm -f  $(DESTDIR)$(MANDIR)/myapp.1

test:
	bats test/

clean:
	rm -rf bin/myapp

Now:

make                                    # Build (stamp version)
sudo make install                       # System-wide install
sudo make uninstall                     # Reverse it
make install PREFIX=$HOME/.local        # Per-user install
make test                               # Run the test suite

The DESTDIR variable is for packagers: make install DESTDIR=/tmp/pkgroot installs into a staging directory rather than the live filesystem — exactly what dpkg-buildpackage and rpmbuild need.

5.2 The install command vs cp

Use install, not cp. It:

install is the right primitive for installing files. Memorise it.

5.3 What’s in lib/myapp/ after install?

Following the lib pattern from §2.2, your installed script /usr/local/bin/myapp finds its libraries via:

SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
# /usr/local/bin → /usr/local/lib/myapp
LIB_DIR="$SCRIPT_DIR/../lib/myapp"
source "$LIB_DIR/log.sh"

If the user installed to $HOME/.local, SCRIPT_DIR is $HOME/.local/bin and $LIB_DIR is $HOME/.local/lib/myapp. Same code path, both prefixes.


6. deb and rpm packages

For wide distribution on Linux, native packages are the right answer. Users do apt install myapp or dnf install myapp and they’re done.

6.1 The easy way: fpm

fpm (Effing Package Management) wraps deb and rpm building behind a single command. Build once, ship to any Linux:

# Install fpm:
sudo apt install ruby ruby-dev rubygems build-essential
sudo gem install fpm

# Stage the install via DESTDIR:
make install DESTDIR=/tmp/pkgroot PREFIX=/usr

# Build a deb from the staging dir:
fpm \
  -s dir \
  -t deb \
  -n myapp \
  -v "$(git describe --tags --dirty)" \
  --description "My amazing shell tool" \
  --maintainer "you@example.com" \
  --license "MIT" \
  --architecture all \
  --depends bash \
  --depends 'coreutils >= 8.30' \
  --depends jq \
  -C /tmp/pkgroot \
  .

# Build an rpm:
fpm -s dir -t rpm  -n myapp ... -C /tmp/pkgroot .

Output: myapp_1.4.2_all.deb and myapp-1.4.2.noarch.rpm. Distribute these to a repo or directly to users.

6.2 Inside the deb — what fpm produces

$ dpkg -c myapp_1.4.2_all.deb
drwxr-xr-x root/root         0 ./usr/
drwxr-xr-x root/root         0 ./usr/bin/
-rwxr-xr-x root/root      4321 ./usr/bin/myapp
drwxr-xr-x root/root         0 ./usr/lib/
drwxr-xr-x root/root         0 ./usr/lib/myapp/
-rw-r--r-- root/root      2048 ./usr/lib/myapp/log.sh
-rw-r--r-- root/root       512 ./usr/lib/myapp/time.sh
drwxr-xr-x root/root         0 ./usr/share/
drwxr-xr-x root/root         0 ./usr/share/myapp/
-rw-r--r-- root/root      1024 ./usr/share/myapp/default.conf
drwxr-xr-x root/root         0 ./usr/share/man/man1/
-rw-r--r-- root/root      4096 ./usr/share/man/man1/myapp.1

A clean FHS layout, dependencies declared so apt resolves them, and version embedded so apt list myapp shows 1.4.2.

6.3 Pre/post-install scripts

For tools that need setup beyond just dropping files (creating users, enabling services, migrating config), fpm accepts hook scripts:

fpm -s dir -t deb \
  --before-install ./packaging/preinst.sh \
  --after-install  ./packaging/postinst.sh \
  --before-remove  ./packaging/prerm.sh \
  --after-remove   ./packaging/postrm.sh \
  ...

Example postinst.sh:

#!/bin/sh
set -e
# Create system user if it doesn't exist.
if ! getent passwd myapp >/dev/null; then
  useradd --system --shell /usr/sbin/nologin --home-dir /var/lib/myapp myapp
fi
# Enable the systemd timer.
systemctl daemon-reload || true
systemctl enable --now myapp.timer || true

Use these sparingly. Every hook is a debugging surface. For most tools, no hooks are needed — just files in their FHS locations.

6.4 apt repository or releases?

Two distribution models:

  1. GitHub releases: upload the .deb/.rpm to a release. Users wget and dpkg -i. Simple, no infrastructure.

  2. APT/YUM repository: host the packages with proper metadata so users apt install myapp after adding your repo. Requires GPG signing, apt-get update integration. More work but smoother UX.

For internal tools, releases are fine. For external/community tools, run a real repo (or use a service like packagecloud.io / Cloudsmith / Gemfury).


7. Homebrew — packaging for macOS

For macOS users, Homebrew is the de facto package manager. A formula is a Ruby file:

# my-tap/Formula/myapp.rb
class Myapp < Formula
  desc "My amazing shell tool"
  homepage "https://github.com/me/myapp"
  url "https://github.com/me/myapp/archive/v1.4.2.tar.gz"
  sha256 "abc123def456..."
  license "MIT"

  depends_on "bash"
  depends_on "coreutils"
  depends_on "jq"

  def install
    system "make", "install", "PREFIX=#{prefix}"
  end

  test do
    assert_match "version 1.4.2", shell_output("#{bin}/myapp --version")
  end
end

Now users add your tap and install:

brew tap me/tap
brew install myapp

Or, if you publish to homebrew-core (after acceptance), they just do brew install myapp. Getting accepted into homebrew-core requires real popularity (>30 GitHub stars and >50 forks at last reading); for early-stage tools, run your own tap.

7.1 Auto-bumping the formula on release

The brew bump-formula-pr command updates the URL and sha256 from a new tarball:

brew bump-formula-pr myapp \
  --url "https://github.com/me/myapp/archive/v1.5.0.tar.gz"

Hook this into your release workflow (GitHub Actions can run it on a tag push) and your Homebrew users get updates automatically.


8. Single-file distribution — when to use, when not to

A common request: “can the whole thing be a single file?” Yes — concatenate all the lib/*.sh files into the script:

# scripts/build-single-file.sh
#!/usr/bin/env bash
set -euo pipefail

OUT=dist/myapp
mkdir -p dist

cat > "$OUT" <<'HEADER'
#!/usr/bin/env bash
set -Eeuo pipefail
HEADER

# Embed all libs (in dependency order):
cat lib/log.sh      >> "$OUT"
cat lib/time.sh     >> "$OUT"
cat lib/http.sh     >> "$OUT"

# Strip shebang and main from bin/myapp, append:
tail -n +2 bin/myapp >> "$OUT"      # skip the #!/usr/bin/env bash line in bin/myapp

chmod +x "$OUT"

Now dist/myapp is a single self-contained executable. Drop it anywhere. curl -L https://example.com/myapp -o /usr/local/bin/myapp && chmod +x is the install command.

8.1 Pros and cons

Single-file pros:

Single-file cons:

For small utilities (~ 500 lines total), single-file is great. For real tools, prefer the FHS layout.

8.2 The “self-extracting installer” trick

A middle ground: ship a single file that, when run with --install, expands itself into FHS layout:

#!/usr/bin/env bash
# Self-extracting installer for myapp.

case ${1:-} in
  --install)
    PREFIX=${PREFIX:-/usr/local}
    cd "$(dirname "$0")"
    # Extract the embedded tarball at the marker:
    sed -e '1,/^__ARCHIVE__$/d' "$0" | tar xz -C "$PREFIX"
    echo "Installed to $PREFIX"
    exit 0
    ;;
  *)
    echo "Usage: $0 --install [PREFIX=/usr/local]" >&2
    exit 1
    ;;
esac

__ARCHIVE__
<binary tar.gz appended after this line by the build>

Pattern is fancy but rarely necessary unless you have constrained-network installs.


9. Shell completion — the polish that users notice

Bash, zsh, and fish each have their own completion formats. The minimum: a bash completion that tab-completes flags and subcommands.

# share/myapp/completions/myapp.bash
_myapp() {
  local cur prev
  cur="${COMP_WORDS[COMP_CWORD]}"
  prev="${COMP_WORDS[COMP_CWORD-1]}"

  case ${prev} in
    --config)
      COMPREPLY=( $(compgen -f -- "$cur") )
      return 0
      ;;
    --user)
      COMPREPLY=( $(compgen -W "alice bob carol" -- "$cur") )
      return 0
      ;;
  esac

  if [[ ${cur} == -* ]]; then
    COMPREPLY=( $(compgen -W "--help --version --config --user --verbose" -- "$cur") )
    return 0
  fi

  # Subcommands:
  COMPREPLY=( $(compgen -W "deploy rollback status logs" -- "$cur") )
}
complete -F _myapp myapp

Install this to /usr/share/bash-completion/completions/myapp (system-wide) or $HOME/.local/share/bash-completion/completions/myapp (per-user). Both are picked up by bash-completion automatically.

In your Makefile:

COMPDIR = $(PREFIX)/share/bash-completion/completions

install: all
	...
	install -m 0644 share/myapp/completions/myapp.bash $(DESTDIR)$(COMPDIR)/myapp

For zsh and fish, add separate files. argc and complgen are tools that generate completions for all three shells from one DSL — useful if you have many subcommands and don’t want to write three completion files by hand.


10. Man pages

A man page is a 30-minute investment that pays off forever. The format:

.TH MYAPP 1 "2024-03-10" "myapp 1.4.2" "User Commands"
.SH NAME
myapp \- amazing shell tool
.SH SYNOPSIS
.B myapp
.RI [ options ]
.I command
.SH DESCRIPTION
\fBmyapp\fR does X, Y, and Z.
.SH OPTIONS
.TP
.B \-V, \-\-version
Print version and exit.
.TP
.BI \-c " FILE\fR, " "\-\-config" " FILE"
Path to config file.
.SH ENVIRONMENT
.TP
.B MYAPP_HOME
Base directory for state files.
.SH FILES
.TP
.I /etc/myapp/myapp.conf
System-wide configuration.
.SH SEE ALSO
.BR cron (8),
.BR systemd.timer (5)
.SH AUTHOR
You <you@example.com>

Save as man/myapp.1. Install to $PREFIX/share/man/man1/myapp.1. The mandb cron job picks it up; man myapp works.

If hand-writing roff is intolerable (it is), use pandoc to convert from Markdown:

pandoc -s -t man docs/myapp.md -o man/myapp.1
# docs/myapp.md
% MYAPP(1) myapp 1.4.2 | User Commands
% You
% March 2024

# NAME

myapp - amazing shell tool

# SYNOPSIS

**myapp** [_options_] _command_

# DESCRIPTION

**myapp** does X, Y, and Z.

# OPTIONS

**-V**, **--version**
:   Print version and exit.

Pandoc’s man-page mode is the right ergonomics: write Markdown, get a real man page.


11. Config file convention

For tools that need configuration, the convention is:

  1. Defaults baked into the script.
  2. System-wide config at /etc/myapp/myapp.conf overrides defaults.
  3. User config at ~/.config/myapp/config overrides system.
  4. Environment variables override config.
  5. Command-line flags override everything.

In code:

# Defaults:
CONFIG_LEVEL=info
CONFIG_HOST=localhost

# System-wide:
[[ -f /etc/myapp/myapp.conf ]] && source /etc/myapp/myapp.conf

# User:
[[ -f "${XDG_CONFIG_HOME:-$HOME/.config}/myapp/config" ]] && \
  source "${XDG_CONFIG_HOME:-$HOME/.config}/myapp/config"

# Env vars:
CONFIG_LEVEL="${MYAPP_LOG_LEVEL:-$CONFIG_LEVEL}"
CONFIG_HOST="${MYAPP_HOST:-$CONFIG_HOST}"

# Flags processed by getopts (overrides above, see L14).

This 5-level cascade is the universal convention in Unix tools. Users expect it. Following it makes your tool feel native; ignoring it makes it feel weird.

XDG paths: $XDG_CONFIG_HOME defaults to $HOME/.config. Honor it — users who relocate their config dir expect tools to follow.


12. The release checklist

Before tagging v1.0:

☐ Shebang: #!/usr/bin/env bash, strict mode in body
☐ Version embedded at build time, --version flag works
☐ shellcheck clean on bin/* and lib/*.sh (no warnings)
☐ bats test suite passes (parallel, multiple OS in CI)
☐ Code coverage measured (kcov)
☐ Cross-platform tested: Linux + macOS + alpine if applicable
☐ FHS layout: bin/, lib/myapp/, share/myapp/, man/man1/
☐ Makefile with: all, install, uninstall, test, clean targets
☐ DESTDIR support in install target (for packagers)
☐ deb and rpm built via fpm
☐ Homebrew formula in tap (or PR to homebrew-core)
☐ Bash completion for flags and subcommands
☐ Man page (myapp.1) checked-in and installed
☐ Config-file cascade: defaults → /etc → $HOME/.config → env → flags
☐ README with: install instructions, quickstart, common operations
☐ CHANGELOG with semver-tagged entries
☐ LICENSE file (MIT, Apache-2.0, GPL — pick one and stick to it)
☐ CI runs: shellcheck, bats, kcov, build .deb, build .rpm, build single-file
☐ Tags pushed: vX.Y.Z annotated tags with `git tag -s` (signed)

That’s the full template. Few projects do all of these on day one — but checking them off over time turns a “script someone wrote” into “a shell tool that’s actually adopted.”


13. End-to-end: a real packaged shell tool

Pulling it all together, a real project’s tree looks like this:

myapp/
├── README.md
├── LICENSE
├── CHANGELOG.md
├── Makefile
├── bin/
│   ├── myapp.in                       # template with @VERSION@
│   └── myapp                          # generated by `make`
├── lib/
│   ├── log.sh
│   ├── time.sh
│   ├── http.sh
│   └── bsd-compat.sh
├── share/
│   ├── default.conf
│   └── completions/
│       ├── myapp.bash
│       ├── myapp.zsh
│       └── myapp.fish
├── docs/
│   └── myapp.md                       # pandoc source for man page
├── man/
│   └── myapp.1                        # generated by `make`
├── packaging/
│   ├── postinst.sh
│   └── homebrew-formula.rb
├── test/
│   ├── lib/
│   │   ├── log.bats
│   │   ├── time.bats
│   │   └── http.bats
│   ├── integration/
│   │   └── myapp.bats
│   ├── fixtures/
│   └── test_helper/
│       ├── bats-support/
│       ├── bats-assert/
│       └── mock.sh
├── .github/workflows/
│   ├── ci.yml                         # shellcheck + bats + kcov
│   └── release.yml                    # build .deb/.rpm + bump homebrew
├── .gitignore
└── shellcheckrc

Build commands a maintainer types:

make                                    # Build (just stamps version)
make test                               # Run bats tests
make install PREFIX=$HOME/.local        # Install for self-testing
git tag -s v1.4.2 -m 'release v1.4.2'
git push origin v1.4.2                  # Triggers release workflow

Install commands a user types:

# Linux:
curl -L https://github.com/me/myapp/releases/v1.4.2/myapp_1.4.2_all.deb -o /tmp/myapp.deb
sudo dpkg -i /tmp/myapp.deb

# macOS:
brew install me/tap/myapp

# Source build:
git clone https://github.com/me/myapp; cd myapp
make && sudo make install

That’s the full life-cycle from “script in ~/bin” to “tool I distribute.”


14. Quick reference card

Shebang

#!/usr/bin/env bash
set -Eeuo pipefail
IFS=$'\n\t'

Find the script’s directory

SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
LIB_DIR="$SCRIPT_DIR/../lib/myapp"
source "$LIB_DIR/log.sh"

FHS layout

$PREFIX/bin/myapp
$PREFIX/lib/myapp/{log.sh,time.sh}
$PREFIX/share/myapp/default.conf
$PREFIX/share/man/man1/myapp.1
$PREFIX/share/bash-completion/completions/myapp

Makefile core

PREFIX ?= /usr/local
install: all
	install -d $(DESTDIR)$(PREFIX)/bin
	install -m 0755 bin/myapp $(DESTDIR)$(PREFIX)/bin/

fpm one-liner

fpm -s dir -t deb -n myapp -v 1.4.2 -C /tmp/pkgroot \
  --depends bash --depends jq .

The 7 must-haves to ship

  1. #!/usr/bin/env bash + strict mode
  2. --version flag
  3. shellcheck-clean
  4. bats test suite
  5. Makefile with install/uninstall
  6. FHS layout (bin/, lib/, share/, man/)
  7. README with install instructions

15. Wrap-up & Tier 3 conclusion

This is the last lesson of Wave 2 (Tier 3 Advanced). Over the past 10 lessons we’ve built up the skills that distinguish a script from a real shell tool:

After Tier 3, you should be able to take a working shell idea and turn it into a tool that:

That’s the Tier 3 Advanced bar. It’s where the real difference between “I write shell” and “I’m a shell engineer” shows up — not in clever one-liners, but in the discipline of treating a .sh file as production code.

Next: Tier 4 Expert (Wave 3). We’ll cover advanced subjects: writing your own shell-callable services, performance profiling under bash, integrating with systemd at the unit-file level, building reusable bash libraries (bash_library_*), debugging production outages from postmortems, and the subtleties of process groups, sessions, and PID-1 patterns in containers. Tier 4 is where shell becomes a real systems-programming tool.

shellbashpackagingmakedebrpmhomebrewfpmportabilityrelease
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments