Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

src2deb builds Debian .deb packages from source. It reads a recipe that lists a set of components, resolves each component’s source, works out the order they must build in, and builds each one inside an unprivileged ferroday-cage sandbox. Every component is built in a Debian root src2deb provisions itself, and the finished packages are collected onto the host.

Its first target is the COSMIC desktop (cosmic-epoch): 27 components, built from source for Debian Trixie and Forky using the debian/ packaging trees upstream ships. Their build graph is nearly flat — a single inter-component build edge — so the order src2deb builds them in is derived from their declared dependencies.

The model

A build is driven by a recipe — a recipe.toml that names a Debian suite and lists the components to build, each with a source — a git repository, or a tree already on disk. src2deb:

  1. resolves each component’s source into an unpacked tree with a debian/ directory — taking that debian/ from a second source, and applying a patch series over the result, where the recipe says so,
  2. reads every debian/control to learn what each component build-depends on and what binary packages it produces, and orders the components so each one builds after the components that produce its build-dependencies,
  3. provisions a build root for each component — the base system, the Rust toolchain, and that component’s build-dependencies — and builds it,
  4. publishes each component’s packages to a local pool, so a later component that build-depends on an earlier one resolves against the packages src2deb just built.

What a run leaves behind is a servable Debian archive, a tree of artifacts, and a provenance manifest tying the two to the revisions they were built from.

Hermetic builds

Each component builds in its own sandbox with a controlled package set and, for the build itself, an isolated network. A package that vendors its dependencies (as COSMIC’s Rust components do) is handled in two passes: a vendor pass with network access that captures the dependencies into the source tree, then an offline build pass that consumes them. What the recipe declares is what the build sees.

The vendor pass is the trust boundary

The build pass runs with an isolated network; the vendor pass runs with the host’s. It runs the component’s own debian/rules clean — arbitrary upstream code — in a sandbox whose filesystem is isolated but whose network is the host’s, so the vendoring step can fetch its crates. Upstream code therefore executes with host network access during that pass. The filesystem sandbox still confines it to the source tree, and only the offline build pass produces the packages, but the vendor pass is where src2deb trusts upstream: the build is hermetic; acquiring the dependencies to build it is not.

Sharing the host’s network means sharing the host’s resolver: /etc/resolv.conf is bound into that sandbox read-only, so upstream code can resolve the names it fetches from. It is the one host file the pipeline exposes to a build — the environment, the tooling, and the keyring a build sees are all the build root’s own — and the build pass, which is the one that produces the packages, runs with an isolated network throughout.

Status

src2deb is at 0.1, and its interfaces will change. This guide grows as the project does.

src2deb is released under MIT OR Apache-2.0.

Where to go next

Quick start

Prerequisites

  • Linux with unprivileged user namespaces enabled — the requirement ferroday-cage imposes for its rootless sandbox.
  • git, to resolve component sources on the host.
  • git-lfs, for components whose repositories keep assets in Git LFS. A build that needs it and cannot find it stops during resolve, so a package is never built against pointer stubs.
  • curl, for components built from a source.tarball release archive or a source.dsc source package. Needed only to fetch what is not already cached, so a work directory that has the archives builds without it.
  • A Rust toolchain, to build src2deb itself.

That is the whole host requirement: src2deb provisions each build root itself, so the Debian build tooling lives inside the sandbox.

Install

src2deb is built from source with Cargo. rust-toolchain.toml pins the Rust version it builds with, which rustup installs on demand.

From a checkout of the repository:

cargo install --path crates/src2deb-cli

That puts src2deb on PATH via ~/.cargo/bin. To build without installing it, cargo build --release leaves the binary at target/release/src2deb.

Build a recipe

Point src2deb at a recipe directory — a directory containing a recipe.toml:

src2deb build recipes/cosmic-epoch --work ./work

--work sets the working directory for sources, build roots, the package cache, the local pool, and build output; it defaults to ./work.

src2deb resolves each component’s source, computes the build order, and builds the components in turn, streaming each build’s output to the terminal. Finished packages are collected under the working directory and published to the local pool so later components resolve against them.

By default a build stops at the first component that fails. Pass --keep-going to build the rest and report a final tally instead:

src2deb build recipes/cosmic-epoch --keep-going

A component fails whether its source will not resolve, its debian/control or debian/changelog cannot be read, or its build exits unsuccessfully; --keep-going covers all of them, so one unreachable repository costs one component rather than the run.

A run that reaches the build phase ends with a summary — how many components built, failed, and were skipped and why; what the run produced and where; and the names of any that failed — and exits non-zero if any component failed. A run building for several architectures gets one section each, plus a line totalling them. A run stopped before that point, by a locked work directory or a dependency cycle or a selection it cannot satisfy, prints the error alone: it has nothing to summarize and writes no manifest.

Choose a target suite and architecture

A recipe names the suite it was written against and, optionally, the architectures to build for. Both are defaults: what a recipe fixes is its components and how they build, not which target a run aims at. --suite and --architecture retarget it:

src2deb build recipes/cosmic-epoch --suite forky
src2deb build recipes/cosmic-epoch --architecture arm64
src2deb build recipes/cosmic-epoch --suite forky --architecture arm64

A recipe that names no architectures builds for whichever host runs it. --architecture is repeatable, so one run covers a whole architecture matrix for a suite:

src2deb build recipes/cosmic-epoch --architecture amd64 --architecture arm64

A run still builds for one suite, so covering several means one run per suite — and because the pool, output tree, manifest, and build roots are all keyed by suite and architecture, those runs may share a single --work directory.

The version tag follows the suite. --suite supersedes a recipe’s version-tag along with the suite it described, so a retargeted run stamps the tag of the suite it is actually building for. A suite src2deb has no tag for is refused until --version-tag names one:

src2deb build recipes/cosmic-epoch --suite sid --version-tag debsid

See Package versions.

An override is not a promise that the recipe suits the target: a suite whose archive cannot satisfy the components’ build-dependencies fails while the build root is provisioned. A target the host cannot run natively builds under emulation and needs a qemu-user binfmt handler installed; see Cross-architecture builds.

Stop a run

Ctrl-C stops a run at the next point where stopping leaves a coherent state behind, rather than killing it mid-provision: components that finished stay built and published, a partly-provisioned build root is removed rather than left half-made, and the manifest still records where the run got to. The run exits 130. See Cancelling a run.

Build in parallel

--jobs N builds up to N components at once, respecting the dependency order:

src2deb build recipes/cosmic-epoch --jobs 4

A component starts as soon as the components that produce its build-dependencies have finished and published. Because the COSMIC graph is nearly flat, most components build independently, so parallelism is close to linear in N. Each line of in-cage output is prefixed with its component, since several builds’ output interleaves. Building defaults to one component at a time.

Preview the build order

The plan subcommand resolves sources and computes the build order without building anything:

src2deb plan recipes/cosmic-epoch
src2deb plan recipes/cosmic-epoch --build-deps

It prints the order to standard output, one component per line with the source it resolved to; --build-deps adds each component’s build-dependencies. Planning still clones each source, because the order is read from every debian/control.

plan takes the same exclusive lock on its work directory that build does, for the same reason — it writes to <work>/sources/. To inspect a recipe’s order while a long build is running, give the plan a --work directory of its own.

Build from a tree you are editing

A component’s source is usually a git repository, which means a change has to be committed and pushed before src2deb can build it. Point source.path at a tree on disk instead and it builds what is there now:

[[components]]
name = "cosmic-comp"
source.path = "../../checkouts/cosmic-comp"

The path is relative to the recipe’s own directory. src2deb copies the tree into the work directory and builds from the copy, so nothing is written into the tree you are editing — which matters, because the build runs the component’s own debian/rules clean in whatever tree it is given.

Packages built this way are marked. The version carries local where a git build carries a commit, the manifest records the input as unpinned, and --skip-published never skips the component. See Building from a tree on disk.

Resume and selective builds

A re-run against the same work directory can skip work already done and narrow what it builds:

# Rebuild only what changed since the last run.
src2deb build recipes/cosmic-epoch --skip-published

# Build one component.
src2deb build recipes/cosmic-epoch --only cosmic-osd

# Resume from a component onward in the build order.
src2deb build recipes/cosmic-epoch --from cosmic-osd

--skip-published skips a component whose source resolves to what a prior run recorded as built in the manifest, so an interrupted or repeated run rebuilds only what changed. --only (repeatable) builds just the named components, and --from builds a component and everything after it in the order; the two are mutually exclusive.

What a narrowed run needs from the pool

Both --only and --from leave components out, and whatever the selected components build-depend on still has to come from somewhere: an archive package from the archive, and a package another component of the recipe produces from the local pool, where an earlier run put it. On a warm pool that is automatic — skipped components’ packages are still there, and a selected component resolves against them.

On a pool that has never held them it is not, and src2deb says so before it provisions anything:

src2deb: unsatisfiable build-dependency: this run builds "cosmic-osd", which
  build-depends on "libcosmic-randr-dev"; that package is produced by component
  "cosmic-randr", which --only leaves out, and the pool does not hold it. Select
  "cosmic-randr" as well, or build it first

A selection naming a component the recipe does not have is refused the same way, before a single source is cloned.

Every run resolves every component’s source whatever it was asked to build, because the build order is read from all of them. A selective run over a cold work directory therefore still clones the whole recipe once; later runs only fetch. A component outside the selection whose source will not resolve is reported and passed over rather than ending the run — it was never going to be built.

Both subcommands take -q/--quiet and -v/--verbose:

  • --quiet prints only failures, cancellation, and the closing summary.
  • The default prints the progress narrative, the provisioning counters, the shared base’s package count, any note that the run’s guarantees changed, and each build’s in-cage output.
  • --verbose adds per-component resolve and vendor detail, each root’s own package count, and per-package provisioning detail.

See Provisioning progress for what each level reports while a build root is being provisioned.

Output

Each component’s artifacts — the .deb files, and the .changes and .buildinfo that describe them — are written under <work>/out/<suite>/<architecture>/<component>/. The local pool under <work>/pool/<suite>/<architecture>/ holds the same packages in a dists-structured archive that later builds resolve against.

The versions on those packages are not the ones the components’ changelogs declare. Every build is stamped with the suite it was built for, the build date, and the source revision — 1.0.0~alpha.7-1+deb13.20260731.abc1234 — which is what lets a rebuild reach a machine that already installed the previous one. See Package versions.

Each run also writes a provenance manifest to <work>/manifests/<recipe>/<suite>/<architecture>.toml, mapping every component to what its source resolved to, the .buildinfo its build wrote, and the package versions it produced.

Those three are what a run is for. The work directory holds a good deal more besides — the sources, the package cache, and the build roots, which are where its size actually goes. See The work directory. For getting the pool onto a machine that installs from it, see Using the pool.

All three are keyed by the same identity: the recipe, suite, and architecture the run targeted. Recipes may therefore share one --work directory freely — that is how separate recipes publish into one pool — and so may the same recipe retargeted at another suite or architecture, without either run overwriting the other’s packages, artifacts, or provenance. See The local pool for why the key is that pair, and The provenance manifest.

One run at a time

A run holds an exclusive lock on its work directory for its whole duration, so a second run against the same --work is cleanly rejected rather than corrupting the shared pool and output. A run killed outright — or ended by a second Ctrl-C — leaves the lockfile behind; the rejection message names it so it can be removed by hand.

Using the pool

A finished run leaves a complete Debian archive at <work>/pool/<suite>/<architecture>/. It is a dists/-structured pool with a Release and a Packages index, which is exactly what apt reads — so serving it is a matter of putting it somewhere a client can reach and pointing the client at it.

This chapter covers getting from that directory to apt install. It takes no view on where a pool should be hosted.

What the pool is

pool/trixie/amd64/
├── dists/
│   └── trixie/
│       ├── Release
│       └── main/
│           └── binary-amd64/
│               ├── Packages
│               ├── Packages.gz
│               └── by-hash/
└── pool/
    └── main/
        └── c/cosmic-comp/cosmic-comp_1.0.0~alpha.7-1+deb13.20260731.abc1234_amd64.deb

One pool serves one suite and one architecture. A run building for several architectures fills several pools, each complete on its own; see The local pool for why they are kept apart.

The pool is unsigned. src2deb writes the Release but signs nothing, which matters for every client below.

What the Release declares

Origin: Texor
Label: COSMIC for Debian
Suite: trixie
Codename: trixie
Architectures: amd64
Components: main
Description: COSMIC desktop packages for Debian
Date: Fri, 31 Jul 2026 00:00:00 UTC

The date is the run’s build date, not the moment of the publish. That is what makes a run pinned with --build-date produce a byte-identical pool every time: a publish clock would leave the one file the pin cannot reach differing between two runs of the same build.

Origin, Label, and Description come from the recipe and are written only when it names them:

origin = "Texor"
label = "COSMIC for Debian"
description = "COSMIC desktop packages for Debian"

They have no defaults. An origin names the organization behind an archive, and src2deb has none to offer on your behalf. A pool that declares none is still a valid archive; it is pinnable only by its URL.

Recipes built into one pool should declare one identity. A pool has a single Release, so the last recipe to publish writes the one every client reads.

Serving it

Over HTTP, from the pool directory:

cd work/pool/trixie/amd64
python3 -m http.server 8000

Any static file server will do — the archive is plain files, and apt asks for them by path. A directory served over HTTP, an object store, or a CDN in front of either all work the same way.

For a client on the same machine, or one that mounts the directory, no server is needed at all: apt reads file:// URLs directly.

Pointing a client at it

Add a source naming the pool’s URL, the suite, and the main component. Because the pool is unsigned, the client has to be told to trust it:

# /etc/apt/sources.list.d/src2deb.sources
Types: deb
URIs: http://build-host.example:8000
Suites: trixie
Components: main
Trusted: yes

The one-line form, for a sources.list entry:

deb [trusted=yes] http://build-host.example:8000 trixie main

And for a pool on the same machine:

deb [trusted=yes] file:///srv/pool/trixie/amd64 trixie main

Then:

sudo apt update
sudo apt install cosmic-desktop

Pinning against the pool

A recipe that named an origin and a label can be pinned on them, which is the form apt’s own documentation leads with:

# /etc/apt/preferences.d/cosmic
Package: *
Pin: release o=Texor,l=COSMIC for Debian
Pin-Priority: 1001

A priority above 1000 installs the pool’s package even where that means downgrading one the archive also ships — which is what a backport stamp otherwise arranges by version alone.

apt policy shows what a client resolved:

500 http://build-host.example:8000 trixie/main amd64 Packages
    release o=Texor,l=COSMIC for Debian,c=main,b=amd64

A pool that declared no identity renders that line with the fields blank, and can be pinned only by its host: Pin: origin "build-host.example".

That last form is a different thing from o=, and apt spells both “origin”:

Pin formMatchesComes from
Pin: release o=Texorthe Release file’s Origin fieldthe recipe’s origin
Pin: origin "apt.example.org"the host serving the archivethe client’s source URL

So an origin naming a hostname is a mistake worth avoiding: it makes the two forms say the same thing and gives up an axis to pin on. apt’s own manual puts it plainly — what follows Origin: in a Release file “is not an Internet address but an author or vendor name”. Name whoever publishes the archive, and let the URL carry the host.

What Trusted: yes gives up

Trusted: yes tells apt to install from the archive without verifying a signature over its Release. Nothing then attests that the packages a client receives are the packages the build produced: anything that can answer the archive’s URL, or modify the files behind it, can substitute a package, and apt will install it.

That is acceptable for a pool on the machine that built it, or one served over a network you control to hosts you control. It is not acceptable for a pool served to anyone else. Sign it instead.

Signing a pool

A signed archive carries an InRelease — the Release document with an inline OpenPGP signature over it — beside the Release in dists/<suite>/. A client given the corresponding public key verifies it and needs no Trusted: yes.

Two things about a src2deb pool decide when signing happens:

  • A publish replaces the Release. A signature covers the exact document it was made over, so publishing invalidates it. Sign after the run that finished the pool, and re-sign after every run that publishes into it.
  • A run with nothing to build does not touch the pool. A fully-skipped --skip-published re-run therefore leaves a signed pool signed.

See Signing follows a run, never precedes it.

Pruning the pool

A pool’s index names one version of each package. Publishing merges each run’s .debs into the index by highest version, so a package superseded by a later build stops being named the moment that build publishes — but the file stays where it was written. Every build carries the build date in its version, so a recipe built nightly writes a fresh set of .deb files each night and leaves the previous set on disk, reachable by nothing.

src2deb prune removes them:

src2deb prune recipes/cosmic-epoch --work /mnt/build/work

By default it keeps one version of each binary package — the version the index names — so the pool on disk ends up exactly matching the archive it serves. --keep N leaves the newest N instead, which is worth doing only to have a superseded .deb to hand to someone or to roll back to by hand: apt is never offered it, because the index names one.

--dry-run reports what would go without removing it:

src2deb: arm64: would remove 78 file(s), 3.1 GiB, of 26 package(s)
src2deb: 1 pool(s) pruned: would remove 78 file(s), 3.1 GiB

Two guarantees hold whatever is pruned:

  • A file the index names is never removed. The index is the pool’s contract with every client resolving against it.
  • No index is rewritten. Nothing indexed is removed, so the Release, the Packages, and any signature written over them still describe the pool exactly.

A build prunes its own pool when told to, once the run has finished:

src2deb build recipes/cosmic-epoch --skip-published --keep 2

After the run rather than as each component publishes, because a superseded file may still be being fetched by a build root provisioning against the pool. For the same reason, prune when no build is running: a client that read an earlier Release may still be fetching a file the prune removes. A pool is pruned across every recipe that publishes into it, since a pool for a suite and architecture is one archive whoever built it.

Before serving a pool to anything but the next build

Three things matter more once something other than src2deb is reading the pool:

  • Its packages may not install. A component builds without anything guaranteeing that its runtime dependencies exist in the suite it was built for. src2deb check resolves them and reports what nothing satisfies; see Checking installability.
  • Publishing is incremental and forward-only: a lower version does not publish, and there is no unpublish.
  • Package versions: every build carries the suite, the build date, and the source revision, which is what makes a rebuild reach a machine that already installed the last one.

Handing the packages to an archive

Serving the pool directly is one destination for a run’s output; ingesting it into a managed archive is the other. See Publishing to an archive.

Checking installability

A run that goes twenty-six for twenty-six says every component built. It says nothing about whether the packages it produced can be installed. Those are different questions, and a package answers the first while failing the second whenever a runtime Depends names something the target suite does not have — a package that only exists in Ubuntu, one that was transitional in the last release and is gone from this one, or one that is simply not packaged yet.

src2deb check asks the second question:

