Package Config Reference ([package])¶
A repo publishes its product as signed OS packages (.deb for Ubuntu, .rpm
for RHEL) by adding a [package] section to its vergil.toml. The section is
optional. A repo without it builds no packages, does not call ci-package, and
sees no change in CI or CD.
With [package] present:
vrg-validatechecks the section and the optional overlay file (see Validation).- PR CI builds every package and install-tests it in a clean container of
each target OS. The result is the
package / evidencegate. - A release builds the same packages, signs them, attaches them to the GitHub release, and notifies the org's package repository, which indexes them.
The design is epic
vergil-project/.github#356
(spec epics/356-binary-package-publishing/spec.md).
Structure¶
[package]
builder = "python" # python | staged
vendor = "vergil" # install root /opt/<vendor>/<name>/
summary = "Shared development tooling for Vergil-managed repositories"
smoke = "vrg-whoami --mode" # the install test runs this after install
# Target selection. Default: every registered target. Set at most one of:
# exclude = ["rhel/*/arm64"] # drop targets (glob)
# targets = ["ubuntu/*/*"] # explicit subset replacing the default
# native = ["ubuntu/26.04/*"] # own build cell, inside that OS
# name = "..." # required for staged; python defaults to pyproject [project].name
# version = "3.14.4+20261001" # explicit package version; default: the repo version
# upstream-version = "3.15.1+20261021" # upstream inputs; package version is <this>.<repo version>
# noarch = true # architecture-independent: built once, deb `all` / rpm `noarch`
[package.python] # optional for builder = "python"
# runtime = "3.14.8" # exact-patch pin; default: derived from the resolved version
# commands = ["vrg-git"] # subset of [project.scripts] to shim; default: all
# For builder = "staged" instead of [package.python]:
# [package.staged]
# build-command = "packaging/build.sh" # populates $VRG_STAGING_ROOT for $VRG_TARGET_ARCH
Several packages: [[package]]¶
A repo that publishes more than one package declares each one as an entry in
an array of tables, [[package]]. Each entry takes the same keys and
subtables as a single [package]. A subtable such as [package.python] or
[package.staged] belongs to the [[package]] entry directly above it.
[[package]]
name = "example-server"
builder = "staged"
vendor = "vergil"
summary = "Example server"
smoke = "example-server --version"
[package.staged]
build-command = "packaging/build-server.sh"
[[package]]
name = "example-client"
builder = "staged"
vendor = "vergil"
summary = "Example client"
smoke = "example-client --version"
[package.staged]
build-command = "packaging/build-client.sh"
A single [package] table is one entry and behaves exactly as before. A
single [[package]] entry behaves the same as a [package] table. With more
than one entry, vrg-validate (and every tool that reads vergil.toml)
refuses the config unless:
- every entry sets an explicit
name, and no two entries share one. Thepyproject.tomlname fallback forbuilder = "python"applies only to a single package. - every entry has the same
vendor. The release wait and the tracking issue resolve one<org>/packagesrepository from it. - the repo has no
packaging/nfpm.overlay.yaml. That overlay has one fixed path, so it would apply to every package. It is allowed only with a single package.
The other repo-level checks run once per entry. For example, a python
entry still requires uv.lock (see Validation).
Every stage handles each entry as its own package. The matrix qualifies
every cell id with the package name (see Matrix
resolution), vrg-package build
--cell and install-test --cell pick the entry from the cell, so each
package builds with its own builder and is install-tested with its own
smoke, and check-runtime checks each python package against its own
runtime (see One runtime per package).
At most one python entry may pin [package.python].runtime. Version
resolution and vrg-update-deps read the pin from the packages list, and
refuse a config where more than one entry pins a runtime, with
several [[package]] entries pin a runtime; pin it on one entry. Any
python entry, pinned or not, makes version resolution require a
published vergil-python runtime (see [package.python]).
The design is epic
vergil-project/.github#472
(spec epics/472-multi-series-runtime/spec.md, §5.1).
Keys¶
| Key | Type | Default | Semantics |
|---|---|---|---|
builder |
string | (required) | python (a Python venv product) or staged (any pre-built tree) |
vendor |
string | (required) | Vendor directory: the product installs under /opt/<vendor>/<name>/ |
summary |
string | (required) | One-line package description |
smoke |
string | (required) | Shell command the install test runs after installing the package |
name |
string | see semantics | Package name. Required for staged; python defaults to pyproject.toml [project].name |
version |
string | repo version | Explicit package version ([0-9][0-9A-Za-z.+~]*), used unchanged as the full package version, for when it is not the repo's semver. A release refuses a version an earlier release already published; see the re-publish check. Not with upstream-version |
upstream-version |
string | (unset) | The upstream inputs a package is built from ([0-9][0-9A-Za-z.+~]*), for example 3.15.1+20261021. The package version is <upstream-version>.<repo version> (3.15.1+20261021.1.0.5 at repo 1.0.5), so every repo release publishes a new version and the inputs show first in apt policy. That version sorts above the same inputs without the suffix and above an earlier repo release, under both dpkg and rpm. Not with version |
noarch |
boolean | false |
Architecture-independent: one build cell on amd64, emitting deb all / rpm noarch |
exclude |
list of globs | [] |
Targets to drop from the default set |
targets |
list of globs | (unset) | Explicit subset replacing the default set |
native |
list of globs | [] |
Selected targets that get their own build cell inside their own OS |
[package.python]¶
Valid only with builder = "python", and optional there.
| Key | Type | Default | Semantics |
|---|---|---|---|
runtime |
string | derived | Exact-patch pin (3.Y.Z) on the vergil-python runtime. Absent, the runtime is derived (see below) |
commands |
list of strings | all of [project.scripts] |
The commands to shim into /usr/bin; each must be in [project.scripts] |
vergil-python publishes one package per CPython series it lists,
vergil-python<X.Y>, as one [[package]] entry each. Every
vergil-python release publishes every listed series. The version is the
entry's upstream-version joined with the vergil-python release,
<cpython>+<pbs-tag>.<release> (for example 3.15.1+20261021.1.0.4);
releases before v1.0.4 are <cpython>+<pbs-tag> alone. Its patch
floats: each release replaces the previous patch. A package always
builds against, installs and depends on the per-series package, at
/opt/vergil/python/<X.Y>.
With no runtime, the series is derived: it is the newest resolved
version whose window entry marks the runtime published (epic
vergil-project/.github#407,
spec §7). The build takes whatever patch the series package serves. This
is the recommended setting.
A runtime pin keeps its X.Y in the window and builds on
vergil-python<X.Y>, never the legacy per-patch vergil-python<X.Y.Z>.
It is exact: after installing the series package, the build checks that
the CPython part of the installed version equals the pinned X.Y.Z. If
the repository serves any other patch, the build fails with the served
version and a fix: remove the pin, or update it to the served patch.
A pin cannot hold the build on an older patch, because the series
package no longer serves it. The pin governs the build only. The built
package depends on vergil-python<X.Y> (>= <installed version>), so
consumers still take later patches of the series.
[package.staged]¶
Valid only with builder = "staged".
| Key | Type | Default | Semantics |
|---|---|---|---|
build-command |
string | (unset) | Command that populates $VRG_STAGING_ROOT (an empty directory standing in for /) for $VRG_TARGET_ARCH. A non-zero exit is a hard error |
A staged package needs a build-command, overlay contents, or both. A
staging root that ends up empty is a build-time error.
The build-command runs from the repo root via bash -c, with these
variables set:
| Variable | Value |
|---|---|
VRG_STAGING_ROOT |
The empty staging directory, standing in for / |
VRG_TARGET_ARCH |
The build cell's architecture (amd64 or arm64) |
VRG_PACKAGE_NAME |
The package being built (its name), so one script can serve several [[package]] entries |
VRG_PACKAGE_VERSION |
The package's full version: <upstream-version>.<repo version>, the version override, or the repo version |
Targets¶
Supported targets live in a registry in vergil-tooling
(src/vergil_tooling/lib/package/targets.py). Each target is
<distro>/<version>/<arch>, and the distro implies the format:
| Target | Format | Suite | glibc | Install-test image |
|---|---|---|---|---|
ubuntu/24.04/{amd64,arm64} |
.deb |
noble |
2.39 | ubuntu:24.04 |
ubuntu/26.04/{amd64,arm64} |
.deb |
resolute |
2.43 | ubuntu:26.04 |
rhel/9/{amd64,arm64} |
.rpm |
el9 |
2.34 | registry.access.redhat.com/ubi9/ubi |
rhel/10/{amd64,arm64} |
.rpm |
el10 |
2.39 | registry.access.redhat.com/ubi10/ubi |
Adding a target, such as Ubuntu 28.04, is a registry change and a vergil-tooling release. No workflow changes.
Matrix resolution and the 2×2 default¶
vrg-package matrix turns the config into explicit JSON lists of build cells
and test cells. The workflows consume only that JSON.
- Every non-
nativeselected target on an architecture shares one build cell (shared-amd64,shared-arm64). It builds inubuntu:24.04and emits every format its targets need. - Each
nativetarget gets its own build cell (native-<distro>-<version>-<arch>) inside a container of that OS. - A
noarchpackage has exactly one build cell,shared-noarch, on amd64. - Every selected target gets its own test cell
(
test-<distro>-<version>-<arch>). - With several
[[package]]entries, each entry resolves its own cells, and every id is qualified with its package name as<name>--<id>, for examplevergil-python3.15--shared-amd64orvergil-python3.15--test-rhel-9-arm64. The double dash never occurs in an unqualified id, so per-cell artifact names never collide. A single package keeps the unqualified ids above.
Every cell in the JSON carries its package name.
With the defaults (2 distros × 2 versions × 2 architectures, no native) this
gives 2 build cells, 4 artifacts (a .deb and an .rpm per architecture)
and 8 test cells. Runners are ubuntu-24.04 for amd64 and ubuntu-24.04-arm
for arm64.
Tiers: full and reduced¶
vrg-package matrix --tier {full,reduced} picks how much of the matrix runs.
The default is full: every build cell and every test cell, as above.
reduced is the cheaper gate for feature PRs (the full matrix runs on release
PRs). It keeps every build cell, because build breaks are cheap to catch
and common, and one test cell per format present (per package, with
several [[package]] entries):
- the oldest selected release of that format (compared numerically, so
RHEL 9 before RHEL 10), on amd64, else on arm64. The oldest release is the
strictest compatibility case: lowest glibc, oldest rpm/dnf and systemd. A
package that installs there nearly always installs on newer releases, which
the
fulltier still covers on release PRs; - drawn from the shared targets; a
nativetarget is chosen only when the format has no shared target.
With the defaults, reduced gives the same 2 build cells and 2 test cells:
test-rhel-9-amd64 and test-ubuntu-24.04-amd64. A deb-only product
(targets = ["ubuntu/*/*"]) gets one, test-ubuntu-24.04-amd64.
The printed JSON carries "tier", and --github-output writes a tier= line
after enabled, build and test. --manifest is unaffected: the build
cells, and so the release artifact manifest, are the same in both tiers.
The release manifest¶
vrg-package matrix --manifest PATH writes packages-manifest.json, which
the release attaches and the index reads:
{
"artifacts": [
{"fmt": "deb", "arch": "amd64", "suite": null, "name": "vergil-python3.14"},
{"fmt": "deb", "arch": "amd64", "suite": null, "name": "vergil-python3.15"}
],
"runtimes": {"vergil-python3.14": null, "vergil-python3.15": null}
}
artifactslists one(fmt, arch, suite)per artifact built. With several packages, each one also names its package asname. A single package's artifact list has nonamekey and is exactly what it always was.runtimesmaps each package name to thevergil-pythonruntime its release resolved, ornullfor a non-pythonpackage.- A single package with a runtime also keeps the top-level
runtime, the shape that older readers check.
The index pairs each manifest entry with exactly one attached file. A named
entry matches only files of that exact package name, so two packages built
for the same (fmt, arch, suite) each claim their own file, and a package
named foo never claims foo-bar's files. An entry without name (a single
package, or any release from before multi-package repos) matches as it always
did. A manifest that names some artifacts but not others is rejected.
Because a shared cell builds on Ubuntu 24.04 for every target, the python
builder applies a glibc floor guard: any ELF object in the venv that needs
a GLIBC_x.y symbol newer than the lowest glibc among the cell's targets is a
hard error. Trip it, and the fix is to mark the affected targets native.
The package revision is 1 for shared builds, and 1~<suite> (deb) or
1.<suite> (rpm) for native builds.
Builders¶
python¶
In each build cell, as root:
- Enable the vergil package repository and install
vergil-python<X.Y>with the OS package manager, as a consumer would. With aruntimepin, check that it serves the pinned patch. - Create the venv at its final path,
/opt/<vendor>/<name>/venv, against/opt/vergil/python/<X.Y>/bin/python<X.Y>. - Install from
uv.lockonly (uv sync --frozen --no-dev --no-editable --compile-bytecode).uv.lockmust exist next tovergil.toml. - Apply the glibc floor guard.
- Add
/usr/bin/<cmd>symlinks tovenv/bin/<cmd>for each shimmed command.
The package depends on the runtime it was built against:
vergil-python<X.Y> (>= <installed-version>) (deb) or
vergil-python<X.Y> >= <installed-version> (rpm).
staged¶
Runs the optional build-command, merges the overlay's files over the staging
root, and packages the result. Used for vergil-python itself and for the
<org>-archive-keyring package.
Install layout¶
/opt/<vendor>/python/<X.Y>/ runtime (vergil-python)
/opt/<vendor>/<name>/venv/ product venv
/usr/bin/<cmd> -> venv/bin/<cmd> command shims
/etc/<vendor>/<name>/ config (overlay, noreplace)
/var/lib/<vendor>/<name>/ state
A package owns every directory strictly below /opt/<vendor>, never
/opt/<vendor> itself, which is shared.
The overlay contract¶
Files beyond the builder's output (systemd units, /etc config, maintainer
scripts) go in an optional packaging/nfpm.overlay.yaml, merged over the nFPM
config the tooling generates. nFPM's schema is not re-encoded in TOML.
The tooling owns the package's identity (name, version, release,
arch and so on). The overlay may set only these top-level keys:
| Key | Meaning |
|---|---|
contents |
Extra files: mappings with a string dst (relative src paths resolve against the repo root) |
scripts |
Maintainer scripts: script name → repo-relative path |
overrides |
Per-format settings under deb / rpm: depends, contents, scripts, and the relationship keys |
provides, conflicts, replaces, recommends, suggests |
Relationships: lists of strings |
depends is not allowed at the top level, because deb and rpm spell
dependencies differently. Put it under overrides.deb.depends and
overrides.rpm.depends.
Systemd units: the helper-macro rule¶
Install tests run in plain containers without systemd as PID 1, so:
- Maintainer scripts that enable or start units must use the standard
helpers, which do nothing when systemd is not running:
deb-systemd-helper/deb-systemd-invokeon Ubuntu, and/usr/lib/systemd/systemd-update-helper(the%systemd_post/%systemd_preunequivalent) on RHEL. - A raw
systemctlcall (bare or by path) in any overlay maintainer script is a hard error at package build time. Comments are ignored. - The install test checks that each shipped unit file is installed and passes
systemd-analyze verify. Whether the service actually starts and works is proven in a lab, not in CI.
Validation¶
vrg-validate treats each of these as a hard error, never a warning:
- a target glob in
targets,excludeornativethat matches nothing (nativemust match a selected target); excludeandtargetsboth set;- a selection that resolves to zero targets;
nativecombined withnoarch = true;- a
[package.python].runtimethat is not an exact3.Y.Zpatch; - a missing
uv.lockfor the python builder; builder = "staged"withoutname, or with neither abuild-commandnor overlaycontents;[package.python]withbuilder = "staged", or[package.staged]withbuilder = "python";- an overlay that is not a YAML mapping or that sets a key outside the list above;
- with several
[[package]]entries, a missing or duplicatename, entries with different vendors, or apackaging/nfpm.overlay.yaml(see Several packages).
Unrecognized keys are the exception: an unknown key in [package],
[package.python] or [package.staged] only prints an
unrecognized key warning on stderr, and validation still passes.
vrg-validate checks the overlay's shape only, because nFPM is not in the dev
container. nFPM's own config check runs at build time in CI.
CI and CD¶
PR CI¶
Add a package job to .github/workflows/ci.yml that calls the reusable
workflow:
It runs matrix, then one build job per build cell (unsigned artifacts),
then one install-test job per test cell. Each install test:
- for
pythonproducts, enables the vergil repository as a consumer would, so the runtime resolves from the live repository (stagedproducts install standalone); - installs the artifact and verifies any shipped systemd units;
- runs
smokeand resolves each shim under a sanitized environment (env -iwith a system-onlyPATH), so only the packaged binaries are found, never theuvcopy of vergil-tooling that drives the test; - removes the package and asserts nothing remains under
/opt/<vendor>/<name>or in the shims.
All legs roll up into the single package / evidence gate. Like the other
evidence gates it is required only where it applies: the per-repo ruleset adds
package / evidence for repos whose vergil.toml has [package]. After
adding [package], re-apply the repo's GitHub configuration so the gate
becomes required.
CD¶
A release builds the same cells, then a separate package-sign job signs every
.rpm and attests every artifact before the release is tagged. Any failure is
fatal before tagging. The caller has two requirements:
- A
package-signingenvironment, restricted tomain, holdingPACKAGE_SIGNING_KEYandPACKAGE_SIGNING_PASSPHRASE. Onlypackage-signuses it, so repos without[package]never reference it. secrets: inheriton thecd-releasecaller. Environment secrets reach a job in a cross-repo reusable workflow only withinherit; an explicit secrets map leavespackage-signwith an empty key (proven by vergil-project/packages#6). Semgrep flagssecrets: inherit, so suppress it inline with a justification:
release:
if: github.ref == 'refs/heads/main'
uses: vergil-project/vergil-actions/.github/workflows/cd-release.yml@v2.1
with:
language: python
# `inherit` is required: environment secrets reach a job in a cross-repo
# reusable workflow only with `secrets: inherit`. The callee is first-party.
secrets: inherit # nosemgrep: yaml.github-actions.security.secrets-inherit.secrets-inherit
After the release is tagged, cd-release dispatches to the org's packages
repository with the org's GitHub App token (APP_CLIENT_ID /
APP_PRIVATE_KEY, also reached through inherit), which indexes the new
packages. A dispatch failure is deferred: it does not fail the release.
The re-publish check¶
A package's identity is its name, full version (<version>-<revision>),
architecture and format. Builds are not byte-reproducible, so a release
that rebuilds an identity an earlier release already published ships
different bytes under the same identity. The package index refuses that
(the same identity must be byte-identical), and since the index is built
from every release of every repo, the refusal fails every repo's index
publish. The index refusal stays as the last line of defence, but two
earlier checks stop such a release before it publishes:
vrg-releasepreflight, on a fresh start, before anything is tagged. This is the earliest point that knows the full versions: the package name, the package version (seeversionandupstream-version) and the revision of every artifact in the release manifest are all known without building. See Releasing.vrg-package check-unpublished, for the CD packaging path to run before it builds, as a backstop for a release that did not go throughvrg-release:
--repo defaults to $GITHUB_REPOSITORY. It needs gh with read
access to the repo's releases.
Both compare the identities the release would publish against those
attached to every earlier stable release that carries a
packages-manifest.json, which are the releases the index collects. The
published identities are read from the attached file names, which nFPM
derives from the same metadata the index reads. The release's own tag is
never counted as an earlier release. Any failure to read the releases
refuses the release; the check never skips. A repo without [package]
passes.
To fix a refusal, bump the package version. For an upstream-version
package, set a new upstream-version, or cut a new repo release: the
package version ends in the repo version, so a new release is a new
version. For a version override, set a new version. The revision
(-1) is set by the tooling and cannot be configured.
One runtime per package¶
Each python package of a release must depend on one runtime on every
architecture.
Build cells run in parallel and at slightly different times, so if each
cell read the version window itself, a window change mid-release could
give each architecture a different runtime. This happened to
vergil-tooling 2.1.258: amd64 got vergil-python3.15 and arm64 got
vergil-python3.14 (vergil-project/vergil-tooling#3478).
To prevent it, the runtime is resolved once:
vrg-package matrixresolves the runtime of eachpythonpackage: theruntimepin, or the derived series. It records the result asruntimeon every build cell of that package and in the manifest'sruntimesmap, and prints it on stderr. Other builders record no runtime (null).vrg-package build --runtime <runtime>builds against the recorded value and never re-reads the window. Pass the cell'sruntimeunchanged. An empty value counts as none recorded, so the same line works for every builder. A value that differs from aruntimepin is an error. Without--runtime, as in a local build, the build resolves the runtime itself. Either way it prints the runtime it chose and where that came from.vrg-package check-runtimefails the release if any package disagrees. Run it where every cell's packages are together, before anything is published:
Every .deb and .rpm of a python package must depend on exactly
the vergil-python<X.Y> recorded for that package, and on no other
runtime series. Each mismatch is listed. A python package with no
files fails, and so does a file whose package the manifest does not
list. Packages recorded with a null runtime are not checked, so a
release of only non-python packages passes. A manifest from before
the runtimes map is checked against its top-level runtime, as one
package.
The package index (packages.toml)¶
The org's package repository lists the products it indexes in
packages.toml, which vrg-package index reads. Retention is optional:
vendor = "vergil"
products = ["vergil-project/packages", "vergil-project/vergil-tooling"]
[retention]
keep = 3 # releases kept per major.minor line (default 3)
lines = 2 # newest major.minor lines kept per product (default 2)
retire = ["vergil-python3.14.8"] # exact package names to drop (default: none)
The index is rebuilt from scratch on every run. For each product it keeps
the newest keep releases in each of the newest lines major.minor lines,
then adds the newest provider of every dependency of a kept package, so
everything indexed is installable.
retire drops packages by exact name, for every format (deb and rpm)
and architecture, whichever release ships them. A retired package is dropped
before dependencies are resolved, so it keeps none of its own dependencies in
the index. Each entry must be a non-empty string, listed once. If a package
the index keeps still depends on a retired name, the index build fails rather
than publishing a package that cannot be installed. To fix it, remove the name
from retire or make sure retention no longer keeps the dependent release.
Omitting retire, or setting it to [], retires nothing.
Every retire entry must name a package that some collected release ships,
in any format or architecture. The check runs against everything collected
before retention drops anything, so a name found only in releases that
retention would evict still counts. An entry that matches nothing fails the
index build. It is either a typo, which would otherwise leave the package
published, or a package whose releases are gone; either way, remove it from
packages.toml.
Checking packages.toml without building¶
validates packages.toml and exits without building anything. It applies
every check the index build makes on the file itself: known keys only, a
non-empty vendor, well-formed and unique products, positive integer
keep and lines, and well-formed and unique retire entries. It uses no
network, signing keys or output directory, so --keys and --out are not
needed. On success it prints vrg-package: packages.toml: ok. On any
invalid value it prints the error and exits non-zero, so a repo's
validation can catch a typo before publish time. Checks that need the
collected releases, such as a retire entry that matches no package, still
run only in the full index build.
Inspecting the matrix¶
prints the resolved build and test cells as JSON without building anything.
Add --tier reduced to see the reduced feature-PR matrix.