Skip to content
shellhacks.dev
Tooling

How to Publish a Shell Tool via Homebrew or apt Step by Step

Publish a shell tool for Homebrew and apt: a release tarball, a formula in a tap, a Debian package with dpkg-deb, checksums and an apt repository.

7 min read
Summarize in AI
Illustration for "How to Publish a Shell Tool via Homebrew or apt Step by Step": a terminal session, gears, file permission bits.

People will not copy a script into /usr/local/bin by hand more than once. A package manager gives them installation, upgrades and removal. For a shell tool, the work to support Homebrew and apt is small and the payoff is large. The steps start with a good release, as in GitHub Releases for distribution, and a good manual, see man pages for scripts. The examples assume Homebrew 4.4 and Debian 13 tooling.

Step 0: a release tarball and checksum

Shell
git tag -a v1.4.0 -m "backup 1.4.0"
git archive --format=tar.gz --prefix=backup-1.4.0/ -o backup-1.4.0.tar.gz v1.4.0
sha256sum backup-1.4.0.tar.gz | tee backup-1.4.0.tar.gz.sha256
gh release create v1.4.0 backup-1.4.0.tar.gz backup-1.4.0.tar.gz.sha256 --notes "Release 1.4.0"
Text
3f1a2b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708  backup-1.4.0.tar.gz

Both package types install from a fixed, checksummed artifact. A tag plus git archive produces a reproducible tarball. Make the project installable with make install PREFIX=/usr/local DESTDIR=... first; packages call it. The checksum shown is a placeholder.

Homebrew: a tap and a formula

Text
class Backup < Formula
  desc "Archive a directory with a timestamp"
  homepage "https://github.com/example/backup"
  url "https://github.com/example/backup/releases/download/v1.4.0/backup-1.4.0.tar.gz"
  sha256 "3f1a2b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708"
  license "MIT"

  depends_on "bash"

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

  test do
    assert_match "backup 1.4.0", shell_output("#{bin}/backup --version")
  end
end
Shell
brew tap-new example/tap
cp backup.rb "$(brew --repository example/tap)/Formula/backup.rb"
brew install --build-from-source example/tap/backup
brew test example/tap/backup
brew audit --strict --new example/tap/backup