src2deb check recipes/cosmic-epoch --work /mnt/build/work
src2deb: reading the archives for trixie/arm64 to check 27 package(s)
src2deb: arm64: cosmic-settings: Depends: network-manager-gnome
src2deb: arm64: cosmic-initial-setup-casper: Depends: casper
src2deb: arm64: 27 package(s), 214 dependencies, 2 unsatisfiable
src2deb: 2 unsatisfiable dependencies across 1 pool(s); apt will refuse those
packages until something provides what they name

It exits non-zero when anything is unsatisfiable, so it belongs between a build and a publish.

Before the build

src2deb check answers after the build, which for a large recipe is hours after the recipe declared the dependency that will fail. src2deb plan --runtime-deps answers before it:

src2deb plan recipes/pop-desktop-data --work /mnt/build/work --runtime-deps
src2deb: arm64: pop-gtk-theme: pop-gtk-theme: Depends: gtk2-engines-murrine
src2deb: arm64: 11 runtime relationship(s) declared, 1 unsatisfiable

It reads each component’s debian/control — the same file the build order comes from — for the Pre-Depends, Depends, and Recommends its binary stanzas declare, and answers each against the target suite, the recipe’s repositories, the pool as it stands, and the packages the recipe itself will build. That last is what makes the question answerable at all before a build: a metapackage depending on everything its recipe produces is satisfied by its siblings, none of which is in any archive yet.

The two answer the same question at different strengths and different times, and neither replaces the other:

plan --runtime-depscheck
Readsdebian/controlthe built .debs in the pool
Sees ${shlibs:Depends}no, it is still a substitutionyes, expanded
Reports Recommendsyesno
Answersbefore the buildafter it
Exit statusunaffectednon-zero when anything is unsatisfiable

A plan reports and does not gate, for the reason a build’s closing check does not: a pool is often built before the packages that complete it. Recommends is included here and not there because the questions differ — apt passes over a Recommends it cannot satisfy, so it is not part of installability, but a Recommends nothing will ever satisfy is a gap in what a recipe delivers.

Costs one release and index fetch per architecture the recipe names, and nothing when the flag is absent.

What it reads

The pool, not the recipe. That matters, because debian/control declares ${shlibs:Depends} while the .deb carries what that expanded to — every library package the build actually linked against. The pool’s index holds each package’s control stanza verbatim, so a check sees the dependencies a client will see rather than the ones the packaging was written with.

It follows that a check covers whatever the pool holds, whichever recipe built it. A work directory shared by three recipes checks as one archive, the same way pruning does, because a pool for a suite and architecture is one archive.

Every pool the suite holds is checked. --architecture narrows that, and is repeatable:

src2deb check recipes/cosmic-epoch --architecture arm64

What counts as available

The same archives a build root is provisioned from: the target suite, any additional repositories the recipe declares, and the pool itself. A dependency is satisfiable when one of those offers a package that satisfies it.

Two consequences worth knowing:

  • A dependency the pool satisfies itself is fine. The packages a recipe builds resolve against each other, so a metapackage depending on everything it pulls in checks clean once those are built.
  • A dependency satisfied only by a declared extra repository checks clean. A recipe declaring a repository is declaring where its packages come from, and the check takes it at its word. A client that does not have that repository configured still cannot install the package.

Alternatives and virtual packages are honoured, over exactly the archives that provision build roots. a | b is satisfied by either; x-terminal-emulator is satisfied by anything that Provides it.

A dependency on a name something provides is reported at --verbose, because being satisfied that way is weaker than being satisfied directly — the clause installs because apt picks one of several providers, and which it picks is apt’s decision rather than the packaging’s:

src2deb: arm64: pop-icon-theme: Depends: adwaita-icon-theme-full is provided by
adwaita-icon-theme

The line says the name is provided, not that it is only provided. Debian policy lets a package provide a name a real package also carries — a transitional package is the usual case — and the archives are read as the names they offer rather than as a catalogue that separates the two.

What is checked

Depends and Pre-Depends — the relationships that make a package installable.

Recommends is not checked. apt passes over a Recommends it cannot satisfy rather than refusing the package, so it does not belong in an answer about installability.

Names are checked, not versions. A dependency’s version constraint is not enforced, for the same reason build roots do not enforce one: a suite is internally consistent, and the version a package resolves to in it is the version that suite ships. What this catches is a dependency on a package that is not there at all, which is the failure that reaches a target machine.

After a build

A build ends with the same check over the pools it published into — every architecture that built something, and no others:

src2deb: 26 built, 0 failed, 1 skipped
src2deb: reading the archives for trixie/arm64 to check 27 package(s)
src2deb: arm64: 27 package(s), 214 dependencies, all satisfiable
src2deb: 1 pool(s) checked: 27 package(s), every dependency satisfiable

It is a note, not a gate: an unsatisfiable dependency does not fail the run. A pool is often built before the packages that complete it — the recipe supplying a dependency may not have run yet — so failing here would refuse a legitimate order of work. src2deb check is where the same answer decides an exit status.

The note is skipped for a run that built nothing and for one that was cancelled. A run that could not reach the archive to ask says so and leaves its own outcome alone: failing to ask whether packages install is not a failure to build them.

It describes the pool as it stands, which is worth remembering after a --keep-going run: a component that failed is a component whose packages are not in the pool, so anything depending on them is reported alongside the failure rather than instead of it.

Acting on a finding

An unsatisfiable dependency has three ways out, and which applies is a property of the package rather than of src2deb:

  • Build it. The dependency is a package that could be built from source, and the answer is a recipe or a component for it. Four of the six COSMIC hit on Debian became one recipe of their own.
  • Drop it. The dependency belongs to a platform this build is not for, and the answer is a patch to debian/control. casper is Ubuntu’s live-boot system, reachable only through one binary package.
  • Point it elsewhere. The dependency named a package that has been renamed or superseded, and something in the suite replaces it. network-manager-gnome is transitional in trixie and absent from forky, where nm-connection-editor takes over.

A dependency reported for one suite and not another is the ordinary case, not a contradiction: suites move, and that is the whole reason the check reads the suite the packages were built for.

What it costs

One pass over the archives per pool: each one’s release and package index, fetched and projected down to the names it offers. A couple of seconds on a warm link, for a pool of any size — the cost is the index, not the number of dependencies asked about.

Nothing is resolved. A check computes no install closure and downloads no package; it reads what the archives carry and answers each dependency against that. So a pool for a foreign architecture is checked as readily as one for the host’s.

In a scheduled build

#!/bin/sh
set -eu
src2deb build recipes/cosmic-epoch --work /mnt/build/work \
    --suite trixie --skip-published --keep 2
src2deb check recipes/cosmic-epoch --work /mnt/build/work --suite trixie
src2deb export recipes/cosmic-epoch --work /mnt/build/work \
    --suite trixie --to /srv/drop/rk1

set -eu makes the check the gate: a suite that moved underneath a package stops the publish rather than reaching a target machine as an apt error. See Publishing to an archive.

Publishing to an archive

The pool a run leaves behind is a complete archive for one suite and one architecture, and serving it directly is the shortest path from a build to apt install. A published archive is usually a different shape: one Release covering every architecture of a suite, managed by an archive tool with snapshots and a signing key behind it.

src2deb export bridges the two. It copies what a work directory holds into a directory laid out for an archive tool to ingest, so a publisher never reads anything under the work directory.

An export carries .deb files and metadata, never an index, so the pool’s own Release — its date and the identity the recipe gave it — does not travel with them. The archive tool writes its own, and the two should agree: give the recipe the same origin and label the publisher declares, so a package reaches a client under one identity whether it came from the pool directly or through the archive.

src2deb export recipes/cosmic-epoch --work /mnt/build/work --to /srv/drop/rk1

What it writes

/srv/drop/rk1/trixie/
├── export.toml
├── manifests/
│   └── cosmic-epoch/
│       ├── amd64.toml
│       └── arm64.toml
├── cosmic-comp_1.0.0+deb13.20260802.abc1234_amd64.buildinfo
├── cosmic-comp_1.0.0+deb13.20260802.abc1234_amd64.changes
├── cosmic-comp_1.0.0+deb13.20260802.abc1234_amd64.deb
├── cosmic-comp_1.0.0+deb13.20260802.abc1234_arm64.deb
└── cosmic-icons_1.0.0+deb13.20260802.abc1234_all.deb

The suite is a directory of its own, so one destination holds several suites. Inside it the packages are flat, so the whole suite is one argument:

aptly repo add cosmic-trixie /srv/drop/rk1/trixie

An archive tool that scans a directory takes the packages and passes over the rest. Keep the directory to what src2deb wrote, though: aptly, for one, fails a whole repo add over a single file named *.deb that it cannot parse, whoever put it there.

Beside each package travel the .changes and .buildinfo its build wrote, and a copy of each architecture’s provenance manifest. Together they are the record of how the packages were built, in a place a publisher can archive next to a release without reaching into a build host’s work directory.

src2deb writes the export and stops there. It does not run an archive tool, sign anything, or upload.

What an export carries

Every component the work directory records as built, for every architecture it holds a manifest for — not only what the last run produced. A --skip-published run may build two components of twenty-six while the archive still wants all twenty-six, and the manifest carries a built record forward for exactly this reason.

To narrow to particular architectures, name them; the flag is repeatable:

src2deb export recipes/cosmic-epoch --to /srv/drop/rk1 --architecture arm64

A component the manifest calls built whose packages are no longer in the output tree fails the export, naming the component. An archive quietly missing a package it was told about is worse than an export that stops.

Architecture: all packages

An arch-indep package’s file name carries no architecture, and its stamped version does not vary with one, so a recipe built for two architectures produces one file name over two sets of bytes. A merged archive holds one of them, so an export carries one of them:

src2deb: cosmic-icons: Architecture: all package taken from arm64, not from amd64
src2deb: set arch-indep-owner in the recipe to build those once rather than once
per architecture

Which copy is carried follows the recipe. With an arch-indep owner declared, the owner’s copy is carried — and the other architecture never builds it in the first place, so there is nothing to choose between. With none, the later version is carried, and architecture name order breaks a tie, so an export is a function of what the work directory holds rather than of the order it was read in.

The .changes and .buildinfo of a build whose arch-indep output was dropped still travel, and still name it: they record what that build produced, which is what they are kept for. Declaring an owner removes the divergence at its source.

Exporting again

An export replaces the one before it. export.toml names every file the export wrote, so the next export into the same directory removes exactly those and writes its own — a scheduled run stays idempotent, and a superseded version never reaches the archive by being left behind.

Three rules make that safe to point at a shared drop directory:

  • An export removes only files an export of its own wrote. Anything else in the directory is left alone.
  • The index is keyed by recipe. Several recipes may export into one directory, and each replaces only its own files — which is the ordinary case for an archive publishing more than one recipe into a suite.
  • The index names every file the directory holds for the recipe, at every moment. It is written before the files it names, and it goes on naming the files it is superseding until they have actually been removed. An export killed partway through therefore leaves nothing unaccounted for, and the next one finishes what it started.

An export into a directory whose export.toml names a different suite is refused, since that means the destination is not the one intended.

Keeping an export beyond the next one is a matter of choosing where it goes: --to /srv/drop/rk1-$(date +%F) writes a fresh directory each time. It is worth knowing what that buys before reaching for it — the pool upstream keeps as many versions as --keep is told to, and an archive tool downstream keeps snapshots, so the export itself is usually the one copy not worth archiving.

A scheduled build and publish

The whole cycle, as a build host runs it overnight:

#!/bin/sh
set -eu
src2deb build recipes/cosmic-epoch --work /mnt/build/work \
    --suite trixie --skip-published --keep 2
src2deb check recipes/cosmic-epoch --work /mnt/build/work --suite trixie
src2deb export recipes/cosmic-epoch --work /mnt/build/work \
    --suite trixie --to /srv/drop/rk1

--skip-published builds only what moved, --keep 2 bounds the pool, the check stops a publish whose packages would not install, and the export replaces the drop directory’s contents with the archive’s current state. What the publisher then does with /srv/drop/rk1/trixie — ingest, snapshot, sign, publish — is outside src2deb.

The check earns its place under set -eu: a build validates that every component builds, and a suite that drops a package overnight makes what built yesterday uninstallable today without failing a single build. See Checking installability.

Troubleshooting

Every message src2deb prints is prefixed src2deb:. This chapter is ordered by when a message appears in a run — before it starts, while sources resolve, while the order is computed, while a build root is provisioned, and while a component builds.

A run that reaches the build phase ends with a summary and exits non-zero if any component failed. A run stopped before that point prints the error alone, writes no manifest, and has nothing to summarize.

Before the run starts

unrecognized option --x

src2deb: unrecognized option --nope
Try 'src2deb --help' for usage.

Exit status 2. Each subcommand accepts its own options: --build-deps belongs to plan, and --keep-going, --jobs, --only, --from, and --skip-published to build. See Command line.

the work directory is locked

src2deb: the work directory is locked by process 4127 (work/.lock); remove that
file if that process is gone

A run holds an exclusive lock on its work directory for its whole duration, so a second run against the same --work is rejected rather than corrupting the shared pool and output. plan takes the same lock, because it writes to <work>/sources/.

Give the second run a --work directory of its own, or wait for the first. When the named process is gone — a run killed outright, or ended by a second Ctrl-C, leaves the lockfile behind — remove the file the message names. A lockfile holding nothing readable as a process id reports the second form, “locked by another run”.

suite "x" is not a numbered Debian release

src2deb: suite "sid" is not a numbered Debian release, so it has no known version
tag; pass --version-tag to name the tag builds for it should carry (for example
"debsid")

src2deb derives a version tag from the suite for each numbered Debian release, and refuses a suite it has no tag for rather than guessing one. Name the tag:

src2deb build recipes/cosmic-epoch --suite sid --version-tag debsid

A recipe written for such a suite may declare version-tag instead, which applies to that recipe’s own suite. See Package versions.

invalid selection

Two forms, both settled before a single source is cloned or a root provisioned.

A selection naming a component the recipe does not have:

src2deb: invalid selection: --only names unknown component "cosmic-osdd"

Check the name against recipe.toml, where component names are free-form.

And a --from whose own source failed:

src2deb: invalid selection: --from names component "cosmic-osd", whose source did
not resolve, so the components after it in the build order cannot be identified

--from names a position in the build order, and a component with no resolved source has none. Fix the source, or name a different starting component.

unsatisfiable build-dependency

A component this run builds needs a package that another component of the recipe produces, and the run neither builds that component nor finds its package in the pool. Refused before anything is provisioned, where it would otherwise surface as a resolver failure deep inside the consumer’s build root.

The message names why the producer is absent, because that decides the fix. The selection left it out:

src2deb: unsatisfiable build-dependency: this run builds "cosmic-osd", which
  build-depends on "libcosmic-randr-dev"; that package is produced by component
  "cosmic-randr", which --only leaves out, and the pool does not hold it. Select
  "cosmic-randr" as well, or build it first

Add the named component to the selection, or build it into the pool first — a pool that already holds the package satisfies the build-dependency, which is why this is refused only when the pool cannot cover it.

Or the producer’s every binary package is Architecture: all, and the recipe leaves those to another architecture:

src2deb: unsatisfiable build-dependency: this run builds "cosmic-osd", which
  build-depends on "cosmic-icons"; that package is produced by component
  "cosmic-icons", which produces only Architecture: all packages, left to
  "amd64" by this recipe, and the pool does not hold it. Stop naming an
  arch-indep owner, so this architecture builds "cosmic-icons" itself; the
  owner's copy is published to the owner's pool, which this build does not
  resolve against

Drop arch-indep-owner so every architecture produces its own. Building for the owner first does not settle this: a pool belongs to one architecture, so the owner’s copy lands in the owner’s pool and this build never resolves against it. See Who builds the Architecture: all packages.

While sources resolve

resolving source for X

src2deb: FAILED cosmic-comp: resolving source for cosmic-comp: <reason>

A clone, fetch, checkout, or submodule update failed. Common causes are an unreachable repository, a git-ref naming a branch or tag that does not exist, and credentials for a private repository.

A resolve failure is a failure of that component rather than of the run, on the same terms as a failed build: --keep-going carries the run past it, records the component as failed with no source recorded, and folds it into the summary and the manifest.

source.git names X, but the checkout at Y was cloned from Z

src2deb: FAILED cosmic-comp: resolving source for cosmic-comp: source.git names
"https://github.com/example/cosmic-comp", but the checkout at
/work/sources/cosmic-comp was cloned from
"https://github.com/pop-os/cosmic-comp"; delete that directory to build from the
repository the recipe names

The recipe was repointed at another repository — a fork, a mirror, a renamed upstream, or the same repository over another protocol — and the work directory still holds a checkout of the old one. Delete the checkout and build again:

rm -rf work/sources/cosmic-comp

A fetch takes its remote from the checkout rather than from the recipe, so the alternative would be to build the old repository and record its commit as though the recipe had asked for it. Repointing the checkout in place is not enough either: the objects, tags, and default branch of the repository it was cloned from survive a git remote set-url, so a ref that resolves only in the old repository would still resolve. A packaging.git overlay is refused the same way, and its checkout is under work/packaging/ instead.

The comparison is exact, so two spellings of one repository — a trailing /, a .git suffix — are refused as well. The remedy is the same, and it costs one re-clone.

A git insteadOf rewrite is not a repoint and does not trigger this. The URL compared is the one the checkout was cloned from, not the one the rewrite sends the fetch to, so a host that redirects github.com at an internal mirror builds as it always did.

is stored with Git LFS, but git lfs is not available

src2deb: FAILED pop-icon-theme: resolving source for pop-icon-theme:
src/assets/wallpaper.png is stored with Git LFS, but `git lfs` is not available;
install git-lfs so the real content is fetched instead of the pointer stubs
standing in for it. Building against a pointer produces a package that installs
cleanly and fails at runtime, so the build stops here

Install git-lfs on the host:

sudo apt install git-lfs

A checkout made without LFS support writes a short text pointer where each asset should be. Those pointers are ordinary valid files, so a build embeds or installs one and succeeds — the substitution surfaces only when the installed program reads the asset and finds a stub. src2deb therefore stops at resolve instead.

is still a Git LFS pointer after git lfs pull

src2deb: FAILED pop-icon-theme: resolving source for pop-icon-theme:
src/assets/wallpaper.png is still a Git LFS pointer after `git lfs pull`; the
content could not be fetched from the LFS server. Building against a pointer
produces a package that installs cleanly and fails at runtime, so the build stops
here

git-lfs is installed and ran, and the content is still missing. The LFS server was unreachable, the objects it should hold are gone, or the repository needs credentials for LFS that the clone did not have. Both messages list up to five pointer paths, then a count of the rest.

is a Git LFS pointer; run git lfs pull in X

