Skip to content

Build as keel-fab, with a version this project chose - #9

Open
marcos-mendez wants to merge 9 commits into
masterfrom
pkg/keel-fab
Open

marcos-mendez wants to merge 9 commits into
masterfrom
pkg/keel-fab

Conversation

@marcos-mendez

@marcos-mendez marcos-mendez commented Sep 28, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #8. It also closes #7, in the same pull request rather than a separate one: #7 asks for a changelog entry that makes the running version exist in git, for the require-changelog wiring, and for provenance checkable from a manifest and a git tag. The first is already true and was true before #7 was filed (below); the other two are here. There is nothing left in #7 that this does not do, and the constraint is one pull request per repository.

What 1.1.1+keel2 actually is, measured before anything was written

The installed package on the build host is byte for byte /root/src/fab/../fab_1.1.1+keel2_all.deb, sha256 1b8ee5bcc2a9e91596f521abe561f4c69a4c1b92e1ee014d952eabd8b6186adf, built there on 2026-09-27 at 17:14:56 UTC. /var/lib/dpkg/info/fab.md5sums is identical to the .deb's own md5sums, and dpkg -V fab is clean.

It was built from /root/src/fab at commit e610377 with a clean worktree. Every file it ships is in git. Comparing the extracted .deb against that checkout, file by file: 22 of 25 byte identical, and the three that differ (/usr/bin/fab, share/make-release-deb.py, share/turnkey-version.py) differ only in dh_python3's shebang normalisation, #!/usr/bin/python3 to #! /usr/bin/python3. product.mk is a06bfe03, as #7 records. There is nothing in the running fab that is not in this repository.

And the changelog entry exists. e610377 is debian: changelog 1.1.1+keel2, the unit slots the build host needs, and it merged to master as #6 at 2026-09-27T19:58:53Z, ten hours before #7 was filed at 2026-09-28T06:06:20Z. git log --oneline -- debian/changelog on master today returns two commits, not one. The measurement in #7 read a stale ref: in the build host clone HEAD is e610377, but remotes/origin/master is 16730a5, the #5 merge, because the clone had not fetched since before #6.

So the defect is not the one #7 describes, and it is worse in one respect and better in another:

#7 says Measured
1.1.1+keel2 appears nowhere in the history It is on master, merged in #6 before #7 was filed
The installed files match HEAD They do, everywhere, modulo a deterministic shebang rewrite
The version was bumped at packaging time True, and it is the real defect: the package was built and installed at 17:14 and no branch carried the version until 19:58
Two behaviour changing pull requests merged with no entry True: #4 and #5 both changed share/product.mk, a path this package ships
Provenance must be checkable from a manifest and a tag Neither packaged version was tagged at all. git tag -l carried only upstream's v0.5..v1.1.1

The last row is what was actually unfixed, and it is why core.manifest's fab_version 1.1.1+keel1 named no commit. Two annotated tags now exist, pushed ahead of this branch so the new check passes: fab/1.1.1+keel1 on b07a733 and fab/1.1.1+keel2 on e610377, each recording what was built from it and that it was tagged after the fact.

One unrelated finding, for whoever owns docs/build-host.md: it records the +keel1 .deb as sha256 f4bf09be9535…, and the file on the host and its own .changes both say 1dc54a20acc2….

The shape, and why

The package becomes keel-fab at 2.0.0. The repository stays fab. Decision 0006 rules on repository names, and infrastructure keeps its upstream name; this renames the Debian package, which is a different axis, and the fork, its history and its upstream compatibility are untouched. No +keelN.

2.0.0 and not 0.1.0, and the changelog gate is what settled it. 0.1.0 was proposed first, matching the scheme every package this project owns already starts at: keel 0.1.0, keel-transition 0.1.0, keel-archive-keyring 0.1.0. The gate wired up in this same branch refused it on its first run, which is the first thing it has ever caught here. dpkg --compare-versions 0.1.0 gt 1.1.1+keel2 is false; require-changelog compares the proposed top version against the base's exactly that way, reprepro and dpkg-genchanges read a changelog as one monotonic series too, and dpkg-genchanges was already warning on the 0.1.0 build. A rename does not give the changelog a fresh start. The reset was the wrong signal as well: the other Keel packages start at 0.1.0 because they had no predecessor, while this code has been building every layer in production since before it was ours. 2.0.0 sorts above every upstream 1.x and both +keelN builds, carries no suffix, and says what happened. tests/packaging.sh now asserts the ordering with that measurement in the comment.

The apt tooling agrees without being changed, which is the strongest argument for the name. apt/lib/build.sh classifies a package as native when the Source starts with keel, the clone has no upstream remote, and neither debian/watch nor debian/upstream/metadata exists. This clone has no upstream remote and neither file, so all three now hold and bin/build-package will accept 0.1.0 on its own. The +keelN guard correctly stops applying instead of having to be overridden with --native.

