Skip to content

build.sh Reference

packages/debian-ubuntu/build.sh turns a package directory into an installable .deb.

It supports two ways of assembling a package behind one interface — a hand-maintained DEBIAN/control tree, or a declarative YAML manifest — so callers, including CI, invoke it identically either way and do not need to know which style a package uses. It also enforces that the version in the manifest matches what the caller expects, which is what stops a mistagged release from reaching the APT repository.

The script builds only. It does not upload, install, or touch any host. Publishing is upload.sh, invoked separately by CI.

Related: packages README for the release process, FPM packaging guide for FPM as a tool.

ToolNeeded forInstall
bashAlways
dpkg-debmode: dpkg, and inspectionPreinstalled on Debian/Ubuntu (dpkg package)
yqAny package with a manifestmikefarah/yq releases
fpmmode: fpmgem install fpm (requires Ruby)

yq must be the Go implementation (mikefarah). The similarly named Python yq uses different expression syntax and will fail. CI gets all of these from the self-hosted homelab,deb-builder runner.

InputRequiredNotes
<package-name>/ directoryYesUnder packages/debian-ubuntu/
<package-name>/package.ymlEffectively yesAbsent triggers the legacy no-manifest path
Payload filesYesA DEBIAN/ tree, or the paths named in files[]
Working directoryYesMust be packages/debian-ubuntu/

Always run from packages/debian-ubuntu/. Every path in an fpm manifest — both files[].src and scripts.* — resolves relative to the current working directory, not to the manifest. Running from anywhere else fails with source path ... does not exist.

None. The script reads no environment variables; every value comes from the arguments, the manifest, or DEBIAN/control.

This is worth knowing because the surrounding pipeline is full of them: NEXUS_URL, NEXUS_USER, BWS_ACCESS_TOKEN and friends belong to upload.sh, not here. A build needs no credentials and no network, so it reproduces from a checkout alone — and there is no need to wrap build.sh in bws run.

Terminal window
./build.sh <package-name>
./build.sh <package-name> --expected-version <version>
./build.sh --help
ArgumentRequiredMeaning
<package-name>YesDirectory under packages/debian-ubuntu/ holding package.yml
--expected-version <version>NoAbort unless the manifest version matches exactly
-h, --helpNoPrint the header comment and exit 0

The package name and flag may appear in either order. Only one positional argument is accepted — a second is an error rather than being ignored.

Catches tag drift. CI derives the version from the pushed git tag (pkg/<package>-<version>) and passes it here, so a tag disagreeing with the manifest fails the build instead of publishing a mislabeled package:

Terminal window
./build.sh hello-toolsera --expected-version 1.0.3

In dpkg mode the version is checked twice — manifest against the flag, then package.yml against DEBIAN/control. Both must agree, which is why a mode: dpkg release requires bumping the version in two files.

  1. Parse arguments. Collect the package name and optional --expected-version, in either order. No package name → exit 2.
  2. Locate the package. Set PACKAGE_DIR=<package-name> and MANIFEST=<package-name>/package.yml. Missing directory → exit 1.
  3. Choose a build mode.
    • Manifest present: require yq (exit 1 if absent), read mode: (default dpkg) and version:.
    • Manifest absent: fall back to dpkg with no manifest version.
  4. Validate the version, only if --expected-version was passed. With no manifest version, read Version: from DEBIAN/control instead. Mismatch → exit 3.
  5. Print the build banner — package name and resolved mode.
  6. Dispatch on mode — see the two sections below. An unrecognized mode: → exit 2.
  7. Confirm the artifact exists. No .deb on disk after the build → exit 1.
  8. Print the summary — output filename, size, and dpkg-deb commands for inspecting the result.
  1. Resolve the payload root: <package-name>/DEBIAN/control, else scripts/<package-name>/DEBIAN/control. Neither → exit 1.
  2. Parse Version: and Architecture: out of the control file.
  3. Cross-check package.yml version against the control version. Mismatch → exit 3.
  4. Normalize permissions across the payload tree (see below).
  5. Run dpkg-deb --build --root-owner-group.
  1. Require fpm on PATH (exit 1 if absent).
  2. Read metadata from the manifest, applying defaults, into an fpm flag array.
  3. Append one --depends per depends[] entry.
  4. Append maintainer-script flags from scripts.*; a declared script that does not exist → exit 1.
  5. Append one --config-files per config_files[] entry.
  6. Create a temp staging directory, with an EXIT trap to remove it.
  7. For each files[] entry: verify the source exists (exit 1 if not), resolve the mode, and copy it into the staging tree at its installed path.
  8. Force all staged directories to 0755.
  9. Remove any existing output file, then run fpm -s dir -t deb over the staging tree.