src2deb: FAILED pop-icon-theme: resolving source for pop-icon-theme:
src/assets/wallpaper.png is a Git LFS pointer; run `git lfs pull` in
/home/someone/pop-icon-theme so the build gets the real content instead of the
pointer stub standing in for it. Building against a pointer produces a package
that installs cleanly and fails at runtime, so the build stops here

The same substitution, found in a source.path tree. Run the command the message names, in the directory it names, and build again:

git -C /home/someone/pop-icon-theme lfs pull

src2deb does not fetch on your behalf here, as it does for a checkout it made itself. The tree is yours, and a build is not the moment to change it.

source.path X cannot be read

src2deb: FAILED cosmic-comp: resolving source for cosmic-comp:
source.path ../../checkouts/cosmic-comp cannot be read: No such file or
directory (os error 2)

The path is wrong, or it is relative to somewhere other than you expected. A relative source.path resolves against the recipe’s directory — the one holding recipe.toml — not against the directory you ran src2deb from. The message shows the joined path, so compare it with where the tree actually is.

source.path X would be copied into itself

src2deb: FAILED cosmic-comp: resolving source for cosmic-comp:
source.path /home/someone/build would be copied into itself
(/home/someone/build/work/sources/cosmic-comp); point it at a tree outside the
work directory

The tree a path source names contains the work directory, or lies inside it. The copy would either walk into its own output or be deleted by the wipe that precedes it, so the component is refused. Move --work outside the source tree, or point the source somewhere else.

the packaging source at X has no debian directory

src2deb: FAILED foo: resolving source for foo: the packaging source at
/work/packaging/foo/debian has no debian directory; a packaging overlay supplies
one, and packaging.subdir names the directory holding it rather than the
directory itself

Almost always the setting pointing one level too deep. A packaging overlay names the directory that holds debian/, so a repository whose root is the packaging tree needs no packaging.subdir at all, and one that keeps its packaging under debian-packaging/foo/debian/ sets packaging.subdir = "debian-packaging/foo".

The other cause is a repository that genuinely has no packaging in it — check the branch. Packaging repositories often keep debian/ on a branch of its own, which packaging.git-ref selects.

packaging.subdir X names Y, which the source does not hold

src2deb: FAILED foo: resolving source for foo: packaging.subdir debian/foo names
/work/packaging/foo/debian/foo, which the source does not hold

The subdirectory is not in the tree that was checked out. The same message appears for source.subdir against a component’s own source. Compare the path in the message with the tree under the work directory, and check packaging.git-ref: a subdirectory that exists on one branch need not exist on another.

the packaging source X and the component's source tree Y sit inside one another

src2deb: FAILED foo: resolving source for foo: the packaging source
/work/sources/foo and the component's source tree /work/sources/foo sit inside
one another, so the overlay would be copied onto itself; point packaging at a
tree outside the work directory

A packaging.path pointing into src2deb’s own work directory, usually at the copy of the source it is meant to overlay. The overlay’s destination is removed before the copy, so this would delete the tree it was about to read. Point packaging.path at the packaging as you keep it — beside the recipe, or wherever you edit it — rather than at anything under --work.

patch X does not apply

src2deb: FAILED cosmic-comp: resolving source for cosmic-comp: patch
recipes/cosmic-epoch/patches/cosmic-comp/0001-fix-build.patch does not apply to
/work/sources/cosmic-comp: error: patch failed: src/shell/mod.rs:412
error: src/shell/mod.rs: patch does not apply

The source moved out from under the patch, which is what happens when a component tracks a branch. src2deb does not fuzz a patch or fall back to a three-way merge, so a patch that no longer matches has to be brought up to date or dropped:

# See what the patch expects against what the source now holds.
cd work/sources/cosmic-comp
git apply --check -v ../../../recipes/cosmic-epoch/patches/cosmic-comp/0001-fix-build.patch

Rebase the patch against the current source and export it again with git format-patch, or pin source.git-ref to the commit the patch was written against. See Patches.

patch X cannot be read

src2deb: FAILED cosmic-comp: resolving source for cosmic-comp: patch
recipes/cosmic-epoch/patches/fix.patch cannot be read: No such file or
directory (os error 2)

A patch path is relative to the recipe’s directory — the one holding recipe.toml — not to the directory you ran src2deb from. The message shows the joined path, so compare it with where the file actually is.

skipping X (not selected); its source did not resolve

src2deb: skipping cosmic-player (not selected); its source did not resolve: <reason>

Reported, then passed over, whatever --keep-going says. Every run resolves every component’s source because the build order is read from all of them, so a narrowed run still clones the whole recipe — but a component the run was never going to build does not fail it. The recipe still has a problem the next full run will hit.

While the order is computed

cannot order the build: a dependency cycle

src2deb: cannot order the build: a dependency cycle involves: cosmic-osd, cosmic-randr

Two or more components each build-depend on a package another produces, so no order satisfies them all. This ends the run outright: there is nothing coherent to build. Break the cycle in the components’ debian/control files.

reading debian/control for X

src2deb: FAILED cosmic-comp: reading debian/control for cosmic-comp: <reason>

The resolved tree has no debian/control, or it will not parse. Check the component’s source.subdir — a component inside a superproject needs it to point at the directory holding the debian/ tree. debian/changelog reports the same way.

While a build root is provisioned

provisioning a build root

src2deb: FAILED cosmic-comp: provisioning a build root: <reason>

Most often a build-dependency the target suite’s archive cannot satisfy, which is what an unsuitable --suite surfaces as: the flag retargets a recipe without promising the recipe suits the target. Check that the component’s Build-Depends exist in the suite you asked for, and add a [[repositories]] entry for anything that lives elsewhere. See Sources and the toolchain.

A foreign-architecture target with no qemu-user binfmt handler also fails here, naming the missing handler. See Requirements.

installing the rustup X toolchain into a build root

src2deb: FAILED cosmic-comp: installing the rustup 1.95.0 toolchain into a build
root: <what the installer wrote>

The recipe pins a rustup toolchain and the install failed. The installer’s output is captured rather than streamed, so the message carries it. Common causes are a version rustup does not publish for the target architecture and a host that cannot reach https://sh.rustup.rs.

While a component builds

vendoring X: debian/rules clean

src2deb: FAILED cosmic-comp: vendoring cosmic-comp: debian/rules clean exited with status 2

The vendor pass failed. It runs with the host’s network so the component can fetch its crates, so this is usually a network problem or an upstream vendoring step that needs a tool the recipe has yet to declare. extra-build-deps adds one:

extra-build-deps = ["just"]

Re-run with -v to see the pass announced, and read the in-cage output above the failure for what the vendoring step itself reported.

building X: dpkg-buildpackage

src2deb: FAILED cosmic-comp: building cosmic-comp: dpkg-buildpackage exited with status 2

An ordinary build failure. The build’s own output is above the message, indented by two spaces. The build pass runs offline from the vendor.tar the vendor pass produced, so a build reporting a failed download means the vendoring step missed something.

Both streams of the build render alike, because dpkg-buildpackage writes its ordinary progress to standard error. A line’s stream says nothing about its severity.

Notes that are not failures

A run reports these whatever it was asked to print, because each changes what the run guarantees rather than what it is doing. The first two report at every verbosity above -q; an unsatisfiable dependency reports even at -q, since it is an answer rather than a narrative.

no unprivileged overlay

src2deb: note: no unprivileged overlay (<reason>); using full reprovisioning,
         which reuses a root a build has written to

The host cannot establish an unprivileged overlay, so src2deb bakes a full root per component instead of layering each component’s build-dependencies over one shared base. Builds still work. They are slower, and a reused root carries the previous build’s writes, which is the weaker of the two isolation guarantees. See Build roots.

foreign-architecture build

src2deb: foreign-architecture build: target arm64, host amd64 (runs through
qemu-user; needs qemu-user-static and binfmt with the F flag)

Every compiler invocation runs under emulation, which costs roughly an order of magnitude in compile time. Expected when you asked for a foreign target; otherwise check --architecture and the recipe’s architectures field. See Cross-architecture builds.

An unsatisfiable dependency after a successful build

src2deb: arm64: cosmic-initial-setup-casper: Depends: casper
src2deb: arm64: 67 package(s), 206 dependencies, 1 unsatisfiable

The named package built and published, and apt will refuse to install it: nothing in the suite, in the recipe’s repositories, or in the pool provides what it depends on. The build is not wrong — this is a property of the packaging and the suite, not of the run — so it reports rather than fails.

src2deb check asks the same question on its own and exits non-zero, which is the form to put in front of a publish. See Checking installability, which covers what to do about a finding.

could not check whether the packages install

The run finished and its closing check could not reach the archive to ask. The packages are built and published; only the question went unanswered. Run src2deb check when the archive is reachable again.

Stopping and resuming

cancelled; stopping

Ctrl-C stops a run at the next point where stopping leaves a coherent state behind, so it can take until the current package, increment, or configure step finishes. The run exits 130. A second Ctrl-C exits immediately and leaves the lockfile behind.

Components that finished stay built, published, and recorded. Re-run with --skip-published to pick up where it stopped. See What a cancelled run leaves behind.

A re-run rebuilds everything

--skip-published reads the manifest for the recipe, suite, and architecture the run targets, and skips a component whose source resolves to what is recorded as built. A run that rebuilds everything is reading a different manifest or a different source: check that --work, --suite, and --architecture match the earlier run, and that the component’s git-ref is a pinned commit rather than a branch that has since moved.

A source.path component is rebuilt every run by design, whatever the manifest records. A path says where a tree was read from and not what it held, so nothing about it establishes that the source has not moved. See Resume state.

A build root is rebuilt when nothing changed

A root is cached on the exact set of packages a bootstrap would install — each one’s name, version, and archive checksum — plus the recipe’s pinned toolchain version. Any of those moving in the archive rebuilds the root from clean, which is what keeps a bumped build-dependency from silently reusing a root provisioned for the old set. See The build-root cache.

Sources and the toolchain

Where the source, the packages, and the compiler come from is declared in the recipe, along separate axes. The recipe reference lists the fields; this chapter explains the model behind them.

Source and archives are different questions. A component’s source is the tree that gets built; an archive source is where its build-dependencies are resolved from. The first two sections below take them in that order.

The guiding constraint for archives is that the underlying resolver is highest-version-wins with no pin priorities. src2deb earns determinism by controlling the resolver’s inputs — the exact set of archives it sees — rather than by layering a preferences engine on top.

Component sources

Each component names one source: a git repository to clone, a tree already on disk, or a release archive to fetch. What gets built is assembled from that source, then any packaging overlay, then any patch series — in that order, before anything reads the tree.

A git source is cloned into the work directory on first use and fetched on later use, then checked out detached at the requested ref. A branch or an unset ref advances to the fetched upstream tip on every run; a tag or a commit resolves to itself. What the run records is the HEAD the tree ended up at, so the revision in a package’s version and in the manifest is always a concrete commit, never the moving ref that named it.

A checkout is only ever updated from the repository it was cloned from. Change source.git to name another one and the component is refused, naming both URLs, because a fetch takes its remote from the checkout and would otherwise go on building the repository the first run cloned. Delete work/sources/<component> and the next run clones the repository the recipe now names. See source.git names X, but the checkout at Y was cloned from Z.

A path source is a tree already on disk, built without being cloned. It is the difference between “edit, commit, push, run” and “run” while working on a packaging tree.

An archive source is a release tarball, fetched and unpacked. It is how most projects that are not Rust ones publish, and an upstream tarball beside a separate debian/ is the native Debian model.

A Debian source package is a .dsc and the tarballs it names, fetched and assembled. It is how you rebuild a package one suite ships for another, and it is the one source that carries its own packaging, its own changelog, and everything its build needs — so it is also the one built with no host network at any point. See Rebuilding a Debian source package.

Building from a tree on disk

A path source is copied into the work directory and built from the copy. Nothing writes into the tree the recipe named.

That matters more than it might sound. The vendor pass binds the source tree read-write and runs the component’s own debian/rules clean in it, which is what triggers the vendoring idiom — leaving a vendor.tar and a vendor/ behind, and deleting whatever that target is written to delete. For a git source, the tree that happens to is a checkout src2deb made and owns. For a path source it would be your working directory.

The copy is made afresh on every run, which is what a git source gets from git checkout --force: a file you delete really disappears from the build, and no state survives from the run before. The cost follows the size of the tree, so a path pointed at a directory that also holds a large build output — a target/, say — pays for that output on every run.

Two further rules follow from a path naming where a tree was read from and nothing about what it held:

  • The build is recorded as unpinned. The manifest writes pinned = false against the input, and the package version carries local where a git build carries an abbreviated commit — so a local build is unmistakable in apt policy output. See Package versions and The provenance manifest.
  • --skip-published never skips it. There is nothing to compare a path against, so a component built from one is rebuilt on every run.

If the tree is a git checkout holding unmaterialized Git LFS pointers, the component fails rather than building a package around the stubs, and the error names the tree to run git lfs pull in. src2deb does not fetch on your behalf here: the tree is yours.

Building from a release archive

An archive source names a URL and the SHA-256 the archive must hash to. https, http, and file URLs are fetched with curl, and the archive may be uncompressed or compressed with gzip, xz, or zstd — read from its content rather than from the URL.

The digest is the whole of the trust: the archive is verified against it before anything is unpacked, on every run and not only the first, so a hostile mirror, a broken proxy, and a truncated download each fail the component rather than building something no one asked for. Nothing about the transport carries any part of that claim, which is what makes curl an acceptable answer to fetching over TLS rather than a compromise.

Archives are cached under the work directory, named by the digest that pins them, so two components naming one archive fetch it once and a re-run fetches nothing — a host with no network, or no curl, still builds from what is there. The unpacked tree, by contrast, is replaced on every run, so each build sees the archive as it stands.

A release archive ships no debian/ of its own, so it needs packaging from elsewhere and a declared version. See Building from a release archive.

Packaging from somewhere else

A component’s tree has to hold a debian/ directory, and not every upstream ships one. A component may therefore name a second tree — resolved by the same two origins, under the same rules — whose debian/ becomes the component’s.

The rule is a narrow one in both directions, and both halves are deliberate. Only debian/ is taken, because a distribution’s packaging repository usually carries a copy of the upstream tree beside its packaging, and that copy is whichever release was last packaged rather than the source you are building. And what is taken replaces the source’s own debian/ rather than merging with it, so there is no per-file precedence to reason about and nothing of an abandoned packaging tree left beside the declared one.

An overlay is a build input like any other: both inputs reach the version stamp and the manifest, and --skip-published rebuilds when either moves. An overlay from a repository is identified by its revision, and one from a directory on disk by a digest over the debian/ tree it supplied — so packaging kept beside the recipe is as comparable from run to run as packaging kept in a repository, and editing it publishes a new package. See Packaging overlays.

Versions for packaging with no history

Packaging assembled this way often has no debian/changelog — a control and a rules are enough to build with, and a release history is not something worth maintaining by hand for a package rebuilt from source each time. The version stamp has nothing to extend in that case, so the component names its version in the recipe and src2deb writes the changelog: one entry, over an identity the recipe or the packaging’s own Maintainer field supplies. The ordinary stamping path then extends that entry, so one code path produces every version src2deb stamps.

version states the version outright and version-from = "git-describe" derives it from the source’s tags. See Components with no changelog.

Patches over the assembled tree

Either kind of source may carry a patch series — local fixes upstream has not taken — applied to the tree src2deb resolved rather than to anything of yours. The series is applied last, after any packaging overlay, so a patch is the way to fix packaging you do not control; and it is applied before any debian/control is read, so a patch may change what a component build-depends on and the build order follows.

A series is a pinned input in its own right, identified by a digest over its members’ contents in order. It is stamped into the package version alongside the source revision, recorded in the manifest, and compared by --skip-published, so editing a patch rebuilds the component and produces a version that supersedes the one built without it. See Patches.

Archive sources

The primary suite and the feed-forward pool are always present. A recipe may add named archives — a backports suite, a vendor archive, a local file:// pool — each with its own suite, mirror, and components. Every added archive is threaded into provisioning for the shared base, each layer, and each full root.

A signed archive must name the keyring its release is verified against: the provisioner has no embedded trust anchor for an archive other than the primary Debian one. A local archive under your control may instead be trusted without a signature.

The toolchain

The Rust compiler and Cargo are selected separately from the archive list, because a rustup toolchain is not a Debian archive:

  • The Debian provider, the default, resolves rustc and cargo from the archive as ordinary build-dependencies. The build is only as new as the suite’s Rust.
  • The rustup provider installs a pinned toolchain into the build root and prefers it on PATH, while the archive’s rustc and cargo stay installed to satisfy the component’s declared build-dependencies. This decouples the compiler from the suite’s Rust cadence — for example, building current COSMIC, which needs a newer rustc than Debian Trixie ships, on Trixie.

The rustup provider fetches the upstream installer from https://sh.rustup.rs over pinned TLS (--proto '=https' --tlsv1.2) and installs the exact toolchain version the recipe names. The installer script itself is not checksum-pinned — this is the standard rustup bootstrap — so a rustup toolchain trusts that fetch in addition to the archive. The Debian provider avoids it, resolving rustc and cargo from the signed archive alone.

The install happens while a build root is being provisioned, not while a build is running, so the toolchain is fetched once per root. Under the layered strategy that means once per run for the whole recipe, rather than once for every component: a build pass writes into a per-component overlay that is discarded when the component finishes, so anything a pass installed would have to be installed again for the next one. The pinned version is part of the root’s cache key, so repinning a recipe’s toolchain provisions a fresh root rather than reusing one holding the version it replaced. See Build roots.

Recipe reference

A recipe is a recipe.toml file in a recipe directory. It names a Debian suite, selects a toolchain, lists any additional archive repositories, and lists the components to build.

Sources and the toolchain explains the model these fields describe; this chapter lists the fields themselves.

Example

name = "cosmic-epoch"
suite = "trixie"

[toolchain.rust]
provider = "rustup"
version = "1.95.0"

[[components]]
name = "cosmic-comp"
source.git = "https://github.com/pop-os/cosmic-comp"
source.git-ref = "master"

[[components]]
name = "cosmic-settings"
source.git = "https://github.com/pop-os/cosmic-settings"