Provides: fab, Conflicts: fab, Replaces: fab, all unversioned. apt-cache rdepends fab on the build host is empty and no debian/control in the organization depends on fab, so Provides satisfies nothing here. It does not protect an install by name: apt prefers a real fab in an enabled archive (TurnKey's, at 999 on the build host) and removes keel-fab for it, and a fab plan cannot resolve a virtual name at all (tkldev#4). What protects the host is the negative pin Package: fab / Pin: release o=turnkeylinux / Pin-Priority: -1 (not Pin: origin "", which only matches a local repository), which makes apt#15 a prerequisite of the conversion. Conflicts and Replaces are the load-bearing pair: the two packages ship the same paths and must never be co-installed. A versioned Provides: fab (= 1.1.1+keel2) was considered and rejected: it would satisfy a versioned dependency on fab, of which there are none anywhere, at the cost of writing upstream's version number back into a package whose point is not to carry it.

The call sites decide the command names, and they say keep them

Counted across the organization (about 700 references, measured in review over 40 repositories; the first count was 10 to 20 percent lower), and not one of them reads the Debian package name: they are $PATH command lookups, make variables and filesystem paths, and Debian imposes no relation between a package's name and the paths it ships.

Symbol References What it is
fab-chroot ~100 command on PATH
fab-apply-overlay 34 command on PATH
fab-plan-resolve 25 command on PATH
fab-apply-removelist 12 command on PATH
fab-apply-patch, fab-install, fab-cpp, fab-query, fab-plan-annotate 21 commands on PATH
fab-investigate, fab-rewind 17 commands on PATH
FAB_PATH ~256 make variable, /turnkey/fab-keel
FAB_ARCH ~92 make variable
$(FAB_PATH)/common 65 a git checkout, not a packaged path
/usr/share/fab 9 packaged path, via FAB_SHARE_PATH
import fab 0 the module is fablib, and always was

So every command name, every path and the fablib module stay exactly as they are. Renaming the binaries is a much larger change and is not in here. The nine /usr/bin/fab-* aliases are each asserted separately in tests/packaging.sh, because the one thing a rename really does break is debian/fab.install, debian/fab.links and debian/fab.docs: debhelper keys them on the binary package name, and a rename that leaves them behind builds a package with no /usr/bin/fab* and no /usr/share/fab while saying nothing, with the failure surfacing at the first fab-chroot of the next build.

Nine sites do read the package name. Eight are listed under "not in this pull request" below. The ninth was inside this repository and is fixed here: fab --version ran apt-cache policy fab, which asks about whatever package is called fab on the machine rather than about itself. After this rename it would answer (none), measured on the build host against a real virtual package. bt-layer:214 writes that answer into every layer manifest as fab_version, so it would have written (none) into the provenance record of every image built afterwards. debian/rules now records the changelog version at /usr/share/fab/version and fablib/version.py reads it back, which is also the right answer on a host that installed a .deb from a file and has no archive entry either way.

Which fab built this layer, in both directions

A manifest records the builder as a version string and nothing else, so the string is provenance only if it resolves to a commit.

  • Manifest to commit. fab_version 1.1.1+keel1 becomes git rev-parse fab/1.1.1+keel1. bin/check-release-tags makes that total: every changelog entry whose distribution is not UNRELEASED is a release, and every release below the newest must have a tag <source>/<version> whose tree carries that version at the top of its changelog. The newest entry is exempt, being the release a pull request is proposing. The source name comes from the entry, so the two versions released before the rename are checked as fab/… and not keel-fab/….
  • Commit to manifest. git describe --match '*/*' on a commit names its release, and that tag's changelog gives the version a manifest would record. No new machinery.

Recording a commit in the manifest as well is worth doing, and is not needed now. keel already keeps any manifest key it does not know and writes it back unchanged (keel/docs/layers.md, "Optional fields"), so a fab_commit field costs one line at bt-layer:214 and nothing at all in keel. What it buys over the tag is the case the tag cannot cover: a .deb built from an untagged or dirty tree, which is exactly what happened on 2026-09-27. The tag invariant closes that procedurally and the CI job keeps it closed, so fab_commit is belt and braces rather than a prerequisite. It is a buildtasks change, so it is a second pull request in that repository, and it is filed as Keel-Linux/buildtasks#12 rather than bundled here.

Converting the build host, and the way back

Nothing was built, installed or changed on the build host. Access there was read only throughout, no lock was taken, and no layer was published or rebuilt. The package was built in a throwaway debian:trixie container to prove debian/rules works and to compare the result against what the host runs.

The conversion is one command and is reversible, because fab_1.1.1+keel2_all.deb stays in /root/src and nothing deletes it:

# forward
apt-get install ./keel-fab_2.0.0_all.deb          # removes fab, Conflicts
# and /etc/apt/preferences.d/keel-fab becomes  Package: keel-fab
#                                              Pin: version 2.*

# back
apt-get install /root/src/fab_1.1.1+keel2_all.deb  # removes keel-fab
# and restore the pin stanza

Verify after either direction with fab --version, dpkg -L keel-fab | grep /usr/bin, and one fab-chroot in a scratch tree.

Two things to know before scheduling it. /etc/apt/preferences.d/keel-fab pins Package: fab / Pin: version 1.1.1+keel1*, and the installed version is 1.1.1+keel2, so that pin matches nothing today and only Debian version ordering is keeping the local build in place over upstream's 1.1.1 at priority 999; it needs fixing whether or not this lands. And docs/build-host.md sections 2 and 3 describe the package and the pin and will need the new name.

Measured

Suite Checks
tests/source-date-epoch.sh 19 of 19
tests/units.sh 56 of 56
tests/packaging.sh 36 of 36
tests/release-tags.sh 15 of 15

126 of 126, 100 percent. The gate stays at 100. The TAP helpers moved to tests/tap.sh when the third suite wanted them; the handbook records copying a test library instead of sharing it as something this project did wrong and would do again unless written down.

What no assertion about debian/ can prove is that the built package is the same package, so that was measured, over the full file set. keel-fab 2.0.0 against the fab 1.1.1+keel2 installed on the build host: 35 entries old, 37 new; 27 at the same path, 26 byte identical (all nine /usr/bin/fab-* symlinks and share/product.mk among them; /usr/bin/fab differs by the get_version change alone); 8 renamed by debhelper keying on the package name (four dist-info files, three under usr/share/doc, runtime.d/fab.rtupdate); 2 new, fablib/version.py and /usr/share/fab/version. Nothing dropped, and dpkg-genchanges no longer warns.

That comparison is also what caught the one real regression in this change, which no reading of debian/ would have. dh_python3 finds a private python directory by the binary package name, so while the package was called fab it picked up /usr/share/fab on its own; renaming it silently dropped the byte-compilation registration and the shebang rewrite. Naming the directory fixes that, but a single call with an argument replaces the default pass rather than adding to it, and the default pass is what moves fablib off /usr/lib/python3.13/dist-packages onto the version independent /usr/lib/python3/dist-packages, so one call trades one silent divergence for a worse one. All three variants were built and compared; both calls are needed, and debian/rules says why.

Not in this pull request, deliberately

Each is a different repository, so each is its own issue and its own pull request.

Where What
apt (turnkeylinux#15) Prerequisite of the conversion. The pin matches nothing today; the conversion needs the negative pin on the real fab above. 4 package-name reads in tests/build-package.bats (:56, :192-194), docs/layout.md pool path, and the native classification depending on there being no upstream remote.
buildtasks (#12) fab_commit in the manifest at bt-layer:214. Also the dead bt-img:113-118 gate, which assumes a fab_<ver>_all.deb filename.
tkldev (#4) tkldev-setup:372 installs fab by name, which Provides does not protect (see above); plan/main:3 names it in a fab plan, which cannot resolve a virtual package and drops it silently. Before the archive ever drops fab.
handbook docs/infra-recovery.md:96 installs fab by name; docs/build-host.md sections 2 to 4. At conversion time.
bootstrap README.rst:32 lists fab as a dependency; Makefile:41 checks for the command, which is unaffected.
organization (tracker#18) Seven more repositories ship something installable and do not call require-changelog, keel-core among them. Audited in Keel-Linux/tracker#18, since it is common to the whole organization.
handbook (turnkeylinux#18) Decision note 0017, its own issue and pull request. docs/build-host.md sections 2 and 3 at conversion time.

Test plan

  • Four suites green, 141 of 141, tests/coverage.sh 100.
  • shellcheck -x clean on every file this branch adds or touches. The remaining findings in tests/regtest.sh and tests/override.sh are upstream's and untouched.
  • The package builds in a debian:trixie container and its contents were compared against the .deb installed on the build host, path set and byte content.
  • bin/check-release-tags passes on this branch, with fab/1.1.1+keel1 and fab/1.1.1+keel2 pushed.
  • Nothing built, installed, rebuilt or changed on the build host; read only access, no lock taken, no layer published.
  • package / changelog, release-tags and tests / coverage all green. The first two ran here for the first time, and package / changelog earned its keep immediately by refusing 0.1.0.
  • Maintainer confirms keel-fab and 2.0.0, and schedules the conversion. Handbook decision note 0017 (Decision note 0017: owning fab, and how a package this project depends on is named handbook#18) carries the reasoning.

Two suites carried the same twenty lines and a third was about to make it
three. The handbook records copying a test library instead of sharing it as
something this project did wrong and would do again unless it was written
down, so the helpers move to tests/tap.sh and the suites source it.

No check changes: source-date-epoch is 19 of 19 and units 56 of 56, as
before.
… from

A layer manifest records the builder as a version string and nothing else:
core.manifest on the mirror carries "fab_version 1.1.1+keel1". That string
is provenance only if it resolves to a commit.

On 2026-09-27 it did not. The package installed on the build host,
fab 1.1.1+keel2, was built at 17:14 from a commit that reached the default
branch at 19:58 and was never tagged, so for those hours the machine that
builds every layer ran a version no clone could name a commit for.

bin/check-release-tags states the rule: every changelog entry whose
distribution is not UNRELEASED is a release, and every release below the
newest must have a tag <source>/<version> whose tree carries that version
at the top of its changelog. The newest is exempt, being the one a pull
request is proposing. The source name comes from the entry, so a version
released before a rename is checked under the name it was released as.

The other direction needs nothing new: git describe on a commit says which
release it belongs to, and that tag's changelog says the version a manifest
would record.

tests/release-tags.sh drives the script against repositories it builds
under mktemp -d, so the suite does not depend on this repository's own
tags: 15 of 15 checks, covering each verdict and each of the three exit
codes.
A +keelN suffix on somebody else's version string says we patched their
thing. The running 1.1.1+keel2 is what that half measure produced: a
version invented at packaging time, on a machine, for a repository whose
default branch did not carry it until three hours later.

The source and binary package are now keel-fab at 0.1.0, the plain scheme
every package this project owns already uses (keel 0.1.0, keel-transition
0.1.0, keel-archive-keyring 0.1.0). The repository keeps its upstream name
and its upstream history, per decision 0006: the rename is of the package,
not of the fork.

The apt tooling agrees without being changed. bin/build-package classifies
a package as native when the Source starts with keel, the clone has no
upstream remote and nothing records an upstream. All three now hold, so the
+keelN guard correctly stops applying to this package instead of having to
be overridden.

Provides, Conflicts and Replaces fab. There is no reverse dependency on fab
anywhere in the organization, so Provides is not what the callers need; it
is there for the two places that install the package by name, the tkldev
recipe's plan and tkldev-setup. Conflicts and Replaces are what matter: the
two packages ship the same paths and must never be installed together.

Nothing a caller can see is renamed. Roughly 380 call sites across this
organization name fab-chroot, fab-apply-overlay, FAB_PATH, $(FAB_PATH)/common
or /usr/share/fab, in buildtasks, in the shared makefiles and in every
recipe Makefile, and not one of them reads the Debian package name: they are
PATH lookups, make variables and filesystem paths. So /usr/bin/fab, the nine
fab-* aliases, /usr/share/fab and the fablib module keep their names exactly.

What does have to move is the three debhelper files, which are keyed on the
binary package name. A rename that leaves debian/fab.install behind builds a
package with no /usr/bin/fab* and no /usr/share/fab, and says so nowhere:
the failure surfaces at the first fab-chroot of the next build. Every
shipped path is a check in tests/packaging.sh for that reason, each of the
nine aliases on its own.

fab --version no longer runs "apt-cache policy fab". That asked about
whatever package is called fab on the machine rather than about itself, so
after this rename it would answer "(none)", and bt-layer writes the answer
into every layer manifest as fab_version. debian/rules records the
changelog version at /usr/share/fab/version and fablib/version.py reads it
back, which is also the right answer on a host that installed the .deb from
a file and has no archive entry at all.

tests/packaging.sh: 34 of 34.
This repository had only tests.yml, calling the shared coverage workflow.
It never called the organization's require-changelog gate, which is the
direct cause of #7: pull requests #4 and #5 both changed share/product.mk,
a path this package ships, and neither added a changelog entry. The version
was bumped at packaging time instead.

The gate has existed in Keel-Linux/.github throughout and 15 repositories
call it. This one now does, with the defaults, since debian/changelog is
where its version lives. The check is "package / changelog".

release-tags runs bin/check-release-tags with a full checkout, because the
tags are the thing under test and the default shallow fetch brings none.
tests/coverage.sh runs source-date-epoch, units, packaging and
release-tags, and counts the checks of all four: 124 of 124, 100 percent,
measured 2026-09-28. The gate stays at 100.

COVERAGE.md records why packaging.sh asserts each shipped path separately,
and why fablib/version.py is driven directly rather than through the fab
entry point, which imports chroot and python3-debian and cannot run on the
coverage runner.
Found by building the package, which no assertion about debian/ could have
caught. dh_python3 recognises a private python directory by the binary
package name, so while the package was called fab it picked up
/usr/share/fab on its own. Renaming the package to keel-fab silently
dropped the byte-compilation registration in /usr/share/python3/runtime.d
and the shebang rewrite of the two scripts under /usr/share/fab.

Naming the directory is the fix, but a single call with the argument
replaces the default pass rather than adding to it, and the default pass is
what moves fablib from /usr/lib/python3.13/dist-packages, where pybuild put
it, to the version independent /usr/lib/python3/dist-packages. So one call
with the argument trades one silent divergence for a worse one. Both calls
are needed.

All three variants were built in a debian:trixie container and compared
against the fab 1.1.1+keel2 .deb the build host has installed. With both
calls, keel-fab 0.1.0 ships the same 26 paths: 24 byte identical, including
all nine /usr/bin/fab-* symlinks and share/product.mk, with /usr/bin/fab
differing only by the get_version change and the rtupdate file only by the
package name inside it, plus the two new files fablib/version.py and
/usr/share/fab/version. Nothing was built, installed or changed on the
build host.

tests/packaging.sh: 35 of 35. tests/coverage.sh: 125 of 125, 100 percent.
…enamed

The first version proposed was 0.1.0, matching the scheme every package this
project already owns: keel 0.1.0, keel-transition 0.1.0,
keel-archive-keyring 0.1.0. The changelog gate wired up in this same branch
refused it, correctly, and that is the first thing the gate has ever caught
here.

dpkg --compare-versions 0.1.0 gt 1.1.1+keel2 is false. require-changelog
compares the proposed top version against the base's exactly that way, and
reprepro and dpkg-genchanges read the file as one monotonic series too, so
renaming the source does not start the numbering over. dpkg-genchanges was
already saying so as a warning on the 0.1.0 build.

The reset was also the wrong signal. The other Keel packages start at 0.1.0
because they had no predecessor; this code has been building every layer in
production since before it was ours, and 0.x would claim it is pre-release.
2.0.0 sorts above every upstream 1.x and above both +keelN builds, carries
no suffix, and says a major break: the name, the numbering and the ownership
all change at once.

tests/packaging.sh now asserts the ordering against the entry below, with
the measurement that produced the rule in the comment, so the next person
renaming a source package reads it here rather than from a red check.

Rebuilt and recompared in the container: keel-fab 2.0.0 against the fab
1.1.1+keel2 installed on the build host has all 28 of its paths, 26 byte
identical, the two that differ being /usr/bin/fab by the get_version change
and the rtupdate file by the package name inside it, plus two new files.
Nothing is missing, and dpkg-genchanges no longer warns.

tests/packaging.sh: 36 of 36. tests/coverage.sh: 126 of 126, 100 percent.
marcos-mendez added a commit to Keel-Linux/handbook that referenced this pull request Sep 28, 2026
The note argued 0.1.0, matching the scheme every package this project
already owns. The changelog gate wired up in Keel-Linux/fab#9 refused it on
its first run, and it was right: dpkg --compare-versions 0.1.0 gt
1.1.1+keel2 is false, require-changelog compares exactly that way, and
reprepro and dpkg-genchanges read a changelog as one monotonic series too.
A rename does not give the file a fresh start.

The note now carries the measurement and the rule it produces: a package
that becomes ours takes the next version above the highest it has already
published, and only a package with no predecessor starts at 0.1.0. That is
the part the next package to move needs, and it was not obvious enough to
get right by reasoning.

The trap entry gains the same lesson as a second paragraph, since it has the
same root, a rename, and was found the same way, by a gate rather than by
reading.

Also the precise comparison numbers: all 28 paths the fab 1.1.1+keel2 on the
build host ships are present in keel-fab 2.0.0 and 26 are byte identical,
with the two differing being /usr/bin/fab by the get_version change and the
rtupdate file by the package name inside it.
@marcos-mendez

Copy link
Copy Markdown
Collaborator Author

Review of #9

Read-only throughout: nothing was built, installed or changed on the build host, no lock was taken. The package was built independently in a throwaway debian:trixie container, and the apt behaviour below was measured in a second container rather than on the host.

First, the thing #7 got wrong

Confirmed, and the correction in this pull request is the right outcome.

e610377 is "debian: changelog 1.1.1+keel2", it is an ancestor of master, and it merged as #6 at 2026-09-27T19:58:53Z. #7 was filed at 2026-09-28T06:06:20Z. git log --oneline origin/master -- debian/changelog returns two commits.

The cause is one step more specific than "the build host's own master". On the host, /root/src/fab is on local branch pkg/keel2 at e610377 with a clean worktree, and git log -- debian/changelog there, at HEAD, already returns both commits. What is stale is the remote-tracking ref: remotes/origin/master on that host is 16730a5, the #5 merge, so the host has not fetched since before #6 landed. master is 858d193, behind 4 of a stale origin/master and behind 6 of the real one. A measurement taken against origin/master in that clone produces exactly the line #7 reports.

The 1.1.1+keel2 installed tree claim also reproduces, exactly, and by a stronger method than file counting. Of the 18 paths the installed package takes from git, 15 are byte identical to e610377, and for the three that are not, re-applying only the shebang normalisation reproduces the installed md5sums:

usr/bin/fab                      f0c0d062b69a7734605a9fa7ea7fb31a  MATCH
usr/share/fab/make-release-deb.py 38ae5d92c156dc6b46e25707dbe25743 MATCH
usr/share/fab/turnkey-version.py  c658c643277f1a8f1dbc3e4eaa9a40aa MATCH

sha256 1b8ee5bc… on /root/src/fab_1.1.1+keel2_all.deb matches, dpkg -V fab is clean, and both annotated tags point where the body says. Tagging after the fact is the honest thing here and the annotations say so in the tag message, which is the right place for it. Nothing running on the build host is outside this repository.

Everything below is about what the change does next, ordered by severity.


HIGH 1. Provides: fab does not protect the build host. Measured.

debian/control:28, and the body's row "tkldev | Both are satisfied by Provides: fab, so neither is urgent".

apt prefers a real package over a virtual provider. The build host has a real fab in an enabled archive:

$ apt-cache policy fab                    # build host, read only
 *** 1.1.1+keel2 100   /var/lib/dpkg/status
     1.1.1       999   http://archive.turnkeylinux.org/debian trixie/main

Reproduced in a container with keel-fab 2.0.0 installed from a file at 100 and a real fab in an archive at 999:

$ apt-get install -s fab          # exactly what tkldev-setup:372 runs
The following packages will be REMOVED:
  keel-fab
The following NEW packages will be installed:
  fab
Remv keel-fab [2.0.0]
Inst fab (1.1.1 …)

Same result when upstream's real fab is 3.0.0, above keel-fab 2.0.0. So after the conversion, one tkldev-setup run, or one run of the disaster-recovery procedure in handbook/docs/infra-recovery.md:96, silently replaces the builder with upstream's fab, losing the SOURCE_DATE_EPOCH handling and the unit phases, and the next build is wrong rather than broken. apt-get upgrade and dist-upgrade are safe: nothing depends on fab, so nothing pulls it in unattended.

What closes it, also measured:

# /etc/apt/preferences.d/keel-fab
Package: fab
Pin: origin ""          # or the TurnKey origin
Pin-Priority: -1

With that in place apt-get install fab correctly answers "keel-fab is already the newest version".

This makes apt#15 a prerequisite of the conversion, not an independent follow-up. Its current text asks for the fab stanza to be widened and then renamed; it does not ask for upstream's real fab to be made unselectable, and that is the part the rename creates. apt#15 also still says the new stanza should pin 0.*, which was written before 2.0.0.

HIGH 2. tkldev/plan/main:3 is not satisfied by Provides: either, and fails silently

Same body row. This one is not an apt question at all. tkldev/Makefile is include $(FAB_PATH)/common/mk/turnkey.mk, so plan/main goes through fab-plan-resolve, and fab's own resolver does not resolve virtual packages:

  • fablib/plan.py:205-206 takes the pool's answer and does package_name = fname.split("_")[0], then self._deps[deps[package_name]]. The .deb filename's first field must be string-equal to the requested name. A keel-fab_2.0.0_all.deb returned for a request of fab raises an uncaught KeyError.
  • _get_provided (plan.py:329) reads Provides only from packages already fetched, and provided is only subtracted from all_missing at plan.py:406. It never causes a provider to be fetched.

And the miss is silent: missing is initialised at plan.py:366 and never mutated, while brokendeps is built by iterating it at plan.py:421-422, so resolve() returns (spec, []) even when all_missing is non-empty. A plan entry the pool cannot supply is dropped from the spec with no error.

That is upstream code and not this pull request's doing, so it is not a blocker for the diff. But it means the deferral rationale is wrong for this site, and it is latent rather than immediate only because apt/pool/main/f/fab/fab_1.1.1+keel1_all.deb is still in the archive: TKLDev images built after the conversion would keep shipping the pre-rename fab while the build host runs keel-fab, and the day fab leaves the pool they would ship no fab at all without saying so. plan/main:3 needs to become keel-fab, and it needs its own issue before the conversion, not after.

HIGH 3. The newest release is exempt forever, which is #7's hole one version deep

bin/check-release-tags:95-105.

The exemption is right for a pull request. The problem is that nothing ever revokes it. release-tags has no if:, so it does run on push: branches: [master], but the script exempts the newest entry there too. So keel-fab 2.0.0 merges with the check green, gets built, gets installed on the host, and bt-layer:214 writes fab_version 2.0.0 into every manifest, while git rev-parse keel-fab/2.0.0 fails, until somebody adds the next changelog entry. That is exactly the state #7 was filed about.

Two ways to close it, either is fine:

  • have the push: master run require the newest release to be tagged, for example bin/check-release-tags --require-newest, and push the tag with the merge as was done for fab/1.1.1+keel1 and fab/1.1.1+keel2; or
  • put the invariant where the defect actually happened, in bin/build-package: refuse to build a .deb whose top changelog version has no tag. The untagged .deb on 2026-09-27 was produced by a build, not by a merge.

There is also a bypass worth knowing about: an UNRELEASED entry on top keeps the newest released entry exempt, because proposed is set from the first non-UNRELEASED entry. Verified:

$ bash bin/check-release-tags .      # changelog: 4.0 UNRELEASED, 3.0 trixie, …
p/3.0 is the release being proposed, not tagged yet

HIGH 4. The gate accepts a lightweight tag on a commit that is not in the branch

bin/check-release-tags:59-61 and :107-120.

has_tag only checks refs/tags/<name> exists, and tag_version only compares the first line of that tree's changelog. Nothing checks the tag is annotated, and nothing checks it is reachable. Demonstrated on a scratch repository: two lightweight tags on an orphan branch whose commits contain entirely different content pass the gate.

p/2.0 names the commit that carries 2.0
p/1.0 names the commit that carries 1.0
rc=0
$ git merge-base --is-ancestor p/2.0 main || echo "NOT reachable from main"
NOT reachable from main

The annotated part is not cosmetic. The note's other direction, git describe --match '*/*', only sees annotated tags:

$ git describe --match '*/*' HEAD~1
fatal: No annotated tags can describe '6ce89b3…'.
However, there were unannotated tags: try --tags.

So the two halves of the "in both directions" invariant are enforced to different standards, and the half that CI checks is the weaker one. Two lines fix it:

[[ "$(git -C "$1" cat-file -t "refs/tags/$2")" == tag ]] || …   # annotated
git -C "$1" merge-base --is-ancestor "refs/tags/$2" HEAD || …   # reachable

The tags actually pushed are annotated and on master, so this is about what the rule permits next time, not about what is there now. tests/release-tags.sh currently locks the looser behaviour in, because every fixture uses git tag "$2".


MEDIUM 5. fab --version can still write a wrong fab_version, through FAB_SHARE_PATH

fablib/version.py:22-24.

share_path() honours FAB_SHARE_PATH. Nothing else in fab or fablib reads that variable any more; its only other life is as a make variable, share/product.mk:60 and common/mk/turnkey.mk:26, both ?= and neither exported, so a normal bt-layer run is fine and I could not make it misfire. But an exported FAB_SHARE_PATH pointing at a checkout, which is a plausible thing for someone debugging fab to do, makes package_version() return unknown, fab --version print it and exit 0, and bt-layer:214 write fab_version unknown into the manifest. bt-layer cannot catch it either: fab_version "$(fab --version)" is a command substitution in an argument list, so even a non-zero exit is discarded under set -e.

Given the stated premise that a wrong provenance record is worse than none, the test override wants to be its own variable, say FAB_VERSION_FILE, so that nothing a build environment sets can redirect the version lookup. Making --version exit non-zero on unknown is worth doing too, but only helps once buildtasks#12 reads the status.

The rest of that path is sound. I built the package and /usr/share/fab/version contains exactly 2.0.0, package_version() returns '2.0.0', and the file can never be empty because read_text().strip() or UNKNOWN covers it. override_dh_install writing into a directory dh_install must have created means a missing directory is a loud build failure, not a silent empty file, which is the right way round.

MEDIUM 6. The "28 paths, 26 byte identical" measurement excludes 7 files without saying so

I rebuilt the package and compared it against the fab 1.1.1+keel2 installed on the host. The substantive claims all hold, and the dh_python3 finding is real and well handled: make-release-deb.py and turnkey-version.py come out byte identical to the installed ones, runtime.d/keel-fab.rtupdate is present, and fablib lands in /usr/lib/python3/dist-packages rather than python3.13, which is the three-way result the two calls were for.

The arithmetic is not reproducible as written. The old package ships 26 regular files plus 9 symlinks. 28 reconstructs only as 19 files plus 9 symlinks, that is, with the 4 dist-info files and the 3 usr/share/doc/fab/ files dropped. All three things the summary does not mention live in that dropped set:

  • usr/share/doc/fab/ becomes usr/share/doc/keel-fab/, so three paths the old package had are not present under their old names. Nothing in the organization reads that path, so the impact is nil, but "all 28 paths the old package had are present" is not what happened.
  • changelog.gz differs, 115f2f5c to 20760196. It is a third differing file, not two.
  • usr/lib/python3/dist-packages/fab-1.1.0.dist-info/ is unchanged, still fab, still 1.1.0.

Also usr/share/python3/runtime.d/fab.rtupdate becomes keel-fab.rtupdate, which the body covers with a glob rather than calling it a rename.

Restating it as "35 entries in the old package, 32 byte identical, 3 renamed by debhelper, 2 new" would be both truer and stronger.

MEDIUM 7. pyproject.toml is untouched, so there are now three version sources

pyproject.toml:7-8 keeps name = "fab", version = "1.1.0". importlib.metadata.version("fab") on the built package returns 1.1.0 while dpkg says keel-fab 2.0.0 and /usr/share/fab/version says 2.0.0. Keeping it frozen is probably deliberate, since it is what keeps the four dist-info files byte identical, but nothing says so, and the next person to "fix" version reporting will reach for importlib.metadata and get 1.1.0. One comment in pyproject.toml or in debian/rules settles it.

MEDIUM 8. The native classification depends on there being no upstream remote, and 0008 invites one

apt/lib/build.sh:83. The argument for the name is that all three native conditions now hold, and one of them is git -C "$dir" remote | grep -qx upstream. Decision 0008 keeps the fork upstream compatible with cherry-picks in both directions, and the ordinary way to cherry-pick from upstream is git remote add upstream. Whoever does that on the build host clone makes bin/build-package classify keel-fab as a rebuild and refuse 2.0.0 for lacking +keelN. Neither this pull request nor apt#15 mentions it. It wants either a note in apt/lib/build.sh or a condition that does not depend on a local remote name.

MEDIUM 9. bin/check-release-tags has no floor

Every non-UNRELEASED entry below the newest is a release, with no lower bound. It passes today only because the changelog has four entries and the oldest, upstream's fab (1.1.1), is UNRELEASED. If a future merge from upstream brings real upstream entries into the file, the job demands fab/1.1.0, fab/1.0.3 and so on forever, and upstream's tags are v1.1.0, v1.0.3. A floor, or a --since, costs a line.

Related, smaller: a version with an epoch can never satisfy the rule, because refs/tags/keel-fab/1:2.0.0 is not a legal refname. Worth a line in the header given the note proposes this rule for the other repositories.

MEDIUM 10. The call-site inventory misses two live sites and understates one scope

The central negative claim is sound and I reproduced it independently across all 40 repositories in the organization: no debian/control anywhere depends on fab, import fab is genuinely zero, no recipe Makefile, no shared makefile in common and nothing in keel, keel-core or buildtasks reads the package name. buildtasks and the 22 recipe Makefiles need no edit. Missing from the table of nine:

  • handbook/docs/infra-recovery.md:96 : apt-get install -y fab deck pool turnkey-chroot …, the documented rebuild-from-a-fresh-Debian procedure. This is the one that combines with HIGH 1: it installs upstream's fab on a recovered host.
  • apt/lib/build.sh:112,118 : build_archive_version passes the repository name to apt-cache policy. That is the same bug this pull request just fixed in fab --version. It is an error-path hint only, so nothing breaks, but it belongs in the row.
  • The handbook row scopes the change to docs/build-host.md "sections 2 and 3". Section 4, "Rebuilding and reinstalling fab", lines 88-111, is the runbook someone will actually execute, and it names dpkg -l fab, apt-cache policy fab and the pin file.

In the other direction, "~30 literal fab assertions in tests/build-package.bats" overcounts: of the 34 occurrences only 4 are package-name reads, at :56 and :192-194. The rest are the repository name, which is not changing. build_changes_path:137 derives filenames from dpkg-parsechangelog -S Source, so the .deb and .changes names follow the rename on their own.

MEDIUM 11. The reference counts are not reproducible, and the table contradicts the prose

Measured across all 40 repositories, excluding .git/:

Symbol Table Measured
fab-chroot ~100 116
fab-apply-overlay 34 39
fab-plan-resolve 25 28
FAB_PATH ~256 298
FAB_ARCH ~92 111
/usr/share/fab 9 31
import fab 0 0

Low by 10 to 20 percent throughout and by 3.4 times for /usr/share/fab. Undercounting is the safe direction for a claim of the form "none of these reads the package name", so the conclusion is unaffected, but the prose says "roughly 380 references" while the table sums to 631. Say which tree and which exclusions produced the numbers, or round them off.

MEDIUM 12. The coverage number is a pass rate

tests/coverage.sh:37-40 computes the share of checks that pass, so it reads 100 whenever the suites are green and can only fall when a test fails. Adding packaging and release-tags to suites= therefore does not measure whether their branches are exercised; the claim rests entirely on the header's hand enumeration. That is pre-existing and honestly described, but docs/traps.md already carries "100 percent line coverage on the same file" as something that misled this project once, and the two new suites are the first here where a branch can go unexercised without the number moving. Worth a sentence in COVERAGE.md.


LOW

  • The count is 126, not 125. 19 + 56 + 36 + 15, and I ran all four: 1..19, 1..56, 1..36, 1..15, zero not ok. The Measured table says 126 and the test plan says 125.
  • "nine hours before The running fab version does not exist in this repository #7 was filed" is 10 hours 7 minutes. 19:58:53Z to 06:06:20Z.
  • "the build host's own master, which git branch -vv there still reports as behind 4" is behind 4 of a origin/master that is itself stale at 16730a5; against the real master it is behind 6. The conclusion is right either way, and the sharper statement is that the host has not fetched since before Package as 1.1.1+keel2 so the build host gets the unit slots #6.
  • "shellcheck -x clean on every file this branch adds or touches" is true at warning level and above. tests/units.sh and tests/source-date-epoch.sh, both touched here, carry 6 and 9 note-level SC2317. Identical counts on master, so nothing was introduced; the claim just wants "no warnings or errors".
  • debian/rules:15 hardcodes debian/keel-fab/. It fails loudly if the name changes again, which is acceptable, but $(shell dh_listpackages) costs nothing.
  • One operator hazard worth a line in the runbook. apt can never co-install the two: with --no-remove it stops at E: Packages need to be removed but remove is disabled. dpkg -i --force-conflicts --force-overwrite can, and because Replaces is one-directional the result is that keel-fab keeps ownership of /usr/bin/fab and all nine aliases even though fab unpacked second. Removing keel-fab then deletes them, leaving fab 1.1.1+keel2 installed, dpkg -V fab exiting 0, and no /usr/bin/fab and no /usr/bin/fab-chroot on the machine. So: never reach for --force-conflicts here, and verify with dpkg -S /usr/bin/fab rather than dpkg -V.

On the conversion itself

The mechanics are sound and I checked all three directions in a container.

  • Forward, no extra flags: apt-get install ./keel-fab_2.0.0_all.deb removes fab, installs keel-fab, and every path including the nine aliases ends up owned by keel-fab.
  • Back, no extra flags and no --allow-downgrades needed since the names differ: apt-get install /root/src/fab_1.1.1+keel2_all.deb removes keel-fab and restores /usr/bin/fab-chroot.
  • Failing halfway does leave a window with no fab at all, but it is recoverable offline from the kept .deb, which is the property that matters.

So the reversibility claim holds. What is not yet true is that it is safe to schedule: HIGH 1 has to be closed in apt#15 first, with the negative pin and not only the widened stanza, and HIGH 2 needs tkldev/plan/main filed before the archive ever drops fab.

Verdict

No CRITICAL. Four HIGH, none of them in the diff itself: the packaging is right, the dh_python3 work is right and well evidenced, the version choice is right and the test pins it, and #7's premise really was wrong. The HIGH items are in the deferral reasoning and in the reach of the new gate, and three of the four are a few lines each.

Warning.

…ree on one version

Four findings from review, all in the reach of the new gate or in what the
package says about itself rather than in the packaging.

The newest release was exempt from the tag rule forever, not just on a pull
request. Nothing revoked the exemption, so keel-fab 2.0.0 would have merged
green, been built, installed and written into every manifest as fab_version
while git rev-parse keel-fab/2.0.0 failed: issue #7's hole, one version
deeper. The workflow now runs the check twice, and the default branch run
passes --require-newest. Push the release tag with the merge, as was done for
fab/1.1.1+keel1 and fab/1.1.1+keel2.

An UNRELEASED entry on top also passed the exemption down to the newest real
release, indefinitely. The exemption belongs to the entry at the top of the
file and to nothing else, which is what the code now says.

The rule accepted a lightweight tag on a commit outside the branch. That
matters because the reverse direction it promises is git describe --match
'*/*', and git describe refuses a lightweight tag outright, so the half CI
enforced was the weaker of the two. A release tag must now be annotated and
an ancestor of HEAD; both fixtures that prove it build the failing case.

The rule also had no floor, so a later merge from upstream bringing real
upstream entries into the changelog would have demanded fab/1.1.0 and
fab/1.0.3 forever, and upstream tags those v1.1.0 and v1.0.3. --since is the
floor and CI passes 1.1.1+keel1, the first version this project released. The
header records that an epoch can never satisfy the rule, since
refs/tags/<source>/1:2.0.0 is not a legal refname.

pyproject.toml was never touched, so the built package told
importlib.metadata it was "fab 1.1.0" while dpkg called it keel-fab 2.0.0 and
/usr/share/fab/version said 2.0.0: three answers to one question, and the
next person to fix version reporting would have reached for the wrong one.
All four now agree and the suite asserts it.

The version override is FAB_VERSION_FILE rather than FAB_SHARE_PATH.
FAB_SHARE_PATH is a build variable; product.mk and turnkey.mk both set it
with ?= and neither exports it, so no build misfires today, but one exported
FAB_SHARE_PATH pointing at a checkout was enough to make bt-layer record
fab_version unknown on every image built afterwards. A check now refuses to
let any build variable back into that lookup.

debian/rules derives the staging directory from dh_listpackages instead of
hardcoding it.

Rebuilt and recompared over the full file set, with nothing excluded this
time: 35 entries in the old package and 37 in the new, 27 at the same path
of which 26 are byte identical, 8 renamed by debhelper keying on the package
name, 2 new, nothing dropped. The earlier "28 paths, 26 identical" quietly
left out the dist-info and usr/share/doc files, and all three unmentioned
changes were inside what it left out.

tests/release-tags.sh: 27 of 27. tests/packaging.sh: 39 of 39.
tests/coverage.sh: 141 of 141, 100 percent.
The comment above the release-tags job carried its first paragraph twice,
the old text left in place when the two invocations were added, and the
old copy still said the newest entry is exempt, which is now true on a
pull request only.
@marcos-mendez

Copy link
Copy Markdown
Collaborator Author

HIGH 3 and 4 are fixed in 6a1f93c (reproduced: an untagged newest release fails under --require-newest, and an UNRELEASED entry on top no longer exempts the release below it; a lightweight or unreachable tag fails). HIGH 1 and 2 gate the conversion, not this merge, and are apt#15 and tkldev#4; note the negative pin has to be Pin: release o=turnkeylinux, since Pin: origin "" only matches a local repository (reproduced, see apt#15). After merging, push an annotated keel-fab/2.0.0 on the merged head, or the default-branch release-tags job goes red.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Make this a Keel package with a version we choose The running fab version does not exist in this repository

1 participant