Skip to content

Decision note 0017: owning fab, and how a package this project depends on is named - #18

Open
marcos-mendez wants to merge 3 commits into
mainfrom
docs/decision-0017-keel-fab
Open

marcos-mendez wants to merge 3 commits into
mainfrom
docs/decision-0017-keel-fab

Conversation

@marcos-mendez

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

Copy link
Copy Markdown
Contributor

Closes #17.

Decision note 0017, and only 0017: 0014, 0015 and 0016 are concurrent work, so the number was taken deliberately rather than as "the next free one". 0011 stays a gap, per the record in #11.

What it records

The maintainer's decision of 2026-09-28 in Keel-Linux/fab#8, and the reasoning that decides the shape rather than assuming it. The rule it settles outlives fab: a Debian package this project depends on and modifies becomes a Keel package with a version this project chooses, not upstream's version with a +keelN suffix. For fab that is keel-fab at 2.0.0.

Implementation is Keel-Linux/fab#9, which closes fab#8 and fab#7. Not merged, and the build host has not been converted.

Three things the note adds beyond the issue

It corrects the record on what 1.1.1+keel2 was. Keel-Linux/fab#7 states that the version exists in no commit and implies the installed files might diverge from git. Both were measured and both are wrong. Every file the package ships is in git: comparing the extracted .deb against e610377, 22 of 25 byte identical and the three that differ do so only in dh_python3's shebang normalisation. And the changelog entry merged as fab#6 at 2026-09-27T19:58:53Z, ten hours before fab#7 was filed; git log -- debian/changelog on master has returned two commits since then. fab#7 was read from a stale ref: the build host clone had not fetched since before fab#6, so its origin/master predates the entry while its HEAD carries it.

That matters for the note because it changes what the defect is. It was never the missing entry. It was that a version string was the only provenance there was, and neither packaged version was tagged at all, so core.manifest's fab_version 1.1.1+keel1 named no commit. The note says that plainly instead of repeating the issue.

It separates three axes that were being conflated. The repository name (0006 rules on it, keeps fab, and stands), the Debian package name (renamed, and this is the first divergence between the two, so it is stated rather than inferred), and the command names (unchanged, decided by 380 call sites of which not one reads the package name). It also states why decision 0015 does not reach these commands: a build tool is not operator facing, and extending 0015 to it would rename 380 call sites to no operator's benefit inside a change whose point is that the build host keeps building.

It argues the name from the tooling rather than from taste. 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. The fab clone already satisfied the last two, so keel-fab makes bin/build-package accept 0.1.0 on its own and the +keelN guard correctly stops applying, with nobody passing --native. The rule that selects the version scheme is keyed on the prefix, so a package whose scheme is ours has to carry the prefix or the policy and the code disagree.

The version number was got wrong first, and the note records how

The note argued 0.1.0, matching the scheme every package this project already owns starts at. The changelog gate wired up in Keel-Linux/fab#9 refused it on its first run and 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 changelog a fresh start.

So the note carries the rule the next package to move needs: a package that becomes ours takes the next version above the highest it has already published, chosen by what the change means, and only a package with no predecessor starts at 0.1.0. For fab that is 2.0.0, which also stops 0.x claiming that the thing building every layer in production is pre-release.

This is recorded rather than quietly corrected because it was not obvious by reasoning; a gate caught it, and the gate only existed because the same pull request wired it up.

The Provides/Replaces/Conflicts reasoning, for tracker#13's benefit