Top-level fields

  • name — the recipe name. Required.

  • suite — the Debian suite to build for, such as trixie or forky. Required. It is the recipe’s default rather than a binding: --suite builds the same recipe against another suite without editing the file, and each suite gets its own pool, output tree, and manifest. Name the suite the recipe was written and tested against.

  • architectures — the target architectures, Debian names such as amd64 or arm64. Optional: a recipe that omits it builds for whichever host runs it, and --architecture selects any other target without editing the file, which is what keeps one recipe serving every target. Name them when the recipe is meaningful for a fixed set.

    A run builds each in turn, in the order named, from one set of resolved sources, into a pool and an output tree of its own. See Cross-architecture builds.

    architectures = ["amd64", "arm64"]
    
  • arch-indep-owner — the architecture that produces this recipe’s Architecture: all packages, such as amd64. Optional: unset, every architecture produces its own, so each pool holds every package the recipe declares and can be served as it stands. Name one when several architectures feed a single published archive, where one name and version must mean one file. Every other architecture then builds only its architecture-dependent packages, and a component whose every binary package is Architecture: all is skipped for them. See Who builds the Architecture: all packages.

  • version-tag — the tag every built version carries, identifying the suite it was built for, such as deb13. Optional: src2deb derives it from the suite for the numbered Debian releases. Name one when the recipe targets a suite outside that set, such as a rolling suite or a derivative.

    It names the tag for this recipe’s suite, and only that one. A --suite override supersedes it along with the suite it described, and the new suite derives its own tag or takes one from --version-tag. See Package versions.

    suite = "sid"
    version-tag = "debsid"
    
  • version-stamp — how this recipe’s packages order against the archive’s own packages of the same version: supersede (the default) or backport. Optional, and the default is right for software the archive does not carry. Set backport for a recipe of rebuilds, such as one built from Debian source packages; a component’s own version-stamp overrides it. See Rebuilds of packages the archive also ships.

    version-stamp = "backport"
    
  • maintainer — the identity src2deb signs a synthesized debian/changelog with, written as Debian writes it. Optional, and consulted only for a component that declares its version; a component’s own maintainer overrides it, and a component with neither takes the Maintainer its debian/control declares.

    maintainer = "Your Name <you@example.org>"
    
  • mirror — the primary archive mirror URL. Defaults to the Debian CDN. Name one to build against a local or regional mirror rather than deb.debian.org.

    mirror = "http://ftp.uk.debian.org/debian"
    

    An http:// or file:// URL. An https:// mirror is refused when the recipe loads, naming the same mirror over http:// as the remedy: an archive is authenticated by the signature on its Release, which is verified after the fetch and is what a tampered mirror fails, so the transport carries no part of that guarantee. Every .deb is then checked against the digest the signed index gives for it.

  • origin, label, description — what the pool’s Release declares itself to be. All optional, and all unwritten by default: an origin names the organization behind an archive, and src2deb has none to offer on your behalf. Name them to make the pool pinnable the way apt’s documentation leads with, and give the same values to every recipe built into one pool — a pool has a single Release, so the last recipe to publish writes it.

    An origin is a vendor name, not a hostname: apt matches the host separately, from the client’s own source URL. See Pinning against the pool.

    Each is one line of a Release, so a value carrying a line break is refused: the remainder would stand as a Release field of its own and the pool would claim something the recipe never said.

    origin = "Texor"
    label = "COSMIC for Debian"
    description = "COSMIC desktop packages for Debian"
    

Toolchain

The [toolchain.rust] table selects where the Rust compiler and Cargo come from. It is optional; the default is the archive’s own Rust.

  • provider — debian (the default) or rustup.

    • debian resolves rustc and cargo from the archive as ordinary build-dependencies. The build is only as new as the suite’s Rust.
    • rustup installs a pinned toolchain with rustup into the build root and prefers it on PATH, while the archive’s rustc and cargo stay installed to satisfy the declared build-dependencies. This decouples the compiler from the suite’s Rust.
  • version — the exact toolchain version, such as 1.95.0. Required when provider = "rustup".

    The value is the toolchain rustup is asked to install, so it takes any name rustup accepts — 1.95.0, stable, nightly-2026-01-01, or a target-qualified 1.95.0-x86_64-unknown-linux-gnu — and nothing else. Letters, digits, and ., -, _: anything more is refused rather than passed to the installer, where a stray space would quietly change what is installed.

Additional repositories

Each [[repositories]] entry adds an archive to resolve build-dependencies from, beyond the primary suite and the feed-forward pool.

  • name — a short identifier, unique within the recipe.

  • suite — the suite to resolve from. Defaults to the recipe’s primary suite, and follows a --suite override with it.

    A suite named here does not follow the override, because it names a specific archive rather than a variation on the primary one: trixie-backports has no automatic counterpart under --suite forky, and guessing one would resolve build-dependencies from the wrong release. A recipe that declares a suite here is a recipe for one target — leave it out to keep the recipe portable, or give each target its own recipe.

  • mirror — the archive mirror URL. Defaults to the recipe’s primary mirror, and is http:// or file:// on the same terms.

  • components — the archive components to enable. Defaults to ["main"].

  • trust-unsigned — trust the repository without verifying a signature, for a local or file:// archive under your control. Defaults to false.

  • keyring — the path to the binary OpenPGP keyring the repository’s release is verified against. Required for a signed repository; omitted for a trust-unsigned one.

A signed repository must name a keyring: the provisioner has no embedded trust anchor for an archive other than the primary Debian one.

The suite, mirror, and components become the fields of the deb line the provisioner writes into the build root’s sources.list.d, one archive to a line. A value carrying whitespace is refused, since the line would read it as two fields and configure an archive other than the one declared. The same holds for the recipe’s own mirror, which stands in that field for every archive that does not name one, and for the scheme each mirror carries.

Worked examples

A backports suite on the primary mirror, verified against Debian’s own archive keyring — the file debian-archive-keyring installs, which most Debian hosts already have:

[[repositories]]
name = "backports"
suite = "trixie-backports"
keyring = "/usr/share/keyrings/debian-archive-keyring.gpg"

A keyring is a binary OpenPGP keyring: the format gpg --export writes, and the format the files under /usr/share/keyrings/ are in. An ASCII-armoured key (.asc) is not one; convert it with gpg --dearmor. The path is read on the host, and only the keys it holds are used to verify that repository’s release.

A pool another src2deb run produced, read over file:// and trusted without a signature — which is what trust-unsigned is for, since src2deb pools are unsigned:

[[repositories]]
name = "prior-pool"
mirror = "file:///srv/build/work/pool/trixie/amd64"
trust-unsigned = true

Only trust an archive unsigned when you control both the archive and the path to it. See Using the pool.

Components

Each [[components]] entry is one buildable component: a source tree with a debian/ directory.

  • name — the component name, unique within the recipe.

  • source — where the component’s source comes from. A component names exactly one origin: source.git, source.path, source.tarball, or source.dsc. Naming more than one, or none, is refused.

    • source.git — the git repository URL to clone.

    • source.git-ref — the branch, tag, or commit to check out. Defaults to the remote’s default branch. It qualifies source.git, and setting it on a path source is refused rather than ignored.

    • source.path — a tree already on disk, built without being cloned. Relative to the recipe’s own directory, so a recipe kept beside the trees it builds names them relatively and moves with them; an absolute path is used as it stands.

      [[components]]
      name = "cosmic-comp"
      source.path = "../../checkouts/cosmic-comp"
      

      The tree is copied into the work directory and built from the copy, so a build never writes into it. Packages built from a path source are marked as such in their version and in the manifest, and --skip-published never skips one. See Building from a tree on disk.

    • source.tarball — a release archive to fetch and unpack, over https, http, or file.

      [[components]]
      name = "foo"
      source.tarball = "https://example.org/releases/foo-1.2.3.tar.xz"
      source.sha256 = "5f2e1a9c3b8d..."
      

      See Building from a release archive below.

    • source.dsc — a Debian source package to fetch and unpack, named by the URL of its .dsc.

      [[components]]
      name = "gtk2-engines-murrine"
      source.dsc = "https://deb.debian.org/debian/pool/main/g/gtk2-engines-murrine/gtk2-engines-murrine_0.98.2-4.dsc"
      source.sha256 = "85789dd8d50f..."
      

      See Rebuilding a Debian source package below.

    • source.sha256 — the SHA-256 the fetched file must hash to, in hexadecimal of either case. Required for source.tarball and source.dsc, and setting it on any other origin is refused rather than ignored.

    • source.subdir — a subdirectory within the source that holds the debian/ tree, for a component that lives inside a larger superproject. The whole source is the tree when unset. It applies to every origin, and must stay inside the source: a .. component or an absolute path is refused, since the tree it names is what the vendor pass binds into a cage that runs upstream’s own debian/rules clean.

  • packaging — where the component’s debian/ directory comes from, for a source that carries none of its own. Optional; see Packaging overlays below. It takes the same settings source does — packaging.git, packaging.git-ref, packaging.path, packaging.tarball, packaging.dsc, packaging.sha256, packaging.subdir — under the same rules.

  • patches — patch files applied over the resolved source tree, in the order given. Optional; see Patches below.

  • version — the upstream version to build the component as, for packaging that carries no debian/changelog. Optional; see Components with no changelog below. Exclusive with version-from.

  • version-from — where to derive that version instead of stating it. The one value is git-describe. Exclusive with version.

  • version-stamp — how this component’s packages order against the archive’s own package of the same version: supersede or backport. Optional, overriding the recipe’s. See Rebuilds of packages the archive also ships.

  • maintainer — the identity a synthesized changelog is signed with, overriding the recipe’s. Optional.

  • extra-build-deps — extra build-dependency package names beyond those debian/control declares. Rarely needed; most build-dependencies are discovered from the control file. Reach for it when a component’s build needs something its packaging does not declare — often a tool the vendor pass runs before dpkg-buildpackage sees the tree at all.

    [[components]]
    name = "cosmic-comp"
    source.git = "https://github.com/pop-os/cosmic-comp"
    extra-build-deps = ["just"]
    

    These are installed into the build root but create no edge in the build order, which is derived from debian/control alone. Naming a package another component produces will not order that component first — declare it in debian/control if it needs to be.

src2deb computes the build order from the components’ declared dependencies, so they may be listed in any order.

Packaging overlays

Not every upstream ships a debian/ directory. For many that do not, someone else’s packaging exists — a distribution’s packaging repository, or one of your own. Point a component at it:

[[components]]
name = "foo"
source.git = "https://github.com/example/foo"

packaging.git = "https://salsa.debian.org/debian/foo"
packaging.git-ref = "debian/latest"

packaging takes the same settings source does, resolved the same way: a git repository is cloned and checked out, a path is a tree already on disk, a git-ref selects the revision, and a subdir names the directory within it that holds debian/.

What is taken, and what is not

The overlay’s debian/ directory becomes the component’s. Nothing else is taken.

That boundary matters, because a distribution’s packaging repository usually carries a copy of the upstream tree beside its packaging — and that copy is not the source you are building, it is whichever release was last packaged. Taking it would silently replace your source with an older one. Only debian/ crosses over, so a repository of either shape works: one holding packaging alone, and one holding packaging beside a tree it happens not to be used for.

In the other direction, the overlay replaces any debian/ the source ships rather than merging with it. There is no per-file precedence to reason about: the packaging that reaches the build is the packaging you declared, with nothing of an abandoned one left beside it — no stale install file naming a path the new packaging never builds, no patches/series applied by a build that was never asked to. The source’s own debian/ is set aside for the build, not lost; drop the packaging setting and the next run has it back.

Both trees are recorded

A component with an overlay has two inputs, and both count:

  • The version carries both, source first: 1.0.0-1+deb13.20260731.abc1234.def5678.
  • The manifest records two [[component.source]] entries, each naming the part it played — role = "source" and role = "packaging". See The provenance manifest.
  • --skip-published rebuilds the component when either moves. New packaging against an unchanged source produces a new package, which is what you want the moment you fix a debian/rules.

SOURCE_GIT_HASH, which packaging reads to stamp a revision into what it builds, is the source’s commit and never the packaging repository’s.

Packaging kept beside the recipe

The other home for packaging is the recipe itself. Nothing has to be published or maintained in a second repository, and the packaging is versioned with the recipe that names it — which is what you want for a one-off, or for a component whose upstream will never carry a debian/ of its own.

[[components]]
name = "foo"
source.git = "https://github.com/example/foo"
packaging.path = "packaging/foo"

packaging.path is relative to the recipe’s own directory, as source.path and patches are, so the recipe directory holds:

recipes/mine/
├── recipe.toml
└── packaging/
    └── foo/
        └── debian/
            ├── control
            ├── rules
            └── ...

One directory per component under packaging/, each holding the debian/ tree to overlay. Nothing enforces that layout — packaging.path names any directory you like — but a recipe that follows it reads at a glance, and a component’s packaging sits where someone looking for it will look.

Nothing is ever written to a packaging source, so a path one is read where it lies rather than copied into the work directory.

What a path overlay is recorded as

An overlay from a repository is identified by the revision it was checked out at. One from a path is identified by a digest over the debian/ tree it supplied:

  [[component.source]]
  role = "packaging"
  kind = "tree"
  value = "483b0e8..."
  pinned = true

The digest covers exactly what src2deb copied — the debian/ directory and nothing beside it — so a README next to your packaging never provokes a rebuild, and editing debian/rules always does. It is over what the directory holds rather than where it is, so moving the packaging or renaming the recipe directory does not republish every component built from it.

That makes local packaging a pinned input, and a component overlaid from one is skippable by --skip-published exactly as one overlaid from a repository is. Note the contrast with source.path, which stays unpinned: a component’s own source is an arbitrarily large tree that src2deb copies and the build writes into, while an overlay contributes one small directory that nothing writes to, so it can be measured cheaply and exactly.

Packaging from a repository, with your own changes

There is one overlay per component, not a stack of them. To take a distribution’s packaging and change part of it, overlay theirs and patch the rest:

[[components]]
name = "foo"
source.git = "https://github.com/example/foo"
packaging.git = "https://salsa.debian.org/debian/foo"
patches = ["patches/0001-build-with-our-features.patch"]

The series is applied after the overlay, so a patch reaches the packaging the overlay supplied. That is deliberately the only way to do it: a second overlay winning per file would let the packaging you layered over drift out from under you with no signal at all, while a patch that no longer applies fails the component and names itself.

Building from a release archive

Most projects that are not Rust ones publish a release tarball rather than expecting you to build from a tag, and an upstream tarball beside a separate debian/ directory is the native Debian model. Name the archive and the digest it must hash to:

[[components]]
name = "foo"
source.tarball = "https://example.org/releases/foo-1.2.3.tar.xz"
source.sha256 = "5f2e1a9c3b8d4e7a2f9016c5b3d8e4a71f0c9d2b6e5a8347c1b0f9e2d6a4c8b1"
packaging.path = "packaging/foo"
version = "1.2.3"

https, http, and file URLs are all fetched, so a local mirror works as well as a release page. The archive may be uncompressed or compressed with gzip, xz, or zstd, read from its content rather than from the URL.

A release archive ships no debian/, so it needs packaging from elsewhere and a declared version. Both are shown above.

The digest is what makes it a source

source.sha256 is required, and the archive is verified against it before anything is unpacked — on every run, not only the first. Nothing about the transport is trusted: a hostile mirror, a broken proxy, and a truncated download all produce something that does not hash to what you declared, and the component fails rather than building it.

That is what makes an archive as good an input as a revision. What a URL serves can change; what it hashes to cannot.

pkg: the archive fetched from https://example.org/releases/foo-1.2.3.tar.xz
hashes to 91af22c..., and the recipe declares sha256 = "5f2e1a9...". Nothing
was unpacked.

A mismatch is worth reading twice. If the archive is the one you meant, correct the recipe. If it is not, you have found a mirror serving something other than what it did when the digest was written down.

The release directory is found for you

A release archive conventionally puts everything under one directory named for the release — foo-1.2.3/ — and src2deb descends into it, so the version does not go into the recipe twice. An archive laid out any other way is taken as it stands, and source.subdir applies within whichever you get.

The one directory never treated as a wrapper is debian/: a distribution publishes its packaging as an archive holding exactly that, and there the single directory is what is being supplied rather than something wrapped around it.

Fetched once, kept by digest

Archives are cached under <work>/tarballs/, named by their digests. Two components naming one archive fetch it once, a recipe that changes a digest names a different file rather than a stale one, and a host with no network — or no curl — still builds from what is already there.

The unpacked tree, by contrast, is replaced on every run, so each build sees the archive as it stands rather than the leavings of the run before.

Archive formats src2deb reads

An archive may be uncompressed or compressed with gzip, xz, or zstd, and may be a POSIX ustar, GNU, pax, or pre-POSIX v7 tar archive. Between them those are every format in circulation, so an archive that fails to unpack is a damaged file rather than an unsupported one. The failure names the archive it was reading:

pkg: unpacking https://example.org/releases/foo-1.2.3.tar.gz: ... is neither a
recognized compression format nor a tar archive. src2deb reads ustar, GNU, pax,
and v7 tar archives, optionally compressed with gzip, xz, or zstd

Compression is detected from the stream’s content rather than from the file name, so an archive served under a name that does not match its contents still unpacks.

Rebuilding a Debian source package

A component may be built from a Debian source package — the .dsc and the tarballs it names. That makes src2deb a rebuild engine: take a package from one suite, build it for another, and publish it in your own pool.

version-stamp = "backport"

[[components]]
name = "gtk2-engines-murrine"
source.dsc = "https://deb.debian.org/debian/pool/main/g/gtk2-engines-murrine/gtk2-engines-murrine_0.98.2-4.dsc"
source.sha256 = "85789dd8d50f0030fb2202dd84a99500e695e39bc947cdfb36c53c3ecf64bc61"

That is the whole recipe entry. A source package already carries its own debian/ directory and its own changelog, so it needs no packaging overlay and no declared version — everything a component normally has to be given, it brings.

version-stamp = "backport" is not required, but it is almost always what you want here: the archive ships this package too, and the setting keeps your rebuild from outranking the archive’s own copy forever. See Rebuilds of packages the archive also ships.

One digest pins the whole package

source.sha256 is the digest of the .dsc itself. The .dsc in turn declares the SHA-256 of every file it names, and each of those is verified before it is unpacked — so the one hash in your recipe reaches the upstream tarball and the packaging tarball alike.

A published .dsc is PGP-signed, and src2deb reads through the signature without checking it. That is deliberate: the signature says who published the file, and your declared digest says which file, which is the stronger claim and the only one that answers “did I get the bytes this recipe was written against”. If you want the signature checked, check it once when you write the digest down.

The patch series is applied by the build, not by src2deb

src2deb assembles the tree from the tarballs and stops there. A 3.0 (quilt) package’s patches arrive in debian/patches/ unapplied, and dpkg-buildpackage applies them inside the cage — it calls dpkg-source --before-build as its first step, before it even checks build-dependencies.

So the tool that owns the source format is the one that unpacks it, and nothing on your build host needs to understand quilt. The tree src2deb hands to the build is byte-for-byte what dpkg-source -x --skip-patches produces.

No vendor pass

