build.sh Reference
Purpose
Section titled “Purpose”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.
Prerequisites, inputs and environment
Section titled “Prerequisites, inputs and environment”Required tools
Section titled “Required tools”| Tool | Needed for | Install |
|---|---|---|
bash | Always | — |
dpkg-deb | mode: dpkg, and inspection | Preinstalled on Debian/Ubuntu (dpkg package) |
yq | Any package with a manifest | mikefarah/yq releases |
fpm | mode: fpm | gem 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.
Required inputs
Section titled “Required inputs”| Input | Required | Notes |
|---|---|---|
<package-name>/ directory | Yes | Under packages/debian-ubuntu/ |
<package-name>/package.yml | Effectively yes | Absent triggers the legacy no-manifest path |
| Payload files | Yes | A DEBIAN/ tree, or the paths named in files[] |
| Working directory | Yes | Must be packages/debian-ubuntu/ |
Always run from
packages/debian-ubuntu/. Every path in anfpmmanifest — bothfiles[].srcandscripts.*— resolves relative to the current working directory, not to the manifest. Running from anywhere else fails withsource path ... does not exist.
Environment variables
Section titled “Environment variables”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.
Arguments and flags
Section titled “Arguments and flags”./build.sh <package-name>./build.sh <package-name> --expected-version <version>./build.sh --help| Argument | Required | Meaning |
|---|---|---|
<package-name> | Yes | Directory under packages/debian-ubuntu/ holding package.yml |
--expected-version <version> | No | Abort unless the manifest version matches exactly |
-h, --help | No | Print 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.
--expected-version
Section titled “--expected-version”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:
./build.sh hello-toolsera --expected-version 1.0.3In 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.
What it does, step by step
Section titled “What it does, step by step”- Parse arguments. Collect the package name and optional
--expected-version, in either order. No package name → exit2. - Locate the package. Set
PACKAGE_DIR=<package-name>andMANIFEST=<package-name>/package.yml. Missing directory → exit1. - Choose a build mode.
- Manifest present: require
yq(exit1if absent), readmode:(defaultdpkg) andversion:. - Manifest absent: fall back to
dpkgwith no manifest version.
- Manifest present: require
- Validate the version, only if
--expected-versionwas passed. With no manifest version, readVersion:fromDEBIAN/controlinstead. Mismatch → exit3. - Print the build banner — package name and resolved mode.
- Dispatch on mode — see the two sections below. An unrecognized
mode:→ exit2. - Confirm the artifact exists. No
.debon disk after the build → exit1. - Print the summary — output filename, size, and
dpkg-debcommands for inspecting the result.
Step 6, mode: dpkg
Section titled “Step 6, mode: dpkg”- Resolve the payload root:
<package-name>/DEBIAN/control, elsescripts/<package-name>/DEBIAN/control. Neither → exit1. - Parse
Version:andArchitecture:out of the control file. - Cross-check
package.ymlversion against the control version. Mismatch → exit3. - Normalize permissions across the payload tree (see below).
- Run
dpkg-deb --build --root-owner-group.
Step 6, mode: fpm
Section titled “Step 6, mode: fpm”- Require
fpmonPATH(exit1if absent). - Read metadata from the manifest, applying defaults, into an
fpmflag array. - Append one
--dependsperdepends[]entry. - Append maintainer-script flags from
scripts.*; a declared script that does not exist → exit1. - Append one
--config-filesperconfig_files[]entry. - Create a temp staging directory, with an
EXITtrap to remove it. - For each
files[]entry: verify the source exists (exit1if not), resolve the mode, and copy it into the staging tree at its installed path. - Force all staged directories to
0755. - Remove any existing output file, then run
fpm -s dir -t debover the staging tree.
Build modes in detail
Section titled “Build modes in detail”mode: dpkg
Section titled “mode: dpkg”For packages maintaining a real DEBIAN/control tree. The manifest carries only
identity:
name: hello-toolseraversion: 1.0.3mode: dpkgPayload location. The control tree is looked for in two places, in order:
<package-name>/DEBIAN/control— legacy layout, payload beside the manifestscripts/<package-name>/DEBIAN/control— current layout, payload underscripts/
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:
| Target | Mode |
|---|---|
| Every directory | 0755 |
Any file named control | 0755 |
Any file under a bin/ or sbin/ path | 0755 |
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.
mode: fpm
Section titled “mode: fpm”For packages assembled from the manifest, with sources that can live anywhere in the repo. Use this for anything new.
Manifest fields
Section titled “Manifest fields”| Field | Required | Default | Notes |
|---|---|---|---|
name | Yes | — | Package name |
version | Yes | — | Package version |
mode | Yes | dpkg | Must be fpm for this path |
description | No | name | Shown by apt show |
maintainer | Yes | — | Name <email> |
license | No | Proprietary | |
architecture | No | all | all for scripts, amd64/arm64 for compiled binaries |
section | No | utils | Without it fpm stamps Section: default |
url | No | omitted | Homepage |
depends[] | No | none | One --depends per entry |
scripts.* | No | none | Maintainer scripts, see below |
config_files[] | No | none | Installed paths dpkg treats as conffiles |
files[] | Yes | — | Payload mapping, see below |
files[] — payload mapping
Section titled “files[] — payload mapping”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/srcis relative topackages/debian-ubuntu/and may point outside it —qbt-tracker-removersources its daemon straight fromutils/, so the checkout install and the.debcannot drift apart.destis the absolute installed path. Adestending in/means “put the file in this directory”, keeping the source basename.- A directory
srccopies its contents intodest, recursively. - A missing
srcaborts the build rather than shipping an incomplete package.
Permissions
Section titled “Permissions”permissions is optional. When omitted, the mode is inferred from dest:
dest prefix | Mode |
|---|---|
/bin/, /sbin/, /usr/bin/, /usr/sbin/, /usr/local/bin/, /usr/local/sbin/ | 0755 |
| Anything else | 0644 |
Staged directories are always 0755.
Quote the permissions value. Write
permissions: "0755", notpermissions: 0755. Unquoted, YAML parses it as a number and the leading zero is lost beforechmodever sees it. Quoting is the only form guaranteed to mean what it looks like.
Why the payload is staged
Section titled “Why the payload is staged”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.
Maintainer scripts
Section titled “Maintainer scripts”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 key | fpm flag | dpkg equivalent |
|---|---|---|
before_install | --before-install | preinst |
after_install | --after-install | postinst |
before_remove | --before-remove | prerm |
after_remove | --after-remove | postrm |
Paths are relative to packages/debian-ubuntu/, and a path that does not exist aborts
the build.
Config files
Section titled “Config files”config_files: - /etc/qbt-tracker-remover/envEach 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.
Outputs and side effects
Section titled “Outputs and side effects”Outputs
Section titled “Outputs”| Output | Where |
|---|---|
<name>_<version>_<arch>.deb | The current directory, packages/debian-ubuntu/ |
| Progress banner and build summary | stdout |
| Error messages | stderr |
| Exit code | See Failure modes |
Naming comes from different places per mode: dpkg takes version and architecture from
DEBIAN/control, fpm from package.yml.
Side effects
Section titled “Side effects”- Overwrites an existing
.debof the same name without prompting. Infpmmode it is deleted first; indpkgmodedpkg-deboverwrites it. mode: dpkgmodifies file modes in your working tree. The permission normalization runschmodagainst the real payload directory, not a copy. Payload files are tracked as100644, so on Linux and macOS this can leavegit statusshowing 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, viamktemp -dand anEXITtrap. The trap fires on failure too, so an aborted build leaves nothing behind. mode: fpmdoes 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
.debis only written, never installed - No service restarts, no host changes, no Nexus upload
- No git operations: it neither reads the tag nor creates one
Failure modes
Section titled “Failure modes”Exit codes
Section titled “Exit codes”| Code | Meaning |
|---|---|
0 | Package built (or --help) |
1 | Missing input or tool: package directory, DEBIAN/control, yq, fpm, a manifest src path, a maintainer script, or the .deb absent after the build |
2 | Usage error: no package name, an unexpected extra argument, or an unknown mode: |
3 | Version 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.
Troubleshooting
Section titled “Troubleshooting”| Symptom | Cause and fix |
|---|---|
yq not found in PATH | Install mikefarah/yq — the Python yq will not work |
fpm not found in PATH | gem install fpm; make sure the gem bin directory is on PATH |
source path '...' does not exist | Almost 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 found | Control tree is in neither <name>/ nor scripts/<name>/ |
unknown mode '...' | mode: must be exactly dpkg or fpm |
maintainer script '...' does not exist | A scripts.* path is wrong, or relative to the manifest instead of the build directory |
| Installs, but the command is not executable | Add 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 build | Expected: permission normalization chmods the payload tree in place |
Verifying a build
Section titled “Verifying a build”dpkg-deb --info netshare-mounter_1.0.4_all.deb # metadatadpkg-deb --contents netshare-mounter_1.0.4_all.deb # file list with modesConfirm the executable bit survived — the failure mode staging exists to prevent:
dpkg-deb --contents netshare-mounter_1.0.4_all.deb | grep bin/# -rwxr-xr-x root/root ... ./usr/local/bin/netshare-mounterKnown rough edges
Section titled “Known rough edges”--helpoutput is truncated. It prints viased -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--helpshows.--expected-versionwith no value exits1silently, becauseshift 2fails underset -ebefore any validation runs.
Where it fits in the pipeline
Section titled “Where it fits in the pipeline”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)Callers
Section titled “Callers”| Caller | How |
|---|---|
.github/workflows/publish-package.yml | On a pkg/*-* tag push, with --expected-version from the tag |
The same workflow via workflow_dispatch | Manual rerun, taking package and version as inputs |
| A developer, locally | Usually without --expected-version, to test before tagging |
What it calls
Section titled “What it calls”yq to read the manifest, then dpkg-deb or fpm to produce the archive. Nothing else
— no other repo script, no network service.
What runs after
Section titled “What runs after”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.
Related
Section titled “Related”- Packages README — release process and repository conventions
- FPM packaging guide — FPM as a tool
- Package testing guide — verifying before release
upload.sh— Nexus upload and metadata rebuild, invoked by CI