For packages maintaining a real DEBIAN/control tree. The manifest carries only identity:

name: hello-toolsera
version: 1.0.3
mode: dpkg

Payload location. The control tree is looked for in two places, in order:

  1. <package-name>/DEBIAN/control — legacy layout, payload beside the manifest
  2. scripts/<package-name>/DEBIAN/control — current layout, payload under scripts/

The second is the convention now, matching where fpm packages keep their sources. hello-toolsera uses it: manifest at hello-toolsera/package.yml, payload tree at scripts/hello-toolsera/.

Version and architecture are parsed from the control file, so Version: and Architecture: must each be a single-token value on their own line.

Permission normalization. Before packaging, modes are forced across the payload root:

TargetMode
Every directory0755
Any file named control0755
Any file under a bin/ or sbin/ path0755

dpkg-deb refuses to build when directories — DEBIAN/ especially — are group- or world-writable, and some checkouts surface directories as 0777 (WSL DrvFs mounts are the usual culprit). The bin/sbin passes restore the executable bit that a core.fileMode=false (Windows) checkout does not carry.

The build runs with --root-owner-group, so files are owned by root:root in the package rather than by whoever built it.

For packages assembled from the manifest, with sources that can live anywhere in the repo. Use this for anything new.

FieldRequiredDefaultNotes
nameYesPackage name
versionYesPackage version
modeYesdpkgMust be fpm for this path
descriptionNonameShown by apt show
maintainerYesName <email>
licenseNoProprietary
architectureNoallall for scripts, amd64/arm64 for compiled binaries
sectionNoutilsWithout it fpm stamps Section: default
urlNoomittedHomepage
depends[]NononeOne --depends per entry
scripts.*NononeMaintainer scripts, see below
config_files[]NononeInstalled paths dpkg treats as conffiles
files[]YesPayload mapping, see below
files:
- src: scripts/qbt-tracker-remover/qbt-tracker-remover
dest: /usr/bin/qbt-tracker-remover
permissions: '0755'
- src: ../../utils/qbittorrent-automation/tracker_remover.py
dest: /usr/lib/qbt-tracker-remover/tracker_remover.py
- src: scripts/netshare-mounter/docs/
dest: /usr/share/doc/netshare-mounter/reference/
  • src is relative to packages/debian-ubuntu/ and may point outside it — qbt-tracker-remover sources its daemon straight from utils/, so the checkout install and the .deb cannot drift apart.
  • dest is the absolute installed path. A dest ending in / means “put the file in this directory”, keeping the source basename.
  • A directory src copies its contents into dest, recursively.
  • A missing src aborts the build rather than shipping an incomplete package.

permissions is optional. When omitted, the mode is inferred from dest:

dest prefixMode
/bin/, /sbin/, /usr/bin/, /usr/sbin/, /usr/local/bin/, /usr/local/sbin/0755
Anything else0644

Staged directories are always 0755.

Quote the permissions value. Write permissions: "0755", not permissions: 0755. Unquoted, YAML parses it as a number and the leading zero is lost before chmod ever sees it. Quoting is the only form guaranteed to mean what it looks like.

The script copies everything into a temporary tree and sets modes there, rather than handing fpm src=dest pairs directly. fpm copies the checkout’s modes verbatim, and git carries no usable executable bit for files committed from a core.fileMode=false (Windows) checkout.

This is not hypothetical: it shipped /usr/local/bin/netshare-mounter as 0644 — installed but not runnable — in version 1.0.3. Staging makes the packaged mode a property of the manifest rather than of whoever’s machine ran the build.

fpm takes these as flags rather than a DEBIAN/ tree, which is what lets an fpm package keep its payload sourced from elsewhere in the repo:

scripts:
after_install: scripts/qbt-tracker-remover/postinst
before_remove: scripts/qbt-tracker-remover/prerm
after_remove: scripts/qbt-tracker-remover/postrm
Manifest keyfpm flagdpkg equivalent
before_install--before-installpreinst
after_install--after-installpostinst
before_remove--before-removeprerm
after_remove--after-removepostrm

Paths are relative to packages/debian-ubuntu/, and a path that does not exist aborts the build.

config_files:
- /etc/qbt-tracker-remover/env

Each entry becomes --config-files, marking the path as a dpkg conffile so an admin’s edits survive upgrades. List the installed path, not the source path.

OutputWhere
<name>_<version>_<arch>.debThe current directory, packages/debian-ubuntu/
Progress banner and build summarystdout
Error messagesstderr
Exit codeSee Failure modes