A tap is a Git repository named homebrew-tap containing a Formula directory. Users run brew install example/tap/backup, which adds the tap on first use. The formula states the URL and hash, dependencies (macOS ships Bash 3.2, so a script needing Bash 4 depends on bash and should use #!/usr/bin/env bash), the install steps and a test. brew audit catches style problems. Submitting to homebrew-core has stricter requirements; a tap is the easy start. Formulas are Ruby, shown here as text.

apt: build a .deb

Shell
mkdir -p pkg/backup_1.4.0_all/DEBIAN
make install DESTDIR="$PWD/pkg/backup_1.4.0_all" PREFIX=/usr
cat > pkg/backup_1.4.0_all/DEBIAN/control <<'EOF'
Package: backup
Version: 1.4.0
Section: utils
Priority: optional
Architecture: all
Depends: bash (>= 5.0), tar
Maintainer: Example Team <[email protected]>
Description: Archive a directory with a timestamp
 Creates compressed archives and prunes old ones.
EOF
dpkg-deb --build --root-owner-group pkg/backup_1.4.0_all
lintian backup_1.4.0_all.deb
sudo apt install ./backup_1.4.0_all.deb

A Debian package is an archive with a DEBIAN/control file and the files laid out under their install paths. Architecture: all marks it as architecture independent, right for shell scripts. lintian checks policy compliance, and a first package usually gets warnings about the man page, copyright file and changelog; fix them if you intend to distribute widely. For more complex packages, debhelper or fpm automate the work.

An apt repository

Shell
reprepro -b repo includedeb stable backup_1.4.0_all.deb
gpg --armor --export [email protected] | sudo tee /etc/apt/keyrings/example.asc
echo "deb [signed-by=/etc/apt/keyrings/example.asc] https://apt.example.test stable main" | sudo tee /etc/apt/sources.list.d/example.list
sudo apt update && sudo apt install backup

A repository is a directory tree with package files and signed metadata, served over HTTPS from any static host. reprepro or aptly builds it. Users add your signing key with signed-by in the source entry rather than the global keyring, which limits what the key can sign for. Hosting a repository means keeping the signing key safe and the metadata fresh. For a small tool, publishing the .deb on the release page and letting users run sudo apt install ./file.deb is much less work.

Test on clean systems

Shell
docker run --rm -v "$PWD":/pkg debian:13-slim sh -c 'apt-get update -qq && apt-get install -y -qq /pkg/backup_1.4.0_all.deb && backup --version'
docker run --rm -v "$PWD":/pkg ubuntu:24.04 sh -c 'apt-get update -qq && apt-get install -y -qq /pkg/backup_1.4.0_all.deb && backup --version'

Install in a clean container to catch undeclared dependencies. Test the upgrade path (install the old version, then the new) and removal. Verify what the package ships with dpkg -c, and see verifying downloads for what users should check.

Deciding whether packaging is worth it

Packaging is a commitment to maintain a release process. Every package manager entry is a place where users expect updates, security fixes and honest metadata. For a script that three colleagues use, a release tarball and an install line in the README are enough. For a tool with a public audience, packaging lowers the barrier to adoption more than almost any feature, because people judge a tool by whether it installs in one command. The question to ask is not whether you can package it, but whether you will release regularly enough to keep the packages from rotting.

Start with the one package manager most of your users have. If your audience is on macOS, a Homebrew tap covers them. If it is on Debian-based servers, a .deb attached to the release, with an optional repository, covers theirs. Adding a second and third target later costs little when the build already produces a clean install tree.

Designing the tool to be packageable

A tool is easy to package when it follows conventions. Install files under a prefix and respect DESTDIR, so a packager can stage the files without touching the real system. Do not write to its own installation directory at runtime; configuration goes to /etc or a user config directory, and state to /var or the user's data directory, following the XDG conventions. Read the version from one source, and print it with --version, since packages need to agree with the tool about it. Declare runtime dependencies explicitly: if the script calls jq, the package should depend on it, and the script should check for it with a clear message.

Avoid installing hooks that run arbitrary commands at install time. A package whose post-install script downloads something from the internet is hard to audit and impossible to install offline. Keep installation to copying files, and let the first run do any setup, with a message that says what it did.

Releases, signing and trust

Users who install your package are trusting your release process. Sign tags and release artifacts, publish the checksums where they can be compared with the download, and say in the README how to verify them. For an apt repository, the signing key is the root of trust, so keep it offline or in a hardware token, use a subkey for routine signing and document how a key rotation will be announced. A compromised repository key is a compromise of every installed system, which makes this the most sensitive secret in the project.

Automate the release. A workflow that builds the archive, the package and the checksums from a tag, runs the installation test in a clean container and uploads the results removes the manual steps where mistakes occur. It also makes a fix release fast, which matters on the day a security problem appears.

Keeping packages current

Package metadata goes stale in quiet ways. A Homebrew formula points at an old URL after a repository is renamed, and a Debian package keeps a dependency on a library that no longer exists. Add a CI job that installs the latest package in a clean container every week and runs the tool's self-test, so rot is found by a job instead of by a user. Respond to packaging issues quickly, and retire a package explicitly, with a final release that prints a notice, rather than leaving it to decay.

Where this does not apply

RPM for Fedora and Red Hat, Arch's PKGBUILD, Nix and Alpine's APKBUILD are additional targets with a similar shape; add them on demand. If the tool is a single file, curl to /usr/local/bin with a checksum in the README is acceptable but offers no upgrades. A tool that needs root to install should say so. See the Homebrew tap documentation and the Debian New Maintainers' Guide.

Share

Keep reading

Get new posts by email

One email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.