This is the first such triple in the organization's packaging, so the note records what each field is for. Conflicts is load bearing and Replaces hands the paths over. Provides satisfies a dependency on the name and nothing more: it does not protect an install by name while TurnKey's archive offers a real fab (apt removes keel-fab for it, reproduced 2026-09-29), and a fab plan cannot resolve it at all (tkldev#4). The negative pin Package: fab / Pin: release o=turnkeylinux / Pin-Priority: -1 is what protects the host; Pin: origin "" only matches a local repository and does not. So apt#15 is a prerequisite of the conversion, and tracker#13 needs the same pin per replaced name.

A versioned Provides: fab (= 1.1.1+keel2) is recorded as considered and rejected: it would satisfy a versioned dependency that exists nowhere, at the cost of writing upstream's version number back into the package whose point is not to carry one.

Also in this pull request: one new trap

docs/traps.md gains "Renaming a Debian package drops whatever debhelper found by its name". debhelper keys .install, .links and .docs on the binary package name, and dh_python3 finds a private python directory the same way, so a rename silently drops whatever was being picked up by name and the failure surfaces at first use. Naming the directory back has its own trap: dh_python3 <dir> processes that directory instead of its default pass, and the default pass is what moves a module off /usr/lib/python3.13/dist-packages onto the version independent path, so one call with the argument trades a missing registration for a module pinned to one python version. Both calls are needed.

It was found by building the old and new packages in a container and diffing them. Every static check on debian/ passed throughout and said nothing, which is the part worth writing down. The entry carries the version lesson as a second paragraph, since it has the same root, a rename, and was likewise found by a tool rather than by reading.

The other trap is left alone on purpose. #13 is adding "two machines answer to tkldev, and they run different fab", and this work hit that trap in its other form: a claim measured from the wrong clone reads exactly like one measured from the wrong machine. The note says so and says the fix needs one word added, "name the clone and its head, not only the machine", but leaves the amendment to #13 so the two do not collide in the same file.

Test plan

  • The note follows the form of 0001-0013: title line, Date, Status, ## Decision, the three part justification of brief section 10, ## Consequences. It also carries ## What this does not decide, ## Traps found while writing this and ## Implementation, following 0016.
  • No em dashes; commas, colons, parentheses and semicolons, per brief section 10.
  • Every factual claim carries its measurement and its date, and the readings from the build host name the file and the digest so the next reader can tell which machine and which clone they came from.
  • Does not contradict 0006 (names the divergence explicitly), 0008 (the fork keeps its history and its remote), 0012 or 0015 (says why it does not reach build tools).
  • Two files only: the note and docs/traps.md. No index to update; docs/decisions/ has none.
  • Maintainer confirms keel-fab and 2.0.0, which is the part fab#9 cannot land without.

Related

This repository ships no package and has no package / changelog gate, so there is no changelog entry to add and no checks are reported on the branch.

…amed

The build host ran fab 1.1.1+keel2, built and installed at 17:14 on
2026-09-27 from a commit that reached the default branch at 19:58, with no
tag on either packaged version. A layer manifest records the builder as a
version string and nothing else, so core.manifest's fab_version 1.1.1+keel1
named no commit, and the project's rebuildability claim did not hold for the
one thing every image is built with.

The note records what was measured before it was written, including two
things the first report of this got wrong: the running files were not
divergent from git, and the changelog entry did exist and had merged nine
hours earlier. What was actually broken is that a version string was the
only provenance there was, and that the string said we had patched somebody
else's release.

The decision is that a package this project depends on and modifies becomes
a Keel package with a version we choose: keel-fab 0.1.0, plain, no +keelN.
The note argues the three axes separately, because they are separate: the
repository keeps its upstream name per 0006, which rules on repositories and
not on package names; the version scheme follows the boundary
apt/lib/build.sh already implements, which the keel prefix is what selects;
and every command name, path and python module stays byte identical, which
the 380 call sites decide rather than a preference. None of them reads the
Debian package name.

It also records the first Provides/Replaces/Conflicts triple in this
organization's packaging with the reason for each field, since tracker#13
will need it and should not copy the fields without reading why fab's
Provides does almost nothing while theirs will do real work.

The trap is new and cost a rebuild to find: debhelper keys its per-package
files on the binary package name, dh_python3 finds a private directory the
same way, and a rename silently drops whatever was being picked up by name.
Only building both packages and comparing them found it.
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
Contributor Author

Review of #18

Reviewed alongside Keel-Linux/fab#9, where the implementation findings are. This comment is about the note as a record: whether it argues its choices, and whether what it states is true.

It argues rather than records. "Why 2.0.0 and not 0.1.0" carries the measurement that refused the first answer and the rule it generalises to. "Why keel-fab is the name the tooling already wanted" derives the name from apt/lib/build.sh instead of from taste, and I confirmed the three conditions: the clone has only origin, and neither debian/watch nor debian/upstream/metadata exists, so Source: keel-fab is the one that flips the classification. The versioned Provides is recorded as considered and rejected with the reason. "What this does not decide" draws the boundary in four places. The three-part justification does the work brief section 10 asks of it rather than restating the decision three times. Recording that the version number was got wrong first, and that a gate rather than reasoning caught it, is the part that will be worth most to the next reader.

The findings below are all in the same place: the note is precise about everything it measured itself and loose about the two or three things it inherited.


HIGH. The Provides row states a mechanism that is false on the build host, and it is offered as the precedent for tracker#13

Lines 132-139, the relationships table:

apt installs a virtual package when exactly one real package provides it, so both keep working untouched.

That holds only when no real package of that name is reachable. On the build host one is, at a higher priority than the local build:

$ 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

Measured in a container with the same shape, keel-fab 2.0.0 installed from a file and a real fab in an archive at 999:

$ apt-get install -s fab
The following packages will be REMOVED:
  keel-fab
The following NEW packages will be installed:
  fab

So of the two sites the row says Provides exists for, one is not protected by it and the other is not protected by apt at all:

  • tkldev-setup:372 is apt-get install -y fab, and it removes keel-fab and installs upstream's fab. So does docs/infra-recovery.md:96, the rebuild-from-a-fresh-Debian procedure, which the note's follow-up table does not list.
  • tkldev/plan/main:3 never reaches apt. tkldev/Makefile is include $(FAB_PATH)/common/mk/turnkey.mk, so that line goes through fab-plan-resolve, and fab's own resolver matches the pool's .deb filenames against literal names (fablib/plan.py:205-206) and reads Provides only from packages it has already fetched (plan.py:329, used only at plan.py:406). It cannot resolve a virtual name. Worse, the miss is silent: missing is never mutated while brokendeps iterates it, so resolve() returns no error for a package the pool could not supply.

This matters more here than in fab#9, because the note says explicitly that the triple is "the precedent for tracker#13's keel-version and keel-sysinfo, which take over from packages that do have a reverse dependency and an operator facing command, so their Provides will be doing real work that fab's is not". Those two are more exposed to this exact failure, not less. The row as written tells the next author that Provides makes install-by-name safe. It does not. What makes it safe is a negative pin on the real package, which I confirmed works:

Package: fab
Pin: origin ""
Pin-Priority: -1

The rest of the row is right and worth keeping: Conflicts is load bearing, Replaces is what hands the paths over, unversioned is the correct choice for both because upstream's fab keeps releasing. Two sentences fix this: Provides satisfies a dependency on the name, it does not win against a real package of that name in an enabled archive, and it does nothing at all for a fab plan.

It also changes the sequencing two paragraphs later. "That is a live defect independent of this note" is true of the pin matching nothing; it is not true of the whole of apt#15. Blocking upstream's real fab is a requirement the rename creates, so apt#15 is a prerequisite of the conversion rather than a neighbour of it.


MEDIUM 1. "all 28 paths the old package had are present" is not what was measured

Line 253. I rebuilt the package and compared it against the fab 1.1.1+keel2 installed on the host. The substance holds: make-release-deb.py and turnkey-version.py come out byte identical to the installed ones, all nine aliases are there, product.mk is a06bfe03, and fablib lands on the version independent path. The counting does not.

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 silently dropped from the comparison. Everything the sentence does not mention is inside the 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 effect is nil, but the claim as phrased is the one thing that is not true.
  • changelog.gz differs. There are three differing files, not two.
  • usr/lib/python3/dist-packages/fab-1.1.0.dist-info/ is unchanged, so the installed package tells Python it is fab 1.1.0 while dpkg calls it keel-fab 2.0.0.

And runtime.d/fab.rtupdate becomes keel-fab.rtupdate, which the sentence covers with a glob rather than naming as a rename. "35 entries in the old package, 32 byte identical, 3 renamed by debhelper, 2 new" is both truer and a stronger claim, and it is also a better illustration of the trap the note is adding, since two of the three renames are debhelper keying on the package name.

MEDIUM 2. The Conflicts row still names keel-fab_0.1.0_all.deb

Line 132. 4668f5a moved the note to 2.0.0 and this one survived. It is inside the table an operator reads to understand the conversion, two screens above the runbook that correctly says 2.0.0. Same file, one word.

MEDIUM 3. The note argues the name from "no upstream remote" while committing to cherry-picks in both directions

Lines 108-120 against the 0008 paragraph at line 66. One of the three native conditions is git -C "$dir" remote | grep -qx upstream (apt/lib/build.sh:83), and the ordinary way to cherry-pick from upstream is to add that remote. Whoever does it on the build host clone makes bin/build-package reclassify keel-fab as a rebuild and refuse 2.0.0 for lacking +keelN. The note is the right place to notice that, since it is the document asserting both things.

MEDIUM 4. "The seven other repositories that ship something and do not gate their changelog"

Line 397, and the same phrase in the pull request body. tracker#18's title says seven, its first sentence says eight, and the true statement is neither: 13 repositories ship something installable and do not call the gate. 8 is 7 recipes plus fab, with the 5 changelog-less Debian sources bucketed separately. Since the note is what the next reader will cite, "the other repositories that do not gate their changelog, audited in tracker#18" without a number would age better than a number that three documents disagree about.

MEDIUM 5. On tracker#18's second group, which the note defers to

You asked for a verdict on this, so: the facts are right and the conclusion is the weaker half.

bin/require-changelog really does return 0 when the changelog is absent, before it even looks at the diff, and the behaviour is locked in by tests/require-changelog.bats, "a repository without a changelog requires nothing". So "a permanently green check asserting nothing" is exact.

But all five of those repositories have debian/control and debian/rules, and dpkg-buildpackage cannot run at all without a changelog: it reads the name, version and distribution from it. The shared build-deb.yml is a plain dpkg-buildpackage -us -uc -b with no dch and no generation step anywhere. So "the version being produced at build time" describes an inherited TurnKey convention rather than a working pipeline here, and the honest answer to "could a package source with no changelog legitimately produce a package" is no. It is also only a convention for three of the five: tklbam, tklbam-python-boto and turnkey-pylib gitignore debian/changelog, while webmin and turnkey-chroot do not, so for those two the absence reads as a plain omission, which is the bug a gate exists to catch.

And the same branch is a live bypass in the 15 repositories that are gated, not just a dormant one in the 5 that are not: the check is [[ ! -f "$changelog" ]] against the checked-out merge ref, so a pull request that deletes debian/changelog, or a typo in a caller's changelog: input, turns package / changelog green.

So the right order is the reverse of the audit's: make the gate refuse a package source it cannot find a changelog for, which is a few lines in a repository whose suite is at 100, and then wire all five up immediately with a red check that states a true fact. A red check that says "this is a package source with no changelog" is strictly more useful than prose in an issue. tracker#18 reaches for this itself in its last bullet and then files it as an open question; it should be the first action, not the last. The one part of its conclusion worth keeping is that wiring the five up before fixing the script would manufacture false assurance.

This does not change anything in note 0017, which only links the audit. It is worth a line in whichever note settles it, since "a gate that cannot find its subject and reports success" is the same shape as the trap the gate was written for.


LOW

  • Line 393: "125 of 125". It is 126. 19 + 56 + 36 + 15, and all four suites run clean: 1..19, 1..56, 1..36, 1..15, zero not ok. fab#9's own table says 126 and its test plan says 125, so this is a number three places disagree about in a note whose standard is that every factual claim carries its measurement.
  • Line 44: "nine hours before fab#7 was filed" is 10 hours 7 minutes, 2026-09-27T19:58:53Z to 2026-09-28T06:06:20Z.
  • Lines 46-47 and 385-389: the stale-clone diagnosis is right and can be sharper. On the build host, /root/src/fab is on 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 remotes/origin/master, which is 16730a5, the Decision 0010 step 3 asks for a three-build comparison and names no command #5 merge: the host has not fetched since before 0014: the two identity files, and what an appliance says about backup #6 landed. master is behind 4 of that stale ref and behind 6 of the real one. That is a better version of the amendment the note wants to hand to The trap of reading product.mk from the wrong tkldev #13, because "name the clone and its head" would not have caught this one either. The word that would is "name the ref you measured": HEAD was current and origin/master was not, in the same clone.
  • docs/traps.md, the new entry: accurate on every point I could check. debhelper does key .install, .links and .docs on the binary package name; dh_python3 does find a private directory as /usr/share/<package>; and the two-call claim is right in effect, which I confirmed by building the package, since the bare call is what puts fablib in /usr/lib/python3/dist-packages rather than python3.13, and the directory call is what restores the shebang rewrite that makes make-release-deb.py and turnkey-version.py byte identical to the installed ones. The version paragraph belongs where it is. One unmeasured word: "lintian is quiet" is the only claim in the entry that nothing in fab#9's test plan backs, and the entry does not need it.
  • The note does not mention pyproject.toml, which keeps name = "fab", version = "1.1.0", so the built package reports three different versions depending on who is asked. Keeping it frozen is probably what holds the dist-info files byte identical; one clause under "What this does not decide" would settle it.
  • Line 273, "the twelve commands": correct, /usr/bin/fab plus fab-investigate, fab-rewind and the nine aliases. Worth adding to the verification line: check ownership with dpkg -S /usr/bin/fab, not dpkg -V. If anyone ever reaches for dpkg -i --force-conflicts here, Replaces being one-directional leaves keel-fab owning /usr/bin/fab and all nine aliases even though fab unpacked second, and removing keel-fab then deletes them while dpkg -V fab still exits 0.

Verdict

The decision is right, the reasoning is genuinely argued, the trap entry is accurate and useful, and the correction of fab#7 is correct: 1.1.1+keel2 was on master before #7 was filed, and I reproduced the byte comparison independently. One HIGH, and it is one paragraph: the Provides row states a mechanism that does not hold on the machine this decision is about, in the sentence the note explicitly offers as precedent for the next two packages. Fix that row and the 0.1.0 in the table above it and I would call it Approve.

Warning.

Review of #18 found the relationships table stating a mechanism that is
false on the build host: apt resolves "apt-get install fab" to a real fab
in an enabled archive over any virtual provider, and TurnKey's archive
offers one at 999. Reproduced on 2026-09-29 in a scratch apt tree:
keel-fab 2.0.0 installed, a real fab served over http, and apt removes
keel-fab to install it.

The fix the review proposed, "Pin: origin \"\"", does not close it. It
passed a test built on a file: repository, where the host name is empty;
against the same package served over http it blocks nothing. Pinning the
real package by its archive, "Pin: release o=turnkeylinux" at -1, does,
and leaves the way back to fab_1.1.1+keel2_all.deb working. The build
host's TurnKey Release files carry Origin: turnkeylinux.

Also: the Conflicts row still named 0.1.0; the package comparison is
restated over the full file set (35 and 37 entries, 26 identical, 8
renamed by debhelper, 2 new); the tag rule is described as it now is
(annotated, reachable, a floor, required on the default branch); the
remote-name fragility of the native classification is stated; the
stale-ref diagnosis is sharpened; the call-site counts carry their
provenance; "lintian is quiet" was not measured and is gone.
@marcos-mendez

Copy link
Copy Markdown
Contributor Author

fad3160: the Provides row now says what it does not do and which pin closes it, with the conversion order (apt#15 first). The pin the review proposed, Pin: origin "", does not block an http archive (it only matches file: repositories); reproduced in a scratch apt tree, and the note records Pin: release o=turnkeylinux instead. MEDIUMs 1 to 4 and the LOWs are folded in.

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.

Decision note 0017: owning fab, and how a package this project depends on is named

1 participant