This is src2deb’s one fully hermetic source kind. Every other source runs pass 1 — debian/rules clean in a cage with the host network, so a component that vendors its dependencies has them before the offline build. A Debian source package already carries everything its build needs; that is what makes it a source package. So the pass is skipped, and the build runs start to finish in an isolated cage.

Formats

3.0 (quilt), 3.0 (native), and native 1.0 source packages are built, which between them is nearly the whole archive. Supplementary upstream tarballs (foo_1.0.orig-docs.tar.xz) are unpacked at their component’s name, as dpkg-source does.

A 1.0 package that carries a .diff.gz is refused, naming the alternative:

pkg: https://.../foo_1.0-1.dsc: declares format "1.0" with the patch file
"foo_1.0-1.diff.gz", which src2deb does not apply. Build this package from a
source that carries its packaging as a tree — a git repository, or a packaging
overlay — rather than from its .dsc

The archive formats above apply to a source package’s tarballs as they do to a release archive.

Taking only the packaging

packaging.dsc takes a source package’s debian/ directory and nothing else, which is how you build upstream’s own git against a distribution’s packaging:

[[components]]
name = "foo"
source.git = "https://github.com/upstream/foo"
packaging.dsc = "https://deb.debian.org/debian/pool/main/f/foo/foo_1.2.3-1.dsc"
packaging.sha256 = "..."

Only the files that can carry a debian/ are fetched, so an overlay does not download an upstream tarball it would then ignore. Note that the packaging’s debian/patches were written against its upstream: if yours has moved, the build fails when dpkg-source cannot apply them, which is the right place to find out.

Components with no changelog

A component’s version normally comes from its own debian/changelog, which src2deb extends with the suite, the date, and the source revision. Packaging assembled from a packaging overlay often has no such history: a control and a rules are enough to build with, and a changelog is not something you want to maintain by hand for a package that is rebuilt from source every time.

Declare the version instead, and src2deb writes the changelog:

[[components]]
name = "foo"
source.git = "https://github.com/example/foo"
packaging.path = "packaging/foo"
version = "1.2.3"

The entry it writes carries the version you declared, the source package name debian/control declares, and a maintainer identity — and the ordinary stamping path then extends it, so the package is versioned exactly as any other:

foo (1.2.3+deb13.20260731.abc1234.def5678) trixie; urgency=medium

Everything downstream reads that changelog, including the vendor pass, so the component builds like any other.

Deriving the version from a tag

A project that tags its releases can state its version once, in the tag:

[[components]]
name = "foo"
source.git = "https://github.com/example/foo"
packaging.path = "packaging/foo"
version-from = "git-describe"

git describe --tags runs against the resolved source, and its output becomes a version: a leading v is dropped, and hyphens become dots so the Debian revision boundary stays where the stamp puts it.

Tag stategit describeVersion
On the tagv1.2.31.2.3
Four commits past itv1.2.3-4-gabc12341.2.3.4.gabc1234

Those order the way the history does, so a build from a later commit supersedes one from an earlier commit even before the build date is taken into account.

A source with no tag in its history has no version to derive, and the component is refused rather than versioned from an abbreviated commit that would not order against the build before it. State the version with version in that case.

git describe reads the repository the source was resolved into, not the source.subdir within it — a member of a superproject takes the superproject’s tag, because that is the only tag there is.

Where the maintainer comes from

The entry is signed with the first identity that is declared:

  1. the component’s own maintainer,
  2. the recipe’s maintainer,
  3. the Maintainer field in the component’s debian/control.

Debian policy makes that last field mandatory, so packaging complete enough to build already carries an identity and most recipes declare nothing at all. src2deb never invents one: a component that declares a version with no identity anywhere is refused.

Write an identity as Debian writes it, Name <email>. An identity with no address, or one carrying a line break or two consecutive spaces, cannot be read back out of a changelog trailer and is refused by the recipe.

A declared version replaces the changelog

version and version-from are the authority wherever they are set. If the assembled tree does ship a debian/changelog, the declared version replaces it — the same rule a packaging overlay follows, and for the same reason: one authority for the version, with no per-entry precedence to reason about. That is what lets you build a project whose upstream changelog has been frozen for years at the version it actually has.

The consequence is that the shipped package carries the declared entry and the stamped one above it, and not the history it replaced. Leave version out for a component whose changelog you want kept.

Dropping the setting restores the tree’s own changelog on the next run, exactly as dropping a packaging overlay does.

The declaration is part of what was built

--skip-published compares the declared version alongside the source fingerprint, so editing version rebuilds the component even though every tree it resolves is byte-identical. The manifest records it as version on the component. See The provenance manifest.

Patches

A component may carry local fixes upstream has not taken. Declare them per component, in the order they apply:

[[components]]
name = "cosmic-comp"
source.git = "https://github.com/pop-os/cosmic-comp"
patches = [
  "patches/cosmic-comp/0001-fix-build-on-trixie.patch",
  "patches/cosmic-comp/0002-relax-a-dependency.patch",
]

Each path is relative to the recipe’s own directory, as source.path is, so a recipe carries its patches alongside it. Keeping them under a directory named for the component is a convention, not a requirement.

The series is applied to the tree src2deb resolved — a git checkout, or its copy of a source.path tree — and never to anything of yours. It is applied last, so a patch may change a file a packaging overlay supplied, and before anything reads the tree, so a patch may change debian/control and the build order follows the patched file.

What a patch may be

Anything git apply accepts: a plain unified diff, a git format-patch output, a patch that adds or deletes files, one that changes a file’s mode. Paths are read at -p1 — the a/ and b/ prefixes git writes — and must stay inside the tree.

Patches apply to either kind of source, over a packaging overlay if there is one, and to a subdir component they apply relative to the subdirectory that holds debian/.

A patch either applies or the component fails

There is no fuzz, no three-way merge, and no .rej file left behind. A patch that no longer matches the source it was written against fails the component, naming the patch:

src2deb: FAILED cosmic-comp: resolving source for cosmic-comp: patch
recipes/cosmic-epoch/patches/cosmic-comp/0001-fix-build-on-trixie.patch does not
apply to /work/sources/cosmic-comp: error: patch failed: src/shell/mod.rs:412

A partly-patched tree is not something to build a package from, so the component stops rather than continuing with whatever did apply. Under --keep-going the rest of the run carries on without it, as with any other resolve failure.

Patches are part of what a package was built from

The series is a pinned input to the component’s fingerprint, identified by a digest over its members’ contents in order. That has three consequences:

  • The version carries it, after the source revision: 1.0.0-1+deb13.20260731.abc1234.5f2e1a9. A patched package and an unpatched one built from the same revision on the same day are therefore distinct, and ordered.
  • The manifest records it as an input of kind patches. See The provenance manifest.
  • --skip-published rebuilds the component when the series changes. Editing a patch, adding one, removing one, or reordering them all count; renaming a patch file does not, since the same patches in the same order produce the same tree.

Removing a patch removes its effect, including any file it added. A source checkout persists between runs and a patch’s new files are untracked, so this is not something a re-checkout would do on its own — src2deb clears what the last run’s series left, so a component always builds the tree its recipe currently describes.

What this is not

patches applies a series and nothing more. It does not manage one: there is no command to add, refresh, or rebase a patch, and none to record one from a modified tree. Use git for that, on a branch of the upstream source, and export with git format-patch.

Nor is it debian/patches. src2deb applies the series directly to the tree, so it works whatever format the source is in and whether or not the packaging uses quilt. A component whose upstream debian/patches you want to extend is better served by patching debian/patches/series itself with one of these.

Recipes in this repository

Three recipes ship with src2deb. Each has a README covering what it builds, the upstream it builds from, and how to run it — together they are the worked examples for everything above.

RecipeBuilds
cosmic-epochThe COSMIC desktop: 27 components from Pop’s debian/ trees, with a pinned rustup toolchain
pop-desktop-dataThe theme, icon, font, and metadata packages COSMIC depends on at runtime
cosmic-debianThe cosmic-desktop metapackage and a compatibility package for a dependency name Debian has retired

All three belong in one pool, and share a work directory to get there. Build them for the same suite and architecture, and apt install cosmic-desktop installs the result. See Using the pool.

Cross-architecture builds

A recipe names the architectures it builds for, and a run builds each of them in turn. A recipe that names none builds for whichever host runs it, and --architecture selects any other target. Building foreign bootstraps the build root for the target and runs the target’s dpkg, maintainer scripts, and dpkg-buildpackage through a qemu-user binfmt handler.

Selecting the targets

architectures takes a list of Debian architecture names:

name = "cosmic-epoch"
suite = "trixie"
architectures = ["amd64", "arm64"]

--architecture names them on the command line instead, and applies to both build and plan. It is repeatable, and replaces whatever the recipe names rather than adding to it:

src2deb build recipes/cosmic-epoch --architecture arm64
src2deb build recipes/cosmic-epoch --architecture amd64 --architecture arm64

A recipe is most portable when it leaves architectures out entirely; name them in the file only when the recipe is meaningful for a fixed set. src2deb prints the effective targets in its opening banner.

The produced .debs carry the target architecture, the local pool indexes them under binary-<arch>, and the provenance manifest records the architecture built for. Each architecture gets a pool, an output tree, a manifest, and a build root of its own, keyed alongside the suite, so runs for several targets share one work directory without overwriting one another. That holds for Architecture: all packages too, whose file names carry no architecture at all.

What a multi-architecture run shares

The architectures are built one after another, in the order they are named, and everything before the first build is done once for the run:

  • Sources resolve once. Every architecture is built from the same checkouts, at the same commits, with the same patches applied. Two separate runs cannot promise that — a git-ref naming a branch may move between them — so a run that targets both is the way to get one set of packages built from one set of sources.
  • The build order is computed once, from those sources’ debian/control files, and every architecture follows it.
  • The build date is one date, so every package the run produces carries the same stamped version whichever architecture built it. See Package versions.

What each architecture settles for itself is its build roots, its pool, its output tree, its manifest, and — through that manifest — what --skip-published skips.

--jobs N parallelizes the components within one architecture, not the architectures themselves. Two emulated builds running alongside each other contend for the same cores and the same package cache without finishing sooner.

A run stops where it is when something goes wrong: a component that fails ends the run unless --keep-going is passed, and a cancel ends it outright. Either way the architectures after it are never started, and the summary says so:

src2deb: the run stopped before building for arm64

The same holds for a failure that is not a component’s — a build root that will not provision, most often a foreign target with no binfmt handler registered. src2deb normally reports such a failure alone, since a run that never built anything has nothing to summarize; but once an architecture has published its packages and written its manifest, that work stands, so the summary is printed and the error appears beneath it:

src2deb: summary (amd64): 26 built, 0 failed, 0 skipped of 26 component(s)
src2deb: 120 artifact(s) produced, in work/out/trixie/amd64
src2deb: summary: 1 architecture(s), 120 artifact(s) in total
src2deb: provisioning a build root: no binfmt handler for arm64
src2deb: the run stopped before building for arm64

Nothing was recorded for the architecture that failed: an architecture writes its manifest only once its components are done, so a later run starts it from clean.

Who builds the Architecture: all packages

An Architecture: all package is architecture-independent: one build serves every architecture. Its file name carries no architecture, and the version src2deb stamps does not vary with one either — so building a recipe for two architectures produces cosmic-icons_1.0+deb13.20260731.abc1234_all.deb twice, under one name and one version, over two different sets of bytes.

Locally that is harmless, because each architecture has a pool of its own. It stops being harmless the moment those architectures merge into a single published archive, where one name and version must mean one file.

By default every architecture produces its own arch-indep packages, so each pool holds every package its recipe declares and can be served exactly as it stands. A run building for several says so before it starts spending the time:

src2deb: no arch-indep owner named, so each of amd64, arm64 builds its own copy
of every Architecture: all package; set arch-indep-owner to build them once

arch-indep-owner hands them to one architecture instead:

name = "cosmic-epoch"
suite = "trixie"
architectures = ["amd64", "arm64"]
arch-indep-owner = "amd64"

or, per run, --arch-indep-owner amd64. Every other architecture then builds only its architecture-dependent packages (dpkg-buildpackage -B rather than -b), and a component whose every binary package is Architecture: all is skipped outright for those architectures — there is nothing left of it to build. An architecture in that position says so before it starts:

src2deb: Architecture: all packages belong to amd64; this architecture builds
only its own, so its pool holds fewer packages than the recipe declares

Which to choose follows from what you do with the pool:

Leave arch-indep-owner unsetName an owner
Each architecture’s poolComplete; servable as it standsMissing the arch-indep packages
Architecture: all packagesBuilt once per architectureBuilt once in total
Emulated build timeSpent rebuilding packages that contain no compiled codeNot spent
BytesOne set per architectureOne set

Name an owner when several architectures feed one published archive. Leave it unset when you serve a per-architecture pool directly — as a test machine does — since that pool has to carry every package the recipe declares.

Either way, src2deb export carries one copy of each Architecture: all package: with no owner declared it has two to choose between, and it says which it took. Naming an owner is what stops the second from being built at all.

One case an owner does not cover: a component that produces only Architecture: all packages, and whose packages another component build-depends on. A non-owner architecture skips that component, and the owner’s copy is published to the owner’s pool, which the non-owner’s build never resolves against. src2deb refuses such a run before provisioning anything:

src2deb: unsatisfiable build-dependency: this run builds "consumer", which
build-depends on "shared-data"; that package is produced by component "data",
which produces only Architecture: all packages, left to "amd64" by this recipe,
and the pool does not hold it. Stop naming an arch-indep owner, so this
architecture builds "data" itself; the owner's copy is published to the owner's
pool, which this build does not resolve against.

Native and foreign

A build is native when the host CPU runs the target’s binaries directly. Identical architectures are native, and an amd64 host runs i386 binaries through its IA-32 compatibility mode, so both need no emulator. Every other pair is foreign. The relation is directional — an i386 host cannot run amd64. src2deb announces a foreign build before provisioning.

A foreign build is emulated rather than cross-compiled: the target’s own toolchain runs under qemu-user, instead of a host toolchain emitting target code. A foreign build is therefore identical in shape to a native one — the same packages, the same debian/rules, the same build-dependency resolution — at the cost of running every compiler invocation under emulation.

Emulation costs roughly an order of magnitude in compile time, which a large Rust source tree turns into hours. Where hardware native to a target is available, prefer running that target there; name the architectures in one run when it is not.

Requirements

A foreign build needs a qemu-user binfmt handler for the target, registered with the fix-binary (F) flag so the interpreter is preloaded and keeps working after a cage pivots into the target root. On Debian and derivatives:

sudo apt install qemu-user-static binfmt-support

The qemu-user-static package registers the handlers with the F flag. Without a registered handler, provisioning a foreign root fails with a message naming the missing handler.

The interpreter is recorded

A changed emulator silently changes compiled output, so a foreign run records which one it ran through — the qemu target name, the path the kernel has registered, that path canonicalized, its SHA-256, whether the handler is enabled, and its flags. See The interpreter record, where the digest’s one caveat is stated: the F flag means the kernel holds the interpreter from registration time, so a digest taken during a build is of the file on disk rather than necessarily of the bytes that ran.

The values come from binfmt_misc rather than from a PATH lookup, which is both the faithful answer and the only available one — a build cage carries no PATH that would find the host’s qemu.

A native run records no interpreter.

Building across hosts

src2deb does not orchestrate hosts. Where several architectures have hardware of their own, run src2deb once per host and collect the resulting pools — each is a complete archive for its own architecture, unless an arch-indep owner is named, in which case only the owner’s carries the Architecture: all packages.

Where several architectures share one work directory, one src2deb export collects all of them into a single directory for an archive; where they do not, each host exports its own.

A --only smoke test still resolves every component’s source, because the build order is read from all of them — so the first run against a fresh work directory clones the whole recipe whatever it goes on to build. The saving is in the building, which under emulation is where the hours are. Later runs fetch rather than clone.

Command line

Five subcommands: build runs a recipe, plan resolves and orders one without building, export copies what a work directory holds out for an archive, prune removes what the pool no longer serves, and check reports whether the pool’s packages can be installed. All five take a recipe directory and the same target options.

src2deb build  RECIPE_DIR [options]
src2deb plan   RECIPE_DIR [options]
src2deb export RECIPE_DIR --to DIR [options]
src2deb prune  RECIPE_DIR [options]
src2deb check  RECIPE_DIR [options]

RECIPE_DIR is a directory containing a recipe.toml. Exactly one is required, and it may appear before or after the options.

Each subcommand takes the work directory’s exclusive lock for as long as it runs, so only one of them works on a --work directory at a time.

Target options

Each overrides the corresponding recipe field, so one recipe serves every target it builds against.

OptionSubcommandsEffect
--work DIRallThe working directory for sources, build roots, the package cache, the pool, and output. Defaults to ./work
--suite SUITEallBuild for a Debian suite such as trixie or forky, superseding the recipe’s suite and the version-tag that described it
--architecture ARCHallBuild for a Debian architecture such as amd64 or arm64, replacing whatever the recipe names. Repeatable: build and plan build each in turn. A recipe naming none builds for the host. For export, prune, and check it narrows what is read rather than retargeting anything
--arch-indep-owner ARCHbuild, plan, exportLeave the recipe’s Architecture: all packages to ARCH. Unset, every architecture produces its own
--version-tag TAGbuild, planStamp built versions with TAG, such as deb13, overriding both the recipe’s version-tag and the tag derived from the suite

The last two are not accepted where they would do nothing: an export carries packages a run already stamped, and a prune and a check read a pool, so none of them has a version to tag, and neither a prune nor a check has arch-indep output to hand anywhere. Naming one there is a usage error rather than a silently ignored flag.

Every value is validated as it is parsed, so a malformed one is a usage error against the flag rather than a failure partway into the run — including a --architecture naming the same architecture twice. Each suite and architecture pair gets its own pool, output tree, manifest, and build roots, so runs for several targets share one --work directory. See Cross-architecture builds and Package versions.

Build options

OptionEffect
--keep-goingBuild the remaining components after one fails and report a final tally. Covers a component whose source will not resolve as well as one whose build fails
--jobs NBuild up to N components concurrently, respecting the dependency order. Defaults to 1
--only CBuild only component C. Repeatable
--from CBuild component C and every component after it in the build order
--skip-publishedSkip a component whose source resolves to what a prior run recorded as built, at the same declared version. A source that is not pinned to exact content is always rebuilt
--build-date DATEStamp every version with DATE (YYYY-MM-DD) instead of today, and hand the build the same SOURCE_DATE_EPOCH. --build-date manifest takes the date the prior run recorded
--keep NPrune the pools the run reached to the newest N versions of each binary package once the run has finished. Unset, nothing is pruned

--only and --from are mutually exclusive, and --jobs takes an integer of 1 or more.