Naming comes from different places per mode: dpkg takes version and architecture from DEBIAN/control, fpm from package.yml.

  • Overwrites an existing .deb of the same name without prompting. In fpm mode it is deleted first; in dpkg mode dpkg-deb overwrites it.
  • mode: dpkg modifies file modes in your working tree. The permission normalization runs chmod against the real payload directory, not a copy. Payload files are tracked as 100644, so on Linux and macOS this can leave git status showing mode changes (100644 → 100755) after a build. That is expected, not corruption — but do not commit it blindly.
  • Creates and removes a temp directory in mode: fpm, via mktemp -d and an EXIT trap. The trap fires on failure too, so an aborted build leaves nothing behind.
  • mode: fpm does not touch the working tree — that is the point of staging.

What it explicitly does not do:

  • No network access, and no credentials required
  • No package installation — the .deb is only written, never installed
  • No service restarts, no host changes, no Nexus upload
  • No git operations: it neither reads the tag nor creates one
CodeMeaning
0Package built (or --help)
1Missing input or tool: package directory, DEBIAN/control, yq, fpm, a manifest src path, a maintainer script, or the .deb absent after the build
2Usage error: no package name, an unexpected extra argument, or an unknown mode:
3Version mismatch — against --expected-version, or package.yml against DEBIAN/control

Exit 3 is the one worth special-casing in automation: it means versions disagree, not that the build is broken. In CI it almost always means a tag was pushed without bumping the manifest.

SymptomCause and fix
yq not found in PATHInstall mikefarah/yq — the Python yq will not work
fpm not found in PATHgem install fpm; make sure the gem bin directory is on PATH
source path '...' does not existAlmost always the wrong working directory — cd packages/debian-ubuntu
manifest version '...' does not match expected '...'Bump version: in package.yml (and DEBIAN/control for mode: dpkg) before tagging
package.yml version (...) does not match DEBIAN/control Version (...)mode: dpkg needs the version bumped in both files
DEBIAN/control file not foundControl tree is in neither <name>/ nor scripts/<name>/
unknown mode '...'mode: must be exactly dpkg or fpm
maintainer script '...' does not existA scripts.* path is wrong, or relative to the manifest instead of the build directory
Installs, but the command is not executableAdd permissions: "0755", or check dest is under a bin/sbin path
Exits 1 with no output at all--expected-version passed without a value — shift 2 fails under set -e
git status dirty after a dpkg buildExpected: permission normalization chmods the payload tree in place
Terminal window
dpkg-deb --info netshare-mounter_1.0.4_all.deb # metadata
dpkg-deb --contents netshare-mounter_1.0.4_all.deb # file list with modes

Confirm the executable bit survived — the failure mode staging exists to prevent:

Terminal window
dpkg-deb --contents netshare-mounter_1.0.4_all.deb | grep bin/
# -rwxr-xr-x root/root ... ./usr/local/bin/netshare-mounter
  • --help output is truncated. It prints via sed -n '2,15p' "$0", a fixed line range, which cuts off mid-sentence at “source paths in fpm manifests are”. The sentence finishes on the next line: “…resolved relative to the current working directory.” Editing the header comment shifts what --help shows.
  • --expected-version with no value exits 1 silently, because shift 2 fails under set -e before any validation runs.
git tag pkg/<name>-<version>
.github/workflows/publish-package.yml (self-hosted: homelab, deb-builder)
├─▶ parse <name> and <version> from the tag
├─▶ ./build.sh <name> --expected-version <version> ◀── this script
│ │
│ ├── yq read package.yml
│ └── dpkg-deb ▸ <name>_<version>_<arch>.deb
│ or fpm
├─▶ bws run -- ./upload.sh <deb>
│ └── Nexus: upload + rebuild APT metadata
└─▶ GitHub Release with the .deb attached
hosts running `apt install <name>`
(APT source configured by the custom_apt_repo Ansible role)
CallerHow
.github/workflows/publish-package.ymlOn a pkg/*-* tag push, with --expected-version from the tag
The same workflow via workflow_dispatchManual rerun, taking package and version as inputs
A developer, locallyUsually without --expected-version, to test before tagging

yq to read the manifest, then dpkg-deb or fpm to produce the archive. Nothing else — no other repo script, no network service.

build.sh hands off nothing directly; the workflow locates the .deb and passes it to upload.sh, which needs NEXUS_URL, NEXUS_REPO, NEXUS_USER and NEXUS_PASS (the credentials injected via bws run). upload.sh uploads the artifact and triggers the Nexus rebuild-index API so APT metadata reflects the new version.

Because build and publish are separate, a local build is always safe: there is no path from running build.sh to changing the repository or any host.