Both --only and --from narrow a run to part of its recipe, so whatever the selected components build-depend on comes from the archive or from the pool. A selection that leaves out a component producing one of those build-dependencies is refused before anything is provisioned, naming the component to add. See What a narrowed run needs from the pool.

Plan options

OptionEffect
--build-depsPrint each component’s build-dependencies alongside the order
--runtime-depsReport what the recipe’s packaging declares that the target cannot satisfy

plan still clones every component’s source, because the build order is read from every debian/control. It takes the same exclusive lock on its work directory that build does, so planning while a build runs wants a --work directory of its own.

--runtime-deps reads each component’s packaging for the runtime relationships it declares and answers them against the target suite, the recipe’s repositories, the pool as it stands, and the packages the recipe itself will build. See Before the build.

The order is one answer whatever the recipe targets, since neither the sources nor the order depends on an architecture. What does is announced as the plan runs: each architecture the recipe names, whether it is foreign, and whether it would produce the recipe’s Architecture: all packages.

A component that declares its version gets a line for it, which is where to see what version-from = "git-describe" derived before a build stamps it into a package:

  1. foo @ source 1f3a9c2e5b7d, packaging 8d4b0e1c7a92
     version: 1.2.3

Export options

OptionEffect
--to DIRWrite the export to DIR/<suite>/. Required
--architecture ARCHCarry only ARCH. Repeatable. By default an export carries every architecture the work directory records a build for

An export carries every component the work directory records as built, not only what the last run produced, and replaces whatever the export before it left in the same directory. See Publishing to an archive.

Prune options

OptionEffect
--keep NKeep the newest N versions of each binary package. Defaults to 1, the version the pool’s index names
--dry-runReport what would be removed without removing it
--architecture ARCHPrune only ARCH’s pool. Repeatable. By default every pool the suite holds is pruned

Pruning covers the pool for a suite, which is shared by every recipe built into the work directory for it. See Pruning the pool.

Check options

OptionEffect
--architecture ARCHCheck only ARCH’s pool. Repeatable. By default every pool the suite holds is checked

A check resolves each pool package’s Depends and Pre-Depends against the target suite, the recipe’s repositories, and the pool, and reports what nothing satisfies. It exits non-zero when anything is unsatisfiable, so it gates a publish; a build ends with the same check over the pools it published into, as a note that does not fail the run. See Checking installability.

Verbosity

OptionPrints
-q, --quietFailures, cancellation, and the closing summary
(default)The progress narrative, the provisioning counters, the shared base’s package count, any note that the run’s guarantees changed, and each build’s in-cage output
-v, --verboseAdds per-component resolve and vendor detail, each root’s own package count, and per-package provisioning detail

Given both, the last on the command line wins. See Provisioning progress.

Information

OptionEffect
-h, --helpPrint usage and exit
-V, --versionPrint the version and exit

Either wins over the rest of the command line, so src2deb build recipes/x --help prints usage.

Streams

Progress, notes, and the closing summary go to standard error. The build order plan produces goes to standard output, so it stays pipeable while a run narrates alongside it:

src2deb plan recipes/cosmic-epoch | tail -5

In-cage build output is passed through to standard error, indented by two spaces. Under --jobs N each line also carries its component’s name. See In-cage build output.

Exit status

StatusMeaning
0Every selected component built, or was skipped as already built; an export or prune finished; or a check found every dependency satisfiable
1A component failed, the run stopped before the build phase, or a check found a dependency nothing satisfies
2A usage error: an unknown option, a malformed value, a missing --to, or a selection naming a component the recipe does not have
130The run was cancelled with Ctrl-C or SIGTERM

130 outranks a component failure: a cancelled run did not finish, so nothing follows about the components it never reached. The failure is still in the summary and the manifest. See Cancelling a run.

Package versions

Every package src2deb builds carries a stamped version: the version the component’s own debian/changelog declares, extended with the suite it was built for, the build date, and the source revision.

1.0.0~alpha.7-1+deb13.20260731.abc1234
└──────┬──────┘ └─┬─┘ └──┬───┘ └──┬──┘
   upstream      suite   date   revision

Why builds are stamped

A component’s version comes from upstream’s debian/changelog, which does not move when only the toolchain or the packaging around it does. Two builds of the same pinned revision would otherwise produce the same version, and apt would never offer the second as an upgrade over the first — so a rebuild carrying a fixed compiler or a patched vendored dependency would never reach anyone who already installed the first.

The stamp makes each build distinct and ordered, which is what an archive needs in order to serve upgrades at all.

How the parts order

The format is chosen for how dpkg compares versions.

  • + opens the suffix. An empty string sorts before any character except ~, so a stamped build sorts after the plain upstream version it was built from. A component rebuilding a package the archive also ships opens it with ~ instead, so the build sorts before it — see Rebuilds of packages the archive also ships.
  • The suite appears as deb13, not trixie. Spelled out, forky sorts before trixie, so a user moving from trixie to forky would see the forky packages as a downgrade and apt would refuse the upgrade. Release numbers sort the way the releases do.
  • The date is YYYYMMDD. Digit runs compare numerically, so each build sorts after the one before it.
  • The revision is the first seven characters of the commit, which makes the source a package was built from legible from apt policy alone. A component built from more than one input carries each of them, joined with ..

Compare two versions with dpkg --compare-versions to confirm any particular pair orders the way you expect.

More than one input

A component built from more than one input carries an abbreviation of each, in the order they were applied. A component with patches is the ordinary case: the upstream revision, then the series applied over it.

1.0.0~alpha.7-1+deb13.20260731.abc1234.5f2e1a9
                                └──┬──┘ └──┬──┘
                              revision   patches

A patched package and an unpatched one built from the same revision on the same day are therefore distinct versions, and ordered — so a fix reaches a machine that already installed the build without it.

A packaging overlay sits between the two, so a component taking its debian/ from a second repository and carrying a local fix besides reads:

1.0.0~alpha.7-1+deb13.20260731.abc1234.def5678.5f2e1a9
                                └──┬──┘ └──┬──┘ └──┬──┘
                              revision  packaging patches

The source’s revision is always first, which is what makes the leading abbreviation mean the same thing on every package src2deb builds.

A component built from a release archive or a Debian source package carries the first seven characters of the digest it was verified against, in the place a commit would sit. It abbreviates the same way for the same reason: it pins exactly what the build consumed.

Rebuilds of packages the archive also ships

Everything above assumes nothing else publishes the package you are building. That holds for software the archive does not carry, and it is why a stamped build outranks the version it was built from: each rebuild supersedes the last, and no one else is claiming the name.

Rebuilding a package Debian ships is the other case. If your build of 0.98.2-4 outranks Debian’s own 0.98.2-4, it wins wherever both are available — including after an upgrade to the suite whose source you built from, where the machine keeps your binary forever instead of moving to the archive’s. That is the trap Debian’s ~bpo convention exists to avoid.

Set version-stamp = "backport", on the recipe or on the component:

[[components]]
name = "gtk2-engines-murrine"
source.dsc = "https://deb.debian.org/debian/pool/main/g/.../gtk2-engines-murrine_0.98.2-4.dsc"
source.sha256 = "85789dd8d50f..."
version-stamp = "backport"

The stamp then opens with ~ rather than +:

0.98.2-4~deb14.20260802.85789dd

~ sorts before everything, including the end of a version, so this sits below the archive’s plain 0.98.2-4. That single character is the whole difference — nothing else about the stamp changes, and every ordering the stamp promises still holds:

below the archive’s own package0.98.2-4~deb14.20260802.85789dd < 0.98.2-4
above the archive’s earlier versions0.98.2-3 < 0.98.2-4~deb14.20260802.85789dd
a later build supersedes an earlier one...~deb14.20260802... < ...~deb14.20260803...
a later suite supersedes an earlier one...~deb13.20260802... < ...~deb14.20260802...

So your rebuild still reaches a machine that has the archive’s older version, and still upgrades itself, and still steps aside the moment the archive catches up.

Choose it when you add the component

Turning backport on for a package you have already published lowers its version, and apt does not offer a downgrade — machines that installed the earlier build keep it until something outranks it. Decide when the component is first declared.

Because the setting changes a package’s version while leaving every input byte-identical, it is recorded in the manifest and compared by --skip-published: changing it rebuilds and republishes rather than skipping.

A local build says so

A component built from a source.path tree has no revision to abbreviate — a path says where a tree was read from, not what it held — so it carries local where a git build carries a commit:

1.0.0~alpha.7-1+deb13.20260731.local

That is deliberate. A package built from someone’s working tree is not a package anyone can reproduce, and the version says so in the one place everybody looks. apt policy shows it without the manifest being consulted, and a local build and a published one are never mistaken for each other. See Building from a tree on disk.

One consequence is worth knowing before it surprises you: successive local builds are distinguished only by the date, so two builds of a changed tree on the same day stamp the same version. apt upgrade sees nothing to do. Install the new .deb directly with dpkg -i, which reinstalls a matching version, or pass a distinct --build-date to separate the two.

Packaging that ships no changelog

Not every component has an upstream version to extend. Packaging assembled from a packaging overlay is often a control and a rules and nothing else — no release history, and so no version for the stamp to build on.

Such a component declares its version in the recipe, and src2deb writes the debian/changelog the packaging lacks:

[[components]]
name = "foo"
source.git = "https://github.com/example/foo"
packaging.path = "packaging/foo"
version = "1.2.3"
foo (1.2.3) UNRELEASED; urgency=medium

  * Version declared by the build recipe; this source carries no changelog of its own.

 -- Your Name <you@example.org>  Fri, 31 Jul 2026 00:00:00 +0000

That entry is a base and not a build record. The stamping path above extends it exactly as it extends an upstream changelog, so the package is versioned by one code path however its version was arrived at:

1.2.3+deb13.20260731.abc1234.def5678

version-from = "git-describe" derives the version from the source’s own tags rather than stating it. See Components with no changelog for both, and for where the maintainer identity comes from.

The declared version is compared by --skip-published alongside the source fingerprint, so editing it rebuilds the component — which it has to, since every tree the component resolves is unchanged and only the version moved.

The date is the build date

The date is when the build ran, not when the source was committed. A rebuild of unchanged pinned sources therefore still supersedes its predecessor, which is what lets a rebuild ship a fixed toolchain to users who already installed the previous one.

The cost is that a rebuild which changes nothing still looks like an upgrade. How often the stamp moves is decided by how often a build runs.

Every component in a run shares one date, taken once when the run starts and in UTC, so a run that spans midnight or builds components in parallel still produces one coherent set.

Pinning the date

--build-date fixes the date instead of taking today’s:

src2deb build recipes/cosmic-epoch --build-date 2026-07-31

The date settles more than how the packages are versioned. src2deb writes it into the changelog entry it prepends, and dpkg-buildpackage derives SOURCE_DATE_EPOCH from that entry — so the build itself sees the same clock, which is what timestamps embedded in the packages are made from. Two runs from the same pinned sources with the same --build-date therefore produce not just the same version but the same build conditions.

That is what makes a build reproducible enough to check. Without it, every run carries a different date, so no two runs can ever be compared.

--build-date manifest takes the date the prior run recorded, which reproduces that build without transcribing anything:

src2deb build recipes/cosmic-epoch --build-date manifest

The run says which date it settled on before it starts:

src2deb: stamping every version with build date 2026-07-31

A work directory that holds no build of this recipe for this suite and architecture records no date, and the run is refused rather than quietly falling back to today — which would produce a build that looks like a reproduction and is not.

One date stamps the whole run, so a run targeting several architectures reads every one of their manifests and needs them to agree. Architectures recorded on different days are refused for the same reason, naming both:

src2deb: cannot settle the build date: the build date was to be taken from the
prior manifests, and they disagree: amd64 records 2026-07-30, arm64 records
2026-07-31; reproduce one architecture at a time, or name the date outright

The default stays today’s date. A moving date is what lets a rebuild reach a machine that already installed the build before it, and that is what an ordinary build wants; pinning is for verifying a build already made.

Verifying a build

The manifest records everything a rebuild needs: the source each component resolved to, the date the run was stamped with, and the .buildinfo each build produced. See The provenance manifest.

  1. Build normally. The manifest records the run.
  2. Rebuild from the same recipe with --build-date manifest, into a work directory of its own so nothing of the first run is reused.
  3. Compare. The stamped versions match exactly when the sources have not moved; the two .buildinfo files name what each build ran against, and differ where the archive moved beneath them.

Pin the recipe’s git-ref values to commits for step 3 to mean anything — a branch ref resolves to whatever upstream has since pushed, and the manifest’s recorded source will say so.

src2deb provides the inputs for this comparison and does not perform it. Compare the artefacts with whatever tool you prefer.

The version tag

The deb13 part is the version tag. src2deb knows the tag for each numbered Debian release, and takes it from the recipe’s suite:

SuiteTag
bookwormdeb12
trixiedeb13
forkydeb14
dukedeb15

A qualified suite takes the tag of the release it qualifies, so trixie-backports tags as deb13.

A recipe targeting a suite outside that set names its own tag:

suite = "sid"
version-tag = "debsid"

src2deb does not guess one. A rolling suite carries no release number, and a tag that does not order the way the releases do is the trap the tag exists to avoid, so a recipe naming an unknown suite is refused until it declares a tag.

The tag follows the suite

A recipe’s version-tag names the tag for the suite that recipe declares, and only that one. When --suite retargets a run, the tag goes with it: the recipe’s own is set aside, and the new suite’s tag is derived. A recipe declaring suite = "sid" and version-tag = "debsid", built with --suite trixie, stamps +deb13.

Anything else would defeat the ordering the tag exists to give. A package built against trixie but stamped debsid claims a suite it was not built for, and two suites’ builds tagged alike do not order against each other at all — which is the failure the tag is there to prevent.

--version-tag names one directly, overriding both the recipe’s and the derived tag. It is what makes a suite src2deb has no tag for buildable without editing the recipe:

src2deb build recipes/cosmic-epoch --suite sid --version-tag debsid

A --suite src2deb does not know, with no --version-tag alongside it, is a usage error rather than a silent fallback.

A tag may contain only the characters a Debian revision allows — alphanumerics, +, ., and ~. The sharp one this excludes is -: a version’s Debian revision begins at its last hyphen, so a tag carrying one moves that boundary and splits the version somewhere other than where it reads as splitting. 1.0.0-1 tagged deb-13 yields upstream 1.0.0-1+deb with revision 13.20260731.abc1234, which still compares, just not as anything intended.

Where the stamp is applied

src2deb prepends a debian/changelog entry declaring the stamped version, so dpkg-buildpackage builds it the way it builds any other version. The entry reuses the maintainer identity from the changelog it sits above, and records the source revision in its text:

cosmic-comp (1.0.0~alpha.7-1+deb13.20260731.abc1234) trixie; urgency=medium

  * Automated build from source abc1234def5678.

 -- Pop Packaging <pop@example.invalid>  Fri, 31 Jul 2026 00:00:00 +0000

Each input names the part it played, so a component assembled from more than one reads without any of them having to be guessed at:

  * Automated build from source abc1234def5678, packaging def5678abc1234,
    patches 5f2e1a9c3b8d.

A source.path tree appears as local rather than as the path it was read from: this text ships inside the .deb, and a build host’s directory layout is not something a package should carry. The manifest, which stays in the work directory, records the path. A packaging overlay taken from a path carries a digest of what it held rather than a path in the first place, so it reads as packaging tree:483b0e8... and names its kind so a digest is not taken for a commit.

The entry lands on the build’s own copy of the source tree, inside the cage — not on the resolved checkout in the work directory. The checkout keeps upstream’s changelog, so each rebuild starts from the same base version rather than compounding suffixes onto the last build’s.

A component that declares its version is the one exception to that last sentence, in form rather than in substance: the base entry it extends is one src2deb wrote into the resolved tree, and each run rewrites it from the recipe. The base version still comes from one place and still does not compound.

The work directory

Everything a run reads and writes lives under one directory, named by --work and defaulting to ./work. It holds the run’s outputs, and also the much larger working state those outputs were made from.

work/
├── out/                       artifacts, per suite and architecture
├── pool/                      the servable .deb archive, per suite and architecture
├── manifests/                 provenance, per recipe, suite, and architecture
├── sources/                   each component's resolved source tree
├── packaging/                 packaging overlays cloned from a repository
├── tarballs/                  fetched archives and .dsc files, named by digest
├── cache/                     downloaded .debs, shared by every build root
├── base/                      the shared base build root, per suite and architecture
├── uppers/                    per-component overlay layers, during a build
├── roots/                     per-component build roots, on hosts with no overlay
└── .lock                      held for the duration of a run

What each entry is

EntryHoldsSafe to deleteCost of deleting it
out/Each component’s .deb, .changes, and .buildinfoYesThe artifacts themselves. The same packages remain in pool/
pool/The dists/-structured archive later builds resolve againstYesEvery package built so far. A selective re-run can no longer resolve against them, and a signed pool loses its signature. To reclaim only what it no longer serves, prune it instead
manifests/One TOML record per recipe, suite, and architectureYesThe run’s provenance, and the state --skip-published reads. The next run rebuilds everything
sources/One tree per component: a git checkout with submodules and LFS content, a copy of a source.path tree, an unpacked source.tarball archive, or a source.dsc source package assembled from the files its .dsc namesYesA full re-clone of every git component on the next run. A path, archive, or source-package component is re-copied, re-unpacked, or reassembled either way
packaging/One tree per component that takes its debian/ from a packaging.git repository, a packaging.tarball archive, or a packaging.dsc source package. A packaging.path overlay is read where it lies and appears here not at allYesA re-clone or re-unpack of each on the next run
tarballs/Fetched archives, each named by the SHA-256 it was verified against and shared by every component that declares it: release tarballs, and the .dsc files and component tarballs a source.dsc namesYesA re-fetch of every archive the next run resolves, which needs curl and the network
cache/Downloaded .deb files, keyed by content and shared across rootsYesA re-download of every package the next provision installs
base/One shared base build root per suite and architecture, at base/<suite>/<arch>/, each with a .plan recording the package set it was provisioned from and a .lock guarding its preparationYes, root and sidecars togetherOne base bootstrap — several hundred packages — per target on the next run
uppers/A component’s overlay layer, and its overlay work directory, while it builds, under uppers/<suite>/<arch>/Between runsNothing. A layer is staged fresh for every build, and the next run clears whatever a killed one left
roots/A fully-provisioned root per component, under roots/<suite>/<arch>/ and each with its own .plan and .lock, on hosts with no unprivileged overlayYesOne full provision per component on the next run
.lockThe exclusive lock a run holdsOnly when no run is activeNothing, if no run is active. See One run at a time

Everything a run writes lands under the work directory, and src2deb creates whatever of it does not exist yet.

Where the size goes

The outputs are the small part. On a recipe of two trivial packages:

672M  work/
533M  work/base
138M  work/cache
500K  work/sources
136K  work/pool
 52K  work/out

base/ and cache/ are the shared base system and the packages it was installed from, and they are near enough constant per target: they are sized by the suite, not by the recipe. What grows with the recipe is sources/ — one tree per component, including vendored Rust crates left in the tree between runs — and pool/, which grows until it is pruned as every run adds a fresh set of .deb files and removes none.

Budget for the base and the cache once per suite and architecture the work directory builds for, and watch sources/ over time. For pool/, run src2deb prune, or pass --keep N to the build that fills it.

What a re-run reuses

A second run against the same work directory reuses, in order of what it saves:

  • The shared base for each target it builds, when that base’s plan key still matches — that is, when the exact set of packages a bootstrap would install has not moved in the archive. See The build-root cache.
  • The package cache, for every .deb it already holds, whatever root wants it.
  • The source checkouts, and the packaging-overlay checkouts beside them, which are fetched and re-checked-out rather than re-cloned. A source.path component is the exception: its tree is copied afresh every run.
  • The pool, which carries earlier components’ packages forward so a selective run can resolve against them.
  • The manifest, which is what --skip-published consults to decide a component’s source has not moved since it was built.

Deleting any one of them costs only the work in the table above; none of them is state a run cannot rebuild.

Sharing one work directory

Several recipes may share a work directory, and so may one recipe retargeted at another suite or architecture. out/, pool/, manifests/, base/, roots/, and uppers/ are keyed by the suite and architecture of the run that wrote them, so no two targets overwrite each other, while sources/ and cache/ are shared outright.

Keying the build roots is what makes a work directory hold a warm base for each target rather than one that whichever run went last had rebuilt. A root’s plan key names the suite and the architecture, so a base bootstrapped for trixie/ amd64 could never be reused for forky/arm64 in any case — sharing the path would only have meant each target discarding the other’s bootstrap. The cost is disk: one base per target rather than one in total.

cache/ is shared safely because it is content-addressed: two architectures’ .deb files are different content and sit beside each other, so every target pays for a package once.

One thing is not keyed: sources/ holds one tree per component name, and packaging/ does the same. Two recipes that name the same component for different sources would fight over one directory. Give them separate work directories, or separate component names.

The provenance manifest

Every run writes a manifest to the work directory, tying the run’s inputs to its outputs: what each component’s source resolved to, the sandbox its builds ran in, and the package versions each build produced. It is the basis of a reproducibility story — the manifest names the revisions to check out and the conditions they were built under.

Location

A manifest belongs to one recipe built for one suite and architecture, and is written to that identity’s own path as each architecture finishes, once every component’s outcome for it is known — so a run building for two architectures writes two manifests:

<work>/manifests/<recipe>/<suite>/<architecture>.toml

Work directories are shared deliberately: pointing two recipes at one --work is how their packages reach a single pool. Giving each identity its own manifest keeps that composable. A run neither overwrites the provenance of the recipe built before it, nor reads resume state from a run that targeted another suite or architecture — where the pool it would resolve against holds none of the same packages, since the pool and the output tree are keyed by that same suite and architecture.

Contents

The manifest records the recipe’s identity, the date the run’s versions were stamped with, the sandbox the run’s builds ran in, the archives its build roots resolved against, and one entry per component, in build order. A component that built carries its produced packages and their versions; a component that failed carries the failure reason. Both carry what their source resolved to, so a run that stops partway still records the exact inputs it reached.

recipe = "cosmic-epoch"
suite = "trixie"
architecture = "amd64"
build-date = "2026-07-31"

# The [sandbox] and [[archive]] sections go here; see below.

[[component]]
name = "cosmic-randr"
status = "built"

  [component.buildinfo]
  path = "out/trixie/amd64/cosmic-randr/cosmic-randr_1.0.0-1+deb13.20260731.1f3a9c2_amd64.buildinfo"
  sha256 = "4c1a0f..."

  [[component.source]]
  role = "source"
  kind = "git"
  value = "1f3a9c2e5b7d..."
  pinned = true

  [[component.package]]
  name = "cosmic-randr"
  version = "1.0.0-1+deb13.20260731.1f3a9c2"

  [[component.package]]
  name = "libcosmic-randr-dev"
  version = "1.0.0-1+deb13.20260731.1f3a9c2"

[[component]]
name = "cosmic-osd"
status = "failed"
error = "building cosmic-osd: dpkg-buildpackage exited with status 2"

  [[component.source]]
  role = "source"
  kind = "git"
  value = "9b2e4d6a8c1f..."
  pinned = true

The source record

Each [[component.source]] entry is one input the component was built from. It names four things:

  • role — what part the input played in assembling the tree. The component’s own source is source; a packaging overlay is packaging; a patch series is patches.
  • kind — what sort of input it is. A git checkout is git; a fetched release archive is sha256; a Debian source package is dsc; a source tree on disk is path; a patch series is patches; a packaging directory on disk is tree.
  • value — what identifies it. For git, the exact HEAD the tree was checked out at, so a branch or default-branch ref is recorded as the concrete revision it resolved to, not the moving ref that named it. For sha256, the digest the archive was verified against before it was unpacked. For dsc, the digest of the .dsc itself, which declares the digest of every file the package is assembled from and so pins all of them. For path, the canonical directory the tree was read from, so the record names one path however the recipe reached it. For patches, a SHA-256 over the series’ members in the order they were applied. For tree, a SHA-256 over the contents of the debian/ directory the overlay supplied.
  • pinned — whether the value names the exact content that went into the build. A hash does. A value that only says where a tree was read from does not, since the tree may be anything by the time the record is read.

tree and path both name a directory on disk and differ in what is recorded about it. A packaging overlay contributes one small directory that nothing writes to, so src2deb measures it and records what it held; a component’s own source is an arbitrarily large tree the build writes into, so the record names where it was read from and says plainly that it pins nothing. The recipe stays the authority for where either was; the manifest is the authority for what the first held.

sha256 and tree are both digests and differ in where the digest came from. A sha256 is one the recipe declared and src2deb verified against an archive it fetched, so the record names something that can be fetched again and checked; a tree is one src2deb measured off a directory the recipe pointed at. Both pin content, and only the first pins something a third party can obtain.

dsc is a declared-and-verified digest like sha256, and is a kind of its own because it identifies something else: not one archive but a whole Debian source package, and one built without the vendor pass — so the record says both what the build consumed and that it was hermetic throughout.

A build from a tree on disk therefore records:

  [[component.source]]
  role = "source"
  kind = "path"
  value = "/home/someone/checkouts/cosmic-comp"
  pinned = false

which is the manifest saying plainly that this build cannot be reproduced from what it records. Where the tree was is worth keeping — it is the only trace of what was built — but it is not a revision, and the record does not let it pass for one.

A component’s tree may be assembled from more than one input, and its record then carries one entry per input, in the order they were applied: the source, then any packaging overlay, then any patch series. A component whose packaging comes from a second repository and which carries a local fix records all three:

  [[component.source]]
  role = "source"
  kind = "git"
  value = "1f3a9c2e5b7d..."
  pinned = true

  [[component.source]]
  role = "packaging"
  kind = "git"
  value = "8d4b0e1c7a92..."
  pinned = true

  [[component.source]]
  role = "patches"
  kind = "patches"
  value = "5f2e1a9c3b8d..."
  pinned = true

The role is what tells the two git entries apart. Nothing else does: a packaging repository and a source repository are the same sort of thing, and only the part each played says which was which. SOURCE_GIT_HASH carries the one whose role is source, so packaging that stamps a revision into what it builds reports the source’s rather than its own.

Role and kind answer different questions, and neither implies the other: an overlay may come from a repository or from a tree on disk, and so may a source. The one pairing that always holds is a patch series, whose kind and role are both patches — it is identified by a digest over the patches, and applying patches is the only thing it does.

A patch series’ digest covers the series’ members in the order they were applied, so it changes when a patch is edited, added, removed, or reordered — and not when one is merely renamed. The recipe remains the authority for which patches were applied; this records what they were.

pinned follows from the kind, and is written out so that a reproducible build can be told from one that only looks like one without knowing which kinds are which. It is what --skip-published rests on: see Resume state.

A component that failed before it resolved anything — its source would not clone, or its debian/control would not read — carries no entry at all, which is the manifest saying it never got that far rather than naming an input it never reached.

The recorded versions are the stamped ones the packages actually carry, so the suite, the build date, and the abbreviated source are legible from the manifest as well as from apt policy. See Package versions.

The declared version

A component whose packaging carries no debian/changelog takes its version from the recipe, and the record names it:

[[component]]
name = "foo"
status = "built"
version = "1.2.3"

version is the upstream version the recipe declared or derived — the base the stamp extends — and it appears only for a component that declares one. A component that takes its version from a changelog it already has records no such field.

It sits beside the source record rather than in it, because it is not a tree the build consumed: it is a name the recipe gave. It is recorded all the same, because it is the one thing a recipe can change that produces different packages while every input the fingerprint names stays exactly where it was — so --skip-published compares it alongside the fingerprint. See Components with no changelog.

The build date

build-date is the date the run stamped into every version it produced, as YYYY-MM-DD. Passing it back as --build-date reproduces that run’s versions and hands the build the same SOURCE_DATE_EPOCH; --build-date manifest reads it from here rather than making you transcribe it. See Pinning the date.

Like the sandbox record, it is carried forward: a run that builds nothing keeps the date of the run that produced the packages this manifest still calls built. Overwriting it with the date of a run that produced nothing would make a later reproduction build against the wrong clock.

The .buildinfo reference

dpkg-buildpackage writes a .buildinfo for every build, and src2deb keeps it alongside the packages in the output tree. A component that built names it:

  • path — where the file is, relative to the work directory, so a work directory that is moved or copied keeps a manifest whose references still resolve.
  • sha256 — the file’s checksum, measured from the bytes on disk, so the recorded file can be told from one that has since changed.

.buildinfo is Debian’s own record of what a package was built against: the exact set of packages installed in the build root, the build environment, and the checksums of the binaries produced. The [sandbox] section below records the environment and the filesystem a build saw, but not the installed package set — .buildinfo is what carries that, in the format the rest of Debian’s tooling already reads.

The manifest names the file rather than restating what it holds, so there is one authority for it instead of two that can disagree. src2deb writes it and carries it; it does not interpret it.

The sandbox record

What a build produces depends on what it was rooted on, the identity it held, the network and limits it ran under, the hardening applied to it, the environment it carried, and the filesystem it saw. None of that follows from the source revisions. The [sandbox] section records all of it, as the build cage actually resolved it:

[sandbox]
component = "cosmic-randr"
network = "isolated"

  [sandbox.root]
  kind = "overlay"
  lower = ["/mnt/build/work/base/trixie/arm64"]
  upper = "/mnt/build/work/uppers/trixie/arm64/cosmic-randr"
  work = "/mnt/build/work/uppers/trixie/arm64/.cosmic-randr.work"

  [sandbox.identity]
  kind = "single"

  [sandbox.hardening]
  kind = "unavailable"

  [sandbox.env]
  HOME = "/root"
  PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
  SOURCE_GIT_HASH = "6e8e795970fa06d434af22775e415b517f7552d3"

  [[sandbox.mount]]
  kind = "procfs"
  target = "/proc"

  [[sandbox.mount]]
  kind = "tmpfs"
  target = "/dev"
  flags = 2
  data = "mode=0755"

  [[sandbox.mount]]
  kind = "symlink"
  path = "/dev/stdin"
  target = "/proc/self/fd/0"

  [[sandbox.mount]]
  kind = "bind"
  source = "/work/sources/cosmic-randr"
  target = "/src"
  read-only = true
  • root is what the command’s filesystem was, and is its own field rather than a mount because it is what the mounts were laid over. overlay is the layered strategy: the shared base as the read-only lower, the component’s build-dependency increment as the writable upper. plain is full reprovisioning’s per-component root. Without this a plain root and an overlay over the same base produced byte-identical records while describing two different builds.
  • identity is single for every build src2deb runs: the calling user is root inside the sandbox and no other id is mapped. It decides whether a build sees uid 0, which is what Rules-Requires-Root handling turns on.
  • network is isolated for a build pass. It is the first thing a reproducibility claim is challenged on.
  • rlimit entries are the resource limits in force, and are absent because src2deb sets none. A build that adapts its parallelism to RLIMIT_NOFILE produces different output.
  • hardening is unavailable when the sandbox library was built without the hardening layer, which is how src2deb builds it. That is recorded rather than omitted, and it is a different fact from applied with no controls set — a build that could have hardened and did not.
  • env is the build command’s complete environment. SOURCE_GIT_HASH is the resolved commit, passed to both passes; packaging that stamps a revision into the built binary reads it from there, so the package reports the commit the manifest names.
  • mount is every mount the sandbox established, in the order it established them — the managed profile first, then src2deb’s own read-only source bind and read-write output bind. Each carries a kind: tmpfs, procfs, devpts, bind, raw, or symlink.

Recording all of this rather than assuming it matters because the sandbox’s base environment and managed mount profile are not fixed by the sandbox library’s version — they may change between releases. The manifest states what a build ran under instead of leaving it to be inferred.

The record is run-level, because every component’s build applies the same environment, the same mount sequence, and the same posture, differing only in host paths: the source and output binds name the component’s own directories, and under the layered strategy so does the overlay upper. What an overlay root says about a build is its shape — a merge of this read-only lower stack with a writable layer — and the lower stack is the shared base every component builds over. component names the one it was taken from: the earliest in build order the run built, so a --jobs N run records what a sequential run would. A run that builds nothing keeps the record already in the manifest, since the packages it still calls built were built under it.

A manifest an older src2deb wrote may not carry a field this one requires, and is refused rather than read with the missing fields defaulted — a default would have the record state a posture no build observed. Delete it and rebuild.

Only the build pass is recorded. The vendor pass runs with the host network to fetch sources into the tree; it does not produce packages, so what it ran under says nothing about what the packages were built from. See How a build runs.

The interpreter record

A foreign build runs every target binary through a qemu-user interpreter: rustc, cc, ld, and every configure probe execute under an emulator, and a changed emulator silently changes compiled output. The manifest already named the architecture and whether the build was foreign; [interpreter] names what executed it.

[interpreter]
name = "aarch64"
path = "/usr/libexec/qemu-binfmt/aarch64-binfmt-P"
resolved = "/usr/bin/qemu-aarch64-static"
sha256 = "bfcd46c842441912baed36158569ac29a7fb656684ca73c1b3b2f0f3971e9bec"
enabled = true
flags = "POF"

The values come from the kernel’s own binfmt_misc registration, which is the path binaries actually execute through — not whatever a PATH lookup would find, and the build environment has no PATH to look in anyway. path is the registration as written, usually a wrapper; resolved is that path canonicalized, and the two are separate facts because repointing the symlink changes the interpreter without changing the registration.

The digest carries a caveat. It is of path, which open follows the symlink along, so it is the real binary’s bytes. But the F flag means the kernel opened and holds the interpreter at registration time, so a digest taken during a build may be of a file that replaced the one actually running. Two runs whose digests differ definitely ran different interpreters; two that agree agree only about the file on disk.

A native build records no [interpreter] at all. Nothing interpreted anything, which is a different statement from having failed to look.

The archive record

A component’s packages were built against a set of build-dependencies, and those came from somewhere. Each [[archive]] entry says where, as the resolver found it:

[[archive]]
mirror = "http://deb.debian.org/debian"
suite = "trixie"
components = ["main"]
release-sha256 = "74122bafc4253d3d42ba3657a21f7219aed1423dcbeb1b3b2c2d52fb66ed7070"
date = "Sat, 11 Jul 2026 09:02:23 UTC"
valid-until = "Sat, 18 Jul 2026 09:02:23 UTC"
signed-by = ["4CB50190207B4758A3F73A796ED0E7B82643E131"]

[[archive]]
mirror = "file:///mnt/build/work/pool/trixie/amd64"
suite = "trixie"
components = ["main"]
release-sha256 = "c50692c33fa2726827a5a6173eedd3d8f56a8f69f52a2ccbe1feabae7186f610"
date = "Mon, 03 Aug 2026 06:41:35 UTC"
signed-by = []
  • mirror is the URL that answered, not the list that was configured. A repository with a fallback resolves against whichever mirror served.
  • release-sha256 is the digest of the release body that was verified. For a signed archive that is the cleartext the signature covers, so it names the exact archive state the signature vouched for.
  • signed-by is the key that verified it. Written empty rather than omitted for an archive trusted unsigned — the run’s own pool is one — because an archive that verified nothing is a fact, and an absent key could not be told from a record written before the field existed.
  • valid-until appears only when the release carried one. Debian’s do; a locally written pool’s does not.

This is what the plan key each root is cached on cannot say. That key digests the selection — names, versions, and package digests — and says nothing about what the selection was made from, and the same suite resolves to different versions a week apart.

Every root a run provisions resolves against the same archives, so a run observes each of them many times over. The states are compared rather than assumed identical, and only the distinct ones are recorded. One entry per configured repository is the ordinary result. Two entries for one mirror and suite is a run that saw the archive publish while it was building against it, and the date each carries is what orders them: some of the run’s roots hold packages selected from one state and some from the other.

The record covers the run rather than any one component, and a run that provisions nothing keeps what is already there, both for the same reasons the sandbox record does.

Resume state

The manifest is also the build-state record --skip-published reads: a component is skipped when its source resolves to what the manifest already records as built, at the same declared version. Every input has to match, so a component gains a rebuild the moment any one of them moves. Each run folds the prior manifest forward — a component this run did not build keeps its earlier record — so the manifest always describes the whole recipe, and a chain of selective runs stays consistent.

A source that is not pinned is never skipped, however exactly it matches the record. An unpinned value says where a tree was read from and not what it held, so a run agreeing with the record establishes nothing about whether the source moved; skipping on that basis would publish an earlier build as though it were this one. In practice that means a source.path component is rebuilt on every run, which is the right answer for a tree someone is editing.

Each architecture reads only the manifest of its own recipe, suite, and architecture, so retargeting a recipe starts from a clean slate rather than skipping components on the strength of packages built for somewhere else. A run building for two architectures therefore skips per architecture: one may be up to date while the other has everything to build.

Carrying the record with the packages

src2deb export copies each architecture’s manifest into the export beside the packages it describes, under manifests/<recipe>/<architecture>.toml. A publisher archiving a release therefore keeps the record of how it was built without reading anything under a build host’s work directory.

How a build runs

A build is driven by the engine over a recipe, in order:

  1. Resolve every component’s source, and plan the order over them. Once per run, whatever it targets: neither a source nor the order over the sources depends on an architecture.
  2. Build each component in that order, and record what happened. Once per architecture the recipe names, in the order it names them.

Splitting there is what makes a run targeting two architectures a build of the same commits at the same stamped versions — a pair of separate runs cannot promise that, since a git-ref naming a branch may move between them. See Cross-architecture builds.

1. Resolve

Every component’s tree is assembled under the work directory, from its source, then any packaging overlay, then any patch series. A git source is cloned or updated, its ref checked out, and its submodules initialized — so a submodule superproject such as cosmic-epoch resolves its members. A source.path tree is copied under the work directory as it stands, afresh each run, so nothing writes into the tree the recipe named. Either way the result is a source tree that holds the component’s debian/ directory, and it is one src2deb owns.

Every component is resolved, whatever the run was asked to build, because the build order derives from all of them: which component produces a package is read from that component’s own debian/control, so the graph is complete only once every control file has been read. A --only run over a cold work directory therefore clones the whole recipe once; later runs fetch.

A component whose source will not resolve is a failure of that component, not of the run — the same as one whose build fails. Without --keep-going the run still stops at the first; with it, the run goes on and the component is recorded as failed. A component outside the run’s selection is weaker still: the run was never going to build it, so its source failing is reported and passed over whatever --keep-going says.

Any Git LFS content in a resolved tree is then fetched. A repository may hold large assets outside itself, leaving a short text pointer in their place, and a checkout made without LFS support writes those pointers as ordinary files. They are valid files, so a build embeds or installs one and succeeds; the substitution only surfaces when the installed program reads the asset and finds a stub. Resolve therefore fetches the real content and then verifies no pointer survives in the tree it is about to build. A surviving pointer fails the component, on the same terms as any other resolve failure.

The check is scoped twice over. It covers the subdirectory actually built, so an unrelated component’s assets elsewhere in a superproject do not concern it; and within that, the files git tracks, which is the only place a pointer can come from. That second bound also keeps it off a previous build’s vendored crates, which stay in the tree between runs and are not the component’s assets.

A source.path tree is scanned on the same terms but never fetched for: a pointer there fails the component with the command that fixes it, and src2deb leaves the tree alone. A path that is not part of a git working tree carries no pointers, and is passed over.

A component that declares a packaging overlay then has one applied. Its source is resolved by the same two origins the component’s own source is, and its debian/ directory replaces whatever the source tree held. Only debian/ is taken, so a packaging repository that also carries a copy of the upstream tree contributes its packaging and not its idea of the source.

Last, a component’s declared patch series is applied over the tree, in the order the recipe lists it — after any overlay, so a patch may fix packaging the recipe did not write. All of this happens inside resolve, before any debian/control is read, so an overlay may supply what a component build-depends on and a patch may change it, and the build order follows the assembled file. A patch that does not apply fails the component; both an overlay and a series are inputs of the component’s fingerprint, so changing either rebuilds. See Packaging overlays and Patches.

2. Plan

Each component’s debian/control is read for two things: what it build-depends on, and the binary packages it produces (its Package: stanzas). An edge runs from a producer to each consumer, and a topological sort of that graph yields the build order. Build-dependencies the recipe’s own components do not produce are left to archive and pool resolution at provision time, so the components may be listed in any order.

Only the first alternative of an a | b build-dependency group is considered, so an in-set build edge must be a direct dependency rather than a later alternative. The COSMIC recipe’s single edge is direct, so this bounds how a future recipe may express an edge rather than affecting the current build.

3. Build each architecture

Each architecture the recipe names is built in turn, against the one resolution above. What it settles for itself is its build roots, the pool it publishes into, its output tree, and its manifest — all keyed by suite and architecture, so the architectures of a run never write over one another.

It also settles which of the recipe’s binary packages to build: an architecture that does not own the recipe’s Architecture: all output builds only the architecture-dependent half, and skips a component that has no other half. See Who builds the Architecture: all packages.

For each component, in order:

  • A build root is provisioned — the base system, the toolchain, and that component’s build-dependencies, resolved against the archive sources and the local pool. The archive is read once for it: a bootstrap installs the plan that resolve produced rather than resolving again, so the packages that land are the ones whose digests were verified against the release. See Build roots, and Provisioning progress below for what it reports while it works.
  • The component is built in two cage passes, for packages that vendor Rust crates, as COSMIC’s do. The vendor pass runs debian/rules clean in a cage with the host network, which triggers the component’s own vendoring and leaves a vendor.tar in the tree. The build pass runs dpkg-buildpackage -nc in a cage with an isolated network, building offline from that vendor.tar; -nc keeps it from re-triggering vendoring. Before it builds, the build pass prepends a debian/changelog entry stamping the build’s version — see Package versions — to its own copy of the tree, leaving the resolved checkout as upstream wrote it. Both passes carry SOURCE_GIT_HASH, the resolved commit, which packaging reads to stamp a revision into the built binary. Output is assembled into lines and streamed to the terminal, and the artifacts are read from the .changes file the build writes.
  • The finished packages are published to the local pool, so the next component resolves against them.

A component’s failure is recorded rather than propagated: by default the run stops at the first failure, and with --keep-going it continues to the next component. Either way the run finishes with a report of what built and what failed.

A run that stops accounts for the components it never began as not reached, so built + failed + skipped covers every component the run set out to build. They are recorded with what their source resolved to, and the manifest keeps whatever it already said of them — a component a prior run built stays recorded as built.

The same rule decides whether the next architecture starts. Without --keep-going a failure stops the run where it is, so the architectures after it are never begun and the summary names them; with it, every architecture is attempted and the summary tallies them all.

The vendor pass, and the one source that skips it

The vendor pass is the only step in a run that executes upstream’s own code with the host network. Everything that produces packages happens in the build pass, which is isolated. That split is deliberate: the build is hermetic, and acquiring the dependencies to build it is not.

A component built from a Debian source package skips the vendor pass entirely. A source package carries everything its build needs — that is what makes it a source package — so there is nothing for the pass to fetch, and the component is built start to finish inside an isolated cage.

Every other source runs it. Whether a checkout, a tree on disk, or a release archive actually needs anything fetched is the packaging’s to decide by what its debian/rules clean target does; for a component that vendors nothing, the pass runs and does nothing.

What ends a run outright is what leaves it nothing coherent to do: a selection naming a component the recipe does not have, a dependency cycle, a target suite with no version tag, a selection that leaves out a producer of a selected component’s build-dependencies, and a shared base that will not bootstrap. Each of those is settled before the build phase, so the run prints the error alone and writes no manifest — there is nothing yet to record.

Building in parallel

With --jobs N, up to N components build concurrently — components within one architecture, not architectures alongside each other. Two emulated builds running at once contend for the same cores and the same package cache without finishing sooner. A scheduler over the dependency graph releases each component the moment the components producing its build-dependencies have published, so the build fans out across the graph’s independent components while still ordering each consumer after its producers. Every phase runs in parallel, including the two that touch shared state. The package cache stages each download under a name unique to its writer, so two components fetching the same package both succeed; and the pool serializes publishes internally while making each one visible to a reader all at once, so a component may resolve against the pool while another publishes into it. A single job (the default) reproduces the sequential order exactly.

What a build root sees of the pool follows from when it was provisioned. A component whose build-dependencies the recipe produces is released only after its producers have published, so it always resolves against their packages — that is what the scheduler is for. A component that depends on none of them is released as soon as a worker is free, so whether a sibling’s packages had reached the pool by then depends on how the run happened to be scheduled.

That is visible only where the pool holds a package name the archive also carries — a rebuild of a package the archive ships, whose default stamp outranks the archive’s own copy wherever both are read. A recipe holding one may resolve its other components differently between two otherwise identical parallel runs, and may miss the build-root cache for the same reason, since a root’s plan key names every package it resolved. --jobs 1 builds in a fixed order and so resolves against a fixed pool.

4. Record

Each architecture writes a provenance manifest to <work>/manifests/<recipe>/<suite>/<architecture>.toml as it finishes, mapping every component to what its source resolved to, the .buildinfo its build wrote, and the package versions it produced. A component whose source never resolved is recorded as failed in every architecture’s manifest: it failed once, for the run, and no architecture has it. See The provenance manifest.

Provisioning progress

Provisioning is the longest stretch of a run: a cold shared base resolves, fetches, and unpacks several hundred packages before the first build starts. It reports as it goes, labeled with the root each event belongs to — base for the shared base, and the component’s own name for a per-component root — so a concurrent run’s interleaved output stays attributable.

At the default verbosity a run announces each root as it starts on it, says how many packages the shared base will install, then counts the packages it downloads and unpacks:

src2deb: provisioning the shared base
src2deb: base: 163 package(s) to install
src2deb: base: downloading 66/163
src2deb: base: unpacking 66/163
src2deb: base: installing the rustup 1.97.0 toolchain
src2deb: building cosmic-comp (1/5)
src2deb: provisioning the build root for cosmic-comp

The base’s package count reports by default because it is the largest single commitment a run makes, and a cold work directory has no other way of saying so before the counters start. Each component’s own count stays behind -v, where it is one line among many rather than the run’s headline cost.

The toolchain line appears only when the recipe pins one and the root was actually provisioned; a root reused from a prior run already carries it. Its own output is captured rather than printed, so a failure reports what the installer wrote and a successful install says nothing further.

On a terminal, and with a single job, the counter is one line rewritten in place. With --jobs N, or with stderr redirected to a file, it becomes a line each time the count passes a tenth of the total — several workers rewriting one row is unreadable, and a redirected stream would collect carriage returns rather than lines.

A package the shared cache already holds is not downloaded, so a warm cache counts only what it has to fetch, and the unpack counter carries the run.

-v replaces the counters with a line per package, and adds each URL fetched and the size of each resolved package set. -q prints no provisioning progress at all.

Notes that report whatever the verbosity

Two things report at every verbosity above -q, because they change what the run guarantees rather than what it is doing:

  • A foreign-architecture target, which runs every compiler invocation under emulation. See Cross-architecture builds.
  • No unprivileged overlay, which drops the run to full reprovisioning, the weaker of the two isolation guarantees.

The layered default stays behind -v, as the stronger strategy and the ordinary case. Both notes are shown in Troubleshooting.

In-cage build output

Each line a build writes is passed through indented by two spaces, from both its standard output and its standard error, unchanged otherwise. The two streams render alike because Debian’s build tooling uses the choice of stream to separate its output rather than to signal severity — dpkg-buildpackage writes its ordinary progress to stderr. With --jobs N, each line also carries its component, since several builds’ output interleaves.

Cancelling a run

Ctrl-C — or SIGTERM — stops a run at the next point where stopping leaves a coherent state behind, rather than killing the process mid-provision. The run prints cancelled; stopping, winds down, writes its manifest, and exits 130.

What “the next point” means depends on what the run is doing:

While it isA cancel takes effect
Cloning sourcesBetween components
Resolving or downloading a root’s packagesAt the next package
Unpacking a rootAt the next package
Configuring a root (dpkg --configure)When configuration finishes
Installing a pinned Rust toolchainWhen the install finishes
Staging a layered incrementWhen the increment finishes
Building a componentWithin a fraction of a second

A build is stopped with SIGTERM first, so dpkg-buildpackage can finish the file it is writing, and killed if it has not exited within five seconds. Every build sandbox is also tied to this process’s lifetime, so even a src2deb that is killed outright leaves no in-cage build running.

A second Ctrl-C exits immediately, for the cases in the table above where the first has to wait.

What a cancelled run leaves behind

Everything the next run can pick up from:

  • Components that finished are built, published to the pool, and recorded in the manifest as built. A later --skip-published run skips them.
  • The component the cancel interrupted, and any it never reached, are recorded as skipped, with what their source resolved to. Nothing claims they were built.
  • The architectures the run had not started have no manifest written for them and nothing published, so nothing was recorded that a later run would have to undo. The summary names them.
  • A partly-provisioned build root is removed rather than left half-made, so it can never be mistaken for a usable one. The next run provisions it from clean.
  • The work directory lock is released, unless the run was ended by a second Ctrl-C or killed outright. Then <work>/.lock survives, and the next run reports it as locked and names the file to remove.

The run’s exit status is 130 whenever it was cancelled, including when a component had already failed: the run did not finish, so nothing can be concluded about the components it never reached.

The local pool

The pool is a dists/-structured .deb archive, trusted without a signature, that carries build-dependencies from one component to the next. It is written up front as a valid empty pool, so the first component can declare it as a repository; each build then adds its packages and regenerates the index. A component that build-depends on an earlier one resolves against the packages src2deb just built.

A pool lives at <work>/pool/<suite>/<architecture>/ and belongs to that pair. Any number of recipes may publish into one pool — that is what sharing a work directory is for — but a run targeting another suite or architecture publishes into a pool of its own.

Scoping by architecture is a requirement. An Architecture: all package’s file name carries no architecture, and its stamped version is identical however the package was built, so the same component built for amd64 and for arm64 yields one file name for two different files. Sharing a pool would mean the second publish overwriting the first and leaving the earlier architecture’s Packages naming a checksum that no longer matches — a hash mismatch at the next apt update.

Scoping by suite is a choice. A rebuild for another suite differs in the deb13/deb14 field of its version, so its file names differ and it could share the pool. It gets one of its own so that a pool is a single servable archive — one that can be signed, mirrored, or discarded without reference to another suite’s builds, and whose index accounts for every file beside it. That is a departure from Debian’s layout, where one pool/ serves every distribution in the archive.

Publishing is incremental and forward-only

A publish merges into whatever the pool already indexes rather than replacing it, and keeps the highest version of each package name. Two consequences are worth knowing before serving a pool to anything but the next build:

  • A lower version does not publish. Its .deb is copied into the pool, but the index keeps the higher version it already recorded. Correcting a package’s version downward therefore needs a pool that never saw the higher one.
  • There is no unpublish. Nothing removes a package from the index, and deleting a .deb by hand leaves the index naming a file that is no longer there, which a client reports as a failed fetch. To stop shipping a package, stop anything from depending on it rather than trying to take it out of the pool.

Both follow from the index being merged under the pool’s lock, which is what lets one component resolve against the pool while another publishes into it.

The pool directory grows until it is pruned

The index keeps only the highest version of each package name, but nothing removes the file a higher version superseded. Because every build carries the build date in its version, each run writes a fresh set of .deb files and leaves the previous set on disk, indexed by nothing. A recipe rebuilt daily accretes its full artifact set per day.

That is the pool’s design: a publish is additive and takes no view on what came before. Removing what it superseded is a separate step — src2deb prune, or --keep N on a build, which runs it once the run has finished. Watch the directory’s size rather than the index’s, which stays one entry per package however many versions sit behind it.

The pool is not the largest thing in a work directory, though: the shared base and the package cache usually are. See The work directory.

Signing follows a run, never precedes it

A run publishes an empty set into its pool before building, so the first component has a valid Release to declare as a repository. A publish replaces that Release, and a signature only covers the Release it was made over, so publishing discards any signature the pool carried.

Sign a pool after the run that finished it, and re-sign after every run that publishes into it. A run with nothing to build does not touch the pool at all, so a signed pool stays signed across a fully-skipped re-run.

Build roots

Every component builds in its own root filesystem, provisioned with the base system, the toolchain, and that component’s build-dependencies. src2deb chooses one of two strategies per run, and caches a root it can safely reuse.

Layered provisioning

On a host that supports an unprivileged overlay, src2deb bootstraps a shared base once — the base system, the generic build toolchain, and the recipe’s pinned Rust toolchain if it names one — and then, for each component, installs only the packages that component adds into a disposable overlay upper. The build cage roots on an overlay of the shared base plus that increment.

One heavy bootstrap serves every component, and the base is never written by a build: each component’s upper is disposed when the component finishes, leaving the base pristine for the next. The upper is staged fresh for every build rather than reused, because the build writes into it through the overlay; reusing it would carry one build’s changes into the next. This is the default strategy.

A run that never gets to unwind — killed outright, or stopped with the second Ctrl-C — leaves its upper and the overlay work directory beside it behind. The next run clears both before staging, so a component recovers on its own rather than inheriting a half-built layer.

Full reprovisioning

Where the host cannot establish an unprivileged overlay, src2deb bakes a fully-configured root filesystem per component instead. This is the fallback: the build writes directly into the root, so a reused root carries the previous build’s changes — the weaker of the two isolation guarantees.

A run that falls back says so, at every verbosity above -q, naming what blocked the overlay:

src2deb: note: no unprivileged overlay (<reason>); using full reprovisioning,
         which reuses a root a build has written to

It reports by default because it changes what the run guarantees. The layered strategy, being both the default and the stronger one, is announced under -v.

The build-root cache

A root that a build does not mutate is cached on its resolved plan. Before provisioning, src2deb asks the provisioner for the exact, archive-verified set of packages a bootstrap would install, and records a key derived from that set — each package’s name, version, and archive checksum — beside the root. A later run reuses the root only when the key still matches, and rebuilds it from clean when the set has changed, so a bumped or added build-dependency never silently reuses a root provisioned for the old set. This keys full reprovisioning’s per-component roots and layered provisioning’s shared base.

The recipe’s pinned Rust toolchain version is part of the key too, because the toolchain is installed into the root as part of provisioning it. Without it the key would describe less than the root holds, and repinning a recipe’s toolchain would reuse a root carrying the version it replaced.

The key names the suite and the architecture as well, so a root provisioned for one target never matches another’s key. Roots are therefore kept per target on disk — base/<suite>/<arch>/, and likewise for roots/ and uppers/ — so a work directory building for two architectures keeps a warm base for each rather than each run rebuilding over the last one’s. See Sharing one work directory.

Every run resolves that plan, including one that goes on to reuse the root untouched: the archive has to be consulted to tell a current root from a stale one. That is why a run reports fetching a release and an index even when it installs nothing.

A run with nothing to build resolves nothing. The skip decision is made before any root is provisioned, so a --skip-published re-run over unchanged sources neither bootstraps the base nor consults the archive.

One resolve, then the install

A bootstrap that needs to run installs the plan that was just resolved, rather than resolving again from scratch. That matters for more than the second index fetch it saves:

  • The set installed is the set the key describes. Between two resolves the archive could publish, and the root would then hold a package set that the key recorded beside it does not describe — a root claiming to be current for a plan it does not hold.
  • The digests are the ones that were verified. Every .deb is still checked against the digest the plan records, and that digest came from an index whose own digest the signed release covered, minutes earlier in the same run.

A layered increment resolves once already — it computes its delta against the base’s installed set and installs that — so this applies to the two bootstraps: a fully-reprovisioned root, and the shared base.

Stopping mid-provision

A bootstrap — the shared base, or a fully-reprovisioned root — is stopped at the next package boundary when a run is cancelled, and the partly-made root is removed rather than left behind. A layered increment cannot be stopped once it starts; it is small, so it runs to completion, and a cancelled run declines to start another. See Cancelling a run.